@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.
- package/README.md +4 -4
- package/dist/canvas/dirty.d.ts +40 -0
- package/dist/canvas/dirty.js +100 -0
- package/dist/canvas/dirty.js.map +1 -0
- package/dist/canvas/events.d.ts +5 -0
- package/dist/canvas/events.js +30 -3
- package/dist/canvas/events.js.map +1 -1
- package/dist/canvas/index.d.ts +5 -1
- package/dist/canvas/index.js +4 -0
- package/dist/canvas/index.js.map +1 -1
- package/dist/canvas/layout.d.ts +12 -2
- package/dist/canvas/layout.js +593 -63
- package/dist/canvas/layout.js.map +1 -1
- package/dist/canvas/paint.d.ts +2 -0
- package/dist/canvas/paint.js +88 -11
- package/dist/canvas/paint.js.map +1 -1
- package/dist/canvas/renderer.d.ts +16 -0
- package/dist/canvas/renderer.js +154 -15
- package/dist/canvas/renderer.js.map +1 -1
- package/dist/canvas/scroll.d.ts +82 -0
- package/dist/canvas/scroll.js +639 -0
- package/dist/canvas/scroll.js.map +1 -0
- package/dist/canvas/selection.d.ts +63 -0
- package/dist/canvas/selection.js +466 -0
- package/dist/canvas/selection.js.map +1 -0
- package/dist/canvas/text.d.ts +6 -3
- package/dist/canvas/text.js +82 -43
- package/dist/canvas/text.js.map +1 -1
- package/dist/canvas/types.d.ts +18 -0
- package/dist/canvas/utils.d.ts +8 -2
- package/dist/canvas/utils.js +42 -16
- package/dist/canvas/utils.js.map +1 -1
- package/dist/canvas/virtualize.d.ts +25 -0
- package/dist/canvas/virtualize.js +65 -0
- package/dist/canvas/virtualize.js.map +1 -0
- package/dist/dom/applicators/index.js +0 -1
- package/dist/dom/applicators/index.js.map +1 -1
- package/dist/dom/applicators/margin.js +58 -17
- package/dist/dom/applicators/margin.js.map +1 -1
- package/dist/dom/applicators/padding.js +42 -17
- package/dist/dom/applicators/padding.js.map +1 -1
- package/dist/dom/components/icon.d.ts +9 -0
- package/dist/dom/components/icon.js +67 -0
- package/dist/dom/components/icon.js.map +1 -0
- package/dist/dom/components/index.js +2 -0
- package/dist/dom/components/index.js.map +1 -1
- package/dist/dom/renderer.d.ts +19 -5
- package/dist/dom/renderer.js +147 -17
- package/dist/dom/renderer.js.map +1 -1
- package/package.json +4 -2
- package/src/canvas/QUICKSTART.md +5 -5
- package/src/canvas/dirty.ts +116 -0
- package/src/canvas/events.ts +34 -3
- package/src/canvas/index.ts +5 -0
- package/src/canvas/layout.ts +653 -100
- package/src/canvas/paint.ts +109 -11
- package/src/canvas/renderer.ts +189 -16
- package/src/canvas/scroll.ts +729 -0
- package/src/canvas/selection.ts +560 -0
- package/src/canvas/text.ts +97 -54
- package/src/canvas/types.ts +26 -0
- package/src/canvas/utils.ts +47 -17
- package/src/canvas/virtualize.ts +81 -0
- package/src/dom/applicators/index.ts +1 -1
- package/src/dom/applicators/margin.ts +57 -11
- package/src/dom/applicators/padding.ts +45 -11
- package/src/dom/components/icon.ts +84 -0
- package/src/dom/components/index.ts +2 -0
- package/src/dom/renderer.ts +155 -17
package/src/canvas/layout.ts
CHANGED
|
@@ -1,15 +1,634 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Layout Engine
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|