lecodes-web-canvas 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,312 @@
1
+ // The TreeUI of a browser page: the painted picture, and the few things a browser shows better than
2
+ // a painter — the way a native host keeps a platform view for them:
3
+ //
4
+ // under the picture a native view (a plugin's web half: a map, a camera) and a video. The
5
+ // painter leaves a window where the node is (`clearRoundRect`), so what the
6
+ // app paints AFTER the node — a button over the map — is over it.
7
+ // over the picture the field being edited: a live <input> / <textarea> in the place of its
8
+ // node, for as long as it has the focus — the caret, the selection, the
9
+ // clipboard and the input method are the browser's. The painter leaves the
10
+ // field's text out meanwhile.
11
+ //
12
+ // Both are placed from the render tree every time it changes: the box of the node, moved by the
13
+ // transforms above it (a screen in a transition carries its field along), cut by the clips above it.
14
+ import type { WasmModule } from "lecodes-web-runtime/src/gen/wasm"
15
+ import type { WebViewFactory, WebViewInstanceHandle } from "lecodes-web-shared/src/ui/nativeView"
16
+ import { getPlayer } from "lecodes-web-shared/src/utils/mediaplayer"
17
+ import { TREE_DEST_NATIVE } from "lecodes-sdk/ui/tree"
18
+ import { hitTest } from "../core/hitTest"
19
+ import type { Host } from "../host/Host"
20
+ import type { PaintOptions } from "../paint/paintTree"
21
+ import { TreeUI, type TreeUIOptions } from "../runtime/TreeUI"
22
+ import type { Rect, RenderNode } from "../types"
23
+
24
+ const FIELD_STYLE = `
25
+ .lc-field { position: absolute; left: 0; top: 0; margin: 0; border: none; outline: none; background: none; resize: none; box-sizing: border-box; transform-origin: 0 0; pointer-events: auto; overflow: hidden; -webkit-appearance: none; appearance: none; }
26
+ .lc-field::placeholder { color: var(--lc-placeholder, #999); opacity: 1; }
27
+ `
28
+
29
+ const hex = (v: number | undefined, fallback: string) => (v === undefined ? fallback : "#" + (v >>> 0).toString(16).padStart(8, "0"))
30
+ const isSurface = (n: { type: string }) => n.type === "native" || n.type === "video"
31
+
32
+ type Matrix = [number, number, number, number, number, number]
33
+ const IDENTITY: Matrix = [1, 0, 0, 1, 0, 0]
34
+ /** `m` then `n` in the coordinate system `m` made (the painter's `transform`). */
35
+ const multiply = (m: Matrix, n: Matrix): Matrix => [
36
+ m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1],
37
+ m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3],
38
+ m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5],
39
+ ]
40
+ const intersect = (a: Rect | null, b: Rect): Rect => {
41
+ if (!a) return b
42
+ const x = Math.max(a.x, b.x), y = Math.max(a.y, b.y)
43
+ return { x, y, width: Math.max(0, Math.min(a.x + a.width, b.x + b.width) - x), height: Math.max(0, Math.min(a.y + a.height, b.y + b.height) - y) }
44
+ }
45
+
46
+ /** Where a node is: its box, the transform of everything above it, the clip of everything above it. */
47
+ type Place = { node: RenderNode, matrix: Matrix, clip: Rect | null }
48
+
49
+ export class BrowserUI extends TreeUI {
50
+ private readonly doc: Document
51
+ private readonly factories = new Map<string, WebViewFactory>()
52
+ private readonly versions = new Map<string, number>()
53
+ private readonly views = new Map<number, { name: string, params: string, handle: WebViewInstanceHandle }>()
54
+ /** The wrapper of a native node's view, by node id. */
55
+ private readonly wrappers = new Map<number, HTMLElement>()
56
+ /** What is under the picture now, by node id (a wrapper, or a video's own element). */
57
+ private readonly shown = new Map<number, HTMLElement>()
58
+ /** The fullscreen native destination (openView kind 3), null when none. */
59
+ private destination: { viewId: number, el: HTMLElement } | null = null
60
+ private field: { id: number, el: HTMLInputElement | HTMLTextAreaElement } | null = null
61
+ private placedFor: unknown = null
62
+
63
+ /** `under` and `over` are the host's layers below and above the painted canvas. */
64
+ constructor(M: WasmModule, host: Host, private readonly under: HTMLElement, private readonly over: HTMLElement, options: TreeUIOptions = {}) {
65
+ super(M, host, options)
66
+ this.doc = under.ownerDocument
67
+ const style = this.doc.createElement("style")
68
+ style.textContent = FIELD_STYLE
69
+ over.appendChild(style)
70
+ }
71
+
72
+ /** What the page shows itself: the painter leaves it out. */
73
+ paintOptions(): PaintOptions { return { surfaces: true, editing: this.field?.id } }
74
+
75
+ // ---- native views: the plugins' web halves -----------------------------------------------------------
76
+ /** `version` = the contract version the half was generated from (1 for one written by hand). */
77
+ registerView(name: string, factory: WebViewFactory, version = 1): void { this.factories.set(name, factory); this.versions.set(name, version) }
78
+ /** A view the next bundle does not have (its plugin is not among the app's): unsupported again. */
79
+ unregisterView(name: string): void { this.factories.delete(name); this.versions.delete(name) }
80
+ isViewSupported(name: string): boolean { return this.factories.has(name) }
81
+ viewVersion(name: string): number { return this.versions.get(name) ?? 0 }
82
+
83
+ private ensureView(viewId: number, name: string, params: string): WebViewInstanceHandle | null {
84
+ const live = this.views.get(viewId)
85
+ // A new project world restarts the SDK's view ids from 1: another name or other params under a
86
+ // live id is an instance of the world that left.
87
+ if (live && (live.name !== name || live.params !== params)) this.destroyView(viewId)
88
+ else if (live) return live.handle
89
+ const factory = this.factories.get(name)
90
+ if (!factory) return null
91
+ let parsed: unknown = {}
92
+ try { parsed = params ? JSON.parse(params) : {} } catch { /* the runtime's JSON */ }
93
+ // An event's payload crosses as the value it is: the app and this host are the same JavaScript.
94
+ const handle = factory(parsed, { emit: (event, data) => this.M.jsb.viewEvent(viewId, event, data) }, this.doc)
95
+ handle.el.style.cssText += ";position:absolute;inset:0;width:100%;height:100%;"
96
+ this.views.set(viewId, { name, params, handle })
97
+ return handle
98
+ }
99
+ private destroyView(viewId: number): void {
100
+ const live = this.views.get(viewId)
101
+ if (!live) return
102
+ this.views.delete(viewId)
103
+ live.handle.destroy?.()
104
+ live.handle.el.remove()
105
+ }
106
+ /** `args` is the app's own array: a half reads it, never keeps or changes it. */
107
+ viewCall(viewId: number, method: string, args: unknown, onComplete: (result?: unknown) => void, onReject: (message: string) => void): void {
108
+ const live = this.views.get(viewId)
109
+ if (!live?.handle.call) { onReject(`NativeView call "${method}": no live instance or no call handler (viewId ${viewId})`); return }
110
+ try {
111
+ Promise.resolve(live.handle.call(method, Array.isArray(args) ? args : [])).then(
112
+ (result) => { if (result === undefined) onComplete(); else onComplete(result) },
113
+ (e) => onReject(e instanceof Error ? e.message : String(e)),
114
+ )
115
+ } catch (e) { onReject(e instanceof Error ? e.message : String(e)) }
116
+ }
117
+
118
+ // A native destination is a surface: it stays where it is until the runtime drops it (a screen
119
+ // may still be coming in over it), under every screen.
120
+ override openView(kind: number, node: number, id: number, viewName: string, paramsJson: string, above: boolean): void {
121
+ super.openView(kind, node, id, viewName, paramsJson, above)
122
+ if (kind !== TREE_DEST_NATIVE) return
123
+ this.dropDestination()
124
+ const handle = this.ensureView(id, viewName, paramsJson)
125
+ if (!handle) return
126
+ const el = this.doc.createElement("div")
127
+ el.style.cssText = "position:absolute;inset:0;pointer-events:auto;"
128
+ el.appendChild(handle.el)
129
+ this.under.prepend(el)
130
+ this.destination = { viewId: id, el }
131
+ }
132
+ override dropView(kind: number, node: number, id: number): void {
133
+ super.dropView(kind, node, id)
134
+ // …unless the one on screen now is that very view, presented again
135
+ if (kind === TREE_DEST_NATIVE && this.destination?.viewId === id && this.destKind !== TREE_DEST_NATIVE) this.dropDestination()
136
+ }
137
+ private dropDestination(): void {
138
+ this.destination?.el.remove()
139
+ this.destination = null
140
+ }
141
+
142
+ override releaseNode(id: number): void {
143
+ const n = this.get(id)
144
+ if (this.field?.id === id) this.unmountField()
145
+ super.releaseNode(id)
146
+ this.shown.get(id)?.remove()
147
+ this.shown.delete(id)
148
+ this.wrappers.delete(id)
149
+ if (n?.type === "native" && n.viewId) this.destroyView(n.viewId)
150
+ }
151
+
152
+ // ---- the field being edited ---------------------------------------------------------------------------
153
+ override focusInput(id: number): void {
154
+ super.focusInput(id)
155
+ if (this.focusedId() !== id || this.field?.id === id) return
156
+ this.unmountField()
157
+ const node = this.get(id)
158
+ if (!node) return
159
+ const el = this.doc.createElement(node.type === "textarea" ? "textarea" : "input")
160
+ el.className = "lc-field"
161
+ el.setAttribute("autocomplete", "off")
162
+ el.value = node.value
163
+ el.addEventListener("input", () => { if (this.focusedId() === id) this.setInputValue(id, el.value) })
164
+ el.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter" && el.tagName === "INPUT") { e.preventDefault(); this.submitInput() } })
165
+ // the browser took the focus away (another window, a tab key): the app's field lost it too
166
+ el.addEventListener("blur", () => { if (this.field?.el === el && this.focusedId() === id) this.blurInput() })
167
+ this.over.appendChild(el)
168
+ this.field = { id, el }
169
+ this.placedFor = null // placed with the next frame; focused now, inside the gesture that asked
170
+ this.place()
171
+ el.focus({ preventScroll: true })
172
+ }
173
+ override blurInput(): void {
174
+ super.blurInput()
175
+ this.unmountField()
176
+ }
177
+ private unmountField(): void {
178
+ const field = this.field
179
+ if (!field) return
180
+ this.field = null
181
+ field.el.remove()
182
+ this.placedFor = null
183
+ }
184
+ override setPropertyString(id: number, prop: string, value: string | null): void {
185
+ super.setPropertyString(id, prop, value)
186
+ // a value the CODE set while the field is edited
187
+ if (prop === "value" && this.field?.id === id) { const v = this.get(id)?.value ?? ""; if (this.field.el.value !== v) this.field.el.value = v }
188
+ }
189
+
190
+ // ---- placing ------------------------------------------------------------------------------------------
191
+ /** The nodes the page shows itself, where the frame has them. */
192
+ private find(): Place[] {
193
+ const found: Place[] = []
194
+ const editing = this.field?.id
195
+ const walk = (node: RenderNode, above: Matrix, clip: Rect | null) => {
196
+ let matrix = above
197
+ const t = node.style.transform
198
+ if (t) {
199
+ // the painter's: the node's matrix about its transform origin (its centre by default)
200
+ const o = node.style.transformOrigin
201
+ const cx = node.rect.x + (o ? (o.xPercent ? node.rect.width * o.x / 100 : o.x) : node.rect.width / 2)
202
+ const cy = node.rect.y + (o ? (o.yPercent ? node.rect.height * o.y / 100 : o.y) : node.rect.height / 2)
203
+ matrix = multiply(multiply(multiply(above, [1, 0, 0, 1, cx, cy]), t as unknown as Matrix), [1, 0, 0, 1, -cx, -cy])
204
+ }
205
+ if (isSurface(node) || node.id === editing) found.push({ node, matrix, clip })
206
+ const inner = node.clip ? intersect(clip, node.rect) : clip
207
+ for (const child of node.children) walk(child, matrix, inner)
208
+ }
209
+ for (const layer of this.frame()) walk(layer.tree, IDENTITY, null)
210
+ return found
211
+ }
212
+ private static put(el: HTMLElement, place: Place): void {
213
+ const { rect } = place.node
214
+ const s = el.style
215
+ s.position = "absolute"
216
+ s.left = "0"
217
+ s.top = "0"
218
+ s.width = rect.width + "px"
219
+ s.height = rect.height + "px"
220
+ s.transformOrigin = "0 0"
221
+ s.transform = `matrix(${place.matrix.join(",")}) translate(${rect.x}px,${rect.y}px)`
222
+ const c = place.clip
223
+ // the clip is in the frame's coordinates, the element's clip-path in its own box
224
+ s.clipPath = c ? `inset(${Math.max(0, c.y - rect.y)}px ${Math.max(0, rect.x + rect.width - (c.x + c.width))}px ${Math.max(0, rect.y + rect.height - (c.y + c.height))}px ${Math.max(0, c.x - rect.x)}px)` : ""
225
+ }
226
+ private surfaceElement(node: RenderNode): HTMLElement | null {
227
+ const n = this.get(node.id)
228
+ if (!n) return null
229
+ if (n.type === "video") {
230
+ const player = getPlayer(n.playerId)
231
+ return player instanceof HTMLVideoElement ? player : null
232
+ }
233
+ if (!n.viewId) return null
234
+ const handle = this.ensureView(n.viewId, n.viewName ?? "", n.viewParams ?? "")
235
+ if (!handle) return null
236
+ let wrapper = this.wrappers.get(n.id)
237
+ if (!wrapper) { wrapper = this.doc.createElement("div"); this.wrappers.set(n.id, wrapper) }
238
+ if (handle.el.parentElement !== wrapper) wrapper.replaceChildren(handle.el)
239
+ return wrapper
240
+ }
241
+ private styleField(el: HTMLInputElement | HTMLTextAreaElement, node: RenderNode): void {
242
+ const s = node.style
243
+ const st = el.style
244
+ const [pl, pt, pr, pb] = node.padding ?? [0, 0, 0, 0]
245
+ st.padding = `${pt}px ${pr}px ${pb}px ${pl}px`
246
+ st.color = hex(s.color, "#000000ff")
247
+ st.caretColor = st.color
248
+ const family = s.fontFamily ?? "Roboto"
249
+ st.fontFamily = this.host.resolveFontFamily?.(family) ?? family
250
+ st.fontSize = (s.fontSize ?? 14) + "px"
251
+ st.fontWeight = String(s.fontWeight ?? 400)
252
+ st.fontStyle = s.fontStyle ?? "normal"
253
+ st.letterSpacing = (s.letterSpacing ?? 0) + "px"
254
+ // a textarea's lines are the node's; an input is one line, centred in the field by the browser
255
+ st.lineHeight = el.tagName === "TEXTAREA" && s.lineHeight !== undefined ? s.lineHeight + "px" : "normal"
256
+ st.textAlign = s.textAlign === "center" ? "center" : s.textAlign === "end" ? "right" : "left"
257
+ st.borderRadius = "0"
258
+ el.placeholder = s.placeholder ?? ""
259
+ st.setProperty("--lc-placeholder", hex(s.placeholderColor, "#999999ff"))
260
+ if (el instanceof HTMLInputElement) { const type = s.inputType ?? "text"; if (el.type !== type) el.type = type }
261
+ }
262
+ /** After the paint: what the page shows itself goes where the frame has it. */
263
+ place(): void {
264
+ const frame = this.frame()
265
+ if (frame === this.placedFor) return
266
+ this.placedFor = frame
267
+ const seen = new Set<number>()
268
+ for (const place of this.find()) {
269
+ const { node } = place
270
+ if (node.id === this.field?.id) {
271
+ this.styleField(this.field.el, node)
272
+ BrowserUI.put(this.field.el, place)
273
+ continue
274
+ }
275
+ const el = this.surfaceElement(node)
276
+ if (!el) continue
277
+ seen.add(node.id)
278
+ if (el.parentElement !== this.under) this.under.appendChild(el)
279
+ el.style.display = "block"
280
+ el.style.opacity = ""
281
+ el.style.pointerEvents = "auto" // where the picture has a window onto it
282
+ if (el instanceof HTMLVideoElement) el.style.objectFit = node.style.objectFit ?? "contain"
283
+ BrowserUI.put(el, place)
284
+ this.shown.set(node.id, el)
285
+ }
286
+ // what the frame no longer shows (a page that left, a node taken out): kept, not seen
287
+ for (const [id, el] of this.shown) if (!seen.has(id)) { el.style.display = "none"; this.shown.delete(id) }
288
+ }
289
+
290
+ // ---- whose the pointer is --------------------------------------------------------------------------------
291
+ /** Whether the point is a surface's: the topmost thing there that takes input is a native view
292
+ * (or the native destination, with nothing of the UI over it) — the event is that view's. */
293
+ surfaceAt(x: number, y: number): boolean {
294
+ const layers = this.frame()
295
+ for (let i = layers.length - 1; i >= 0; i--) {
296
+ const layer = layers[i]!
297
+ if (layer.leaving) continue
298
+ const hit = hitTest(layer.tree, x, y, (n) => n.type === "native" || (this.get(n.id)?.interactive ?? false) || (this.get(n.id)?.editable ?? false) || !!n.scroll)
299
+ if (hit) return hit.type === "native"
300
+ if (layer.backdrop) return false
301
+ }
302
+ return this.destination !== null
303
+ }
304
+
305
+ dispose(): void {
306
+ this.unmountField()
307
+ for (const id of [...this.views.keys()]) this.destroyView(id)
308
+ this.dropDestination()
309
+ for (const el of this.shown.values()) if (!(el instanceof HTMLVideoElement)) el.remove()
310
+ this.shown.clear()
311
+ }
312
+ }
@@ -0,0 +1,12 @@
1
+ // What the browser renderer shows of the host's own: the toast strip.
2
+
3
+ /** A transient toast strip at the bottom of the root (duration in ms; ≤ 0 = 2 s). */
4
+ export function showToast(doc: Document, root: HTMLElement, message: string, duration: number) {
5
+ const el = doc.createElement("div")
6
+ el.textContent = message
7
+ el.style.cssText =
8
+ "position:absolute;left:50%;bottom:48px;transform:translateX(-50%);max-width:80%;padding:10px 16px;border-radius:8px;" +
9
+ "background:rgba(30,30,30,0.92);color:#fff;font:13px/1.4 system-ui,sans-serif;z-index:30;pointer-events:none;"
10
+ root.appendChild(el)
11
+ setTimeout(() => el.remove(), duration > 0 ? duration : 2000)
12
+ }
@@ -0,0 +1,192 @@
1
+ // The canvas renderer in the browser: the TreeUI (the runtime's HostUI table) painted to a 2D canvas
2
+ // that lies over the scene canvases, with a layer under it for the native views and the videos and
3
+ // a layer over it for the field being edited (./BrowserUI.ts). The host calls `render()` after each
4
+ // layout pass.
5
+ //
6
+ // The canvas is the host's: it sizes the backing store (device px, the host's page), the renderer
7
+ // only paints. Nothing here draws a keyboard or a device — the page is the app's window.
8
+ //
9
+ // POINTERS. The painted canvas takes no pointer itself (`pointer-events: none`): an event lands on
10
+ // what is under the pointer in the page — a native view, a scene canvas, the container — and the
11
+ // renderer meets it on its way down, at the container, in the capture phase. There the render tree
12
+ // says whose the point is. The UI's: the event goes to the TreeUI's routing (logical px), and
13
+ // stops before a view under the picture hears of it. A native view's: it goes on to the view.
14
+ import type { WasmModule } from "lecodes-web-runtime/src/gen/wasm"
15
+ import type { CanvasHost } from "lecodes-web-shared/src/canvas/createCanvasHost"
16
+ import { getUrl } from "lecodes-web-shared/src/utils/fetch"
17
+ import { viewScale } from "lecodes-web-shared/src/ui/viewScale"
18
+ import { BrowserHost } from "../host/BrowserHost"
19
+ import { Canvas2DPainter } from "../paint/Canvas2DPainter"
20
+ import { BrowserUI } from "./BrowserUI"
21
+ import { showToast } from "./chrome"
22
+
23
+ /** Hold this long before a press counts as a long press (the runtime asks the node's listener). */
24
+ const LONG_PRESS_MS = 500
25
+
26
+ export type SafeAreaEdge = "top" | "right" | "bottom" | "left"
27
+ /** Insets in logical px; `bars` = the edges that are exact-height system bars (`comfort-*` adds its
28
+ * knob past a bar instead of flooring against it). */
29
+ export type SafeArea = { top: number, right: number, bottom: number, left: number, bars?: readonly SafeAreaEdge[] }
30
+
31
+ export type CanvasRendererOptions = {
32
+ M: WasmModule
33
+ /** The canvas to paint on, over the scene canvases. */
34
+ canvas: HTMLCanvasElement
35
+ canvasHost?: CanvasHost
36
+ /** What the window loses to the on-screen keyboard, in logical px. */
37
+ keyboardHeight?: () => number
38
+ }
39
+
40
+ /** What a page fires for one press besides its pointer events: stopped with them on the way to a
41
+ * view under the picture. */
42
+ const FOLLOWERS = ["mousedown", "mouseup", "click", "dblclick", "contextmenu", "touchstart", "touchmove", "touchend"] as const
43
+
44
+ export const createCanvasRenderer = (root: HTMLElement, options: CanvasRendererOptions) => {
45
+ const doc = root.ownerDocument
46
+ const { canvas } = options
47
+ canvas.style.display = "block"
48
+ canvas.style.pointerEvents = "none"
49
+ root.style.touchAction = "none"
50
+ const context = canvas.getContext("2d")!
51
+
52
+ const layer = () => {
53
+ const el = doc.createElement("div")
54
+ el.style.cssText = "position:absolute;inset:0;overflow:hidden;pointer-events:none;"
55
+ return el
56
+ }
57
+ const under = layer()
58
+ const over = layer()
59
+ root.insertBefore(under, canvas)
60
+ canvas.after(over)
61
+
62
+ // Uploaded-asset image srcs ({ _id }) resolve to blob urls through the fetch store; UIImage(canvas)
63
+ // surfaces through the canvas host.
64
+ const host = new BrowserHost({ resolveAssetUrl: (id) => getUrl(id), canvasSurfaces: options.canvasHost })
65
+ const ui = new BrowserUI(options.M, host, under, over, {
66
+ keyboardHeight: options.keyboardHeight ?? (() => 0),
67
+ onToast: (message, duration) => showToast(doc, root, message, duration),
68
+ })
69
+
70
+ // The painter bakes the resolution factor into its transform: a new one when the factor moves.
71
+ let factor = 1
72
+ let painter = new Canvas2DPainter(context, factor)
73
+ // A canvas that took a new size was WIPED: paint once even if nothing changed.
74
+ let wiped = true
75
+ const render = () => {
76
+ if (wiped || ui.needsPaint()) {
77
+ wiped = false
78
+ ui.paint(painter, ui.paintOptions())
79
+ }
80
+ ui.place()
81
+ }
82
+
83
+ /** The viewport in logical px and the device px per logical px; the canvas already has the size. */
84
+ const resize = (width: number, height: number, nextFactor: number) => {
85
+ if (width <= 0 || height <= 0) return
86
+ if (nextFactor !== factor) { factor = nextFactor; painter = new Canvas2DPainter(context, factor) }
87
+ wiped = true
88
+ ui.resize(width, height)
89
+ }
90
+ const setSafeArea = (area: SafeArea) => {
91
+ ui.setSafeArea([area.top, area.right, area.bottom, area.left], area.bars ?? [])
92
+ }
93
+
94
+ // ---- pointers (logical px: clientX and the rect are VISUAL px under a CSS zoom) ----
95
+ const local = (e: { clientX: number, clientY: number }): [number, number] => {
96
+ const rect = root.getBoundingClientRect()
97
+ const scale = viewScale(root)
98
+ return [(e.clientX - rect.left) / scale, (e.clientY - rect.top) / scale]
99
+ }
100
+ const inField = (e: Event) => e.target instanceof Node && over.contains(e.target)
101
+ const toView = (e: Event) => e.target instanceof Node && under.contains(e.target)
102
+ /** The event is the UI's: a view under the picture does not hear of it. */
103
+ const keep = (e: Event) => { if (toView(e)) e.stopPropagation() }
104
+
105
+ /** The pointers the UI has (down on it, until they are up): theirs is every event that follows. */
106
+ const held = new Set<number>()
107
+ const longPress = new Map<number, number>()
108
+ const clearLongPress = (id: number) => { const t = longPress.get(id); if (t !== undefined) { clearTimeout(t); longPress.delete(id) } }
109
+
110
+ const onDown = (e: PointerEvent) => {
111
+ if (inField(e)) return // the field being edited: the browser's own caret and selection
112
+ const [x, y] = local(e)
113
+ if (ui.surfaceAt(x, y)) return
114
+ keep(e)
115
+ held.add(e.pointerId)
116
+ try { root.setPointerCapture(e.pointerId) } catch { /* detached */ }
117
+ ui.pointerDown(e.pointerId, x, y)
118
+ clearLongPress(e.pointerId)
119
+ longPress.set(e.pointerId, window.setTimeout(() => { longPress.delete(e.pointerId); ui.longPress(e.pointerId, x, y) }, LONG_PRESS_MS))
120
+ }
121
+ // A mouse move is also the cursor's hover (button or not): stored here, resolved once per frame
122
+ // (TreeUI.tickHover). A finger hovers nothing.
123
+ const onMove = (e: PointerEvent) => {
124
+ const [x, y] = local(e)
125
+ if (held.has(e.pointerId)) { keep(e); ui.pointerMove(e.pointerId, x, y) }
126
+ if (e.pointerType !== "mouse") return
127
+ if (inField(e) || (!held.has(e.pointerId) && ui.surfaceAt(x, y))) ui.pointerLeave()
128
+ else ui.pointerHover(x, y)
129
+ }
130
+ const onLeave = (e: PointerEvent) => { if (e.pointerType === "mouse") ui.pointerLeave() }
131
+ const onUp = (e: PointerEvent) => {
132
+ if (!held.delete(e.pointerId)) return
133
+ keep(e)
134
+ clearLongPress(e.pointerId)
135
+ const [x, y] = local(e)
136
+ ui.pointerUp(e.pointerId, x, y)
137
+ }
138
+ const onCancel = (e: PointerEvent) => {
139
+ if (!held.delete(e.pointerId)) return
140
+ clearLongPress(e.pointerId)
141
+ ui.pointerCancel(e.pointerId)
142
+ }
143
+ const onWheel = (e: WheelEvent) => {
144
+ if (inField(e)) return
145
+ const [x, y] = local(e)
146
+ if (ui.surfaceAt(x, y)) return
147
+ keep(e)
148
+ const k = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? ui.height : 1
149
+ if (ui.wheel(x, y, e.deltaX * k, e.deltaY * k)) e.preventDefault()
150
+ }
151
+ // What follows a press the UI took: not the view's either. A mouse press on the UI moves no
152
+ // focus and selects nothing (the field being edited keeps its caret while a button is pressed).
153
+ const onFollower = (e: Event) => {
154
+ if (inField(e)) return
155
+ const point = "clientX" in e ? (e as MouseEvent) : (e as TouchEvent).changedTouches?.[0]
156
+ if (!point) return
157
+ const [x, y] = local(point)
158
+ if (ui.surfaceAt(x, y)) return
159
+ keep(e)
160
+ if (e.type === "mousedown") e.preventDefault()
161
+ }
162
+
163
+ root.addEventListener("pointerdown", onDown, true)
164
+ root.addEventListener("pointermove", onMove, true)
165
+ root.addEventListener("pointerleave", onLeave)
166
+ root.addEventListener("pointerup", onUp, true)
167
+ root.addEventListener("pointercancel", onCancel, true)
168
+ root.addEventListener("wheel", onWheel, { passive: false, capture: true })
169
+ for (const type of FOLLOWERS) root.addEventListener(type, onFollower, true)
170
+
171
+ return {
172
+ ui,
173
+ render,
174
+ resize,
175
+ setSafeArea,
176
+ destroy() {
177
+ for (const id of longPress.keys()) clearLongPress(id)
178
+ root.removeEventListener("pointerdown", onDown, true)
179
+ root.removeEventListener("pointermove", onMove, true)
180
+ root.removeEventListener("pointerleave", onLeave)
181
+ root.removeEventListener("pointerup", onUp, true)
182
+ root.removeEventListener("pointercancel", onCancel, true)
183
+ root.removeEventListener("wheel", onWheel, true)
184
+ for (const type of FOLLOWERS) root.removeEventListener(type, onFollower, true)
185
+ ui.dispose()
186
+ under.remove()
187
+ over.remove()
188
+ },
189
+ }
190
+ }
191
+
192
+ export type CanvasRenderer = ReturnType<typeof createCanvasRenderer>