From 4fe2b75dc81a3b15ea7c3cc2a9ed6992adeade57 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 20 May 2026 18:32:01 +0300 Subject: [PATCH] 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 --- packages/core/src/io/subgraph.ts | 49 +++++++++-- .../core/src/kiwi/node-change/export-node.ts | 40 ++++++++- tests/engine/io/subgraph.test.ts | 83 +++++++++++++++++++ 3 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 tests/engine/io/subgraph.test.ts diff --git a/packages/core/src/io/subgraph.ts b/packages/core/src/io/subgraph.ts index 3f4e299e9..8bceb431e 100644 --- a/packages/core/src/io/subgraph.ts +++ b/packages/core/src/io/subgraph.ts @@ -10,14 +10,8 @@ export interface ExtractedGraph { function cloneIntoGraph(source: SceneGraph, ids: Set): 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) { } } +function collectAncestors(source: SceneGraph, id: string, out: Set) { + 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() + 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) { + 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 ids.add(pageId) } + collectComponentDependencies(source, ids) return ids } function pageNodeIds(source: SceneGraph, pageId: string): Set { - const ids = new Set([source.rootId, pageId]) + const ids = new Set([source.rootId]) collectDescendants(source, pageId, ids) + collectComponentDependencies(source, ids) return ids } diff --git a/packages/core/src/kiwi/node-change/export-node.ts b/packages/core/src/kiwi/node-change/export-node.ts index 26d2dfce5..a00e7b992 100644 --- a/packages/core/src/kiwi/node-change/export-node.ts +++ b/packages/core/src/kiwi/node-change/export-node.ts @@ -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() + 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 diff --git a/tests/engine/io/subgraph.test.ts b/tests/engine/io/subgraph.test.ts new file mode 100644 index 000000000..af635f73f --- /dev/null +++ b/tests/engine/io/subgraph.test.ts @@ -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') + }) +})