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.
- package/README.md +5 -0
- package/dist-embed/VERSION.json +5 -0
- package/dist-embed/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
- package/dist-embed/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/dist-embed/assets/basis-C64VHDVD.js +1 -0
- package/dist-embed/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/dist-embed/assets/createViewer2d-D12Wy3VF.js +1 -0
- package/dist-embed/assets/createViewerLite-CP-WHGLg.js +707 -0
- package/dist-embed/assets/creator-gl-BF2CQzeI.wasm +0 -0
- package/dist-embed/assets/creator-gl.js-B_0GrQRe.symbols +5379 -0
- package/dist-embed/assets/creator-ui-BX5Fvnbt.js +1 -0
- package/dist-embed/assets/creator-ui-CzKHLH2Q.wasm +0 -0
- package/dist-embed/assets/creator2d-BtQ-1_B0.wasm +0 -0
- package/dist-embed/assets/draco-BiISTFcR.js +118 -0
- package/dist-embed/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/dist-embed/assets/host-CWKB1WDd.js +162 -0
- package/dist-embed/assets/index-CIK8PnUA.js +2 -0
- package/dist-embed/assets/lit-DUE9rhY9.filamat +0 -0
- package/dist-embed/assets/loadCreatorUiNode-UAdhjRTd.js +1 -0
- package/dist-embed/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
- package/dist-embed/assets/particles-DxYDmI1O.filamat +0 -0
- package/dist-embed/assets/particles-quad-DwYo9yCK.filamat +0 -0
- package/dist-embed/assets/shadow-C9xJpqlY.filamat +0 -0
- package/dist-embed/assets/uberarchive-uXjRYa9p.bin +0 -0
- package/dist-embed/assets/unlit-pe4tFY5b.filamat +0 -0
- package/dist-embed/assets/unlit-transparent-D9OyaOBR.filamat +0 -0
- package/dist-embed/assets/video-eedvhMvS.filamat +0 -0
- package/dist-embed/assets/worker-Caf-yYEI.js +2 -0
- package/dist-embed/embed.html +48 -0
- package/dist-embed/embed.js +187 -0
- package/embed.html +48 -0
- package/package.json +35 -0
- package/src/assets/banner.png +0 -0
- package/src/assets/image2.png +0 -0
- package/src/assets/landing.mp4 +0 -0
- package/src/assets/neutral_ibl128.ktx +0 -0
- package/src/assets/neutral_ibl256.ktx +0 -0
- package/src/assets/output.mp4 +0 -0
- package/src/assets/output2.mp4 +0 -0
- package/src/assets/texture.png +0 -0
- package/src/assets/vue.svg +1 -0
- package/src/canvas/createCanvasHost.ts +2 -0
- package/src/createViewer.ts +103 -0
- package/src/embed.ts +515 -0
- package/src/host.ts +428 -0
- package/src/lite-test.ts +160 -0
- package/src/main.ts +77 -0
- package/src/sourcemapError.ts +2 -0
- package/src/ui/CuiProps.gen.ts +103 -0
- package/src/ui/UIScreen.ts +486 -0
- package/src/ui/createBareUI.ts +32 -0
- package/src/ui/createMeasureSystem.ts +220 -0
- package/src/ui/createNodeElement.ts +77 -0
- package/src/ui/createPagerSystem.ts +345 -0
- package/src/ui/createTouchSystem.ts +2 -0
- package/src/ui/createUI.ts +806 -0
- package/src/ui/creator-ui.js +14 -0
- package/src/ui/creator-ui.wasm +0 -0
- package/src/ui/defaultStyle.css +176 -0
- package/src/ui/layers.ts +9 -0
- package/src/ui/mountCanvasUI.ts +213 -0
- package/src/ui/nativeViewSystem.ts +91 -0
- package/src/ui/presentPlayer.ts +156 -0
- package/src/ui/props-apply.ts +403 -0
- package/src/ui/router.ts +2 -0
- package/src/ui/safeAreaSpec.ts +3 -0
- package/src/ui/shells.ts +37 -0
- package/src/ui/toasts.ts +2 -0
- package/src/ui/transitions.ts +250 -0
- package/src/ui/ui.d.ts +78 -0
- package/src/ui/vListSystem.ts +212 -0
- package/src/ui/viewScale.ts +2 -0
- package/src/utils/animate.ts +2 -0
- package/src/utils/createUtils.ts +2 -0
- package/src/utils/fetch.ts +3 -0
- package/src/utils/fonts.ts +2 -0
- package/src/utils/globals.ts +2 -0
- package/src/utils/imageNode.ts +260 -0
- package/src/utils/openFilePicker.ts +2 -0
- package/src/utils/svg.ts +2 -0
- package/src/utils/videoNode.ts +2 -0
- package/src/utils/ws.ts +2 -0
- package/src/viewer/createViewer.ts +993 -0
- package/src/viewer/creator-gl.js +14 -0
- package/src/viewer/creator-gl.js.symbols +5379 -0
- package/src/viewer/creator-gl.wasm +0 -0
- package/src/viewer/lit.filamat +0 -0
- package/src/viewer/mediaplayer.ts +3 -0
- package/src/viewer/particles-quad.filamat +0 -0
- package/src/viewer/particles.filamat +0 -0
- package/src/viewer/physics.ts +140 -0
- package/src/viewer/shadow.filamat +0 -0
- package/src/viewer/uberarchive.bin +0 -0
- package/src/viewer/unlit-transparent.filamat +0 -0
- package/src/viewer/unlit.filamat +0 -0
- package/src/viewer/validateGlb.ts +262 -0
- package/src/viewer/video.filamat +0 -0
- package/src/viewer/wasmCrashSymbolizer.ts +133 -0
- package/src/viewer2d/createViewer2d.ts +438 -0
- package/src/viewer2d/creator2d.js +14 -0
- package/src/viewer2d/creator2d.wasm +0 -0
- package/src/viewer2d/sprite2d.ts +80 -0
- 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>
|