diff --git a/packages/core/src/figma-api/accessors/basic.ts b/packages/core/src/figma-api/accessors/basic.ts index de29a24a7..fc99848fa 100644 --- a/packages/core/src/figma-api/accessors/basic.ts +++ b/packages/core/src/figma-api/accessors/basic.ts @@ -9,8 +9,7 @@ import { type ProxyThis } from '#core/figma-api/accessor-utils' import type { NodeProxyHost } from '#core/figma-api/proxy' - -type FigmaTransform = [[number, number, number], [number, number, number]] +import type { FigmaTransform } from '#core/figma-api/types' const TRANSFORM_FIELDS = new Set(['x', 'y', 'rotation', 'flipX', 'flipY']) @@ -107,10 +106,14 @@ export function installBasicNodeProxyAccessors( const node = raw(this, internals) const sourceTransform = node.source.fig.rawTransform if (sourceTransform && preservesRawTransform(node)) { - return [ - [sourceTransform.m00, sourceTransform.m01, sourceTransform.m02], - [sourceTransform.m10, sourceTransform.m11, sourceTransform.m12] - ] + return figmaTransform([ + sourceTransform.m00, + sourceTransform.m01, + sourceTransform.m02, + sourceTransform.m10, + sourceTransform.m11, + sourceTransform.m12 + ]) } return figmaTransform(getNodeLocalMatrix(node)) } diff --git a/packages/core/src/figma-api/proxy.ts b/packages/core/src/figma-api/proxy.ts index 1b81726ec..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,10 +63,10 @@ export class FigmaNodeProxy { declare readonly width: number declare readonly height: number declare rotation: number - declare readonly relativeTransform: [[number, number, number], [number, number, 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 45c4a1718..1aae2c2fa 100644 --- a/packages/core/src/layout.ts +++ b/packages/core/src/layout.ts @@ -63,7 +63,9 @@ export function computeAllLayouts(graph: SceneGraph, scopeId?: string): void { const rootId = scopeId ?? graph.rootId const visited = new Set() computeLayoutsBottomUp(graph, rootId, visited) - applyEffectiveGeneratedTextLayout(graph, rootId) + 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 index e280a2945..883ca4357 100644 --- a/packages/core/src/layout/effective-generated-text.ts +++ b/packages/core/src/layout/effective-generated-text.ts @@ -73,15 +73,24 @@ function canShapeGeneratedText(graph: SceneGraph, node: SceneNode): boolean { ) } +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((child) => child.visible && child.layoutPositioning !== 'ABSOLUTE') + const children = graph.getChildren(node.id).filter(participatesInIntrinsicSize) if (children.length === 0) return null const childSizes = children.map((child) => sizes.get(child.id) ?? child) @@ -119,12 +128,10 @@ function intrinsicSizeWithEffectiveStretch( if (!intrinsic || node.layoutMode !== 'VERTICAL' || axisSizing(node, 'width') !== 'HUG') { return intrinsic } - const children = graph - .getChildren(node.id) - .filter((child) => child.visible && child.layoutPositioning !== 'ABSOLUTE') + 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) || child.layoutAlignSelf !== 'STRETCH' + (child) => affected.has(child.id) || !stretchesCrossAxis(child, node) ) if (widthCandidates.length === 0) return intrinsic return { @@ -147,7 +154,11 @@ function stretchChildrenToEffectiveWidth( const oldContentWidth = oldIntrinsicWidth - node.paddingLeft - node.paddingRight const nextContentWidth = nextWidth - node.paddingLeft - node.paddingRight for (const child of graph.getChildren(node.id)) { - if (child.layoutAlignSelf !== 'STRETCH' || Math.abs(child.width - oldContentWidth) >= 0.001) { + if ( + !participatesInIntrinsicSize(child) || + !stretchesCrossAxis(child, node) || + Math.abs(child.width - oldContentWidth) >= 0.001 + ) { continue } const updates: Partial = { width: nextContentWidth } @@ -261,7 +272,7 @@ function propagateIntrinsicSizes( } } -export function applyEffectiveGeneratedTextLayout(graph: SceneGraph, rootId: string): void { +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 }]) @@ -270,6 +281,7 @@ export function applyEffectiveGeneratedTextLayout(graph: SceneGraph, rootId: str const affected = new Set() updateGeneratedTextWidths(graph, nodes, currentSizes, affected) - if (affected.size === 0) return + if (affected.size === 0) return false propagateIntrinsicSizes(graph, nodes, originalSizes, currentSizes, affected) + return true } 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 index d79330008..3c44cf79b 100644 --- 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 @@ -68,6 +68,87 @@ describe('effective generated FIG text layout', () => { 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]