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,220 @@
1
+ // The measure-time default text font. Themable (docs/ui-theme-plan.md system defaults): the
2
+ // `fontFamily` theme key retargets it via setDefaultFontFamily — the DOM side follows through
3
+ // the `.screen { font-family: var(--fontFamily) }` rule, this keeps MEASUREMENT in agreement.
4
+ export let defaultFontFamily = "Roboto"
5
+ export const setDefaultFontFamily = (family: string | null) => {
6
+ defaultFontFamily = family ? cssFontFamily(family) : "Roboto"
7
+ }
8
+
9
+ // The SDK spells the fixed-width system family "monospaced" (the iOS name); CSS spells it
10
+ // "monospace", and an unknown keyword invalidates the whole font-family declaration. Every
11
+ // other family — system keyword or registerFont name — passes through untouched.
12
+ export const cssFontFamily = (family: string) => family === "monospaced" ? "monospace" : family
13
+
14
+ export const createMeasureSystem = (getNode: (screenId: number, nodeId: number) => HTMLElement | null | undefined) => {
15
+ const tempArr = new ArrayBuffer(8);
16
+ const dataView = new DataView(tempArr)
17
+
18
+ const offscreenCanvas = new OffscreenCanvas(1000, 1000)
19
+ const ctx = offscreenCanvas.getContext("2d")!
20
+
21
+ const measure = (screenId: number, nodeId: number, width: number, _widthType: number, height: number, _heightType: number) => {
22
+ const el = getNode(screenId, nodeId)
23
+ if (!el) {
24
+ return 0
25
+ }
26
+
27
+ // intrinsic size for image-like leaves.
28
+ // data-viewbox is the reliable "this is an SVG" marker — checked FIRST, for both
29
+ // <img> and masked <div>, because an svg <img> is also an HTMLImageElement and would
30
+ // otherwise fall into the raster branch and get the upscale clamp.
31
+ let intrinsicW = 0
32
+ let intrinsicH = 0
33
+ let allowUpscale = false
34
+ let isImageLike = false
35
+
36
+ // A sourceRect crop (atlas frame) is painted on a background-image <div>, so it is neither
37
+ // an <img> nor an svg — check it first. The intrinsic size is the frame (w/h, in texture
38
+ // px), not the whole atlas, otherwise Yoga would size the node to the entire spritesheet.
39
+ const sourceRect = (el as any)._sourceRect as [number, number, number, number] | undefined
40
+
41
+ if (sourceRect) {
42
+ intrinsicW = sourceRect[2]
43
+ intrinsicH = sourceRect[3]
44
+ isImageLike = true // raster — keep the no-upscale clamp below
45
+ el.setAttribute("data-measured", "")
46
+ } else if (el.hasAttribute("data-viewbox")) {
47
+ // viewBox = "min-x min-y width height"
48
+ const vb = el.getAttribute("data-viewbox")!.split(/[\s,]+/).map(Number)
49
+ intrinsicW = vb[2]
50
+ intrinsicH = vb[3]
51
+ allowUpscale = true // vector — scales without quality loss
52
+ isImageLike = true
53
+ el.setAttribute("data-measured", "")
54
+ } else if (el instanceof HTMLImageElement) {
55
+ intrinsicW = el.naturalWidth
56
+ intrinsicH = el.naturalHeight
57
+ isImageLike = true // raster — keep the no-upscale clamp below
58
+ el.setAttribute("data-measured", "")
59
+ }
60
+
61
+ if (isImageLike) {
62
+ if (intrinsicW === 0 || intrinsicH === 0) {
63
+ dataView.setFloat32(4, 0, true)
64
+ dataView.setFloat32(0, 0, true)
65
+ return dataView.getBigUint64(0, true)
66
+ }
67
+
68
+ const fit = Math.min(width / intrinsicW, height / intrinsicH)
69
+ const k =
70
+ isNaN(width) && isNaN(height) ? 1 :
71
+ isNaN(width) ? height / intrinsicH :
72
+ isNaN(height) ? width / intrinsicW :
73
+ allowUpscale ? fit : Math.min(fit, 1)
74
+
75
+ dataView.setFloat32(4, intrinsicW * k, true)
76
+ dataView.setFloat32(0, intrinsicH * k, true)
77
+ return dataView.getBigUint64(0, true)
78
+ }
79
+
80
+ // text branch — also serves the editables (input/textarea), whose content lives in `.value`,
81
+ // not `.textContent`: an input measures as one intrinsic line, a textarea wraps its value so
82
+ // a field with no explicit height grows with its content (Yoga clamps via min/maxHeight).
83
+ ctx.font = `${el.style.fontWeight || 'normal'} ${(el.style.fontSize || "14px")} ${el.style.fontFamily || defaultFontFamily}`
84
+
85
+ // letter-spacing — ctx.letterSpacing is honored by measureText, so wrapText picks it up
86
+ // automatically. The ctx is reused across calls, so always reset it (even to 0).
87
+ const letterSpacing = el.style.letterSpacing
88
+ if (letterSpacing && letterSpacing !== "normal") {
89
+ ctx.letterSpacing = letterSpacing.endsWith("em")
90
+ ? `${parseFloat(letterSpacing) * (parseFloat(el.style.fontSize) || 14)}px`
91
+ : letterSpacing
92
+ } else {
93
+ ctx.letterSpacing = "0px"
94
+ }
95
+
96
+ let lineHeight
97
+ if (el.style.lineHeight.endsWith("em")) {
98
+ lineHeight = parseFloat(el.style.lineHeight) * (parseFloat(el.style.fontSize) || 14)
99
+ } else {
100
+ lineHeight = parseFloat(el.style.lineHeight) || (parseFloat(el.style.fontSize) || 14) * 1.2
101
+ }
102
+
103
+ if (el instanceof HTMLInputElement) {
104
+ dataView.setFloat32(4, Math.ceil(ctx.measureText(el.value).width), true)
105
+ dataView.setFloat32(0, Math.ceil(lineHeight), true)
106
+ return dataView.getBigUint64(0, true)
107
+ }
108
+
109
+ // When width is NaN/unconstrained (Undefined measure mode), Yoga is asking for the
110
+ // intrinsic width — measure against infinity so the text does not wrap (and does not
111
+ // shrink character-by-character, which is what happens when every NaN comparison is false).
112
+ const measureWidth = isNaN(width) ? Infinity : width
113
+ const text = el instanceof HTMLTextAreaElement ? el.value : el.textContent!!
114
+ const measured = wrapText(ctx, text, measureWidth)
115
+
116
+ // lineClamp: report the CLAMPED height, otherwise Yoga keeps the full wrapped size and the
117
+ // CSS clamp just leaves dead space under the ellipsis. Width has to be recomputed over the
118
+ // surviving lines only — the widest line is often one of the ones that got cut.
119
+ const clamp = (el as any)._lineClamp as number | undefined
120
+ let lineCount = measured.lines.length
121
+ let maxWidth = measured.maxWidth
122
+ if (clamp && clamp > 0 && lineCount > clamp) {
123
+ lineCount = clamp
124
+ maxWidth = 0
125
+ for (let i = 0; i < clamp; i++) {
126
+ maxWidth = Math.max(maxWidth, ctx.measureText(measured.lines[i]).width)
127
+ }
128
+ }
129
+
130
+ el.style.position = "absolute"
131
+
132
+ dataView.setFloat32(4, Math.ceil(maxWidth), true)
133
+ dataView.setFloat32(0, Math.ceil(lineCount * lineHeight), true)
134
+
135
+ return dataView.getBigUint64(0, true)
136
+ }
137
+
138
+ return { measure }
139
+ }
140
+
141
+ export const wrapText = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, text: string, maxWidth: number,) => {
142
+ const resultLines: string[] = [];
143
+ let totalMaxWidth = 0;
144
+
145
+ // Обрабатываем каждую строку (учитывая явные переносы \n)
146
+ const lines = text.split('\n');
147
+
148
+ for (const line of lines) {
149
+ let current = line;
150
+
151
+ if (current.length === 0) {
152
+ resultLines.push(current);
153
+ continue
154
+ }
155
+ while (current) {
156
+ // Быстрая проверка: если вся строка помещается
157
+ if (ctx.measureText(current).width <= maxWidth) {
158
+ resultLines.push(current);
159
+ totalMaxWidth = Math.max(totalMaxWidth, ctx.measureText(current).width);
160
+ break;
161
+ }
162
+
163
+ let splitPosition = 0;
164
+ let lastSpaceIndex = -1;
165
+ let spaceIndex = 0;
166
+
167
+ // Ищем последний подходящий пробел для переноса
168
+ while ((spaceIndex = current.indexOf(' ', spaceIndex + 1)) !== -1) {
169
+ const segment = current.substring(0, spaceIndex);
170
+ if (ctx.measureText(segment).width > maxWidth) break;
171
+ lastSpaceIndex = spaceIndex;
172
+ }
173
+
174
+ // Если нашли подходящий пробел - используем его
175
+ if (lastSpaceIndex > 0) {
176
+ splitPosition = lastSpaceIndex;
177
+ } else {
178
+ // Стартуем с длины первого слова
179
+ let wordEnd = current.indexOf(' ');
180
+ if (wordEnd === -1) wordEnd = current.length;
181
+
182
+ const firstWord = current.substring(0, wordEnd);
183
+ // Если первое слово не помещается - разбиваем его
184
+ if (ctx.measureText(firstWord).width > maxWidth) {
185
+ for (let i = 1; i <= firstWord.length; i++) {
186
+ const segment = firstWord.substring(0, i);
187
+ if (ctx.measureText(segment).width > maxWidth) {
188
+ splitPosition = i - 1;
189
+ break;
190
+ }
191
+ }
192
+ } else {
193
+ // Ищем место разрыва после первого слова
194
+ for (let i = wordEnd + 1; i <= current.length; i++) {
195
+ const segment = current.substring(0, i);
196
+ if (ctx.measureText(segment).width > maxWidth) {
197
+ splitPosition = i - 1;
198
+ break;
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ // Защита от бесконечного цикла
205
+ if (splitPosition <= 0) splitPosition = 1;
206
+
207
+ const newLine = current.substring(0, splitPosition);
208
+ resultLines.push(newLine);
209
+ totalMaxWidth = Math.max(totalMaxWidth, ctx.measureText(newLine).width);
210
+
211
+ // Обновляем оставшуюся строку (пропускаем пробел при разбивке по пробелу)
212
+ current = current.substring(splitPosition + (lastSpaceIndex > 0 ? 1 : 0));
213
+ }
214
+ }
215
+
216
+ return {
217
+ lines: resultLines,
218
+ maxWidth: totalMaxWidth
219
+ };
220
+ };
@@ -0,0 +1,77 @@
1
+ import { ImageSrc, isSvgSrc, svgSrcTint } from "../utils/svg"
2
+ import { getVideoElement } from "../utils/videoNode"
3
+
4
+ // Pure factory — no side effects beyond element creation.
5
+ // For images: svg + tint → masked <div>, everything else → <img>.
6
+ // Image source/mask configuration happens later in configureImage().
7
+ export function createNodeElement(node: any): HTMLElement {
8
+ switch (node.type) {
9
+ case "image": {
10
+ // svg + tint, or a sourceRect crop, both render as a <div> (masked / background-image)
11
+ // rather than an <img>. The crop must be a <div> from the start: it can't be swapped from
12
+ // an <img> at mount because the element isn't attached to its parent yet. The tint can come
13
+ // from the style OR from the SvgSource itself (source-level tintColor).
14
+ if (isSvgSrc(node._src as ImageSrc) && (node._style?.tintColor || svgSrcTint(node._src as ImageSrc))) {
15
+ return document.createElement("div")
16
+ }
17
+ if (node._sourceRect) {
18
+ return document.createElement("div")
19
+ }
20
+ return document.createElement("img")
21
+ }
22
+
23
+ case "video":
24
+ // Reuse the VideoPlayer's own <video> element so playback control drives the UI directly.
25
+ // It is reconfigured (hidden styling cleared) in UIScreen via configureVideo.
26
+ return getVideoElement(node)
27
+
28
+ case "button": {
29
+ const button = document.createElement("button")
30
+ // A button with an onTouchStart drives its own gesture (ev.track): the touch stays with the
31
+ // app instead of turning into a native pan (touchDefaultsGuard). Plain buttons pan natively.
32
+ if (node.touchStartListeners?.length > 0) button.classList.add("interactive")
33
+ return button
34
+ }
35
+
36
+ case "screen": {
37
+ const screen = document.createElement("div")
38
+ screen.classList.add("screen")
39
+ if (node.scrollable) screen.classList.add("scrollable")
40
+ if (node.touchStartListeners?.length > 0) screen.classList.add("interactive")
41
+ return screen
42
+ }
43
+
44
+ case "scrollable": {
45
+ const el = document.createElement("div")
46
+ el.classList.add("scrollable")
47
+ return el
48
+ }
49
+
50
+ case "pager": {
51
+ const el = document.createElement("div")
52
+ el.classList.add("pager")
53
+ return el
54
+ }
55
+
56
+ case "vlist": {
57
+ const el = document.createElement("div")
58
+ el.classList.add("vlist")
59
+ return el
60
+ }
61
+
62
+ case "spacer": {
63
+ const el = document.createElement("div")
64
+ el.classList.add("spacer")
65
+ return el
66
+ }
67
+
68
+ case "input":
69
+ return document.createElement("input")
70
+
71
+ case "textarea":
72
+ return document.createElement("textarea")
73
+
74
+ default:
75
+ return document.createElement("div")
76
+ }
77
+ }
@@ -0,0 +1,345 @@
1
+ import type { UIScreen } from "./UIScreen"
2
+
3
+ // UIPager — wire node type "pager" (docs/pager-component-plan.md): the system's ONE
4
+ // screen-navigation element. Sibling tabs that swipe side to side, with a navigation STACK per
5
+ // tab. On web the tab strip is CSS scroll-snap (one snap cell per tab, same mechanism as the
6
+ // screen host); each tab's stack is a pile of absolutely-positioned page roots of which only the
7
+ // top is shown. Stack transitions are instant — web wraps CSS scroll-snap with no custom
8
+ // transitions (the iOS host is the animated reference).
9
+ //
10
+ // The pager element is a yoga leaf in its own screen; its pages are NOT yoga children — every
11
+ // page is a full UIScreen, an independent layout root laid out to the pager's box.
12
+ //
13
+ // SDK ↔ host contract (no dedicated bridge methods):
14
+ // - SDK → host: select/push/pop/popToRoot/replace arrive over the generic `_creatorUI.command`
15
+ // channel. The SDK mutates `children`/`_lens`/`_index` BEFORE sending, so the handler
16
+ // reconciles against the node's live state; the command name only picks the transition.
17
+ // - host → SDK: a committed user swipe between tabs reports `jsNode._hostDidSelect(index)` — an
18
+ // idempotent no-op SDK-side when the logical index already matches (programmatic selects
19
+ // settle on the index the SDK already holds).
20
+ // - Swipe arbitration: tab-paging is enabled only with >1 tab AND the current tab at its root
21
+ // (depth 1) — drilled in, the strip locks (overflow hidden), mirroring the iOS dataSource
22
+ // detach. There is no edge back-swipe on web, so `_hostDidPop` is never host-initiated here.
23
+ // - Lifecycle: exactly one page in the whole pager is "open" — the current tab's top, while the
24
+ // pager's owning screen chain is active. Page `ol`/`cl` drive `UIPager.current` SDK-side and
25
+ // cascade into pagers/screen hosts nested inside pages.
26
+
27
+ type Page = {
28
+ node: any // the SDK screen node of this page
29
+ el: HTMLElement // absolutely-positioned stack layer inside the tab cell
30
+ host: UIScreen // the page's layout root
31
+ }
32
+
33
+ type Tab = {
34
+ el: HTMLElement // scroll-snap cell; sized to the pager box by CSS
35
+ pages: Page[] // stack, root → top; only the top is visible
36
+ }
37
+
38
+ type PagerInstance = {
39
+ nodeId: number
40
+ el: HTMLElement
41
+ jsNode: any
42
+ screen: UIScreen // the screen the pager element lives in (for reference)
43
+ tabs: Tab[]
44
+ width: number
45
+ height: number
46
+ mounted: boolean // did the first layout run (initial scroll applied)?
47
+ laidOut: boolean
48
+ active: boolean // is the pager's own screen chain active? (gates page onOpen/onClose)
49
+ currentTab: number // settled tab index
50
+ targetTab: number // tab we intend to be on (select target / settled) — realign on resize
51
+ openPage: Page | null // page whose onOpen fired without a matching onClose
52
+ settleTimer: ReturnType<typeof setTimeout> | null
53
+ setActive(active: boolean): void
54
+ }
55
+
56
+ export type PagerDeps = {
57
+ // Create a registered, un-mounted layout root for a page's screen node (createUI's `newScreen`).
58
+ newScreen: (node: any) => UIScreen
59
+ }
60
+
61
+ const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v))
62
+
63
+ export const createPagerSystem = (_module: any, deps: PagerDeps) => {
64
+
65
+ const instances = new Map<number, PagerInstance>() // pager yogaId → instance
66
+
67
+ // The flat tab-major `children` partitioned by `_lens` into per-tab stacks (root → top).
68
+ // Falls back to one-screen-per-tab if the partition doesn't cover the children.
69
+ const segments = (jsNode: any): any[][] => {
70
+ const nodes: any[] = (jsNode.children ?? []).filter(Boolean)
71
+ const lens: number[] = Array.isArray(jsNode._lens) ? jsNode._lens : []
72
+ const total = lens.reduce((a, b) => a + b, 0)
73
+ if (total !== nodes.length || lens.some(l => l < 1)) return nodes.map(n => [n])
74
+ const result: any[][] = []
75
+ let offset = 0
76
+ for (const len of lens) {
77
+ result.push(nodes.slice(offset, offset + len))
78
+ offset += len
79
+ }
80
+ return result
81
+ }
82
+
83
+ const clampedIndex = (inst: PagerInstance) =>
84
+ clamp(inst.jsNode._index ?? 0, 0, Math.max(0, inst.tabs.length - 1))
85
+
86
+ const buildPage = (inst: PagerInstance, node: any): Page => {
87
+ const el = document.createElement("div")
88
+ el.className = "pager-page"
89
+ // The page root fills its layer (= the pager box) via the `.screen` class (100%/absolute).
90
+ const host = deps.newScreen(node)
91
+ el.appendChild(host.root)
92
+ if (inst.width > 0 && inst.height > 0) host.applyLayout(inst.width, inst.height)
93
+ return { node, el, host }
94
+ }
95
+
96
+ const fireOpen = (p: Page) => {
97
+ for (const cb of p.node.ol ?? []) cb()
98
+ }
99
+ const fireClose = (p: Page) => {
100
+ for (const cb of p.node.cl ?? []) cb()
101
+ }
102
+ // A page screen may itself contain pagers — chain activation down through it.
103
+ const cascade = (p: Page, active: boolean) => {
104
+ ;(p.host as any).setPagersActive?.(active)
105
+ }
106
+
107
+ // The current tab's top page is open iff the whole owner chain is active. Exchange `ol`/`cl`
108
+ // when that changes (compared by page identity, so tab switches AND stack changes both land here).
109
+ const refreshLifecycle = (inst: PagerInstance) => {
110
+ const target = inst.active && inst.laidOut
111
+ ? inst.tabs[inst.currentTab]?.pages[inst.tabs[inst.currentTab].pages.length - 1] ?? null
112
+ : null
113
+ if (inst.openPage === target) return
114
+ if (inst.openPage) { fireClose(inst.openPage); cascade(inst.openPage, false) }
115
+ inst.openPage = target
116
+ if (target) { fireOpen(target); cascade(target, true) }
117
+ }
118
+
119
+ // Tab-paging is available only while there are neighbors to reach AND the current tab sits at
120
+ // its root — drilled in (depth > 1), the strip locks (the iOS dataSource-detach analog).
121
+ const updatePagingLocked = (inst: PagerInstance) => {
122
+ const depth = inst.tabs[inst.currentTab]?.pages.length ?? 0
123
+ inst.el.classList.toggle("pager_locked", inst.tabs.length <= 1 || depth > 1)
124
+ }
125
+
126
+ // Only the stack top is displayed; the layers below keep their DOM (and state) hidden.
127
+ const updateStackVisibility = (tab: Tab) => {
128
+ for (let i = 0; i < tab.pages.length; i++) {
129
+ tab.pages[i].el.classList.toggle("pager-page_hidden", i !== tab.pages.length - 1)
130
+ }
131
+ }
132
+
133
+ const scrollToTab = (inst: PagerInstance, index: number, animated: boolean) => {
134
+ const i = clamp(index, 0, Math.max(0, inst.tabs.length - 1))
135
+ inst.el.scrollTo({ left: i * inst.width, behavior: animated ? "smooth" : "auto" })
136
+ }
137
+
138
+ const scrollOf = (inst: PagerInstance) => {
139
+ const size = inst.el.clientWidth
140
+ return size > 0 ? inst.el.scrollLeft / size : 0
141
+ }
142
+
143
+ const onSettle = (inst: PagerInstance, index: number) => {
144
+ const i = clamp(index, 0, Math.max(0, inst.tabs.length - 1))
145
+ inst.targetTab = i // a user swipe is also an intent change
146
+ if (i === inst.currentTab) return
147
+ inst.currentTab = i
148
+ // Host-driven tab change: sync the SDK's logical index + onSelect (idempotent there).
149
+ inst.jsNode._hostDidSelect?.(i)
150
+ refreshLifecycle(inst)
151
+ updatePagingLocked(inst)
152
+ }
153
+
154
+ const onScroll = (inst: PagerInstance) => {
155
+ if (inst.settleTimer !== null) clearTimeout(inst.settleTimer)
156
+ const pos = scrollOf(inst)
157
+ const rounded = Math.round(pos)
158
+ // Already snapped to a tab → settle now; otherwise wait for the scroll to come to rest.
159
+ if (Math.abs(pos - rounded) < 0.005) {
160
+ inst.settleTimer = null
161
+ onSettle(inst, rounded)
162
+ } else {
163
+ inst.settleTimer = setTimeout(() => {
164
+ inst.settleTimer = null
165
+ onSettle(inst, Math.round(scrollOf(inst)))
166
+ }, 80)
167
+ }
168
+ }
169
+
170
+ // ---- reconcile every tab's stack against the node's live wire state (children + _lens) ----
171
+ // Pages are reused by node identity; the SDK mutated the state before sending the command, so
172
+ // this one pass serves push / pop / popToRoot / replace alike (transitions are instant on web).
173
+ const reconcile = (inst: PagerInstance) => {
174
+ const segs = segments(inst.jsNode)
175
+ const byNode = new Map<any, Page>()
176
+ for (const tab of inst.tabs) for (const p of tab.pages) byNode.set(p.node, p)
177
+
178
+ // Grow/shrink the tab cells to the new segmentation: `content` (setContent & co.) can change
179
+ // the tab count, stack commands never do. Pages of a dropped cell stay in `byNode`, so one
180
+ // that moved into a surviving tab is reused and the rest are torn down by the sweep below.
181
+ while (inst.tabs.length < segs.length) {
182
+ const tabEl = document.createElement("div")
183
+ tabEl.className = "pager-tab"
184
+ inst.el.appendChild(tabEl)
185
+ inst.tabs.push({ el: tabEl, pages: [] })
186
+ }
187
+ while (inst.tabs.length > segs.length) inst.tabs.pop()!.el.remove()
188
+ const maxTab = Math.max(0, inst.tabs.length - 1)
189
+ inst.currentTab = Math.min(inst.currentTab, maxTab)
190
+ inst.targetTab = Math.min(inst.targetTab, maxTab)
191
+
192
+ const seen = new Set<Page>()
193
+ for (let t = 0; t < inst.tabs.length; t++) {
194
+ const tab = inst.tabs[t]
195
+ const target = segs[t] ?? []
196
+ const next: Page[] = []
197
+ for (const node of target) {
198
+ let p = byNode.get(node)
199
+ if (!p) p = buildPage(inst, node)
200
+ seen.add(p)
201
+ next.push(p)
202
+ tab.el.appendChild(p.el) // appendChild moves an existing layer into stack order
203
+ }
204
+ tab.pages = next
205
+ updateStackVisibility(tab)
206
+ }
207
+
208
+ // tear down pages that left every stack (popped / replaced away)
209
+ for (const p of byNode.values()) {
210
+ if (seen.has(p)) continue
211
+ // A removed open page's onClose must fire while its tree is alive — refreshLifecycle below
212
+ // only sees the survivors. Nested pagers/hosts close via host.remove().
213
+ if (p === inst.openPage) { fireClose(p); cascade(p, false); inst.openPage = null }
214
+ p.host.remove()
215
+ p.el.remove()
216
+ }
217
+
218
+ refreshLifecycle(inst)
219
+ updatePagingLocked(inst)
220
+ }
221
+
222
+ // ---- called from UIScreen.addNodeRecursive when node.type === "pager" ----
223
+ const register = (screen: UIScreen, jsNode: any, el: HTMLElement, yogaId: number) => {
224
+ const inst: PagerInstance = {
225
+ nodeId: yogaId, el, jsNode, screen,
226
+ tabs: [],
227
+ width: 0, height: 0,
228
+ mounted: false, laidOut: false, active: false,
229
+ currentTab: 0, targetTab: 0,
230
+ openPage: null,
231
+ settleTimer: null,
232
+ setActive(active: boolean) {
233
+ if (this.active === active) return
234
+ this.active = active
235
+ refreshLifecycle(this)
236
+ },
237
+ }
238
+ instances.set(yogaId, inst)
239
+
240
+ for (const seg of segments(jsNode)) {
241
+ const tabEl = document.createElement("div")
242
+ tabEl.className = "pager-tab"
243
+ const tab: Tab = { el: tabEl, pages: [] }
244
+ for (const node of seg) {
245
+ const page = buildPage(inst, node)
246
+ tab.pages.push(page)
247
+ tabEl.appendChild(page.el)
248
+ }
249
+ updateStackVisibility(tab)
250
+ inst.tabs.push(tab)
251
+ el.appendChild(tabEl)
252
+ }
253
+
254
+ inst.currentTab = clampedIndex(inst)
255
+ inst.targetTab = inst.currentTab
256
+ updatePagingLocked(inst)
257
+
258
+ el.addEventListener("scroll", () => onScroll(inst))
259
+ }
260
+
261
+ // ---- called from UIScreen.updateSizes for the pager element (after its box size is set) ----
262
+ const updateLayout = (yogaId: number) => {
263
+ const inst = instances.get(yogaId)
264
+ if (!inst) return
265
+ const w = parseFloat(inst.el.style.width) || inst.el.clientWidth
266
+ const h = parseFloat(inst.el.style.height) || inst.el.clientHeight
267
+ if (w <= 0 || h <= 0) return
268
+
269
+ const sizeChanged = w !== inst.width || h !== inst.height
270
+ inst.width = w
271
+ inst.height = h
272
+ for (const tab of inst.tabs) {
273
+ for (const p of tab.pages) p.host.applyLayout(w, h) // idempotent when the size is unchanged
274
+ }
275
+
276
+ if (!inst.mounted) {
277
+ inst.mounted = true
278
+ inst.currentTab = clampedIndex(inst)
279
+ inst.targetTab = inst.currentTab
280
+ scrollToTab(inst, inst.currentTab, false)
281
+ } else if (sizeChanged) {
282
+ // Realign ONLY on an actual resize, and to the intended target tab — NOT on every recalc
283
+ // (updateLayout runs on every recalc of the owning screen, and re-scrolling then would
284
+ // clobber an in-flight select whose smooth scroll hasn't settled).
285
+ scrollToTab(inst, inst.targetTab, false)
286
+ }
287
+ inst.laidOut = true
288
+ refreshLifecycle(inst)
289
+ updatePagingLocked(inst)
290
+ }
291
+
292
+ // ---- _creatorUI.command dispatch for pager nodes ----
293
+ const command = (jsNode: any, name: string, ...args: any[]) => {
294
+ const inst = instances.get(jsNode._id)
295
+ if (!inst) return
296
+ switch (name) {
297
+ case "select": {
298
+ // Programmatic tab switch — instant unless the SDK asked for the slide (select(i, true)).
299
+ // Never user-cancellable, so state syncs immediately; an animated scroll settles on the
300
+ // index the SDK already holds (no _hostDidSelect echo).
301
+ const i = clampedIndex(inst)
302
+ inst.targetTab = i
303
+ if (i !== inst.currentTab) {
304
+ inst.currentTab = i
305
+ refreshLifecycle(inst)
306
+ updatePagingLocked(inst)
307
+ }
308
+ if (inst.width > 0) scrollToTab(inst, i, args[0] === true && inst.laidOut)
309
+ break
310
+ }
311
+ case "push":
312
+ case "pop":
313
+ case "popToRoot":
314
+ case "replace":
315
+ reconcile(inst)
316
+ break
317
+ // The pages themselves changed (setContent / append / insert / remove): same reconcile,
318
+ // but the tab count may have moved, so re-land the strip on the (clamped) selected tab.
319
+ case "content":
320
+ reconcile(inst)
321
+ if (inst.width > 0) scrollToTab(inst, inst.targetTab, false)
322
+ break
323
+ }
324
+ }
325
+
326
+ const unregister = (yogaId: number) => {
327
+ const inst = instances.get(yogaId)
328
+ if (!inst) return
329
+ if (inst.settleTimer !== null) clearTimeout(inst.settleTimer)
330
+ for (const tab of inst.tabs) {
331
+ for (const p of tab.pages) {
332
+ p.host.remove()
333
+ p.el.remove()
334
+ }
335
+ tab.el.remove()
336
+ }
337
+ inst.tabs = []
338
+ inst.openPage = null
339
+ instances.delete(yogaId)
340
+ }
341
+
342
+ return { register, updateLayout, unregister, command, instances }
343
+ }
344
+
345
+ export type PagerSystem = ReturnType<typeof createPagerSystem>
@@ -0,0 +1,2 @@
1
+ // Moved to viewer-lite; re-export shim.
2
+ export * from "lecodes-viewer-lite/src/ui/createTouchSystem"