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:
parent
eeefd069e6
commit
ea7ca7ec7a
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
1
packages/core/src/figma-api/types.ts
Normal file
1
packages/core/src/figma-api/types.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type FigmaTransform = [[number, number, number], [number, number, number]]
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue