flexily 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -16
- package/dist/classic/layout.d.ts.map +1 -1
- package/dist/classic/layout.js +10 -1
- package/dist/classic/layout.js.map +1 -1
- package/dist/classic/node.js +1 -1
- package/dist/constants.d.ts +1 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +2 -1
- package/dist/constants.js.map +1 -1
- package/dist/index-classic.d.ts +1 -1
- package/dist/index-classic.d.ts.map +1 -1
- package/dist/index-classic.js +5 -5
- package/dist/index-classic.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/layout-helpers.d.ts +1 -4
- package/dist/layout-helpers.d.ts.map +1 -1
- package/dist/layout-helpers.js +2 -7
- package/dist/layout-helpers.js.map +1 -1
- package/dist/layout-zero.js +195 -39
- package/dist/layout-zero.js.map +1 -1
- package/dist/logger.js +2 -2
- package/dist/logger.js.map +1 -1
- package/dist/node-zero.js +1 -1
- package/dist/testing.js +4 -4
- package/dist/trace.js +1 -1
- package/dist/types.js +2 -2
- package/dist/types.js.map +1 -1
- package/dist/utils.d.ts +11 -3
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +46 -21
- package/dist/utils.js.map +1 -1
- package/package.json +11 -3
- package/src/CLAUDE.md +36 -21
- package/src/classic/layout.ts +105 -45
- package/src/classic/node.ts +60 -0
- package/src/constants.ts +2 -1
- package/src/index-classic.ts +1 -1
- package/src/index.ts +1 -1
- package/src/layout-flex-lines.ts +70 -3
- package/src/layout-helpers.ts +27 -7
- package/src/layout-stats.ts +0 -2
- package/src/layout-zero.ts +587 -160
- package/src/node-zero.ts +98 -2
- package/src/testing.ts +20 -14
- package/src/types.ts +22 -15
- package/src/utils.ts +47 -21
package/src/CLAUDE.md
CHANGED
|
@@ -16,16 +16,16 @@ Flexily is a pure-JavaScript flexbox layout engine with a Yoga-compatible API. T
|
|
|
16
16
|
│ │
|
|
17
17
|
┌──────┴──────┐ ┌──────┴───────┐
|
|
18
18
|
│ node-zero.ts│ │layout-zero.ts │
|
|
19
|
-
│ (1412 LOC) │ │ (
|
|
19
|
+
│ (1412 LOC) │ │ (2029 LOC) │
|
|
20
20
|
│ Node class │ │ layoutNode │
|
|
21
21
|
└──────┬──────┘ └──────┬────────┘
|
|
22
22
|
│ │
|
|
23
23
|
┌──────┴──────┐ ┌──────────────────┼──────────────────┐
|
|
24
24
|
│ types.ts │ │ │ │
|
|
25
25
|
│ Interfaces │ layout-helpers.ts layout-flex-lines.ts │
|
|
26
|
-
└─────────────┘ (
|
|
26
|
+
└─────────────┘ (160 LOC) (349 LOC) │
|
|
27
27
|
Edge resolution Pre-alloc arrays layout-measure.ts
|
|
28
|
-
Line breaking (
|
|
28
|
+
Line breaking (259 LOC)
|
|
29
29
|
Flex distribution measureNode
|
|
30
30
|
|
|
31
31
|
layout-traversal.ts (70 LOC) - Tree traversal (markSubtreeLayoutSeen, countNodes)
|
|
@@ -42,22 +42,22 @@ Flexily is a pure-JavaScript flexbox layout engine with a Yoga-compatible API. T
|
|
|
42
42
|
- Factory function API: `Node.create()` (no `new` in user code, though `Node` is a class internally)
|
|
43
43
|
- Yoga-compatible API surface: same method names, same constants, drop-in replacement
|
|
44
44
|
- Pure JavaScript: no WASM, no native dependencies, synchronous initialization
|
|
45
|
-
-
|
|
45
|
+
- Module-level pre-allocated arrays with save/restore for re-entrancy (measureFunc/baselineFunc may call calculateLayout on other trees)
|
|
46
46
|
|
|
47
47
|
## Source Files
|
|
48
48
|
|
|
49
49
|
| File | LOC | Role | Hot path? |
|
|
50
50
|
| ---------------------- | ----- | -------------------------------------------------------------------- | ------------------------------ |
|
|
51
|
-
| `layout-zero.ts` |
|
|
52
|
-
| `layout-helpers.ts` |
|
|
53
|
-
| `layout-flex-lines.ts` |
|
|
54
|
-
| `layout-measure.ts` |
|
|
51
|
+
| `layout-zero.ts` | 2029 | Core layout: `computeLayout()`, `layoutNode()` (11 phases) | **Yes** - most critical |
|
|
52
|
+
| `layout-helpers.ts` | 160 | Edge resolution: margins, padding, borders | **Yes** - called per edge |
|
|
53
|
+
| `layout-flex-lines.ts` | 349 | Pre-alloc arrays, `breakIntoLines()`, `distributeFlexSpaceForLine()` | **Yes** - flex distribution |
|
|
54
|
+
| `layout-measure.ts` | 259 | `measureNode()` — intrinsic sizing | **Yes** - sizing pass |
|
|
55
55
|
| `layout-traversal.ts` | 70 | Tree traversal: `markSubtreeLayoutSeen()`, `countNodes()` | Moderate |
|
|
56
|
-
| `layout-stats.ts` |
|
|
56
|
+
| `layout-stats.ts` | 41 | Debug/benchmark counters | No (counters only) |
|
|
57
57
|
| `node-zero.ts` | 1412 | Node class, tree ops, caching | **Yes** - second most critical |
|
|
58
|
-
| `types.ts` |
|
|
59
|
-
| `utils.ts` |
|
|
60
|
-
| `constants.ts` |
|
|
58
|
+
| `types.ts` | 232 | `FlexInfo`, `Style`, `Layout`, `Value` interfaces | No (types only) |
|
|
59
|
+
| `utils.ts` | 240 | `resolveValue`, `applyMinMax`, edge helpers, shared traversal stack | Yes (called frequently) |
|
|
60
|
+
| `constants.ts` | 82 | Yoga-compatible numeric constants | No |
|
|
61
61
|
| `logger.ts` | 67 | Conditional debug logger (`log.debug?.()`) | No (conditional) |
|
|
62
62
|
| `testing.ts` | 209 | `getLayout`, `diffLayouts`, `expectRelayoutMatchesFresh` | No (test only) |
|
|
63
63
|
| `classic/` | ~2900 | Allocating reference algorithm | No (debugging only) |
|
|
@@ -125,6 +125,7 @@ Single pass over children:
|
|
|
125
125
|
### Phase 7a: Estimate Line Cross Sizes
|
|
126
126
|
|
|
127
127
|
- Tentative cross-axis sizes from definite child dimensions
|
|
128
|
+
- For measureFunc children: uses `child.flex.mainSize` (from flex distribution), not parent `mainAxisSize`. This ensures text wrapping is measured at the child's actual constrained width.
|
|
128
129
|
- Auto-sized children use 0 (actual size computed in Phase 8)
|
|
129
130
|
|
|
130
131
|
### Phase 7b: Apply alignContent
|
|
@@ -146,6 +147,14 @@ The most complex phase. For each relative child:
|
|
|
146
147
|
8. Apply cross-axis alignment offset (flex-end, center, baseline)
|
|
147
148
|
9. Advance `mainPos` for next child
|
|
148
149
|
|
|
150
|
+
### Phase 9b: Re-stretch Auto-Sized Cross Axis
|
|
151
|
+
|
|
152
|
+
- When cross axis was auto (NaN) during Phase 8, `ALIGN_STRETCH` children need re-stretching to the now-known cross size
|
|
153
|
+
|
|
154
|
+
### Phase 9c: Re-align Auto-Sized Cross Axis
|
|
155
|
+
|
|
156
|
+
- When `crossAxisSize` was NaN during Phase 8, alignment offsets for `ALIGN_CENTER`, `ALIGN_FLEX_END`, and cross-axis auto margins computed NaN. Phase 9c recomputes these using the final shrink-wrapped cross size.
|
|
157
|
+
|
|
149
158
|
### Phase 9: Shrink-Wrap Auto-Sized Containers
|
|
150
159
|
|
|
151
160
|
- For containers without explicit size, compute actual used space from children
|
|
@@ -184,7 +193,7 @@ let _lineItemSpacings = new Float64Array(32) // Per-line item spacing
|
|
|
184
193
|
|
|
185
194
|
These grow dynamically if >32 lines (rare). Total memory: ~1,344 bytes (4 Float64Arrays × 256 bytes + 1 Uint16Array × 64 bytes + Array overhead).
|
|
186
195
|
|
|
187
|
-
**
|
|
196
|
+
**Re-entrancy**: A `measureFunc` or `baselineFunc` may synchronously call `calculateLayout()` on a separate tree. `enterLayout()`/`exitLayout()` in `layout-flex-lines.ts` bracket nested calls to save and restore the module-level scratch arrays, preventing corruption of the outer pass's state. Only allocates on re-entrant calls (depth > 0).
|
|
188
197
|
|
|
189
198
|
### Per-Node FlexInfo (`node.flex`)
|
|
190
199
|
|
|
@@ -218,6 +227,8 @@ interface FlexInfo {
|
|
|
218
227
|
lastAvailH
|
|
219
228
|
lastOffsetX
|
|
220
229
|
lastOffsetY
|
|
230
|
+
lastAbsX
|
|
231
|
+
lastAbsY
|
|
221
232
|
lastDir
|
|
222
233
|
layoutValid
|
|
223
234
|
}
|
|
@@ -275,12 +286,16 @@ flex.lastAvailW = availableWidth
|
|
|
275
286
|
flex.lastAvailH = availableHeight
|
|
276
287
|
flex.lastOffsetX = offsetX
|
|
277
288
|
flex.lastOffsetY = offsetY
|
|
289
|
+
flex.lastAbsX = absX
|
|
290
|
+
flex.lastAbsY = absY
|
|
278
291
|
flex.lastDir = direction
|
|
279
292
|
flex.layoutValid = true
|
|
280
293
|
```
|
|
281
294
|
|
|
282
295
|
On next call, if `layoutValid && !isDirty && same constraints`, the entire subtree is skipped. Only position delta is propagated (if offset changed).
|
|
283
296
|
|
|
297
|
+
**`absX`/`absY` must be fingerprinted** because edge-based rounding depends on absolute position: `width = round(absX + nodeWidth) - round(absX)`. A fractional shift in absX (e.g., from a sibling's width change) changes the rounded result even when availW/availH/direction are unchanged.
|
|
298
|
+
|
|
284
299
|
**`Object.is()` is required** for NaN-safe comparison. `NaN === NaN` is `false`; `Object.is(NaN, NaN)` is `true`. NaN represents "unconstrained" -- a legitimate and common constraint value.
|
|
285
300
|
|
|
286
301
|
### Invalidation Triggers
|
|
@@ -316,11 +331,11 @@ This is Yoga's algorithm. Layout positions stored in `layout.left`/`layout.top`
|
|
|
316
331
|
|
|
317
332
|
## measureNode vs layoutNode
|
|
318
333
|
|
|
319
|
-
`measureNode()` (~
|
|
334
|
+
`measureNode()` (~260 lines) is a lightweight alternative to `layoutNode()` (~1900 lines). It computes `width` and `height` but NOT `left`/`top`. Used during Phase 5 for intrinsic sizing of auto-sized container children. Save/restore of `layout.width`/`layout.height` is required around `measureNode` calls because it overwrites those fields.
|
|
320
335
|
|
|
321
|
-
## Integration: How
|
|
336
|
+
## Integration: How Silvery Uses Flexily
|
|
322
337
|
|
|
323
|
-
|
|
338
|
+
Silvery uses Flexily through an adapter layer:
|
|
324
339
|
|
|
325
340
|
1. `silvery/src/layout-engine.ts` defines the `LayoutEngine` / `LayoutNode` interfaces
|
|
326
341
|
2. `silvery/src/adapters/flexily-zero-adapter.ts` wraps `Node` in `FlexilyZeroNodeAdapter`
|
|
@@ -335,7 +350,7 @@ silvery calls `calculateLayout()` on every render. The no-change case (cursor mo
|
|
|
335
350
|
| ----------------------------------------- | ---------------------------------------- | ------------------------------------- | ---------------------------------------------- |
|
|
336
351
|
| `overflow:hidden/scroll` + `flexShrink:0` | Item expands to content (ignores parent) | Item shrinks to fit parent | 4.5: auto min-size = 0 for overflow containers |
|
|
337
352
|
| Default `flexShrink` | 0 (Yoga native default) | 0 (matches Yoga) | CSS default is 1 |
|
|
338
|
-
| Default `flexDirection` | Column |
|
|
353
|
+
| Default `flexDirection` | Column | Row (CSS default) | Row |
|
|
339
354
|
| Baseline alignment | Full spec (recursive first-child) | Simplified (no recursive propagation) | Recursive first-child |
|
|
340
355
|
|
|
341
356
|
The `flexShrink` override for overflow containers (line ~1244 in layout-zero.ts) is the most significant divergence. Without it, `overflow:hidden` children inside constrained parents balloon to content size, defeating the purpose of clipping.
|
|
@@ -362,10 +377,10 @@ Edge-based properties use 6-element arrays: `[left, top, right, bottom, start, e
|
|
|
362
377
|
|
|
363
378
|
Border widths are plain numbers (always points). Logical border slots use `NaN` as "not set" sentinel.
|
|
364
379
|
|
|
365
|
-
### Default Style
|
|
380
|
+
### Default Style
|
|
366
381
|
|
|
367
382
|
```typescript
|
|
368
|
-
flexDirection:
|
|
383
|
+
flexDirection: ROW // CSS default (diverges from Yoga's COLUMN)
|
|
369
384
|
flexShrink: 0 // CSS default is 1
|
|
370
385
|
alignItems: STRETCH // Same as CSS
|
|
371
386
|
flexBasis: AUTO // Same as CSS
|
|
@@ -451,11 +466,11 @@ cd vendor/flexily && bun run build
|
|
|
451
466
|
|
|
452
467
|
| Layer | Tests | Command | What it verifies |
|
|
453
468
|
| ------------------ | --------- | ---------------------------------------------------------------------------- | --------------------------------- |
|
|
454
|
-
| Yoga compat |
|
|
469
|
+
| Yoga compat | 44 | `bun test tests/yoga-comparison.test.ts tests/yoga-overflow-compare.test.ts` | Identical output to Yoga |
|
|
455
470
|
| Feature tests | ~110 | `bun test tests/layout.test.ts` | Each flexbox feature in isolation |
|
|
456
471
|
| **Re-layout fuzz** | **1200+** | `bun test tests/relayout-consistency.test.ts` | Incremental matches fresh |
|
|
457
472
|
| Mutation testing | 4+ | `bun scripts/mutation-test.ts` | Fuzz catches cache mutations |
|
|
458
|
-
| All tests |
|
|
473
|
+
| All tests | 1495 | `bun test` | Everything |
|
|
459
474
|
|
|
460
475
|
The fuzz tests are the most important layer. They've caught 3 distinct caching bugs that all single-pass tests missed.
|
|
461
476
|
|
package/src/classic/layout.ts
CHANGED
|
@@ -205,7 +205,10 @@ function breakIntoLines(children: ChildLayout[], mainAxisSize: number, mainGap:
|
|
|
205
205
|
let lineMainSize = 0
|
|
206
206
|
|
|
207
207
|
for (const child of children) {
|
|
208
|
-
|
|
208
|
+
// CSS spec 9.3.4: line breaking uses the "hypothetical main size" which is
|
|
209
|
+
// the flex base size clamped to min/max, not the unclamped base size.
|
|
210
|
+
const hypotheticalMainSize = Math.max(child.minMain, Math.min(child.maxMain, child.baseSize))
|
|
211
|
+
const childMainSize = hypotheticalMainSize + child.mainMargin
|
|
209
212
|
const gapIfNotFirst = currentLine.length > 0 ? mainGap : 0
|
|
210
213
|
|
|
211
214
|
// Check if child fits on current line
|
|
@@ -528,7 +531,9 @@ function layoutNode(
|
|
|
528
531
|
// This ensures children's absolute positions include parent's position offset
|
|
529
532
|
let parentPosOffsetX = 0
|
|
530
533
|
let parentPosOffsetY = 0
|
|
531
|
-
|
|
534
|
+
// CSS spec: position:static ignores insets (top/left/right/bottom).
|
|
535
|
+
// Only position:relative applies insets as offsets from normal flow position.
|
|
536
|
+
if (style.positionType === C.POSITION_TYPE_RELATIVE) {
|
|
532
537
|
const leftPos = style.position[0]
|
|
533
538
|
const topPos = style.position[1]
|
|
534
539
|
const rightPos = style.position[2]
|
|
@@ -935,6 +940,11 @@ function layoutNode(
|
|
|
935
940
|
childCross = crossDim.value
|
|
936
941
|
} else if (crossDim.unit === C.UNIT_PERCENT && !Number.isNaN(crossAxisSize)) {
|
|
937
942
|
childCross = crossAxisSize * (crossDim.value / 100)
|
|
943
|
+
} else if (childLayout.node.children.length > 0) {
|
|
944
|
+
// Auto-sized container children: Phase 5 already laid them out with
|
|
945
|
+
// layoutNode to compute baseSize. The cross-axis size is available
|
|
946
|
+
// on the child's layout (height for row, width for column).
|
|
947
|
+
childCross = isRow ? childLayout.node.layout.height : childLayout.node.layout.width
|
|
938
948
|
} else {
|
|
939
949
|
// Auto - use a default or measure. For now, use 0 and let stretch handle it.
|
|
940
950
|
childCross = 0
|
|
@@ -990,6 +1000,16 @@ function layoutNode(
|
|
|
990
1000
|
}
|
|
991
1001
|
break
|
|
992
1002
|
|
|
1003
|
+
case C.ALIGN_SPACE_EVENLY:
|
|
1004
|
+
// Equal spacing between lines and at edges
|
|
1005
|
+
if (numLines > 0) {
|
|
1006
|
+
const spaceEvenlyGap = freeSpace / (numLines + 1)
|
|
1007
|
+
for (let i = 0; i < numLines; i++) {
|
|
1008
|
+
lineCrossOffsets[i]! += spaceEvenlyGap * (i + 1)
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
break
|
|
1012
|
+
|
|
993
1013
|
case C.ALIGN_STRETCH:
|
|
994
1014
|
// Distribute extra space evenly among lines
|
|
995
1015
|
if (freeSpace > 0 && numLines > 0) {
|
|
@@ -1251,11 +1271,12 @@ function layoutNode(
|
|
|
1251
1271
|
const fractionalLeft = innerLeft + childX
|
|
1252
1272
|
const fractionalTop = innerTop + childY
|
|
1253
1273
|
|
|
1254
|
-
// Compute position offsets for RELATIVE
|
|
1274
|
+
// Compute position offsets for RELATIVE positioned children
|
|
1275
|
+
// CSS spec: position:static ignores insets; only position:relative applies them.
|
|
1255
1276
|
// These must be included in the absolute position BEFORE rounding (Yoga-compatible)
|
|
1256
1277
|
let posOffsetX = 0
|
|
1257
1278
|
let posOffsetY = 0
|
|
1258
|
-
if (childStyle.positionType === C.POSITION_TYPE_RELATIVE
|
|
1279
|
+
if (childStyle.positionType === C.POSITION_TYPE_RELATIVE) {
|
|
1259
1280
|
const relLeftPos = childStyle.position[0]
|
|
1260
1281
|
const relTopPos = childStyle.position[1]
|
|
1261
1282
|
const relRightPos = childStyle.position[2]
|
|
@@ -1469,7 +1490,7 @@ function layoutNode(
|
|
|
1469
1490
|
}
|
|
1470
1491
|
}
|
|
1471
1492
|
|
|
1472
|
-
if (crossOffset
|
|
1493
|
+
if (crossOffset !== 0) {
|
|
1473
1494
|
if (isRow) {
|
|
1474
1495
|
child.layout.top += Math.round(crossOffset)
|
|
1475
1496
|
} else {
|
|
@@ -1605,6 +1626,7 @@ function layoutNode(
|
|
|
1605
1626
|
// Content box dimensions for percentage resolution of absolute children
|
|
1606
1627
|
const absContentBoxW = absPaddingBoxW - paddingLeft - paddingRight
|
|
1607
1628
|
const absContentBoxH = absPaddingBoxH - paddingTop - paddingBottom
|
|
1629
|
+
const isRow = isRowDirection(style.flexDirection)
|
|
1608
1630
|
|
|
1609
1631
|
for (const child of absoluteChildren) {
|
|
1610
1632
|
const childStyle = child.style
|
|
@@ -1626,10 +1648,11 @@ function layoutNode(
|
|
|
1626
1648
|
const hasTop = topPos.unit !== C.UNIT_UNDEFINED
|
|
1627
1649
|
const hasBottom = bottomPos.unit !== C.UNIT_UNDEFINED
|
|
1628
1650
|
|
|
1629
|
-
|
|
1630
|
-
const
|
|
1631
|
-
const
|
|
1632
|
-
const
|
|
1651
|
+
// Yoga resolves percentage position offsets against the content box dimensions
|
|
1652
|
+
const leftOffset = resolveValue(leftPos, absContentBoxW)
|
|
1653
|
+
const topOffset = resolveValue(topPos, absContentBoxH)
|
|
1654
|
+
const rightOffset = resolveValue(rightPos, absContentBoxW)
|
|
1655
|
+
const bottomOffset = resolveValue(bottomPos, absContentBoxH)
|
|
1633
1656
|
|
|
1634
1657
|
// Calculate available size for absolute child using padding box
|
|
1635
1658
|
const contentW = absPaddingBoxW
|
|
@@ -1702,28 +1725,45 @@ function layoutNode(
|
|
|
1702
1725
|
const childHeight = child.layout.height
|
|
1703
1726
|
|
|
1704
1727
|
// Apply alignment when no explicit position set
|
|
1705
|
-
// For absolute children, align-items
|
|
1728
|
+
// For absolute children, align-items applies on cross axis, justify-content on main axis
|
|
1729
|
+
// Row: X = main axis (justifyContent), Y = cross axis (alignItems)
|
|
1730
|
+
// Column: X = cross axis (alignItems), Y = main axis (justifyContent)
|
|
1706
1731
|
if (!hasLeft && !hasRight) {
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1732
|
+
if (isRow) {
|
|
1733
|
+
// Row: X is main axis, use justifyContent
|
|
1734
|
+
const freeSpaceX = contentW - childWidth - childMarginLeft - childMarginRight
|
|
1735
|
+
switch (style.justifyContent) {
|
|
1736
|
+
case C.JUSTIFY_CENTER:
|
|
1737
|
+
childX = childMarginLeft + freeSpaceX / 2
|
|
1738
|
+
break
|
|
1739
|
+
case C.JUSTIFY_FLEX_END:
|
|
1740
|
+
childX = childMarginLeft + freeSpaceX
|
|
1741
|
+
break
|
|
1742
|
+
default: // FLEX_START
|
|
1743
|
+
childX = childMarginLeft
|
|
1744
|
+
break
|
|
1745
|
+
}
|
|
1746
|
+
} else {
|
|
1747
|
+
// Column: X is cross axis, use alignItems/alignSelf
|
|
1748
|
+
let alignment = style.alignItems
|
|
1749
|
+
if (childStyle.alignSelf !== C.ALIGN_AUTO) {
|
|
1750
|
+
alignment = childStyle.alignSelf
|
|
1751
|
+
}
|
|
1752
|
+
const freeSpaceX = contentW - childWidth - childMarginLeft - childMarginRight
|
|
1753
|
+
switch (alignment) {
|
|
1754
|
+
case C.ALIGN_CENTER:
|
|
1755
|
+
childX = childMarginLeft + freeSpaceX / 2
|
|
1756
|
+
break
|
|
1757
|
+
case C.ALIGN_FLEX_END:
|
|
1758
|
+
childX = childMarginLeft + freeSpaceX
|
|
1759
|
+
break
|
|
1760
|
+
case C.ALIGN_STRETCH:
|
|
1761
|
+
// Stretch: already handled by setting width to fill
|
|
1762
|
+
break
|
|
1763
|
+
default: // FLEX_START
|
|
1764
|
+
childX = childMarginLeft
|
|
1765
|
+
break
|
|
1766
|
+
}
|
|
1727
1767
|
}
|
|
1728
1768
|
} else if (!hasLeft && hasRight) {
|
|
1729
1769
|
// Position from right edge
|
|
@@ -1734,19 +1774,41 @@ function layoutNode(
|
|
|
1734
1774
|
}
|
|
1735
1775
|
|
|
1736
1776
|
if (!hasTop && !hasBottom) {
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1777
|
+
if (isRow) {
|
|
1778
|
+
// Row: Y is cross axis, use alignItems/alignSelf
|
|
1779
|
+
let alignment = style.alignItems
|
|
1780
|
+
if (childStyle.alignSelf !== C.ALIGN_AUTO) {
|
|
1781
|
+
alignment = childStyle.alignSelf
|
|
1782
|
+
}
|
|
1783
|
+
const freeSpaceY = contentH - childHeight - childMarginTop - childMarginBottom
|
|
1784
|
+
switch (alignment) {
|
|
1785
|
+
case C.ALIGN_CENTER:
|
|
1786
|
+
childY = childMarginTop + freeSpaceY / 2
|
|
1787
|
+
break
|
|
1788
|
+
case C.ALIGN_FLEX_END:
|
|
1789
|
+
childY = childMarginTop + freeSpaceY
|
|
1790
|
+
break
|
|
1791
|
+
case C.ALIGN_STRETCH:
|
|
1792
|
+
// Stretch: already handled by setting height to fill
|
|
1793
|
+
break
|
|
1794
|
+
default: // FLEX_START
|
|
1795
|
+
childY = childMarginTop
|
|
1796
|
+
break
|
|
1797
|
+
}
|
|
1798
|
+
} else {
|
|
1799
|
+
// Column: Y is main axis, use justifyContent
|
|
1800
|
+
const freeSpaceY = contentH - childHeight - childMarginTop - childMarginBottom
|
|
1801
|
+
switch (style.justifyContent) {
|
|
1802
|
+
case C.JUSTIFY_CENTER:
|
|
1803
|
+
childY = childMarginTop + freeSpaceY / 2
|
|
1804
|
+
break
|
|
1805
|
+
case C.JUSTIFY_FLEX_END:
|
|
1806
|
+
childY = childMarginTop + freeSpaceY
|
|
1807
|
+
break
|
|
1808
|
+
default: // FLEX_START
|
|
1809
|
+
childY = childMarginTop
|
|
1810
|
+
break
|
|
1811
|
+
}
|
|
1750
1812
|
}
|
|
1751
1813
|
} else if (!hasTop && hasBottom) {
|
|
1752
1814
|
// Position from bottom edge
|
|
@@ -1769,14 +1831,12 @@ function layoutNode(
|
|
|
1769
1831
|
// so the public API remains consistent between versions.
|
|
1770
1832
|
|
|
1771
1833
|
export let layoutNodeCalls = 0
|
|
1772
|
-
export let resolveEdgeCalls = 0
|
|
1773
1834
|
export let layoutSizingCalls = 0
|
|
1774
1835
|
export let layoutPositioningCalls = 0
|
|
1775
1836
|
export let layoutCacheHits = 0
|
|
1776
1837
|
|
|
1777
1838
|
export function resetLayoutStats(): void {
|
|
1778
1839
|
layoutNodeCalls = 0
|
|
1779
|
-
resolveEdgeCalls = 0
|
|
1780
1840
|
layoutSizingCalls = 0
|
|
1781
1841
|
layoutPositioningCalls = 0
|
|
1782
1842
|
layoutCacheHits = 0
|
package/src/classic/node.ts
CHANGED
|
@@ -109,6 +109,18 @@ export class Node {
|
|
|
109
109
|
* ```
|
|
110
110
|
*/
|
|
111
111
|
insertChild(child: Node, index: number): void {
|
|
112
|
+
// Cycle guard: prevent self-insertion or insertion of an ancestor
|
|
113
|
+
if (child === this) {
|
|
114
|
+
throw new Error("Cannot insert a node as a child of itself")
|
|
115
|
+
}
|
|
116
|
+
let ancestor: Node | null = this._parent
|
|
117
|
+
while (ancestor !== null) {
|
|
118
|
+
if (ancestor === child) {
|
|
119
|
+
throw new Error("Cannot insert an ancestor as a child (would create a cycle)")
|
|
120
|
+
}
|
|
121
|
+
ancestor = ancestor._parent
|
|
122
|
+
}
|
|
123
|
+
|
|
112
124
|
if (child._parent !== null) {
|
|
113
125
|
child._parent.removeChild(child)
|
|
114
126
|
}
|
|
@@ -152,6 +164,19 @@ export class Node {
|
|
|
152
164
|
this._baselineFunc = null
|
|
153
165
|
}
|
|
154
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Free this node and all descendants recursively.
|
|
169
|
+
* Each node is detached from its parent and cleaned up.
|
|
170
|
+
*/
|
|
171
|
+
freeRecursive(): void {
|
|
172
|
+
// Free children first (leaves to root)
|
|
173
|
+
const children = [...this._children]
|
|
174
|
+
for (const child of children) {
|
|
175
|
+
child.freeRecursive()
|
|
176
|
+
}
|
|
177
|
+
this.free()
|
|
178
|
+
}
|
|
179
|
+
|
|
155
180
|
/**
|
|
156
181
|
* Dispose the node (calls free)
|
|
157
182
|
*/
|
|
@@ -377,6 +402,41 @@ export class Node {
|
|
|
377
402
|
return this._layout.height
|
|
378
403
|
}
|
|
379
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Get the computed right edge position after layout (left + width).
|
|
407
|
+
*/
|
|
408
|
+
getComputedRight(): number {
|
|
409
|
+
return this._layout.left + this._layout.width
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Get the computed bottom edge position after layout (top + height).
|
|
414
|
+
*/
|
|
415
|
+
getComputedBottom(): number {
|
|
416
|
+
return this._layout.top + this._layout.height
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Get the computed padding for a specific edge after layout.
|
|
421
|
+
*/
|
|
422
|
+
getComputedPadding(edge: number): number {
|
|
423
|
+
return getEdgeValue(this._style.padding, edge).value
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Get the computed margin for a specific edge after layout.
|
|
428
|
+
*/
|
|
429
|
+
getComputedMargin(edge: number): number {
|
|
430
|
+
return getEdgeValue(this._style.margin, edge).value
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Get the computed border width for a specific edge after layout.
|
|
435
|
+
*/
|
|
436
|
+
getComputedBorder(edge: number): number {
|
|
437
|
+
return getEdgeBorderValue(this._style.border, edge)
|
|
438
|
+
}
|
|
439
|
+
|
|
380
440
|
// ============================================================================
|
|
381
441
|
// Internal Accessors (for layout algorithm)
|
|
382
442
|
// ============================================================================
|
package/src/constants.ts
CHANGED
|
@@ -25,6 +25,7 @@ export const ALIGN_STRETCH = 4
|
|
|
25
25
|
export const ALIGN_BASELINE = 5
|
|
26
26
|
export const ALIGN_SPACE_BETWEEN = 6
|
|
27
27
|
export const ALIGN_SPACE_AROUND = 7
|
|
28
|
+
export const ALIGN_SPACE_EVENLY = 8
|
|
28
29
|
|
|
29
30
|
// Justify
|
|
30
31
|
export const JUSTIFY_FLEX_START = 0
|
|
@@ -64,7 +65,7 @@ export const OVERFLOW_VISIBLE = 0
|
|
|
64
65
|
export const OVERFLOW_HIDDEN = 1
|
|
65
66
|
export const OVERFLOW_SCROLL = 2
|
|
66
67
|
|
|
67
|
-
// Direction (
|
|
68
|
+
// Direction (LTR and RTL are both supported)
|
|
68
69
|
export const DIRECTION_INHERIT = 0
|
|
69
70
|
export const DIRECTION_LTR = 1
|
|
70
71
|
export const DIRECTION_RTL = 2
|
package/src/index-classic.ts
CHANGED
|
@@ -46,6 +46,7 @@ export {
|
|
|
46
46
|
ALIGN_BASELINE,
|
|
47
47
|
ALIGN_SPACE_BETWEEN,
|
|
48
48
|
ALIGN_SPACE_AROUND,
|
|
49
|
+
ALIGN_SPACE_EVENLY,
|
|
49
50
|
// Justify
|
|
50
51
|
JUSTIFY_FLEX_START,
|
|
51
52
|
JUSTIFY_CENTER,
|
|
@@ -102,7 +103,6 @@ export { createDefaultStyle, createValue } from "./types.js"
|
|
|
102
103
|
// Layout stats (for debugging/benchmarking)
|
|
103
104
|
export {
|
|
104
105
|
layoutNodeCalls,
|
|
105
|
-
resolveEdgeCalls,
|
|
106
106
|
layoutSizingCalls,
|
|
107
107
|
layoutPositioningCalls,
|
|
108
108
|
layoutCacheHits,
|
package/src/index.ts
CHANGED
|
@@ -46,6 +46,7 @@ export {
|
|
|
46
46
|
ALIGN_BASELINE,
|
|
47
47
|
ALIGN_SPACE_BETWEEN,
|
|
48
48
|
ALIGN_SPACE_AROUND,
|
|
49
|
+
ALIGN_SPACE_EVENLY,
|
|
49
50
|
// Justify
|
|
50
51
|
JUSTIFY_FLEX_START,
|
|
51
52
|
JUSTIFY_CENTER,
|
|
@@ -102,7 +103,6 @@ export { createDefaultStyle, createValue } from "./types.js"
|
|
|
102
103
|
// Layout stats (for debugging/benchmarking)
|
|
103
104
|
export {
|
|
104
105
|
layoutNodeCalls,
|
|
105
|
-
resolveEdgeCalls,
|
|
106
106
|
layoutSizingCalls,
|
|
107
107
|
layoutPositioningCalls,
|
|
108
108
|
layoutCacheHits,
|
package/src/layout-flex-lines.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* Pre-allocated arrays for zero-allocation flex-wrap layout,
|
|
5
5
|
* plus the line-breaking and flex-distribution algorithms.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Re-entrancy: A user measureFunc or baselineFunc may synchronously call
|
|
8
|
+
* calculateLayout() on a separate tree. saveLineState/restoreLineState
|
|
9
|
+
* bracket such nested calls to protect the outer pass's scratch arrays.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import * as C from "./constants.js"
|
|
@@ -89,6 +90,69 @@ export function growLineArrays(needed: number): void {
|
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
// ============================================================================
|
|
94
|
+
// Re-entrancy Support
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// When a measureFunc/baselineFunc synchronously calls calculateLayout() on
|
|
97
|
+
// another tree, we must save and restore the module-level scratch arrays so
|
|
98
|
+
// the outer pass resumes with its own data intact.
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Saved snapshot of all module-level line arrays.
|
|
102
|
+
* Allocated only on re-entrant calls (depth > 0).
|
|
103
|
+
*/
|
|
104
|
+
export interface LineStateSave {
|
|
105
|
+
crossSizes: Float64Array<ArrayBuffer>
|
|
106
|
+
crossOffsets: Float64Array<ArrayBuffer>
|
|
107
|
+
lengths: Uint16Array<ArrayBuffer>
|
|
108
|
+
justifyStarts: Float64Array<ArrayBuffer>
|
|
109
|
+
itemSpacings: Float64Array<ArrayBuffer>
|
|
110
|
+
children: Node[][]
|
|
111
|
+
maxLines: number
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Current re-entrancy depth. 0 = outermost (no save needed). */
|
|
115
|
+
let _layoutDepth = 0
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Enter a layout pass. If re-entrant (depth > 0), saves current line state.
|
|
119
|
+
* @returns The saved state (to pass to restoreLineState), or null at depth 0.
|
|
120
|
+
*/
|
|
121
|
+
export function enterLayout(): LineStateSave | null {
|
|
122
|
+
const depth = _layoutDepth++
|
|
123
|
+
if (depth === 0) return null // Outermost — no save needed
|
|
124
|
+
|
|
125
|
+
// Save current state (allocates only on re-entrant calls — rare)
|
|
126
|
+
const saved: LineStateSave = {
|
|
127
|
+
crossSizes: _lineCrossSizes.slice(),
|
|
128
|
+
crossOffsets: _lineCrossOffsets.slice(),
|
|
129
|
+
lengths: _lineLengths.slice(),
|
|
130
|
+
justifyStarts: _lineJustifyStarts.slice(),
|
|
131
|
+
itemSpacings: _lineItemSpacings.slice(),
|
|
132
|
+
children: _lineChildren.map((arr) => arr.slice()),
|
|
133
|
+
maxLines: MAX_FLEX_LINES,
|
|
134
|
+
}
|
|
135
|
+
return saved
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Exit a layout pass. If re-entrant, restores saved line state.
|
|
140
|
+
* @param saved - The state returned by enterLayout (null at depth 0).
|
|
141
|
+
*/
|
|
142
|
+
export function exitLayout(saved: LineStateSave | null): void {
|
|
143
|
+
_layoutDepth--
|
|
144
|
+
if (!saved) return // Outermost — nothing to restore
|
|
145
|
+
|
|
146
|
+
// Restore saved state
|
|
147
|
+
MAX_FLEX_LINES = saved.maxLines
|
|
148
|
+
_lineCrossSizes = saved.crossSizes
|
|
149
|
+
_lineCrossOffsets = saved.crossOffsets
|
|
150
|
+
_lineLengths = saved.lengths
|
|
151
|
+
_lineJustifyStarts = saved.justifyStarts
|
|
152
|
+
_lineItemSpacings = saved.itemSpacings
|
|
153
|
+
_lineChildren = saved.children
|
|
154
|
+
}
|
|
155
|
+
|
|
92
156
|
/**
|
|
93
157
|
* Epsilon value for floating point comparisons in flex distribution.
|
|
94
158
|
* Used to determine when remaining space is negligible and iteration should stop.
|
|
@@ -141,7 +205,10 @@ export function breakIntoLines(
|
|
|
141
205
|
if (child.flex.relativeIndex < 0) continue
|
|
142
206
|
|
|
143
207
|
const flex = child.flex
|
|
144
|
-
|
|
208
|
+
// CSS spec 9.3.4: line breaking uses the "hypothetical main size" which is
|
|
209
|
+
// the flex base size clamped to min/max, not the unclamped base size.
|
|
210
|
+
const hypotheticalMainSize = Math.max(flex.minMain, Math.min(flex.maxMain, flex.baseSize))
|
|
211
|
+
const childMainSize = hypotheticalMainSize + flex.mainMargin
|
|
145
212
|
const gapIfNotFirst = lineChildCount > 0 ? mainGap : 0
|
|
146
213
|
|
|
147
214
|
// Check if child fits on current line
|