@hypen-space/web 0.4.46 → 0.4.81

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.
Files changed (69) hide show
  1. package/README.md +4 -4
  2. package/dist/canvas/dirty.d.ts +40 -0
  3. package/dist/canvas/dirty.js +100 -0
  4. package/dist/canvas/dirty.js.map +1 -0
  5. package/dist/canvas/events.d.ts +5 -0
  6. package/dist/canvas/events.js +30 -3
  7. package/dist/canvas/events.js.map +1 -1
  8. package/dist/canvas/index.d.ts +5 -1
  9. package/dist/canvas/index.js +4 -0
  10. package/dist/canvas/index.js.map +1 -1
  11. package/dist/canvas/layout.d.ts +12 -2
  12. package/dist/canvas/layout.js +593 -63
  13. package/dist/canvas/layout.js.map +1 -1
  14. package/dist/canvas/paint.d.ts +2 -0
  15. package/dist/canvas/paint.js +88 -11
  16. package/dist/canvas/paint.js.map +1 -1
  17. package/dist/canvas/renderer.d.ts +16 -0
  18. package/dist/canvas/renderer.js +154 -15
  19. package/dist/canvas/renderer.js.map +1 -1
  20. package/dist/canvas/scroll.d.ts +82 -0
  21. package/dist/canvas/scroll.js +639 -0
  22. package/dist/canvas/scroll.js.map +1 -0
  23. package/dist/canvas/selection.d.ts +63 -0
  24. package/dist/canvas/selection.js +466 -0
  25. package/dist/canvas/selection.js.map +1 -0
  26. package/dist/canvas/text.d.ts +6 -3
  27. package/dist/canvas/text.js +82 -43
  28. package/dist/canvas/text.js.map +1 -1
  29. package/dist/canvas/types.d.ts +18 -0
  30. package/dist/canvas/utils.d.ts +8 -2
  31. package/dist/canvas/utils.js +42 -16
  32. package/dist/canvas/utils.js.map +1 -1
  33. package/dist/canvas/virtualize.d.ts +25 -0
  34. package/dist/canvas/virtualize.js +65 -0
  35. package/dist/canvas/virtualize.js.map +1 -0
  36. package/dist/dom/applicators/index.js +0 -1
  37. package/dist/dom/applicators/index.js.map +1 -1
  38. package/dist/dom/applicators/margin.js +58 -17
  39. package/dist/dom/applicators/margin.js.map +1 -1
  40. package/dist/dom/applicators/padding.js +42 -17
  41. package/dist/dom/applicators/padding.js.map +1 -1
  42. package/dist/dom/components/icon.d.ts +9 -0
  43. package/dist/dom/components/icon.js +67 -0
  44. package/dist/dom/components/icon.js.map +1 -0
  45. package/dist/dom/components/index.js +2 -0
  46. package/dist/dom/components/index.js.map +1 -1
  47. package/dist/dom/renderer.d.ts +19 -5
  48. package/dist/dom/renderer.js +147 -17
  49. package/dist/dom/renderer.js.map +1 -1
  50. package/package.json +4 -2
  51. package/src/canvas/QUICKSTART.md +5 -5
  52. package/src/canvas/dirty.ts +116 -0
  53. package/src/canvas/events.ts +34 -3
  54. package/src/canvas/index.ts +5 -0
  55. package/src/canvas/layout.ts +653 -100
  56. package/src/canvas/paint.ts +109 -11
  57. package/src/canvas/renderer.ts +189 -16
  58. package/src/canvas/scroll.ts +729 -0
  59. package/src/canvas/selection.ts +560 -0
  60. package/src/canvas/text.ts +97 -54
  61. package/src/canvas/types.ts +26 -0
  62. package/src/canvas/utils.ts +47 -17
  63. package/src/canvas/virtualize.ts +81 -0
  64. package/src/dom/applicators/index.ts +1 -1
  65. package/src/dom/applicators/margin.ts +57 -11
  66. package/src/dom/applicators/padding.ts +45 -11
  67. package/src/dom/components/icon.ts +84 -0
  68. package/src/dom/components/index.ts +2 -0
  69. package/src/dom/renderer.ts +155 -17
@@ -1,15 +1,634 @@
1
1
  /**
2
2
  * Layout Engine
3
3
  *
4
- * Flexbox-like layout system for positioning virtual nodes
4
+ * Uses Taffy (Rust/WASM) for spec-compliant CSS Flexbox, Grid, and Block
5
+ * layout. Falls back to a basic flexbox implementation in environments
6
+ * without WASM support (e.g. test runners).
5
7
  */
6
8
 
7
9
  import type { VirtualNode, Layout, BoxSpacing } from "./types.js";
8
10
  import { parseSpacing, parseSize } from "./utils.js";
9
11
  import { measureText } from "./text.js";
10
12
 
