From 3eb3a921a0dd43e9aa5bfe4e3e8c175f64265300 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 3 Mar 2026 23:20:55 +0300 Subject: [PATCH] Detect component sets and skip internal canvas on paste FRAME nodes with VARIANT componentPropDefs are now promoted to COMPONENT_SET on import (both .fig files and clipboard paste). Clipboard paste now respects the Internal Only Canvas: components on it are used to populate instance children but are not pasted as visible nodes, matching Figma's behavior. --- packages/core/src/clipboard.ts | 33 ++++++++++++++++++++-- packages/core/src/kiwi/kiwi-convert.ts | 9 +++++- tests/engine/clipboard.test.ts | 34 ++++++++++++++++++++++ tests/engine/fig-import.test.ts | 39 ++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 3 deletions(-) diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index a53b2336a..62ac23096 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -148,7 +148,24 @@ export function importClipboardNodes( } } + const internalCanvasIds = new Set() + for (const [id, nc] of guidMap) { + if (nc.type === 'CANVAS' && (nc as unknown as Record).internalOnly) { + internalCanvasIds.add(id) + } + } + + const internalFigmaIds = new Set() + function markInternal(id: string) { + internalFigmaIds.add(id) + for (const [childId, pid] of parentMap) { + if (pid === id && !internalFigmaIds.has(childId)) markInternal(childId) + } + } + for (const canvasId of internalCanvasIds) markInternal(canvasId) + const topLevel: string[] = [] + const internalTopLevel: string[] = [] for (const [id, nc] of guidMap) { if (NON_VISUAL_TYPES.has(nc.type ?? '')) continue const parentId = parentMap.get(id) @@ -157,7 +174,11 @@ export function importClipboardNodes( !guidMap.has(parentId) || NON_VISUAL_TYPES.has(guidMap.get(parentId)?.type ?? '') ) { - topLevel.push(id) + if (parentId && internalCanvasIds.has(parentId)) { + internalTopLevel.push(id) + } else { + topLevel.push(id) + } } } @@ -180,7 +201,7 @@ export function importClipboardNodes( const node = graph.createNode(nodeType, ourParentId, props) created.set(figmaId, node.id) - if (ourParentId === targetParentId) createdIds.push(node.id) + if (ourParentId === targetParentId && !internalFigmaIds.has(figmaId)) createdIds.push(node.id) const children: string[] = [] for (const [childId, pid] of parentMap) { @@ -198,6 +219,9 @@ export function importClipboardNodes( } } + for (const id of internalTopLevel) { + createNode(id, targetParentId) + } for (const id of topLevel) { createNode(id, targetParentId) } @@ -216,6 +240,11 @@ export function importClipboardNodes( graph.populateInstanceChildren(ourId, ourComponentId) } + for (const figmaId of internalTopLevel) { + const ourId = created.get(figmaId) + if (ourId) graph.deleteNode(ourId) + } + return createdIds } diff --git a/packages/core/src/kiwi/kiwi-convert.ts b/packages/core/src/kiwi/kiwi-convert.ts index a8024b2a9..85d2c775c 100644 --- a/packages/core/src/kiwi/kiwi-convert.ts +++ b/packages/core/src/kiwi/kiwi-convert.ts @@ -396,7 +396,8 @@ export function nodeChangeToProps( nc: NodeChange, blobs: Uint8Array[] ): Partial & { nodeType: NodeType | 'DOCUMENT' | 'VARIABLE' } { - const nodeType = mapNodeType(nc.type) + let nodeType = mapNodeType(nc.type) + if (nodeType === 'FRAME' && isComponentSet(nc)) nodeType = 'COMPONENT_SET' const x = nc.transform?.m02 ?? 0 const y = nc.transform?.m12 ?? 0 @@ -508,6 +509,12 @@ export function nodeChangeToProps( } } +function isComponentSet(nc: NodeChange): boolean { + const defs = ext(nc).componentPropDefs as Array<{ type?: string }> | undefined + if (!defs?.length) return false + return defs.some((d) => d.type === 'VARIANT') +} + function extractSymbolId(nc: NodeChange): string { const sd = (nc as unknown as Record).symbolData as | { symbolID?: GUID } diff --git a/tests/engine/clipboard.test.ts b/tests/engine/clipboard.test.ts index e0c209792..0d13bfac4 100644 --- a/tests/engine/clipboard.test.ts +++ b/tests/engine/clipboard.test.ts @@ -287,6 +287,40 @@ describe('importClipboardNodes', () => { expect(instanceChildren[0].type).toBe('VECTOR') }) + it('internal canvas components populate instances but are not pasted', () => { + const { graph, pageId } = createGraphWithPage() + + const nodeChanges = [ + { guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Doc' }, + { guid: { sessionID: 0, localID: 1 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' }, type: 'CANVAS', name: 'Page 1' }, + // Internal Only Canvas with component + { guid: { sessionID: 99, localID: 2 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '"' }, type: 'CANVAS', name: 'Internal Only Canvas', internalOnly: true }, + { guid: { sessionID: 1, localID: 10 }, parentIndex: { guid: { sessionID: 99, localID: 2 }, position: '!' }, type: 'SYMBOL', name: 'Icon', size: { x: 24, y: 24 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } }, + { guid: { sessionID: 1, localID: 11 }, parentIndex: { guid: { sessionID: 1, localID: 10 }, position: '!' }, type: 'VECTOR', name: 'Path', size: { x: 24, y: 24 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } }, + // Visible page with instance + { guid: { sessionID: 2, localID: 20 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, type: 'INSTANCE', name: 'Icon', size: { x: 24, y: 24 }, transform: { m00: 1, m01: 0, m02: 50, m10: 0, m11: 1, m12: 50 }, symbolData: { symbolID: { sessionID: 1, localID: 10 } } }, + ] as any[] + + const created = importClipboardNodes(nodeChanges, graph, pageId) + expect(created).toHaveLength(1) + + const instance = graph.getNode(created[0])! + expect(instance.type).toBe('INSTANCE') + expect(instance.name).toBe('Icon') + + const children = graph.getChildren(instance.id) + expect(children).toHaveLength(1) + expect(children[0].name).toBe('Path') + expect(children[0].type).toBe('VECTOR') + + // Component should NOT exist as a visible node + for (const node of graph.getAllNodes()) { + if (node.type === 'COMPONENT' && node.name === 'Icon') { + throw new Error('Internal component should not be pasted as visible node') + } + } + }) + it('imports textAutoResize from clipboard data', () => { const { graph, pageId } = createGraphWithPage() diff --git a/tests/engine/fig-import.test.ts b/tests/engine/fig-import.test.ts index 92a01aade..8f8660fa1 100644 --- a/tests/engine/fig-import.test.ts +++ b/tests/engine/fig-import.test.ts @@ -354,3 +354,42 @@ describe('fig-import: multiple fills', () => { expect(n.fills[1].opacity).toBe(0.5) }) }) + +describe('fig-import: component set detection', () => { + test('FRAME with VARIANT componentPropDefs becomes COMPONENT_SET', () => { + const changes: NodeChange[] = [ + doc(), + canvas(), + { + ...node('FRAME', 10, 1), + name: 'Button', + componentPropDefs: [ + { id: { sessionID: 0, localID: 1 }, name: 'State', type: 'VARIANT' }, + ], + } as unknown as NodeChange, + { ...node('SYMBOL', 11, 1), parentIndex: { guid: { sessionID: 1, localID: 10 }, position: '!' }, name: 'State=Default' } as NodeChange, + { ...node('SYMBOL', 12, 1), parentIndex: { guid: { sessionID: 1, localID: 10 }, position: '"' }, name: 'State=Hover' } as NodeChange, + ] + const graph = importNodeChanges(changes, []) + const page = graph.getPages()[0] + const set = graph.getChildren(page.id)[0] + expect(set.type).toBe('COMPONENT_SET') + expect(set.name).toBe('Button') + const children = graph.getChildren(set.id) + expect(children).toHaveLength(2) + expect(children[0].type).toBe('COMPONENT') + expect(children[1].type).toBe('COMPONENT') + }) + + test('FRAME without componentPropDefs stays FRAME', () => { + const changes: NodeChange[] = [ + doc(), + canvas(), + node('FRAME', 10, 1, { name: 'Regular Frame' }), + ] + const graph = importNodeChanges(changes, []) + const page = graph.getPages()[0] + const frame = graph.getChildren(page.id)[0] + expect(frame.type).toBe('FRAME') + }) +})