@hypen-space/web 0.4.46 → 0.4.82

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