flexily 0.5.2 → 0.6.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.
Files changed (50) hide show
  1. package/dist/chunk-CBBoxR_p.mjs +26 -0
  2. package/dist/constants-BNURa6H7.mjs +65 -0
  3. package/dist/constants-BNURa6H7.mjs.map +1 -0
  4. package/dist/constants-D7ythAJC.d.mts +64 -0
  5. package/dist/constants-D7ythAJC.d.mts.map +1 -0
  6. package/{src/classic/node.ts → dist/index-classic.d.mts} +118 -619
  7. package/dist/index-classic.d.mts.map +1 -0
  8. package/dist/index-classic.mjs +1909 -0
  9. package/dist/index-classic.mjs.map +1 -0
  10. package/dist/index.d.mts +195 -0
  11. package/dist/index.d.mts.map +1 -0
  12. package/dist/index.mjs +3279 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/dist/node-zero-75maLs2s.d.mts +762 -0
  15. package/dist/node-zero-75maLs2s.d.mts.map +1 -0
  16. package/dist/src-BWyhokNZ.mjs +692 -0
  17. package/dist/src-BWyhokNZ.mjs.map +1 -0
  18. package/dist/src-DdSLylRA.mjs +816 -0
  19. package/dist/src-DdSLylRA.mjs.map +1 -0
  20. package/dist/testing.d.mts +55 -0
  21. package/dist/testing.d.mts.map +1 -0
  22. package/dist/testing.mjs +154 -0
  23. package/dist/testing.mjs.map +1 -0
  24. package/dist/types--IozHd4V.mjs +283 -0
  25. package/dist/types--IozHd4V.mjs.map +1 -0
  26. package/dist/types-DG1H4DVR.d.mts +157 -0
  27. package/dist/types-DG1H4DVR.d.mts.map +1 -0
  28. package/package.json +33 -24
  29. package/src/CLAUDE.md +0 -527
  30. package/src/classic/layout.ts +0 -1843
  31. package/src/constants.ts +0 -82
  32. package/src/create-flexily.ts +0 -153
  33. package/src/index-classic.ts +0 -110
  34. package/src/index.ts +0 -133
  35. package/src/layout-flex-lines.ts +0 -413
  36. package/src/layout-helpers.ts +0 -160
  37. package/src/layout-measure.ts +0 -259
  38. package/src/layout-stats.ts +0 -41
  39. package/src/layout-traversal.ts +0 -70
  40. package/src/layout-zero.ts +0 -2219
  41. package/src/logger.ts +0 -68
  42. package/src/monospace-measurer.ts +0 -68
  43. package/src/node-zero.ts +0 -1508
  44. package/src/pretext-measurer.ts +0 -86
  45. package/src/test-measurer.ts +0 -219
  46. package/src/testing.ts +0 -215
  47. package/src/text-layout.ts +0 -75
  48. package/src/trace.ts +0 -252
  49. package/src/types.ts +0 -236
  50. package/src/utils.ts +0 -243
