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 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
+ }
@@ -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
+ }