lecodes-web-canvas 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ import type { BorderRadius, RenderNode, ResolvedStyle } from "../types"
2
+ import { rgbaToHex } from "../core/util/colors"
3
+
4
+ export interface SerializeOptions {
5
+ /** Decimal places for rects (default 2). */
6
+ precision?: number
7
+ /** Include the `id` (creator-ui node id) — off by default; ids are unstable across runs. */
8
+ includeIds?: boolean
9
+ }
10
+
11
+ /**
12
+ * Serialize a render tree into a plain, JSON-friendly object — the primary text output. Colors
13
+ * become "#rrggbbaa", rects are rounded, and only meaningful fields appear, so the result is
14
+ * readable for AI feedback and stable for snapshot assertions.
15
+ */
16
+ export function serializeRenderTree(tree: RenderNode | null, opts: SerializeOptions = {}): unknown {
17
+ if (!tree) return null
18
+ return serializeNode(tree, opts.precision ?? 2, opts.includeIds ?? false)
19
+ }
20
+
21
+ /** Convenience: the serialized tree as pretty JSON text. */
22
+ export function renderTreeToText(tree: RenderNode | null, opts?: SerializeOptions): string {
23
+ return JSON.stringify(serializeRenderTree(tree, opts), null, 2)
24
+ }
25
+
26
+ /**
27
+ * Serialize a whole frame — the main screen plus any widget overlays — so the JSON describes
28
+ * everything painted (PNG/JSON parity). Widget trees are attached as an `overlays` array on the main
29
+ * root (they composite above it). With no widgets this returns the bare main tree, unchanged; with no
30
+ * main screen but live widgets it returns `{ type: "frame", overlays }`.
31
+ */
32
+ export function serializeFrame(main: RenderNode | null, widgets: RenderNode[] = [], opts: SerializeOptions = {}): unknown {
33
+ const precision = opts.precision ?? 2
34
+ const includeIds = opts.includeIds ?? false
35
+ const root = main ? serializeNode(main, precision, includeIds) : null
36
+ if (widgets.length === 0) return root
37
+ const overlays = widgets.map((w) => serializeNode(w, precision, includeIds))
38
+ if (root) {
39
+ root.overlays = overlays
40
+ return root
41
+ }
42
+ return { type: "frame", overlays }
43
+ }
44
+
45
+ /** Convenience: the serialized frame (screen + overlays) as pretty JSON text. */
46
+ export function frameToText(main: RenderNode | null, widgets: RenderNode[] = [], opts?: SerializeOptions): string {
47
+ return JSON.stringify(serializeFrame(main, widgets, opts), null, 2)
48
+ }
49
+
50
+ function round(n: number, p: number): number {
51
+ const f = 10 ** p
52
+ return Math.round(n * f) / f
53
+ }
54
+
55
+ function serializeNode(node: RenderNode, precision: number, includeIds: boolean): any {
56
+ const r = node.rect
57
+ const out: any = { type: node.type }
58
+ if (node.name) out.name = node.name
59
+ out.rect = [round(r.x, precision), round(r.y, precision), round(r.width, precision), round(r.height, precision)]
60
+ if (includeIds) out.id = node.id
61
+
62
+ const style = serializeStyle(node.style)
63
+ if (style) out.style = style
64
+
65
+ if (node.text !== undefined) out.text = node.text
66
+ if (node.lines && (node.lines.length !== 1 || node.lines[0] !== node.text)) out.lines = node.lines
67
+ // Editables: show what the field says — its typed value, or the placeholder an empty field
68
+ // renders (also the AI-facing selector for label-less inputs).
69
+ if (node.value !== undefined && node.value !== "") out.value = node.value
70
+ if (node.style.placeholder !== undefined) out.placeholder = node.style.placeholder
71
+ if (node.image) {
72
+ out.image = { src: node.image.src, natural: [node.image.naturalWidth, node.image.naturalHeight] }
73
+ }
74
+ if (node.sourceRect) out.sourceRect = node.sourceRect
75
+ if (node.state && (node.state.pressed || node.state.classes?.length)) {
76
+ out.state = {
77
+ ...(node.state.pressed ? { pressed: true } : {}),
78
+ ...(node.state.classes?.length ? { classes: node.state.classes } : {}),
79
+ }
80
+ }
81
+ if (node.scroll) {
82
+ out.scroll = { offset: round(node.scroll.offset, precision), content: round(node.scroll.content, precision) }
83
+ }
84
+
85
+ if (node.children.length > 0) {
86
+ out.children = node.children.map((c) => serializeNode(c, precision, includeIds))
87
+ }
88
+ return out
89
+ }
90
+
91
+ function radiusObj(radius: BorderRadius) {
92
+ if (radius.tl === radius.tr && radius.tr === radius.br && radius.br === radius.bl) return radius.tl
93
+ return [radius.tl, radius.tr, radius.br, radius.bl]
94
+ }
95
+
96
+ function serializeStyle(s: ResolvedStyle): Record<string, unknown> | undefined {
97
+ const out: Record<string, unknown> = {}
98
+ if (s.backgroundColor !== undefined) out.backgroundColor = rgbaToHex(s.backgroundColor)
99
+ if (s.opacity !== undefined) out.opacity = s.opacity
100
+ if (s.color !== undefined) out.color = rgbaToHex(s.color)
101
+ if (s.fontSize !== undefined) out.fontSize = s.fontSize
102
+ if (s.fontFamily !== undefined) out.fontFamily = s.fontFamily
103
+ if (s.fontWeight !== undefined) out.fontWeight = s.fontWeight
104
+ if (s.fontStyle !== undefined && s.fontStyle !== "normal") out.fontStyle = s.fontStyle
105
+ if (s.lineHeight !== undefined) out.lineHeight = s.lineHeight
106
+ if (s.letterSpacing) out.letterSpacing = s.letterSpacing
107
+ if (s.textAlign !== undefined && s.textAlign !== "start") out.textAlign = s.textAlign
108
+ if (s.textDecoration !== undefined && s.textDecoration !== "none") out.textDecoration = s.textDecoration
109
+ // Only an ACTIVE clamp is worth showing: `lines` already carries the visible result (with its "…"),
110
+ // and `textOverflow` means nothing without a clamp.
111
+ if (s.lineClamp) {
112
+ out.lineClamp = s.lineClamp
113
+ if (s.textOverflow === "clip") out.textOverflow = s.textOverflow
114
+ }
115
+ if (s.objectFit !== undefined) out.objectFit = s.objectFit
116
+ if (s.tintColor !== undefined) out.tintColor = rgbaToHex(s.tintColor)
117
+ if (s.overlayColor !== undefined) out.overlayColor = rgbaToHex(s.overlayColor)
118
+ if (s.backgroundImage !== undefined) out.backgroundImage = s.backgroundImage
119
+ if (s.backgroundSize !== undefined) out.backgroundSize = s.backgroundSize
120
+ if (s.backgroundGradients !== undefined && s.backgroundGradients.length > 0) {
121
+ out.backgroundGradients = s.backgroundGradients.map((g) => {
122
+ const stops = g.stops.map((st) => ({ color: rgbaToHex(st.color), position: st.position }))
123
+ if (g.type === "radial") {
124
+ const rad: Record<string, unknown> = { type: "radial", shape: g.shape, extent: g.extent, cx: g.cx, cy: g.cy, stops }
125
+ if (g.extent === "explicit") {
126
+ rad.rx = g.rx
127
+ rad.ry = g.ry
128
+ rad.rxUnit = g.rxUnit
129
+ rad.ryUnit = g.ryUnit
130
+ }
131
+ return rad
132
+ }
133
+ return { angle: g.angle, stops } // linear stays shape-compatible with the pre-radial output
134
+ })
135
+ }
136
+
137
+ if (s.borderRadius && (s.borderRadius.tl || s.borderRadius.tr || s.borderRadius.br || s.borderRadius.bl)) {
138
+ out.borderRadius = radiusObj(s.borderRadius)
139
+ }
140
+ if (s.border) {
141
+ const b: Record<string, unknown> = {}
142
+ for (const side of ["top", "right", "bottom", "left"] as const) {
143
+ const sd = s.border[side]
144
+ if (sd && sd.width > 0) b[side] = { width: sd.width, color: rgbaToHex(sd.color) }
145
+ }
146
+ if (Object.keys(b).length > 0) out.border = b
147
+ }
148
+
149
+ return Object.keys(out).length > 0 ? out : undefined
150
+ }
package/src/types.ts ADDED
@@ -0,0 +1,299 @@
1
+ // Shared, platform-agnostic types for the renderer.
2
+
3
+ /** An axis-aligned rectangle in CSS pixels. */
4
+ export interface Rect {
5
+ x: number
6
+ y: number
7
+ width: number
8
+ height: number
9
+ }
10
+
11
+ /**
12
+ * A color packed as 0xRRGGBBAA (the same layout creator-ui hands back to the uint color handlers,
13
+ * and the same RGBA packing the desktop tgfx renderer uses). `null`/absent = no color.
14
+ */
15
+ export type RGBA = number
16
+
17
+ export interface BorderSide {
18
+ width: number
19
+ color: RGBA
20
+ }
21
+
22
+ export interface BorderRadius {
23
+ tl: number
24
+ tr: number
25
+ br: number
26
+ bl: number
27
+ }
28
+
29
+ export interface GradientStop {
30
+ color: RGBA
31
+ /** 0..1 */
32
+ position: number
33
+ }
34
+
35
+ /** A linear gradient. `angle` is CSS degrees (0 = to top, 90 = to right, clockwise). */
36
+ export interface LinearGradient {
37
+ type: "linear"
38
+ angle: number
39
+ stops: GradientStop[]
40
+ }
41
+
42
+ /** CSS `<extent-keyword>` for a radial gradient (default `farthest-corner`). */
43
+ export type RadialExtent = "closest-side" | "closest-corner" | "farthest-side" | "farthest-corner"
44
+
45
+ /**
46
+ * A radial gradient — the frequently-used subset creator-ui resolves into geometry: shape, size, and
47
+ * a keyword/percent center (`cx`/`cy` as a fraction of the box, 0..1). Size is either a CSS extent
48
+ * keyword or `"explicit"` — the latter carries `rx`/`ry` (CSS px, or a box-axis fraction when the
49
+ * matching unit is `"fraction"`, from a `%` radius).
50
+ */
51
+ export interface RadialGradient {
52
+ type: "radial"
53
+ shape: "circle" | "ellipse"
54
+ extent: RadialExtent | "explicit"
55
+ cx: number
56
+ cy: number
57
+ rx?: number
58
+ ry?: number
59
+ rxUnit?: "px" | "fraction"
60
+ ryUnit?: "px" | "fraction"
61
+ stops: GradientStop[]
62
+ }
63
+
64
+ export type Gradient = LinearGradient | RadialGradient
65
+
66
+ /**
67
+ * Paint-ready style for one node. Filled by the layout engine's per-prop style handlers, plus
68
+ * text-property inheritance resolved while building the render tree. Only paint-relevant props
69
+ * live here — layout props (flex, padding, width…) are consumed inside creator-ui.
70
+ */
71
+ export interface ResolvedStyle {
72
+ backgroundColor?: RGBA
73
+ opacity?: number
74
+ /** A screen: black over its content and children, 0..1 (a transition's pose). */
75
+ dim?: number
76
+
77
+ border?: Partial<Record<"top" | "right" | "bottom" | "left", BorderSide>>
78
+ borderRadius?: BorderRadius
79
+ overflow?: "visible" | "hidden"
80
+
81
+ /** `"none"` removes the node (and its subtree) from the render tree; anything else shows it. */
82
+ display?: string
83
+
84
+ // text (resolved with inheritance)
85
+ color?: RGBA
86
+ fontSize?: number
87
+ fontFamily?: string
88
+ fontWeight?: number
89
+ fontStyle?: "normal" | "italic"
90
+ /** Always set by the record: the core resolves `normal` (fontSize × 1.2) and every explicit form. */
91
+ lineHeight: number
92
+ letterSpacing?: number
93
+ textAlign?: "start" | "center" | "end"
94
+ textDecoration?: "underline" | "line-through" | "none"
95
+ /** Cap the text at N lines (0 / undefined = unbounded). NOT inherited, unlike the props above —
96
+ * it belongs to the text node that carries it (registry `nodes: ["text"]`). */
97
+ lineClamp?: number
98
+ /** How a clamped line ends. Only meaningful together with `lineClamp`; default "ellipsis". */
99
+ textOverflow?: "ellipsis" | "clip"
100
+
101
+ // image
102
+ objectFit?: "cover" | "contain" | "fill"
103
+ tintColor?: RGBA
104
+
105
+ // input / textarea
106
+ placeholder?: string
107
+ placeholderColor?: RGBA
108
+ inputType?: string
109
+
110
+ // background image (string url) painted behind content
111
+ backgroundImage?: string
112
+ backgroundSize?: "cover" | "contain" | "tile"
113
+
114
+ // background gradient layer stack (parsed from the CSS string the wasm forwards). `[0]` is the
115
+ // topmost layer — CSS paints the first-listed background-image layer on top. Usually one entry.
116
+ backgroundGradients?: Gradient[]
117
+
118
+ /** Scroll axis for a scrollable node ("x" = horizontal, "y" = vertical, the default). */
119
+ scrollAxis?: "x" | "y"
120
+
121
+ // widget: a full-screen backdrop behind the widget that intercepts taps (onOverlayTap)
122
+ overlayColor?: RGBA
123
+
124
+ /** 2D affine transform [a,b,c,d,e,f] (creator-ui parses the `transform` string → matrix). Applied
125
+ * about the node's centre (CSS default), to the node + its subtree. Undefined = identity. */
126
+ transform?: [number, number, number, number, number, number]
127
+ /** Pivot of `transform`, per axis a % of the box (`percent`) or px (creator-ui parses the
128
+ * `transformOrigin` string and dispatches it typed on its origin channel). Undefined = the centre. */
129
+ transformOrigin?: { x: number, y: number, xPercent: boolean, yPercent: boolean }
130
+ }
131
+
132
+ export type NodeType =
133
+ | "screen"
134
+ | "box"
135
+ | "row"
136
+ | "column"
137
+ | "text"
138
+ | "image"
139
+ | "button"
140
+ | string
141
+
142
+ /**
143
+ * The authoring-layer (worker) node the renderer consumes — the other end of the `_creatorUI`
144
+ * contract. The renderer never mutates it for layout (it mirrors it into a `SceneNode`); it only reads
145
+ * these fields and, once, writes back `_id`. The single-letter listener arrays and `_`-prefixed fields
146
+ * are the stable wire names the worker emits (chisel's method-DCE keeps `_*` members) — documented once
147
+ * here so Renderer / Screen / SceneNode / TouchSystem don't each re-discover them. The index signature
148
+ * keeps forward-compatibility with fields not yet enumerated.
149
+ */
150
+ export interface WorkerNode {
151
+ type: string
152
+ /** The creator-ui (Yoga) node id — assigned by the renderer at mount, 0 once released. */
153
+ _id: number
154
+ /** The raw authoring style object (primitives + `$`-class state blocks like `$pressed`). */
155
+ _style?: Record<string, any>
156
+ /** Active user style classes (names without the leading `$`), set via `setClass`. Kept on the
157
+ * node so a re-mounted node (screens re-assign `_id` on every open) comes back with them active. */
158
+ _classes?: Set<string>
159
+ children?: (WorkerNode | null | undefined)[]
160
+ /** Author-given semantic name (LeCodes `name`). */
161
+ name?: string
162
+
163
+ // ----- content (read the `_`-backing first; the public getter is DCE'd outside the bundle) -----
164
+ text?: string
165
+ _text?: string
166
+ src?: unknown
167
+ _src?: unknown
168
+ /** Image source crop (atlas frame), texture px, stashed before mount. */
169
+ _sourceRect?: [number, number, number, number]
170
+ /** Current input/textarea value (the worker's `input.value`). */
171
+ value?: unknown
172
+ /** Input/textarea value as last set by code, stashed before mount (pre-filled form). */
173
+ _value?: string
174
+
175
+ // ----- flags -----
176
+ scrollable?: boolean
177
+ /** vlist: an inverted (chat) list — starts scrolled to the end; appends stick to the bottom. */
178
+ _inverted?: boolean
179
+
180
+ // ----- vlist windowing (the renderer drives `_syncWindow`; the worker re-renders items) -----
181
+ _keys?: string[]
182
+ _estimates?: number[]
183
+ _syncWindow?: (removed: string[], keys: string[]) => void
184
+ _endThreshold?: number
185
+ _startThreshold?: number
186
+
187
+ // ----- interaction (node-level dispatch, like the Android views) -----
188
+ _emitClick?: (fingerId: number, x: number, y: number) => any
189
+ _emitTouchStart?: (fingerId: number, x: number, y: number) => any
190
+ touchStartListeners?: any[]
191
+
192
+ // ----- listeners (worker wire names) -----
193
+ ol?: (() => void)[] // onOpen (screen)
194
+ cl?: ((...args: any[]) => void)[] // onClose (screen) OR onChange (input) — overloaded by type
195
+ ll?: ((layout: { left: number; top: number; width: number; height: number }) => void)[] // onLayout
196
+ fl?: (() => void)[] // onFocus (input)
197
+ bl?: (() => void)[] // onBlur (input)
198
+ sl?: ((offset: number) => void)[] // onScroll
199
+ osl?: ((over: number) => void)[] // onOverscroll
200
+ srl?: (() => void)[] // onScrollRelease
201
+ otl?: (() => void)[] // onOverlayTap (widget)
202
+ erl?: (() => void)[] // onEndReached (vlist)
203
+ strl?: (() => void)[] // onStartReached (vlist)
204
+
205
+ /** Escape hatch for worker fields not yet enumerated here. */
206
+ [key: string]: any
207
+ }
208
+
209
+ /** Decoded image info, produced by the host. */
210
+ export interface ImageInfo {
211
+ src: string
212
+ naturalWidth: number
213
+ naturalHeight: number
214
+ /** Opaque backend handle (e.g. an HTMLImageElement / ImageBitmap) for the canvas painter. */
215
+ handle?: unknown
216
+ /** A vector source (SVG) the host can re-rasterize: the painter asks for the handle at the DEVICE
217
+ * size it is about to draw (w × h px), so an icon is never a stretched bitmap. Absent when the
218
+ * backend already draws the source at the destination scale (a browser's SVG `<img>`). */
219
+ rasterize?(w: number, h: number): unknown
220
+ }
221
+
222
+ /**
223
+ * The render tree: the resolved, absolute-positioned snapshot of one screen. It is BOTH the
224
+ * semantic description (serialized to JSON for AI feedback / tests) AND the paint source
225
+ * (walked by paintTree into a Painter). Fully resolved — no inheritance or wrapping left to do.
226
+ */
227
+ export interface RenderNode {
228
+ /** The creator-ui (Yoga) node id. */
229
+ id: number
230
+ type: NodeType
231
+ /** Author-given semantic name (LeCodes `name`): a stable selector for tests + AI review feedback. */
232
+ name?: string
233
+ /** Absolute rectangle, in CSS pixels. */
234
+ rect: Rect
235
+ style: ResolvedStyle
236
+
237
+ text?: string
238
+ /** Text after wrapping to the node's content width (its width minus padding). */
239
+ lines?: string[]
240
+
241
+ /** Resolved padding `[left, top, right, bottom]` (px), present on text/input nodes that have it.
242
+ * The rect is the border box; self-rendered content (text / input value) is inset by this. */
243
+ padding?: [number, number, number, number]
244
+
245
+ /** Current text of an input/textarea node ("" when empty → the placeholder is shown). */
246
+ value?: string
247
+
248
+ image?: ImageInfo
249
+ /** Per-node source crop (atlas/spritesheet), in texture px — overrides object-fit. */
250
+ sourceRect?: [number, number, number, number]
251
+
252
+ state?: { pressed?: boolean; focused?: boolean; classes?: string[] }
253
+
254
+ /** Set when the node clips its children (scrollable, or overflow:hidden). */
255
+ clip?: boolean
256
+ /** Present for scrollable nodes: scroll axis + current offset + total content size along it. */
257
+ scroll?: { offset: number; content: number; axis: "x" | "y" }
258
+
259
+ children: RenderNode[]
260
+ }
261
+
262
+ /**
263
+ * One compositing layer: the main screen, the screen that LEFT and is still shown (the runtime
264
+ * moves both through their own transform / opacity / dim until it drops it), or a widget overlay.
265
+ * A widget with `overlayColor` carries a full-screen `backdrop` painted behind it (and which
266
+ * intercepts taps → onOverlayTap).
267
+ */
268
+ export interface Layer {
269
+ tree: RenderNode
270
+ /** True for the screen that left: painted, never the target of input or of a semantic read. */
271
+ leaving?: boolean
272
+ /** True for a widget overlay (composited above the main screen). */
273
+ widget?: boolean
274
+ backdrop?: { color: RGBA; jsNode: WorkerNode }
275
+ }
276
+
277
+ /**
278
+ * @deprecated A uniform transform that used to scale the whole UI into the band above the keyboard.
279
+ * The UI is now re-laid-out into the reduced height instead, so this is no longer produced.
280
+ */
281
+ export interface UITransform {
282
+ scale: number
283
+ tx: number
284
+ ty: number
285
+ }
286
+
287
+ /**
288
+ * Simulated on-screen keyboard state. The keyboard *visual* is the host's (DOM on web); Core owns
289
+ * this state. When an input is focused the UI is RE-LAID-OUT into the area above `rect` (like
290
+ * iOS/Android), not scaled. Emitted to the host via `onKeyboardChange`.
291
+ */
292
+ export interface KeyboardState {
293
+ /** The bottom band the keyboard occupies (host draws its keyboard here); the UI lays out above it. */
294
+ rect: Rect
295
+ /** @deprecated No longer set — the UI is re-laid-out above the keyboard, not scaled. */
296
+ transform?: UITransform
297
+ /** The focused input: its absolute rect, current value, and type (for the host's capture field). */
298
+ input: { nodeId: number; rect: Rect; value: string; type: string }
299
+ }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />