fix(fig): preserve effective clone geometry
- Restore authoritative image-leaf bounds after final component swaps\n- Preserve derived cross-axis positions for thin divider clones\n- Add synthetic and Gold Preview regression coverage
This commit is contained in:
parent
eb55cb1025
commit
f9701ef855
|
|
@ -50,6 +50,7 @@
|
|||
### Fixed
|
||||
|
||||
- Preserve Figma’s imported glyph outlines through layout and appearance updates so text keeps its intended weight and shape.
|
||||
- Keep swapped image avatars and thin stepper dividers at their effective imported size and position.
|
||||
- Scale proportion-constrained `.fig` instance geometry through fixed wrapper layers so imported logos and icons retain their intended size.
|
||||
- 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)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,88 @@ function buildCloneUpdates(
|
|||
return updates
|
||||
}
|
||||
|
||||
export function reconcileEffectiveCloneGeometry(
|
||||
ctx: OverrideContext,
|
||||
scaledInstanceIds: Set<string>
|
||||
): void {
|
||||
restoreScaledInstanceLeafBounds(ctx, scaledInstanceIds)
|
||||
restoreThinCloneCrossPositions(ctx)
|
||||
}
|
||||
|
||||
function restoreScaledInstanceLeafBounds(
|
||||
ctx: OverrideContext,
|
||||
scaledInstanceIds: Set<string>
|
||||
): void {
|
||||
for (const instanceId of scaledInstanceIds) {
|
||||
const instance = ctx.graph.getNode(instanceId)
|
||||
if (instance?.layoutMode !== 'NONE' || instance.childIds.length !== 1) continue
|
||||
const child = ctx.graph.getNode(instance.childIds[0])
|
||||
if (
|
||||
!child ||
|
||||
child.childIds.length > 0 ||
|
||||
child.horizontalConstraint !== 'SCALE' ||
|
||||
child.verticalConstraint !== 'SCALE'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const width = child.figmaDerivedLayout?.width
|
||||
const height = child.figmaDerivedLayout?.height
|
||||
const restoresDerivedBounds = width !== undefined && height !== undefined
|
||||
const restoresImageBounds =
|
||||
!restoresDerivedBounds &&
|
||||
child.type === 'ROUNDED_RECTANGLE' &&
|
||||
child.fills.some((fill) => fill.type === 'IMAGE')
|
||||
if (!restoresDerivedBounds && !restoresImageBounds) continue
|
||||
const restoredWidth = width ?? instance.width
|
||||
const restoredHeight = height ?? instance.height
|
||||
if (child.width === restoredWidth && child.height === restoredHeight) continue
|
||||
ctx.graph.updateNode(child.id, { width: restoredWidth, height: restoredHeight })
|
||||
}
|
||||
}
|
||||
|
||||
function isThinCenteredCrossChild(parent: SceneNode, clone: SceneNode): boolean {
|
||||
if (parent.counterAxisAlign !== 'CENTER') return false
|
||||
if (parent.layoutMode === 'HORIZONTAL') return clone.height <= 1 && clone.width > clone.height
|
||||
if (parent.layoutMode === 'VERTICAL') return clone.width <= 1 && clone.height > clone.width
|
||||
return false
|
||||
}
|
||||
|
||||
function thinCloneCrossPosition(
|
||||
graph: OverrideContext['graph'],
|
||||
clone: SceneNode
|
||||
): NonNullable<SceneNode['figmaDerivedLayout']> | null {
|
||||
if (
|
||||
clone.source.format !== null ||
|
||||
!clone.componentId ||
|
||||
!clone.name.endsWith('Divider') ||
|
||||
!clone.parentId ||
|
||||
clone.figmaDerivedLayout?.x !== undefined ||
|
||||
clone.figmaDerivedLayout?.y !== undefined
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const parent = graph.getNode(clone.parentId)
|
||||
const source = graph.getNode(clone.componentId)
|
||||
const sourceLayout = source?.figmaDerivedLayout
|
||||
if (!parent || !source || sourceLayout?.x === undefined || sourceLayout.y === undefined) return null
|
||||
if (clone.width !== source.width || clone.height !== source.height) return null
|
||||
return isThinCenteredCrossChild(parent, clone) ? sourceLayout : null
|
||||
}
|
||||
|
||||
function restoreThinCloneCrossPositions(ctx: OverrideContext): void {
|
||||
for (const clone of overrideCandidates(ctx.graph, ctx.activeNodeIds)) {
|
||||
const sourceLayout = thinCloneCrossPosition(ctx.graph, clone)
|
||||
if (!sourceLayout) continue
|
||||
ctx.graph.updateNode(clone.id, {
|
||||
figmaDerivedLayout: {
|
||||
...clone.figmaDerivedLayout,
|
||||
x: sourceLayout.x,
|
||||
y: sourceLayout.y
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function applyGeneratedFreeformStretch(ctx: OverrideContext): void {
|
||||
for (const node of overrideCandidates(ctx.graph, ctx.activeNodeIds)) {
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -29,7 +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 { applyGeneratedFreeformStretch, reconcileEffectiveCloneGeometry } from './derived-symbol-data/propagate'
|
||||
import { populateInstances } from './populate'
|
||||
import { preComputeRoots } from './resolve'
|
||||
import { applySymbolOverrides } from './symbol/overrides'
|
||||
|
|
@ -361,6 +361,14 @@ export function populateAndApplyOverrides(
|
|||
propagateResolvedFills(graph, new Set([...ctx.kiwiPropertyNodes, ...overriddenNodes]))
|
||||
propagateResolvedTextClones(graph, ctx.activeNodeIds)
|
||||
applyConstraintScaling(ctx)
|
||||
const scaledInstances = new Set<string>()
|
||||
for (const node of overrideCandidates(graph, ctx.activeNodeIds)) {
|
||||
if (node.type !== 'INSTANCE' || !node.componentId) continue
|
||||
const component = graph.getNode(node.componentId)
|
||||
if (component && (node.width !== component.width || node.height !== component.height)) {
|
||||
scaledInstances.add(node.id)
|
||||
}
|
||||
}
|
||||
applyComponentProperties(ctx)
|
||||
|
||||
// Final component-property swaps can replace descendants targeted by earlier
|
||||
|
|
@ -374,6 +382,9 @@ export function populateAndApplyOverrides(
|
|||
ctx.protectedFields,
|
||||
ctx.preComputedClones
|
||||
)
|
||||
// Final swaps recreate descendants from component defaults. Reconcile only
|
||||
// geometry that already had an authoritative effective size or cross-axis position.
|
||||
reconcileEffectiveCloneGeometry(ctx, scaledInstances)
|
||||
applyResolvedNumericBindings(graph, ctx.activeNodeIds)
|
||||
applyGeneratedFreeformStretch(ctx)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -260,6 +260,89 @@ describe('@open-pencil/fig instance interpretation', () => {
|
|||
expect(globalScans).toBe(2)
|
||||
})
|
||||
|
||||
test('restores effective image and thin-clone geometry after final swaps', () => {
|
||||
const graph = new SceneGraph()
|
||||
const pageId = graph.getPages()[0].id
|
||||
const avatarComponent = graph.createNode('COMPONENT', pageId, { width: 100, height: 100 })
|
||||
graph.createNode('ROUNDED_RECTANGLE', avatarComponent.id, {
|
||||
width: 100,
|
||||
height: 100,
|
||||
horizontalConstraint: 'SCALE',
|
||||
verticalConstraint: 'SCALE',
|
||||
fills: [{ type: 'IMAGE', imageHash: 'avatar', opacity: 1, visible: true, blendMode: 'NORMAL' }]
|
||||
})
|
||||
const avatar = graph.createNode('INSTANCE', pageId, {
|
||||
componentId: avatarComponent.id,
|
||||
width: 14,
|
||||
height: 14
|
||||
})
|
||||
|
||||
const dividerComponent = graph.createNode('COMPONENT', pageId, {
|
||||
width: 112,
|
||||
height: 28,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
counterAxisAlign: 'CENTER'
|
||||
})
|
||||
const dividerSource = graph.createNode('RECTANGLE', dividerComponent.id, {
|
||||
x: 0,
|
||||
y: 13.5,
|
||||
width: 112,
|
||||
height: 1,
|
||||
figmaDerivedLayout: { x: 0, y: 13.5, width: 112, height: 1 }
|
||||
})
|
||||
const dividerInstance = graph.createNode('INSTANCE', pageId, {
|
||||
componentId: dividerComponent.id,
|
||||
width: 112,
|
||||
height: 28,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
counterAxisAlign: 'CENTER'
|
||||
})
|
||||
|
||||
populateAndApplyOverrides(graph, new Map(), new Map())
|
||||
|
||||
const avatarLeaf = graph.getChildren(avatar.id)[0]
|
||||
expect(avatarLeaf).toMatchObject({ width: 14, height: 14 })
|
||||
const divider = graph.getChildren(dividerInstance.id)[0]
|
||||
expect(divider.componentId).toBe(dividerSource.id)
|
||||
expect(divider.figmaDerivedLayout).toMatchObject({ x: 0, y: 13.5 })
|
||||
})
|
||||
|
||||
test('leaves unrelated scaled vector and multi-child instance geometry unchanged', () => {
|
||||
const graph = new SceneGraph()
|
||||
const pageId = graph.getPages()[0].id
|
||||
const vectorComponent = graph.createNode('COMPONENT', pageId, { width: 100, height: 100 })
|
||||
graph.createNode('VECTOR', vectorComponent.id, {
|
||||
width: 20,
|
||||
height: 10,
|
||||
horizontalConstraint: 'SCALE',
|
||||
verticalConstraint: 'SCALE'
|
||||
})
|
||||
const vectorInstance = graph.createNode('INSTANCE', pageId, {
|
||||
componentId: vectorComponent.id,
|
||||
width: 50,
|
||||
height: 50
|
||||
})
|
||||
const multiComponent = graph.createNode('COMPONENT', pageId, { width: 100, height: 100 })
|
||||
graph.createNode('ROUNDED_RECTANGLE', multiComponent.id, {
|
||||
width: 100,
|
||||
height: 100,
|
||||
horizontalConstraint: 'SCALE',
|
||||
verticalConstraint: 'SCALE',
|
||||
fills: [{ type: 'IMAGE', imageHash: 'avatar', opacity: 1, visible: true, blendMode: 'NORMAL' }]
|
||||
})
|
||||
graph.createNode('RECTANGLE', multiComponent.id, { width: 10, height: 10 })
|
||||
const multiInstance = graph.createNode('INSTANCE', pageId, {
|
||||
componentId: multiComponent.id,
|
||||
width: 50,
|
||||
height: 50
|
||||
})
|
||||
|
||||
populateAndApplyOverrides(graph, new Map(), new Map())
|
||||
|
||||
expect(graph.getChildren(vectorInstance.id)[0]).toMatchObject({ width: 10, height: 5 })
|
||||
expect(graph.getChildren(multiInstance.id)[0]).toMatchObject({ width: 50, height: 50 })
|
||||
})
|
||||
|
||||
test('resolves text clone chains to their source values', () => {
|
||||
const graph = new SceneGraph()
|
||||
const pageId = graph.getPages()[0].id
|
||||
|
|
|
|||
|
|
@ -86,9 +86,27 @@ describe('derived instance layout regressions', () => {
|
|||
expect(closeIcon?.visible).toBe(true)
|
||||
expect(closeGlyph?.visible).toBe(true)
|
||||
expect(avatarShape?.fills.some((fill) => fill.type === 'IMAGE' && fill.visible)).toBe(true)
|
||||
expect(avatarShape?.width).toBeCloseTo(avatar?.width ?? 0, 3)
|
||||
expect(avatarShape?.height).toBeCloseTo(avatar?.height ?? 0, 3)
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps generated stepper dividers at their effective derived positions', () => {
|
||||
const dividers = layoutNodes.filter(
|
||||
(node) =>
|
||||
node.name === 'Right Divider' &&
|
||||
node.width > 100 &&
|
||||
node.componentId &&
|
||||
node.source.format === null
|
||||
)
|
||||
expect(dividers).toHaveLength(3)
|
||||
const generatedDividers = dividers.filter(
|
||||
(node) => layoutGraph.getNode(node.componentId)?.figmaDerivedLayout?.y === 13.5
|
||||
)
|
||||
expect(generatedDividers).toHaveLength(3)
|
||||
for (const divider of generatedDividers) expect(divider.y).toBeCloseTo(13.5, 3)
|
||||
})
|
||||
|
||||
test('does not collapse unrelated datepicker instances to the page origin', () => {
|
||||
const datepicker = previewChild(layoutGraph, layoutNodes, '_datepicker')
|
||||
expect(datepicker?.x).toBeCloseTo(765.2428, 3)
|
||||
|
|
|
|||
Loading…
Reference in a new issue