lecodes-viewer 0.19.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.
Files changed (103) hide show
  1. package/README.md +5 -0
  2. package/dist-embed/VERSION.json +5 -0
  3. package/dist-embed/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
  4. package/dist-embed/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  5. package/dist-embed/assets/basis-C64VHDVD.js +1 -0
  6. package/dist-embed/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  7. package/dist-embed/assets/createViewer2d-D12Wy3VF.js +1 -0
  8. package/dist-embed/assets/createViewerLite-CP-WHGLg.js +707 -0
  9. package/dist-embed/assets/creator-gl-BF2CQzeI.wasm +0 -0
  10. package/dist-embed/assets/creator-gl.js-B_0GrQRe.symbols +5379 -0
  11. package/dist-embed/assets/creator-ui-BX5Fvnbt.js +1 -0
  12. package/dist-embed/assets/creator-ui-CzKHLH2Q.wasm +0 -0
  13. package/dist-embed/assets/creator2d-BtQ-1_B0.wasm +0 -0
  14. package/dist-embed/assets/draco-BiISTFcR.js +118 -0
  15. package/dist-embed/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  16. package/dist-embed/assets/host-CWKB1WDd.js +162 -0
  17. package/dist-embed/assets/index-CIK8PnUA.js +2 -0
  18. package/dist-embed/assets/lit-DUE9rhY9.filamat +0 -0
  19. package/dist-embed/assets/loadCreatorUiNode-UAdhjRTd.js +1 -0
  20. package/dist-embed/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
  21. package/dist-embed/assets/particles-DxYDmI1O.filamat +0 -0
  22. package/dist-embed/assets/particles-quad-DwYo9yCK.filamat +0 -0
  23. package/dist-embed/assets/shadow-C9xJpqlY.filamat +0 -0
  24. package/dist-embed/assets/uberarchive-uXjRYa9p.bin +0 -0
  25. package/dist-embed/assets/unlit-pe4tFY5b.filamat +0 -0
  26. package/dist-embed/assets/unlit-transparent-D9OyaOBR.filamat +0 -0
  27. package/dist-embed/assets/video-eedvhMvS.filamat +0 -0
  28. package/dist-embed/assets/worker-Caf-yYEI.js +2 -0
  29. package/dist-embed/embed.html +48 -0
  30. package/dist-embed/embed.js +187 -0
  31. package/embed.html +48 -0
  32. package/package.json +35 -0
  33. package/src/assets/banner.png +0 -0
  34. package/src/assets/image2.png +0 -0
  35. package/src/assets/landing.mp4 +0 -0
  36. package/src/assets/neutral_ibl128.ktx +0 -0
  37. package/src/assets/neutral_ibl256.ktx +0 -0
  38. package/src/assets/output.mp4 +0 -0
  39. package/src/assets/output2.mp4 +0 -0
  40. package/src/assets/texture.png +0 -0
  41. package/src/assets/vue.svg +1 -0
  42. package/src/canvas/createCanvasHost.ts +2 -0
  43. package/src/createViewer.ts +103 -0
  44. package/src/embed.ts +515 -0
  45. package/src/host.ts +428 -0
  46. package/src/lite-test.ts +160 -0
  47. package/src/main.ts +77 -0
  48. package/src/sourcemapError.ts +2 -0
  49. package/src/ui/CuiProps.gen.ts +103 -0
  50. package/src/ui/UIScreen.ts +486 -0
  51. package/src/ui/createBareUI.ts +32 -0
  52. package/src/ui/createMeasureSystem.ts +220 -0
  53. package/src/ui/createNodeElement.ts +77 -0
  54. package/src/ui/createPagerSystem.ts +345 -0
  55. package/src/ui/createTouchSystem.ts +2 -0
  56. package/src/ui/createUI.ts +806 -0
  57. package/src/ui/creator-ui.js +14 -0
  58. package/src/ui/creator-ui.wasm +0 -0
  59. package/src/ui/defaultStyle.css +176 -0
  60. package/src/ui/layers.ts +9 -0
  61. package/src/ui/mountCanvasUI.ts +213 -0
  62. package/src/ui/nativeViewSystem.ts +91 -0
  63. package/src/ui/presentPlayer.ts +156 -0
  64. package/src/ui/props-apply.ts +403 -0
  65. package/src/ui/router.ts +2 -0
  66. package/src/ui/safeAreaSpec.ts +3 -0
  67. package/src/ui/shells.ts +37 -0
  68. package/src/ui/toasts.ts +2 -0
  69. package/src/ui/transitions.ts +250 -0
  70. package/src/ui/ui.d.ts +78 -0
  71. package/src/ui/vListSystem.ts +212 -0
  72. package/src/ui/viewScale.ts +2 -0
  73. package/src/utils/animate.ts +2 -0
  74. package/src/utils/createUtils.ts +2 -0
  75. package/src/utils/fetch.ts +3 -0
  76. package/src/utils/fonts.ts +2 -0
  77. package/src/utils/globals.ts +2 -0
  78. package/src/utils/imageNode.ts +260 -0
  79. package/src/utils/openFilePicker.ts +2 -0
  80. package/src/utils/svg.ts +2 -0
  81. package/src/utils/videoNode.ts +2 -0
  82. package/src/utils/ws.ts +2 -0
  83. package/src/viewer/createViewer.ts +993 -0
  84. package/src/viewer/creator-gl.js +14 -0
  85. package/src/viewer/creator-gl.js.symbols +5379 -0
  86. package/src/viewer/creator-gl.wasm +0 -0
  87. package/src/viewer/lit.filamat +0 -0
  88. package/src/viewer/mediaplayer.ts +3 -0
  89. package/src/viewer/particles-quad.filamat +0 -0
  90. package/src/viewer/particles.filamat +0 -0
  91. package/src/viewer/physics.ts +140 -0
  92. package/src/viewer/shadow.filamat +0 -0
  93. package/src/viewer/uberarchive.bin +0 -0
  94. package/src/viewer/unlit-transparent.filamat +0 -0
  95. package/src/viewer/unlit.filamat +0 -0
  96. package/src/viewer/validateGlb.ts +262 -0
  97. package/src/viewer/video.filamat +0 -0
  98. package/src/viewer/wasmCrashSymbolizer.ts +133 -0
  99. package/src/viewer2d/createViewer2d.ts +438 -0
  100. package/src/viewer2d/creator2d.js +14 -0
  101. package/src/viewer2d/creator2d.wasm +0 -0
  102. package/src/viewer2d/sprite2d.ts +80 -0
  103. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,806 @@
