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:
parent
c1ddbf7039
commit
4fe2b75dc8
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
83
tests/engine/io/subgraph.test.ts
Normal file
83
tests/engine/io/subgraph.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue