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,156 @@
|
|
|
1
|
+
// Destination transition player (docs/navigation-presentable-plan.md, phase 2 — web reference).
|
|
2
|
+
// Named transitions are predefined TransitionSpecs; both shapes play through element.animate(),
|
|
3
|
+
// so a custom spec from the SDK needs no extra machinery.
|
|
4
|
+
//
|
|
5
|
+
// The static rule (mirrors the Android SurfaceView constraint so apps feel the same): a side
|
|
6
|
+
// marked `static` (the scene canvas) never animates — only the screen side moves. Screens are
|
|
7
|
+
// position:absolute so they always paint above the in-flow canvas, which makes the degradations
|
|
8
|
+
// coherent: entering a scene slides the old screen away to reveal it; leaving a scene slides the
|
|
9
|
+
// new screen in over it.
|
|
10
|
+
|
|
11
|
+
export type SpecTransform = { x?: number | string, y?: number | string, scale?: number, opacity?: number }
|
|
12
|
+
export type TransitionSpecLike = {
|
|
13
|
+
duration?: number
|
|
14
|
+
easing?: [number, number, number, number]
|
|
15
|
+
incoming?: { from?: SpecTransform }
|
|
16
|
+
outgoing?: { to?: SpecTransform }
|
|
17
|
+
dim?: number
|
|
18
|
+
onTop?: "incoming" | "outgoing"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const STACK_EASING: [number, number, number, number] = [0.2, 0, 0, 1]
|
|
22
|
+
|
|
23
|
+
const NAMED: Record<string, TransitionSpecLike> = {
|
|
24
|
+
"none": {},
|
|
25
|
+
"fade": { duration: 280, incoming: { from: { opacity: 0 } } },
|
|
26
|
+
"slide-from-right": { duration: 400, incoming: { from: { x: "100%" } }, outgoing: { to: { x: "-30%" } } },
|
|
27
|
+
"slide-from-left": { duration: 400, incoming: { from: { x: "-100%" } }, outgoing: { to: { x: "30%" } } },
|
|
28
|
+
"slide-from-top": { duration: 400, incoming: { from: { y: "-100%" } }, outgoing: { to: { y: "30%" } } },
|
|
29
|
+
"slide-from-bottom": { duration: 400, incoming: { from: { y: "100%" } }, outgoing: { to: { y: "-30%" } } },
|
|
30
|
+
"zoom": { duration: 400, easing: [0.33, 1.2, 0.62, 1], incoming: { from: { scale: 0.985 } } },
|
|
31
|
+
"zoom-in": { duration: 400, easing: [0.33, 1.2, 0.62, 1], incoming: { from: { scale: 1.08, opacity: 0 } }, onTop: "incoming" },
|
|
32
|
+
"zoom-out": { duration: 420, outgoing: { to: { scale: 0.92, opacity: 0 } }, onTop: "outgoing" },
|
|
33
|
+
"push": { duration: 350, easing: STACK_EASING, incoming: { from: { x: "100%" } }, outgoing: { to: { x: "-25%" } }, dim: 0.3, onTop: "incoming" },
|
|
34
|
+
"pop": { duration: 350, easing: STACK_EASING, incoming: { from: { x: "-25%" } }, outgoing: { to: { x: "100%" } }, dim: 0.3, onTop: "outgoing" },
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const toTransform = (t: SpecTransform): string => {
|
|
38
|
+
const parts: string[] = []
|
|
39
|
+
if (t.x !== undefined || t.y !== undefined) {
|
|
40
|
+
const px = (v: number | string | undefined) => v === undefined ? "0px" : typeof v === "number" ? v + "px" : v
|
|
41
|
+
parts.push(`translate(${px(t.x)}, ${px(t.y)})`)
|
|
42
|
+
}
|
|
43
|
+
if (t.scale !== undefined) parts.push(`scale(${t.scale})`)
|
|
44
|
+
return parts.length > 0 ? parts.join(" ") : "none"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const poseKeyframe = (t: SpecTransform): Keyframe => {
|
|
48
|
+
const kf: Keyframe = { transform: toTransform(t) }
|
|
49
|
+
if (t.opacity !== undefined) kf.opacity = t.opacity
|
|
50
|
+
return kf
|
|
51
|
+
}
|
|
52
|
+
const IDENTITY: Keyframe = { transform: "none", opacity: 1 }
|
|
53
|
+
|
|
54
|
+
// The exit pose for an outgoing side when the incoming one is static (a scene): mirror the
|
|
55
|
+
// incoming entry so the reveal keeps the transition's direction (push's 100%-from-right becomes
|
|
56
|
+
// a full slide-out to the left); pure opacity entries become a fade-out.
|
|
57
|
+
const mirrorExit = (from: SpecTransform): SpecTransform => {
|
|
58
|
+
const neg = (v: number | string | undefined) =>
|
|
59
|
+
v === undefined ? undefined : typeof v === "number" ? -v : v.startsWith("-") ? v.slice(1) : "-" + v
|
|
60
|
+
const out: SpecTransform = {}
|
|
61
|
+
if (from.x !== undefined) out.x = neg(from.x)
|
|
62
|
+
if (from.y !== undefined) out.y = neg(from.y)
|
|
63
|
+
if (from.scale !== undefined) out.scale = 2 - from.scale
|
|
64
|
+
if (from.opacity !== undefined || Object.keys(out).length === 0) out.opacity = 0
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type Side = { el: HTMLElement, static?: boolean }
|
|
69
|
+
|
|
70
|
+
export const createPresentPlayer = (container: HTMLElement) => {
|
|
71
|
+
let finishActive: (() => void) | null = null
|
|
72
|
+
|
|
73
|
+
/** Play `transition` between the mounted `incoming` and the still-mounted `outgoing`; calls
|
|
74
|
+
* `onDone` exactly once (after the animation, or immediately for none/unsupported) — the
|
|
75
|
+
* caller physically unmounts the outgoing side there. A new play() snaps the previous
|
|
76
|
+
* transition to its end first. */
|
|
77
|
+
const play = (opts: {
|
|
78
|
+
incoming?: Side
|
|
79
|
+
outgoing?: Side
|
|
80
|
+
transition: string | TransitionSpecLike | null | undefined
|
|
81
|
+
onDone: () => void
|
|
82
|
+
}) => {
|
|
83
|
+
finishActive?.()
|
|
84
|
+
|
|
85
|
+
const spec = typeof opts.transition === "string" ? NAMED[opts.transition] : opts.transition ?? undefined
|
|
86
|
+
const inSide = opts.incoming && !opts.incoming.static ? opts.incoming.el : null
|
|
87
|
+
const outSide = opts.outgoing && !opts.outgoing.static ? opts.outgoing.el : null
|
|
88
|
+
|
|
89
|
+
let inFrom = spec?.incoming?.from
|
|
90
|
+
let outTo = spec?.outgoing?.to
|
|
91
|
+
|
|
92
|
+
// Static-side degradations (see header). A poseless spec ("none") stays instant — the
|
|
93
|
+
// degradations only reshape transitions that actually wanted to move something.
|
|
94
|
+
if (opts.incoming?.static && outSide && (spec?.incoming || spec?.outgoing)) {
|
|
95
|
+
// The incoming scene can't animate: unless this is an exit-shaped transition (onTop:
|
|
96
|
+
// "outgoing" — pop/zoom-out already fully remove the old side), the outgoing screen leaves
|
|
97
|
+
// by the mirror of the entry it was supposed to be covered by.
|
|
98
|
+
if (spec?.onTop !== "outgoing") outTo = inFrom ? mirrorExit(inFrom) : (outTo ?? { opacity: 0 })
|
|
99
|
+
inFrom = undefined
|
|
100
|
+
}
|
|
101
|
+
if (opts.outgoing?.static && inSide && !inFrom && spec && Object.keys(spec).length > 0) {
|
|
102
|
+
inFrom = { opacity: 0 } // something must move; fade the screen in over the scene
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const hasWork = (inSide && inFrom) || (outSide && outTo)
|
|
106
|
+
if (!spec || !hasWork || typeof (inSide ?? outSide)?.animate !== "function") {
|
|
107
|
+
opts.onDone()
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const duration = spec.duration ?? 300
|
|
112
|
+
const easing = spec.easing ? `cubic-bezier(${spec.easing.join(",")})` : "cubic-bezier(0.25, 0.1, 0.25, 1)"
|
|
113
|
+
const animations: Animation[] = []
|
|
114
|
+
|
|
115
|
+
// z-order for the duration of the transition (screens only — the static canvas stays in
|
|
116
|
+
// flow at the bottom). Dim sits between the two.
|
|
117
|
+
const onTop = spec.onTop ?? "incoming"
|
|
118
|
+
const topEl = onTop === "incoming" ? inSide : outSide
|
|
119
|
+
const bottomEl = onTop === "incoming" ? outSide : inSide
|
|
120
|
+
if (topEl) topEl.style.zIndex = "3"
|
|
121
|
+
if (bottomEl) bottomEl.style.zIndex = "1"
|
|
122
|
+
|
|
123
|
+
let dimEl: HTMLElement | null = null
|
|
124
|
+
if (spec.dim && spec.dim > 0) {
|
|
125
|
+
dimEl = document.createElement("div")
|
|
126
|
+
dimEl.style.cssText = "position:absolute;inset:0;background:#000;pointer-events:none;z-index:2"
|
|
127
|
+
container.appendChild(dimEl)
|
|
128
|
+
// Entering (push): the under layer darkens; exiting (pop): it un-darkens.
|
|
129
|
+
const [a, b] = onTop === "incoming" ? [0, spec.dim] : [spec.dim, 0]
|
|
130
|
+
animations.push(dimEl.animate([{ opacity: a }, { opacity: b }], { duration, easing }))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (inSide && inFrom) {
|
|
134
|
+
animations.push(inSide.animate([poseKeyframe(inFrom), IDENTITY], { duration, easing }))
|
|
135
|
+
}
|
|
136
|
+
if (outSide && outTo) {
|
|
137
|
+
animations.push(outSide.animate([IDENTITY, poseKeyframe(outTo)], { duration, easing, fill: "forwards" }))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let done = false
|
|
141
|
+
const cleanup = () => {
|
|
142
|
+
if (done) return
|
|
143
|
+
done = true
|
|
144
|
+
finishActive = null
|
|
145
|
+
for (const a of animations) a.cancel() // drops fill:forwards so a detached-not-freed screen restores clean
|
|
146
|
+
if (topEl) topEl.style.zIndex = ""
|
|
147
|
+
if (bottomEl) bottomEl.style.zIndex = ""
|
|
148
|
+
dimEl?.remove()
|
|
149
|
+
opts.onDone()
|
|
150
|
+
}
|
|
151
|
+
finishActive = cleanup
|
|
152
|
+
Promise.all(animations.map(a => a.finished)).then(cleanup, cleanup)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { play, finish: () => finishActive?.() }
|
|
156
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// The DOM host's property-application surface (docs/ui-property-registry-plan.md, phase 3).
|
|
2
|
+
//
|
|
3
|
+
// One entry per creator-ui host property, keyed and compiler-checked against the generated
|
|
4
|
+
// registry surface (CuiProps.gen.ts): a missing key, an extra key, a reset handler on a prop
|
|
5
|
+
// that resets through the set channel, or a MISSING reset on a host-reset prop are all type
|
|
6
|
+
// errors. "unsupported" is the explicit opt-out and feeds the parity registry.
|
|
7
|
+
//
|
|
8
|
+
// String-channel values arrive here already UTF8-decoded; float/uint as numbers. The matrix
|
|
9
|
+
// singleton channel (transform) is registered here too.
|
|
10
|
+
import { CUI_HOST_PROPS, type CuiHostPropKey } from "./CuiProps.gen"
|
|
11
|
+
import { cssFontFamily } from "./createMeasureSystem"
|
|
12
|
+
import type { UIScreen } from "./UIScreen"
|
|
13
|
+
|
|
14
|
+
type ValueOf<K extends CuiHostPropKey> =
|
|
15
|
+
(typeof CUI_HOST_PROPS)[K]["channel"] extends "string" ? string : number
|
|
16
|
+
|
|
17
|
+
type Impl<K extends CuiHostPropKey> =
|
|
18
|
+
| "unsupported"
|
|
19
|
+
| ((typeof CUI_HOST_PROPS)[K]["hostReset"] extends true
|
|
20
|
+
? { set(screenId: number, nodeId: number, value: ValueOf<K>): void
|
|
21
|
+
reset(screenId: number, nodeId: number): void }
|
|
22
|
+
: { set(screenId: number, nodeId: number, value: ValueOf<K>): void
|
|
23
|
+
reset?: never })
|
|
24
|
+
|
|
25
|
+
export interface UIPropsCtx {
|
|
26
|
+
module: any
|
|
27
|
+
root: HTMLElement
|
|
28
|
+
getNode: (screenId: number, nodeId: number) => HTMLElement | undefined
|
|
29
|
+
getScreen: (screenId: number) => UIScreen | undefined
|
|
30
|
+
addStringHandler(key: string, cb: (screenId: number, nodePtr: number, str: number) => void): void
|
|
31
|
+
addFloatHandler(key: string, cb: (screenId: number, nodePtr: number, val: number) => void): void
|
|
32
|
+
addUIntHandler(key: string, cb: (screenId: number, nodePtr: number, val: number) => void): void
|
|
33
|
+
addResetHandler(key: string, cb: (screenId: number, nodePtr: number) => void): void
|
|
34
|
+
addMatrixHandler(cb: (screenId: number, nodePtr: number, matrixPtr: number) => void): void
|
|
35
|
+
addTransformOriginHandler(cb: (screenId: number, nodePtr: number, x: number, y: number, xPercent: number, yPercent: number) => void): void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const registerUIProps = (ctx: UIPropsCtx) => {
|
|
39
|
+
const { module, root, getNode, getScreen } = ctx
|
|
40
|
+
|
|
41
|
+
const hexColor = (val: number) => "#" + (val >>> 0).toString(16).padStart(8, "0")
|
|
42
|
+
|
|
43
|
+
function applyBg(el: HTMLElement) {
|
|
44
|
+
const layers = []
|
|
45
|
+
if ((el as any)._bgGradient) layers.push((el as any)._bgGradient) // сверху
|
|
46
|
+
if ((el as any)._bgImage) layers.push((el as any)._bgImage) // под градиентом
|
|
47
|
+
el.style.backgroundImage = layers.join(", ")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// lineClamp / textOverflow. Applied together (and alongside `display`, which the clamp has to
|
|
51
|
+
// take over) because -webkit-line-clamp ALWAYS ellipsizes — there is no CSS opt-out. A hard cut
|
|
52
|
+
// is therefore spelled a different way: Yoga has already sized the box to the clamped line
|
|
53
|
+
// count (see createMeasureSystem), so plain overflow:hidden does it. That makes "clip"
|
|
54
|
+
// approximate on the web — it cuts on the line-height box, not on the glyph as UIKit does.
|
|
55
|
+
const applyTextClamp = (el: HTMLElement) => {
|
|
56
|
+
const clamp = (el as any)._lineClamp as number | undefined
|
|
57
|
+
const declared = (el as any)._display as string | undefined
|
|
58
|
+
const clamped = !!clamp && clamp > 0
|
|
59
|
+
const ellipsis = clamped && (el as any)._textOverflow !== "clip"
|
|
60
|
+
|
|
61
|
+
if (ellipsis) {
|
|
62
|
+
el.style.setProperty("-webkit-box-orient", "vertical")
|
|
63
|
+
el.style.setProperty("-webkit-line-clamp", String(clamp))
|
|
64
|
+
} else {
|
|
65
|
+
el.style.removeProperty("-webkit-box-orient")
|
|
66
|
+
el.style.removeProperty("-webkit-line-clamp")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// An explicit `display: none` still wins — hiding a clamped node must keep working.
|
|
70
|
+
el.style.display = declared === "none" ? "none"
|
|
71
|
+
: ellipsis ? "-webkit-box"
|
|
72
|
+
: (declared ?? "")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Style writers shared by the side/corner families (the key doubles as the camelCase CSS prop).
|
|
76
|
+
const setStyle = (prop: string) => (screenId: number, nodeId: number, value: string) => {
|
|
77
|
+
;(getNode(screenId, nodeId)!.style as any)[prop] = value
|
|
78
|
+
}
|
|
79
|
+
const borderWidth = (side: string) => ({
|
|
80
|
+
set(screenId: number, nodeId: number, val: number) {
|
|
81
|
+
const el = getNode(screenId, nodeId)!
|
|
82
|
+
;(el.style as any)[`border${side}Width`] = val + "px"
|
|
83
|
+
el.style.boxSizing = "border-box"
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
const borderColor = (side: string) => ({
|
|
87
|
+
set(screenId: number, nodeId: number, val: number) {
|
|
88
|
+
const el = getNode(screenId, nodeId)!
|
|
89
|
+
;(el.style as any)[`border${side}Color`] = hexColor(val)
|
|
90
|
+
;(el.style as any)[`border${side}Style`] = "solid"
|
|
91
|
+
el.style.boxSizing = "border-box"
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
const cornerRadius = (corner: string) => ({
|
|
95
|
+
set(screenId: number, nodeId: number, val: number) {
|
|
96
|
+
;(getNode(screenId, nodeId)!.style as any)[`border${corner}Radius`] = val + "px"
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const impl: { [K in CuiHostPropKey]: Impl<K> } = {
|
|
101
|
+
// ---- layout-dual ----
|
|
102
|
+
// `display` is routed through applyTextClamp because an active lineClamp needs to own the
|
|
103
|
+
// property (-webkit-box). Reset dispatches "flex" through this same handler (registry default).
|
|
104
|
+
display: {
|
|
105
|
+
set(screenId, nodeId, value) {
|
|
106
|
+
const el = getNode(screenId, nodeId)!
|
|
107
|
+
;(el as any)._display = value
|
|
108
|
+
applyTextClamp(el)
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
// "visible" maps to "" so the stylesheet (e.g. .scrollable) keeps owning untouched nodes.
|
|
112
|
+
overflow: {
|
|
113
|
+
set(screenId, nodeId, value) {
|
|
114
|
+
getNode(screenId, nodeId)!.style.overflow = value === "visible" ? "" : value
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
// ---- scroll ----
|
|
119
|
+
scrollDirection: {
|
|
120
|
+
set(screenId, nodeId, value) {
|
|
121
|
+
const el = getNode(screenId, nodeId)!
|
|
122
|
+
if (value === 2) el.classList.add("scrollable_horizontal")
|
|
123
|
+
else el.classList.remove("scrollable_horizontal")
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
showScrollbar: {
|
|
127
|
+
set(screenId, nodeId, value) {
|
|
128
|
+
;(getNode(screenId, nodeId)!.style as any).scrollbarWidth = value > 0.5 ? "" : "none"
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
overscrollMode: {
|
|
132
|
+
set(screenId, nodeId, value) {
|
|
133
|
+
// "absorb" (rubber-band without chaining) has no exact CSS spelling — contain is closest.
|
|
134
|
+
;(getNode(screenId, nodeId)!.style as any).overscrollBehavior =
|
|
135
|
+
value === "default" ? "" : value === "none" ? "none" : "contain"
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
keyboardDismissMode: "unsupported", // browser owns the virtual keyboard
|
|
139
|
+
snap: "unsupported", // CSS scroll-snap needs per-child align coordination
|
|
140
|
+
refreshControlColor: "unsupported", // no DOM pull-to-refresh control
|
|
141
|
+
|
|
142
|
+
// ---- border ----
|
|
143
|
+
borderWidth: borderWidth(""),
|
|
144
|
+
borderTopWidth: borderWidth("Top"),
|
|
145
|
+
borderRightWidth: borderWidth("Right"),
|
|
146
|
+
borderBottomWidth: borderWidth("Bottom"),
|
|
147
|
+
borderLeftWidth: borderWidth("Left"),
|
|
148
|
+
// Yoga lays these edge variants out, but no host paints them (registry note).
|
|
149
|
+
borderHorizontalWidth: "unsupported",
|
|
150
|
+
borderVerticalWidth: "unsupported",
|
|
151
|
+
borderStartWidth: "unsupported",
|
|
152
|
+
borderEndWidth: "unsupported",
|
|
153
|
+
borderColor: borderColor(""),
|
|
154
|
+
borderTopColor: borderColor("Top"),
|
|
155
|
+
borderRightColor: borderColor("Right"),
|
|
156
|
+
borderBottomColor: borderColor("Bottom"),
|
|
157
|
+
borderLeftColor: borderColor("Left"),
|
|
158
|
+
borderRadius: {
|
|
159
|
+
set(screenId, nodeId, val) { getNode(screenId, nodeId)!.style.borderRadius = val + "px" },
|
|
160
|
+
},
|
|
161
|
+
// Per-corner longhands, dispatched by the core's masked `borderRadius` string path ("0 0 20 20").
|
|
162
|
+
borderTopLeftRadius: cornerRadius("TopLeft"),
|
|
163
|
+
borderTopRightRadius: cornerRadius("TopRight"),
|
|
164
|
+
borderBottomRightRadius: cornerRadius("BottomRight"),
|
|
165
|
+
borderBottomLeftRadius: cornerRadius("BottomLeft"),
|
|
166
|
+
|
|
167
|
+
// ---- text ----
|
|
168
|
+
// Theme-kind reset: the core dispatches the theme value through set; only a theme WITHOUT a
|
|
169
|
+
// `color` key lands here — clear the inline value so defaultStyle.css's cascade takes over.
|
|
170
|
+
color: {
|
|
171
|
+
set(screenId, nodeId, val) { getNode(screenId, nodeId)!.style.color = hexColor(val) },
|
|
172
|
+
reset(screenId, nodeId) { getNode(screenId, nodeId)!.style.color = "" },
|
|
173
|
+
},
|
|
174
|
+
fontFamily: {
|
|
175
|
+
set(screenId, nodeId, value) {
|
|
176
|
+
getNode(screenId, nodeId)!.style.fontFamily = cssFontFamily(value)
|
|
177
|
+
},
|
|
178
|
+
reset(screenId, nodeId) { getNode(screenId, nodeId)!.style.fontFamily = "" },
|
|
179
|
+
},
|
|
180
|
+
fontSize: {
|
|
181
|
+
set(screenId, nodeId, val) { getNode(screenId, nodeId)!.style.fontSize = val + "px" },
|
|
182
|
+
},
|
|
183
|
+
fontWeight: {
|
|
184
|
+
set(screenId, nodeId, val) { getNode(screenId, nodeId)!.style.fontWeight = val.toString() },
|
|
185
|
+
},
|
|
186
|
+
fontStyle: {
|
|
187
|
+
set(screenId, nodeId, val) {
|
|
188
|
+
if (val === 0) getNode(screenId, nodeId)!.style.fontStyle = "normal"
|
|
189
|
+
else if (val === 2) getNode(screenId, nodeId)!.style.fontStyle = "italic"
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
lineHeight: {
|
|
193
|
+
set(screenId, nodeId, val) { getNode(screenId, nodeId)!.style.lineHeight = val + "px" },
|
|
194
|
+
},
|
|
195
|
+
letterSpacing: {
|
|
196
|
+
set(screenId, nodeId, val) { getNode(screenId, nodeId)!.style.letterSpacing = val + "px" },
|
|
197
|
+
},
|
|
198
|
+
textAlign: { set: setStyle("textAlign") },
|
|
199
|
+
textDecoration: { set: setStyle("textDecoration") },
|
|
200
|
+
lineClamp: {
|
|
201
|
+
set(screenId, nodeId, val) {
|
|
202
|
+
const el = getNode(screenId, nodeId)!
|
|
203
|
+
;(el as any)._lineClamp = val
|
|
204
|
+
// `overflow` is a function of the clamp alone, written ONLY here — never from
|
|
205
|
+
// applyTextClamp, which also runs for `display` on non-text nodes and would clear the
|
|
206
|
+
// overflow a scrollable/cell set at build time (UIScreen). Reset dispatches 0 here too.
|
|
207
|
+
el.style.overflow = val > 0 ? "hidden" : ""
|
|
208
|
+
applyTextClamp(el)
|
|
209
|
+
// The clamp changes the node's measured HEIGHT but touches no Yoga style, so nothing marks
|
|
210
|
+
// the leaf dirty on its own — Yoga would reuse the cached full-text measure. The recalc()
|
|
211
|
+
// that updateStyle/mergeStyle run after this handler then picks up the new size.
|
|
212
|
+
module._markNodeDirty(nodeId)
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
textOverflow: {
|
|
216
|
+
set(screenId, nodeId, value) {
|
|
217
|
+
const el = getNode(screenId, nodeId)!
|
|
218
|
+
;(el as any)._textOverflow = value
|
|
219
|
+
applyTextClamp(el)
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
// ---- box / background ----
|
|
224
|
+
backgroundColor: {
|
|
225
|
+
set(screenId, nodeId, val) { getNode(screenId, nodeId)!.style.backgroundColor = hexColor(val) },
|
|
226
|
+
},
|
|
227
|
+
backgroundImage: {
|
|
228
|
+
set(screenId, nodeId, value) {
|
|
229
|
+
const el = getNode(screenId, nodeId)!
|
|
230
|
+
// "" is the registry's no-image default — clear the layer (never emit `url()`).
|
|
231
|
+
;(el as any)._bgImage = value ? `url(${value})` : undefined
|
|
232
|
+
applyBg(el)
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
backgroundSize: { set: setStyle("backgroundSize") },
|
|
236
|
+
// The raw validated CSS gradient string, emitted alongside the struct channel for DOM hosts;
|
|
237
|
+
// "" = no gradient (parse failure or removal).
|
|
238
|
+
backgroundGradientStr: {
|
|
239
|
+
set(screenId, nodeId, value) {
|
|
240
|
+
const el = getNode(screenId, nodeId)!
|
|
241
|
+
;(el as any)._bgGradient = value || undefined
|
|
242
|
+
applyBg(el)
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
rippleColor: "unsupported", // no press-ripple system on the DOM host
|
|
246
|
+
|
|
247
|
+
// ---- effects ----
|
|
248
|
+
opacity: {
|
|
249
|
+
set(screenId, nodeId, val) { getNode(screenId, nodeId)!.style.opacity = val.toString() },
|
|
250
|
+
},
|
|
251
|
+
pointerEvents: {
|
|
252
|
+
set(screenId, nodeId, value) {
|
|
253
|
+
getNode(screenId, nodeId)!.style.pointerEvents = value === "none" ? "none" : "auto"
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
// ---- image ----
|
|
258
|
+
objectFit: {
|
|
259
|
+
set(screenId, nodeId, value) {
|
|
260
|
+
const el = getNode(screenId, nodeId)!
|
|
261
|
+
// An explicit sourceRect crop overrides objectFit (the frame always fills the box, via
|
|
262
|
+
// background-size on the crop <div>) — ignore objectFit rather than picking a least-bad fit.
|
|
263
|
+
if ((el as any)._sourceRect) return
|
|
264
|
+
if (el.hasAttribute("data-viewbox")) {
|
|
265
|
+
const size = value === "cover" ? "cover" : value === "fill" ? "100% 100%" : "contain"
|
|
266
|
+
const s = el.style as any
|
|
267
|
+
s.webkitMaskSize = s.maskSize = size
|
|
268
|
+
} else {
|
|
269
|
+
el.style.objectFit = value
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
tintColor: {
|
|
274
|
+
set(screenId, nodeId, val) {
|
|
275
|
+
const tint = hexColor(val)
|
|
276
|
+
const screen = getScreen(screenId)
|
|
277
|
+
const el = screen?.nodeMap.get(nodeId)!
|
|
278
|
+
if (el instanceof HTMLDivElement) {
|
|
279
|
+
el.style.backgroundColor = tint // already masked div → just recolor
|
|
280
|
+
} else if (el instanceof HTMLImageElement && el.hasAttribute("data-viewbox")) {
|
|
281
|
+
screen!.tintSvgImage(nodeId, tint) // svg img + new tint → swap to masked div
|
|
282
|
+
} else {
|
|
283
|
+
el.style.backgroundColor = tint // non-svg img: harmless, no mask possible
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
// Presence prop: no tint = the image's original colors (masked div swaps back to <img>).
|
|
287
|
+
reset(screenId, nodeId) { getScreen(screenId)?.untintSvgImage(nodeId) },
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
// ---- input ----
|
|
291
|
+
placeholder: {
|
|
292
|
+
set(screenId, nodeId, value) {
|
|
293
|
+
;(getNode(screenId, nodeId) as HTMLInputElement).placeholder = value
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
placeholderColor: {
|
|
297
|
+
set(screenId, nodeId, val) {
|
|
298
|
+
;(getNode(screenId, nodeId) as HTMLInputElement).style
|
|
299
|
+
.setProperty("--placeholder-color", hexColor(val))
|
|
300
|
+
},
|
|
301
|
+
// Platform-default prop: absent = the browser's placeholder gray (defaultStyle.css fallback).
|
|
302
|
+
reset(screenId, nodeId) {
|
|
303
|
+
;(getNode(screenId, nodeId) as HTMLInputElement).style.removeProperty("--placeholder-color")
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
type: {
|
|
307
|
+
set(screenId, nodeId, value) {
|
|
308
|
+
;(getNode(screenId, nodeId) as HTMLInputElement).type = value
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
enterKey: {
|
|
312
|
+
set(screenId, nodeId, value) {
|
|
313
|
+
;(getNode(screenId, nodeId) as HTMLInputElement).enterKeyHint = value
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
autocapitalize: {
|
|
317
|
+
set(screenId, nodeId, value) {
|
|
318
|
+
;(getNode(screenId, nodeId) as HTMLInputElement).autocapitalize = value
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
autocorrect: {
|
|
322
|
+
set(screenId, nodeId, val) {
|
|
323
|
+
getNode(screenId, nodeId)!.setAttribute("autocorrect", val > 0.5 ? "on" : "off")
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
maxLength: {
|
|
327
|
+
set(screenId, nodeId, val) {
|
|
328
|
+
const el = getNode(screenId, nodeId) as HTMLInputElement
|
|
329
|
+
if (val > 0) el.maxLength = val
|
|
330
|
+
else el.removeAttribute("maxlength") // registry: 0 = unlimited
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
keyboardShrink: "unsupported", // browser owns the visual-viewport policy
|
|
334
|
+
keyboardDismiss: "unsupported",
|
|
335
|
+
|
|
336
|
+
// ---- widget ----
|
|
337
|
+
// overlayColor: a full-screen layer behind the widget that intercepts clicks. It is kept
|
|
338
|
+
// separate from the widget subtree and sits below `.widget` (z-index 10) but above the scene.
|
|
339
|
+
// A pointerup on it fires onOverlayTap. The layer exists for any non-null overlayColor
|
|
340
|
+
// ("transparent" included); only overlayColor: null removes it (presence prop → host reset).
|
|
341
|
+
// Teardown on hide is handled by UIScreen.removeFromHTML.
|
|
342
|
+
overlayColor: {
|
|
343
|
+
set(screenId, _nodeId, val) {
|
|
344
|
+
const screen = getScreen(screenId)
|
|
345
|
+
if (!screen) return
|
|
346
|
+
let overlay = screen.overlayElement
|
|
347
|
+
if (!overlay) {
|
|
348
|
+
overlay = document.createElement("div")
|
|
349
|
+
overlay.style.position = "absolute"
|
|
350
|
+
overlay.style.inset = "0"
|
|
351
|
+
overlay.style.pointerEvents = "auto"
|
|
352
|
+
overlay.style.zIndex = "9"
|
|
353
|
+
overlay.addEventListener("click", () => {
|
|
354
|
+
for (const listener of screen.node.otl ?? []) listener()
|
|
355
|
+
})
|
|
356
|
+
root.appendChild(overlay)
|
|
357
|
+
screen.overlayElement = overlay
|
|
358
|
+
}
|
|
359
|
+
overlay.style.background = hexColor(val)
|
|
360
|
+
},
|
|
361
|
+
reset(screenId) {
|
|
362
|
+
const screen = getScreen(screenId)
|
|
363
|
+
if (!screen) return
|
|
364
|
+
screen.overlayElement?.remove()
|
|
365
|
+
screen.overlayElement = null
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
sheetDetents: "unsupported", // docs/bottom-sheet-plan.md owns these — no web sheet yet
|
|
369
|
+
sheetDetent: "unsupported",
|
|
370
|
+
sheetDismissible: "unsupported",
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---- registration (channel-driven; string pointers decode before the impl sees them) --------
|
|
374
|
+
const unsupported: CuiHostPropKey[] = []
|
|
375
|
+
for (const key of Object.keys(CUI_HOST_PROPS) as CuiHostPropKey[]) {
|
|
376
|
+
const entry = impl[key] as { set: Function, reset?: Function } | "unsupported"
|
|
377
|
+
if (entry === "unsupported") { unsupported.push(key); continue }
|
|
378
|
+
const { channel } = CUI_HOST_PROPS[key]
|
|
379
|
+
if (channel === "string") {
|
|
380
|
+
ctx.addStringHandler(key, (screenId, nodePtr, str) =>
|
|
381
|
+
entry.set(screenId, nodePtr, module.UTF8ToString(str)))
|
|
382
|
+
} else if (channel === "uint") {
|
|
383
|
+
ctx.addUIntHandler(key, (screenId, nodePtr, val) => entry.set(screenId, nodePtr, val))
|
|
384
|
+
} else {
|
|
385
|
+
ctx.addFloatHandler(key, (screenId, nodePtr, val) => entry.set(screenId, nodePtr, val))
|
|
386
|
+
}
|
|
387
|
+
if (entry.reset) ctx.addResetHandler(key, (screenId, nodePtr) => entry.reset!(screenId, nodePtr))
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Singleton matrix channel (transform).
|
|
391
|
+
ctx.addMatrixHandler((screenId, nodePtr, matrixPtr) => {
|
|
392
|
+
const i = matrixPtr >> 2
|
|
393
|
+
const f = module.HEAPF32
|
|
394
|
+
getNode(screenId, nodePtr)!.style.transform =
|
|
395
|
+
`matrix(${f[i + 0]}, ${f[i + 1]}, ${f[i + 3]}, ${f[i + 4]}, ${f[i + 6]}, ${f[i + 7]})`
|
|
396
|
+
})
|
|
397
|
+
// Singleton origin channel (transformOrigin) — the matrix's pivot, per axis % of the box or px.
|
|
398
|
+
ctx.addTransformOriginHandler((screenId, nodePtr, x, y, xPercent, yPercent) => {
|
|
399
|
+
getNode(screenId, nodePtr)!.style.transformOrigin = `${x}${xPercent ? "%" : "px"} ${y}${yPercent ? "%" : "px"}`
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
return { unsupported }
|
|
403
|
+
}
|
package/src/ui/router.ts
ADDED
package/src/ui/shells.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Device shells the runner can emulate for an embedder that drives it by name (the landing's
|
|
2
|
+
// `?shell=` / `lecodes-shell`). "abstract" is the neutral frame with the host's default web insets;
|
|
3
|
+
// "ios"/"android" pin the safe-area insets to that device's real values so a screen that skips
|
|
4
|
+
// safe-top visibly collides with the Dynamic Island / status bar.
|
|
5
|
+
//
|
|
6
|
+
// The values mirror the editor's SHELL_SAFE_AREAS (frontend simulator/shells.ts) and lecodes-design's
|
|
7
|
+
// board shells (`lecodes render --device`) — portrait matches the board; landscape is the simulator's
|
|
8
|
+
// own (the board is portrait-only). This is the runner-owned copy so the catalog lives with the host
|
|
9
|
+
// that applies it; the URL/postMessage driver only needs a shell NAME from the embedder.
|
|
10
|
+
|
|
11
|
+
import type { SafeAreaSpec } from "./safeAreaSpec"
|
|
12
|
+
|
|
13
|
+
export type SimulatorShell = "abstract" | "ios" | "android"
|
|
14
|
+
|
|
15
|
+
/** Ordered shell ids (also the accepted `?shell=` values). */
|
|
16
|
+
export const SIMULATOR_SHELLS: SimulatorShell[] = ["abstract", "ios", "android"]
|
|
17
|
+
|
|
18
|
+
export const SHELL_SAFE_AREAS: Record<SimulatorShell, SafeAreaSpec | undefined> = {
|
|
19
|
+
abstract: undefined,
|
|
20
|
+
// Dynamic Island + home indicator (iPhone 14 Pro+) — clearance zones, breathing room built in.
|
|
21
|
+
// Landscape hides the status bar: symmetric island-side insets + a shallower home indicator.
|
|
22
|
+
ios: { portrait: [59, 0, 34, 0], landscape: [0, 59, 21, 59] },
|
|
23
|
+
// Status bar + 3-button navigation bar — both exact-height system BARS (`comfort-*` ADDS its
|
|
24
|
+
// knob past them instead of flooring). Both stay put in landscape.
|
|
25
|
+
android: {
|
|
26
|
+
portrait: [24, 0, 48, 0],
|
|
27
|
+
landscape: [24, 0, 48, 0],
|
|
28
|
+
portraitBars: ["top", "bottom"],
|
|
29
|
+
landscapeBars: ["top", "bottom"],
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Narrow an untrusted string (URL param / postMessage) to a known shell; anything else → abstract. */
|
|
34
|
+
export const asShell = (raw: unknown): SimulatorShell =>
|
|
35
|
+
typeof raw === "string" && (SIMULATOR_SHELLS as string[]).includes(raw)
|
|
36
|
+
? (raw as SimulatorShell)
|
|
37
|
+
: "abstract"
|
package/src/ui/toasts.ts
ADDED