@hypen-space/web 0.4.81 → 0.4.83
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/dist/canvas/events.d.ts +30 -2
- package/dist/canvas/events.js +112 -30
- package/dist/canvas/events.js.map +1 -1
- package/dist/canvas/input.d.ts +15 -2
- package/dist/canvas/input.js +43 -24
- package/dist/canvas/input.js.map +1 -1
- package/dist/canvas/layout.js +479 -96
- package/dist/canvas/layout.js.map +1 -1
- package/dist/canvas/paint.d.ts +4 -0
- package/dist/canvas/paint.js +141 -29
- package/dist/canvas/paint.js.map +1 -1
- package/dist/canvas/props.d.ts +55 -0
- package/dist/canvas/props.js +121 -0
- package/dist/canvas/props.js.map +1 -0
- package/dist/canvas/renderer.d.ts +13 -0
- package/dist/canvas/renderer.js +109 -11
- package/dist/canvas/renderer.js.map +1 -1
- package/dist/canvas/scroll.d.ts +27 -3
- package/dist/canvas/scroll.js +66 -26
- package/dist/canvas/scroll.js.map +1 -1
- package/dist/canvas/selection.js +3 -4
- package/dist/canvas/selection.js.map +1 -1
- package/dist/canvas/utils.d.ts +23 -3
- package/dist/canvas/utils.js +90 -30
- package/dist/canvas/utils.js.map +1 -1
- package/dist/dom/applicators/border.js +9 -0
- package/dist/dom/applicators/border.js.map +1 -1
- package/dist/dom/applicators/font.js +18 -1
- package/dist/dom/applicators/font.js.map +1 -1
- package/dist/dom/applicators/layout.js +44 -8
- package/dist/dom/applicators/layout.js.map +1 -1
- package/dist/dom/applicators/margin.js +18 -53
- package/dist/dom/applicators/margin.js.map +1 -1
- package/dist/dom/applicators/padding.js +21 -33
- package/dist/dom/applicators/padding.js.map +1 -1
- package/dist/dom/applicators/typography.js +13 -1
- package/dist/dom/applicators/typography.js.map +1 -1
- package/dist/dom/components/column.d.ts +11 -2
- package/dist/dom/components/column.js +12 -4
- package/dist/dom/components/column.js.map +1 -1
- package/dist/dom/components/image.js +9 -3
- package/dist/dom/components/image.js.map +1 -1
- package/dist/dom/components/input.js +11 -0
- package/dist/dom/components/input.js.map +1 -1
- package/dist/dom/components/textarea.js +8 -0
- package/dist/dom/components/textarea.js.map +1 -1
- package/dist/dom/renderer.d.ts +18 -0
- package/dist/dom/renderer.js +87 -3
- package/dist/dom/renderer.js.map +1 -1
- package/package.json +2 -2
- package/src/canvas/PARITY.md +254 -0
- package/src/canvas/events.ts +113 -32
- package/src/canvas/input.ts +44 -25
- package/src/canvas/layout.ts +492 -91
- package/src/canvas/paint.ts +143 -29
- package/src/canvas/props.ts +117 -0
- package/src/canvas/renderer.ts +132 -11
- package/src/canvas/scroll.ts +62 -29
- package/src/canvas/selection.ts +3 -4
- package/src/canvas/utils.ts +83 -31
- package/src/dom/applicators/border.ts +9 -0
- package/src/dom/applicators/font.ts +16 -1
- package/src/dom/applicators/layout.ts +34 -7
- package/src/dom/applicators/margin.ts +16 -43
- package/src/dom/applicators/padding.ts +19 -27
- package/src/dom/applicators/typography.ts +11 -1
- package/src/dom/components/column.ts +12 -4
- package/src/dom/components/image.ts +8 -3
- package/src/dom/components/input.ts +11 -0
- package/src/dom/components/textarea.ts +8 -0
- package/src/dom/renderer.ts +92 -3
package/src/canvas/layout.ts
CHANGED
|
@@ -7,8 +7,27 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { VirtualNode, Layout, BoxSpacing } from "./types.js";
|
|
10
|
-
import { parseSpacing, parseSize } from "./utils.js";
|
|
10
|
+
import { parseSpacing, parseSize, cssLengthToPx, cssLengthToDimension } from "./utils.js";
|
|
11
11
|
import { measureText } from "./text.js";
|
|
12
|
+
import { getImageNaturalAspect } from "./paint.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Components whose size is intrinsic to the component itself (icon, avatar,
|
|
16
|
+
* checkbox, etc.) should not be shrunk by their flex parent. Without this,
|
|
17
|
+
* an icon inside a transparent button with no width collapses to 0×0 in
|
|
18
|
+
* tight cross-axis containers.
|
|
19
|
+
*/
|
|
20
|
+
const INTRINSIC_SIZED = new Set([
|
|
21
|
+
"icon",
|
|
22
|
+
"avatar",
|
|
23
|
+
"badge",
|
|
24
|
+
"checkbox",
|
|
25
|
+
"radio",
|
|
26
|
+
"switch",
|
|
27
|
+
"toggle",
|
|
28
|
+
"spinner",
|
|
29
|
+
"loading",
|
|
30
|
+
]);
|
|
12
31
|
|
|
13
32
|
// Taffy imports — loaded lazily to avoid hard failure when WASM unavailable
|
|
14
33
|
let taffy: typeof import("taffy-layout") | null = null;
|
|
@@ -45,55 +64,122 @@ export async function initTaffyLayout(): Promise<boolean> {
|
|
|
45
64
|
|
|
46
65
|
/** Parse a Hypen prop value into a Taffy Dimension ("auto" | number | "N%") */
|
|
47
66
|
function toDimension(value: any): "auto" | number | `${number}%` {
|
|
48
|
-
|
|
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";
|
|
67
|
+
return cssLengthToDimension(value);
|
|
56
68
|
}
|
|
57
69
|
|
|
58
70
|
function toLengthPct(value: any): number | `${number}%` {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (value.endsWith("%")) return value as `${number}%`;
|
|
62
|
-
const n = parseFloat(value);
|
|
63
|
-
return isNaN(n) ? 0 : n;
|
|
64
|
-
}
|
|
65
|
-
return 0;
|
|
71
|
+
const d = cssLengthToDimension(value);
|
|
72
|
+
return d === "auto" ? 0 : d;
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
function toLengthPctAuto(value: any): "auto" | number | `${number}%` {
|
|
69
|
-
|
|
70
|
-
|
|
76
|
+
return cssLengthToDimension(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parse an aspect-ratio prop. Accepts a number, `"W / H"`, or `"W"`.
|
|
81
|
+
* Returns the width/height ratio, or null when unparseable.
|
|
82
|
+
*/
|
|
83
|
+
function parseAspectRatio(value: any): number | null {
|
|
84
|
+
if (typeof value === "number") return value > 0 ? value : null;
|
|
85
|
+
if (typeof value !== "string") return null;
|
|
86
|
+
const s = value.trim();
|
|
87
|
+
const slash = s.indexOf("/");
|
|
88
|
+
if (slash !== -1) {
|
|
89
|
+
const w = parseFloat(s.slice(0, slash));
|
|
90
|
+
const h = parseFloat(s.slice(slash + 1));
|
|
91
|
+
if (isFinite(w) && isFinite(h) && h !== 0) return w / h;
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const n = parseFloat(s);
|
|
95
|
+
return isFinite(n) && n > 0 ? n : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Read whether a node is a scrollable container, and on which axes. We can't
|
|
100
|
+
* import from `scroll.ts` here without creating a circular dependency, so
|
|
101
|
+
* inline the parsing — keep it in sync with `getScrollAxes`.
|
|
102
|
+
*/
|
|
103
|
+
function readScrollAxes(props: Record<string, any>): { x: boolean; y: boolean } {
|
|
104
|
+
const overflow = props.overflow;
|
|
105
|
+
if (overflow === "scroll" || overflow === "auto") return { x: true, y: true };
|
|
106
|
+
const scrollable = props.scrollable;
|
|
107
|
+
if (scrollable === true) return { x: true, y: true };
|
|
108
|
+
if (scrollable === "horizontal") return { x: true, y: false };
|
|
109
|
+
if (scrollable === "vertical") return { x: false, y: true };
|
|
110
|
+
if (scrollable === "both") return { x: true, y: true };
|
|
111
|
+
return { x: false, y: false };
|
|
71
112
|
}
|
|
72
113
|
|
|
73
114
|
/**
|
|
74
115
|
* Build a Taffy Style from a VirtualNode's props. This centralises the
|
|
75
116
|
* mapping between Hypen's declarative property names and CSS-level concepts.
|
|
117
|
+
*
|
|
118
|
+
* `parent` is the immediate parent VirtualNode (or null for the root) so
|
|
119
|
+
* children of a Stack can place themselves in the single grid cell, and
|
|
120
|
+
* children of a scrollable container can opt out of flex-shrink along the
|
|
121
|
+
* scroll axis (otherwise a horizontal strip's items collapse to the
|
|
122
|
+
* strip's width instead of overflowing).
|
|
76
123
|
*/
|
|
77
|
-
function buildTaffyStyle(
|
|
124
|
+
function buildTaffyStyle(
|
|
125
|
+
node: VirtualNode,
|
|
126
|
+
parent: VirtualNode | null = null,
|
|
127
|
+
): InstanceType<typeof import("taffy-layout").Style> {
|
|
78
128
|
const T = taffy!;
|
|
79
129
|
const props = node.props;
|
|
80
130
|
const type = node.type.toLowerCase();
|
|
131
|
+
const parentType = parent?.type.toLowerCase() ?? null;
|
|
132
|
+
const parentScrollAxes = parent ? readScrollAxes(parent.props) : { x: false, y: false };
|
|
81
133
|
|
|
82
134
|
const style = new T.Style();
|
|
83
135
|
|
|
84
136
|
// --- Display / direction ---------------------------------------------------
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
137
|
+
// Stack overlays children in a single grid cell — matches the DOM
|
|
138
|
+
// renderer's `grid-template-areas: "stack"` strategy. Children get
|
|
139
|
+
// `gridColumn: 1 / 2; gridRow: 1 / 2` further down.
|
|
140
|
+
const isStack = type === "stack";
|
|
141
|
+
// `List` is the DSL's vertical-stack iterator (see dom/components/list.ts
|
|
142
|
+
// — `flex-direction: column` is its default). Match that here so feeds
|
|
143
|
+
// like Notifications stack their rows vertically instead of flowing
|
|
144
|
+
// sideways. The `direction` prop can still flip it to row.
|
|
145
|
+
const isListColumn = type === "list" && props.direction !== "horizontal";
|
|
146
|
+
const isColumn = !isStack && (type === "column" || isListColumn || props.flexDirection === "column");
|
|
147
|
+
const isGrid = !isStack && (type === "grid" || props.display === "grid");
|
|
148
|
+
|
|
149
|
+
if (isStack) {
|
|
150
|
+
style.display = T.Display.Grid;
|
|
151
|
+
// Auto tracks: the single cell sizes to fit the largest child instead
|
|
152
|
+
// of `1fr` (which would inflate the Stack to its parent's full size and
|
|
153
|
+
// stretch the children to match — see PARITY.md).
|
|
154
|
+
style.gridTemplateColumns = [{ min: "auto", max: "auto" }];
|
|
155
|
+
style.gridTemplateRows = [{ min: "auto", max: "auto" }];
|
|
156
|
+
// Default top-left matches the DOM Stack (`justify-items: start;
|
|
157
|
+
// align-items: start`) and iOS/Android ZStack defaults — also keeps
|
|
158
|
+
// explicitly-sized children at their declared size instead of letting
|
|
159
|
+
// Taffy's default `stretch` overwrite the box.
|
|
160
|
+
style.justifyItems = props.horizontalAlignment
|
|
161
|
+
? mapAlign(T, props.horizontalAlignment)
|
|
162
|
+
: T.AlignItems.Start;
|
|
163
|
+
style.alignItems = props.verticalAlignment
|
|
164
|
+
? mapAlign(T, props.verticalAlignment)
|
|
165
|
+
: T.AlignItems.Start;
|
|
166
|
+
} else if (isGrid) {
|
|
89
167
|
style.display = T.Display.Grid;
|
|
90
168
|
|
|
91
169
|
// --- Grid template tracks ------------------------------------------------
|
|
92
|
-
|
|
93
|
-
|
|
170
|
+
// The DSL's `.gridColumns(N)` / `.gridRows(N)` applicators land on
|
|
171
|
+
// props with the applicator's own name (`gridColumns`, `gridRows`).
|
|
172
|
+
// The DOM renderer also accepts the older shorthand `columns`/`rows`
|
|
173
|
+
// and the spec-name `gridTemplateColumns`/`gridTemplateRows`. A bare
|
|
174
|
+
// number N expands to N equal `1fr` tracks — same logic as
|
|
175
|
+
// `dom/applicators/advanced-layout.ts`.
|
|
176
|
+
const colsProp = props.gridTemplateColumns ?? props.gridColumns ?? props.columns;
|
|
177
|
+
const rowsProp = props.gridTemplateRows ?? props.gridRows ?? props.rows;
|
|
178
|
+
if (colsProp !== undefined) {
|
|
179
|
+
style.gridTemplateColumns = parseGridTemplate(colsProp);
|
|
94
180
|
}
|
|
95
|
-
if (
|
|
96
|
-
style.gridTemplateRows = parseGridTemplate(
|
|
181
|
+
if (rowsProp !== undefined) {
|
|
182
|
+
style.gridTemplateRows = parseGridTemplate(rowsProp);
|
|
97
183
|
}
|
|
98
184
|
|
|
99
185
|
// --- Grid auto flow ------------------------------------------------------
|
|
@@ -107,6 +193,12 @@ function buildTaffyStyle(node: VirtualNode): InstanceType<typeof import("taffy-l
|
|
|
107
193
|
}
|
|
108
194
|
if (props.gridAutoRows) {
|
|
109
195
|
style.gridAutoRows = parseTrackSizingList(props.gridAutoRows);
|
|
196
|
+
} else if (typeof props.__autoRowsPx === "number") {
|
|
197
|
+
// Set by `annotateCollapsedAspectGrids` after a first layout pass —
|
|
198
|
+
// pin implicit rows to the height we want each cell to be (column
|
|
199
|
+
// width / aspectRatio), so `aspect-square` images don't overlap.
|
|
200
|
+
const px = props.__autoRowsPx as number;
|
|
201
|
+
style.gridAutoRows = [{ min: px, max: px }];
|
|
110
202
|
}
|
|
111
203
|
|
|
112
204
|
// --- Grid template areas -------------------------------------------------
|
|
@@ -117,7 +209,16 @@ function buildTaffyStyle(node: VirtualNode): InstanceType<typeof import("taffy-l
|
|
|
117
209
|
style.display = T.Display.Flex;
|
|
118
210
|
}
|
|
119
211
|
|
|
120
|
-
|
|
212
|
+
if (!isStack) {
|
|
213
|
+
style.flexDirection = isColumn ? T.FlexDirection.Column : T.FlexDirection.Row;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- Stack child placement -------------------------------------------------
|
|
217
|
+
// Place every child of a Stack in the single 1×1 cell so they overlap.
|
|
218
|
+
if (parentType === "stack") {
|
|
219
|
+
style.gridColumn = { start: 1, end: 2 };
|
|
220
|
+
style.gridRow = { start: 1, end: 2 };
|
|
221
|
+
}
|
|
121
222
|
|
|
122
223
|
// --- Grid child placement (applies regardless of parent display) ----------
|
|
123
224
|
if (props.gridColumn) {
|
|
@@ -140,23 +241,52 @@ function buildTaffyStyle(node: VirtualNode): InstanceType<typeof import("taffy-l
|
|
|
140
241
|
}
|
|
141
242
|
|
|
142
243
|
// --- Flex properties -------------------------------------------------------
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
244
|
+
// The `flex: <n>` shorthand (Tailwind's `flex-1` is `flex: 1 1 0%`) sets
|
|
245
|
+
// grow + shrink + basis at once. Without honouring the basis here, an
|
|
246
|
+
// overflowing flex item starts at its intrinsic content size (e.g. a
|
|
247
|
+
// huge feed) and Taffy redistributes from there, which under-allocates
|
|
248
|
+
// the BottomNav and pushes it off-screen on routes with tall content.
|
|
249
|
+
const flexShorthand = props.flex !== undefined ? parseFloat(props.flex) : NaN;
|
|
250
|
+
style.flexGrow = parseFloat(props.flexGrow) || (Number.isFinite(flexShorthand) ? flexShorthand : 0);
|
|
251
|
+
// Default flex-shrink is 0 when:
|
|
252
|
+
// - the component has an intrinsic size (icon, avatar, …) — otherwise it
|
|
253
|
+
// collapses to 0×0 in a tight cross-axis container, e.g. an icon inside
|
|
254
|
+
// a transparent button with only `padding.0`.
|
|
255
|
+
// - the parent is a scrollable container along the main axis — otherwise
|
|
256
|
+
// a horizontal strip's items shrink to fit instead of overflowing,
|
|
257
|
+
// making the strip pointless.
|
|
258
|
+
const parentIsRow = parentType !== "column" && parentType !== "stack" && parentType !== null;
|
|
259
|
+
const parentIsCol = parentType === "column";
|
|
260
|
+
const parentMainAxisScrolls =
|
|
261
|
+
(parentIsRow && parentScrollAxes.x) || (parentIsCol && parentScrollAxes.y);
|
|
262
|
+
const defaultShrink = INTRINSIC_SIZED.has(type) || parentMainAxisScrolls ? 0 : 1;
|
|
263
|
+
style.flexShrink = props.flexShrink !== undefined ? parseFloat(props.flexShrink) : defaultShrink;
|
|
264
|
+
if (props.flexBasis !== undefined) {
|
|
265
|
+
style.flexBasis = toDimension(props.flexBasis);
|
|
266
|
+
} else if (Number.isFinite(flexShorthand)) {
|
|
267
|
+
// `flex: 1` shorthand → basis 0%. Pinning basis is the load-bearing
|
|
268
|
+
// bit: an item with `flex: 1 1 auto` starts at its content size and
|
|
269
|
+
// distributes from there, leaving siblings in a tight container with
|
|
270
|
+
// negative space; `flex: 1 1 0%` starts at zero and grows up.
|
|
271
|
+
style.flexBasis = 0;
|
|
272
|
+
}
|
|
146
273
|
|
|
147
274
|
if (props.flexWrap === "wrap") style.flexWrap = T.FlexWrap.Wrap;
|
|
148
275
|
else if (props.flexWrap === "wrap-reverse") style.flexWrap = T.FlexWrap.WrapReverse;
|
|
149
276
|
|
|
150
277
|
// --- Alignment (Hypen uses verticalAlignment / horizontalAlignment) --------
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
278
|
+
// Stack handles its own alignment via justifyItems/alignItems above.
|
|
279
|
+
if (!isStack) {
|
|
280
|
+
const justifyRaw = isColumn
|
|
281
|
+
? (props.verticalAlignment || props.justifyContent)
|
|
282
|
+
: (props.horizontalAlignment || props.justifyContent);
|
|
283
|
+
const alignRaw = isColumn
|
|
284
|
+
? (props.horizontalAlignment || props.alignItems)
|
|
285
|
+
: (props.verticalAlignment || props.alignItems);
|
|
286
|
+
|
|
287
|
+
if (justifyRaw) style.justifyContent = mapJustify(T, justifyRaw);
|
|
288
|
+
if (alignRaw) style.alignItems = mapAlign(T, alignRaw);
|
|
289
|
+
}
|
|
160
290
|
|
|
161
291
|
// --- Size ------------------------------------------------------------------
|
|
162
292
|
const w = parseSize(props.width);
|
|
@@ -170,14 +300,14 @@ function buildTaffyStyle(node: VirtualNode): InstanceType<typeof import("taffy-l
|
|
|
170
300
|
};
|
|
171
301
|
} else if (type === "divider" || type === "separator") {
|
|
172
302
|
const orientation = props.orientation || "horizontal";
|
|
173
|
-
const thickness =
|
|
303
|
+
const thickness = cssLengthToPx(props.thickness) ?? 1;
|
|
174
304
|
if (orientation === "vertical") {
|
|
175
305
|
style.size = { width: w ?? thickness, height: h !== null ? h : "100%" };
|
|
176
306
|
} else {
|
|
177
307
|
style.size = { width: w !== null ? w : "100%", height: h ?? thickness };
|
|
178
308
|
}
|
|
179
309
|
} else if (type === "checkbox" || type === "radio") {
|
|
180
|
-
const sz =
|
|
310
|
+
const sz = cssLengthToPx(props.size) ?? 20;
|
|
181
311
|
style.size = { width: w ?? sz, height: h ?? sz };
|
|
182
312
|
} else if (type === "switch" || type === "toggle") {
|
|
183
313
|
style.size = { width: w ?? 44, height: h ?? 24 };
|
|
@@ -186,26 +316,67 @@ function buildTaffyStyle(node: VirtualNode): InstanceType<typeof import("taffy-l
|
|
|
186
316
|
} else if (type === "progress" || type === "progressbar") {
|
|
187
317
|
style.size = { width: w ?? 200, height: h ?? 8 };
|
|
188
318
|
} else if (type === "spinner" || type === "loading") {
|
|
189
|
-
const sz =
|
|
319
|
+
const sz = cssLengthToPx(props.size) ?? 24;
|
|
190
320
|
style.size = { width: w ?? sz, height: h ?? sz };
|
|
191
321
|
} else if (type === "badge") {
|
|
192
322
|
style.size = { width: w ?? 20, height: h ?? 20 };
|
|
193
323
|
} else if (type === "avatar") {
|
|
194
|
-
const sz =
|
|
324
|
+
const sz = cssLengthToPx(props.size) ?? 40;
|
|
195
325
|
style.size = { width: w ?? sz, height: h ?? sz };
|
|
196
326
|
} else if (type === "icon") {
|
|
197
|
-
const sz =
|
|
327
|
+
const sz = cssLengthToPx(props.size) ?? 24;
|
|
198
328
|
style.size = { width: w ?? sz, height: h ?? sz };
|
|
329
|
+
} else if (type === "input" || type === "textarea" || type === "select") {
|
|
330
|
+
// Form controls have no painted children, so without an intrinsic
|
|
331
|
+
// height they collapse to padding only (the social Search bar
|
|
332
|
+
// rendered as a 4-tall pill instead of the ~36 the DOM produces).
|
|
333
|
+
// Match the DOM box: outer = line-height(s) + padding + border.
|
|
334
|
+
// Taffy defaults to `box-sizing: border-box`, so we add the
|
|
335
|
+
// padding/border ourselves into `size.height`.
|
|
336
|
+
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
337
|
+
const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.5;
|
|
338
|
+
const minRows = type === "textarea" ? Math.max(1, Number(props.rows) || 3) : 1;
|
|
339
|
+
const padTop = cssLengthToPx(props.paddingTop ?? props.padding) ?? 0;
|
|
340
|
+
const padBottom = cssLengthToPx(props.paddingBottom ?? props.padding) ?? 0;
|
|
341
|
+
const borderTop = cssLengthToPx(props.borderTopWidth ?? props.borderWidth) ?? 0;
|
|
342
|
+
const borderBottom = cssLengthToPx(props.borderBottomWidth ?? props.borderWidth) ?? 0;
|
|
343
|
+
const intrinsicH = lineHeight * minRows + padTop + padBottom + borderTop + borderBottom;
|
|
344
|
+
style.size = { width: toDimension(props.width), height: h ?? intrinsicH };
|
|
199
345
|
} else {
|
|
200
|
-
|
|
346
|
+
// Children of a Grid that ask for `width: 100%` actually mean "fill
|
|
347
|
+
// the cell" — that's the DOM behavior (`grid > * { justify-self:
|
|
348
|
+
// stretch }`). Taffy handles this via the grid item's default
|
|
349
|
+
// stretch alignment when the size is auto, but a literal 100% creates
|
|
350
|
+
// a circular dependency with track sizing (the cell wants to be 1fr
|
|
351
|
+
// of the grid, the item wants to be 100% of the cell, and Taffy
|
|
352
|
+
// collapses both to the item's max-content). Strip the 100% on grid
|
|
353
|
+
// children so the Taffy grid stretch wins. Same for height.
|
|
354
|
+
const parentIsGridLayout =
|
|
355
|
+
parent &&
|
|
356
|
+
(parent.type.toLowerCase() === "grid" || parent.props.display === "grid");
|
|
357
|
+
const widthIn = parentIsGridLayout && props.width === "100%" ? undefined : props.width;
|
|
358
|
+
const heightIn = parentIsGridLayout && props.height === "100%" ? undefined : props.height;
|
|
359
|
+
style.size = { width: toDimension(widthIn), height: toDimension(heightIn) };
|
|
201
360
|
}
|
|
202
361
|
|
|
203
362
|
// --- Min / Max constraints -------------------------------------------------
|
|
363
|
+
// Default `min-width: 0` for children of a Grid container. CSS makes
|
|
364
|
+
// grid items default to `min-width: auto` (= min-content) which prevents
|
|
365
|
+
// them shrinking below their intrinsic min. For an Image with
|
|
366
|
+
// `aspect-ratio: 1`, that "min" is the image's natural max-content —
|
|
367
|
+
// so the grid track sizing collapses (one item ends up 360px wide and
|
|
368
|
+
// squashes its row). Explicitly setting min: 0 is the standard CSS
|
|
369
|
+
// workaround and matches the DOM grid's `justify-self: stretch` shape.
|
|
370
|
+
const parentIsGrid =
|
|
371
|
+
parent &&
|
|
372
|
+
(parent.type.toLowerCase() === "grid" || parent.props.display === "grid");
|
|
204
373
|
if (props.minWidth !== undefined || props.minHeight !== undefined) {
|
|
205
374
|
style.minSize = {
|
|
206
375
|
width: toDimension(props.minWidth),
|
|
207
376
|
height: toDimension(props.minHeight),
|
|
208
377
|
};
|
|
378
|
+
} else if (parentIsGrid) {
|
|
379
|
+
style.minSize = { width: 0, height: 0 };
|
|
209
380
|
}
|
|
210
381
|
if (props.maxWidth !== undefined || props.maxHeight !== undefined) {
|
|
211
382
|
style.maxSize = {
|
|
@@ -216,32 +387,66 @@ function buildTaffyStyle(node: VirtualNode): InstanceType<typeof import("taffy-l
|
|
|
216
387
|
|
|
217
388
|
// --- Margin ----------------------------------------------------------------
|
|
218
389
|
const m = parseSpacing(props.margin || 0);
|
|
219
|
-
if (props.marginTop !== undefined) m.top =
|
|
220
|
-
if (props.marginRight !== undefined) m.right =
|
|
221
|
-
if (props.marginBottom !== undefined) m.bottom =
|
|
222
|
-
if (props.marginLeft !== undefined) m.left =
|
|
390
|
+
if (props.marginTop !== undefined) m.top = cssLengthToPx(props.marginTop) ?? 0;
|
|
391
|
+
if (props.marginRight !== undefined) m.right = cssLengthToPx(props.marginRight) ?? 0;
|
|
392
|
+
if (props.marginBottom !== undefined) m.bottom = cssLengthToPx(props.marginBottom) ?? 0;
|
|
393
|
+
if (props.marginLeft !== undefined) m.left = cssLengthToPx(props.marginLeft) ?? 0;
|
|
223
394
|
style.margin = { top: m.top, right: m.right, bottom: m.bottom, left: m.left };
|
|
224
395
|
|
|
225
396
|
// --- Padding ---------------------------------------------------------------
|
|
226
397
|
const p = parseSpacing(props.padding || 0);
|
|
227
|
-
if (props.paddingTop !== undefined) p.top =
|
|
228
|
-
if (props.paddingRight !== undefined) p.right =
|
|
229
|
-
if (props.paddingBottom !== undefined) p.bottom =
|
|
230
|
-
if (props.paddingLeft !== undefined) p.left =
|
|
398
|
+
if (props.paddingTop !== undefined) p.top = cssLengthToPx(props.paddingTop) ?? 0;
|
|
399
|
+
if (props.paddingRight !== undefined) p.right = cssLengthToPx(props.paddingRight) ?? 0;
|
|
400
|
+
if (props.paddingBottom !== undefined) p.bottom = cssLengthToPx(props.paddingBottom) ?? 0;
|
|
401
|
+
if (props.paddingLeft !== undefined) p.left = cssLengthToPx(props.paddingLeft) ?? 0;
|
|
231
402
|
style.padding = { top: p.top, right: p.right, bottom: p.bottom, left: p.left };
|
|
232
403
|
|
|
233
404
|
// --- Border ----------------------------------------------------------------
|
|
234
|
-
const bw =
|
|
405
|
+
const bw = cssLengthToPx(props.borderWidth) ?? 0;
|
|
235
406
|
if (bw > 0) {
|
|
236
407
|
style.border = { top: bw, right: bw, bottom: bw, left: bw };
|
|
237
408
|
}
|
|
238
409
|
|
|
239
410
|
// --- Gap -------------------------------------------------------------------
|
|
240
|
-
const gap =
|
|
411
|
+
const gap = cssLengthToPx(props.gap) ?? 0;
|
|
241
412
|
if (gap > 0) {
|
|
242
413
|
style.gap = { width: gap, height: gap };
|
|
243
414
|
}
|
|
244
415
|
|
|
416
|
+
// --- Aspect ratio ----------------------------------------------------------
|
|
417
|
+
// Engine emits values like `"1"` or `16/9`. Taffy expects a number.
|
|
418
|
+
if (props.aspectRatio !== undefined) {
|
|
419
|
+
const ar = parseAspectRatio(props.aspectRatio);
|
|
420
|
+
if (ar !== null) style.aspectRatio = ar;
|
|
421
|
+
} else if (type === "image") {
|
|
422
|
+
// Implicit aspect ratio from the decoded image's intrinsic size — only
|
|
423
|
+
// useful when the caller fixes one dimension and lets the other follow.
|
|
424
|
+
// Without this, an image with `width: 100` stretches to whatever Taffy
|
|
425
|
+
// picks for the height (the parent's full height under the default
|
|
426
|
+
// `align-items: stretch` of a flex row).
|
|
427
|
+
//
|
|
428
|
+
// We can't rely on Taffy's `aspectRatio` alone: with `align-items:
|
|
429
|
+
// stretch`, the cross-axis size is forced before aspect-ratio is
|
|
430
|
+
// applied. Pin both dimensions explicitly instead.
|
|
431
|
+
const wParsed = parseSize(props.width);
|
|
432
|
+
const hParsed = parseSize(props.height);
|
|
433
|
+
if ((wParsed === null) !== (hParsed === null)) {
|
|
434
|
+
const src = props.src ?? props[0];
|
|
435
|
+
if (typeof src === "string") {
|
|
436
|
+
const intrinsic = getImageNaturalAspect(src);
|
|
437
|
+
if (intrinsic !== null) {
|
|
438
|
+
if (wParsed !== null) {
|
|
439
|
+
style.size = { width: wParsed, height: wParsed / intrinsic };
|
|
440
|
+
style.aspectRatio = intrinsic;
|
|
441
|
+
} else if (hParsed !== null) {
|
|
442
|
+
style.size = { width: hParsed * intrinsic, height: hParsed };
|
|
443
|
+
style.aspectRatio = intrinsic;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
245
450
|
// --- Position (absolute) ---------------------------------------------------
|
|
246
451
|
if (props.position === "absolute") {
|
|
247
452
|
style.position = T.Position.Absolute;
|
|
@@ -251,6 +456,29 @@ function buildTaffyStyle(node: VirtualNode): InstanceType<typeof import("taffy-l
|
|
|
251
456
|
if (props.bottom !== undefined) style.bottom = toLengthPctAuto(props.bottom);
|
|
252
457
|
}
|
|
253
458
|
|
|
459
|
+
// --- Overflow (scrollable containers) -------------------------------------
|
|
460
|
+
// For scroll containers we need a careful balance:
|
|
461
|
+
// - Children must lay out at their full intrinsic positions so
|
|
462
|
+
// `ScrollManager.updateScrollBounds` can compute scrollHeight (a feed
|
|
463
|
+
// with overflow:Hidden gets its children clamped and the page won't
|
|
464
|
+
// scroll).
|
|
465
|
+
// - But the CONTAINER itself must not bubble its overflowing min-content
|
|
466
|
+
// up into its parent (a horizontally-scrollable Stories row with 5+
|
|
467
|
+
// w-24 items inflated the whole feed to 607px on a 470px canvas).
|
|
468
|
+
//
|
|
469
|
+
// Setting overflow to `Scroll` on the relevant axis tells Taffy to treat
|
|
470
|
+
// this container as a scroll port — children flow at intrinsic sizes but
|
|
471
|
+
// the container's own contribution to the parent stays bounded. The
|
|
472
|
+
// non-scroll axis stays Visible so siblings still see the right content
|
|
473
|
+
// size when they need to.
|
|
474
|
+
const ownAxes = readScrollAxes(props);
|
|
475
|
+
if (ownAxes.x || ownAxes.y) {
|
|
476
|
+
style.overflow = {
|
|
477
|
+
x: ownAxes.x ? T.Overflow.Scroll : T.Overflow.Visible,
|
|
478
|
+
y: ownAxes.y ? T.Overflow.Scroll : T.Overflow.Visible,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
254
482
|
return style;
|
|
255
483
|
}
|
|
256
484
|
|
|
@@ -324,10 +552,27 @@ function parseGridTemplate(value: any): any[] {
|
|
|
324
552
|
return value.map(parseTrackSizing);
|
|
325
553
|
}
|
|
326
554
|
if (typeof value === "string") {
|
|
555
|
+
// The DSL's `.gridColumns(3)` sometimes arrives here as `"3"` after
|
|
556
|
+
// stringification (engine emits numeric applicator args as strings).
|
|
557
|
+
// Treat a bare integer string the same way the DOM renderer does —
|
|
558
|
+
// expand to N equal 1fr tracks. `repeat(N, 1fr)` shorthand would work
|
|
559
|
+
// too but Taffy wants the expanded track list.
|
|
560
|
+
const trimmed = value.trim();
|
|
561
|
+
if (/^\d+$/.test(trimmed)) {
|
|
562
|
+
const n = parseInt(trimmed, 10);
|
|
563
|
+
return Array.from({ length: n }, () => ({
|
|
564
|
+
min: "auto",
|
|
565
|
+
max: "1fr" as `${number}fr`,
|
|
566
|
+
}));
|
|
567
|
+
}
|
|
327
568
|
return value.split(/\s+/).filter(Boolean).map(parseTrackSizing);
|
|
328
569
|
}
|
|
329
570
|
if (typeof value === "number") {
|
|
330
|
-
|
|
571
|
+
// Bare number = column count. DOM: `repeat(N, 1fr)`.
|
|
572
|
+
return Array.from({ length: value }, () => ({
|
|
573
|
+
min: "auto",
|
|
574
|
+
max: "1fr" as `${number}fr`,
|
|
575
|
+
}));
|
|
331
576
|
}
|
|
332
577
|
return [];
|
|
333
578
|
}
|
|
@@ -454,6 +699,17 @@ interface TextMeasureContext {
|
|
|
454
699
|
paddingV: number;
|
|
455
700
|
}
|
|
456
701
|
|
|
702
|
+
/**
|
|
703
|
+
* Marker context for image leaves whose height should follow the column-
|
|
704
|
+
* stretched width via aspect-ratio. Without this, Taffy's grid auto-row
|
|
705
|
+
* sizing only sees the image's max-content (= 0 for a leaf with no fixed
|
|
706
|
+
* height) and a `gridColumns(3)` of `aspect-square` images collapses to
|
|
707
|
+
* gap-tall rows that overlap by 150px each (the Search explore grid).
|
|
708
|
+
*/
|
|
709
|
+
interface ImageAspectMeasureContext {
|
|
710
|
+
aspectRatio: number;
|
|
711
|
+
}
|
|
712
|
+
|
|
457
713
|
/**
|
|
458
714
|
* Build a TaffyTree mirroring the VirtualNode tree. Returns the root Taffy
|
|
459
715
|
* node id and a mapping from Taffy node ids → VirtualNodes.
|
|
@@ -461,24 +717,30 @@ interface TextMeasureContext {
|
|
|
461
717
|
function buildTree(
|
|
462
718
|
tree: InstanceType<typeof import("taffy-layout").TaffyTree>,
|
|
463
719
|
node: VirtualNode,
|
|
720
|
+
parent: VirtualNode | null = null,
|
|
464
721
|
): bigint {
|
|
465
722
|
const T = taffy!;
|
|
466
723
|
const props = node.props;
|
|
467
|
-
const style = buildTaffyStyle(node);
|
|
468
|
-
|
|
469
|
-
// Text leaf nodes — create with measurement context
|
|
470
|
-
|
|
724
|
+
const style = buildTaffyStyle(node, parent);
|
|
725
|
+
|
|
726
|
+
// Text leaf nodes — create with measurement context. The engine emits the
|
|
727
|
+
// type capitalised (`"Text"`); compare case-insensitively so a Taffy leaf
|
|
728
|
+
// is created. Without this, every Text node was treated as an empty
|
|
729
|
+
// container with size 0×0, and downstream siblings were placed on top of
|
|
730
|
+
// each other ("1,431 likes" overlapping the caption row was the visible
|
|
731
|
+
// symptom).
|
|
732
|
+
if (node.type.toLowerCase() === "text" && props[0] && node.children.length === 0) {
|
|
471
733
|
const text = String(props[0] || "");
|
|
472
|
-
const fontSize =
|
|
734
|
+
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
473
735
|
const fontWeight = props.fontWeight || "normal";
|
|
474
736
|
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
475
|
-
const lineHeight =
|
|
737
|
+
const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
|
|
476
738
|
|
|
477
739
|
const p = parseSpacing(props.padding || 0);
|
|
478
|
-
if (props.paddingTop !== undefined) p.top =
|
|
479
|
-
if (props.paddingRight !== undefined) p.right =
|
|
480
|
-
if (props.paddingBottom !== undefined) p.bottom =
|
|
481
|
-
if (props.paddingLeft !== undefined) p.left =
|
|
740
|
+
if (props.paddingTop !== undefined) p.top = cssLengthToPx(props.paddingTop) ?? 0;
|
|
741
|
+
if (props.paddingRight !== undefined) p.right = cssLengthToPx(props.paddingRight) ?? 0;
|
|
742
|
+
if (props.paddingBottom !== undefined) p.bottom = cssLengthToPx(props.paddingBottom) ?? 0;
|
|
743
|
+
if (props.paddingLeft !== undefined) p.left = cssLengthToPx(props.paddingLeft) ?? 0;
|
|
482
744
|
|
|
483
745
|
const ctx: TextMeasureContext = {
|
|
484
746
|
text,
|
|
@@ -493,10 +755,26 @@ function buildTree(
|
|
|
493
755
|
return tree.newLeafWithContext(style, ctx);
|
|
494
756
|
}
|
|
495
757
|
|
|
758
|
+
// Image leaves with aspect-ratio: contribute height = stretched-width /
|
|
759
|
+
// aspectRatio to grid/flex track sizing via the measure callback. The
|
|
760
|
+
// `style.aspectRatio` alone isn't enough — Taffy's grid auto-row pass asks
|
|
761
|
+
// for the leaf's max-content height, which for an image with no fixed
|
|
762
|
+
// height is 0; without the callback the row collapses.
|
|
763
|
+
if (node.type.toLowerCase() === "image" && node.children.length === 0) {
|
|
764
|
+
const ar =
|
|
765
|
+
props.aspectRatio !== undefined
|
|
766
|
+
? parseAspectRatio(props.aspectRatio)
|
|
767
|
+
: null;
|
|
768
|
+
if (ar !== null) {
|
|
769
|
+
const ictx: ImageAspectMeasureContext = { aspectRatio: ar };
|
|
770
|
+
return tree.newLeafWithContext(style, ictx);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
496
774
|
// Container nodes
|
|
497
775
|
const childIds: bigint[] = [];
|
|
498
776
|
for (const child of node.children) {
|
|
499
|
-
childIds.push(buildTree(tree, child));
|
|
777
|
+
childIds.push(buildTree(tree, child, node));
|
|
500
778
|
}
|
|
501
779
|
|
|
502
780
|
return tree.newWithChildren(style, childIds);
|
|
@@ -527,7 +805,7 @@ function writeLayout(
|
|
|
527
805
|
const absY = parentY + tl.y + (isRoot ? tl.marginTop : 0);
|
|
528
806
|
|
|
529
807
|
const borderColor = node.props.borderColor || "transparent";
|
|
530
|
-
const borderRadius =
|
|
808
|
+
const borderRadius = cssLengthToPx(node.props.borderRadius) ?? 0;
|
|
531
809
|
// Taffy computes uniform border width per side; pick top as representative
|
|
532
810
|
const borderWidth = tl.borderTop;
|
|
533
811
|
|
|
@@ -568,6 +846,73 @@ function writeLayout(
|
|
|
568
846
|
}
|
|
569
847
|
}
|
|
570
848
|
|
|
849
|
+
/**
|
|
850
|
+
* After a layout pass, detect Grid containers whose implicit row tracks
|
|
851
|
+
* collapsed against aspect-ratio image children — Taffy treats the
|
|
852
|
+
* leaves' max-content height as 0 (the measure callback can't anchor the
|
|
853
|
+
* height without a known width during track sizing), so the rows get
|
|
854
|
+
* sized to gap-only and items overflow their tracks by ~150px each.
|
|
855
|
+
*
|
|
856
|
+
* Mark the grid with a private hint (`__autoRowsPx`) carrying the height
|
|
857
|
+
* we want each implicit row to be — derived from the first item's
|
|
858
|
+
* computed width and aspect-ratio after the first pass. The next layout
|
|
859
|
+
* pass reads this hint in `buildTaffyStyle` and sets explicit
|
|
860
|
+
* `gridAutoRows`. Returns true if any grid was marked (caller re-runs
|
|
861
|
+
* layout from scratch).
|
|
862
|
+
*/
|
|
863
|
+
function annotateCollapsedAspectGrids(node: VirtualNode): boolean {
|
|
864
|
+
let touched = false;
|
|
865
|
+
for (const child of node.children) {
|
|
866
|
+
if (annotateCollapsedAspectGrids(child)) touched = true;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (!node.layout) return touched;
|
|
870
|
+
const t = node.type.toLowerCase();
|
|
871
|
+
const isGrid = t === "grid" || node.props.display === "grid";
|
|
872
|
+
if (!isGrid) return touched;
|
|
873
|
+
// Only adjust grids that haven't already been pinned (avoid loops).
|
|
874
|
+
if (node.props.__autoRowsPx) return touched;
|
|
875
|
+
|
|
876
|
+
let firstAspectKid: VirtualNode | null = null;
|
|
877
|
+
let aspectRatio = 0;
|
|
878
|
+
for (const child of node.children) {
|
|
879
|
+
if (child.type.toLowerCase() !== "image" || !child.layout) continue;
|
|
880
|
+
const ar =
|
|
881
|
+
child.props.aspectRatio !== undefined
|
|
882
|
+
? parseAspectRatio(child.props.aspectRatio)
|
|
883
|
+
: null;
|
|
884
|
+
if (ar === null) continue;
|
|
885
|
+
firstAspectKid = child;
|
|
886
|
+
aspectRatio = ar;
|
|
887
|
+
break;
|
|
888
|
+
}
|
|
889
|
+
if (!firstAspectKid) return touched;
|
|
890
|
+
|
|
891
|
+
const itemWidth = firstAspectKid.layout!.width;
|
|
892
|
+
const expectedRowHeight = itemWidth / aspectRatio;
|
|
893
|
+
if (expectedRowHeight <= 0) return touched;
|
|
894
|
+
|
|
895
|
+
// Find a sibling image in the next row to confirm collapse: if its y
|
|
896
|
+
// delta from the first row is less than the expected row height (minus
|
|
897
|
+
// a tolerance for fractional pixels and gap rounding), the rows
|
|
898
|
+
// overlapped — this is the symptom we're fixing.
|
|
899
|
+
let nextRowKid: VirtualNode | null = null;
|
|
900
|
+
for (const child of node.children) {
|
|
901
|
+
if (child === firstAspectKid) continue;
|
|
902
|
+
if (!child.layout) continue;
|
|
903
|
+
if (child.layout.y > firstAspectKid.layout!.y + 1) {
|
|
904
|
+
nextRowKid = child;
|
|
905
|
+
break;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if (!nextRowKid) return touched;
|
|
909
|
+
const rowDelta = nextRowKid.layout!.y - firstAspectKid.layout!.y;
|
|
910
|
+
if (rowDelta >= expectedRowHeight - 1) return touched;
|
|
911
|
+
|
|
912
|
+
node.props.__autoRowsPx = expectedRowHeight;
|
|
913
|
+
return true;
|
|
914
|
+
}
|
|
915
|
+
|
|
571
916
|
/**
|
|
572
917
|
* Compute layout for a virtual node tree using Taffy.
|
|
573
918
|
* Requires `initTaffyLayout()` to have been called and resolved.
|
|
@@ -586,11 +931,50 @@ function computeLayoutTaffy(
|
|
|
586
931
|
try {
|
|
587
932
|
const rootId = buildTree(tree, node);
|
|
588
933
|
|
|
934
|
+
// Pin the root to the canvas's available size when it has no explicit
|
|
935
|
+
// size of its own. Without this, Taffy lets the root grow to its
|
|
936
|
+
// min-content — and a horizontally-scrollable Stories row with 5+
|
|
937
|
+
// items at w-24 inflated the entire feed to 607px on a 470px canvas,
|
|
938
|
+
// pushing the BottomNav off-screen.
|
|
939
|
+
const rootProps = node.props;
|
|
940
|
+
const rootHasExplicitWidth =
|
|
941
|
+
rootProps.width !== undefined || rootProps["width.0"] !== undefined;
|
|
942
|
+
const rootHasExplicitHeight =
|
|
943
|
+
rootProps.height !== undefined || rootProps["height.0"] !== undefined;
|
|
944
|
+
if (!rootHasExplicitWidth || !rootHasExplicitHeight) {
|
|
945
|
+
const rootStyle = tree.getStyle(rootId);
|
|
946
|
+
rootStyle.size = {
|
|
947
|
+
width: rootHasExplicitWidth
|
|
948
|
+
? cssLengthToDimension(rootProps.width ?? rootProps["width.0"])
|
|
949
|
+
: availableWidth,
|
|
950
|
+
height: rootHasExplicitHeight
|
|
951
|
+
? cssLengthToDimension(rootProps.height ?? rootProps["height.0"])
|
|
952
|
+
: availableHeight,
|
|
953
|
+
};
|
|
954
|
+
tree.setStyle(rootId, rootStyle);
|
|
955
|
+
}
|
|
956
|
+
|
|
589
957
|
// Compute layout with a measure function for text leaf nodes
|
|
590
958
|
tree.computeLayoutWithMeasure(
|
|
591
959
|
rootId,
|
|
592
960
|
{ width: availableWidth, height: availableHeight },
|
|
593
961
|
(knownDimensions, availableSpace, _nodeId, context, _style) => {
|
|
962
|
+
// Image-with-aspect-ratio leaf: derive missing dim from the other
|
|
963
|
+
// via aspectRatio so grid track sizing sees a proper max-content
|
|
964
|
+
// height. Falls back to availableSpace.width when nothing's known.
|
|
965
|
+
const ictx = context as ImageAspectMeasureContext | undefined;
|
|
966
|
+
if (ictx && typeof ictx.aspectRatio === "number") {
|
|
967
|
+
const w =
|
|
968
|
+
knownDimensions.width ??
|
|
969
|
+
(typeof availableSpace.width === "number"
|
|
970
|
+
? availableSpace.width
|
|
971
|
+
: undefined);
|
|
972
|
+
const h =
|
|
973
|
+
knownDimensions.height ??
|
|
974
|
+
(w !== undefined ? w / ictx.aspectRatio : undefined);
|
|
975
|
+
return { width: w ?? 0, height: h ?? 0 };
|
|
976
|
+
}
|
|
977
|
+
|
|
594
978
|
const tctx = context as TextMeasureContext | undefined;
|
|
595
979
|
if (!tctx?.text) {
|
|
596
980
|
return { width: knownDimensions.width ?? 0, height: knownDimensions.height ?? 0 };
|
|
@@ -607,9 +991,14 @@ function computeLayoutTaffy(
|
|
|
607
991
|
lineHeight: tctx.lineHeight,
|
|
608
992
|
}, maxWidth);
|
|
609
993
|
|
|
994
|
+
// `Math.ceil` the reported width: pretext returns a fractional
|
|
995
|
+
// pixel width (e.g. 141.71875 for "View all 87 comments"), Taffy
|
|
996
|
+
// gives the box exactly that, but at paint time we re-measure with
|
|
997
|
+
// a maxWidth of the integer-rounded box width and pretext then
|
|
998
|
+
// wraps because 141.71 > 141. Reserve the next whole pixel up.
|
|
610
999
|
return {
|
|
611
|
-
width: knownDimensions.width ?? (metrics.width + tctx.paddingH),
|
|
612
|
-
height: knownDimensions.height ?? (metrics.height + tctx.paddingV),
|
|
1000
|
+
width: knownDimensions.width ?? (Math.ceil(metrics.width) + tctx.paddingH),
|
|
1001
|
+
height: knownDimensions.height ?? (Math.ceil(metrics.height) + tctx.paddingV),
|
|
613
1002
|
};
|
|
614
1003
|
},
|
|
615
1004
|
);
|
|
@@ -640,6 +1029,14 @@ export function computeLayout(
|
|
|
640
1029
|
): void {
|
|
641
1030
|
if (taffyReady && taffy) {
|
|
642
1031
|
computeLayoutTaffy(ctx, node, availableWidth, availableHeight, x, y);
|
|
1032
|
+
// After the first pass, Taffy's grid auto-row sizing for aspect-ratio
|
|
1033
|
+
// image leaves can collapse rows (the leaf's max-content height is 0
|
|
1034
|
+
// before column widths are known). Detect that pattern and re-run
|
|
1035
|
+
// with explicit `gridAutoRows` derived from the now-known column
|
|
1036
|
+
// width — see `annotateCollapsedAspectGrids`.
|
|
1037
|
+
if (annotateCollapsedAspectGrids(node)) {
|
|
1038
|
+
computeLayoutTaffy(ctx, node, availableWidth, availableHeight, x, y);
|
|
1039
|
+
}
|
|
643
1040
|
} else {
|
|
644
1041
|
computeLayoutFallback(ctx, node, availableWidth, availableHeight, x, y);
|
|
645
1042
|
}
|
|
@@ -660,20 +1057,20 @@ function computeLayoutFallback(
|
|
|
660
1057
|
const props = node.props;
|
|
661
1058
|
|
|
662
1059
|
let margin = parseSpacing(props.margin || 0);
|
|
663
|
-
if (props.marginTop !== undefined) margin.top =
|
|
664
|
-
if (props.marginRight !== undefined) margin.right =
|
|
665
|
-
if (props.marginBottom !== undefined) margin.bottom =
|
|
666
|
-
if (props.marginLeft !== undefined) margin.left =
|
|
1060
|
+
if (props.marginTop !== undefined) margin.top = cssLengthToPx(props.marginTop) ?? 0;
|
|
1061
|
+
if (props.marginRight !== undefined) margin.right = cssLengthToPx(props.marginRight) ?? 0;
|
|
1062
|
+
if (props.marginBottom !== undefined) margin.bottom = cssLengthToPx(props.marginBottom) ?? 0;
|
|
1063
|
+
if (props.marginLeft !== undefined) margin.left = cssLengthToPx(props.marginLeft) ?? 0;
|
|
667
1064
|
|
|
668
1065
|
let padding = parseSpacing(props.padding || 0);
|
|
669
|
-
if (props.paddingTop !== undefined) padding.top =
|
|
670
|
-
if (props.paddingRight !== undefined) padding.right =
|
|
671
|
-
if (props.paddingBottom !== undefined) padding.bottom =
|
|
672
|
-
if (props.paddingLeft !== undefined) padding.left =
|
|
1066
|
+
if (props.paddingTop !== undefined) padding.top = cssLengthToPx(props.paddingTop) ?? 0;
|
|
1067
|
+
if (props.paddingRight !== undefined) padding.right = cssLengthToPx(props.paddingRight) ?? 0;
|
|
1068
|
+
if (props.paddingBottom !== undefined) padding.bottom = cssLengthToPx(props.paddingBottom) ?? 0;
|
|
1069
|
+
if (props.paddingLeft !== undefined) padding.left = cssLengthToPx(props.paddingLeft) ?? 0;
|
|
673
1070
|
|
|
674
|
-
const borderWidth =
|
|
1071
|
+
const borderWidth = cssLengthToPx(props.borderWidth) ?? 0;
|
|
675
1072
|
const borderColor = props.borderColor || "transparent";
|
|
676
|
-
const borderRadius =
|
|
1073
|
+
const borderRadius = cssLengthToPx(props.borderRadius) ?? 0;
|
|
677
1074
|
|
|
678
1075
|
const availableAfterMargin = {
|
|
679
1076
|
width: availableWidth - margin.left - margin.right,
|
|
@@ -693,7 +1090,7 @@ function computeLayoutFallback(
|
|
|
693
1090
|
if (height === null) height = availableAfterMargin.height;
|
|
694
1091
|
} else if (type === "divider" || type === "separator") {
|
|
695
1092
|
const orientation = props.orientation || "horizontal";
|
|
696
|
-
const thickness =
|
|
1093
|
+
const thickness = cssLengthToPx(props.thickness) ?? 1;
|
|
697
1094
|
if (orientation === "vertical") {
|
|
698
1095
|
if (width === null) width = thickness;
|
|
699
1096
|
if (height === null) height = availableAfterMargin.height;
|
|
@@ -702,7 +1099,7 @@ function computeLayoutFallback(
|
|
|
702
1099
|
if (height === null) height = thickness;
|
|
703
1100
|
}
|
|
704
1101
|
} else if (type === "checkbox" || type === "radio") {
|
|
705
|
-
const size =
|
|
1102
|
+
const size = cssLengthToPx(props.size) ?? 20;
|
|
706
1103
|
if (width === null) width = size;
|
|
707
1104
|
if (height === null) height = size;
|
|
708
1105
|
} else if (type === "switch" || type === "toggle") {
|
|
@@ -715,28 +1112,28 @@ function computeLayoutFallback(
|
|
|
715
1112
|
if (width === null) width = 200;
|
|
716
1113
|
if (height === null) height = 8;
|
|
717
1114
|
} else if (type === "spinner" || type === "loading") {
|
|
718
|
-
const size =
|
|
1115
|
+
const size = cssLengthToPx(props.size) ?? 24;
|
|
719
1116
|
if (width === null) width = size;
|
|
720
1117
|
if (height === null) height = size;
|
|
721
1118
|
} else if (type === "badge") {
|
|
722
1119
|
if (width === null) width = 20;
|
|
723
1120
|
if (height === null) height = 20;
|
|
724
1121
|
} else if (type === "avatar") {
|
|
725
|
-
const size =
|
|
1122
|
+
const size = cssLengthToPx(props.size) ?? 40;
|
|
726
1123
|
if (width === null) width = size;
|
|
727
1124
|
if (height === null) height = size;
|
|
728
1125
|
} else if (type === "icon") {
|
|
729
|
-
const size =
|
|
1126
|
+
const size = cssLengthToPx(props.size) ?? 24;
|
|
730
1127
|
if (width === null) width = size;
|
|
731
1128
|
if (height === null) height = size;
|
|
732
1129
|
}
|
|
733
1130
|
|
|
734
|
-
if (node.type === "text" && node.props[0]) {
|
|
1131
|
+
if (node.type.toLowerCase() === "text" && node.props[0]) {
|
|
735
1132
|
const text = String(node.props[0] || "");
|
|
736
|
-
const fontSize =
|
|
1133
|
+
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
737
1134
|
const fontWeight = props.fontWeight || "normal";
|
|
738
1135
|
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
739
|
-
const lineHeight =
|
|
1136
|
+
const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
|
|
740
1137
|
|
|
741
1138
|
const maxWidth = width || availableAfterMargin.width - padding.left - padding.right;
|
|
742
1139
|
const metrics = measureText(ctx, text, { fontSize, fontWeight, fontFamily, lineHeight }, maxWidth);
|
|
@@ -801,7 +1198,7 @@ function layoutChildrenFallback(ctx: CanvasRenderingContext2D, parent: VirtualNo
|
|
|
801
1198
|
const alignItems = isColumn
|
|
802
1199
|
? (props.horizontalAlignment || "flex-start")
|
|
803
1200
|
: (props.verticalAlignment || "flex-start");
|
|
804
|
-
const gap =
|
|
1201
|
+
const gap = cssLengthToPx(props.gap) ?? 0;
|
|
805
1202
|
|
|
806
1203
|
const availableWidth = layout.contentWidth;
|
|
807
1204
|
const availableHeight = layout.contentHeight;
|
|
@@ -819,7 +1216,11 @@ function layoutChildrenFallback(ctx: CanvasRenderingContext2D, parent: VirtualNo
|
|
|
819
1216
|
|
|
820
1217
|
for (const child of parent.children) {
|
|
821
1218
|
const flexGrow = parseFloat(child.props.flexGrow) || parseFloat(child.props.flex) || 0;
|
|
822
|
-
const
|
|
1219
|
+
const childType = child.type.toLowerCase();
|
|
1220
|
+
const defaultShrink = INTRINSIC_SIZED.has(childType) ? 0 : 1;
|
|
1221
|
+
const flexShrink = child.props.flexShrink !== undefined
|
|
1222
|
+
? parseFloat(child.props.flexShrink)
|
|
1223
|
+
: defaultShrink;
|
|
823
1224
|
const flexBasis = parseSize(child.props.flexBasis);
|
|
824
1225
|
|
|
825
1226
|
computeLayoutFallback(ctx, child, availableWidth, availableHeight, 0, 0);
|