mirror of
https://github.com/wavetermdev/waveterm
synced 2026-05-23 08:48:28 +00:00
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:
parent
34062ad226
commit
7955bf6279
4 changed files with 203 additions and 11 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
23
tsunami/frontend/src/types/vdom.d.ts
vendored
23
tsunami/frontend/src/types/vdom.d.ts
vendored
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
37
tsunami/frontend/src/util/base64.ts
Normal file
37
tsunami/frontend/src/util/base64.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
Loading…
Reference in a new issue