fix(layout): refresh effective text flow

- Recompute Yoga positions after shaped text changes effective geometry

- Resolve inherited stretch and skip hidden or absolute children

- Share and normalize Plugin API transform matrices
This commit is contained in:
Danila Poyarkov 2026-08-05 16:07:11 +03:00
parent eeefd069e6
commit ea7ca7ec7a
6 changed files with 119 additions and 19 deletions

View file

@ -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))
}

View file

@ -32,6 +32,7 @@ import { nodeProxyToJSON } from './serialization'
import { setFirstStrokeAlign, setFirstStrokeWeight, setIndependentStrokeWeight } from './strokes'
import * as TextProxy from './text'
import * as Traversal from './traversal'
import type { FigmaTransform } from './types'
const MIXED = Symbol('mixed')
@ -62,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

View file

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

View file

@ -63,7 +63,9 @@ export function computeAllLayouts(graph: SceneGraph, scopeId?: string): void {
const rootId = scopeId ?? graph.rootId
const visited = new Set<string>()
computeLayoutsBottomUp(graph, rootId, visited)
applyEffectiveGeneratedTextLayout(graph, rootId)
if (applyEffectiveGeneratedTextLayout(graph, rootId)) {
computeLayoutsBottomUp(graph, rootId, new Set())
}
}
function computeLayoutsBottomUp(graph: SceneGraph, nodeId: string, visited: Set<string>): void {

View file

@ -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<string, Size>
): 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<SceneNode> = { 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<string>()
updateGeneratedTextWidths(graph, nodes, currentSizes, affected)
if (affected.size === 0) return
if (affected.size === 0) return false
propagateIntrinsicSizes(graph, nodes, originalSizes, currentSizes, affected)
return true
}

View file

@ -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]