fix(fig): export stretched auto-layout frames
- Translate parent-level cross-axis stretch to Figma child alignment - Preserve explicit and absolute child behavior during export - Cover empty frames, encoded fields, and round-trip semantics
This commit is contained in:
parent
e10ca1af1b
commit
617482e323
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- Save auto-layout frames that stretch their children to `.fig` without failing. (#427)
|
||||
- Reduce large `.fig` page-switch work to the active page and coalesce Layers tree rebuilds. (#420)
|
||||
- Center text glyphs within explicit line-height leading in CanvasKit paragraph rendering.
|
||||
|
||||
|
|
|
|||
|
|
@ -250,6 +250,7 @@ function materializeFigmaBlob(
|
|||
}
|
||||
|
||||
function normalizeFigmaPayloadValue(key: string, value: unknown): unknown {
|
||||
if (key === 'stackCounterAlignItems' && value === 'STRETCH') return 'MIN'
|
||||
if (
|
||||
(key === 'stackJustify' ||
|
||||
key === 'stackPrimaryAlignItems' ||
|
||||
|
|
|
|||
|
|
@ -305,6 +305,28 @@ function normalizeStackCounterAlign(value: string | undefined): string | undefin
|
|||
return value === 'SPACE_EVENLY' ? 'SPACE_BETWEEN' : value
|
||||
}
|
||||
|
||||
function normalizeStackCounterAlignItems(value: string | undefined): string | undefined {
|
||||
const normalized = normalizeStackCounterAlign(value)
|
||||
// Figma models cross-axis stretch on each child, not on counterAxisAlignItems.
|
||||
return normalized === 'STRETCH' ? 'MIN' : normalized
|
||||
}
|
||||
|
||||
function serializeInheritedCounterAxisStretch(
|
||||
node: SceneNode,
|
||||
nc: KiwiNodeChange,
|
||||
graph: SceneGraph
|
||||
): void {
|
||||
if (!node.parentId || node.layoutAlignSelf !== 'AUTO' || node.layoutPositioning === 'ABSOLUTE')
|
||||
return
|
||||
const parent = graph.getNode(node.parentId)
|
||||
if (
|
||||
parent?.counterAxisAlign === 'STRETCH' &&
|
||||
(parent.layoutMode === 'HORIZONTAL' || parent.layoutMode === 'VERTICAL')
|
||||
) {
|
||||
nc.stackChildAlignSelf = 'STRETCH'
|
||||
}
|
||||
}
|
||||
|
||||
function preserveTrailingPadding(
|
||||
explicitValue: number | undefined,
|
||||
leadingValue: number | undefined,
|
||||
|
|
@ -316,7 +338,7 @@ function preserveTrailingPadding(
|
|||
return normalizedValue !== inheritedValue ? normalizedValue : undefined
|
||||
}
|
||||
|
||||
function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void {
|
||||
function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange, graph: SceneGraph): void {
|
||||
if (!node.source.id) upsertPluginData(node, LAYOUT_DIRECTION_PLUGIN_KEY, node.layoutDirection)
|
||||
const figLayout = node.source.fig.layout
|
||||
if (figLayout) {
|
||||
|
|
@ -337,7 +359,7 @@ function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void {
|
|||
)
|
||||
nc.stackCounterAlign = normalizeStackCounterAlign(figLayout.stackCounterAlign)
|
||||
nc.stackJustify = normalizeStackJustify(figLayout.stackJustify)
|
||||
nc.stackCounterAlignItems = normalizeStackCounterAlign(figLayout.stackCounterAlignItems)
|
||||
nc.stackCounterAlignItems = normalizeStackCounterAlignItems(figLayout.stackCounterAlignItems)
|
||||
nc.stackPrimaryAlignItems = normalizeStackJustify(figLayout.stackPrimaryAlignItems)
|
||||
// For imported nodes, figLayout captures the original kiwi NC values.
|
||||
// Preserve omitted sizing fields instead of materializing schema defaults.
|
||||
|
|
@ -354,6 +376,7 @@ function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void {
|
|||
nc.stackCounterSpacing = figLayout.stackCounterSpacing
|
||||
nc.bordersTakeSpace = figLayout.bordersTakeSpace
|
||||
if (figLayout.stackReverseZIndex) nc.stackReverseZIndex = true
|
||||
serializeInheritedCounterAxisStretch(node, nc, graph)
|
||||
return
|
||||
}
|
||||
if (node.layoutMode !== 'NONE' && node.layoutMode !== 'GRID') {
|
||||
|
|
@ -366,7 +389,7 @@ function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void {
|
|||
nc.stackPrimarySizing = node.primaryAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED'
|
||||
nc.stackCounterSizing = node.counterAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED'
|
||||
nc.stackPrimaryAlignItems = normalizeStackJustify(node.primaryAxisAlign)
|
||||
nc.stackCounterAlignItems = normalizeStackCounterAlign(node.counterAxisAlign)
|
||||
nc.stackCounterAlignItems = normalizeStackCounterAlignItems(node.counterAxisAlign)
|
||||
if (node.layoutWrap === 'WRAP') nc.stackWrap = 'WRAP'
|
||||
if (node.counterAxisSpacing > 0) nc.stackCounterSpacing = node.counterAxisSpacing
|
||||
nc.bordersTakeSpace = node.strokesIncludedInLayout
|
||||
|
|
@ -376,6 +399,8 @@ function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void {
|
|||
if (node.layoutGrow > 0) nc.stackChildPrimaryGrow = node.layoutGrow
|
||||
if (node.layoutAlignSelf !== 'AUTO') {
|
||||
nc.stackChildAlignSelf = node.layoutAlignSelf
|
||||
} else {
|
||||
serializeInheritedCounterAxisStretch(node, nc, graph)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -519,7 +544,7 @@ export function sceneNodeToKiwi(
|
|||
serializeCornerRadii,
|
||||
serializeTextProps: (textNode, nc, textGraph, digests, textBlobs, glyphs) =>
|
||||
serializeTextProps(textNode, nc, textGraph, digests, textBlobs, glyphs, runtime),
|
||||
serializeLayoutProps,
|
||||
serializeLayoutProps: (layoutNode, nc) => serializeLayoutProps(layoutNode, nc, graph),
|
||||
serializeGeometry,
|
||||
serializeVariableBindings,
|
||||
sceneNodeToKiwi: sceneNodeToKiwiWithContext
|
||||
|
|
|
|||
86
tests/engine/io/fig/export/auto-layout.test.ts
Normal file
86
tests/engine/io/fig/export/auto-layout.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { beforeAll, describe, expect, test } from 'bun:test'
|
||||
|
||||
import { exportFigFile, initCodec, parseFigFile, SceneGraph } from '@open-pencil/core'
|
||||
import { parseFigBuffer } from '@open-pencil/fig'
|
||||
|
||||
function decodeExport(bytes: Uint8Array) {
|
||||
return parseFigBuffer(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength))
|
||||
}
|
||||
|
||||
describe('.fig auto-layout export', () => {
|
||||
beforeAll(async () => {
|
||||
await initCodec()
|
||||
})
|
||||
|
||||
test('translates inherited counter-axis stretch to Figma child alignment', async () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const frame = graph.createNode('FRAME', page.id, {
|
||||
name: 'Stretch frame',
|
||||
width: 200,
|
||||
height: 200,
|
||||
layoutMode: 'VERTICAL',
|
||||
counterAxisAlign: 'STRETCH'
|
||||
})
|
||||
graph.createNode('RECTANGLE', frame.id, {
|
||||
name: 'Inherited stretch',
|
||||
width: 50,
|
||||
height: 50
|
||||
})
|
||||
graph.createNode('RECTANGLE', frame.id, {
|
||||
name: 'Explicit center',
|
||||
width: 50,
|
||||
height: 50,
|
||||
layoutAlignSelf: 'CENTER'
|
||||
})
|
||||
graph.createNode('RECTANGLE', frame.id, {
|
||||
name: 'Absolute child',
|
||||
width: 50,
|
||||
height: 50,
|
||||
layoutPositioning: 'ABSOLUTE'
|
||||
})
|
||||
|
||||
const exported = await exportFigFile(graph)
|
||||
const nodeChanges = decodeExport(exported).nodeChanges
|
||||
|
||||
expect(nodeChanges.find((node) => node.name === frame.name)?.stackCounterAlignItems).toBe('MIN')
|
||||
expect(nodeChanges.find((node) => node.name === 'Inherited stretch')?.stackChildAlignSelf).toBe(
|
||||
'STRETCH'
|
||||
)
|
||||
expect(nodeChanges.find((node) => node.name === 'Explicit center')?.stackChildAlignSelf).toBe(
|
||||
'CENTER'
|
||||
)
|
||||
expect(
|
||||
nodeChanges.find((node) => node.name === 'Absolute child')?.stackChildAlignSelf
|
||||
).toBeUndefined()
|
||||
|
||||
const reimported = await parseFigFile(
|
||||
exported.buffer.slice(exported.byteOffset, exported.byteOffset + exported.byteLength)
|
||||
)
|
||||
const importedNodes = reimported.getAllNodes()
|
||||
expect(importedNodes.find((node) => node.name === 'Stretch frame')?.counterAxisAlign).toBe(
|
||||
'MIN'
|
||||
)
|
||||
expect(importedNodes.find((node) => node.name === 'Inherited stretch')?.layoutAlignSelf).toBe(
|
||||
'STRETCH'
|
||||
)
|
||||
})
|
||||
|
||||
test('exports an empty frame with counter-axis stretch', async () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
graph.createNode('FRAME', page.id, {
|
||||
name: 'Empty stretch frame',
|
||||
width: 200,
|
||||
height: 200,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
counterAxisAlign: 'STRETCH'
|
||||
})
|
||||
|
||||
const exported = await exportFigFile(graph)
|
||||
const frame = decodeExport(exported).nodeChanges.find(
|
||||
(node) => node.name === 'Empty stretch frame'
|
||||
)
|
||||
expect(frame?.stackCounterAlignItems).toBe('MIN')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue