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,101 @@
1
+ import type { BorderRadius, Gradient, ImageInfo, Rect, RGBA } from "../types"
2
+ import type { FontSpec } from "../host/Host"
3
+ import type { Borders, DrawImageOpts, Painter } from "./Painter"
4
+ import { rgbaToHex } from "../core/util/colors"
5
+
6
+ /**
7
+ * A Painter that records primitive ops instead of drawing — Bun-testable and a readable low-level
8
+ * "display list" dump. Proves the paintTree walk without a canvas, and doubles as a golden oracle.
9
+ */
10
+ export class RecordingPainter implements Painter {
11
+ ops: string[] = []
12
+ private precision: number
13
+
14
+ constructor(precision = 2) {
15
+ this.precision = precision
16
+ }
17
+
18
+ private n(v: number): number {
19
+ const f = 10 ** this.precision
20
+ return Math.round(v * f) / f
21
+ }
22
+ private r(rect: Rect): string {
23
+ return `[${this.n(rect.x)}, ${this.n(rect.y)}, ${this.n(rect.width)}, ${this.n(rect.height)}]`
24
+ }
25
+ private rad(radius?: BorderRadius): string {
26
+ if (!radius || (!radius.tl && !radius.tr && !radius.br && !radius.bl)) return ""
27
+ return ` r=${this.n(radius.tl)},${this.n(radius.tr)},${this.n(radius.br)},${this.n(radius.bl)}`
28
+ }
29
+
30
+ beginFrame(width: number, height: number) {
31
+ this.ops.push(`beginFrame ${this.n(width)}x${this.n(height)}`)
32
+ }
33
+ endFrame() {
34
+ this.ops.push("endFrame")
35
+ }
36
+ save() {
37
+ this.ops.push("save")
38
+ }
39
+ restore() {
40
+ this.ops.push("restore")
41
+ }
42
+ setAlpha(multiplier: number) {
43
+ this.ops.push(`setAlpha ${this.n(multiplier)}`)
44
+ }
45
+ beginGroup(alpha: number) {
46
+ this.ops.push(`beginGroup ${this.n(alpha)}`)
47
+ }
48
+ endGroup() {
49
+ this.ops.push("endGroup")
50
+ }
51
+ translate(x: number, y: number) {
52
+ this.ops.push(`translate ${this.n(x)},${this.n(y)}`)
53
+ }
54
+ scale(sx: number, sy: number) {
55
+ this.ops.push(`scale ${this.n(sx)},${this.n(sy)}`)
56
+ }
57
+ transform(a: number, b: number, c: number, d: number, e: number, f: number) {
58
+ this.ops.push(`transform ${this.n(a)},${this.n(b)},${this.n(c)},${this.n(d)},${this.n(e)},${this.n(f)}`)
59
+ }
60
+ clipRoundRect(rect: Rect, radius?: BorderRadius) {
61
+ this.ops.push(`clip ${this.r(rect)}${this.rad(radius)}`)
62
+ }
63
+ fillRoundRect(rect: Rect, radius: BorderRadius | undefined, color: RGBA) {
64
+ this.ops.push(`fillRect ${this.r(rect)}${this.rad(radius)} ${rgbaToHex(color)}`)
65
+ }
66
+ clearRoundRect(rect: Rect, radius: BorderRadius | undefined) {
67
+ this.ops.push(`clearRect ${this.r(rect)}${this.rad(radius)}`)
68
+ }
69
+ fillGradient(rect: Rect, radius: BorderRadius | undefined, gradient: Gradient) {
70
+ const stops = gradient.stops.map((s) => `${rgbaToHex(s.color)}@${this.n(s.position)}`).join(",")
71
+ let shape: string
72
+ if (gradient.type === "radial") {
73
+ const size =
74
+ gradient.extent === "explicit"
75
+ ? `r=${this.n(gradient.rx ?? 0)}${gradient.rxUnit === "fraction" ? "f" : ""},${this.n(gradient.ry ?? 0)}${gradient.ryUnit === "fraction" ? "f" : ""}`
76
+ : gradient.extent
77
+ shape = `radial ${gradient.shape} ${size} at=${this.n(gradient.cx)},${this.n(gradient.cy)}`
78
+ } else {
79
+ shape = `angle=${this.n(gradient.angle)}`
80
+ }
81
+ this.ops.push(`gradient ${this.r(rect)}${this.rad(radius)} ${shape} [${stops}]`)
82
+ }
83
+ strokeBorder(rect: Rect, radius: BorderRadius | undefined, sides: Borders) {
84
+ const parts = (["top", "right", "bottom", "left"] as const)
85
+ .filter((s) => sides[s])
86
+ .map((s) => `${s}=${this.n(sides[s]!.width)}/${rgbaToHex(sides[s]!.color)}`)
87
+ this.ops.push(`border ${this.r(rect)}${this.rad(radius)} ${parts.join(" ")}`)
88
+ }
89
+ drawImage(image: ImageInfo, src: Rect | null, dst: Rect, opts: DrawImageOpts) {
90
+ const tint = opts.tint !== undefined ? ` tint=${rgbaToHex(opts.tint)}` : ""
91
+ const crop = src ? ` src=${this.r(src)}` : ""
92
+ this.ops.push(`image "${image.src}" dst=${this.r(dst)}${crop} fit=${opts.fit}${tint}`)
93
+ }
94
+ fillText(text: string, x: number, baselineY: number, font: FontSpec, color: RGBA) {
95
+ this.ops.push(`text ${JSON.stringify(text)} @ ${this.n(x)},${this.n(baselineY)} ${font.size}px ${rgbaToHex(color)}`)
96
+ }
97
+
98
+ toText(): string {
99
+ return this.ops.join("\n")
100
+ }
101
+ }
@@ -0,0 +1,314 @@
1
+ import type { BorderRadius, Layer, RenderNode, UITransform } from "../types"
2
+ import type { Host, FontSpec } from "../host/Host"
3
+ import type { Borders, Painter } from "./Painter"
4
+ import { clampRadius, hasRadius } from "../core/util/geometry"
5
+ import { isTransparent } from "../core/util/colors"
6
+ import { isSvgLikeSrc } from "../core/util/svgSrc"
7
+ import { wrapText } from "../core/text"
8
+
9
+ /** What the host shows ITSELF, so the painter leaves it out. */
10
+ export type PaintOptions = {
11
+ /** A native view and a video are surfaces UNDER the picture: their box is a window onto them
12
+ * (absent: a video is a placeholder, a native view nothing). */
13
+ surfaces?: boolean
14
+ /** The field being edited, by node id: a live <input> stands over it, with the text and the caret. */
15
+ editing?: number
16
+ }
17
+
18
+ /**
19
+ * Walk the render tree and replay it into a Painter. All paint *logic* (clip-on-radius, border
20
+ * stacking, text alignment + baseline) lives here, so every backend shares it and only the leaf
21
+ * primitives differ. `host` is used for text metrics (deterministic across backends).
22
+ */
23
+ export function paintTree(tree: RenderNode | null, painter: Painter, host: Host, options: PaintOptions = {}) {
24
+ if (!tree) return
25
+ painter.beginFrame(tree.rect.width, tree.rect.height)
26
+ paintNode(tree, painter, host, options)
27
+ painter.endFrame()
28
+ }
29
+
30
+ /**
31
+ * Paint a full frame of compositing layers (main screen + widget overlays) in one begin/end. A
32
+ * widget's `backdrop` is a full-viewport fill painted behind it.
33
+ */
34
+ export function paintFrame(
35
+ layers: Layer[],
36
+ painter: Painter,
37
+ host: Host,
38
+ width: number,
39
+ height: number,
40
+ transform?: UITransform,
41
+ options: PaintOptions = {},
42
+ ) {
43
+ painter.beginFrame(width, height)
44
+ // The simulated keyboard rescales the whole UI into the band above it (the keyboard itself is the
45
+ // host's — DOM on web). The bottom band is left empty for the host to draw over.
46
+ if (transform) {
47
+ painter.save()
48
+ painter.translate(transform.tx, transform.ty)
49
+ painter.scale(transform.scale, transform.scale)
50
+ }
51
+ // z-order (matches the DOM): the base screen(s) first, then ALL widget backdrops, then ALL widget
52
+ // bodies — so with ≥2 overlays a later widget's backdrop never covers an earlier widget.
53
+ const widgets = layers.filter((l) => l.widget)
54
+ for (const l of layers) if (!l.widget) paintNode(l.tree, painter, host, options)
55
+ for (const l of widgets) if (l.backdrop) painter.fillRoundRect({ x: 0, y: 0, width, height }, undefined, l.backdrop.color)
56
+ for (const l of widgets) paintNode(l.tree, painter, host, options)
57
+ if (transform) painter.restore()
58
+ painter.endFrame()
59
+ }
60
+
61
+ function paintNode(node: RenderNode, painter: Painter, host: Host, options: PaintOptions) {
62
+ const { style, rect } = node
63
+ painter.save()
64
+ // A translucent container fades as a GROUP (composited offscreen, then faded once) so overlapping
65
+ // children blend like the DOM's CSS opacity; a leaf uses cheap per-primitive alpha (identical for a
66
+ // single fill, no offscreen needed).
67
+ const opacity = style.opacity !== undefined && style.opacity < 1 ? style.opacity : undefined
68
+ const grouped = opacity !== undefined && node.children.length > 0
69
+ if (grouped) painter.beginGroup(opacity!)
70
+ else if (opacity !== undefined) painter.setAlpha(opacity)
71
+
72
+ // 2D transform about `transformOrigin` (the node centre by default), affecting the node + its subtree.
73
+ if (style.transform) {
74
+ const [a, b, c, d, e, f] = style.transform
75
+ const o = style.transformOrigin
76
+ const cx = rect.x + (o ? (o.xPercent ? rect.width * o.x / 100 : o.x) : rect.width / 2)
77
+ const cy = rect.y + (o ? (o.yPercent ? rect.height * o.y / 100 : o.y) : rect.height / 2)
78
+ painter.translate(cx, cy)
79
+ painter.transform(a, b, c, d, e, f)
80
+ painter.translate(-cx, -cy)
81
+ }
82
+
83
+ const radius = clampRadius(style.borderRadius, rect)
84
+
85
+ // background color
86
+ if (!isTransparent(style.backgroundColor)) {
87
+ painter.fillRoundRect(rect, radius, style.backgroundColor!)
88
+ }
89
+
90
+ // background image (string url, painted behind content)
91
+ if (style.backgroundImage) {
92
+ const bg = host.getImage(style.backgroundImage)
93
+ if (bg && bg.naturalWidth > 0 && bg.naturalHeight > 0) {
94
+ painter.save()
95
+ if (hasRadius(radius)) painter.clipRoundRect(rect, radius)
96
+ painter.drawImage(bg, null, rect, { fit: bgSizeToFit(style.backgroundSize) })
97
+ painter.restore()
98
+ }
99
+ }
100
+
101
+ // background gradient layers (painted over the background image, like the DOM layer order).
102
+ // CSS paints the first-listed layer on top, so paint the stack back-to-front (last → first).
103
+ if (style.backgroundGradients) {
104
+ for (let i = style.backgroundGradients.length - 1; i >= 0; i--) {
105
+ const g = style.backgroundGradients[i]
106
+ if (g.stops.length > 0) painter.fillGradient(rect, radius, g)
107
+ }
108
+ }
109
+
110
+ // image (clipped to the radius if any). A source crop overrides object-fit (the frame fills the box).
111
+ if (node.image && node.image.naturalWidth > 0 && node.image.naturalHeight > 0) {
112
+ const crop = node.sourceRect
113
+ painter.save()
114
+ if (hasRadius(radius)) painter.clipRoundRect(rect, radius)
115
+ painter.drawImage(node.image, crop ? { x: crop[0], y: crop[1], width: crop[2], height: crop[3] } : null, rect, {
116
+ // DOM default: `img { object-fit: contain }`. A sourceRect crop always fills its box.
117
+ fit: crop ? "fill" : (style.objectFit ?? "contain"),
118
+ // tintColor recolors a mask-capable (SVG) image; on an opaque raster the DOM no-ops it, so
119
+ // only tint SVG sources — otherwise a photo would become a flat silhouette.
120
+ tint: isSvgLikeSrc(node.image.src) ? style.tintColor : undefined,
121
+ radius,
122
+ })
123
+ painter.restore()
124
+ }
125
+
126
+ // text
127
+ if (node.type === "text" && node.lines && node.lines.length > 0) {
128
+ paintText(node, painter, host)
129
+ }
130
+
131
+ // input / textarea: the value (or placeholder), single line, clipped, with a caret when focused
132
+ if ((node.type === "input" || node.type === "textarea") && node.id !== options.editing) {
133
+ paintInput(node, painter, host, radius)
134
+ }
135
+
136
+ // a surface under the picture: its box is a window onto it. Without one, a video is a sized
137
+ // placeholder
138
+ if (node.type === "video" || node.type === "native") {
139
+ if (options.surfaces) painter.clearRoundRect(rect, radius)
140
+ else if (node.type === "video") paintVideoPlaceholder(node, painter, host, radius)
141
+ }
142
+
143
+ // clip children to the viewport (scrollable / overflow:hidden)
144
+ if (node.clip) painter.clipRoundRect(rect, radius)
145
+
146
+ // children
147
+ for (const child of node.children) paintNode(child, painter, host, options)
148
+
149
+ // a screen's dim: black over its content and children (a transition's pose)
150
+ if (style.dim !== undefined && style.dim > 0) {
151
+ painter.fillRoundRect(rect, radius, Math.round(Math.min(1, style.dim) * 255) >>> 0)
152
+ }
153
+
154
+ // border on top
155
+ const sides = visibleBorders(style.border)
156
+ if (sides) painter.strokeBorder(rect, radius, sides)
157
+
158
+ if (grouped) painter.endGroup()
159
+ painter.restore()
160
+ }
161
+
162
+ function bgSizeToFit(size: "cover" | "contain" | "tile" | undefined): "cover" | "contain" | "fill" {
163
+ // tile isn't supported yet — fall back to cover.
164
+ return size === "contain" ? "contain" : "cover"
165
+ }
166
+
167
+ function visibleBorders(border: RenderNode["style"]["border"]): Borders | null {
168
+ if (!border) return null
169
+ const out: Borders = {}
170
+ let any = false
171
+ for (const side of ["top", "right", "bottom", "left"] as const) {
172
+ const sd = border[side]
173
+ if (sd && sd.width > 0 && (sd.color >>> 0) % 256 !== 0) {
174
+ out[side] = sd
175
+ any = true
176
+ }
177
+ }
178
+ return any ? out : null
179
+ }
180
+
181
+ // The painter gets a raw ctx (no host), so the host's family mapping (CSS-generic aliases,
182
+ // headless concrete faces) is applied here, when the FontSpec is built.
183
+ const familyOf = (host: Host, family: string | undefined) => {
184
+ const fam = family ?? "Roboto"
185
+ return host.resolveFontFamily?.(fam) ?? fam
186
+ }
187
+
188
+ function paintText(node: RenderNode, painter: Painter, host: Host) {
189
+ const s = node.style
190
+ const font: FontSpec = {
191
+ family: familyOf(host, s.fontFamily),
192
+ size: s.fontSize ?? 14,
193
+ weight: s.fontWeight ?? 400,
194
+ italic: s.fontStyle === "italic",
195
+ letterSpacing: s.letterSpacing ?? 0,
196
+ }
197
+ const color = s.color ?? 0x000000ff
198
+ const lineHeight = s.lineHeight
199
+ const align = s.textAlign ?? "start"
200
+ const metrics = host.fontMetrics(font)
201
+ const { rect, lines } = node
202
+ // The rect is the border box; inset by the node's own padding (the lines were already wrapped to
203
+ // this content width in renderTree). Matches the DOM, which applies padding via box-sizing.
204
+ const pl = node.padding?.[0] ?? 0
205
+ const pt = node.padding?.[1] ?? 0
206
+ const pr = node.padding?.[2] ?? 0
207
+ const contentX = rect.x + pl
208
+ const contentW = rect.width - pl - pr
209
+
210
+ for (let i = 0; i < lines!.length; i++) {
211
+ const line = lines![i]
212
+ const lineTop = rect.y + pt + i * lineHeight
213
+ const baselineY = lineTop + (lineHeight - (metrics.ascent + metrics.descent)) / 2 + metrics.ascent
214
+
215
+ const lineWidth = host.measureText(line, font)
216
+ const x =
217
+ align === "center"
218
+ ? contentX + (contentW - lineWidth) / 2
219
+ : align === "end"
220
+ ? contentX + contentW - lineWidth
221
+ : contentX
222
+
223
+ painter.fillText(line, x, baselineY, font, color)
224
+
225
+ if (s.textDecoration === "underline" || s.textDecoration === "line-through") {
226
+ const thickness = Math.max(1, font.size / 14)
227
+ const y = s.textDecoration === "underline" ? baselineY + thickness : baselineY - font.size * 0.3
228
+ painter.fillRoundRect({ x, y, width: lineWidth, height: thickness }, undefined, color)
229
+ }
230
+ }
231
+ }
232
+
233
+ function paintVideoPlaceholder(node: RenderNode, painter: Painter, host: Host, radius: BorderRadius | undefined) {
234
+ const { rect } = node
235
+ // Dark placeholder fill (unless the author set a background), so the video reads as a sized box.
236
+ if (isTransparent(node.style.backgroundColor)) painter.fillRoundRect(rect, radius, 0x111317ff)
237
+ // Centred play glyph.
238
+ const size = Math.min(rect.width, rect.height) * 0.3
239
+ if (size >= 6) {
240
+ const font: FontSpec = { family: "Roboto", size, weight: 400, italic: false, letterSpacing: 0 }
241
+ const metrics = host.fontMetrics(font)
242
+ const w = host.measureText("▶", font)
243
+ const x = rect.x + (rect.width - w) / 2
244
+ const baselineY = rect.y + (rect.height - (metrics.ascent + metrics.descent)) / 2 + metrics.ascent
245
+ painter.fillText("▶", x, baselineY, font, 0xffffffcc)
246
+ }
247
+ }
248
+
249
+ function paintInput(node: RenderNode, painter: Painter, host: Host, radius: BorderRadius | undefined) {
250
+ const s = node.style
251
+ const font: FontSpec = {
252
+ family: familyOf(host, s.fontFamily),
253
+ size: s.fontSize ?? 14,
254
+ weight: s.fontWeight ?? 400,
255
+ italic: s.fontStyle === "italic",
256
+ letterSpacing: s.letterSpacing ?? 0,
257
+ }
258
+ const value = node.value ?? ""
259
+ const empty = value.length === 0
260
+ const masked = s.inputType === "password" && !empty
261
+ const display = empty ? (s.placeholder ?? "") : masked ? "•".repeat(value.length) : value
262
+ const color = empty ? (s.placeholderColor ?? 0x999999ff) : (s.color ?? 0x000000ff)
263
+ const { rect } = node
264
+ // Inset the value/placeholder/caret by the field's own padding (rect is the border box).
265
+ const pl = node.padding?.[0] ?? 0
266
+ const pt = node.padding?.[1] ?? 0
267
+ const pr = node.padding?.[2] ?? 0
268
+ const pb = node.padding?.[3] ?? 0
269
+ const contentX = rect.x + pl
270
+ const contentW = rect.width - pl - pr
271
+ const contentY = rect.y + pt
272
+ const contentH = rect.height - pt - pb
273
+ const metrics = host.fontMetrics(font)
274
+ const lineHeight = s.lineHeight
275
+ const align = s.textAlign ?? "start"
276
+
277
+ // A textarea wraps to the content width and top-aligns its lines; an input is a single line,
278
+ // vertically centred in the field.
279
+ const multiline = node.type === "textarea"
280
+ const lines = multiline ? wrapText(display, contentW, (t) => host.measureText(t, font)).lines : [display]
281
+ const lineX = (w: number) =>
282
+ align === "center" ? contentX + (contentW - w) / 2 : align === "end" ? contentX + contentW - w : contentX
283
+ const lineBaseline = (i: number) =>
284
+ multiline
285
+ ? contentY + i * lineHeight + (lineHeight - (metrics.ascent + metrics.descent)) / 2 + metrics.ascent
286
+ : contentY + (contentH - (metrics.ascent + metrics.descent)) / 2 + metrics.ascent
287
+
288
+ painter.save()
289
+ painter.clipRoundRect(rect, radius) // keep content inside the field
290
+ for (let i = 0; i < lines.length; i++) {
291
+ const line = lines[i]
292
+ if (line.length > 0) painter.fillText(line, lineX(host.measureText(line, font)), lineBaseline(i), font, color)
293
+ }
294
+
295
+ // Caret at the end of the real value (or the start when empty) while focused. Static (no blink).
296
+ if (node.state?.focused) {
297
+ const valueLines = empty
298
+ ? [""]
299
+ : multiline
300
+ ? wrapText(masked ? "•".repeat(value.length) : value, contentW, (t) => host.measureText(t, font)).lines
301
+ : [display]
302
+ const i = valueLines.length - 1
303
+ const lineW = host.measureText(valueLines[i], font)
304
+ const caretX = empty ? lineX(0) : lineX(lineW) + lineW
305
+ const baselineY = lineBaseline(i)
306
+ const caretW = Math.max(1, font.size / 14)
307
+ painter.fillRoundRect(
308
+ { x: caretX, y: baselineY - metrics.ascent, width: caretW, height: metrics.ascent + metrics.descent },
309
+ undefined,
310
+ s.color ?? 0x000000ff,
311
+ )
312
+ }
313
+ painter.restore()
314
+ }