Merge branch 'master' into polish-pr-437

This commit is contained in:
Danila Poyarkov 2026-08-05 16:47:58 +03:00 committed by GitHub
commit a63de8caaf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 2248 additions and 242 deletions

View file

@ -49,7 +49,8 @@
### Fixed
- Match Figma Plugin API vector path and network editing, including bounds, winding rules, region fills, validation, and handle mirroring. (#444)
- Match Figma auto-layout spacing, padding, min/max constraints, scalar variable bindings, CanvasKit-shaped generated text, imported text bounds, and nested instance geometry more closely.
- Match Figma Plugin API vector path and network editing, including bounds, transforms, 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)
- Restore Anthropic AI connections in the web app instead of failing with a browser endpoint error. (#438)

View file

@ -12,6 +12,10 @@
"label": "Open…",
"accelerator": "CmdOrCtrl+O"
},
{
"id": "open-storage-workspace",
"label": "Open Storage Workspace…"
},
{
"type": "separator"
},

View file

@ -1,4 +1,4 @@
import type { SceneNode } from '@open-pencil/scene-graph'
import { getNodeLocalMatrix, getWorldMatrix, type SceneNode } from '@open-pencil/scene-graph'
import type { Rect } from '@open-pencil/scene-graph/primitives'
import {
@ -9,6 +9,30 @@ import {
type ProxyThis
} from '#core/figma-api/accessor-utils'
import type { NodeProxyHost } from '#core/figma-api/proxy'
import type { FigmaTransform } from '#core/figma-api/types'
const TRANSFORM_FIELDS = new Set(['x', 'y', 'rotation', 'flipX', 'flipY'])
function preservesRawTransform(node: SceneNode): boolean {
return !node.source.editedFields.some((field) => TRANSFORM_FIELDS.has(field))
}
function cleanTransformValue(value: number): number {
if (Math.abs(value) < 1e-12) return 0
const nearestInteger = Math.round(value)
return Math.abs(value - nearestInteger) < 1e-12 ? nearestInteger : value
}
function figmaTransform(matrix: number[]): FigmaTransform {
return [
[
cleanTransformValue(matrix[0]),
cleanTransformValue(matrix[1]),
cleanTransformValue(matrix[2])
],
[cleanTransformValue(matrix[3]), cleanTransformValue(matrix[4]), cleanTransformValue(matrix[5])]
]
}
export function installBasicNodeProxyAccessors(
prototype: object,
@ -66,19 +90,37 @@ export function installBasicNodeProxyAccessors(
},
rotation: {
get(this: ProxyThis): number {
return raw(this, internals).rotation
const node = raw(this, internals)
const sourceTransform = node.source.fig.rawTransform
if (sourceTransform && preservesRawTransform(node)) {
return Math.atan2(-sourceTransform.m10, sourceTransform.m00) * (180 / Math.PI)
}
return node.rotation
},
set(this: ProxyThis, value: number) {
graph(this, internals).updateNode(nodeId(this, internals), { rotation: value })
}
},
relativeTransform: {
get(this: ProxyThis): FigmaTransform {
const node = raw(this, internals)
const sourceTransform = node.source.fig.rawTransform
if (sourceTransform && preservesRawTransform(node)) {
return figmaTransform([
sourceTransform.m00,
sourceTransform.m01,
sourceTransform.m02,
sourceTransform.m10,
sourceTransform.m11,
sourceTransform.m12
])
}
return figmaTransform(getNodeLocalMatrix(node))
}
},
absoluteTransform: {
get(this: ProxyThis): [[number, number, number], [number, number, number]] {
const pos = graph(this, internals).getAbsolutePosition(nodeId(this, internals))
return [
[1, 0, pos.x],
[0, 1, pos.y]
]
get(this: ProxyThis): FigmaTransform {
return figmaTransform(getWorldMatrix(raw(this, internals), graph(this, internals)))
}
},
absoluteBoundingBox: {

View file

@ -32,6 +32,7 @@ import { nodeProxyToJSON } from './serialization'
import { setFirstStrokeAlign, setFirstStrokeWeight, setIndependentStrokeWeight } from './strokes'
import * as TextProxy from './text'
import * as Traversal from './traversal'
import type { FigmaTransform } from './types'
const MIXED = Symbol('mixed')
@ -62,9 +63,10 @@ export class FigmaNodeProxy {
declare readonly width: number
declare readonly height: number
declare rotation: number
declare readonly relativeTransform: FigmaTransform
declare resize: (width: number, height: number) => void
declare resizeWithoutConstraints: (width: number, height: number) => void
declare readonly absoluteTransform: [[number, number, number], [number, number, number]]
declare readonly absoluteTransform: FigmaTransform
declare readonly absoluteBoundingBox: Rect
declare readonly absoluteRenderBounds: Rect

View file

@ -0,0 +1 @@
export type FigmaTransform = [[number, number, number], [number, number, number]]

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,8 @@ import {
} from 'yoga-layout'
import { applyYogaLayout } from './layout/apply'
import { usesDetachedDerivedLayout } from './layout/derived'
import { applyEffectiveGeneratedTextLayout } from './layout/effective-generated-text'
import { buildGridTree, createGridChildNode } from './layout/grid'
import { resolveNodeLayoutDirection } from './text/direction'
export {
@ -39,19 +41,15 @@ export function computeLayout(graph: SceneGraph, frameId: string): void {
if (!frame || frame.layoutMode === 'NONE') return
const rootDirection = resolveComputedLayoutDirection(graph, frame)
const yogaDirection = rootDirection === 'RTL' ? Direction.RTL : Direction.LTR
const yogaRoot =
frame.layoutMode === 'GRID'
? buildGridTree(graph, frame, rootDirection)
: buildYogaTree(graph, frame, rootDirection)
yogaRoot.calculateLayout(
undefined,
undefined,
rootDirection === 'RTL' ? Direction.RTL : Direction.LTR
)
yogaRoot.calculateLayout(undefined, undefined, yogaDirection)
applyYogaLayout(graph, frame, yogaRoot, computeLayout)
freeYogaTree(yogaRoot)
}
function resolveComputedLayoutDirection(
graph: SceneGraph,
node: Pick<SceneNode, 'layoutDirection' | 'parentId'>
@ -62,8 +60,12 @@ function resolveComputedLayoutDirection(
}
export function computeAllLayouts(graph: SceneGraph, scopeId?: string): void {
const rootId = scopeId ?? graph.rootId
const visited = new Set<string>()
computeLayoutsBottomUp(graph, scopeId ?? graph.rootId, visited)
computeLayoutsBottomUp(graph, rootId, visited)
if (applyEffectiveGeneratedTextLayout(graph, rootId)) {
computeLayoutsBottomUp(graph, rootId, new Set())
}
}
function computeLayoutsBottomUp(graph: SceneGraph, nodeId: string, visited: Set<string>): void {
@ -84,8 +86,6 @@ function preservesImportedInstanceLayout(node: SceneNode): boolean {
return node.type === 'INSTANCE' && node.source.format === 'fig'
}
// --- Flex layout ---
function buildYogaTree(
graph: SceneGraph,
frame: SceneNode,
@ -118,7 +118,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 +150,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 +230,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 +323,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 +354,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 +433,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 +456,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 +495,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 +554,7 @@ function configureNonTextLeaf(
function setMainAxisSizing(
yogaNode: YogaNode,
axis: 'width' | 'height',
sizing: string,
sizing: AxisSizing,
fixedValue: number,
grow: number
): void {
@ -452,7 +583,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

@ -0,0 +1,287 @@
import type { SceneGraph, SceneNode, Size } from '@open-pencil/scene-graph'
import { getTextMeasurer } from './text-measurement'
type LayoutAxis = 'width' | 'height'
const MIN_EFFECTIVE_TEXT_WIDTH_CHANGE = 1.5
const MAX_STRETCHED_TEXT_WIDTH_CHANGE = 8
const MAX_COMPONENT_LINEAGE_DEPTH = 20
function axisSizing(node: SceneNode, axis: LayoutAxis): SceneNode['primaryAxisSizing'] {
const isPrimary =
(node.layoutMode === 'HORIZONTAL' && axis === 'width') ||
(node.layoutMode === 'VERTICAL' && axis === 'height')
return isPrimary ? node.primaryAxisSizing : node.counterAxisSizing
}
function canResizeIntrinsicAxis(node: SceneNode, axis: LayoutAxis): boolean {
return (
axisSizing(node, axis) === 'HUG' ||
(node.source.format === 'fig' && node.figmaDerivedLayout?.[axis] === undefined)
)
}
function terminalTextSource(graph: SceneGraph, node: SceneNode): SceneNode | undefined {
let current = node
for (let depth = 0; current.componentId && depth < MAX_COMPONENT_LINEAGE_DEPTH; depth++) {
const source = graph.getNode(current.componentId)
if (!source) break
current = source
}
return current.type === 'TEXT' ? current : undefined
}
function parentHugsWidth(graph: SceneGraph, node: SceneNode): boolean {
const parent = node.parentId ? graph.getNode(node.parentId) : undefined
return parent !== undefined && axisSizing(parent, 'width') === 'HUG'
}
function hasFixedWidthTextAncestor(graph: SceneGraph, node: SceneNode): boolean {
let current = node
for (let depth = 0; current.componentId && depth < MAX_COMPONENT_LINEAGE_DEPTH; depth++) {
const source = graph.getNode(current.componentId)
if (!source) break
if (source.type === 'TEXT' && source.textAutoResize === 'HEIGHT') return true
current = source
}
return false
}
function canShapeGeneratedText(graph: SceneGraph, node: SceneNode): boolean {
if (
node.type !== 'TEXT' ||
node.source.format === 'fig' ||
!node.componentId ||
!node.figmaDerivedLayout ||
node.figmaDerivedLayout.width !== node.width ||
node.figmaDerivedLayout.height !== node.height
) {
return false
}
const sourceText = terminalTextSource(graph, node)
if (sourceText?.source.format !== 'fig') return false
if (node.textAutoResize === 'WIDTH_AND_HEIGHT') {
return !hasFixedWidthTextAncestor(graph, node)
}
return (
node.textAutoResize === 'HEIGHT' &&
node.layoutAlignSelf === 'STRETCH' &&
parentHugsWidth(graph, node) &&
sourceText.text === node.text
)
}
function stretchesCrossAxis(child: SceneNode, parent: SceneNode): boolean {
return (
child.layoutAlignSelf === 'STRETCH' ||
(child.layoutAlignSelf === 'AUTO' && parent.counterAxisAlign === 'STRETCH')
)
}
function participatesInIntrinsicSize(node: SceneNode): boolean {
return node.visible && node.layoutPositioning !== 'ABSOLUTE'
}
function intrinsicSize(
graph: SceneGraph,
node: SceneNode,
sizes: ReadonlyMap<string, Size>
): Size | null {
if (node.layoutMode !== 'HORIZONTAL' && node.layoutMode !== 'VERTICAL') return null
const children = graph.getChildren(node.id).filter(participatesInIntrinsicSize)
if (children.length === 0) return null
const childSizes = children.map((child) => sizes.get(child.id) ?? child)
const gap =
node.primaryAxisAlign === 'SPACE_BETWEEN'
? 0
: node.itemSpacing * Math.max(0, children.length - 1)
if (node.layoutMode === 'HORIZONTAL') {
return {
width:
node.paddingLeft +
node.paddingRight +
childSizes.reduce((sum, child) => sum + child.width, gap),
height:
node.paddingTop + node.paddingBottom + Math.max(...childSizes.map((child) => child.height))
}
}
return {
width:
node.paddingLeft + node.paddingRight + Math.max(...childSizes.map((child) => child.width)),
height:
node.paddingTop +
node.paddingBottom +
childSizes.reduce((sum, child) => sum + child.height, gap)
}
}
function intrinsicSizeWithEffectiveStretch(
graph: SceneGraph,
node: SceneNode,
sizes: ReadonlyMap<string, Size>,
affected: ReadonlySet<string>
): Size | null {
const intrinsic = intrinsicSize(graph, node, sizes)
if (!intrinsic || node.layoutMode !== 'VERTICAL' || axisSizing(node, 'width') !== 'HUG') {
return intrinsic
}
const children = graph.getChildren(node.id).filter(participatesInIntrinsicSize)
if (!children.some((child) => affected.has(child.id))) return intrinsic
const widthCandidates = children.filter(
(child) => affected.has(child.id) || !stretchesCrossAxis(child, node)
)
if (widthCandidates.length === 0) return intrinsic
return {
...intrinsic,
width:
node.paddingLeft +
node.paddingRight +
Math.max(...widthCandidates.map((child) => (sizes.get(child.id) ?? child).width))
}
}
function stretchChildrenToEffectiveWidth(
graph: SceneGraph,
node: SceneNode,
oldIntrinsicWidth: number,
nextWidth: number,
currentSizes: Map<string, Size>,
affected: Set<string>
): void {
const oldContentWidth = oldIntrinsicWidth - node.paddingLeft - node.paddingRight
const nextContentWidth = nextWidth - node.paddingLeft - node.paddingRight
for (const child of graph.getChildren(node.id)) {
if (
!participatesInIntrinsicSize(child) ||
!stretchesCrossAxis(child, node) ||
Math.abs(child.width - oldContentWidth) >= 0.001
) {
continue
}
const updates: Partial<SceneNode> = { width: nextContentWidth }
if (child.figmaDerivedLayout) {
updates.figmaDerivedLayout = { ...child.figmaDerivedLayout, width: nextContentWidth }
}
graph.updateNode(child.id, updates)
currentSizes.set(child.id, { width: nextContentWidth, height: child.height })
affected.add(child.id)
}
}
function collectPostorder(graph: SceneGraph, rootId: string): SceneNode[] {
const result: SceneNode[] = []
const visit = (nodeId: string): void => {
const node = graph.getNode(nodeId)
if (!node) return
for (const childId of node.childIds) visit(childId)
result.push(node)
}
visit(rootId)
return result
}
function updateGeneratedTextWidths(
graph: SceneGraph,
nodes: SceneNode[],
currentSizes: Map<string, Size>,
affected: Set<string>
): void {
const measure = getTextMeasurer()
if (!measure) return
for (const node of nodes) {
if (!canShapeGeneratedText(graph, node)) continue
const measured = measure(node)
if (!measured || measured.width <= 0) continue
const widthChange = node.width - measured.width
if (
widthChange < MIN_EFFECTIVE_TEXT_WIDTH_CHANGE ||
(node.textAutoResize === 'HEIGHT' && widthChange > MAX_STRETCHED_TEXT_WIDTH_CHANGE)
) {
continue
}
graph.updateNode(node.id, {
width: measured.width,
figmaDerivedLayout: { ...node.figmaDerivedLayout, width: measured.width }
})
currentSizes.set(node.id, { width: measured.width, height: node.height })
affected.add(node.id)
}
}
function propagateIntrinsicSizes(
graph: SceneGraph,
nodes: SceneNode[],
originalSizes: ReadonlyMap<string, Size>,
currentSizes: Map<string, Size>,
affected: Set<string>
): void {
for (const node of nodes) {
if (node.type === 'TEXT') continue
const children = graph.getChildren(node.id)
if (!children.some((child) => affected.has(child.id))) continue
const oldIntrinsic = intrinsicSize(graph, node, originalSizes)
const nextIntrinsic = intrinsicSizeWithEffectiveStretch(graph, node, currentSizes, affected)
const oldSize = originalSizes.get(node.id)
if (!oldIntrinsic || !nextIntrinsic || !oldSize) continue
const updates: Partial<SceneNode> = {}
let nextWidth = oldSize.width
let nextHeight = oldSize.height
if (
canResizeIntrinsicAxis(node, 'width') &&
Math.abs(oldSize.width - oldIntrinsic.width) < 0.001
) {
nextWidth = nextIntrinsic.width
updates.width = nextWidth
}
if (
canResizeIntrinsicAxis(node, 'height') &&
Math.abs(oldSize.height - oldIntrinsic.height) < 0.001
) {
nextHeight = nextIntrinsic.height
updates.height = nextHeight
}
if (Object.keys(updates).length === 0) continue
if (node.figmaDerivedLayout) {
updates.figmaDerivedLayout = {
...node.figmaDerivedLayout,
...(updates.width === undefined ? {} : { width: nextWidth }),
...(updates.height === undefined ? {} : { height: nextHeight })
}
}
if (updates.width !== undefined) {
stretchChildrenToEffectiveWidth(
graph,
node,
oldIntrinsic.width,
nextWidth,
currentSizes,
affected
)
}
graph.updateNode(node.id, updates)
currentSizes.set(node.id, { width: nextWidth, height: nextHeight })
affected.add(node.id)
}
}
export function applyEffectiveGeneratedTextLayout(graph: SceneGraph, rootId: string): boolean {
const nodes = collectPostorder(graph, rootId)
const originalSizes = new Map(
nodes.map((node) => [node.id, { width: node.width, height: node.height }])
)
const currentSizes = new Map(originalSizes)
const affected = new Set<string>()
updateGeneratedTextWidths(graph, nodes, currentSizes, affected)
if (affected.size === 0) return false
propagateIntrinsicSizes(graph, nodes, originalSizes, currentSizes, affected)
return true
}

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

@ -28,7 +28,7 @@ import { TEXT_PICTURE_KEYS } from './text-picture'
import * as Variables from './variables'
import { normalizeVectorNetwork } from './vector-network'
export type { GUID, Color, Vector } from './primitives'
export type { GUID, Color, Vector, Size } from './primitives'
export * from './types'
import type { Emitter } from 'nanoevents'

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

@ -7,6 +7,7 @@
"text": "Text",
"new": "Neu",
"open": "Öffnen…",
"openStorageWorkspace": "Speicher-Arbeitsbereich öffnen…",
"save": "Speichern",
"saveAs": "Speichern unter…",
"exportSelection": "Auswahl exportieren…",

View file

@ -7,6 +7,7 @@
"text": "Texto",
"new": "Nuevo",
"open": "Abrir…",
"openStorageWorkspace": "Abrir espacio de almacenamiento…",
"save": "Guardar",
"saveAs": "Guardar como…",
"exportSelection": "Exportar selección…",

View file

@ -7,6 +7,7 @@
"text": "Texte",
"new": "Nouveau",
"open": "Ouvrir…",
"openStorageWorkspace": "Ouvrir lespace de stockage…",
"save": "Enregistrer",
"saveAs": "Enregistrer sous…",
"exportSelection": "Exporter la sélection…",

View file

@ -7,6 +7,7 @@
"text": "Testo",
"new": "Nuovo",
"open": "Apri…",
"openStorageWorkspace": "Apri area di archiviazione…",
"save": "Salva",
"saveAs": "Salva con nome…",
"exportSelection": "Esporta selezione…",

View file

@ -7,6 +7,7 @@
"text": "テキスト",
"new": "新規作成",
"open": "開く…",
"openStorageWorkspace": "ストレージワークスペースを開く…",
"save": "保存",
"saveAs": "名前を付けて保存…",
"exportSelection": "選択範囲をエクスポート…",

View file

@ -7,6 +7,7 @@
"text": "Tekst",
"new": "Nowy",
"open": "Otwórz…",
"openStorageWorkspace": "Otwórz obszar przechowywania…",
"save": "Zapisz",
"saveAs": "Zapisz jako…",
"exportSelection": "Eksportuj zaznaczenie…",

View file

@ -7,6 +7,7 @@
"text": "Текст",
"new": "Создать",
"open": "Открыть…",
"openStorageWorkspace": "Открыть рабочее пространство хранилища…",
"save": "Сохранить",
"saveAs": "Сохранить как…",
"exportSelection": "Экспорт выделения…",

View file

@ -7,6 +7,7 @@
"text": "文本",
"new": "新建",
"open": "打开…",
"openStorageWorkspace": "打开存储工作区…",
"save": "保存",
"saveAs": "另存为…",
"exportSelection": "导出所选内容…",

View file

@ -10,6 +10,7 @@ export const menuMessageDefaults = {
new: 'New',
open: 'Open…',
openStorageWorkspace: 'Open storage workspace…',
save: 'Save',
saveAs: 'Save as…',
exportSelection: 'Export selection…',

View file

@ -1,4 +1,5 @@
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import type { MenuEntry } from '@open-pencil/vue'
import { useEditorCommands, useI18n } from '@open-pencil/vue'
@ -6,6 +7,7 @@ import { useEditorCommands, useI18n } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store'
import { openSettingsDialog } from '@/app/settings/dialog'
import { createSharedEditorMenuActions } from '@/app/shell/menu/editor-actions'
import { openStorageWorkspace } from '@/app/shell/menu/navigation'
import type { AppMenuActionItem, AppMenuEntry, AppMenuGroupSchema } from '@/app/shell/menu/schema'
import { APP_MENU_SCHEMA } from '@/app/shell/menu/schema'
import { createSelectionMenuActions } from '@/app/shell/menu/selection-actions'
@ -29,6 +31,7 @@ function isSeparator(entry: AppMenuEntry): entry is Extract<AppMenuEntry, { type
export function useAppMenu() {
const store = useEditorStore()
const router = useRouter()
const {
commands,
menuItem: commandMenuItem,
@ -41,6 +44,7 @@ export function useAppMenu() {
const translatedMenuItemLabels: Partial<Record<string, keyof typeof menu.value>> = {
new: 'new',
open: 'open',
'open-storage-workspace': 'openStorageWorkspace',
save: 'save',
'save-as': 'saveAs',
'export-selection': 'exportSelection',
@ -94,6 +98,7 @@ export function useAppMenu() {
void import('@/app/tabs').then((m) => m.createTab())
},
open: () => void openFileDialog(),
'open-storage-workspace': () => openStorageWorkspace(router),
save: () => void store.saveFigFile(),
'save-as': () => void store.saveFigFileAs(),
'export-selection': () => exportSelection('png'),

View file

@ -0,0 +1,5 @@
import type { Router } from 'vue-router'
export function openStorageWorkspace(router: Router): void {
void router.push('/storage')
}

View file

@ -33,6 +33,7 @@ export const APP_MENU_SCHEMA = [
items: [
{ id: 'new', label: 'New', shortcut: 'MOD+N' },
{ id: 'open', label: 'Open…', shortcut: 'MOD+O' },
{ id: 'open-storage-workspace', label: 'Open Storage Workspace…' },
{ type: 'separator' },
{ id: 'save', label: 'Save', shortcut: 'MOD+S' },
{ id: 'save-as', label: 'Save As…', shortcut: 'MOD+SHIFT+S' },

View file

@ -7,6 +7,7 @@ import { useEditorStore } from '@/app/editor/active-store'
import { openSettingsDialog } from '@/app/settings/dialog'
import { createSharedEditorMenuActions } from '@/app/shell/menu/editor-actions'
import { importFileDialog, openFileDialog } from '@/app/shell/menu/files'
import { openStorageWorkspace } from '@/app/shell/menu/navigation'
import { APP_MENU_SCHEMA, type AppMenuEntry } from '@/app/shell/menu/schema'
import { createSelectionMenuActions } from '@/app/shell/menu/selection-actions'
import { useAppTheme } from '@/app/shell/theme'
@ -40,6 +41,9 @@ export function useMenu() {
const actions: Partial<Record<string, () => void>> = {
new: () => createTab(),
open: () => void openFileDialog(),
'open-storage-workspace': () => {
void import('@/router').then(({ default: router }) => openStorageWorkspace(router))
},
close: () => {
if (activeTab.value) closeTab(activeTab.value.id)
},

View file

@ -20,6 +20,7 @@ test('File menu opens and shows items', async () => {
const items = await menu.locator('[role="menuitem"]').allTextContents()
expect(items.some((t) => t.includes('Open'))).toBe(true)
expect(items.some((t) => t.includes('Open storage workspace'))).toBe(true)
expect(items.some((t) => t.includes('Save'))).toBe(true)
expect(items.some((t) => t.includes('Save as'))).toBe(true)
@ -155,3 +156,11 @@ test('Zoom to fit via View menu works', async () => {
const zoomAfter = await getStoreStateNumber('zoom')
expect(zoomAfter).not.toBe(zoomBefore)
})
test('Open storage workspace navigates from the File menu', async () => {
await editor.page.getByRole('menuitem', { name: 'File', exact: true }).click()
await editor.page.getByRole('menuitem', { name: 'Open storage workspace…' }).click()
await expect(editor.page).toHaveURL(/\/storage$/)
await expect(editor.page.getByRole('heading', { name: 'Storage workspace' })).toBeVisible()
})

View file

@ -43,6 +43,15 @@ describe('APP_MENU_SCHEMA', () => {
expect(commandIds).toContain('selection.moveToPage')
})
test('includes storage workspace navigation in the shared File menu', () => {
const fileMenu = APP_MENU_SCHEMA.find((group) => group.label === 'File')
const entries = fileMenu ? actionItems(fileMenu.items) : []
expect(entries).toContainEqual(
expect.objectContaining({ id: 'open-storage-workspace', label: 'Open Storage Workspace…' })
)
})
test('keeps move-to-page destination selection in the browser menu', () => {
const objectMenu = APP_MENU_SCHEMA.find((group) => group.label === 'Object')
const entries = objectMenu ? actionItems(objectMenu.items) : []

View file

@ -3,6 +3,65 @@ import { describe, expect, test } from 'bun:test'
import { createAPI } from '../helpers'
describe('absolute position', () => {
test('relative and absolute transforms include rotation and reflection', () => {
const api = createAPI()
const parent = api.createFrame()
parent.x = 100
parent.y = 200
const child = api.createRectangle()
parent.appendChild(child)
child.x = 10
child.y = 20
child.resize(50, 30)
child.rotation = 90
expect(child.relativeTransform).toEqual([
[0, -1, 50],
[1, 0, 10]
])
expect(child.absoluteTransform).toEqual([
[0, -1, 150],
[1, 0, 210]
])
})
test('exposes preserved FIG reflection transforms and Figma rotation', () => {
const api = createAPI()
const node = api.createVector()
const raw = api.graph.getNode(node.id)
expect(raw).toBeDefined()
if (!raw) return
api.graph.updateNode(node.id, {
x: 5,
y: 8,
width: 14,
height: 8,
rotation: -180,
flipX: true,
source: {
...raw.source,
format: 'fig',
id: '94:5463',
fig: {
...raw.source.fig,
rawTransform: { m00: 1, m01: 0, m02: 5, m10: 0, m11: -1, m12: 16 }
}
}
})
expect(node.x).toBe(5)
expect(node.y).toBe(8)
expect(node.rotation).toBe(-0)
expect(node.relativeTransform).toEqual([
[1, 0, 5],
[0, -1, 16]
])
expect(node.absoluteTransform).toEqual([
[1, 0, 5],
[0, -1, 16]
])
})
test('absoluteBoundingBox accounts for nesting', () => {
const api = createAPI()
const parent = api.createFrame()

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

@ -0,0 +1,284 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { computeAllLayouts, SceneGraph, setTextMeasurer } from '@open-pencil/core'
function importedText(graph: SceneGraph, text: string, width: number, height: number, id: string) {
const page = graph.getPages()[0]
const node = graph.createNode('TEXT', page.id, {
width,
height,
text,
textAutoResize: 'WIDTH_AND_HEIGHT'
})
graph.updateNode(node.id, { source: { ...node.source, format: 'fig', id } })
return node
}
afterEach(() => setTextMeasurer(null))
describe('effective generated FIG text layout', () => {
test('shapes generated text and recomputes its HUG width lineage', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const source = importedText(graph, 'Effective instance text', 342, 20, '1:1')
const checkbox = graph.createNode('INSTANCE', page.id, {
width: 366,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
componentId: 'component'
})
graph.updateNode(checkbox.id, {
source: { ...checkbox.source, format: 'fig', id: '1:5' }
})
graph.createNode('INSTANCE', checkbox.id, { width: 16, height: 16 })
const textWrapper = graph.createNode('FRAME', checkbox.id, {
width: 350,
height: 40,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
paddingLeft: 8,
componentId: 'text-wrapper',
figmaDerivedLayout: { width: 350, height: 40 }
})
const generatedText = graph.createNode('TEXT', textWrapper.id, {
width: 342,
height: 20,
text: source.text,
textAutoResize: 'WIDTH_AND_HEIGHT',
componentId: source.id,
figmaDerivedLayout: { width: 342, height: 20 }
})
setTextMeasurer(() => ({ width: 336, height: 20 }))
computeAllLayouts(graph)
expect(graph.getNode(generatedText.id)).toMatchObject({
width: 336,
height: 20,
figmaDerivedLayout: { width: 336, height: 20 }
})
expect(graph.getNode(textWrapper.id)).toMatchObject({
width: 344,
figmaDerivedLayout: { width: 344, height: 40 }
})
expect(graph.getNode(checkbox.id)).toMatchObject({ width: 360, height: 40 })
expect(graph.getNode(source.id)).toMatchObject({ width: 342, height: 20 })
})
test('repositions centered generated text after effective shaping', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const source = importedText(graph, 'Centered label', 100, 20, '1:7')
const parent = graph.createNode('FRAME', page.id, {
width: 200,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
primaryAxisAlign: 'CENTER'
})
const generatedText = graph.createNode('TEXT', parent.id, {
width: 100,
height: 20,
text: source.text,
textAutoResize: 'WIDTH_AND_HEIGHT',
componentId: source.id,
figmaDerivedLayout: { width: 100, height: 20 }
})
setTextMeasurer(() => ({ width: 80, height: 20 }))
computeAllLayouts(graph)
expect(graph.getNode(generatedText.id)).toMatchObject({ x: 60, width: 80 })
})
test('resizes visible inherited-stretch children but preserves excluded children', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const source = importedText(graph, 'Chart heading', 100, 20, '1:8')
const parent = graph.createNode('FRAME', page.id, {
width: 120,
height: 80,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
paddingLeft: 10,
paddingRight: 10,
counterAxisAlign: 'STRETCH',
componentId: 'parent',
figmaDerivedLayout: { width: 120, height: 80 }
})
const generatedText = graph.createNode('TEXT', parent.id, {
width: 100,
height: 20,
text: source.text,
textAutoResize: 'WIDTH_AND_HEIGHT',
componentId: source.id,
figmaDerivedLayout: { width: 100, height: 20 }
})
const inheritedStretch = graph.createNode('RECTANGLE', parent.id, {
width: 100,
height: 10,
layoutAlignSelf: 'AUTO',
figmaDerivedLayout: { width: 100, height: 10 }
})
const hiddenStretch = graph.createNode('RECTANGLE', parent.id, {
width: 100,
height: 10,
visible: false,
layoutAlignSelf: 'AUTO',
figmaDerivedLayout: { width: 100, height: 10 }
})
const absoluteStretch = graph.createNode('RECTANGLE', parent.id, {
width: 100,
height: 10,
layoutPositioning: 'ABSOLUTE',
layoutAlignSelf: 'AUTO',
figmaDerivedLayout: { width: 100, height: 10 }
})
setTextMeasurer(() => ({ width: 80, height: 20 }))
computeAllLayouts(graph)
expect(graph.getNode(parent.id)).toMatchObject({ width: 100 })
expect(graph.getNode(inheritedStretch.id)).toMatchObject({ width: 80 })
expect(graph.getNode(hiddenStretch.id)).toMatchObject({ width: 100 })
expect(graph.getNode(absoluteStretch.id)).toMatchObject({ width: 100 })
})
test('preserves fixed generated ancestors after shaping their text', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const source = importedText(graph, 'Choose file', 76, 20, '1:2')
const input = graph.createNode('FRAME', page.id, {
width: 108,
height: 40,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
paddingLeft: 16,
paddingRight: 16,
componentId: 'input',
figmaDerivedLayout: { width: 108, height: 40 }
})
const generatedText = graph.createNode('TEXT', input.id, {
width: 76,
height: 20,
text: source.text,
textAutoResize: 'WIDTH_AND_HEIGHT',
componentId: source.id,
figmaDerivedLayout: { width: 76, height: 20 }
})
setTextMeasurer(() => ({ width: 74, height: 20 }))
computeAllLayouts(graph)
expect(graph.getNode(generatedText.id)).toMatchObject({ width: 74, height: 20 })
expect(graph.getNode(input.id)).toMatchObject({
width: 108,
height: 40,
figmaDerivedLayout: { width: 108, height: 40 }
})
})
test('reshapes stretched generated source text only inside HUG-width parents', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const source = importedText(graph, 'Bar Chart - Interactive', 333, 30, '1:3')
graph.updateNode(source.id, { textAutoResize: 'HEIGHT' })
const cardHeader = graph.createNode('FRAME', page.id, {
width: 381,
height: 102,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
paddingLeft: 24,
paddingRight: 24,
componentId: 'card-header',
figmaDerivedLayout: { width: 381, height: 102 }
})
const generatedText = graph.createNode('TEXT', cardHeader.id, {
width: 333,
height: 30,
text: source.text,
textAutoResize: 'HEIGHT',
layoutAlignSelf: 'STRETCH',
componentId: source.id,
figmaDerivedLayout: { width: 333, height: 30 }
})
setTextMeasurer(() => ({ width: 329, height: 37 }))
computeAllLayouts(graph)
expect(graph.getNode(generatedText.id)).toMatchObject({ width: 329, height: 30 })
expect(graph.getNode(cardHeader.id)).toMatchObject({
width: 377,
height: 102,
figmaDerivedLayout: { width: 377, height: 102 }
})
})
test('preserves fixed-width text inherited through the component lineage', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const source = importedText(graph, 'Label', 38, 14, '1:6')
graph.updateNode(source.id, { textAutoResize: 'WIDTH_AND_HEIGHT' })
const fixedIntermediate = graph.createNode('TEXT', page.id, {
width: 280,
height: 14,
text: 'Email',
textAutoResize: 'HEIGHT',
componentId: source.id,
figmaDerivedLayout: { width: 280, height: 14 }
})
const generatedText = graph.createNode('TEXT', page.id, {
width: 302,
height: 14,
text: 'Name',
textAutoResize: 'WIDTH_AND_HEIGHT',
componentId: fixedIntermediate.id,
figmaDerivedLayout: { width: 302, height: 14 }
})
setTextMeasurer(() => ({ width: 36, height: 17 }))
computeAllLayouts(graph)
expect(graph.getNode(generatedText.id)).toMatchObject({ width: 302, height: 14 })
})
test('preserves stretched override text and direct imported text bounds', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const source = importedText(graph, 'Source copy', 333, 20, '1:4')
graph.updateNode(source.id, { textAutoResize: 'HEIGHT' })
const parent = graph.createNode('FRAME', page.id, {
width: 381,
height: 68,
layoutMode: 'VERTICAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'HUG',
paddingLeft: 24,
paddingRight: 24,
componentId: 'parent',
figmaDerivedLayout: { width: 381, height: 68 }
})
const overrideText = graph.createNode('TEXT', parent.id, {
width: 333,
height: 20,
text: 'A different instance override',
textAutoResize: 'HEIGHT',
layoutAlignSelf: 'STRETCH',
componentId: source.id,
figmaDerivedLayout: { width: 333, height: 20 }
})
setTextMeasurer(() => ({ width: 329, height: 20 }))
computeAllLayouts(graph)
expect(graph.getNode(overrideText.id)).toMatchObject({ width: 333, height: 20 })
expect(graph.getNode(source.id)).toMatchObject({ width: 333, height: 20 })
})
})

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)