fix(io): keep page descendants in fig exports

- Preserve the source document root when extracting export subgraphs
- Include page descendants when exporting a single page to .fig
- Cover page subgraph extraction so CLI .fig exports are not empty
This commit is contained in:
Danila Poyarkov 2026-05-20 18:32:01 +03:00
parent c1ddbf7039
commit 4fe2b75dc8
3 changed files with 161 additions and 11 deletions

View file

@ -10,14 +10,8 @@ export interface ExtractedGraph {
function cloneIntoGraph(source: SceneGraph, ids: Set<string>): SceneGraph {
const graph = new SceneGraph()
const root = graph.getNode(graph.rootId)
if (root) {
root.childIds = []
root.width = 0
root.height = 0
}
graph.rootId = source.rootId
graph.nodes = new Map()
if (root) graph.nodes.set(root.id, root)
graph.images = new Map(source.images)
graph.variables = new Map()
graph.variableCollections = new Map()
@ -116,6 +110,43 @@ function collectDescendants(source: SceneGraph, id: string, out: Set<string>) {
}
}
function collectAncestors(source: SceneGraph, id: string, out: Set<string>) {
let current = source.getNode(id)
while (current?.parentId) {
out.add(current.parentId)
current = source.getNode(current.parentId)
}
}
function resolveInstanceComponentId(source: SceneGraph, componentId: string): string {
const seen = new Set<string>()
let currentId = componentId
while (!seen.has(currentId)) {
seen.add(currentId)
const node = source.getNode(currentId)
if (node?.type !== 'INSTANCE' || !node.componentId) return currentId
currentId = node.componentId
}
return componentId
}
function collectComponentDependencies(source: SceneGraph, ids: Set<string>) {
let changed = true
while (changed) {
changed = false
for (const id of Array.from(ids)) {
const node = source.getNode(id)
if (node?.type !== 'INSTANCE' || !node.componentId) continue
const componentId = resolveInstanceComponentId(source, node.componentId)
if (ids.has(componentId)) continue
const before = ids.size
collectAncestors(source, componentId, ids)
collectDescendants(source, componentId, ids)
changed ||= ids.size !== before
}
}
}
export function findPageId(source: SceneGraph, nodeId: string): string | null {
let current = source.getNode(nodeId)
while (current?.parentId) {
@ -156,12 +187,14 @@ function collectSelectionIds(source: SceneGraph, nodeIds: string[]): Set<string>
ids.add(pageId)
}
collectComponentDependencies(source, ids)
return ids
}
function pageNodeIds(source: SceneGraph, pageId: string): Set<string> {
const ids = new Set<string>([source.rootId, pageId])
const ids = new Set<string>([source.rootId])
collectDescendants(source, pageId, ids)
collectComponentDependencies(source, ids)
return ids
}

View file

@ -99,6 +99,31 @@ function parseGuidOrNull(value: string) {
return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null
}
function resolveInstanceComponentId(context: SceneNodeToKiwiContext, componentId: string): string {
const seen = new Set<string>()
let currentId = componentId
while (!seen.has(currentId)) {
seen.add(currentId)
const node = context.graph.getNode(currentId)
if (node?.type !== 'INSTANCE' || !node.componentId) return currentId
currentId = node.componentId
}
return componentId
}
function getOrCreateNodeGuid(
context: SceneNodeToKiwiContext,
nodeId: string,
localIdCounter: { value: number }
): GUID | undefined {
if (!context.graph.getNode(nodeId)) return undefined
const existing = context.nodeIdToGuid?.get(nodeId)
if (existing) return existing
const guid = { sessionID: 1, localID: localIdCounter.value++ }
context.nodeIdToGuid?.set(nodeId, guid)
return guid
}
function applyComponentMetadata(node: SceneNode, nc: KiwiNodeChange): void {
if (node.componentKey) nc.componentKey = node.componentKey
if (node.sourceLibraryKey) nc.sourceLibraryKey = node.sourceLibraryKey
@ -209,9 +234,10 @@ export function sceneNodeToKiwiWithContext(
localIdCounter: { value: number },
context: SceneNodeToKiwiContext
): KiwiNodeChange[] {
const localID = localIdCounter.value++
const guid = { sessionID: 1, localID }
context.nodeIdToGuid?.set(node.id, guid)
const guid = getOrCreateNodeGuid(context, node.id, localIdCounter) ?? {
sessionID: 1,
localID: localIdCounter.value++
}
const strokePaints = createStrokePaints(context, node)
@ -231,6 +257,14 @@ export function sceneNodeToKiwiWithContext(
applyNodeVisualProps(context, node, nc)
applyComponentMetadata(node, nc)
if (node.type === 'INSTANCE' && node.componentId) {
const symbolID = getOrCreateNodeGuid(
context,
resolveInstanceComponentId(context, node.componentId),
localIdCounter
)
if (symbolID) nc.symbolData = { symbolID }
}
if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type)
if (strokePaints.length > 0) nc.strokePaints = strokePaints

View file

@ -0,0 +1,83 @@
import { describe, expect, test } from 'bun:test'
import { exportFigFile, extractExportGraph, parseFigFile } from '@open-pencil/core/io'
import { initCodec } from '@open-pencil/core/kiwi'
import { SceneGraph } from '@open-pencil/core/scene-graph'
describe('export subgraph extraction', () => {
test('page extraction keeps the source root and page descendants', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const frame = graph.createNode('FRAME', page.id, {
name: 'Frame',
x: 10,
y: 20,
width: 100,
height: 100
})
const text = graph.createNode('TEXT', frame.id, {
name: 'Label',
text: 'Hello',
width: 50,
height: 20
})
const extracted = extractExportGraph(graph, { scope: 'page', pageId: page.id })
const extractedPages = extracted.graph.getPages(true)
expect(extracted.graph.rootId).toBe(graph.rootId)
expect(extractedPages.map((node) => node.id)).toEqual([page.id])
expect(extracted.graph.getNode(page.id)?.childIds).toEqual([frame.id])
expect(extracted.graph.getNode(frame.id)?.childIds).toEqual([text.id])
expect(extracted.graph.getNode(text.id)?.text).toBe('Hello')
})
test('page extraction includes component dependencies for instances', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const libraryPage = graph.addPage('Library')
const component = graph.createNode('COMPONENT', libraryPage.id, {
name: 'Button',
width: 100,
height: 40
})
graph.createNode('TEXT', component.id, {
name: 'Label',
text: 'Button',
width: 80,
height: 20
})
const instance = graph.createInstance(component.id, page.id)
const extracted = extractExportGraph(graph, { scope: 'page', pageId: page.id })
expect(extracted.graph.getNode(instance.id)?.type).toBe('INSTANCE')
expect(extracted.graph.getNode(component.id)?.type).toBe('COMPONENT')
expect(extracted.graph.getNode(libraryPage.id)?.type).toBe('CANVAS')
})
test('fig page export preserves valid instance component references', async () => {
await initCodec()
const graph = new SceneGraph()
const page = graph.getPages()[0]
const libraryPage = graph.addPage('Library')
const component = graph.createNode('COMPONENT', libraryPage.id, {
name: 'Button',
width: 100,
height: 40
})
const instance = graph.createInstance(component.id, page.id)
if (!instance) throw new Error('Expected instance')
const extracted = extractExportGraph(graph, { scope: 'page', pageId: page.id })
const exported = await exportFigFile(extracted.graph)
const parsed = await parseFigFile(exported.buffer as ArrayBuffer)
const parsedInstance = [...parsed.getAllNodes()].find(
(node) => node.type === 'INSTANCE' && node.name === instance.name
)
expect(parsedInstance?.type).toBe('INSTANCE')
expect(parsedInstance?.componentId).toBeTruthy()
expect(parsed.getNode(parsedInstance?.componentId ?? '')?.type).toBe('COMPONENT')
})
})