mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-21 16:38:23 +01:00
vdom 3 (#1033)
This commit is contained in:
parent
c1c90bb4f8
commit
46783ba315
@ -29,6 +29,7 @@ func GenerateWshClient() error {
|
||||
"github.com/wavetermdev/waveterm/pkg/waveobj",
|
||||
"github.com/wavetermdev/waveterm/pkg/wconfig",
|
||||
"github.com/wavetermdev/waveterm/pkg/wps",
|
||||
"github.com/wavetermdev/waveterm/pkg/vdom",
|
||||
})
|
||||
wshDeclMap := wshrpc.GenerateWshCommandDeclMap()
|
||||
for _, key := range utilfn.GetOrderedMapKeys(wshDeclMap) {
|
||||
|
@ -14,7 +14,7 @@ import (
|
||||
|
||||
func Page(ctx context.Context, props map[string]any) any {
|
||||
clicked, setClicked := vdom.UseState(ctx, false)
|
||||
var clickedDiv *vdom.Elem
|
||||
var clickedDiv *vdom.VDomElem
|
||||
if clicked {
|
||||
clickedDiv = vdom.Bind(`<div>clicked</div>`, nil)
|
||||
}
|
||||
@ -35,7 +35,7 @@ func Page(ctx context.Context, props map[string]any) any {
|
||||
}
|
||||
|
||||
func Button(ctx context.Context, props map[string]any) any {
|
||||
ref := vdom.UseRef(ctx, nil)
|
||||
ref := vdom.UseVDomRef(ctx)
|
||||
clName, setClName := vdom.UseState(ctx, "button")
|
||||
vdom.UseEffect(ctx, func() func() {
|
||||
fmt.Printf("Button useEffect\n")
|
||||
|
@ -4,9 +4,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/wavetermdev/waveterm/pkg/vdom"
|
||||
"github.com/wavetermdev/waveterm/pkg/vdom/vdomclient"
|
||||
"github.com/wavetermdev/waveterm/pkg/wshutil"
|
||||
)
|
||||
|
||||
@ -17,27 +20,59 @@ func init() {
|
||||
var htmlCmd = &cobra.Command{
|
||||
Use: "html",
|
||||
Hidden: true,
|
||||
Short: "Launch a demo html-mode terminal",
|
||||
Run: htmlRun,
|
||||
PreRunE: preRunSetupRpcClient,
|
||||
Short: "launch demo vdom application",
|
||||
RunE: htmlRun,
|
||||
}
|
||||
|
||||
func htmlRun(cmd *cobra.Command, args []string) {
|
||||
defer wshutil.DoShutdown("normal exit", 0, true)
|
||||
setTermHtmlMode()
|
||||
for {
|
||||
var buf [1]byte
|
||||
_, err := WrappedStdin.Read(buf[:])
|
||||
if err != nil {
|
||||
wshutil.DoShutdown(fmt.Sprintf("stdin closed/error (%v)", err), 1, true)
|
||||
}
|
||||
if buf[0] == 0x03 {
|
||||
wshutil.DoShutdown("read Ctrl-C from stdin", 1, true)
|
||||
break
|
||||
}
|
||||
if buf[0] == 'x' {
|
||||
wshutil.DoShutdown("read 'x' from stdin", 0, true)
|
||||
break
|
||||
}
|
||||
func MakeVDom() *vdom.VDomElem {
|
||||
vdomStr := `
|
||||
<div>
|
||||
<h1 style="color:red; background-color: #bind:$.bgcolor; border-radius: 4px; padding: 5px;">hello vdom world</h1>
|
||||
<div><bind key="$.text"/> | num[<bind key="$.num"/>]</div>
|
||||
<div>
|
||||
<button onClick="#globalevent:clickinc">increment</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
elem := vdom.Bind(vdomStr, nil)
|
||||
return elem
|
||||
}
|
||||
|
||||
func GlobalEventHandler(client *vdomclient.Client, event vdom.VDomEvent) {
|
||||
if event.PropName == "clickinc" {
|
||||
client.SetAtomVal("num", client.GetAtomVal("num").(int)+1)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func htmlRun(cmd *cobra.Command, args []string) error {
|
||||
WriteStderr("running wsh html %q\n", RpcContext.BlockId)
|
||||
|
||||
client, err := vdomclient.MakeClient(&vdom.VDomBackendOpts{CloseOnCtrlC: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client.SetGlobalEventHandler(GlobalEventHandler)
|
||||
log.Printf("created client: %v\n", client)
|
||||
client.SetAtomVal("bgcolor", "#0000ff77")
|
||||
client.SetAtomVal("text", "initial text")
|
||||
client.SetAtomVal("num", 0)
|
||||
client.SetRootElem(MakeVDom())
|
||||
err = client.CreateVDomContext()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("created context\n")
|
||||
go func() {
|
||||
<-client.DoneCh
|
||||
wshutil.DoShutdown("vdom closed by FE", 0, true)
|
||||
}()
|
||||
log.Printf("created vdom context\n")
|
||||
go func() {
|
||||
time.Sleep(5 * time.Second)
|
||||
client.SetAtomVal("text", "updated text")
|
||||
client.SendAsyncInitiation()
|
||||
}()
|
||||
<-client.DoneCh
|
||||
return nil
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ type FullBlockProps = {
|
||||
|
||||
function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel): ViewModel {
|
||||
if (blockView === "term") {
|
||||
return makeTerminalModel(blockId);
|
||||
return makeTerminalModel(blockId, nodeModel);
|
||||
}
|
||||
if (blockView === "preview") {
|
||||
return makePreviewModel(blockId, nodeModel);
|
||||
@ -253,7 +253,7 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => {
|
||||
|
||||
const Block = memo((props: BlockProps) => {
|
||||
counterInc("render-Block");
|
||||
counterInc("render-Block-" + props.nodeModel.blockId.substring(0, 8));
|
||||
counterInc("render-Block-" + props.nodeModel?.blockId?.substring(0, 8));
|
||||
const [blockData, loading] = useWaveObjectValue<Block>(makeORef("block", props.nodeModel.blockId));
|
||||
const bcm = getBlockComponentModel(props.nodeModel.blockId);
|
||||
let viewModel = bcm?.viewModel;
|
||||
@ -264,6 +264,7 @@ const Block = memo((props: BlockProps) => {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
unregisterBlockComponentModel(props.nodeModel.blockId);
|
||||
viewModel?.dispose?.();
|
||||
};
|
||||
}, []);
|
||||
if (loading || isBlank(props.nodeModel.blockId) || blockData == null) {
|
||||
|
@ -18,6 +18,10 @@ class RpcResponseHelper {
|
||||
this.done = cmdMsg.reqid == null;
|
||||
}
|
||||
|
||||
getSource(): string {
|
||||
return this.cmdMsg?.source;
|
||||
}
|
||||
|
||||
sendResponse(msg: RpcMessage) {
|
||||
if (this.done || util.isBlank(this.cmdMsg.reqid)) {
|
||||
return;
|
||||
|
@ -217,6 +217,21 @@ class RpcApiType {
|
||||
return client.wshRpcCall("test", data, opts);
|
||||
}
|
||||
|
||||
// command "vdomasyncinitiation" [call]
|
||||
VDomAsyncInitiationCommand(client: WshClient, data: VDomAsyncInitiationRequest, opts?: RpcOpts): Promise<void> {
|
||||
return client.wshRpcCall("vdomasyncinitiation", data, opts);
|
||||
}
|
||||
|
||||
// command "vdomcreatecontext" [call]
|
||||
VDomCreateContextCommand(client: WshClient, data: VDomCreateContext, opts?: RpcOpts): Promise<void> {
|
||||
return client.wshRpcCall("vdomcreatecontext", data, opts);
|
||||
}
|
||||
|
||||
// command "vdomrender" [call]
|
||||
VDomRenderCommand(client: WshClient, data: VDomFrontendUpdate, opts?: RpcOpts): Promise<VDomBackendUpdate> {
|
||||
return client.wshRpcCall("vdomrender", data, opts);
|
||||
}
|
||||
|
||||
// command "webselector" [call]
|
||||
WebSelectorCommand(client: WshClient, data: CommandWebSelectorData, opts?: RpcOpts): Promise<string[]> {
|
||||
return client.wshRpcCall("webselector", data, opts);
|
||||
|
52
frontend/app/view/term/term-wsh.tsx
Normal file
52
frontend/app/view/term/term-wsh.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { WOS } from "@/app/store/global";
|
||||
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 { TermViewModel } from "@/app/view/term/term";
|
||||
import debug from "debug";
|
||||
|
||||
const dlog = debug("wave:vdom");
|
||||
|
||||
export class TermWshClient extends WshClient {
|
||||
blockId: string;
|
||||
model: TermViewModel;
|
||||
|
||||
constructor(blockId: string, model: TermViewModel) {
|
||||
super(makeFeBlockRouteId(blockId));
|
||||
this.blockId = blockId;
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
handle_vdomcreatecontext(rh: RpcResponseHelper, data: VDomCreateContext) {
|
||||
console.log("vdom-create", rh.getSource(), data);
|
||||
this.model.vdomModel.reset();
|
||||
this.model.vdomModel.backendRoute = rh.getSource();
|
||||
if (!data.persist) {
|
||||
const unsubFn = waveEventSubscribe({
|
||||
eventType: "route:gone",
|
||||
scope: rh.getSource(),
|
||||
handler: () => {
|
||||
RpcApi.SetMetaCommand(this, {
|
||||
oref: WOS.makeORef("block", this.blockId),
|
||||
meta: { "term:mode": null },
|
||||
});
|
||||
unsubFn();
|
||||
},
|
||||
});
|
||||
}
|
||||
RpcApi.SetMetaCommand(this, {
|
||||
oref: WOS.makeORef("block", this.blockId),
|
||||
meta: { "term:mode": "html" },
|
||||
});
|
||||
this.model.vdomModel.queueUpdate(true);
|
||||
}
|
||||
|
||||
handle_vdomasyncinitiation(rh: RpcResponseHelper, data: VDomAsyncInitiationRequest) {
|
||||
console.log("async-initiation", rh.getSource(), data);
|
||||
this.model.vdomModel.queueUpdate(true);
|
||||
}
|
||||
}
|
@ -4,12 +4,15 @@
|
||||
import { getAllGlobalKeyBindings } from "@/app/store/keymodel";
|
||||
import { waveEventSubscribe } from "@/app/store/wps";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { makeFeBlockRouteId } from "@/app/store/wshrouter";
|
||||
import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { TermWshClient } from "@/app/view/term/term-wsh";
|
||||
import { VDomView } from "@/app/view/term/vdom";
|
||||
import { VDomModel } from "@/app/view/term/vdom-model";
|
||||
import { NodeModel } from "@/layout/index";
|
||||
import { WOS, atoms, getConnStatusAtom, getSettingsKeyAtom, globalStore, useSettingsPrefixAtom } from "@/store/global";
|
||||
import * as services from "@/store/services";
|
||||
import * as keyutil from "@/util/keyutil";
|
||||
import * as util from "@/util/util";
|
||||
import clsx from "clsx";
|
||||
import * as jotai from "jotai";
|
||||
import * as React from "react";
|
||||
@ -19,102 +22,35 @@ import { computeTheme } from "./termutil";
|
||||
import { TermWrap } from "./termwrap";
|
||||
import "./xterm.css";
|
||||
|
||||
const keyMap = {
|
||||
Enter: "\r",
|
||||
Backspace: "\x7f",
|
||||
Tab: "\t",
|
||||
Escape: "\x1b",
|
||||
ArrowUp: "\x1b[A",
|
||||
ArrowDown: "\x1b[B",
|
||||
ArrowRight: "\x1b[C",
|
||||
ArrowLeft: "\x1b[D",
|
||||
Insert: "\x1b[2~",
|
||||
Delete: "\x1b[3~",
|
||||
Home: "\x1b[1~",
|
||||
End: "\x1b[4~",
|
||||
PageUp: "\x1b[5~",
|
||||
PageDown: "\x1b[6~",
|
||||
};
|
||||
|
||||
function keyboardEventToASCII(event: React.KeyboardEvent<HTMLInputElement>): string {
|
||||
// check modifiers
|
||||
// if no modifiers are set, just send the key
|
||||
if (!event.altKey && !event.ctrlKey && !event.metaKey) {
|
||||
if (event.key == null || event.key == "") {
|
||||
return "";
|
||||
}
|
||||
if (keyMap[event.key] != null) {
|
||||
return keyMap[event.key];
|
||||
}
|
||||
if (event.key.length == 1) {
|
||||
return event.key;
|
||||
} else {
|
||||
console.log("not sending keyboard event", event.key, event);
|
||||
}
|
||||
}
|
||||
// if meta or alt is set, there is no ASCII representation
|
||||
if (event.metaKey || event.altKey) {
|
||||
return "";
|
||||
}
|
||||
// if ctrl is set, if it is a letter, subtract 64 from the uppercase value to get the ASCII value
|
||||
if (event.ctrlKey) {
|
||||
if (
|
||||
(event.key.length === 1 && event.key >= "A" && event.key <= "Z") ||
|
||||
(event.key >= "a" && event.key <= "z")
|
||||
) {
|
||||
const key = event.key.toUpperCase();
|
||||
return String.fromCharCode(key.charCodeAt(0) - 64);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
type InitialLoadDataType = {
|
||||
loaded: boolean;
|
||||
heldData: Uint8Array[];
|
||||
};
|
||||
|
||||
function vdomText(text: string): VDomElem {
|
||||
return {
|
||||
tag: "#text",
|
||||
text: text,
|
||||
};
|
||||
}
|
||||
|
||||
const testVDom: VDomElem = {
|
||||
id: "testid1",
|
||||
tag: "div",
|
||||
children: [
|
||||
{
|
||||
id: "testh1",
|
||||
tag: "h1",
|
||||
children: [vdomText("Hello World")],
|
||||
},
|
||||
{
|
||||
id: "testp",
|
||||
tag: "p",
|
||||
children: [vdomText("This is a paragraph (from VDOM)")],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
class TermViewModel {
|
||||
viewType: string;
|
||||
nodeModel: NodeModel;
|
||||
connected: boolean;
|
||||
termRef: React.RefObject<TermWrap>;
|
||||
blockAtom: jotai.Atom<Block>;
|
||||
termMode: jotai.Atom<string>;
|
||||
htmlElemFocusRef: React.RefObject<HTMLInputElement>;
|
||||
blockId: string;
|
||||
viewIcon: jotai.Atom<string>;
|
||||
viewName: jotai.Atom<string>;
|
||||
blockBg: jotai.Atom<MetaType>;
|
||||
manageConnection: jotai.Atom<boolean>;
|
||||
connStatus: jotai.Atom<ConnStatus>;
|
||||
termWshClient: TermWshClient;
|
||||
shellProcStatusRef: React.MutableRefObject<string>;
|
||||
vdomModel: VDomModel;
|
||||
|
||||
constructor(blockId: string) {
|
||||
constructor(blockId: string, nodeModel: NodeModel) {
|
||||
this.viewType = "term";
|
||||
this.blockId = blockId;
|
||||
this.termWshClient = new TermWshClient(blockId, this);
|
||||
DefaultRouter.registerRoute(makeFeBlockRouteId(blockId), this.termWshClient);
|
||||
this.nodeModel = nodeModel;
|
||||
this.vdomModel = new VDomModel(blockId, nodeModel, null, this.termWshClient);
|
||||
this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);
|
||||
this.termMode = jotai.atom((get) => {
|
||||
const blockData = get(this.blockAtom);
|
||||
@ -152,6 +88,10 @@ class TermViewModel {
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId));
|
||||
}
|
||||
|
||||
giveFocus(): boolean {
|
||||
let termMode = globalStore.get(this.termMode);
|
||||
if (termMode == "term") {
|
||||
@ -159,15 +99,70 @@ class TermViewModel {
|
||||
this.termRef.current.terminal.focus();
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
if (this.htmlElemFocusRef?.current) {
|
||||
this.htmlElemFocusRef.current.focus();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
keyDownHandler(waveEvent: WaveKeyboardEvent): boolean {
|
||||
if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) {
|
||||
const blockAtom = WOS.getWaveObjectAtom<Block>(`block:${this.blockId}`);
|
||||
const blockData = globalStore.get(blockAtom);
|
||||
const newTermMode = blockData?.meta?.["term:mode"] == "html" ? null : "html";
|
||||
RpcApi.SetMetaCommand(TabRpcClient, {
|
||||
oref: WOS.makeORef("block", this.blockId),
|
||||
meta: { "term:mode": newTermMode },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const blockData = globalStore.get(this.blockAtom);
|
||||
if (blockData.meta?.["term:mode"] == "html") {
|
||||
return this.vdomModel?.globalKeydownHandler(waveEvent);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
handleTerminalKeydown(event: KeyboardEvent): boolean {
|
||||
const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event);
|
||||
if (waveEvent.type != "keydown") {
|
||||
return true;
|
||||
}
|
||||
if (this.keyDownHandler(waveEvent)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
// deal with terminal specific keybindings
|
||||
if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) {
|
||||
const p = navigator.clipboard.readText();
|
||||
p.then((text) => {
|
||||
this.termRef.current?.terminal.paste(text);
|
||||
});
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
} else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) {
|
||||
const sel = this.termRef.current?.terminal.getSelection();
|
||||
navigator.clipboard.writeText(sel);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
if (this.shellProcStatusRef.current != "running" && keyutil.checkKeyPressed(waveEvent, "Enter")) {
|
||||
// restart
|
||||
const tabId = globalStore.get(atoms.staticTabId);
|
||||
const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: tabId, blockid: this.blockId });
|
||||
prtn.catch((e) => console.log("error controller resync (enter)", this.blockId, e));
|
||||
return false;
|
||||
}
|
||||
const globalKeys = getAllGlobalKeyBindings();
|
||||
for (const key of globalKeys) {
|
||||
if (keyutil.checkKeyPressed(waveEvent, key)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
setTerminalTheme(themeName: string) {
|
||||
RpcApi.SetMetaCommand(TabRpcClient, {
|
||||
oref: WOS.makeORef("block", this.blockId),
|
||||
@ -215,8 +210,8 @@ class TermViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
function makeTerminalModel(blockId: string): TermViewModel {
|
||||
return new TermViewModel(blockId);
|
||||
function makeTerminalModel(blockId: string, nodeModel: NodeModel): TermViewModel {
|
||||
return new TermViewModel(blockId, nodeModel);
|
||||
}
|
||||
|
||||
interface TerminalViewProps {
|
||||
@ -247,63 +242,22 @@ const TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) =>
|
||||
});
|
||||
|
||||
const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
||||
const viewRef = React.createRef<HTMLDivElement>();
|
||||
const viewRef = React.useRef<HTMLDivElement>(null);
|
||||
const connectElemRef = React.useRef<HTMLDivElement>(null);
|
||||
const termRef = React.useRef<TermWrap>(null);
|
||||
model.termRef = termRef;
|
||||
const shellProcStatusRef = React.useRef<string>(null);
|
||||
const htmlElemFocusRef = React.useRef<HTMLInputElement>(null);
|
||||
model.htmlElemFocusRef = htmlElemFocusRef;
|
||||
const spstatusRef = React.useRef<string>(null);
|
||||
model.shellProcStatusRef = spstatusRef;
|
||||
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
|
||||
const termSettingsAtom = useSettingsPrefixAtom("term");
|
||||
const termSettings = jotai.useAtomValue(termSettingsAtom);
|
||||
let termMode = blockData?.meta?.["term:mode"] ?? "term";
|
||||
if (termMode != "term" && termMode != "html") {
|
||||
termMode = "term";
|
||||
}
|
||||
const termModeRef = React.useRef(termMode);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleTerminalKeydown(event: KeyboardEvent): boolean {
|
||||
const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event);
|
||||
if (waveEvent.type != "keydown") {
|
||||
return true;
|
||||
}
|
||||
// deal with terminal specific keybindings
|
||||
if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
RpcApi.SetMetaCommand(TabRpcClient, {
|
||||
oref: WOS.makeORef("block", blockId),
|
||||
meta: { "term:mode": null },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) {
|
||||
const p = navigator.clipboard.readText();
|
||||
p.then((text) => {
|
||||
termRef.current?.terminal.paste(text);
|
||||
});
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
} else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) {
|
||||
const sel = termRef.current?.terminal.getSelection();
|
||||
navigator.clipboard.writeText(sel);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
if (shellProcStatusRef.current != "running" && keyutil.checkKeyPressed(waveEvent, "Enter")) {
|
||||
// restart
|
||||
const tabId = globalStore.get(atoms.staticTabId);
|
||||
const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: tabId, blockid: blockId });
|
||||
prtn.catch((e) => console.log("error controller resync (enter)", blockId, e));
|
||||
return false;
|
||||
}
|
||||
const globalKeys = getAllGlobalKeyBindings();
|
||||
for (const key of globalKeys) {
|
||||
if (keyutil.checkKeyPressed(waveEvent, key)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const fullConfig = globalStore.get(atoms.fullConfigAtom);
|
||||
const termTheme = computeTheme(fullConfig, blockData?.meta?.["term:theme"]);
|
||||
const themeCopy = { ...termTheme };
|
||||
@ -335,7 +289,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
||||
scrollback: termScrollback,
|
||||
},
|
||||
{
|
||||
keydownHandler: handleTerminalKeydown,
|
||||
keydownHandler: model.handleTerminalKeydown.bind(model),
|
||||
useWebGl: !termSettings?.["term:disablewebgl"],
|
||||
}
|
||||
);
|
||||
@ -352,29 +306,13 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
||||
};
|
||||
}, [blockId, termSettings]);
|
||||
|
||||
const handleHtmlKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event);
|
||||
if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) {
|
||||
// reset term:mode
|
||||
RpcApi.SetMetaCommand(TabRpcClient, {
|
||||
oref: WOS.makeORef("block", blockId),
|
||||
meta: { "term:mode": null },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const asciiVal = keyboardEventToASCII(event);
|
||||
if (asciiVal.length == 0) {
|
||||
return false;
|
||||
}
|
||||
const b64data = util.stringToBase64(asciiVal);
|
||||
RpcApi.ControllerInputCommand(TabRpcClient, { blockid: blockId, inputdata64: b64data });
|
||||
return true;
|
||||
};
|
||||
|
||||
let termMode = blockData?.meta?.["term:mode"] ?? "term";
|
||||
if (termMode != "term" && termMode != "html") {
|
||||
termMode = "term";
|
||||
React.useEffect(() => {
|
||||
if (termModeRef.current == "html" && termMode == "term") {
|
||||
// focus the terminal
|
||||
model.giveFocus();
|
||||
}
|
||||
termModeRef.current = termMode;
|
||||
}, [termMode]);
|
||||
|
||||
// set intitial controller status, and then subscribe for updates
|
||||
React.useEffect(() => {
|
||||
@ -382,7 +320,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
||||
if (status == null) {
|
||||
return;
|
||||
}
|
||||
shellProcStatusRef.current = status;
|
||||
model.shellProcStatusRef.current = status;
|
||||
if (status == "running") {
|
||||
termRef.current?.setIsRunning(true);
|
||||
} else {
|
||||
@ -418,26 +356,9 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
||||
<TermThemeUpdater blockId={blockId} termRef={termRef} />
|
||||
<TermStickers config={stickerConfig} />
|
||||
<div key="conntectElem" className="term-connectelem" ref={connectElemRef}></div>
|
||||
<div
|
||||
key="htmlElem"
|
||||
className="term-htmlelem"
|
||||
onClick={() => {
|
||||
if (htmlElemFocusRef.current != null) {
|
||||
htmlElemFocusRef.current.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div key="htmlElemFocus" className="term-htmlelem-focus">
|
||||
<input
|
||||
type="text"
|
||||
value={""}
|
||||
ref={htmlElemFocusRef}
|
||||
onKeyDown={handleHtmlKeyDown}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div key="htmlElem" className="term-htmlelem">
|
||||
<div key="htmlElemContent" className="term-htmlelem-content">
|
||||
<VDomView rootNode={testVDom} />
|
||||
<VDomView blockId={blockId} nodeModel={model.nodeModel} viewRef={viewRef} model={model.vdomModel} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
528
frontend/app/view/term/vdom-model.tsx
Normal file
528
frontend/app/view/term/vdom-model.tsx
Normal file
@ -0,0 +1,528 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { globalStore, WOS } from "@/app/store/global";
|
||||
import { makeORef } from "@/app/store/wos";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { TermWshClient } from "@/app/view/term/term-wsh";
|
||||
import { NodeModel } from "@/layout/index";
|
||||
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<string>;
|
||||
};
|
||||
|
||||
type RefContainer = {
|
||||
refFn: (elem: HTMLElement) => void;
|
||||
vdomRef: VDomRef;
|
||||
elem: HTMLElement;
|
||||
updated: boolean;
|
||||
};
|
||||
|
||||
function makeVDomIdMap(vdom: VDomElem, idMap: Map<string, VDomElem>) {
|
||||
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 convertEvent(e: React.SyntheticEvent, fromProp: string): any {
|
||||
if (e == null) {
|
||||
return null;
|
||||
}
|
||||
if (fromProp == "onClick") {
|
||||
return { type: "click" };
|
||||
}
|
||||
if (fromProp == "onKeyDown") {
|
||||
const waveKeyEvent = adaptFromReactOrNativeKeyEvent(e as React.KeyboardEvent);
|
||||
return waveKeyEvent;
|
||||
}
|
||||
if (fromProp == "onFocus") {
|
||||
return { type: "focus" };
|
||||
}
|
||||
if (fromProp == "onBlur") {
|
||||
return { type: "blur" };
|
||||
}
|
||||
return { type: "unknown" };
|
||||
}
|
||||
|
||||
export class VDomModel {
|
||||
blockId: string;
|
||||
nodeModel: NodeModel;
|
||||
viewRef: React.RefObject<HTMLDivElement>;
|
||||
vdomRoot: jotai.PrimitiveAtom<VDomElem> = jotai.atom();
|
||||
atoms: Map<string, AtomContainer> = new Map(); // key is atomname
|
||||
refs: Map<string, RefContainer> = new Map(); // key is refid
|
||||
batchedEvents: VDomEvent[] = [];
|
||||
messages: VDomMessage[] = [];
|
||||
needsInitialization: boolean = true;
|
||||
needsResync: boolean = true;
|
||||
vdomNodeVersion: WeakMap<VDomElem, jotai.PrimitiveAtom<number>> = new WeakMap();
|
||||
compoundAtoms: Map<string, jotai.PrimitiveAtom<{ [key: string]: any }>> = new Map();
|
||||
rootRefId: string = crypto.randomUUID();
|
||||
termWshClient: TermWshClient;
|
||||
backendRoute: string;
|
||||
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 };
|
||||
|
||||
constructor(
|
||||
blockId: string,
|
||||
nodeModel: NodeModel,
|
||||
viewRef: React.RefObject<HTMLDivElement>,
|
||||
termWshClient: TermWshClient
|
||||
) {
|
||||
this.blockId = blockId;
|
||||
this.nodeModel = nodeModel;
|
||||
this.viewRef = viewRef;
|
||||
this.termWshClient = termWshClient;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
globalStore.set(this.vdomRoot, null);
|
||||
this.atoms.clear();
|
||||
this.refs.clear();
|
||||
this.batchedEvents = [];
|
||||
this.messages = [];
|
||||
this.needsResync = true;
|
||||
this.needsInitialization = true;
|
||||
this.vdomNodeVersion = new WeakMap();
|
||||
this.compoundAtoms.clear();
|
||||
this.rootRefId = crypto.randomUUID();
|
||||
this.backendRoute = null;
|
||||
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;
|
||||
}
|
||||
|
||||
globalKeydownHandler(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({
|
||||
waveid: null,
|
||||
propname: "onKeyDown",
|
||||
eventdata: 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) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
if (this.hasPendingRequest) {
|
||||
if (force) {
|
||||
this.needsImmediateUpdate = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!force && !this.needsUpdate) {
|
||||
return;
|
||||
}
|
||||
if (this.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 beUpdate = await RpcApi.VDomRenderCommand(TabRpcClient, feUpdate, { route: this.backendRoute });
|
||||
this.handleBackendUpdate(beUpdate);
|
||||
} 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<string>) {
|
||||
for (let atomName of atomNames) {
|
||||
let container = this.getAtomContainer(atomName);
|
||||
container.usedBy.add(waveId);
|
||||
}
|
||||
}
|
||||
|
||||
tagUnuseAtoms(waveId: string, atomNames: Set<string>) {
|
||||
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<string, VDomElem>) {
|
||||
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<string, VDomElem>) {
|
||||
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<string, VDomElem>) {
|
||||
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<string, VDomElem>) {
|
||||
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 (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;
|
||||
}
|
||||
const idMap = new Map<string, VDomElem>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callVDomFunc(fnDecl: VDomFunc, e: any, compId: string, propName: string) {
|
||||
const eventData = convertEvent(e, propName);
|
||||
if (fnDecl.globalevent) {
|
||||
const waveEvent: VDomEvent = {
|
||||
waveid: null,
|
||||
propname: fnDecl.globalevent,
|
||||
eventdata: eventData,
|
||||
};
|
||||
this.batchedEvents.push(waveEvent);
|
||||
} else {
|
||||
const vdomEvent: VDomEvent = {
|
||||
waveid: compId,
|
||||
propname: propName,
|
||||
eventdata: eventData,
|
||||
};
|
||||
this.batchedEvents.push(vdomEvent);
|
||||
}
|
||||
this.queueUpdate();
|
||||
}
|
||||
|
||||
createFeUpdate(): VDomFrontendUpdate {
|
||||
const blockORef = makeORef("block", this.blockId);
|
||||
const blockAtom = WOS.getWaveObjectAtom<Block>(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,
|
||||
initialize: this.needsInitialization,
|
||||
rendercontext: renderContext,
|
||||
dispose: this.shouldDispose,
|
||||
resync: this.needsResync,
|
||||
events: this.batchedEvents,
|
||||
refupdates: this.getRefUpdates(),
|
||||
};
|
||||
this.needsResync = false;
|
||||
this.needsInitialization = false;
|
||||
this.batchedEvents = [];
|
||||
if (this.shouldDispose) {
|
||||
this.disposed = true;
|
||||
}
|
||||
return feUpdate;
|
||||
}
|
||||
}
|
@ -1,9 +1,25 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { VDomModel } from "@/app/view/term/vdom-model";
|
||||
import { NodeModel } from "@/layout/index";
|
||||
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
|
||||
import { useAtomValueSafe } from "@/util/util";
|
||||
import debug from "debug";
|
||||
import * as jotai from "jotai";
|
||||
import * as React from "react";
|
||||
|
||||
const TextTag = "#text";
|
||||
const FragmentTag = "#fragment";
|
||||
const WaveTextTag = "wave:text";
|
||||
const WaveNullTag = "wave:null";
|
||||
|
||||
const VDomObjType_Ref = "ref";
|
||||
const VDomObjType_Binding = "binding";
|
||||
const VDomObjType_Func = "func";
|
||||
|
||||
const dlog = debug("wave:vdom");
|
||||
|
||||
const AllowedTags: { [tagName: string]: boolean } = {
|
||||
div: true,
|
||||
b: true,
|
||||
@ -30,38 +46,38 @@ const AllowedTags: { [tagName: string]: boolean } = {
|
||||
form: true,
|
||||
};
|
||||
|
||||
function convertVDomFunc(fnDecl: VDomFuncType, compId: string, propName: string): (e: any) => void {
|
||||
function convertVDomFunc(model: VDomModel, fnDecl: VDomFunc, compId: string, propName: string): (e: any) => void {
|
||||
return (e: any) => {
|
||||
if ((propName == "onKeyDown" || propName == "onKeyDownCapture") && fnDecl["#keys"]) {
|
||||
let waveEvent = adaptFromReactOrNativeKeyEvent(e);
|
||||
for (let keyDesc of fnDecl["#keys"]) {
|
||||
for (let keyDesc of fnDecl.keys || []) {
|
||||
if (checkKeyPressed(waveEvent, keyDesc)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
callFunc(e, compId, propName);
|
||||
model.callVDomFunc(fnDecl, e, compId, propName);
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (fnDecl["#preventDefault"]) {
|
||||
if (fnDecl.preventdefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
if (fnDecl["#stopPropagation"]) {
|
||||
if (fnDecl.stoppropagation) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
callFunc(e, compId, propName);
|
||||
model.callVDomFunc(fnDecl, e, compId, propName);
|
||||
};
|
||||
}
|
||||
|
||||
function convertElemToTag(elem: VDomElem): JSX.Element | string {
|
||||
function convertElemToTag(elem: VDomElem, model: VDomModel): JSX.Element | string {
|
||||
if (elem == null) {
|
||||
return null;
|
||||
}
|
||||
if (elem.tag == "#text") {
|
||||
if (elem.tag == TextTag) {
|
||||
return elem.text;
|
||||
}
|
||||
return React.createElement(VDomTag, { elem: elem, key: elem.id });
|
||||
return React.createElement(VDomTag, { key: elem.waveid, elem, model });
|
||||
}
|
||||
|
||||
function isObject(v: any): boolean {
|
||||
@ -72,19 +88,35 @@ function isArray(v: any): boolean {
|
||||
return Array.isArray(v);
|
||||
}
|
||||
|
||||
function callFunc(e: any, compId: string, propName: string) {
|
||||
console.log("callfunc", compId, propName);
|
||||
}
|
||||
|
||||
function updateRefFunc(elem: any, ref: VDomRefType) {
|
||||
console.log("updateref", ref["#ref"], elem);
|
||||
}
|
||||
|
||||
function VDomTag({ elem }: { elem: VDomElem }) {
|
||||
if (!AllowedTags[elem.tag]) {
|
||||
return <div>{"Invalid Tag <" + elem.tag + ">"}</div>;
|
||||
function resolveBinding(binding: VDomBinding, model: VDomModel): [any, string[]] {
|
||||
const bindName = binding.bind;
|
||||
if (bindName == null || bindName == "") {
|
||||
return [null, []];
|
||||
}
|
||||
// for now we only recognize $.[atomname] bindings
|
||||
if (!bindName.startsWith("$.")) {
|
||||
return [null, []];
|
||||
}
|
||||
const atomName = bindName.substring(2);
|
||||
if (atomName == "") {
|
||||
return [null, []];
|
||||
}
|
||||
const atom = model.getAtomContainer(atomName);
|
||||
if (atom == null) {
|
||||
return [null, []];
|
||||
}
|
||||
return [atom.val, [atomName]];
|
||||
}
|
||||
|
||||
type GenericPropsType = { [key: string]: any };
|
||||
|
||||
// returns props, and a set of atom keys used in the props
|
||||
function convertProps(elem: VDomElem, model: VDomModel): [GenericPropsType, Set<string>] {
|
||||
let props: GenericPropsType = {};
|
||||
let atomKeys = new Set<string>();
|
||||
if (elem.props == null) {
|
||||
return [props, atomKeys];
|
||||
}
|
||||
let props = {};
|
||||
for (let key in elem.props) {
|
||||
let val = elem.props[key];
|
||||
if (val == null) {
|
||||
@ -94,35 +126,149 @@ function VDomTag({ elem }: { elem: VDomElem }) {
|
||||
if (val == null) {
|
||||
continue;
|
||||
}
|
||||
if (isObject(val) && "#ref" in val) {
|
||||
props[key] = (elem: HTMLElement) => {
|
||||
updateRefFunc(elem, val);
|
||||
};
|
||||
if (isObject(val) && val.type == VDomObjType_Ref) {
|
||||
const valRef = val as VDomRef;
|
||||
const refContainer = model.getOrCreateRefContainer(valRef);
|
||||
props[key] = refContainer.refFn;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isObject(val) && "#func" in val) {
|
||||
props[key] = convertVDomFunc(val, elem.id, key);
|
||||
if (isObject(val) && val.type == VDomObjType_Func) {
|
||||
const valFunc = val as VDomFunc;
|
||||
props[key] = convertVDomFunc(model, valFunc, elem.waveid, key);
|
||||
continue;
|
||||
}
|
||||
if (isObject(val) && val.type == VDomObjType_Binding) {
|
||||
const [propVal, atomDeps] = resolveBinding(val as VDomBinding, model);
|
||||
props[key] = propVal;
|
||||
for (let atomDep of atomDeps) {
|
||||
atomKeys.add(atomDep);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (key == "style" && isObject(val)) {
|
||||
// assuming the entire style prop wasn't bound, look through the individual keys and bind them
|
||||
for (let styleKey in val) {
|
||||
let styleVal = val[styleKey];
|
||||
if (isObject(styleVal) && styleVal.type == VDomObjType_Binding) {
|
||||
const [stylePropVal, styleAtomDeps] = resolveBinding(styleVal as VDomBinding, model);
|
||||
val[styleKey] = stylePropVal;
|
||||
for (let styleAtomDep of styleAtomDeps) {
|
||||
atomKeys.add(styleAtomDep);
|
||||
}
|
||||
}
|
||||
}
|
||||
// fallthrough to set props[key] = val
|
||||
}
|
||||
props[key] = val;
|
||||
}
|
||||
return [props, atomKeys];
|
||||
}
|
||||
|
||||
function convertChildren(elem: VDomElem, model: VDomModel): (string | JSX.Element)[] {
|
||||
let childrenComps: (string | JSX.Element)[] = [];
|
||||
if (elem.children) {
|
||||
if (elem.children == null) {
|
||||
return childrenComps;
|
||||
}
|
||||
for (let child of elem.children) {
|
||||
if (child == null) {
|
||||
continue;
|
||||
}
|
||||
childrenComps.push(convertElemToTag(child));
|
||||
childrenComps.push(convertElemToTag(child, model));
|
||||
}
|
||||
return childrenComps;
|
||||
}
|
||||
|
||||
function stringSetsEqual(set1: Set<string>, set2: Set<string>): boolean {
|
||||
if (set1.size != set2.size) {
|
||||
return false;
|
||||
}
|
||||
for (let elem of set1) {
|
||||
if (!set2.has(elem)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (elem.tag == "#fragment") {
|
||||
return true;
|
||||
}
|
||||
|
||||
function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) {
|
||||
const version = jotai.useAtomValue(model.getVDomNodeVersionAtom(elem));
|
||||
const [oldAtomKeys, setOldAtomKeys] = React.useState<Set<string>>(new Set());
|
||||
let [props, atomKeys] = convertProps(elem, model);
|
||||
React.useEffect(() => {
|
||||
if (stringSetsEqual(atomKeys, oldAtomKeys)) {
|
||||
return;
|
||||
}
|
||||
model.tagUnuseAtoms(elem.waveid, oldAtomKeys);
|
||||
model.tagUseAtoms(elem.waveid, atomKeys);
|
||||
setOldAtomKeys(atomKeys);
|
||||
}, [atomKeys]);
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
model.tagUnuseAtoms(elem.waveid, oldAtomKeys);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (elem.tag == WaveNullTag) {
|
||||
return null;
|
||||
}
|
||||
if (elem.tag == WaveTextTag) {
|
||||
return props.text;
|
||||
}
|
||||
if (!AllowedTags[elem.tag]) {
|
||||
return <div>{"Invalid Tag <" + elem.tag + ">"}</div>;
|
||||
}
|
||||
let childrenComps = convertChildren(elem, model);
|
||||
dlog("children", childrenComps);
|
||||
if (elem.tag == FragmentTag) {
|
||||
return childrenComps;
|
||||
}
|
||||
props.key = "e-" + elem.waveid;
|
||||
return React.createElement(elem.tag, props, childrenComps);
|
||||
}
|
||||
|
||||
function VDomView({ rootNode }: { rootNode: VDomElem }) {
|
||||
let rtn = convertElemToTag(rootNode);
|
||||
function vdomText(text: string): VDomElem {
|
||||
return {
|
||||
tag: "#text",
|
||||
text: text,
|
||||
};
|
||||
}
|
||||
|
||||
const testVDom: VDomElem = {
|
||||
waveid: "testid1",
|
||||
tag: "div",
|
||||
children: [
|
||||
{
|
||||
waveid: "testh1",
|
||||
tag: "h1",
|
||||
children: [vdomText("Hello World")],
|
||||
},
|
||||
{
|
||||
waveid: "testp",
|
||||
tag: "p",
|
||||
children: [vdomText("This is a paragraph (from VDOM)")],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function VDomView({
|
||||
blockId,
|
||||
nodeModel,
|
||||
viewRef,
|
||||
model,
|
||||
}: {
|
||||
blockId: string;
|
||||
nodeModel: NodeModel;
|
||||
viewRef: React.RefObject<HTMLDivElement>;
|
||||
model: VDomModel;
|
||||
}) {
|
||||
let rootNode = useAtomValueSafe(model?.vdomRoot);
|
||||
if (!model || viewRef.current == null || rootNode == null) {
|
||||
return null;
|
||||
}
|
||||
dlog("render", rootNode);
|
||||
model.viewRef = viewRef;
|
||||
let rtn = convertElemToTag(rootNode, model);
|
||||
return <div className="vdom">{rtn}</div>;
|
||||
}
|
||||
|
||||
|
1
frontend/types/custom.d.ts
vendored
1
frontend/types/custom.d.ts
vendored
@ -274,6 +274,7 @@ declare global {
|
||||
getSettingsMenuItems?: () => ContextMenuItem[];
|
||||
giveFocus?: () => boolean;
|
||||
keyDownHandler?: (e: WaveKeyboardEvent) => boolean;
|
||||
dispose?: () => void;
|
||||
}
|
||||
|
||||
type UpdaterStatus = "up-to-date" | "checking" | "downloading" | "ready" | "error" | "installing";
|
||||
|
160
frontend/types/gotypes.d.ts
vendored
160
frontend/types/gotypes.d.ts
vendored
@ -192,6 +192,16 @@ declare global {
|
||||
count: number;
|
||||
};
|
||||
|
||||
// vdom.DomRect
|
||||
type DomRect = {
|
||||
top: number;
|
||||
left: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// waveobj.FileDef
|
||||
type FileDef = {
|
||||
filetype?: string;
|
||||
@ -324,6 +334,9 @@ declare global {
|
||||
"term:localshellpath"?: string;
|
||||
"term:localshellopts"?: string[];
|
||||
"term:scrollback"?: number;
|
||||
"vdom:*"?: boolean;
|
||||
"vdom:initialized"?: boolean;
|
||||
"vdom:correlationid"?: string;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
@ -588,27 +601,150 @@ declare global {
|
||||
checkboxstat?: boolean;
|
||||
};
|
||||
|
||||
// vdom.Elem
|
||||
// vdom.VDomAsyncInitiationRequest
|
||||
type VDomAsyncInitiationRequest = {
|
||||
type: "asyncinitiationrequest";
|
||||
ts: number;
|
||||
blockid?: string;
|
||||
};
|
||||
|
||||
// vdom.VDomBackendOpts
|
||||
type VDomBackendOpts = {
|
||||
closeonctrlc?: boolean;
|
||||
globalkeyboardevents?: boolean;
|
||||
};
|
||||
|
||||
// vdom.VDomBackendUpdate
|
||||
type VDomBackendUpdate = {
|
||||
type: "backendupdate";
|
||||
ts: number;
|
||||
blockid: string;
|
||||
opts?: VDomBackendOpts;
|
||||
renderupdates?: VDomRenderUpdate[];
|
||||
statesync?: VDomStateSync[];
|
||||
refoperations?: VDomRefOperation[];
|
||||
messages?: VDomMessage[];
|
||||
};
|
||||
|
||||
// vdom.VDomBinding
|
||||
type VDomBinding = {
|
||||
type: "binding";
|
||||
bind: string;
|
||||
};
|
||||
|
||||
// vdom.VDomCreateContext
|
||||
type VDomCreateContext = {
|
||||
type: "createcontext";
|
||||
ts: number;
|
||||
meta?: MetaType;
|
||||
newblock?: boolean;
|
||||
persist?: boolean;
|
||||
};
|
||||
|
||||
// vdom.VDomElem
|
||||
type VDomElem = {
|
||||
id?: string;
|
||||
waveid?: string;
|
||||
tag: string;
|
||||
props?: {[key: string]: any};
|
||||
children?: VDomElem[];
|
||||
text?: string;
|
||||
};
|
||||
|
||||
// vdom.VDomFuncType
|
||||
type VDomFuncType = {
|
||||
#func: string;
|
||||
#stopPropagation?: boolean;
|
||||
#preventDefault?: boolean;
|
||||
#keys?: string[];
|
||||
// vdom.VDomEvent
|
||||
type VDomEvent = {
|
||||
waveid: string;
|
||||
propname: string;
|
||||
eventdata: any;
|
||||
};
|
||||
|
||||
// vdom.VDomRefType
|
||||
type VDomRefType = {
|
||||
#ref: string;
|
||||
current: any;
|
||||
// vdom.VDomFrontendUpdate
|
||||
type VDomFrontendUpdate = {
|
||||
type: "frontendupdate";
|
||||
ts: number;
|
||||
blockid: string;
|
||||
correlationid?: string;
|
||||
initialize?: boolean;
|
||||
dispose?: boolean;
|
||||
resync?: boolean;
|
||||
rendercontext?: VDomRenderContext;
|
||||
events?: VDomEvent[];
|
||||
statesync?: VDomStateSync[];
|
||||
refupdates?: VDomRefUpdate[];
|
||||
messages?: VDomMessage[];
|
||||
};
|
||||
|
||||
// vdom.VDomFunc
|
||||
type VDomFunc = {
|
||||
type: "func";
|
||||
stoppropagation?: boolean;
|
||||
preventdefault?: boolean;
|
||||
globalevent?: string;
|
||||
keys?: string[];
|
||||
};
|
||||
|
||||
// vdom.VDomMessage
|
||||
type VDomMessage = {
|
||||
messagetype: string;
|
||||
message: string;
|
||||
stacktrace?: string;
|
||||
params?: any[];
|
||||
};
|
||||
|
||||
// vdom.VDomRef
|
||||
type VDomRef = {
|
||||
type: "ref";
|
||||
refid: string;
|
||||
trackposition?: boolean;
|
||||
position?: VDomRefPosition;
|
||||
hascurrent?: boolean;
|
||||
};
|
||||
|
||||
// vdom.VDomRefOperation
|
||||
type VDomRefOperation = {
|
||||
refid: string;
|
||||
op: string;
|
||||
params?: any[];
|
||||
};
|
||||
|
||||
// vdom.VDomRefPosition
|
||||
type VDomRefPosition = {
|
||||
offsetheight: number;
|
||||
offsetwidth: number;
|
||||
scrollheight: number;
|
||||
scrollwidth: number;
|
||||
scrolltop: number;
|
||||
boundingclientrect: DomRect;
|
||||
};
|
||||
|
||||
// vdom.VDomRefUpdate
|
||||
type VDomRefUpdate = {
|
||||
refid: string;
|
||||
hascurrent: boolean;
|
||||
position?: VDomRefPosition;
|
||||
};
|
||||
|
||||
// vdom.VDomRenderContext
|
||||
type VDomRenderContext = {
|
||||
blockid: string;
|
||||
focused: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
rootrefid: string;
|
||||
background?: boolean;
|
||||
};
|
||||
|
||||
// vdom.VDomRenderUpdate
|
||||
type VDomRenderUpdate = {
|
||||
updatetype: "root"|"append"|"replace"|"remove"|"insert";
|
||||
waveid?: string;
|
||||
vdom: VDomElem;
|
||||
index?: number;
|
||||
};
|
||||
|
||||
// vdom.VDomStateSync
|
||||
type VDomStateSync = {
|
||||
atom: string;
|
||||
value: any;
|
||||
};
|
||||
|
||||
type WSCommandType = {
|
||||
|
@ -241,6 +241,56 @@ function adaptFromElectronKeyEvent(event: any): WaveKeyboardEvent {
|
||||
return rtn;
|
||||
}
|
||||
|
||||
const keyMap = {
|
||||
Enter: "\r",
|
||||
Backspace: "\x7f",
|
||||
Tab: "\t",
|
||||
Escape: "\x1b",
|
||||
ArrowUp: "\x1b[A",
|
||||
ArrowDown: "\x1b[B",
|
||||
ArrowRight: "\x1b[C",
|
||||
ArrowLeft: "\x1b[D",
|
||||
Insert: "\x1b[2~",
|
||||
Delete: "\x1b[3~",
|
||||
Home: "\x1b[1~",
|
||||
End: "\x1b[4~",
|
||||
PageUp: "\x1b[5~",
|
||||
PageDown: "\x1b[6~",
|
||||
};
|
||||
|
||||
function keyboardEventToASCII(event: WaveKeyboardEvent): string {
|
||||
// check modifiers
|
||||
// if no modifiers are set, just send the key
|
||||
if (!event.alt && !event.control && !event.meta) {
|
||||
if (event.key == null || event.key == "") {
|
||||
return "";
|
||||
}
|
||||
if (keyMap[event.key] != null) {
|
||||
return keyMap[event.key];
|
||||
}
|
||||
if (event.key.length == 1) {
|
||||
return event.key;
|
||||
} else {
|
||||
console.log("not sending keyboard event", event.key, event);
|
||||
}
|
||||
}
|
||||
// if meta or alt is set, there is no ASCII representation
|
||||
if (event.meta || event.alt) {
|
||||
return "";
|
||||
}
|
||||
// if ctrl is set, if it is a letter, subtract 64 from the uppercase value to get the ASCII value
|
||||
if (event.control) {
|
||||
if (
|
||||
(event.key.length === 1 && event.key >= "A" && event.key <= "Z") ||
|
||||
(event.key >= "a" && event.key <= "z")
|
||||
) {
|
||||
const key = event.key.toUpperCase();
|
||||
return String.fromCharCode(key.charCodeAt(0) - 64);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export {
|
||||
adaptFromElectronKeyEvent,
|
||||
adaptFromReactOrNativeKeyEvent,
|
||||
@ -248,6 +298,7 @@ export {
|
||||
getKeyUtilPlatform,
|
||||
isCharacterKeyEvent,
|
||||
isInputEvent,
|
||||
keyboardEventToASCII,
|
||||
keydownWrapper,
|
||||
parseKeyDescription,
|
||||
setKeyUtilPlatform,
|
||||
|
@ -42,9 +42,13 @@ var ExtraTypes = []any{
|
||||
wshutil.RpcMessage{},
|
||||
wshrpc.WshServerCommandMeta{},
|
||||
userinput.UserInputRequest{},
|
||||
vdom.Elem{},
|
||||
vdom.VDomFuncType{},
|
||||
vdom.VDomRefType{},
|
||||
vdom.VDomCreateContext{},
|
||||
vdom.VDomElem{},
|
||||
vdom.VDomFunc{},
|
||||
vdom.VDomRef{},
|
||||
vdom.VDomBinding{},
|
||||
vdom.VDomFrontendUpdate{},
|
||||
vdom.VDomBackendUpdate{},
|
||||
waveobj.MetaTSType{},
|
||||
}
|
||||
|
||||
|
89
pkg/util/utilfn/compare.go
Normal file
89
pkg/util/utilfn/compare.go
Normal file
@ -0,0 +1,89 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package utilfn
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// this is a shallow equal, but with special handling for numeric types
|
||||
// it will up convert to float64 and compare
|
||||
func JsonValEqual(a, b any) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
typeA := reflect.TypeOf(a)
|
||||
typeB := reflect.TypeOf(b)
|
||||
if typeA == typeB && typeA.Comparable() {
|
||||
return a == b
|
||||
}
|
||||
if IsNumericType(a) && IsNumericType(b) {
|
||||
return CompareAsFloat64(a, b)
|
||||
}
|
||||
if typeA != typeB {
|
||||
return false
|
||||
}
|
||||
// for slices and maps, compare their pointers
|
||||
valA := reflect.ValueOf(a)
|
||||
valB := reflect.ValueOf(b)
|
||||
switch valA.Kind() {
|
||||
case reflect.Slice, reflect.Map:
|
||||
return valA.Pointer() == valB.Pointer()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Helper to check if a value is a numeric type
|
||||
func IsNumericType(val any) bool {
|
||||
switch val.(type) {
|
||||
case int, int8, int16, int32, int64,
|
||||
uint, uint8, uint16, uint32, uint64,
|
||||
float32, float64:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to handle numeric comparisons as float64
|
||||
func CompareAsFloat64(a, b any) bool {
|
||||
valA, okA := ToFloat64(a)
|
||||
valB, okB := ToFloat64(b)
|
||||
return okA && okB && valA == valB
|
||||
}
|
||||
|
||||
// Convert various numeric types to float64 for comparison
|
||||
func ToFloat64(val any) (float64, bool) {
|
||||
switch v := val.(type) {
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int8:
|
||||
return float64(v), true
|
||||
case int16:
|
||||
return float64(v), true
|
||||
case int32:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
case uint:
|
||||
return float64(v), true
|
||||
case uint8:
|
||||
return float64(v), true
|
||||
case uint16:
|
||||
return float64(v), true
|
||||
case uint32:
|
||||
return float64(v), true
|
||||
case uint64:
|
||||
return float64(v), true
|
||||
case float32:
|
||||
return float64(v), true
|
||||
case float64:
|
||||
return v, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
159
pkg/vdom/cssparser/cssparser.go
Normal file
159
pkg/vdom/cssparser/cssparser.go
Normal file
@ -0,0 +1,159 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cssparser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type Parser struct {
|
||||
Input string
|
||||
Pos int
|
||||
Length int
|
||||
InQuote bool
|
||||
QuoteChar rune
|
||||
OpenParens int
|
||||
Debug bool
|
||||
}
|
||||
|
||||
func MakeParser(input string) *Parser {
|
||||
return &Parser{
|
||||
Input: input,
|
||||
Length: len(input),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Parser) Parse() (map[string]string, error) {
|
||||
result := make(map[string]string)
|
||||
lastProp := ""
|
||||
for {
|
||||
p.skipWhitespace()
|
||||
if p.eof() {
|
||||
break
|
||||
}
|
||||
propName, err := p.parseIdentifierColon(lastProp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lastProp = propName
|
||||
p.skipWhitespace()
|
||||
value, err := p.parseValue(propName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[propName] = value
|
||||
p.skipWhitespace()
|
||||
if p.eof() {
|
||||
break
|
||||
}
|
||||
if !p.expectChar(';') {
|
||||
break
|
||||
}
|
||||
}
|
||||
p.skipWhitespace()
|
||||
if !p.eof() {
|
||||
return nil, fmt.Errorf("bad style attribute, unexpected character %q at pos %d", string(p.Input[p.Pos]), p.Pos+1)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Parser) parseIdentifierColon(lastProp string) (string, error) {
|
||||
start := p.Pos
|
||||
for !p.eof() {
|
||||
c := p.peekChar()
|
||||
if isIdentChar(c) || c == '-' {
|
||||
p.advance()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
attrName := p.Input[start:p.Pos]
|
||||
p.skipWhitespace()
|
||||
if p.eof() {
|
||||
return "", fmt.Errorf("bad style attribute, expected colon after property %q, got EOF, at pos %d", attrName, p.Pos+1)
|
||||
}
|
||||
if attrName == "" {
|
||||
return "", fmt.Errorf("bad style attribute, invalid property name after property %q, at pos %d", lastProp, p.Pos+1)
|
||||
}
|
||||
if !p.expectChar(':') {
|
||||
return "", fmt.Errorf("bad style attribute, bad property name starting with %q, expected colon, got %q, at pos %d", attrName, string(p.Input[p.Pos]), p.Pos+1)
|
||||
}
|
||||
return attrName, nil
|
||||
}
|
||||
|
||||
func (p *Parser) parseValue(propName string) (string, error) {
|
||||
start := p.Pos
|
||||
quotePos := 0
|
||||
parenPosStack := make([]int, 0)
|
||||
for !p.eof() {
|
||||
c := p.peekChar()
|
||||
if p.InQuote {
|
||||
if c == p.QuoteChar {
|
||||
p.InQuote = false
|
||||
} else if c == '\\' {
|
||||
p.advance()
|
||||
}
|
||||
} else {
|
||||
if c == '"' || c == '\'' {
|
||||
p.InQuote = true
|
||||
p.QuoteChar = c
|
||||
quotePos = p.Pos
|
||||
} else if c == '(' {
|
||||
p.OpenParens++
|
||||
parenPosStack = append(parenPosStack, p.Pos)
|
||||
} else if c == ')' {
|
||||
if p.OpenParens == 0 {
|
||||
return "", fmt.Errorf("unmatched ')' at pos %d", p.Pos+1)
|
||||
}
|
||||
p.OpenParens--
|
||||
parenPosStack = parenPosStack[:len(parenPosStack)-1]
|
||||
} else if c == ';' && p.OpenParens == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
p.advance()
|
||||
}
|
||||
if p.eof() && p.InQuote {
|
||||
return "", fmt.Errorf("bad style attribute, while parsing attribute %q, unmatched quote at pos %d", propName, quotePos+1)
|
||||
}
|
||||
if p.eof() && p.OpenParens > 0 {
|
||||
return "", fmt.Errorf("bad style attribute, while parsing property %q, unmatched '(' at pos %d", propName, parenPosStack[len(parenPosStack)-1]+1)
|
||||
}
|
||||
return strings.TrimSpace(p.Input[start:p.Pos]), nil
|
||||
}
|
||||
|
||||
func isIdentChar(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
}
|
||||
|
||||
func (p *Parser) skipWhitespace() {
|
||||
for !p.eof() && unicode.IsSpace(p.peekChar()) {
|
||||
p.advance()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Parser) expectChar(expected rune) bool {
|
||||
if !p.eof() && p.peekChar() == expected {
|
||||
p.advance()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *Parser) peekChar() rune {
|
||||
if p.Pos >= p.Length {
|
||||
return 0
|
||||
}
|
||||
return rune(p.Input[p.Pos])
|
||||
}
|
||||
|
||||
func (p *Parser) advance() {
|
||||
p.Pos++
|
||||
}
|
||||
|
||||
func (p *Parser) eof() bool {
|
||||
return p.Pos >= p.Length
|
||||
}
|
81
pkg/vdom/cssparser/cssparser_test.go
Normal file
81
pkg/vdom/cssparser/cssparser_test.go
Normal file
@ -0,0 +1,81 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cssparser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func compareMaps(a, b map[string]string) error {
|
||||
if len(a) != len(b) {
|
||||
return fmt.Errorf("map length mismatch: %d != %d", len(a), len(b))
|
||||
}
|
||||
for k, v := range a {
|
||||
if b[k] != v {
|
||||
return fmt.Errorf("value mismatch for key %s: %q != %q", k, v, b[k])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestParse1(t *testing.T) {
|
||||
style := `background: url("example;with;semicolons.jpg"); color: red; margin-right: 5px; content: "hello;world";`
|
||||
p := MakeParser(style)
|
||||
parsed, err := p.Parse()
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
return
|
||||
}
|
||||
expected := map[string]string{
|
||||
"background": `url("example;with;semicolons.jpg")`,
|
||||
"color": "red",
|
||||
"margin-right": "5px",
|
||||
"content": `"hello;world"`,
|
||||
}
|
||||
if err := compareMaps(parsed, expected); err != nil {
|
||||
t.Fatalf("Parsed map does not match expected: %v", err)
|
||||
}
|
||||
|
||||
style = `margin-right: calc(10px + 5px); color: red; font-family: "Arial";`
|
||||
p = MakeParser(style)
|
||||
parsed, err = p.Parse()
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
return
|
||||
}
|
||||
expected = map[string]string{
|
||||
"margin-right": `calc(10px + 5px)`,
|
||||
"color": "red",
|
||||
"font-family": `"Arial"`,
|
||||
}
|
||||
if err := compareMaps(parsed, expected); err != nil {
|
||||
t.Fatalf("Parsed map does not match expected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParserErrors(t *testing.T) {
|
||||
style := `hello more: bad;`
|
||||
p := MakeParser(style)
|
||||
_, err := p.Parse()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
log.Printf("got expected error: %v\n", err)
|
||||
style = `background: url("example.jpg`
|
||||
p = MakeParser(style)
|
||||
_, err = p.Parse()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
log.Printf("got expected error: %v\n", err)
|
||||
style = `foo: url(...`
|
||||
p = MakeParser(style)
|
||||
_, err = p.Parse()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
log.Printf("got expected error: %v\n", err)
|
||||
}
|
108
pkg/vdom/vdom.go
108
pkg/vdom/vdom.go
@ -15,35 +15,6 @@ import (
|
||||
|
||||
// ReactNode types = nil | string | Elem
|
||||
|
||||
const TextTag = "#text"
|
||||
const FragmentTag = "#fragment"
|
||||
|
||||
const ChildrenPropKey = "children"
|
||||
const KeyPropKey = "key"
|
||||
|
||||
// doubles as VDOM structure
|
||||
type Elem struct {
|
||||
Id string `json:"id,omitempty"` // used for vdom
|
||||
Tag string `json:"tag"`
|
||||
Props map[string]any `json:"props,omitempty"`
|
||||
Children []Elem `json:"children,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type VDomRefType struct {
|
||||
RefId string `json:"#ref"`
|
||||
Current any `json:"current"`
|
||||
}
|
||||
|
||||
// can be used to set preventDefault/stopPropagation
|
||||
type VDomFuncType struct {
|
||||
Fn any `json:"-"` // the actual function to call (called via reflection)
|
||||
FuncType string `json:"#func"`
|
||||
StopPropagation bool `json:"#stopPropagation,omitempty"`
|
||||
PreventDefault bool `json:"#preventDefault,omitempty"`
|
||||
Keys []string `json:"#keys,omitempty"` // special for keyDown events a list of keys to "capture"
|
||||
}
|
||||
|
||||
// generic hook structure
|
||||
type Hook struct {
|
||||
Init bool // is initialized
|
||||
@ -56,7 +27,7 @@ type Hook struct {
|
||||
|
||||
type CFunc = func(ctx context.Context, props map[string]any) any
|
||||
|
||||
func (e *Elem) Key() string {
|
||||
func (e *VDomElem) Key() string {
|
||||
keyVal, ok := e.Props[KeyPropKey]
|
||||
if !ok {
|
||||
return ""
|
||||
@ -68,8 +39,8 @@ func (e *Elem) Key() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func TextElem(text string) Elem {
|
||||
return Elem{Tag: TextTag, Text: text}
|
||||
func TextElem(text string) VDomElem {
|
||||
return VDomElem{Tag: TextTag, Text: text}
|
||||
}
|
||||
|
||||
func mergeProps(props *map[string]any, newProps map[string]any) {
|
||||
@ -85,8 +56,8 @@ func mergeProps(props *map[string]any, newProps map[string]any) {
|
||||
}
|
||||
}
|
||||
|
||||
func E(tag string, parts ...any) *Elem {
|
||||
rtn := &Elem{Tag: tag}
|
||||
func E(tag string, parts ...any) *VDomElem {
|
||||
rtn := &VDomElem{Tag: tag}
|
||||
for _, part := range parts {
|
||||
if part == nil {
|
||||
continue
|
||||
@ -135,19 +106,44 @@ func UseState[T any](ctx context.Context, initialVal T) (T, func(T)) {
|
||||
}
|
||||
setVal := func(newVal T) {
|
||||
hookVal.Val = newVal
|
||||
vc.Root.AddRenderWork(vc.Comp.Id)
|
||||
vc.Root.AddRenderWork(vc.Comp.WaveId)
|
||||
}
|
||||
return rtnVal, setVal
|
||||
}
|
||||
|
||||
func UseRef(ctx context.Context, initialVal any) *VDomRefType {
|
||||
func UseAtom[T any](ctx context.Context, atomName string) (T, func(T)) {
|
||||
vc, hookVal := getHookFromCtx(ctx)
|
||||
if !hookVal.Init {
|
||||
hookVal.Init = true
|
||||
refId := vc.Comp.Id + ":" + strconv.Itoa(hookVal.Idx)
|
||||
hookVal.Val = &VDomRefType{RefId: refId, Current: initialVal}
|
||||
closedWaveId := vc.Comp.WaveId
|
||||
hookVal.UnmountFn = func() {
|
||||
atom := vc.Root.GetAtom(atomName)
|
||||
delete(atom.UsedBy, closedWaveId)
|
||||
}
|
||||
refVal, ok := hookVal.Val.(*VDomRefType)
|
||||
}
|
||||
atom := vc.Root.GetAtom(atomName)
|
||||
atom.UsedBy[vc.Comp.WaveId] = true
|
||||
atomVal, ok := atom.Val.(T)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("UseAtom %q value type mismatch (expected %T, got %T)", atomName, atomVal, atom.Val))
|
||||
}
|
||||
setVal := func(newVal T) {
|
||||
atom.Val = newVal
|
||||
for waveId := range atom.UsedBy {
|
||||
vc.Root.AddRenderWork(waveId)
|
||||
}
|
||||
}
|
||||
return atomVal, setVal
|
||||
}
|
||||
|
||||
func UseVDomRef(ctx context.Context) *VDomRef {
|
||||
vc, hookVal := getHookFromCtx(ctx)
|
||||
if !hookVal.Init {
|
||||
hookVal.Init = true
|
||||
refId := vc.Comp.WaveId + ":" + strconv.Itoa(hookVal.Idx)
|
||||
hookVal.Val = &VDomRef{Type: ObjectType_Ref, RefId: refId}
|
||||
}
|
||||
refVal, ok := hookVal.Val.(*VDomRef)
|
||||
if !ok {
|
||||
panic("UseRef hook value is not a ref (possible out of order or conditional hooks)")
|
||||
}
|
||||
@ -159,7 +155,7 @@ func UseId(ctx context.Context) string {
|
||||
if vc == nil {
|
||||
panic("UseId must be called within a component (no context)")
|
||||
}
|
||||
return vc.Comp.Id
|
||||
return vc.Comp.WaveId
|
||||
}
|
||||
|
||||
func depsEqual(deps1 []any, deps2 []any) bool {
|
||||
@ -181,7 +177,7 @@ func UseEffect(ctx context.Context, fn func() func(), deps []any) {
|
||||
hookVal.Init = true
|
||||
hookVal.Fn = fn
|
||||
hookVal.Deps = deps
|
||||
vc.Root.AddEffectWork(vc.Comp.Id, hookVal.Idx)
|
||||
vc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx)
|
||||
return
|
||||
}
|
||||
if depsEqual(hookVal.Deps, deps) {
|
||||
@ -189,7 +185,7 @@ func UseEffect(ctx context.Context, fn func() func(), deps []any) {
|
||||
}
|
||||
hookVal.Fn = fn
|
||||
hookVal.Deps = deps
|
||||
vc.Root.AddEffectWork(vc.Comp.Id, hookVal.Idx)
|
||||
vc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx)
|
||||
}
|
||||
|
||||
func numToString[T any](value T) (string, bool) {
|
||||
@ -207,24 +203,24 @@ func numToString[T any](value T) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func partToElems(part any) []Elem {
|
||||
func partToElems(part any) []VDomElem {
|
||||
if part == nil {
|
||||
return nil
|
||||
}
|
||||
switch part := part.(type) {
|
||||
case string:
|
||||
return []Elem{TextElem(part)}
|
||||
case *Elem:
|
||||
return []VDomElem{TextElem(part)}
|
||||
case *VDomElem:
|
||||
if part == nil {
|
||||
return nil
|
||||
}
|
||||
return []Elem{*part}
|
||||
case Elem:
|
||||
return []Elem{part}
|
||||
case []Elem:
|
||||
return []VDomElem{*part}
|
||||
case VDomElem:
|
||||
return []VDomElem{part}
|
||||
case []VDomElem:
|
||||
return part
|
||||
case []*Elem:
|
||||
var rtn []Elem
|
||||
case []*VDomElem:
|
||||
var rtn []VDomElem
|
||||
for _, e := range part {
|
||||
if e == nil {
|
||||
continue
|
||||
@ -235,11 +231,11 @@ func partToElems(part any) []Elem {
|
||||
}
|
||||
sval, ok := numToString(part)
|
||||
if ok {
|
||||
return []Elem{TextElem(sval)}
|
||||
return []VDomElem{TextElem(sval)}
|
||||
}
|
||||
partVal := reflect.ValueOf(part)
|
||||
if partVal.Kind() == reflect.Slice {
|
||||
var rtn []Elem
|
||||
var rtn []VDomElem
|
||||
for i := 0; i < partVal.Len(); i++ {
|
||||
subPart := partVal.Index(i).Interface()
|
||||
rtn = append(rtn, partToElems(subPart)...)
|
||||
@ -248,14 +244,14 @@ func partToElems(part any) []Elem {
|
||||
}
|
||||
stringer, ok := part.(fmt.Stringer)
|
||||
if ok {
|
||||
return []Elem{TextElem(stringer.String())}
|
||||
return []VDomElem{TextElem(stringer.String())}
|
||||
}
|
||||
jsonStr, jsonErr := json.Marshal(part)
|
||||
if jsonErr == nil {
|
||||
return []Elem{TextElem(string(jsonStr))}
|
||||
return []VDomElem{TextElem(string(jsonStr))}
|
||||
}
|
||||
typeText := "invalid:" + reflect.TypeOf(part).String()
|
||||
return []Elem{TextElem(typeText)}
|
||||
return []VDomElem{TextElem(typeText)}
|
||||
}
|
||||
|
||||
func isWaveTag(tag string) bool {
|
||||
|
@ -13,10 +13,10 @@ type ChildKey struct {
|
||||
}
|
||||
|
||||
type Component struct {
|
||||
Id string
|
||||
WaveId string
|
||||
Tag string
|
||||
Key string
|
||||
Elem *Elem
|
||||
Elem *VDomElem
|
||||
Mounted bool
|
||||
|
||||
// hooks
|
||||
|
@ -10,11 +10,18 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/wavetermdev/htmltoken"
|
||||
"github.com/wavetermdev/waveterm/pkg/vdom/cssparser"
|
||||
)
|
||||
|
||||
// can tokenize and bind HTML to Elems
|
||||
|
||||
func appendChildToStack(stack []*Elem, child *Elem) {
|
||||
const Html_BindPrefix = "#bind:"
|
||||
const Html_ParamPrefix = "#param:"
|
||||
const Html_GlobalEventPrefix = "#globalevent"
|
||||
const Html_BindParamTagName = "bindparam"
|
||||
const Html_BindTagName = "bind"
|
||||
|
||||
func appendChildToStack(stack []*VDomElem, child *VDomElem) {
|
||||
if child == nil {
|
||||
return
|
||||
}
|
||||
@ -25,14 +32,14 @@ func appendChildToStack(stack []*Elem, child *Elem) {
|
||||
parent.Children = append(parent.Children, *child)
|
||||
}
|
||||
|
||||
func pushElemStack(stack []*Elem, elem *Elem) []*Elem {
|
||||
func pushElemStack(stack []*VDomElem, elem *VDomElem) []*VDomElem {
|
||||
if elem == nil {
|
||||
return stack
|
||||
}
|
||||
return append(stack, elem)
|
||||
}
|
||||
|
||||
func popElemStack(stack []*Elem) []*Elem {
|
||||
func popElemStack(stack []*VDomElem) []*VDomElem {
|
||||
if len(stack) <= 1 {
|
||||
return stack
|
||||
}
|
||||
@ -41,14 +48,14 @@ func popElemStack(stack []*Elem) []*Elem {
|
||||
return stack[:len(stack)-1]
|
||||
}
|
||||
|
||||
func curElemTag(stack []*Elem) string {
|
||||
func curElemTag(stack []*VDomElem) string {
|
||||
if len(stack) == 0 {
|
||||
return ""
|
||||
}
|
||||
return stack[len(stack)-1].Tag
|
||||
}
|
||||
|
||||
func finalizeStack(stack []*Elem) *Elem {
|
||||
func finalizeStack(stack []*VDomElem) *VDomElem {
|
||||
if len(stack) == 0 {
|
||||
return nil
|
||||
}
|
||||
@ -74,8 +81,38 @@ func getAttr(token htmltoken.Token, key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func tokenToElem(token htmltoken.Token, data map[string]any) *Elem {
|
||||
elem := &Elem{Tag: token.Data}
|
||||
func attrToProp(attrVal string, params map[string]any) any {
|
||||
if strings.HasPrefix(attrVal, Html_ParamPrefix) {
|
||||
bindKey := attrVal[len(Html_ParamPrefix):]
|
||||
bindVal, ok := params[bindKey]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return bindVal
|
||||
}
|
||||
if strings.HasPrefix(attrVal, Html_BindPrefix) {
|
||||
bindKey := attrVal[len(Html_BindPrefix):]
|
||||
if bindKey == "" {
|
||||
return nil
|
||||
}
|
||||
return &VDomBinding{Type: ObjectType_Binding, Bind: bindKey}
|
||||
}
|
||||
if strings.HasPrefix(attrVal, Html_GlobalEventPrefix) {
|
||||
splitArr := strings.Split(attrVal, ":")
|
||||
if len(splitArr) < 2 {
|
||||
return nil
|
||||
}
|
||||
eventName := splitArr[1]
|
||||
if eventName == "" {
|
||||
return nil
|
||||
}
|
||||
return &VDomFunc{Type: ObjectType_Func, GlobalEvent: eventName}
|
||||
}
|
||||
return attrVal
|
||||
}
|
||||
|
||||
func tokenToElem(token htmltoken.Token, params map[string]any) *VDomElem {
|
||||
elem := &VDomElem{Tag: token.Data}
|
||||
if len(token.Attr) > 0 {
|
||||
elem.Props = make(map[string]any)
|
||||
}
|
||||
@ -83,16 +120,8 @@ func tokenToElem(token htmltoken.Token, data map[string]any) *Elem {
|
||||
if attr.Key == "" || attr.Val == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(attr.Val, "#bind:") {
|
||||
bindKey := attr.Val[6:]
|
||||
bindVal, ok := data[bindKey]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
elem.Props[attr.Key] = bindVal
|
||||
continue
|
||||
}
|
||||
elem.Props[attr.Key] = attr.Val
|
||||
propVal := attrToProp(attr.Val, params)
|
||||
elem.Props[attr.Key] = propVal
|
||||
}
|
||||
return elem
|
||||
}
|
||||
@ -177,12 +206,101 @@ func processTextStr(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func Bind(htmlStr string, data map[string]any) *Elem {
|
||||
func makePathStr(elemPath []string) string {
|
||||
return strings.Join(elemPath, " ")
|
||||
}
|
||||
|
||||
func capitalizeAscii(s string) string {
|
||||
if s == "" || s[0] < 'a' || s[0] > 'z' {
|
||||
return s
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
|
||||
func toReactName(input string) string {
|
||||
// Check for CSS custom properties (variables) which start with '--'
|
||||
if strings.HasPrefix(input, "--") {
|
||||
return input
|
||||
}
|
||||
parts := strings.Split(input, "-")
|
||||
result := ""
|
||||
index := 0
|
||||
if parts[0] == "" && len(parts) > 1 {
|
||||
// handle vendor prefixes
|
||||
prefix := parts[1]
|
||||
if prefix == "ms" {
|
||||
result += "ms"
|
||||
} else {
|
||||
result += capitalizeAscii(prefix)
|
||||
}
|
||||
index = 2 // Skip the empty string and prefix
|
||||
} else {
|
||||
result += parts[0]
|
||||
index = 1
|
||||
}
|
||||
// Convert remaining parts to CamelCase
|
||||
for ; index < len(parts); index++ {
|
||||
if parts[index] != "" {
|
||||
result += capitalizeAscii(parts[index])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func convertStyleToReactStyles(styleMap map[string]string, params map[string]any) map[string]any {
|
||||
if len(styleMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
rtn := make(map[string]any)
|
||||
for key, val := range styleMap {
|
||||
rtn[toReactName(key)] = attrToProp(val, params)
|
||||
}
|
||||
return rtn
|
||||
}
|
||||
|
||||
func fixStyleAttribute(elem *VDomElem, params map[string]any, elemPath []string) error {
|
||||
styleText, ok := elem.Props["style"].(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
parser := cssparser.MakeParser(styleText)
|
||||
m, err := parser.Parse()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v (at %s)", err, makePathStr(elemPath))
|
||||
}
|
||||
elem.Props["style"] = convertStyleToReactStyles(m, params)
|
||||
return nil
|
||||
}
|
||||
|
||||
func fixupStyleAttributes(elem *VDomElem, params map[string]any, elemPath []string) {
|
||||
if elem == nil {
|
||||
return
|
||||
}
|
||||
// call fixStyleAttribute, and walk children
|
||||
elemCountMap := make(map[string]int)
|
||||
if len(elemPath) == 0 {
|
||||
elemPath = append(elemPath, elem.Tag)
|
||||
}
|
||||
fixStyleAttribute(elem, params, elemPath)
|
||||
for i := range elem.Children {
|
||||
child := &elem.Children[i]
|
||||
elemCountMap[child.Tag]++
|
||||
subPath := child.Tag
|
||||
if elemCountMap[child.Tag] > 1 {
|
||||
subPath = fmt.Sprintf("%s[%d]", child.Tag, elemCountMap[child.Tag])
|
||||
}
|
||||
elemPath = append(elemPath, subPath)
|
||||
fixupStyleAttributes(&elem.Children[i], params, elemPath)
|
||||
elemPath = elemPath[:len(elemPath)-1]
|
||||
}
|
||||
}
|
||||
|
||||
func Bind(htmlStr string, params map[string]any) *VDomElem {
|
||||
htmlStr = processWhitespace(htmlStr)
|
||||
r := strings.NewReader(htmlStr)
|
||||
iter := htmltoken.NewTokenizer(r)
|
||||
var elemStack []*Elem
|
||||
elemStack = append(elemStack, &Elem{Tag: FragmentTag})
|
||||
var elemStack []*VDomElem
|
||||
elemStack = append(elemStack, &VDomElem{Tag: FragmentTag})
|
||||
var tokenErr error
|
||||
outer:
|
||||
for {
|
||||
@ -190,15 +308,15 @@ outer:
|
||||
token := iter.Token()
|
||||
switch tokenType {
|
||||
case htmltoken.StartTagToken:
|
||||
if token.Data == "bind" {
|
||||
tokenErr = errors.New("bind tag must be self closing")
|
||||
if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName {
|
||||
tokenErr = errors.New("bind tags must be self closing")
|
||||
break outer
|
||||
}
|
||||
elem := tokenToElem(token, data)
|
||||
elem := tokenToElem(token, params)
|
||||
elemStack = pushElemStack(elemStack, elem)
|
||||
case htmltoken.EndTagToken:
|
||||
if token.Data == "bind" {
|
||||
tokenErr = errors.New("bind tag must be self closing")
|
||||
if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName {
|
||||
tokenErr = errors.New("bind tags must be self closing")
|
||||
break outer
|
||||
}
|
||||
if len(elemStack) <= 1 {
|
||||
@ -211,16 +329,22 @@ outer:
|
||||
}
|
||||
elemStack = popElemStack(elemStack)
|
||||
case htmltoken.SelfClosingTagToken:
|
||||
if token.Data == "bind" {
|
||||
if token.Data == Html_BindParamTagName {
|
||||
keyAttr := getAttr(token, "key")
|
||||
dataVal := data[keyAttr]
|
||||
dataVal := params[keyAttr]
|
||||
elemList := partToElems(dataVal)
|
||||
for _, elem := range elemList {
|
||||
appendChildToStack(elemStack, &elem)
|
||||
}
|
||||
continue
|
||||
}
|
||||
elem := tokenToElem(token, data)
|
||||
if token.Data == Html_BindTagName {
|
||||
keyAttr := getAttr(token, "key")
|
||||
binding := &VDomBinding{Type: ObjectType_Binding, Bind: keyAttr}
|
||||
appendChildToStack(elemStack, &VDomElem{Tag: WaveTextTag, Props: map[string]any{"text": binding}})
|
||||
continue
|
||||
}
|
||||
elem := tokenToElem(token, params)
|
||||
appendChildToStack(elemStack, elem)
|
||||
case htmltoken.TextToken:
|
||||
if token.Data == "" {
|
||||
@ -249,5 +373,7 @@ outer:
|
||||
errTextElem := TextElem(tokenErr.Error())
|
||||
appendChildToStack(elemStack, &errTextElem)
|
||||
}
|
||||
return finalizeStack(elemStack)
|
||||
rtn := finalizeStack(elemStack)
|
||||
fixupStyleAttributes(rtn, params, nil)
|
||||
return rtn
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import (
|
||||
"reflect"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
|
||||
)
|
||||
|
||||
type vdomContextKeyType struct{}
|
||||
@ -22,13 +23,20 @@ type VDomContextVal struct {
|
||||
HookIdx int
|
||||
}
|
||||
|
||||
type Atom struct {
|
||||
Val any
|
||||
Dirty bool
|
||||
UsedBy map[string]bool // component waveid -> true
|
||||
}
|
||||
|
||||
type RootElem struct {
|
||||
OuterCtx context.Context
|
||||
Root *Component
|
||||
CFuncs map[string]CFunc
|
||||
CompMap map[string]*Component // component id -> component
|
||||
CompMap map[string]*Component // component waveid -> component
|
||||
EffectWorkQueue []*EffectWorkElem
|
||||
NeedsRenderMap map[string]bool
|
||||
Atoms map[string]*Atom
|
||||
}
|
||||
|
||||
const (
|
||||
@ -57,9 +65,49 @@ func MakeRoot() *RootElem {
|
||||
Root: nil,
|
||||
CFuncs: make(map[string]CFunc),
|
||||
CompMap: make(map[string]*Component),
|
||||
Atoms: make(map[string]*Atom),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RootElem) GetAtom(name string) *Atom {
|
||||
atom, ok := r.Atoms[name]
|
||||
if !ok {
|
||||
atom = &Atom{UsedBy: make(map[string]bool)}
|
||||
r.Atoms[name] = atom
|
||||
}
|
||||
return atom
|
||||
}
|
||||
|
||||
func (r *RootElem) GetAtomVal(name string) any {
|
||||
atom := r.GetAtom(name)
|
||||
return atom.Val
|
||||
}
|
||||
|
||||
func (r *RootElem) GetStateSync(full bool) []VDomStateSync {
|
||||
stateSync := make([]VDomStateSync, 0)
|
||||
for atomName, atom := range r.Atoms {
|
||||
if atom.Dirty || full {
|
||||
stateSync = append(stateSync, VDomStateSync{Atom: atomName, Value: atom.Val})
|
||||
atom.Dirty = false
|
||||
}
|
||||
}
|
||||
return stateSync
|
||||
}
|
||||
|
||||
func (r *RootElem) SetAtomVal(name string, val any, markDirty bool) {
|
||||
atom := r.GetAtom(name)
|
||||
if !markDirty {
|
||||
atom.Val = val
|
||||
return
|
||||
}
|
||||
// try to avoid setting the value and marking as dirty if it's the "same"
|
||||
if utilfn.JsonValEqual(val, atom.Val) {
|
||||
return
|
||||
}
|
||||
atom.Val = val
|
||||
atom.Dirty = true
|
||||
}
|
||||
|
||||
func (r *RootElem) SetOuterCtx(ctx context.Context) {
|
||||
r.OuterCtx = ctx
|
||||
}
|
||||
@ -68,30 +116,60 @@ func (r *RootElem) RegisterComponent(name string, cfunc CFunc) {
|
||||
r.CFuncs[name] = cfunc
|
||||
}
|
||||
|
||||
func (r *RootElem) Render(elem *Elem) {
|
||||
func (r *RootElem) Render(elem *VDomElem) {
|
||||
log.Printf("Render %s\n", elem.Tag)
|
||||
r.render(elem, &r.Root)
|
||||
}
|
||||
|
||||
func (r *RootElem) Event(id string, propName string) {
|
||||
func (vdf *VDomFunc) CallFn() {
|
||||
if vdf.Fn == nil {
|
||||
return
|
||||
}
|
||||
rval := reflect.ValueOf(vdf.Fn)
|
||||
if rval.Kind() != reflect.Func {
|
||||
return
|
||||
}
|
||||
rval.Call(nil)
|
||||
}
|
||||
|
||||
func callVDomFn(fnVal any, data any) {
|
||||
if fnVal == nil {
|
||||
return
|
||||
}
|
||||
fn := fnVal
|
||||
if vdf, ok := fnVal.(*VDomFunc); ok {
|
||||
fn = vdf.Fn
|
||||
}
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
rval := reflect.ValueOf(fn)
|
||||
if rval.Kind() != reflect.Func {
|
||||
return
|
||||
}
|
||||
rtype := rval.Type()
|
||||
if rtype.NumIn() == 0 {
|
||||
rval.Call(nil)
|
||||
return
|
||||
}
|
||||
if rtype.NumIn() == 1 {
|
||||
rval.Call([]reflect.Value{reflect.ValueOf(data)})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RootElem) Event(id string, propName string, data any) {
|
||||
comp := r.CompMap[id]
|
||||
if comp == nil || comp.Elem == nil {
|
||||
return
|
||||
}
|
||||
fnVal := comp.Elem.Props[propName]
|
||||
if fnVal == nil {
|
||||
return
|
||||
}
|
||||
fn, ok := fnVal.(func())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fn()
|
||||
callVDomFn(fnVal, data)
|
||||
}
|
||||
|
||||
// this will be called by the frontend to say the DOM has been mounted
|
||||
// it will eventually send any updated "refs" to the backend as well
|
||||
func (r *RootElem) runWork() {
|
||||
func (r *RootElem) RunWork() {
|
||||
workQueue := r.EffectWorkQueue
|
||||
r.EffectWorkQueue = nil
|
||||
// first, run effect cleanups
|
||||
@ -123,7 +201,7 @@ func (r *RootElem) runWork() {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RootElem) render(elem *Elem, comp **Component) {
|
||||
func (r *RootElem) render(elem *VDomElem, comp **Component) {
|
||||
if elem == nil || elem.Tag == "" {
|
||||
r.unmount(comp)
|
||||
return
|
||||
@ -171,13 +249,13 @@ func (r *RootElem) unmount(comp **Component) {
|
||||
r.unmount(&child)
|
||||
}
|
||||
}
|
||||
delete(r.CompMap, (*comp).Id)
|
||||
delete(r.CompMap, (*comp).WaveId)
|
||||
*comp = nil
|
||||
}
|
||||
|
||||
func (r *RootElem) createComp(tag string, key string, comp **Component) {
|
||||
*comp = &Component{Id: uuid.New().String(), Tag: tag, Key: key}
|
||||
r.CompMap[(*comp).Id] = *comp
|
||||
*comp = &Component{WaveId: uuid.New().String(), Tag: tag, Key: key}
|
||||
r.CompMap[(*comp).WaveId] = *comp
|
||||
}
|
||||
|
||||
func (r *RootElem) renderText(text string, comp **Component) {
|
||||
@ -186,7 +264,7 @@ func (r *RootElem) renderText(text string, comp **Component) {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RootElem) renderChildren(elems []Elem, curChildren []*Component) []*Component {
|
||||
func (r *RootElem) renderChildren(elems []VDomElem, curChildren []*Component) []*Component {
|
||||
newChildren := make([]*Component, len(elems))
|
||||
curCM := make(map[ChildKey]*Component)
|
||||
usedMap := make(map[*Component]bool)
|
||||
@ -217,7 +295,7 @@ func (r *RootElem) renderChildren(elems []Elem, curChildren []*Component) []*Com
|
||||
return newChildren
|
||||
}
|
||||
|
||||
func (r *RootElem) renderSimple(elem *Elem, comp **Component) {
|
||||
func (r *RootElem) renderSimple(elem *VDomElem, comp **Component) {
|
||||
if (*comp).Comp != nil {
|
||||
r.unmount(&(*comp).Comp)
|
||||
}
|
||||
@ -243,7 +321,7 @@ func getRenderContext(ctx context.Context) *VDomContextVal {
|
||||
return v.(*VDomContextVal)
|
||||
}
|
||||
|
||||
func (r *RootElem) renderComponent(cfunc CFunc, elem *Elem, comp **Component) {
|
||||
func (r *RootElem) renderComponent(cfunc CFunc, elem *VDomElem, comp **Component) {
|
||||
if (*comp).Children != nil {
|
||||
for _, child := range (*comp).Children {
|
||||
r.unmount(&child)
|
||||
@ -262,11 +340,11 @@ func (r *RootElem) renderComponent(cfunc CFunc, elem *Elem, comp **Component) {
|
||||
r.unmount(&(*comp).Comp)
|
||||
return
|
||||
}
|
||||
var rtnElem *Elem
|
||||
var rtnElem *VDomElem
|
||||
if len(rtnElemArr) == 1 {
|
||||
rtnElem = &rtnElemArr[0]
|
||||
} else {
|
||||
rtnElem = &Elem{Tag: FragmentTag, Children: rtnElemArr}
|
||||
rtnElem = &VDomElem{Tag: FragmentTag, Children: rtnElemArr}
|
||||
}
|
||||
r.render(rtnElem, &(*comp).Comp)
|
||||
}
|
||||
@ -282,7 +360,7 @@ func convertPropsToVDom(props map[string]any) map[string]any {
|
||||
}
|
||||
val := reflect.ValueOf(v)
|
||||
if val.Kind() == reflect.Func {
|
||||
vdomProps[k] = VDomFuncType{FuncType: "server"}
|
||||
vdomProps[k] = VDomFunc{Type: ObjectType_Func}
|
||||
continue
|
||||
}
|
||||
vdomProps[k] = v
|
||||
@ -290,8 +368,8 @@ func convertPropsToVDom(props map[string]any) map[string]any {
|
||||
return vdomProps
|
||||
}
|
||||
|
||||
func convertBaseToVDom(c *Component) *Elem {
|
||||
elem := &Elem{Id: c.Id, Tag: c.Tag}
|
||||
func convertBaseToVDom(c *Component) *VDomElem {
|
||||
elem := &VDomElem{WaveId: c.WaveId, Tag: c.Tag}
|
||||
if c.Elem != nil {
|
||||
elem.Props = convertPropsToVDom(c.Elem.Props)
|
||||
}
|
||||
@ -304,12 +382,12 @@ func convertBaseToVDom(c *Component) *Elem {
|
||||
return elem
|
||||
}
|
||||
|
||||
func convertToVDom(c *Component) *Elem {
|
||||
func convertToVDom(c *Component) *VDomElem {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if c.Tag == TextTag {
|
||||
return &Elem{Tag: TextTag, Text: c.Text}
|
||||
return &VDomElem{Tag: TextTag, Text: c.Text}
|
||||
}
|
||||
if isBaseTag(c.Tag) {
|
||||
return convertBaseToVDom(c)
|
||||
@ -318,11 +396,11 @@ func convertToVDom(c *Component) *Elem {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RootElem) makeVDom(comp *Component) *Elem {
|
||||
func (r *RootElem) makeVDom(comp *Component) *VDomElem {
|
||||
vdomElem := convertToVDom(comp)
|
||||
return vdomElem
|
||||
}
|
||||
|
||||
func (r *RootElem) MakeVDom() *Elem {
|
||||
func (r *RootElem) MakeVDom() *VDomElem {
|
||||
return r.makeVDom(r.Root)
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ type TestContext struct {
|
||||
|
||||
func Page(ctx context.Context, props map[string]any) any {
|
||||
clicked, setClicked := UseState(ctx, false)
|
||||
var clickedDiv *Elem
|
||||
var clickedDiv *VDomElem
|
||||
if clicked {
|
||||
clickedDiv = Bind(`<div>clicked</div>`, nil)
|
||||
}
|
||||
@ -30,8 +30,8 @@ func Page(ctx context.Context, props map[string]any) any {
|
||||
`
|
||||
<div>
|
||||
<h1>hello world</h1>
|
||||
<Button onClick="#bind:clickFn">hello</Button>
|
||||
<bind key="clickedDiv"/>
|
||||
<Button onClick="#param:clickFn">hello</Button>
|
||||
<bindparam key="clickedDiv"/>
|
||||
</div>
|
||||
`,
|
||||
map[string]any{"clickFn": clickFn, "clickedDiv": clickedDiv},
|
||||
@ -39,7 +39,7 @@ func Page(ctx context.Context, props map[string]any) any {
|
||||
}
|
||||
|
||||
func Button(ctx context.Context, props map[string]any) any {
|
||||
ref := UseRef(ctx, nil)
|
||||
ref := UseVDomRef(ctx)
|
||||
clName, setClName := UseState(ctx, "button")
|
||||
UseEffect(ctx, func() func() {
|
||||
fmt.Printf("Button useEffect\n")
|
||||
@ -52,8 +52,8 @@ func Button(ctx context.Context, props map[string]any) any {
|
||||
testContext.ButtonId = compId
|
||||
}
|
||||
return Bind(`
|
||||
<div className="#bind:clName" ref="#bind:ref" onClick="#bind:onClick">
|
||||
<bind key="children"/>
|
||||
<div className="#param:clName" ref="#param:ref" onClick="#param:onClick">
|
||||
<bindparam key="children"/>
|
||||
</div>
|
||||
`, map[string]any{"clName": clName, "ref": ref, "onClick": props["onClick"], "children": props["children"]})
|
||||
}
|
||||
@ -85,10 +85,10 @@ func Test1(t *testing.T) {
|
||||
t.Fatalf("root.Root is nil")
|
||||
}
|
||||
printVDom(root)
|
||||
root.runWork()
|
||||
root.RunWork()
|
||||
printVDom(root)
|
||||
root.Event(testContext.ButtonId, "onClick")
|
||||
root.runWork()
|
||||
root.Event(testContext.ButtonId, "onClick", nil)
|
||||
root.RunWork()
|
||||
printVDom(root)
|
||||
}
|
||||
|
||||
@ -111,8 +111,8 @@ func TestBind(t *testing.T) {
|
||||
elem = Bind(`
|
||||
<div>
|
||||
<h1>hello world</h1>
|
||||
<Button onClick="#bind:clickFn">hello</Button>
|
||||
<bind key="clickedDiv"/>
|
||||
<Button onClick="#param:clickFn">hello</Button>
|
||||
<bindparam key="clickedDiv"/>
|
||||
</div>
|
||||
`, nil)
|
||||
jsonBytes, _ = json.MarshalIndent(elem, "", " ")
|
||||
|
195
pkg/vdom/vdom_types.go
Normal file
195
pkg/vdom/vdom_types.go
Normal file
@ -0,0 +1,195 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package vdom
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
||||
)
|
||||
|
||||
const TextTag = "#text"
|
||||
const WaveTextTag = "wave:text"
|
||||
const WaveNullTag = "wave:null"
|
||||
const FragmentTag = "#fragment"
|
||||
const BindTag = "#bind"
|
||||
|
||||
const ChildrenPropKey = "children"
|
||||
const KeyPropKey = "key"
|
||||
|
||||
const ObjectType_Ref = "ref"
|
||||
const ObjectType_Binding = "binding"
|
||||
const ObjectType_Func = "func"
|
||||
|
||||
// vdom element
|
||||
type VDomElem struct {
|
||||
WaveId string `json:"waveid,omitempty"` // required, except for #text nodes
|
||||
Tag string `json:"tag"`
|
||||
Props map[string]any `json:"props,omitempty"`
|
||||
Children []VDomElem `json:"children,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
//// protocol messages
|
||||
|
||||
type VDomCreateContext struct {
|
||||
Type string `json:"type" tstype:"\"createcontext\""`
|
||||
Ts int64 `json:"ts"`
|
||||
Meta waveobj.MetaMapType `json:"meta,omitempty"`
|
||||
NewBlock bool `json:"newblock,omitempty"`
|
||||
Persist bool `json:"persist,omitempty"`
|
||||
}
|
||||
|
||||
type VDomAsyncInitiationRequest struct {
|
||||
Type string `json:"type" tstype:"\"asyncinitiationrequest\""`
|
||||
Ts int64 `json:"ts"`
|
||||
BlockId string `json:"blockid,omitempty"`
|
||||
}
|
||||
|
||||
func MakeAsyncInitiationRequest(blockId string) VDomAsyncInitiationRequest {
|
||||
return VDomAsyncInitiationRequest{
|
||||
Type: "asyncinitiationrequest",
|
||||
Ts: time.Now().UnixMilli(),
|
||||
BlockId: blockId,
|
||||
}
|
||||
}
|
||||
|
||||
type VDomFrontendUpdate struct {
|
||||
Type string `json:"type" tstype:"\"frontendupdate\""`
|
||||
Ts int64 `json:"ts"`
|
||||
BlockId string `json:"blockid"`
|
||||
CorrelationId string `json:"correlationid,omitempty"`
|
||||
Initialize bool `json:"initialize,omitempty"` // initialize the app
|
||||
Dispose bool `json:"dispose,omitempty"` // the vdom context was closed
|
||||
Resync bool `json:"resync,omitempty"` // resync (send all backend data). useful when the FE reloads
|
||||
RenderContext VDomRenderContext `json:"rendercontext,omitempty"`
|
||||
Events []VDomEvent `json:"events,omitempty"`
|
||||
StateSync []VDomStateSync `json:"statesync,omitempty"`
|
||||
RefUpdates []VDomRefUpdate `json:"refupdates,omitempty"`
|
||||
Messages []VDomMessage `json:"messages,omitempty"`
|
||||
}
|
||||
|
||||
type VDomBackendUpdate struct {
|
||||
Type string `json:"type" tstype:"\"backendupdate\""`
|
||||
Ts int64 `json:"ts"`
|
||||
BlockId string `json:"blockid"`
|
||||
Opts *VDomBackendOpts `json:"opts,omitempty"`
|
||||
RenderUpdates []VDomRenderUpdate `json:"renderupdates,omitempty"`
|
||||
StateSync []VDomStateSync `json:"statesync,omitempty"`
|
||||
RefOperations []VDomRefOperation `json:"refoperations,omitempty"`
|
||||
Messages []VDomMessage `json:"messages,omitempty"`
|
||||
}
|
||||
|
||||
///// prop types
|
||||
|
||||
// used in props
|
||||
type VDomBinding struct {
|
||||
Type string `json:"type" tstype:"\"binding\""`
|
||||
Bind string `json:"bind"`
|
||||
}
|
||||
|
||||
// used in props
|
||||
type VDomFunc struct {
|
||||
Fn any `json:"-"` // server side function (called with reflection)
|
||||
Type string `json:"type" tstype:"\"func\""`
|
||||
StopPropagation bool `json:"stoppropagation,omitempty"`
|
||||
PreventDefault bool `json:"preventdefault,omitempty"`
|
||||
GlobalEvent string `json:"globalevent,omitempty"`
|
||||
Keys []string `json:"keys,omitempty"` // special for keyDown events a list of keys to "capture"
|
||||
}
|
||||
|
||||
// used in props
|
||||
type VDomRef struct {
|
||||
Type string `json:"type" tstype:"\"ref\""`
|
||||
RefId string `json:"refid"`
|
||||
TrackPosition bool `json:"trackposition,omitempty"`
|
||||
Position *VDomRefPosition `json:"position,omitempty"`
|
||||
HasCurrent bool `json:"hascurrent,omitempty"`
|
||||
}
|
||||
|
||||
type DomRect struct {
|
||||
Top float64 `json:"top"`
|
||||
Left float64 `json:"left"`
|
||||
Right float64 `json:"right"`
|
||||
Bottom float64 `json:"bottom"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}
|
||||
|
||||
type VDomRefPosition struct {
|
||||
OffsetHeight int `json:"offsetheight"`
|
||||
OffsetWidth int `json:"offsetwidth"`
|
||||
ScrollHeight int `json:"scrollheight"`
|
||||
ScrollWidth int `json:"scrollwidth"`
|
||||
ScrollTop int `json:"scrolltop"`
|
||||
BoundingClientRect DomRect `json:"boundingclientrect"`
|
||||
}
|
||||
|
||||
///// subbordinate protocol types
|
||||
|
||||
type VDomEvent struct {
|
||||
WaveId string `json:"waveid"`
|
||||
PropName string `json:"propname"`
|
||||
EventData any `json:"eventdata"`
|
||||
}
|
||||
|
||||
type VDomRenderContext struct {
|
||||
BlockId string `json:"blockid"`
|
||||
Focused bool `json:"focused"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
RootRefId string `json:"rootrefid"`
|
||||
Background bool `json:"background,omitempty"`
|
||||
}
|
||||
|
||||
type VDomStateSync struct {
|
||||
Atom string `json:"atom"`
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
type VDomRefUpdate struct {
|
||||
RefId string `json:"refid"`
|
||||
HasCurrent bool `json:"hascurrent"`
|
||||
Position *VDomRefPosition `json:"position,omitempty"`
|
||||
}
|
||||
|
||||
type VDomBackendOpts struct {
|
||||
CloseOnCtrlC bool `json:"closeonctrlc,omitempty"`
|
||||
GlobalKeyboardEvents bool `json:"globalkeyboardevents,omitempty"`
|
||||
}
|
||||
|
||||
type VDomRenderUpdate struct {
|
||||
UpdateType string `json:"updatetype" tstype:"\"root\"|\"append\"|\"replace\"|\"remove\"|\"insert\""`
|
||||
WaveId string `json:"waveid,omitempty"`
|
||||
VDom VDomElem `json:"vdom"`
|
||||
Index *int `json:"index,omitempty"`
|
||||
}
|
||||
|
||||
type VDomRefOperation struct {
|
||||
RefId string `json:"refid"`
|
||||
Op string `json:"op" tsype:"\"focus\""`
|
||||
Params []any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type VDomMessage struct {
|
||||
MessageType string `json:"messagetype"`
|
||||
Message string `json:"message"`
|
||||
StackTrace string `json:"stacktrace,omitempty"`
|
||||
Params []any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// matches WaveKeyboardEvent
|
||||
type VDomKeyboardEvent struct {
|
||||
Type string `json:"type"`
|
||||
Key string `json:"key"`
|
||||
Code string `json:"code"`
|
||||
Shift bool `json:"shift,omitempty"`
|
||||
Control bool `json:"ctrl,omitempty"`
|
||||
Alt bool `json:"alt,omitempty"`
|
||||
Meta bool `json:"meta,omitempty"`
|
||||
Cmd bool `json:"cmd,omitempty"`
|
||||
Option bool `json:"option,omitempty"`
|
||||
Repeat bool `json:"repeat,omitempty"`
|
||||
Location int `json:"location,omitempty"`
|
||||
}
|
199
pkg/vdom/vdomclient/vdomclient.go
Normal file
199
pkg/vdom/vdomclient/vdomclient.go
Normal file
@ -0,0 +1,199 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package vdomclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/wavetermdev/waveterm/pkg/vdom"
|
||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
||||
"github.com/wavetermdev/waveterm/pkg/wps"
|
||||
"github.com/wavetermdev/waveterm/pkg/wshrpc"
|
||||
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
|
||||
"github.com/wavetermdev/waveterm/pkg/wshutil"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Root *vdom.RootElem
|
||||
RootElem *vdom.VDomElem
|
||||
RpcClient *wshutil.WshRpc
|
||||
RpcContext *wshrpc.RpcContext
|
||||
ServerImpl *VDomServerImpl
|
||||
IsDone bool
|
||||
RouteId string
|
||||
DoneReason string
|
||||
DoneOnce *sync.Once
|
||||
DoneCh chan struct{}
|
||||
Opts vdom.VDomBackendOpts
|
||||
GlobalEventHandler func(client *Client, event vdom.VDomEvent)
|
||||
}
|
||||
|
||||
type VDomServerImpl struct {
|
||||
Client *Client
|
||||
BlockId string
|
||||
}
|
||||
|
||||
func (*VDomServerImpl) WshServerImpl() {}
|
||||
|
||||
func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom.VDomFrontendUpdate) (*vdom.VDomBackendUpdate, error) {
|
||||
if feUpdate.Dispose {
|
||||
log.Printf("got dispose from frontend\n")
|
||||
impl.Client.doShutdown("got dispose from frontend")
|
||||
return nil, nil
|
||||
}
|
||||
if impl.Client.IsDone {
|
||||
return nil, nil
|
||||
}
|
||||
// set atoms
|
||||
for _, ss := range feUpdate.StateSync {
|
||||
impl.Client.Root.SetAtomVal(ss.Atom, ss.Value, false)
|
||||
}
|
||||
// run events
|
||||
for _, event := range feUpdate.Events {
|
||||
if event.WaveId == "" {
|
||||
if impl.Client.GlobalEventHandler != nil {
|
||||
impl.Client.GlobalEventHandler(impl.Client, event)
|
||||
}
|
||||
} else {
|
||||
impl.Client.Root.Event(event.WaveId, event.PropName, event.EventData)
|
||||
}
|
||||
}
|
||||
if feUpdate.Initialize || feUpdate.Resync {
|
||||
return impl.Client.fullRender()
|
||||
}
|
||||
return impl.Client.incrementalRender()
|
||||
}
|
||||
|
||||
func (c *Client) doShutdown(reason string) {
|
||||
c.DoneOnce.Do(func() {
|
||||
c.DoneReason = reason
|
||||
c.IsDone = true
|
||||
close(c.DoneCh)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) SetGlobalEventHandler(handler func(client *Client, event vdom.VDomEvent)) {
|
||||
c.GlobalEventHandler = handler
|
||||
}
|
||||
|
||||
func MakeClient(opts *vdom.VDomBackendOpts) (*Client, error) {
|
||||
client := &Client{
|
||||
Root: vdom.MakeRoot(),
|
||||
DoneCh: make(chan struct{}),
|
||||
DoneOnce: &sync.Once{},
|
||||
}
|
||||
if opts != nil {
|
||||
client.Opts = *opts
|
||||
}
|
||||
jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName)
|
||||
if jwtToken == "" {
|
||||
return nil, fmt.Errorf("no %s env var set", wshutil.WaveJwtTokenVarName)
|
||||
}
|
||||
rpcCtx, err := wshutil.ExtractUnverifiedRpcContext(jwtToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error extracting rpc context from %s: %v", wshutil.WaveJwtTokenVarName, err)
|
||||
}
|
||||
client.RpcContext = rpcCtx
|
||||
if client.RpcContext == nil || client.RpcContext.BlockId == "" {
|
||||
return nil, fmt.Errorf("no block id in rpc context")
|
||||
}
|
||||
client.ServerImpl = &VDomServerImpl{BlockId: client.RpcContext.BlockId, Client: client}
|
||||
sockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error extracting socket name from %s: %v", wshutil.WaveJwtTokenVarName, err)
|
||||
}
|
||||
rpcClient, err := wshutil.SetupDomainSocketRpcClient(sockName, client.ServerImpl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error setting up domain socket rpc client: %v", err)
|
||||
}
|
||||
client.RpcClient = rpcClient
|
||||
authRtn, err := wshclient.AuthenticateCommand(client.RpcClient, jwtToken, &wshrpc.RpcOpts{NoResponse: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error authenticating rpc connection: %v", err)
|
||||
}
|
||||
client.RouteId = authRtn.RouteId
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) SetRootElem(elem *vdom.VDomElem) {
|
||||
c.RootElem = elem
|
||||
}
|
||||
|
||||
func (c *Client) CreateVDomContext() error {
|
||||
err := wshclient.VDomCreateContextCommand(c.RpcClient, vdom.VDomCreateContext{}, &wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.RpcContext.BlockId)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wshclient.EventSubCommand(c.RpcClient, wps.SubscriptionRequest{Event: "blockclose", Scopes: []string{
|
||||
waveobj.MakeORef("block", c.RpcContext.BlockId).String(),
|
||||
}}, nil)
|
||||
c.RpcClient.EventListener.On("blockclose", func(event *wps.WaveEvent) {
|
||||
c.doShutdown("got blockclose event")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) SendAsyncInitiation() {
|
||||
wshclient.VDomAsyncInitiationCommand(c.RpcClient, vdom.MakeAsyncInitiationRequest(c.RpcContext.BlockId), &wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.RpcContext.BlockId)})
|
||||
}
|
||||
|
||||
func (c *Client) SetAtomVals(m map[string]any) {
|
||||
for k, v := range m {
|
||||
c.Root.SetAtomVal(k, v, true)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SetAtomVal(name string, val any) {
|
||||
c.Root.SetAtomVal(name, val, true)
|
||||
}
|
||||
|
||||
func (c *Client) GetAtomVal(name string) any {
|
||||
return c.Root.GetAtomVal(name)
|
||||
}
|
||||
|
||||
func makeNullVDom() *vdom.VDomElem {
|
||||
return &vdom.VDomElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag}
|
||||
}
|
||||
|
||||
func (c *Client) fullRender() (*vdom.VDomBackendUpdate, error) {
|
||||
c.Root.RunWork()
|
||||
c.Root.Render(c.RootElem)
|
||||
renderedVDom := c.Root.MakeVDom()
|
||||
if renderedVDom == nil {
|
||||
renderedVDom = makeNullVDom()
|
||||
}
|
||||
return &vdom.VDomBackendUpdate{
|
||||
Type: "backendupdate",
|
||||
Ts: time.Now().UnixMilli(),
|
||||
BlockId: c.RpcContext.BlockId,
|
||||
Opts: &c.Opts,
|
||||
RenderUpdates: []vdom.VDomRenderUpdate{
|
||||
{UpdateType: "root", VDom: *renderedVDom},
|
||||
},
|
||||
StateSync: c.Root.GetStateSync(true),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) incrementalRender() (*vdom.VDomBackendUpdate, error) {
|
||||
c.Root.RunWork()
|
||||
renderedVDom := c.Root.MakeVDom()
|
||||
if renderedVDom == nil {
|
||||
renderedVDom = makeNullVDom()
|
||||
}
|
||||
return &vdom.VDomBackendUpdate{
|
||||
Type: "backendupdate",
|
||||
Ts: time.Now().UnixMilli(),
|
||||
BlockId: c.RpcContext.BlockId,
|
||||
RenderUpdates: []vdom.VDomRenderUpdate{
|
||||
{UpdateType: "root", VDom: *renderedVDom},
|
||||
},
|
||||
StateSync: c.Root.GetStateSync(false),
|
||||
}, nil
|
||||
}
|
@ -78,6 +78,10 @@ const (
|
||||
MetaKey_TermLocalShellOpts = "term:localshellopts"
|
||||
MetaKey_TermScrollback = "term:scrollback"
|
||||
|
||||
MetaKey_VDomClear = "vdom:*"
|
||||
MetaKey_VDomInitialized = "vdom:initialized"
|
||||
MetaKey_VDomCorrelationId = "vdom:correlationid"
|
||||
|
||||
MetaKey_Count = "count"
|
||||
)
|
||||
|
||||
|
@ -79,6 +79,10 @@ type MetaTSType struct {
|
||||
TermLocalShellOpts []string `json:"term:localshellopts,omitempty"` // matches settings
|
||||
TermScrollback *int `json:"term:scrollback,omitempty"`
|
||||
|
||||
VDomClear bool `json:"vdom:*,omitempty"`
|
||||
VDomInitialized bool `json:"vdom:initialized,omitempty"`
|
||||
VDomCorrelationId string `json:"vdom:correlationid,omitempty"`
|
||||
|
||||
Count int `json:"count,omitempty"` // temp for cpu plot. will remove later
|
||||
}
|
||||
|
||||
|
@ -11,6 +11,7 @@ const (
|
||||
Event_BlockFile = "blockfile"
|
||||
Event_Config = "config"
|
||||
Event_UserInput = "userinput"
|
||||
Event_RouteGone = "route:gone"
|
||||
)
|
||||
|
||||
type WaveEvent struct {
|
||||
|
@ -11,6 +11,7 @@ import (
|
||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
||||
"github.com/wavetermdev/waveterm/pkg/wconfig"
|
||||
"github.com/wavetermdev/waveterm/pkg/wps"
|
||||
"github.com/wavetermdev/waveterm/pkg/vdom"
|
||||
)
|
||||
|
||||
// command "authenticate", wshserver.AuthenticateCommand
|
||||
@ -260,6 +261,24 @@ func TestCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// command "vdomasyncinitiation", wshserver.VDomAsyncInitiationCommand
|
||||
func VDomAsyncInitiationCommand(w *wshutil.WshRpc, data vdom.VDomAsyncInitiationRequest, opts *wshrpc.RpcOpts) error {
|
||||
_, err := sendRpcRequestCallHelper[any](w, "vdomasyncinitiation", data, opts)
|
||||
return err
|
||||
}
|
||||
|
||||
// command "vdomcreatecontext", wshserver.VDomCreateContextCommand
|
||||
func VDomCreateContextCommand(w *wshutil.WshRpc, data vdom.VDomCreateContext, opts *wshrpc.RpcOpts) error {
|
||||
_, err := sendRpcRequestCallHelper[any](w, "vdomcreatecontext", data, opts)
|
||||
return err
|
||||
}
|
||||
|
||||
// command "vdomrender", wshserver.VDomRenderCommand
|
||||
func VDomRenderCommand(w *wshutil.WshRpc, data vdom.VDomFrontendUpdate, opts *wshrpc.RpcOpts) (*vdom.VDomBackendUpdate, error) {
|
||||
resp, err := sendRpcRequestCallHelper[*vdom.VDomBackendUpdate](w, "vdomrender", data, opts)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// command "webselector", wshserver.WebSelectorCommand
|
||||
func WebSelectorCommand(w *wshutil.WshRpc, data wshrpc.CommandWebSelectorData, opts *wshrpc.RpcOpts) ([]string, error) {
|
||||
resp, err := sendRpcRequestCallHelper[[]string](w, "webselector", data, opts)
|
||||
|
@ -11,6 +11,7 @@ import (
|
||||
"reflect"
|
||||
|
||||
"github.com/wavetermdev/waveterm/pkg/ijson"
|
||||
"github.com/wavetermdev/waveterm/pkg/vdom"
|
||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
||||
"github.com/wavetermdev/waveterm/pkg/wconfig"
|
||||
"github.com/wavetermdev/waveterm/pkg/wps"
|
||||
@ -69,6 +70,10 @@ const (
|
||||
|
||||
Command_WebSelector = "webselector"
|
||||
Command_Notify = "notify"
|
||||
|
||||
Command_VDomCreateContext = "vdomcreatecontext"
|
||||
Command_VDomAsyncInitiation = "vdomasyncinitiation"
|
||||
Command_VDomRender = "vdomrender"
|
||||
)
|
||||
|
||||
type RespOrErrorUnion[T any] struct {
|
||||
@ -126,8 +131,16 @@ type WshRpcInterface interface {
|
||||
RemoteFileJoinCommand(ctx context.Context, paths []string) (*FileInfo, error)
|
||||
RemoteStreamCpuDataCommand(ctx context.Context) chan RespOrErrorUnion[TimeSeriesData]
|
||||
|
||||
// emain
|
||||
WebSelectorCommand(ctx context.Context, data CommandWebSelectorData) ([]string, error)
|
||||
NotifyCommand(ctx context.Context, notificationOptions WaveNotificationOptions) error
|
||||
|
||||
// terminal
|
||||
VDomCreateContextCommand(ctx context.Context, data vdom.VDomCreateContext) error
|
||||
VDomAsyncInitiationCommand(ctx context.Context, data vdom.VDomAsyncInitiationRequest) error
|
||||
|
||||
// proc
|
||||
VDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) (*vdom.VDomBackendUpdate, error)
|
||||
}
|
||||
|
||||
// for frontend
|
||||
|
@ -60,6 +60,10 @@ func MakeTabRouteId(tabId string) string {
|
||||
return "tab:" + tabId
|
||||
}
|
||||
|
||||
func MakeFeBlockRouteId(blockId string) string {
|
||||
return "feblock:" + blockId
|
||||
}
|
||||
|
||||
var DefaultRouter = NewWshRouter()
|
||||
|
||||
func NewWshRouter() *WshRouter {
|
||||
@ -322,6 +326,7 @@ func (router *WshRouter) UnregisterRoute(routeId string) {
|
||||
}
|
||||
go func() {
|
||||
wps.Broker.UnsubscribeAll(routeId)
|
||||
wps.Broker.Publish(wps.WaveEvent{Event: wps.Event_RouteGone, Scopes: []string{routeId}})
|
||||
}()
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user