diff --git a/cmd/wsh/cmd/wshcmd-html.go b/cmd/wsh/cmd/wshcmd-html.go index e28ac73ae0..a16bc821d1 100644 --- a/cmd/wsh/cmd/wshcmd-html.go +++ b/cmd/wsh/cmd/wshcmd-html.go @@ -6,7 +6,6 @@ package cmd import ( "context" "log" - "time" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/vdom" @@ -18,7 +17,7 @@ import ( ) var htmlCmdNewBlock bool -var GlobalVDomClient *vdomclient.Client +var HtmlVDomClient *vdomclient.Client = vdomclient.MakeClient(&vdom.VDomBackendOpts{CloseOnCtrlC: true}) func init() { htmlCmd.Flags().BoolVarP(&htmlCmdNewBlock, "newblock", "n", false, "create a new block") @@ -32,172 +31,198 @@ var htmlCmd = &cobra.Command{ RunE: htmlRun, } -func StyleTag(ctx context.Context, props map[string]any) any { - return vdom.Bind(` - - `, nil) +// Prop Types +type BgItemProps struct { + Bg string `json:"bg"` + Label string `json:"label"` + OnClick func() `json:"onClick"` } -type BgItemProps struct { - Bg string - Label string +type BgListProps struct { + Items []BgItem `json:"items"` +} + +type BgItem struct { + Bg string `json:"bg"` + Label string `json:"label"` } -func BgItemTag(ctx context.Context, props BgItemProps) any { - clickFn := func() { - log.Printf("bg item clicked %q\n", props.Bg) - blockInfo, err := wshclient.BlockInfoCommand(GlobalVDomClient.RpcClient, GlobalVDomClient.RpcContext.BlockId, nil) - if err != nil { - log.Printf("error getting block info: %v\n", err) - return +// Components +var Style = vdomclient.DefineComponent[struct{}](HtmlVDomClient, "Style", + func(ctx context.Context, _ struct{}) any { + return vdom.E("style", nil, ` + .root { + padding: 10px; + } + + .background { + display: flex; + align-items: center; + width: 100%; + } + + .background-inner { + max-width: 300px; + } + + .bg-item { + cursor: pointer; + padding: 8px 12px; + border-radius: 4px; + display: flex; + flex-direction: row; + align-items: flex-start; + justify-content: flex-start; + } + + .bg-item:hover { + background-color: var(--button-grey-hover-bg); + } + + .bg-preview { + width: 20px; + height: 20px; + margin-right: 10px; + border-radius: 50%; + border: 1px solid #777; + } + + .bg-label { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + `) + }, +) + +var BgItemTag = vdomclient.DefineComponent[BgItemProps](HtmlVDomClient, "BgItem", + func(ctx context.Context, props BgItemProps) any { + return vdom.E("div", + vdom.P("className", "bg-item"), + vdom.P("onClick", props.OnClick), + vdom.E("div", + vdom.P("className", "bg-preview"), + vdom.PStyle("background", props.Bg), + ), + vdom.E("div", + vdom.P("className", "bg-label"), + props.Label, + ), + ) + }, +) + +var BgList = vdomclient.DefineComponent[BgListProps](HtmlVDomClient, "BgList", + func(ctx context.Context, props BgListProps) any { + setBackground := func(bg string) func() { + return func() { + blockInfo, err := wshclient.BlockInfoCommand(HtmlVDomClient.RpcClient, HtmlVDomClient.RpcContext.BlockId, nil) + if err != nil { + log.Printf("error getting block info: %v\n", err) + return + } + err = wshclient.SetMetaCommand(HtmlVDomClient.RpcClient, wshrpc.CommandSetMetaData{ + ORef: waveobj.ORef{OType: "tab", OID: blockInfo.TabId}, + Meta: map[string]any{"bg": bg}, + }, nil) + if err != nil { + log.Printf("error setting meta: %v\n", err) + } + } } - log.Printf("block info: tabid=%q\n", blockInfo.TabId) - err = wshclient.SetMetaCommand(GlobalVDomClient.RpcClient, wshrpc.CommandSetMetaData{ - ORef: waveobj.ORef{OType: "tab", OID: blockInfo.TabId}, - Meta: map[string]any{"bg": props.Bg}, - }, nil) - if err != nil { - log.Printf("error setting meta: %v\n", err) + + items := make([]*vdom.VDomElem, 0, len(props.Items)) + for _, item := range props.Items { + items = append(items, BgItemTag(BgItemProps{ + Bg: item.Bg, + Label: item.Label, + OnClick: setBackground(item.Bg), + })) } - // wshclient.SetMetaCommand(GlobalVDomClient.RpcClient) - } - params := map[string]any{ - "bg": props.Bg, - "label": props.Label, - "clickHandler": clickFn, - } - return vdom.Bind(` -
-
-
-
`, params) -} -func AllBgItemsTag(ctx context.Context, props map[string]any) any { - items := []map[string]any{ - {"bg": nil, "label": "default"}, - {"bg": "#ff0000", "label": "red"}, - {"bg": "#00ff00", "label": "green"}, - {"bg": "#0000ff", "label": "blue"}, - } - bgElems := make([]*vdom.VDomElem, 0) - for _, item := range items { - elem := vdom.E("BgItemTag", item) - bgElems = append(bgElems, elem) - } - return vdom.Bind(` -
-
- -
-
- `, map[string]any{"bgElems": bgElems}) -} + return vdom.E("div", + vdom.P("className", "background"), + vdom.E("div", + vdom.P("className", "background-inner"), + items, + ), + ) + }, +) -func MakeVDom() *vdom.VDomElem { - vdomStr := ` -
- -

Set Background

-
- -
-
- -
-
- -
-
- ` - elem := vdom.Bind(vdomStr, nil) - return elem -} +var App = vdomclient.DefineComponent[struct{}](HtmlVDomClient, "App", + func(ctx context.Context, _ struct{}) any { + inputText, setInputText := vdom.UseState(ctx, "start") -func GlobalEventHandler(client *vdomclient.Client, event vdom.VDomEvent) { - if event.EventType == "clickinc" { - client.SetAtomVal("num", client.GetAtomVal("num").(int)+1) - return - } -} + bgItems := []BgItem{ + {Bg: "", Label: "default"}, + {Bg: "#ff0000", Label: "red"}, + {Bg: "#00ff00", Label: "green"}, + {Bg: "#0000ff", Label: "blue"}, + } + + return vdom.E("div", + vdom.P("className", "root"), + Style(struct{}{}), + vdom.E("h1", nil, "Set Background"), + vdom.E("div", nil, + vdom.E("wave:markdown", + vdom.P("text", "*quick vdom application to set background colors*"), + ), + ), + vdom.E("div", nil, + BgList(BgListProps{Items: bgItems}), + ), + vdom.E("div", nil, + vdom.E("img", + vdom.P("style", "width: 100%; height: 100%; max-width: 300px; max-height: 300px; object-fit: contain;"), + vdom.P("src", "vdom:///test.png"), + ), + ), + vdom.E("div", nil, + vdom.E("input", + vdom.P("type", "text"), + vdom.P("value", inputText), + vdom.P("onChange", func(e vdom.VDomEvent) { + setInputText(e.TargetValue) + }), + ), + vdom.E("div", nil, + "text ", inputText, + ), + ), + ) + }, +) func htmlRun(cmd *cobra.Command, args []string) error { WriteStderr("running wsh html %q\n", RpcContext.BlockId) - - client, err := vdomclient.MakeClient(&vdom.VDomBackendOpts{CloseOnCtrlC: true}) + client := HtmlVDomClient + err := client.Connect() if err != nil { return err } - GlobalVDomClient = client - client.SetGlobalEventHandler(GlobalEventHandler) - log.Printf("created client: %v\n", client) - client.RegisterComponent("StyleTag", StyleTag) - client.RegisterComponent("BgItemTag", BgItemTag) - client.RegisterComponent("AllBgItemsTag", AllBgItemsTag) + + // Set up the root component + client.SetRootElem(App(struct{}{})) + + // Set up file handler client.RegisterFileHandler("/test.png", "~/Downloads/IMG_1939.png") - client.SetRootElem(MakeVDom()) + + // Create the VDOM context err = client.CreateVDomContext(&vdom.VDomTarget{NewBlock: htmlCmdNewBlock}) if err != nil { return err } - log.Printf("created context\n") + + // Handle shutdown 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) - log.Printf("updating text\n") - client.SetAtomVal("text", "updated text") - err := client.SendAsyncInitiation() - if err != nil { - log.Printf("error sending async initiation: %v\n", err) - } - }() + <-client.DoneCh return nil } diff --git a/frontend/app/view/vdom/vdom-model.tsx b/frontend/app/view/vdom/vdom-model.tsx index 44ab2e0f66..f7bdf144f8 100644 --- a/frontend/app/view/vdom/vdom-model.tsx +++ b/frontend/app/view/vdom/vdom-model.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { BlockNodeModel } from "@/app/block/blocktypes"; -import { getBlockMetaKeyAtom, globalStore, WOS } from "@/app/store/global"; +import { getBlockMetaKeyAtom, globalStore, PLATFORM, WOS } from "@/app/store/global"; import { makeORef } from "@/app/store/wos"; import { waveEventSubscribe } from "@/app/store/wps"; import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; @@ -43,24 +43,45 @@ function makeVDomIdMap(vdom: VDomElem, idMap: Map) { } } -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; +function annotateEvent(event: VDomEvent, propName: string, reactEvent: React.SyntheticEvent) { + if (reactEvent == null) { + return; } - if (fromProp == "onFocus") { - return { type: "focus" }; + if (propName == "onChange") { + const changeEvent = reactEvent as React.ChangeEvent; + event.targetvalue = changeEvent.target?.value; + event.targetchecked = changeEvent.target?.checked; + } + if (propName == "onClick" || propName == "onMouseDown") { + const mouseEvent = reactEvent as React.MouseEvent; + event.mousedata = { + button: mouseEvent.button, + buttons: mouseEvent.buttons, + alt: mouseEvent.altKey, + control: mouseEvent.ctrlKey, + shift: mouseEvent.shiftKey, + meta: mouseEvent.metaKey, + clientx: mouseEvent.clientX, + clienty: mouseEvent.clientY, + pagex: mouseEvent.pageX, + pagey: mouseEvent.pageY, + screenx: mouseEvent.screenX, + screeny: mouseEvent.screenY, + movementx: mouseEvent.movementX, + movementy: mouseEvent.movementY, + }; + if (PLATFORM == "darwin") { + event.mousedata.cmd = event.mousedata.meta; + event.mousedata.option = event.mousedata.alt; + } else { + event.mousedata.cmd = event.mousedata.alt; + event.mousedata.option = event.mousedata.meta; + } } - if (fromProp == "onBlur") { - return { type: "blur" }; + if (propName == "onKeyDown") { + const waveKeyEvent = adaptFromReactOrNativeKeyEvent(reactEvent as React.KeyboardEvent); + event.keydata = waveKeyEvent; } - return { type: "unknown" }; } class VDomWshClient extends WshClient { @@ -200,7 +221,7 @@ export class VDomModel { this.batchedEvents.push({ waveid: null, eventtype: "onKeyDown", - eventdata: e, + keydata: e, }); this.queueUpdate(); return true; @@ -540,26 +561,22 @@ export class VDomModel { } } } + if (update.haswork) { + this.queueUpdate(true); + } } - callVDomFunc(fnDecl: VDomFunc, e: any, compId: string, propName: string) { - const eventData = convertEvent(e, propName); + callVDomFunc(fnDecl: VDomFunc, e: React.SyntheticEvent, compId: string, propName: string) { + const vdomEvent: VDomEvent = { + waveid: compId, + eventtype: propName, + }; if (fnDecl.globalevent) { - const waveEvent: VDomEvent = { - waveid: null, - eventtype: fnDecl.globalevent, - eventdata: eventData, - }; - this.batchedEvents.push(waveEvent); - } else { - const vdomEvent: VDomEvent = { - waveid: compId, - eventtype: propName, - eventdata: eventData, - }; - this.batchedEvents.push(vdomEvent); + vdomEvent.globaleventtype = fnDecl.globalevent; } - this.queueUpdate(); + annotateEvent(vdomEvent, propName, e); + this.batchedEvents.push(vdomEvent); + this.queueUpdate(true); } createFeUpdate(): VDomFrontendUpdate { diff --git a/frontend/app/view/vdom/vdom-utils.tsx b/frontend/app/view/vdom/vdom-utils.tsx index c4bc7f2e9e..0f049c35bc 100644 --- a/frontend/app/view/vdom/vdom-utils.tsx +++ b/frontend/app/view/vdom/vdom-utils.tsx @@ -136,3 +136,49 @@ export function validateAndWrapReactStyle(model: VDomModel, style: Record { + if (!transferElem.waveid) { + return; // Skip elements without waveid + } + const elem: VDomElem = { + waveid: transferElem.tag !== "#text" ? transferElem.waveid : undefined, + tag: transferElem.tag, + props: transferElem.props, + text: transferElem.text, + children: [], // Placeholder to be populated later + }; + elemMap[transferElem.waveid] = elem; + + // Collect root elements + if (transferElem.root) { + roots.push(elem); + } + }); + + // Now populate children for each element + transferElems.forEach((transferElem) => { + if (!transferElem.waveid || !transferElem.children) return; + + const currentElem = elemMap[transferElem.waveid]; + currentElem.children = transferElem.children + .map((childId) => elemMap[childId]) + .filter((child) => child !== undefined); // Filter out any undefined children + }); + + return roots; +} diff --git a/frontend/app/view/vdom/vdom.tsx b/frontend/app/view/vdom/vdom.tsx index 89fd6f14d5..786ba13d56 100644 --- a/frontend/app/view/vdom/vdom.tsx +++ b/frontend/app/view/vdom/vdom.tsx @@ -145,8 +145,9 @@ const SvgUrlIdAttributes = { function convertVDomFunc(model: VDomModel, fnDecl: VDomFunc, compId: string, propName: string): (e: any) => void { return (e: any) => { if ((propName == "onKeyDown" || propName == "onKeyDownCapture") && fnDecl["#keys"]) { + dlog("key event", fnDecl, e); let waveEvent = adaptFromReactOrNativeKeyEvent(e); - for (let keyDesc of fnDecl.keys || []) { + for (let keyDesc of fnDecl["#keys"] || []) { if (checkKeyPressed(waveEvent, keyDesc)) { e.preventDefault(); e.stopPropagation(); diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 42d4dc78d3..f3fb1279b8 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -134,48 +134,6 @@ declare global { keyType: string; }; - interface WaveKeyboardEvent { - type: "keydown" | "keyup" | "keypress" | "unknown"; - /** - * Equivalent to KeyboardEvent.key. - */ - key: string; - /** - * Equivalent to KeyboardEvent.code. - */ - code: string; - /** - * Equivalent to KeyboardEvent.shiftKey. - */ - shift: boolean; - /** - * Equivalent to KeyboardEvent.controlKey. - */ - control: boolean; - /** - * Equivalent to KeyboardEvent.altKey. - */ - alt: boolean; - /** - * Equivalent to KeyboardEvent.metaKey. - */ - meta: boolean; - /** - * cmd is special, on mac it is meta, on windows it is alt - */ - cmd: boolean; - /** - * option is special, on mac it is alt, on windows it is meta - */ - option: boolean; - - repeat: boolean; - /** - * Equivalent to KeyboardEvent.location. - */ - location: number; - } - type SubjectWithRef = rxjs.Subject & { refCount: number; release: () => void }; type HeaderElem = diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 8c0f80e932..44bfcc9ba0 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -650,6 +650,7 @@ declare global { ts: number; blockid: string; opts?: VDomBackendOpts; + haswork?: boolean; renderupdates?: VDomRenderUpdate[]; statesync?: VDomStateSync[]; refoperations?: VDomRefOperation[]; @@ -684,7 +685,13 @@ declare global { type VDomEvent = { waveid: string; eventtype: string; - eventdata: any; + globaleventtype?: string; + targetvalue?: string; + targetchecked?: boolean; + targetname?: string; + targetid?: string; + keydata?: WaveKeyboardEvent; + mousedata?: WavePointerData; }; // vdom.VDomFrontendUpdate @@ -708,7 +715,7 @@ declare global { stoppropagation?: boolean; preventdefault?: boolean; globalevent?: string; - keys?: string[]; + #keys?: string[]; }; // vdom.VDomMessage @@ -855,6 +862,21 @@ declare global { datadir: string; }; + // vdom.WaveKeyboardEvent + type WaveKeyboardEvent = { + type: "keydown"|"keyup"|"keypress"|"unknown"; + key: string; + code: string; + repeat?: boolean; + location?: number; + shift?: boolean; + control?: boolean; + alt?: boolean; + meta?: boolean; + cmd?: boolean; + option?: boolean; + }; + // wshrpc.WaveNotificationOptions type WaveNotificationOptions = { title?: string; @@ -878,6 +900,26 @@ declare global { obj?: WaveObj; }; + // vdom.WavePointerData + type WavePointerData = { + button: number; + buttons: number; + clientx?: number; + clienty?: number; + pagex?: number; + pagey?: number; + screenx?: number; + screeny?: number; + movementx?: number; + movementy?: number; + shift?: boolean; + control?: boolean; + alt?: boolean; + meta?: boolean; + cmd?: boolean; + option?: boolean; + }; + // waveobj.Window type WaveWindow = WaveObj & { workspaceid: string; diff --git a/pkg/util/utilfn/marshal.go b/pkg/util/utilfn/marshal.go new file mode 100644 index 0000000000..fca0dd3736 --- /dev/null +++ b/pkg/util/utilfn/marshal.go @@ -0,0 +1,145 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package utilfn + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + + "github.com/mitchellh/mapstructure" +) + +func ReUnmarshal(out any, in any) error { + barr, err := json.Marshal(in) + if err != nil { + return err + } + return json.Unmarshal(barr, out) +} + +// does a mapstructure using "json" tags +func DoMapStructure(out any, input any) error { + dconfig := &mapstructure.DecoderConfig{ + Result: out, + TagName: "json", + } + decoder, err := mapstructure.NewDecoder(dconfig) + if err != nil { + return err + } + return decoder.Decode(input) +} + +func MapToStruct(in map[string]any, out any) error { + // Check that out is a pointer + outValue := reflect.ValueOf(out) + if outValue.Kind() != reflect.Ptr { + return fmt.Errorf("out parameter must be a pointer, got %v", outValue.Kind()) + } + + // Get the struct it points to + elem := outValue.Elem() + if elem.Kind() != reflect.Struct { + return fmt.Errorf("out parameter must be a pointer to struct, got pointer to %v", elem.Kind()) + } + + // Get type information + typ := elem.Type() + + // For each field in the struct + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + + // Skip unexported fields + if !field.IsExported() { + continue + } + + name := getJSONName(field) + if value, ok := in[name]; ok { + if err := setValue(elem.Field(i), value); err != nil { + return fmt.Errorf("error setting field %s: %w", name, err) + } + } + } + + return nil +} + +func StructToMap(in any) (map[string]any, error) { + // Get value and handle pointer + val := reflect.ValueOf(in) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + // Check that we have a struct + if val.Kind() != reflect.Struct { + return nil, fmt.Errorf("input must be a struct or pointer to struct, got %v", val.Kind()) + } + + // Get type information + typ := val.Type() + out := make(map[string]any) + + // For each field in the struct + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + + // Skip unexported fields + if !field.IsExported() { + continue + } + + name := getJSONName(field) + out[name] = val.Field(i).Interface() + } + + return out, nil +} + +// getJSONName returns the field name to use for JSON mapping +func getJSONName(field reflect.StructField) string { + tag := field.Tag.Get("json") + if tag == "" || tag == "-" { + return field.Name + } + return strings.Split(tag, ",")[0] +} + +// setValue attempts to set a reflect.Value with a given interface{} value +func setValue(field reflect.Value, value any) error { + if value == nil { + return nil + } + + valueRef := reflect.ValueOf(value) + + // Direct assignment if types are exactly equal + if valueRef.Type() == field.Type() { + field.Set(valueRef) + return nil + } + + // Check if types are assignable + if valueRef.Type().AssignableTo(field.Type()) { + field.Set(valueRef) + return nil + } + + // If field is pointer and value isn't already a pointer, try address + if field.Kind() == reflect.Ptr && valueRef.Kind() != reflect.Ptr { + return setValue(field, valueRef.Addr().Interface()) + } + + // Try conversion if types are convertible + if valueRef.Type().ConvertibleTo(field.Type()) { + field.Set(valueRef.Convert(field.Type())) + return nil + } + + return fmt.Errorf("cannot set value of type %v to field of type %v", valueRef.Type(), field.Type()) +} diff --git a/pkg/util/utilfn/utilfn.go b/pkg/util/utilfn/utilfn.go index 914dc4e97e..2e3acdfeb8 100644 --- a/pkg/util/utilfn/utilfn.go +++ b/pkg/util/utilfn/utilfn.go @@ -29,8 +29,6 @@ import ( "syscall" "text/template" "unicode/utf8" - - "github.com/mitchellh/mapstructure" ) var HexDigits = []byte{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'} @@ -751,27 +749,6 @@ func IndentString(indent string, str string) string { return rtn.String() } -func ReUnmarshal(out any, in any) error { - barr, err := json.Marshal(in) - if err != nil { - return err - } - return json.Unmarshal(barr, out) -} - -// does a mapstructure using "json" tags -func DoMapStructure(out any, input any) error { - dconfig := &mapstructure.DecoderConfig{ - Result: out, - TagName: "json", - } - decoder, err := mapstructure.NewDecoder(dconfig) - if err != nil { - return err - } - return decoder.Decode(input) -} - func SliceIdx[T comparable](arr []T, elem T) int { for idx, e := range arr { if e == elem { diff --git a/pkg/vdom/vdom.go b/pkg/vdom/vdom.go index d7c66177a5..a3cc04a7f9 100644 --- a/pkg/vdom/vdom.go +++ b/pkg/vdom/vdom.go @@ -7,10 +7,13 @@ import ( "context" "encoding/json" "fmt" + "log" "reflect" "strconv" "strings" "unicode" + + "github.com/wavetermdev/waveterm/pkg/util/utilfn" ) // ReactNode types = nil | string | Elem @@ -25,6 +28,17 @@ type Hook struct { Deps []any } +type Component[P any] func(props P) *VDomElem + +type styleAttrWrapper struct { + StyleAttr string + Val any +} + +type styleAttrMapWrapper struct { + StyleAttrMap map[string]any +} + func (e *VDomElem) Key() string { keyVal, ok := e.Props[KeyPropKey] if !ok { @@ -54,6 +68,20 @@ func mergeProps(props *map[string]any, newProps map[string]any) { } } +func mergeStyleAttr(props *map[string]any, styleAttr styleAttrWrapper) { + if *props == nil { + *props = make(map[string]any) + } + if (*props)["style"] == nil { + (*props)["style"] = make(map[string]any) + } + styleMap, ok := (*props)["style"].(map[string]any) + if !ok { + return + } + styleMap[styleAttr.StyleAttr] = styleAttr.Val +} + func E(tag string, parts ...any) *VDomElem { rtn := &VDomElem{Tag: tag} for _, part := range parts { @@ -65,13 +93,49 @@ func E(tag string, parts ...any) *VDomElem { mergeProps(&rtn.Props, props) continue } + if styleAttr, ok := part.(styleAttrWrapper); ok { + mergeStyleAttr(&rtn.Props, styleAttr) + continue + } + if styleAttrMap, ok := part.(styleAttrMapWrapper); ok { + for k, v := range styleAttrMap.StyleAttrMap { + mergeStyleAttr(&rtn.Props, styleAttrWrapper{StyleAttr: k, Val: v}) + } + continue + } elems := partToElems(part) rtn.Children = append(rtn.Children, elems...) } return rtn } -func P(propName string, propVal any) map[string]any { +func Props(props any) map[string]any { + m, err := utilfn.StructToMap(props) + if err != nil { + return nil + } + return m +} + +func PStyle(styleAttr string, propVal any) any { + return styleAttrWrapper{StyleAttr: styleAttr, Val: propVal} +} + +func P(propName string, propVal any) any { + if propVal == nil { + return map[string]any{propName: nil} + } + if propName == "style" { + strVal, ok := propVal.(string) + if ok { + styleMap, err := styleAttrStrToStyleMap(strVal, nil) + if err == nil { + return styleAttrMapWrapper{StyleAttrMap: styleMap} + } + log.Printf("Error parsing style attribute: %v\n", err) + return nil + } + } return map[string]any{propName: propVal} } diff --git a/pkg/vdom/vdom_comp.go b/pkg/vdom/vdom_comp.go index 118375b369..12392ab983 100644 --- a/pkg/vdom/vdom_comp.go +++ b/pkg/vdom/vdom_comp.go @@ -12,7 +12,7 @@ type ChildKey struct { Key string } -type Component struct { +type ComponentImpl struct { WaveId string Tag string Key string @@ -26,13 +26,13 @@ type Component struct { Text string // base component -- vdom, wave elem, or #fragment - Children []*Component + Children []*ComponentImpl // component -> component - Comp *Component + Comp *ComponentImpl } -func (c *Component) compMatch(tag string, key string) bool { +func (c *ComponentImpl) compMatch(tag string, key string) bool { if c == nil { return false } diff --git a/pkg/vdom/vdom_html.go b/pkg/vdom/vdom_html.go index 67721efc2d..dc94b7c8c9 100644 --- a/pkg/vdom/vdom_html.go +++ b/pkg/vdom/vdom_html.go @@ -273,17 +273,25 @@ func convertStyleToReactStyles(styleMap map[string]string, params map[string]any return rtn } +func styleAttrStrToStyleMap(styleText string, params map[string]any) (map[string]any, error) { + parser := cssparser.MakeParser(styleText) + m, err := parser.Parse() + if err != nil { + return nil, err + } + return convertStyleToReactStyles(m, params), nil +} + 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() + styleMap, err := styleAttrStrToStyleMap(styleText, params) if err != nil { return fmt.Errorf("%v (at %s)", err, makePathStr(elemPath)) } - elem.Props["style"] = convertStyleToReactStyles(m, params) + elem.Props["style"] = styleMap return nil } diff --git a/pkg/vdom/vdom_root.go b/pkg/vdom/vdom_root.go index 3f26bc660c..b59f5dd37e 100644 --- a/pkg/vdom/vdom_root.go +++ b/pkg/vdom/vdom_root.go @@ -6,7 +6,6 @@ package vdom import ( "context" "fmt" - "log" "reflect" "github.com/google/uuid" @@ -19,7 +18,7 @@ var vdomContextKey = vdomContextKeyType{} type VDomContextVal struct { Root *RootElem - Comp *Component + Comp *ComponentImpl HookIdx int } @@ -31,9 +30,9 @@ type Atom struct { type RootElem struct { OuterCtx context.Context - Root *Component + Root *ComponentImpl CFuncs map[string]any - CompMap map[string]*Component // component waveid -> component + CompMap map[string]*ComponentImpl // component waveid -> component EffectWorkQueue []*EffectWorkElem NeedsRenderMap map[string]bool Atoms map[string]*Atom @@ -64,7 +63,7 @@ func MakeRoot() *RootElem { return &RootElem{ Root: nil, CFuncs: make(map[string]any), - CompMap: make(map[string]*Component), + CompMap: make(map[string]*ComponentImpl), Atoms: make(map[string]*Atom), } } @@ -151,11 +150,10 @@ func (r *RootElem) RegisterComponent(name string, cfunc any) error { } func (r *RootElem) Render(elem *VDomElem) { - log.Printf("Render %s\n", elem.Tag) r.render(elem, &r.Root) } -func (vdf *VDomFunc) CallFn() { +func (vdf *VDomFunc) CallFn(event VDomEvent) { if vdf.Fn == nil { return } @@ -163,10 +161,18 @@ func (vdf *VDomFunc) CallFn() { if rval.Kind() != reflect.Func { return } - rval.Call(nil) + rtype := rval.Type() + if rtype.NumIn() == 0 { + rval.Call(nil) + } + if rtype.NumIn() == 1 { + if rtype.In(0) == reflect.TypeOf((*VDomEvent)(nil)).Elem() { + rval.Call([]reflect.Value{reflect.ValueOf(event)}) + } + } } -func callVDomFn(fnVal any, data any) { +func callVDomFn(fnVal any, data VDomEvent) { if fnVal == nil { return } @@ -192,13 +198,13 @@ func callVDomFn(fnVal any, data any) { } } -func (r *RootElem) Event(id string, propName string, data any) { +func (r *RootElem) Event(id string, propName string, event VDomEvent) { comp := r.CompMap[id] if comp == nil || comp.Elem == nil { return } fnVal := comp.Elem.Props[propName] - callVDomFn(fnVal, data) + callVDomFn(fnVal, event) } // this will be called by the frontend to say the DOM has been mounted @@ -235,7 +241,7 @@ func (r *RootElem) RunWork() { } } -func (r *RootElem) render(elem *VDomElem, comp **Component) { +func (r *RootElem) render(elem *VDomElem, comp **ComponentImpl) { if elem == nil || elem.Tag == "" { r.unmount(comp) return @@ -264,7 +270,7 @@ func (r *RootElem) render(elem *VDomElem, comp **Component) { r.renderComponent(cfunc, elem, comp) } -func (r *RootElem) unmount(comp **Component) { +func (r *RootElem) unmount(comp **ComponentImpl) { if *comp == nil { return } @@ -287,21 +293,21 @@ func (r *RootElem) unmount(comp **Component) { *comp = nil } -func (r *RootElem) createComp(tag string, key string, comp **Component) { - *comp = &Component{WaveId: uuid.New().String(), Tag: tag, Key: key} +func (r *RootElem) createComp(tag string, key string, comp **ComponentImpl) { + *comp = &ComponentImpl{WaveId: uuid.New().String(), Tag: tag, Key: key} r.CompMap[(*comp).WaveId] = *comp } -func (r *RootElem) renderText(text string, comp **Component) { +func (r *RootElem) renderText(text string, comp **ComponentImpl) { if (*comp).Text != text { (*comp).Text = text } } -func (r *RootElem) renderChildren(elems []VDomElem, curChildren []*Component) []*Component { - newChildren := make([]*Component, len(elems)) - curCM := make(map[ChildKey]*Component) - usedMap := make(map[*Component]bool) +func (r *RootElem) renderChildren(elems []VDomElem, curChildren []*ComponentImpl) []*ComponentImpl { + newChildren := make([]*ComponentImpl, len(elems)) + curCM := make(map[ChildKey]*ComponentImpl) + usedMap := make(map[*ComponentImpl]bool) for idx, child := range curChildren { if child.Key != "" { curCM[ChildKey{Tag: child.Tag, Idx: 0, Key: child.Key}] = child @@ -311,7 +317,7 @@ func (r *RootElem) renderChildren(elems []VDomElem, curChildren []*Component) [] } for idx, elem := range elems { elemKey := elem.Key() - var curChild *Component + var curChild *ComponentImpl if elemKey != "" { curChild = curCM[ChildKey{Tag: elem.Tag, Idx: 0, Key: elemKey}] } else { @@ -329,14 +335,14 @@ func (r *RootElem) renderChildren(elems []VDomElem, curChildren []*Component) [] return newChildren } -func (r *RootElem) renderSimple(elem *VDomElem, comp **Component) { +func (r *RootElem) renderSimple(elem *VDomElem, comp **ComponentImpl) { if (*comp).Comp != nil { r.unmount(&(*comp).Comp) } (*comp).Children = r.renderChildren(elem.Children, (*comp).Children) } -func (r *RootElem) makeRenderContext(comp *Component) context.Context { +func (r *RootElem) makeRenderContext(comp *ComponentImpl) context.Context { var ctx context.Context if r.OuterCtx != nil { ctx = r.OuterCtx @@ -359,7 +365,15 @@ func callCFunc(cfunc any, ctx context.Context, props map[string]any) any { rval := reflect.ValueOf(cfunc) arg2Type := rval.Type().In(1) arg2Val := reflect.New(arg2Type) - utilfn.ReUnmarshal(arg2Val.Interface(), props) + // if arg2 is a map, just pass props + if arg2Type.Kind() == reflect.Map { + arg2Val.Elem().Set(reflect.ValueOf(props)) + } else { + err := utilfn.MapToStruct(props, arg2Val.Interface()) + if err != nil { + fmt.Printf("error unmarshalling props: %v\n", err) + } + } rtnVal := rval.Call([]reflect.Value{reflect.ValueOf(ctx), arg2Val.Elem()}) if len(rtnVal) == 0 { return nil @@ -367,7 +381,7 @@ func callCFunc(cfunc any, ctx context.Context, props map[string]any) any { return rtnVal[0].Interface() } -func (r *RootElem) renderComponent(cfunc any, elem *VDomElem, comp **Component) { +func (r *RootElem) renderComponent(cfunc any, elem *VDomElem, comp **ComponentImpl) { if (*comp).Children != nil { for _, child := range (*comp).Children { r.unmount(&child) @@ -414,7 +428,7 @@ func convertPropsToVDom(props map[string]any) map[string]any { return vdomProps } -func convertBaseToVDom(c *Component) *VDomElem { +func convertBaseToVDom(c *ComponentImpl) *VDomElem { elem := &VDomElem{WaveId: c.WaveId, Tag: c.Tag} if c.Elem != nil { elem.Props = convertPropsToVDom(c.Elem.Props) @@ -428,7 +442,7 @@ func convertBaseToVDom(c *Component) *VDomElem { return elem } -func convertToVDom(c *Component) *VDomElem { +func convertToVDom(c *ComponentImpl) *VDomElem { if c == nil { return nil } @@ -442,7 +456,7 @@ func convertToVDom(c *Component) *VDomElem { } } -func (r *RootElem) makeVDom(comp *Component) *VDomElem { +func (r *RootElem) makeVDom(comp *ComponentImpl) *VDomElem { vdomElem := convertToVDom(comp) return vdomElem } @@ -450,3 +464,53 @@ func (r *RootElem) makeVDom(comp *Component) *VDomElem { func (r *RootElem) MakeVDom() *VDomElem { return r.makeVDom(r.Root) } + +func ConvertElemsToTransferElems(elems []VDomElem) []VDomTransferElem { + var transferElems []VDomTransferElem + textCounter := 0 // Counter for generating unique IDs for #text nodes + + // Helper function to recursively process each VDomElem in preorder + var processElem func(elem VDomElem, isRoot bool) string + processElem = func(elem VDomElem, isRoot bool) string { + // Handle #text nodes by generating a unique placeholder ID + if elem.Tag == "#text" { + textId := fmt.Sprintf("text-%d", textCounter) + textCounter++ + transferElems = append(transferElems, VDomTransferElem{ + Root: isRoot, + WaveId: textId, + Tag: elem.Tag, + Text: elem.Text, + Props: nil, + Children: nil, + }) + return textId + } + + // Convert children to WaveId references, handling potential #text nodes + childrenIds := make([]string, len(elem.Children)) + for i, child := range elem.Children { + childrenIds[i] = processElem(child, false) // Children are not roots + } + + // Create the VDomTransferElem for the current element + transferElem := VDomTransferElem{ + Root: isRoot, + WaveId: elem.WaveId, + Tag: elem.Tag, + Props: elem.Props, + Children: childrenIds, + Text: elem.Text, + } + transferElems = append(transferElems, transferElem) + + return elem.WaveId + } + + // Start processing each top-level element, marking them as roots + for _, elem := range elems { + processElem(elem, true) + } + + return transferElems +} diff --git a/pkg/vdom/vdom_test.go b/pkg/vdom/vdom_test.go index 42e8214d8e..db56a4415a 100644 --- a/pkg/vdom/vdom_test.go +++ b/pkg/vdom/vdom_test.go @@ -90,7 +90,7 @@ func Test1(t *testing.T) { printVDom(root) root.RunWork() printVDom(root) - root.Event(testContext.ButtonId, "onClick", nil) + root.Event(testContext.ButtonId, "onClick", VDomEvent{EventType: "onClick"}) root.RunWork() printVDom(root) } diff --git a/pkg/vdom/vdom_types.go b/pkg/vdom/vdom_types.go index 1c09d28175..e3b3c00e9f 100644 --- a/pkg/vdom/vdom_types.go +++ b/pkg/vdom/vdom_types.go @@ -31,6 +31,16 @@ type VDomElem struct { Text string `json:"text,omitempty"` } +// the over the wire format for a vdom element +type VDomTransferElem struct { + Root bool `json:"root,omitempty"` + WaveId string `json:"waveid,omitempty"` // required, except for #text nodes + Tag string `json:"tag"` + Props map[string]any `json:"props,omitempty"` + Children []string `json:"children,omitempty"` + Text string `json:"text,omitempty"` +} + //// protocol messages type VDomCreateContext struct { @@ -74,6 +84,7 @@ type VDomBackendUpdate struct { Ts int64 `json:"ts"` BlockId string `json:"blockid"` Opts *VDomBackendOpts `json:"opts,omitempty"` + HasWork bool `json:"haswork,omitempty"` RenderUpdates []VDomRenderUpdate `json:"renderupdates,omitempty"` StateSync []VDomStateSync `json:"statesync,omitempty"` RefOperations []VDomRefOperation `json:"refoperations,omitempty"` @@ -95,7 +106,7 @@ type VDomFunc struct { 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" + Keys []string `json:"#keys,omitempty"` // special for keyDown events a list of keys to "capture" } // used in props @@ -128,9 +139,15 @@ type VDomRefPosition struct { ///// subbordinate protocol types type VDomEvent struct { - WaveId string `json:"waveid"` // empty for global events - EventType string `json:"eventtype"` - EventData any `json:"eventdata"` + WaveId string `json:"waveid"` + EventType string `json:"eventtype"` // usually the prop name (e.g. onClick, onKeyDown) + GlobalEventType string `json:"globaleventtype,omitempty"` + TargetValue string `json:"targetvalue,omitempty"` + TargetChecked bool `json:"targetchecked,omitempty"` + TargetName string `json:"targetname,omitempty"` + TargetId string `json:"targetid,omitempty"` + KeyData *WaveKeyboardEvent `json:"keydata,omitempty"` + MouseData *WavePointerData `json:"mousedata,omitempty"` } type VDomRenderContext struct { @@ -199,3 +216,41 @@ type VDomKeyboardEvent struct { Repeat bool `json:"repeat,omitempty"` Location int `json:"location,omitempty"` } + +type WaveKeyboardEvent struct { + Type string `json:"type" tstype:"\"keydown\"|\"keyup\"|\"keypress\"|\"unknown\""` + Key string `json:"key"` // KeyboardEvent.key + Code string `json:"code"` // KeyboardEvent.code + Repeat bool `json:"repeat,omitempty"` + Location int `json:"location,omitempty"` // KeyboardEvent.location + + // modifiers + Shift bool `json:"shift,omitempty"` + Control bool `json:"control,omitempty"` + Alt bool `json:"alt,omitempty"` + Meta bool `json:"meta,omitempty"` + Cmd bool `json:"cmd,omitempty"` // special (on mac it is meta, on windows/linux it is alt) + Option bool `json:"option,omitempty"` // special (on mac it is alt, on windows/linux it is meta) +} + +type WavePointerData struct { + Button int `json:"button"` + Buttons int `json:"buttons"` + + ClientX int `json:"clientx,omitempty"` + ClientY int `json:"clienty,omitempty"` + PageX int `json:"pagex,omitempty"` + PageY int `json:"pagey,omitempty"` + ScreenX int `json:"screenx,omitempty"` + ScreenY int `json:"screeny,omitempty"` + MovementX int `json:"movementx,omitempty"` + MovementY int `json:"movementy,omitempty"` + + // Modifiers + Shift bool `json:"shift,omitempty"` + Control bool `json:"control,omitempty"` + Alt bool `json:"alt,omitempty"` + Meta bool `json:"meta,omitempty"` + Cmd bool `json:"cmd,omitempty"` // special (on mac it is meta, on windows/linux it is alt) + Option bool `json:"option,omitempty"` // special (on mac it is alt, on windows/linux it is meta) +} diff --git a/pkg/vdom/vdomclient/vdomclient.go b/pkg/vdom/vdomclient/vdomclient.go index b21bfdbed8..97972aa433 100644 --- a/pkg/vdom/vdomclient/vdomclient.go +++ b/pkg/vdom/vdomclient/vdomclient.go @@ -4,12 +4,14 @@ package vdomclient import ( + "context" "fmt" "log" "net/http" "os" "sync" "time" + "unicode" "github.com/google/uuid" "github.com/gorilla/mux" @@ -64,7 +66,7 @@ func (c *Client) SetOverrideUrlHandler(handler http.Handler) { c.OverrideUrlHandler = handler } -func MakeClient(opts *vdom.VDomBackendOpts) (*Client, error) { +func MakeClient(opts *vdom.VDomBackendOpts) *Client { client := &Client{ Lock: &sync.Mutex{}, Root: vdom.MakeRoot(), @@ -74,34 +76,38 @@ func MakeClient(opts *vdom.VDomBackendOpts) (*Client, error) { if opts != nil { client.Opts = *opts } + return client +} + +func (client *Client) Connect() error { jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName) if jwtToken == "" { - return nil, fmt.Errorf("no %s env var set", wshutil.WaveJwtTokenVarName) + return 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) + return 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") + return 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) + return 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) + return 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) + return fmt.Errorf("error authenticating rpc connection: %v", err) } client.RouteId = authRtn.RouteId - return client, nil + return nil } func (c *Client) SetRootElem(elem *vdom.VDomElem) { @@ -170,6 +176,19 @@ func makeNullVDom() *vdom.VDomElem { return &vdom.VDomElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag} } +func DefineComponent[P any](client *Client, name string, renderFn func(ctx context.Context, props P) any) vdom.Component[P] { + if name == "" { + panic("Component name cannot be empty") + } + if !unicode.IsUpper(rune(name[0])) { + panic("Component name must start with an uppercase letter") + } + client.RegisterComponent(name, renderFn) + return func(props P) *vdom.VDomElem { + return vdom.E(name, vdom.Props(props)) + } +} + func (c *Client) RegisterComponent(name string, cfunc any) error { return c.Root.RegisterComponent(name, cfunc) } @@ -185,6 +204,7 @@ func (c *Client) fullRender() (*vdom.VDomBackendUpdate, error) { Type: "backendupdate", Ts: time.Now().UnixMilli(), BlockId: c.RpcContext.BlockId, + HasWork: len(c.Root.EffectWorkQueue) > 0, Opts: &c.Opts, RenderUpdates: []vdom.VDomRenderUpdate{ {UpdateType: "root", VDom: *renderedVDom}, diff --git a/pkg/vdom/vdomclient/vdomserverimpl.go b/pkg/vdom/vdomclient/vdomserverimpl.go index 2d8e352b8d..69dc34c381 100644 --- a/pkg/vdom/vdomclient/vdomserverimpl.go +++ b/pkg/vdom/vdomclient/vdomserverimpl.go @@ -36,15 +36,15 @@ func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom } // run events for _, event := range feUpdate.Events { - if event.WaveId == "" { + if event.GlobalEventType != "" { if impl.Client.GlobalEventHandler != nil { impl.Client.GlobalEventHandler(impl.Client, event) } } else { - impl.Client.Root.Event(event.WaveId, event.EventType, event.EventData) + impl.Client.Root.Event(event.WaveId, event.EventType, event) } } - if feUpdate.Resync { + if feUpdate.Resync || true { return impl.Client.fullRender() } return impl.Client.incrementalRender()