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
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Loads the reused creator-ui (Yoga + style parser) WASM. The glue is built `-sENVIRONMENT=web`,
|
|
2
|
+
// so headless (Bun) needs a `window` shim and the wasm bytes handed in via `wasmBinary` (proven to
|
|
3
|
+
// work in a probe: shim + wasmBinary → a full layout round-trip).
|
|
4
|
+
//
|
|
5
|
+
// This file is BROWSER-SAFE: it has no Node imports. The headless (fs-based) loader lives in
|
|
6
|
+
// `loadCreatorUiNode.ts` so a browser bundle (viewer/frontend) never pulls `node:fs` into its graph.
|
|
7
|
+
|
|
8
|
+
/** The subset of the emscripten module the adapter uses. */
|
|
9
|
+
export interface CreatorUiModule {
|
|
10
|
+
_malloc(size: number): number
|
|
11
|
+
_free(ptr: number): void
|
|
12
|
+
stringToUTF8(str: string, ptr: number, maxBytes: number): void
|
|
13
|
+
UTF8ToString(ptr: number): string
|
|
14
|
+
lengthBytesUTF8(str: string): number
|
|
15
|
+
addFunction(fn: (...args: any[]) => any, sig: string): number
|
|
16
|
+
HEAPF32: Float32Array
|
|
17
|
+
HEAPU32: Uint32Array
|
|
18
|
+
|
|
19
|
+
_setMeasureFunc(funcPtr: number): void
|
|
20
|
+
// The paint state (creator-ui/paint.gen.h): the dirty records of the frame + a node's record in place.
|
|
21
|
+
_paintCollect(): number
|
|
22
|
+
_paintNodes(): number
|
|
23
|
+
_paintRecords(): number
|
|
24
|
+
_cuiPaintOf(nodePtr: number): number
|
|
25
|
+
_cuiStringOf(id: number): number
|
|
26
|
+
_cuiGradientOf(id: number, outCountPtr: number): number
|
|
27
|
+
|
|
28
|
+
_createNodeRoot(screenId: number): number
|
|
29
|
+
_createNode(parentId: number, index: number, hasMeasureFunc: number): number
|
|
30
|
+
_removeNode(parentId: number, nodeId: number): void
|
|
31
|
+
_freeNode(rootId: number): void
|
|
32
|
+
_freeWidget(rootId: number): void
|
|
33
|
+
|
|
34
|
+
_setFloatExt(layer: number, nodeId: number, keyPtr: number, value: number): void
|
|
35
|
+
_setStringExt(layer: number, nodeId: number, keyPtr: number, valuePtr: number): void
|
|
36
|
+
_resetProp(nodeId: number, keyPtr: number): void
|
|
37
|
+
// Layer-scoped clear (`prop: null` inside a `$class` block, `$landscape`/`$portrait` too): the
|
|
38
|
+
// null lives in `layer` (reset semantics while active); layer 256 keeps _resetProp's erase semantics.
|
|
39
|
+
_resetPropExt(layer: number, nodeId: number, keyPtr: number): void
|
|
40
|
+
_markNodeDirty(nodeId: number): void
|
|
41
|
+
|
|
42
|
+
_getLayer(nodeId: number, keyPtr: number): number
|
|
43
|
+
_enableNodeLayer(nodeId: number, layer: number): void
|
|
44
|
+
_disableNodeLayer(nodeId: number, layer: number): void
|
|
45
|
+
|
|
46
|
+
_calculate(rootId: number, width: number, height: number): void
|
|
47
|
+
_calculateWidget(rootId: number, width: number, height: number): void
|
|
48
|
+
_getLayoutPointer(nodeId: number, outPtr: number): number
|
|
49
|
+
_getPaddingPointer(nodeId: number, outPtr: number): number
|
|
50
|
+
_getScreenId(nodeId: number): number
|
|
51
|
+
_getContentHeight(nodeId: number): number
|
|
52
|
+
_getContentWidth(nodeId: number): number
|
|
53
|
+
|
|
54
|
+
_setConditionValue(width: number, height: number): void
|
|
55
|
+
// (top, right, bottom, left[, nodePtr]) — CSS order; passing a node re-applies the dynamic
|
|
56
|
+
// `safe-*` styles over that subtree immediately (recalcPaddings).
|
|
57
|
+
_setSafePaddings(top: number, right: number, bottom: number, left: number, nodePtr?: number): void
|
|
58
|
+
// Which edges are exact-height system bars (comfort-* adds its knob) vs clearance insets
|
|
59
|
+
// (comfort-* floors). Bitmask: top=1, right=2, bottom=4, left=8. Call before _setSafePaddings.
|
|
60
|
+
_setSafeAreaBars(barEdges: number): void
|
|
61
|
+
|
|
62
|
+
// Theme variables (docs/ui-theme-plan.md): stage one key (both args are C strings; value 0
|
|
63
|
+
// removes), then _applyTheme() re-resolves dynamic styles on every live root — returns 1 when
|
|
64
|
+
// anything re-dispatched (relayout + repaint wanted).
|
|
65
|
+
_setThemeValue(namePtr: number, valuePtr: number): void
|
|
66
|
+
_applyTheme(): number
|
|
67
|
+
|
|
68
|
+
// The core's color parser (color.h): CSS color string → packed 0xRRGGBBAA (web build). The
|
|
69
|
+
// SAME parser node color styles go through — used for theme color values that never ride a
|
|
70
|
+
// node (the themable default text color).
|
|
71
|
+
_getColor(valuePtr: number): number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type CreateModule = (opts: Record<string, unknown>) => Promise<CreatorUiModule>
|
|
75
|
+
|
|
76
|
+
export type { CreateModule }
|
|
77
|
+
|
|
78
|
+
/** Load the module from a caller-provided emscripten factory + wasm bytes (browser path). */
|
|
79
|
+
export async function loadCreatorUiFromModule(
|
|
80
|
+
createModule: CreateModule,
|
|
81
|
+
wasmBinary: ArrayBuffer | Uint8Array,
|
|
82
|
+
): Promise<CreatorUiModule> {
|
|
83
|
+
if (typeof (globalThis as any).window === "undefined") {
|
|
84
|
+
;(globalThis as any).window = globalThis
|
|
85
|
+
}
|
|
86
|
+
return createModule({ wasmBinary, locateFile: () => "" })
|
|
87
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// The paint state reader (creator-ui/paint.gen.h, CuiPaint.gen.ts): the runtime keeps one resolved
|
|
2
|
+
// paint record per node; this renderer READS it — the dirty records `_paintCollect` hands out once
|
|
3
|
+
// per frame, or a node's record in place (`_cuiPaintOf`) whenever it measures. The per-prop style
|
|
4
|
+
// handlers this file replaced (2026-09-24) wrote the same ResolvedStyle field by field; here one
|
|
5
|
+
// record write fills it, and the text style arrives EFFECTIVE (the core inherits), so no ancestor
|
|
6
|
+
// walk remains anywhere in the renderer.
|
|
7
|
+
import type { ResolvedStyle } from "../../types"
|
|
8
|
+
import { CUI_FIELDS, CUI_NAMES, CUI_PAINT_WORDS, CuiFontStyle, CuiOverflow, CuiScrollDirection, cuiBit } from "../CuiPaint.gen"
|
|
9
|
+
import { readGradientStructs } from "./readGradient"
|
|
10
|
+
|
|
11
|
+
/** The wasm module surface the reader needs (a subset of CreatorUiModule). */
|
|
12
|
+
export interface PaintModule {
|
|
13
|
+
HEAPF32: Float32Array
|
|
14
|
+
HEAPU32: Uint32Array
|
|
15
|
+
UTF8ToString(ptr: number): string
|
|
16
|
+
_malloc(size: number): number
|
|
17
|
+
_paintCollect(): number
|
|
18
|
+
_paintNodes(): number
|
|
19
|
+
_paintRecords(): number
|
|
20
|
+
_cuiPaintOf(nodePtr: number): number
|
|
21
|
+
_cuiStringOf(id: number): number
|
|
22
|
+
_cuiGradientOf(id: number, outCountPtr: number): number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const F = CUI_FIELDS
|
|
26
|
+
const ALIGN: ResolvedStyle["textAlign"][] = ["start", "center", "end", "start", "end"] // start center end left right
|
|
27
|
+
|
|
28
|
+
let countPtr = 0
|
|
29
|
+
|
|
30
|
+
/** Read the record at `ptr` (a word-aligned CUIPaint) into `style`. */
|
|
31
|
+
export function readPaintRecord(M: PaintModule, ptr: number, style: ResolvedStyle): void {
|
|
32
|
+
const u32 = M.HEAPU32, f32 = M.HEAPF32
|
|
33
|
+
const w = ptr >>> 2
|
|
34
|
+
const presentLo = u32[w], presentHi = u32[w + 1]
|
|
35
|
+
const has = (bit: number) => cuiBit(presentLo, presentHi, bit)
|
|
36
|
+
const str = (word: number): string | undefined => {
|
|
37
|
+
const id = u32[w + word]
|
|
38
|
+
return id === 0 ? undefined : M.UTF8ToString(M._cuiStringOf(id))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// box
|
|
42
|
+
style.backgroundColor = u32[w + F.backgroundColor.word] >>> 0
|
|
43
|
+
style.opacity = f32[w + F.opacity.word]
|
|
44
|
+
style.dim = f32[w + F.dim.word]
|
|
45
|
+
style.display = CUI_NAMES.display[u32[w + F.display.word]]
|
|
46
|
+
style.overflow = u32[w + F.overflow.word] === CuiOverflow.hidden ? "hidden" : "visible"
|
|
47
|
+
style.scrollAxis = u32[w + F.scrollDirection.word] === CuiScrollDirection.horizontal ? "x" : "y"
|
|
48
|
+
// borders: the core fans borderWidth / borderColor out into the four sides (paint.cpp)
|
|
49
|
+
style.border = {
|
|
50
|
+
top: { width: f32[w + F.borderTopWidth.word], color: u32[w + F.borderTopColor.word] >>> 0 },
|
|
51
|
+
right: { width: f32[w + F.borderRightWidth.word], color: u32[w + F.borderRightColor.word] >>> 0 },
|
|
52
|
+
bottom: { width: f32[w + F.borderBottomWidth.word], color: u32[w + F.borderBottomColor.word] >>> 0 },
|
|
53
|
+
left: { width: f32[w + F.borderLeftWidth.word], color: u32[w + F.borderLeftColor.word] >>> 0 },
|
|
54
|
+
}
|
|
55
|
+
style.borderRadius = {
|
|
56
|
+
tl: f32[w + F.borderTopLeftRadius.word], tr: f32[w + F.borderTopRightRadius.word],
|
|
57
|
+
br: f32[w + F.borderBottomRightRadius.word], bl: f32[w + F.borderBottomLeftRadius.word],
|
|
58
|
+
}
|
|
59
|
+
// background layers
|
|
60
|
+
style.backgroundImage = str(F.backgroundImage.word)
|
|
61
|
+
style.backgroundSize = CUI_NAMES.backgroundSize[u32[w + F.backgroundSize.word]] as ResolvedStyle["backgroundSize"]
|
|
62
|
+
const gradientId = u32[w + F.backgroundGradient.word]
|
|
63
|
+
if (gradientId === 0) style.backgroundGradients = undefined
|
|
64
|
+
else {
|
|
65
|
+
if (countPtr === 0) countPtr = M._malloc(4)
|
|
66
|
+
const gp = M._cuiGradientOf(gradientId, countPtr)
|
|
67
|
+
const count = M.HEAPU32[countPtr >>> 2]
|
|
68
|
+
style.backgroundGradients = gp !== 0 && count > 0 ? readGradientStructs(M.HEAPF32, M.HEAPU32, gp, count) : undefined
|
|
69
|
+
}
|
|
70
|
+
// transform: the 2D affine [a,b,c,d,e,f] of the 3x3 (column-major m0 m1 m3 m4 m6 m7); identity = absent
|
|
71
|
+
const t = w + F.transform.word
|
|
72
|
+
const a = f32[t], b = f32[t + 1], c = f32[t + 3], d = f32[t + 4], e = f32[t + 6], f = f32[t + 7]
|
|
73
|
+
style.transform = a === 1 && b === 0 && c === 0 && d === 1 && e === 0 && f === 0 ? undefined : [a, b, c, d, e, f]
|
|
74
|
+
const o = w + F.transformOrigin.word
|
|
75
|
+
style.transformOrigin = { x: f32[o], y: f32[o + 1], xPercent: u32[o + 2] !== 0, yPercent: u32[o + 3] !== 0 }
|
|
76
|
+
|
|
77
|
+
// text (effective; absent = the renderer default — core/text.ts DEFAULTS)
|
|
78
|
+
style.color = has(F.color.bit) ? u32[w + F.color.word] >>> 0 : undefined
|
|
79
|
+
style.fontFamily = has(F.fontFamily.bit) ? str(F.fontFamily.word) : undefined
|
|
80
|
+
style.fontSize = f32[w + F.fontSize.word]
|
|
81
|
+
style.fontWeight = f32[w + F.fontWeight.word]
|
|
82
|
+
style.fontStyle = u32[w + F.fontStyle.word] === CuiFontStyle.italic ? "italic" : "normal"
|
|
83
|
+
style.lineHeight = f32[w + F.lineHeight.word]
|
|
84
|
+
style.letterSpacing = f32[w + F.letterSpacing.word]
|
|
85
|
+
style.textAlign = ALIGN[u32[w + F.textAlign.word]] ?? "start"
|
|
86
|
+
style.textDecoration = CUI_NAMES.textDecoration[u32[w + F.textDecoration.word]] as ResolvedStyle["textDecoration"]
|
|
87
|
+
style.lineClamp = f32[w + F.lineClamp.word]
|
|
88
|
+
style.textOverflow = CUI_NAMES.textOverflow[u32[w + F.textOverflow.word]] as ResolvedStyle["textOverflow"]
|
|
89
|
+
|
|
90
|
+
// image / input / widget (presence props: absent = the renderer default)
|
|
91
|
+
style.objectFit = CUI_NAMES.objectFit[u32[w + F.objectFit.word]] as ResolvedStyle["objectFit"]
|
|
92
|
+
style.tintColor = has(F.tintColor.bit) ? u32[w + F.tintColor.word] >>> 0 : undefined
|
|
93
|
+
style.placeholder = str(F.placeholder.word)
|
|
94
|
+
style.placeholderColor = has(F.placeholderColor.bit) ? u32[w + F.placeholderColor.word] >>> 0 : undefined
|
|
95
|
+
style.inputType = CUI_NAMES.type[u32[w + F.type.word]]
|
|
96
|
+
style.overlayColor = has(F.overlayColor.bit) ? u32[w + F.overlayColor.word] >>> 0 : undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The node's record has changes the frame's sync has not taken yet. */
|
|
100
|
+
export function paintPending(M: PaintModule, nodePtr: number): boolean {
|
|
101
|
+
const p = M._cuiPaintOf(nodePtr)
|
|
102
|
+
if (p === 0) return false
|
|
103
|
+
const w = p >>> 2
|
|
104
|
+
return M.HEAPU32[w + 2] !== 0 || M.HEAPU32[w + 3] !== 0
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Read a live node's current record in place (a measure that runs before this frame's sync). */
|
|
108
|
+
export function readPaintOf(M: PaintModule, nodePtr: number, style: ResolvedStyle): boolean {
|
|
109
|
+
const p = M._cuiPaintOf(nodePtr)
|
|
110
|
+
if (p === 0) return false
|
|
111
|
+
readPaintRecord(M, p, style)
|
|
112
|
+
return true
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Once per frame, before layout: every record that changed since the last call, into its node's
|
|
117
|
+
* style via `apply`. Returns the number of records taken.
|
|
118
|
+
*/
|
|
119
|
+
export function syncPaint(M: PaintModule, apply: (nodePtr: number, read: (style: ResolvedStyle) => void) => void): number {
|
|
120
|
+
const n = M._paintCollect()
|
|
121
|
+
if (n === 0) return 0
|
|
122
|
+
const nodes = M._paintNodes() >>> 2
|
|
123
|
+
const records = M._paintRecords()
|
|
124
|
+
for (let i = 0; i < n; i++) {
|
|
125
|
+
const ptr = M.HEAPU32[nodes + i]
|
|
126
|
+
const rec = records + i * CUI_PAINT_WORDS * 4
|
|
127
|
+
apply(ptr, (style) => readPaintRecord(M, rec, style))
|
|
128
|
+
}
|
|
129
|
+
return n
|
|
130
|
+
}
|
|
131
|
+
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Gradient, GradientStop, RadialExtent } from "../../types"
|
|
2
|
+
|
|
3
|
+
// Decodes the creator-ui CUIGradient struct (creator-ui/include/creator-ui/creator-ui.h) out of the
|
|
4
|
+
// wasm heap. wasm32 layout, all fields 4-byte aligned:
|
|
5
|
+
// CUIGradient { float angle@0; u32 type@4; u32 stopCount@8; u32 _pad@12; CUIGradientStop* stops@16;
|
|
6
|
+
// u32 shape@20; u32 extent@24; float cx@28; float cy@32;
|
|
7
|
+
// float rx@36; float ry@40; u32 rUnitX@44; u32 rUnitY@48 }
|
|
8
|
+
// CUIGradientStop { u32 color@0; float position@4 } (8 bytes, tightly packed)
|
|
9
|
+
// The radial fields are appended AFTER the 4-byte `stops` pointer, so the linear prefix
|
|
10
|
+
// (angle/type/stopCount/stops) is byte-identical to before. `color` is COLOR_FORMAT = 0xRRGGBBAA on
|
|
11
|
+
// web (== our RGBA), so no conversion is needed. extent == 4 means explicit rx/ry (rUnit 0 = px, 1 =
|
|
12
|
+
// box-axis fraction). Pure (takes heap views) so it can be unit-tested with a synthetic buffer.
|
|
13
|
+
|
|
14
|
+
const EXTENTS: RadialExtent[] = ["farthest-corner", "closest-side", "closest-corner", "farthest-side"]
|
|
15
|
+
|
|
16
|
+
// sizeof(CUIGradient) on wasm32 (13 × 4-byte fields), pinned by a static_assert in gradient.h. The
|
|
17
|
+
// multi-layer handler hands us a pointer to `count` of these laid out back-to-back.
|
|
18
|
+
const CUI_GRADIENT_STRIDE = 52
|
|
19
|
+
|
|
20
|
+
/** Decode a `count`-long CUIGradient array (the background-image layer stack, [0] = topmost). */
|
|
21
|
+
export function readGradientStructs(f32: Float32Array, u32: Uint32Array, ptr: number, count: number): Gradient[] {
|
|
22
|
+
const out: Gradient[] = []
|
|
23
|
+
for (let k = 0; k < count; k++) out.push(readGradientStruct(f32, u32, ptr + k * CUI_GRADIENT_STRIDE))
|
|
24
|
+
return out
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readGradientStruct(f32: Float32Array, u32: Uint32Array, ptr: number): Gradient {
|
|
28
|
+
const i = ptr >> 2
|
|
29
|
+
const angle = f32[i]
|
|
30
|
+
const type = u32[i + 1]
|
|
31
|
+
const stopCount = u32[i + 2]
|
|
32
|
+
const stopsPtr = u32[i + 4]
|
|
33
|
+
|
|
34
|
+
const stops: GradientStop[] = []
|
|
35
|
+
for (let s = 0; s < stopCount; s++) {
|
|
36
|
+
const b = (stopsPtr + s * 8) >> 2
|
|
37
|
+
stops.push({ color: u32[b] >>> 0, position: f32[b + 1] })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (type === 1) {
|
|
41
|
+
const extentCode = u32[i + 6]
|
|
42
|
+
const base = {
|
|
43
|
+
type: "radial" as const,
|
|
44
|
+
shape: (u32[i + 5] === 1 ? "circle" : "ellipse") as "circle" | "ellipse",
|
|
45
|
+
cx: f32[i + 7],
|
|
46
|
+
cy: f32[i + 8],
|
|
47
|
+
stops,
|
|
48
|
+
}
|
|
49
|
+
if (extentCode === 4) {
|
|
50
|
+
return {
|
|
51
|
+
...base,
|
|
52
|
+
extent: "explicit",
|
|
53
|
+
rx: f32[i + 9],
|
|
54
|
+
ry: f32[i + 10],
|
|
55
|
+
rxUnit: u32[i + 11] === 1 ? "fraction" : "px",
|
|
56
|
+
ryUnit: u32[i + 12] === 1 ? "fraction" : "px",
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { ...base, extent: EXTENTS[extentCode] ?? "farthest-corner" }
|
|
60
|
+
}
|
|
61
|
+
return { type: "linear", angle, stops }
|
|
62
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { BorderRadius, Gradient, ImageInfo, RadialGradient, Rect, RGBA } from "../types"
|
|
2
|
+
import type { FontSpec } from "../host/Host"
|
|
3
|
+
import type { Borders, DrawImageOpts, Painter } from "./Painter"
|
|
4
|
+
import { rgbaToCss } from "../core/util/colors"
|
|
5
|
+
import { clampRadius, computeObjectFit, gradientLine, radialGeometry } from "../core/util/geometry"
|
|
6
|
+
|
|
7
|
+
/** Painter backend over a CanvasRenderingContext2D (browser). */
|
|
8
|
+
type AnyCtx = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D
|
|
9
|
+
|
|
10
|
+
export class Canvas2DPainter implements Painter {
|
|
11
|
+
private ctx: AnyCtx
|
|
12
|
+
private dpr: number
|
|
13
|
+
// Stack of in-progress opacity groups: each captures the parent ctx + an offscreen layer + alpha.
|
|
14
|
+
private groups: { parent: AnyCtx; layer: HTMLCanvasElement | OffscreenCanvas; alpha: number }[] = []
|
|
15
|
+
|
|
16
|
+
constructor(ctx: CanvasRenderingContext2D, dpr = 1) {
|
|
17
|
+
this.ctx = ctx
|
|
18
|
+
this.dpr = dpr
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
beginGroup(alpha: number) {
|
|
22
|
+
const cur = this.ctx
|
|
23
|
+
const w = (cur.canvas as HTMLCanvasElement | OffscreenCanvas).width
|
|
24
|
+
const h = (cur.canvas as HTMLCanvasElement | OffscreenCanvas).height
|
|
25
|
+
let layer: HTMLCanvasElement | OffscreenCanvas
|
|
26
|
+
if (typeof OffscreenCanvas !== "undefined") {
|
|
27
|
+
layer = new OffscreenCanvas(w, h)
|
|
28
|
+
} else {
|
|
29
|
+
layer = document.createElement("canvas")
|
|
30
|
+
layer.width = w
|
|
31
|
+
layer.height = h
|
|
32
|
+
}
|
|
33
|
+
const lctx = (layer as any).getContext("2d") as AnyCtx
|
|
34
|
+
lctx.setTransform(cur.getTransform()) // paint the subtree in the same coordinate space
|
|
35
|
+
this.groups.push({ parent: cur, layer, alpha })
|
|
36
|
+
this.ctx = lctx
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
endGroup() {
|
|
40
|
+
const g = this.groups.pop()
|
|
41
|
+
if (!g) return
|
|
42
|
+
this.ctx = g.parent
|
|
43
|
+
this.ctx.save()
|
|
44
|
+
this.ctx.setTransform(1, 0, 0, 1, 0, 0) // composite the layer in device pixels
|
|
45
|
+
this.ctx.globalAlpha *= g.alpha
|
|
46
|
+
this.ctx.drawImage(g.layer as CanvasImageSource, 0, 0)
|
|
47
|
+
this.ctx.restore()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beginFrame(width: number, height: number) {
|
|
51
|
+
const { ctx, dpr } = this
|
|
52
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
53
|
+
ctx.clearRect(0, 0, width, height)
|
|
54
|
+
ctx.globalAlpha = 1
|
|
55
|
+
}
|
|
56
|
+
endFrame() {}
|
|
57
|
+
|
|
58
|
+
save() {
|
|
59
|
+
this.ctx.save()
|
|
60
|
+
}
|
|
61
|
+
restore() {
|
|
62
|
+
this.ctx.restore()
|
|
63
|
+
}
|
|
64
|
+
setAlpha(multiplier: number) {
|
|
65
|
+
this.ctx.globalAlpha *= multiplier
|
|
66
|
+
}
|
|
67
|
+
translate(x: number, y: number) {
|
|
68
|
+
this.ctx.translate(x, y)
|
|
69
|
+
}
|
|
70
|
+
scale(sx: number, sy: number) {
|
|
71
|
+
this.ctx.scale(sx, sy)
|
|
72
|
+
}
|
|
73
|
+
transform(a: number, b: number, c: number, d: number, e: number, f: number) {
|
|
74
|
+
this.ctx.transform(a, b, c, d, e, f)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Append a (rounded) rect sub-path to the current path — no beginPath, so callers can build
|
|
78
|
+
* multi-subpath paths (e.g. an even-odd ring). */
|
|
79
|
+
private addRectPath(rect: Rect, radius?: BorderRadius) {
|
|
80
|
+
const ctx = this.ctx
|
|
81
|
+
if (radius && (radius.tl || radius.tr || radius.br || radius.bl)) {
|
|
82
|
+
ctx.roundRect(rect.x, rect.y, rect.width, rect.height, [radius.tl, radius.tr, radius.br, radius.bl])
|
|
83
|
+
} else {
|
|
84
|
+
ctx.rect(rect.x, rect.y, rect.width, rect.height)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private path(rect: Rect, radius?: BorderRadius) {
|
|
89
|
+
this.ctx.beginPath()
|
|
90
|
+
this.addRectPath(rect, radius)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
clipRoundRect(rect: Rect, radius?: BorderRadius) {
|
|
94
|
+
this.path(rect, radius)
|
|
95
|
+
this.ctx.clip()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
fillRoundRect(rect: Rect, radius: BorderRadius | undefined, color: RGBA) {
|
|
99
|
+
this.path(rect, radius)
|
|
100
|
+
this.ctx.fillStyle = rgbaToCss(color)
|
|
101
|
+
this.ctx.fill()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
clearRoundRect(rect: Rect, radius: BorderRadius | undefined) {
|
|
105
|
+
const ctx = this.ctx
|
|
106
|
+
ctx.save()
|
|
107
|
+
ctx.globalCompositeOperation = "destination-out"
|
|
108
|
+
ctx.globalAlpha = 1
|
|
109
|
+
this.path(rect, radius)
|
|
110
|
+
ctx.fillStyle = "#000"
|
|
111
|
+
ctx.fill()
|
|
112
|
+
ctx.restore()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fillGradient(rect: Rect, radius: BorderRadius | undefined, gradient: Gradient) {
|
|
116
|
+
if (gradient.type === "radial") {
|
|
117
|
+
this.fillRadial(rect, radius, gradient)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
const { x0, y0, x1, y1 } = gradientLine(gradient.angle, rect)
|
|
121
|
+
const grad = this.ctx.createLinearGradient(x0, y0, x1, y1)
|
|
122
|
+
for (const stop of gradient.stops) grad.addColorStop(Math.max(0, Math.min(1, stop.position)), rgbaToCss(stop.color))
|
|
123
|
+
this.path(rect, radius)
|
|
124
|
+
this.ctx.fillStyle = grad
|
|
125
|
+
this.ctx.fill()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private fillRadial(rect: Rect, radius: BorderRadius | undefined, gradient: RadialGradient) {
|
|
129
|
+
const { cx, cy, rx, ry } = radialGeometry(gradient, rect)
|
|
130
|
+
const ctx = this.ctx
|
|
131
|
+
// createRadialGradient only makes circles; scale the context anisotropically so a unit-radius
|
|
132
|
+
// circle becomes the rx×ry ellipse. Clip to the (rounded) rect first — the clip is captured in
|
|
133
|
+
// the pre-scale space, so it stays put while we paint the ellipse in the scaled space.
|
|
134
|
+
const sx = rx || 1e-6
|
|
135
|
+
const sy = ry || 1e-6
|
|
136
|
+
ctx.save()
|
|
137
|
+
this.path(rect, radius)
|
|
138
|
+
ctx.clip()
|
|
139
|
+
ctx.translate(cx, cy)
|
|
140
|
+
ctx.scale(sx, sy)
|
|
141
|
+
const grad = ctx.createRadialGradient(0, 0, 0, 0, 0, 1)
|
|
142
|
+
for (const stop of gradient.stops) grad.addColorStop(Math.max(0, Math.min(1, stop.position)), rgbaToCss(stop.color))
|
|
143
|
+
ctx.fillStyle = grad
|
|
144
|
+
// Cover the whole clipped rect in the scaled coordinate space (canvas extends the last stop
|
|
145
|
+
// outward, so pixels past the ellipse edge get the final color, matching CSS).
|
|
146
|
+
const u0 = (rect.x - cx) / sx
|
|
147
|
+
const u1 = (rect.x + rect.width - cx) / sx
|
|
148
|
+
const v0 = (rect.y - cy) / sy
|
|
149
|
+
const v1 = (rect.y + rect.height - cy) / sy
|
|
150
|
+
ctx.fillRect(u0, v0, u1 - u0, v1 - v0)
|
|
151
|
+
ctx.restore()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
strokeBorder(rect: Rect, radius: BorderRadius | undefined, sides: Borders) {
|
|
155
|
+
const ctx = this.ctx
|
|
156
|
+
const present = (["top", "right", "bottom", "left"] as const).filter((s) => sides[s])
|
|
157
|
+
const uniform =
|
|
158
|
+
present.length === 4 &&
|
|
159
|
+
present.every((s) => sides[s]!.width === sides.top!.width && sides[s]!.color === sides.top!.color)
|
|
160
|
+
|
|
161
|
+
if (uniform) {
|
|
162
|
+
const w = sides.top!.width
|
|
163
|
+
// Border-box: the border lies fully INSIDE rect. Fill the ring between the outer rounded-rect
|
|
164
|
+
// and an inner rounded-rect inset by the border width (inner corner radius = outer − w), as an
|
|
165
|
+
// even-odd fill. We do NOT use a centred ctx.stroke(): a stroke straddles the path (half would
|
|
166
|
+
// sit outside the box), and the inset-path trick relied on roundRect clamping its radius — which
|
|
167
|
+
// @napi-rs/canvas (skia) does differently than browsers, so the border leaked outside the box.
|
|
168
|
+
const outer = clampRadius(radius, rect)
|
|
169
|
+
ctx.beginPath()
|
|
170
|
+
this.addRectPath(rect, outer)
|
|
171
|
+
const inner: Rect = { x: rect.x + w, y: rect.y + w, width: rect.width - 2 * w, height: rect.height - 2 * w }
|
|
172
|
+
if (inner.width > 0 && inner.height > 0) {
|
|
173
|
+
const innerRadius = outer && clampRadius(
|
|
174
|
+
{
|
|
175
|
+
tl: Math.max(0, outer.tl - w), tr: Math.max(0, outer.tr - w),
|
|
176
|
+
br: Math.max(0, outer.br - w), bl: Math.max(0, outer.bl - w),
|
|
177
|
+
},
|
|
178
|
+
inner,
|
|
179
|
+
)
|
|
180
|
+
this.addRectPath(inner, innerRadius || undefined)
|
|
181
|
+
}
|
|
182
|
+
ctx.fillStyle = rgbaToCss(sides.top!.color)
|
|
183
|
+
ctx.fill("evenodd")
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Per-side fallback: straight edges (radius ignored for mismatched borders).
|
|
188
|
+
for (const s of present) {
|
|
189
|
+
const { width, color } = sides[s]!
|
|
190
|
+
ctx.fillStyle = rgbaToCss(color)
|
|
191
|
+
if (s === "top") ctx.fillRect(rect.x, rect.y, rect.width, width)
|
|
192
|
+
else if (s === "bottom") ctx.fillRect(rect.x, rect.y + rect.height - width, rect.width, width)
|
|
193
|
+
else if (s === "left") ctx.fillRect(rect.x, rect.y, width, rect.height)
|
|
194
|
+
else ctx.fillRect(rect.x + rect.width - width, rect.y, width, rect.height)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private scratch?: OffscreenCanvas | HTMLCanvasElement
|
|
199
|
+
private scratchCtx?: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D
|
|
200
|
+
private getScratch(w: number, h: number) {
|
|
201
|
+
if (!this.scratch) {
|
|
202
|
+
this.scratch = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(w, h) : document.createElement("canvas")
|
|
203
|
+
this.scratchCtx = (this.scratch as any).getContext("2d")
|
|
204
|
+
}
|
|
205
|
+
if (this.scratch.width < w || this.scratch.height < h) {
|
|
206
|
+
this.scratch.width = Math.max(this.scratch.width, w)
|
|
207
|
+
this.scratch.height = Math.max(this.scratch.height, h)
|
|
208
|
+
}
|
|
209
|
+
return this.scratchCtx!
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
drawImage(image: ImageInfo, srcRect: Rect | null, dst: Rect, opts: DrawImageOpts) {
|
|
213
|
+
if (!image.handle) return
|
|
214
|
+
// An explicit crop fills the box; otherwise resolve object-fit.
|
|
215
|
+
const { src, dst: d } = srcRect
|
|
216
|
+
? { src: srcRect, dst }
|
|
217
|
+
: computeObjectFit(image.naturalWidth, image.naturalHeight, dst, opts.fit)
|
|
218
|
+
// The destination in DEVICE pixels: what a vector source rasterizes at, and the tint scratch's
|
|
219
|
+
// size — the frame is painted under the dpr transform, so a CSS-px scratch would be upscaled.
|
|
220
|
+
const dw = Math.max(1, Math.ceil(d.width * this.dpr))
|
|
221
|
+
const dh = Math.max(1, Math.ceil(d.height * this.dpr))
|
|
222
|
+
const handle = (image.rasterize ? image.rasterize(dw, dh) : image.handle) as CanvasImageSource
|
|
223
|
+
|
|
224
|
+
// When we're drawing the WHOLE image (no atlas crop, not a cover-crop) draw it WITHOUT a source
|
|
225
|
+
// rect. A viewBox-only SVG `<img>` has the browser's default 300×150 raster, so a source crop
|
|
226
|
+
// (computed from the viewBox size) samples the wrong region and the icon vanishes — drawing the
|
|
227
|
+
// whole image straight into the box lets the backend re-rasterize the SVG to fit (matches the DOM,
|
|
228
|
+
// and keeps the working tinted/scratch path consistent with the direct path).
|
|
229
|
+
const whole = !srcRect && opts.fit !== "cover"
|
|
230
|
+
|
|
231
|
+
if (opts.tint === undefined) {
|
|
232
|
+
if (whole) this.ctx.drawImage(handle, d.x, d.y, d.width, d.height)
|
|
233
|
+
else this.ctx.drawImage(handle, src.x, src.y, src.width, src.height, d.x, d.y, d.width, d.height)
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
// Tint on an offscreen so `source-in` only recolors the image pixels.
|
|
237
|
+
const sc = this.getScratch(dw, dh)
|
|
238
|
+
sc.clearRect(0, 0, dw, dh)
|
|
239
|
+
sc.globalCompositeOperation = "source-over"
|
|
240
|
+
if (whole) sc.drawImage(handle, 0, 0, dw, dh)
|
|
241
|
+
else sc.drawImage(handle, src.x, src.y, src.width, src.height, 0, 0, dw, dh)
|
|
242
|
+
sc.globalCompositeOperation = "source-in"
|
|
243
|
+
sc.fillStyle = rgbaToCss(opts.tint)
|
|
244
|
+
sc.fillRect(0, 0, dw, dh)
|
|
245
|
+
sc.globalCompositeOperation = "source-over"
|
|
246
|
+
this.ctx.drawImage(sc.canvas as CanvasImageSource, 0, 0, dw, dh, d.x, d.y, d.width, d.height)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
fillText(text: string, x: number, baselineY: number, font: FontSpec, color: RGBA) {
|
|
250
|
+
const ctx = this.ctx
|
|
251
|
+
ctx.font = `${font.italic ? "italic " : ""}${font.weight} ${font.size}px ${font.family}`
|
|
252
|
+
ctx.textBaseline = "alphabetic"
|
|
253
|
+
ctx.textAlign = "left"
|
|
254
|
+
if ("letterSpacing" in ctx) (ctx as any).letterSpacing = `${font.letterSpacing}px`
|
|
255
|
+
ctx.fillStyle = rgbaToCss(color)
|
|
256
|
+
ctx.fillText(text, x, baselineY)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { BorderRadius, BorderSide, Gradient, ImageInfo, Rect, RGBA } from "../types"
|
|
2
|
+
import type { FontSpec } from "../host/Host"
|
|
3
|
+
|
|
4
|
+
export type Borders = Partial<Record<"top" | "right" | "bottom" | "left", BorderSide>>
|
|
5
|
+
|
|
6
|
+
export interface DrawImageOpts {
|
|
7
|
+
fit: "cover" | "contain" | "fill"
|
|
8
|
+
tint?: RGBA
|
|
9
|
+
radius?: BorderRadius
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The low-level paint primitive interface. `paintTree` walks the render tree and replays it into a
|
|
14
|
+
* Painter, so every backend (Canvas2D, the test RecordingPainter, the future tgfx painter) shares
|
|
15
|
+
* the SAME paint logic and only the leaf primitives differ. All coordinates are ABSOLUTE px — the
|
|
16
|
+
* render tree is already absolute-positioned, so no transform stack is needed.
|
|
17
|
+
*/
|
|
18
|
+
export interface Painter {
|
|
19
|
+
beginFrame(width: number, height: number): void
|
|
20
|
+
endFrame(): void
|
|
21
|
+
|
|
22
|
+
/** Save/restore the clip + alpha + transform state. */
|
|
23
|
+
save(): void
|
|
24
|
+
restore(): void
|
|
25
|
+
|
|
26
|
+
/** Multiply the current alpha (composes through save/restore). */
|
|
27
|
+
setAlpha(multiplier: number): void
|
|
28
|
+
|
|
29
|
+
/** Begin a compositing group: subsequent paint is collected into an offscreen layer and composited
|
|
30
|
+
* once at `alpha` on `endGroup` — true CSS group opacity (a translucent container fades as a unit,
|
|
31
|
+
* not per-primitive). Pair with `endGroup`. */
|
|
32
|
+
beginGroup(alpha: number): void
|
|
33
|
+
endGroup(): void
|
|
34
|
+
|
|
35
|
+
/** Translate the coordinate system (composes through save/restore). */
|
|
36
|
+
translate(x: number, y: number): void
|
|
37
|
+
/** Scale the coordinate system (composes through save/restore). */
|
|
38
|
+
scale(sx: number, sy: number): void
|
|
39
|
+
/** Multiply in a 2D affine matrix [a,b,c,d,e,f] (composes through save/restore). */
|
|
40
|
+
transform(a: number, b: number, c: number, d: number, e: number, f: number): void
|
|
41
|
+
|
|
42
|
+
/** Intersect the clip with a (possibly rounded) rectangle. */
|
|
43
|
+
clipRoundRect(rect: Rect, radius?: BorderRadius): void
|
|
44
|
+
|
|
45
|
+
fillRoundRect(rect: Rect, radius: BorderRadius | undefined, color: RGBA): void
|
|
46
|
+
|
|
47
|
+
/** Take away what is painted in a (possibly rounded) rect, down to transparent: the window a
|
|
48
|
+
* surface UNDER the painted picture is seen through (a native view, a video). What is painted
|
|
49
|
+
* after it goes over the surface. */
|
|
50
|
+
clearRoundRect(rect: Rect, radius: BorderRadius | undefined): void
|
|
51
|
+
|
|
52
|
+
/** Fill a (possibly rounded) rect with a linear or radial gradient. */
|
|
53
|
+
fillGradient(rect: Rect, radius: BorderRadius | undefined, gradient: Gradient): void
|
|
54
|
+
|
|
55
|
+
/** Per-side borders, drawn inside `rect`, honoring `radius`. */
|
|
56
|
+
strokeBorder(rect: Rect, radius: BorderRadius | undefined, sides: Borders): void
|
|
57
|
+
|
|
58
|
+
drawImage(image: ImageInfo, src: Rect | null, dst: Rect, opts: DrawImageOpts): void
|
|
59
|
+
|
|
60
|
+
/** A single, already-wrapped line of text drawn at its baseline. */
|
|
61
|
+
fillText(text: string, x: number, baselineY: number, font: FontSpec, color: RGBA): void
|
|
62
|
+
}
|