From d7ba0bfef96d0e00ce73f441af5590923d290af2 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 2 Mar 2026 17:22:09 +0300 Subject: [PATCH] Skip non-visual node types in clipboard import (variables, widgets, etc.) Figma clipboard data includes VARIABLE_SET, VARIABLE, and other non-visual types that were falling through to the default RECTANGLE case in mapNodeType. These now join DOCUMENT and CANVAS in the skipTypes set. --- packages/core/src/clipboard.ts | 21 +++++- tests/engine/clipboard.test.ts | 129 +++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 tests/engine/clipboard.test.ts diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index 2b8025239..490e0baec 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -121,7 +121,26 @@ export function importClipboardNodes( } } - const skipTypes = new Set(['DOCUMENT', 'CANVAS']) + const skipTypes = new Set([ + 'DOCUMENT', + 'CANVAS', + 'VARIABLE_SET', + 'VARIABLE', + 'VARIABLE_COLLECTION', + 'STYLE', + 'STYLE_SET', + 'INTERNAL_ONLY_NODE', + 'WIDGET', + 'STAMP', + 'STICKY', + 'SHAPE_WITH_TEXT', + 'CONNECTOR', + 'CODE_BLOCK', + 'TABLE_NODE', + 'TABLE_CELL', + 'SECTION_OVERLAY', + 'SLIDE', + ]) const topLevel: string[] = [] for (const [id, nc] of guidMap) { if (skipTypes.has(nc.type ?? '')) continue diff --git a/tests/engine/clipboard.test.ts b/tests/engine/clipboard.test.ts new file mode 100644 index 000000000..1beca93a7 --- /dev/null +++ b/tests/engine/clipboard.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'bun:test' + +import { + parseFigmaClipboard, + importClipboardNodes, +} from '../../packages/core/src/clipboard' +import { SceneGraph } from '../../packages/core/src/scene-graph' + +function makeClipboardHtml(nodeChanges: unknown[], meta = { fileKey: 'test', pasteID: 1, dataType: 'scene' }) { + // Minimal fig-kiwi clipboard: just meta + empty figma buffer + // For real parsing we'd need actual Kiwi binary — these tests use importClipboardNodes directly + const metaB64 = btoa(JSON.stringify(meta)) + return `` +} + +function createGraphWithPage(): { graph: SceneGraph; pageId: string } { + const graph = new SceneGraph() + graph.addPage('Test') + return { graph, pageId: graph.rootId } +} + +describe('importClipboardNodes', () => { + it('skips VARIABLE_SET and VARIABLE nodes', () => { + const { graph, pageId } = createGraphWithPage() + + const nodeChanges = [ + { guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Document' }, + { guid: { sessionID: 0, localID: 1 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' }, type: 'CANVAS', name: 'Page 1' }, + { guid: { sessionID: 0, localID: 2 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, type: 'VARIABLE_SET', name: 'Primitives' }, + { guid: { sessionID: 0, localID: 3 }, parentIndex: { guid: { sessionID: 0, localID: 2 }, position: '!' }, type: 'VARIABLE', name: 'Colors/Brand/500' }, + { guid: { sessionID: 0, localID: 10 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '"' }, type: 'FRAME', name: 'Card', size: { x: 300, y: 200 }, transform: { m00: 1, m01: 0, m02: 50, m10: 0, m11: 1, m12: 50 } }, + { guid: { sessionID: 0, localID: 11 }, parentIndex: { guid: { sessionID: 0, localID: 10 }, position: '!' }, type: 'TEXT', name: 'Title', size: { x: 200, y: 30 }, transform: { m00: 1, m01: 0, m02: 10, m10: 0, m11: 1, m12: 10 }, textData: { characters: 'Hello' }, fontSize: 16 }, + ] as any[] + + const created = importClipboardNodes(nodeChanges, graph, pageId) + expect(created).toHaveLength(1) + + const card = graph.getNode(created[0])! + expect(card.type).toBe('FRAME') + expect(card.name).toBe('Card') + + const children = graph.getChildren(card.id) + expect(children).toHaveLength(1) + expect(children[0].type).toBe('TEXT') + expect(children[0].name).toBe('Title') + + const allNodes = [...graph.getAllNodes()] + const variableNodes = allNodes.filter(n => n.name.includes('Primitives') || n.name.includes('Colors/')) + expect(variableNodes).toHaveLength(0) + }) + + it('skips non-visual Figma types', () => { + const { graph, pageId } = createGraphWithPage() + + const nonVisualTypes = ['WIDGET', 'STAMP', 'STICKY', 'CONNECTOR', 'CODE_BLOCK', 'SHAPE_WITH_TEXT', 'TABLE_NODE', 'TABLE_CELL'] + 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' }, + ...nonVisualTypes.map((type, i) => ({ + guid: { sessionID: 0, localID: 100 + i }, + parentIndex: { guid: { sessionID: 0, localID: 1 }, position: String.fromCharCode(33 + i) }, + type, + name: `${type}_node`, + size: { x: 100, y: 100 }, + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + })), + { guid: { sessionID: 0, localID: 200 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: 'z' }, type: 'RECTANGLE', name: 'RealShape', size: { x: 50, y: 50 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } }, + ] as any[] + + const created = importClipboardNodes(nodeChanges, graph, pageId) + expect(created).toHaveLength(1) + expect(graph.getNode(created[0])!.name).toBe('RealShape') + }) + + it('imports nested frames with children', () => { + 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' }, + { guid: { sessionID: 0, localID: 10 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, type: 'FRAME', name: 'Outer', size: { x: 400, y: 300 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } }, + { guid: { sessionID: 0, localID: 11 }, parentIndex: { guid: { sessionID: 0, localID: 10 }, position: '!' }, type: 'FRAME', name: 'Inner', size: { x: 200, y: 100 }, transform: { m00: 1, m01: 0, m02: 20, m10: 0, m11: 1, m12: 20 } }, + { guid: { sessionID: 0, localID: 12 }, parentIndex: { guid: { sessionID: 0, localID: 11 }, position: '!' }, type: 'TEXT', name: 'Label', size: { x: 100, y: 20 }, transform: { m00: 1, m01: 0, m02: 5, m10: 0, m11: 1, m12: 5 }, textData: { characters: 'Test' }, fontSize: 14 }, + ] as any[] + + const created = importClipboardNodes(nodeChanges, graph, pageId) + expect(created).toHaveLength(1) + + const outer = graph.getNode(created[0])! + expect(outer.name).toBe('Outer') + + const innerList = graph.getChildren(outer.id) + expect(innerList).toHaveLength(1) + expect(innerList[0].name).toBe('Inner') + + const labels = graph.getChildren(innerList[0].id) + expect(labels).toHaveLength(1) + expect(labels[0].name).toBe('Label') + expect(labels[0].text).toBe('Test') + }) + + it('preserves fills and strokes', () => { + 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' }, + { + guid: { sessionID: 0, localID: 10 }, + parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, + type: 'RECTANGLE', + name: 'Colored', + size: { x: 100, y: 100 }, + transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, + fillPaints: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }], + strokePaints: [{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, opacity: 1, visible: true }], + strokeWeight: 2, + }, + ] as any[] + + const created = importClipboardNodes(nodeChanges, graph, pageId) + const node = graph.getNode(created[0])! + expect(node.fills).toHaveLength(1) + expect(node.fills[0].color.r).toBe(1) + expect(node.strokes).toHaveLength(1) + expect(node.strokes[0].color.b).toBe(1) + expect(node.strokes[0].weight).toBe(2) + }) +})