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.
- package/package.json +26 -0
- package/src/contract.d.ts +16 -0
- package/src/core/SceneNode.ts +109 -0
- package/src/core/hitTest.ts +30 -0
- package/src/core/text.ts +259 -0
- package/src/core/util/colors.ts +30 -0
- package/src/core/util/genericFamilies.ts +30 -0
- package/src/core/util/geometry.ts +121 -0
- package/src/core/util/svgSrc.ts +141 -0
- package/src/host/BrowserHost.ts +132 -0
- package/src/host/HeadlessHost.ts +64 -0
- package/src/host/Host.ts +85 -0
- package/src/layout/CuiPaint.gen.ts +121 -0
- package/src/layout/LayoutEngine.ts +78 -0
- package/src/layout/creatorUi/loadCreatorUi.ts +87 -0
- package/src/layout/creatorUi/paint-read.ts +131 -0
- package/src/layout/creatorUi/readGradient.ts +62 -0
- package/src/paint/Canvas2DPainter.ts +258 -0
- package/src/paint/Painter.ts +62 -0
- package/src/paint/RecordingPainter.ts +101 -0
- package/src/paint/paintTree.ts +314 -0
- package/src/runtime/TreeUI.ts +822 -0
- package/src/serialize/serializeRenderTree.ts +150 -0
- package/src/types.ts +299 -0
- package/src/vite-env.d.ts +1 -0
- package/src/web/BrowserUI.ts +312 -0
- package/src/web/chrome.ts +12 -0
- package/src/web/renderer.ts +192 -0
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
/// <reference path="../contract.d.ts" />
|
|
2
|
+
// The tree UI: the HostUI table of the runtime (sdk/src/bridges/tree.d.ts `HostUI`) implemented
|
|
3
|
+
// in TypeScript over the wasm runtime module — the shared core of the web renderers (the canvas
|
|
4
|
+
// mount in the browser, the DOM renderer, the headless host). The runtime OWNS the tree (creator-ui
|
|
5
|
+
// Yoga, the screens / destinations / widgets / pager / vlist); this class keeps one HNode per node
|
|
6
|
+
// id (the yoga pointer), the paint-ready style creator-ui's handlers write into it, the content
|
|
7
|
+
// properties, and the boxes of the last layout pass — and turns them into the render tree (the
|
|
8
|
+
// RenderNode: the semantic JSON AND the paint source), hit-tests pointers over it and reports what
|
|
9
|
+
// happened back to the runtime through the creatorWeb* entry points (node events, touches, scroll,
|
|
10
|
+
// focus / change). Nothing of JavaScript reaches it: the SDK's element objects live behind the
|
|
11
|
+
// runtime's emitter.
|
|
12
|
+
import type { WasmModule } from "lecodes-web-runtime/src/gen/wasm"
|
|
13
|
+
import type { WasmHostUI } from "lecodes-web-runtime/src/gen/host.gen"
|
|
14
|
+
import { paintPending, readPaintOf, syncPaint, type PaintModule } from "../layout/creatorUi/paint-read"
|
|
15
|
+
import type { CreatorUiModule } from "../layout/creatorUi/loadCreatorUi"
|
|
16
|
+
import type { Host } from "../host/Host"
|
|
17
|
+
import type { Layer, Rect, RenderNode, ResolvedStyle, WorkerNode } from "../types"
|
|
18
|
+
import type { SceneNode } from "../core/SceneNode"
|
|
19
|
+
import type { Painter } from "../paint/Painter"
|
|
20
|
+
import { clampLines, measureEditableNode, measureImageNode, measureTextNode, resolveTextStyle, toFontSpec, wrapText } from "../core/text"
|
|
21
|
+
import { paintFrame, type PaintOptions } from "../paint/paintTree"
|
|
22
|
+
import { frameToText, serializeFrame } from "../serialize/serializeRenderTree"
|
|
23
|
+
import { hitTest } from "../core/hitTest"
|
|
24
|
+
import { svgToDataUri } from "../core/util/svgSrc"
|
|
25
|
+
import {
|
|
26
|
+
TREE_DEST_SCREEN, TREE_EVENT_BLUR, TREE_EVENT_CHANGE, TREE_EVENT_FOCUS, TREE_EVENT_OVERLAY_TAP, TREE_EVENT_OVERSCROLL,
|
|
27
|
+
TREE_EVENT_SCROLL_RELEASE, TREE_EVENT_SUBMIT, TREE_FLAG_BLUR, TREE_FLAG_CHANGE, TREE_FLAG_CLICK, TREE_FLAG_FOCUS,
|
|
28
|
+
TREE_FLAG_INTERACTIVE, TREE_FLAG_LAYOUT, TREE_FLAG_LONG_PRESS, TREE_FLAG_OVERLAY_TAP, TREE_FLAG_OVERSCROLL, TREE_FLAG_SCROLL,
|
|
29
|
+
TREE_FLAG_SCROLL_RELEASE, TREE_FLAG_SUBMIT,
|
|
30
|
+
} from "lecodes-sdk/ui/tree"
|
|
31
|
+
|
|
32
|
+
/** Fraction of the viewport the simulated keyboard occupies while an input is focused (the number
|
|
33
|
+
* every simulator host uses); the focused root re-flows above it (runtime pkgSetShrunkRoot). */
|
|
34
|
+
const KEYBOARD_FRACTION = 0.32
|
|
35
|
+
/** Drag distance (px) before a press commits to scrolling. */
|
|
36
|
+
const SCROLL_THRESHOLD = 4
|
|
37
|
+
|
|
38
|
+
/** One node of the runtime's tree as this renderer sees it. Structurally a web-canvas SceneNode for
|
|
39
|
+
* the shared text / measure helpers (they read `style`, `parent`, `text`, `value`, `sourceRect`). */
|
|
40
|
+
/**
|
|
41
|
+
* The image src as the SDK puts it on the wire (sdk/src/ui/UIImage.ts): a url, `id:<buffer>` (the
|
|
42
|
+
* fetch host serves it), `canvas:<id>` (a baked _creatorCanvas surface → the web-canvas host's
|
|
43
|
+
* `lecodes-canvas://` scheme), `svg:<markup>` (an inline SvgSource → the data-uri the image decoder
|
|
44
|
+
* loads, encoded once per distinct markup). A `scene2d:` source has no image: the box is the render.
|
|
45
|
+
*/
|
|
46
|
+
const svgUris = new Map<string, string>()
|
|
47
|
+
function wireSrc(s: string | undefined): string | undefined {
|
|
48
|
+
if (!s) return s
|
|
49
|
+
if (s.startsWith("canvas:")) return `lecodes-canvas://${s.slice(7)}`
|
|
50
|
+
if (s.startsWith("svg:")) {
|
|
51
|
+
let uri = svgUris.get(s)
|
|
52
|
+
if (uri === undefined) { uri = svgToDataUri(s.slice(4)); svgUris.set(s, uri) }
|
|
53
|
+
return uri
|
|
54
|
+
}
|
|
55
|
+
return s
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class HNode {
|
|
59
|
+
parent: HNode | null = null
|
|
60
|
+
children: HNode[] = []
|
|
61
|
+
style: ResolvedStyle = { lineHeight: 14 * 1.2 } // the core's fresh-node record; the first paint sync replaces it
|
|
62
|
+
name?: string
|
|
63
|
+
text = ""
|
|
64
|
+
src: string | undefined
|
|
65
|
+
value = ""
|
|
66
|
+
sourceRect?: [number, number, number, number]
|
|
67
|
+
viewName?: string
|
|
68
|
+
viewParams?: string
|
|
69
|
+
/** "playerId" of a video node / "viewId" of a native node (setPropertyInt). */
|
|
70
|
+
playerId = 0
|
|
71
|
+
viewId = 0
|
|
72
|
+
flags = 0
|
|
73
|
+
/** The box of the last layout pass (parent-relative; a root's is its own frame). */
|
|
74
|
+
layoutRect: Rect | null = null
|
|
75
|
+
scrollOffset = 0
|
|
76
|
+
wrapCache?: { key: string, lines: string[] }
|
|
77
|
+
/** The minimal WorkerNode the shared helpers expect (unused fields). */
|
|
78
|
+
readonly jsNode: Record<string, unknown>
|
|
79
|
+
constructor(readonly id: number, readonly type: string) { this.jsNode = { type, _id: id } }
|
|
80
|
+
get measured(): boolean { return this.type === "text" || this.type === "image" || this.editable }
|
|
81
|
+
get editable(): boolean { return this.type === "input" || this.type === "textarea" }
|
|
82
|
+
get scrollable(): boolean { return this.type === "scrollable" || this.type === "scrollableScreen" || this.type === "vlist" }
|
|
83
|
+
get scrollAxis(): "x" | "y" { return this.style.scrollAxis === "x" ? "x" : "y" }
|
|
84
|
+
get interactive(): boolean { return (this.flags & TREE_FLAG_INTERACTIVE) !== 0 }
|
|
85
|
+
get imageSrcRaw(): unknown { return this.type === "image" ? wireSrc(this.src) : undefined }
|
|
86
|
+
has(flag: number): boolean { return (this.flags & flag) !== 0 }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type Gesture = {
|
|
90
|
+
node: HNode | null // the pressed interactive node
|
|
91
|
+
scroll: HNode | null // the overflowing scrollable this gesture may drive
|
|
92
|
+
backdrop: HNode | null // a modal widget whose backdrop was tapped
|
|
93
|
+
edit: HNode | null // the editable the press landed in
|
|
94
|
+
global: boolean // fell through to the scene
|
|
95
|
+
dismiss: boolean // a clean release blurs the focused input
|
|
96
|
+
lastX: number
|
|
97
|
+
lastY: number
|
|
98
|
+
acc: number
|
|
99
|
+
scrolling: boolean
|
|
100
|
+
swallowClick: boolean // a long-press listener took the gesture
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type TreeUIOptions = {
|
|
104
|
+
/** The app-facing keyboard listener state: the host reports the band height through the runtime. */
|
|
105
|
+
onKeyboard?: (height: number) => void
|
|
106
|
+
/** The band the on-screen keyboard takes while an input is focused, in logical px. Absent = a
|
|
107
|
+
* simulated one (a fixed fraction of the height: the headless host, where nothing else tells);
|
|
108
|
+
* a browser page answers with what its window really loses. */
|
|
109
|
+
keyboardHeight?: () => number
|
|
110
|
+
/** A toast to show (the headless host records it, a browser mount paints one). */
|
|
111
|
+
onToast?: (message: string, duration: number) => void
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class TreeUI implements WasmHostUI {
|
|
115
|
+
readonly nodes = new Map<number, HNode>()
|
|
116
|
+
/** The presented screen root (TREE_DEST_SCREEN), null while a scene / nothing is presented. */
|
|
117
|
+
root: HNode | null = null
|
|
118
|
+
/** The screen that LEFT and is still shown: the runtime moves it and the incoming one through
|
|
119
|
+
* their own transform / opacity / dim (a transition is tween tracks on the roots — a host plays
|
|
120
|
+
* none of its own) and drops it when the transition lands (dropView). Painted only. */
|
|
121
|
+
leaving: HNode | null = null
|
|
122
|
+
/** …over the current screen, else under it. */
|
|
123
|
+
leavingAbove = false
|
|
124
|
+
/** Per pager: the page its current tab shows, and the one that left that place and is still
|
|
125
|
+
* shown — the runtime moves both through their own transform / opacity / dim and says when the
|
|
126
|
+
* one that left goes (HostUI.pagerShowPage / pagerDropPage). `above` = over the page shown. */
|
|
127
|
+
private pagerShown = new Map<number, HNode>()
|
|
128
|
+
private pagerLeaving = new Map<number, { page: HNode, above: boolean }>()
|
|
129
|
+
/** TREE_DEST_* of the current destination, -1 when none. */
|
|
130
|
+
destKind = -1
|
|
131
|
+
readonly widgetRoots: HNode[] = []
|
|
132
|
+
width = 0
|
|
133
|
+
height = 0
|
|
134
|
+
private focused: HNode | null = null
|
|
135
|
+
private readonly pressed = new Set<number>()
|
|
136
|
+
private readonly gestures = new Map<number, Gesture>()
|
|
137
|
+
private readonly vlists = new Set<HNode>()
|
|
138
|
+
private frameCache: Layer[] | null = null
|
|
139
|
+
private painted: Layer[] | null = null
|
|
140
|
+
private fontsPending = 0
|
|
141
|
+
private lastToast: string | null = null
|
|
142
|
+
/** Bumped by every mutation; the render tree is rebuilt when it changed. */
|
|
143
|
+
private dirty = true
|
|
144
|
+
/** The host's hook on the destination change (TREE_DEST_*; -1 = none) — `id` is the sceneId of a
|
|
145
|
+
* scene destination: it shows / hides its scene canvases. */
|
|
146
|
+
onDestination: ((kind: number, id: number) => void) | null = null
|
|
147
|
+
/** The destination that left was dropped (HostUI.dropView): the host takes a surface away here,
|
|
148
|
+
* not when the next destination opens — a screen may still be coming in over it. */
|
|
149
|
+
onDropped: ((kind: number, id: number) => void) | null = null
|
|
150
|
+
|
|
151
|
+
constructor(readonly M: WasmModule, readonly host: Host, private readonly options: TreeUIOptions = {}) {}
|
|
152
|
+
|
|
153
|
+
// ---- setup ------------------------------------------------------------------------------------
|
|
154
|
+
/** Register the measure function with creator-ui (once, before any node). Style needs no
|
|
155
|
+
* registration: the runtime keeps every node's paint record, this renderer reads it (paint-read.ts). */
|
|
156
|
+
install(): void {
|
|
157
|
+
const view = new DataView(new ArrayBuffer(8))
|
|
158
|
+
const measure = (_screenId: number, nodePtr: number, width: number, _wType: number, height: number, _hType: number): bigint => {
|
|
159
|
+
const r = this.measure(nodePtr, width, height)
|
|
160
|
+
view.setFloat32(4, r.width, true)
|
|
161
|
+
view.setFloat32(0, r.height, true)
|
|
162
|
+
return view.getBigUint64(0, true)
|
|
163
|
+
}
|
|
164
|
+
this.M._setMeasureFunc(this.M.addFunction(measure, "jiififi"))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private measure(nodeId: number, width: number, height: number): { width: number, height: number } {
|
|
168
|
+
const n = this.nodes.get(nodeId)
|
|
169
|
+
if (!n) return { width: 0, height: 0 }
|
|
170
|
+
// A layout the runtime runs before this frame's sync (it lays a screen out when it presents
|
|
171
|
+
// it) must still measure with the font the record holds NOW.
|
|
172
|
+
const P = this.M as unknown as PaintModule
|
|
173
|
+
if (paintPending(P, nodeId)) readPaintOf(P, nodeId, n.style)
|
|
174
|
+
const sn = n as unknown as SceneNode
|
|
175
|
+
if (n.type === "text") return measureTextNode(sn, this.host, width)
|
|
176
|
+
if (n.type === "image") return measureImageNode(sn, this.host, width, height)
|
|
177
|
+
if (n.editable) return measureEditableNode(sn, this.host, width)
|
|
178
|
+
return { width: 0, height: 0 }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The viewport in logical px; safe-area insets in CSS order + the system-bar edges. */
|
|
182
|
+
resize(width: number, height: number): void {
|
|
183
|
+
this.width = width
|
|
184
|
+
this.height = height
|
|
185
|
+
this.M._creatorWebResize(width, height)
|
|
186
|
+
this.M._setConditionValue(width, height)
|
|
187
|
+
this.dirty = true
|
|
188
|
+
}
|
|
189
|
+
setSafeArea(area: [number, number, number, number] | null, bars: readonly ("top" | "right" | "bottom" | "left")[] = []): void {
|
|
190
|
+
const [t, r, b, l] = area ?? (this.width > this.height ? [0, 8, 0, 20] : [20, 0, 8, 0])
|
|
191
|
+
const BIT = { top: 1, right: 2, bottom: 4, left: 8 }
|
|
192
|
+
this.M._setSafeAreaBars(bars.reduce((m, e) => m | BIT[e], 0))
|
|
193
|
+
this.M._setSafePaddings(t, r, b, l, 0)
|
|
194
|
+
this.dirty = true
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---- the frame ---------------------------------------------------------------------------------
|
|
198
|
+
/** After a runtime tick: lay every live root out (the runtime's pass), read the boxes, settle the
|
|
199
|
+
* virtualized lists, report onLayout to the nodes that listen. */
|
|
200
|
+
layoutFrame(): void {
|
|
201
|
+
const M = this.M
|
|
202
|
+
// Every style write since the last frame sits in the core's dirty paint records: take them
|
|
203
|
+
// BEFORE layout (measure reads the font) — a non-empty batch is a mutation of the render tree.
|
|
204
|
+
const changed = syncPaint(M as unknown as PaintModule, (ptr, read) => {
|
|
205
|
+
const node = this.nodes.get(ptr)
|
|
206
|
+
if (node) read(node.style)
|
|
207
|
+
})
|
|
208
|
+
if (changed > 0) this.dirty = true
|
|
209
|
+
const n = M._creatorWebLayoutFrame(this.width, this.height)
|
|
210
|
+
if (n > 0) this.readLayoutBatch(n)
|
|
211
|
+
for (const v of this.vlists) {
|
|
212
|
+
if (!M._vlistExists(v.id)) continue
|
|
213
|
+
M._vlistUpdateLayout(v.id)
|
|
214
|
+
// the mounted items are detached core roots the batch never visits: read their boxes here
|
|
215
|
+
for (const item of v.children) this.deliverSubtree(item)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** Whether the render tree changed since the last `frame()` (a mount paints only then). */
|
|
219
|
+
/** Whether the picture is behind the tree: something changed since the frame that was PAINTED.
|
|
220
|
+
* Not since the frame that was last built — a hit test or a hover builds one between a change
|
|
221
|
+
* and the paint, and the change is still owed its picture. */
|
|
222
|
+
needsPaint(): boolean { return this.dirty || this.frameCache === null || this.frameCache !== this.painted }
|
|
223
|
+
/** Consume a subtree's new layouts (getLayoutPointer: one-shot per node) into the cached boxes. */
|
|
224
|
+
private deliverSubtree(node: HNode): void {
|
|
225
|
+
const M = this.M
|
|
226
|
+
if (!this.layoutPtr) this.layoutPtr = M._malloc(16)
|
|
227
|
+
if (M._getLayoutPointer(node.id, this.layoutPtr)) {
|
|
228
|
+
const i = this.layoutPtr >> 2
|
|
229
|
+
const rect = { x: M.HEAPF32[i], y: M.HEAPF32[i + 1], width: M.HEAPF32[i + 2], height: M.HEAPF32[i + 3] }
|
|
230
|
+
const old = node.layoutRect
|
|
231
|
+
const changed = !old || old.x !== rect.x || old.y !== rect.y || old.width !== rect.width || old.height !== rect.height
|
|
232
|
+
node.layoutRect = rect
|
|
233
|
+
if (changed) this.dirty = true
|
|
234
|
+
if (changed && node.has(TREE_FLAG_LAYOUT)) M._creatorWebNodeLayout(node.id, rect.x, rect.y, rect.width, rect.height)
|
|
235
|
+
}
|
|
236
|
+
for (const c of node.children) this.deliverSubtree(c)
|
|
237
|
+
}
|
|
238
|
+
private layoutPtr = 0
|
|
239
|
+
private readLayoutBatch(n: number): void {
|
|
240
|
+
const M = this.M
|
|
241
|
+
const nodesPtr = M._collectNodes() >>> 2
|
|
242
|
+
const rectsPtr = M._collectRects() >>> 2
|
|
243
|
+
const u32 = M.HEAPU32, f32 = M.HEAPF32
|
|
244
|
+
for (let i = 0; i < n; i++) {
|
|
245
|
+
const ptr = u32[nodesPtr + i]
|
|
246
|
+
const node = this.nodes.get(ptr)
|
|
247
|
+
if (!node) continue
|
|
248
|
+
const l = f32[rectsPtr + i * 4], t = f32[rectsPtr + i * 4 + 1], r = f32[rectsPtr + i * 4 + 2], b = f32[rectsPtr + i * 4 + 3]
|
|
249
|
+
const rect = { x: l, y: t, width: r - l, height: b - t }
|
|
250
|
+
const old = node.layoutRect
|
|
251
|
+
const changed = !old || old.x !== rect.x || old.y !== rect.y || old.width !== rect.width || old.height !== rect.height
|
|
252
|
+
node.layoutRect = rect
|
|
253
|
+
if (changed) this.dirty = true
|
|
254
|
+
if (changed && node.has(TREE_FLAG_LAYOUT)) M._creatorWebNodeLayout(ptr, l, t, rect.width, rect.height)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Every compositing layer, bottom-to-top: the screen — and the one that left, under or over it
|
|
259
|
+
* — then the widgets in show order. */
|
|
260
|
+
frame(): Layer[] {
|
|
261
|
+
if (this.frameCache && !this.dirty) return this.frameCache
|
|
262
|
+
const layers: Layer[] = []
|
|
263
|
+
const viewport = { x: 0, y: 0, width: this.width, height: this.height }
|
|
264
|
+
const left = this.leaving ? this.build(this.leaving, viewport) : null
|
|
265
|
+
if (left && !this.leavingAbove) layers.push({ tree: left, leaving: true })
|
|
266
|
+
if (this.root) {
|
|
267
|
+
const tree = this.build(this.root, viewport)
|
|
268
|
+
if (tree) layers.push({ tree })
|
|
269
|
+
}
|
|
270
|
+
if (left && this.leavingAbove) layers.push({ tree: left, leaving: true })
|
|
271
|
+
for (const w of this.widgetRoots) {
|
|
272
|
+
const box = w.layoutRect ?? { x: 0, y: 0, width: this.width, height: this.height }
|
|
273
|
+
const tree = this.build(w, box)
|
|
274
|
+
if (!tree) continue
|
|
275
|
+
const color = tree.style.overlayColor
|
|
276
|
+
layers.push({ tree, widget: true, backdrop: color !== undefined ? { color, jsNode: w.jsNode as WorkerNode } : undefined })
|
|
277
|
+
}
|
|
278
|
+
this.frameCache = layers
|
|
279
|
+
this.dirty = false
|
|
280
|
+
return layers
|
|
281
|
+
}
|
|
282
|
+
renderTree(): RenderNode | null { return this.frame().find((l) => !l.widget && !l.leaving)?.tree ?? null }
|
|
283
|
+
widgetTrees(): RenderNode[] { return this.frame().filter((l) => l.widget).map((l) => l.tree) }
|
|
284
|
+
/** The widget overlay trees, in show order (the scenario engine reads them). */
|
|
285
|
+
widgets(): RenderNode[] { return this.widgetTrees() }
|
|
286
|
+
serialize(): unknown { return serializeFrame(this.renderTree(), this.widgetTrees()) }
|
|
287
|
+
toText(): string { return frameToText(this.renderTree(), this.widgetTrees()) }
|
|
288
|
+
paint(painter: Painter, options?: PaintOptions): void {
|
|
289
|
+
const frame = this.frame()
|
|
290
|
+
paintFrame(frame, painter, this.host, this.width, this.height, undefined, options)
|
|
291
|
+
this.painted = frame
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The absolute, fully-resolved render tree of a root (the web-canvas builder, over the runtime's
|
|
295
|
+
* boxes: a pager shows its selected page, a vlist places its mounted items at the core's offsets). */
|
|
296
|
+
private build(root: HNode, rootRect: Rect): RenderNode | null {
|
|
297
|
+
const M = this.M
|
|
298
|
+
const walk = (node: HNode, px: number, py: number, isRoot: boolean): RenderNode | null => {
|
|
299
|
+
if (node.style.display === "none") return null
|
|
300
|
+
let x: number, y: number, width: number, height: number
|
|
301
|
+
if (isRoot) { x = rootRect.x; y = rootRect.y; width = rootRect.width; height = rootRect.height }
|
|
302
|
+
else {
|
|
303
|
+
const rel = node.layoutRect
|
|
304
|
+
if (!rel) return null
|
|
305
|
+
x = px + rel.x; y = py + rel.y; width = rel.width; height = rel.height
|
|
306
|
+
}
|
|
307
|
+
const out: RenderNode = { id: node.id, type: node.type, rect: { x, y, width, height }, style: node.style, children: [] }
|
|
308
|
+
if (node.name) out.name = node.name
|
|
309
|
+
const sn = node as unknown as SceneNode
|
|
310
|
+
if (node.type === "text") {
|
|
311
|
+
const t = resolveTextStyle(sn)
|
|
312
|
+
const font = toFontSpec(t)
|
|
313
|
+
out.text = node.text
|
|
314
|
+
const pad = this.padding(node.id)
|
|
315
|
+
if (pad[0] || pad[1] || pad[2] || pad[3]) out.padding = pad
|
|
316
|
+
const contentW = width - pad[0] - pad[2]
|
|
317
|
+
const key = `${contentW}|${font.size}|${font.weight}|${font.italic ? 1 : 0}|${font.letterSpacing}|${font.family}|${node.style.lineClamp ?? 0}|${node.style.textOverflow ?? ""}|${node.text}`
|
|
318
|
+
if (node.wrapCache?.key === key) out.lines = node.wrapCache.lines
|
|
319
|
+
else {
|
|
320
|
+
const measure = (s: string) => this.host.measureText(s, font)
|
|
321
|
+
const lines = clampLines(wrapText(node.text, contentW, measure).lines, node.style, contentW, measure)
|
|
322
|
+
node.wrapCache = { key, lines }
|
|
323
|
+
out.lines = lines
|
|
324
|
+
}
|
|
325
|
+
out.style = { ...node.style, color: t.color, fontSize: t.fontSize, fontFamily: t.fontFamily, fontWeight: t.fontWeight, fontStyle: t.fontStyle, lineHeight: t.lineHeight, letterSpacing: t.letterSpacing, textAlign: t.textAlign, textDecoration: t.textDecoration }
|
|
326
|
+
} else if (node.type === "image") {
|
|
327
|
+
const src = this.host.resolveSrc(node.imageSrcRaw)
|
|
328
|
+
const info = src ? this.host.getImage(src) : undefined
|
|
329
|
+
out.image = info ?? (src ? { src, naturalWidth: 0, naturalHeight: 0 } : undefined)
|
|
330
|
+
if (node.sourceRect) out.sourceRect = node.sourceRect
|
|
331
|
+
} else if (node.editable) {
|
|
332
|
+
out.value = node.value
|
|
333
|
+
const pad = this.padding(node.id)
|
|
334
|
+
if (pad[0] || pad[1] || pad[2] || pad[3]) out.padding = pad
|
|
335
|
+
const t = resolveTextStyle(sn)
|
|
336
|
+
out.style = { ...node.style, color: t.color, fontSize: t.fontSize, fontFamily: t.fontFamily, fontWeight: t.fontWeight, fontStyle: t.fontStyle, lineHeight: t.lineHeight, letterSpacing: t.letterSpacing, textAlign: t.textAlign, textDecoration: t.textDecoration }
|
|
337
|
+
}
|
|
338
|
+
if (node.interactive) out.state = { pressed: this.pressed.has(node.id) }
|
|
339
|
+
if (node.editable && this.focused === node) out.state = { ...out.state, focused: true }
|
|
340
|
+
if (node.style.overflow === "hidden") out.clip = true
|
|
341
|
+
let childX = x, childY = y
|
|
342
|
+
if (node.scrollable) {
|
|
343
|
+
const axis = node.scrollAxis
|
|
344
|
+
const content = axis === "x" ? M._getContentWidth(node.id) : M._getContentHeight(node.id)
|
|
345
|
+
out.clip = true
|
|
346
|
+
out.scroll = { offset: node.scrollOffset, content, axis }
|
|
347
|
+
if (axis === "x") childX = x - node.scrollOffset
|
|
348
|
+
else childY = y - node.scrollOffset
|
|
349
|
+
}
|
|
350
|
+
if (node.type === "pager") {
|
|
351
|
+
// the selected tab's top page: an independent root laid out at the pager's box
|
|
352
|
+
const tab = M._creatorWebPagerSelectedIndex(node.id)
|
|
353
|
+
const len = M._creatorWebPagerStackLen(node.id, tab)
|
|
354
|
+
const pageId = len > 0 ? M._creatorWebPagerPageNode(node.id, tab, len - 1) : 0
|
|
355
|
+
const page = pageId ? this.nodes.get(pageId) : undefined
|
|
356
|
+
if (page) this.pagerShown.set(node.id, page); else this.pagerShown.delete(node.id)
|
|
357
|
+
const left = this.pagerLeaving.get(node.id)
|
|
358
|
+
for (const p of left && left.page !== page ? (left.above ? [page, left.page] : [left.page, page]) : [page]) {
|
|
359
|
+
const r = p ? walk(p, x, y, false) : null
|
|
360
|
+
if (r) out.children.push(r)
|
|
361
|
+
}
|
|
362
|
+
return out
|
|
363
|
+
}
|
|
364
|
+
if (node.type === "vlist") {
|
|
365
|
+
// mounted items: each a detached core root at the offset the window math gave it
|
|
366
|
+
for (const child of node.children) {
|
|
367
|
+
const itemRoot = M._getParentNode(child.id)
|
|
368
|
+
if (!itemRoot) continue
|
|
369
|
+
const off = M._vlistItemOffsetOf(node.id, itemRoot)
|
|
370
|
+
if (Number.isNaN(off)) continue
|
|
371
|
+
const rel = child.layoutRect ?? { x: 0, y: 0, width: 0, height: 0 }
|
|
372
|
+
const r = walk(child, x - rel.x, y + off - node.scrollOffset - rel.y, false)
|
|
373
|
+
if (r) out.children.push(r)
|
|
374
|
+
}
|
|
375
|
+
return out
|
|
376
|
+
}
|
|
377
|
+
for (const child of node.children) { const r = walk(child, childX, childY, false); if (r) out.children.push(r) }
|
|
378
|
+
return out
|
|
379
|
+
}
|
|
380
|
+
return walk(root, 0, 0, true)
|
|
381
|
+
}
|
|
382
|
+
/** A node's resolved padding, [left, top, right, bottom] (an inspector's). */
|
|
383
|
+
paddingOf(nodeId: number): [number, number, number, number] { return this.padding(nodeId) }
|
|
384
|
+
private padding(nodeId: number): [number, number, number, number] {
|
|
385
|
+
const M = this.M
|
|
386
|
+
if (!this.padPtr) this.padPtr = M._malloc(16)
|
|
387
|
+
M._getPaddingPointer(nodeId, this.padPtr)
|
|
388
|
+
const i = this.padPtr >> 2
|
|
389
|
+
return [M.HEAPF32[i], M.HEAPF32[i + 1], M.HEAPF32[i + 2], M.HEAPF32[i + 3]]
|
|
390
|
+
}
|
|
391
|
+
private padPtr = 0
|
|
392
|
+
/** Call an export that takes a C string. */
|
|
393
|
+
private withStr<T>(s: string, fn: (ptr: number) => T): T {
|
|
394
|
+
const p = this.M.stringToNewUTF8(s)
|
|
395
|
+
try { return fn(p) } finally { this.M._free(p) }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---- lookups ---------------------------------------------------------------------------------------
|
|
399
|
+
get(id: number): HNode | undefined { return this.nodes.get(id) }
|
|
400
|
+
/** Rendered nodes by their author-given `name`, the screen first, then the widgets. */
|
|
401
|
+
findAll(name: string): RenderNode[] {
|
|
402
|
+
const out: RenderNode[] = []
|
|
403
|
+
const walk = (n: RenderNode) => { if (n.name === name) out.push(n); for (const c of n.children) walk(c) }
|
|
404
|
+
for (const l of this.frame()) walk(l.tree)
|
|
405
|
+
return out
|
|
406
|
+
}
|
|
407
|
+
/** The absolute rect of a rendered node, null when it is not on screen. */
|
|
408
|
+
findRect(id: number): Rect | null {
|
|
409
|
+
const walk = (n: RenderNode): Rect | null => {
|
|
410
|
+
if (n.id === id) return n.rect
|
|
411
|
+
for (const c of n.children) { const r = walk(c); if (r) return r }
|
|
412
|
+
return null
|
|
413
|
+
}
|
|
414
|
+
for (const l of this.frame()) { const r = walk(l.tree); if (r) return r }
|
|
415
|
+
return null
|
|
416
|
+
}
|
|
417
|
+
/** The last toast shown (the host paints none). */
|
|
418
|
+
toast(): string | null { return this.lastToast }
|
|
419
|
+
pendingFonts(): number { return this.fontsPending }
|
|
420
|
+
keyboardHeight(): number {
|
|
421
|
+
if (!this.focused) return 0
|
|
422
|
+
return this.options.keyboardHeight ? this.options.keyboardHeight() : Math.round(this.height * KEYBOARD_FRACTION)
|
|
423
|
+
}
|
|
424
|
+
focusedId(): number { return this.focused?.id ?? 0 }
|
|
425
|
+
|
|
426
|
+
// ---- HostUI: the tree ---------------------------------------------------------------------------
|
|
427
|
+
/** The node the runtime made last (an inspector ties the element the SDK is making to it). */
|
|
428
|
+
lastCreated = 0
|
|
429
|
+
nodeFactory(id: number, type: string, parent: number, index: number): void {
|
|
430
|
+
this.lastCreated = id
|
|
431
|
+
const n = new HNode(id, type)
|
|
432
|
+
this.nodes.set(id, n)
|
|
433
|
+
if (type === "vlist") this.vlists.add(n)
|
|
434
|
+
const p = this.nodes.get(parent)
|
|
435
|
+
if (p) this.link(p, n, Math.max(0, index))
|
|
436
|
+
this.dirty = true
|
|
437
|
+
}
|
|
438
|
+
private link(p: HNode, c: HNode, index: number): void {
|
|
439
|
+
if (c.parent) c.parent.children = c.parent.children.filter((x) => x !== c)
|
|
440
|
+
c.parent = p
|
|
441
|
+
p.children.splice(Math.min(index, p.children.length), 0, c)
|
|
442
|
+
}
|
|
443
|
+
insertNode(parent: number, child: number, index: number): void {
|
|
444
|
+
const p = this.nodes.get(parent), c = this.nodes.get(child)
|
|
445
|
+
if (p && c) this.link(p, c, index)
|
|
446
|
+
this.dirty = true
|
|
447
|
+
}
|
|
448
|
+
removeNode(parent: number, child: number): void {
|
|
449
|
+
const p = this.nodes.get(parent), c = this.nodes.get(child)
|
|
450
|
+
if (!p || !c) return
|
|
451
|
+
this.forget(c)
|
|
452
|
+
p.children = p.children.filter((x) => x !== c)
|
|
453
|
+
if (c.parent === p) c.parent = null
|
|
454
|
+
if (this.pagerLeaving.get(parent)?.page === c) this.pagerLeaving.delete(parent)
|
|
455
|
+
if (this.pagerShown.get(parent) === c) this.pagerShown.delete(parent)
|
|
456
|
+
this.dirty = true
|
|
457
|
+
}
|
|
458
|
+
releaseNode(id: number): void {
|
|
459
|
+
const n = this.nodes.get(id)
|
|
460
|
+
if (!n) return
|
|
461
|
+
this.nodes.delete(id)
|
|
462
|
+
this.forget(n)
|
|
463
|
+
if (n.parent) n.parent.children = n.parent.children.filter((x) => x !== n)
|
|
464
|
+
if (this.root === n) this.root = null
|
|
465
|
+
if (this.leaving === n) this.leaving = null
|
|
466
|
+
this.pagerShown.delete(id)
|
|
467
|
+
this.pagerLeaving.delete(id)
|
|
468
|
+
for (const [pager, left] of this.pagerLeaving) if (left.page === n) this.pagerLeaving.delete(pager)
|
|
469
|
+
if (this.focused === n) { this.focused = null; this.M._creatorWebSetShrunkRoot(0, 0); this.emitKeyboard() }
|
|
470
|
+
const wi = this.widgetRoots.indexOf(n)
|
|
471
|
+
if (wi >= 0) this.widgetRoots.splice(wi, 1)
|
|
472
|
+
this.vlists.delete(n)
|
|
473
|
+
this.pressed.delete(id)
|
|
474
|
+
this.dirty = true
|
|
475
|
+
}
|
|
476
|
+
/** A node leaving the tree must leave every in-flight gesture too. */
|
|
477
|
+
private forget(n: HNode): void {
|
|
478
|
+
for (const g of this.gestures.values()) {
|
|
479
|
+
if (g.node === n) g.node = null
|
|
480
|
+
if (g.scroll === n) g.scroll = null
|
|
481
|
+
if (g.backdrop === n) g.backdrop = null
|
|
482
|
+
if (g.edit === n) g.edit = null
|
|
483
|
+
}
|
|
484
|
+
for (const c of n.children) this.forget(c)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ---- HostUI: content -----------------------------------------------------------------------------
|
|
488
|
+
setPropertyString(id: number, prop: string, value: string | null): void {
|
|
489
|
+
const n = this.nodes.get(id)
|
|
490
|
+
if (!n) return
|
|
491
|
+
const v = value ?? ""
|
|
492
|
+
switch (prop) {
|
|
493
|
+
case "text": n.text = v; n.wrapCache = undefined; break
|
|
494
|
+
case "value": n.value = v; break
|
|
495
|
+
case "name": n.name = v || undefined; break
|
|
496
|
+
case "src": {
|
|
497
|
+
n.src = v || undefined
|
|
498
|
+
const src = this.host.resolveSrc(this.srcOf(n))
|
|
499
|
+
if (src && !this.host.getImage(src)) {
|
|
500
|
+
this.host.loadImage(src).then(() => { if (this.nodes.get(id) === n && this.M._nodeHasMeasure(id)) this.M._markNodeDirty(id); this.dirty = true }).catch(() => {})
|
|
501
|
+
}
|
|
502
|
+
break
|
|
503
|
+
}
|
|
504
|
+
case "sourceRect": {
|
|
505
|
+
const p = v.split(" ").map(Number)
|
|
506
|
+
n.sourceRect = p.length === 4 ? [p[0], p[1], p[2], p[3]] : undefined
|
|
507
|
+
break
|
|
508
|
+
}
|
|
509
|
+
case "focus": if (v === "1" || v === "true") this.focusInput(id); else if (this.focused === n) this.blurInput(); break
|
|
510
|
+
case "viewName": n.viewName = v; break
|
|
511
|
+
case "viewParams": n.viewParams = v; break
|
|
512
|
+
default: break // viewParams, placeholder-like props reach the style through creator-ui
|
|
513
|
+
}
|
|
514
|
+
this.dirty = true
|
|
515
|
+
}
|
|
516
|
+
private srcOf(n: HNode): unknown { return wireSrc(n.src) }
|
|
517
|
+
setPropertyInt(id: number, prop: string, value: number): void {
|
|
518
|
+
const n = this.nodes.get(id)
|
|
519
|
+
if (n) { if (prop === "playerId") n.playerId = value; else if (prop === "viewId") n.viewId = value }
|
|
520
|
+
this.dirty = true
|
|
521
|
+
}
|
|
522
|
+
setFlags(id: number, mask: number): void { const n = this.nodes.get(id); if (n) n.flags = mask >>> 0 }
|
|
523
|
+
|
|
524
|
+
// ---- HostUI: presentation -------------------------------------------------------------------------
|
|
525
|
+
/** The screen that was shown stays painted until the runtime drops it — one at a time: the
|
|
526
|
+
* runtime lands a transition before it starts the next one. */
|
|
527
|
+
private leave(above: boolean): void {
|
|
528
|
+
if (!this.root) return
|
|
529
|
+
this.leaving = this.root
|
|
530
|
+
this.leavingAbove = above
|
|
531
|
+
this.root = null
|
|
532
|
+
}
|
|
533
|
+
openView(kind: number, node: number, id: number, _viewName: string, _paramsJson: string, above: boolean): void {
|
|
534
|
+
this.destKind = kind
|
|
535
|
+
const next = kind === TREE_DEST_SCREEN ? this.nodes.get(node) ?? null : null
|
|
536
|
+
if (next !== this.root) this.leave(!above || next === null) // a screen next to a surface is above it
|
|
537
|
+
if (this.leaving === next) this.leaving = null
|
|
538
|
+
this.root = next
|
|
539
|
+
if (this.focused) this.blurInput() // a navigation dismisses the keyboard
|
|
540
|
+
this.dirty = true
|
|
541
|
+
this.onDestination?.(kind, id)
|
|
542
|
+
}
|
|
543
|
+
dropView(kind: number, node: number, id: number): void {
|
|
544
|
+
if (kind === TREE_DEST_SCREEN) {
|
|
545
|
+
const n = this.nodes.get(node) ?? null
|
|
546
|
+
if (n !== null && this.leaving === n) this.leaving = null
|
|
547
|
+
if (n !== null && this.root === n) this.root = null
|
|
548
|
+
}
|
|
549
|
+
this.dirty = true
|
|
550
|
+
this.onDropped?.(kind, id)
|
|
551
|
+
}
|
|
552
|
+
closeView(): void { this.destKind = -1; this.leave(true); this.dirty = true; this.onDestination?.(-1, 0) }
|
|
553
|
+
widgetOpen(node: number, _ownerKind: number, _ownerId: number, _ownerNode: number): void {
|
|
554
|
+
const w = this.nodes.get(node)
|
|
555
|
+
if (w && !this.widgetRoots.includes(w)) this.widgetRoots.push(w)
|
|
556
|
+
this.dirty = true
|
|
557
|
+
}
|
|
558
|
+
widgetClose(node: number): void {
|
|
559
|
+
const w = this.nodes.get(node)
|
|
560
|
+
for (const g of this.gestures.values()) if (g.backdrop === w) g.backdrop = null
|
|
561
|
+
const i = w ? this.widgetRoots.indexOf(w) : -1
|
|
562
|
+
if (i >= 0) this.widgetRoots.splice(i, 1)
|
|
563
|
+
this.dirty = true
|
|
564
|
+
}
|
|
565
|
+
registerFont(url: string, fontFamily: string, weight: number, style: number, onComplete: () => void, onReject: (err: string) => void): void {
|
|
566
|
+
this.fontsPending++
|
|
567
|
+
this.host.loadFont(fontFamily, url, { weight, style: style === 2 ? "italic" : "normal" })
|
|
568
|
+
.then(() => { this.invalidateText(); onComplete() }, (e) => onReject(String(e)))
|
|
569
|
+
.finally(() => { this.fontsPending-- })
|
|
570
|
+
}
|
|
571
|
+
/** A late face changes metrics without touching any style: re-measure every text leaf. */
|
|
572
|
+
private invalidateText(): void {
|
|
573
|
+
for (const n of this.nodes.values()) {
|
|
574
|
+
n.wrapCache = undefined
|
|
575
|
+
if (n.measured && n.type !== "image" && this.M._nodeHasMeasure(n.id)) this.M._markNodeDirty(n.id)
|
|
576
|
+
}
|
|
577
|
+
this.dirty = true
|
|
578
|
+
}
|
|
579
|
+
showToast(message: string, duration: number): void { this.lastToast = message; this.options.onToast?.(message, duration) }
|
|
580
|
+
getButtonPressed(node: number): boolean { return this.pressed.has(node) }
|
|
581
|
+
getTextValue(node: number): string { return this.nodes.get(node)?.value ?? "" }
|
|
582
|
+
getBoundingRect(node: number): ArrayLike<number> | null {
|
|
583
|
+
const r = this.findRect(node)
|
|
584
|
+
return r ? [r.x, r.y, r.width, r.height] : null
|
|
585
|
+
}
|
|
586
|
+
longPressHandler(pointerId: number, _node: number): void {
|
|
587
|
+
const g = this.gestures.get(pointerId)
|
|
588
|
+
if (g) g.swallowClick = true
|
|
589
|
+
}
|
|
590
|
+
vlistScroll(node: number, scrollY: number, _animated: boolean): void {
|
|
591
|
+
const n = this.nodes.get(node)
|
|
592
|
+
if (n) n.scrollOffset = scrollY
|
|
593
|
+
this.dirty = true
|
|
594
|
+
}
|
|
595
|
+
/** "select" / "content": the pages are re-read from the runtime's pulls when the frame is built;
|
|
596
|
+
* nothing is in flight then (the runtime lands a pager's transition before either). */
|
|
597
|
+
pagerCommand(node: number, _cmd: string, _animated: boolean): void {
|
|
598
|
+
this.pagerLeaving.delete(node)
|
|
599
|
+
this.dirty = true
|
|
600
|
+
}
|
|
601
|
+
/** `page` is the top of the current tab now: the page shown there stays shown, under it or over
|
|
602
|
+
* it, until the runtime drops it. */
|
|
603
|
+
pagerShowPage(node: number, page: number, above: boolean): void {
|
|
604
|
+
const next = this.nodes.get(page), shown = this.pagerShown.get(node)
|
|
605
|
+
if (next) this.pagerShown.set(node, next)
|
|
606
|
+
if (shown && shown !== next) this.pagerLeaving.set(node, { page: shown, above: !above })
|
|
607
|
+
this.dirty = true
|
|
608
|
+
}
|
|
609
|
+
pagerDropPage(node: number, page: number): void {
|
|
610
|
+
if (this.pagerLeaving.get(node)?.page.id === page) this.pagerLeaving.delete(node)
|
|
611
|
+
this.dirty = true
|
|
612
|
+
}
|
|
613
|
+
pagerPageMounted(_page: number, _mounted: boolean): void { this.dirty = true }
|
|
614
|
+
refreshDone(_node: number): void {}
|
|
615
|
+
|
|
616
|
+
// ---- input: focus + values --------------------------------------------------------------------------
|
|
617
|
+
private rootOf(n: HNode): HNode { let r = n; while (r.parent) r = r.parent; return r }
|
|
618
|
+
private emitKeyboard(): void {
|
|
619
|
+
const h = this.keyboardHeight()
|
|
620
|
+
this.options.onKeyboard?.(h)
|
|
621
|
+
this.M._creatorWebKeyboardEvent(h, 0)
|
|
622
|
+
}
|
|
623
|
+
focusInput(id: number): void {
|
|
624
|
+
const n = this.nodes.get(id)
|
|
625
|
+
if (!n || !n.editable || this.focused === n) return
|
|
626
|
+
if (this.focused) this.blurInput()
|
|
627
|
+
this.focused = n
|
|
628
|
+
this.M._setNodeFocused(id, 1)
|
|
629
|
+
if (n.has(TREE_FLAG_FOCUS)) this.M._creatorWebNodeEvent(id, TREE_EVENT_FOCUS)
|
|
630
|
+
// the focused-root rule: only the root that owns the input re-flows above the keyboard band
|
|
631
|
+
this.M._creatorWebSetShrunkRoot(this.rootOf(n).id, this.keyboardHeight())
|
|
632
|
+
this.emitKeyboard()
|
|
633
|
+
this.dirty = true
|
|
634
|
+
}
|
|
635
|
+
blurInput(): void {
|
|
636
|
+
const n = this.focused
|
|
637
|
+
if (!n) return
|
|
638
|
+
this.focused = null
|
|
639
|
+
this.M._setNodeFocused(n.id, 0)
|
|
640
|
+
if (n.has(TREE_FLAG_BLUR)) this.M._creatorWebNodeEvent(n.id, TREE_EVENT_BLUR)
|
|
641
|
+
this.M._creatorWebSetShrunkRoot(0, 0)
|
|
642
|
+
this.emitKeyboard()
|
|
643
|
+
this.dirty = true
|
|
644
|
+
}
|
|
645
|
+
/** A value typed by the user: onChange fires (a code-set value comes through setPropertyString). */
|
|
646
|
+
setInputValue(id: number, value: string): void {
|
|
647
|
+
const n = this.nodes.get(id)
|
|
648
|
+
if (!n) return
|
|
649
|
+
n.value = value
|
|
650
|
+
if (n.has(TREE_FLAG_CHANGE)) this.withStr(value, (p) => this.M._creatorWebNodeEventStr(id, TREE_EVENT_CHANGE, p))
|
|
651
|
+
if (this.M._nodeHasMeasure(id)) this.M._markNodeDirty(id)
|
|
652
|
+
this.dirty = true
|
|
653
|
+
}
|
|
654
|
+
/** The return key on the focused input: onSubmit with its value, then the keyboard goes. */
|
|
655
|
+
submitInput(): boolean {
|
|
656
|
+
const n = this.focused
|
|
657
|
+
if (!n) return false
|
|
658
|
+
if (n.has(TREE_FLAG_SUBMIT)) this.withStr(n.value, (p) => this.M._creatorWebNodeEventStr(n.id, TREE_EVENT_SUBMIT, p))
|
|
659
|
+
if (this.focused === n) this.blurInput()
|
|
660
|
+
return true
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ---- input: scrolling ---------------------------------------------------------------------------------
|
|
664
|
+
private scrollMax(n: HNode): number {
|
|
665
|
+
const rect = this.findRect(n.id) ?? n.layoutRect ?? { width: 0, height: 0 }
|
|
666
|
+
const viewport = n.scrollAxis === "x" ? rect.width : rect.height
|
|
667
|
+
const content = n.scrollAxis === "x" ? this.M._getContentWidth(n.id) : this.M._getContentHeight(n.id)
|
|
668
|
+
return Math.max(0, content - viewport)
|
|
669
|
+
}
|
|
670
|
+
/** Scroll a scrollable by `delta` px along its axis (clamped); onScroll / onOverscroll fire. */
|
|
671
|
+
scrollBy(id: number, delta: number): boolean {
|
|
672
|
+
const n = this.nodes.get(id)
|
|
673
|
+
if (!n || !n.scrollable) return false
|
|
674
|
+
const max = this.scrollMax(n)
|
|
675
|
+
const desired = n.scrollOffset + delta
|
|
676
|
+
const next = Math.min(max, Math.max(0, desired))
|
|
677
|
+
const over = desired < 0 ? desired : desired > max ? desired - max : 0
|
|
678
|
+
if (over !== 0 && n.has(TREE_FLAG_OVERSCROLL)) this.M._creatorWebNodeEventNum(id, TREE_EVENT_OVERSCROLL, over)
|
|
679
|
+
if (next === n.scrollOffset) return false
|
|
680
|
+
n.scrollOffset = next
|
|
681
|
+
if (n.type === "vlist" && this.M._vlistExists(id)) this.M._vlistOnScroll(id, next)
|
|
682
|
+
if (n.has(TREE_FLAG_SCROLL)) this.M._creatorWebNodeScroll(id, next)
|
|
683
|
+
this.dirty = true
|
|
684
|
+
return true
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ---- input: pointers (the tgfx renderer's routing, over the render tree) -----------------------------------
|
|
688
|
+
private topInteractive(x: number, y: number): HNode | null {
|
|
689
|
+
const layers = this.frame()
|
|
690
|
+
for (let i = layers.length - 1; i >= 0; i--) {
|
|
691
|
+
const hit = hitTest(layers[i].tree, x, y, (n) => this.nodes.get(n.id)?.interactive ?? false)
|
|
692
|
+
if (hit) return this.nodes.get(hit.id) ?? null
|
|
693
|
+
if (layers[i].backdrop) return null
|
|
694
|
+
}
|
|
695
|
+
return null
|
|
696
|
+
}
|
|
697
|
+
private setPressed(n: HNode, pressed: boolean): void {
|
|
698
|
+
if (pressed) this.pressed.add(n.id); else this.pressed.delete(n.id)
|
|
699
|
+
if (this.nodes.get(n.id) === n) this.M._setNodePressed(n.id, pressed ? 1 : 0)
|
|
700
|
+
this.dirty = true
|
|
701
|
+
}
|
|
702
|
+
// ---- input: the mouse cursor (hover) — the tgfx renderer's tickHover over the render tree ----------------
|
|
703
|
+
private hoverX = 0
|
|
704
|
+
private hoverY = 0
|
|
705
|
+
private hoverInside = false
|
|
706
|
+
private hoverMoved = false
|
|
707
|
+
/** The cursor's position (logical px), button or not. Only stored: `tickHover` resolves the node
|
|
708
|
+
* under it once per frame and hands it to the runtime (emitHover → `$hovered`, onMouseEnter). */
|
|
709
|
+
pointerHover(x: number, y: number): void { this.hoverX = x; this.hoverY = y; this.hoverInside = true; this.hoverMoved = true }
|
|
710
|
+
/** The cursor left the surface: nothing is hovered. */
|
|
711
|
+
pointerLeave(): void { if (!this.hoverInside) return; this.hoverInside = false; this.hoverMoved = true }
|
|
712
|
+
/** Whether this runtime module routes hover (the export landed with the 2026-09-27 rebuild). */
|
|
713
|
+
get hasHover(): boolean { return typeof this.M._creatorWebHover === "function" }
|
|
714
|
+
/** Once per frame, after layout: the topmost interactive node under the cursor → the runtime.
|
|
715
|
+
* Resolved when the cursor moved or the frame changed under a still cursor, never otherwise. */
|
|
716
|
+
tickHover(): void {
|
|
717
|
+
if (!this.hasHover) return
|
|
718
|
+
if (!this.hoverMoved && !this.dirty) return
|
|
719
|
+
this.hoverMoved = false
|
|
720
|
+
const node = this.hoverInside ? this.topInteractive(this.hoverX, this.hoverY) : null
|
|
721
|
+
this.M._creatorWebHover(node?.id ?? 0, this.hoverX, this.hoverY)
|
|
722
|
+
}
|
|
723
|
+
pointerDown(fingerId: number, x: number, y: number): void {
|
|
724
|
+
const M = this.M
|
|
725
|
+
if (M._creatorWeb2dSceneOpen()) M._creatorWeb2dEmitPointer(0, fingerId, x, y)
|
|
726
|
+
const g: Gesture = { node: null, scroll: null, backdrop: null, edit: null, global: false, dismiss: true, lastX: x, lastY: y, acc: 0, scrolling: false, swallowClick: false }
|
|
727
|
+
this.gestures.set(fingerId, g)
|
|
728
|
+
const layers = this.frame()
|
|
729
|
+
// an editable takes the press (focus), a modal backdrop blocks below
|
|
730
|
+
for (let i = layers.length - 1; i >= 0; i--) {
|
|
731
|
+
const e = hitTest(layers[i].tree, x, y, (n) => this.nodes.get(n.id)?.editable ?? false)
|
|
732
|
+
if (e) { const n = this.nodes.get(e.id)!; this.focusInput(n.id); g.edit = n; g.dismiss = false; return }
|
|
733
|
+
if (layers[i].backdrop) break
|
|
734
|
+
}
|
|
735
|
+
for (let i = layers.length - 1; i >= 0; i--) {
|
|
736
|
+
const layer = layers[i]
|
|
737
|
+
const inter = hitTest(layer.tree, x, y, (n) => this.nodes.get(n.id)?.interactive ?? false)
|
|
738
|
+
const scrollHit = hitTest(layer.tree, x, y, (n) => !!n.scroll && n.scroll.content > (n.scroll.axis === "x" ? n.rect.width : n.rect.height) + 0.5)
|
|
739
|
+
const scroll = scrollHit ? this.nodes.get(scrollHit.id) ?? null : null
|
|
740
|
+
if (inter) {
|
|
741
|
+
const n = this.nodes.get(inter.id)!
|
|
742
|
+
g.node = n
|
|
743
|
+
g.scroll = scroll
|
|
744
|
+
g.dismiss = n.type !== "button" // a control keeps the keyboard up
|
|
745
|
+
this.setPressed(n, true)
|
|
746
|
+
M._creatorWebNodeTouchStart(n.id, fingerId, x, y)
|
|
747
|
+
return
|
|
748
|
+
}
|
|
749
|
+
if (scroll) { g.scroll = scroll; return }
|
|
750
|
+
if (layer.backdrop) {
|
|
751
|
+
const r = layer.tree.rect
|
|
752
|
+
const insideBody = x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
|
|
753
|
+
g.backdrop = insideBody ? null : this.nodes.get(layer.tree.id) ?? null
|
|
754
|
+
return
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
g.global = true
|
|
758
|
+
M._creatorWebSceneTouchStart(fingerId, x, y)
|
|
759
|
+
}
|
|
760
|
+
pointerMove(fingerId: number, x: number, y: number): void {
|
|
761
|
+
const g = this.gestures.get(fingerId)
|
|
762
|
+
if (!g) return
|
|
763
|
+
const M = this.M
|
|
764
|
+
if (M._creatorWeb2dSceneOpen()) M._creatorWeb2dEmitPointer(1, fingerId, x, y)
|
|
765
|
+
const dx = x - g.lastX, dy = y - g.lastY
|
|
766
|
+
g.lastX = x; g.lastY = y
|
|
767
|
+
g.acc += Math.abs(dx) + Math.abs(dy)
|
|
768
|
+
if (g.edit) return
|
|
769
|
+
if (!g.scrolling && g.scroll && g.acc > SCROLL_THRESHOLD) {
|
|
770
|
+
g.scrolling = true
|
|
771
|
+
if (g.node) this.setPressed(g.node, false)
|
|
772
|
+
}
|
|
773
|
+
if (g.scrolling && g.scroll) { this.scrollBy(g.scroll.id, g.scroll.scrollAxis === "x" ? -dx : -dy); return }
|
|
774
|
+
M._creatorWebTouchMove(fingerId, x, y, dx, dy)
|
|
775
|
+
}
|
|
776
|
+
pointerUp(fingerId: number, x: number, y: number): void {
|
|
777
|
+
const g = this.gestures.get(fingerId)
|
|
778
|
+
if (!g) return
|
|
779
|
+
this.gestures.delete(fingerId)
|
|
780
|
+
const M = this.M
|
|
781
|
+
if (M._creatorWeb2dSceneOpen()) M._creatorWeb2dEmitPointer(2, fingerId, x, y)
|
|
782
|
+
const dx = x - g.lastX, dy = y - g.lastY
|
|
783
|
+
M._creatorWebTouchEnd(fingerId, x, y, dx, dy)
|
|
784
|
+
if (g.edit) return
|
|
785
|
+
if (g.scrolling && g.scroll && g.scroll.has(TREE_FLAG_SCROLL_RELEASE)) M._creatorWebNodeEvent(g.scroll.id, TREE_EVENT_SCROLL_RELEASE)
|
|
786
|
+
if (g.dismiss && !g.scrolling && this.focused) this.blurInput()
|
|
787
|
+
if (g.node) {
|
|
788
|
+
this.setPressed(g.node, false)
|
|
789
|
+
if (!g.scrolling && !g.swallowClick && this.topInteractive(x, y) === g.node && g.node.has(TREE_FLAG_CLICK)) M._creatorWebNodeClick(g.node.id, fingerId, x, y)
|
|
790
|
+
} else if (g.backdrop) {
|
|
791
|
+
if (!g.scrolling && g.backdrop.has(TREE_FLAG_OVERLAY_TAP)) M._creatorWebNodeEvent(g.backdrop.id, TREE_EVENT_OVERLAY_TAP)
|
|
792
|
+
} else if (g.global) {
|
|
793
|
+
if (!g.scrolling) M._creatorWebSceneTouchClick(fingerId, x, y)
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
/** The pointer left without a release (the browser's pointercancel): every gesture state goes. */
|
|
797
|
+
pointerCancel(fingerId: number): void {
|
|
798
|
+
const g = this.gestures.get(fingerId)
|
|
799
|
+
if (!g) return
|
|
800
|
+
this.gestures.delete(fingerId)
|
|
801
|
+
const M = this.M
|
|
802
|
+
if (M._creatorWeb2dSceneOpen()) M._creatorWeb2dEmitPointer(3, fingerId, g.lastX, g.lastY)
|
|
803
|
+
if (g.node) this.setPressed(g.node, false)
|
|
804
|
+
M._creatorWebTouchCancel(fingerId)
|
|
805
|
+
}
|
|
806
|
+
/** A wheel notch over (x, y): scrolls the overflowing scrollable under it (a mouse host). */
|
|
807
|
+
wheel(x: number, y: number, dx: number, dy: number): boolean {
|
|
808
|
+
const layers = this.frame()
|
|
809
|
+
for (let i = layers.length - 1; i >= 0; i--) {
|
|
810
|
+
const hit = hitTest(layers[i].tree, x, y, (n) => !!n.scroll && n.scroll.content > (n.scroll.axis === "x" ? n.rect.width : n.rect.height) + 0.5)
|
|
811
|
+
if (hit) { const n = this.nodes.get(hit.id); return n ? this.scrollBy(n.id, n.scrollAxis === "x" ? dx : dy) : false }
|
|
812
|
+
if (layers[i].backdrop) return false
|
|
813
|
+
}
|
|
814
|
+
return false
|
|
815
|
+
}
|
|
816
|
+
/** A long press: the runtime asks the node's listener; a handled one swallows the release's click. */
|
|
817
|
+
longPress(fingerId: number, x: number, y: number): void {
|
|
818
|
+
const g = this.gestures.get(fingerId)
|
|
819
|
+
if (g?.node && g.node.has(TREE_FLAG_LONG_PRESS)) this.M._creatorWebNodeLongPress(g.node.id, fingerId, x, y)
|
|
820
|
+
}
|
|
821
|
+
isVlist(n: HNode): boolean { return this.vlists.has(n) }
|
|
822
|
+
}
|