@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/dist/canvas/layout.js
CHANGED
|
@@ -5,8 +5,26 @@
|
|
|
5
5
|
* layout. Falls back to a basic flexbox implementation in environments
|
|
6
6
|
* without WASM support (e.g. test runners).
|
|
7
7
|
*/
|
|
8
|
-
import { parseSpacing, parseSize } from "./utils.js";
|
|
8
|
+
import { parseSpacing, parseSize, cssLengthToPx, cssLengthToDimension } from "./utils.js";
|
|
9
9
|
import { measureText } from "./text.js";
|
|
10
|
+
import { getImageNaturalAspect } from "./paint.js";
|
|
11
|
+
/**
|
|
12
|
+
* Components whose size is intrinsic to the component itself (icon, avatar,
|
|
13
|
+
* checkbox, etc.) should not be shrunk by their flex parent. Without this,
|
|
14
|
+
* an icon inside a transparent button with no width collapses to 0×0 in
|
|
15
|
+
* tight cross-axis containers.
|
|
16
|
+
*/
|
|
17
|
+
const INTRINSIC_SIZED = new Set([
|
|
18
|
+
"icon",
|
|
19
|
+
"avatar",
|
|
20
|
+
"badge",
|
|
21
|
+
"checkbox",
|
|
22
|
+
"radio",
|
|
23
|
+
"switch",
|
|
24
|
+
"toggle",
|
|
25
|
+
"spinner",
|
|
26
|
+
"loading",
|
|
27
|
+
]);
|
|
10
28
|
// Taffy imports — loaded lazily to avoid hard failure when WASM unavailable
|
|
11
29
|
let taffy = null;
|
|
12
30
|
let taffyReady = false;
|
|
@@ -41,54 +59,119 @@ export async function initTaffyLayout() {
|
|
|
41
59
|
// ---------------------------------------------------------------------------
|
|
42
60
|
/** Parse a Hypen prop value into a Taffy Dimension ("auto" | number | "N%") */
|
|
43
61
|
function toDimension(value) {
|
|
44
|
-
|
|
45
|
-
return "auto";
|
|
46
|
-
if (typeof value === "number")
|
|
47
|
-
return value;
|
|
48
|
-
if (typeof value === "string") {
|
|
49
|
-
if (value.endsWith("%"))
|
|
50
|
-
return value;
|
|
51
|
-
const n = parseFloat(value);
|
|
52
|
-
return isNaN(n) ? "auto" : n;
|
|
53
|
-
}
|
|
54
|
-
return "auto";
|
|
62
|
+
return cssLengthToDimension(value);
|
|
55
63
|
}
|
|
56
64
|
function toLengthPct(value) {
|
|
65
|
+
const d = cssLengthToDimension(value);
|
|
66
|
+
return d === "auto" ? 0 : d;
|
|
67
|
+
}
|
|
68
|
+
function toLengthPctAuto(value) {
|
|
69
|
+
return cssLengthToDimension(value);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Parse an aspect-ratio prop. Accepts a number, `"W / H"`, or `"W"`.
|
|
73
|
+
* Returns the width/height ratio, or null when unparseable.
|
|
74
|
+
*/
|
|
75
|
+
function parseAspectRatio(value) {
|
|
57
76
|
if (typeof value === "number")
|
|
58
|
-
return value;
|
|
59
|
-
if (typeof value
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
return value > 0 ? value : null;
|
|
78
|
+
if (typeof value !== "string")
|
|
79
|
+
return null;
|
|
80
|
+
const s = value.trim();
|
|
81
|
+
const slash = s.indexOf("/");
|
|
82
|
+
if (slash !== -1) {
|
|
83
|
+
const w = parseFloat(s.slice(0, slash));
|
|
84
|
+
const h = parseFloat(s.slice(slash + 1));
|
|
85
|
+
if (isFinite(w) && isFinite(h) && h !== 0)
|
|
86
|
+
return w / h;
|
|
87
|
+
return null;
|
|
64
88
|
}
|
|
65
|
-
|
|
89
|
+
const n = parseFloat(s);
|
|
90
|
+
return isFinite(n) && n > 0 ? n : null;
|
|
66
91
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Read whether a node is a scrollable container, and on which axes. We can't
|
|
94
|
+
* import from `scroll.ts` here without creating a circular dependency, so
|
|
95
|
+
* inline the parsing — keep it in sync with `getScrollAxes`.
|
|
96
|
+
*/
|
|
97
|
+
function readScrollAxes(props) {
|
|
98
|
+
const overflow = props.overflow;
|
|
99
|
+
if (overflow === "scroll" || overflow === "auto")
|
|
100
|
+
return { x: true, y: true };
|
|
101
|
+
const scrollable = props.scrollable;
|
|
102
|
+
if (scrollable === true)
|
|
103
|
+
return { x: true, y: true };
|
|
104
|
+
if (scrollable === "horizontal")
|
|
105
|
+
return { x: true, y: false };
|
|
106
|
+
if (scrollable === "vertical")
|
|
107
|
+
return { x: false, y: true };
|
|
108
|
+
if (scrollable === "both")
|
|
109
|
+
return { x: true, y: true };
|
|
110
|
+
return { x: false, y: false };
|
|
71
111
|
}
|
|
72
112
|
/**
|
|
73
113
|
* Build a Taffy Style from a VirtualNode's props. This centralises the
|
|
74
114
|
* mapping between Hypen's declarative property names and CSS-level concepts.
|
|
115
|
+
*
|
|
116
|
+
* `parent` is the immediate parent VirtualNode (or null for the root) so
|
|
117
|
+
* children of a Stack can place themselves in the single grid cell, and
|
|
118
|
+
* children of a scrollable container can opt out of flex-shrink along the
|
|
119
|
+
* scroll axis (otherwise a horizontal strip's items collapse to the
|
|
120
|
+
* strip's width instead of overflowing).
|
|
75
121
|
*/
|
|
76
|
-
function buildTaffyStyle(node) {
|
|
122
|
+
function buildTaffyStyle(node, parent = null) {
|
|
77
123
|
const T = taffy;
|
|
78
124
|
const props = node.props;
|
|
79
125
|
const type = node.type.toLowerCase();
|
|
126
|
+
const parentType = parent?.type.toLowerCase() ?? null;
|
|
127
|
+
const parentScrollAxes = parent ? readScrollAxes(parent.props) : { x: false, y: false };
|
|
80
128
|
const style = new T.Style();
|
|
81
129
|
// --- Display / direction ---------------------------------------------------
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
130
|
+
// Stack overlays children in a single grid cell — matches the DOM
|
|
131
|
+
// renderer's `grid-template-areas: "stack"` strategy. Children get
|
|
132
|
+
// `gridColumn: 1 / 2; gridRow: 1 / 2` further down.
|
|
133
|
+
const isStack = type === "stack";
|
|
134
|
+
// `List` is the DSL's vertical-stack iterator (see dom/components/list.ts
|
|
135
|
+
// — `flex-direction: column` is its default). Match that here so feeds
|
|
136
|
+
// like Notifications stack their rows vertically instead of flowing
|
|
137
|
+
// sideways. The `direction` prop can still flip it to row.
|
|
138
|
+
const isListColumn = type === "list" && props.direction !== "horizontal";
|
|
139
|
+
const isColumn = !isStack && (type === "column" || isListColumn || props.flexDirection === "column");
|
|
140
|
+
const isGrid = !isStack && (type === "grid" || props.display === "grid");
|
|
141
|
+
if (isStack) {
|
|
142
|
+
style.display = T.Display.Grid;
|
|
143
|
+
// Auto tracks: the single cell sizes to fit the largest child instead
|
|
144
|
+
// of `1fr` (which would inflate the Stack to its parent's full size and
|
|
145
|
+
// stretch the children to match — see PARITY.md).
|
|
146
|
+
style.gridTemplateColumns = [{ min: "auto", max: "auto" }];
|
|
147
|
+
style.gridTemplateRows = [{ min: "auto", max: "auto" }];
|
|
148
|
+
// Default top-left matches the DOM Stack (`justify-items: start;
|
|
149
|
+
// align-items: start`) and iOS/Android ZStack defaults — also keeps
|
|
150
|
+
// explicitly-sized children at their declared size instead of letting
|
|
151
|
+
// Taffy's default `stretch` overwrite the box.
|
|
152
|
+
style.justifyItems = props.horizontalAlignment
|
|
153
|
+
? mapAlign(T, props.horizontalAlignment)
|
|
154
|
+
: T.AlignItems.Start;
|
|
155
|
+
style.alignItems = props.verticalAlignment
|
|
156
|
+
? mapAlign(T, props.verticalAlignment)
|
|
157
|
+
: T.AlignItems.Start;
|
|
158
|
+
}
|
|
159
|
+
else if (isGrid) {
|
|
85
160
|
style.display = T.Display.Grid;
|
|
86
161
|
// --- Grid template tracks ------------------------------------------------
|
|
87
|
-
|
|
88
|
-
|
|
162
|
+
// The DSL's `.gridColumns(N)` / `.gridRows(N)` applicators land on
|
|
163
|
+
// props with the applicator's own name (`gridColumns`, `gridRows`).
|
|
164
|
+
// The DOM renderer also accepts the older shorthand `columns`/`rows`
|
|
165
|
+
// and the spec-name `gridTemplateColumns`/`gridTemplateRows`. A bare
|
|
166
|
+
// number N expands to N equal `1fr` tracks — same logic as
|
|
167
|
+
// `dom/applicators/advanced-layout.ts`.
|
|
168
|
+
const colsProp = props.gridTemplateColumns ?? props.gridColumns ?? props.columns;
|
|
169
|
+
const rowsProp = props.gridTemplateRows ?? props.gridRows ?? props.rows;
|
|
170
|
+
if (colsProp !== undefined) {
|
|
171
|
+
style.gridTemplateColumns = parseGridTemplate(colsProp);
|
|
89
172
|
}
|
|
90
|
-
if (
|
|
91
|
-
style.gridTemplateRows = parseGridTemplate(
|
|
173
|
+
if (rowsProp !== undefined) {
|
|
174
|
+
style.gridTemplateRows = parseGridTemplate(rowsProp);
|
|
92
175
|
}
|
|
93
176
|
// --- Grid auto flow ------------------------------------------------------
|
|
94
177
|
if (props.gridAutoFlow) {
|
|
@@ -101,6 +184,13 @@ function buildTaffyStyle(node) {
|
|
|
101
184
|
if (props.gridAutoRows) {
|
|
102
185
|
style.gridAutoRows = parseTrackSizingList(props.gridAutoRows);
|
|
103
186
|
}
|
|
187
|
+
else if (typeof props.__autoRowsPx === "number") {
|
|
188
|
+
// Set by `annotateCollapsedAspectGrids` after a first layout pass —
|
|
189
|
+
// pin implicit rows to the height we want each cell to be (column
|
|
190
|
+
// width / aspectRatio), so `aspect-square` images don't overlap.
|
|
191
|
+
const px = props.__autoRowsPx;
|
|
192
|
+
style.gridAutoRows = [{ min: px, max: px }];
|
|
193
|
+
}
|
|
104
194
|
// --- Grid template areas -------------------------------------------------
|
|
105
195
|
if (props.gridTemplateAreas) {
|
|
106
196
|
style.gridTemplateAreas = parseGridTemplateAreas(props.gridTemplateAreas);
|
|
@@ -109,7 +199,15 @@ function buildTaffyStyle(node) {
|
|
|
109
199
|
else {
|
|
110
200
|
style.display = T.Display.Flex;
|
|
111
201
|
}
|
|
112
|
-
|
|
202
|
+
if (!isStack) {
|
|
203
|
+
style.flexDirection = isColumn ? T.FlexDirection.Column : T.FlexDirection.Row;
|
|
204
|
+
}
|
|
205
|
+
// --- Stack child placement -------------------------------------------------
|
|
206
|
+
// Place every child of a Stack in the single 1×1 cell so they overlap.
|
|
207
|
+
if (parentType === "stack") {
|
|
208
|
+
style.gridColumn = { start: 1, end: 2 };
|
|
209
|
+
style.gridRow = { start: 1, end: 2 };
|
|
210
|
+
}
|
|
113
211
|
// --- Grid child placement (applies regardless of parent display) ----------
|
|
114
212
|
if (props.gridColumn) {
|
|
115
213
|
style.gridColumn = parseGridLine(props.gridColumn);
|
|
@@ -130,25 +228,53 @@ function buildTaffyStyle(node) {
|
|
|
130
228
|
style.gridRowEnd = parseGridPlacement(props.gridRowEnd);
|
|
131
229
|
}
|
|
132
230
|
// --- Flex properties -------------------------------------------------------
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
231
|
+
// The `flex: <n>` shorthand (Tailwind's `flex-1` is `flex: 1 1 0%`) sets
|
|
232
|
+
// grow + shrink + basis at once. Without honouring the basis here, an
|
|
233
|
+
// overflowing flex item starts at its intrinsic content size (e.g. a
|
|
234
|
+
// huge feed) and Taffy redistributes from there, which under-allocates
|
|
235
|
+
// the BottomNav and pushes it off-screen on routes with tall content.
|
|
236
|
+
const flexShorthand = props.flex !== undefined ? parseFloat(props.flex) : NaN;
|
|
237
|
+
style.flexGrow = parseFloat(props.flexGrow) || (Number.isFinite(flexShorthand) ? flexShorthand : 0);
|
|
238
|
+
// Default flex-shrink is 0 when:
|
|
239
|
+
// - the component has an intrinsic size (icon, avatar, …) — otherwise it
|
|
240
|
+
// collapses to 0×0 in a tight cross-axis container, e.g. an icon inside
|
|
241
|
+
// a transparent button with only `padding.0`.
|
|
242
|
+
// - the parent is a scrollable container along the main axis — otherwise
|
|
243
|
+
// a horizontal strip's items shrink to fit instead of overflowing,
|
|
244
|
+
// making the strip pointless.
|
|
245
|
+
const parentIsRow = parentType !== "column" && parentType !== "stack" && parentType !== null;
|
|
246
|
+
const parentIsCol = parentType === "column";
|
|
247
|
+
const parentMainAxisScrolls = (parentIsRow && parentScrollAxes.x) || (parentIsCol && parentScrollAxes.y);
|
|
248
|
+
const defaultShrink = INTRINSIC_SIZED.has(type) || parentMainAxisScrolls ? 0 : 1;
|
|
249
|
+
style.flexShrink = props.flexShrink !== undefined ? parseFloat(props.flexShrink) : defaultShrink;
|
|
250
|
+
if (props.flexBasis !== undefined) {
|
|
136
251
|
style.flexBasis = toDimension(props.flexBasis);
|
|
252
|
+
}
|
|
253
|
+
else if (Number.isFinite(flexShorthand)) {
|
|
254
|
+
// `flex: 1` shorthand → basis 0%. Pinning basis is the load-bearing
|
|
255
|
+
// bit: an item with `flex: 1 1 auto` starts at its content size and
|
|
256
|
+
// distributes from there, leaving siblings in a tight container with
|
|
257
|
+
// negative space; `flex: 1 1 0%` starts at zero and grows up.
|
|
258
|
+
style.flexBasis = 0;
|
|
259
|
+
}
|
|
137
260
|
if (props.flexWrap === "wrap")
|
|
138
261
|
style.flexWrap = T.FlexWrap.Wrap;
|
|
139
262
|
else if (props.flexWrap === "wrap-reverse")
|
|
140
263
|
style.flexWrap = T.FlexWrap.WrapReverse;
|
|
141
264
|
// --- Alignment (Hypen uses verticalAlignment / horizontalAlignment) --------
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
265
|
+
// Stack handles its own alignment via justifyItems/alignItems above.
|
|
266
|
+
if (!isStack) {
|
|
267
|
+
const justifyRaw = isColumn
|
|
268
|
+
? (props.verticalAlignment || props.justifyContent)
|
|
269
|
+
: (props.horizontalAlignment || props.justifyContent);
|
|
270
|
+
const alignRaw = isColumn
|
|
271
|
+
? (props.horizontalAlignment || props.alignItems)
|
|
272
|
+
: (props.verticalAlignment || props.alignItems);
|
|
273
|
+
if (justifyRaw)
|
|
274
|
+
style.justifyContent = mapJustify(T, justifyRaw);
|
|
275
|
+
if (alignRaw)
|
|
276
|
+
style.alignItems = mapAlign(T, alignRaw);
|
|
277
|
+
}
|
|
152
278
|
// --- Size ------------------------------------------------------------------
|
|
153
279
|
const w = parseSize(props.width);
|
|
154
280
|
const h = parseSize(props.height);
|
|
@@ -161,7 +287,7 @@ function buildTaffyStyle(node) {
|
|
|
161
287
|
}
|
|
162
288
|
else if (type === "divider" || type === "separator") {
|
|
163
289
|
const orientation = props.orientation || "horizontal";
|
|
164
|
-
const thickness =
|
|
290
|
+
const thickness = cssLengthToPx(props.thickness) ?? 1;
|
|
165
291
|
if (orientation === "vertical") {
|
|
166
292
|
style.size = { width: w ?? thickness, height: h !== null ? h : "100%" };
|
|
167
293
|
}
|
|
@@ -170,7 +296,7 @@ function buildTaffyStyle(node) {
|
|
|
170
296
|
}
|
|
171
297
|
}
|
|
172
298
|
else if (type === "checkbox" || type === "radio") {
|
|
173
|
-
const sz =
|
|
299
|
+
const sz = cssLengthToPx(props.size) ?? 20;
|
|
174
300
|
style.size = { width: w ?? sz, height: h ?? sz };
|
|
175
301
|
}
|
|
176
302
|
else if (type === "switch" || type === "toggle") {
|
|
@@ -183,30 +309,71 @@ function buildTaffyStyle(node) {
|
|
|
183
309
|
style.size = { width: w ?? 200, height: h ?? 8 };
|
|
184
310
|
}
|
|
185
311
|
else if (type === "spinner" || type === "loading") {
|
|
186
|
-
const sz =
|
|
312
|
+
const sz = cssLengthToPx(props.size) ?? 24;
|
|
187
313
|
style.size = { width: w ?? sz, height: h ?? sz };
|
|
188
314
|
}
|
|
189
315
|
else if (type === "badge") {
|
|
190
316
|
style.size = { width: w ?? 20, height: h ?? 20 };
|
|
191
317
|
}
|
|
192
318
|
else if (type === "avatar") {
|
|
193
|
-
const sz =
|
|
319
|
+
const sz = cssLengthToPx(props.size) ?? 40;
|
|
194
320
|
style.size = { width: w ?? sz, height: h ?? sz };
|
|
195
321
|
}
|
|
196
322
|
else if (type === "icon") {
|
|
197
|
-
const sz =
|
|
323
|
+
const sz = cssLengthToPx(props.size) ?? 24;
|
|
198
324
|
style.size = { width: w ?? sz, height: h ?? sz };
|
|
199
325
|
}
|
|
326
|
+
else if (type === "input" || type === "textarea" || type === "select") {
|
|
327
|
+
// Form controls have no painted children, so without an intrinsic
|
|
328
|
+
// height they collapse to padding only (the social Search bar
|
|
329
|
+
// rendered as a 4-tall pill instead of the ~36 the DOM produces).
|
|
330
|
+
// Match the DOM box: outer = line-height(s) + padding + border.
|
|
331
|
+
// Taffy defaults to `box-sizing: border-box`, so we add the
|
|
332
|
+
// padding/border ourselves into `size.height`.
|
|
333
|
+
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
334
|
+
const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.5;
|
|
335
|
+
const minRows = type === "textarea" ? Math.max(1, Number(props.rows) || 3) : 1;
|
|
336
|
+
const padTop = cssLengthToPx(props.paddingTop ?? props.padding) ?? 0;
|
|
337
|
+
const padBottom = cssLengthToPx(props.paddingBottom ?? props.padding) ?? 0;
|
|
338
|
+
const borderTop = cssLengthToPx(props.borderTopWidth ?? props.borderWidth) ?? 0;
|
|
339
|
+
const borderBottom = cssLengthToPx(props.borderBottomWidth ?? props.borderWidth) ?? 0;
|
|
340
|
+
const intrinsicH = lineHeight * minRows + padTop + padBottom + borderTop + borderBottom;
|
|
341
|
+
style.size = { width: toDimension(props.width), height: h ?? intrinsicH };
|
|
342
|
+
}
|
|
200
343
|
else {
|
|
201
|
-
|
|
344
|
+
// Children of a Grid that ask for `width: 100%` actually mean "fill
|
|
345
|
+
// the cell" — that's the DOM behavior (`grid > * { justify-self:
|
|
346
|
+
// stretch }`). Taffy handles this via the grid item's default
|
|
347
|
+
// stretch alignment when the size is auto, but a literal 100% creates
|
|
348
|
+
// a circular dependency with track sizing (the cell wants to be 1fr
|
|
349
|
+
// of the grid, the item wants to be 100% of the cell, and Taffy
|
|
350
|
+
// collapses both to the item's max-content). Strip the 100% on grid
|
|
351
|
+
// children so the Taffy grid stretch wins. Same for height.
|
|
352
|
+
const parentIsGridLayout = parent &&
|
|
353
|
+
(parent.type.toLowerCase() === "grid" || parent.props.display === "grid");
|
|
354
|
+
const widthIn = parentIsGridLayout && props.width === "100%" ? undefined : props.width;
|
|
355
|
+
const heightIn = parentIsGridLayout && props.height === "100%" ? undefined : props.height;
|
|
356
|
+
style.size = { width: toDimension(widthIn), height: toDimension(heightIn) };
|
|
202
357
|
}
|
|
203
358
|
// --- Min / Max constraints -------------------------------------------------
|
|
359
|
+
// Default `min-width: 0` for children of a Grid container. CSS makes
|
|
360
|
+
// grid items default to `min-width: auto` (= min-content) which prevents
|
|
361
|
+
// them shrinking below their intrinsic min. For an Image with
|
|
362
|
+
// `aspect-ratio: 1`, that "min" is the image's natural max-content —
|
|
363
|
+
// so the grid track sizing collapses (one item ends up 360px wide and
|
|
364
|
+
// squashes its row). Explicitly setting min: 0 is the standard CSS
|
|
365
|
+
// workaround and matches the DOM grid's `justify-self: stretch` shape.
|
|
366
|
+
const parentIsGrid = parent &&
|
|
367
|
+
(parent.type.toLowerCase() === "grid" || parent.props.display === "grid");
|
|
204
368
|
if (props.minWidth !== undefined || props.minHeight !== undefined) {
|
|
205
369
|
style.minSize = {
|
|
206
370
|
width: toDimension(props.minWidth),
|
|
207
371
|
height: toDimension(props.minHeight),
|
|
208
372
|
};
|
|
209
373
|
}
|
|
374
|
+
else if (parentIsGrid) {
|
|
375
|
+
style.minSize = { width: 0, height: 0 };
|
|
376
|
+
}
|
|
210
377
|
if (props.maxWidth !== undefined || props.maxHeight !== undefined) {
|
|
211
378
|
style.maxSize = {
|
|
212
379
|
width: toDimension(props.maxWidth),
|
|
@@ -216,35 +383,71 @@ function buildTaffyStyle(node) {
|
|
|
216
383
|
// --- Margin ----------------------------------------------------------------
|
|
217
384
|
const m = parseSpacing(props.margin || 0);
|
|
218
385
|
if (props.marginTop !== undefined)
|
|
219
|
-
m.top =
|
|
386
|
+
m.top = cssLengthToPx(props.marginTop) ?? 0;
|
|
220
387
|
if (props.marginRight !== undefined)
|
|
221
|
-
m.right =
|
|
388
|
+
m.right = cssLengthToPx(props.marginRight) ?? 0;
|
|
222
389
|
if (props.marginBottom !== undefined)
|
|
223
|
-
m.bottom =
|
|
390
|
+
m.bottom = cssLengthToPx(props.marginBottom) ?? 0;
|
|
224
391
|
if (props.marginLeft !== undefined)
|
|
225
|
-
m.left =
|
|
392
|
+
m.left = cssLengthToPx(props.marginLeft) ?? 0;
|
|
226
393
|
style.margin = { top: m.top, right: m.right, bottom: m.bottom, left: m.left };
|
|
227
394
|
// --- Padding ---------------------------------------------------------------
|
|
228
395
|
const p = parseSpacing(props.padding || 0);
|
|
229
396
|
if (props.paddingTop !== undefined)
|
|
230
|
-
p.top =
|
|
397
|
+
p.top = cssLengthToPx(props.paddingTop) ?? 0;
|
|
231
398
|
if (props.paddingRight !== undefined)
|
|
232
|
-
p.right =
|
|
399
|
+
p.right = cssLengthToPx(props.paddingRight) ?? 0;
|
|
233
400
|
if (props.paddingBottom !== undefined)
|
|
234
|
-
p.bottom =
|
|
401
|
+
p.bottom = cssLengthToPx(props.paddingBottom) ?? 0;
|
|
235
402
|
if (props.paddingLeft !== undefined)
|
|
236
|
-
p.left =
|
|
403
|
+
p.left = cssLengthToPx(props.paddingLeft) ?? 0;
|
|
237
404
|
style.padding = { top: p.top, right: p.right, bottom: p.bottom, left: p.left };
|
|
238
405
|
// --- Border ----------------------------------------------------------------
|
|
239
|
-
const bw =
|
|
406
|
+
const bw = cssLengthToPx(props.borderWidth) ?? 0;
|
|
240
407
|
if (bw > 0) {
|
|
241
408
|
style.border = { top: bw, right: bw, bottom: bw, left: bw };
|
|
242
409
|
}
|
|
243
410
|
// --- Gap -------------------------------------------------------------------
|
|
244
|
-
const gap =
|
|
411
|
+
const gap = cssLengthToPx(props.gap) ?? 0;
|
|
245
412
|
if (gap > 0) {
|
|
246
413
|
style.gap = { width: gap, height: gap };
|
|
247
414
|
}
|
|
415
|
+
// --- Aspect ratio ----------------------------------------------------------
|
|
416
|
+
// Engine emits values like `"1"` or `16/9`. Taffy expects a number.
|
|
417
|
+
if (props.aspectRatio !== undefined) {
|
|
418
|
+
const ar = parseAspectRatio(props.aspectRatio);
|
|
419
|
+
if (ar !== null)
|
|
420
|
+
style.aspectRatio = ar;
|
|
421
|
+
}
|
|
422
|
+
else if (type === "image") {
|
|
423
|
+
// Implicit aspect ratio from the decoded image's intrinsic size — only
|
|
424
|
+
// useful when the caller fixes one dimension and lets the other follow.
|
|
425
|
+
// Without this, an image with `width: 100` stretches to whatever Taffy
|
|
426
|
+
// picks for the height (the parent's full height under the default
|
|
427
|
+
// `align-items: stretch` of a flex row).
|
|
428
|
+
//
|
|
429
|
+
// We can't rely on Taffy's `aspectRatio` alone: with `align-items:
|
|
430
|
+
// stretch`, the cross-axis size is forced before aspect-ratio is
|
|
431
|
+
// applied. Pin both dimensions explicitly instead.
|
|
432
|
+
const wParsed = parseSize(props.width);
|
|
433
|
+
const hParsed = parseSize(props.height);
|
|
434
|
+
if ((wParsed === null) !== (hParsed === null)) {
|
|
435
|
+
const src = props.src ?? props[0];
|
|
436
|
+
if (typeof src === "string") {
|
|
437
|
+
const intrinsic = getImageNaturalAspect(src);
|
|
438
|
+
if (intrinsic !== null) {
|
|
439
|
+
if (wParsed !== null) {
|
|
440
|
+
style.size = { width: wParsed, height: wParsed / intrinsic };
|
|
441
|
+
style.aspectRatio = intrinsic;
|
|
442
|
+
}
|
|
443
|
+
else if (hParsed !== null) {
|
|
444
|
+
style.size = { width: hParsed * intrinsic, height: hParsed };
|
|
445
|
+
style.aspectRatio = intrinsic;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
248
451
|
// --- Position (absolute) ---------------------------------------------------
|
|
249
452
|
if (props.position === "absolute") {
|
|
250
453
|
style.position = T.Position.Absolute;
|
|
@@ -257,6 +460,28 @@ function buildTaffyStyle(node) {
|
|
|
257
460
|
if (props.bottom !== undefined)
|
|
258
461
|
style.bottom = toLengthPctAuto(props.bottom);
|
|
259
462
|
}
|
|
463
|
+
// --- Overflow (scrollable containers) -------------------------------------
|
|
464
|
+
// For scroll containers we need a careful balance:
|
|
465
|
+
// - Children must lay out at their full intrinsic positions so
|
|
466
|
+
// `ScrollManager.updateScrollBounds` can compute scrollHeight (a feed
|
|
467
|
+
// with overflow:Hidden gets its children clamped and the page won't
|
|
468
|
+
// scroll).
|
|
469
|
+
// - But the CONTAINER itself must not bubble its overflowing min-content
|
|
470
|
+
// up into its parent (a horizontally-scrollable Stories row with 5+
|
|
471
|
+
// w-24 items inflated the whole feed to 607px on a 470px canvas).
|
|
472
|
+
//
|
|
473
|
+
// Setting overflow to `Scroll` on the relevant axis tells Taffy to treat
|
|
474
|
+
// this container as a scroll port — children flow at intrinsic sizes but
|
|
475
|
+
// the container's own contribution to the parent stays bounded. The
|
|
476
|
+
// non-scroll axis stays Visible so siblings still see the right content
|
|
477
|
+
// size when they need to.
|
|
478
|
+
const ownAxes = readScrollAxes(props);
|
|
479
|
+
if (ownAxes.x || ownAxes.y) {
|
|
480
|
+
style.overflow = {
|
|
481
|
+
x: ownAxes.x ? T.Overflow.Scroll : T.Overflow.Visible,
|
|
482
|
+
y: ownAxes.y ? T.Overflow.Scroll : T.Overflow.Visible,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
260
485
|
return style;
|
|
261
486
|
}
|
|
262
487
|
function mapJustify(T, value) {
|
|
@@ -325,10 +550,27 @@ function parseGridTemplate(value) {
|
|
|
325
550
|
return value.map(parseTrackSizing);
|
|
326
551
|
}
|
|
327
552
|
if (typeof value === "string") {
|
|
553
|
+
// The DSL's `.gridColumns(3)` sometimes arrives here as `"3"` after
|
|
554
|
+
// stringification (engine emits numeric applicator args as strings).
|
|
555
|
+
// Treat a bare integer string the same way the DOM renderer does —
|
|
556
|
+
// expand to N equal 1fr tracks. `repeat(N, 1fr)` shorthand would work
|
|
557
|
+
// too but Taffy wants the expanded track list.
|
|
558
|
+
const trimmed = value.trim();
|
|
559
|
+
if (/^\d+$/.test(trimmed)) {
|
|
560
|
+
const n = parseInt(trimmed, 10);
|
|
561
|
+
return Array.from({ length: n }, () => ({
|
|
562
|
+
min: "auto",
|
|
563
|
+
max: "1fr",
|
|
564
|
+
}));
|
|
565
|
+
}
|
|
328
566
|
return value.split(/\s+/).filter(Boolean).map(parseTrackSizing);
|
|
329
567
|
}
|
|
330
568
|
if (typeof value === "number") {
|
|
331
|
-
|
|
569
|
+
// Bare number = column count. DOM: `repeat(N, 1fr)`.
|
|
570
|
+
return Array.from({ length: value }, () => ({
|
|
571
|
+
min: "auto",
|
|
572
|
+
max: "1fr",
|
|
573
|
+
}));
|
|
332
574
|
}
|
|
333
575
|
return [];
|
|
334
576
|
}
|
|
@@ -442,26 +684,31 @@ function parseGridTemplateAreas(value) {
|
|
|
442
684
|
* Build a TaffyTree mirroring the VirtualNode tree. Returns the root Taffy
|
|
443
685
|
* node id and a mapping from Taffy node ids → VirtualNodes.
|
|
444
686
|
*/
|
|
445
|
-
function buildTree(tree, node) {
|
|
687
|
+
function buildTree(tree, node, parent = null) {
|
|
446
688
|
const T = taffy;
|
|
447
689
|
const props = node.props;
|
|
448
|
-
const style = buildTaffyStyle(node);
|
|
449
|
-
// Text leaf nodes — create with measurement context
|
|
450
|
-
|
|
690
|
+
const style = buildTaffyStyle(node, parent);
|
|
691
|
+
// Text leaf nodes — create with measurement context. The engine emits the
|
|
692
|
+
// type capitalised (`"Text"`); compare case-insensitively so a Taffy leaf
|
|
693
|
+
// is created. Without this, every Text node was treated as an empty
|
|
694
|
+
// container with size 0×0, and downstream siblings were placed on top of
|
|
695
|
+
// each other ("1,431 likes" overlapping the caption row was the visible
|
|
696
|
+
// symptom).
|
|
697
|
+
if (node.type.toLowerCase() === "text" && props[0] && node.children.length === 0) {
|
|
451
698
|
const text = String(props[0] || "");
|
|
452
|
-
const fontSize =
|
|
699
|
+
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
453
700
|
const fontWeight = props.fontWeight || "normal";
|
|
454
701
|
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
455
|
-
const lineHeight =
|
|
702
|
+
const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
|
|
456
703
|
const p = parseSpacing(props.padding || 0);
|
|
457
704
|
if (props.paddingTop !== undefined)
|
|
458
|
-
p.top =
|
|
705
|
+
p.top = cssLengthToPx(props.paddingTop) ?? 0;
|
|
459
706
|
if (props.paddingRight !== undefined)
|
|
460
|
-
p.right =
|
|
707
|
+
p.right = cssLengthToPx(props.paddingRight) ?? 0;
|
|
461
708
|
if (props.paddingBottom !== undefined)
|
|
462
|
-
p.bottom =
|
|
709
|
+
p.bottom = cssLengthToPx(props.paddingBottom) ?? 0;
|
|
463
710
|
if (props.paddingLeft !== undefined)
|
|
464
|
-
p.left =
|
|
711
|
+
p.left = cssLengthToPx(props.paddingLeft) ?? 0;
|
|
465
712
|
const ctx = {
|
|
466
713
|
text,
|
|
467
714
|
fontSize,
|
|
@@ -473,10 +720,24 @@ function buildTree(tree, node) {
|
|
|
473
720
|
};
|
|
474
721
|
return tree.newLeafWithContext(style, ctx);
|
|
475
722
|
}
|
|
723
|
+
// Image leaves with aspect-ratio: contribute height = stretched-width /
|
|
724
|
+
// aspectRatio to grid/flex track sizing via the measure callback. The
|
|
725
|
+
// `style.aspectRatio` alone isn't enough — Taffy's grid auto-row pass asks
|
|
726
|
+
// for the leaf's max-content height, which for an image with no fixed
|
|
727
|
+
// height is 0; without the callback the row collapses.
|
|
728
|
+
if (node.type.toLowerCase() === "image" && node.children.length === 0) {
|
|
729
|
+
const ar = props.aspectRatio !== undefined
|
|
730
|
+
? parseAspectRatio(props.aspectRatio)
|
|
731
|
+
: null;
|
|
732
|
+
if (ar !== null) {
|
|
733
|
+
const ictx = { aspectRatio: ar };
|
|
734
|
+
return tree.newLeafWithContext(style, ictx);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
476
737
|
// Container nodes
|
|
477
738
|
const childIds = [];
|
|
478
739
|
for (const child of node.children) {
|
|
479
|
-
childIds.push(buildTree(tree, child));
|
|
740
|
+
childIds.push(buildTree(tree, child, node));
|
|
480
741
|
}
|
|
481
742
|
return tree.newWithChildren(style, childIds);
|
|
482
743
|
}
|
|
@@ -496,7 +757,7 @@ function writeLayout(tree, taffyId, node, parentX, parentY, isRoot = false) {
|
|
|
496
757
|
const absX = parentX + tl.x + (isRoot ? tl.marginLeft : 0);
|
|
497
758
|
const absY = parentY + tl.y + (isRoot ? tl.marginTop : 0);
|
|
498
759
|
const borderColor = node.props.borderColor || "transparent";
|
|
499
|
-
const borderRadius =
|
|
760
|
+
const borderRadius = cssLengthToPx(node.props.borderRadius) ?? 0;
|
|
500
761
|
// Taffy computes uniform border width per side; pick top as representative
|
|
501
762
|
const borderWidth = tl.borderTop;
|
|
502
763
|
const layout = {
|
|
@@ -533,6 +794,78 @@ function writeLayout(tree, taffyId, node, parentX, parentY, isRoot = false) {
|
|
|
533
794
|
writeLayout(tree, childTaffyId, node.children[i], absX, absY);
|
|
534
795
|
}
|
|
535
796
|
}
|
|
797
|
+
/**
|
|
798
|
+
* After a layout pass, detect Grid containers whose implicit row tracks
|
|
799
|
+
* collapsed against aspect-ratio image children — Taffy treats the
|
|
800
|
+
* leaves' max-content height as 0 (the measure callback can't anchor the
|
|
801
|
+
* height without a known width during track sizing), so the rows get
|
|
802
|
+
* sized to gap-only and items overflow their tracks by ~150px each.
|
|
803
|
+
*
|
|
804
|
+
* Mark the grid with a private hint (`__autoRowsPx`) carrying the height
|
|
805
|
+
* we want each implicit row to be — derived from the first item's
|
|
806
|
+
* computed width and aspect-ratio after the first pass. The next layout
|
|
807
|
+
* pass reads this hint in `buildTaffyStyle` and sets explicit
|
|
808
|
+
* `gridAutoRows`. Returns true if any grid was marked (caller re-runs
|
|
809
|
+
* layout from scratch).
|
|
810
|
+
*/
|
|
811
|
+
function annotateCollapsedAspectGrids(node) {
|
|
812
|
+
let touched = false;
|
|
813
|
+
for (const child of node.children) {
|
|
814
|
+
if (annotateCollapsedAspectGrids(child))
|
|
815
|
+
touched = true;
|
|
816
|
+
}
|
|
817
|
+
if (!node.layout)
|
|
818
|
+
return touched;
|
|
819
|
+
const t = node.type.toLowerCase();
|
|
820
|
+
const isGrid = t === "grid" || node.props.display === "grid";
|
|
821
|
+
if (!isGrid)
|
|
822
|
+
return touched;
|
|
823
|
+
// Only adjust grids that haven't already been pinned (avoid loops).
|
|
824
|
+
if (node.props.__autoRowsPx)
|
|
825
|
+
return touched;
|
|
826
|
+
let firstAspectKid = null;
|
|
827
|
+
let aspectRatio = 0;
|
|
828
|
+
for (const child of node.children) {
|
|
829
|
+
if (child.type.toLowerCase() !== "image" || !child.layout)
|
|
830
|
+
continue;
|
|
831
|
+
const ar = child.props.aspectRatio !== undefined
|
|
832
|
+
? parseAspectRatio(child.props.aspectRatio)
|
|
833
|
+
: null;
|
|
834
|
+
if (ar === null)
|
|
835
|
+
continue;
|
|
836
|
+
firstAspectKid = child;
|
|
837
|
+
aspectRatio = ar;
|
|
838
|
+
break;
|
|
839
|
+
}
|
|
840
|
+
if (!firstAspectKid)
|
|
841
|
+
return touched;
|
|
842
|
+
const itemWidth = firstAspectKid.layout.width;
|
|
843
|
+
const expectedRowHeight = itemWidth / aspectRatio;
|
|
844
|
+
if (expectedRowHeight <= 0)
|
|
845
|
+
return touched;
|
|
846
|
+
// Find a sibling image in the next row to confirm collapse: if its y
|
|
847
|
+
// delta from the first row is less than the expected row height (minus
|
|
848
|
+
// a tolerance for fractional pixels and gap rounding), the rows
|
|
849
|
+
// overlapped — this is the symptom we're fixing.
|
|
850
|
+
let nextRowKid = null;
|
|
851
|
+
for (const child of node.children) {
|
|
852
|
+
if (child === firstAspectKid)
|
|
853
|
+
continue;
|
|
854
|
+
if (!child.layout)
|
|
855
|
+
continue;
|
|
856
|
+
if (child.layout.y > firstAspectKid.layout.y + 1) {
|
|
857
|
+
nextRowKid = child;
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
if (!nextRowKid)
|
|
862
|
+
return touched;
|
|
863
|
+
const rowDelta = nextRowKid.layout.y - firstAspectKid.layout.y;
|
|
864
|
+
if (rowDelta >= expectedRowHeight - 1)
|
|
865
|
+
return touched;
|
|
866
|
+
node.props.__autoRowsPx = expectedRowHeight;
|
|
867
|
+
return true;
|
|
868
|
+
}
|
|
536
869
|
/**
|
|
537
870
|
* Compute layout for a virtual node tree using Taffy.
|
|
538
871
|
* Requires `initTaffyLayout()` to have been called and resolved.
|
|
@@ -542,8 +875,41 @@ function computeLayoutTaffy(ctx, node, availableWidth, availableHeight, x, y) {
|
|
|
542
875
|
const tree = new T.TaffyTree();
|
|
543
876
|
try {
|
|
544
877
|
const rootId = buildTree(tree, node);
|
|
878
|
+
// Pin the root to the canvas's available size when it has no explicit
|
|
879
|
+
// size of its own. Without this, Taffy lets the root grow to its
|
|
880
|
+
// min-content — and a horizontally-scrollable Stories row with 5+
|
|
881
|
+
// items at w-24 inflated the entire feed to 607px on a 470px canvas,
|
|
882
|
+
// pushing the BottomNav off-screen.
|
|
883
|
+
const rootProps = node.props;
|
|
884
|
+
const rootHasExplicitWidth = rootProps.width !== undefined || rootProps["width.0"] !== undefined;
|
|
885
|
+
const rootHasExplicitHeight = rootProps.height !== undefined || rootProps["height.0"] !== undefined;
|
|
886
|
+
if (!rootHasExplicitWidth || !rootHasExplicitHeight) {
|
|
887
|
+
const rootStyle = tree.getStyle(rootId);
|
|
888
|
+
rootStyle.size = {
|
|
889
|
+
width: rootHasExplicitWidth
|
|
890
|
+
? cssLengthToDimension(rootProps.width ?? rootProps["width.0"])
|
|
891
|
+
: availableWidth,
|
|
892
|
+
height: rootHasExplicitHeight
|
|
893
|
+
? cssLengthToDimension(rootProps.height ?? rootProps["height.0"])
|
|
894
|
+
: availableHeight,
|
|
895
|
+
};
|
|
896
|
+
tree.setStyle(rootId, rootStyle);
|
|
897
|
+
}
|
|
545
898
|
// Compute layout with a measure function for text leaf nodes
|
|
546
899
|
tree.computeLayoutWithMeasure(rootId, { width: availableWidth, height: availableHeight }, (knownDimensions, availableSpace, _nodeId, context, _style) => {
|
|
900
|
+
// Image-with-aspect-ratio leaf: derive missing dim from the other
|
|
901
|
+
// via aspectRatio so grid track sizing sees a proper max-content
|
|
902
|
+
// height. Falls back to availableSpace.width when nothing's known.
|
|
903
|
+
const ictx = context;
|
|
904
|
+
if (ictx && typeof ictx.aspectRatio === "number") {
|
|
905
|
+
const w = knownDimensions.width ??
|
|
906
|
+
(typeof availableSpace.width === "number"
|
|
907
|
+
? availableSpace.width
|
|
908
|
+
: undefined);
|
|
909
|
+
const h = knownDimensions.height ??
|
|
910
|
+
(w !== undefined ? w / ictx.aspectRatio : undefined);
|
|
911
|
+
return { width: w ?? 0, height: h ?? 0 };
|
|
912
|
+
}
|
|
547
913
|
const tctx = context;
|
|
548
914
|
if (!tctx?.text) {
|
|
549
915
|
return { width: knownDimensions.width ?? 0, height: knownDimensions.height ?? 0 };
|
|
@@ -557,9 +923,14 @@ function computeLayoutTaffy(ctx, node, availableWidth, availableHeight, x, y) {
|
|
|
557
923
|
fontFamily: tctx.fontFamily,
|
|
558
924
|
lineHeight: tctx.lineHeight,
|
|
559
925
|
}, maxWidth);
|
|
926
|
+
// `Math.ceil` the reported width: pretext returns a fractional
|
|
927
|
+
// pixel width (e.g. 141.71875 for "View all 87 comments"), Taffy
|
|
928
|
+
// gives the box exactly that, but at paint time we re-measure with
|
|
929
|
+
// a maxWidth of the integer-rounded box width and pretext then
|
|
930
|
+
// wraps because 141.71 > 141. Reserve the next whole pixel up.
|
|
560
931
|
return {
|
|
561
|
-
width: knownDimensions.width ?? (metrics.width + tctx.paddingH),
|
|
562
|
-
height: knownDimensions.height ?? (metrics.height + tctx.paddingV),
|
|
932
|
+
width: knownDimensions.width ?? (Math.ceil(metrics.width) + tctx.paddingH),
|
|
933
|
+
height: knownDimensions.height ?? (Math.ceil(metrics.height) + tctx.paddingV),
|
|
563
934
|
};
|
|
564
935
|
});
|
|
565
936
|
writeLayout(tree, rootId, node, x, y, /* isRoot */ true);
|
|
@@ -580,6 +951,14 @@ function computeLayoutTaffy(ctx, node, availableWidth, availableHeight, x, y) {
|
|
|
580
951
|
export function computeLayout(ctx, node, availableWidth, availableHeight, x = 0, y = 0) {
|
|
581
952
|
if (taffyReady && taffy) {
|
|
582
953
|
computeLayoutTaffy(ctx, node, availableWidth, availableHeight, x, y);
|
|
954
|
+
// After the first pass, Taffy's grid auto-row sizing for aspect-ratio
|
|
955
|
+
// image leaves can collapse rows (the leaf's max-content height is 0
|
|
956
|
+
// before column widths are known). Detect that pattern and re-run
|
|
957
|
+
// with explicit `gridAutoRows` derived from the now-known column
|
|
958
|
+
// width — see `annotateCollapsedAspectGrids`.
|
|
959
|
+
if (annotateCollapsedAspectGrids(node)) {
|
|
960
|
+
computeLayoutTaffy(ctx, node, availableWidth, availableHeight, x, y);
|
|
961
|
+
}
|
|
583
962
|
}
|
|
584
963
|
else {
|
|
585
964
|
computeLayoutFallback(ctx, node, availableWidth, availableHeight, x, y);
|
|
@@ -592,25 +971,25 @@ function computeLayoutFallback(ctx, node, availableWidth, availableHeight, x = 0
|
|
|
592
971
|
const props = node.props;
|
|
593
972
|
let margin = parseSpacing(props.margin || 0);
|
|
594
973
|
if (props.marginTop !== undefined)
|
|
595
|
-
margin.top =
|
|
974
|
+
margin.top = cssLengthToPx(props.marginTop) ?? 0;
|
|
596
975
|
if (props.marginRight !== undefined)
|
|
597
|
-
margin.right =
|
|
976
|
+
margin.right = cssLengthToPx(props.marginRight) ?? 0;
|
|
598
977
|
if (props.marginBottom !== undefined)
|
|
599
|
-
margin.bottom =
|
|
978
|
+
margin.bottom = cssLengthToPx(props.marginBottom) ?? 0;
|
|
600
979
|
if (props.marginLeft !== undefined)
|
|
601
|
-
margin.left =
|
|
980
|
+
margin.left = cssLengthToPx(props.marginLeft) ?? 0;
|
|
602
981
|
let padding = parseSpacing(props.padding || 0);
|
|
603
982
|
if (props.paddingTop !== undefined)
|
|
604
|
-
padding.top =
|
|
983
|
+
padding.top = cssLengthToPx(props.paddingTop) ?? 0;
|
|
605
984
|
if (props.paddingRight !== undefined)
|
|
606
|
-
padding.right =
|
|
985
|
+
padding.right = cssLengthToPx(props.paddingRight) ?? 0;
|
|
607
986
|
if (props.paddingBottom !== undefined)
|
|
608
|
-
padding.bottom =
|
|
987
|
+
padding.bottom = cssLengthToPx(props.paddingBottom) ?? 0;
|
|
609
988
|
if (props.paddingLeft !== undefined)
|
|
610
|
-
padding.left =
|
|
611
|
-
const borderWidth =
|
|
989
|
+
padding.left = cssLengthToPx(props.paddingLeft) ?? 0;
|
|
990
|
+
const borderWidth = cssLengthToPx(props.borderWidth) ?? 0;
|
|
612
991
|
const borderColor = props.borderColor || "transparent";
|
|
613
|
-
const borderRadius =
|
|
992
|
+
const borderRadius = cssLengthToPx(props.borderRadius) ?? 0;
|
|
614
993
|
const availableAfterMargin = {
|
|
615
994
|
width: availableWidth - margin.left - margin.right,
|
|
616
995
|
height: availableHeight - margin.top - margin.bottom,
|
|
@@ -632,7 +1011,7 @@ function computeLayoutFallback(ctx, node, availableWidth, availableHeight, x = 0
|
|
|
632
1011
|
}
|
|
633
1012
|
else if (type === "divider" || type === "separator") {
|
|
634
1013
|
const orientation = props.orientation || "horizontal";
|
|
635
|
-
const thickness =
|
|
1014
|
+
const thickness = cssLengthToPx(props.thickness) ?? 1;
|
|
636
1015
|
if (orientation === "vertical") {
|
|
637
1016
|
if (width === null)
|
|
638
1017
|
width = thickness;
|
|
@@ -647,7 +1026,7 @@ function computeLayoutFallback(ctx, node, availableWidth, availableHeight, x = 0
|
|
|
647
1026
|
}
|
|
648
1027
|
}
|
|
649
1028
|
else if (type === "checkbox" || type === "radio") {
|
|
650
|
-
const size =
|
|
1029
|
+
const size = cssLengthToPx(props.size) ?? 20;
|
|
651
1030
|
if (width === null)
|
|
652
1031
|
width = size;
|
|
653
1032
|
if (height === null)
|
|
@@ -672,7 +1051,7 @@ function computeLayoutFallback(ctx, node, availableWidth, availableHeight, x = 0
|
|
|
672
1051
|
height = 8;
|
|
673
1052
|
}
|
|
674
1053
|
else if (type === "spinner" || type === "loading") {
|
|
675
|
-
const size =
|
|
1054
|
+
const size = cssLengthToPx(props.size) ?? 24;
|
|
676
1055
|
if (width === null)
|
|
677
1056
|
width = size;
|
|
678
1057
|
if (height === null)
|
|
@@ -685,25 +1064,25 @@ function computeLayoutFallback(ctx, node, availableWidth, availableHeight, x = 0
|
|
|
685
1064
|
height = 20;
|
|
686
1065
|
}
|
|
687
1066
|
else if (type === "avatar") {
|
|
688
|
-
const size =
|
|
1067
|
+
const size = cssLengthToPx(props.size) ?? 40;
|
|
689
1068
|
if (width === null)
|
|
690
1069
|
width = size;
|
|
691
1070
|
if (height === null)
|
|
692
1071
|
height = size;
|
|
693
1072
|
}
|
|
694
1073
|
else if (type === "icon") {
|
|
695
|
-
const size =
|
|
1074
|
+
const size = cssLengthToPx(props.size) ?? 24;
|
|
696
1075
|
if (width === null)
|
|
697
1076
|
width = size;
|
|
698
1077
|
if (height === null)
|
|
699
1078
|
height = size;
|
|
700
1079
|
}
|
|
701
|
-
if (node.type === "text" && node.props[0]) {
|
|
1080
|
+
if (node.type.toLowerCase() === "text" && node.props[0]) {
|
|
702
1081
|
const text = String(node.props[0] || "");
|
|
703
|
-
const fontSize =
|
|
1082
|
+
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
704
1083
|
const fontWeight = props.fontWeight || "normal";
|
|
705
1084
|
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
706
|
-
const lineHeight =
|
|
1085
|
+
const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
|
|
707
1086
|
const maxWidth = width || availableAfterMargin.width - padding.left - padding.right;
|
|
708
1087
|
const metrics = measureText(ctx, text, { fontSize, fontWeight, fontFamily, lineHeight }, maxWidth);
|
|
709
1088
|
if (!width)
|
|
@@ -764,7 +1143,7 @@ function layoutChildrenFallback(ctx, parent) {
|
|
|
764
1143
|
const alignItems = isColumn
|
|
765
1144
|
? (props.horizontalAlignment || "flex-start")
|
|
766
1145
|
: (props.verticalAlignment || "flex-start");
|
|
767
|
-
const gap =
|
|
1146
|
+
const gap = cssLengthToPx(props.gap) ?? 0;
|
|
768
1147
|
const availableWidth = layout.contentWidth;
|
|
769
1148
|
const availableHeight = layout.contentHeight;
|
|
770
1149
|
const childInfo = [];
|
|
@@ -773,7 +1152,11 @@ function layoutChildrenFallback(ctx, parent) {
|
|
|
773
1152
|
let totalFlexShrink = 0;
|
|
774
1153
|
for (const child of parent.children) {
|
|
775
1154
|
const flexGrow = parseFloat(child.props.flexGrow) || parseFloat(child.props.flex) || 0;
|
|
776
|
-
const
|
|
1155
|
+
const childType = child.type.toLowerCase();
|
|
1156
|
+
const defaultShrink = INTRINSIC_SIZED.has(childType) ? 0 : 1;
|
|
1157
|
+
const flexShrink = child.props.flexShrink !== undefined
|
|
1158
|
+
? parseFloat(child.props.flexShrink)
|
|
1159
|
+
: defaultShrink;
|
|
777
1160
|
const flexBasis = parseSize(child.props.flexBasis);
|
|
778
1161
|
computeLayoutFallback(ctx, child, availableWidth, availableHeight, 0, 0);
|
|
779
1162
|
const childLayout = child.layout;
|