1
+ // @ts-ignore
2
+ import createModule from './creator-ui'
3
+ import wasmLib from './creator-ui.wasm?url'
4
+ import { getUrl } from "../utils/fetch"
5
+ import { createToastSystem } from "./toasts"
6
+ import { createTouchSystem } from "./createTouchSystem"
7
+ import { installKeyboardDismissGuard } from "lecodes-viewer-lite/src/ui/keyboardDismissGuard"
8
+ import { installTouchDefaultsGuard } from "lecodes-viewer-lite/src/ui/touchDefaultsGuard"
9
+ import { createTransitionSystem } from "./transitions"
10
+ import { createMeasureSystem, cssFontFamily, setDefaultFontFamily } from "./createMeasureSystem"
11
+ import { registerUIProps } from "./props-apply"
12
+ import defaultStyleRaw from './defaultStyle.css?raw'
13
+ import { UIScreen } from './UIScreen'
14
+ import { Router } from './router'
15
+ import { convertSvgToDataUri, isSvgSrc } from '../utils/svg'
16
+ import { createVListSystem } from './vListSystem'
17
+ import { createPagerSystem } from './createPagerSystem'
18
+ import { createNativeViewSystem } from './nativeViewSystem'
19
+ import { createPresentPlayer } from './presentPlayer'
20
+ import { getVideoElement } from '../utils/videoNode'
21
+ import { DEFAULT_SAFE_AREA, orientedSafeArea, type SafeAreaSpec } from './safeAreaSpec'
22
+ const LAYER_DEFAULT = 256
23
+ // creator-ui setSafeAreaBars bitmask, in the setSafePaddings arg order.
24
+ const EDGE_BIT = { top: 1, right: 2, bottom: 4, left: 8 } as const
25
+
26
+ export const createUI = async (root: HTMLElement, canvasHost?: { surfaceToDataUrl(surfaceId: number): string | null }) => {
27
+
28
+ const wasmBinary = await fetch(wasmLib).then(resp => resp.arrayBuffer())
29
+ // const nodeMap = new Map<number, HTMLElement>()
30
+ // const weakMap = new WeakMap<HTMLElement, number>()
31
+
32
+ // const nodeOnLayoutEventsMap = new Map<number, ((layout: any) => void)[]>()
33
+
34
+ const module = await createModule({ wasmBinary })
35
+
36
+ const styleBlock = document.createElement("style")
37
+ styleBlock.innerHTML = defaultStyleRaw
38
+ root.append(styleBlock)
39
+
40
+ // Screens (positive ids) and widgets (negative ids) live in id-keyed Maps so that a
41
+ // closed screen/widget can be reclaimed. Ids come from monotonic counters — they stay
42
+ // stable and are never reused, preserving the C++ NodeContext.screenId contract.
43
+ const screens = new Map<number, UIScreen>()
44
+ let nextScreenId = 0
45
+ const widgets = new Map<number, UIScreen>()
46
+ let widgetsId = 1
47
+
48
+ const getNode = (screenId: number, nodeId: number) =>
49
+ (screenId < 0 ? widgets : screens).get(screenId)?.nodeMap.get(nodeId)
50
+
51
+ const getScreen = (screenId: number) =>
52
+ (screenId < 0 ? widgets : screens).get(screenId)
53
+
54
+ // Single teardown hook: every permanent remove() leaves the registry, making the
55
+ // screen/widget GC-eligible. Router history uses removeFromHTML() (not remove()), so
56
+ // history screens stay registered and restorable.
57
+ const deregister = (screenId: number) => {
58
+ if (screenId < 0) widgets.delete(screenId)
59
+ else screens.delete(screenId)
60
+ }
61
+
62
+ const measureSystem = createMeasureSystem(getNode)
63
+ const func = module.addFunction(measureSystem.measure, "jiififi")
64
+ module._setMeasureFunc(func)
65
+
66
+ const vlistSystem = createVListSystem(module)
67
+ // Assigned once `newScreen` exists (the screen host / pager build each page as its own layout
68
+ // root). Referenced only from runtime paths (openScreen/newScreen/showWidget), which run after
69
+ // createUI returns.
70
+ let pagerSystem: ReturnType<typeof createPagerSystem>
71
+
72
+ let currentScreen: UIScreen | null = null
73
+
74
+ const addStringHandler = (key: string, callback: (screenId: number, nodePtr: number, str: number) => void) => {
75
+ const maxLen = (key as string).length + 1;
76
+ const keyPtr = module._malloc(maxLen);
77
+ module.stringToUTF8(key, keyPtr, maxLen);
78
+
79
+ const func = module.addFunction(callback, "viii")
80
+
81
+ module._addStringHandler(keyPtr, func);
82
+ module._free(keyPtr)
83
+ }
84
+ const addFloatHandler = (key: string, callback: (screenId: number, nodePtr: number, val: number) => void) => {
85
+ const maxLen = key.length + 1;
86
+ const valuePtr = module._malloc(maxLen);
87
+ module.stringToUTF8(key, valuePtr, maxLen);
88
+ const func = module.addFunction(callback, "viif")
89
+ module._addFloatHandler(valuePtr, func)
90
+ module._free(valuePtr)
91
+ }
92
+ const addUIntHandler = (key: string, callback: (screenId: number, nodePtr: number, val: number) => void) => {
93
+ const maxLen = key.length + 1;
94
+ const valuePtr = module._malloc(maxLen);
95
+ module.stringToUTF8(key, valuePtr, maxLen);
96
+ const func = module.addFunction(callback, "viii")
97
+ module._addUIntHandler(valuePtr, func)
98
+ module._free(valuePtr)
99
+ }
100
+ const addResetHandler = (key: string, callback: (screenId: number, nodePtr: number) => void) => {
101
+ const maxLen = key.length + 1;
102
+ const valuePtr = module._malloc(maxLen);
103
+ module.stringToUTF8(key, valuePtr, maxLen);
104
+ const func = module.addFunction(callback, "vii")
105
+ module._addResetHandler(valuePtr, func)
106
+ module._free(valuePtr)
107
+ }
108
+
109
+ const addMatrixHandler = (callback: (screenId: number, nodePtr: number, matrixPtr: number) => void) => {
110
+ const func = module.addFunction(callback, "viii")
111
+ module._setMatrixHandler(func)
112
+ }
113
+ const addTransformOriginHandler = (callback: (screenId: number, nodePtr: number, x: number, y: number, xPercent: number, yPercent: number) => void) => {
114
+ const func = module.addFunction(callback, "viiffii")
115
+ module._setTransformOriginHandler(func)
116
+ }
117
+
118
+ // Property application (every set + reset dispatch from the core) lives in props-apply.ts,
119
+ // enum-exhaustive against the generated registry surface (CuiProps.gen.ts).
120
+ registerUIProps({ module, root, getNode, getScreen, addStringHandler, addFloatHandler, addUIntHandler, addResetHandler, addMatrixHandler, addTransformOriginHandler })
121
+
122
+ // Style keys are a tiny, fixed set; intern their WASM pointers once instead of
123
+ // mallocing/freeing on every applyStyle call. The cache lives for the module
124
+ // lifetime by design (not a leak).
125
+ const keyPtrCache = new Map<string, number>()
126
+ const internKey = (key: string) => {
127
+ let ptr = keyPtrCache.get(key)!
128
+ if (ptr === undefined) {
129
+ const maxLen = key.length + 1
130
+ ptr = module._malloc(maxLen)
131
+ module.stringToUTF8(key, ptr, maxLen)
132
+ keyPtrCache.set(key, ptr)
133
+ }
134
+ return ptr
135
+ }
136
+
137
+ const applyStyle = (layer: number, yogaId: number, key: string, value: number | string | boolean) => {
138
+ const keyPtr = internKey(key)
139
+
140
+ // A reactive/conditional style can resolve to undefined (e.g. `active ? "#fff" : undefined`).
141
+ // Treat it as a reset, exactly like an explicit null — otherwise `typeof undefined` falls into
142
+ // the string `default` branch below and lengthBytesUTF8(undefined) throws. Matches viewer-lite's
143
+ // applyStyleValue (createLiteUI.ts), which clears the prop for null/undefined alike.
144
+ // Layer-scoped (_resetPropExt, not _resetProp): a null inside a `$class`/orientation block
145
+ // must land in THAT layer — reset semantics only while it's active — never erase the base
146
+ // entry. The core keeps erase semantics when `layer` is the default (256).
147
+ if (value === undefined) {
148
+ module._resetPropExt(layer, yogaId, keyPtr)
149
+ return
150
+ }
151
+
152
+ switch(typeof value) {
153
+ case "number":
154
+ module._setFloatExt(layer, yogaId, keyPtr, value)
155
+ break
156
+ case "boolean":
157
+ module._setFloatExt(layer, yogaId, keyPtr, value? 1: 0)
158
+ break
159
+ case "object":
160
+ if (value === null) {
161
+ module._resetPropExt(layer, yogaId, keyPtr)
162
+ } else if ("_id" in value) {
163
+ applyStyle(layer, yogaId, key, getUrl((value as any)._id))
164
+ } else if ("svg" in value) {
165
+ applyStyle(layer, yogaId, key, convertSvgToDataUri((value as any).svg))
166
+ } else {
167
+ const layer = module._getLayer(yogaId, keyPtr)
168
+ transitionSystem.applyStyleTransition(yogaId, value, layer)
169
+ }
170
+ break
171
+ default:
172
+ const maxLen = module.lengthBytesUTF8(value) + 1;
173
+ const valuePtr = module._malloc(maxLen);
174
+ module.stringToUTF8(value, valuePtr, maxLen);
175
+
176
+ module._setStringExt(layer, yogaId, keyPtr, valuePtr)
177
+ module._free(valuePtr)
178
+ }
179
+ }
180
+
181
+ // Layout size of the root. Unlike getBoundingClientRect, client sizes ignore CSS transforms,
182
+ // so a host's appear animation (e.g. the editor simulator's scale/translate pop) can't poison
183
+ // a screen/widget laid out while it plays.
184
+ const rootSize = (): [number, number] => [root.clientWidth, root.clientHeight]
185
+
186
+ // ==== destinations (docs/navigation-presentable-plan.md — phase 2, web reference host) ========
187
+ // Exactly one destination (screen / scene / native view / fullscreen video) is visible at a
188
+ // time. The scene canvas is no longer an always-on bottom layer: presenting a scene shows its
189
+ // canvas and activates the engine; covering it closes the engine (rendering pauses) and hides
190
+ // the canvas. Screens stay position:absolute siblings, so during transitions they naturally
191
+ // paint above the in-flow canvas (see presentPlayer's static rule).
192
+
193
+ type SceneHooks = { el: HTMLElement, open(sceneId: number): void, close(): void }
194
+ let sceneHost: { scene3d?: SceneHooks, scene2d?: SceneHooks } = {}
195
+ // Wired by host.ts once the engines exist (same late-wiring seam as _setCreator).
196
+ const _setSceneHost = (hooks: { scene3d?: SceneHooks, scene2d?: SceneHooks }) => { sceneHost = hooks }
197
+
198
+ const nativeViews = createNativeViewSystem()
199
+ const presentPlayer = createPresentPlayer(root)
200
+
201
+ type Dest =
202
+ | { kind: "screen", node: any, screen: UIScreen }
203
+ | { kind: "scene3d", desc: any }
204
+ | { kind: "scene2d", desc: any }
205
+ | { kind: "native", desc: any, cell?: HTMLElement }
206
+ | { kind: "videoView", desc: any, wrapper?: HTMLElement, restore?: { parent: HTMLElement | null, next: Node | null, cssText: string } }
207
+
208
+ let current: Dest | null = null
209
+
210
+ const newScreen = (node: any) => {
211
+ const screen = new UIScreen(module, touchSystem, applyStyle, classLayer, vlistSystem, pagerSystem, deregister, canvasHost, nativeViews)
212
+ const id = nextScreenId++
213
+ screens.set(id, screen)
214
+ screen.init(id, node)
215
+ return screen
216
+ }
217
+
218
+ // Built here (not at the top) because each page is hosted by its own `newScreen` layout root.
219
+ pagerSystem = createPagerSystem(module, { newScreen })
220
+
221
+ const makeDest = (view: any): Dest => {
222
+ switch (view?.type) {
223
+ case "scene3d":
224
+ case "scene2d": return { kind: view.type, desc: view }
225
+ case "native": return { kind: "native", desc: view }
226
+ case "videoView": return { kind: "videoView", desc: view }
227
+ default: return { kind: "screen", node: view, screen: newScreen(view) }
228
+ }
229
+ }
230
+
231
+ // Presentation lifecycle arrays: screens carry ol/cl on the node; descriptors on their `_p`.
232
+ const fireLifecycle = (dest: Dest, key: "ol" | "cl") => {
233
+ const owner = dest.kind === "screen" ? dest.node : dest.desc._p
234
+ for (const callback of owner?.[key] ?? []) callback()
235
+ }
236
+
237
+ // Attached widgets (widget.attachTo(owner)) show only while their owner is the current
238
+ // destination. Owner identity: the screen node object itself, or the scene/native descriptor.
239
+ const ownerIsCurrent = (ownerDesc: any) =>
240
+ current !== null && (current.kind === "screen" ? current.node === ownerDesc : current.desc === ownerDesc)
241
+ const updateAttachedWidgets = () => {
242
+ for (const widget of widgets.values()) {
243
+ if (widget.ownerDesc === undefined) continue
244
+ const visible = ownerIsCurrent(widget.ownerDesc)
245
+ widget.root.style.display = visible ? "" : "none"
246
+ if (widget.overlayElement) widget.overlayElement.style.display = visible ? "" : "none"
247
+ }
248
+ }
249
+
250
+ // Mounts a destination and returns the element the transition moves (scenes are static — the
251
+ // canvas never animates, mirroring the Android SurfaceView rule; screens animate over it).
252
+ const mountDest = (dest: Dest): { el: HTMLElement, static?: boolean } | undefined => {
253
+ if (dest.kind === "screen") {
254
+ root.appendChild(dest.screen.root)
255
+ dest.screen.applyLayout(cachedWidth, cachedHeight)
256
+ fireLifecycle(dest, "ol")
257
+ dest.screen.setPagersActive(true) // open embedded pages (after the screen's onOpen)
258
+ return { el: dest.screen.root }
259
+ }
260
+ if (dest.kind === "scene3d" || dest.kind === "scene2d") {
261
+ const hooks = sceneHost[dest.kind]
262
+ if (!hooks) {
263
+ console.error(`[le.codes] openView: this host has no ${dest.kind === "scene3d" ? "3D" : "2D"} engine`)
264
+ return undefined
265
+ }
266
+ hooks.open(dest.desc.sceneId) // the host activates the engine — the SDK no longer does
267
+ hooks.el.style.display = "block"
268
+ fireLifecycle(dest, "ol")
269
+ return { el: hooks.el, static: true }
270
+ }
271
+ if (dest.kind === "native") {
272
+ const cell = dest.cell ??= document.createElement("div")
273
+ cell.style.cssText = "position:absolute;inset:0;overflow:hidden"
274
+ nativeViews.mountInto(dest.desc._p, cell)
275
+ root.appendChild(cell)
276
+ fireLifecycle(dest, "ol")
277
+ return { el: cell }
278
+ }
279
+ // videoView — fullscreen promotion of the player's own <video> element: reparent it into a
280
+ // fullscreen wrapper (playback continues) and put it back on dismiss.
281
+ const videoEl = getVideoElement(dest.desc.node)
282
+ dest.restore = { parent: videoEl.parentElement, next: videoEl.nextSibling, cssText: videoEl.style.cssText }
283
+ const wrapper = dest.wrapper ??= document.createElement("div")
284
+ wrapper.style.cssText = "position:absolute;inset:0;background:#000"
285
+ videoEl.style.cssText = "position:absolute;inset:0;width:100%;height:100%;object-fit:contain"
286
+ wrapper.appendChild(videoEl)
287
+ root.appendChild(wrapper)
288
+ fireLifecycle(dest, "ol")
289
+ return { el: wrapper }
290
+ }
291
+
292
+ const destElement = (dest: Dest): { el: HTMLElement, static?: boolean } | undefined => {
293
+ if (dest.kind === "screen") return { el: dest.screen.root }
294
+ if (dest.kind === "scene3d" || dest.kind === "scene2d") {
295
+ const hooks = sceneHost[dest.kind]
296
+ return hooks ? { el: hooks.el, static: true } : undefined
297
+ }
298
+ if (dest.kind === "native") return dest.cell ? { el: dest.cell } : undefined
299
+ return dest.wrapper ? { el: dest.wrapper } : undefined
300
+ }
301
+
302
+ // Physical dismount, after the transition. `free` releases a screen for good unless the router
303
+ // still holds it in history (then it only detaches, staying restorable).
304
+ const unmountDest = (dest: Dest, free: boolean) => {
305
+ if (dest.kind === "screen") {
306
+ if (free && !router.stack.includes(dest)) dest.screen.remove()
307
+ else dest.screen.removeFromHTML()
308
+ return
309
+ }
310
+ if (dest.kind === "scene3d" || dest.kind === "scene2d") {
311
+ const hooks = sceneHost[dest.kind]
312
+ if (hooks) { hooks.close(); hooks.el.style.display = "none" } // rendering pauses while covered
313
+ return
314
+ }
315
+ if (dest.kind === "native") {
316
+ dest.cell?.remove() // the live instance survives (keeps its state) — only the mount point goes
317
+ return
318
+ }
319
+ const videoEl = getVideoElement(dest.desc.node)
320
+ if (dest.restore) {
321
+ videoEl.style.cssText = dest.restore.cssText
322
+ if (dest.restore.parent) dest.restore.parent.insertBefore(videoEl, dest.restore.next)
323
+ else videoEl.remove()
324
+ }
325
+ dest.wrapper?.remove()
326
+ }
327
+
328
+ // Router history leaving for good (replace/pop/re-init). If the dest is mid-exit-animation,
329
+ // defer the free to the transition's onDone.
330
+ let animatingPrev: Dest | null = null
331
+ let animatingPrevFree = false
332
+ const freeDest = (dest: Dest) => {
333
+ if (dest === animatingPrev) { animatingPrevFree = true; return }
334
+ if (dest.kind === "screen") dest.screen.remove()
335
+ }
336
+
337
+ const show = (next: Dest | null, transition: string | object | null | undefined, freePrev: boolean) => {
338
+ presentPlayer.finish() // snap any in-flight transition (dismounts its outgoing side)
339
+ const prev = current
340
+ if (prev === next) return
341
+ if (prev) fireLifecycle(prev, "cl") // onClose fires at transition start (native parity)
342
+
343
+ current = next
344
+ currentScreen = next?.kind === "screen" ? next.screen : null
345
+ const incoming = next ? mountDest(next) : undefined
346
+ updateAttachedWidgets()
347
+
348
+ if (!prev) return
349
+ animatingPrev = prev
350
+ animatingPrevFree = false
351
+ presentPlayer.play({
352
+ incoming,
353
+ outgoing: destElement(prev),
354
+ transition,
355
+ onDone: () => {
356
+ const wantFree = freePrev || animatingPrevFree
357
+ animatingPrev = null
358
+ unmountDest(prev, wantFree)
359
+ },
360
+ })
361
+ }
362
+
363
+ // ---- the bridge surface ----------------------------------------------------------------------
364
+
365
+ const openView = (view: any, transition?: string | object) => {
366
+ if (router.show) routerHide()
367
+ const [ width, height ] = rootSize()
368
+ _onResize(width, height)
369
+ show(makeDest(view), transition ?? "none", true)
370
+ }
371
+ const closeView = () => {
372
+ show(null, "none", true)
373
+ }
374
+
375
+ // Legacy aliases — previously compiled bundles keep running, with the new one-destination
376
+ // semantics (an openScreen over a scene now replaces it instead of overlaying it).
377
+ const openScreen = (screenObj: any) => openView(screenObj, "none")
378
+
379
+ let onRouterChange: (view: any) => void
380
+ // The shared Router class doesn't thread a transition through onScreen — stash it around the call.
381
+ let pendingTransition: string | object | null = null
382
+ const router = new Router<Dest>((dest) => {
383
+ const transition = pendingTransition
384
+ pendingTransition = null
385
+ show(dest, transition, false) // router history detaches, never frees (freeDest handles that)
386
+ onRouterChange?.(dest.kind === "screen" ? dest.node : dest.desc)
387
+ }, freeDest)
388
+
389
+ const routerOpen = (homePage: any, onChange: (view: any) => void) => {
390
+ if (current) {
391
+ // A current router destination is freed by the stack loop below (detach only here to avoid
392
+ // a double free); anything else is closed outright.
393
+ fireLifecycle(current, "cl")
394
+ unmountDest(current, !router.stack.includes(current))
395
+ current = null
396
+ currentScreen = null
397
+ }
398
+ // Re-initializing the router: free any leftover history from a previous session.
399
+ for (const dest of router.stack) freeDest(dest)
400
+ router.stack = []
401
+
402
+ const [ width, height ] = rootSize()
403
+ _onResize(width, height)
404
+
405
+ onRouterChange = onChange
406
+ router.init(makeDest(homePage))
407
+ }
408
+
409
+ const routerPush = (page: any, transition?: string | object) => {
410
+ pendingTransition = transition ?? "push"
411
+ router.push(makeDest(page))
412
+ }
413
+
414
+ const routerReplace = (page: any, transition?: string | object) => {
415
+ pendingTransition = transition ?? "fade"
416
+ router.replace(makeDest(page))
417
+ }
418
+
419
+ const routerPop = (to: number, transition?: string | object) => {
420
+ pendingTransition = transition ?? "pop"
421
+ router.pop(to)
422
+ }
423
+
424
+ const routerHide = () => {
425
+ router.hide()
426
+ if (current && router.stack.includes(current)) {
427
+ presentPlayer.finish()
428
+ fireLifecycle(current, "cl")
429
+ const detached = current
430
+ current = null
431
+ currentScreen = null
432
+ unmountDest(detached, false)
433
+ updateAttachedWidgets()
434
+ }
435
+ }
436
+
437
+ const routerRestore = () => {
438
+ router.restore()
439
+ }
440
+
441
+ const getTextValue = (nodeId: number): string => {
442
+ // Resolve the owning screen by id — an input can live inside a hosted page (its own layout
443
+ // root), not just the active screen.
444
+ const owner = getScreen(module._getScreenId(nodeId)) ?? currentScreen
445
+ const element = owner?.nodeMap.get(nodeId) as (HTMLInputElement | HTMLTextAreaElement)
446
+ if (!element) return ""
447
+ return element.value
448
+ }
449
+
450
+ // Absolute device-space rect [left, top, width, height] backing el.getBoundingClientRect().
451
+ // Read from the DOM rather than the yoga tree so scroll offsets, vlist windows, and in-flight
452
+ // transition transforms are all included for free; normalized against the app root's own rect
453
+ // (and its scale, when the editor preview shrinks the device frame) so values come back in
454
+ // logical px — the same space widget top/left position in.
455
+ const getBoundingClientRect = (nodeId: number): [number, number, number, number] | null => {
456
+ const owner = getScreen(module._getScreenId(nodeId)) ?? currentScreen
457
+ const element = owner?.nodeMap.get(nodeId)
458
+ if (!element || !element.isConnected) return null
459
+ const rect = element.getBoundingClientRect()
460
+ // an all-zero DOMRect means display:none — a screen parked in router history
461
+ if (rect.width === 0 && rect.height === 0 && rect.left === 0 && rect.top === 0) return null
462
+ const appRect = root.getBoundingClientRect()
463
+ const [displayWidth] = getDisplaySize()
464
+ const scale = displayWidth > 0 && appRect.width > 0 ? appRect.width / displayWidth : 1
465
+ return [
466
+ (rect.left - appRect.left) / scale, (rect.top - appRect.top) / scale,
467
+ rect.width / scale, rect.height / scale,
468
+ ]
469
+ }
470
+
471
+ const closeScreen = () => closeView()
472
+
473
+ const updateNode = (nodeId: number, prop: string, value: string | number) => {
474
+ const screenId = module._getScreenId(nodeId)
475
+ const screen = getScreen(screenId)
476
+ if (!screen) return
477
+ const element = screen.nodeMap.get(nodeId)
478
+ if (!element) return
479
+
480
+ switch (prop) {
481
+ case "text":
482
+ element.textContent = value as string
483
+ module._markNodeDirty(nodeId)
484
+ screen.recalc()
485
+ break
486
+ case "value":
487
+ (element as HTMLInputElement).value = value as string
488
+ // Auto-grow: a content-measured textarea re-measures on programmatic value sets too.
489
+ if (element instanceof HTMLTextAreaElement && module._nodeHasMeasure(nodeId)) {
490
+ module._markNodeDirty(nodeId)
491
+ screen.recalc()
492
+ }
493
+ break
494
+ case "src":
495
+ screen.updateImageSrc(element, value as any)
496
+ break
497
+ }
498
+ }
499
+
500
+ // Render only a sub-rectangle of an image's source texture — the atlas / spritesheet
501
+ // primitive. Coords are texture pixels (= CSS px on web, no density scaling). The crop is
502
+ // applied by swapping the <img> for a background-image <div> (see setImageSourceRect).
503
+ const setSourceRect = (nodeId: number, x: number, y: number, w: number, h: number) => {
504
+ getScreen(module._getScreenId(nodeId))?.setSourceRect(nodeId, x, y, w, h)
505
+ }
506
+
507
+ // Canvas.update() → repaint every live UIImage(canvas) node showing this baked surface with the
508
+ // re-rasterized pixels (draw-only, no relayout). The surface can back nodes on any screen/widget
509
+ // — including ones parked in router history — so sweep them all.
510
+ const refreshCanvasSurface = (surfaceId: number) => {
511
+ for (const screen of screens.values()) screen.refreshCanvasSurface(surfaceId)
512
+ for (const widget of widgets.values()) widget.refreshCanvasSurface(surfaceId)
513
+ }
514
+
515
+ // Fonts the user code registered, tracked so _destroy can remove them from the (process-wide)
516
+ // document.fonts set instead of letting them accumulate across runs.
517
+ const registeredFonts: FontFace[] = []
518
+
519
+ // Every cached yoga text measure goes stale when a face lands after layout (or the default
520
+ // font retargets): dirty the measure leaves everywhere — screens in history included — and
521
+ // lay the live surfaces out again.
522
+ const dirtyAllTextMeasures = () => {
523
+ for (const collection of [screens, widgets]) {
524
+ for (const screen of collection.values()) {
525
+ for (const id of screen.nodeMap.keys()) {
526
+ if (module._nodeHasMeasure(id)) module._markNodeDirty(id)
527
+ }
528
+ }
529
+ }
530
+ }
531
+ const relayoutAll = () => {
532
+ if (!cachedWidth || !cachedHeight) return
533
+ currentScreen?.applyLayout(cachedWidth, cachedHeight)
534
+ for (const widget of widgets.values()) widget.applyLayout(cachedWidth, cachedHeight)
535
+ }
536
+
537
+ const registerFont = (fontFamily: string, url: string, options: { weight?: number, style?: string }, onComplete: () => void, onReject: () => void) => {
538
+ const font = new FontFace(fontFamily, `url(${url})`, options as any)
539
+ // Added before load resolves: DOM text swaps the moment the bytes land (font-display: swap
540
+ // semantics), whether or not anything awaits the legacy completion callback.
541
+ document.fonts.add(font)
542
+ registeredFonts.push(font)
543
+ font.load().then(() => {
544
+ // Late arrival is the fallback path (boot faces are prefetched via the `// fonts:` header):
545
+ // re-measure + re-layout so yoga agrees with the swapped-in glyphs.
546
+ dirtyAllTextMeasures()
547
+ relayoutAll()
548
+ onComplete()
549
+ })
550
+ .catch(onReject)
551
+ }
552
+
553
+ const updateStyle = (nodeId: number, prop: string, value: string | number) => {
554
+ applyStyle(LAYER_DEFAULT, nodeId, prop, value)
555
+ // Recalc the node's own screen — not necessarily the active one (the node may live
556
+ // on a screen that's currently in history).
557
+ getScreen(module._getScreenId(nodeId))?.recalc()
558
+ }
559
+ const mergeStyle = (nodeId: number, style: any) => {
560
+ for (let [ key, value ] of Object.entries(style)) {
561
+ applyStyle(LAYER_DEFAULT, nodeId, key, value as string | number)
562
+ }
563
+ getScreen(module._getScreenId(nodeId))?.recalc()
564
+ }
565
+
566
+ // Resolve a user style class ("$"+name) to its layer id (allocated app-global by getLayer on
567
+ // first sight). Reuses the interned-key pointer cache. The declaration path needs no changes —
568
+ // a `$checked: {...}` block already flows through applyStyle → getLayer → applyStyleTransition.
569
+ const classLayer = (nodeId: number, name: string) => module._getLayer(nodeId, internKey("$" + name))
570
+
571
+ // Class toggling lives in creator-ui (setNodeClass): name→layer conversion + the DESCENDANT
572
+ // CASCADE — a class set here is active on the whole subtree (docs/style-class-cascade-plan.md).
573
+ const setClass = (nodeId: number, name: string, enabled: boolean) => {
574
+ module._setNodeClass(nodeId, internKey(name), enabled)
575
+ getScreen(module._getScreenId(nodeId))?.recalc()
576
+ }
577
+
578
+ const getOverlay = (yogaId: number) => getScreen(module._getScreenId(yogaId))?.overlayElement
579
+ const transitionSystem = createTransitionSystem(module, styleBlock, getNode, applyStyle, getOverlay)
580
+
581
+ const toast = createToastSystem(root)
582
+
583
+ let onResizeCallback: (width: number, height: number) => void = () => {}
584
+ let cachedWidth: number
585
+ let cachedHeight: number
586
+ // Simulated safe insets: the neutral web defaults, unless a device-emulating embedder (the
587
+ // editor Simulator's iOS/Android shells) pins a spec via _setSafeAreaSpec.
588
+ let safeAreaSpec: SafeAreaSpec = DEFAULT_SAFE_AREA
589
+ const _onResize = (width: number, height: number) => {
590
+ if (width === 0 || height === 0) return
591
+
592
+ // Bars first — the paddings call is what re-resolves the dynamic safe-*/comfort-* styles.
593
+ const { insets, bars } = orientedSafeArea(safeAreaSpec, width > height)
594
+ module._setSafeAreaBars(bars.reduce((mask, edge) => mask | EDGE_BIT[edge], 0))
595
+ module._setSafePaddings(insets[0], insets[1], insets[2], insets[3])
596
+ module._setConditionValue(width, height)
597
+ if (currentScreen) {
598
+ currentScreen?.applyLayout(width, height)
599
+ }
600
+ // Widgets are their own layout roots — without this they'd keep the dimensions they were
601
+ // opened with forever (the "gap after opening during the simulator's pop animation" bug).
602
+ for (const widget of widgets.values()) {
603
+ widget.applyLayout(width, height)
604
+ }
605
+
606
+ cachedWidth = width
607
+ cachedHeight = height
608
+
609
+ onResizeCallback(width, height)
610
+ }
611
+
612
+ const registerResizeEvent = (callback: (width: number, height: number) => void) => {
613
+ onResizeCallback = callback
614
+ }
615
+ // Current display size (device.width / device.height) — the last size seen by _onResize.
616
+ const getDisplaySize = (): [number, number] => [cachedWidth ?? 0, cachedHeight ?? 0]
617
+
618
+ // Host-config: pin the safe-area insets/bars (null restores the web defaults); re-applies
619
+ // through _onResize when the host is already sized.
620
+ const setSafeAreaSpec = (spec: SafeAreaSpec | null) => {
621
+ safeAreaSpec = spec ?? DEFAULT_SAFE_AREA
622
+ if (cachedWidth && cachedHeight) _onResize(cachedWidth, cachedHeight)
623
+ }
624
+
625
+ // _creatorUI.setTheme (docs/ui-theme-plan.md): merge app theme values into the core table,
626
+ // then re-resolve every live root's dynamic styles (var() refs, comfort-* tokens). Numbers are
627
+ // lengths → "px" suffix (the core's bare-number string branches mean percent); null removes.
628
+ // Colors/strings re-dispatch through the registered handlers synchronously; layout props mark
629
+ // yoga dirty, so the visible roots relayout the same way a resize does (covered screens
630
+ // relayout when shown).
631
+ const setTheme = (values: Record<string, string | number | null>) => {
632
+ for (const key of Object.keys(values)) {
633
+ const value = key === "fontFamily" && typeof values[key] === "string"
634
+ ? cssFontFamily(values[key] as string)
635
+ : values[key]
636
+ const str = value == null ? null : typeof value === "number" ? `${value}px` : value
637
+ const keyLen = key.length + 1
638
+ const keyPtr = module._malloc(keyLen)
639
+ module.stringToUTF8(key, keyPtr, keyLen)
640
+ let valPtr = 0
641
+ if (str !== null) {
642
+ const valLen = module.lengthBytesUTF8(str) + 1
643
+ valPtr = module._malloc(valLen)
644
+ module.stringToUTF8(str, valPtr, valLen)
645
+ }
646
+ module._setThemeValue(keyPtr, valPtr)
647
+ if (valPtr) module._free(valPtr)
648
+ module._free(keyPtr)
649
+ // Mirror as a CSS custom property on the app root: `var(--x)` values the DOM renders and
650
+ // the theme-sourced defaults (.screen color/font-family in defaultStyle.css) re-resolve
651
+ // natively. Env names never reach here (the SDK strips them; the core ignores them too).
652
+ if (str === null) root.style.removeProperty(`--${key}`)
653
+ else root.style.setProperty(`--${key}`, str)
654
+ }
655
+
656
+ let relayout = module._applyTheme()
657
+
658
+ // System default: the `fontFamily` theme key retargets the default text font. The DOM side
659
+ // follows via CSS above — measurement must agree, and every text node's cached yoga measure
660
+ // is stale, so dirty the measure leaves everywhere (screens in history included).
661
+ if ("fontFamily" in values) {
662
+ setDefaultFontFamily(values.fontFamily == null ? null : String(values.fontFamily))
663
+ dirtyAllTextMeasures()
664
+ relayout = true
665
+ }
666
+
667
+ if (relayout && cachedWidth && cachedHeight) {
668
+ currentScreen?.applyLayout(cachedWidth, cachedHeight)
669
+ for (const widget of widgets.values()) {
670
+ widget.applyLayout(cachedWidth, cachedHeight)
671
+ }
672
+ }
673
+ }
674
+
675
+ const touchSystem = createTouchSystem(root)
676
+ touchSystem.onNodeStateChanged((nodeId, pressed) => {
677
+ // Flips the built-in onPressed layer AND the cascading `$pressed` class (creator-ui pairs them).
678
+ module._setNodePressed(nodeId, pressed)
679
+ })
680
+
681
+ // Touch: native defaults (focus, scroll) stay on editables/scrollers; gesture surfaces (canvas,
682
+ // `.interactive`) keep the touch for the app — see viewer-lite's touchDefaultsGuard. Root-level
683
+ // touch-action drops double-tap/pinch zoom on the app surface without touching native panning.
684
+ const savedTouchAction = root.style.touchAction
685
+ root.style.touchAction = "pan-x pan-y"
686
+ const removeTouchGuard = installTouchDefaultsGuard(root, { scroller: ".scrollable,.vlist,.pager" })
687
+ root.addEventListener("pointerdown", touchSystem.onSceneTouchDown)
688
+ root.addEventListener("pointerup", touchSystem.onSceneTouchEnd)
689
+ root.addEventListener("pointercancel", touchSystem.onSceneTouchCancel)
690
+ const removeDismissGuard = installKeyboardDismissGuard(root)
691
+
692
+ const _destroy = () => {
693
+ removeDismissGuard()
694
+ removeTouchGuard()
695
+ root.style.touchAction = savedTouchAction
696
+ root.removeEventListener("pointerdown", touchSystem.onSceneTouchDown)
697
+ root.removeEventListener("pointerup", touchSystem.onSceneTouchEnd)
698
+ root.removeEventListener("pointercancel", touchSystem.onSceneTouchCancel)
699
+ touchSystem._destroy()
700
+ nativeViews.destroy()
701
+ for (const font of registeredFonts) document.fonts.delete(font)
702
+ registeredFonts.length = 0
703
+ }
704
+
705
+ const showWidget = (widgetJS: any, ownerDesc?: any) => {
706
+ for (let w of widgets.values()) {
707
+ if (w.node === widgetJS) return
708
+ }
709
+ const screen = new UIScreen(module, touchSystem, applyStyle, classLayer, vlistSystem, pagerSystem, deregister, canvasHost, nativeViews)
710
+ screen.isWidget = true
711
+ screen.ownerDesc = ownerDesc // attached widget: visible only while the owner is presented
712
+
713
+ widgets.set(-widgetsId, screen)
714
+ screen.init(-widgetsId, widgetJS)
715
+ widgetsId++
716
+
717
+ const [ width, height ] = rootSize()
718
+ screen.applyLayout(width, height)
719
+
720
+ screen.root.classList.add("widget")
721
+ // A widget with a root-level touch listener catches taps on its whole surface (pointer-events:
722
+ // all); without one it stays pointer-transparent so taps fall through to the scene beneath — same
723
+ // rule as `.screen` (createNodeElement). Non-interactive children re-enable events themselves.
724
+ if (widgetJS.touchStartListeners?.length > 0) screen.root.classList.add("interactive")
725
+ root.appendChild(screen.root)
726
+ screen.setPagersActive(true) // a pager inside a widget opens its current page
727
+ if (ownerDesc !== undefined) updateAttachedWidgets()
728
+ }
729
+ const hideWidget = (widgetJS: any) => {
730
+ if (!widgetJS._id) return
731
+ const screenId = module._getScreenId(widgetJS._id)
732
+ if (screenId >= 0) return
733
+ const widget = getScreen(screenId)
734
+ widget?.remove() // self-deregisters via the deregister hook
735
+ }
736
+
737
+ const insertNode = (index: number, nodeId: number, node: any) => {
738
+ const screenId = module._getScreenId(nodeId)
739
+ const screen = getScreen(screenId)
740
+ screen?.insertNode(index, nodeId, node)
741
+ }
742
+ const removeNode = (nodeId: number, node: any) => {
743
+ const screenId = module._getScreenId(nodeId)
744
+ const screen = getScreen(screenId)
745
+ screen?.removeNode(nodeId, node)
746
+ }
747
+ const setContent = (nodeId: number, oldChildren: any[], newChildren: any[]) => {
748
+ const screenId = module._getScreenId(nodeId)
749
+ const screen = getScreen(screenId)
750
+ screen?.setContent(nodeId, oldChildren, newChildren)
751
+ }
752
+
753
+ return {
754
+ openScreen,
755
+ closeScreen,
756
+ routerOpen,
757
+ routerPush,
758
+ routerReplace,
759
+ routerPop,
760
+ routerHide,
761
+ routerRestore,
762
+ // Presentable navigation (docs/navigation-presentable-plan.md) — the SDK feature-detects these.
763
+ openView,
764
+ closeView,
765
+ isViewSupported: nativeViews.isViewSupported,
766
+ viewCall: nativeViews.viewCall,
767
+ // Host-side plugin registration (the embedding app calls host._creatorUI._registerView).
768
+ _registerView: nativeViews.registerView,
769
+ _setSceneHost,
770
+ updateNode,
771
+ setSourceRect,
772
+ refreshCanvasSurface,
773
+ updateStyle,
774
+ mergeStyle,
775
+ setClass,
776
+ registerFont,
777
+ // Browser host: CDN faces should come as woff2 (the font() macro's generated code reads this).
778
+ fontFormat: "woff2",
779
+ toast,
780
+ registerTouchStartEvent: touchSystem.registerTouchStartEvent,
781
+ registerTouchEndEvent: touchSystem.registerTouchEndEvent,
782
+ isButtonPressed: touchSystem.isButtonPressed,
783
+ registerResizeEvent,
784
+ getDisplaySize,
785
+ getTextValue,
786
+ getBoundingClientRect,
787
+ insertNode,
788
+ removeNode,
789
+ setContent,
790
+ animateTo: transitionSystem.animateTo,
791
+ animateFrom: transitionSystem.animateFrom,
792
+ showWidget,
793
+ hideWidget,
794
+ _onResize,
795
+ _destroy,
796
+ _setCreator: touchSystem.setCreator,
797
+ _setSafeAreaSpec: setSafeAreaSpec,
798
+ setTheme,
799
+ ...vlistSystem.api,
800
+ // Generic command channel — pager nodes route to their system, everything else to vlist.
801
+ command: (jsNode: any, name: string, ...args: any[]) => {
802
+ if (jsNode?.type === "pager") return pagerSystem.command(jsNode, name, ...args)
803
+ return vlistSystem.api.command(jsNode, name, ...args)
804
+ },
805
+ }
806
+ }