Merge pull request #462 from open-pencil/shadcn-compat-gaps
fix(fig): improve imported component layout parity
This commit is contained in:
commit
bbeffc54cb
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
13
packages/core/src/layout/derived.ts
Normal file
13
packages/core/src/layout/derived.ts
Normal 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)
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
14
packages/fig/src/instance-overrides/sync/clone-update.ts
Normal file
14
packages/fig/src/instance-overrides/sync/clone-update.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
99
packages/fig/src/node-change/variable-bindings.ts
Normal file
99
packages/fig/src/node-change/variable-bindings.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -329,6 +329,8 @@ export interface NodeChange {
|
|||
stackChildPrimaryGrow?: number
|
||||
stackChildAlignSelf?: string
|
||||
stackCounterSpacing?: number
|
||||
minSize?: { value?: Vector }
|
||||
maxSize?: { value?: Vector }
|
||||
// Frame
|
||||
clipsContent?: boolean
|
||||
frameMaskDisabled?: boolean
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ export type { NodeCloneMode } from './copy'
|
|||
const INSTANCE_SYNC_PROPS: (keyof SceneNode)[] = [
|
||||
'width',
|
||||
'height',
|
||||
'minWidth',
|
||||
'maxWidth',
|
||||
'minHeight',
|
||||
'maxHeight',
|
||||
'fills',
|
||||
'strokes',
|
||||
'effects',
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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), {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue