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,141 @@
|
|
|
1
|
+
// Image-source resolution, shared by every Host. The authoring layer (worker) hands an image `src`
|
|
2
|
+
// (or `backgroundImage`) as one of: a string url, an inline `SvgSource` ({ svg }), or a host buffer
|
|
3
|
+
// handle ({ _id } — an SDK `FetchResponse`/`File`, both carrying the buffer id under `_id`). A raw
|
|
4
|
+
// DOM `File` is a separate, browser-embed-only case. Only the string case is platform-free; the rest
|
|
5
|
+
// need a host-provided URL (asset bytes / object URL), so each Host wires those callbacks in.
|
|
6
|
+
|
|
7
|
+
/** An inline SVG source (`SvgSource(...)` in the worker). */
|
|
8
|
+
export function isSvgSource(src: unknown): src is { svg: string } {
|
|
9
|
+
return typeof src === "object" && src !== null && "svg" in (src as Record<string, unknown>)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A host buffer handle: an SDK `FetchResponse` / `File` / uploaded asset, keyed by its buffer id.
|
|
13
|
+
* Checked BEFORE isFileLike — an SDK `File` is a plain object with `_id`, not a DOM File. */
|
|
14
|
+
function hasId(src: unknown): src is { _id: number } {
|
|
15
|
+
return typeof src === "object" && src !== null && "_id" in (src as Record<string, unknown>)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A baked `_creatorCanvas` surface (UIImage(canvas)) — resolved to a texture via a CanvasSurfaceProvider. */
|
|
19
|
+
function hasCanvasSurface(src: unknown): src is { canvasSurface: number } {
|
|
20
|
+
return typeof src === "object" && src !== null && "canvasSurface" in (src as Record<string, unknown>)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Synthetic url a `{ canvasSurface }` src resolves to; the host turns it back into a baked surface. */
|
|
24
|
+
export const CANVAS_SRC_PREFIX = "lecodes-canvas://"
|
|
25
|
+
/** Parse a `lecodes-canvas://N` url back to its surface id, or undefined. */
|
|
26
|
+
export function canvasSurfaceId(src: string): number | undefined {
|
|
27
|
+
return src.startsWith(CANVAS_SRC_PREFIX) ? Number(src.slice(CANVAS_SRC_PREFIX.length)) : undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A raw DOM `File` — only ever reaches here from a browser embed handing one in directly. The SDK's
|
|
31
|
+
* `File` is NOT one of these: it's a `{ _id, name, size }` buffer handle caught by hasId above. */
|
|
32
|
+
function isFileLike(src: unknown): boolean {
|
|
33
|
+
return typeof File !== "undefined" && src instanceof File
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Inline SVG → a `data:image/svg+xml` URI an `<img>` / decoder can load (mirrors the DOM viewer). */
|
|
37
|
+
export function svgToDataUri(svg: string): string {
|
|
38
|
+
let s = svg.trim()
|
|
39
|
+
// A data: URI is parsed as a standalone XML document — xmlns is mandatory there (inline DOM <svg>
|
|
40
|
+
// tolerates its absence; a data-uri image without it renders broken).
|
|
41
|
+
if (!/\sxmlns\s*=/.test(s)) s = s.replace(/<svg\b/, '<svg xmlns="http://www.w3.org/2000/svg"')
|
|
42
|
+
const bytes = new TextEncoder().encode(s)
|
|
43
|
+
let binary = ""
|
|
44
|
+
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
45
|
+
return `data:image/svg+xml;base64,${btoa(binary)}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** True if a *resolved* src is an SVG (data-uri or `.svg` url) — i.e. a mask-capable, tintable image.
|
|
49
|
+
* Used to gate `tintColor`: SVG recolors (mask), an opaque raster does not (the DOM no-ops it). */
|
|
50
|
+
export function isSvgLikeSrc(src: string | undefined): boolean {
|
|
51
|
+
return !!src && (src.startsWith("data:image/svg+xml") || /\.svg(\?|#|$)/i.test(src))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Intrinsic size of an inline SVG, taken from its `viewBox` ("min-x min-y width height"), or its
|
|
56
|
+
* `<svg>` width/height attributes, or null. SVG is a vector, so this is the size to lay out at (and
|
|
57
|
+
* it may upscale). Matches the DOM, which reads the viewBox into `data-viewbox` for measuring.
|
|
58
|
+
*/
|
|
59
|
+
export function extractSvgViewBox(svg: string): { width: number; height: number } | null {
|
|
60
|
+
// Only read the opening <svg> tag, so child element width/height never leak in.
|
|
61
|
+
const tag = svg.match(/<svg\b[^>]*>/i)?.[0] ?? ""
|
|
62
|
+
const vb = tag.match(/viewBox\s*=\s*["']\s*[\d.+-]+\s+[\d.+-]+\s+([\d.+-]+)\s+([\d.+-]+)\s*["']/i)
|
|
63
|
+
if (vb) {
|
|
64
|
+
const w = parseFloat(vb[1]), h = parseFloat(vb[2])
|
|
65
|
+
if (w > 0 && h > 0) return { width: w, height: h }
|
|
66
|
+
}
|
|
67
|
+
const w = parseFloat(tag.match(/\bwidth\s*=\s*["']?([\d.]+)/i)?.[1] ?? "")
|
|
68
|
+
const h = parseFloat(tag.match(/\bheight\s*=\s*["']?([\d.]+)/i)?.[1] ?? "")
|
|
69
|
+
if (w > 0 && h > 0) return { width: w, height: h }
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Decode an `data:image/svg+xml` URI (base64 or text/url-encoded) back to its markup, or null. */
|
|
74
|
+
export function decodeSvgDataUri(src: string): string | null {
|
|
75
|
+
const m = /^data:image\/svg\+xml(;[^,]*)?,([\s\S]*)$/.exec(src)
|
|
76
|
+
if (!m) return null
|
|
77
|
+
const data = m[2]
|
|
78
|
+
try {
|
|
79
|
+
if (/;base64/i.test(m[1] ?? "")) {
|
|
80
|
+
const bin = atob(data)
|
|
81
|
+
const bytes = new Uint8Array(bin.length)
|
|
82
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
|
83
|
+
return new TextDecoder().decode(bytes)
|
|
84
|
+
}
|
|
85
|
+
return decodeURIComponent(data)
|
|
86
|
+
} catch {
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The intrinsic (viewBox) size of an SVG from its resolved src. SVGs without explicit width/height
|
|
93
|
+
* have NO intrinsic size, so a browser `<img>` reports the CSS default 300×150 — wrong for layout +
|
|
94
|
+
* object-fit. Reading the viewBox instead makes canvas/native size SVGs to their box like the DOM
|
|
95
|
+
* does. Inline data-uris are decoded synchronously; a remote/blob `.svg` url needs `fetchText`.
|
|
96
|
+
*/
|
|
97
|
+
export async function svgIntrinsicSize(
|
|
98
|
+
src: string,
|
|
99
|
+
fetchText?: (url: string) => Promise<string | null>,
|
|
100
|
+
): Promise<{ width: number; height: number } | null> {
|
|
101
|
+
if (!isSvgLikeSrc(src)) return null
|
|
102
|
+
const inline = decodeSvgDataUri(src)
|
|
103
|
+
if (inline) return extractSvgViewBox(inline)
|
|
104
|
+
if (!fetchText) return null
|
|
105
|
+
const text = await fetchText(src).catch(() => null)
|
|
106
|
+
return text ? extractSvgViewBox(text) : null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface SrcResolverOptions {
|
|
110
|
+
/** Resolve a host buffer handle ({ _id } — SDK `FetchResponse` / `File`) to a url (e.g. a blob url
|
|
111
|
+
* for the fetched bytes). */
|
|
112
|
+
assetUrl?: (id: number) => string | undefined
|
|
113
|
+
/** Resolve a raw DOM `File` to a url (browser: `URL.createObjectURL`). Not the SDK `File` — that
|
|
114
|
+
* one is a buffer handle and goes through `assetUrl`. */
|
|
115
|
+
fileUrl?: (file: unknown) => string | undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build a memoized `resolveSrc(rawSrc) → string | undefined`. Strings pass through; `SvgSource` →
|
|
120
|
+
* data-uri; `{ _id }` (SDK `FetchResponse`/`File`) and a raw DOM `File` go through the injected
|
|
121
|
+
* callbacks (undefined when not wired, e.g. headless). Memoized by object identity so SVG
|
|
122
|
+
* data-uris / blob urls are created once per source.
|
|
123
|
+
*/
|
|
124
|
+
export function createSrcResolver(opts: SrcResolverOptions = {}): (src: unknown) => string | undefined {
|
|
125
|
+
const cache = new WeakMap<object, string>()
|
|
126
|
+
return (src: unknown): string | undefined => {
|
|
127
|
+
if (src == null) return undefined
|
|
128
|
+
if (typeof src === "string") return src
|
|
129
|
+
if (typeof src !== "object") return undefined
|
|
130
|
+
const obj = src as object
|
|
131
|
+
const cached = cache.get(obj)
|
|
132
|
+
if (cached !== undefined) return cached
|
|
133
|
+
let out: string | undefined
|
|
134
|
+
if (isSvgSource(obj)) out = svgToDataUri(obj.svg)
|
|
135
|
+
else if (hasCanvasSurface(obj)) out = CANVAS_SRC_PREFIX + obj.canvasSurface
|
|
136
|
+
else if (hasId(obj)) out = opts.assetUrl?.(obj._id)
|
|
137
|
+
else if (isFileLike(obj)) out = opts.fileUrl?.(obj)
|
|
138
|
+
if (out !== undefined) cache.set(obj, out)
|
|
139
|
+
return out
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { ImageInfo } from "../types"
|
|
2
|
+
import { resolveCanvasImage, type CanvasSurfaceProvider, type FontSpec, type Host } from "./Host"
|
|
3
|
+
import { createSrcResolver, isSvgLikeSrc, svgIntrinsicSize } from "../core/util/svgSrc"
|
|
4
|
+
|
|
5
|
+
// The SDK spells the fixed-width system family "monospaced" (the iOS name); CSS spells it
|
|
6
|
+
// "monospace". Other generics ("serif", "sans-serif") are real CSS keywords and resolve natively.
|
|
7
|
+
const cssFamily = (family: string) => (family === "monospaced" ? "monospace" : family)
|
|
8
|
+
|
|
9
|
+
function fontCss(font: FontSpec): string {
|
|
10
|
+
return `${font.italic ? "italic " : ""}${font.weight} ${font.size}px ${cssFamily(font.family)}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface BrowserHostOptions {
|
|
14
|
+
/** Resolve an uploaded asset ({ _id }) to a url — the app injects this (e.g. the viewer's
|
|
15
|
+
* `getUrl(id)` blob-url resolver). Without it, `{ _id }` image sources don't render. */
|
|
16
|
+
resolveAssetUrl?: (id: number) => string | undefined
|
|
17
|
+
/** Resolve `UIImage(canvas)` sources (baked `_creatorCanvas` surfaces) to textures. */
|
|
18
|
+
canvasSurfaces?: CanvasSurfaceProvider
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Browser host: text metrics via a measuring 2D context, image decode via HTMLImageElement. */
|
|
22
|
+
export class BrowserHost implements Host {
|
|
23
|
+
private ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D
|
|
24
|
+
private images = new Map<string, ImageInfo>()
|
|
25
|
+
private pending = new Map<string, Promise<ImageInfo>>()
|
|
26
|
+
private resolver: (src: unknown) => string | undefined
|
|
27
|
+
private canvasSurfaces?: CanvasSurfaceProvider
|
|
28
|
+
|
|
29
|
+
constructor(opts: BrowserHostOptions = {}) {
|
|
30
|
+
// Measure on a DOM canvas whenever a document exists: a main-thread OffscreenCanvas context
|
|
31
|
+
// does not reliably resolve document.fonts faces in every browser (Safari), which would keep
|
|
32
|
+
// measuring custom faces with the fallback while the visible canvas paints the real one.
|
|
33
|
+
// OffscreenCanvas stays for workers, where there is no document.
|
|
34
|
+
const canvas =
|
|
35
|
+
typeof document !== "undefined"
|
|
36
|
+
? (document.createElement("canvas") as HTMLCanvasElement)
|
|
37
|
+
: new OffscreenCanvas(8, 8)
|
|
38
|
+
this.ctx = (canvas as any).getContext("2d")
|
|
39
|
+
this.canvasSurfaces = opts.canvasSurfaces
|
|
40
|
+
// Inline SVG → data-uri; uploaded assets via the app's resolver; File → object url.
|
|
41
|
+
this.resolver = createSrcResolver({
|
|
42
|
+
assetUrl: opts.resolveAssetUrl,
|
|
43
|
+
fileUrl: (file) => URL.createObjectURL(file as File),
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
resolveSrc(src: unknown): string | undefined {
|
|
48
|
+
return this.resolver(src)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Image decodes still in flight — lets a host idle its paint loop until they land (the
|
|
52
|
+
* headless NodeCanvasHost exposes the same probe). */
|
|
53
|
+
pendingImages(): number {
|
|
54
|
+
return this.pending.size
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
resolveFontFamily(family: string): string {
|
|
58
|
+
return cssFamily(family)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
measureText(text: string, font: FontSpec): number {
|
|
62
|
+
this.ctx.font = fontCss(font)
|
|
63
|
+
;(this.ctx as any).letterSpacing = `${font.letterSpacing}px`
|
|
64
|
+
return this.ctx.measureText(text).width
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fontMetrics(font: FontSpec) {
|
|
68
|
+
this.ctx.font = fontCss(font)
|
|
69
|
+
const m = this.ctx.measureText("Mg")
|
|
70
|
+
// The FONT's ascent/descent — the metrics CSS line-box layout centers inside line-height.
|
|
71
|
+
// The ink extent (actualBoundingBox of the sample glyphs) is smaller, which would place the
|
|
72
|
+
// baseline ~1px higher than the DOM/native render of the same text.
|
|
73
|
+
const ascent = m.fontBoundingBoxAscent || m.actualBoundingBoxAscent || font.size * 0.8
|
|
74
|
+
const descent = m.fontBoundingBoxDescent || m.actualBoundingBoxDescent || font.size * 0.2
|
|
75
|
+
return { ascent, descent }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getImage(src: string): ImageInfo | undefined {
|
|
79
|
+
return resolveCanvasImage(src, this.canvasSurfaces) ?? this.images.get(src)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
loadImage(src: string): Promise<ImageInfo> {
|
|
83
|
+
const canvasImg = resolveCanvasImage(src, this.canvasSurfaces)
|
|
84
|
+
if (canvasImg) return Promise.resolve(canvasImg) // baked surface — already rasterized, no decode
|
|
85
|
+
const cached = this.images.get(src)
|
|
86
|
+
if (cached) return Promise.resolve(cached)
|
|
87
|
+
const inFlight = this.pending.get(src)
|
|
88
|
+
if (inFlight) return inFlight
|
|
89
|
+
|
|
90
|
+
const promise = (async (): Promise<ImageInfo> => {
|
|
91
|
+
const img = await this.decode(src)
|
|
92
|
+
let naturalWidth = img.naturalWidth
|
|
93
|
+
let naturalHeight = img.naturalHeight
|
|
94
|
+
// A browser gives a viewBox-only SVG the CSS default 300×150 intrinsic size, which breaks layout
|
|
95
|
+
// + object-fit (the icon won't size to its box). Use the real viewBox instead — matching the DOM
|
|
96
|
+
// (which reads the viewBox) and the native renderers.
|
|
97
|
+
if (isSvgLikeSrc(src)) {
|
|
98
|
+
const vb = await svgIntrinsicSize(src, (url) => fetch(url).then((r) => (r.ok ? r.text() : null)))
|
|
99
|
+
if (vb) { naturalWidth = vb.width; naturalHeight = vb.height }
|
|
100
|
+
}
|
|
101
|
+
const info: ImageInfo = { src, naturalWidth, naturalHeight, handle: img }
|
|
102
|
+
this.images.set(src, info)
|
|
103
|
+
this.pending.delete(src)
|
|
104
|
+
return info
|
|
105
|
+
})()
|
|
106
|
+
this.pending.set(src, promise)
|
|
107
|
+
promise.catch(() => this.pending.delete(src)) // a decode failure must clear the in-flight slot
|
|
108
|
+
return promise
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Decode an image url into an HTMLImageElement (resolves on load, rejects on error). */
|
|
112
|
+
private decode(src: string): Promise<HTMLImageElement> {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
const img = new Image()
|
|
115
|
+
img.onload = () => resolve(img)
|
|
116
|
+
img.onerror = (e) => reject(e)
|
|
117
|
+
img.src = src
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Browser host: CDN faces should come as woff2 (the font() macro's generated code reads this).
|
|
122
|
+
fontFormat = "woff2" as const
|
|
123
|
+
|
|
124
|
+
async loadFont(family: string, url: string, options: { weight?: number; style?: string }): Promise<void> {
|
|
125
|
+
if (typeof (globalThis as any).FontFace === "undefined") return
|
|
126
|
+
const face = new FontFace(family, `url(${url})`, options as FontFaceDescriptors)
|
|
127
|
+
// Added before load resolves: text swaps in the moment the bytes land, whether or not
|
|
128
|
+
// anything awaits the legacy completion callback (Renderer purges wrap caches on resolve).
|
|
129
|
+
;(document as any).fonts.add(face)
|
|
130
|
+
await face.load()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ImageInfo } from "../types"
|
|
2
|
+
import { resolveCanvasImage, type CanvasSurfaceProvider, type FontSpec, type Host } from "./Host"
|
|
3
|
+
import { createSrcResolver } from "../core/util/svgSrc"
|
|
4
|
+
|
|
5
|
+
export interface HeadlessHostOptions {
|
|
6
|
+
/** Per-character advance as a fraction of font size (default 0.5 — a stable monospace-ish model). */
|
|
7
|
+
charWidthFactor?: number
|
|
8
|
+
/** Preloaded intrinsic image sizes, keyed by src, so layout is deterministic with no I/O. */
|
|
9
|
+
images?: Record<string, { width: number; height: number }>
|
|
10
|
+
/** Resolve `UIImage(canvas)` sources (baked `_creatorCanvas` surfaces) to textures. */
|
|
11
|
+
canvasSurfaces?: CanvasSurfaceProvider
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Deterministic, I/O-free host for headless runs and tests. Text metrics follow a simple, stable
|
|
16
|
+
* model (so snapshots don't depend on installed fonts), and image sizes come from a preloaded map.
|
|
17
|
+
*/
|
|
18
|
+
export class HeadlessHost implements Host {
|
|
19
|
+
private factor: number
|
|
20
|
+
private images = new Map<string, ImageInfo>()
|
|
21
|
+
private canvasSurfaces?: CanvasSurfaceProvider
|
|
22
|
+
|
|
23
|
+
constructor(opts: HeadlessHostOptions = {}) {
|
|
24
|
+
this.factor = opts.charWidthFactor ?? 0.5
|
|
25
|
+
this.canvasSurfaces = opts.canvasSurfaces
|
|
26
|
+
for (const [src, size] of Object.entries(opts.images ?? {})) {
|
|
27
|
+
this.images.set(src, { src, naturalWidth: size.width, naturalHeight: size.height })
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Register an intrinsic image size after construction. */
|
|
32
|
+
setImageSize(src: string, width: number, height: number) {
|
|
33
|
+
this.images.set(src, { src, naturalWidth: width, naturalHeight: height })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
measureText(text: string, font: FontSpec): number {
|
|
37
|
+
if (text.length === 0) return 0
|
|
38
|
+
return text.length * (font.size * this.factor + font.letterSpacing)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
fontMetrics(font: FontSpec) {
|
|
42
|
+
return { ascent: font.size * 0.8, descent: font.size * 0.2 }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getImage(src: string): ImageInfo | undefined {
|
|
46
|
+
return resolveCanvasImage(src, this.canvasSurfaces) ?? this.images.get(src)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
loadImage(src: string): Promise<ImageInfo> {
|
|
50
|
+
return Promise.resolve(resolveCanvasImage(src, this.canvasSurfaces) ?? this.images.get(src) ?? { src, naturalWidth: 0, naturalHeight: 0 })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** No real fonts headless — metrics are synthetic, so this is a no-op. */
|
|
54
|
+
loadFont(): Promise<void> {
|
|
55
|
+
return Promise.resolve()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Inline SVG → data-uri; uploaded `{ _id }` / `File` have no bytes headless → undefined (project
|
|
59
|
+
// image assets compile to string urls in the CLI, so this is the rare case).
|
|
60
|
+
private resolver = createSrcResolver()
|
|
61
|
+
resolveSrc(src: unknown): string | undefined {
|
|
62
|
+
return this.resolver(src)
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/host/Host.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ImageInfo } from "../types"
|
|
2
|
+
import { canvasSurfaceId } from "../core/util/svgSrc"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Access to baked `_creatorCanvas` surfaces, so a `UIImage(canvas)` resolves to a texture. `canvas` is
|
|
6
|
+
* a drawable the painter can `drawImage` (an OffscreenCanvas on web, a napi canvas headless); width/
|
|
7
|
+
* height are the surface's device pixels. Injected into a Host so it can turn a `lecodes-canvas://N`
|
|
8
|
+
* src into an ImageInfo.
|
|
9
|
+
*/
|
|
10
|
+
export interface CanvasSurfaceProvider {
|
|
11
|
+
getSurface(surfaceId: number): { canvas: unknown, width: number, height: number } | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** If `src` is a `lecodes-canvas://N` url, resolve it to an ImageInfo via the provider (the baked
|
|
15
|
+
* surface is already rasterized → synchronous). Returns undefined for any other src. */
|
|
16
|
+
export function resolveCanvasImage(src: string, provider: CanvasSurfaceProvider | undefined): ImageInfo | undefined {
|
|
17
|
+
if (!provider) return undefined
|
|
18
|
+
const id = canvasSurfaceId(src)
|
|
19
|
+
if (id === undefined) return undefined
|
|
20
|
+
const surf = provider.getSurface(id)
|
|
21
|
+
if (!surf) return undefined
|
|
22
|
+
return { src, naturalWidth: surf.width, naturalHeight: surf.height, handle: surf.canvas }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A fully-resolved font, as the core asks the host to measure it. */
|
|
26
|
+
export interface FontSpec {
|
|
27
|
+
family: string
|
|
28
|
+
size: number
|
|
29
|
+
weight: number
|
|
30
|
+
italic: boolean
|
|
31
|
+
letterSpacing: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Platform capabilities the portable core can't implement itself. Injected so the core runs
|
|
36
|
+
* headless under Bun (HeadlessHost) and in the browser (BrowserHost) unchanged.
|
|
37
|
+
*/
|
|
38
|
+
export interface Host {
|
|
39
|
+
/** Advance width of a single line of text, in CSS pixels. */
|
|
40
|
+
measureText(text: string, font: FontSpec): number
|
|
41
|
+
|
|
42
|
+
/** Vertical metrics for a font (used for line-height fallback and the text baseline). */
|
|
43
|
+
fontMetrics(font: FontSpec): { ascent: number; descent: number }
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Synchronous cache lookup for an image's intrinsic size. Returns undefined until the image is
|
|
47
|
+
* decoded — the core then measures it as 0×0 (collapsed), exactly like the DOM renderer before
|
|
48
|
+
* `naturalWidth` is known.
|
|
49
|
+
*/
|
|
50
|
+
getImage(src: string): ImageInfo | undefined
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Begin (or reuse) an async decode. Resolves with the intrinsic size; the caller marks the
|
|
54
|
+
* owning screen dirty so the next render picks up the now-known size. May resolve synchronously
|
|
55
|
+
* via the cache in HeadlessHost.
|
|
56
|
+
*/
|
|
57
|
+
loadImage(src: string): Promise<ImageInfo>
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Load a custom font so subsequent `measureText`/painting use it. Web: `FontFace`; headless:
|
|
61
|
+
* disk/network bytes into the canvas backend (NodeCanvasHost) or a no-op (synthetic metrics);
|
|
62
|
+
* desktop: a tgfx typeface load. Resolves once the font is usable (or has fallen back).
|
|
63
|
+
*/
|
|
64
|
+
loadFont(family: string, url: string, options: { weight?: number; style?: string }): Promise<void>
|
|
65
|
+
|
|
66
|
+
/** Preferred CDN face container (`_creatorUI.fontFormat`): browser hosts "woff2"; absent = ttf. */
|
|
67
|
+
fontFormat?: "woff2" | "ttf"
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Map a style-level fontFamily to what this host's text stack can actually resolve — the CSS
|
|
71
|
+
* generic families, mostly: browser hosts alias the SDK's "monospaced" to the CSS keyword
|
|
72
|
+
* "monospace"; headless pins every generic to a vendored concrete face (genericFamilies.ts),
|
|
73
|
+
* because Skia has no generic resolution. Applied by paintTree when building a FontSpec; hosts
|
|
74
|
+
* also apply it inside their own measureText/fontMetrics so layout and paint always agree.
|
|
75
|
+
*/
|
|
76
|
+
resolveFontFamily?(family: string): string
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve an authoring image source to a loadable url string, or undefined if it can't be. The
|
|
80
|
+
* source is a string url, an inline `SvgSource` ({ svg }), an uploaded asset ({ _id }), or a
|
|
81
|
+
* `File`. String + SVG are platform-free; `{ _id }`/`File` need platform url resolution, which only
|
|
82
|
+
* some hosts wire (e.g. the browser host with the app's asset resolver). Memoized per source.
|
|
83
|
+
*/
|
|
84
|
+
resolveSrc(src: unknown): string | undefined
|
|
85
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// AUTO-GENERATED from engines/ui/properties.mjs — DO NOT EDIT. Regenerate: bun run gen:ui-props
|
|
2
|
+
//
|
|
3
|
+
// The CUIPaint record layout (engines/ui/include/creator-ui/paint.gen.h) for a reader over the wasm
|
|
4
|
+
// heap: `HEAPU32[(ptr >> 2) + word]` / `HEAPF32[...]`. Bits index the present / dirty masks
|
|
5
|
+
// (two u32 words each: lo at word 0 / 2, hi at word 1 / 3).
|
|
6
|
+
|
|
7
|
+
export const CUI_PAINT_WORDS = 78
|
|
8
|
+
export const CUI_PRESENT_LO = 0, CUI_PRESENT_HI = 1, CUI_DIRTY_LO = 2, CUI_DIRTY_HI = 3
|
|
9
|
+
export const CUI_INHERIT_MASK_LO = 4096, CUI_INHERIT_MASK_HI = 4080
|
|
10
|
+
export const CUI_MEASURE_MASK_LO = 0, CUI_MEASURE_MASK_HI = 5104 // a dirty measure field re-measures a text leaf
|
|
11
|
+
|
|
12
|
+
export type CuiKind = "float" | "color" | "enum" | "str" | "matrix" | "origin" | "gradient"
|
|
13
|
+
|
|
14
|
+
export const CUI_FIELDS = {
|
|
15
|
+
display: { bit: 0, word: 4, kind: "enum" },
|
|
16
|
+
overflow: { bit: 1, word: 5, kind: "enum" },
|
|
17
|
+
scrollDirection: { bit: 2, word: 6, kind: "enum" },
|
|
18
|
+
borderWidth: { bit: 3, word: 7, kind: "float" },
|
|
19
|
+
borderTopWidth: { bit: 4, word: 8, kind: "float" },
|
|
20
|
+
borderRightWidth: { bit: 5, word: 9, kind: "float" },
|
|
21
|
+
borderBottomWidth: { bit: 6, word: 10, kind: "float" },
|
|
22
|
+
borderLeftWidth: { bit: 7, word: 11, kind: "float" },
|
|
23
|
+
borderHorizontalWidth: { bit: 8, word: 12, kind: "float" },
|
|
24
|
+
borderVerticalWidth: { bit: 9, word: 13, kind: "float" },
|
|
25
|
+
borderStartWidth: { bit: 10, word: 14, kind: "float" },
|
|
26
|
+
borderEndWidth: { bit: 11, word: 15, kind: "float" },
|
|
27
|
+
color: { bit: 12, word: 16, kind: "color" },
|
|
28
|
+
backgroundColor: { bit: 13, word: 17, kind: "color" },
|
|
29
|
+
borderColor: { bit: 14, word: 18, kind: "color" },
|
|
30
|
+
borderTopColor: { bit: 15, word: 19, kind: "color" },
|
|
31
|
+
borderRightColor: { bit: 16, word: 20, kind: "color" },
|
|
32
|
+
borderBottomColor: { bit: 17, word: 21, kind: "color" },
|
|
33
|
+
borderLeftColor: { bit: 18, word: 22, kind: "color" },
|
|
34
|
+
placeholderColor: { bit: 19, word: 23, kind: "color" },
|
|
35
|
+
refreshControlColor: { bit: 20, word: 24, kind: "color" },
|
|
36
|
+
tintColor: { bit: 21, word: 25, kind: "color" },
|
|
37
|
+
rippleColor: { bit: 22, word: 26, kind: "color" },
|
|
38
|
+
overlayColor: { bit: 23, word: 27, kind: "color" },
|
|
39
|
+
backgroundImage: { bit: 24, word: 28, kind: "str" },
|
|
40
|
+
backgroundSize: { bit: 25, word: 29, kind: "enum" },
|
|
41
|
+
backgroundGradient: { bit: 26, word: 30, kind: "gradient" },
|
|
42
|
+
borderRadius: { bit: 27, word: 31, kind: "float" },
|
|
43
|
+
borderTopLeftRadius: { bit: 28, word: 32, kind: "float" },
|
|
44
|
+
borderTopRightRadius: { bit: 29, word: 33, kind: "float" },
|
|
45
|
+
borderBottomRightRadius: { bit: 30, word: 34, kind: "float" },
|
|
46
|
+
borderBottomLeftRadius: { bit: 31, word: 35, kind: "float" },
|
|
47
|
+
opacity: { bit: 32, word: 36, kind: "float" },
|
|
48
|
+
transform: { bit: 33, word: 37, kind: "matrix" },
|
|
49
|
+
transformOrigin: { bit: 34, word: 46, kind: "origin" },
|
|
50
|
+
pointerEvents: { bit: 35, word: 50, kind: "enum" },
|
|
51
|
+
fontSize: { bit: 36, word: 51, kind: "float" },
|
|
52
|
+
fontWeight: { bit: 37, word: 52, kind: "float" },
|
|
53
|
+
fontStyle: { bit: 38, word: 53, kind: "enum" },
|
|
54
|
+
fontFamily: { bit: 39, word: 54, kind: "str" },
|
|
55
|
+
lineHeight: { bit: 40, word: 55, kind: "float" },
|
|
56
|
+
letterSpacing: { bit: 41, word: 56, kind: "float" },
|
|
57
|
+
textAlign: { bit: 42, word: 57, kind: "enum" },
|
|
58
|
+
textDecoration: { bit: 43, word: 58, kind: "enum" },
|
|
59
|
+
lineClamp: { bit: 44, word: 59, kind: "float" },
|
|
60
|
+
textOverflow: { bit: 45, word: 60, kind: "enum" },
|
|
61
|
+
objectFit: { bit: 46, word: 61, kind: "enum" },
|
|
62
|
+
placeholder: { bit: 47, word: 62, kind: "str" },
|
|
63
|
+
type: { bit: 48, word: 63, kind: "enum" },
|
|
64
|
+
enterKey: { bit: 49, word: 64, kind: "enum" },
|
|
65
|
+
autocapitalize: { bit: 50, word: 65, kind: "enum" },
|
|
66
|
+
autocorrect: { bit: 51, word: 66, kind: "float" },
|
|
67
|
+
maxLength: { bit: 52, word: 67, kind: "float" },
|
|
68
|
+
keyboardShrink: { bit: 53, word: 68, kind: "float" },
|
|
69
|
+
keyboardDismiss: { bit: 54, word: 69, kind: "float" },
|
|
70
|
+
showScrollbar: { bit: 55, word: 70, kind: "float" },
|
|
71
|
+
overscrollMode: { bit: 56, word: 71, kind: "enum" },
|
|
72
|
+
keyboardDismissMode: { bit: 57, word: 72, kind: "enum" },
|
|
73
|
+
snap: { bit: 58, word: 73, kind: "enum" },
|
|
74
|
+
sheetDetents: { bit: 59, word: 74, kind: "str" },
|
|
75
|
+
sheetDetent: { bit: 60, word: 75, kind: "float" },
|
|
76
|
+
sheetDismissible: { bit: 61, word: 76, kind: "float" },
|
|
77
|
+
dim: { bit: 62, word: 77, kind: "float" },
|
|
78
|
+
} as const satisfies Record<string, { bit: number, word: number, kind: CuiKind }>
|
|
79
|
+
|
|
80
|
+
export type CuiFieldKey = keyof typeof CUI_FIELDS
|
|
81
|
+
|
|
82
|
+
export const CuiDisplay = { "flex": 0, "none": 1 } as const
|
|
83
|
+
export const CuiOverflow = { "visible": 0, "hidden": 1, "scroll": 2 } as const
|
|
84
|
+
export const CuiScrollDirection = { "vertical": 0, "horizontal": 1, "all": 2 } as const
|
|
85
|
+
export const CuiBackgroundSize = { "cover": 0, "contain": 1, "fill": 2, "tile": 3 } as const
|
|
86
|
+
export const CuiPointerEvents = { "all": 0, "none": 1 } as const
|
|
87
|
+
export const CuiFontStyle = { "normal": 0, "italic": 1 } as const
|
|
88
|
+
export const CuiTextAlign = { "start": 0, "center": 1, "end": 2, "left": 3, "right": 4 } as const
|
|
89
|
+
export const CuiTextDecoration = { "underline": 0, "line-through": 1, "none": 2 } as const
|
|
90
|
+
export const CuiTextOverflow = { "ellipsis": 0, "clip": 1 } as const
|
|
91
|
+
export const CuiObjectFit = { "cover": 0, "contain": 1, "fill": 2 } as const
|
|
92
|
+
export const CuiType = { "text": 0, "password": 1, "tel": 2, "email": 3, "number": 4, "decimal": 5, "url": 6, "search": 7, "date": 8, "time": 9 } as const
|
|
93
|
+
export const CuiEnterKey = { "done": 0, "go": 1, "next": 2, "search": 3, "send": 4 } as const
|
|
94
|
+
export const CuiAutocapitalize = { "none": 0, "sentences": 1, "words": 2, "characters": 3 } as const
|
|
95
|
+
export const CuiOverscrollMode = { "default": 0, "none": 1, "absorb": 2 } as const
|
|
96
|
+
export const CuiKeyboardDismissMode = { "interactive": 0, "scroll": 1, "none": 2 } as const
|
|
97
|
+
export const CuiSnap = { "none": 0, "start": 1, "center": 2, "end": 3 } as const
|
|
98
|
+
|
|
99
|
+
/** The registry spelling of each enum code, by field. */
|
|
100
|
+
export const CUI_NAMES = {
|
|
101
|
+
display: ["flex", "none"],
|
|
102
|
+
overflow: ["visible", "hidden", "scroll"],
|
|
103
|
+
scrollDirection: ["vertical", "horizontal", "all"],
|
|
104
|
+
backgroundSize: ["cover", "contain", "fill", "tile"],
|
|
105
|
+
pointerEvents: ["all", "none"],
|
|
106
|
+
fontStyle: ["normal", "italic"],
|
|
107
|
+
textAlign: ["start", "center", "end", "left", "right"],
|
|
108
|
+
textDecoration: ["underline", "line-through", "none"],
|
|
109
|
+
textOverflow: ["ellipsis", "clip"],
|
|
110
|
+
objectFit: ["cover", "contain", "fill"],
|
|
111
|
+
type: ["text", "password", "tel", "email", "number", "decimal", "url", "search", "date", "time"],
|
|
112
|
+
enterKey: ["done", "go", "next", "search", "send"],
|
|
113
|
+
autocapitalize: ["none", "sentences", "words", "characters"],
|
|
114
|
+
overscrollMode: ["default", "none", "absorb"],
|
|
115
|
+
keyboardDismissMode: ["interactive", "scroll", "none"],
|
|
116
|
+
snap: ["none", "start", "center", "end"],
|
|
117
|
+
} as const
|
|
118
|
+
|
|
119
|
+
/** present / dirty bit test on the (lo, hi) word pair. */
|
|
120
|
+
export const cuiBit = (lo: number, hi: number, bit: number): boolean =>
|
|
121
|
+
bit < 32 ? ((lo >>> bit) & 1) === 1 : ((hi >>> (bit - 32)) & 1) === 1
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Rect, ResolvedStyle } from "../types"
|
|
2
|
+
|
|
3
|
+
/** Returns the intrinsic size of a measured leaf (text / image), given the constraints. */
|
|
4
|
+
export type MeasureCallback = (
|
|
5
|
+
screenId: number,
|
|
6
|
+
nodeId: number,
|
|
7
|
+
width: number,
|
|
8
|
+
height: number,
|
|
9
|
+
) => { width: number; height: number }
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How the layout engine reaches a node's paint-ready style object so its per-prop style handlers
|
|
13
|
+
* (color, border, fontSize, …) can write resolved values into it.
|
|
14
|
+
*/
|
|
15
|
+
export interface StyleSink {
|
|
16
|
+
getStyle(screenId: number, nodeId: number): ResolvedStyle | undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One safe-area edge, in the CSS inset order the tuples use. */
|
|
20
|
+
export type SafeEdge = "top" | "right" | "bottom" | "left"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The layout-engine seam. The core depends only on this; `CreatorUiLayout` implements it over the
|
|
24
|
+
* reused creator-ui (Yoga) WASM, and the C++ port implements it over the native creator-ui lib.
|
|
25
|
+
*/
|
|
26
|
+
export interface LayoutEngine {
|
|
27
|
+
/** Create a screen/widget root node owned by `screenId`. Returns the node id. */
|
|
28
|
+
createRoot(screenId: number): number
|
|
29
|
+
/** Create a child of `parentId` at `index`. `hasMeasureFunc` for text/image leaves. */
|
|
30
|
+
createChild(parentId: number, index: number, hasMeasureFunc: boolean): number
|
|
31
|
+
removeChild(parentId: number, nodeId: number): void
|
|
32
|
+
freeTree(rootId: number, isWidget: boolean): void
|
|
33
|
+
|
|
34
|
+
/** Apply one style prop. `value: null` resets it. Layout props are consumed internally; paint
|
|
35
|
+
* props are forwarded to the registered handlers (→ the StyleSink's ResolvedStyle). */
|
|
36
|
+
setStyle(nodeId: number, key: string, value: number | string | boolean | null, layer?: number): void
|
|
37
|
+
markDirty(nodeId: number): void
|
|
38
|
+
|
|
39
|
+
/** The creator-ui layer a `$`-class style key maps to (`$hovered` / `$pressed` / `$focused` → the
|
|
40
|
+
* fixed interaction class layers 60267–60269, `$landscape` / `$portrait` → the global orientation
|
|
41
|
+
* layers 258 / 257, any other `$name` → its app-global layer allocated on first sight from 272);
|
|
42
|
+
* a key without `$` is not a state block → CUI_LAYER_NONE (0). */
|
|
43
|
+
getLayer(nodeId: number, key: string): number
|
|
44
|
+
/** Activate a layer's styles for a node (re-fires the style handlers with the layer's values). */
|
|
45
|
+
enableNodeLayer(nodeId: number, layer: number): void
|
|
46
|
+
/** Deactivate a layer's styles (re-fires the handlers with the remaining active layers). */
|
|
47
|
+
disableNodeLayer(nodeId: number, layer: number): void
|
|
48
|
+
|
|
49
|
+
calculate(rootId: number, width: number, height: number, isWidget: boolean): void
|
|
50
|
+
/** Parent-relative computed layout, or null if the node has none (e.g. display:none). */
|
|
51
|
+
getLayout(nodeId: number): Rect | null
|
|
52
|
+
/** Resolved padding edges `[left, top, right, bottom]` (px). The laid-out rect is the border box,
|
|
53
|
+
* so self-rendered content (text / input value) must be inset by these — Yoga gives no other hook. */
|
|
54
|
+
getPadding(nodeId: number): [number, number, number, number]
|
|
55
|
+
getScreenId(nodeId: number): number
|
|
56
|
+
|
|
57
|
+
/** Total laid-out content size of a node (incl. padding) — for scroll extent. */
|
|
58
|
+
getContentHeight(nodeId: number): number
|
|
59
|
+
getContentWidth(nodeId: number): number
|
|
60
|
+
|
|
61
|
+
setConditionValue(width: number, height: number): void
|
|
62
|
+
/** Safe-area insets in CSS order (top, right, bottom, left) — the values the `safe-*` padding/margin
|
|
63
|
+
* keywords resolve to. Passing `nodeId` re-applies those dynamic styles over that subtree now (so a
|
|
64
|
+
* live change takes effect without an orientation change); omit it to only update the globals. */
|
|
65
|
+
setSafePaddings(top: number, right: number, bottom: number, left: number, nodeId?: number): void
|
|
66
|
+
/** Declares which safe-area edges are exact-height system BARS (Android status bar / 3-button nav
|
|
67
|
+
* bar — content at the inset touches the chrome) vs clearance insets (iOS notch/home indicator,
|
|
68
|
+
* gesture nav). `comfort-*` ADDS its knob past a bar edge and floors (max) a clearance edge.
|
|
69
|
+
* Call before `setSafePaddings` — the paddings call is what re-resolves dynamic styles. */
|
|
70
|
+
setSafeAreaBars(bars: readonly SafeEdge[]): void
|
|
71
|
+
/** Merge app theme values (docs/ui-theme-plan.md): `var(--name)` refs + the comfort-* knobs.
|
|
72
|
+
* Numbers are lengths (px), null removes. Returns true when live styles re-dispatched —
|
|
73
|
+
* the caller should relayout + repaint. */
|
|
74
|
+
setTheme(values: Record<string, string | number | null>): boolean
|
|
75
|
+
|
|
76
|
+
setStyleSink(sink: StyleSink): void
|
|
77
|
+
setMeasureCallback(cb: MeasureCallback): void
|
|
78
|
+
}
|