From 87f278600fa947d1fb155204a8cdf777e5b4f13e Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 10 Nov 2025 18:49:06 -0800 Subject: [PATCH] deal with file data... also onChange event on file inputs... --- tsunami/frontend/src/model/tsunami-model.tsx | 110 ++++++++++++++++++- tsunami/frontend/src/types/vdom.d.ts | 23 ++++ tsunami/frontend/src/util/base64.ts | 37 +++++++ tsunami/vdom/vdom_types.go | 44 +++++++- 4 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 tsunami/frontend/src/util/base64.ts diff --git a/tsunami/frontend/src/model/tsunami-model.tsx b/tsunami/frontend/src/model/tsunami-model.tsx index 9ad6ac0a31..61857dbebe 100644 --- a/tsunami/frontend/src/model/tsunami-model.tsx +++ b/tsunami/frontend/src/model/tsunami-model.tsx @@ -4,6 +4,7 @@ import debug from "debug"; import * as jotai from "jotai"; +import { arrayBufferToBase64 } from "@/util/base64"; import { getOrCreateClientId } from "@/util/clientid"; import { adaptFromReactOrNativeKeyEvent } from "@/util/keyutil"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; @@ -38,6 +39,28 @@ function isBlank(v: string): boolean { return v == null || v === ""; } +async function fileToVDomFileData(file: File, fieldname: string): Promise { + const maxSize = 5 * 1024 * 1024; + if (file.size > maxSize) { + return { + fieldname: fieldname, + name: file.name, + size: file.size, + type: file.type, + error: "File size exceeds 5MB limit", + }; + } + const buffer = await file.arrayBuffer(); + const data64 = arrayBufferToBase64(buffer); + return { + fieldname: fieldname, + name: file.name, + size: file.size, + type: file.type, + data64: data64, + }; +} + function annotateEvent(event: VDomEvent, propName: string, reactEvent: React.SyntheticEvent) { if (reactEvent == null) { return; @@ -47,7 +70,7 @@ function annotateEvent(event: VDomEvent, propName: string, reactEvent: React.Syn event.targetvalue = changeEvent.target?.value; event.targetchecked = changeEvent.target?.checked; } - if (propName == "onClick" || propName == "onMouseDown") { + if (propName == "onClick" || propName == "onMouseDown" || propName == "onMouseUp" || propName == "onDoubleClick") { const mouseEvent = reactEvent as React.MouseEvent; event.mousedata = { button: mouseEvent.button, @@ -79,6 +102,69 @@ function annotateEvent(event: VDomEvent, propName: string, reactEvent: React.Syn } } +async function asyncAnnotateEvent(event: VDomEvent, propName: string, reactEvent: React.SyntheticEvent) { + if (propName == "onSubmit") { + const formEvent = reactEvent as React.FormEvent; + const form = formEvent.currentTarget; + + event.targetname = form.name; + event.targetid = form.id; + + const formData: VDomFormData = { + method: (form.method || "get").toUpperCase(), + enctype: form.enctype || "application/x-www-form-urlencoded", + fields: {}, + files: {}, + }; + + if (form.action) { + formData.action = form.action; + } + if (form.id) { + formData.formid = form.id; + } + if (form.name) { + formData.formname = form.name; + } + + const formDataObj = new FormData(form); + + for (const [key, value] of formDataObj.entries()) { + if (value instanceof File) { + if (!value.name && value.size === 0) { + continue; + } + if (!formData.files[key]) { + formData.files[key] = []; + } + formData.files[key].push(await fileToVDomFileData(value, key)); + } else { + if (!formData.fields[key]) { + formData.fields[key] = []; + } + formData.fields[key].push(value.toString()); + } + } + + event.formdata = formData; + } + if (propName == "onChange") { + const changeEvent = reactEvent as React.ChangeEvent; + if (changeEvent.target?.type === "file" && changeEvent.target.files) { + event.targetname = changeEvent.target.name; + event.targetid = changeEvent.target.id; + + const files: VDomFileData[] = []; + const fieldname = changeEvent.target.name || changeEvent.target.id || "file"; + for (let i = 0; i < changeEvent.target.files.length; i++) { + const file = changeEvent.target.files[i]; + files.push(await fileToVDomFileData(file, fieldname)); + } + event.targetfiles = files; + } + } +} + export class TsunamiModel { clientId: string; serverId: string; @@ -109,7 +195,7 @@ export class TsunamiModel { cachedTitle: string | null = null; cachedShortDesc: string | null = null; reason: string | null = null; - currentModal: jotai.PrimitiveAtom = jotai.atom(null); + currentModal: jotai.PrimitiveAtom = jotai.atom(null) as jotai.PrimitiveAtom; constructor() { this.clientId = getOrCreateClientId(); @@ -631,9 +717,23 @@ export class TsunamiModel { if (fnDecl.globalevent) { vdomEvent.globaleventtype = fnDecl.globalevent; } - annotateEvent(vdomEvent, propName, e); - this.batchedEvents.push(vdomEvent); - this.queueUpdate(true, "event"); + const needsAsync = + propName == "onSubmit" || + (propName == "onChange" && (e.target as HTMLInputElement)?.type === "file"); + if (needsAsync) { + asyncAnnotateEvent(vdomEvent, propName, e) + .then(() => { + this.batchedEvents.push(vdomEvent); + this.queueUpdate(true, "event"); + }) + .catch((err) => { + console.error("Error processing event:", err); + }); + } else { + annotateEvent(vdomEvent, propName, e); + this.batchedEvents.push(vdomEvent); + this.queueUpdate(true, "event"); + } } createFeUpdate(): VDomFrontendUpdate { diff --git a/tsunami/frontend/src/types/vdom.d.ts b/tsunami/frontend/src/types/vdom.d.ts index 58651fca5f..485ada680b 100644 --- a/tsunami/frontend/src/types/vdom.d.ts +++ b/tsunami/frontend/src/types/vdom.d.ts @@ -42,8 +42,10 @@ type VDomEvent = { targetchecked?: boolean; targetname?: string; targetid?: string; + targetfiles?: VDomFileData[]; keydata?: VDomKeyboardEvent; mousedata?: VDomPointerData; + formdata?: VDomFormData; }; // vdom.VDomFrontendUpdate @@ -204,3 +206,24 @@ type VDomPointerData = { cmd?: boolean; option?: boolean; }; + +// vdom.VDomFormData +type VDomFormData = { + action?: string; + method: string; + enctype: string; + formid?: string; + formname?: string; + fields: { [key: string]: string[] }; + files: { [key: string]: VDomFileData[] }; +}; + +// vdom.VDomFileData +type VDomFileData = { + fieldname: string; + name: string; + size: number; + type: string; + data64?: string; + error?: string; +}; diff --git a/tsunami/frontend/src/util/base64.ts b/tsunami/frontend/src/util/base64.ts new file mode 100644 index 0000000000..8f628ae93f --- /dev/null +++ b/tsunami/frontend/src/util/base64.ts @@ -0,0 +1,37 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import base64 from "base64-js"; + +export function base64ToString(b64: string): string { + if (b64 == null) { + return null; + } + if (b64 == "") { + return ""; + } + const stringBytes = base64.toByteArray(b64); + return new TextDecoder().decode(stringBytes); +} + +export function stringToBase64(input: string): string { + const stringBytes = new TextEncoder().encode(input); + return base64.fromByteArray(stringBytes); +} + +export function base64ToArray(b64: string): Uint8Array { + const cleanB64 = b64.replace(/\s+/g, ""); + return base64.toByteArray(cleanB64); +} + +export function base64ToArrayBuffer(b64: string): ArrayBuffer { + const cleanB64 = b64.replace(/\s+/g, ""); + const u8 = base64.toByteArray(cleanB64); // Uint8Array + // Force a plain ArrayBuffer slice (no SharedArrayBuffer, no offset issues) + return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer; +} + +export function arrayBufferToBase64(buffer: ArrayBuffer): string { + const u8 = new Uint8Array(buffer); + return base64.fromByteArray(u8); +} diff --git a/tsunami/vdom/vdom_types.go b/tsunami/vdom/vdom_types.go index 29bbc86835..d20a02ac3d 100644 --- a/tsunami/vdom/vdom_types.go +++ b/tsunami/vdom/vdom_types.go @@ -66,12 +66,14 @@ type VDomEvent struct { WaveId string `json:"waveid"` EventType string `json:"eventtype"` // usually the prop name (e.g. onClick, onKeyDown) GlobalEventType string `json:"globaleventtype,omitempty"` - TargetValue string `json:"targetvalue,omitempty"` - TargetChecked bool `json:"targetchecked,omitempty"` - TargetName string `json:"targetname,omitempty"` - TargetId string `json:"targetid,omitempty"` - KeyData *VDomKeyboardEvent `json:"keydata,omitempty"` - MouseData *VDomPointerData `json:"mousedata,omitempty"` + TargetValue string `json:"targetvalue,omitempty"` // set for onChange events on input/textarea/select + TargetChecked bool `json:"targetchecked,omitempty"` // set for onChange events on checkbox/radio inputs + TargetName string `json:"targetname,omitempty"` // target element's name attribute + TargetId string `json:"targetid,omitempty"` // target element's id attribute + TargetFiles []VDomFileData `json:"targetfiles,omitempty"` // set for onChange events on file inputs + KeyData *VDomKeyboardEvent `json:"keydata,omitempty"` // set for onKeyDown events + MouseData *VDomPointerData `json:"mousedata,omitempty"` // set for onClick, onMouseDown, onMouseUp, onDoubleClick events + FormData *VDomFormData `json:"formdata,omitempty"` // set for onSubmit events on forms } type VDomKeyboardEvent struct { @@ -112,6 +114,36 @@ type VDomPointerData struct { Option bool `json:"option,omitempty"` // special (on mac it is alt, on windows/linux it is meta) } +type VDomFormData struct { + Action string `json:"action,omitempty"` + Method string `json:"method"` + Enctype string `json:"enctype"` + FormId string `json:"formid,omitempty"` + FormName string `json:"formname,omitempty"` + Fields map[string][]string `json:"fields"` + Files map[string][]VDomFileData `json:"files"` +} + +func (f *VDomFormData) GetField(fieldName string) string { + if f.Fields == nil { + return "" + } + values := f.Fields[fieldName] + if len(values) == 0 { + return "" + } + return values[0] +} + +type VDomFileData struct { + FieldName string `json:"fieldname"` + Name string `json:"name"` + Size int64 `json:"size"` + Type string `json:"type"` + Data64 []byte `json:"data64,omitempty"` + Error string `json:"error,omitempty"` +} + type VDomRefOperation struct { RefId string `json:"refid"` Op string `json:"op"`