tsunami -- handle onSubmit and onChange for file inputs (#2541)

new VDomFormData and VDomFileData (and an async path for event handling on the FE)
This commit is contained in:
Mike Sawka 2025-11-10 20:00:18 -08:00 committed by GitHub
parent 34062ad226
commit 7955bf6279
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 203 additions and 11 deletions

View file

@ -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<VDomFileData> {
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<any>;
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<HTMLFormElement>;
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<HTMLInputElement>;
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<ModalConfig | null> = jotai.atom(null);
currentModal: jotai.PrimitiveAtom<ModalConfig | null> = jotai.atom(null) as jotai.PrimitiveAtom<ModalConfig | null>;
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 {

View file

@ -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;
};

View file

@ -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<ArrayBufferLike> {
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<ArrayBufferLike>
// 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);
}

View file

@ -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"`