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.
- package/package.json +26 -0
- package/src/contract.d.ts +16 -0
- package/src/core/SceneNode.ts +109 -0
- package/src/core/hitTest.ts +30 -0
- package/src/core/text.ts +259 -0
- package/src/core/util/colors.ts +30 -0
- package/src/core/util/genericFamilies.ts +30 -0
- package/src/core/util/geometry.ts +121 -0
- package/src/core/util/svgSrc.ts +141 -0
- package/src/host/BrowserHost.ts +132 -0
- package/src/host/HeadlessHost.ts +64 -0
- package/src/host/Host.ts +85 -0
- package/src/layout/CuiPaint.gen.ts +121 -0
- package/src/layout/LayoutEngine.ts +78 -0
- package/src/layout/creatorUi/loadCreatorUi.ts +87 -0
- package/src/layout/creatorUi/paint-read.ts +131 -0
- package/src/layout/creatorUi/readGradient.ts +62 -0
- package/src/paint/Canvas2DPainter.ts +258 -0
- package/src/paint/Painter.ts +62 -0
- package/src/paint/RecordingPainter.ts +101 -0
- package/src/paint/paintTree.ts +314 -0
- package/src/runtime/TreeUI.ts +822 -0
- package/src/serialize/serializeRenderTree.ts +150 -0
- package/src/types.ts +299 -0
- package/src/vite-env.d.ts +1 -0
- package/src/web/BrowserUI.ts +312 -0
- package/src/web/chrome.ts +12 -0
- package/src/web/renderer.ts +192 -0
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lecodes-web-canvas",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "The tree UI of LeCodes' web renderers: the runtime's HostUI in TypeScript (TreeUI) + the render tree, painted to a 2D canvas — in the browser (web/renderer) or headless (lecodes-headless)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "src/runtime/TreeUI.ts",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"lecodes-sdk": "2.0.0",
|
|
10
|
+
"lecodes-web-runtime": "2.0.0",
|
|
11
|
+
"lecodes-web-shared": "2.0.0"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"typescript": "~5.8.3",
|
|
15
|
+
"vite": "^6.3.5"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The host contract's globals (`_creatorTree`, `Handle`, …) come from sdk/src/bridges/*.d.ts; the
|
|
2
|
+
// tree UI references this file so the SDK's tree constants type-check here.
|
|
3
|
+
import "lecodes-sdk/bridges/tree"
|
|
4
|
+
import "lecodes-sdk/bridges/app"
|
|
5
|
+
import "lecodes-sdk/bridges/gl"
|
|
6
|
+
import "lecodes-sdk/bridges/2d"
|
|
7
|
+
import "lecodes-sdk/bridges/nav"
|
|
8
|
+
import "lecodes-sdk/bridges/canvas"
|
|
9
|
+
import "lecodes-sdk/bridges/device"
|
|
10
|
+
import "lecodes-sdk/bridges/fetch"
|
|
11
|
+
import "lecodes-sdk/bridges/files"
|
|
12
|
+
import "lecodes-sdk/bridges/storage"
|
|
13
|
+
import "lecodes-sdk/bridges/socket"
|
|
14
|
+
import "lecodes-sdk/bridges/service"
|
|
15
|
+
import "lecodes-sdk/bridges/input"
|
|
16
|
+
import "lecodes-sdk/bridges/media"
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { NodeType, Rect, ResolvedStyle, WorkerNode } from "../types"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A retained renderer node, mirroring one authoring-layer (worker) node. It owns the creator-ui
|
|
5
|
+
* (Yoga) id, the resolved paint style (mutated in place by the layout engine's style handlers),
|
|
6
|
+
* and the tree links. `jsNode` is the original authoring node — read for `text`, image `src`,
|
|
7
|
+
* `_sourceRect`, and the touch/click listeners; never mutated for layout.
|
|
8
|
+
*/
|
|
9
|
+
export class SceneNode {
|
|
10
|
+
id: number
|
|
11
|
+
type: NodeType
|
|
12
|
+
jsNode: WorkerNode
|
|
13
|
+
parent: SceneNode | null = null
|
|
14
|
+
children: SceneNode[] = []
|
|
15
|
+
style: ResolvedStyle = { lineHeight: 14 * 1.2 } // the core's fresh-node record; the first paint sync replaces it
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The last-known parent-relative layout. creator-ui's getLayoutPointer is a *consume-new-layout*
|
|
19
|
+
* call (returns nothing once read, until the next calculate), so we snapshot it here after each
|
|
20
|
+
* calculate and build the render tree from this cache — making the build idempotent.
|
|
21
|
+
*/
|
|
22
|
+
layoutRect: Rect | null = null
|
|
23
|
+
|
|
24
|
+
/** text / image leaves carry a measure function in creator-ui. */
|
|
25
|
+
readonly measured: boolean
|
|
26
|
+
|
|
27
|
+
/** Vertical scroll offset (px) for scrollable nodes; 0 otherwise. */
|
|
28
|
+
scrollOffset = 0
|
|
29
|
+
|
|
30
|
+
/** Image source crop (atlas frame), texture px. Set via setSourceRect or from `_sourceRect`. */
|
|
31
|
+
sourceRect?: [number, number, number, number]
|
|
32
|
+
|
|
33
|
+
/** Author-given semantic name (LeCodes `name`): a stable selector for tests + AI review feedback. */
|
|
34
|
+
readonly name?: string
|
|
35
|
+
|
|
36
|
+
/** Current text of an input/textarea node (the renderer owns it, like the DOM `<input>.value`). */
|
|
37
|
+
value = ""
|
|
38
|
+
|
|
39
|
+
/** Memoized text wrap (text nodes): the last wrap result keyed by text + content width + font. The
|
|
40
|
+
* render tree is rebuilt every frame while scrolling/animating, and re-wrapping calls the host's
|
|
41
|
+
* measureText per line — caching skips that when nothing about the text changed. Cleared globally on
|
|
42
|
+
* registerFont (a new face changes metrics without changing the key). */
|
|
43
|
+
wrapCache?: { key: string; lines: string[] }
|
|
44
|
+
|
|
45
|
+
constructor(id: number, type: NodeType, jsNode: WorkerNode) {
|
|
46
|
+
this.id = id
|
|
47
|
+
this.type = type
|
|
48
|
+
this.jsNode = jsNode
|
|
49
|
+
// Keep in sync with Screen.addNode's hasMeasure (createChild flag). Editables are measured
|
|
50
|
+
// leaves: an input is one intrinsic line, a textarea's height follows its content.
|
|
51
|
+
this.measured = type === "text" || type === "image" || type === "input" || type === "textarea"
|
|
52
|
+
if (typeof jsNode?.name === "string" && jsNode.name.length > 0) this.name = jsNode.name
|
|
53
|
+
// A crop set before mount is stashed on the authoring node — pick it up here.
|
|
54
|
+
if (type === "image" && Array.isArray(jsNode?._sourceRect)) {
|
|
55
|
+
this.sourceRect = jsNode._sourceRect.slice() as [number, number, number, number]
|
|
56
|
+
}
|
|
57
|
+
// Same stash for a value set before mount (a pre-filled form): the SDK's `input.value =` writes
|
|
58
|
+
// `_value` whether or not the node is mounted, and the host seeds the field from it here.
|
|
59
|
+
if ((type === "input" || type === "textarea") && typeof jsNode?._value === "string") {
|
|
60
|
+
this.value = jsNode._value
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Read the `_`-prefixed backing fields, not the public accessors: chisel's method-DCE keeps
|
|
65
|
+
// `_*` members but drops the `text`/`src` getter pair when no *bundled* code references the
|
|
66
|
+
// name (the renderer is outside the bundle). Same convention as the DOM viewer (`node._text`).
|
|
67
|
+
get text(): string | undefined {
|
|
68
|
+
return this.type === "text" ? (this.jsNode?._text ?? this.jsNode?.text ?? "") : undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The raw authoring image source (string url / `SvgSource` / `{ _id }` asset / `File`), or
|
|
72
|
+
* undefined. Resolve it to a loadable url with `Host.resolveSrc` at the point of use. */
|
|
73
|
+
get imageSrcRaw(): unknown {
|
|
74
|
+
return this.type === "image" ? (this.jsNode?._src ?? this.jsNode?.src) : undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True if this node scrolls its content (`scrollable`, a `vlist`, or a `makeScrollable` screen). */
|
|
78
|
+
get scrollable(): boolean {
|
|
79
|
+
return this.type === "scrollable" || this.type === "vlist" || this.jsNode?.scrollable === true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True if this node is an editable field (input / textarea). */
|
|
83
|
+
get editable(): boolean {
|
|
84
|
+
return this.type === "input" || this.type === "textarea"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Scroll axis for a scrollable node ("x" horizontal, "y" vertical — the default). */
|
|
88
|
+
get scrollAxis(): "x" | "y" {
|
|
89
|
+
return this.style.scrollAxis === "x" ? "x" : "y"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** True if this node has any pointer listeners (button / interactive screen). */
|
|
93
|
+
get interactive(): boolean {
|
|
94
|
+
const n = this.jsNode
|
|
95
|
+
if (!n) return false
|
|
96
|
+
// A screen/widget SURFACE is pointer-transparent unless the app registered a root-level touch
|
|
97
|
+
// listener — the web host's `.screen { pointer-events: none }` + `.interactive` rule. Every SDK
|
|
98
|
+
// screen carries the `_emitTouchStart` METHOD, so testing for the method alone would make any
|
|
99
|
+
// open screen swallow the taps meant for the 2D/3D scene beneath it (HUD-over-game).
|
|
100
|
+
if (n.type === "screen" || n.type === "widget") {
|
|
101
|
+
return Array.isArray(n.touchStartListeners) && n.touchStartListeners.length > 0
|
|
102
|
+
}
|
|
103
|
+
return (
|
|
104
|
+
typeof n._emitClick === "function" ||
|
|
105
|
+
typeof n._emitTouchStart === "function" ||
|
|
106
|
+
(Array.isArray(n.touchStartListeners) && n.touchStartListeners.length > 0)
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { RenderNode } from "../types"
|
|
2
|
+
|
|
3
|
+
function contains(node: RenderNode, x: number, y: number): boolean {
|
|
4
|
+
const r = node.rect
|
|
5
|
+
return x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Find the topmost node satisfying `accept` (e.g. interactive) under a point. Walks in paint order
|
|
10
|
+
* and returns the last match — later siblings and deeper children paint on top, so they win, which
|
|
11
|
+
* matches the DOM event-bubbling target the worker buttons expect.
|
|
12
|
+
*/
|
|
13
|
+
export function hitTest(
|
|
14
|
+
root: RenderNode,
|
|
15
|
+
x: number,
|
|
16
|
+
y: number,
|
|
17
|
+
accept: (node: RenderNode) => boolean,
|
|
18
|
+
): RenderNode | null {
|
|
19
|
+
let found: RenderNode | null = null
|
|
20
|
+
const walk = (node: RenderNode) => {
|
|
21
|
+
const inside = contains(node, x, y)
|
|
22
|
+
if (inside && accept(node)) found = node
|
|
23
|
+
// A clipping node (scrollable / overflow:hidden) hides children outside its rect — so a child
|
|
24
|
+
// scrolled out of view isn't hittable, matching the DOM's overflow clipping.
|
|
25
|
+
if (node.clip && !inside) return
|
|
26
|
+
for (const child of node.children) walk(child)
|
|
27
|
+
}
|
|
28
|
+
walk(root)
|
|
29
|
+
return found
|
|
30
|
+
}
|
package/src/core/text.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import type { ResolvedStyle, RGBA } from "../types"
|
|
2
|
+
import type { FontSpec, Host } from "../host/Host"
|
|
3
|
+
import type { SceneNode } from "./SceneNode"
|
|
4
|
+
import { extractSvgViewBox, isSvgSource } from "./util/svgSrc"
|
|
5
|
+
|
|
6
|
+
/** Effective, fully-defaulted text style for a node (after walking inheritance). */
|
|
7
|
+
export interface EffectiveText {
|
|
8
|
+
color: RGBA
|
|
9
|
+
fontSize: number
|
|
10
|
+
fontFamily: string
|
|
11
|
+
fontWeight: number
|
|
12
|
+
fontStyle: "normal" | "italic"
|
|
13
|
+
lineHeight: number
|
|
14
|
+
letterSpacing: number
|
|
15
|
+
textAlign: "start" | "center" | "end"
|
|
16
|
+
textDecoration: "underline" | "line-through" | "none"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The last-resort values for a node whose record carries no color / font (the runtime inherits
|
|
20
|
+
// text style and the theme's defaults core-side; these only fill what is absent there).
|
|
21
|
+
const DEFAULTS = {
|
|
22
|
+
color: 0x000000ff as RGBA,
|
|
23
|
+
fontSize: 14,
|
|
24
|
+
fontFamily: "Roboto",
|
|
25
|
+
fontWeight: 400,
|
|
26
|
+
fontStyle: "normal" as const,
|
|
27
|
+
letterSpacing: 0,
|
|
28
|
+
textAlign: "start" as const,
|
|
29
|
+
textDecoration: "none" as const,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A node's text style: its ResolvedStyle already holds the EFFECTIVE values (the runtime's paint
|
|
34
|
+
* record inherits text style core-side), so this only fills the defaults for absent fields. The
|
|
35
|
+
* measure callback and the render-tree build both call this, so layout and paint never disagree.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveTextStyle(node: SceneNode): EffectiveText {
|
|
38
|
+
const s = node.style
|
|
39
|
+
const { color, fontSize, fontFamily, fontWeight, fontStyle, lineHeight, letterSpacing, textAlign, textDecoration } = s
|
|
40
|
+
|
|
41
|
+
const size = fontSize ?? DEFAULTS.fontSize
|
|
42
|
+
return {
|
|
43
|
+
color: color ?? DEFAULTS.color,
|
|
44
|
+
fontSize: size,
|
|
45
|
+
fontFamily: fontFamily ?? DEFAULTS.fontFamily,
|
|
46
|
+
fontWeight: fontWeight ?? DEFAULTS.fontWeight,
|
|
47
|
+
fontStyle: fontStyle ?? DEFAULTS.fontStyle,
|
|
48
|
+
lineHeight,
|
|
49
|
+
letterSpacing: letterSpacing ?? DEFAULTS.letterSpacing,
|
|
50
|
+
textAlign: textAlign ?? DEFAULTS.textAlign,
|
|
51
|
+
textDecoration: textDecoration ?? DEFAULTS.textDecoration,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function toFontSpec(t: EffectiveText): FontSpec {
|
|
56
|
+
return {
|
|
57
|
+
family: t.fontFamily,
|
|
58
|
+
size: t.fontSize,
|
|
59
|
+
weight: t.fontWeight,
|
|
60
|
+
italic: t.fontStyle === "italic",
|
|
61
|
+
letterSpacing: t.letterSpacing,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Greedy word-wrap to `maxWidth` (honoring explicit \n, and breaking over-long words). Returns the
|
|
67
|
+
* lines and the widest line. Ported from the DOM measure system, but measuring via the host so it
|
|
68
|
+
* works headless. `maxWidth = Infinity` yields intrinsic (no-wrap) sizing.
|
|
69
|
+
*/
|
|
70
|
+
export function wrapText(
|
|
71
|
+
text: string,
|
|
72
|
+
maxWidth: number,
|
|
73
|
+
measure: (s: string) => number,
|
|
74
|
+
): { lines: string[]; maxWidth: number } {
|
|
75
|
+
const result: string[] = []
|
|
76
|
+
let widest = 0
|
|
77
|
+
|
|
78
|
+
for (const rawLine of text.split("\n")) {
|
|
79
|
+
if (rawLine.length === 0) {
|
|
80
|
+
result.push("")
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
let current = rawLine
|
|
84
|
+
while (current) {
|
|
85
|
+
if (measure(current) <= maxWidth) {
|
|
86
|
+
result.push(current)
|
|
87
|
+
widest = Math.max(widest, measure(current))
|
|
88
|
+
break
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let splitPos = 0
|
|
92
|
+
let lastSpace = -1
|
|
93
|
+
let spaceIdx = 0
|
|
94
|
+
while ((spaceIdx = current.indexOf(" ", spaceIdx + 1)) !== -1) {
|
|
95
|
+
if (measure(current.substring(0, spaceIdx)) > maxWidth) break
|
|
96
|
+
lastSpace = spaceIdx
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (lastSpace > 0) {
|
|
100
|
+
splitPos = lastSpace
|
|
101
|
+
} else {
|
|
102
|
+
let wordEnd = current.indexOf(" ")
|
|
103
|
+
if (wordEnd === -1) wordEnd = current.length
|
|
104
|
+
const firstWord = current.substring(0, wordEnd)
|
|
105
|
+
if (measure(firstWord) > maxWidth) {
|
|
106
|
+
for (let i = 1; i <= firstWord.length; i++) {
|
|
107
|
+
if (measure(firstWord.substring(0, i)) > maxWidth) {
|
|
108
|
+
splitPos = i - 1
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
for (let i = wordEnd + 1; i <= current.length; i++) {
|
|
114
|
+
if (measure(current.substring(0, i)) > maxWidth) {
|
|
115
|
+
splitPos = i - 1
|
|
116
|
+
break
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (splitPos <= 0) splitPos = 1
|
|
123
|
+
const line = current.substring(0, splitPos)
|
|
124
|
+
result.push(line)
|
|
125
|
+
widest = Math.max(widest, measure(line))
|
|
126
|
+
current = current.substring(splitPos + (lastSpace > 0 ? 1 : 0))
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { lines: result, maxWidth: widest }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Appended to the last surviving line when a clamp cut the text and textOverflow is "ellipsis". */
|
|
134
|
+
const ELLIPSIS = "…"
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Apply `lineClamp` / `textOverflow` to a wrapped line list. Returns the surviving lines — with the
|
|
138
|
+
* last one shortened so `…` fits the content width, unless `textOverflow: "clip"` asks for a hard
|
|
139
|
+
* cut. creator-ui touches neither prop (it changes no Yoga style), so clamping — including
|
|
140
|
+
* reporting the clamped box — is entirely the host's job: measure and the render tree both call
|
|
141
|
+
* this, so the measured height and the painted lines can't disagree.
|
|
142
|
+
*
|
|
143
|
+
* Returns the input array itself when nothing is clamped, so callers can skip the re-measure.
|
|
144
|
+
*/
|
|
145
|
+
export function clampLines(
|
|
146
|
+
lines: string[],
|
|
147
|
+
style: ResolvedStyle,
|
|
148
|
+
maxWidth: number,
|
|
149
|
+
measure: (s: string) => number,
|
|
150
|
+
): string[] {
|
|
151
|
+
const clamp = style.lineClamp ?? 0
|
|
152
|
+
if (clamp <= 0 || lines.length <= clamp) return lines
|
|
153
|
+
const kept = lines.slice(0, clamp)
|
|
154
|
+
if (style.textOverflow === "clip") return kept
|
|
155
|
+
kept[clamp - 1] = ellipsize(kept[clamp - 1], maxWidth, measure)
|
|
156
|
+
return kept
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Trim `line` (from the end, on whole characters) until `line…` fits `maxWidth`. */
|
|
160
|
+
function ellipsize(line: string, maxWidth: number, measure: (s: string) => number): string {
|
|
161
|
+
// Unconstrained (intrinsic) measure: nothing to fit into, so the ellipsis just widens the box.
|
|
162
|
+
if (!isFinite(maxWidth)) return line + ELLIPSIS
|
|
163
|
+
const chars = [...line] // by code point — never cut a surrogate pair in half
|
|
164
|
+
let end = chars.length
|
|
165
|
+
while (end > 0 && measure(chars.slice(0, end).join("") + ELLIPSIS) > maxWidth) end--
|
|
166
|
+
return chars.slice(0, end).join("").trimEnd() + ELLIPSIS
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Measure a text node for creator-ui (returns intrinsic/wrapped size for the given constraints). */
|
|
170
|
+
export function measureTextNode(node: SceneNode, host: Host, width: number): { width: number; height: number } {
|
|
171
|
+
const t = resolveTextStyle(node)
|
|
172
|
+
const font = toFontSpec(t)
|
|
173
|
+
const measureWidth = isNaN(width) ? Infinity : width
|
|
174
|
+
const measure = (s: string) => host.measureText(s, font)
|
|
175
|
+
const wrapped = wrapText(node.text ?? "", measureWidth, measure)
|
|
176
|
+
const lines = clampLines(wrapped.lines, node.style, measureWidth, measure)
|
|
177
|
+
// A clamp reports the CLAMPED box, otherwise the layout would reflow around the full string and
|
|
178
|
+
// leave dead space under the last line. The width has to be recomputed over the surviving lines
|
|
179
|
+
// only — the widest line is often one of the ones that got cut.
|
|
180
|
+
let widest = wrapped.maxWidth
|
|
181
|
+
if (lines !== wrapped.lines) {
|
|
182
|
+
widest = 0
|
|
183
|
+
for (const line of lines) widest = Math.max(widest, measure(line))
|
|
184
|
+
}
|
|
185
|
+
return { width: Math.ceil(widest), height: Math.ceil(lines.length * t.lineHeight) }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Measure an editable field (input / textarea) for creator-ui. An input is one text line; a
|
|
189
|
+
* textarea wraps its value at the given width (empty → one line), so a field with no explicit
|
|
190
|
+
* height grows with its content — Yoga clamps via the node's minHeight/maxHeight styles. Font
|
|
191
|
+
* fallbacks mirror paintInput: measure and paint must agree on the line grid. */
|
|
192
|
+
export function measureEditableNode(node: SceneNode, host: Host, width: number): { width: number; height: number } {
|
|
193
|
+
const s = node.style
|
|
194
|
+
const font: FontSpec = {
|
|
195
|
+
family: s.fontFamily ?? "Roboto",
|
|
196
|
+
size: s.fontSize ?? 14,
|
|
197
|
+
weight: s.fontWeight ?? 400,
|
|
198
|
+
italic: s.fontStyle === "italic",
|
|
199
|
+
letterSpacing: s.letterSpacing ?? 0,
|
|
200
|
+
}
|
|
201
|
+
const lineHeight = s.lineHeight
|
|
202
|
+
const value = node.value ?? ""
|
|
203
|
+
if (node.type === "input") {
|
|
204
|
+
return { width: Math.ceil(host.measureText(value, font)), height: Math.ceil(lineHeight) }
|
|
205
|
+
}
|
|
206
|
+
const measureWidth = isNaN(width) ? Infinity : width
|
|
207
|
+
const wrapped = wrapText(value, measureWidth, (t) => host.measureText(t, font))
|
|
208
|
+
return { width: Math.ceil(wrapped.maxWidth), height: Math.ceil(Math.max(1, wrapped.lines.length) * lineHeight) }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Measure an image node for creator-ui (intrinsic size). A source crop (atlas frame) uses the crop's
|
|
212
|
+
* w/h. An SVG is a vector → its size comes from the viewBox and it may upscale; a raster is clamped
|
|
213
|
+
* to not upscale. */
|
|
214
|
+
export function measureImageNode(
|
|
215
|
+
node: SceneNode,
|
|
216
|
+
host: Host,
|
|
217
|
+
width: number,
|
|
218
|
+
height: number,
|
|
219
|
+
): { width: number; height: number } {
|
|
220
|
+
const raw = node.imageSrcRaw
|
|
221
|
+
let iw = 0
|
|
222
|
+
let ih = 0
|
|
223
|
+
|
|
224
|
+
if (node.sourceRect) {
|
|
225
|
+
iw = node.sourceRect[2]
|
|
226
|
+
ih = node.sourceRect[3]
|
|
227
|
+
} else if (isSvgSource(raw)) {
|
|
228
|
+
// Inline SVG: size from the viewBox (no decode needed — works on the headless/JSON path too).
|
|
229
|
+
const vb = extractSvgViewBox(raw.svg)
|
|
230
|
+
if (vb) {
|
|
231
|
+
iw = vb.width
|
|
232
|
+
ih = vb.height
|
|
233
|
+
} else {
|
|
234
|
+
const info = host.getImage(host.resolveSrc(raw) ?? "")
|
|
235
|
+
iw = info?.naturalWidth ?? 0
|
|
236
|
+
ih = info?.naturalHeight ?? 0
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
const src = host.resolveSrc(raw)
|
|
240
|
+
const info = src ? host.getImage(src) : undefined
|
|
241
|
+
iw = info?.naturalWidth ?? 0
|
|
242
|
+
ih = info?.naturalHeight ?? 0
|
|
243
|
+
}
|
|
244
|
+
if (iw === 0 || ih === 0) return { width: 0, height: 0 }
|
|
245
|
+
|
|
246
|
+
// Never past the intrinsic size under a constraint (the CSS rule, iOS's and the desktop's measure):
|
|
247
|
+
// an SVG scales without quality loss, but an unsized icon still lays out at its own size — a box
|
|
248
|
+
// that names a dimension is exact for yoga, which sizes past this answer anyway.
|
|
249
|
+
const fit = Math.min(width / iw, height / ih)
|
|
250
|
+
const k =
|
|
251
|
+
isNaN(width) && isNaN(height)
|
|
252
|
+
? 1
|
|
253
|
+
: isNaN(width)
|
|
254
|
+
? height / ih
|
|
255
|
+
: isNaN(height)
|
|
256
|
+
? width / iw
|
|
257
|
+
: Math.min(fit, 1)
|
|
258
|
+
return { width: iw * k, height: ih * k }
|
|
259
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { RGBA } from "../../types"
|
|
2
|
+
|
|
3
|
+
export interface RGBAChannels {
|
|
4
|
+
r: number
|
|
5
|
+
g: number
|
|
6
|
+
b: number
|
|
7
|
+
a: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Unpack a 0xRRGGBBAA color into channels (a in 0..255). */
|
|
11
|
+
export function unpackRGBA(v: RGBA): RGBAChannels {
|
|
12
|
+
const u = v >>> 0
|
|
13
|
+
return { r: (u >>> 24) & 0xff, g: (u >>> 16) & 0xff, b: (u >>> 8) & 0xff, a: u & 0xff }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 0xRRGGBBAA → "#rrggbbaa" (lowercase), the readable form used in the JSON output. */
|
|
17
|
+
export function rgbaToHex(v: RGBA): string {
|
|
18
|
+
return "#" + (v >>> 0).toString(16).padStart(8, "0")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 0xRRGGBBAA → a CSS/canvas "rgba(r, g, b, a)" string. */
|
|
22
|
+
export function rgbaToCss(v: RGBA): string {
|
|
23
|
+
const { r, g, b, a } = unpackRGBA(v)
|
|
24
|
+
return `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(4)})`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** True if the color is fully transparent (skip painting). */
|
|
28
|
+
export function isTransparent(v: RGBA | undefined): boolean {
|
|
29
|
+
return v === undefined || (v >>> 0 & 0xff) === 0
|
|
30
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Skia (@napi-rs/canvas) does not resolve CSS generic font families at all — "serif",
|
|
3
|
+
* "sans-serif", "monospace" and the SDK's "monospaced" spelling all land on the same default
|
|
4
|
+
* fallback face (probe-verified, even with system fonts enumerated). Headless hosts therefore
|
|
5
|
+
* pin each generic to a vendored concrete family (registered in headless/fonts.ts), which keeps
|
|
6
|
+
* generic text real and deterministic on any machine — including fontless Linux render servers.
|
|
7
|
+
* Browser hosts don't use this map: CSS resolves generics natively there (they only alias the
|
|
8
|
+
* SDK's "monospaced" to the CSS keyword "monospace").
|
|
9
|
+
*/
|
|
10
|
+
export const HEADLESS_GENERIC_FAMILIES: Record<string, string> = {
|
|
11
|
+
"sans-serif": "Roboto",
|
|
12
|
+
"serif": "PT Serif",
|
|
13
|
+
"monospace": "JetBrains Mono",
|
|
14
|
+
"monospaced": "JetBrains Mono", // the SDK/iOS spelling
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const resolveHeadlessFamily = (family: string): string =>
|
|
18
|
+
HEADLESS_GENERIC_FAMILIES[family] ?? family
|
|
19
|
+
|
|
20
|
+
// The family list of a `ctx.font` shorthand sits after the size token ("italic 500 16px/1.2 …").
|
|
21
|
+
const SHORTHAND_FAMILIES = /^(.*?\S+px(?:\/\S+)?\s+)(.+)$/
|
|
22
|
+
|
|
23
|
+
/** Rewrite generic families inside a canvas `ctx.font` shorthand ("16px serif" → "16px PT Serif"),
|
|
24
|
+
* for the headless canvas replay. Unparseable strings pass through untouched. */
|
|
25
|
+
export const resolveHeadlessFontShorthand = (font: string): string => {
|
|
26
|
+
const m = font.match(SHORTHAND_FAMILIES)
|
|
27
|
+
if (!m) return font
|
|
28
|
+
const families = m[2].split(",").map((f) => resolveHeadlessFamily(f.trim()))
|
|
29
|
+
return m[1] + families.join(", ")
|
|
30
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { BorderRadius, RadialGradient, Rect } from "../../types"
|
|
2
|
+
|
|
3
|
+
/** Clamp the corner radii so opposite corners never overlap (CSS behavior). */
|
|
4
|
+
export function clampRadius(radius: BorderRadius | undefined, rect: Rect): BorderRadius | undefined {
|
|
5
|
+
if (!radius) return undefined
|
|
6
|
+
const max = Math.min(rect.width, rect.height) / 2
|
|
7
|
+
return {
|
|
8
|
+
tl: Math.min(radius.tl, max),
|
|
9
|
+
tr: Math.min(radius.tr, max),
|
|
10
|
+
br: Math.min(radius.br, max),
|
|
11
|
+
bl: Math.min(radius.bl, max),
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function hasRadius(radius: BorderRadius | undefined): boolean {
|
|
16
|
+
return !!radius && (radius.tl > 0 || radius.tr > 0 || radius.br > 0 || radius.bl > 0)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve object-fit into source (image px) + destination (screen px) rects, ready for a canvas
|
|
21
|
+
* drawImage(src.x, src.y, src.w, src.h, dst.x, dst.y, dst.w, dst.h).
|
|
22
|
+
*/
|
|
23
|
+
export function computeObjectFit(
|
|
24
|
+
naturalWidth: number,
|
|
25
|
+
naturalHeight: number,
|
|
26
|
+
box: Rect,
|
|
27
|
+
fit: "cover" | "contain" | "fill",
|
|
28
|
+
): { src: Rect; dst: Rect } {
|
|
29
|
+
const full: Rect = { x: 0, y: 0, width: naturalWidth, height: naturalHeight }
|
|
30
|
+
if (fit === "fill" || naturalWidth === 0 || naturalHeight === 0) {
|
|
31
|
+
return { src: full, dst: box }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (fit === "cover") {
|
|
35
|
+
// Crop the image so it fills the box without distortion.
|
|
36
|
+
const scale = Math.max(box.width / naturalWidth, box.height / naturalHeight)
|
|
37
|
+
const sw = box.width / scale
|
|
38
|
+
const sh = box.height / scale
|
|
39
|
+
return {
|
|
40
|
+
src: { x: (naturalWidth - sw) / 2, y: (naturalHeight - sh) / 2, width: sw, height: sh },
|
|
41
|
+
dst: box,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// contain: scale to fit inside, centered.
|
|
46
|
+
const scale = Math.min(box.width / naturalWidth, box.height / naturalHeight)
|
|
47
|
+
const dw = naturalWidth * scale
|
|
48
|
+
const dh = naturalHeight * scale
|
|
49
|
+
return {
|
|
50
|
+
src: full,
|
|
51
|
+
dst: { x: box.x + (box.width - dw) / 2, y: box.y + (box.height - dh) / 2, width: dw, height: dh },
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a CSS gradient angle (0 = to top, 90 = to right, clockwise) into a gradient line through a
|
|
57
|
+
* rect (start = 0%, end = 100%), using the standard CSS endpoint projection.
|
|
58
|
+
*/
|
|
59
|
+
export function gradientLine(angle: number, rect: Rect) {
|
|
60
|
+
const rad = (angle * Math.PI) / 180
|
|
61
|
+
const dx = Math.sin(rad)
|
|
62
|
+
const dy = -Math.cos(rad)
|
|
63
|
+
const cx = rect.x + rect.width / 2
|
|
64
|
+
const cy = rect.y + rect.height / 2
|
|
65
|
+
const halfLen = (Math.abs(rect.width * dx) + Math.abs(rect.height * dy)) / 2
|
|
66
|
+
return {
|
|
67
|
+
x0: cx - dx * halfLen,
|
|
68
|
+
y0: cy - dy * halfLen,
|
|
69
|
+
x1: cx + dx * halfLen,
|
|
70
|
+
y1: cy + dy * halfLen,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve a radial gradient's CSS geometry against a rect: the center (absolute px) and the two
|
|
76
|
+
* semi-axes `rx`/`ry` (px) of the ending ellipse. Implements the CSS extent keywords; a `circle`
|
|
77
|
+
* uses a single radius (equal axes). For the corner extents an ellipse keeps the matching side
|
|
78
|
+
* ellipse's aspect ratio and passes through that corner (the √2 factor for an axis-aligned box).
|
|
79
|
+
*/
|
|
80
|
+
export function radialGeometry(g: RadialGradient, rect: Rect) {
|
|
81
|
+
const cx = rect.x + g.cx * rect.width
|
|
82
|
+
const cy = rect.y + g.cy * rect.height
|
|
83
|
+
|
|
84
|
+
// Explicit radii: px used directly, a "fraction" (from a % radius) resolves against the box axis.
|
|
85
|
+
if (g.extent === "explicit") {
|
|
86
|
+
const rx = g.rxUnit === "fraction" ? (g.rx ?? 0) * rect.width : (g.rx ?? 0)
|
|
87
|
+
const ry = g.ryUnit === "fraction" ? (g.ry ?? 0) * rect.height : (g.ry ?? 0)
|
|
88
|
+
return { cx, cy, rx, ry }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// distances from the center to each side
|
|
92
|
+
const left = g.cx * rect.width
|
|
93
|
+
const right = (1 - g.cx) * rect.width
|
|
94
|
+
const top = g.cy * rect.height
|
|
95
|
+
const bottom = (1 - g.cy) * rect.height
|
|
96
|
+
const nearX = Math.min(left, right)
|
|
97
|
+
const farX = Math.max(left, right)
|
|
98
|
+
const nearY = Math.min(top, bottom)
|
|
99
|
+
const farY = Math.max(top, bottom)
|
|
100
|
+
|
|
101
|
+
let rx: number
|
|
102
|
+
let ry: number
|
|
103
|
+
if (g.shape === "circle") {
|
|
104
|
+
let r: number
|
|
105
|
+
switch (g.extent) {
|
|
106
|
+
case "closest-side": r = Math.min(nearX, nearY); break
|
|
107
|
+
case "farthest-side": r = Math.max(farX, farY); break
|
|
108
|
+
case "closest-corner": r = Math.hypot(nearX, nearY); break
|
|
109
|
+
default: r = Math.hypot(farX, farY); break // farthest-corner
|
|
110
|
+
}
|
|
111
|
+
rx = ry = r
|
|
112
|
+
} else {
|
|
113
|
+
switch (g.extent) {
|
|
114
|
+
case "closest-side": rx = nearX; ry = nearY; break
|
|
115
|
+
case "farthest-side": rx = farX; ry = farY; break
|
|
116
|
+
case "closest-corner": rx = nearX * Math.SQRT2; ry = nearY * Math.SQRT2; break
|
|
117
|
+
default: rx = farX * Math.SQRT2; ry = farY * Math.SQRT2; break // farthest-corner
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { cx, cy, rx, ry }
|
|
121
|
+
}
|