Files
kjol/go/wasmruntime/host_wasm.go

518 lines
15 KiB
Go

//go:build js && wasm
package wasmruntime
import (
"syscall/js"
"kjol/wasmruntime/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 <html>.
//
// 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 <html>. 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 <input>/<textarea> —
// what the formula editor needs to insert a function at the cursor.
func SelectionRange(r *vdom.Ref) (start, end int) {
n, ok := node(r)
if !ok {
return 0, 0
}
return n.Get("selectionStart").Int(), n.Get("selectionEnd").Int()
}
func SetSelectionRange(r *vdom.Ref, start, end int) {
if n, ok := node(r); ok {
n.Call("setSelectionRange", start, end)
}
}
func ScrollIntoView(r *vdom.Ref, smooth bool, block string) {
n, ok := node(r)
if !ok {
return
}
behavior := "auto"
if smooth {
behavior = "smooth"
}
if block == "" {
block = ScrollBlockCenter
}
opts := js.Global().Get("Object").New()
opts.Set("behavior", behavior)
opts.Set("block", block)
n.Call("scrollIntoView", opts)
}
func ScrollLeft(r *vdom.Ref) float64 {
n, ok := node(r)
if !ok {
return 0
}
return n.Get("scrollLeft").Float()
}
func SetScrollLeft(r *vdom.Ref, x float64) {
if n, ok := node(r); ok {
n.Set("scrollLeft", x)
}
}
// OverflowsX reports whether an element's content is wider than the box it is
// clipped to — i.e. something is hidden.
//
// It compares scrollWidth (the full content) against clientWidth (the visible content
// box). Measure/getBoundingClientRect is the WRONG comparison here: it reports the
// element's own border box, which is by definition the size it was clipped to, so it
// can never reveal an overflow.
//
// A detached or display:none element has no layout and reports 0/0; that is not an
// overflow, so it answers false rather than a misleading true.
func OverflowsX(r *vdom.Ref) bool {
n, ok := node(r)
if !ok {
return false
}
client := n.Get("clientWidth").Float()
if client == 0 {
return false
}
// Sub-pixel layout means scrollWidth can exceed clientWidth by a hair on content
// that visually fits. Round up to whole pixels before believing it.
return n.Get("scrollWidth").Float() > client+1
}
// ---- hit testing (outside-click) ----
// Contains reports whether target lies inside r's subtree. target is an
// Event.Target(). This is how a floating panel decides a click was "outside".
func Contains(r *vdom.Ref, target any) bool {
n, ok := node(r)
if !ok {
return false
}
t, ok := target.(js.Value)
if !ok || !t.Truthy() {
return false
}
return n.Call("contains", t).Bool()
}
// ClosestAttr walks up from target to the nearest ancestor matching selector and
// returns that element's attr. It returns the attribute *value* rather than a
// handle on purpose: the floating layer identifies panels by string id, so Go can
// compare against its own open-order stack without needing JS object identity.
func ClosestAttr(target any, selector, attr string) (string, bool) {
t, ok := target.(js.Value)
if !ok || !t.Truthy() {
return "", false
}
// target may be a text node (clicks land on text); closest() is an Element
// method, so climb to the nearest element first.
if t.Get("nodeType").Int() != 1 {
t = t.Get("parentElement")
if !t.Truthy() {
return "", false
}
}
el := t.Call("closest", selector)
if !el.Truthy() {
return "", false
}
v := el.Call("getAttribute", attr)
if !v.Truthy() {
return "", false
}
return v.String(), true
}
// QuerySelector finds an existing element anywhere in the document — for the
// tutorial, whose steps target app elements it does not own.
func QuerySelector(selector string) *vdom.Ref {
r := vdom.NewRef()
if el := document.Call("querySelector", selector); el.Truthy() {
vdom.SetRefNode(r, el)
}
return r
}
// ---- global listeners ----
// OnWindow installs a window-level listener and returns its remover.
//
// capture matters: `scroll` does not bubble, so a capture-phase listener on
// window is the only way to hear scrolling inside a nested scroll container — a
// floating panel anchored to a row in a scrollable table depends on it.
func OnWindow(event string, capture bool, fn func(vdom.Event)) Unsub {
return listen(window, event, capture, fn)
}
// OnDocument installs a document-level listener and returns its remover. Used for
// outside-click (mousedown, which fires before focus moves) and Escape (keydown).
func OnDocument(event string, capture bool, fn func(vdom.Event)) Unsub {
return listen(document, event, capture, fn)
}
func listen(target js.Value, event string, capture bool, fn func(vdom.Event)) Unsub {
cb := js.FuncOf(func(_ js.Value, args []js.Value) any {
var ev js.Value
if len(args) > 0 {
ev = args[0]
}
fn(clientEvent{js: ev})
return nil
})
target.Call("addEventListener", event, cb, capture)
return func() {
target.Call("removeEventListener", event, cb, capture)
cb.Release()
}
}
// ObserveResize fires fn whenever the element's own size changes. This is the fix
// for a real gap in the original TSX kit, which repositioned floating panels only
// on scroll and window resize — so a panel whose *content* grew (an async-loaded
// list, a filtered dropdown) stayed at its stale position.
func ObserveResize(r *vdom.Ref, fn func()) Unsub {
n, ok := node(r)
if !ok {
return func() {}
}
ctor := window.Get("ResizeObserver")
if !ctor.Truthy() {
return func() {}
}
cb := js.FuncOf(func(js.Value, []js.Value) any { fn(); return nil })
obs := ctor.New(cb)
obs.Call("observe", n)
return func() {
obs.Call("disconnect")
cb.Release()
}
}
// ---- timers ----
// SetTimeout schedules fn. The hover bridge (moving the cursor from a trigger onto
// its panel across a gap without the panel vanishing) is built on these.
func SetTimeout(ms int, fn func()) int {
var cb js.Func
cb = js.FuncOf(func(js.Value, []js.Value) any {
cb.Release()
fn()
return nil
})
return window.Call("setTimeout", cb, ms).Int()
}
func ClearTimeout(id int) {
if id != 0 {
window.Call("clearTimeout", id)
}
}
// ---- localStorage ----
// StorageGet/Set/Remove wrap localStorage. Every call is guarded: localStorage
// throws in private-mode Safari and when storage is full, and a table remembering
// its column widths is never worth taking the app down for.
func StorageGet(key string) (string, bool) {
ls := window.Get("localStorage")
if !ls.Truthy() {
return "", false
}
v := tryCall(ls, "getItem", key)
if !v.Truthy() {
return "", false
}
return v.String(), true
}
func StorageSet(key, value string) {
if ls := window.Get("localStorage"); ls.Truthy() {
tryCall(ls, "setItem", key, value)
}
}
func StorageRemove(key string) {
if ls := window.Get("localStorage"); ls.Truthy() {
tryCall(ls, "removeItem", key)
}
}
// tryCall invokes a JS method, swallowing a thrown exception (js.Value.Call panics
// on throw) and returning undefined instead.
func tryCall(recv js.Value, method string, args ...any) (out js.Value) {
defer func() {
if recover() != nil {
out = js.Undefined()
}
}()
return recv.Call(method, args...)
}
// ---- getting bytes out of wasm ----
// Download hands bytes to the browser as a file: Blob -> object URL -> a synthetic
// <a download> click -> revoke. This is the only way out of wasm for a generated
// CSV or PDF.
func Download(filename, mime string, data []byte) {
url := blobURL(mime, data)
defer window.Get("URL").Call("revokeObjectURL", url)
a := document.Call("createElement", "a")
a.Set("href", url)
a.Set("download", filename)
document.Get("body").Call("appendChild", a)
a.Call("click")
document.Get("body").Call("removeChild", a)
}
// Print loads bytes into a hidden iframe and opens the browser's print dialog on
// it — how a generated PDF gets printed without first being saved.
func Print(mime string, data []byte) {
url := blobURL(mime, data)
frame := document.Call("createElement", "iframe")
frame.Get("style").Call("setProperty", "display", "none")
frame.Set("src", url)
var onload js.Func
onload = js.FuncOf(func(js.Value, []js.Value) any {
defer onload.Release()
w := frame.Get("contentWindow")
if w.Truthy() {
w.Call("focus")
w.Call("print")
}
// The iframe must outlive the dialog, so the URL is revoked on a delay
// rather than immediately: revoking it while the dialog is open blanks the
// preview in Chrome.
SetTimeout(60_000, func() {
window.Get("URL").Call("revokeObjectURL", url)
if frame.Get("parentNode").Truthy() {
document.Get("body").Call("removeChild", frame)
}
})
return nil
})
frame.Set("onload", onload)
document.Get("body").Call("appendChild", frame)
}
func blobURL(mime string, data []byte) string {
buf := js.Global().Get("Uint8Array").New(len(data))
js.CopyBytesToJS(buf, data)
parts := js.Global().Get("Array").New()
parts.Call("push", buf)
opts := js.Global().Get("Object").New()
opts.Set("type", mime)
blob := js.Global().Get("Blob").New(parts, opts)
return window.Get("URL").Call("createObjectURL", blob).String()
}