diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e621457..32a612a28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,8 +49,8 @@ ### Fixed -- Match Figma auto-layout spacing, padding, min/max constraints, scalar variable bindings, imported text bounds, and nested instance geometry more closely. -- Match Figma Plugin API vector path and network editing, including bounds, winding rules, region fills, validation, and handle mirroring. (#444) +- 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/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/layout.ts b/packages/core/src/layout.ts index 23c522d4a..1aae2c2fa 100644 --- a/packages/core/src/layout.ts +++ b/packages/core/src/layout.ts @@ -13,6 +13,7 @@ import { 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 { @@ -40,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 @@ -63,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 { 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/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/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/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 }) + }) +})