518 lines
15 KiB
Go
518 lines
15 KiB
Go
//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 <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()
|
|
}
|