//go:build js && wasm package wasmruntime import ( "syscall/js" "kjol/vdom" ) // The browser half of the host API (see host.go for the contract and the two // rules for callers). var window = js.Global() // node resolves a Ref to its live DOM handle, or an invalid js.Value if the ref // is unattached. Every entry point below guards on this, so calling a host // function against an unmounted ref is a silent no-op rather than a panic — the // common case during the first render, before the reconciler has committed. func node(r *vdom.Ref) (js.Value, bool) { if r == nil { return js.Value{}, false } n, ok := r.Node().(js.Value) if !ok || !n.Truthy() { return js.Value{}, false } return n, true } // ---- measurement ---- // Measure runs getBoundingClientRect on an element. Returns the zero Rect if the // ref is not mounted — callers must check Rect.Empty() before positioning against // it (see host.go), or the panel lands at 0,0. func Measure(r *vdom.Ref) Rect { n, ok := node(r) if !ok { return Rect{} } b := n.Call("getBoundingClientRect") return Rect{ X: b.Get("left").Float(), Y: b.Get("top").Float(), Width: b.Get("width").Float(), Height: b.Get("height").Float(), } } // Viewport is window.innerWidth/innerHeight — the collision boundary for the // floating engine. func Viewport() Size { return Size{ Width: window.Get("innerWidth").Float(), Height: window.Get("innerHeight").Float(), } } // CSSVarPx reads a CSS custom property off :root and resolves it to pixels, // handling rem (against the root font size) and px. Returns 0 if unset or // unparseable. Used for --radius-default, so the tutorial spotlight's corners // match the app's theme. func CSSVarPx(name string) float64 { root := document.Get("documentElement") style := window.Call("getComputedStyle", root) raw := style.Call("getPropertyValue", name).String() return parseCSSLength(raw, style.Get("fontSize").String()) } // ---- theme ---- // PrefersDark reports whether the OS asks for a dark colour scheme. func PrefersDark() bool { mq := window.Call("matchMedia", "(prefers-color-scheme: dark)") return mq.Truthy() && mq.Get("matches").Bool() } // OnMediaChange watches a media query. The theme controller uses it to follow the OS // while the user has not overridden it — a preference changed in the system settings // should reach an open tab, not wait for a reload. func OnMediaChange(query string, fn func(matches bool)) Unsub { mq := window.Call("matchMedia", query) if !mq.Truthy() { return func() {} } cb := js.FuncOf(func(_ js.Value, args []js.Value) any { matches := false if len(args) > 0 { matches = args[0].Get("matches").Bool() } fn(matches) return nil }) mq.Call("addEventListener", "change", cb) return func() { mq.Call("removeEventListener", "change", cb) cb.Release() } } // SetRootClass adds or removes a class on . // // The THEME lives there rather than on the app's root element because the page's own // background — the thing behind everything, painted before the app mounts — is styled // from . A dark class on #app leaves a white margin around a dark page. func SetRootClass(class string, on bool) { list := document.Get("documentElement").Get("classList") if on { list.Call("add", class) return } list.Call("remove", class) } // ---- frame timing + the post-render hook ---- // Now is performance.now(): milliseconds since the page began navigating, with // sub-millisecond resolution. // // It is measured from the same origin the browser uses for its own timings, so a // reading taken when the first render commits IS the time it took this app to become // interactive — not an interval the app started and stopped itself. func Now() float64 { return window.Get("performance").Call("now").Float() } func RAF(fn func()) int { var cb js.Func cb = js.FuncOf(func(js.Value, []js.Value) any { cb.Release() fn() return nil }) return window.Call("requestAnimationFrame", cb).Int() } func CancelRAF(id int) { window.Call("cancelAnimationFrame", id) } // afterRender holds callbacks queued for the next render commit. See mount.go, // which drains it once the DOM is up to date. var afterRender []func() // AfterRender runs fn once, right after the current render has been committed to // the DOM. This is how a component measures something it just rendered: signal // writes only *schedule* a render, so a ref read in the same tick is still empty. // // open.Set(true) // wasmruntime.AfterRender(func() { reposition() }) // the panel exists by now // // If no render is pending (nothing changed), fn still runs — on the next frame — // so a caller can never be stranded waiting for a commit that will not come. func AfterRender(fn func()) { afterRender = append(afterRender, fn) if !renderScheduled { // No re-render coming; run on the next frame so the caller's contract // ("after the DOM is settled") still holds. RAF(flushAfterRender) } } func flushAfterRender() { if len(afterRender) == 0 { return } pending := afterRender afterRender = nil for _, fn := range pending { fn() } } // ---- imperative DOM writes (bypassing the vdom on purpose) ---- // SetStyle writes an inline style property directly on the element. Positioning // goes through here rather than through a signal: a signal write re-renders the // entire tree, and repositioning happens on every scroll and resize frame. func SetStyle(r *vdom.Ref, prop, value string) { if n, ok := node(r); ok { n.Get("style").Call("setProperty", prop, value) } } func RemoveStyle(r *vdom.Ref, prop string) { if n, ok := node(r); ok { n.Get("style").Call("removeProperty", prop) } } // SetText / SetHTML write content imperatively. SetHTML exists for the formula // editor's syntax-highlight overlay, which is repainted per keystroke and must not // go through a full re-render. func SetText(r *vdom.Ref, text string) { if n, ok := node(r); ok { n.Set("textContent", text) } } func SetHTML(r *vdom.Ref, html string) { if n, ok := node(r); ok { n.Set("innerHTML", html) } } // ---- focus, selection, scrolling ---- func Focus(r *vdom.Ref) { if n, ok := node(r); ok { n.Call("focus") } } func Blur(r *vdom.Ref) { if n, ok := node(r); ok { n.Call("blur") } } // SelectionRange / SetSelectionRange expose the caret in an /