Merge pull request #462 from open-pencil/shadcn-compat-gaps

fix(fig): improve imported component layout parity
This commit is contained in:
Danila Poyarkov 2026-08-03 19:06:25 +03:00 committed by GitHub
commit bbeffc54cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1508 additions and 224 deletions

View file

@ -49,6 +49,7 @@
### Fixed
- Match Figma auto-layout spacing, padding, min/max constraints, scalar variable bindings, imported text bounds, and nested instance geometry more closely.
- Match Figma Plugin API vector path and network editing, including bounds, winding rules, region fills, validation, and handle mirroring. (#444)
- Let AI and MCP tools create arbitrary vectors from SVG path data, validating input without leaving blank layers behind. (#440)
- Improve AI design accuracy by exposing every supported shape, including visible stroke colors and weights in visual descriptions, and accepting supported inline SVG attributes without false warnings. (#445, #447, #448)

View file

@ -8,8 +8,8 @@ import {
nodeChangeToProps,
shouldImportTextAsAutoSize,
sortChildren,
setVariableColorResolver,
VARIABLE_BINDING_FIELDS_INVERSE
resolveVariableConsumptionEntry,
setVariableColorResolver
} from '@open-pencil/fig/node-change'
import type { NodeChange, VariableDataValuesEntry, Color, GUID } from '@open-pencil/kiwi/fig/codec'
import { SceneGraph } from '@open-pencil/scene-graph'
@ -351,10 +351,8 @@ function importVariableBindings(
const nodeId = guidToNodeId.get(ncId)
if (!nodeId) continue
for (const entry of nc.variableConsumptionMap.entries) {
const varGuid = entry.variableData?.value?.alias?.guid
if (!varGuid) continue
const field = VARIABLE_BINDING_FIELDS_INVERSE[entry.variableField ?? '']
if (field) graph.bindVariable(nodeId, field, guidToString(varGuid))
const binding = resolveVariableConsumptionEntry(entry)
if (binding) graph.bindVariable(nodeId, binding.field, binding.variableId)
}
}
}

View file

@ -12,6 +12,7 @@ import {
} from 'yoga-layout'
import { applyYogaLayout } from './layout/apply'
import { usesDetachedDerivedLayout } from './layout/derived'
import { buildGridTree, createGridChildNode } from './layout/grid'
import { resolveNodeLayoutDirection } from './text/direction'
export {
@ -84,8 +85,6 @@ function preservesImportedInstanceLayout(node: SceneNode): boolean {
return node.type === 'INSTANCE' && node.source.format === 'fig'
}
// --- Flex layout ---
function buildYogaTree(
graph: SceneGraph,
frame: SceneNode,
@ -118,7 +117,7 @@ function buildYogaTree(
} else if (child.layoutMode !== 'NONE') {
configureChildAsAutoLayout(yogaChild, child, frame, graph, direction)
} else {
configureChildAsLeaf(yogaChild, child, frame)
configureChildAsLeaf(yogaChild, child, frame, graph)
}
root.insertChild(yogaChild, root.getChildCount())
@ -150,13 +149,14 @@ function configureFlexContainer(
yogaNode.setPadding(Edge.Bottom, node.paddingBottom)
yogaNode.setPadding(Edge.Left, node.paddingLeft)
const primaryGap = node.primaryAxisAlign === 'SPACE_BETWEEN' ? 0 : node.itemSpacing
yogaNode.setGap(
Gutter.Column,
node.layoutMode === 'HORIZONTAL' ? node.itemSpacing : node.counterAxisSpacing
node.layoutMode === 'HORIZONTAL' ? primaryGap : node.counterAxisSpacing
)
yogaNode.setGap(
Gutter.Row,
node.layoutMode === 'HORIZONTAL' ? node.counterAxisSpacing : node.itemSpacing
node.layoutMode === 'HORIZONTAL' ? node.counterAxisSpacing : primaryGap
)
applyMinMaxConstraints(yogaNode, node)
@ -229,6 +229,91 @@ function configureChildAsGrid(
}
}
type AxisSizing = SceneNode['primaryAxisSizing']
function sizesFitParent(
parent: SceneNode,
childCount: number,
sizes: Array<number | undefined>,
axis: 'width' | 'height'
): boolean {
if (sizes.some((size) => size === undefined)) return false
const padding =
axis === 'width'
? parent.paddingLeft + parent.paddingRight
: parent.paddingTop + parent.paddingBottom
const gap =
parent.primaryAxisAlign === 'SPACE_BETWEEN'
? 0
: parent.itemSpacing * Math.max(0, childCount - 1)
const available = axis === 'width' ? parent.width : parent.height
const total = sizes.reduce<number>((sum, size) => sum + (size ?? 0), padding + gap)
return Math.abs(total - available) < 0.001
}
function derivedMainAxisFitsParent(
graph: SceneGraph,
parent: SceneNode,
child: SceneNode,
axis: 'width' | 'height'
): boolean {
const children = graph
.getChildren(parent.id)
.filter((candidate) => candidate.visible && candidate.layoutPositioning !== 'ABSOLUTE')
if (children.length === 0) return false
const sizes = children.map((candidate) => candidate.figmaDerivedLayout?.[axis])
return (
sizesFitParent(parent, children.length, sizes, axis) &&
child.figmaDerivedLayout?.[axis] !== undefined
)
}
function usesAuthoritativeGeneratedStretch(parent: SceneNode, child: SceneNode): boolean {
if (
child.layoutAlignSelf !== 'STRETCH' ||
parent.source.format === 'fig' ||
!parent.figmaDerivedLayout
) {
return false
}
const derivedCrossSize =
parent.layoutMode === 'HORIZONTAL'
? parent.figmaDerivedLayout.height
: parent.figmaDerivedLayout.width
const parentCrossSize = parent.layoutMode === 'HORIZONTAL' ? parent.height : parent.width
return derivedCrossSize !== undefined && Math.abs(derivedCrossSize - parentCrossSize) < 0.001
}
function configureAutoLayoutChildSizing(
yogaChild: YogaNode,
child: SceneNode,
parent: SceneNode,
graph: SceneGraph,
widthSizing: AxisSizing,
heightSizing: AxisSizing
): void {
const isParentRow = parent.layoutMode === 'HORIZONTAL'
const fixedDerivedMainAxis = isParentRow
? derivedMainAxisFitsParent(graph, parent, child, 'width')
: derivedMainAxisFitsParent(graph, parent, child, 'height')
const stretchesAuthoritativeCrossAxis = usesAuthoritativeGeneratedStretch(parent, child)
if (isParentRow) {
if (fixedDerivedMainAxis) yogaChild.setWidth(child.figmaDerivedLayout?.width ?? child.width)
else setMainAxisSizing(yogaChild, 'width', widthSizing, child.width, child.layoutGrow)
if (!stretchesAuthoritativeCrossAxis) {
setCrossAxisSizing(yogaChild, 'height', heightSizing, child.height)
}
return
}
if (!stretchesAuthoritativeCrossAxis) {
setCrossAxisSizing(yogaChild, 'width', widthSizing, child.width)
}
if (fixedDerivedMainAxis) yogaChild.setHeight(child.figmaDerivedLayout?.height ?? child.height)
else setMainAxisSizing(yogaChild, 'height', heightSizing, child.height, child.layoutGrow)
}
function configureChildAsAutoLayout(
yogaChild: YogaNode,
child: SceneNode,
@ -237,25 +322,23 @@ function configureChildAsAutoLayout(
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)
}
configureAutoLayoutChildSizing(yogaChild, child, parent, graph, widthSizing, heightSizing)
const selfAlign = mapAlignSelf(child.layoutAlignSelf)
if (selfAlign != null) yogaChild.setAlignSelf(selfAlign)
if (usesDetachedDerivedLayout(child)) {
const derived = child.figmaDerivedLayout
if (widthSizing === 'HUG') yogaChild.setWidth(derived?.width ?? child.width)
if (heightSizing === 'HUG') yogaChild.setHeight(derived?.height ?? child.height)
applyMinMaxConstraints(yogaChild, child)
return
}
configureFlexContainer(yogaChild, child, direction)
const grandchildren = graph.getChildren(child.id)
@ -270,13 +353,75 @@ function configureChildAsAutoLayout(
} else if (gc.layoutMode !== 'NONE') {
configureChildAsAutoLayout(yogaGC, gc, child, graph, direction)
} else {
configureChildAsLeaf(yogaGC, gc, child)
configureChildAsLeaf(yogaGC, gc, child, graph)
}
yogaChild.insertChild(yogaGC, yogaChild.getChildCount())
}
}
function configureChildAsLeaf(yogaChild: YogaNode, child: SceneNode, parent: SceneNode): void {
function derivedGrowingLeafFitsParent(
graph: SceneGraph,
parent: SceneNode,
child: SceneNode,
axis: 'width' | 'height'
): boolean {
if (
child.type !== 'TEXT' ||
child.layoutGrow <= 0 ||
child.figmaDerivedLayout?.[axis] === undefined
) {
return false
}
const children = graph
.getChildren(parent.id)
.filter((candidate) => candidate.visible && candidate.layoutPositioning !== 'ABSOLUTE')
const sizes = children.map((candidate) => {
if (candidate.layoutGrow > 0) return candidate.figmaDerivedLayout?.[axis]
return axis === 'width' ? candidate.width : candidate.height
})
return sizesFitParent(parent, children.length, sizes, axis)
}
function configureTextLeafWithoutMeasurer(
yogaChild: YogaNode,
child: SceneNode,
parent: SceneNode,
fixedDerivedMainAxis: boolean
): void {
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 estimated = estimateTextSize(child)
yogaChild.setWidth(estimated.width)
yogaChild.setHeight(estimated.height)
}
return
}
if (child.textAutoResize !== 'HEIGHT') return
const isRow = parent.layoutMode === 'HORIZONTAL'
const measurementWidth = fixedDerivedMainAxis
? (child.figmaDerivedLayout?.width ?? child.width)
: child.width
const stretches =
child.layoutAlignSelf === 'STRETCH' ||
(child.layoutAlignSelf === 'AUTO' && parent.counterAxisAlign === 'STRETCH')
if (!(!isRow && stretches) && !fixedDerivedMainAxis) yogaChild.setWidth(child.width)
if (hasStoredSize) yogaChild.setHeight(child.height)
else yogaChild.setHeight(estimateTextSize(child, measurementWidth).height)
}
function configureChildAsLeaf(
yogaChild: YogaNode,
child: SceneNode,
parent: SceneNode,
graph: SceneGraph
): void {
const isRow = parent.layoutMode === 'HORIZONTAL'
const selfOverride = child.layoutAlignSelf !== 'AUTO'
const stretchCross = selfOverride
@ -287,39 +432,19 @@ function configureChildAsLeaf(yogaChild: YogaNode, child: SceneNode, parent: Sce
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)
const fixedDerivedMainAxis = isRow
? derivedGrowingLeafFitsParent(graph, parent, child, 'width')
: derivedGrowingLeafFitsParent(graph, parent, child, 'height')
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)
}
}
if (fixedDerivedMainAxis) {
if (isRow) yogaChild.setWidth(child.figmaDerivedLayout?.width ?? child.width)
else yogaChild.setHeight(child.figmaDerivedLayout?.height ?? child.height)
}
if (needsMeasureFunc) {
configureTextLeaf(yogaChild, child, parent, fixedDerivedMainAxis)
} else if (isText && !textMeasurer && child.textAutoResize !== 'NONE') {
configureTextLeafWithoutMeasurer(yogaChild, child, parent, fixedDerivedMainAxis)
} else {
configureNonTextLeaf(yogaChild, child, isRow, stretchCross)
}
@ -330,11 +455,16 @@ function configureChildAsLeaf(yogaChild: YogaNode, child: SceneNode, parent: Sce
applyMinMaxConstraints(yogaChild, child)
}
function configureTextLeaf(yogaChild: YogaNode, child: SceneNode, parent: SceneNode): void {
function configureTextLeaf(
yogaChild: YogaNode,
child: SceneNode,
parent: SceneNode,
fixedDerivedMainAxis = false
): void {
const autoResize = child.textAutoResize
const isRow = parent.layoutMode === 'HORIZONTAL'
if (child.layoutGrow > 0) {
if (child.layoutGrow > 0 && !fixedDerivedMainAxis) {
yogaChild.setFlexGrow(child.layoutGrow)
}
@ -364,11 +494,11 @@ function configureTextLeaf(yogaChild: YogaNode, child: SceneNode, parent: SceneN
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.
// Let Yoga stretch fill-width text instead of fixing its stored width.
const fillsWidth = !isRow && stretchesCross
const fixedWidth = child.width
const fixedWidth = fixedDerivedMainAxis
? (child.figmaDerivedLayout?.width ?? child.width)
: child.width
if (child.layoutGrow <= 0 && !fillsWidth) {
yogaChild.setWidth(fixedWidth)
}
@ -423,7 +553,7 @@ function configureNonTextLeaf(
function setMainAxisSizing(
yogaNode: YogaNode,
axis: 'width' | 'height',
sizing: string,
sizing: AxisSizing,
fixedValue: number,
grow: number
): void {
@ -452,7 +582,7 @@ function setMainAxisSizing(
function setCrossAxisSizing(
yogaNode: YogaNode,
axis: 'width' | 'height',
sizing: string,
sizing: AxisSizing,
fixedValue: number
): void {
switch (sizing) {

View file

@ -2,8 +2,26 @@ import type { Node as YogaNode } from 'yoga-layout'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { usesDetachedDerivedLayout } from './derived'
export type ComputeLayoutFn = (graph: SceneGraph, frameId: string) => void
function preservesImportedHugCrossSize(
graph: SceneGraph,
frame: SceneNode,
axis: 'width' | 'height'
): boolean {
if (frame.source.format !== 'fig' || frame.counterAxisSizing !== 'HUG') return false
const expectedMode = axis === 'width' ? 'VERTICAL' : 'HORIZONTAL'
if (frame.layoutMode !== expectedMode) return false
return graph
.getChildren(frame.id)
.some(
(child) =>
child.layoutAlignSelf === 'STRETCH' && child.figmaDerivedLayout?.[axis] !== undefined
)
}
function applyFrameSize(graph: SceneGraph, frame: SceneNode, yogaNode: YogaNode): void {
if (frame.layoutMode === 'GRID') {
if (frame.gridTemplateRows.length === 0) {
@ -24,28 +42,77 @@ function applyFrameSize(graph: SceneGraph, frame: SceneNode, yogaNode: YogaNode)
else updates.height = derived?.height ?? computedH
}
if (frame.counterAxisSizing === 'HUG') {
if (frame.layoutMode === 'HORIZONTAL') updates.height = derived?.height ?? computedH
else updates.width = derived?.width ?? computedW
if (frame.layoutMode === 'HORIZONTAL') {
updates.height = preservesImportedHugCrossSize(graph, frame, 'height')
? frame.height
: (derived?.height ?? computedH)
} else {
updates.width = preservesImportedHugCrossSize(graph, frame, 'width')
? frame.width
: (derived?.width ?? computedW)
}
}
graph.updateNode(frame.id, updates)
}
function frameSourceIsFig(graph: SceneGraph, parentId: string | null): boolean {
return parentId ? graph.getNode(parentId)?.source.format === 'fig' : false
}
function computedChildPosition(
child: SceneNode,
yogaChild: YogaNode,
axis: 'x' | 'y',
preservesImportedGeometry: boolean
): number {
if (preservesImportedGeometry) return child[axis]
const computed = axis === 'x' ? yogaChild.getComputedLeft() : yogaChild.getComputedTop()
if (child.type === 'INSTANCE') return computed
return child.figmaDerivedLayout?.[axis] ?? computed
}
function preservesStaleImportedTextSize(child: SceneNode, axis: 'width' | 'height'): boolean {
const derivedSize = child.figmaDerivedLayout?.[axis]
return (
child.type === 'TEXT' &&
child.source.format === 'fig' &&
derivedSize !== undefined &&
Math.abs(child[axis] - derivedSize) > 0.001
)
}
function computedChildSize(
child: SceneNode,
yogaChild: YogaNode,
axis: 'width' | 'height',
preservesImportedFrameGeometry: boolean
): number {
if (preservesImportedFrameGeometry || preservesStaleImportedTextSize(child, axis)) {
return child[axis]
}
const computed = axis === 'width' ? yogaChild.getComputedWidth() : yogaChild.getComputedHeight()
if (child.type === 'TEXT' && child.source.format === 'fig') {
return computed > 0 ? computed : child[axis]
}
return child.figmaDerivedLayout?.[axis] ?? computed
}
function updateChildFromYoga(graph: SceneGraph, child: SceneNode, yogaChild: YogaNode): void {
if (!child.visible || child.layoutPositioning === 'ABSOLUTE') return
const derived = child.figmaDerivedLayout
const preservesImportedFrameGeometry =
child.type === 'FRAME' &&
child.source.format === 'fig' &&
frameSourceIsFig(graph, child.parentId)
const preservesImportedPosition =
preservesImportedFrameGeometry ||
(child.source.format === 'fig' && Math.abs(child.rotation) > 0.001)
graph.updateNode(child.id, {
x:
child.type === 'INSTANCE'
? yogaChild.getComputedLeft()
: (derived?.x ?? yogaChild.getComputedLeft()),
y:
child.type === 'INSTANCE'
? yogaChild.getComputedTop()
: (derived?.y ?? yogaChild.getComputedTop()),
width: derived?.width ?? yogaChild.getComputedWidth(),
height: derived?.height ?? yogaChild.getComputedHeight()
x: computedChildPosition(child, yogaChild, 'x', preservesImportedPosition),
y: computedChildPosition(child, yogaChild, 'y', preservesImportedPosition),
width: computedChildSize(child, yogaChild, 'width', preservesImportedFrameGeometry),
height: computedChildSize(child, yogaChild, 'height', preservesImportedFrameGeometry)
})
}
@ -94,16 +161,18 @@ export function applyYogaLayout(
updateChildFromYoga(graph, child, yogaChild)
if (!child.visible) continue
if (preservesImportedInstanceInternals(child)) continue
if (usesDetachedDerivedLayout(child)) {
computeLayout(graph, child.id)
continue
}
if (child.layoutMode !== 'NONE') {
if (child.layoutMode === 'GRID' && child.visible && child.layoutPositioning !== 'ABSOLUTE') {
if (child.layoutMode === 'GRID' && child.layoutPositioning !== 'ABSOLUTE') {
computeLayout(graph, child.id)
} else if (
frame.layoutMode === 'GRID' &&
child.visible &&
child.layoutPositioning !== 'ABSOLUTE'
) {
} else if (frame.layoutMode === 'GRID' && child.layoutPositioning !== 'ABSOLUTE') {
recomputeGridChild(graph, child, computeLayout)
} else {
applyYogaLayout(graph, child, yogaChild, computeLayout)

View file

@ -0,0 +1,13 @@
import type { SceneNode } from '@open-pencil/scene-graph'
export function usesDetachedDerivedLayout(child: SceneNode): boolean {
const derived = child.figmaDerivedLayout
if (!derived || child.layoutMode === 'NONE' || child.layoutGrow > 0) return false
const isRow = child.layoutMode === 'HORIZONTAL'
const widthSizing = isRow ? child.primaryAxisSizing : child.counterAxisSizing
const heightSizing = isRow ? child.counterAxisSizing : child.primaryAxisSizing
return (
(widthSizing === 'HUG' && derived.width !== undefined) ||
(heightSizing === 'HUG' && derived.height !== undefined)
)
}

View file

@ -1,10 +1,14 @@
import type { SceneGraph, SceneNode, VectorNetwork } from '@open-pencil/scene-graph'
import { copyGeometryPaths, scaleGeometryPaths } from '@open-pencil/scene-graph/copy'
import { constrainedChildRect } from '@open-pencil/scene-graph/resize'
import { isFieldProtected } from './patches'
import { buildClonesMap } from './sync'
import type { OverrideContext } from './types'
import { overrideCandidates } from './utils'
const MAX_CLONE_CHAIN_DEPTH = 10
/**
* Apply SCALE constraint resizing to children of instances whose size
* differs from their component's original size, then propagate the
@ -21,7 +25,7 @@ export function applyConstraintScaling(ctx: OverrideContext): void {
const basis = resolveScaleBasis(graph, node, comp)
if (!basis) continue
// Skip if instance uses auto-layout — layout engine handles child sizing
positionPinnedAbsoluteChildren(ctx, node, basis)
if (node.layoutMode !== 'NONE') continue
const sx = node.width / basis.width
@ -44,18 +48,118 @@ export function applyConstraintScaling(ctx: OverrideContext): void {
}
if (scaled.size > 0) propagateScaling(ctx, scaled)
normalizeOutOfBoundsSingleChildren(ctx)
}
function isCloneOfSource(graph: SceneGraph, child: SceneNode, sourceId: string): boolean {
let current: SceneNode | undefined = child
for (let depth = 0; depth < MAX_CLONE_CHAIN_DEPTH && current?.componentId; depth++) {
if (current.componentId === sourceId) return true
current = graph.getNode(current.componentId)
}
return false
}
function pinnedPositionUpdates(
ctx: OverrideContext,
child: SceneNode,
resized: ReturnType<typeof constrainedChildRect>
): Partial<SceneNode> {
const updates: Partial<SceneNode> = {}
const horizontalPinned =
child.horizontalConstraint === 'MAX' || child.horizontalConstraint === 'CENTER'
const verticalPinned = child.verticalConstraint === 'MAX' || child.verticalConstraint === 'CENTER'
if (
horizontalPinned &&
child.figmaDerivedLayout?.x === undefined &&
!isFieldProtected(ctx.protectedFields, child.id, 'x') &&
child.x !== resized.x
) {
updates.x = resized.x
}
if (
verticalPinned &&
child.figmaDerivedLayout?.y === undefined &&
!isFieldProtected(ctx.protectedFields, child.id, 'y') &&
child.y !== resized.y
) {
updates.y = resized.y
}
return updates
}
function stretchedChildSizeUpdates(
ctx: OverrideContext,
child: SceneNode,
resized: ReturnType<typeof constrainedChildRect>
): Partial<SceneNode> {
const updates: Partial<SceneNode> = {}
if (
child.horizontalConstraint === 'STRETCH' &&
child.figmaDerivedLayout?.width === undefined &&
!isFieldProtected(ctx.protectedFields, child.id, 'width') &&
child.width !== resized.width
) {
updates.width = resized.width
}
if (
child.verticalConstraint === 'STRETCH' &&
child.figmaDerivedLayout?.height === undefined &&
!isFieldProtected(ctx.protectedFields, child.id, 'height') &&
child.height !== resized.height
) {
updates.height = resized.height
}
return updates
}
function pinnedChildUpdates(
ctx: OverrideContext,
child: SceneNode,
resized: ReturnType<typeof constrainedChildRect>
): Partial<SceneNode> {
return {
...pinnedPositionUpdates(ctx, child, resized),
...stretchedChildSizeUpdates(ctx, child, resized)
}
}
function positionPinnedAbsoluteChildren(
ctx: OverrideContext,
instance: SceneNode,
source: SceneNode
): void {
const count = Math.min(instance.childIds.length, source.childIds.length)
for (let index = 0; index < count; index++) {
const child = ctx.graph.getNode(instance.childIds[index])
const sourceChild = ctx.graph.getNode(source.childIds[index])
if (!child || !sourceChild || child.layoutPositioning !== 'ABSOLUTE') continue
if (child.componentId && !isCloneOfSource(ctx.graph, child, sourceChild.id)) continue
const resized = constrainedChildRect(
sourceChild,
source,
instance,
child.horizontalConstraint,
child.verticalConstraint
)
const updates = pinnedChildUpdates(ctx, child, resized)
if (Object.keys(updates).length > 0) ctx.graph.updateNode(child.id, updates)
}
}
function resolveScaleBasis(
graph: SceneGraph,
instance: SceneNode,
component: SceneNode
): { width: number; height: number } | null {
): SceneNode | null {
if (instance.width !== component.width || instance.height !== component.height) return component
let source: SceneNode = component
for (let depth = 0; depth < 10 && source.type === 'INSTANCE' && source.componentId; depth++) {
for (
let depth = 0;
depth < MAX_CLONE_CHAIN_DEPTH && source.type === 'INSTANCE' && source.componentId;
depth++
) {
const next = graph.getNode(source.componentId)
if (!next || next.width <= 0 || next.height <= 0) break
if (instance.width !== next.width || instance.height !== next.height) return next
@ -174,29 +278,6 @@ function scaleChildren(
}
}
function normalizeOutOfBoundsSingleChildren(ctx: OverrideContext): void {
const { graph } = ctx
for (const parent of overrideCandidates(graph, ctx.activeNodeIds)) {
if (parent.childIds.length !== 1) continue
const child = graph.getNode(parent.childIds[0])
if (!child?.visible || !child.componentId) continue
if (ctx.geometryOverrideNodes.has(child.id) || child.figmaDerivedLayout?.x !== undefined)
continue
const outsideParent =
child.x < -0.01 ||
child.y < -0.01 ||
child.x + child.width > parent.width + 0.01 ||
child.y + child.height > parent.height + 0.01
if (outsideParent) {
graph.updateNode(child.id, {
x: 0,
y: 0,
figmaDerivedLayout: { ...child.figmaDerivedLayout, x: 0, y: 0 }
})
}
}
}
function propagateScaling(ctx: OverrideContext, scaled: Set<string>): void {
const { graph } = ctx
const clonesOf = buildClonesMap(graph, ctx.activeNodeIds)

View file

@ -3,6 +3,22 @@ import { copyGeometryPaths } from '@open-pencil/scene-graph/copy'
import { buildClonesMap } from '../sync'
import type { OverrideContext } from '../types'
import { overrideCandidates } from '../utils'
function buildSizeOverriddenCloneUpdates(source: SceneNode, clone: SceneNode): Partial<SceneNode> {
if (clone.type !== 'INSTANCE' || !source.figmaDerivedLayout) return {}
const sourceLayout = source.figmaDerivedLayout
return {
...(sourceLayout.x === undefined ? {} : { x: sourceLayout.x }),
...(sourceLayout.y === undefined ? {} : { y: sourceLayout.y }),
figmaDerivedLayout: {
...sourceLayout,
...clone.figmaDerivedLayout,
x: sourceLayout.x ?? clone.figmaDerivedLayout?.x,
y: sourceLayout.y ?? clone.figmaDerivedLayout?.y
}
}
}
function buildCloneUpdates(
ctx: OverrideContext,
@ -12,7 +28,7 @@ function buildCloneUpdates(
sizeSet: Set<string>
): Partial<SceneNode> {
const updates: Partial<SceneNode> = {}
if (sizeSet.has(cloneId)) return updates
if (sizeSet.has(cloneId)) return buildSizeOverriddenCloneUpdates(source, clone)
if (source.width !== clone.width) updates.width = source.width
if (source.height !== clone.height) updates.height = source.height
if (source.x !== clone.x) updates.x = source.x
@ -32,6 +48,44 @@ function buildCloneUpdates(
return updates
}
export function applyGeneratedFreeformStretch(ctx: OverrideContext): void {
for (const node of overrideCandidates(ctx.graph, ctx.activeNodeIds)) {
if (
node.source.format === 'fig' ||
!node.figmaDerivedLayout ||
!node.parentId ||
node.layoutPositioning === 'ABSOLUTE'
) {
continue
}
const parent = ctx.graph.getNode(node.parentId)
if (
!parent ||
parent.source.format === 'fig' ||
parent.layoutMode !== 'NONE' ||
!parent.figmaDerivedLayout
) {
continue
}
const updates: Partial<SceneNode> = {}
if (
node.horizontalConstraint === 'STRETCH' &&
node.figmaDerivedLayout.width !== undefined &&
node.figmaDerivedLayout.width === parent.figmaDerivedLayout.width
) {
updates.width = node.figmaDerivedLayout.width
}
if (
node.verticalConstraint === 'STRETCH' &&
node.figmaDerivedLayout.height !== undefined &&
node.figmaDerivedLayout.height === parent.figmaDerivedLayout.height
) {
updates.height = node.figmaDerivedLayout.height
}
if (Object.keys(updates).length > 0) ctx.graph.updateNode(node.id, updates)
}
}
export function propagateDsdChanges(
ctx: OverrideContext,
modified: Set<string>,

View file

@ -1,5 +1,5 @@
export { buildDsdLayoutUpdates } from './derived-symbol-data/layout'
export { propagateDsdChanges } from './derived-symbol-data/propagate'
export { applyGeneratedFreeformStretch, propagateDsdChanges } from './derived-symbol-data/propagate'
export { protectField, type ProtectionMap } from './patches'
export { syncChildrenDeep, syncNodeProps } from './sync'
export type {
@ -16,7 +16,7 @@ export type {
import { isEqual } from 'es-toolkit/predicate'
import { guidToString } from '@open-pencil/fig/node-change'
import { guidToString, resolvedNumericBindingUpdate } from '@open-pencil/fig/node-change'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import {
copyFills,
@ -29,6 +29,7 @@ import type { JsonObject } from '@open-pencil/scene-graph/primitives'
import { applyComponentProperties } from './component-props'
import { applyConstraintScaling } from './constraints'
import { applyDerivedSymbolData } from './derived-symbol-data'
import { applyGeneratedFreeformStretch } from './derived-symbol-data/propagate'
import { populateInstances } from './populate'
import { preComputeRoots } from './resolve'
import { applySymbolOverrides } from './symbol/overrides'
@ -265,8 +266,20 @@ function buildOverrideContext(
}
}
function applyResolvedNumericBindings(graph: SceneGraph, activeNodeIds?: Set<string>): void {
for (const node of overrideCandidates(graph, activeNodeIds)) {
const updates: Partial<SceneNode> = {}
for (const [field, variableId] of Object.entries(node.boundVariables)) {
if (Array.isArray(variableId)) continue
const value = graph.resolveNumberVariableForNode(node.id, variableId)
if (value === undefined) continue
Object.assign(updates, resolvedNumericBindingUpdate(field, value))
}
if (Object.keys(updates).length > 0) graph.updateNode(node.id, updates)
}
}
/**
* Populate empty instances from their components and apply symbol overrides.
*
* Shared between .fig file import and clipboard paste. Both paths produce
* a SceneGraph with INSTANCE nodes whose componentId references have been
@ -361,4 +374,6 @@ export function populateAndApplyOverrides(
ctx.protectedFields,
ctx.preComputedClones
)
applyResolvedNumericBindings(graph, ctx.activeNodeIds)
applyGeneratedFreeformStretch(ctx)
}

View file

@ -0,0 +1,14 @@
import { copyInstanceComponentProps, type SceneNode } from '@open-pencil/scene-graph'
export function cloneInstanceUpdate(
source: SceneNode,
componentId: string | null,
extra: Partial<SceneNode> = {}
): Partial<SceneNode> {
return {
...copyInstanceComponentProps(source),
componentId,
figmaDerivedLayout: source.figmaDerivedLayout ? { ...source.figmaDerivedLayout } : null,
...extra
}
}

View file

@ -2,6 +2,7 @@ import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { ProtectionMap } from '../patches'
import { overrideCandidates } from '../utils'
import { cloneInstanceUpdate } from './clone-update'
import { syncNodeProps } from './fields'
import { indexCloneSubtree, remapRepopulatedChildSources, snapshotChildSources } from './sources'
@ -20,7 +21,10 @@ export function recloneChildren(
const previousSources = snapshotChildSources(graph, tgtNode.id)
for (const childId of Array.from(tgtNode.childIds)) graph.deleteNode(childId)
graph.updateNode(tgtNode.id, { name: srcChild.name, componentId: srcChild.componentId })
graph.updateNode(
tgtNode.id,
cloneInstanceUpdate(srcChild, srcChild.componentId, { name: srcChild.name })
)
syncNodeProps(graph, srcChild, tgtNode, protections)
if (srcChild.childIds.length > 0) {
graph.populateInstanceChildren(tgtNode.id, srcChildId, 'fig-import')

View file

@ -29,6 +29,7 @@ type SyncFn = (
) => void
type DirectSyncKey = 'text' | 'visible' | 'opacity' | 'locked' | 'layoutGrow' | 'textAutoResize'
type ScalarBindingKey = 'opacity'
type CopiedSyncKey = 'fills' | 'strokes' | 'effects' | 'styleRuns'
function assignDirectUpdate(
@ -128,6 +129,20 @@ const COPIED_SYNCERS: SyncFn[] = [
copiedSync('styleRuns', 'styleRuns')
]
function syncScalarBinding(
key: ScalarBindingKey,
source: SceneNode,
target: SceneNode,
updates: Partial<SceneNode>
): void {
const sourceVariableId = source.boundVariables[key]
const targetVariableId = target.boundVariables[key]
if (targetVariableId === sourceVariableId) return
const bindings = { ...(updates.boundVariables ?? target.boundVariables) }
if (sourceVariableId) bindings[key] = sourceVariableId
updates.boundVariables = sourceVariableId ? bindings : omit(bindings, [key])
}
function syncFields(
source: SceneNode,
target: SceneNode,
@ -136,6 +151,7 @@ function syncFields(
): void {
for (const sync of DIRECT_SYNCERS) sync(source, target, updates, protections)
for (const sync of COPIED_SYNCERS) sync(source, target, updates, protections)
syncScalarBinding('opacity', source, target, updates)
}
export function syncNodeProps(

View file

@ -1,6 +1,7 @@
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { overrideCandidates } from '../utils'
import { cloneInstanceUpdate } from './clone-update'
interface ChildSourceSnapshot {
id: string
@ -112,6 +113,43 @@ function resolveChildPath(graph: SceneGraph, parentId: string, path: number[]):
return node
}
function cloneIdsForReplacement(
graph: SceneGraph,
previousId: string,
replacementId: string,
cloneSources?: Map<string, string[]>
): Set<string> {
return new Set([
...(cloneSources?.get(previousId) ?? []),
...(cloneSources?.get(replacementId) ?? []),
...(graph.instanceIndex.get(previousId) ?? []),
...(graph.instanceIndex.get(replacementId) ?? [])
])
}
function indexReplacementClone(
cloneSources: Map<string, string[]> | undefined,
replacementId: string,
cloneId: string
): void {
if (!cloneSources) return
const sourceIds = cloneSourceIds(cloneSources)
let known = sourceIds.get(replacementId)
if (!known) {
known = new Set()
sourceIds.set(replacementId, known)
}
if (known.has(cloneId)) return
known.add(cloneId)
let replacements = cloneSources.get(replacementId)
if (!replacements) {
replacements = []
cloneSources.set(replacementId, replacements)
}
replacements.push(cloneId)
}
/**
* Redirect descendants that cloned the removed branch to its structural
* replacements. Without this, deep instances keep componentId references to
@ -128,30 +166,14 @@ export function remapRepopulatedChildSources(
for (const previous of previousSources) {
const replacement = resolveChildPath(graph, parentId, previous.path)
if (!replacement || replacement.type !== previous.type) continue
const cloneIds = new Set([
...(cloneSources?.get(previous.id) ?? []),
...(graph.instanceIndex.get(previous.id) ?? [])
])
const cloneIds = cloneIdsForReplacement(graph, previous.id, replacement.id, cloneSources)
for (const cloneId of cloneIds) {
const clone = graph.getNode(cloneId)
if (clone?.componentId !== previous.id) continue
graph.updateNode(cloneId, { componentId: replacement.id })
if (cloneSources) {
let replacements = cloneSources.get(replacement.id)
if (!replacements) {
replacements = []
cloneSources.set(replacement.id, replacements)
}
if (!replacements.includes(cloneId)) {
replacements.push(cloneId)
let known = cloneSourceIds(cloneSources).get(replacement.id)
if (!known) {
known = new Set()
cloneSourceIds(cloneSources).set(replacement.id, known)
}
known.add(cloneId)
}
if (clone?.componentId !== previous.id && clone?.componentId !== replacement.id) {
continue
}
graph.updateNode(cloneId, cloneInstanceUpdate(replacement, replacement.id))
indexReplacementClone(cloneSources, replacement.id, cloneId)
}
}
}

View file

@ -65,54 +65,7 @@ import type {
import type { GUID } from '@open-pencil/scene-graph/primitives'
export { guidToString, stringToGuid } from '@open-pencil/kiwi/fig/guid'
export const VARIABLE_BINDING_FIELDS: Record<string, string> = {
// Corner radius
cornerRadius: 'CORNER_RADIUS',
topLeftRadius: 'RECTANGLE_TOP_LEFT_CORNER_RADIUS',
topRightRadius: 'RECTANGLE_TOP_RIGHT_CORNER_RADIUS',
bottomLeftRadius: 'RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS',
bottomRightRadius: 'RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS',
// Stroke
strokeWeight: 'STROKE_WEIGHT',
borderTopWeight: 'BORDER_TOP_WEIGHT',
borderBottomWeight: 'BORDER_BOTTOM_WEIGHT',
borderLeftWeight: 'BORDER_LEFT_WEIGHT',
borderRightWeight: 'BORDER_RIGHT_WEIGHT',
// Auto-layout spacing & padding
itemSpacing: 'STACK_SPACING',
paddingLeft: 'STACK_PADDING_LEFT',
paddingTop: 'STACK_PADDING_TOP',
paddingRight: 'STACK_PADDING_RIGHT',
paddingBottom: 'STACK_PADDING_BOTTOM',
counterAxisSpacing: 'STACK_COUNTER_SPACING',
// Grid gaps
gridRowGap: 'GRID_ROW_GAP',
gridColumnGap: 'GRID_COLUMN_GAP',
// Visibility & opacity
visible: 'VISIBLE',
opacity: 'OPACITY',
// Dimensions
width: 'WIDTH',
height: 'HEIGHT',
minWidth: 'MIN_WIDTH',
maxWidth: 'MAX_WIDTH',
minHeight: 'MIN_HEIGHT',
maxHeight: 'MAX_HEIGHT',
// Position & rotation
x: 'X_POSITION',
y: 'Y_POSITION',
rotation: 'ROTATION',
// Text
fontSize: 'FONT_SIZE',
letterSpacing: 'LETTER_SPACING',
lineHeight: 'LINE_HEIGHT',
fontFamily: 'FONT_FAMILY'
}
export const VARIABLE_BINDING_FIELDS_INVERSE: Record<string, string> = Object.fromEntries(
Object.entries(VARIABLE_BINDING_FIELDS).map(([k, v]) => [v, k])
)
export { VARIABLE_BINDING_FIELDS, VARIABLE_BINDING_FIELDS_INVERSE } from './variable-bindings'
interface FigVariableModeMap {
entries?: Array<{
@ -428,25 +381,15 @@ function convertTextProps(nc: NodeChange, blobs: Uint8Array[]): TextProps {
}
}
function consumesVariableField(nc: NodeChange, field: string): boolean {
return nc.variableConsumptionMap?.entries?.some((entry) => entry.variableField === field) ?? false
}
function convertLayoutPadding(
nc: NodeChange
): Pick<SceneNode, 'paddingTop' | 'paddingBottom' | 'paddingLeft' | 'paddingRight'> {
const basePadding = nc.stackPadding ?? 0
const verticalPadding = nc.stackVerticalPadding ?? basePadding
const horizontalPadding = nc.stackHorizontalPadding ?? basePadding
return {
paddingTop: verticalPadding,
paddingBottom:
nc.stackPaddingBottom ??
(consumesVariableField(nc, 'STACK_PADDING_TOP') ? basePadding : verticalPadding),
paddingLeft: horizontalPadding,
paddingRight:
nc.stackPaddingRight ??
(consumesVariableField(nc, 'STACK_PADDING_LEFT') ? basePadding : horizontalPadding)
paddingTop: nc.stackVerticalPadding ?? basePadding,
paddingBottom: nc.stackPaddingBottom ?? basePadding,
paddingLeft: nc.stackHorizontalPadding ?? basePadding,
paddingRight: nc.stackPaddingRight ?? basePadding
}
}
@ -470,6 +413,16 @@ function visibleContainerDerivedLayout(
}
}
function minimumSizeDimension(size: NodeChange['minSize'], axis: 'x' | 'y'): number | null {
const value = size?.value?.[axis]
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
}
function maximumSizeDimension(size: NodeChange['maxSize'], axis: 'x' | 'y'): number | null {
const value = size?.value?.[axis]
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
}
function convertLayoutProps(
nc: NodeChange
): Pick<
@ -663,10 +616,10 @@ export function nodeChangeToProps(
verticalConstraint: mapConstraint(nc.verticalConstraint as string),
...convertLayoutProps(nc),
...vectorAndStrokeProps,
minWidth: (nc.minWidth ?? null) as number | null,
maxWidth: (nc.maxWidth ?? null) as number | null,
minHeight: (nc.minHeight ?? null) as number | null,
maxHeight: (nc.maxHeight ?? null) as number | null,
minWidth: minimumSizeDimension(nc.minSize, 'x'),
maxWidth: maximumSizeDimension(nc.maxSize, 'x'),
minHeight: minimumSizeDimension(nc.minSize, 'y'),
maxHeight: maximumSizeDimension(nc.maxSize, 'y'),
isMask: nc.mask ?? false,
maskType: (nc.maskType ?? 'ALPHA') as 'ALPHA' | 'VECTOR' | 'LUMINANCE',
maskIsOutline: nc.maskIsOutline ?? false,

View file

@ -362,6 +362,9 @@ const RAW_FIELDS_OVERRIDE_BLOCKLIST = new Set([
'derivedSymbolData',
'derivedSymbolDataLayoutVersion',
'sourceLibraryKey',
// Normalized constraints are authoritative, including when an edit clears them.
'minSize',
'maxSize',
// Variable consumption maps: explicit serialization always sets these when
// bindings exist, and our VARIABLE_BINDING_FIELDS mapping may produce different
// kiwi field names than the original raw data for library variable references.

View file

@ -15,5 +15,6 @@ export * from './style-refs'
export * from './style-runs'
export * from './text-data-export'
export * from './text-values'
export * from './variable-bindings'
export * from './vector-geometry'
export * from './vector-network'

View file

@ -10,6 +10,7 @@ import {
} from '@open-pencil/scene-graph'
import { readEffectiveFigmaRawField } from '../source-metadata'
import { resolveVariableConsumptionEntry } from './variable-bindings'
export const OPEN_PENCIL_PLUGIN_ID = 'open-pencil'
export const TEXT_DIRECTION_PLUGIN_KEY = 'textDirection'
@ -76,6 +77,10 @@ export function extractBoundVariables(nc: NodeChange): Record<string, string> {
const bindings = parseBoundVariablesPluginValue(
getOpenPencilPluginValue(nc, BOUND_VARIABLES_PLUGIN_KEY)
)
for (const entry of nc.variableConsumptionMap?.entries ?? []) {
const binding = resolveVariableConsumptionEntry(entry)
if (binding) bindings[binding.field] = binding.variableId
}
nc.fillPaints?.forEach((paint, i) => {
const variableGuid =
paint.colorVariableBinding?.variableID ?? paint.colorVar?.value?.alias?.guid

View file

@ -310,8 +310,23 @@ function preserveTrailingPadding(
return normalizedValue !== inheritedValue ? normalizedValue : undefined
}
function serializeSizeConstraints(node: SceneNode, nc: KiwiNodeChange): void {
if (node.minWidth != null || node.minHeight != null) {
nc.minSize = { value: { x: node.minWidth ?? 0, y: node.minHeight ?? 0 } }
}
if (node.maxWidth != null || node.maxHeight != null) {
nc.maxSize = {
value: {
x: node.maxWidth ?? Number.POSITIVE_INFINITY,
y: node.maxHeight ?? Number.POSITIVE_INFINITY
}
}
}
}
function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange, graph: SceneGraph): void {
if (!node.source.id) upsertPluginData(node, LAYOUT_DIRECTION_PLUGIN_KEY, node.layoutDirection)
serializeSizeConstraints(node, nc)
const figLayout = node.source.fig.layout
if (figLayout) {
nc.stackMode = normalizeStackMode(figLayout.stackMode)

View file

@ -0,0 +1,99 @@
import type { VariableConsumptionEntry } from '@open-pencil/kiwi/fig/codec'
import { guidToString } from '@open-pencil/kiwi/fig/guid'
import type { SceneNode } from '@open-pencil/scene-graph'
export const VARIABLE_BINDING_FIELDS: Record<string, string> = {
cornerRadius: 'CORNER_RADIUS',
topLeftRadius: 'RECTANGLE_TOP_LEFT_CORNER_RADIUS',
topRightRadius: 'RECTANGLE_TOP_RIGHT_CORNER_RADIUS',
bottomLeftRadius: 'RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS',
bottomRightRadius: 'RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS',
strokeWeight: 'STROKE_WEIGHT',
borderTopWeight: 'BORDER_TOP_WEIGHT',
borderBottomWeight: 'BORDER_BOTTOM_WEIGHT',
borderLeftWeight: 'BORDER_LEFT_WEIGHT',
borderRightWeight: 'BORDER_RIGHT_WEIGHT',
itemSpacing: 'STACK_SPACING',
paddingLeft: 'STACK_PADDING_LEFT',
paddingTop: 'STACK_PADDING_TOP',
paddingRight: 'STACK_PADDING_RIGHT',
paddingBottom: 'STACK_PADDING_BOTTOM',
counterAxisSpacing: 'STACK_COUNTER_SPACING',
gridRowGap: 'GRID_ROW_GAP',
gridColumnGap: 'GRID_COLUMN_GAP',
visible: 'VISIBLE',
opacity: 'OPACITY',
width: 'WIDTH',
height: 'HEIGHT',
minWidth: 'MIN_WIDTH',
maxWidth: 'MAX_WIDTH',
minHeight: 'MIN_HEIGHT',
maxHeight: 'MAX_HEIGHT',
x: 'X_POSITION',
y: 'Y_POSITION',
rotation: 'ROTATION',
fontSize: 'FONT_SIZE',
letterSpacing: 'LETTER_SPACING',
lineHeight: 'LINE_HEIGHT',
fontFamily: 'FONT_FAMILY'
}
export const VARIABLE_BINDING_FIELDS_INVERSE: Record<string, string> = Object.fromEntries(
Object.entries(VARIABLE_BINDING_FIELDS).map(([field, kiwiField]) => [kiwiField, field])
)
export interface ResolvedVariableConsumption {
field: string
variableId: string
}
export function resolveVariableConsumptionEntry(
entry: VariableConsumptionEntry
): ResolvedVariableConsumption | undefined {
const field = entry.variableField
? VARIABLE_BINDING_FIELDS_INVERSE[entry.variableField]
: undefined
const guid = entry.variableData?.value?.alias?.guid
return field && guid ? { field, variableId: guidToString(guid) } : undefined
}
const NUMERIC_BINDING_FIELDS = new Set([
'cornerRadius',
'topLeftRadius',
'topRightRadius',
'bottomLeftRadius',
'bottomRightRadius',
'strokeWeight',
'borderTopWeight',
'borderBottomWeight',
'borderLeftWeight',
'borderRightWeight',
'itemSpacing',
'paddingLeft',
'paddingTop',
'paddingRight',
'paddingBottom',
'counterAxisSpacing',
'gridRowGap',
'gridColumnGap',
'width',
'height',
'minWidth',
'maxWidth',
'minHeight',
'maxHeight',
'x',
'y',
'rotation',
'fontSize',
'letterSpacing',
'lineHeight'
])
export function resolvedNumericBindingUpdate(
field: string,
value: number
): Partial<SceneNode> | undefined {
if (field === 'opacity') return { opacity: Math.max(0, Math.min(1, value / 100)) }
return NUMERIC_BINDING_FIELDS.has(field) ? { [field]: value } : undefined
}

View file

@ -27,6 +27,135 @@ describe('@open-pencil/fig instance interpretation', () => {
expect(graph.getNode(populated?.childIds[0] ?? '')?.text).toBe('Label')
})
test('repositions pinned children through nested resized instances', () => {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id
const component = graph.createNode('COMPONENT', pageId, {
width: 442,
height: 32,
layoutMode: 'HORIZONTAL'
})
graph.createNode('TEXT', component.id, { x: 32, y: 6, width: 80, height: 20 })
graph.createNode('TEXT', component.id, { x: 120, y: 6, width: 80, height: 20 })
graph.createNode('INSTANCE', component.id, {
x: 420,
y: 9,
width: 14,
height: 14,
layoutPositioning: 'ABSOLUTE',
horizontalConstraint: 'MAX',
verticalConstraint: 'CENTER'
})
const source = graph.createNode('INSTANCE', pageId, {
width: 256,
height: 32,
layoutMode: 'HORIZONTAL',
componentId: component.id
})
const instance = graph.createNode('INSTANCE', pageId, {
width: 256,
height: 32,
layoutMode: 'HORIZONTAL',
componentId: source.id
})
populateAndApplyOverrides(graph, new Map(), new Map())
const pinned = graph.getChildren(instance.id)[2]
expect(pinned).toMatchObject({ x: 234, y: 9, width: 14, height: 14 })
})
test('resizes stretched absolute children with resized instances', () => {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id
const component = graph.createNode('COMPONENT', pageId, {
width: 100,
height: 80,
layoutMode: 'HORIZONTAL'
})
graph.createNode('RECTANGLE', component.id, {
x: 10,
y: 10,
width: 80,
height: 60,
layoutPositioning: 'ABSOLUTE',
horizontalConstraint: 'STRETCH',
verticalConstraint: 'STRETCH'
})
const instance = graph.createNode('INSTANCE', pageId, {
width: 200,
height: 120,
layoutMode: 'HORIZONTAL',
componentId: component.id
})
populateAndApplyOverrides(graph, new Map(), new Map())
expect(graph.getChildren(instance.id)[0]).toMatchObject({
x: 10,
y: 10,
width: 180,
height: 100
})
})
test('applies pinned constraints inside resized freeform instances', () => {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id
const component = graph.createNode('COMPONENT', pageId, { width: 100, height: 80 })
graph.createNode('RECTANGLE', component.id, {
x: 80,
y: 10,
width: 10,
height: 60,
layoutPositioning: 'ABSOLUTE',
horizontalConstraint: 'MAX',
verticalConstraint: 'STRETCH'
})
const instance = graph.createNode('INSTANCE', pageId, {
width: 200,
height: 120,
componentId: component.id
})
populateAndApplyOverrides(graph, new Map(), new Map())
expect(graph.getChildren(instance.id)[0]).toMatchObject({
x: 180,
y: 10,
width: 10,
height: 100
})
})
test('preserves an inset child when a nested instance becomes narrower', () => {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id
const field = graph.createNode('COMPONENT', pageId, { width: 280, height: 40 })
graph.createNode('TEXT', field.id, {
x: 16,
y: 10,
width: 248,
height: 20,
text: 'Placeholder'
})
const source = graph.createNode('INSTANCE', pageId, {
width: 240,
height: 40,
componentId: field.id
})
const instance = graph.createNode('INSTANCE', pageId, {
width: 180,
height: 40,
componentId: source.id
})
populateAndApplyOverrides(graph, new Map(), new Map())
const placeholder = graph.getChildren(instance.id)[0]
expect(placeholder).toMatchObject({ x: 16, y: 10, text: 'Placeholder' })
})
test('limits lazy population to required global propagation scans', () => {
const graph = new SceneGraph()
const activePage = graph.getPages()[0]
@ -83,6 +212,41 @@ describe('@open-pencil/fig instance interpretation', () => {
expect(graph.getNode(leaf.id)).toMatchObject({ width: 80, fills: source.fills })
})
test('synchronizes opacity bindings with their resolved value', () => {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id
const source = graph.createNode('INSTANCE', pageId, {
opacity: 0.5,
boundVariables: { opacity: 'opacity-var' }
})
const target = graph.createNode('INSTANCE', pageId, {
opacity: 1,
componentId: source.id
})
syncNodeProps(graph, source, target)
expect(graph.getNode(target.id)).toMatchObject({
opacity: 0.5,
boundVariables: { opacity: 'opacity-var' }
})
})
test('clears opacity bindings removed from the source', () => {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id
const source = graph.createNode('INSTANCE', pageId, { opacity: 1 })
const target = graph.createNode('INSTANCE', pageId, {
opacity: 0.5,
componentId: source.id,
boundVariables: { opacity: 'stale-opacity-var', width: 'width-var' }
})
syncNodeProps(graph, source, target)
expect(graph.getNode(target.id)?.boundVariables).toEqual({ width: 'width-var' })
})
test('preserves protected text while synchronizing other fields', () => {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id

View file

@ -329,6 +329,8 @@ export interface NodeChange {
stackChildPrimaryGrow?: number
stackChildAlignSelf?: string
stackCounterSpacing?: number
minSize?: { value?: Vector }
maxSize?: { value?: Vector }
// Frame
clipsContent?: boolean
frameMaskDisabled?: boolean

View file

@ -7,6 +7,10 @@ export type { NodeCloneMode } from './copy'
const INSTANCE_SYNC_PROPS: (keyof SceneNode)[] = [
'width',
'height',
'minWidth',
'maxWidth',
'minHeight',
'maxHeight',
'fills',
'strokes',
'effects',

View file

@ -234,7 +234,10 @@ describe('Figma component property import', () => {
parentIndex: { guid: pageGuid, position: '"' },
type: 'SYMBOL',
name: 'icon/user',
size: { x: 16, y: 16 },
size: { x: 16, y: 24 },
minSize: { value: { x: 0, y: 24 } },
stackMode: 'HORIZONTAL',
stackHorizontalPadding: 3,
transform: { m00: 1, m01: 0, m02: 40, m10: 0, m11: 1, m12: 0 }
},
{
@ -345,7 +348,13 @@ describe('Figma component property import', () => {
.map((id) => graph.getNode(id))
.find((node) => node?.type === 'INSTANCE')
const iconChild = icon?.childIds.map((id) => graph.getNode(id)).find(Boolean)
expect(icon?.name).toBe('icon/user')
expect(icon).toMatchObject({
name: 'icon/user',
layoutMode: 'HORIZONTAL',
minHeight: 24,
paddingLeft: 3,
paddingRight: 0
})
expect(iconChild?.name).toBe('user-path')
expect(iconChild?.strokes[0]?.color).toEqual({ r: 0.2, g: 0.25, b: 0.33, a: 1 })
})

View file

@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test'
import {
applyGeneratedFreeformStretch,
buildDsdLayoutUpdates,
propagateDsdChanges,
type OverrideContext
@ -35,6 +36,89 @@ describe('fig import derived symbol data', () => {
expect(clone.figmaDerivedLayout).toEqual(source.figmaDerivedLayout)
})
test('inherits derived positions when a clone has an explicit derived size', () => {
const graph = new SceneGraph()
const source = graph.createNode('INSTANCE', pageId(graph), {
x: 152,
y: 0,
width: 136,
height: 40,
figmaDerivedLayout: { x: 152, y: 0, width: 136, height: 40 }
})
const clone = graph.createNode('INSTANCE', pageId(graph), {
x: 136,
y: 0,
width: 144,
height: 48,
componentId: source.id,
figmaDerivedLayout: { x: 136, y: 4, width: 144, height: 48 }
})
const ctx = {
graph,
activeNodeIds: new Set([source.id, clone.id]),
geometryOverrideNodes: new Set()
} as OverrideContext
propagateDsdChanges(ctx, new Set([source.id]), new Set([clone.id]))
expect(graph.getNode(clone.id)).toMatchObject({
x: 152,
y: 0,
figmaDerivedLayout: {
x: 152,
y: 0,
width: 144,
height: 48
}
})
})
test('applies generated stretch dimensions inside authoritative freeform parents', () => {
const graph = new SceneGraph()
const parent = graph.createNode('FRAME', pageId(graph), {
width: 232,
height: 24,
layoutMode: 'NONE',
figmaDerivedLayout: { width: 232, height: 24 }
})
const text = graph.createNode('TEXT', parent.id, {
width: 256,
height: 20,
horizontalConstraint: 'STRETCH',
figmaDerivedLayout: { width: 232, height: 20 }
})
const ctx = {
graph,
activeNodeIds: new Set([parent.id, text.id])
} as OverrideContext
applyGeneratedFreeformStretch(ctx)
expect(graph.getNode(text.id)?.width).toBe(232)
})
test('ignores stretch axes without derived dimensions', () => {
const graph = new SceneGraph()
const parent = graph.createNode('FRAME', pageId(graph), {
width: 232,
height: 24,
layoutMode: 'NONE',
figmaDerivedLayout: { width: 232 }
})
const child = graph.createNode('FRAME', parent.id, {
width: 100,
height: 20,
horizontalConstraint: 'STRETCH',
verticalConstraint: 'STRETCH',
figmaDerivedLayout: { height: 20 }
})
const ctx = { graph, activeNodeIds: new Set([parent.id, child.id]) } as OverrideContext
applyGeneratedFreeformStretch(ctx)
expect(graph.getNode(child.id)).toMatchObject({ width: 100, height: 20 })
})
test('keeps the existing position when derived data only changes size', () => {
const graph = new SceneGraph()
const component = graph.createNode('COMPONENT', pageId(graph), { x: 100, y: 100 })

View file

@ -7,29 +7,13 @@ import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { canvas, doc, node } from '../helpers'
describe('fig-import: auto-layout alignment', () => {
test('keeps variable-bound leading padding independent', () => {
test('keeps leading padding independent when trailing fields are omitted', () => {
const props = nodeChangeToProps(
{
type: 'FRAME',
stackMode: 'VERTICAL',
stackVerticalPadding: 8,
stackHorizontalPadding: 6,
variableConsumptionMap: {
entries: [
{
variableField: 'STACK_PADDING_TOP',
variableData: {
value: { alias: { guid: { sessionID: 2, localID: 1 } } }
}
},
{
variableField: 'STACK_PADDING_LEFT',
variableData: {
value: { alias: { guid: { sessionID: 2, localID: 2 } } }
}
}
]
}
stackHorizontalPadding: 6
} as NodeChange,
[]
)
@ -39,6 +23,22 @@ describe('fig-import: auto-layout alignment', () => {
expect(props.paddingRight).toBe(0)
})
test('imports min and max size vectors as axis constraints', () => {
const props = nodeChangeToProps(
{
type: 'FRAME',
minSize: { value: { x: 192, y: 0 } },
maxSize: { value: { x: 672, y: -1 } }
} as NodeChange,
[]
)
expect(props.minWidth).toBe(192)
expect(props.minHeight).toBeNull()
expect(props.maxWidth).toBe(672)
expect(props.maxHeight).toBeNull()
})
test('maps SPACE_EVENLY kiwi primary alignment to Figma space-between', () => {
const graph = importNodeChanges([
doc(),

View file

@ -83,6 +83,67 @@ describe('fig-import: variable asset refs', () => {
})
})
test('imports native scalar variable bindings', () => {
const graph = importNodeChanges([
doc(),
canvas(),
{
...node('VARIABLE_SET', 20, 1),
variableSetModes: [{ id: { sessionID: 10, localID: 1 }, name: 'Default' }]
} as NodeChange,
{
...node('VARIABLE', 21, 1),
variableSetID: { guid: { sessionID: 1, localID: 20 } },
variableResolvedType: 'FLOAT',
variableDataValues: {
entries: [
{
modeID: { sessionID: 10, localID: 1 },
variableData: {
dataType: 'FLOAT',
resolvedDataType: 'FLOAT',
value: { floatValue: 50 }
}
}
]
}
} as NodeChange,
node('FRAME', 30, 1, {
opacity: 0.2,
variableConsumptionMap: {
entries: [
{
variableField: 'OPACITY',
variableData: {
dataType: 'ALIAS',
resolvedDataType: 'FLOAT',
value: { alias: { guid: { sessionID: 1, localID: 21 } } }
}
},
{
variableField: 'WIDTH',
variableData: {
dataType: 'ALIAS',
resolvedDataType: 'FLOAT',
value: { alias: { guid: { sessionID: 1, localID: 21 } } }
}
}
]
}
})
])
const frame = expectDefined(
[...graph.getAllNodes()].find((candidate) => candidate.name === 'FRAME_30'),
'bound frame'
)
expect(frame.opacity).toBe(0.5)
expect(frame.width).toBe(50)
expect(frame.boundVariables.opacity).toBe('1:21')
expect(frame.boundVariables.width).toBe('1:21')
expect(graph.resolveNumberVariableForNode(frame.id, '1:21')).toBe(50)
})
test('resolves color variables and aliases by assetRef', () => {
const graph = importNodeChanges([
doc(),

View file

@ -373,10 +373,10 @@ describe('Figma Kiwi schema coverage', () => {
expect(
Object.fromEntries([...buckets].map(([bucket, items]) => [bucket, items.length]))
).toEqual({
modeled: 112,
modeled: 114,
schemaTag: 60,
internalBookkeeping: 17,
rawPreserved: 53,
rawPreserved: 51,
styleLibraryMetadata: 39,
componentInstanceMetadata: 33,
textMetadata: 23,

View file

@ -43,6 +43,10 @@ describe('roundtrip: export → re-import', () => {
paddingRight: 24,
paddingBottom: 24,
paddingLeft: 24,
minWidth: 320,
minHeight: 240,
maxWidth: 500,
maxHeight: null,
cornerRadius: 12,
fills: [
{
@ -255,6 +259,15 @@ describe('roundtrip: export → re-import', () => {
expect(container.paddingLeft).toBe(24)
})
test('preserves min and max size constraints', () => {
const container = reImportedNodes.find((n) => n.name === 'Container')
expect(container).toBeDefined()
expect(expectDefined(container, 'container').minWidth).toBe(320)
expect(container.minHeight).toBe(240)
expect(container.maxWidth).toBe(500)
expect(container.maxHeight).toBeNull()
})
test('preserves corner radius', () => {
const container = reImportedNodes.find((n) => n.name === 'Container')
expect(container).toBeDefined()

View file

@ -140,6 +140,29 @@ describe('fig roundtrip source metadata', () => {
}
})
test('does not restore cleared imported size constraints from raw metadata', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const frame = graph.createNode('FRAME', page.id, { name: 'Cleared constraints' })
frame.source.format = 'fig'
frame.source.id = '4:501'
frame.source.fig.rawNodeFields.minSize = { value: { x: 120, y: 80 } }
frame.source.fig.rawNodeFields.maxSize = {
value: { x: 500, y: Number.POSITIVE_INFINITY }
}
const decoded = decodeExport(await exportFigFile(graph))
const exported = decoded.nodeChanges.find(
(nodeChange) => nodeChange.guid && guidToString(nodeChange.guid) === '4:501'
)
expect(exported).toBeDefined()
expect(exported?.minSize).toBeUndefined()
expect(exported?.maxSize).toBeUndefined()
expect(frame.source.fig.rawNodeFields.minSize).toBeDefined()
expect(frame.source.fig.rawNodeFields.maxSize).toBeDefined()
})
test('preserves imported rich text schema metadata for round-trip', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]

View file

@ -40,6 +40,7 @@ describe('alignment - primary axis', () => {
const frame = autoFrame(graph, pageId(graph), {
width: 400,
height: 100,
itemSpacing: 32,
primaryAxisAlign: 'SPACE_BETWEEN'
})
rect(graph, frame.id, 50, 50)
@ -54,6 +55,27 @@ describe('alignment - primary axis', () => {
expect(children[1].x).toBeCloseTo(175, 0)
})
test('space-between alignment ignores stored primary spacing vertically', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {
layoutMode: 'VERTICAL',
width: 100,
height: 400,
itemSpacing: 32,
primaryAxisAlign: 'SPACE_BETWEEN'
})
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].y).toBe(0)
expect(children[1].y).toBeCloseTo(175, 0)
expect(children[2].y).toBe(350)
})
test('center alignment (vertical)', () => {
const graph = new SceneGraph()
const frame = autoFrame(graph, pageId(graph), {

View file

@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test'
import { computeAllLayouts, SceneGraph } from '@open-pencil/core'
import { getAbsolutePositionFull } from '@open-pencil/scene-graph'
describe('imported auto-layout bounds', () => {
test('preserves visible hug container bounds when hidden children would collapse layout', () => {
@ -104,6 +105,373 @@ describe('imported auto-layout bounds', () => {
expect(graph.getNode(field.id)).toMatchObject({ x: 0, y: 0, width: 276, height: 40 })
})
test('uses imported HUG dimensions when positioning following siblings', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const column = graph.createNode('FRAME', page.id, {
width: 200,
height: 100,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED'
})
graph.createNode('FRAME', column.id, {
width: 200,
height: 1,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
paddingTop: 4,
paddingBottom: 4,
strokes: [
{
color: { r: 0, g: 0, b: 0, a: 1 },
weight: 1,
opacity: 1,
visible: true,
align: 'CENTER'
}
],
figmaDerivedLayout: { x: 0, y: 0, width: 200, height: 1 }
})
const following = graph.createNode('RECTANGLE', column.id, { width: 200, height: 20 })
computeAllLayouts(graph)
expect(graph.getNode(following.id)?.y).toBe(1)
})
test('positions generated fill children from exact imported dimensions and gaps', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const row = graph.createNode('FRAME', page.id, {
width: 288,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
itemSpacing: 16
})
const first = graph.createNode('INSTANCE', row.id, {
width: 136,
height: 14,
layoutMode: 'HORIZONTAL',
layoutGrow: 1,
figmaDerivedLayout: { width: 136, height: 14 }
})
const second = graph.createNode('INSTANCE', row.id, {
width: 136,
height: 40,
layoutMode: 'HORIZONTAL',
layoutGrow: 1,
figmaDerivedLayout: { width: 136, height: 40 }
})
graph.createNode('RECTANGLE', first.id, { width: 200, height: 14 })
computeAllLayouts(graph)
expect(graph.getNode(first.id)).toMatchObject({ x: 0, width: 136 })
expect(graph.getNode(second.id)).toMatchObject({ x: 152, width: 136 })
})
test('uses exact imported dimensions with space-between despite stored spacing', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const row = graph.createNode('FRAME', page.id, {
width: 288,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
primaryAxisAlign: 'SPACE_BETWEEN',
itemSpacing: 16
})
const first = graph.createNode('INSTANCE', row.id, {
width: 136,
height: 40,
layoutMode: 'HORIZONTAL',
layoutGrow: 1,
figmaDerivedLayout: { width: 136, height: 40 }
})
const second = graph.createNode('INSTANCE', row.id, {
width: 152,
height: 40,
layoutMode: 'HORIZONTAL',
layoutGrow: 1,
figmaDerivedLayout: { width: 152, height: 40 }
})
computeAllLayouts(graph)
expect(graph.getNode(first.id)).toMatchObject({ x: 0, width: 136 })
expect(graph.getNode(second.id)).toMatchObject({ x: 136, width: 152 })
})
test('keeps normal flex sizing when imported dimensions do not fit the parent', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const row = graph.createNode('FRAME', page.id, {
width: 320,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
itemSpacing: 16
})
const first = graph.createNode('INSTANCE', row.id, {
width: 136,
height: 14,
layoutMode: 'HORIZONTAL',
layoutGrow: 1,
figmaDerivedLayout: { width: 136, height: 14 }
})
const second = graph.createNode('INSTANCE', row.id, {
width: 136,
height: 40,
layoutMode: 'HORIZONTAL',
layoutGrow: 1,
figmaDerivedLayout: { width: 136, height: 40 }
})
computeAllLayouts(graph)
expect(graph.getNode(first.id)).toMatchObject({ x: 0, width: 136 })
expect(graph.getNode(second.id)).toMatchObject({ x: 168, width: 136 })
})
test('stretches generated children inside authoritative imported bounds', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const column = graph.createNode('INSTANCE', page.id, {
width: 302,
height: 60,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
figmaDerivedLayout: { width: 302, height: 60 }
})
const label = graph.createNode('INSTANCE', column.id, {
width: 280,
height: 14,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
layoutAlignSelf: 'STRETCH'
})
computeAllLayouts(graph)
expect(graph.getNode(label.id)).toMatchObject({ x: 0, width: 302 })
})
test('does not infer authoritative stretch without generated parent bounds', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const column = graph.createNode('FRAME', page.id, {
width: 624,
height: 290,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED'
})
const label = graph.createNode('INSTANCE', column.id, {
width: 44,
height: 14,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
layoutAlignSelf: 'STRETCH'
})
computeAllLayouts(graph)
expect(graph.getNode(label.id)?.width).toBe(44)
})
test('preserves hidden child geometry while excluding it from parent flow', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const column = graph.createNode('FRAME', page.id, {
width: 280,
height: 40,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED'
})
const label = graph.createNode('INSTANCE', column.id, {
width: 280,
height: 14,
visible: false,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG'
})
graph.createNode('TEXT', label.id, {
width: 37,
height: 14,
text: 'Label',
textAutoResize: 'WIDTH_AND_HEIGHT',
figmaDerivedLayout: { x: 0, y: 0, width: 37, height: 14 }
})
computeAllLayouts(graph)
expect(graph.getNode(label.id)).toMatchObject({ width: 280, height: 14, visible: false })
})
test('uses exact derived width for a growing text leaf when siblings fill the parent', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const row = graph.createNode('INSTANCE', page.id, {
width: 224,
height: 32,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
itemSpacing: 8,
paddingTop: 8,
paddingRight: 8,
paddingBottom: 8,
paddingLeft: 8
})
graph.createNode('INSTANCE', row.id, { width: 16, height: 16 })
const text = graph.createNode('TEXT', row.id, {
width: 196,
height: 20,
text: 'Models',
textAutoResize: 'HEIGHT',
layoutGrow: 1,
figmaDerivedLayout: { width: 160, height: 20 }
})
const chevron = graph.createNode('INSTANCE', row.id, { width: 16, height: 16 })
computeAllLayouts(graph)
expect(graph.getNode(text.id)).toMatchObject({ x: 32, width: 160 })
expect(graph.getNode(chevron.id)?.x).toBe(200)
})
test('lets live Yoga resize imported text when stored and derived sizes agree', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const frame = graph.createNode('FRAME', page.id, {
width: 200,
height: 100,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
paddingLeft: 20,
paddingRight: 20
})
const text = graph.createNode('TEXT', frame.id, {
width: 424,
height: 40,
layoutAlignSelf: 'STRETCH',
figmaDerivedLayout: { width: 424, height: 40 }
})
graph.updateNode(text.id, {
source: { ...text.source, format: 'fig', id: '1:3' }
})
computeAllLayouts(graph)
expect(graph.getNode(text.id)).toMatchObject({ width: 160, height: 40 })
})
test('preserves imported HUG cross size backed by stretched derived children', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const frame = graph.createNode('FRAME', page.id, {
width: 381,
height: 102,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
paddingTop: 24,
paddingRight: 24,
paddingBottom: 24,
paddingLeft: 24
})
graph.updateNode(frame.id, { source: { ...frame.source, format: 'fig' } })
graph.createNode('TEXT', frame.id, {
width: 333,
height: 30,
text: 'Bar Chart',
textAutoResize: 'HEIGHT',
layoutAlignSelf: 'STRETCH',
figmaDerivedLayout: { width: 333, height: 30 }
})
computeAllLayouts(graph)
expect(graph.getNode(frame.id)?.width).toBe(381)
})
test('preserves direct imported frame geometry inside imported parents', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const parent = graph.createNode('FRAME', page.id, {
width: 768,
height: 454,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
counterAxisAlign: 'CENTER'
})
const child = graph.createNode('FRAME', parent.id, {
x: 192,
y: 40,
width: 384,
height: 414,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
layoutAlignSelf: 'STRETCH'
})
graph.updateNode(parent.id, { source: { ...parent.source, format: 'fig' } })
graph.updateNode(child.id, { source: { ...child.source, format: 'fig' } })
computeAllLayouts(graph)
expect(graph.getNode(child.id)).toMatchObject({ x: 192, y: 40, width: 384, height: 414 })
})
test('preserves transformed bounds for rotated imported flow children', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const parent = graph.createNode('FRAME', page.id, {
width: 17,
height: 20,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
paddingLeft: 16
})
const separator = graph.createNode('INSTANCE', parent.id, {
x: 6.5,
y: 9.5,
width: 20,
height: 1,
rotation: -90
})
graph.updateNode(separator.id, {
source: { ...separator.source, format: 'fig' }
})
const before = getAbsolutePositionFull(separator, graph)
computeAllLayouts(graph)
const afterNode = graph.getNode(separator.id)
expect(afterNode).toBeDefined()
const after = getAbsolutePositionFull(afterNode ?? separator, graph)
expect(after).toMatchObject({
boundX: before.boundX,
boundY: before.boundY,
width: before.width,
height: before.height
})
})
test('uses Yoga positions for imported instances while preserving imported size', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]

View file

@ -48,6 +48,47 @@ describe('text measurement', () => {
expect(graph.getNode(tabs.id)?.height).toBe(42)
})
test('direct imported text keeps its NodeChange bounds over glyph layout metadata', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const frame = autoFrame(graph, page, {
width: 424,
height: 100,
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED'
})
const text = graph.createNode('TEXT', frame.id, {
text: 'Truncated preview',
width: 424,
height: 40,
textAutoResize: 'HEIGHT',
figmaDerivedLayout: { width: 424, height: 120 },
source: {
format: 'fig',
id: '1:2',
orderKey: '!',
editedFields: [],
fig: {
rawNodeFields: {},
rawTransform: null,
rawSize: null,
layout: null,
derivedSymbolDataLayoutVersion: null,
derivedSymbolData: [],
symbolOverrides: [],
componentPropAssignments: [],
uniformScaleFactor: null
}
}
})
setTextMeasurer(() => ({ width: 424, height: 120 }))
computeAllLayouts(graph, page)
setTextMeasurer(null)
expect(graph.getNode(text.id)?.height).toBe(40)
})
test('live text without derived glyphs still uses CanvasKit measurement', () => {
const graph = new SceneGraph()
const page = pageId(graph)