2579 lines
81 KiB
TypeScript
2579 lines
81 KiB
TypeScript
/**
|
||
* Pure-TypeScript port of yoga-layout (Meta's flexbox engine).
|
||
*
|
||
* This matches the `yoga-layout/load` API surface used by src/ink/layout/yoga.ts.
|
||
* The upstream C++ source is ~2500 lines in CalculateLayout.cpp alone; this port
|
||
* is a simplified single-pass flexbox implementation that covers the subset of
|
||
* features Ink actually uses:
|
||
* - flex-direction (row/column + reverse)
|
||
* - flex-grow / flex-shrink / flex-basis
|
||
* - align-items / align-self (stretch, flex-start, center, flex-end)
|
||
* - justify-content (all six values)
|
||
* - margin / padding / border / gap
|
||
* - width / height / min / max (point, percent, auto)
|
||
* - position: relative / absolute
|
||
* - display: flex / none
|
||
* - measure functions (for text nodes)
|
||
*
|
||
* Also implemented for spec parity (not used by Ink):
|
||
* - margin: auto (main + cross axis, overrides justify/align)
|
||
* - multi-pass flex clamping when children hit min/max constraints
|
||
* - flex-grow/shrink against container min/max when size is indefinite
|
||
*
|
||
* Also implemented for spec parity (not used by Ink):
|
||
* - flex-wrap: wrap / wrap-reverse (multi-line flex)
|
||
* - align-content (positions wrapped lines on cross axis)
|
||
*
|
||
* Also implemented for spec parity (not used by Ink):
|
||
* - display: contents (children lifted to grandparent, box removed)
|
||
*
|
||
* Also implemented for spec parity (not used by Ink):
|
||
* - baseline alignment (align-items/align-self: baseline)
|
||
*
|
||
* Not implemented (not used by Ink):
|
||
* - aspect-ratio
|
||
* - box-sizing: content-box
|
||
* - RTL direction (Ink always passes Direction.LTR)
|
||
*
|
||
* Upstream: https://github.com/facebook/yoga
|
||
*/
|
||
|
||
import {
|
||
Align,
|
||
BoxSizing,
|
||
Dimension,
|
||
Direction,
|
||
Display,
|
||
Edge,
|
||
Errata,
|
||
ExperimentalFeature,
|
||
FlexDirection,
|
||
Gutter,
|
||
Justify,
|
||
MeasureMode,
|
||
Overflow,
|
||
PositionType,
|
||
Unit,
|
||
Wrap,
|
||
} from './enums.js'
|
||
|
||
export {
|
||
Align,
|
||
BoxSizing,
|
||
Dimension,
|
||
Direction,
|
||
Display,
|
||
Edge,
|
||
Errata,
|
||
ExperimentalFeature,
|
||
FlexDirection,
|
||
Gutter,
|
||
Justify,
|
||
MeasureMode,
|
||
Overflow,
|
||
PositionType,
|
||
Unit,
|
||
Wrap,
|
||
}
|
||
|
||
// --
|
||
// Value types
|
||
|
||
export type Value = {
|
||
unit: Unit
|
||
value: number
|
||
}
|
||
|
||
const UNDEFINED_VALUE: Value = { unit: Unit.Undefined, value: NaN }
|
||
const AUTO_VALUE: Value = { unit: Unit.Auto, value: NaN }
|
||
|
||
function pointValue(v: number): Value {
|
||
return { unit: Unit.Point, value: v }
|
||
}
|
||
function percentValue(v: number): Value {
|
||
return { unit: Unit.Percent, value: v }
|
||
}
|
||
|
||
function resolveValue(v: Value, ownerSize: number): number {
|
||
switch (v.unit) {
|
||
case Unit.Point:
|
||
return v.value
|
||
case Unit.Percent:
|
||
return isNaN(ownerSize) ? NaN : (v.value * ownerSize) / 100
|
||
default:
|
||
return NaN
|
||
}
|
||
}
|
||
|
||
function isDefined(n: number): boolean {
|
||
return !isNaN(n)
|
||
}
|
||
|
||
// NaN-safe equality for layout-cache input comparison
|
||
function sameFloat(a: number, b: number): boolean {
|
||
return a === b || (a !== a && b !== b)
|
||
}
|
||
|
||
// --
|
||
// Layout result (computed values)
|
||
|
||
type Layout = {
|
||
left: number
|
||
top: number
|
||
width: number
|
||
height: number
|
||
// Computed per-edge values (resolved to physical edges)
|
||
border: [number, number, number, number] // left, top, right, bottom
|
||
padding: [number, number, number, number]
|
||
margin: [number, number, number, number]
|
||
}
|
||
|
||
// --
|
||
// Style (input values)
|
||
|
||
type Style = {
|
||
direction: Direction
|
||
flexDirection: FlexDirection
|
||
justifyContent: Justify
|
||
alignItems: Align
|
||
alignSelf: Align
|
||
alignContent: Align
|
||
flexWrap: Wrap
|
||
overflow: Overflow
|
||
display: Display
|
||
positionType: PositionType
|
||
|
||
flexGrow: number
|
||
flexShrink: number
|
||
flexBasis: Value
|
||
|
||
// 9-edge arrays indexed by Edge enum
|
||
margin: Value[]
|
||
padding: Value[]
|
||
border: Value[]
|
||
position: Value[]
|
||
|
||
// 3-gutter array indexed by Gutter enum
|
||
gap: Value[]
|
||
|
||
width: Value
|
||
height: Value
|
||
minWidth: Value
|
||
minHeight: Value
|
||
maxWidth: Value
|
||
maxHeight: Value
|
||
}
|
||
|
||
function defaultStyle(): Style {
|
||
return {
|
||
direction: Direction.Inherit,
|
||
flexDirection: FlexDirection.Column,
|
||
justifyContent: Justify.FlexStart,
|
||
alignItems: Align.Stretch,
|
||
alignSelf: Align.Auto,
|
||
alignContent: Align.FlexStart,
|
||
flexWrap: Wrap.NoWrap,
|
||
overflow: Overflow.Visible,
|
||
display: Display.Flex,
|
||
positionType: PositionType.Relative,
|
||
flexGrow: 0,
|
||
flexShrink: 0,
|
||
flexBasis: AUTO_VALUE,
|
||
margin: new Array(9).fill(UNDEFINED_VALUE),
|
||
padding: new Array(9).fill(UNDEFINED_VALUE),
|
||
border: new Array(9).fill(UNDEFINED_VALUE),
|
||
position: new Array(9).fill(UNDEFINED_VALUE),
|
||
gap: new Array(3).fill(UNDEFINED_VALUE),
|
||
width: AUTO_VALUE,
|
||
height: AUTO_VALUE,
|
||
minWidth: UNDEFINED_VALUE,
|
||
minHeight: UNDEFINED_VALUE,
|
||
maxWidth: UNDEFINED_VALUE,
|
||
maxHeight: UNDEFINED_VALUE,
|
||
}
|
||
}
|
||
|
||
// --
|
||
// Edge resolution — yoga's 9-edge model collapsed to 4 physical edges
|
||
|
||
const EDGE_LEFT = 0
|
||
const EDGE_TOP = 1
|
||
const EDGE_RIGHT = 2
|
||
const EDGE_BOTTOM = 3
|
||
|
||
function resolveEdge(
|
||
edges: Value[],
|
||
physicalEdge: number,
|
||
ownerSize: number,
|
||
// For margin/position we allow auto; for padding/border auto resolves to 0
|
||
allowAuto = false,
|
||
): number {
|
||
// Precedence: specific edge > horizontal/vertical > all
|
||
let v = edges[physicalEdge]!
|
||
if (v.unit === Unit.Undefined) {
|
||
if (physicalEdge === EDGE_LEFT || physicalEdge === EDGE_RIGHT) {
|
||
v = edges[Edge.Horizontal]!
|
||
} else {
|
||
v = edges[Edge.Vertical]!
|
||
}
|
||
}
|
||
if (v.unit === Unit.Undefined) {
|
||
v = edges[Edge.All]!
|
||
}
|
||
// Start/End map to Left/Right for LTR (Ink is always LTR)
|
||
if (v.unit === Unit.Undefined) {
|
||
if (physicalEdge === EDGE_LEFT) v = edges[Edge.Start]!
|
||
if (physicalEdge === EDGE_RIGHT) v = edges[Edge.End]!
|
||
}
|
||
if (v.unit === Unit.Undefined) return 0
|
||
if (v.unit === Unit.Auto) return allowAuto ? NaN : 0
|
||
return resolveValue(v, ownerSize)
|
||
}
|
||
|
||
function resolveEdgeRaw(edges: Value[], physicalEdge: number): Value {
|
||
let v = edges[physicalEdge]!
|
||
if (v.unit === Unit.Undefined) {
|
||
if (physicalEdge === EDGE_LEFT || physicalEdge === EDGE_RIGHT) {
|
||
v = edges[Edge.Horizontal]!
|
||
} else {
|
||
v = edges[Edge.Vertical]!
|
||
}
|
||
}
|
||
if (v.unit === Unit.Undefined) v = edges[Edge.All]!
|
||
if (v.unit === Unit.Undefined) {
|
||
if (physicalEdge === EDGE_LEFT) v = edges[Edge.Start]!
|
||
if (physicalEdge === EDGE_RIGHT) v = edges[Edge.End]!
|
||
}
|
||
return v
|
||
}
|
||
|
||
function isMarginAuto(edges: Value[], physicalEdge: number): boolean {
|
||
return resolveEdgeRaw(edges, physicalEdge).unit === Unit.Auto
|
||
}
|
||
|
||
// Setter helpers for the _hasAutoMargin / _hasPosition fast-path flags.
|
||
// Unit.Undefined = 0, Unit.Auto = 3.
|
||
function hasAnyAutoEdge(edges: Value[]): boolean {
|
||
for (let i = 0; i < 9; i++) if (edges[i]!.unit === 3) return true
|
||
return false
|
||
}
|
||
function hasAnyDefinedEdge(edges: Value[]): boolean {
|
||
for (let i = 0; i < 9; i++) if (edges[i]!.unit !== 0) return true
|
||
return false
|
||
}
|
||
|
||
// Hot path: resolve all 4 physical edges in one pass, writing into `out`.
|
||
// Equivalent to calling resolveEdge() 4× with allowAuto=false, but hoists the
|
||
// shared fallback lookups (Horizontal/Vertical/All/Start/End) and avoids
|
||
// allocating a fresh 4-array on every layoutNode() call.
|
||
function resolveEdges4Into(
|
||
edges: Value[],
|
||
ownerSize: number,
|
||
out: [number, number, number, number],
|
||
): void {
|
||
// Hoist fallbacks once — the 4 per-edge chains share these reads.
|
||
const eH = edges[6]! // Edge.Horizontal
|
||
const eV = edges[7]! // Edge.Vertical
|
||
const eA = edges[8]! // Edge.All
|
||
const eS = edges[4]! // Edge.Start
|
||
const eE = edges[5]! // Edge.End
|
||
const pctDenom = isNaN(ownerSize) ? NaN : ownerSize / 100
|
||
|
||
// Left: edges[0] → Horizontal → All → Start
|
||
let v = edges[0]!
|
||
if (v.unit === 0) v = eH
|
||
if (v.unit === 0) v = eA
|
||
if (v.unit === 0) v = eS
|
||
out[0] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0
|
||
|
||
// Top: edges[1] → Vertical → All
|
||
v = edges[1]!
|
||
if (v.unit === 0) v = eV
|
||
if (v.unit === 0) v = eA
|
||
out[1] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0
|
||
|
||
// Right: edges[2] → Horizontal → All → End
|
||
v = edges[2]!
|
||
if (v.unit === 0) v = eH
|
||
if (v.unit === 0) v = eA
|
||
if (v.unit === 0) v = eE
|
||
out[2] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0
|
||
|
||
// Bottom: edges[3] → Vertical → All
|
||
v = edges[3]!
|
||
if (v.unit === 0) v = eV
|
||
if (v.unit === 0) v = eA
|
||
out[3] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0
|
||
}
|
||
|
||
// --
|
||
// Axis helpers
|
||
|
||
function isRow(dir: FlexDirection): boolean {
|
||
return dir === FlexDirection.Row || dir === FlexDirection.RowReverse
|
||
}
|
||
function isReverse(dir: FlexDirection): boolean {
|
||
return dir === FlexDirection.RowReverse || dir === FlexDirection.ColumnReverse
|
||
}
|
||
function crossAxis(dir: FlexDirection): FlexDirection {
|
||
return isRow(dir) ? FlexDirection.Column : FlexDirection.Row
|
||
}
|
||
function leadingEdge(dir: FlexDirection): number {
|
||
switch (dir) {
|
||
case FlexDirection.Row:
|
||
return EDGE_LEFT
|
||
case FlexDirection.RowReverse:
|
||
return EDGE_RIGHT
|
||
case FlexDirection.Column:
|
||
return EDGE_TOP
|
||
case FlexDirection.ColumnReverse:
|
||
return EDGE_BOTTOM
|
||
}
|
||
}
|
||
function trailingEdge(dir: FlexDirection): number {
|
||
switch (dir) {
|
||
case FlexDirection.Row:
|
||
return EDGE_RIGHT
|
||
case FlexDirection.RowReverse:
|
||
return EDGE_LEFT
|
||
case FlexDirection.Column:
|
||
return EDGE_BOTTOM
|
||
case FlexDirection.ColumnReverse:
|
||
return EDGE_TOP
|
||
}
|
||
}
|
||
|
||
// --
|
||
// Public types
|
||
|
||
export type MeasureFunction = (
|
||
width: number,
|
||
widthMode: MeasureMode,
|
||
height: number,
|
||
heightMode: MeasureMode,
|
||
) => { width: number; height: number }
|
||
|
||
export type Size = { width: number; height: number }
|
||
|
||
// --
|
||
// Config
|
||
|
||
export type Config = {
|
||
pointScaleFactor: number
|
||
errata: Errata
|
||
useWebDefaults: boolean
|
||
free(): void
|
||
isExperimentalFeatureEnabled(_: ExperimentalFeature): boolean
|
||
setExperimentalFeatureEnabled(_: ExperimentalFeature, __: boolean): void
|
||
setPointScaleFactor(factor: number): void
|
||
getErrata(): Errata
|
||
setErrata(errata: Errata): void
|
||
setUseWebDefaults(v: boolean): void
|
||
}
|
||
|
||
function createConfig(): Config {
|
||
const config: Config = {
|
||
pointScaleFactor: 1,
|
||
errata: Errata.None,
|
||
useWebDefaults: false,
|
||
free() {},
|
||
isExperimentalFeatureEnabled() {
|
||
return false
|
||
},
|
||
setExperimentalFeatureEnabled() {},
|
||
setPointScaleFactor(f) {
|
||
config.pointScaleFactor = f
|
||
},
|
||
getErrata() {
|
||
return config.errata
|
||
},
|
||
setErrata(e) {
|
||
config.errata = e
|
||
},
|
||
setUseWebDefaults(v) {
|
||
config.useWebDefaults = v
|
||
},
|
||
}
|
||
return config
|
||
}
|
||
|
||
// --
|
||
// Node implementation
|
||
|
||
export class Node {
|
||
style: Style
|
||
layout: Layout
|
||
parent: Node | null
|
||
children: Node[]
|
||
measureFunc: MeasureFunction | null
|
||
config: Config
|
||
isDirty_: boolean
|
||
isReferenceBaseline_: boolean
|
||
|
||
// Per-layout scratch (not public API)
|
||
_flexBasis = 0
|
||
_mainSize = 0
|
||
_crossSize = 0
|
||
_lineIndex = 0
|
||
// Fast-path flags maintained by style setters. Per CPU profile, the
|
||
// positioning loop calls isMarginAuto 6× and resolveEdgeRaw(position) 4×
|
||
// per child per layout pass — ~11k calls for the 1000-node bench, nearly
|
||
// all of which return false/undefined since most nodes have no auto
|
||
// margins and no position insets. These flags let us skip straight to
|
||
// the common case with a single branch.
|
||
_hasAutoMargin = false
|
||
_hasPosition = false
|
||
// Same pattern for the 3× resolveEdges4Into calls at the top of every
|
||
// layoutNode(). In the 1000-node bench ~67% of those calls operate on
|
||
// all-undefined edge arrays (most nodes have no border; only cols have
|
||
// padding; only leaf cells have margin) — a single-branch skip beats
|
||
// ~20 property reads + ~15 compares + 4 writes of zeros.
|
||
_hasPadding = false
|
||
_hasBorder = false
|
||
_hasMargin = false
|
||
// -- Dirty-flag layout cache. Mirrors upstream CalculateLayout.cpp's
|
||
// layoutNodeInternal: skip a subtree entirely when it's clean and we're
|
||
// asking the same question we cached the answer to. Two slots since
|
||
// each node typically sees a measure call (performLayout=false, from
|
||
// computeFlexBasis) followed by a layout call (performLayout=true) with
|
||
// different inputs per parent pass — a single slot thrashes. Re-layout
|
||
// bench (dirty one leaf, recompute root) went 2.7x→1.1x with this:
|
||
// clean siblings skip straight through, only the dirty chain recomputes.
|
||
_lW = NaN
|
||
_lH = NaN
|
||
_lWM: MeasureMode = 0
|
||
_lHM: MeasureMode = 0
|
||
_lOW = NaN
|
||
_lOH = NaN
|
||
_lFW = false
|
||
_lFH = false
|
||
// _hasL stores INPUTS early (before compute) but layout.width/height are
|
||
// mutated by the multi-entry cache and by subsequent compute calls with
|
||
// different inputs. Without storing OUTPUTS, a _hasL hit returns whatever
|
||
// layout.width/height happened to be left by the last call — the scrollbox
|
||
// vpH=33→2624 bug. Store + restore outputs like the multi-entry cache does.
|
||
_lOutW = NaN
|
||
_lOutH = NaN
|
||
_hasL = false
|
||
_mW = NaN
|
||
_mH = NaN
|
||
_mWM: MeasureMode = 0
|
||
_mHM: MeasureMode = 0
|
||
_mOW = NaN
|
||
_mOH = NaN
|
||
_mOutW = NaN
|
||
_mOutH = NaN
|
||
_hasM = false
|
||
// Cached computeFlexBasis result. For clean children, basis only depends
|
||
// on the container's inner dimensions — if those haven't changed, skip the
|
||
// layoutNode(performLayout=false) recursion entirely. This is the hot path
|
||
// for scroll: 500-message content container is dirty, its 499 clean
|
||
// children each get measured ~20× as the dirty chain's measure/layout
|
||
// passes cascade. Basis cache short-circuits at the child boundary.
|
||
_fbBasis = NaN
|
||
_fbOwnerW = NaN
|
||
_fbOwnerH = NaN
|
||
_fbAvailMain = NaN
|
||
_fbAvailCross = NaN
|
||
_fbCrossMode: MeasureMode = 0
|
||
// Generation at which _fbBasis was written. Dirty nodes from a PREVIOUS
|
||
// generation have stale cache (subtree changed), but within the SAME
|
||
// generation the cache is fresh — the dirty chain's measure→layout
|
||
// cascade invokes computeFlexBasis ≥2^depth times per calculateLayout on
|
||
// fresh-mounted items, and the subtree doesn't change between calls.
|
||
// Gating on generation instead of isDirty_ lets fresh mounts (virtual
|
||
// scroll) cache-hit after first compute: 105k visits → ~10k.
|
||
_fbGen = -1
|
||
// Multi-entry layout cache — stores (inputs → computed w,h) so hits with
|
||
// different inputs than _hasL can restore the right dimensions. Upstream
|
||
// yoga uses 16; 4 covers Ink's dirty-chain depth. Packed as flat arrays
|
||
// to avoid per-entry object allocs. Slot i uses indices [i*8, i*8+8) in
|
||
// _cIn (aW,aH,wM,hM,oW,oH,fW,fH) and [i*2, i*2+2) in _cOut (w,h).
|
||
_cIn: Float64Array | null = null
|
||
_cOut: Float64Array | null = null
|
||
_cGen = -1
|
||
_cN = 0
|
||
_cWr = 0
|
||
|
||
constructor(config?: Config) {
|
||
this.style = defaultStyle()
|
||
this.layout = {
|
||
left: 0,
|
||
top: 0,
|
||
width: 0,
|
||
height: 0,
|
||
border: [0, 0, 0, 0],
|
||
padding: [0, 0, 0, 0],
|
||
margin: [0, 0, 0, 0],
|
||
}
|
||
this.parent = null
|
||
this.children = []
|
||
this.measureFunc = null
|
||
this.config = config ?? DEFAULT_CONFIG
|
||
this.isDirty_ = true
|
||
this.isReferenceBaseline_ = false
|
||
_yogaLiveNodes++
|
||
}
|
||
|
||
// -- Tree
|
||
|
||
insertChild(child: Node, index: number): void {
|
||
child.parent = this
|
||
this.children.splice(index, 0, child)
|
||
this.markDirty()
|
||
}
|
||
removeChild(child: Node): void {
|
||
const idx = this.children.indexOf(child)
|
||
if (idx >= 0) {
|
||
this.children.splice(idx, 1)
|
||
child.parent = null
|
||
this.markDirty()
|
||
}
|
||
}
|
||
getChild(index: number): Node {
|
||
return this.children[index]!
|
||
}
|
||
getChildCount(): number {
|
||
return this.children.length
|
||
}
|
||
getParent(): Node | null {
|
||
return this.parent
|
||
}
|
||
|
||
// -- Lifecycle
|
||
|
||
free(): void {
|
||
this.parent = null
|
||
this.children = []
|
||
this.measureFunc = null
|
||
this._cIn = null
|
||
this._cOut = null
|
||
_yogaLiveNodes--
|
||
}
|
||
freeRecursive(): void {
|
||
for (const c of this.children) c.freeRecursive()
|
||
this.free()
|
||
}
|
||
reset(): void {
|
||
this.style = defaultStyle()
|
||
this.children = []
|
||
this.parent = null
|
||
this.measureFunc = null
|
||
this.isDirty_ = true
|
||
this._hasAutoMargin = false
|
||
this._hasPosition = false
|
||
this._hasPadding = false
|
||
this._hasBorder = false
|
||
this._hasMargin = false
|
||
this._hasL = false
|
||
this._hasM = false
|
||
this._cN = 0
|
||
this._cWr = 0
|
||
this._fbBasis = NaN
|
||
}
|
||
|
||
// -- Dirty tracking
|
||
|
||
markDirty(): void {
|
||
this.isDirty_ = true
|
||
if (this.parent && !this.parent.isDirty_) this.parent.markDirty()
|
||
}
|
||
isDirty(): boolean {
|
||
return this.isDirty_
|
||
}
|
||
hasNewLayout(): boolean {
|
||
return true
|
||
}
|
||
markLayoutSeen(): void {}
|
||
|
||
// -- Measure function
|
||
|
||
setMeasureFunc(fn: MeasureFunction | null): void {
|
||
this.measureFunc = fn
|
||
this.markDirty()
|
||
}
|
||
unsetMeasureFunc(): void {
|
||
this.measureFunc = null
|
||
this.markDirty()
|
||
}
|
||
|
||
// -- Computed layout getters
|
||
|
||
getComputedLeft(): number {
|
||
return this.layout.left
|
||
}
|
||
getComputedTop(): number {
|
||
return this.layout.top
|
||
}
|
||
getComputedWidth(): number {
|
||
return this.layout.width
|
||
}
|
||
getComputedHeight(): number {
|
||
return this.layout.height
|
||
}
|
||
getComputedRight(): number {
|
||
const p = this.parent
|
||
return p ? p.layout.width - this.layout.left - this.layout.width : 0
|
||
}
|
||
getComputedBottom(): number {
|
||
const p = this.parent
|
||
return p ? p.layout.height - this.layout.top - this.layout.height : 0
|
||
}
|
||
getComputedLayout(): {
|
||
left: number
|
||
top: number
|
||
right: number
|
||
bottom: number
|
||
width: number
|
||
height: number
|
||
} {
|
||
return {
|
||
left: this.layout.left,
|
||
top: this.layout.top,
|
||
right: this.getComputedRight(),
|
||
bottom: this.getComputedBottom(),
|
||
width: this.layout.width,
|
||
height: this.layout.height,
|
||
}
|
||
}
|
||
getComputedBorder(edge: Edge): number {
|
||
return this.layout.border[physicalEdge(edge)]!
|
||
}
|
||
getComputedPadding(edge: Edge): number {
|
||
return this.layout.padding[physicalEdge(edge)]!
|
||
}
|
||
getComputedMargin(edge: Edge): number {
|
||
return this.layout.margin[physicalEdge(edge)]!
|
||
}
|
||
|
||
// -- Style setters: dimensions
|
||
|
||
setWidth(v: number | 'auto' | string | undefined): void {
|
||
this.style.width = parseDimension(v)
|
||
this.markDirty()
|
||
}
|
||
setWidthPercent(v: number): void {
|
||
this.style.width = percentValue(v)
|
||
this.markDirty()
|
||
}
|
||
setWidthAuto(): void {
|
||
this.style.width = AUTO_VALUE
|
||
this.markDirty()
|
||
}
|
||
setHeight(v: number | 'auto' | string | undefined): void {
|
||
this.style.height = parseDimension(v)
|
||
this.markDirty()
|
||
}
|
||
setHeightPercent(v: number): void {
|
||
this.style.height = percentValue(v)
|
||
this.markDirty()
|
||
}
|
||
setHeightAuto(): void {
|
||
this.style.height = AUTO_VALUE
|
||
this.markDirty()
|
||
}
|
||
setMinWidth(v: number | string | undefined): void {
|
||
this.style.minWidth = parseDimension(v)
|
||
this.markDirty()
|
||
}
|
||
setMinWidthPercent(v: number): void {
|
||
this.style.minWidth = percentValue(v)
|
||
this.markDirty()
|
||
}
|
||
setMinHeight(v: number | string | undefined): void {
|
||
this.style.minHeight = parseDimension(v)
|
||
this.markDirty()
|
||
}
|
||
setMinHeightPercent(v: number): void {
|
||
this.style.minHeight = percentValue(v)
|
||
this.markDirty()
|
||
}
|
||
setMaxWidth(v: number | string | undefined): void {
|
||
this.style.maxWidth = parseDimension(v)
|
||
this.markDirty()
|
||
}
|
||
setMaxWidthPercent(v: number): void {
|
||
this.style.maxWidth = percentValue(v)
|
||
this.markDirty()
|
||
}
|
||
setMaxHeight(v: number | string | undefined): void {
|
||
this.style.maxHeight = parseDimension(v)
|
||
this.markDirty()
|
||
}
|
||
setMaxHeightPercent(v: number): void {
|
||
this.style.maxHeight = percentValue(v)
|
||
this.markDirty()
|
||
}
|
||
|
||
// -- Style setters: flex
|
||
|
||
setFlexDirection(dir: FlexDirection): void {
|
||
this.style.flexDirection = dir
|
||
this.markDirty()
|
||
}
|
||
setFlexGrow(v: number | undefined): void {
|
||
this.style.flexGrow = v ?? 0
|
||
this.markDirty()
|
||
}
|
||
setFlexShrink(v: number | undefined): void {
|
||
this.style.flexShrink = v ?? 0
|
||
this.markDirty()
|
||
}
|
||
setFlex(v: number | undefined): void {
|
||
if (v === undefined || isNaN(v)) {
|
||
this.style.flexGrow = 0
|
||
this.style.flexShrink = 0
|
||
} else if (v > 0) {
|
||
this.style.flexGrow = v
|
||
this.style.flexShrink = 1
|
||
this.style.flexBasis = pointValue(0)
|
||
} else if (v < 0) {
|
||
this.style.flexGrow = 0
|
||
this.style.flexShrink = -v
|
||
} else {
|
||
this.style.flexGrow = 0
|
||
this.style.flexShrink = 0
|
||
}
|
||
this.markDirty()
|
||
}
|
||
setFlexBasis(v: number | 'auto' | string | undefined): void {
|
||
this.style.flexBasis = parseDimension(v)
|
||
this.markDirty()
|
||
}
|
||
setFlexBasisPercent(v: number): void {
|
||
this.style.flexBasis = percentValue(v)
|
||
this.markDirty()
|
||
}
|
||
setFlexBasisAuto(): void {
|
||
this.style.flexBasis = AUTO_VALUE
|
||
this.markDirty()
|
||
}
|
||
setFlexWrap(wrap: Wrap): void {
|
||
this.style.flexWrap = wrap
|
||
this.markDirty()
|
||
}
|
||
|
||
// -- Style setters: alignment
|
||
|
||
setAlignItems(a: Align): void {
|
||
this.style.alignItems = a
|
||
this.markDirty()
|
||
}
|
||
setAlignSelf(a: Align): void {
|
||
this.style.alignSelf = a
|
||
this.markDirty()
|
||
}
|
||
setAlignContent(a: Align): void {
|
||
this.style.alignContent = a
|
||
this.markDirty()
|
||
}
|
||
setJustifyContent(j: Justify): void {
|
||
this.style.justifyContent = j
|
||
this.markDirty()
|
||
}
|
||
|
||
// -- Style setters: display / position / overflow
|
||
|
||
setDisplay(d: Display): void {
|
||
this.style.display = d
|
||
this.markDirty()
|
||
}
|
||
getDisplay(): Display {
|
||
return this.style.display
|
||
}
|
||
setPositionType(t: PositionType): void {
|
||
this.style.positionType = t
|
||
this.markDirty()
|
||
}
|
||
setPosition(edge: Edge, v: number | string | undefined): void {
|
||
this.style.position[edge] = parseDimension(v)
|
||
this._hasPosition = hasAnyDefinedEdge(this.style.position)
|
||
this.markDirty()
|
||
}
|
||
setPositionPercent(edge: Edge, v: number): void {
|
||
this.style.position[edge] = percentValue(v)
|
||
this._hasPosition = true
|
||
this.markDirty()
|
||
}
|
||
setPositionAuto(edge: Edge): void {
|
||
this.style.position[edge] = AUTO_VALUE
|
||
this._hasPosition = true
|
||
this.markDirty()
|
||
}
|
||
setOverflow(o: Overflow): void {
|
||
this.style.overflow = o
|
||
this.markDirty()
|
||
}
|
||
setDirection(d: Direction): void {
|
||
this.style.direction = d
|
||
this.markDirty()
|
||
}
|
||
setBoxSizing(_: BoxSizing): void {
|
||
// Not implemented — Ink doesn't use content-box
|
||
}
|
||
|
||
// -- Style setters: spacing
|
||
|
||
setMargin(edge: Edge, v: number | 'auto' | string | undefined): void {
|
||
const val = parseDimension(v)
|
||
this.style.margin[edge] = val
|
||
if (val.unit === Unit.Auto) this._hasAutoMargin = true
|
||
else this._hasAutoMargin = hasAnyAutoEdge(this.style.margin)
|
||
this._hasMargin =
|
||
this._hasAutoMargin || hasAnyDefinedEdge(this.style.margin)
|
||
this.markDirty()
|
||
}
|
||
setMarginPercent(edge: Edge, v: number): void {
|
||
this.style.margin[edge] = percentValue(v)
|
||
this._hasAutoMargin = hasAnyAutoEdge(this.style.margin)
|
||
this._hasMargin = true
|
||
this.markDirty()
|
||
}
|
||
setMarginAuto(edge: Edge): void {
|
||
this.style.margin[edge] = AUTO_VALUE
|
||
this._hasAutoMargin = true
|
||
this._hasMargin = true
|
||
this.markDirty()
|
||
}
|
||
setPadding(edge: Edge, v: number | string | undefined): void {
|
||
this.style.padding[edge] = parseDimension(v)
|
||
this._hasPadding = hasAnyDefinedEdge(this.style.padding)
|
||
this.markDirty()
|
||
}
|
||
setPaddingPercent(edge: Edge, v: number): void {
|
||
this.style.padding[edge] = percentValue(v)
|
||
this._hasPadding = true
|
||
this.markDirty()
|
||
}
|
||
setBorder(edge: Edge, v: number | undefined): void {
|
||
this.style.border[edge] = v === undefined ? UNDEFINED_VALUE : pointValue(v)
|
||
this._hasBorder = hasAnyDefinedEdge(this.style.border)
|
||
this.markDirty()
|
||
}
|
||
setGap(gutter: Gutter, v: number | string | undefined): void {
|
||
this.style.gap[gutter] = parseDimension(v)
|
||
this.markDirty()
|
||
}
|
||
setGapPercent(gutter: Gutter, v: number): void {
|
||
this.style.gap[gutter] = percentValue(v)
|
||
this.markDirty()
|
||
}
|
||
|
||
// -- Style getters (partial — only what tests need)
|
||
|
||
getFlexDirection(): FlexDirection {
|
||
return this.style.flexDirection
|
||
}
|
||
getJustifyContent(): Justify {
|
||
return this.style.justifyContent
|
||
}
|
||
getAlignItems(): Align {
|
||
return this.style.alignItems
|
||
}
|
||
getAlignSelf(): Align {
|
||
return this.style.alignSelf
|
||
}
|
||
getAlignContent(): Align {
|
||
return this.style.alignContent
|
||
}
|
||
getFlexGrow(): number {
|
||
return this.style.flexGrow
|
||
}
|
||
getFlexShrink(): number {
|
||
return this.style.flexShrink
|
||
}
|
||
getFlexBasis(): Value {
|
||
return this.style.flexBasis
|
||
}
|
||
getFlexWrap(): Wrap {
|
||
return this.style.flexWrap
|
||
}
|
||
getWidth(): Value {
|
||
return this.style.width
|
||
}
|
||
getHeight(): Value {
|
||
return this.style.height
|
||
}
|
||
getOverflow(): Overflow {
|
||
return this.style.overflow
|
||
}
|
||
getPositionType(): PositionType {
|
||
return this.style.positionType
|
||
}
|
||
getDirection(): Direction {
|
||
return this.style.direction
|
||
}
|
||
|
||
// -- Unused API stubs (present for API parity)
|
||
|
||
copyStyle(_: Node): void {}
|
||
setDirtiedFunc(_: unknown): void {}
|
||
unsetDirtiedFunc(): void {}
|
||
setIsReferenceBaseline(v: boolean): void {
|
||
this.isReferenceBaseline_ = v
|
||
this.markDirty()
|
||
}
|
||
isReferenceBaseline(): boolean {
|
||
return this.isReferenceBaseline_
|
||
}
|
||
setAspectRatio(_: number | undefined): void {}
|
||
getAspectRatio(): number {
|
||
return NaN
|
||
}
|
||
setAlwaysFormsContainingBlock(_: boolean): void {}
|
||
|
||
// -- Layout entry point
|
||
|
||
calculateLayout(
|
||
ownerWidth: number | undefined,
|
||
ownerHeight: number | undefined,
|
||
_direction?: Direction,
|
||
): void {
|
||
_yogaNodesVisited = 0
|
||
_yogaMeasureCalls = 0
|
||
_yogaCacheHits = 0
|
||
_generation++
|
||
const w = ownerWidth === undefined ? NaN : ownerWidth
|
||
const h = ownerHeight === undefined ? NaN : ownerHeight
|
||
layoutNode(
|
||
this,
|
||
w,
|
||
h,
|
||
isDefined(w) ? MeasureMode.Exactly : MeasureMode.Undefined,
|
||
isDefined(h) ? MeasureMode.Exactly : MeasureMode.Undefined,
|
||
w,
|
||
h,
|
||
true,
|
||
)
|
||
// Root's own position = margin + position insets (yoga applies position
|
||
// to the root even without a parent container; this matters for rounding
|
||
// since the root's abs top/left seeds the pixel-grid walk).
|
||
const mar = this.layout.margin
|
||
const posL = resolveValue(
|
||
resolveEdgeRaw(this.style.position, EDGE_LEFT),
|
||
isDefined(w) ? w : 0,
|
||
)
|
||
const posT = resolveValue(
|
||
resolveEdgeRaw(this.style.position, EDGE_TOP),
|
||
isDefined(w) ? w : 0,
|
||
)
|
||
this.layout.left = mar[EDGE_LEFT] + (isDefined(posL) ? posL : 0)
|
||
this.layout.top = mar[EDGE_TOP] + (isDefined(posT) ? posT : 0)
|
||
roundLayout(this, this.config.pointScaleFactor, 0, 0)
|
||
}
|
||
}
|
||
|
||
const DEFAULT_CONFIG = createConfig()
|
||
|
||
const CACHE_SLOTS = 4
|
||
function cacheWrite(
|
||
node: Node,
|
||
aW: number,
|
||
aH: number,
|
||
wM: MeasureMode,
|
||
hM: MeasureMode,
|
||
oW: number,
|
||
oH: number,
|
||
fW: boolean,
|
||
fH: boolean,
|
||
wasDirty: boolean,
|
||
): void {
|
||
if (!node._cIn) {
|
||
node._cIn = new Float64Array(CACHE_SLOTS * 8)
|
||
node._cOut = new Float64Array(CACHE_SLOTS * 2)
|
||
}
|
||
// First write after a dirty clears stale entries from before the dirty.
|
||
// _cGen < _generation means entries are from a previous calculateLayout;
|
||
// if wasDirty, the subtree changed since then → old dimensions invalid.
|
||
// Clean nodes' old entries stay — same subtree → same result for same
|
||
// inputs, so cross-generation caching works (the scroll hot path where
|
||
// 499 clean messages cache-hit while one dirty leaf recomputes).
|
||
if (wasDirty && node._cGen !== _generation) {
|
||
node._cN = 0
|
||
node._cWr = 0
|
||
}
|
||
// LRU write index wraps; _cN stays at CACHE_SLOTS so the read scan always
|
||
// checks all populated slots (not just those since last wrap).
|
||
const i = node._cWr++ % CACHE_SLOTS
|
||
if (node._cN < CACHE_SLOTS) node._cN = node._cWr
|
||
const o = i * 8
|
||
const cIn = node._cIn
|
||
cIn[o] = aW
|
||
cIn[o + 1] = aH
|
||
cIn[o + 2] = wM
|
||
cIn[o + 3] = hM
|
||
cIn[o + 4] = oW
|
||
cIn[o + 5] = oH
|
||
cIn[o + 6] = fW ? 1 : 0
|
||
cIn[o + 7] = fH ? 1 : 0
|
||
node._cOut![i * 2] = node.layout.width
|
||
node._cOut![i * 2 + 1] = node.layout.height
|
||
node._cGen = _generation
|
||
}
|
||
|
||
// Store computed layout.width/height into the single-slot cache output fields.
|
||
// _hasL/_hasM inputs are committed at the TOP of layoutNode (before compute);
|
||
// outputs must be committed HERE (after compute) so a cache hit can restore
|
||
// the correct dimensions. Without this, a _hasL hit returns whatever
|
||
// layout.width/height was left by the last call — which may be the intrinsic
|
||
// content height from a heightMode=Undefined measure pass rather than the
|
||
// constrained viewport height from the layout pass. That's the scrollbox
|
||
// vpH=33→2624 bug: scrollTop clamps to 0, viewport goes blank.
|
||
function commitCacheOutputs(node: Node, performLayout: boolean): void {
|
||
if (performLayout) {
|
||
node._lOutW = node.layout.width
|
||
node._lOutH = node.layout.height
|
||
} else {
|
||
node._mOutW = node.layout.width
|
||
node._mOutH = node.layout.height
|
||
}
|
||
}
|
||
|
||
// --
|
||
// Core flexbox algorithm
|
||
|
||
// Profiling counters — reset per calculateLayout, read via getYogaCounters.
|
||
// Incremented on each calculateLayout(). Nodes stamp _fbGen/_cGen when
|
||
// their cache is written; a cache entry with gen === _generation was
|
||
// computed THIS pass and is fresh regardless of isDirty_ state.
|
||
let _generation = 0
|
||
let _yogaNodesVisited = 0
|
||
let _yogaMeasureCalls = 0
|
||
let _yogaCacheHits = 0
|
||
let _yogaLiveNodes = 0
|
||
export function getYogaCounters(): {
|
||
visited: number
|
||
measured: number
|
||
cacheHits: number
|
||
live: number
|
||
} {
|
||
return {
|
||
visited: _yogaNodesVisited,
|
||
measured: _yogaMeasureCalls,
|
||
cacheHits: _yogaCacheHits,
|
||
live: _yogaLiveNodes,
|
||
}
|
||
}
|
||
|
||
function layoutNode(
|
||
node: Node,
|
||
availableWidth: number,
|
||
availableHeight: number,
|
||
widthMode: MeasureMode,
|
||
heightMode: MeasureMode,
|
||
ownerWidth: number,
|
||
ownerHeight: number,
|
||
performLayout: boolean,
|
||
// When true, ignore style dimension on this axis — the flex container
|
||
// has already determined the main size (flex-basis + grow/shrink result).
|
||
forceWidth = false,
|
||
forceHeight = false,
|
||
): void {
|
||
_yogaNodesVisited++
|
||
const style = node.style
|
||
const layout = node.layout
|
||
|
||
// Dirty-flag skip: clean subtree + matching inputs → layout object already
|
||
// holds the answer. A cached layout result also satisfies a measure request
|
||
// (positions are a superset of dimensions); the reverse does not hold.
|
||
// Same-generation entries are fresh regardless of isDirty_ — they were
|
||
// computed THIS calculateLayout, the subtree hasn't changed since.
|
||
// Previous-generation entries need !isDirty_ (a dirty node's cache from
|
||
// before the dirty is stale).
|
||
// sameGen bypass only for MEASURE calls — a layout-pass cache hit would
|
||
// skip the child-positioning recursion (STEP 5), leaving children at
|
||
// stale positions. Measure calls only need w/h which the cache stores.
|
||
const sameGen = node._cGen === _generation && !performLayout
|
||
if (!node.isDirty_ || sameGen) {
|
||
if (
|
||
!node.isDirty_ &&
|
||
node._hasL &&
|
||
node._lWM === widthMode &&
|
||
node._lHM === heightMode &&
|
||
node._lFW === forceWidth &&
|
||
node._lFH === forceHeight &&
|
||
sameFloat(node._lW, availableWidth) &&
|
||
sameFloat(node._lH, availableHeight) &&
|
||
sameFloat(node._lOW, ownerWidth) &&
|
||
sameFloat(node._lOH, ownerHeight)
|
||
) {
|
||
_yogaCacheHits++
|
||
layout.width = node._lOutW
|
||
layout.height = node._lOutH
|
||
return
|
||
}
|
||
// Multi-entry cache: scan for matching inputs, restore cached w/h on hit.
|
||
// Covers the scroll case where a dirty ancestor's measure→layout cascade
|
||
// produces N>1 distinct input combos per clean child — the single _hasL
|
||
// slot thrashed, forcing full subtree recursion. With 500-message
|
||
// scrollbox and one dirty leaf, this took dirty-leaf relayout from
|
||
// 76k layoutNode calls (21.7×nodes) to 4k (1.2×nodes), 6.86ms → 550µs.
|
||
// Same-generation check covers fresh-mounted (dirty) nodes during
|
||
// virtual scroll — the dirty chain invokes them ≥2^depth times, first
|
||
// call writes cache, rest hit: 105k visits → ~10k for 1593-node tree.
|
||
if (node._cN > 0 && (sameGen || !node.isDirty_)) {
|
||
const cIn = node._cIn!
|
||
for (let i = 0; i < node._cN; i++) {
|
||
const o = i * 8
|
||
if (
|
||
cIn[o + 2] === widthMode &&
|
||
cIn[o + 3] === heightMode &&
|
||
cIn[o + 6] === (forceWidth ? 1 : 0) &&
|
||
cIn[o + 7] === (forceHeight ? 1 : 0) &&
|
||
sameFloat(cIn[o]!, availableWidth) &&
|
||
sameFloat(cIn[o + 1]!, availableHeight) &&
|
||
sameFloat(cIn[o + 4]!, ownerWidth) &&
|
||
sameFloat(cIn[o + 5]!, ownerHeight)
|
||
) {
|
||
layout.width = node._cOut![i * 2]!
|
||
layout.height = node._cOut![i * 2 + 1]!
|
||
_yogaCacheHits++
|
||
return
|
||
}
|
||
}
|
||
}
|
||
if (
|
||
!node.isDirty_ &&
|
||
!performLayout &&
|
||
node._hasM &&
|
||
node._mWM === widthMode &&
|
||
node._mHM === heightMode &&
|
||
sameFloat(node._mW, availableWidth) &&
|
||
sameFloat(node._mH, availableHeight) &&
|
||
sameFloat(node._mOW, ownerWidth) &&
|
||
sameFloat(node._mOH, ownerHeight)
|
||
) {
|
||
layout.width = node._mOutW
|
||
layout.height = node._mOutH
|
||
_yogaCacheHits++
|
||
return
|
||
}
|
||
}
|
||
// Commit cache inputs up front so every return path leaves a valid entry.
|
||
// Only clear isDirty_ on the LAYOUT pass — the measure pass (computeFlexBasis
|
||
// → layoutNode(performLayout=false)) runs before the layout pass in the same
|
||
// calculateLayout call. Clearing dirty during measure lets the subsequent
|
||
// layout pass hit the STALE _hasL cache from the previous calculateLayout
|
||
// (before children were inserted), so ScrollBox content height never grows
|
||
// and sticky-scroll never follows new content. A dirty node's _hasL entry is
|
||
// stale by definition — invalidate it so the layout pass recomputes.
|
||
const wasDirty = node.isDirty_
|
||
if (performLayout) {
|
||
node._lW = availableWidth
|
||
node._lH = availableHeight
|
||
node._lWM = widthMode
|
||
node._lHM = heightMode
|
||
node._lOW = ownerWidth
|
||
node._lOH = ownerHeight
|
||
node._lFW = forceWidth
|
||
node._lFH = forceHeight
|
||
node._hasL = true
|
||
node.isDirty_ = false
|
||
// Previous approach cleared _cN here to prevent stale pre-dirty entries
|
||
// from hitting (long-continuous blank-screen bug). Now replaced by
|
||
// generation stamping: the cache check requires sameGen || !isDirty_, so
|
||
// previous-generation entries from a dirty node can't hit. Clearing here
|
||
// would wipe fresh same-generation entries from an earlier measure call,
|
||
// forcing recompute on the layout call.
|
||
if (wasDirty) node._hasM = false
|
||
} else {
|
||
node._mW = availableWidth
|
||
node._mH = availableHeight
|
||
node._mWM = widthMode
|
||
node._mHM = heightMode
|
||
node._mOW = ownerWidth
|
||
node._mOH = ownerHeight
|
||
node._hasM = true
|
||
// Don't clear isDirty_. For DIRTY nodes, invalidate _hasL so the upcoming
|
||
// performLayout=true call recomputes with the new child set (otherwise
|
||
// sticky-scroll never follows new content — the bug from 4557bc9f9c).
|
||
// Clean nodes keep _hasL: their layout from the previous generation is
|
||
// still valid, they're only here because an ancestor is dirty and called
|
||
// with different inputs than cached.
|
||
if (wasDirty) node._hasL = false
|
||
}
|
||
|
||
// Resolve padding/border/margin against ownerWidth (yoga uses ownerWidth for %)
|
||
// Write directly into the pre-allocated layout arrays — avoids 3 allocs per
|
||
// layoutNode call and 12 resolveEdge calls (was the #1 hotspot per CPU profile).
|
||
// Skip entirely when no edges are set — the 4-write zero is cheaper than
|
||
// the ~20 reads + ~15 compares resolveEdges4Into does to produce zeros.
|
||
const pad = layout.padding
|
||
const bor = layout.border
|
||
const mar = layout.margin
|
||
if (node._hasPadding) resolveEdges4Into(style.padding, ownerWidth, pad)
|
||
else pad[0] = pad[1] = pad[2] = pad[3] = 0
|
||
if (node._hasBorder) resolveEdges4Into(style.border, ownerWidth, bor)
|
||
else bor[0] = bor[1] = bor[2] = bor[3] = 0
|
||
if (node._hasMargin) resolveEdges4Into(style.margin, ownerWidth, mar)
|
||
else mar[0] = mar[1] = mar[2] = mar[3] = 0
|
||
|
||
const paddingBorderWidth = pad[0] + pad[2] + bor[0] + bor[2]
|
||
const paddingBorderHeight = pad[1] + pad[3] + bor[1] + bor[3]
|
||
|
||
// Resolve style dimensions
|
||
const styleWidth = forceWidth ? NaN : resolveValue(style.width, ownerWidth)
|
||
const styleHeight = forceHeight
|
||
? NaN
|
||
: resolveValue(style.height, ownerHeight)
|
||
|
||
// If style dimension is defined, it overrides the available size
|
||
let width = availableWidth
|
||
let height = availableHeight
|
||
let wMode = widthMode
|
||
let hMode = heightMode
|
||
if (isDefined(styleWidth)) {
|
||
width = styleWidth
|
||
wMode = MeasureMode.Exactly
|
||
}
|
||
if (isDefined(styleHeight)) {
|
||
height = styleHeight
|
||
hMode = MeasureMode.Exactly
|
||
}
|
||
|
||
// Apply min/max constraints to the node's own dimensions
|
||
width = boundAxis(style, true, width, ownerWidth, ownerHeight)
|
||
height = boundAxis(style, false, height, ownerWidth, ownerHeight)
|
||
|
||
// Measure-func leaf node
|
||
if (node.measureFunc && node.children.length === 0) {
|
||
const innerW =
|
||
wMode === MeasureMode.Undefined
|
||
? NaN
|
||
: Math.max(0, width - paddingBorderWidth)
|
||
const innerH =
|
||
hMode === MeasureMode.Undefined
|
||
? NaN
|
||
: Math.max(0, height - paddingBorderHeight)
|
||
_yogaMeasureCalls++
|
||
const measured = node.measureFunc(innerW, wMode, innerH, hMode)
|
||
node.layout.width =
|
||
wMode === MeasureMode.Exactly
|
||
? width
|
||
: boundAxis(
|
||
style,
|
||
true,
|
||
(measured.width ?? 0) + paddingBorderWidth,
|
||
ownerWidth,
|
||
ownerHeight,
|
||
)
|
||
node.layout.height =
|
||
hMode === MeasureMode.Exactly
|
||
? height
|
||
: boundAxis(
|
||
style,
|
||
false,
|
||
(measured.height ?? 0) + paddingBorderHeight,
|
||
ownerWidth,
|
||
ownerHeight,
|
||
)
|
||
commitCacheOutputs(node, performLayout)
|
||
// Write cache even for dirty nodes — fresh-mounted items during virtual
|
||
// scroll are dirty on first layout, but the dirty chain's measure→layout
|
||
// cascade invokes them ≥2^depth times per calculateLayout. Writing here
|
||
// lets the 2nd+ calls hit cache (isDirty_ was cleared in the layout pass
|
||
// above). Measured: 105k visits → 10k for a 1593-node fresh-mount tree.
|
||
cacheWrite(
|
||
node,
|
||
availableWidth,
|
||
availableHeight,
|
||
widthMode,
|
||
heightMode,
|
||
ownerWidth,
|
||
ownerHeight,
|
||
forceWidth,
|
||
forceHeight,
|
||
wasDirty,
|
||
)
|
||
return
|
||
}
|
||
|
||
// Leaf node with no children and no measure func
|
||
if (node.children.length === 0) {
|
||
node.layout.width =
|
||
wMode === MeasureMode.Exactly
|
||
? width
|
||
: boundAxis(style, true, paddingBorderWidth, ownerWidth, ownerHeight)
|
||
node.layout.height =
|
||
hMode === MeasureMode.Exactly
|
||
? height
|
||
: boundAxis(style, false, paddingBorderHeight, ownerWidth, ownerHeight)
|
||
commitCacheOutputs(node, performLayout)
|
||
// Write cache even for dirty nodes — fresh-mounted items during virtual
|
||
// scroll are dirty on first layout, but the dirty chain's measure→layout
|
||
// cascade invokes them ≥2^depth times per calculateLayout. Writing here
|
||
// lets the 2nd+ calls hit cache (isDirty_ was cleared in the layout pass
|
||
// above). Measured: 105k visits → 10k for a 1593-node fresh-mount tree.
|
||
cacheWrite(
|
||
node,
|
||
availableWidth,
|
||
availableHeight,
|
||
widthMode,
|
||
heightMode,
|
||
ownerWidth,
|
||
ownerHeight,
|
||
forceWidth,
|
||
forceHeight,
|
||
wasDirty,
|
||
)
|
||
return
|
||
}
|
||
|
||
// Container with children — run flexbox algorithm
|
||
const mainAxis = style.flexDirection
|
||
const crossAx = crossAxis(mainAxis)
|
||
const isMainRow = isRow(mainAxis)
|
||
|
||
const mainSize = isMainRow ? width : height
|
||
const crossSize = isMainRow ? height : width
|
||
const mainMode = isMainRow ? wMode : hMode
|
||
const crossMode = isMainRow ? hMode : wMode
|
||
const mainPadBorder = isMainRow ? paddingBorderWidth : paddingBorderHeight
|
||
const crossPadBorder = isMainRow ? paddingBorderHeight : paddingBorderWidth
|
||
|
||
const innerMainSize = isDefined(mainSize)
|
||
? Math.max(0, mainSize - mainPadBorder)
|
||
: NaN
|
||
const innerCrossSize = isDefined(crossSize)
|
||
? Math.max(0, crossSize - crossPadBorder)
|
||
: NaN
|
||
|
||
// Resolve gap
|
||
const gapMain = resolveGap(
|
||
style,
|
||
isMainRow ? Gutter.Column : Gutter.Row,
|
||
innerMainSize,
|
||
)
|
||
|
||
// Partition children into flow vs absolute. display:contents nodes are
|
||
// transparent — their children are lifted into the grandparent's child list
|
||
// (recursively), and the contents node itself gets zero layout.
|
||
const flowChildren: Node[] = []
|
||
const absChildren: Node[] = []
|
||
collectLayoutChildren(node, flowChildren, absChildren)
|
||
|
||
// ownerW/H are the reference sizes for resolving children's percentage
|
||
// values. Per CSS, a % width resolves against the parent's content-box
|
||
// width. If this node's width is indefinite, children's % widths are also
|
||
// indefinite — do NOT fall through to the grandparent's size.
|
||
const ownerW = isDefined(width) ? width : NaN
|
||
const ownerH = isDefined(height) ? height : NaN
|
||
const isWrap = style.flexWrap !== Wrap.NoWrap
|
||
const gapCross = resolveGap(
|
||
style,
|
||
isMainRow ? Gutter.Row : Gutter.Column,
|
||
innerCrossSize,
|
||
)
|
||
|
||
// STEP 1: Compute flex-basis for each flow child and break into lines.
|
||
// Single-line (NoWrap) containers always get one line; multi-line containers
|
||
// break when accumulated basis+margin+gap exceeds innerMainSize.
|
||
for (const c of flowChildren) {
|
||
c._flexBasis = computeFlexBasis(
|
||
c,
|
||
mainAxis,
|
||
innerMainSize,
|
||
innerCrossSize,
|
||
crossMode,
|
||
ownerW,
|
||
ownerH,
|
||
)
|
||
}
|
||
const lines: Node[][] = []
|
||
if (!isWrap || !isDefined(innerMainSize) || flowChildren.length === 0) {
|
||
for (const c of flowChildren) c._lineIndex = 0
|
||
lines.push(flowChildren)
|
||
} else {
|
||
// Line-break decisions use the min/max-clamped basis (flexbox spec §9.3.5:
|
||
// "hypothetical main size"), not the raw flex-basis.
|
||
let lineStart = 0
|
||
let lineLen = 0
|
||
for (let i = 0; i < flowChildren.length; i++) {
|
||
const c = flowChildren[i]!
|
||
const hypo = boundAxis(c.style, isMainRow, c._flexBasis, ownerW, ownerH)
|
||
const outer = Math.max(0, hypo) + childMarginForAxis(c, mainAxis, ownerW)
|
||
const withGap = i > lineStart ? gapMain : 0
|
||
if (i > lineStart && lineLen + withGap + outer > innerMainSize) {
|
||
lines.push(flowChildren.slice(lineStart, i))
|
||
lineStart = i
|
||
lineLen = outer
|
||
} else {
|
||
lineLen += withGap + outer
|
||
}
|
||
c._lineIndex = lines.length
|
||
}
|
||
lines.push(flowChildren.slice(lineStart))
|
||
}
|
||
const lineCount = lines.length
|
||
const isBaseline = isBaselineLayout(node, flowChildren)
|
||
|
||
// STEP 2+3: For each line, resolve flexible lengths and lay out children to
|
||
// measure cross sizes. Track per-line consumed main and max cross.
|
||
const lineConsumedMain: number[] = new Array(lineCount)
|
||
const lineCrossSizes: number[] = new Array(lineCount)
|
||
// Baseline layout tracks max ascent (baseline + leading margin) per line so
|
||
// baseline-aligned items can be positioned at maxAscent - childBaseline.
|
||
const lineMaxAscent: number[] = isBaseline ? new Array(lineCount).fill(0) : []
|
||
let maxLineMain = 0
|
||
let totalLinesCross = 0
|
||
for (let li = 0; li < lineCount; li++) {
|
||
const line = lines[li]!
|
||
const lineGap = line.length > 1 ? gapMain * (line.length - 1) : 0
|
||
let lineBasis = lineGap
|
||
for (const c of line) {
|
||
lineBasis += c._flexBasis + childMarginForAxis(c, mainAxis, ownerW)
|
||
}
|
||
// Resolve flexible lengths against available inner main. For indefinite
|
||
// containers with min/max, flex against the clamped size.
|
||
let availMain = innerMainSize
|
||
if (!isDefined(availMain)) {
|
||
const mainOwner = isMainRow ? ownerWidth : ownerHeight
|
||
const minM = resolveValue(
|
||
isMainRow ? style.minWidth : style.minHeight,
|
||
mainOwner,
|
||
)
|
||
const maxM = resolveValue(
|
||
isMainRow ? style.maxWidth : style.maxHeight,
|
||
mainOwner,
|
||
)
|
||
if (isDefined(maxM) && lineBasis > maxM - mainPadBorder) {
|
||
availMain = Math.max(0, maxM - mainPadBorder)
|
||
} else if (isDefined(minM) && lineBasis < minM - mainPadBorder) {
|
||
availMain = Math.max(0, minM - mainPadBorder)
|
||
}
|
||
}
|
||
resolveFlexibleLengths(
|
||
line,
|
||
availMain,
|
||
lineBasis,
|
||
isMainRow,
|
||
ownerW,
|
||
ownerH,
|
||
)
|
||
|
||
// Lay out each child in this line to measure cross
|
||
let lineCross = 0
|
||
for (const c of line) {
|
||
const cStyle = c.style
|
||
const childAlign =
|
||
cStyle.alignSelf === Align.Auto ? style.alignItems : cStyle.alignSelf
|
||
const cMarginCross = childMarginForAxis(c, crossAx, ownerW)
|
||
let childCrossSize = NaN
|
||
let childCrossMode: MeasureMode = MeasureMode.Undefined
|
||
const resolvedCrossStyle = resolveValue(
|
||
isMainRow ? cStyle.height : cStyle.width,
|
||
isMainRow ? ownerH : ownerW,
|
||
)
|
||
const crossLeadE = isMainRow ? EDGE_TOP : EDGE_LEFT
|
||
const crossTrailE = isMainRow ? EDGE_BOTTOM : EDGE_RIGHT
|
||
const hasCrossAutoMargin =
|
||
c._hasAutoMargin &&
|
||
(isMarginAuto(cStyle.margin, crossLeadE) ||
|
||
isMarginAuto(cStyle.margin, crossTrailE))
|
||
// Single-line stretch goes directly to the container cross size.
|
||
// Multi-line wrap measures intrinsic cross (Undefined mode) so
|
||
// flex-grow grandchildren don't expand to the container — the line
|
||
// cross size is determined first, then items are re-stretched.
|
||
if (isDefined(resolvedCrossStyle)) {
|
||
childCrossSize = resolvedCrossStyle
|
||
childCrossMode = MeasureMode.Exactly
|
||
} else if (
|
||
childAlign === Align.Stretch &&
|
||
!hasCrossAutoMargin &&
|
||
!isWrap &&
|
||
isDefined(innerCrossSize) &&
|
||
crossMode === MeasureMode.Exactly
|
||
) {
|
||
childCrossSize = Math.max(0, innerCrossSize - cMarginCross)
|
||
childCrossMode = MeasureMode.Exactly
|
||
} else if (!isWrap && isDefined(innerCrossSize)) {
|
||
childCrossSize = Math.max(0, innerCrossSize - cMarginCross)
|
||
childCrossMode = MeasureMode.AtMost
|
||
}
|
||
const cw = isMainRow ? c._mainSize : childCrossSize
|
||
const ch = isMainRow ? childCrossSize : c._mainSize
|
||
layoutNode(
|
||
c,
|
||
cw,
|
||
ch,
|
||
isMainRow ? MeasureMode.Exactly : childCrossMode,
|
||
isMainRow ? childCrossMode : MeasureMode.Exactly,
|
||
ownerW,
|
||
ownerH,
|
||
performLayout,
|
||
isMainRow,
|
||
!isMainRow,
|
||
)
|
||
c._crossSize = isMainRow ? c.layout.height : c.layout.width
|
||
lineCross = Math.max(lineCross, c._crossSize + cMarginCross)
|
||
}
|
||
// Baseline layout: line cross size must fit maxAscent + maxDescent of
|
||
// baseline-aligned children (yoga STEP 8). Only applies to row direction.
|
||
if (isBaseline) {
|
||
let maxAscent = 0
|
||
let maxDescent = 0
|
||
for (const c of line) {
|
||
if (resolveChildAlign(node, c) !== Align.Baseline) continue
|
||
const mTop = resolveEdge(c.style.margin, EDGE_TOP, ownerW)
|
||
const mBot = resolveEdge(c.style.margin, EDGE_BOTTOM, ownerW)
|
||
const ascent = calculateBaseline(c) + mTop
|
||
const descent = c.layout.height + mTop + mBot - ascent
|
||
if (ascent > maxAscent) maxAscent = ascent
|
||
if (descent > maxDescent) maxDescent = descent
|
||
}
|
||
lineMaxAscent[li] = maxAscent
|
||
if (maxAscent + maxDescent > lineCross) {
|
||
lineCross = maxAscent + maxDescent
|
||
}
|
||
}
|
||
// layoutNode(c) at line ~1117 above already resolved c.layout.margin[] via
|
||
// resolveEdges4Into with the same ownerW — read directly instead of
|
||
// re-resolving through childMarginForAxis → 2× resolveEdge.
|
||
const mainLead = leadingEdge(mainAxis)
|
||
const mainTrail = trailingEdge(mainAxis)
|
||
let consumed = lineGap
|
||
for (const c of line) {
|
||
const cm = c.layout.margin
|
||
consumed += c._mainSize + cm[mainLead]! + cm[mainTrail]!
|
||
}
|
||
lineConsumedMain[li] = consumed
|
||
lineCrossSizes[li] = lineCross
|
||
maxLineMain = Math.max(maxLineMain, consumed)
|
||
totalLinesCross += lineCross
|
||
}
|
||
const totalCrossGap = lineCount > 1 ? gapCross * (lineCount - 1) : 0
|
||
totalLinesCross += totalCrossGap
|
||
|
||
// STEP 4: Determine container dimensions. Per yoga's STEP 9, for both
|
||
// AtMost (FitContent) and Undefined (MaxContent) the node sizes to its
|
||
// content — AtMost is NOT a hard clamp, items may overflow the available
|
||
// space (CSS "fit-content" behavior). Only Scroll overflow clamps to the
|
||
// available size. Wrap containers that broke into multiple lines under
|
||
// AtMost fill the available main size since they wrapped at that boundary.
|
||
const isScroll = style.overflow === Overflow.Scroll
|
||
const contentMain = maxLineMain + mainPadBorder
|
||
const finalMainSize =
|
||
mainMode === MeasureMode.Exactly
|
||
? mainSize
|
||
: mainMode === MeasureMode.AtMost && isScroll
|
||
? Math.max(Math.min(mainSize, contentMain), mainPadBorder)
|
||
: isWrap && lineCount > 1 && mainMode === MeasureMode.AtMost
|
||
? mainSize
|
||
: contentMain
|
||
const contentCross = totalLinesCross + crossPadBorder
|
||
const finalCrossSize =
|
||
crossMode === MeasureMode.Exactly
|
||
? crossSize
|
||
: crossMode === MeasureMode.AtMost && isScroll
|
||
? Math.max(Math.min(crossSize, contentCross), crossPadBorder)
|
||
: contentCross
|
||
node.layout.width = boundAxis(
|
||
style,
|
||
true,
|
||
isMainRow ? finalMainSize : finalCrossSize,
|
||
ownerWidth,
|
||
ownerHeight,
|
||
)
|
||
node.layout.height = boundAxis(
|
||
style,
|
||
false,
|
||
isMainRow ? finalCrossSize : finalMainSize,
|
||
ownerWidth,
|
||
ownerHeight,
|
||
)
|
||
commitCacheOutputs(node, performLayout)
|
||
// Write cache even for dirty nodes — fresh-mounted items during virtual scroll
|
||
cacheWrite(
|
||
node,
|
||
availableWidth,
|
||
availableHeight,
|
||
widthMode,
|
||
heightMode,
|
||
ownerWidth,
|
||
ownerHeight,
|
||
forceWidth,
|
||
forceHeight,
|
||
wasDirty,
|
||
)
|
||
|
||
if (!performLayout) return
|
||
|
||
// STEP 5: Position lines (align-content) and children (justify-content +
|
||
// align-items + auto margins).
|
||
const actualInnerMain =
|
||
(isMainRow ? node.layout.width : node.layout.height) - mainPadBorder
|
||
const actualInnerCross =
|
||
(isMainRow ? node.layout.height : node.layout.width) - crossPadBorder
|
||
const mainLeadEdgePhys = leadingEdge(mainAxis)
|
||
const mainTrailEdgePhys = trailingEdge(mainAxis)
|
||
const crossLeadEdgePhys = isMainRow ? EDGE_TOP : EDGE_LEFT
|
||
const crossTrailEdgePhys = isMainRow ? EDGE_BOTTOM : EDGE_RIGHT
|
||
const reversed = isReverse(mainAxis)
|
||
const mainContainerSize = isMainRow ? node.layout.width : node.layout.height
|
||
const crossLead = pad[crossLeadEdgePhys]! + bor[crossLeadEdgePhys]!
|
||
|
||
// Align-content: distribute free cross space among lines. Single-line
|
||
// containers use the full cross size for the one line (align-items handles
|
||
// positioning within it).
|
||
let lineCrossOffset = crossLead
|
||
let betweenLines = gapCross
|
||
const freeCross = actualInnerCross - totalLinesCross
|
||
if (lineCount === 1 && !isWrap && !isBaseline) {
|
||
lineCrossSizes[0] = actualInnerCross
|
||
} else {
|
||
const remCross = Math.max(0, freeCross)
|
||
switch (style.alignContent) {
|
||
case Align.FlexStart:
|
||
break
|
||
case Align.Center:
|
||
lineCrossOffset += freeCross / 2
|
||
break
|
||
case Align.FlexEnd:
|
||
lineCrossOffset += freeCross
|
||
break
|
||
case Align.Stretch:
|
||
if (lineCount > 0 && remCross > 0) {
|
||
const add = remCross / lineCount
|
||
for (let i = 0; i < lineCount; i++) lineCrossSizes[i]! += add
|
||
}
|
||
break
|
||
case Align.SpaceBetween:
|
||
if (lineCount > 1) betweenLines += remCross / (lineCount - 1)
|
||
break
|
||
case Align.SpaceAround:
|
||
if (lineCount > 0) {
|
||
betweenLines += remCross / lineCount
|
||
lineCrossOffset += remCross / lineCount / 2
|
||
}
|
||
break
|
||
case Align.SpaceEvenly:
|
||
if (lineCount > 0) {
|
||
betweenLines += remCross / (lineCount + 1)
|
||
lineCrossOffset += remCross / (lineCount + 1)
|
||
}
|
||
break
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
// For wrap-reverse, lines stack from the trailing cross edge. Walk lines in
|
||
// order but flip the cross position within the container.
|
||
const wrapReverse = style.flexWrap === Wrap.WrapReverse
|
||
const crossContainerSize = isMainRow ? node.layout.height : node.layout.width
|
||
let lineCrossPos = lineCrossOffset
|
||
for (let li = 0; li < lineCount; li++) {
|
||
const line = lines[li]!
|
||
const lineCross = lineCrossSizes[li]!
|
||
const consumedMain = lineConsumedMain[li]!
|
||
const n = line.length
|
||
|
||
// Re-stretch children whose cross is auto and align is stretch, now that
|
||
// the line cross size is known. Needed for multi-line wrap (line cross
|
||
// wasn't known during initial measure) AND single-line when the container
|
||
// cross was not Exactly (initial stretch at ~line 1250 was skipped because
|
||
// innerCrossSize wasn't defined — the container sized to max child cross).
|
||
if (isWrap || crossMode !== MeasureMode.Exactly) {
|
||
for (const c of line) {
|
||
const cStyle = c.style
|
||
const childAlign =
|
||
cStyle.alignSelf === Align.Auto ? style.alignItems : cStyle.alignSelf
|
||
const crossStyleDef = isDefined(
|
||
resolveValue(
|
||
isMainRow ? cStyle.height : cStyle.width,
|
||
isMainRow ? ownerH : ownerW,
|
||
),
|
||
)
|
||
const hasCrossAutoMargin =
|
||
c._hasAutoMargin &&
|
||
(isMarginAuto(cStyle.margin, crossLeadEdgePhys) ||
|
||
isMarginAuto(cStyle.margin, crossTrailEdgePhys))
|
||
if (
|
||
childAlign === Align.Stretch &&
|
||
!crossStyleDef &&
|
||
!hasCrossAutoMargin
|
||
) {
|
||
const cMarginCross = childMarginForAxis(c, crossAx, ownerW)
|
||
const target = Math.max(0, lineCross - cMarginCross)
|
||
if (c._crossSize !== target) {
|
||
const cw = isMainRow ? c._mainSize : target
|
||
const ch = isMainRow ? target : c._mainSize
|
||
layoutNode(
|
||
c,
|
||
cw,
|
||
ch,
|
||
MeasureMode.Exactly,
|
||
MeasureMode.Exactly,
|
||
ownerW,
|
||
ownerH,
|
||
performLayout,
|
||
isMainRow,
|
||
!isMainRow,
|
||
)
|
||
c._crossSize = target
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Justify-content + auto margins for this line
|
||
let mainOffset = pad[mainLeadEdgePhys]! + bor[mainLeadEdgePhys]!
|
||
let betweenMain = gapMain
|
||
let numAutoMarginsMain = 0
|
||
for (const c of line) {
|
||
if (!c._hasAutoMargin) continue
|
||
if (isMarginAuto(c.style.margin, mainLeadEdgePhys)) numAutoMarginsMain++
|
||
if (isMarginAuto(c.style.margin, mainTrailEdgePhys)) numAutoMarginsMain++
|
||
}
|
||
const freeMain = actualInnerMain - consumedMain
|
||
const remainingMain = Math.max(0, freeMain)
|
||
const autoMarginMainSize =
|
||
numAutoMarginsMain > 0 && remainingMain > 0
|
||
? remainingMain / numAutoMarginsMain
|
||
: 0
|
||
if (numAutoMarginsMain === 0) {
|
||
switch (style.justifyContent) {
|
||
case Justify.FlexStart:
|
||
break
|
||
case Justify.Center:
|
||
mainOffset += freeMain / 2
|
||
break
|
||
case Justify.FlexEnd:
|
||
mainOffset += freeMain
|
||
break
|
||
case Justify.SpaceBetween:
|
||
if (n > 1) betweenMain += remainingMain / (n - 1)
|
||
break
|
||
case Justify.SpaceAround:
|
||
if (n > 0) {
|
||
betweenMain += remainingMain / n
|
||
mainOffset += remainingMain / n / 2
|
||
}
|
||
break
|
||
case Justify.SpaceEvenly:
|
||
if (n > 0) {
|
||
betweenMain += remainingMain / (n + 1)
|
||
mainOffset += remainingMain / (n + 1)
|
||
}
|
||
break
|
||
}
|
||
}
|
||
|
||
const effectiveLineCrossPos = wrapReverse
|
||
? crossContainerSize - lineCrossPos - lineCross
|
||
: lineCrossPos
|
||
|
||
let pos = mainOffset
|
||
for (const c of line) {
|
||
const cMargin = c.style.margin
|
||
// c.layout.margin[] was populated by resolveEdges4Into inside the
|
||
// layoutNode(c) call above (same ownerW). Read resolved values directly
|
||
// instead of re-running the edge fallback chain 4× via resolveEdge.
|
||
// Auto margins resolve to 0 in layout.margin, so autoMarginMainSize
|
||
// substitution still uses the isMarginAuto check against style.
|
||
const cLayoutMargin = c.layout.margin
|
||
let autoMainLead = false
|
||
let autoMainTrail = false
|
||
let autoCrossLead = false
|
||
let autoCrossTrail = false
|
||
let mMainLead: number
|
||
let mMainTrail: number
|
||
let mCrossLead: number
|
||
let mCrossTrail: number
|
||
if (c._hasAutoMargin) {
|
||
autoMainLead = isMarginAuto(cMargin, mainLeadEdgePhys)
|
||
autoMainTrail = isMarginAuto(cMargin, mainTrailEdgePhys)
|
||
autoCrossLead = isMarginAuto(cMargin, crossLeadEdgePhys)
|
||
autoCrossTrail = isMarginAuto(cMargin, crossTrailEdgePhys)
|
||
mMainLead = autoMainLead
|
||
? autoMarginMainSize
|
||
: cLayoutMargin[mainLeadEdgePhys]!
|
||
mMainTrail = autoMainTrail
|
||
? autoMarginMainSize
|
||
: cLayoutMargin[mainTrailEdgePhys]!
|
||
mCrossLead = autoCrossLead ? 0 : cLayoutMargin[crossLeadEdgePhys]!
|
||
mCrossTrail = autoCrossTrail ? 0 : cLayoutMargin[crossTrailEdgePhys]!
|
||
} else {
|
||
// Fast path: no auto margins — read resolved values directly.
|
||
mMainLead = cLayoutMargin[mainLeadEdgePhys]!
|
||
mMainTrail = cLayoutMargin[mainTrailEdgePhys]!
|
||
mCrossLead = cLayoutMargin[crossLeadEdgePhys]!
|
||
mCrossTrail = cLayoutMargin[crossTrailEdgePhys]!
|
||
}
|
||
|
||
const mainPos = reversed
|
||
? mainContainerSize - (pos + mMainLead) - c._mainSize
|
||
: pos + mMainLead
|
||
|
||
const childAlign =
|
||
c.style.alignSelf === Align.Auto ? style.alignItems : c.style.alignSelf
|
||
let crossPos = effectiveLineCrossPos + mCrossLead
|
||
const crossFree = lineCross - c._crossSize - mCrossLead - mCrossTrail
|
||
if (autoCrossLead && autoCrossTrail) {
|
||
crossPos += Math.max(0, crossFree) / 2
|
||
} else if (autoCrossLead) {
|
||
crossPos += Math.max(0, crossFree)
|
||
} else if (autoCrossTrail) {
|
||
// stays at leading
|
||
} else {
|
||
switch (childAlign) {
|
||
case Align.FlexStart:
|
||
case Align.Stretch:
|
||
if (wrapReverse) crossPos += crossFree
|
||
break
|
||
case Align.Center:
|
||
crossPos += crossFree / 2
|
||
break
|
||
case Align.FlexEnd:
|
||
if (!wrapReverse) crossPos += crossFree
|
||
break
|
||
case Align.Baseline:
|
||
// Row direction only (isBaselineLayout checked this). Position so
|
||
// the child's baseline aligns with the line's max ascent. Per
|
||
// yoga: top = currentLead + maxAscent - childBaseline + leadingPosition.
|
||
if (isBaseline) {
|
||
crossPos =
|
||
effectiveLineCrossPos +
|
||
lineMaxAscent[li]! -
|
||
calculateBaseline(c)
|
||
}
|
||
break
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
// Relative position offsets. Fast path: no position insets set →
|
||
// skip 4× resolveEdgeRaw + 4× resolveValue + 4× isDefined.
|
||
let relX = 0
|
||
let relY = 0
|
||
if (c._hasPosition) {
|
||
const relLeft = resolveValue(
|
||
resolveEdgeRaw(c.style.position, EDGE_LEFT),
|
||
ownerW,
|
||
)
|
||
const relRight = resolveValue(
|
||
resolveEdgeRaw(c.style.position, EDGE_RIGHT),
|
||
ownerW,
|
||
)
|
||
const relTop = resolveValue(
|
||
resolveEdgeRaw(c.style.position, EDGE_TOP),
|
||
ownerW,
|
||
)
|
||
const relBottom = resolveValue(
|
||
resolveEdgeRaw(c.style.position, EDGE_BOTTOM),
|
||
ownerW,
|
||
)
|
||
relX = isDefined(relLeft)
|
||
? relLeft
|
||
: isDefined(relRight)
|
||
? -relRight
|
||
: 0
|
||
relY = isDefined(relTop)
|
||
? relTop
|
||
: isDefined(relBottom)
|
||
? -relBottom
|
||
: 0
|
||
}
|
||
|
||
if (isMainRow) {
|
||
c.layout.left = mainPos + relX
|
||
c.layout.top = crossPos + relY
|
||
} else {
|
||
c.layout.left = crossPos + relX
|
||
c.layout.top = mainPos + relY
|
||
}
|
||
pos += c._mainSize + mMainLead + mMainTrail + betweenMain
|
||
}
|
||
lineCrossPos += lineCross + betweenLines
|
||
}
|
||
|
||
// STEP 6: Absolute-positioned children
|
||
for (const c of absChildren) {
|
||
layoutAbsoluteChild(
|
||
node,
|
||
c,
|
||
node.layout.width,
|
||
node.layout.height,
|
||
pad,
|
||
bor,
|
||
)
|
||
}
|
||
}
|
||
|
||
function layoutAbsoluteChild(
|
||
parent: Node,
|
||
child: Node,
|
||
parentWidth: number,
|
||
parentHeight: number,
|
||
pad: [number, number, number, number],
|
||
bor: [number, number, number, number],
|
||
): void {
|
||
const cs = child.style
|
||
const posLeft = resolveEdgeRaw(cs.position, EDGE_LEFT)
|
||
const posRight = resolveEdgeRaw(cs.position, EDGE_RIGHT)
|
||
const posTop = resolveEdgeRaw(cs.position, EDGE_TOP)
|
||
const posBottom = resolveEdgeRaw(cs.position, EDGE_BOTTOM)
|
||
|
||
const rLeft = resolveValue(posLeft, parentWidth)
|
||
const rRight = resolveValue(posRight, parentWidth)
|
||
const rTop = resolveValue(posTop, parentHeight)
|
||
const rBottom = resolveValue(posBottom, parentHeight)
|
||
|
||
// Absolute children's percentage dimensions resolve against the containing
|
||
// block's padding-box (parent size minus border), per CSS §10.1.
|
||
const paddingBoxW = parentWidth - bor[0] - bor[2]
|
||
const paddingBoxH = parentHeight - bor[1] - bor[3]
|
||
let cw = resolveValue(cs.width, paddingBoxW)
|
||
let ch = resolveValue(cs.height, paddingBoxH)
|
||
|
||
// If both left+right defined and width not, derive width
|
||
if (!isDefined(cw) && isDefined(rLeft) && isDefined(rRight)) {
|
||
cw = paddingBoxW - rLeft - rRight
|
||
}
|
||
if (!isDefined(ch) && isDefined(rTop) && isDefined(rBottom)) {
|
||
ch = paddingBoxH - rTop - rBottom
|
||
}
|
||
|
||
layoutNode(
|
||
child,
|
||
cw,
|
||
ch,
|
||
isDefined(cw) ? MeasureMode.Exactly : MeasureMode.Undefined,
|
||
isDefined(ch) ? MeasureMode.Exactly : MeasureMode.Undefined,
|
||
paddingBoxW,
|
||
paddingBoxH,
|
||
true,
|
||
)
|
||
|
||
// Margin of absolute child (applied in addition to insets)
|
||
const mL = resolveEdge(cs.margin, EDGE_LEFT, parentWidth)
|
||
const mT = resolveEdge(cs.margin, EDGE_TOP, parentWidth)
|
||
const mR = resolveEdge(cs.margin, EDGE_RIGHT, parentWidth)
|
||
const mB = resolveEdge(cs.margin, EDGE_BOTTOM, parentWidth)
|
||
|
||
const mainAxis = parent.style.flexDirection
|
||
const reversed = isReverse(mainAxis)
|
||
const mainRow = isRow(mainAxis)
|
||
const wrapReverse = parent.style.flexWrap === Wrap.WrapReverse
|
||
// alignSelf overrides alignItems for absolute children (same as flow items)
|
||
const alignment =
|
||
cs.alignSelf === Align.Auto ? parent.style.alignItems : cs.alignSelf
|
||
|
||
// Position
|
||
let left: number
|
||
if (isDefined(rLeft)) {
|
||
left = bor[0] + rLeft + mL
|
||
} else if (isDefined(rRight)) {
|
||
left = parentWidth - bor[2] - rRight - child.layout.width - mR
|
||
} else if (mainRow) {
|
||
// Main axis — justify-content, flipped for reversed
|
||
const lead = pad[0] + bor[0]
|
||
const trail = parentWidth - pad[2] - bor[2]
|
||
left = reversed
|
||
? trail - child.layout.width - mR
|
||
: justifyAbsolute(
|
||
parent.style.justifyContent,
|
||
lead,
|
||
trail,
|
||
child.layout.width,
|
||
) + mL
|
||
} else {
|
||
left =
|
||
alignAbsolute(
|
||
alignment,
|
||
pad[0] + bor[0],
|
||
parentWidth - pad[2] - bor[2],
|
||
child.layout.width,
|
||
wrapReverse,
|
||
) + mL
|
||
}
|
||
|
||
let top: number
|
||
if (isDefined(rTop)) {
|
||
top = bor[1] + rTop + mT
|
||
} else if (isDefined(rBottom)) {
|
||
top = parentHeight - bor[3] - rBottom - child.layout.height - mB
|
||
} else if (mainRow) {
|
||
top =
|
||
alignAbsolute(
|
||
alignment,
|
||
pad[1] + bor[1],
|
||
parentHeight - pad[3] - bor[3],
|
||
child.layout.height,
|
||
wrapReverse,
|
||
) + mT
|
||
} else {
|
||
const lead = pad[1] + bor[1]
|
||
const trail = parentHeight - pad[3] - bor[3]
|
||
top = reversed
|
||
? trail - child.layout.height - mB
|
||
: justifyAbsolute(
|
||
parent.style.justifyContent,
|
||
lead,
|
||
trail,
|
||
child.layout.height,
|
||
) + mT
|
||
}
|
||
|
||
child.layout.left = left
|
||
child.layout.top = top
|
||
}
|
||
|
||
function justifyAbsolute(
|
||
justify: Justify,
|
||
leadEdge: number,
|
||
trailEdge: number,
|
||
childSize: number,
|
||
): number {
|
||
switch (justify) {
|
||
case Justify.Center:
|
||
return leadEdge + (trailEdge - leadEdge - childSize) / 2
|
||
case Justify.FlexEnd:
|
||
return trailEdge - childSize
|
||
default:
|
||
return leadEdge
|
||
}
|
||
}
|
||
|
||
function alignAbsolute(
|
||
align: Align,
|
||
leadEdge: number,
|
||
trailEdge: number,
|
||
childSize: number,
|
||
wrapReverse: boolean,
|
||
): number {
|
||
// Wrap-reverse flips the cross axis: flex-start/stretch go to trailing,
|
||
// flex-end goes to leading (yoga's absoluteLayoutChild flips the align value
|
||
// when the containing block has wrap-reverse).
|
||
switch (align) {
|
||
case Align.Center:
|
||
return leadEdge + (trailEdge - leadEdge - childSize) / 2
|
||
case Align.FlexEnd:
|
||
return wrapReverse ? leadEdge : trailEdge - childSize
|
||
default:
|
||
return wrapReverse ? trailEdge - childSize : leadEdge
|
||
}
|
||
}
|
||
|
||
function computeFlexBasis(
|
||
child: Node,
|
||
mainAxis: FlexDirection,
|
||
availableMain: number,
|
||
availableCross: number,
|
||
crossMode: MeasureMode,
|
||
ownerWidth: number,
|
||
ownerHeight: number,
|
||
): number {
|
||
// Same-generation cache hit: basis was computed THIS calculateLayout, so
|
||
// it's fresh regardless of isDirty_. Covers both clean children (scrolling
|
||
// past unchanged messages) AND fresh-mounted dirty children (virtual
|
||
// scroll mounts new items — the dirty chain's measure→layout cascade
|
||
// invokes this ≥2^depth times, but the child's subtree doesn't change
|
||
// between calls within one calculateLayout). For clean children with
|
||
// cache from a PREVIOUS generation, also hit if inputs match — isDirty_
|
||
// gates since a dirty child's previous-gen cache is stale.
|
||
const sameGen = child._fbGen === _generation
|
||
if (
|
||
(sameGen || !child.isDirty_) &&
|
||
child._fbCrossMode === crossMode &&
|
||
sameFloat(child._fbOwnerW, ownerWidth) &&
|
||
sameFloat(child._fbOwnerH, ownerHeight) &&
|
||
sameFloat(child._fbAvailMain, availableMain) &&
|
||
sameFloat(child._fbAvailCross, availableCross)
|
||
) {
|
||
return child._fbBasis
|
||
}
|
||
const cs = child.style
|
||
const isMainRow = isRow(mainAxis)
|
||
|
||
// Explicit flex-basis
|
||
const basis = resolveValue(cs.flexBasis, availableMain)
|
||
if (isDefined(basis)) {
|
||
const b = Math.max(0, basis)
|
||
child._fbBasis = b
|
||
child._fbOwnerW = ownerWidth
|
||
child._fbOwnerH = ownerHeight
|
||
child._fbAvailMain = availableMain
|
||
child._fbAvailCross = availableCross
|
||
child._fbCrossMode = crossMode
|
||
child._fbGen = _generation
|
||
return b
|
||
}
|
||
|
||
// Style dimension on main axis
|
||
const mainStyleDim = isMainRow ? cs.width : cs.height
|
||
const mainOwner = isMainRow ? ownerWidth : ownerHeight
|
||
const resolved = resolveValue(mainStyleDim, mainOwner)
|
||
if (isDefined(resolved)) {
|
||
const b = Math.max(0, resolved)
|
||
child._fbBasis = b
|
||
child._fbOwnerW = ownerWidth
|
||
child._fbOwnerH = ownerHeight
|
||
child._fbAvailMain = availableMain
|
||
child._fbAvailCross = availableCross
|
||
child._fbCrossMode = crossMode
|
||
child._fbGen = _generation
|
||
return b
|
||
}
|
||
|
||
// Need to measure the child to get its natural size
|
||
const crossStyleDim = isMainRow ? cs.height : cs.width
|
||
const crossOwner = isMainRow ? ownerHeight : ownerWidth
|
||
let crossConstraint = resolveValue(crossStyleDim, crossOwner)
|
||
let crossConstraintMode: MeasureMode = isDefined(crossConstraint)
|
||
? MeasureMode.Exactly
|
||
: MeasureMode.Undefined
|
||
if (!isDefined(crossConstraint) && isDefined(availableCross)) {
|
||
crossConstraint = availableCross
|
||
crossConstraintMode =
|
||
crossMode === MeasureMode.Exactly && isStretchAlign(child)
|
||
? MeasureMode.Exactly
|
||
: MeasureMode.AtMost
|
||
}
|
||
|
||
// Upstream yoga (YGNodeComputeFlexBasisForChild) passes the available inner
|
||
// width with mode AtMost when the subtree will call a measure-func — so text
|
||
// nodes don't report unconstrained intrinsic width as flex-basis, which
|
||
// would force siblings to shrink and the text to wrap at the wrong width.
|
||
// Passing Undefined here made Ink's <Text> inside <Box flexGrow={1}> get
|
||
// width = intrinsic instead of available, dropping chars at wrap boundaries.
|
||
//
|
||
// Two constraints on when this applies:
|
||
// - Width only. Height is never constrained during basis measurement —
|
||
// column containers must measure children at natural height so
|
||
// scrollable content can overflow (constraining height clips ScrollBox).
|
||
// - Subtree has a measure-func. Pure layout subtrees (no measure-func)
|
||
// with flex-grow children would grow into the AtMost constraint,
|
||
// inflating the basis (breaks YGMinMaxDimensionTest flex_grow_in_at_most
|
||
// where a flexGrow:1 child should stay at basis 0, not grow to 100).
|
||
let mainConstraint = NaN
|
||
let mainConstraintMode: MeasureMode = MeasureMode.Undefined
|
||
if (isMainRow && isDefined(availableMain) && hasMeasureFuncInSubtree(child)) {
|
||
mainConstraint = availableMain
|
||
mainConstraintMode = MeasureMode.AtMost
|
||
}
|
||
|
||
const mw = isMainRow ? mainConstraint : crossConstraint
|
||
const mh = isMainRow ? crossConstraint : mainConstraint
|
||
const mwMode = isMainRow ? mainConstraintMode : crossConstraintMode
|
||
const mhMode = isMainRow ? crossConstraintMode : mainConstraintMode
|
||
|
||
layoutNode(child, mw, mh, mwMode, mhMode, ownerWidth, ownerHeight, false)
|
||
const b = isMainRow ? child.layout.width : child.layout.height
|
||
child._fbBasis = b
|
||
child._fbOwnerW = ownerWidth
|
||
child._fbOwnerH = ownerHeight
|
||
child._fbAvailMain = availableMain
|
||
child._fbAvailCross = availableCross
|
||
child._fbCrossMode = crossMode
|
||
child._fbGen = _generation
|
||
return b
|
||
}
|
||
|
||
function hasMeasureFuncInSubtree(node: Node): boolean {
|
||
if (node.measureFunc) return true
|
||
for (const c of node.children) {
|
||
if (hasMeasureFuncInSubtree(c)) return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
function resolveFlexibleLengths(
|
||
children: Node[],
|
||
availableInnerMain: number,
|
||
totalFlexBasis: number,
|
||
isMainRow: boolean,
|
||
ownerW: number,
|
||
ownerH: number,
|
||
): void {
|
||
// Multi-pass flex distribution per CSS flexbox spec §9.7 "Resolving Flexible
|
||
// Lengths": distribute free space, detect min/max violations, freeze all
|
||
// violators, redistribute among unfrozen children. Repeat until stable.
|
||
const n = children.length
|
||
const frozen: boolean[] = new Array(n).fill(false)
|
||
const initialFree = isDefined(availableInnerMain)
|
||
? availableInnerMain - totalFlexBasis
|
||
: 0
|
||
// Freeze inflexible items at their clamped basis
|
||
for (let i = 0; i < n; i++) {
|
||
const c = children[i]!
|
||
const clamped = boundAxis(c.style, isMainRow, c._flexBasis, ownerW, ownerH)
|
||
const inflexible =
|
||
!isDefined(availableInnerMain) ||
|
||
(initialFree >= 0 ? c.style.flexGrow === 0 : c.style.flexShrink === 0)
|
||
if (inflexible) {
|
||
c._mainSize = Math.max(0, clamped)
|
||
frozen[i] = true
|
||
} else {
|
||
c._mainSize = c._flexBasis
|
||
}
|
||
}
|
||
// Iteratively distribute until no violations. Free space is recomputed each
|
||
// pass: initial free space minus the delta frozen children consumed beyond
|
||
// (or below) their basis.
|
||
const unclamped: number[] = new Array(n)
|
||
for (let iter = 0; iter <= n; iter++) {
|
||
let frozenDelta = 0
|
||
let totalGrow = 0
|
||
let totalShrinkScaled = 0
|
||
let unfrozenCount = 0
|
||
for (let i = 0; i < n; i++) {
|
||
const c = children[i]!
|
||
if (frozen[i]) {
|
||
frozenDelta += c._mainSize - c._flexBasis
|
||
} else {
|
||
totalGrow += c.style.flexGrow
|
||
totalShrinkScaled += c.style.flexShrink * c._flexBasis
|
||
unfrozenCount++
|
||
}
|
||
}
|
||
if (unfrozenCount === 0) break
|
||
let remaining = initialFree - frozenDelta
|
||
// Spec §9.7 step 4c: if sum of flex factors < 1, only distribute
|
||
// initialFree × sum, not the full remaining space (partial flex).
|
||
if (remaining > 0 && totalGrow > 0 && totalGrow < 1) {
|
||
const scaled = initialFree * totalGrow
|
||
if (scaled < remaining) remaining = scaled
|
||
} else if (remaining < 0 && totalShrinkScaled > 0) {
|
||
let totalShrink = 0
|
||
for (let i = 0; i < n; i++) {
|
||
if (!frozen[i]) totalShrink += children[i]!.style.flexShrink
|
||
}
|
||
if (totalShrink < 1) {
|
||
const scaled = initialFree * totalShrink
|
||
if (scaled > remaining) remaining = scaled
|
||
}
|
||
}
|
||
// Compute targets + violations for all unfrozen children
|
||
let totalViolation = 0
|
||
for (let i = 0; i < n; i++) {
|
||
if (frozen[i]) continue
|
||
const c = children[i]!
|
||
let t = c._flexBasis
|
||
if (remaining > 0 && totalGrow > 0) {
|
||
t += (remaining * c.style.flexGrow) / totalGrow
|
||
} else if (remaining < 0 && totalShrinkScaled > 0) {
|
||
t +=
|
||
(remaining * (c.style.flexShrink * c._flexBasis)) / totalShrinkScaled
|
||
}
|
||
unclamped[i] = t
|
||
const clamped = Math.max(
|
||
0,
|
||
boundAxis(c.style, isMainRow, t, ownerW, ownerH),
|
||
)
|
||
c._mainSize = clamped
|
||
totalViolation += clamped - t
|
||
}
|
||
// Freeze per spec §9.7 step 5: if totalViolation is zero freeze all; if
|
||
// positive freeze min-violators; if negative freeze max-violators.
|
||
if (totalViolation === 0) break
|
||
let anyFrozen = false
|
||
for (let i = 0; i < n; i++) {
|
||
if (frozen[i]) continue
|
||
const v = children[i]!._mainSize - unclamped[i]!
|
||
if ((totalViolation > 0 && v > 0) || (totalViolation < 0 && v < 0)) {
|
||
frozen[i] = true
|
||
anyFrozen = true
|
||
}
|
||
}
|
||
if (!anyFrozen) break
|
||
}
|
||
}
|
||
|
||
function isStretchAlign(child: Node): boolean {
|
||
const p = child.parent
|
||
if (!p) return false
|
||
const align =
|
||
child.style.alignSelf === Align.Auto
|
||
? p.style.alignItems
|
||
: child.style.alignSelf
|
||
return align === Align.Stretch
|
||
}
|
||
|
||
function resolveChildAlign(parent: Node, child: Node): Align {
|
||
return child.style.alignSelf === Align.Auto
|
||
? parent.style.alignItems
|
||
: child.style.alignSelf
|
||
}
|
||
|
||
// Baseline of a node per CSS Flexbox §8.5 / yoga's YGBaseline. Leaf nodes
|
||
// (no children) use their own height. Containers recurse into the first
|
||
// baseline-aligned child on the first line (or the first flow child if none
|
||
// are baseline-aligned), returning that child's baseline + its top offset.
|
||
function calculateBaseline(node: Node): number {
|
||
let baselineChild: Node | null = null
|
||
for (const c of node.children) {
|
||
if (c._lineIndex > 0) break
|
||
if (c.style.positionType === PositionType.Absolute) continue
|
||
if (c.style.display === Display.None) continue
|
||
if (
|
||
resolveChildAlign(node, c) === Align.Baseline ||
|
||
c.isReferenceBaseline_
|
||
) {
|
||
baselineChild = c
|
||
break
|
||
}
|
||
if (baselineChild === null) baselineChild = c
|
||
}
|
||
if (baselineChild === null) return node.layout.height
|
||
return calculateBaseline(baselineChild) + baselineChild.layout.top
|
||
}
|
||
|
||
// A container uses baseline layout only for row direction, when either
|
||
// align-items is baseline or any flow child has align-self: baseline.
|
||
function isBaselineLayout(node: Node, flowChildren: Node[]): boolean {
|
||
if (!isRow(node.style.flexDirection)) return false
|
||
if (node.style.alignItems === Align.Baseline) return true
|
||
for (const c of flowChildren) {
|
||
if (c.style.alignSelf === Align.Baseline) return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
function childMarginForAxis(
|
||
child: Node,
|
||
axis: FlexDirection,
|
||
ownerWidth: number,
|
||
): number {
|
||
if (!child._hasMargin) return 0
|
||
const lead = resolveEdge(child.style.margin, leadingEdge(axis), ownerWidth)
|
||
const trail = resolveEdge(child.style.margin, trailingEdge(axis), ownerWidth)
|
||
return lead + trail
|
||
}
|
||
|
||
function resolveGap(style: Style, gutter: Gutter, ownerSize: number): number {
|
||
let v = style.gap[gutter]!
|
||
if (v.unit === Unit.Undefined) v = style.gap[Gutter.All]!
|
||
const r = resolveValue(v, ownerSize)
|
||
return isDefined(r) ? Math.max(0, r) : 0
|
||
}
|
||
|
||
function boundAxis(
|
||
style: Style,
|
||
isWidth: boolean,
|
||
value: number,
|
||
ownerWidth: number,
|
||
ownerHeight: number,
|
||
): number {
|
||
const minV = isWidth ? style.minWidth : style.minHeight
|
||
const maxV = isWidth ? style.maxWidth : style.maxHeight
|
||
const minU = minV.unit
|
||
const maxU = maxV.unit
|
||
// Fast path: no min/max constraints set. Per CPU profile this is the
|
||
// overwhelmingly common case (~32k calls/layout on the 1000-node bench,
|
||
// nearly all with undefined min/max) — skipping 2× resolveValue + 2× isNaN
|
||
// that always no-op. Unit.Undefined = 0.
|
||
if (minU === 0 && maxU === 0) return value
|
||
const owner = isWidth ? ownerWidth : ownerHeight
|
||
let v = value
|
||
// Inlined resolveValue: Unit.Point=1, Unit.Percent=2. `m === m` is !isNaN.
|
||
if (maxU === 1) {
|
||
if (v > maxV.value) v = maxV.value
|
||
} else if (maxU === 2) {
|
||
const m = (maxV.value * owner) / 100
|
||
if (m === m && v > m) v = m
|
||
}
|
||
if (minU === 1) {
|
||
if (v < minV.value) v = minV.value
|
||
} else if (minU === 2) {
|
||
const m = (minV.value * owner) / 100
|
||
if (m === m && v < m) v = m
|
||
}
|
||
return v
|
||
}
|
||
|
||
function zeroLayoutRecursive(node: Node): void {
|
||
for (const c of node.children) {
|
||
c.layout.left = 0
|
||
c.layout.top = 0
|
||
c.layout.width = 0
|
||
c.layout.height = 0
|
||
// Invalidate layout cache — without this, unhide → calculateLayout finds
|
||
// the child clean (!isDirty_) with _hasL intact, hits the cache at line
|
||
// ~1086, restores stale _lOutW/_lOutH, and returns early — skipping the
|
||
// child-positioning recursion. Grandchildren stay at (0,0,0,0) from the
|
||
// zeroing above and render invisible. isDirty_=true also gates _cN and
|
||
// _fbBasis via their (sameGen || !isDirty_) checks — _cGen/_fbGen freeze
|
||
// during hide so sameGen is false on unhide.
|
||
c.isDirty_ = true
|
||
c._hasL = false
|
||
c._hasM = false
|
||
zeroLayoutRecursive(c)
|
||
}
|
||
}
|
||
|
||
function collectLayoutChildren(node: Node, flow: Node[], abs: Node[]): void {
|
||
// Partition a node's children into flow and absolute lists, flattening
|
||
// display:contents subtrees so their children are laid out as direct
|
||
// children of this node (per CSS display:contents spec — the box is removed
|
||
// from the layout tree but its children remain, lifted to the grandparent).
|
||
for (const c of node.children) {
|
||
const disp = c.style.display
|
||
if (disp === Display.None) {
|
||
c.layout.left = 0
|
||
c.layout.top = 0
|
||
c.layout.width = 0
|
||
c.layout.height = 0
|
||
zeroLayoutRecursive(c)
|
||
} else if (disp === Display.Contents) {
|
||
c.layout.left = 0
|
||
c.layout.top = 0
|
||
c.layout.width = 0
|
||
c.layout.height = 0
|
||
// Recurse — nested display:contents lifts all the way up. The contents
|
||
// node's own margin/padding/position/dimensions are ignored.
|
||
collectLayoutChildren(c, flow, abs)
|
||
} else if (c.style.positionType === PositionType.Absolute) {
|
||
abs.push(c)
|
||
} else {
|
||
flow.push(c)
|
||
}
|
||
}
|
||
}
|
||
|
||
function roundLayout(
|
||
node: Node,
|
||
scale: number,
|
||
absLeft: number,
|
||
absTop: number,
|
||
): void {
|
||
if (scale === 0) return
|
||
const l = node.layout
|
||
const nodeLeft = l.left
|
||
const nodeTop = l.top
|
||
const nodeWidth = l.width
|
||
const nodeHeight = l.height
|
||
|
||
const absNodeLeft = absLeft + nodeLeft
|
||
const absNodeTop = absTop + nodeTop
|
||
|
||
// Upstream YGRoundValueToPixelGrid: text nodes (has measureFunc) floor their
|
||
// positions so wrapped text never starts past its allocated column. Width
|
||
// uses ceil-if-fractional to avoid clipping the last glyph. Non-text nodes
|
||
// use standard round. Matches yoga's PixelGrid.cpp — without this, justify
|
||
// center/space-evenly positions are off-by-one vs WASM and flex-shrink
|
||
// overflow places siblings at the wrong column.
|
||
const isText = node.measureFunc !== null
|
||
l.left = roundValue(nodeLeft, scale, false, isText)
|
||
l.top = roundValue(nodeTop, scale, false, isText)
|
||
|
||
// Width/height rounded via absolute edges to avoid cumulative drift
|
||
const absRight = absNodeLeft + nodeWidth
|
||
const absBottom = absNodeTop + nodeHeight
|
||
const hasFracW = !isWholeNumber(nodeWidth * scale)
|
||
const hasFracH = !isWholeNumber(nodeHeight * scale)
|
||
l.width =
|
||
roundValue(absRight, scale, isText && hasFracW, isText && !hasFracW) -
|
||
roundValue(absNodeLeft, scale, false, isText)
|
||
l.height =
|
||
roundValue(absBottom, scale, isText && hasFracH, isText && !hasFracH) -
|
||
roundValue(absNodeTop, scale, false, isText)
|
||
|
||
for (const c of node.children) {
|
||
roundLayout(c, scale, absNodeLeft, absNodeTop)
|
||
}
|
||
}
|
||
|
||
function isWholeNumber(v: number): boolean {
|
||
const frac = v - Math.floor(v)
|
||
return frac < 0.0001 || frac > 0.9999
|
||
}
|
||
|
||
function roundValue(
|
||
v: number,
|
||
scale: number,
|
||
forceCeil: boolean,
|
||
forceFloor: boolean,
|
||
): number {
|
||
let scaled = v * scale
|
||
let frac = scaled - Math.floor(scaled)
|
||
if (frac < 0) frac += 1
|
||
// Float-epsilon tolerance matches upstream YGDoubleEqual (1e-4)
|
||
if (frac < 0.0001) {
|
||
scaled = Math.floor(scaled)
|
||
} else if (frac > 0.9999) {
|
||
scaled = Math.ceil(scaled)
|
||
} else if (forceCeil) {
|
||
scaled = Math.ceil(scaled)
|
||
} else if (forceFloor) {
|
||
scaled = Math.floor(scaled)
|
||
} else {
|
||
// Round half-up (>= 0.5 goes up), per upstream
|
||
scaled = Math.floor(scaled) + (frac >= 0.4999 ? 1 : 0)
|
||
}
|
||
return scaled / scale
|
||
}
|
||
|
||
// --
|
||
// Helpers
|
||
|
||
function parseDimension(v: number | string | undefined): Value {
|
||
if (v === undefined) return UNDEFINED_VALUE
|
||
if (v === 'auto') return AUTO_VALUE
|
||
if (typeof v === 'number') {
|
||
// WASM yoga's YGFloatIsUndefined treats NaN and ±Infinity as undefined.
|
||
// Ink passes height={Infinity} (e.g. LogSelector maxHeight default) and
|
||
// expects it to mean "unconstrained" — storing it as a literal point value
|
||
// makes the node height Infinity and breaks all downstream layout.
|
||
return Number.isFinite(v) ? pointValue(v) : UNDEFINED_VALUE
|
||
}
|
||
if (typeof v === 'string' && v.endsWith('%')) {
|
||
return percentValue(parseFloat(v))
|
||
}
|
||
const n = parseFloat(v)
|
||
return isNaN(n) ? UNDEFINED_VALUE : pointValue(n)
|
||
}
|
||
|
||
function physicalEdge(edge: Edge): number {
|
||
switch (edge) {
|
||
case Edge.Left:
|
||
case Edge.Start:
|
||
return EDGE_LEFT
|
||
case Edge.Top:
|
||
return EDGE_TOP
|
||
case Edge.Right:
|
||
case Edge.End:
|
||
return EDGE_RIGHT
|
||
case Edge.Bottom:
|
||
return EDGE_BOTTOM
|
||
default:
|
||
return EDGE_LEFT
|
||
}
|
||
}
|
||
|
||
// --
|
||
// Module API matching yoga-layout/load
|
||
|
||
export type Yoga = {
|
||
Config: {
|
||
create(): Config
|
||
destroy(config: Config): void
|
||
}
|
||
Node: {
|
||
create(config?: Config): Node
|
||
createDefault(): Node
|
||
createWithConfig(config: Config): Node
|
||
destroy(node: Node): void
|
||
}
|
||
}
|
||
|
||
const YOGA_INSTANCE: Yoga = {
|
||
Config: {
|
||
create: createConfig,
|
||
destroy() {},
|
||
},
|
||
Node: {
|
||
create: (config?: Config) => new Node(config),
|
||
createDefault: () => new Node(),
|
||
createWithConfig: (config: Config) => new Node(config),
|
||
destroy() {},
|
||
},
|
||
}
|
||
|
||
export function loadYoga(): Promise<Yoga> {
|
||
return Promise.resolve(YOGA_INSTANCE)
|
||
}
|
||
|
||
export default YOGA_INSTANCE
|