diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cfd8758a..32a612a28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/desktop/generated/menu.json b/desktop/generated/menu.json index 11ae1e163..db0f54726 100644 --- a/desktop/generated/menu.json +++ b/desktop/generated/menu.json @@ -12,6 +12,10 @@ "label": "Open…", "accelerator": "CmdOrCtrl+O" }, + { + "id": "open-storage-workspace", + "label": "Open Storage Workspace…" + }, { "type": "separator" }, diff --git a/packages/core/src/figma-api/accessors/basic.ts b/packages/core/src/figma-api/accessors/basic.ts index c04286367..fc99848fa 100644 --- a/packages/core/src/figma-api/accessors/basic.ts +++ b/packages/core/src/figma-api/accessors/basic.ts @@ -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: { diff --git a/packages/core/src/figma-api/proxy.ts b/packages/core/src/figma-api/proxy.ts index 4a311c92b..1ca98ccef 100644 --- a/packages/core/src/figma-api/proxy.ts +++ b/packages/core/src/figma-api/proxy.ts @@ -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 diff --git a/packages/core/src/figma-api/types.ts b/packages/core/src/figma-api/types.ts new file mode 100644 index 000000000..981dc6ad9 --- /dev/null +++ b/packages/core/src/figma-api/types.ts @@ -0,0 +1 @@ +export type FigmaTransform = [[number, number, number], [number, number, number]] diff --git a/packages/core/src/kiwi/fig/import.ts b/packages/core/src/kiwi/fig/import.ts index af858e05b..6cd5dbbea 100644 --- a/packages/core/src/kiwi/fig/import.ts +++ b/packages/core/src/kiwi/fig/import.ts @@ -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) } } } diff --git a/packages/core/src/layout.ts b/packages/core/src/layout.ts index d653d726c..1aae2c2fa 100644 --- a/packages/core/src/layout.ts +++ b/packages/core/src/layout.ts @@ -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 @@ -62,8 +60,12 @@ function resolveComputedLayoutDirection( } export function computeAllLayouts(graph: SceneGraph, scopeId?: string): void { + const rootId = scopeId ?? graph.rootId const visited = new Set() - 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): 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, + 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((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) { diff --git a/packages/core/src/layout/apply.ts b/packages/core/src/layout/apply.ts index 0c3806b8f..218ffd536 100644 --- a/packages/core/src/layout/apply.ts +++ b/packages/core/src/layout/apply.ts @@ -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) diff --git a/packages/core/src/layout/derived.ts b/packages/core/src/layout/derived.ts new file mode 100644 index 000000000..3b9ec64a3 --- /dev/null +++ b/packages/core/src/layout/derived.ts @@ -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) + ) +} diff --git a/packages/core/src/layout/effective-generated-text.ts b/packages/core/src/layout/effective-generated-text.ts new file mode 100644 index 000000000..883ca4357 --- /dev/null +++ b/packages/core/src/layout/effective-generated-text.ts @@ -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 +): 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, + affected: ReadonlySet +): 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, + affected: Set +): 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 = { 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, + affected: Set +): 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, + currentSizes: Map, + affected: Set +): 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 = {} + 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() + + updateGeneratedTextWidths(graph, nodes, currentSizes, affected) + if (affected.size === 0) return false + propagateIntrinsicSizes(graph, nodes, originalSizes, currentSizes, affected) + return true +} diff --git a/packages/fig/src/instance-overrides/constraints.ts b/packages/fig/src/instance-overrides/constraints.ts index b6e90aeb1..7f1b87613 100644 --- a/packages/fig/src/instance-overrides/constraints.ts +++ b/packages/fig/src/instance-overrides/constraints.ts @@ -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 +): Partial { + const updates: Partial = {} + 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 +): Partial { + const updates: Partial = {} + 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 +): Partial { + 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): void { const { graph } = ctx const clonesOf = buildClonesMap(graph, ctx.activeNodeIds) diff --git a/packages/fig/src/instance-overrides/derived-symbol-data/propagate.ts b/packages/fig/src/instance-overrides/derived-symbol-data/propagate.ts index 48b5af8c5..7422c34ae 100644 --- a/packages/fig/src/instance-overrides/derived-symbol-data/propagate.ts +++ b/packages/fig/src/instance-overrides/derived-symbol-data/propagate.ts @@ -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 { + 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 ): Partial { const updates: Partial = {} - 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 = {} + 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, diff --git a/packages/fig/src/instance-overrides/index.ts b/packages/fig/src/instance-overrides/index.ts index 7f241d371..a1571163b 100644 --- a/packages/fig/src/instance-overrides/index.ts +++ b/packages/fig/src/instance-overrides/index.ts @@ -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): void { + for (const node of overrideCandidates(graph, activeNodeIds)) { + const updates: Partial = {} + 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) } diff --git a/packages/fig/src/instance-overrides/sync/clone-update.ts b/packages/fig/src/instance-overrides/sync/clone-update.ts new file mode 100644 index 000000000..ac39be9fd --- /dev/null +++ b/packages/fig/src/instance-overrides/sync/clone-update.ts @@ -0,0 +1,14 @@ +import { copyInstanceComponentProps, type SceneNode } from '@open-pencil/scene-graph' + +export function cloneInstanceUpdate( + source: SceneNode, + componentId: string | null, + extra: Partial = {} +): Partial { + return { + ...copyInstanceComponentProps(source), + componentId, + figmaDerivedLayout: source.figmaDerivedLayout ? { ...source.figmaDerivedLayout } : null, + ...extra + } +} diff --git a/packages/fig/src/instance-overrides/sync/clones.ts b/packages/fig/src/instance-overrides/sync/clones.ts index 57da545d5..c492fcf92 100644 --- a/packages/fig/src/instance-overrides/sync/clones.ts +++ b/packages/fig/src/instance-overrides/sync/clones.ts @@ -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') diff --git a/packages/fig/src/instance-overrides/sync/fields.ts b/packages/fig/src/instance-overrides/sync/fields.ts index 102fa931f..2fa2ce2a1 100644 --- a/packages/fig/src/instance-overrides/sync/fields.ts +++ b/packages/fig/src/instance-overrides/sync/fields.ts @@ -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 +): 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( diff --git a/packages/fig/src/instance-overrides/sync/sources.ts b/packages/fig/src/instance-overrides/sync/sources.ts index 84ab89139..cd2253cc8 100644 --- a/packages/fig/src/instance-overrides/sync/sources.ts +++ b/packages/fig/src/instance-overrides/sync/sources.ts @@ -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 +): Set { + return new Set([ + ...(cloneSources?.get(previousId) ?? []), + ...(cloneSources?.get(replacementId) ?? []), + ...(graph.instanceIndex.get(previousId) ?? []), + ...(graph.instanceIndex.get(replacementId) ?? []) + ]) +} + +function indexReplacementClone( + cloneSources: Map | 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) } } } diff --git a/packages/fig/src/node-change/convert.ts b/packages/fig/src/node-change/convert.ts index 76ec3c18d..347c29f72 100644 --- a/packages/fig/src/node-change/convert.ts +++ b/packages/fig/src/node-change/convert.ts @@ -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 = { - // 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 = 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 { 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, diff --git a/packages/fig/src/node-change/export-node.ts b/packages/fig/src/node-change/export-node.ts index 0c0e564b0..7399a6edf 100644 --- a/packages/fig/src/node-change/export-node.ts +++ b/packages/fig/src/node-change/export-node.ts @@ -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. diff --git a/packages/fig/src/node-change/index.ts b/packages/fig/src/node-change/index.ts index 279fd8919..a7792438f 100644 --- a/packages/fig/src/node-change/index.ts +++ b/packages/fig/src/node-change/index.ts @@ -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' diff --git a/packages/fig/src/node-change/plugin-data.ts b/packages/fig/src/node-change/plugin-data.ts index 74e39b664..237ce514c 100644 --- a/packages/fig/src/node-change/plugin-data.ts +++ b/packages/fig/src/node-change/plugin-data.ts @@ -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 { 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 diff --git a/packages/fig/src/node-change/serialize.ts b/packages/fig/src/node-change/serialize.ts index 7f7005e24..b5aae3380 100644 --- a/packages/fig/src/node-change/serialize.ts +++ b/packages/fig/src/node-change/serialize.ts @@ -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) diff --git a/packages/fig/src/node-change/variable-bindings.ts b/packages/fig/src/node-change/variable-bindings.ts new file mode 100644 index 000000000..c9d2a9315 --- /dev/null +++ b/packages/fig/src/node-change/variable-bindings.ts @@ -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 = { + 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 = 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 | undefined { + if (field === 'opacity') return { opacity: Math.max(0, Math.min(1, value / 100)) } + return NUMERIC_BINDING_FIELDS.has(field) ? { [field]: value } : undefined +} diff --git a/packages/fig/tests/instance-overrides.test.ts b/packages/fig/tests/instance-overrides.test.ts index cf542b302..c034ab972 100644 --- a/packages/fig/tests/instance-overrides.test.ts +++ b/packages/fig/tests/instance-overrides.test.ts @@ -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 diff --git a/packages/kiwi/src/fig/codec.ts b/packages/kiwi/src/fig/codec.ts index 9469f8c67..cae125439 100644 --- a/packages/kiwi/src/fig/codec.ts +++ b/packages/kiwi/src/fig/codec.ts @@ -329,6 +329,8 @@ export interface NodeChange { stackChildPrimaryGrow?: number stackChildAlignSelf?: string stackCounterSpacing?: number + minSize?: { value?: Vector } + maxSize?: { value?: Vector } // Frame clipsContent?: boolean frameMaskDisabled?: boolean diff --git a/packages/scene-graph/src/index.ts b/packages/scene-graph/src/index.ts index 694fd5d6f..9995e3723 100644 --- a/packages/scene-graph/src/index.ts +++ b/packages/scene-graph/src/index.ts @@ -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' diff --git a/packages/scene-graph/src/instances.ts b/packages/scene-graph/src/instances.ts index ad059a626..988e9db04 100644 --- a/packages/scene-graph/src/instances.ts +++ b/packages/scene-graph/src/instances.ts @@ -7,6 +7,10 @@ export type { NodeCloneMode } from './copy' const INSTANCE_SYNC_PROPS: (keyof SceneNode)[] = [ 'width', 'height', + 'minWidth', + 'maxWidth', + 'minHeight', + 'maxHeight', 'fills', 'strokes', 'effects', diff --git a/packages/vue/src/i18n/locales/de/menu.json b/packages/vue/src/i18n/locales/de/menu.json index b134eaa5a..407588820 100644 --- a/packages/vue/src/i18n/locales/de/menu.json +++ b/packages/vue/src/i18n/locales/de/menu.json @@ -7,6 +7,7 @@ "text": "Text", "new": "Neu", "open": "Öffnen…", + "openStorageWorkspace": "Speicher-Arbeitsbereich öffnen…", "save": "Speichern", "saveAs": "Speichern unter…", "exportSelection": "Auswahl exportieren…", diff --git a/packages/vue/src/i18n/locales/es/menu.json b/packages/vue/src/i18n/locales/es/menu.json index 87b905704..65cf1e7f3 100644 --- a/packages/vue/src/i18n/locales/es/menu.json +++ b/packages/vue/src/i18n/locales/es/menu.json @@ -7,6 +7,7 @@ "text": "Texto", "new": "Nuevo", "open": "Abrir…", + "openStorageWorkspace": "Abrir espacio de almacenamiento…", "save": "Guardar", "saveAs": "Guardar como…", "exportSelection": "Exportar selección…", diff --git a/packages/vue/src/i18n/locales/fr/menu.json b/packages/vue/src/i18n/locales/fr/menu.json index 11b1b52ed..6f40df1df 100644 --- a/packages/vue/src/i18n/locales/fr/menu.json +++ b/packages/vue/src/i18n/locales/fr/menu.json @@ -7,6 +7,7 @@ "text": "Texte", "new": "Nouveau", "open": "Ouvrir…", + "openStorageWorkspace": "Ouvrir l’espace de stockage…", "save": "Enregistrer", "saveAs": "Enregistrer sous…", "exportSelection": "Exporter la sélection…", diff --git a/packages/vue/src/i18n/locales/it/menu.json b/packages/vue/src/i18n/locales/it/menu.json index 353bb59e6..38b0798d2 100644 --- a/packages/vue/src/i18n/locales/it/menu.json +++ b/packages/vue/src/i18n/locales/it/menu.json @@ -7,6 +7,7 @@ "text": "Testo", "new": "Nuovo", "open": "Apri…", + "openStorageWorkspace": "Apri area di archiviazione…", "save": "Salva", "saveAs": "Salva con nome…", "exportSelection": "Esporta selezione…", diff --git a/packages/vue/src/i18n/locales/ja/menu.json b/packages/vue/src/i18n/locales/ja/menu.json index fcc382d11..b61a9cee6 100644 --- a/packages/vue/src/i18n/locales/ja/menu.json +++ b/packages/vue/src/i18n/locales/ja/menu.json @@ -7,6 +7,7 @@ "text": "テキスト", "new": "新規作成", "open": "開く…", + "openStorageWorkspace": "ストレージワークスペースを開く…", "save": "保存", "saveAs": "名前を付けて保存…", "exportSelection": "選択範囲をエクスポート…", diff --git a/packages/vue/src/i18n/locales/pl/menu.json b/packages/vue/src/i18n/locales/pl/menu.json index a18548565..1661c6ce7 100644 --- a/packages/vue/src/i18n/locales/pl/menu.json +++ b/packages/vue/src/i18n/locales/pl/menu.json @@ -7,6 +7,7 @@ "text": "Tekst", "new": "Nowy", "open": "Otwórz…", + "openStorageWorkspace": "Otwórz obszar przechowywania…", "save": "Zapisz", "saveAs": "Zapisz jako…", "exportSelection": "Eksportuj zaznaczenie…", diff --git a/packages/vue/src/i18n/locales/ru/menu.json b/packages/vue/src/i18n/locales/ru/menu.json index 2cd15ea19..bf6091bcb 100644 --- a/packages/vue/src/i18n/locales/ru/menu.json +++ b/packages/vue/src/i18n/locales/ru/menu.json @@ -7,6 +7,7 @@ "text": "Текст", "new": "Создать", "open": "Открыть…", + "openStorageWorkspace": "Открыть рабочее пространство хранилища…", "save": "Сохранить", "saveAs": "Сохранить как…", "exportSelection": "Экспорт выделения…", diff --git a/packages/vue/src/i18n/locales/zh-cn/menu.json b/packages/vue/src/i18n/locales/zh-cn/menu.json index 2afa9985b..34dbf687e 100644 --- a/packages/vue/src/i18n/locales/zh-cn/menu.json +++ b/packages/vue/src/i18n/locales/zh-cn/menu.json @@ -7,6 +7,7 @@ "text": "文本", "new": "新建", "open": "打开…", + "openStorageWorkspace": "打开存储工作区…", "save": "保存", "saveAs": "另存为…", "exportSelection": "导出所选内容…", diff --git a/packages/vue/src/i18n/messages/menu.ts b/packages/vue/src/i18n/messages/menu.ts index a63a5edf2..7c7b731fd 100644 --- a/packages/vue/src/i18n/messages/menu.ts +++ b/packages/vue/src/i18n/messages/menu.ts @@ -10,6 +10,7 @@ export const menuMessageDefaults = { new: 'New', open: 'Open…', + openStorageWorkspace: 'Open storage workspace…', save: 'Save', saveAs: 'Save as…', exportSelection: 'Export selection…', diff --git a/src/app/shell/menu/app-menu.ts b/src/app/shell/menu/app-menu.ts index e3b40cbbe..916faec1c 100644 --- a/src/app/shell/menu/app-menu.ts +++ b/src/app/shell/menu/app-menu.ts @@ -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> = { 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'), diff --git a/src/app/shell/menu/navigation.ts b/src/app/shell/menu/navigation.ts new file mode 100644 index 000000000..945ead55a --- /dev/null +++ b/src/app/shell/menu/navigation.ts @@ -0,0 +1,5 @@ +import type { Router } from 'vue-router' + +export function openStorageWorkspace(router: Router): void { + void router.push('/storage') +} diff --git a/src/app/shell/menu/schema.ts b/src/app/shell/menu/schema.ts index a1a8603a4..cfb3c57ee 100644 --- a/src/app/shell/menu/schema.ts +++ b/src/app/shell/menu/schema.ts @@ -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' }, diff --git a/src/app/shell/menu/use.ts b/src/app/shell/menu/use.ts index a51ffae7b..303182945 100644 --- a/src/app/shell/menu/use.ts +++ b/src/app/shell/menu/use.ts @@ -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 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) }, diff --git a/tests/e2e/app/menu.spec.ts b/tests/e2e/app/menu.spec.ts index 3bd6703bd..97dca435f 100644 --- a/tests/e2e/app/menu.spec.ts +++ b/tests/e2e/app/menu.spec.ts @@ -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() +}) diff --git a/tests/engine/app/shell/menu/schema.test.ts b/tests/engine/app/shell/menu/schema.test.ts index 71505c033..68108fe35 100644 --- a/tests/engine/app/shell/menu/schema.test.ts +++ b/tests/engine/app/shell/menu/schema.test.ts @@ -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) : [] diff --git a/tests/engine/figma/api/absolute/position.test.ts b/tests/engine/figma/api/absolute/position.test.ts index f93e0dc9f..e83dd2795 100644 --- a/tests/engine/figma/api/absolute/position.test.ts +++ b/tests/engine/figma/api/absolute/position.test.ts @@ -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() diff --git a/tests/engine/io/fig/import/component-props.test.ts b/tests/engine/io/fig/import/component-props.test.ts index 9c42486d8..21c5a8a83 100644 --- a/tests/engine/io/fig/import/component-props.test.ts +++ b/tests/engine/io/fig/import/component-props.test.ts @@ -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 }) }) diff --git a/tests/engine/io/fig/import/derived-symbol-data.test.ts b/tests/engine/io/fig/import/derived-symbol-data.test.ts index 3df2f7b29..14eacc567 100644 --- a/tests/engine/io/fig/import/derived-symbol-data.test.ts +++ b/tests/engine/io/fig/import/derived-symbol-data.test.ts @@ -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 }) diff --git a/tests/engine/io/fig/import/legacy/auto-layout/basic.test.ts b/tests/engine/io/fig/import/legacy/auto-layout/basic.test.ts index 92d27c603..11fabfb01 100644 --- a/tests/engine/io/fig/import/legacy/auto-layout/basic.test.ts +++ b/tests/engine/io/fig/import/legacy/auto-layout/basic.test.ts @@ -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(), diff --git a/tests/engine/io/fig/import/legacy/variables.test.ts b/tests/engine/io/fig/import/legacy/variables.test.ts index 509587af6..98d9210be 100644 --- a/tests/engine/io/fig/import/legacy/variables.test.ts +++ b/tests/engine/io/fig/import/legacy/variables.test.ts @@ -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(), diff --git a/tests/engine/io/fig/import/schema-coverage.test.ts b/tests/engine/io/fig/import/schema-coverage.test.ts index fbefc34b7..b243438d6 100644 --- a/tests/engine/io/fig/import/schema-coverage.test.ts +++ b/tests/engine/io/fig/import/schema-coverage.test.ts @@ -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, diff --git a/tests/engine/io/fig/roundtrip/basic.test.ts b/tests/engine/io/fig/roundtrip/basic.test.ts index b35730b1b..f99998259 100644 --- a/tests/engine/io/fig/roundtrip/basic.test.ts +++ b/tests/engine/io/fig/roundtrip/basic.test.ts @@ -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() diff --git a/tests/engine/io/fig/roundtrip/source-metadata.test.ts b/tests/engine/io/fig/roundtrip/source-metadata.test.ts index c5c9238ef..6f7f3709e 100644 --- a/tests/engine/io/fig/roundtrip/source-metadata.test.ts +++ b/tests/engine/io/fig/roundtrip/source-metadata.test.ts @@ -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] diff --git a/tests/engine/layout/auto-layout/alignment/primary-axis.test.ts b/tests/engine/layout/auto-layout/alignment/primary-axis.test.ts index de0c7e15a..0efb5b42a 100644 --- a/tests/engine/layout/auto-layout/alignment/primary-axis.test.ts +++ b/tests/engine/layout/auto-layout/alignment/primary-axis.test.ts @@ -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), { diff --git a/tests/engine/layout/auto-layout/imported-derived-layout.test.ts b/tests/engine/layout/auto-layout/imported-derived-layout.test.ts index 714c14f06..ecb2d93fc 100644 --- a/tests/engine/layout/auto-layout/imported-derived-layout.test.ts +++ b/tests/engine/layout/auto-layout/imported-derived-layout.test.ts @@ -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] diff --git a/tests/engine/layout/auto-layout/imported-derived-layout/effective-generated-text.test.ts b/tests/engine/layout/auto-layout/imported-derived-layout/effective-generated-text.test.ts new file mode 100644 index 000000000..3c44cf79b --- /dev/null +++ b/tests/engine/layout/auto-layout/imported-derived-layout/effective-generated-text.test.ts @@ -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 }) + }) +}) diff --git a/tests/engine/layout/auto-layout/text/measurement.test.ts b/tests/engine/layout/auto-layout/text/measurement.test.ts index 8b0d2deca..d4f48be42 100644 --- a/tests/engine/layout/auto-layout/text/measurement.test.ts +++ b/tests/engine/layout/auto-layout/text/measurement.test.ts @@ -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)