openpencil/packages/core/src/layout.ts
2026-07-02 08:53:48 +03:00

470 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
Align,
Direction,
Display,
FlexDirection,
Gutter,
Edge,
MeasureMode,
Overflow,
Wrap,
type Node as YogaNode
} from 'yoga-layout'
import { applyYogaLayout } from './layout/apply'
import { buildGridTree, createGridChildNode } from './layout/grid'
import { resolveNodeLayoutDirection } from './text/direction'
export {
estimateTextSize,
getTextMeasurer,
setTextMeasurer,
type TextMeasurer
} from './layout/text-measurement'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { estimateTextSize, getTextMeasurer } from './layout/text-measurement'
import {
applyMinMaxConstraints,
configureAbsoluteChild,
createYogaNode,
freeYogaTree,
mapAlign,
mapAlignSelf,
mapGridTrack,
mapJustify
} from './layout/yoga-helpers'
export function computeLayout(graph: SceneGraph, frameId: string): void {
const frame = graph.getNode(frameId)
if (!frame || frame.layoutMode === 'NONE') return
const rootDirection = resolveComputedLayoutDirection(graph, frame)
const yogaRoot =
frame.layoutMode === 'GRID'
? buildGridTree(graph, frame, rootDirection)
: buildYogaTree(graph, frame, rootDirection)
yogaRoot.calculateLayout(
undefined,
undefined,
rootDirection === 'RTL' ? Direction.RTL : Direction.LTR
)
applyYogaLayout(graph, frame, yogaRoot, computeLayout)
freeYogaTree(yogaRoot)
}
function resolveComputedLayoutDirection(
graph: SceneGraph,
node: Pick<SceneNode, 'layoutDirection' | 'parentId'>
): 'LTR' | 'RTL' {
const parent = node.parentId ? graph.getNode(node.parentId) : null
const inheritedDirection = parent ? resolveComputedLayoutDirection(graph, parent) : 'LTR'
return resolveNodeLayoutDirection(node, inheritedDirection)
}
export function computeAllLayouts(graph: SceneGraph, scopeId?: string): void {
const visited = new Set<string>()
computeLayoutsBottomUp(graph, scopeId ?? graph.rootId, visited)
}
function computeLayoutsBottomUp(graph: SceneGraph, nodeId: string, visited: Set<string>): void {
const node = graph.getNode(nodeId)
if (!node || visited.has(nodeId)) return
visited.add(nodeId)
for (const childId of node.childIds) {
computeLayoutsBottomUp(graph, childId, visited)
}
if (node.layoutMode !== 'NONE' && !preservesImportedInstanceLayout(node)) {
computeLayout(graph, nodeId)
}
}
function preservesImportedInstanceLayout(node: SceneNode): boolean {
return node.type === 'INSTANCE' && node.source.format === 'fig'
}
// --- Flex layout ---
function buildYogaTree(
graph: SceneGraph,
frame: SceneNode,
inheritedDirection: 'LTR' | 'RTL'
): YogaNode {
const root = createYogaNode()
const direction = resolveNodeLayoutDirection(frame, inheritedDirection)
if (frame.primaryAxisSizing === 'FIXED') {
if (frame.layoutMode === 'HORIZONTAL') root.setWidth(frame.width)
else root.setHeight(frame.height)
}
if (frame.counterAxisSizing === 'FIXED') {
if (frame.layoutMode === 'HORIZONTAL') root.setHeight(frame.height)
else root.setWidth(frame.width)
}
configureFlexContainer(root, frame, direction)
const children = graph.getChildren(frame.id)
for (const child of children) {
const yogaChild = createYogaNode()
if (child.layoutPositioning === 'ABSOLUTE') {
configureAbsoluteChild(yogaChild, child)
} else if (!child.visible) {
yogaChild.setDisplay(Display.None)
} else if (child.layoutMode === 'GRID') {
configureChildAsGrid(yogaChild, child, frame, graph, direction)
} else if (child.layoutMode !== 'NONE') {
configureChildAsAutoLayout(yogaChild, child, frame, graph, direction)
} else {
configureChildAsLeaf(yogaChild, child, frame)
}
root.insertChild(yogaChild, root.getChildCount())
}
return root
}
function configureFlexContainer(
yogaNode: YogaNode,
node: SceneNode,
direction: Exclude<SceneNode['layoutDirection'], 'AUTO'>
): void {
yogaNode.setDirection(direction === 'RTL' ? Direction.RTL : Direction.LTR)
yogaNode.setFlexDirection(
node.layoutMode === 'HORIZONTAL' ? FlexDirection.Row : FlexDirection.Column
)
yogaNode.setFlexWrap(node.layoutWrap === 'WRAP' ? Wrap.Wrap : Wrap.NoWrap)
yogaNode.setJustifyContent(mapJustify(node.primaryAxisAlign))
yogaNode.setAlignItems(mapAlign(node.counterAxisAlign))
if (node.clipsContent) yogaNode.setOverflow(Overflow.Hidden)
if (node.layoutWrap === 'WRAP' && node.counterAxisAlignContent === 'SPACE_BETWEEN') {
yogaNode.setAlignContent(Align.SpaceBetween)
}
yogaNode.setPadding(Edge.Top, node.paddingTop)
yogaNode.setPadding(Edge.Right, node.paddingRight)
yogaNode.setPadding(Edge.Bottom, node.paddingBottom)
yogaNode.setPadding(Edge.Left, node.paddingLeft)
yogaNode.setGap(
Gutter.Column,
node.layoutMode === 'HORIZONTAL' ? node.itemSpacing : node.counterAxisSpacing
)
yogaNode.setGap(
Gutter.Row,
node.layoutMode === 'HORIZONTAL' ? node.counterAxisSpacing : node.itemSpacing
)
applyMinMaxConstraints(yogaNode, node)
}
function configureChildAsGrid(
yogaChild: YogaNode,
child: SceneNode,
parent: SceneNode,
graph: SceneGraph,
inheritedDirection: 'LTR' | 'RTL'
): void {
const direction = resolveNodeLayoutDirection(child, inheritedDirection)
yogaChild.setDisplay(Display.Grid)
yogaChild.setDirection(direction === 'RTL' ? Direction.RTL : Direction.LTR)
if (child.gridTemplateColumns.length > 0) {
yogaChild.setGridTemplateColumns(child.gridTemplateColumns.map(mapGridTrack))
}
if (child.gridTemplateRows.length > 0) {
yogaChild.setGridTemplateRows(child.gridTemplateRows.map(mapGridTrack))
}
yogaChild.setGap(Gutter.Column, child.gridColumnGap)
yogaChild.setGap(Gutter.Row, child.gridRowGap)
yogaChild.setPadding(Edge.Top, child.paddingTop)
yogaChild.setPadding(Edge.Right, child.paddingRight)
yogaChild.setPadding(Edge.Bottom, child.paddingBottom)
yogaChild.setPadding(Edge.Left, child.paddingLeft)
const isParentRow = parent.layoutMode === 'HORIZONTAL'
const selfOverride = child.layoutAlignSelf !== 'AUTO'
const stretchCross = selfOverride
? child.layoutAlignSelf === 'STRETCH'
: parent.counterAxisAlign === 'STRETCH'
if (child.layoutGrow > 0) {
yogaChild.setFlexGrow(child.layoutGrow)
yogaChild.setFlexShrink(1)
yogaChild.setFlexBasis(0)
if (!stretchCross) {
if (isParentRow) yogaChild.setHeight(child.height)
else yogaChild.setWidth(child.width)
}
} else {
if (isParentRow) {
yogaChild.setWidth(child.width)
if (!stretchCross) yogaChild.setHeight(child.height)
} else {
if (child.gridTemplateRows.length > 0) yogaChild.setHeight(child.height)
if (!stretchCross) yogaChild.setWidth(child.width)
}
}
const selfAlign = mapAlignSelf(child.layoutAlignSelf)
if (selfAlign != null) yogaChild.setAlignSelf(selfAlign)
applyMinMaxConstraints(yogaChild, child)
const grandchildren = graph.getChildren(child.id)
for (const gc of grandchildren) {
if (gc.layoutPositioning === 'ABSOLUTE') {
const yogaGC = createYogaNode()
configureAbsoluteChild(yogaGC, gc)
yogaChild.insertChild(yogaGC, yogaChild.getChildCount())
} else {
yogaChild.insertChild(createGridChildNode(gc), yogaChild.getChildCount())
}
}
}
function configureChildAsAutoLayout(
yogaChild: YogaNode,
child: SceneNode,
parent: SceneNode,
graph: SceneGraph,
inheritedDirection: 'LTR' | 'RTL'
): void {
const direction = resolveNodeLayoutDirection(child, inheritedDirection)
const isParentRow = parent.layoutMode === 'HORIZONTAL'
const isChildRow = child.layoutMode === 'HORIZONTAL'
const widthSizing = isChildRow ? child.primaryAxisSizing : child.counterAxisSizing
const heightSizing = isChildRow ? child.counterAxisSizing : child.primaryAxisSizing
// Main axis: width for row parent, height for col parent — use grow for FILL
// Cross axis: height for row parent, width for col parent — use stretch for FILL
if (isParentRow) {
setMainAxisSizing(yogaChild, 'width', widthSizing, child.width, child.layoutGrow)
setCrossAxisSizing(yogaChild, 'height', heightSizing, child.height)
} else {
setCrossAxisSizing(yogaChild, 'width', widthSizing, child.width)
setMainAxisSizing(yogaChild, 'height', heightSizing, child.height, child.layoutGrow)
}
const selfAlign = mapAlignSelf(child.layoutAlignSelf)
if (selfAlign != null) yogaChild.setAlignSelf(selfAlign)
configureFlexContainer(yogaChild, child, direction)
const grandchildren = graph.getChildren(child.id)
for (const gc of grandchildren) {
const yogaGC = createYogaNode()
if (gc.layoutPositioning === 'ABSOLUTE') {
configureAbsoluteChild(yogaGC, gc)
} else if (!gc.visible) {
yogaGC.setDisplay(Display.None)
} else if (gc.layoutMode === 'GRID') {
configureChildAsGrid(yogaGC, gc, child, graph, direction)
} else if (gc.layoutMode !== 'NONE') {
configureChildAsAutoLayout(yogaGC, gc, child, graph, direction)
} else {
configureChildAsLeaf(yogaGC, gc, child)
}
yogaChild.insertChild(yogaGC, yogaChild.getChildCount())
}
}
function configureChildAsLeaf(yogaChild: YogaNode, child: SceneNode, parent: SceneNode): void {
const isRow = parent.layoutMode === 'HORIZONTAL'
const selfOverride = child.layoutAlignSelf !== 'AUTO'
const stretchCross = selfOverride
? child.layoutAlignSelf === 'STRETCH'
: parent.counterAxisAlign === 'STRETCH'
const isText = child.type === 'TEXT'
const textMeasurer = getTextMeasurer()
const needsMeasureFunc = isText && textMeasurer && child.textAutoResize !== 'NONE'
if (needsMeasureFunc) {
configureTextLeaf(yogaChild, child, parent)
} else if (isText && !textMeasurer && child.textAutoResize !== 'NONE') {
// No CanvasKit — prefer stored dimensions from .fig import (Figma's
// ground truth) over the rough character-count estimate. Only fall back
// to estimateTextSize for newly-created nodes that still carry the
// 100×100 default SceneNode size.
const hasStoredSize =
child.width > 0 && child.height > 0 && !(child.width === 100 && child.height === 100)
if (child.textAutoResize === 'WIDTH_AND_HEIGHT') {
if (hasStoredSize) {
yogaChild.setWidth(child.width)
yogaChild.setHeight(child.height)
} else {
const est = estimateTextSize(child)
yogaChild.setWidth(est.width)
yogaChild.setHeight(est.height)
}
} else if (child.textAutoResize === 'HEIGHT') {
const stretches =
child.layoutAlignSelf === 'STRETCH' ||
(child.layoutAlignSelf === 'AUTO' && parent.counterAxisAlign === 'STRETCH')
if (!(!isRow && stretches)) {
yogaChild.setWidth(child.width)
}
if (hasStoredSize) {
yogaChild.setHeight(child.height)
} else {
const est = estimateTextSize(child, child.width)
yogaChild.setHeight(est.height)
}
}
} else {
configureNonTextLeaf(yogaChild, child, isRow, stretchCross)
}
const selfAlign = mapAlignSelf(child.layoutAlignSelf)
if (selfAlign != null) yogaChild.setAlignSelf(selfAlign)
applyMinMaxConstraints(yogaChild, child)
}
function configureTextLeaf(yogaChild: YogaNode, child: SceneNode, parent: SceneNode): void {
const autoResize = child.textAutoResize
const isRow = parent.layoutMode === 'HORIZONTAL'
if (child.layoutGrow > 0) {
yogaChild.setFlexGrow(child.layoutGrow)
}
const cache = new Map<number, { width: number; height: number }>()
const UNCONSTRAINED_KEY = -1
if (autoResize === 'WIDTH_AND_HEIGHT') {
const importedSize = child.figmaDerivedLayout
if (importedSize?.width !== undefined && importedSize.height !== undefined) {
yogaChild.setWidth(child.width)
yogaChild.setHeight(child.height)
return
}
yogaChild.setMeasureFunc((width, widthMode, _height, _heightMode) => {
const maxW = widthMode === MeasureMode.Undefined ? undefined : width
const cacheKey = maxW === undefined ? UNCONSTRAINED_KEY : Math.round(maxW)
const cached = cache.get(cacheKey)
if (cached) return cached
const measured = getTextMeasurer()?.(child, maxW)
const result = measured ?? estimateTextSize(child, maxW)
cache.set(cacheKey, result)
return result
})
} else if (autoResize === 'HEIGHT') {
const stretchesCross =
child.layoutAlignSelf === 'STRETCH' ||
(child.layoutAlignSelf === 'AUTO' && parent.counterAxisAlign === 'STRETCH')
// Don't set fixed width when text stretches on cross axis (w="fill" in
// flex="col" parent) — setWidth blocks Yoga's alignSelf:stretch, leaving
// text at 100px default instead of filling the parent.
const fillsWidth = !isRow && stretchesCross
const fixedWidth = child.width
if (child.layoutGrow <= 0 && !fillsWidth) {
yogaChild.setWidth(fixedWidth)
}
yogaChild.setMeasureFunc((width, widthMode, _height, _heightMode) => {
let constraintW = fixedWidth
if (fillsWidth) {
if (widthMode !== MeasureMode.Undefined) constraintW = width
} else if (widthMode !== MeasureMode.Undefined) {
constraintW = Math.min(width, fixedWidth || width)
}
const cacheKey = Math.round(constraintW)
const cached = cache.get(cacheKey)
if (cached) return cached
const measured = getTextMeasurer()?.(child, constraintW)
const result = {
width: constraintW,
height: measured?.height ?? estimateTextSize(child, constraintW).height
}
cache.set(cacheKey, result)
return result
})
}
}
function configureNonTextLeaf(
yogaChild: YogaNode,
child: SceneNode,
isRow: boolean,
stretchCross: boolean
): void {
const w = child.width
const h = child.height
if (child.layoutGrow > 0) {
yogaChild.setFlexGrow(child.layoutGrow)
if (!stretchCross) {
if (isRow) yogaChild.setHeight(h)
else yogaChild.setWidth(w)
}
} else {
if (isRow) {
yogaChild.setWidth(w)
if (!stretchCross) yogaChild.setHeight(h)
} else {
yogaChild.setHeight(h)
if (!stretchCross) yogaChild.setWidth(w)
}
}
}
function setMainAxisSizing(
yogaNode: YogaNode,
axis: 'width' | 'height',
sizing: string,
fixedValue: number,
grow: number
): void {
if (grow > 0) {
yogaNode.setFlexGrow(grow)
yogaNode.setFlexShrink(1)
yogaNode.setFlexBasis(0)
return
}
switch (sizing) {
case 'FIXED':
if (axis === 'width') yogaNode.setWidth(fixedValue)
else yogaNode.setHeight(fixedValue)
break
case 'HUG':
break
case 'FILL':
yogaNode.setFlexGrow(1)
yogaNode.setFlexShrink(1)
yogaNode.setFlexBasis(0)
break
}
}
function setCrossAxisSizing(
yogaNode: YogaNode,
axis: 'width' | 'height',
sizing: string,
fixedValue: number
): void {
switch (sizing) {
case 'FIXED':
if (axis === 'width') yogaNode.setWidth(fixedValue)
else yogaNode.setHeight(fixedValue)
break
case 'HUG':
break
case 'FILL':
yogaNode.setAlignSelf(Align.Stretch)
break
}
}