@@ -1,1843 +0,0 @@
1
- /**
2
- * Flexily Layout Algorithm
3
- *
4
- * Core flexbox layout computation extracted from node.ts.
5
- * Based on Planning-nl/flexbox.js reference implementation.
6
- */
7
-
8
- import * as C from "../constants.js"
9
- import type { Node } from "./node.js"
10
- import type { Value } from "../types.js"
11
- import { resolveValue, applyMinMax } from "../utils.js"
12
- import { log } from "../logger.js"
13
-
14
- // ============================================================================
15
- // Helper Functions
16
- // ============================================================================
17
-
18
- /**
19
- * Check if flex direction is row-oriented (horizontal main axis).
20
- */
21
- export function isRowDirection(flexDirection: number): boolean {
22
- return flexDirection === C.FLEX_DIRECTION_ROW || flexDirection === C.FLEX_DIRECTION_ROW_REVERSE
23
- }
24
-
25
- /**
26
- * Check if flex direction is reversed.
27
- */
28
- export function isReverseDirection(flexDirection: number): boolean {
29
- return flexDirection === C.FLEX_DIRECTION_ROW_REVERSE || flexDirection === C.FLEX_DIRECTION_COLUMN_REVERSE
30
- }
31
-
32
- /**
33
- * Get the logical edge value (START/END) for a given physical index.
34
- * Returns undefined if no logical value applies to this physical edge.
35
- *
36
- * The mapping depends on flex direction and text direction:
37
- * - Row LTR: START→left, END→right (swapped if reverse)
38
- * - Row RTL: START→right, END→left (swapped if reverse)
39
- * - Column: START→top, END→bottom (swapped if reverse)
40
- */
41
- function getLogicalEdgeValue(
42
- arr: [Value, Value, Value, Value, Value, Value],
43
- physicalIndex: number,
44
- _flexDirection: number,
45
- direction: number = C.DIRECTION_LTR,
46
- ): Value | undefined {
47
- const isRTL = direction === C.DIRECTION_RTL
48
-
49
- // START/END always map to left/right (inline direction)
50
- if (physicalIndex === 0) {
51
- return isRTL ? arr[5] : arr[4] // Left: START (LTR) or END (RTL)
52
- } else if (physicalIndex === 2) {
53
- return isRTL ? arr[4] : arr[5] // Right: END (LTR) or START (RTL)
54
- }
55
- return undefined
56
- }
57
-
58
- /**
59
- * Resolve logical (START/END) margins/padding to physical values.
60
- * EDGE_START/EDGE_END always resolve along the inline (horizontal) axis:
61
- * - LTR: START→left, END→right
62
- * - RTL: START→right, END→left
63
- *
64
- * Physical edges (LEFT/RIGHT/TOP/BOTTOM) are used directly.
65
- * When both physical and logical are set, logical takes precedence.
66
- */
67
- export function resolveEdgeValue(
68
- arr: [Value, Value, Value, Value, Value, Value],
69
- physicalIndex: number, // 0=left, 1=top, 2=right, 3=bottom
70
- flexDirection: number,
71
- availableSize: number,
72
- direction: number = C.DIRECTION_LTR,
73
- ): number {
74
- const logicalValue = getLogicalEdgeValue(arr, physicalIndex, flexDirection, direction)
75
-
76
- // Logical takes precedence if defined
77
- if (logicalValue && logicalValue.unit !== C.UNIT_UNDEFINED) {
78
- return resolveValue(logicalValue, availableSize)
79
- }
80
-
81
- // Fall back to physical
82
- return resolveValue(arr[physicalIndex]!, availableSize)
83
- }
84
-
85
- /**
86
- * Check if a logical edge margin is set to auto.
87
- */
88
- export function isEdgeAuto(
89
- arr: [Value, Value, Value, Value, Value, Value],
90
- physicalIndex: number,
91
- flexDirection: number,
92
- direction: number = C.DIRECTION_LTR,
93
- ): boolean {
94
- const logicalValue = getLogicalEdgeValue(arr, physicalIndex, flexDirection, direction)
95
-
96
- // Check logical first
97
- if (logicalValue && logicalValue.unit !== C.UNIT_UNDEFINED) {
98
- return logicalValue.unit === C.UNIT_AUTO
99
- }
100
-
101
- // Fall back to physical
102
- return arr[physicalIndex]!.unit === C.UNIT_AUTO
103
- }
104
-
105
- /**
106
- * Resolve logical (START/END) border widths to physical values.
107
- * Border values are plain numbers (always points), so resolution is simpler
108
- * than for margin/padding. Uses NaN as the "not set" sentinel for logical slots.
109
- * When both physical and logical are set, logical takes precedence.
110
- */
111
- export function resolveEdgeBorderValue(
112
- arr: [number, number, number, number, number, number],
113
- physicalIndex: number, // 0=left, 1=top, 2=right, 3=bottom
114
- _flexDirection: number,
115
- direction: number = C.DIRECTION_LTR,
116
- ): number {
117
- const isRTL = direction === C.DIRECTION_RTL
118
-
119
- // START/END always map to left/right (inline direction)
120
- let logicalSlot: number | undefined
121
- if (physicalIndex === 0) logicalSlot = isRTL ? 5 : 4
122
- else if (physicalIndex === 2) logicalSlot = isRTL ? 4 : 5
123
-
124
- // Logical takes precedence if set (NaN = not set)
125
- if (logicalSlot !== undefined && !Number.isNaN(arr[logicalSlot])) {
126
- return arr[logicalSlot]!
127
- }
128
- return arr[physicalIndex]!
129
- }
130
-
131
- export function markSubtreeLayoutSeen(node: Node): void {
132
- for (const child of node.children) {
133
- ;(child as Node)["_hasNewLayout"] = true
134
- markSubtreeLayoutSeen(child)
135
- }
136
- }
137
-
138
- export function countNodes(node: Node): number {
139
- let count = 1
140
- for (const child of node.children) {
141
- count += countNodes(child)
142
- }
143
- return count
144
- }
145
-
146
- // ============================================================================
147
- // Layout Algorithm
148
- // Based on Planning-nl/flexbox.js reference implementation
149
- // ============================================================================
150
-
151
- /**
152
- * Epsilon value for floating point comparisons in flex distribution.
153
- * Used to determine when remaining space is negligible and iteration should stop.
154
- */
155
- const EPSILON_FLOAT = 0.001
156
-
157
- /**
158
- * Child layout information for flex distribution.
159
- */
160
- interface ChildLayout {
161
- node: Node
162
- mainSize: number
163
- baseSize: number // Original base size before flex distribution (for weighted shrink)
164
- mainMargin: number // Total main-axis margin (non-auto only)
165
- flexGrow: number
166
- flexShrink: number
167
- minMain: number
168
- maxMain: number
169
- // Auto margin tracking (main axis)
170
- mainStartMarginAuto: boolean
171
- mainEndMarginAuto: boolean
172
- mainStartMarginValue: number // Resolved or 0 if auto (will be computed later)
173
- mainEndMarginValue: number // Resolved or 0 if auto (will be computed later)
174
- // Frozen flag: set when item was clamped to min/max during hypothetical sizing
175
- frozen: boolean
176
- }
177
-
178
- /**
179
- * A flex line containing children and cross-axis sizing info.
180
- * Used for flex-wrap to group items that fit on one line.
181
- */
182
- interface FlexLine {
183
- children: ChildLayout[]
184
- crossSize: number // Maximum cross size of items in this line
185
- crossStart: number // Computed cross-axis start position
186
- }
187
-
188
- /**
189
- * Break children into flex lines based on available main-axis space.
190
- *
191
- * @param children - All children to potentially wrap
192
- * @param mainAxisSize - Available main-axis space (NaN for unconstrained)
193
- * @param mainGap - Gap between items on main axis
194
- * @param wrap - Wrap mode (WRAP_NO_WRAP, WRAP_WRAP, WRAP_WRAP_REVERSE)
195
- * @returns Array of flex lines
196
- */
197
- function breakIntoLines(children: ChildLayout[], mainAxisSize: number, mainGap: number, wrap: number): FlexLine[] {
198
- // No wrapping or unconstrained - all children on one line
199
- if (wrap === C.WRAP_NO_WRAP || Number.isNaN(mainAxisSize) || children.length === 0) {
200
- return [{ children, crossSize: 0, crossStart: 0 }]
201
- }
202
-
203
- const lines: FlexLine[] = []
204
- let currentLine: ChildLayout[] = []
205
- let lineMainSize = 0
206
-
207
- for (const child of children) {
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
212
- const gapIfNotFirst = currentLine.length > 0 ? mainGap : 0
213
-
214
- // Check if child fits on current line
215
- if (currentLine.length > 0 && lineMainSize + gapIfNotFirst + childMainSize > mainAxisSize) {
216
- // Start a new line
217
- lines.push({ children: currentLine, crossSize: 0, crossStart: 0 })
218
- currentLine = [child]
219
- lineMainSize = childMainSize
220
- } else {
221
- // Add to current line
222
- currentLine.push(child)
223
- lineMainSize += gapIfNotFirst + childMainSize
224
- }
225
- }
226
-
227
- // Don't forget the last line
228
- if (currentLine.length > 0) {
229
- lines.push({ children: currentLine, crossSize: 0, crossStart: 0 })
230
- }
231
-
232
- // Reverse lines for wrap-reverse
233
- if (wrap === C.WRAP_WRAP_REVERSE) {
234
- lines.reverse()
235
- }
236
-
237
- return lines
238
- }
239
-
240
- /**
241
- * Distribute free space among flex children using grow or shrink factors.
242
- * Handles both positive (grow) and negative (shrink) free space.
243
- *
244
- * For shrinking, per CSS Flexbox spec, the shrink factor is weighted by the item's
245
- * base size: scaledShrinkFactor = flexShrink * baseSize
246
- *
247
- * @param children - Array of child layout info to distribute space among
248
- * @param freeSpace - Amount of space to distribute (positive for grow, negative for shrink)
249
- */
250
- function distributeFlexSpace(children: ChildLayout[], initialFreeSpace: number): void {
251
- // CSS Flexbox spec section 9.7: Resolving Flexible Lengths
252
- // This implements the iterative algorithm where items are frozen when they hit constraints.
253
- //
254
- // Key insight: Items start at BASE size, not hypothetical. Free space was calculated from
255
- // hypothetical sizes. When distributing, items that hit min/max are frozen and we redistribute.
256
-
257
- const isGrowing = initialFreeSpace > 0
258
- if (initialFreeSpace === 0) return
259
-
260
- // Single-child fast path: skip iteration, direct assignment
261
- if (children.length === 1) {
262
- const child = children[0]!
263
- const canFlex = isGrowing ? child.flexGrow > 0 : child.flexShrink > 0
264
- if (canFlex) {
265
- const target = child.baseSize + initialFreeSpace
266
- child.mainSize = Math.max(child.minMain, Math.min(child.maxMain, target))
267
- }
268
- return
269
- }
270
-
271
- // Calculate container inner size from initial state (before any mutations)
272
- // freeSpace was computed from BASE sizes, so: container = freeSpace + sum(base)
273
- let totalBase = 0
274
- for (const childLayout of children) {
275
- totalBase += childLayout.baseSize
276
- }
277
- const containerInner = initialFreeSpace + totalBase
278
-
279
- // Initialize: all items start unfrozen
280
- for (const childLayout of children) {
281
- childLayout.frozen = false
282
- }
283
-
284
- // Track current free space (will be recalculated each iteration)
285
- let freeSpace = initialFreeSpace
286
-
287
- // Iterate until all items are frozen or free space is negligible
288
- let iterations = 0
289
- const maxIterations = children.length + 1 // Prevent infinite loops
290
-
291
- while (iterations++ < maxIterations) {
292
- // Calculate total flex factor for unfrozen items
293
- let totalFlex = 0
294
- for (const childLayout of children) {
295
- if (childLayout.frozen) continue
296
- if (isGrowing) {
297
- totalFlex += childLayout.flexGrow
298
- } else {
299
- // Shrink weighted by base size per CSS spec
300
- totalFlex += childLayout.flexShrink * childLayout.baseSize
301
- }
302
- }
303
-
304
- if (totalFlex === 0) break
305
-
306
- // CSS Flexbox spec: when total flex-grow is less than 1, only distribute that fraction
307
- let effectiveFreeSpace = freeSpace
308
- if (isGrowing && totalFlex < 1) {
309
- effectiveFreeSpace = freeSpace * totalFlex
310
- }
311
-
312
- // Calculate target sizes for unfrozen items
313
- let totalViolation = 0
314
- for (const childLayout of children) {
315
- if (childLayout.frozen) continue
316
-
317
- // Calculate target from base size + proportional free space
318
- const flexFactor = isGrowing ? childLayout.flexGrow : childLayout.flexShrink * childLayout.baseSize
319
- const ratio = totalFlex > 0 ? flexFactor / totalFlex : 0
320
- const target = childLayout.baseSize + effectiveFreeSpace * ratio
321
-
322
- // Clamp by min/max
323
- const clamped = Math.max(childLayout.minMain, Math.min(childLayout.maxMain, target))
324
- const violation = clamped - target
325
- totalViolation += violation
326
-
327
- // Store clamped target
328
- childLayout.mainSize = clamped
329
- }
330
-
331
- // Freeze items based on violations (CSS spec 9.7 step 9)
332
- let anyFrozen = false
333
- if (Math.abs(totalViolation) < EPSILON_FLOAT) {
334
- // No violations - freeze all remaining items and we're done
335
- for (const childLayout of children) {
336
- childLayout.frozen = true
337
- }
338
- break
339
- } else if (totalViolation > 0) {
340
- // Positive total violation: freeze items with positive violations (clamped UP to min)
341
- for (const childLayout of children) {
342
- if (childLayout.frozen) continue
343
- const target =
344
- childLayout.baseSize +
345
- ((isGrowing ? childLayout.flexGrow : childLayout.flexShrink * childLayout.baseSize) / totalFlex) *
346
- effectiveFreeSpace
347
- if (childLayout.mainSize > target + EPSILON_FLOAT) {
348
- childLayout.frozen = true
349
- anyFrozen = true
350
- }
351
- }
352
- } else {
353
- // Negative total violation: freeze items with negative violations (clamped DOWN to max)
354
- for (const childLayout of children) {
355
- if (childLayout.frozen) continue
356
- const flexFactor = isGrowing ? childLayout.flexGrow : childLayout.flexShrink * childLayout.baseSize
357
- const target = childLayout.baseSize + (flexFactor / totalFlex) * effectiveFreeSpace
358
- if (childLayout.mainSize < target - EPSILON_FLOAT) {
359
- childLayout.frozen = true
360
- anyFrozen = true
361
- }
362
- }
363
- }
364
-
365
- if (!anyFrozen) break
366
-
367
- // Recalculate free space for next iteration
368
- // After freezing, available = container - frozen sizes
369
- // Free space = available - sum of unfrozen BASE sizes
370
- let frozenSpace = 0
371
- let unfrozenBase = 0
372
- for (const childLayout of children) {
373
- if (childLayout.frozen) {
374
- frozenSpace += childLayout.mainSize
375
- } else {
376
- unfrozenBase += childLayout.baseSize
377
- }
378
- }
379
- // New free space = container - frozen - unfrozen base sizes
380
- freeSpace = containerInner - frozenSpace - unfrozenBase
381
- }
382
- }
383
-
384
- /**
385
- * Compute layout for a node tree.
386
- *
387
- * @param root - Root node of the tree
388
- * @param availableWidth - Available width for layout
389
- * @param availableHeight - Available height for layout
390
- * @param direction - Text direction (LTR or RTL), affects horizontal edge resolution
391
- */
392
- export function computeLayout(
393
- root: Node,
394
- availableWidth: number,
395
- availableHeight: number,
396
- direction: number = C.DIRECTION_LTR,
397
- ): void {
398
- // Pass absolute position (0,0) for root node - used for Yoga-compatible edge rounding
399
- layoutNode(root, availableWidth, availableHeight, 0, 0, 0, 0, direction)
400
- }
401
-
402
- /**
403
- * Layout a node and its children.
404
- *
405
- * @param absX - Absolute X position from document root (for Yoga-compatible edge rounding)
406
- * @param absY - Absolute Y position from document root (for Yoga-compatible edge rounding)
407
- * @param direction - Text direction (LTR or RTL), affects horizontal edge resolution
408
- */
409
- function layoutNode(
410
- node: Node,
411
- availableWidth: number,
412
- availableHeight: number,
413
- offsetX: number,
414
- offsetY: number,
415
- absX: number,
416
- absY: number,
417
- direction: number = C.DIRECTION_LTR,
418
- ): void {
419
- log.debug?.(
420
- "layoutNode called: availW=%d, availH=%d, offsetX=%d, offsetY=%d, absX=%d, absY=%d, children=%d",
421
- availableWidth,
422
- availableHeight,
423
- offsetX,
424
- offsetY,
425
- absX,
426
- absY,
427
- node.children.length,
428
- )
429
- const style = node.style
430
- const layout = node.layout
431
-
432
- // Handle display: none
433
- if (style.display === C.DISPLAY_NONE) {
434
- layout.left = 0
435
- layout.top = 0
436
- layout.width = 0
437
- layout.height = 0
438
- return
439
- }
440
-
441
- // Calculate spacing
442
- // CSS spec: percentage margins AND padding resolve against containing block's WIDTH only
443
- // Use resolveEdgeValue to respect logical EDGE_START/END
444
- const marginLeft = resolveEdgeValue(style.margin, 0, style.flexDirection, availableWidth, direction)
445
- const marginTop = resolveEdgeValue(style.margin, 1, style.flexDirection, availableWidth, direction)
446
- const marginRight = resolveEdgeValue(style.margin, 2, style.flexDirection, availableWidth, direction)
447
- const marginBottom = resolveEdgeValue(style.margin, 3, style.flexDirection, availableWidth, direction)
448
-
449
- const paddingLeft = resolveEdgeValue(style.padding, 0, style.flexDirection, availableWidth, direction)
450
- const paddingTop = resolveEdgeValue(style.padding, 1, style.flexDirection, availableWidth, direction)
451
- const paddingRight = resolveEdgeValue(style.padding, 2, style.flexDirection, availableWidth, direction)
452
- const paddingBottom = resolveEdgeValue(style.padding, 3, style.flexDirection, availableWidth, direction)
453
-
454
- const borderLeft = resolveEdgeBorderValue(style.border, 0, style.flexDirection, direction)
455
- const borderTop = resolveEdgeBorderValue(style.border, 1, style.flexDirection, direction)
456
- const borderRight = resolveEdgeBorderValue(style.border, 2, style.flexDirection, direction)
457
- const borderBottom = resolveEdgeBorderValue(style.border, 3, style.flexDirection, direction)
458
-
459
- // Calculate node dimensions
460
- // When available dimension is NaN (unconstrained), auto-sized nodes use NaN
461
- // and will be sized by shrink-wrap logic based on children
462
- let nodeWidth: number
463
- if (style.width.unit === C.UNIT_POINT) {
464
- nodeWidth = style.width.value
465
- } else if (style.width.unit === C.UNIT_PERCENT) {
466
- // Percentage against NaN (auto-sized parent) resolves to 0 via resolveValue
467
- nodeWidth = resolveValue(style.width, availableWidth)
468
- } else if (Number.isNaN(availableWidth)) {
469
- // Unconstrained: use NaN to signal shrink-wrap (will be computed from children)
470
- nodeWidth = NaN
471
- } else {
472
- nodeWidth = availableWidth - marginLeft - marginRight
473
- }
474
- // Apply min/max constraints (works even with NaN available for point-based constraints)
475
- nodeWidth = applyMinMax(nodeWidth, style.minWidth, style.maxWidth, availableWidth)
476
-
477
- let nodeHeight: number
478
- if (style.height.unit === C.UNIT_POINT) {
479
- nodeHeight = style.height.value
480
- } else if (style.height.unit === C.UNIT_PERCENT) {
481
- // Percentage against NaN (auto-sized parent) resolves to 0 via resolveValue
482
- nodeHeight = resolveValue(style.height, availableHeight)
483
- } else if (Number.isNaN(availableHeight)) {
484
- // Unconstrained: use NaN to signal shrink-wrap (will be computed from children)
485
- nodeHeight = NaN
486
- } else {
487
- nodeHeight = availableHeight - marginTop - marginBottom
488
- }
489
-
490
- // Apply aspect ratio constraint
491
- // If aspectRatio is set and one dimension is auto (NaN), derive it from the other
492
- const aspectRatio = style.aspectRatio
493
- if (!Number.isNaN(aspectRatio) && aspectRatio > 0) {
494
- const widthIsAuto = Number.isNaN(nodeWidth) || style.width.unit === C.UNIT_AUTO
495
- const heightIsAuto = Number.isNaN(nodeHeight) || style.height.unit === C.UNIT_AUTO
496
-
497
- if (widthIsAuto && !heightIsAuto && !Number.isNaN(nodeHeight)) {
498
- // Height is defined, width is auto: width = height * aspectRatio
499
- nodeWidth = nodeHeight * aspectRatio
500
- } else if (heightIsAuto && !widthIsAuto && !Number.isNaN(nodeWidth)) {
501
- // Width is defined, height is auto: height = width / aspectRatio
502
- nodeHeight = nodeWidth / aspectRatio
503
- }
504
- // If both are defined or both are auto, aspectRatio doesn't apply at this stage
505
- }
506
-
507
- // Apply min/max constraints (works even with NaN available for point-based constraints)
508
- nodeHeight = applyMinMax(nodeHeight, style.minHeight, style.maxHeight, availableHeight)
509
-
510
- // Content area (inside border and padding)
511
- // When node dimensions are NaN (unconstrained), content dimensions are also NaN
512
- const innerLeft = borderLeft + paddingLeft
513
- const innerTop = borderTop + paddingTop
514
- const innerRight = borderRight + paddingRight
515
- const innerBottom = borderBottom + paddingBottom
516
-
517
- // Enforce box model constraint: minimum size = padding + border
518
- const minInnerWidth = innerLeft + innerRight
519
- const minInnerHeight = innerTop + innerBottom
520
- if (!Number.isNaN(nodeWidth) && nodeWidth < minInnerWidth) {
521
- nodeWidth = minInnerWidth
522
- }
523
- if (!Number.isNaN(nodeHeight) && nodeHeight < minInnerHeight) {
524
- nodeHeight = minInnerHeight
525
- }
526
-
527
- const contentWidth = Number.isNaN(nodeWidth) ? NaN : Math.max(0, nodeWidth - innerLeft - innerRight)
528
- const contentHeight = Number.isNaN(nodeHeight) ? NaN : Math.max(0, nodeHeight - innerTop - innerBottom)
529
-
530
- // Compute position offsets early (needed for children's absolute position calculation)
531
- // This ensures children's absolute positions include parent's position offset
532
- let parentPosOffsetX = 0
533
- let parentPosOffsetY = 0
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) {
537
- const leftPos = style.position[0]
538
- const topPos = style.position[1]
539
- const rightPos = style.position[2]
540
- const bottomPos = style.position[3]
541
-
542
- if (leftPos.unit !== C.UNIT_UNDEFINED) {
543
- parentPosOffsetX = resolveValue(leftPos, availableWidth)
544
- } else if (rightPos.unit !== C.UNIT_UNDEFINED) {
545
- parentPosOffsetX = -resolveValue(rightPos, availableWidth)
546
- }
547
-
548
- if (topPos.unit !== C.UNIT_UNDEFINED) {
549
- parentPosOffsetY = resolveValue(topPos, availableHeight)
550
- } else if (bottomPos.unit !== C.UNIT_UNDEFINED) {
551
- parentPosOffsetY = -resolveValue(bottomPos, availableHeight)
552
- }
553
- }
554
-
555
- // Handle measure function (text nodes)
556
- if (node.hasMeasureFunc() && node.children.length === 0) {
557
- const measureFunc = node.measureFunc!
558
- // For unconstrained dimensions (NaN), treat as auto-sizing
559
- const widthIsAuto =
560
- style.width.unit === C.UNIT_AUTO || style.width.unit === C.UNIT_UNDEFINED || Number.isNaN(nodeWidth)
561
- const heightIsAuto =
562
- style.height.unit === C.UNIT_AUTO || style.height.unit === C.UNIT_UNDEFINED || Number.isNaN(nodeHeight)
563
- const widthMode = widthIsAuto ? C.MEASURE_MODE_AT_MOST : C.MEASURE_MODE_EXACTLY
564
- const heightMode = heightIsAuto ? C.MEASURE_MODE_UNDEFINED : C.MEASURE_MODE_EXACTLY
565
-
566
- // For unconstrained width, use a large value; measureFunc should return intrinsic size
567
- const measureWidth = Number.isNaN(contentWidth) ? Infinity : contentWidth
568
- const measureHeight = Number.isNaN(contentHeight) ? Infinity : contentHeight
569
-
570
- const measured = measureFunc(measureWidth, widthMode, measureHeight, heightMode)
571
-
572
- if (widthIsAuto) {
573
- nodeWidth = measured.width + innerLeft + innerRight
574
- }
575
- if (heightIsAuto) {
576
- nodeHeight = measured.height + innerTop + innerBottom
577
- }
578
-
579
- layout.width = Math.round(nodeWidth)
580
- layout.height = Math.round(nodeHeight)
581
- layout.left = Math.round(offsetX + marginLeft)
582
- layout.top = Math.round(offsetY + marginTop)
583
- return
584
- }
585
-
586
- // Handle leaf nodes without measureFunc - when unconstrained, use padding+border as intrinsic size
587
- if (node.children.length === 0) {
588
- // For leaf nodes without measureFunc, intrinsic size is just padding+border
589
- if (Number.isNaN(nodeWidth)) {
590
- nodeWidth = innerLeft + innerRight
591
- }
592
- if (Number.isNaN(nodeHeight)) {
593
- nodeHeight = innerTop + innerBottom
594
- }
595
- layout.width = Math.round(nodeWidth)
596
- layout.height = Math.round(nodeHeight)
597
- layout.left = Math.round(offsetX + marginLeft)
598
- layout.top = Math.round(offsetY + marginTop)
599
- return
600
- }
601
-
602
- // Separate relative and absolute children
603
- // Filter out display:none children - they don't participate in layout at all
604
- const relativeChildren = node.children.filter(
605
- (c) => c.style.positionType !== C.POSITION_TYPE_ABSOLUTE && c.style.display !== C.DISPLAY_NONE,
606
- )
607
- const absoluteChildren = node.children.filter(
608
- (c) => c.style.positionType === C.POSITION_TYPE_ABSOLUTE && c.style.display !== C.DISPLAY_NONE,
609
- )
610
-
611
- // Flex layout for relative children
612
- log.debug?.(
613
- "layoutNode: node.children=%d, relativeChildren=%d, absolute=%d",
614
- node.children.length,
615
- relativeChildren.length,
616
- absoluteChildren.length,
617
- )
618
- if (relativeChildren.length > 0) {
619
- const isRow = isRowDirection(style.flexDirection)
620
- const isReverse = isReverseDirection(style.flexDirection)
621
-
622
- const mainAxisSize = isRow ? contentWidth : contentHeight
623
- const crossAxisSize = isRow ? contentHeight : contentWidth
624
- const mainGap = isRow ? style.gap[0] : style.gap[1]
625
-
626
- // Prepare child layout info
627
-
628
- const children: ChildLayout[] = []
629
- let totalBaseMain = 0
630
-
631
- for (const child of relativeChildren) {
632
- const childStyle = child.style
633
-
634
- // Check for auto margins on main axis
635
- // Physical indices depend on axis and reverse direction:
636
- // - Row: main-start=left(0), main-end=right(2)
637
- // - Row-reverse: main-start=right(2), main-end=left(0)
638
- // - Column: main-start=top(1), main-end=bottom(3)
639
- // - Column-reverse: main-start=bottom(3), main-end=top(1)
640
- const mainStartIndex = isRow ? (isReverse ? 2 : 0) : isReverse ? 3 : 1
641
- const mainEndIndex = isRow ? (isReverse ? 0 : 2) : isReverse ? 1 : 3
642
- const mainStartMarginAuto = isEdgeAuto(childStyle.margin, mainStartIndex, style.flexDirection)
643
- const mainEndMarginAuto = isEdgeAuto(childStyle.margin, mainEndIndex, style.flexDirection)
644
-
645
- // Resolve non-auto margins (auto margins resolve to 0 initially)
646
- // CSS spec: percentage margins resolve against containing block's WIDTH only
647
- // For row: mainAxisSize is contentWidth; for column: crossAxisSize is contentWidth
648
- const parentWidth = isRow ? mainAxisSize : crossAxisSize
649
- const mainStartMarginValue = mainStartMarginAuto
650
- ? 0
651
- : resolveEdgeValue(childStyle.margin, mainStartIndex, style.flexDirection, parentWidth, direction)
652
- const mainEndMarginValue = mainEndMarginAuto
653
- ? 0
654
- : resolveEdgeValue(childStyle.margin, mainEndIndex, style.flexDirection, parentWidth, direction)
655
-
656
- // Total non-auto margin for flex calculations
657
- const mainMargin = mainStartMarginValue + mainEndMarginValue
658
-
659
- // Determine base size (flex-basis or explicit size)
660
- let baseSize = 0
661
- if (childStyle.flexBasis.unit === C.UNIT_POINT) {
662
- baseSize = childStyle.flexBasis.value
663
- } else if (childStyle.flexBasis.unit === C.UNIT_PERCENT) {
664
- baseSize = mainAxisSize * (childStyle.flexBasis.value / 100)
665
- } else {
666
- const sizeVal = isRow ? childStyle.width : childStyle.height
667
- if (sizeVal.unit === C.UNIT_POINT) {
668
- baseSize = sizeVal.value
669
- } else if (sizeVal.unit === C.UNIT_PERCENT) {
670
- baseSize = mainAxisSize * (sizeVal.value / 100)
671
- } else if (child.hasMeasureFunc() && childStyle.flexGrow === 0) {
672
- // For auto-sized children with measureFunc but no flexGrow,
673
- // pre-measure to get intrinsic size for justify-content calculation
674
- // CSS spec: percentage margins resolve against containing block's WIDTH only
675
- // Use resolveEdgeValue to respect logical EDGE_START/END
676
- const crossMargin = isRow
677
- ? resolveEdgeValue(childStyle.margin, 1, style.flexDirection, contentWidth, direction) +
678
- resolveEdgeValue(childStyle.margin, 3, style.flexDirection, contentWidth, direction)
679
- : resolveEdgeValue(childStyle.margin, 0, style.flexDirection, contentWidth, direction) +
680
- resolveEdgeValue(childStyle.margin, 2, style.flexDirection, contentWidth, direction)
681
- const availCross = crossAxisSize - crossMargin
682
- const measured = child.measureFunc!(
683
- mainAxisSize,
684
- C.MEASURE_MODE_AT_MOST,
685
- availCross,
686
- C.MEASURE_MODE_UNDEFINED,
687
- )
688
- baseSize = isRow ? measured.width : measured.height
689
- } else if (child.children.length > 0) {
690
- // For auto-sized children WITH children but no measureFunc,
691
- // recursively compute intrinsic size by laying out with unconstrained main axis
692
- // Use 0,0 for absX/absY since this is just measurement, not final positioning
693
- layoutNode(child, isRow ? NaN : crossAxisSize, isRow ? crossAxisSize : NaN, 0, 0, 0, 0, direction)
694
- baseSize = isRow ? child.layout.width : child.layout.height
695
- } else {
696
- // For auto-sized LEAF children without measureFunc, use padding + border as minimum
697
- // This ensures elements with only padding still have proper size
698
- // CSS spec: percentage padding resolves against containing block's WIDTH only
699
- // Use resolveEdgeValue to respect logical EDGE_START/END
700
- // For row: mainAxisSize is contentWidth; for column: crossAxisSize is contentWidth
701
- const parentWidth = isRow ? mainAxisSize : crossAxisSize
702
- const childPadding = isRow
703
- ? resolveEdgeValue(childStyle.padding, 0, childStyle.flexDirection, parentWidth, direction) +
704
- resolveEdgeValue(childStyle.padding, 2, childStyle.flexDirection, parentWidth, direction)
705
- : resolveEdgeValue(childStyle.padding, 1, childStyle.flexDirection, parentWidth, direction) +
706
- resolveEdgeValue(childStyle.padding, 3, childStyle.flexDirection, parentWidth, direction)
707
- const childBorder = isRow
708
- ? resolveEdgeBorderValue(childStyle.border, 0, childStyle.flexDirection, direction) +
709
- resolveEdgeBorderValue(childStyle.border, 2, childStyle.flexDirection, direction)
710
- : resolveEdgeBorderValue(childStyle.border, 1, childStyle.flexDirection, direction) +
711
- resolveEdgeBorderValue(childStyle.border, 3, childStyle.flexDirection, direction)
712
- baseSize = childPadding + childBorder
713
- }
714
- }
715
-
716
- // Min/max on main axis
717
- const minVal = isRow ? childStyle.minWidth : childStyle.minHeight
718
- const maxVal = isRow ? childStyle.maxWidth : childStyle.maxHeight
719
- const minMain = minVal.unit !== C.UNIT_UNDEFINED ? resolveValue(minVal, mainAxisSize) : 0
720
- const maxMain = maxVal.unit !== C.UNIT_UNDEFINED ? resolveValue(maxVal, mainAxisSize) : Infinity
721
-
722
- // Clamp base size to get hypothetical size (CSS Flexbox spec)
723
- const hypotheticalSize = Math.max(minMain, Math.min(maxMain, baseSize))
724
-
725
- children.push({
726
- node: child,
727
- mainSize: baseSize, // Start from base size - distribution happens from here
728
- baseSize,
729
- mainMargin,
730
- flexGrow: childStyle.flexGrow,
731
- flexShrink: childStyle.flexShrink,
732
- minMain,
733
- maxMain,
734
- mainStartMarginAuto,
735
- mainEndMarginAuto,
736
- mainStartMarginValue,
737
- mainEndMarginValue,
738
- frozen: false, // Will be set during distribution
739
- })
740
-
741
- // Free space calculation uses BASE sizes (per Yoga/CSS spec algorithm)
742
- // The freeze loop handles min/max clamping iteratively
743
- totalBaseMain += baseSize + mainMargin
744
- }
745
-
746
- // Break children into flex lines for wrap support
747
- const lines = breakIntoLines(children, mainAxisSize, mainGap, style.flexWrap)
748
- const crossGap = isRow ? style.gap[1] : style.gap[0]
749
-
750
- // Process each line: distribute flex space
751
- for (const line of lines) {
752
- const lineChildren = line.children
753
- if (lineChildren.length === 0) continue
754
-
755
- // Calculate total base main and gaps for this line
756
- const lineTotalBaseMain = lineChildren.reduce((sum, c) => sum + c.baseSize + c.mainMargin, 0)
757
- const lineTotalGaps = lineChildren.length > 1 ? mainGap * (lineChildren.length - 1) : 0
758
-
759
- // Distribute free space using grow or shrink factors
760
- let effectiveMainSize = mainAxisSize
761
- if (Number.isNaN(mainAxisSize)) {
762
- // Shrink-wrap mode - check if max constraint applies
763
- const maxMainVal = isRow ? style.maxWidth : style.maxHeight
764
- if (maxMainVal.unit !== C.UNIT_UNDEFINED) {
765
- const maxMain = resolveValue(maxMainVal, isRow ? availableWidth : availableHeight)
766
- if (!Number.isNaN(maxMain) && lineTotalBaseMain + lineTotalGaps > maxMain) {
767
- const innerMain = isRow ? innerLeft + innerRight : innerTop + innerBottom
768
- effectiveMainSize = maxMain - innerMain
769
- }
770
- }
771
- }
772
-
773
- if (!Number.isNaN(effectiveMainSize)) {
774
- const adjustedFreeSpace = effectiveMainSize - lineTotalBaseMain - lineTotalGaps
775
- distributeFlexSpace(lineChildren, adjustedFreeSpace)
776
- }
777
-
778
- // Apply min/max constraints to final sizes
779
- for (const childLayout of lineChildren) {
780
- childLayout.mainSize = Math.max(childLayout.minMain, Math.min(childLayout.maxMain, childLayout.mainSize))
781
- }
782
- }
783
-
784
- // Calculate final used space and justify-content
785
- // For single-line, use all children; for multi-line, this applies per-line during positioning
786
- const totalGaps = children.length > 1 ? mainGap * (children.length - 1) : 0
787
- const usedMain = children.reduce((sum, c) => sum + c.mainSize + c.mainMargin, 0) + totalGaps
788
- // For auto-sized containers (NaN mainAxisSize), there's no remaining space to justify
789
- // Use NaN check instead of style check - handles minWidth/minHeight constraints properly
790
- const remainingSpace = Number.isNaN(mainAxisSize) ? 0 : mainAxisSize - usedMain
791
-
792
- // Handle auto margins on main axis
793
- // Auto margins absorb free space BEFORE justify-content
794
- const totalAutoMargins = children.reduce(
795
- (sum, c) => sum + (c.mainStartMarginAuto ? 1 : 0) + (c.mainEndMarginAuto ? 1 : 0),
796
- 0,
797
- )
798
- let hasAutoMargins = totalAutoMargins > 0
799
-
800
- // Auto margins absorb ALL remaining space (including negative for overflow positioning)
801
- if (hasAutoMargins) {
802
- const autoMarginValue = remainingSpace / totalAutoMargins
803
- for (const childLayout of children) {
804
- if (childLayout.mainStartMarginAuto) {
805
- childLayout.mainStartMarginValue = autoMarginValue
806
- }
807
- if (childLayout.mainEndMarginAuto) {
808
- childLayout.mainEndMarginValue = autoMarginValue
809
- }
810
- }
811
- }
812
- // When space is negative or zero, auto margins stay at 0
813
-
814
- let startOffset = 0
815
- let itemSpacing = mainGap
816
-
817
- // justify-content is ignored when any auto margins exist
818
- if (!hasAutoMargins) {
819
- switch (style.justifyContent) {
820
- case C.JUSTIFY_FLEX_END:
821
- startOffset = remainingSpace
822
- break
823
- case C.JUSTIFY_CENTER:
824
- startOffset = remainingSpace / 2
825
- break
826
- case C.JUSTIFY_SPACE_BETWEEN:
827
- // Only apply space-between when remaining space is positive
828
- // With overflow (negative), fall back to flex-start behavior
829
- if (children.length > 1 && remainingSpace > 0) {
830
- itemSpacing = mainGap + remainingSpace / (children.length - 1)
831
- }
832
- break
833
- case C.JUSTIFY_SPACE_AROUND:
834
- if (children.length > 0) {
835
- const extraSpace = remainingSpace / children.length
836
- startOffset = extraSpace / 2
837
- itemSpacing = mainGap + extraSpace
838
- }
839
- break
840
- case C.JUSTIFY_SPACE_EVENLY:
841
- if (children.length > 0) {
842
- const extraSpace = remainingSpace / (children.length + 1)
843
- startOffset = extraSpace
844
- itemSpacing = mainGap + extraSpace
845
- }
846
- break
847
- }
848
- }
849
-
850
- // NOTE: We do NOT round sizes here. Instead, we use edge-based rounding below.
851
- // This ensures adjacent elements share exact boundaries without gaps.
852
- // The key insight: round(pos) gives the edge position, width = round(end) - round(start)
853
-
854
- // Compute baseline alignment info if needed
855
- // For ALIGN_BASELINE in row direction, we need to know the max baseline first
856
- let maxBaseline = 0
857
- const childBaselines: number[] = []
858
- const hasBaselineAlignment =
859
- style.alignItems === C.ALIGN_BASELINE || relativeChildren.some((c) => c.style.alignSelf === C.ALIGN_BASELINE)
860
-
861
- if (hasBaselineAlignment && isRow) {
862
- // First pass: compute each child's baseline and find the maximum
863
- for (let i = 0; i < children.length; i++) {
864
- const childLayout = children[i]!
865
- const child = childLayout.node
866
- const childStyle = child.style
867
-
868
- // Get cross-axis (top/bottom) margins for this child
869
- // Use resolveEdgeValue to respect logical EDGE_START/END
870
- const topMargin = resolveEdgeValue(childStyle.margin, 1, style.flexDirection, contentWidth, direction)
871
-
872
- // Compute child's dimensions - need to do a mini-layout or use the cached size
873
- let childWidth: number
874
- let childHeight: number
875
- const widthDim = childStyle.width
876
- const heightDim = childStyle.height
877
-
878
- if (widthDim.unit === C.UNIT_POINT) {
879
- childWidth = widthDim.value
880
- } else if (widthDim.unit === C.UNIT_PERCENT && !Number.isNaN(mainAxisSize)) {
881
- childWidth = mainAxisSize * (widthDim.value / 100)
882
- } else {
883
- childWidth = childLayout.mainSize
884
- }
885
-
886
- if (heightDim.unit === C.UNIT_POINT) {
887
- childHeight = heightDim.value
888
- } else if (heightDim.unit === C.UNIT_PERCENT && !Number.isNaN(crossAxisSize)) {
889
- childHeight = crossAxisSize * (heightDim.value / 100)
890
- } else {
891
- // Auto height - need to layout to get intrinsic size
892
- // For now, do a preliminary layout (measurement, not final positioning)
893
- layoutNode(child, childLayout.mainSize, NaN, 0, 0, 0, 0, direction)
894
- childWidth = child.layout.width
895
- childHeight = child.layout.height
896
- }
897
-
898
- // Compute baseline: use baselineFunc if available, otherwise use bottom of content box
899
- let baseline: number
900
- if (child.baselineFunc !== null) {
901
- // Custom baseline function provided (e.g., for text nodes)
902
- baseline = topMargin + child.baselineFunc(childWidth, childHeight)
903
- } else {
904
- // Fallback: bottom of content box (default for non-text elements)
905
- // Note: We don't recursively propagate first-child baselines to avoid O(n^depth) cost
906
- // This is a simplification from CSS spec but acceptable for TUI use cases
907
- baseline = topMargin + childHeight
908
- }
909
- childBaselines.push(baseline)
910
- maxBaseline = Math.max(maxBaseline, baseline)
911
- }
912
- }
913
-
914
- // Compute line cross-axis sizes and offsets for flex-wrap
915
- // Each child needs to know its line's cross offset
916
- const childLineIndex = new Map<ChildLayout, number>()
917
- const lineCrossOffsets: number[] = []
918
- let cumulativeCrossOffset = 0
919
-
920
- for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
921
- const line = lines[lineIdx]!
922
- lineCrossOffsets.push(cumulativeCrossOffset)
923
-
924
- // Calculate max cross size for this line
925
- let maxLineCross = 0
926
- for (const childLayout of line.children) {
927
- childLineIndex.set(childLayout, lineIdx)
928
- // Estimate child cross size (will be computed more precisely during layout)
929
- const childStyle = childLayout.node.style
930
- const crossDim = isRow ? childStyle.height : childStyle.width
931
- const crossMarginStart = isRow
932
- ? resolveEdgeValue(childStyle.margin, 1, style.flexDirection, contentWidth, direction)
933
- : resolveEdgeValue(childStyle.margin, 0, style.flexDirection, contentWidth, direction)
934
- const crossMarginEnd = isRow
935
- ? resolveEdgeValue(childStyle.margin, 3, style.flexDirection, contentWidth, direction)
936
- : resolveEdgeValue(childStyle.margin, 2, style.flexDirection, contentWidth, direction)
937
-
938
- let childCross = 0
939
- if (crossDim.unit === C.UNIT_POINT) {
940
- childCross = crossDim.value
941
- } else if (crossDim.unit === C.UNIT_PERCENT && !Number.isNaN(crossAxisSize)) {
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
948
- } else {
949
- // Auto - use a default or measure. For now, use 0 and let stretch handle it.
950
- childCross = 0
951
- }
952
- maxLineCross = Math.max(maxLineCross, childCross + crossMarginStart + crossMarginEnd)
953
- }
954
- line.crossSize = maxLineCross > 0 ? maxLineCross : crossAxisSize / lines.length
955
- cumulativeCrossOffset += line.crossSize + crossGap
956
- }
957
-
958
- // Apply alignContent to distribute lines in the cross axis
959
- // This affects how multiple flex lines are positioned within the container
960
- const isWrapReverse = style.flexWrap === C.WRAP_WRAP_REVERSE
961
- const numLines = lines.length
962
- if (!Number.isNaN(crossAxisSize) && numLines > 0) {
963
- const totalLineCrossSize = cumulativeCrossOffset - crossGap // Remove trailing gap
964
- const freeSpace = crossAxisSize - totalLineCrossSize
965
- const alignContent = style.alignContent
966
-
967
- // Reset offsets based on alignContent
968
- if (freeSpace > 0 || alignContent === C.ALIGN_STRETCH) {
969
- switch (alignContent) {
970
- case C.ALIGN_FLEX_END:
971
- // Lines packed at end
972
- for (let i = 0; i < numLines; i++) {
973
- lineCrossOffsets[i]! += freeSpace
974
- }
975
- break
976
-
977
- case C.ALIGN_CENTER:
978
- // Lines centered
979
- const centerOffset = freeSpace / 2
980
- for (let i = 0; i < numLines; i++) {
981
- lineCrossOffsets[i]! += centerOffset
982
- }
983
- break
984
-
985
- case C.ALIGN_SPACE_BETWEEN:
986
- // First line at start, last at end, evenly distributed
987
- if (numLines > 1) {
988
- const gap = freeSpace / (numLines - 1)
989
- for (let i = 1; i < numLines; i++) {
990
- lineCrossOffsets[i]! += gap * i
991
- }
992
- }
993
- break
994
-
995
- case C.ALIGN_SPACE_AROUND:
996
- // Even spacing with half-space at edges
997
- const halfGap = freeSpace / (numLines * 2)
998
- for (let i = 0; i < numLines; i++) {
999
- lineCrossOffsets[i]! += halfGap + halfGap * 2 * i
1000
- }
1001
- break
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
-
1013
- case C.ALIGN_STRETCH:
1014
- // Distribute extra space evenly among lines
1015
- if (freeSpace > 0 && numLines > 0) {
1016
- const extraPerLine = freeSpace / numLines
1017
- for (let i = 0; i < numLines; i++) {
1018
- lines[i]!.crossSize += extraPerLine
1019
- // Recalculate offset for subsequent lines
1020
- if (i > 0) {
1021
- lineCrossOffsets[i] = lineCrossOffsets[i - 1]! + lines[i - 1]!.crossSize + crossGap
1022
- }
1023
- }
1024
- }
1025
- break
1026
-
1027
- // ALIGN_FLEX_START is the default - lines already at start
1028
- }
1029
- }
1030
-
1031
- // For wrap-reverse, lines should be positioned from the end of the cross axis
1032
- // The lines are already in reversed order from breakIntoLines().
1033
- // We just need to shift them so they align to the end instead of the start.
1034
- if (isWrapReverse) {
1035
- let totalLineCrossSize = 0
1036
- for (let i = 0; i < numLines; i++) {
1037
- totalLineCrossSize += lines[i]!.crossSize
1038
- }
1039
- totalLineCrossSize += crossGap * (numLines - 1)
1040
- const crossStartOffset = crossAxisSize - totalLineCrossSize
1041
- for (let i = 0; i < numLines; i++) {
1042
- lineCrossOffsets[i]! += crossStartOffset
1043
- }
1044
- }
1045
- }
1046
-
1047
- // Position and layout children
1048
- // For reverse directions, start from the END of the container
1049
- // For RTL row layouts, treat as reversed (children flow right-to-left)
1050
- // RTL + reverse cancels out (XOR behavior)
1051
- const isRTL = direction === C.DIRECTION_RTL
1052
- const effectiveReverse = isRow ? isRTL !== isReverse : isReverse
1053
- // Use fractional mainPos for edge-based rounding
1054
- let mainPos = effectiveReverse ? mainAxisSize - startOffset : startOffset
1055
- let currentLineIdx = -1
1056
-
1057
- log.debug?.(
1058
- "positioning children: isRow=%s, startOffset=%d, relativeChildren=%d, isReverse=%s, lines=%d",
1059
- isRow,
1060
- startOffset,
1061
- relativeChildren.length,
1062
- isReverse,
1063
- lines.length,
1064
- )
1065
-
1066
- for (let i = 0; i < children.length; i++) {
1067
- const childLayout = children[i]!
1068
- const child = childLayout.node
1069
- const childStyle = child.style
1070
-
1071
- // Check if we've moved to a new line (for flex-wrap)
1072
- const childLineIdx = childLineIndex.get(childLayout) ?? 0
1073
- if (childLineIdx !== currentLineIdx) {
1074
- currentLineIdx = childLineIdx
1075
- // Reset mainPos for new line
1076
- mainPos = effectiveReverse ? mainAxisSize - startOffset : startOffset
1077
- }
1078
-
1079
- // Get cross-axis offset for this child's line
1080
- const lineCrossOffset = lineCrossOffsets[childLineIdx] ?? 0
1081
-
1082
- // For main-axis margins, use computed auto margin values
1083
- // For cross-axis margins, resolve normally (auto margins on cross axis handled separately)
1084
- let childMarginLeft: number
1085
- let childMarginTop: number
1086
- let childMarginRight: number
1087
- let childMarginBottom: number
1088
-
1089
- // CSS spec: percentage margins resolve against containing block's WIDTH only
1090
- // Use resolveEdgeValue to respect logical EDGE_START/END
1091
- if (isRow) {
1092
- // Row: main axis is horizontal
1093
- // In row-reverse, mainStart=right(2), mainEnd=left(0)
1094
- childMarginLeft =
1095
- childLayout.mainStartMarginAuto && !isReverse
1096
- ? childLayout.mainStartMarginValue
1097
- : childLayout.mainEndMarginAuto && isReverse
1098
- ? childLayout.mainEndMarginValue
1099
- : resolveEdgeValue(childStyle.margin, 0, style.flexDirection, contentWidth, direction)
1100
- childMarginRight =
1101
- childLayout.mainEndMarginAuto && !isReverse
1102
- ? childLayout.mainEndMarginValue
1103
- : childLayout.mainStartMarginAuto && isReverse
1104
- ? childLayout.mainStartMarginValue
1105
- : resolveEdgeValue(childStyle.margin, 2, style.flexDirection, contentWidth, direction)
1106
- childMarginTop = resolveEdgeValue(childStyle.margin, 1, style.flexDirection, contentWidth, direction)
1107
- childMarginBottom = resolveEdgeValue(childStyle.margin, 3, style.flexDirection, contentWidth, direction)
1108
- } else {
1109
- // Column: main axis is vertical
1110
- // In column-reverse, mainStart=bottom(3), mainEnd=top(1)
1111
- childMarginTop =
1112
- childLayout.mainStartMarginAuto && !isReverse
1113
- ? childLayout.mainStartMarginValue
1114
- : childLayout.mainEndMarginAuto && isReverse
1115
- ? childLayout.mainEndMarginValue
1116
- : resolveEdgeValue(childStyle.margin, 1, style.flexDirection, contentWidth, direction)
1117
- childMarginBottom =
1118
- childLayout.mainEndMarginAuto && !isReverse
1119
- ? childLayout.mainEndMarginValue
1120
- : childLayout.mainStartMarginAuto && isReverse
1121
- ? childLayout.mainStartMarginValue
1122
- : resolveEdgeValue(childStyle.margin, 3, style.flexDirection, contentWidth, direction)
1123
- childMarginLeft = resolveEdgeValue(childStyle.margin, 0, style.flexDirection, contentWidth, direction)
1124
- childMarginRight = resolveEdgeValue(childStyle.margin, 2, style.flexDirection, contentWidth, direction)
1125
- }
1126
-
1127
- // Main axis size comes from flex algorithm (already rounded)
1128
- const childMainSize = childLayout.mainSize
1129
-
1130
- // Cross axis: determine alignment mode
1131
- let alignment = style.alignItems
1132
- if (childStyle.alignSelf !== C.ALIGN_AUTO) {
1133
- alignment = childStyle.alignSelf
1134
- }
1135
-
1136
- // Cross axis size depends on alignment and child's explicit dimensions
1137
- // IMPORTANT: Resolve percent against parent's cross axis, not child's available
1138
- let childCrossSize: number
1139
- const crossDim = isRow ? childStyle.height : childStyle.width
1140
- const crossMargin = isRow ? childMarginTop + childMarginBottom : childMarginLeft + childMarginRight
1141
-
1142
- // Check if parent has definite cross-axis size
1143
- // Parent can have definite cross from either:
1144
- // 1. Explicit style (width/height in points or percent)
1145
- // 2. Definite available space (crossAxisSize is not NaN)
1146
- const parentCrossDim = isRow ? style.height : style.width
1147
- const parentHasDefiniteCrossStyle = parentCrossDim.unit === C.UNIT_POINT || parentCrossDim.unit === C.UNIT_PERCENT
1148
- // crossAxisSize comes from available space - if it's a real number, we have a constraint
1149
- const parentHasDefiniteCross = parentHasDefiniteCrossStyle || !Number.isNaN(crossAxisSize)
1150
-
1151
- if (crossDim.unit === C.UNIT_POINT) {
1152
- // Explicit cross size
1153
- childCrossSize = crossDim.value
1154
- } else if (crossDim.unit === C.UNIT_PERCENT) {
1155
- // Percent of PARENT's cross axis (resolveValue handles NaN → 0)
1156
- childCrossSize = resolveValue(crossDim, crossAxisSize)
1157
- } else if (parentHasDefiniteCross && alignment === C.ALIGN_STRETCH) {
1158
- // Stretch alignment with definite parent cross size - fill the cross axis
1159
- childCrossSize = crossAxisSize - crossMargin
1160
- } else {
1161
- // Non-stretch alignment or no definite cross size - shrink-wrap to content
1162
- childCrossSize = NaN
1163
- }
1164
-
1165
- // Apply cross-axis min/max constraints
1166
- const crossMinVal = isRow ? childStyle.minHeight : childStyle.minWidth
1167
- const crossMaxVal = isRow ? childStyle.maxHeight : childStyle.maxWidth
1168
- const crossMin = crossMinVal.unit !== C.UNIT_UNDEFINED ? resolveValue(crossMinVal, crossAxisSize) : 0
1169
- const crossMax = crossMaxVal.unit !== C.UNIT_UNDEFINED ? resolveValue(crossMaxVal, crossAxisSize) : Infinity
1170
-
1171
- // Apply constraints - for NaN (shrink-wrap), use min as floor
1172
- if (Number.isNaN(childCrossSize)) {
1173
- // For shrink-wrap, min sets the floor - child will be at least this size
1174
- if (crossMin > 0) {
1175
- childCrossSize = crossMin
1176
- }
1177
- } else {
1178
- childCrossSize = Math.max(crossMin, Math.min(crossMax, childCrossSize))
1179
- }
1180
-
1181
- // Handle intrinsic sizing for auto-sized children
1182
- // For auto main size children, use flex-computed size if flexGrow > 0,
1183
- // otherwise pass remaining available space for shrink-wrap behavior
1184
- const mainDim = isRow ? childStyle.width : childStyle.height
1185
- const mainIsAuto = mainDim.unit === C.UNIT_AUTO || mainDim.unit === C.UNIT_UNDEFINED
1186
- const hasFlexGrow = childLayout.flexGrow > 0
1187
- // Check if parent has definite main-axis size
1188
- const parentMainDim = isRow ? style.width : style.height
1189
- const parentHasDefiniteMain = parentMainDim.unit === C.UNIT_POINT || parentMainDim.unit === C.UNIT_PERCENT
1190
- // Use flex-computed mainSize for all cases - it includes padding/border as minimum
1191
- // The flex algorithm already computed the proper size based on content/padding/border
1192
- let effectiveMainSize: number
1193
- if (hasFlexGrow) {
1194
- effectiveMainSize = childMainSize
1195
- } else if (mainIsAuto) {
1196
- // Child is auto: use flex-computed size which includes padding/border minimum
1197
- effectiveMainSize = childMainSize
1198
- } else {
1199
- effectiveMainSize = childMainSize
1200
- }
1201
-
1202
- let childWidth = isRow ? effectiveMainSize : childCrossSize
1203
- let childHeight = isRow ? childCrossSize : effectiveMainSize
1204
-
1205
- // Only use measure function for intrinsic sizing when flexGrow is NOT set
1206
- // When flexGrow > 0, the flex algorithm determines size, not the content
1207
- const shouldMeasure = child.hasMeasureFunc() && child.children.length === 0 && !hasFlexGrow
1208
- if (shouldMeasure) {
1209
- const widthAuto = childStyle.width.unit === C.UNIT_AUTO || childStyle.width.unit === C.UNIT_UNDEFINED
1210
- const heightAuto = childStyle.height.unit === C.UNIT_AUTO || childStyle.height.unit === C.UNIT_UNDEFINED
1211
-
1212
- if (widthAuto || heightAuto) {
1213
- // Call measure function with available space
1214
- const widthMode = widthAuto ? C.MEASURE_MODE_AT_MOST : C.MEASURE_MODE_EXACTLY
1215
- const heightMode = heightAuto ? C.MEASURE_MODE_UNDEFINED : C.MEASURE_MODE_EXACTLY
1216
-
1217
- // For unconstrained dimensions (NaN), use Infinity for measure func
1218
- const rawAvailW = widthAuto
1219
- ? isRow
1220
- ? mainAxisSize - mainPos // Remaining space after previous children
1221
- : crossAxisSize - crossMargin
1222
- : childStyle.width.value
1223
- const rawAvailH = heightAuto
1224
- ? isRow
1225
- ? crossAxisSize - crossMargin
1226
- : mainAxisSize - mainPos // Remaining space for COLUMN
1227
- : childStyle.height.value
1228
- const availW = Number.isNaN(rawAvailW) ? Infinity : rawAvailW
1229
- const availH = Number.isNaN(rawAvailH) ? Infinity : rawAvailH
1230
-
1231
- const measured = child.measureFunc!(availW, widthMode, availH, heightMode)
1232
-
1233
- // For measure function nodes without flexGrow, intrinsic size takes precedence
1234
- if (widthAuto) {
1235
- childWidth = measured.width
1236
- }
1237
- if (heightAuto) {
1238
- childHeight = measured.height
1239
- }
1240
- }
1241
- }
1242
-
1243
- // Child position within content area (fractional for edge-based rounding)
1244
- // For reverse directions (including RTL for row), position from mainPos - childSize
1245
- // IMPORTANT: In reverse, swap which margin is applied to which side
1246
- // EDGE_START (margin[0]/[1]) becomes the trailing margin in reverse layout
1247
- // EDGE_END (margin[2]/[3]) becomes the leading margin in reverse layout
1248
- // For flex-wrap, add lineCrossOffset to cross-axis position
1249
- let childX: number
1250
- let childY: number
1251
- if (effectiveReverse) {
1252
- if (isRow) {
1253
- // Row-reverse or RTL: items positioned from right
1254
- // In RTL, EDGE_START is the right edge, so use childMarginRight as trailing margin
1255
- // In row-reverse LTR, EDGE_END is the right edge, so use childMarginRight too
1256
- childX = mainPos - childMainSize - childMarginRight
1257
- childY = lineCrossOffset + childMarginTop
1258
- } else {
1259
- // Column-reverse: items positioned from bottom
1260
- childX = lineCrossOffset + childMarginLeft
1261
- childY = mainPos - childMainSize - childMarginTop
1262
- }
1263
- } else {
1264
- childX = isRow ? mainPos + childMarginLeft : lineCrossOffset + childMarginLeft
1265
- childY = isRow ? lineCrossOffset + childMarginTop : mainPos + childMarginTop
1266
- }
1267
-
1268
- // Edge-based rounding using ABSOLUTE coordinates (Yoga-compatible)
1269
- // This ensures adjacent elements share exact boundaries without gaps
1270
- // Key insight: round absolute edges, derive sizes from differences
1271
- const fractionalLeft = innerLeft + childX
1272
- const fractionalTop = innerTop + childY
1273
-
1274
- // Compute position offsets for RELATIVE positioned children
1275
- // CSS spec: position:static ignores insets; only position:relative applies them.
1276
- // These must be included in the absolute position BEFORE rounding (Yoga-compatible)
1277
- let posOffsetX = 0
1278
- let posOffsetY = 0
1279
- if (childStyle.positionType === C.POSITION_TYPE_RELATIVE) {
1280
- const relLeftPos = childStyle.position[0]
1281
- const relTopPos = childStyle.position[1]
1282
- const relRightPos = childStyle.position[2]
1283
- const relBottomPos = childStyle.position[3]
1284
-
1285
- // Left offset (takes precedence over right)
1286
- if (relLeftPos.unit !== C.UNIT_UNDEFINED) {
1287
- posOffsetX = resolveValue(relLeftPos, contentWidth)
1288
- } else if (relRightPos.unit !== C.UNIT_UNDEFINED) {
1289
- posOffsetX = -resolveValue(relRightPos, contentWidth)
1290
- }
1291
-
1292
- // Top offset (takes precedence over bottom)
1293
- if (relTopPos.unit !== C.UNIT_UNDEFINED) {
1294
- posOffsetY = resolveValue(relTopPos, contentHeight)
1295
- } else if (relBottomPos.unit !== C.UNIT_UNDEFINED) {
1296
- posOffsetY = -resolveValue(relBottomPos, contentHeight)
1297
- }
1298
- }
1299
-
1300
- // Compute ABSOLUTE float positions for edge rounding (including position offsets)
1301
- // absX/absY are the parent's absolute position from document root
1302
- // Include BOTH parent's position offset and child's position offset
1303
- const absChildLeft = absX + marginLeft + parentPosOffsetX + fractionalLeft + posOffsetX
1304
- const absChildTop = absY + marginTop + parentPosOffsetY + fractionalTop + posOffsetY
1305
-
1306
- // For main axis: round ABSOLUTE edges and derive size
1307
- // Only use edge-based rounding when childMainSize is valid (positive)
1308
- let roundedAbsMainStart: number
1309
- let roundedAbsMainEnd: number
1310
- let edgeBasedMainSize: number
1311
- const useEdgeBasedRounding = childMainSize > 0
1312
-
1313
- // Compute child's box model minimum early (needed for edge-based rounding)
1314
- // Use resolveEdgeValue to respect logical EDGE_START/END for padding
1315
- const childPaddingL = resolveEdgeValue(childStyle.padding, 0, childStyle.flexDirection, contentWidth, direction)
1316
- const childPaddingT = resolveEdgeValue(childStyle.padding, 1, childStyle.flexDirection, contentWidth, direction)
1317
- const childPaddingR = resolveEdgeValue(childStyle.padding, 2, childStyle.flexDirection, contentWidth, direction)
1318
- const childPaddingB = resolveEdgeValue(childStyle.padding, 3, childStyle.flexDirection, contentWidth, direction)
1319
- const childBorderL = resolveEdgeBorderValue(childStyle.border, 0, childStyle.flexDirection, direction)
1320
- const childBorderT = resolveEdgeBorderValue(childStyle.border, 1, childStyle.flexDirection, direction)
1321
- const childBorderR = resolveEdgeBorderValue(childStyle.border, 2, childStyle.flexDirection, direction)
1322
- const childBorderB = resolveEdgeBorderValue(childStyle.border, 3, childStyle.flexDirection, direction)
1323
- const childMinW = childPaddingL + childPaddingR + childBorderL + childBorderR
1324
- const childMinH = childPaddingT + childPaddingB + childBorderT + childBorderB
1325
- const childMinMain = isRow ? childMinW : childMinH
1326
-
1327
- // Apply box model constraint to childMainSize before edge rounding
1328
- const constrainedMainSize = Math.max(childMainSize, childMinMain)
1329
-
1330
- if (useEdgeBasedRounding) {
1331
- if (isRow) {
1332
- roundedAbsMainStart = Math.round(absChildLeft)
1333
- roundedAbsMainEnd = Math.round(absChildLeft + constrainedMainSize)
1334
- edgeBasedMainSize = roundedAbsMainEnd - roundedAbsMainStart
1335
- } else {
1336
- roundedAbsMainStart = Math.round(absChildTop)
1337
- roundedAbsMainEnd = Math.round(absChildTop + constrainedMainSize)
1338
- edgeBasedMainSize = roundedAbsMainEnd - roundedAbsMainStart
1339
- }
1340
- } else {
1341
- // For children without valid main size, use simple rounding
1342
- roundedAbsMainStart = isRow ? Math.round(absChildLeft) : Math.round(absChildTop)
1343
- edgeBasedMainSize = childMinMain // Use minimum size instead of 0
1344
- }
1345
-
1346
- // Calculate child's RELATIVE position (stored in layout)
1347
- // Yoga behavior: position is rounded locally, size uses absolute edge rounding
1348
- // This ensures sizes are pixel-perfect at document level while positions remain intuitive
1349
- const childLeft = Math.round(fractionalLeft + posOffsetX)
1350
- const childTop = Math.round(fractionalTop + posOffsetY)
1351
-
1352
- // Check if cross axis is auto-sized (needed for deciding what to pass to layoutNode)
1353
- const crossDimForLayoutCall = isRow ? childStyle.height : childStyle.width
1354
- const crossIsAutoForLayoutCall =
1355
- crossDimForLayoutCall.unit === C.UNIT_AUTO || crossDimForLayoutCall.unit === C.UNIT_UNDEFINED
1356
- const mainDimForLayoutCall = isRow ? childStyle.width : childStyle.height
1357
-
1358
- // For auto-sized children (no flexGrow, no measureFunc), pass NaN to let them compute intrinsic size
1359
- // Otherwise layoutNode would subtract margins from the available size
1360
- // IMPORTANT: For percent-sized children, pass PARENT's content size so the child resolves its
1361
- // percent against the correct containing block. This ensures grandchildren also resolve correctly.
1362
- const mainIsPercent = mainDimForLayoutCall.unit === C.UNIT_PERCENT
1363
- const crossIsPercent = crossDimForLayoutCall.unit === C.UNIT_PERCENT
1364
-
1365
- let passWidthToChild: number
1366
- if (isRow && mainIsAuto && !hasFlexGrow) {
1367
- passWidthToChild = NaN
1368
- } else if (!isRow && crossIsAutoForLayoutCall && !parentHasDefiniteCross) {
1369
- passWidthToChild = NaN
1370
- } else if (isRow && mainIsPercent) {
1371
- // Percent width (main axis in row): pass parent's content width
1372
- passWidthToChild = contentWidth
1373
- } else if (!isRow && crossIsPercent) {
1374
- // Percent width (cross axis in column): pass parent's content width
1375
- passWidthToChild = contentWidth
1376
- } else {
1377
- passWidthToChild = childWidth
1378
- }
1379
-
1380
- let passHeightToChild: number
1381
- if (!isRow && mainIsAuto && !hasFlexGrow) {
1382
- passHeightToChild = NaN
1383
- } else if (isRow && crossIsAutoForLayoutCall && !parentHasDefiniteCross) {
1384
- passHeightToChild = NaN
1385
- } else if (!isRow && mainIsPercent) {
1386
- // Percent height (main axis in column): pass parent's content height
1387
- passHeightToChild = contentHeight
1388
- } else if (isRow && crossIsPercent) {
1389
- // Percent height (cross axis in row): pass parent's content height
1390
- passHeightToChild = contentHeight
1391
- } else {
1392
- passHeightToChild = childHeight
1393
- }
1394
-
1395
- // Recurse to layout any grandchildren
1396
- // Pass the child's FLOAT absolute position (margin box start, before child's own margin)
1397
- // absChildLeft/Top include the child's margins, so subtract them to get margin box start
1398
- const childAbsX = absChildLeft - childMarginLeft
1399
- const childAbsY = absChildTop - childMarginTop
1400
- layoutNode(child, passWidthToChild, passHeightToChild, childLeft, childTop, childAbsX, childAbsY, direction)
1401
-
1402
- // Enforce box model constraint: child can't be smaller than its padding + border
1403
- // (using childMinW/childMinH computed earlier for edge-based rounding)
1404
- if (childWidth < childMinW) childWidth = childMinW
1405
- if (childHeight < childMinH) childHeight = childMinH
1406
-
1407
- // Set this child's layout - override what layoutNode computed
1408
- // Override if any of:
1409
- // - Child has explicit main dimension (not auto)
1410
- // - Child has flexGrow > 0 (flex distribution applied)
1411
- // - Child has measureFunc
1412
- // - Parent did flex distribution (effectiveMainSize not NaN) - covers flex-shrink case
1413
- const hasMeasure = child.hasMeasureFunc() && child.children.length === 0
1414
- const parentDidFlexDistribution = !Number.isNaN(effectiveMainSize)
1415
- if (!mainIsAuto || hasFlexGrow || hasMeasure || parentDidFlexDistribution) {
1416
- // Use edge-based rounding: size = round(end_edge) - round(start_edge)
1417
- if (isRow) {
1418
- child.layout.width = edgeBasedMainSize
1419
- } else {
1420
- child.layout.height = edgeBasedMainSize
1421
- }
1422
- }
1423
- // Cross axis: only override for explicit sizing or when we have a real constraint
1424
- // For auto-sized children, let layoutNode determine the size
1425
- const crossDimForCheck = isRow ? childStyle.height : childStyle.width
1426
- const crossIsAuto = crossDimForCheck.unit === C.UNIT_AUTO || crossDimForCheck.unit === C.UNIT_UNDEFINED
1427
- // Only override if child has explicit sizing OR parent has explicit cross size
1428
- // When parent has auto cross size, let children shrink-wrap first
1429
- // Note: parentCrossDim and parentHasDefiniteCross already computed above
1430
- const parentCrossIsAuto = !parentHasDefiniteCross
1431
- // Also check if childCrossSize was constrained by min/max - if so, we should override
1432
- const hasCrossMinMax = crossMinVal.unit !== C.UNIT_UNDEFINED || crossMaxVal.unit !== C.UNIT_UNDEFINED
1433
- const shouldOverrideCross =
1434
- !crossIsAuto ||
1435
- (!parentCrossIsAuto && alignment === C.ALIGN_STRETCH) ||
1436
- (hasCrossMinMax && !Number.isNaN(childCrossSize))
1437
- if (shouldOverrideCross) {
1438
- if (isRow) {
1439
- child.layout.height = Math.round(childHeight)
1440
- } else {
1441
- child.layout.width = Math.round(childWidth)
1442
- }
1443
- }
1444
- // Store RELATIVE position (within parent's content area), not absolute
1445
- // This matches Yoga's behavior where getComputedLeft/Top return relative positions
1446
- // Position offsets are already included in childLeft/childTop via edge-based rounding
1447
- child.layout.left = childLeft
1448
- child.layout.top = childTop
1449
-
1450
- // Update childWidth/childHeight to match actual computed layout for mainPos calculation
1451
- childWidth = child.layout.width
1452
- childHeight = child.layout.height
1453
-
1454
- // Apply cross-axis alignment offset
1455
- const finalCrossSize = isRow ? child.layout.height : child.layout.width
1456
- let crossOffset = 0
1457
-
1458
- // Check for auto margins on cross axis - they override alignment
1459
- const crossStartMargin = isRow ? childStyle.margin[1] : childStyle.margin[0] // top for row, left for column
1460
- const crossEndMargin = isRow ? childStyle.margin[3] : childStyle.margin[2] // bottom for row, right for column
1461
- const hasAutoStartMargin = crossStartMargin.unit === C.UNIT_AUTO
1462
- const hasAutoEndMargin = crossEndMargin.unit === C.UNIT_AUTO
1463
- const availableCrossSpace = crossAxisSize - finalCrossSize - crossMargin
1464
-
1465
- if (hasAutoStartMargin && hasAutoEndMargin) {
1466
- // Both auto: center the item
1467
- crossOffset = availableCrossSpace / 2
1468
- } else if (hasAutoStartMargin) {
1469
- // Auto start margin: push to end
1470
- crossOffset = availableCrossSpace
1471
- } else if (hasAutoEndMargin) {
1472
- // Auto end margin: stay at start (crossOffset = 0)
1473
- crossOffset = 0
1474
- } else {
1475
- // No auto margins: use alignment
1476
- switch (alignment) {
1477
- case C.ALIGN_FLEX_END:
1478
- crossOffset = availableCrossSpace
1479
- break
1480
- case C.ALIGN_CENTER:
1481
- crossOffset = availableCrossSpace / 2
1482
- break
1483
- case C.ALIGN_BASELINE:
1484
- // Baseline alignment only applies to row direction
1485
- // For column direction, it falls through to flex-start (default)
1486
- if (isRow && childBaselines.length > 0) {
1487
- crossOffset = maxBaseline - childBaselines[i]!
1488
- }
1489
- break
1490
- }
1491
- }
1492
-
1493
- if (crossOffset !== 0) {
1494
- if (isRow) {
1495
- child.layout.top += Math.round(crossOffset)
1496
- } else {
1497
- child.layout.left += Math.round(crossOffset)
1498
- }
1499
- }
1500
-
1501
- // Advance main position using CONSTRAINED size for proper positioning
1502
- // Use constrainedMainSize (box model minimum applied) instead of childLayout.mainSize
1503
- const fractionalMainSize = constrainedMainSize
1504
- // Use computed margin values (including auto margins)
1505
- const totalMainMargin = childLayout.mainStartMarginValue + childLayout.mainEndMarginValue
1506
- log.debug?.(
1507
- " child %d: mainPos=%d → top=%d (fractionalMainSize=%d, totalMainMargin=%d)",
1508
- i,
1509
- mainPos,
1510
- child.layout.top,
1511
- fractionalMainSize,
1512
- totalMainMargin,
1513
- )
1514
- if (effectiveReverse) {
1515
- mainPos -= fractionalMainSize + totalMainMargin
1516
- if (i < children.length - 1) {
1517
- mainPos -= itemSpacing
1518
- }
1519
- } else {
1520
- mainPos += fractionalMainSize + totalMainMargin
1521
- if (i < children.length - 1) {
1522
- mainPos += itemSpacing
1523
- }
1524
- }
1525
- }
1526
-
1527
- // For auto-sized containers (including root), shrink-wrap to content
1528
- // Compute actual used main space from child layouts (not pre-computed childLayout.mainSize which may be 0)
1529
- let actualUsedMain = 0
1530
- for (const childLayout of children) {
1531
- const childMainSize = isRow ? childLayout.node.layout.width : childLayout.node.layout.height
1532
- const totalMainMargin = childLayout.mainStartMarginValue + childLayout.mainEndMarginValue
1533
- actualUsedMain += childMainSize + totalMainMargin
1534
- }
1535
- actualUsedMain += totalGaps
1536
-
1537
- if (isRow && style.width.unit !== C.UNIT_POINT && style.width.unit !== C.UNIT_PERCENT) {
1538
- // Auto-width row: shrink-wrap to content
1539
- nodeWidth = actualUsedMain + innerLeft + innerRight
1540
- }
1541
- if (!isRow && style.height.unit !== C.UNIT_POINT && style.height.unit !== C.UNIT_PERCENT) {
1542
- // Auto-height column: shrink-wrap to content
1543
- nodeHeight = actualUsedMain + innerTop + innerBottom
1544
- }
1545
- // For cross axis, find the max child size
1546
- // CSS spec: percentage margins resolve against containing block's WIDTH only
1547
- // Use resolveEdgeValue to respect logical EDGE_START/END
1548
- let maxCrossSize = 0
1549
- for (const childLayout of children) {
1550
- const childCross = isRow ? childLayout.node.layout.height : childLayout.node.layout.width
1551
- const childMargin = isRow
1552
- ? resolveEdgeValue(childLayout.node.style.margin, 1, style.flexDirection, contentWidth, direction) +
1553
- resolveEdgeValue(childLayout.node.style.margin, 3, style.flexDirection, contentWidth, direction)
1554
- : resolveEdgeValue(childLayout.node.style.margin, 0, style.flexDirection, contentWidth, direction) +
1555
- resolveEdgeValue(childLayout.node.style.margin, 2, style.flexDirection, contentWidth, direction)
1556
- maxCrossSize = Math.max(maxCrossSize, childCross + childMargin)
1557
- }
1558
- // Cross-axis shrink-wrap for auto-sized dimension
1559
- // Only shrink-wrap if the original available size was truly unconstrained (NaN)
1560
- // If a definite size was passed to calculateLayout, keep that size
1561
- if (
1562
- isRow &&
1563
- style.height.unit !== C.UNIT_POINT &&
1564
- style.height.unit !== C.UNIT_PERCENT &&
1565
- Number.isNaN(availableHeight)
1566
- ) {
1567
- // Auto-height row with unconstrained height: shrink-wrap to max child height
1568
- nodeHeight = maxCrossSize + innerTop + innerBottom
1569
- }
1570
- if (
1571
- !isRow &&
1572
- style.width.unit !== C.UNIT_POINT &&
1573
- style.width.unit !== C.UNIT_PERCENT &&
1574
- Number.isNaN(availableWidth)
1575
- ) {
1576
- // Auto-width column with unconstrained width: shrink-wrap to max child width
1577
- nodeWidth = maxCrossSize + innerLeft + innerRight
1578
- }
1579
- }
1580
-
1581
- // Re-apply min/max constraints after any shrink-wrap adjustments
1582
- // This ensures containers don't violate their constraints after auto-sizing
1583
- nodeWidth = applyMinMax(nodeWidth, style.minWidth, style.maxWidth, availableWidth)
1584
- nodeHeight = applyMinMax(nodeHeight, style.minHeight, style.maxHeight, availableHeight)
1585
-
1586
- // Re-enforce box model constraint: minimum size = padding + border
1587
- // This must be applied AFTER applyMinMax since min/max can't reduce below padding+border
1588
- if (!Number.isNaN(nodeWidth) && nodeWidth < minInnerWidth) {
1589
- nodeWidth = minInnerWidth
1590
- }
1591
- if (!Number.isNaN(nodeHeight) && nodeHeight < minInnerHeight) {
1592
- nodeHeight = minInnerHeight
1593
- }
1594
-
1595
- // Set this node's layout using edge-based rounding (Yoga-compatible)
1596
- // Use parentPosOffsetX/Y computed earlier (includes position offsets)
1597
- // Compute absolute positions for edge-based rounding
1598
- const absNodeLeft = absX + marginLeft + parentPosOffsetX
1599
- const absNodeTop = absY + marginTop + parentPosOffsetY
1600
- const absNodeRight = absNodeLeft + nodeWidth
1601
- const absNodeBottom = absNodeTop + nodeHeight
1602
-
1603
- // Round edges and derive sizes (Yoga algorithm)
1604
- const roundedAbsLeft = Math.round(absNodeLeft)
1605
- const roundedAbsTop = Math.round(absNodeTop)
1606
- const roundedAbsRight = Math.round(absNodeRight)
1607
- const roundedAbsBottom = Math.round(absNodeBottom)
1608
-
1609
- layout.width = roundedAbsRight - roundedAbsLeft
1610
- layout.height = roundedAbsBottom - roundedAbsTop
1611
- // Position is relative to parent, derived from absolute rounding
1612
- const roundedAbsParentLeft = Math.round(absX)
1613
- const roundedAbsParentTop = Math.round(absY)
1614
- layout.left = roundedAbsLeft - roundedAbsParentLeft
1615
- layout.top = roundedAbsTop - roundedAbsParentTop
1616
-
1617
- // Layout absolute children - handle left/right/top/bottom offsets
1618
- // Absolute positioning uses the PADDING BOX as the containing block
1619
- // (inside border but INCLUDING padding, not the content box)
1620
- const absInnerLeft = borderLeft
1621
- const absInnerTop = borderTop
1622
- const absInnerRight = borderRight
1623
- const absInnerBottom = borderBottom
1624
- const absPaddingBoxW = nodeWidth - absInnerLeft - absInnerRight
1625
- const absPaddingBoxH = nodeHeight - absInnerTop - absInnerBottom
1626
- // Content box dimensions for percentage resolution of absolute children
1627
- const absContentBoxW = absPaddingBoxW - paddingLeft - paddingRight
1628
- const absContentBoxH = absPaddingBoxH - paddingTop - paddingBottom
1629
- const isRow = isRowDirection(style.flexDirection)
1630
-
1631
- for (const child of absoluteChildren) {
1632
- const childStyle = child.style
1633
- // CSS spec: percentage margins resolve against containing block's WIDTH only
1634
- // Use resolveEdgeValue to respect logical EDGE_START/END
1635
- const childMarginLeft = resolveEdgeValue(childStyle.margin, 0, style.flexDirection, nodeWidth, direction)
1636
- const childMarginTop = resolveEdgeValue(childStyle.margin, 1, style.flexDirection, nodeWidth, direction)
1637
- const childMarginRight = resolveEdgeValue(childStyle.margin, 2, style.flexDirection, nodeWidth, direction)
1638
- const childMarginBottom = resolveEdgeValue(childStyle.margin, 3, style.flexDirection, nodeWidth, direction)
1639
-
1640
- // Position offsets from setPosition(edge, value)
1641
- const leftPos = childStyle.position[0]
1642
- const topPos = childStyle.position[1]
1643
- const rightPos = childStyle.position[2]
1644
- const bottomPos = childStyle.position[3]
1645
-
1646
- const hasLeft = leftPos.unit !== C.UNIT_UNDEFINED
1647
- const hasRight = rightPos.unit !== C.UNIT_UNDEFINED
1648
- const hasTop = topPos.unit !== C.UNIT_UNDEFINED
1649
- const hasBottom = bottomPos.unit !== C.UNIT_UNDEFINED
1650
-
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)
1656
-
1657
- // Calculate available size for absolute child using padding box
1658
- const contentW = absPaddingBoxW
1659
- const contentH = absPaddingBoxH
1660
-
1661
- // Determine child width
1662
- // - If both left and right set with auto width: stretch to fill
1663
- // - If auto width but NOT both left and right: shrink to intrinsic (NaN)
1664
- // - For percentage width: resolve against content box
1665
- // - Otherwise (explicit width): use available width as constraint
1666
- let childAvailWidth: number
1667
- const widthIsAuto = childStyle.width.unit === C.UNIT_AUTO || childStyle.width.unit === C.UNIT_UNDEFINED
1668
- const widthIsPercent = childStyle.width.unit === C.UNIT_PERCENT
1669
- if (widthIsAuto && hasLeft && hasRight) {
1670
- childAvailWidth = contentW - leftOffset - rightOffset - childMarginLeft - childMarginRight
1671
- } else if (widthIsAuto) {
1672
- childAvailWidth = NaN // Shrink to intrinsic size
1673
- } else if (widthIsPercent) {
1674
- // Percentage widths resolve against content box (inside padding)
1675
- childAvailWidth = absContentBoxW
1676
- } else {
1677
- childAvailWidth = contentW
1678
- }
1679
-
1680
- // Determine child height
1681
- // - If both top and bottom set with auto height: stretch to fill
1682
- // - If auto height but NOT both top and bottom: shrink to intrinsic (NaN)
1683
- // - For percentage height: resolve against content box
1684
- // - Otherwise (explicit height): use available height as constraint
1685
- let childAvailHeight: number
1686
- const heightIsAuto = childStyle.height.unit === C.UNIT_AUTO || childStyle.height.unit === C.UNIT_UNDEFINED
1687
- const heightIsPercent = childStyle.height.unit === C.UNIT_PERCENT
1688
- if (heightIsAuto && hasTop && hasBottom) {
1689
- childAvailHeight = contentH - topOffset - bottomOffset - childMarginTop - childMarginBottom
1690
- } else if (heightIsAuto) {
1691
- childAvailHeight = NaN // Shrink to intrinsic size
1692
- } else if (heightIsPercent) {
1693
- // Percentage heights resolve against content box (inside padding)
1694
- childAvailHeight = absContentBoxH
1695
- } else {
1696
- childAvailHeight = contentH
1697
- }
1698
-
1699
- // Compute child position
1700
- let childX = childMarginLeft + leftOffset
1701
- let childY = childMarginTop + topOffset
1702
-
1703
- // First, layout the child to get its dimensions
1704
- // Use padding box origin (absInnerLeft/Top = border only)
1705
- // Compute child's absolute position (margin box start, before child's own margin)
1706
- // Parent's padding box = absX + marginLeft + borderLeft = absX + marginLeft + absInnerLeft
1707
- // Child's margin box = parent's padding box + leftOffset
1708
- const childAbsX = absX + marginLeft + absInnerLeft + leftOffset
1709
- const childAbsY = absY + marginTop + absInnerTop + topOffset
1710
- // Preserve NaN for shrink-wrap mode - only clamp real numbers to 0
1711
- const clampIfNumber = (v: number) => (Number.isNaN(v) ? NaN : Math.max(0, v))
1712
- layoutNode(
1713
- child,
1714
- clampIfNumber(childAvailWidth),
1715
- clampIfNumber(childAvailHeight),
1716
- layout.left + absInnerLeft + childX,
1717
- layout.top + absInnerTop + childY,
1718
- childAbsX,
1719
- childAbsY,
1720
- direction,
1721
- )
1722
-
1723
- // Now compute final position based on right/bottom if left/top not set
1724
- const childWidth = child.layout.width
1725
- const childHeight = child.layout.height
1726
-
1727
- // Apply alignment when no explicit position set
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)
1731
- if (!hasLeft && !hasRight) {
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
- }
1767
- }
1768
- } else if (!hasLeft && hasRight) {
1769
- // Position from right edge
1770
- childX = contentW - rightOffset - childMarginRight - childWidth
1771
- } else if (hasLeft && hasRight && widthIsAuto) {
1772
- // Stretch width already handled above
1773
- child.layout.width = Math.round(childAvailWidth)
1774
- }
1775
-
1776
- if (!hasTop && !hasBottom) {
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
- }
1812
- }
1813
- } else if (!hasTop && hasBottom) {
1814
- // Position from bottom edge
1815
- childY = contentH - bottomOffset - childMarginBottom - childHeight
1816
- } else if (hasTop && hasBottom && heightIsAuto) {
1817
- // Stretch height already handled above
1818
- child.layout.height = Math.round(childAvailHeight)
1819
- }
1820
-
1821
- // Set final position (relative to container padding box)
1822
- child.layout.left = Math.round(absInnerLeft + childX)
1823
- child.layout.top = Math.round(absInnerTop + childY)
1824
- }
1825
- }
1826
-
1827
- // ============================================================================
1828
- // Layout Stats (stubs for API compatibility with zero-alloc version)
1829
- // ============================================================================
1830
- // The classic algorithm doesn't track detailed stats, but we export stubs
1831
- // so the public API remains consistent between versions.
1832
-
1833
- export let layoutNodeCalls = 0
1834
- export let layoutSizingCalls = 0
1835
- export let layoutPositioningCalls = 0
1836
- export let layoutCacheHits = 0
1837
-
1838
- export function resetLayoutStats(): void {
1839
- layoutNodeCalls = 0
1840
- layoutSizingCalls = 0
1841
- layoutPositioningCalls = 0
1842
- layoutCacheHits = 0
1843
- }