13
+ // Taffy imports — loaded lazily to avoid hard failure when WASM unavailable
14
+ let taffy: typeof import("taffy-layout") | null = null;
15
+ let taffyReady = false;
16
+ let taffyInitPromise: Promise<void> | null = null;
17
+
18
+ /**
19
+ * Initialise the Taffy WASM module. Safe to call multiple times; only the
20
+ * first call actually loads WASM. Resolves immediately on subsequent calls.
21
+ */
22
+ export async function initTaffyLayout(): Promise<boolean> {
23
+ if (taffyReady) return true;
24
+ if (taffyInitPromise) {
25
+ await taffyInitPromise;
26
+ return taffyReady;
27
+ }
28
+ taffyInitPromise = (async () => {
29
+ try {
30
+ taffy = await import("taffy-layout");
31
+ await taffy.loadTaffy();
32
+ taffyReady = true;
33
+ } catch {
34
+ taffy = null;
35
+ taffyReady = false;
36
+ }
37
+ })();
38
+ await taffyInitPromise;
39
+ return taffyReady;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Helpers: map Hypen VirtualNode props → Taffy Style
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /** Parse a Hypen prop value into a Taffy Dimension ("auto" | number | "N%") */
47
+ function toDimension(value: any): "auto" | number | `${number}%` {
48
+ if (value === undefined || value === null || value === "auto") return "auto";
49
+ if (typeof value === "number") return value;
50
+ if (typeof value === "string") {
51
+ if (value.endsWith("%")) return value as `${number}%`;
52
+ const n = parseFloat(value);
53
+ return isNaN(n) ? "auto" : n;
54
+ }
55
+ return "auto";
56
+ }
57
+
58
+ function toLengthPct(value: any): number | `${number}%` {
59
+ if (typeof value === "number") return value;
60
+ if (typeof value === "string") {
61
+ if (value.endsWith("%")) return value as `${number}%`;
62
+ const n = parseFloat(value);
63
+ return isNaN(n) ? 0 : n;
64
+ }
65
+ return 0;
66
+ }
67
+
68
+ function toLengthPctAuto(value: any): "auto" | number | `${number}%` {
69
+ if (value === "auto" || value === undefined || value === null) return "auto";
70
+ return toLengthPct(value) as any;
71
+ }
72
+
11
73
  /**
12
- * Compute layout for a virtual node tree
74
+ * Build a Taffy Style from a VirtualNode's props. This centralises the
75
+ * mapping between Hypen's declarative property names and CSS-level concepts.
76
+ */
77
+ function buildTaffyStyle(node: VirtualNode): InstanceType<typeof import("taffy-layout").Style> {
78
+ const T = taffy!;
79
+ const props = node.props;
80
+ const type = node.type.toLowerCase();
81
+
82
+ const style = new T.Style();
83
+
84
+ // --- Display / direction ---------------------------------------------------
85
+ const isColumn = type === "column" || props.flexDirection === "column";
86
+ const isGrid = type === "grid" || props.display === "grid";
87
+
88
+ if (isGrid) {
89
+ style.display = T.Display.Grid;
90
+
91
+ // --- Grid template tracks ------------------------------------------------
92
+ if (props.gridTemplateColumns) {
93
+ style.gridTemplateColumns = parseGridTemplate(props.gridTemplateColumns);
94
+ }
95
+ if (props.gridTemplateRows) {
96
+ style.gridTemplateRows = parseGridTemplate(props.gridTemplateRows);
97
+ }
98
+
99
+ // --- Grid auto flow ------------------------------------------------------
100
+ if (props.gridAutoFlow) {
101
+ style.gridAutoFlow = mapGridAutoFlow(T, props.gridAutoFlow);
102
+ }
103
+
104
+ // --- Grid auto tracks ----------------------------------------------------
105
+ if (props.gridAutoColumns) {
106
+ style.gridAutoColumns = parseTrackSizingList(props.gridAutoColumns);
107
+ }
108
+ if (props.gridAutoRows) {
109
+ style.gridAutoRows = parseTrackSizingList(props.gridAutoRows);
110
+ }
111
+
112
+ // --- Grid template areas -------------------------------------------------
113
+ if (props.gridTemplateAreas) {
114
+ style.gridTemplateAreas = parseGridTemplateAreas(props.gridTemplateAreas);
115
+ }
116
+ } else {
117
+ style.display = T.Display.Flex;
118
+ }
119
+
120
+ style.flexDirection = isColumn ? T.FlexDirection.Column : T.FlexDirection.Row;
121
+
122
+ // --- Grid child placement (applies regardless of parent display) ----------
123
+ if (props.gridColumn) {
124
+ style.gridColumn = parseGridLine(props.gridColumn);
125
+ }
126
+ if (props.gridRow) {
127
+ style.gridRow = parseGridLine(props.gridRow);
128
+ }
129
+ if (props.gridColumnStart !== undefined) {
130
+ style.gridColumnStart = parseGridPlacement(props.gridColumnStart);
131
+ }
132
+ if (props.gridColumnEnd !== undefined) {
133
+ style.gridColumnEnd = parseGridPlacement(props.gridColumnEnd);
134
+ }
135
+ if (props.gridRowStart !== undefined) {
136
+ style.gridRowStart = parseGridPlacement(props.gridRowStart);
137
+ }
138
+ if (props.gridRowEnd !== undefined) {
139
+ style.gridRowEnd = parseGridPlacement(props.gridRowEnd);
140
+ }
141
+
142
+ // --- Flex properties -------------------------------------------------------
143
+ style.flexGrow = parseFloat(props.flexGrow) || parseFloat(props.flex) || 0;
144
+ style.flexShrink = props.flexShrink !== undefined ? parseFloat(props.flexShrink) : 1;
145
+ if (props.flexBasis !== undefined) style.flexBasis = toDimension(props.flexBasis);
146
+
147
+ if (props.flexWrap === "wrap") style.flexWrap = T.FlexWrap.Wrap;
148
+ else if (props.flexWrap === "wrap-reverse") style.flexWrap = T.FlexWrap.WrapReverse;
149
+
150
+ // --- Alignment (Hypen uses verticalAlignment / horizontalAlignment) --------
151
+ const justifyRaw = isColumn
152
+ ? (props.verticalAlignment || props.justifyContent)
153
+ : (props.horizontalAlignment || props.justifyContent);
154
+ const alignRaw = isColumn
155
+ ? (props.horizontalAlignment || props.alignItems)
156
+ : (props.verticalAlignment || props.alignItems);
157
+
158
+ if (justifyRaw) style.justifyContent = mapJustify(T, justifyRaw);
159
+ if (alignRaw) style.alignItems = mapAlign(T, alignRaw);
160
+
161
+ // --- Size ------------------------------------------------------------------
162
+ const w = parseSize(props.width);
163
+ const h = parseSize(props.height);
164
+
165
+ // Component-type defaults
166
+ if (type === "app" || type === "spacer") {
167
+ style.size = {
168
+ width: w !== null ? w : "100%",
169
+ height: h !== null ? h : "100%",
170
+ };
171
+ } else if (type === "divider" || type === "separator") {
172
+ const orientation = props.orientation || "horizontal";
173
+ const thickness = parseFloat(props.thickness) || 1;
174
+ if (orientation === "vertical") {
175
+ style.size = { width: w ?? thickness, height: h !== null ? h : "100%" };
176
+ } else {
177
+ style.size = { width: w !== null ? w : "100%", height: h ?? thickness };
178
+ }
179
+ } else if (type === "checkbox" || type === "radio") {
180
+ const sz = parseFloat(props.size) || 20;
181
+ style.size = { width: w ?? sz, height: h ?? sz };
182
+ } else if (type === "switch" || type === "toggle") {
183
+ style.size = { width: w ?? 44, height: h ?? 24 };
184
+ } else if (type === "slider") {
185
+ style.size = { width: w ?? 200, height: h ?? 20 };
186
+ } else if (type === "progress" || type === "progressbar") {
187
+ style.size = { width: w ?? 200, height: h ?? 8 };
188
+ } else if (type === "spinner" || type === "loading") {
189
+ const sz = parseFloat(props.size) || 24;
190
+ style.size = { width: w ?? sz, height: h ?? sz };
191
+ } else if (type === "badge") {
192
+ style.size = { width: w ?? 20, height: h ?? 20 };
193
+ } else if (type === "avatar") {
194
+ const sz = parseFloat(props.size) || 40;
195
+ style.size = { width: w ?? sz, height: h ?? sz };
196
+ } else if (type === "icon") {
197
+ const sz = parseFloat(props.size) || 24;
198
+ style.size = { width: w ?? sz, height: h ?? sz };
199
+ } else {
200
+ style.size = { width: toDimension(props.width), height: toDimension(props.height) };
201
+ }
202
+
203
+ // --- Min / Max constraints -------------------------------------------------
204
+ if (props.minWidth !== undefined || props.minHeight !== undefined) {
205
+ style.minSize = {
206
+ width: toDimension(props.minWidth),
207
+ height: toDimension(props.minHeight),
208
+ };
209
+ }
210
+ if (props.maxWidth !== undefined || props.maxHeight !== undefined) {
211
+ style.maxSize = {
212
+ width: toDimension(props.maxWidth),
213
+ height: toDimension(props.maxHeight),
214
+ };
215
+ }
216
+
217
+ // --- Margin ----------------------------------------------------------------
218
+ const m = parseSpacing(props.margin || 0);
219
+ if (props.marginTop !== undefined) m.top = parseFloat(props.marginTop) || 0;
220
+ if (props.marginRight !== undefined) m.right = parseFloat(props.marginRight) || 0;
221
+ if (props.marginBottom !== undefined) m.bottom = parseFloat(props.marginBottom) || 0;
222
+ if (props.marginLeft !== undefined) m.left = parseFloat(props.marginLeft) || 0;
223
+ style.margin = { top: m.top, right: m.right, bottom: m.bottom, left: m.left };
224
+
225
+ // --- Padding ---------------------------------------------------------------
226
+ const p = parseSpacing(props.padding || 0);
227
+ if (props.paddingTop !== undefined) p.top = parseFloat(props.paddingTop) || 0;
228
+ if (props.paddingRight !== undefined) p.right = parseFloat(props.paddingRight) || 0;
229
+ if (props.paddingBottom !== undefined) p.bottom = parseFloat(props.paddingBottom) || 0;
230
+ if (props.paddingLeft !== undefined) p.left = parseFloat(props.paddingLeft) || 0;
231
+ style.padding = { top: p.top, right: p.right, bottom: p.bottom, left: p.left };
232
+
233
+ // --- Border ----------------------------------------------------------------
234
+ const bw = parseFloat(props.borderWidth) || 0;
235
+ if (bw > 0) {
236
+ style.border = { top: bw, right: bw, bottom: bw, left: bw };
237
+ }
238
+
239
+ // --- Gap -------------------------------------------------------------------
240
+ const gap = parseFloat(props.gap) || 0;
241
+ if (gap > 0) {
242
+ style.gap = { width: gap, height: gap };
243
+ }
244
+
245
+ // --- Position (absolute) ---------------------------------------------------
246
+ if (props.position === "absolute") {
247
+ style.position = T.Position.Absolute;
248
+ if (props.top !== undefined) style.top = toLengthPctAuto(props.top);
249
+ if (props.left !== undefined) style.left = toLengthPctAuto(props.left);
250
+ if (props.right !== undefined) style.right = toLengthPctAuto(props.right);
251
+ if (props.bottom !== undefined) style.bottom = toLengthPctAuto(props.bottom);
252
+ }
253
+
254
+ return style;
255
+ }
256
+
257
+ function mapJustify(T: typeof import("taffy-layout"), value: string) {
258
+ switch (value) {
259
+ case "center": return T.JustifyContent.Center;
260
+ case "flex-end": case "end": return T.JustifyContent.End;
261
+ case "space-between": return T.JustifyContent.SpaceBetween;
262
+ case "space-around": return T.JustifyContent.SpaceAround;
263
+ case "space-evenly": return T.JustifyContent.SpaceEvenly;
264
+ default: return T.JustifyContent.Start;
265
+ }
266
+ }
267
+
268
+ function mapAlign(T: typeof import("taffy-layout"), value: string) {
269
+ switch (value) {
270
+ case "center": return T.AlignItems.Center;
271
+ case "flex-end": case "end": return T.AlignItems.End;
272
+ case "stretch": return T.AlignItems.Stretch;
273
+ case "baseline": return T.AlignItems.Baseline;
274
+ default: return T.AlignItems.Start;
275
+ }
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Grid helpers: parse Hypen prop values → Taffy grid types
280
+ // ---------------------------------------------------------------------------
281
+
282
+ /**
283
+ * Parse a single track sizing value like "100", "auto", "1fr", "50%",
284
+ * "min-content", "max-content" into a TrackSizingFunction.
285
+ */
286
+ function parseTrackSizing(value: string | number): { min: any; max: any } {
287
+ if (typeof value === "number") {
288
+ return { min: value, max: value };
289
+ }
290
+
291
+ const s = String(value).trim();
292
+ if (s === "auto") return { min: "auto", max: "auto" };
293
+ if (s === "min-content") return { min: "min-content", max: "min-content" };
294
+ if (s === "max-content") return { min: "max-content", max: "max-content" };
295
+
296
+ // Fractional units e.g. "1fr", "2.5fr"
297
+ if (s.endsWith("fr")) {
298
+ return { min: "auto", max: s as `${number}fr` };
299
+ }
300
+
301
+ // Percentage e.g. "50%"
302
+ if (s.endsWith("%")) {
303
+ return { min: s as `${number}%`, max: s as `${number}%` };
304
+ }
305
+
306
+ // Plain number in a string
307
+ const n = parseFloat(s);
308
+ if (!isNaN(n)) {
309
+ return { min: n, max: n };
310
+ }
311
+
312
+ return { min: "auto", max: "auto" };
313
+ }
314
+
315
+ /**
316
+ * Parse a grid-template-columns / grid-template-rows value.
317
+ * Accepts:
318
+ * - An array of values: [100, "1fr", "auto"]
319
+ * - A space-separated string: "100 1fr auto"
320
+ * - A single value: "1fr"
321
+ */
322
+ function parseGridTemplate(value: any): any[] {
323
+ if (Array.isArray(value)) {
324
+ return value.map(parseTrackSizing);
325
+ }
326
+ if (typeof value === "string") {
327
+ return value.split(/\s+/).filter(Boolean).map(parseTrackSizing);
328
+ }
329
+ if (typeof value === "number") {
330
+ return [parseTrackSizing(value)];
331
+ }
332
+ return [];
333
+ }
334
+
335
+ /**
336
+ * Parse grid-auto-columns / grid-auto-rows (TrackSizingFunction[]).
337
+ * Same format as template tracks but semantically for implicit tracks.
338
+ */
339
+ function parseTrackSizingList(value: any): any[] {
340
+ return parseGridTemplate(value);
341
+ }
342
+
343
+ /** Map a gridAutoFlow string to Taffy's GridAutoFlow enum. */
344
+ function mapGridAutoFlow(T: typeof import("taffy-layout"), value: string) {
345
+ switch (value) {
346
+ case "column": return T.GridAutoFlow.Column;
347
+ case "row-dense": case "dense": return T.GridAutoFlow.RowDense;
348
+ case "column-dense": return T.GridAutoFlow.ColumnDense;
349
+ default: return T.GridAutoFlow.Row;
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Parse a single GridPlacement value.
355
+ * Accepts: "auto", a number (line index), or { span: N }.
356
+ */
357
+ function parseGridPlacement(value: any): any {
358
+ if (value === "auto" || value === undefined || value === null) return "auto";
359
+ if (typeof value === "number") return value;
360
+ if (typeof value === "string") {
361
+ const trimmed = value.trim();
362
+ if (trimmed === "auto") return "auto";
363
+ // "span N"
364
+ const spanMatch = trimmed.match(/^span\s+(\d+)$/);
365
+ if (spanMatch) return { span: parseInt(spanMatch[1], 10) };
366
+ const n = parseInt(trimmed, 10);
367
+ if (!isNaN(n)) return n;
368
+ return "auto";
369
+ }
370
+ // Already an object like { span: 2 } or { line: 1, ident: "foo" }
371
+ if (typeof value === "object") return value;
372
+ return "auto";
373
+ }
374
+
375
+ /**
376
+ * Parse a grid-column / grid-row shorthand into a Line<GridPlacement>.
377
+ * Accepts:
378
+ * - An object { start, end }
379
+ * - A string "1 / 3" or "1 / span 2"
380
+ */
381
+ function parseGridLine(value: any): { start: any; end: any } {
382
+ if (typeof value === "object" && value !== null && "start" in value) {
383
+ return {
384
+ start: parseGridPlacement(value.start),
385
+ end: parseGridPlacement(value.end),
386
+ };
387
+ }
388
+ if (typeof value === "string") {
389
+ const parts = value.split("/").map((s: string) => s.trim());
390
+ return {
391
+ start: parseGridPlacement(parts[0]),
392
+ end: parts.length > 1 ? parseGridPlacement(parts[1]) : "auto",
393
+ };
394
+ }
395
+ return { start: "auto", end: "auto" };
396
+ }
397
+
398
+ /**
399
+ * Parse grid-template-areas.
400
+ * Accepts an array of strings like ["header header", "sidebar main", "footer footer"].
401
+ * Each string is a row; each word is a cell name (or "." for empty).
402
+ * Returns an array of GridTemplateArea objects.
403
+ */
404
+ function parseGridTemplateAreas(value: any): any[] {
405
+ if (!Array.isArray(value)) return [];
406
+
407
+ const rows: string[][] = value.map((row: string) =>
408
+ String(row).split(/\s+/).filter(Boolean)
409
+ );
410
+
411
+ if (rows.length === 0) return [];
412
+
413
+ // Collect unique area names (skip "." which means empty)
414
+ const areaNames = new Set<string>();
415
+ for (const row of rows) {
416
+ for (const cell of row) {
417
+ if (cell !== ".") areaNames.add(cell);
418
+ }
419
+ }
420
+
421
+ // For each area name, find its bounding rectangle
422
+ const areas: any[] = [];
423
+ for (const name of areaNames) {
424
+ let rowStart = Infinity, rowEnd = -1, colStart = Infinity, colEnd = -1;
425
+ for (let r = 0; r < rows.length; r++) {
426
+ for (let c = 0; c < rows[r].length; c++) {
427
+ if (rows[r][c] === name) {
428
+ rowStart = Math.min(rowStart, r + 1);
429
+ rowEnd = Math.max(rowEnd, r + 2); // end line is exclusive
430
+ colStart = Math.min(colStart, c + 1);
431
+ colEnd = Math.max(colEnd, c + 2);
432
+ }
433
+ }
434
+ }
435
+ if (rowEnd > 0) {
436
+ areas.push({ name, rowStart, rowEnd, columnStart: colStart, columnEnd: colEnd });
437
+ }
438
+ }
439
+
440
+ return areas;
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // Taffy-based layout
445
+ // ---------------------------------------------------------------------------
446
+
447
+ interface TextMeasureContext {
448
+ text: string;
449
+ fontSize: number;
450
+ fontWeight: string | number;
451
+ fontFamily: string;
452
+ lineHeight: number;
453
+ paddingH: number;
454
+ paddingV: number;
455
+ }
456
+
457
+ /**
458
+ * Build a TaffyTree mirroring the VirtualNode tree. Returns the root Taffy
459
+ * node id and a mapping from Taffy node ids → VirtualNodes.
460
+ */
461
+ function buildTree(
462
+ tree: InstanceType<typeof import("taffy-layout").TaffyTree>,
463
+ node: VirtualNode,
464
+ ): bigint {
465
+ const T = taffy!;
466
+ const props = node.props;
467
+ const style = buildTaffyStyle(node);
468
+
469
+ // Text leaf nodes — create with measurement context
470
+ if (node.type === "text" && props[0] && node.children.length === 0) {
471
+ const text = String(props[0] || "");
472
+ const fontSize = parseFloat(props.fontSize) || 16;
473
+ const fontWeight = props.fontWeight || "normal";
474
+ const fontFamily = props.fontFamily || "system-ui, sans-serif";
475
+ const lineHeight = parseFloat(props.lineHeight) || fontSize * 1.2;
476
+
477
+ const p = parseSpacing(props.padding || 0);
478
+ if (props.paddingTop !== undefined) p.top = parseFloat(props.paddingTop) || 0;
479
+ if (props.paddingRight !== undefined) p.right = parseFloat(props.paddingRight) || 0;
480
+ if (props.paddingBottom !== undefined) p.bottom = parseFloat(props.paddingBottom) || 0;
481
+ if (props.paddingLeft !== undefined) p.left = parseFloat(props.paddingLeft) || 0;
482
+
483
+ const ctx: TextMeasureContext = {
484
+ text,
485
+ fontSize,
486
+ fontWeight,
487
+ fontFamily,
488
+ lineHeight,
489
+ paddingH: p.left + p.right,
490
+ paddingV: p.top + p.bottom,
491
+ };
492
+
493
+ return tree.newLeafWithContext(style, ctx);
494
+ }
495
+
496
+ // Container nodes
497
+ const childIds: bigint[] = [];
498
+ for (const child of node.children) {
499
+ childIds.push(buildTree(tree, child));
500
+ }
501
+
502
+ return tree.newWithChildren(style, childIds);
503
+ }
504
+
505
+ /**
506
+ * Walk the TaffyTree in the same order as the VirtualNode tree and write
507
+ * computed layout data back to each VirtualNode.
508
+ *
509
+ * @param isRoot - true for the root node, where Taffy's x/y are 0 and
510
+ * margin must be added manually to match the expected
511
+ * absolute-position convention.
512
+ */
513
+ function writeLayout(
514
+ tree: InstanceType<typeof import("taffy-layout").TaffyTree>,
515
+ taffyId: bigint,
516
+ node: VirtualNode,
517
+ parentX: number,
518
+ parentY: number,
519
+ isRoot: boolean = false,
520
+ ): void {
521
+ const tl = tree.getLayout(taffyId);
522
+
523
+ // Taffy positions children relative to parent's content area (margin
524
+ // included in x/y). For the root node there is no parent, so Taffy
525
+ // reports x=0/y=0 — we must add the margin ourselves.
526
+ const absX = parentX + tl.x + (isRoot ? tl.marginLeft : 0);
527
+ const absY = parentY + tl.y + (isRoot ? tl.marginTop : 0);
528
+
529
+ const borderColor = node.props.borderColor || "transparent";
530
+ const borderRadius = parseFloat(node.props.borderRadius) || 0;
531
+ // Taffy computes uniform border width per side; pick top as representative
532
+ const borderWidth = tl.borderTop;
533
+
534
+ const layout: Layout = {
535
+ x: absX,
536
+ y: absY,
537
+ width: tl.width,
538
+ height: tl.height,
539
+ margin: {
540
+ top: tl.marginTop,
541
+ right: tl.marginRight,
542
+ bottom: tl.marginBottom,
543
+ left: tl.marginLeft,
544
+ },
545
+ padding: {
546
+ top: tl.paddingTop,
547
+ right: tl.paddingRight,
548
+ bottom: tl.paddingBottom,
549
+ left: tl.paddingLeft,
550
+ },
551
+ border: {
552
+ width: borderWidth,
553
+ color: borderColor,
554
+ radius: borderRadius,
555
+ },
556
+ contentX: tl.paddingLeft + borderWidth,
557
+ contentY: tl.paddingTop + borderWidth,
558
+ contentWidth: tl.width - tl.paddingLeft - tl.paddingRight - borderWidth * 2,
559
+ contentHeight: tl.height - tl.paddingTop - tl.paddingBottom - borderWidth * 2,
560
+ };
561
+
562
+ node.layout = layout;
563
+
564
+ // Recurse children (same order as buildTree)
565
+ for (let i = 0; i < node.children.length; i++) {
566
+ const childTaffyId = tree.getChildAtIndex(taffyId, i);
567
+ writeLayout(tree, childTaffyId, node.children[i], absX, absY);
568
+ }
569
+ }
570
+
571
+ /**
572
+ * Compute layout for a virtual node tree using Taffy.
573
+ * Requires `initTaffyLayout()` to have been called and resolved.
574
+ */
575
+ function computeLayoutTaffy(
576
+ ctx: CanvasRenderingContext2D,
577
+ node: VirtualNode,
578
+ availableWidth: number,
579
+ availableHeight: number,
580
+ x: number,
581
+ y: number,
582
+ ): void {
583
+ const T = taffy!;
584
+ const tree = new T.TaffyTree();
585
+
586
+ try {
587
+ const rootId = buildTree(tree, node);
588
+
589
+ // Compute layout with a measure function for text leaf nodes
590
+ tree.computeLayoutWithMeasure(
591
+ rootId,
592
+ { width: availableWidth, height: availableHeight },
593
+ (knownDimensions, availableSpace, _nodeId, context, _style) => {
594
+ const tctx = context as TextMeasureContext | undefined;
595
+ if (!tctx?.text) {
596
+ return { width: knownDimensions.width ?? 0, height: knownDimensions.height ?? 0 };
597
+ }
598
+
599
+ const maxWidth = typeof availableSpace.width === "number"
600
+ ? availableSpace.width - tctx.paddingH
601
+ : undefined;
602
+
603
+ const metrics = measureText(ctx, tctx.text, {
604
+ fontSize: tctx.fontSize,
605
+ fontWeight: tctx.fontWeight,
606
+ fontFamily: tctx.fontFamily,
607
+ lineHeight: tctx.lineHeight,
608
+ }, maxWidth);
609
+
610
+ return {
611
+ width: knownDimensions.width ?? (metrics.width + tctx.paddingH),
612
+ height: knownDimensions.height ?? (metrics.height + tctx.paddingV),
613
+ };
614
+ },
615
+ );
616
+
617
+ writeLayout(tree, rootId, node, x, y, /* isRoot */ true);
618
+ } finally {
619
+ tree.free();
620
+ }
621
+ }
622
+
623
+ // ---------------------------------------------------------------------------
624
+ // Public API
625
+ // ---------------------------------------------------------------------------
626
+
627
+ /**
628
+ * Compute layout for a virtual node tree.
629
+ *
630
+ * Uses Taffy (WASM) when available for spec-compliant CSS Flexbox/Grid/Block
631
+ * layout. Falls back to a basic JS flexbox implementation otherwise.
13
632
  */
14
633
  export function computeLayout(
15
634
  ctx: CanvasRenderingContext2D,
@@ -17,19 +636,35 @@ export function computeLayout(
17
636
  availableWidth: number,
18
637
  availableHeight: number,
19
638
  x: number = 0,
20
- y: number = 0
639
+ y: number = 0,
640
+ ): void {
641
+ if (taffyReady && taffy) {
642
+ computeLayoutTaffy(ctx, node, availableWidth, availableHeight, x, y);
643
+ } else {
644
+ computeLayoutFallback(ctx, node, availableWidth, availableHeight, x, y);
645
+ }
646
+ }
647
+
648
+ // ---------------------------------------------------------------------------
649
+ // Fallback: original JS flexbox implementation (used in tests / non-WASM)
650
+ // ---------------------------------------------------------------------------
651
+
652
+ function computeLayoutFallback(
653
+ ctx: CanvasRenderingContext2D,
654
+ node: VirtualNode,
655
+ availableWidth: number,
656
+ availableHeight: number,
657
+ x: number = 0,
658
+ y: number = 0,
21
659
  ): void {
22
- // Parse props
23
660
  const props = node.props;
24
661
 
25
- // Support individual margins (marginTop, marginRight, etc.)
26
662
  let margin = parseSpacing(props.margin || 0);
27
663
  if (props.marginTop !== undefined) margin.top = parseFloat(props.marginTop) || 0;
28
664
  if (props.marginRight !== undefined) margin.right = parseFloat(props.marginRight) || 0;
29
665
  if (props.marginBottom !== undefined) margin.bottom = parseFloat(props.marginBottom) || 0;
30
666
  if (props.marginLeft !== undefined) margin.left = parseFloat(props.marginLeft) || 0;
31
667
 
32
- // Support individual padding (paddingTop, paddingRight, etc.)
33
668
  let padding = parseSpacing(props.padding || 0);
34
669
  if (props.paddingTop !== undefined) padding.top = parseFloat(props.paddingTop) || 0;
35
670
  if (props.paddingRight !== undefined) padding.right = parseFloat(props.paddingRight) || 0;
@@ -40,31 +675,25 @@ export function computeLayout(
40
675
  const borderColor = props.borderColor || "transparent";
41
676
  const borderRadius = parseFloat(props.borderRadius) || 0;
42
677
 
43
- // Calculate available space after margin
44
678
  const availableAfterMargin = {
45
679
  width: availableWidth - margin.left - margin.right,
46
680
  height: availableHeight - margin.top - margin.bottom,
47
681
  };
48
682
 
49
- // Determine size
50
683
  let width = parseSize(props.width);
51
684
  let height = parseSize(props.height);
52
685
 
53
- // Default sizing for specific component types
54
686
  const type = node.type.toLowerCase();
55
687
 
56
688
  if (type === "app") {
57
- // App fills the full available space by default
58
689
  if (width === null) width = availableAfterMargin.width;
59
690
  if (height === null) height = availableAfterMargin.height;
60
691
  } else if (type === "spacer") {
61
- // Spacer fills available space by default
62
692
  if (width === null) width = availableAfterMargin.width;
63
693
  if (height === null) height = availableAfterMargin.height;
64
694
  } else if (type === "divider" || type === "separator") {
65
695
  const orientation = props.orientation || "horizontal";
66
696
  const thickness = parseFloat(props.thickness) || 1;
67
-
68
697
  if (orientation === "vertical") {
69
698
  if (width === null) width = thickness;
70
699
  if (height === null) height = availableAfterMargin.height;
@@ -102,7 +731,6 @@ export function computeLayout(
102
731
  if (height === null) height = size;
103
732
  }
104
733
 
105
- // Intrinsic sizing for text nodes
106
734
  if (node.type === "text" && node.props[0]) {
107
735
  const text = String(node.props[0] || "");
108
736
  const fontSize = parseFloat(props.fontSize) || 16;
@@ -117,11 +745,9 @@ export function computeLayout(
117
745
  if (!height) height = metrics.height + padding.top + padding.bottom;
118
746
  }
119
747
 
120
- // Default to available size if not specified
121
748
  if (width === null) width = availableAfterMargin.width;
122
749
  if (height === null) height = availableAfterMargin.height;
123
750
 
124
- // Apply constraints
125
751
  const minWidth = parseSize(props.minWidth);
126
752
  const maxWidth = parseSize(props.maxWidth);
127
753
  const minHeight = parseSize(props.minHeight);
@@ -132,7 +758,6 @@ export function computeLayout(
132
758
  if (minHeight !== null) height = Math.max(height, minHeight);
133
759
  if (maxHeight !== null) height = Math.min(height, maxHeight);
134
760
 
135
- // Create layout object
136
761
  const layout: Layout = {
137
762
  x: x + margin.left,
138
763
  y: y + margin.top,
@@ -153,29 +778,23 @@ export function computeLayout(
153
778
 
154
779
  node.layout = layout;
155
780
 
156
- // Layout children if container
157
781
  if (node.children.length > 0) {
158
- layoutChildren(ctx, node);
782
+ layoutChildrenFallback(ctx, node);
159
783
  }
160
784
  }
161
785
 
162
- /**
163
- * Layout children using flexbox-like rules
164
- */
165
- function layoutChildren(ctx: CanvasRenderingContext2D, parent: VirtualNode): void {
786
+ function layoutChildrenFallback(ctx: CanvasRenderingContext2D, parent: VirtualNode): void {
166
787
  const layout = parent.layout!;
167
788
  const props = parent.props;
168
789
 
169
- // Stack components overlay children on top of each other
170
790
  if (parent.type.toLowerCase() === "stack") {
171
- layoutStackChildren(ctx, parent);
791
+ layoutStackChildrenFallback(ctx, parent);
172
792
  return;
173
793
  }
174
794
 
175
795
  const flexDirection = props.flexDirection || (parent.type === "column" ? "column" : "row");
176
796
  const isColumn = flexDirection === "column";
177
797
 
178
- // Resolve verticalAlignment/horizontalAlignment to main/cross axis based on direction
179
798
  const justifyContent = isColumn
180
799
  ? (props.verticalAlignment || "flex-start")
181
800
  : (props.horizontalAlignment || "flex-start");
@@ -187,7 +806,6 @@ function layoutChildren(ctx: CanvasRenderingContext2D, parent: VirtualNode): voi
187
806
  const availableWidth = layout.contentWidth;
188
807
  const availableHeight = layout.contentHeight;
189
808
 
190
- // First pass: compute intrinsic sizes and collect flex info
191
809
  const childInfo: Array<{
192
810
  width: number;
193
811
  height: number;
@@ -204,20 +822,11 @@ function layoutChildren(ctx: CanvasRenderingContext2D, parent: VirtualNode): voi
204
822
  const flexShrink = parseFloat(child.props.flexShrink) || 1;
205
823
  const flexBasis = parseSize(child.props.flexBasis);
206
824
 
207
- // Compute child layout with available space
208
- computeLayout(
209
- ctx,
210
- child,
211
- availableWidth,
212
- availableHeight,
213
- 0,
214
- 0
215
- );
825
+ computeLayoutFallback(ctx, child, availableWidth, availableHeight, 0, 0);
216
826
 
217
827
  const childLayout = child.layout!;
218
828
  let mainSize = isColumn ? childLayout.height : childLayout.width;
219
829
 
220
- // Use flexBasis if specified
221
830
  if (flexBasis !== null) {
222
831
  mainSize = flexBasis;
223
832
  if (isColumn) {
@@ -227,73 +836,49 @@ function layoutChildren(ctx: CanvasRenderingContext2D, parent: VirtualNode): voi
227
836
  }
228
837
  }
229
838
 
230
- childInfo.push({
231
- width: childLayout.width,
232
- height: childLayout.height,
233
- flexGrow,
234
- flexShrink,
235
- flexBasis,
236
- });
237
-
839
+ childInfo.push({ width: childLayout.width, height: childLayout.height, flexGrow, flexShrink, flexBasis });
238
840
  totalMainSize += mainSize;
239
841
  totalFlexGrow += flexGrow;
240
842
  totalFlexShrink += flexShrink;
241
843
  }
242
844
 
243
- // Add gaps
244
845
  const totalGap = gap * (parent.children.length - 1);
245
846
  totalMainSize += totalGap;
246
847
 
247
- // Apply flex grow/shrink
248
848
  const availableMain = isColumn ? availableHeight : availableWidth;
249
849
  let remainingSpace = availableMain - totalMainSize;
250
850
 
251
- // Distribute remaining space to flex-grow children
252
851
  if (remainingSpace > 0 && totalFlexGrow > 0) {
253
852
  const spacePerFlex = remainingSpace / totalFlexGrow;
254
-
255
853
  for (let i = 0; i < parent.children.length; i++) {
256
854
  const info = childInfo[i];
257
855
  if (info.flexGrow > 0) {
258
856
  const extraSpace = spacePerFlex * info.flexGrow;
259
- if (isColumn) {
260
- info.height += extraSpace;
261
- } else {
262
- info.width += extraSpace;
263
- }
857
+ if (isColumn) info.height += extraSpace;
858
+ else info.width += extraSpace;
264
859
  totalMainSize += extraSpace;
265
860
  }
266
861
  }
267
-
268
862
  remainingSpace = 0;
269
863
  }
270
864
 
271
- // Apply flex shrink if needed (content is too large)
272
865
  if (remainingSpace < 0 && totalFlexShrink > 0) {
273
866
  const shrinkPerFlex = Math.abs(remainingSpace) / totalFlexShrink;
274
-
275
867
  for (let i = 0; i < parent.children.length; i++) {
276
868
  const info = childInfo[i];
277
869
  if (info.flexShrink > 0) {
278
870
  const shrinkSpace = Math.min(
279
871
  shrinkPerFlex * info.flexShrink,
280
- isColumn ? info.height : info.width
872
+ isColumn ? info.height : info.width,
281
873
  );
282
-
283
- if (isColumn) {
284
- info.height = Math.max(0, info.height - shrinkSpace);
285
- } else {
286
- info.width = Math.max(0, info.width - shrinkSpace);
287
- }
288
-
874
+ if (isColumn) info.height = Math.max(0, info.height - shrinkSpace);
875
+ else info.width = Math.max(0, info.width - shrinkSpace);
289
876
  totalMainSize -= shrinkSpace;
290
877
  }
291
878
  }
292
-
293
879
  remainingSpace = availableMain - totalMainSize;
294
880
  }
295
881
 
296
- // Calculate starting position based on justifyContent
297
882
  let mainStart = 0;
298
883
  let spacing = 0;
299
884
 
@@ -308,7 +893,6 @@ function layoutChildren(ctx: CanvasRenderingContext2D, parent: VirtualNode): voi
308
893
  mainStart = spacing / 2;
309
894
  }
310
895
 
311
- // Position children
312
896
  let currentMain = mainStart;
313
897
 
314
898
  for (let i = 0; i < parent.children.length; i++) {
@@ -316,11 +900,9 @@ function layoutChildren(ctx: CanvasRenderingContext2D, parent: VirtualNode): voi
316
900
  const childLayout = child.layout!;
317
901
  const info = childInfo[i];
318
902
 
319
- // Update child layout with flex-adjusted size
320
903
  childLayout.width = info.width;
321
904
  childLayout.height = info.height;
322
905
 
323
- // Calculate cross position based on alignItems
324
906
  let crossStart = 0;
325
907
  const availableCross = isColumn ? availableWidth : availableHeight;
326
908
  const childCross = isColumn ? info.width : info.height;
@@ -331,7 +913,6 @@ function layoutChildren(ctx: CanvasRenderingContext2D, parent: VirtualNode): voi
331
913
  crossStart = availableCross - childCross;
332
914
  }
333
915
 
334
- // Update child position relative to parent content area
335
916
  if (isColumn) {
336
917
  childLayout.x = layout.x + layout.contentX + crossStart;
337
918
  childLayout.y = layout.y + layout.contentY + currentMain;
@@ -348,65 +929,37 @@ function layoutChildren(ctx: CanvasRenderingContext2D, parent: VirtualNode): voi
348
929
  }
349
930
  }
350
931
 
351
- /**
352
- * Layout Stack children - overlays children on top of each other
353
- */
354
- function layoutStackChildren(ctx: CanvasRenderingContext2D, parent: VirtualNode): void {
932
+ function layoutStackChildrenFallback(ctx: CanvasRenderingContext2D, parent: VirtualNode): void {
355
933
  const layout = parent.layout!;
356
934
  const props = parent.props;
357
935
 
358
- // Stack uses horizontalAlignment/verticalAlignment directly
359
936
  const horizontalAlignment = props.horizontalAlignment || "flex-start";
360
937
  const verticalAlignment = props.verticalAlignment || "flex-start";
361
938
 
362
939
  const availableWidth = layout.contentWidth;
363
940
  const availableHeight = layout.contentHeight;
364
941
 
365
- // Layout all children at the same position (stacked on top of each other)
366
942
  for (const child of parent.children) {
367
- // Compute child layout
368
- computeLayout(
369
- ctx,
370
- child,
371
- availableWidth,
372
- availableHeight,
373
- 0,
374
- 0
375
- );
943
+ computeLayoutFallback(ctx, child, availableWidth, availableHeight, 0, 0);
376
944
 
377
945
  const childLayout = child.layout!;
378
946
 
379
- // Calculate position based on alignment
380
947
  let x = 0;
381
948
  let y = 0;
382
949
 
383
- // Horizontal alignment
384
950
  if (horizontalAlignment === "center") {
385
951
  x = (availableWidth - childLayout.width) / 2;
386
952
  } else if (horizontalAlignment === "flex-end") {
387
953
  x = availableWidth - childLayout.width;
388
954
  }
389
955
 
390
- // Vertical alignment
391
956
  if (verticalAlignment === "center") {
392
957
  y = (availableHeight - childLayout.height) / 2;
393
958
  } else if (verticalAlignment === "flex-end") {
394
959
  y = availableHeight - childLayout.height;
395
960
  }
396
961
 
397
- // Update child position relative to parent content area
398
962
  childLayout.x = layout.x + layout.contentX + x;
399
963
  childLayout.y = layout.y + layout.contentY + y;
400
964
  }
401
965
  }
402
-
403
-
404
-
405
-
406
-
407
-
408
-
409
-
410
-
411
-
412
-