From 42e85aea6fc5adc787c07c030438f5d3fc3b5de4 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Tue, 5 Nov 2024 23:07:45 -0800 Subject: [PATCH] VDom 10 (#1206) * get RefOperations and RefUpdates working. * implement a API that can be called using RefOperations * prop to disable rehype/markdown (memory leak) --- cmd/wsh/cmd/wshcmd-html.go | 2 + frontend/app/element/markdown.tsx | 67 +++++++++++---------------- frontend/app/view/vdom/vdom-model.tsx | 24 +++++++++- frontend/app/view/vdom/vdom-utils.tsx | 47 +++++++++++++++++++ frontend/app/view/vdom/vdom.tsx | 15 +++++- frontend/types/gotypes.d.ts | 1 + pkg/vdom/vdom.go | 22 +++++++++ pkg/vdom/vdom_root.go | 48 +++++++++++++++++++ pkg/vdom/vdom_types.go | 7 +-- pkg/vdom/vdomclient/vdomclient.go | 6 ++- pkg/vdom/vdomclient/vdomserverimpl.go | 6 +++ 11 files changed, 196 insertions(+), 49 deletions(-) diff --git a/cmd/wsh/cmd/wshcmd-html.go b/cmd/wsh/cmd/wshcmd-html.go index b17119f21..c7af59493 100644 --- a/cmd/wsh/cmd/wshcmd-html.go +++ b/cmd/wsh/cmd/wshcmd-html.go @@ -130,6 +130,8 @@ var App = vdomclient.DefineComponent[struct{}](HtmlVDomClient, "App", vdom.E("div", nil, vdom.E("wave:markdown", vdom.P("text", "*quick vdom application to set background colors*"), + vdom.P("scrollable", false), + vdom.P("rehype", false), ), ), vdom.E("div", nil, diff --git a/frontend/app/element/markdown.tsx b/frontend/app/element/markdown.tsx index ccbdf1004..fe2386bd2 100644 --- a/frontend/app/element/markdown.tsx +++ b/frontend/app/element/markdown.tsx @@ -180,6 +180,7 @@ type MarkdownProps = { onClickExecute?: (cmd: string) => void; resolveOpts?: MarkdownResolveOpts; scrollable?: boolean; + rehype?: boolean; }; const Markdown = ({ @@ -190,6 +191,7 @@ const Markdown = ({ className, resolveOpts, scrollable = true, + rehype = true, onClickExecute, }: MarkdownProps) => { const textAtomValue = useAtomValueSafe(textAtom); @@ -250,6 +252,29 @@ const Markdown = ({ }, [showToc, tocRef]); text = textAtomValue ?? text; + let rehypePlugins = null; + if (rehype) { + rehypePlugins = [ + rehypeRaw, + rehypeHighlight, + () => + rehypeSanitize({ + ...defaultSchema, + attributes: { + ...defaultSchema.attributes, + span: [ + ...(defaultSchema.attributes?.span || []), + // Allow all class names starting with `hljs-`. + ["className", /^hljs-./], + // Alternatively, to allow only certain class names: + // ['className', 'hljs-number', 'hljs-title', 'hljs-variable'] + ], + }, + tagNames: [...(defaultSchema.tagNames || []), "span"], + }), + () => rehypeSlug({ prefix: idPrefix }), + ]; + } const ScrollableMarkdown = () => { return ( @@ -260,26 +285,7 @@ const Markdown = ({ > - rehypeSanitize({ - ...defaultSchema, - attributes: { - ...defaultSchema.attributes, - span: [ - ...(defaultSchema.attributes?.span || []), - // Allow all class names starting with `hljs-`. - ["className", /^hljs-./], - // Alternatively, to allow only certain class names: - // ['className', 'hljs-number', 'hljs-title', 'hljs-variable'] - ], - }, - tagNames: [...(defaultSchema.tagNames || []), "span"], - }), - () => rehypeSlug({ prefix: idPrefix }), - ]} + rehypePlugins={rehypePlugins} components={markdownComponents} > {text} @@ -293,26 +299,7 @@ const Markdown = ({
- rehypeSanitize({ - ...defaultSchema, - attributes: { - ...defaultSchema.attributes, - span: [ - ...(defaultSchema.attributes?.span || []), - // Allow all class names starting with `hljs-`. - ["className", /^hljs-./], - // Alternatively, to allow only certain class names: - // ['className', 'hljs-number', 'hljs-title', 'hljs-variable'] - ], - }, - tagNames: [...(defaultSchema.tagNames || []), "span"], - }), - () => rehypeSlug({ prefix: idPrefix }), - ]} + rehypePlugins={rehypePlugins} components={markdownComponents} > {text} diff --git a/frontend/app/view/vdom/vdom-model.tsx b/frontend/app/view/vdom/vdom-model.tsx index 6e2ffe3f9..1d3520688 100644 --- a/frontend/app/view/vdom/vdom-model.tsx +++ b/frontend/app/view/vdom/vdom-model.tsx @@ -9,7 +9,7 @@ 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 { mergeBackendUpdates, restoreVDomElems } from "@/app/view/vdom/vdom-utils"; +import { applyCanvasOp, mergeBackendUpdates, restoreVDomElems } from "@/app/view/vdom/vdom-utils"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import debug from "debug"; import * as jotai from "jotai"; @@ -94,7 +94,7 @@ class VDomWshClient extends WshClient { } handle_vdomasyncinitiation(rh: RpcResponseHelper, data: VDomAsyncInitiationRequest) { - console.log("async-initiation", rh.getSource(), data); + dlog("async-initiation", rh.getSource(), data); this.model.queueUpdate(true); } } @@ -130,6 +130,9 @@ export class VDomModel { persist: jotai.Atom; routeGoneUnsub: () => void; routeConfirmed: boolean = false; + refOutputStore: Map = new Map(); + globalVersion: jotai.PrimitiveAtom = jotai.atom(0); + hasBackendWork: boolean = false; constructor(blockId: string, nodeModel: BlockNodeModel) { this.viewType = "vdom"; @@ -201,6 +204,9 @@ export class VDomModel { 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); } @@ -537,6 +543,10 @@ export class VDomModel { 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`); @@ -575,7 +585,17 @@ export class VDomModel { } } } + 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); } } diff --git a/frontend/app/view/vdom/vdom-utils.tsx b/frontend/app/view/vdom/vdom-utils.tsx index 17a8e4475..ce6052def 100644 --- a/frontend/app/view/vdom/vdom-utils.tsx +++ b/frontend/app/view/vdom/vdom-utils.tsx @@ -197,3 +197,50 @@ export function mergeBackendUpdates(baseUpdate: VDomBackendUpdate, nextUpdate: V baseUpdate.statesync.push(...nextUpdate.statesync); } } + +export function applyCanvasOp(canvas: HTMLCanvasElement, canvasOp: VDomRefOperation, refStore: Map) { + const ctx = canvas.getContext("2d"); + if (!ctx) { + console.error("Canvas 2D context not available."); + return; + } + + let { op, params, outputref } = canvasOp; + if (params == null) { + params = []; + } + if (op == null || op == "") { + return; + } + // Resolve any reference parameters in params + const resolvedParams: any[] = []; + params.forEach((param) => { + if (typeof param === "string" && param.startsWith("#ref:")) { + const refId = param.slice(5); // Remove "#ref:" prefix + resolvedParams.push(refStore.get(refId)); + } else if (typeof param === "string" && param.startsWith("#spreadRef:")) { + const refId = param.slice(11); // Remove "#spreadRef:" prefix + const arrayRef = refStore.get(refId); + if (Array.isArray(arrayRef)) { + resolvedParams.push(...arrayRef); // Spread array elements + } else { + console.error(`Reference ${refId} is not an array and cannot be spread.`); + } + } else { + resolvedParams.push(param); + } + }); + + // Apply the operation on the canvas context + if (op === "dropRef" && params.length > 0 && typeof params[0] === "string") { + refStore.delete(params[0]); + } else if (op === "addRef" && outputref) { + refStore.set(outputref, resolvedParams[0]); + } else if (typeof ctx[op as keyof CanvasRenderingContext2D] === "function") { + (ctx[op as keyof CanvasRenderingContext2D] as Function).apply(ctx, resolvedParams); + } else if (op in ctx) { + (ctx as any)[op] = resolvedParams[0]; + } else { + console.error(`Unsupported canvas operation: ${op}`); + } +} diff --git a/frontend/app/view/vdom/vdom.tsx b/frontend/app/view/vdom/vdom.tsx index c4098dfcd..19c3fc4b3 100644 --- a/frontend/app/view/vdom/vdom.tsx +++ b/frontend/app/view/vdom/vdom.tsx @@ -72,6 +72,7 @@ const AllowedSimpleTags: { [tagName: string]: boolean } = { br: true, pre: true, code: true, + canvas: true, }; const AllowedSvgTags = { @@ -350,7 +351,13 @@ function useVDom(model: VDomModel, elem: VDomElem): GenericPropsType { function WaveMarkdown({ elem, model }: { elem: VDomElem; model: VDomModel }) { const props = useVDom(model, elem); return ( - + ); } @@ -452,11 +459,15 @@ const testVDom: VDomElem = { }; function VDomRoot({ model }: { model: VDomModel }) { + let version = jotai.useAtomValue(model.globalVersion); let rootNode = jotai.useAtomValue(model.vdomRoot); + React.useEffect(() => { + model.renderDone(version); + }, [version]); if (model.viewRef.current == null || rootNode == null) { return null; } - dlog("render", rootNode); + dlog("render", version, rootNode); let rtn = convertElemToTag(rootNode, model); return
{rtn}
; } diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index d59b5c028..2954c8f8d 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -741,6 +741,7 @@ declare global { refid: string; op: string; params?: any[]; + outputref?: string; }; // vdom.VDomRefPosition diff --git a/pkg/vdom/vdom.go b/pkg/vdom/vdom.go index 945dbdcb5..4266ddf60 100644 --- a/pkg/vdom/vdom.go +++ b/pkg/vdom/vdom.go @@ -346,6 +346,28 @@ func UseId(ctx context.Context) string { return vc.Comp.WaveId } +func UseRenderTs(ctx context.Context) int64 { + vc := getRenderContext(ctx) + if vc == nil { + panic("UseRenderTs must be called within a component (no context)") + } + return vc.Root.RenderTs +} + +func QueueRefOp(ctx context.Context, ref *VDomRef, op VDomRefOperation) { + if ref == nil || !ref.HasCurrent { + return + } + vc := getRenderContext(ctx) + if vc == nil { + panic("QueueRefOp must be called within a component (no context)") + } + if op.RefId == "" { + op.RefId = ref.RefId + } + vc.Root.QueueRefOp(op) +} + func depsEqual(deps1 []any, deps2 []any) bool { if len(deps1) != len(deps2) { return false diff --git a/pkg/vdom/vdom_root.go b/pkg/vdom/vdom_root.go index 954edd9af..9bcbf7844 100644 --- a/pkg/vdom/vdom_root.go +++ b/pkg/vdom/vdom_root.go @@ -6,7 +6,10 @@ package vdom import ( "context" "fmt" + "log" "reflect" + "strconv" + "strings" "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/util/utilfn" @@ -36,11 +39,13 @@ type Atom struct { type RootElem struct { OuterCtx context.Context Root *ComponentImpl + RenderTs int64 CFuncs map[string]any CompMap map[string]*ComponentImpl // component waveid -> component EffectWorkQueue []*EffectWorkElem NeedsRenderMap map[string]bool Atoms map[string]*Atom + RefOperations []VDomRefOperation } const ( @@ -414,6 +419,49 @@ func (r *RootElem) renderComponent(cfunc any, elem *VDomElem, comp **ComponentIm r.render(rtnElem, &(*comp).Comp) } +func (r *RootElem) UpdateRef(updateRef VDomRefUpdate) { + refId := updateRef.RefId + split := strings.SplitN(refId, ":", 2) + if len(split) != 2 { + log.Printf("invalid ref id: %s\n", refId) + return + } + waveId := split[0] + hookIdx, err := strconv.Atoi(split[1]) + if err != nil { + log.Printf("invalid ref id (bad hook idx): %s\n", refId) + return + } + comp := r.CompMap[waveId] + if comp == nil { + return + } + if hookIdx < 0 || hookIdx >= len(comp.Hooks) { + return + } + hook := comp.Hooks[hookIdx] + if hook == nil { + return + } + ref, ok := hook.Val.(*VDomRef) + if !ok { + return + } + ref.HasCurrent = updateRef.HasCurrent + ref.Position = updateRef.Position + r.AddRenderWork(waveId) +} + +func (r *RootElem) QueueRefOp(op VDomRefOperation) { + r.RefOperations = append(r.RefOperations, op) +} + +func (r *RootElem) GetRefOperations() []VDomRefOperation { + ops := r.RefOperations + r.RefOperations = nil + return ops +} + func convertPropsToVDom(props map[string]any) map[string]any { if len(props) == 0 { return nil diff --git a/pkg/vdom/vdom_types.go b/pkg/vdom/vdom_types.go index ae77fcd8a..a2d1ea6f9 100644 --- a/pkg/vdom/vdom_types.go +++ b/pkg/vdom/vdom_types.go @@ -188,9 +188,10 @@ type VDomRenderUpdate struct { } type VDomRefOperation struct { - RefId string `json:"refid"` - Op string `json:"op" tsype:"\"focus\""` - Params []any `json:"params,omitempty"` + RefId string `json:"refid"` + Op string `json:"op"` + Params []any `json:"params,omitempty"` + OutputRef string `json:"outputref,omitempty"` } type VDomMessage struct { diff --git a/pkg/vdom/vdomclient/vdomclient.go b/pkg/vdom/vdomclient/vdomclient.go index ee26e0ecc..47a79c7e6 100644 --- a/pkg/vdom/vdomclient/vdomclient.go +++ b/pkg/vdom/vdomclient/vdomclient.go @@ -211,7 +211,8 @@ func (c *Client) fullRender() (*vdom.VDomBackendUpdate, error) { RenderUpdates: []vdom.VDomRenderUpdate{ {UpdateType: "root", VDom: renderedVDom}, }, - StateSync: c.Root.GetStateSync(true), + RefOperations: c.Root.GetRefOperations(), + StateSync: c.Root.GetStateSync(true), }, nil } @@ -228,7 +229,8 @@ func (c *Client) incrementalRender() (*vdom.VDomBackendUpdate, error) { RenderUpdates: []vdom.VDomRenderUpdate{ {UpdateType: "root", VDom: renderedVDom}, }, - StateSync: c.Root.GetStateSync(false), + RefOperations: c.Root.GetRefOperations(), + StateSync: c.Root.GetStateSync(false), }, nil } diff --git a/pkg/vdom/vdomclient/vdomserverimpl.go b/pkg/vdom/vdomclient/vdomserverimpl.go index f79584e17..e14c2f476 100644 --- a/pkg/vdom/vdomclient/vdomserverimpl.go +++ b/pkg/vdom/vdomclient/vdomserverimpl.go @@ -46,6 +46,8 @@ func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom return respChan } + impl.Client.Root.RenderTs = feUpdate.Ts + // set atoms for _, ss := range feUpdate.StateSync { impl.Client.Root.SetAtomVal(ss.Atom, ss.Value, false) @@ -60,6 +62,10 @@ func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom impl.Client.Root.Event(event.WaveId, event.EventType, event) } } + // update refs + for _, ref := range feUpdate.RefUpdates { + impl.Client.Root.UpdateRef(ref) + } var update *vdom.VDomBackendUpdate var err error