// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { BlockNodeModel } from "@/app/block/blocktypes"; import { getBlockMetaKeyAtom, globalStore, PLATFORM, WOS } from "@/app/store/global"; import { makeORef } from "@/app/store/wos"; import { waveEventSubscribe } from "@/app/store/wps"; import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; import { RpcApi } from "@/app/store/wshclientapi"; import { makeFeBlockRouteId } from "@/app/store/wshrouter"; import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; import { applyCanvasOp, mergeBackendUpdates, restoreVDomElems } from "@/app/view/vdom/vdom-utils"; import { getWebServerEndpoint } from "@/util/endpoints"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import debug from "debug"; import * as jotai from "jotai"; const dlog = debug("wave:vdom"); type AtomContainer = { val: any; beVal: any; usedBy: Set; }; type RefContainer = { refFn: (elem: HTMLElement) => void; vdomRef: VDomRef; elem: HTMLElement; updated: boolean; }; function makeVDomIdMap(vdom: VDomElem, idMap: Map) { if (vdom == null) { return; } if (vdom.waveid != null) { idMap.set(vdom.waveid, vdom); } if (vdom.children == null) { return; } for (let child of vdom.children) { makeVDomIdMap(child, idMap); } } function annotateEvent(event: VDomEvent, propName: string, reactEvent: React.SyntheticEvent) { if (reactEvent == null) { return; } if (propName == "onChange") { const changeEvent = reactEvent as React.ChangeEvent; event.targetvalue = changeEvent.target?.value; event.targetchecked = changeEvent.target?.checked; } if (propName == "onClick" || propName == "onMouseDown") { const mouseEvent = reactEvent as React.MouseEvent; event.mousedata = { button: mouseEvent.button, buttons: mouseEvent.buttons, alt: mouseEvent.altKey, control: mouseEvent.ctrlKey, shift: mouseEvent.shiftKey, meta: mouseEvent.metaKey, clientx: mouseEvent.clientX, clienty: mouseEvent.clientY, pagex: mouseEvent.pageX, pagey: mouseEvent.pageY, screenx: mouseEvent.screenX, screeny: mouseEvent.screenY, movementx: mouseEvent.movementX, movementy: mouseEvent.movementY, }; if (PLATFORM == "darwin") { event.mousedata.cmd = event.mousedata.meta; event.mousedata.option = event.mousedata.alt; } else { event.mousedata.cmd = event.mousedata.alt; event.mousedata.option = event.mousedata.meta; } } if (propName == "onKeyDown") { const waveKeyEvent = adaptFromReactOrNativeKeyEvent(reactEvent as React.KeyboardEvent); event.keydata = waveKeyEvent; } } class VDomWshClient extends WshClient { model: VDomModel; constructor(model: VDomModel) { super(makeFeBlockRouteId(model.blockId)); this.model = model; } handle_vdomasyncinitiation(rh: RpcResponseHelper, data: VDomAsyncInitiationRequest) { dlog("async-initiation", rh.getSource(), data); this.model.queueUpdate(true); } } export class VDomModel { blockId: string; nodeModel: BlockNodeModel; viewType: string; viewIcon: jotai.Atom; viewName: jotai.Atom; viewRef: React.RefObject = { current: null }; vdomRoot: jotai.PrimitiveAtom = jotai.atom(); atoms: Map = new Map(); // key is atomname refs: Map = new Map(); // key is refid batchedEvents: VDomEvent[] = []; messages: VDomMessage[] = []; needsResync: boolean = true; vdomNodeVersion: WeakMap> = new WeakMap(); compoundAtoms: Map> = new Map(); rootRefId: string = crypto.randomUUID(); backendRoute: jotai.Atom; backendOpts: VDomBackendOpts; shouldDispose: boolean; disposed: boolean; hasPendingRequest: boolean; needsUpdate: boolean; maxNormalUpdateIntervalMs: number = 100; needsImmediateUpdate: boolean; lastUpdateTs: number = 0; queuedUpdate: { timeoutId: any; ts: number; quick: boolean }; contextActive: jotai.PrimitiveAtom; wshClient: VDomWshClient; persist: jotai.Atom; routeGoneUnsub: () => void; routeConfirmed: boolean = false; refOutputStore: Map = new Map(); globalVersion: jotai.PrimitiveAtom = jotai.atom(0); hasBackendWork: boolean = false; noPadding: jotai.PrimitiveAtom; constructor(blockId: string, nodeModel: BlockNodeModel) { this.viewType = "vdom"; this.blockId = blockId; this.nodeModel = nodeModel; this.contextActive = jotai.atom(false); this.reset(); this.viewIcon = jotai.atom("bolt"); this.viewName = jotai.atom("Wave App"); this.backendRoute = jotai.atom((get) => { const blockData = get(WOS.getWaveObjectAtom(makeORef("block", this.blockId))); return blockData?.meta?.["vdom:route"]; }); this.noPadding = jotai.atom(true); this.persist = getBlockMetaKeyAtom(this.blockId, "vdom:persist"); this.wshClient = new VDomWshClient(this); DefaultRouter.registerRoute(this.wshClient.routeId, this.wshClient); const curBackendRoute = globalStore.get(this.backendRoute); if (curBackendRoute) { this.queueUpdate(true); } this.routeGoneUnsub = waveEventSubscribe({ eventType: "route:gone", scope: curBackendRoute, handler: (event: WaveEvent) => { this.disposed = true; const shouldPersist = globalStore.get(this.persist); if (!shouldPersist) { this.nodeModel?.onClose?.(); } }, }); RpcApi.WaitForRouteCommand(TabRpcClient, { routeid: curBackendRoute, waitms: 4000 }, { timeout: 5000 }).then( (routeOk: boolean) => { if (routeOk) { this.routeConfirmed = true; this.queueUpdate(true); } else { this.disposed = true; const shouldPersist = globalStore.get(this.persist); if (!shouldPersist) { this.nodeModel?.onClose?.(); } } } ); } dispose() { DefaultRouter.unregisterRoute(this.wshClient.routeId); this.routeGoneUnsub?.(); } reset() { globalStore.set(this.vdomRoot, null); this.atoms.clear(); this.refs.clear(); this.batchedEvents = []; this.messages = []; this.needsResync = true; this.vdomNodeVersion = new WeakMap(); this.compoundAtoms.clear(); this.rootRefId = crypto.randomUUID(); this.backendOpts = {}; this.shouldDispose = false; this.disposed = false; this.hasPendingRequest = false; this.needsUpdate = false; this.maxNormalUpdateIntervalMs = 100; this.needsImmediateUpdate = false; this.lastUpdateTs = 0; this.queuedUpdate = null; this.refOutputStore.clear(); this.globalVersion = jotai.atom(0); this.hasBackendWork = false; globalStore.set(this.contextActive, false); } getBackendRoute(): string { const blockData = globalStore.get(WOS.getWaveObjectAtom(makeORef("block", this.blockId))); return blockData?.meta?.["vdom:route"]; } transformVDomUrl(url: string): string { if (url == null || url == "") { return null; } if (!url.startsWith("vdom://")) { return url; } const absUrl = url.substring(7); return this.makeVDomUrl(absUrl); } makeVDomUrl(path: string): string { if (path == null || path == "") { return null; } if (!path.startsWith("/")) { return null; } const backendRouteId = this.getBackendRouteId(); if (backendRouteId == null) { return null; } const wsEndpoint = getWebServerEndpoint(); const fullUrl = wsEndpoint + "/vdom/" + backendRouteId + path; return fullUrl; } keyDownHandler(e: WaveKeyboardEvent): boolean { if (this.backendOpts?.closeonctrlc && checkKeyPressed(e, "Ctrl:c")) { this.shouldDispose = true; this.queueUpdate(true); return true; } if (this.backendOpts?.globalkeyboardevents) { if (e.cmd || e.meta) { return false; } this.batchedEvents.push({ globaleventtype: "onKeyDown", waveid: null, eventtype: "onKeyDown", keydata: e, }); this.queueUpdate(); return true; } return false; } hasRefUpdates() { for (let ref of this.refs.values()) { if (ref.updated) { return true; } } return false; } getRefUpdates(): VDomRefUpdate[] { let updates: VDomRefUpdate[] = []; for (let ref of this.refs.values()) { if (ref.updated || (ref.vdomRef.trackposition && ref.elem != null)) { const ru: VDomRefUpdate = { refid: ref.vdomRef.refid, hascurrent: ref.vdomRef.hascurrent, }; if (ref.vdomRef.trackposition && ref.elem != null) { ru.position = { offsetheight: ref.elem.offsetHeight, offsetwidth: ref.elem.offsetWidth, scrollheight: ref.elem.scrollHeight, scrollwidth: ref.elem.scrollWidth, scrolltop: ref.elem.scrollTop, boundingclientrect: ref.elem.getBoundingClientRect(), }; } updates.push(ru); ref.updated = false; } } return updates; } queueUpdate(quick: boolean = false, delay: number = 10) { if (this.disposed) { return; } this.needsUpdate = true; let nowTs = Date.now(); if (delay > this.maxNormalUpdateIntervalMs) { delay = this.maxNormalUpdateIntervalMs; } if (quick) { if (this.queuedUpdate) { if (this.queuedUpdate.quick || this.queuedUpdate.ts <= nowTs) { return; } clearTimeout(this.queuedUpdate.timeoutId); this.queuedUpdate = null; } let timeoutId = setTimeout(() => { this._sendRenderRequest(true); }, 0); this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs, quick: true }; return; } if (this.queuedUpdate) { return; } let lastUpdateDiff = nowTs - this.lastUpdateTs; let timeoutMs: number = null; if (lastUpdateDiff >= this.maxNormalUpdateIntervalMs) { // it has been a while since the last update, so use delay timeoutMs = delay; } else { timeoutMs = this.maxNormalUpdateIntervalMs - lastUpdateDiff; } if (timeoutMs < delay) { timeoutMs = delay; } let timeoutId = setTimeout(() => { this._sendRenderRequest(false); }, timeoutMs); this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs + timeoutMs, quick: false }; } async _sendRenderRequest(force: boolean) { this.queuedUpdate = null; if (this.disposed || !this.routeConfirmed) { return; } if (this.hasPendingRequest) { if (force) { this.needsImmediateUpdate = true; } return; } if (!force && !this.needsUpdate) { return; } const backendRoute = globalStore.get(this.backendRoute); if (backendRoute == null) { console.log("vdom-model", "no backend route"); return; } this.hasPendingRequest = true; this.needsImmediateUpdate = false; try { const feUpdate = this.createFeUpdate(); dlog("fe-update", feUpdate); const beUpdateGen = await RpcApi.VDomRenderCommand(TabRpcClient, feUpdate, { route: backendRoute }); let baseUpdate: VDomBackendUpdate = null; for await (const beUpdate of beUpdateGen) { if (baseUpdate === null) { baseUpdate = beUpdate; } else { mergeBackendUpdates(baseUpdate, beUpdate); } } if (baseUpdate !== null) { restoreVDomElems(baseUpdate); dlog("be-update", baseUpdate); this.handleBackendUpdate(baseUpdate); } dlog("update cycle done"); } finally { this.lastUpdateTs = Date.now(); this.hasPendingRequest = false; } if (this.needsImmediateUpdate) { this.queueUpdate(true); } } getAtomContainer(atomName: string): AtomContainer { let container = this.atoms.get(atomName); if (container == null) { container = { val: null, beVal: null, usedBy: new Set(), }; this.atoms.set(atomName, container); } return container; } getOrCreateRefContainer(vdomRef: VDomRef): RefContainer { let container = this.refs.get(vdomRef.refid); if (container == null) { container = { refFn: (elem: HTMLElement) => { container.elem = elem; const hasElem = elem != null; if (vdomRef.hascurrent != hasElem) { container.updated = true; vdomRef.hascurrent = hasElem; } }, vdomRef: vdomRef, elem: null, updated: false, }; this.refs.set(vdomRef.refid, container); } return container; } tagUseAtoms(waveId: string, atomNames: Set) { for (let atomName of atomNames) { let container = this.getAtomContainer(atomName); container.usedBy.add(waveId); } } tagUnuseAtoms(waveId: string, atomNames: Set) { for (let atomName of atomNames) { let container = this.getAtomContainer(atomName); container.usedBy.delete(waveId); } } getVDomNodeVersionAtom(vdom: VDomElem) { let atom = this.vdomNodeVersion.get(vdom); if (atom == null) { atom = jotai.atom(0); this.vdomNodeVersion.set(vdom, atom); } return atom; } incVDomNodeVersion(vdom: VDomElem) { if (vdom == null) { return; } const atom = this.getVDomNodeVersionAtom(vdom); globalStore.set(atom, globalStore.get(atom) + 1); } addErrorMessage(message: string) { this.messages.push({ messagetype: "error", message: message, }); } handleRenderUpdates(update: VDomBackendUpdate, idMap: Map) { if (!update.renderupdates) { return; } for (let renderUpdate of update.renderupdates) { if (renderUpdate.updatetype == "root") { globalStore.set(this.vdomRoot, renderUpdate.vdom); continue; } if (renderUpdate.updatetype == "append") { let parent = idMap.get(renderUpdate.waveid); if (parent == null) { this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); continue; } if (parent.children == null) { parent.children = []; } parent.children.push(renderUpdate.vdom); this.incVDomNodeVersion(parent); continue; } if (renderUpdate.updatetype == "replace") { let parent = idMap.get(renderUpdate.waveid); if (parent == null) { this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); continue; } if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) { this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`); continue; } parent.children[renderUpdate.index] = renderUpdate.vdom; this.incVDomNodeVersion(parent); continue; } if (renderUpdate.updatetype == "remove") { let parent = idMap.get(renderUpdate.waveid); if (parent == null) { this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); continue; } if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) { this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`); continue; } parent.children.splice(renderUpdate.index, 1); this.incVDomNodeVersion(parent); continue; } if (renderUpdate.updatetype == "insert") { let parent = idMap.get(renderUpdate.waveid); if (parent == null) { this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); continue; } if (parent.children == null) { parent.children = []; } if (renderUpdate.index < 0 || parent.children.length < renderUpdate.index) { this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`); continue; } parent.children.splice(renderUpdate.index, 0, renderUpdate.vdom); this.incVDomNodeVersion(parent); continue; } this.addErrorMessage(`Unknown updatetype ${renderUpdate.updatetype}`); } } setAtomValue(atomName: string, value: any, fromBe: boolean, idMap: Map) { dlog("setAtomValue", atomName, value, fromBe); let container = this.getAtomContainer(atomName); container.val = value; if (fromBe) { container.beVal = value; } for (let id of container.usedBy) { this.incVDomNodeVersion(idMap.get(id)); } } handleStateSync(update: VDomBackendUpdate, idMap: Map) { if (update.statesync == null) { return; } for (let sync of update.statesync) { this.setAtomValue(sync.atom, sync.value, true, idMap); } } getRefElem(refId: string): HTMLElement { if (refId == this.rootRefId) { return this.viewRef.current; } const ref = this.refs.get(refId); return ref?.elem; } handleRefOperations(update: VDomBackendUpdate, idMap: Map) { if (update.refoperations == null) { return; } for (let refOp of update.refoperations) { const elem = this.getRefElem(refOp.refid); if (elem == null) { this.addErrorMessage(`Could not find ref with id ${refOp.refid}`); continue; } if (elem instanceof HTMLCanvasElement) { applyCanvasOp(elem, refOp, this.refOutputStore); continue; } if (refOp.op == "focus") { if (elem == null) { this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: elem is null`); continue; } try { elem.focus(); } catch (e) { this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: ${e.message}`); } } else { this.addErrorMessage(`Unknown ref operation ${refOp.refid} ${refOp.op}`); } } } handleBackendUpdate(update: VDomBackendUpdate) { if (update == null) { return; } globalStore.set(this.contextActive, true); const idMap = new Map(); const vdomRoot = globalStore.get(this.vdomRoot); if (update.opts != null) { this.backendOpts = update.opts; } makeVDomIdMap(vdomRoot, idMap); this.handleRenderUpdates(update, idMap); this.handleStateSync(update, idMap); this.handleRefOperations(update, idMap); if (update.messages) { for (let message of update.messages) { console.log("vdom-message", this.blockId, message.messagetype, message.message); if (message.stacktrace) { console.log("vdom-message-stacktrace", message.stacktrace); } } } globalStore.set(this.globalVersion, globalStore.get(this.globalVersion) + 1); if (update.haswork) { this.hasBackendWork = true; } } renderDone(version: number) { // called when the render is done dlog("renderDone", version); if (this.hasRefUpdates() || this.hasBackendWork) { this.hasBackendWork = false; this.queueUpdate(true); } } callVDomFunc(fnDecl: VDomFunc, e: React.SyntheticEvent, compId: string, propName: string) { const vdomEvent: VDomEvent = { waveid: compId, eventtype: propName, }; if (fnDecl.globalevent) { vdomEvent.globaleventtype = fnDecl.globalevent; } annotateEvent(vdomEvent, propName, e); this.batchedEvents.push(vdomEvent); this.queueUpdate(true); } createFeUpdate(): VDomFrontendUpdate { const blockORef = makeORef("block", this.blockId); const blockAtom = WOS.getWaveObjectAtom(blockORef); const blockData = globalStore.get(blockAtom); const isBlockFocused = globalStore.get(this.nodeModel.isFocused); const renderContext: VDomRenderContext = { blockid: this.blockId, focused: isBlockFocused, width: this.viewRef?.current?.offsetWidth ?? 0, height: this.viewRef?.current?.offsetHeight ?? 0, rootrefid: this.rootRefId, background: false, }; const feUpdate: VDomFrontendUpdate = { type: "frontendupdate", ts: Date.now(), blockid: this.blockId, rendercontext: renderContext, dispose: this.shouldDispose, resync: this.needsResync, events: this.batchedEvents, refupdates: this.getRefUpdates(), }; this.needsResync = false; this.batchedEvents = []; if (this.shouldDispose) { this.disposed = true; } return feUpdate; } getBackendRouteId(): string { const fullRoute = globalStore.get(this.backendRoute); if (fullRoute == null || !fullRoute.startsWith("proc:")) { return null; } return fullRoute?.split(":")[1]; } }