diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index 490e0baec..6d587eb98 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -102,6 +102,50 @@ function decodeVectorData(nc: KiwiNodeChange, blobs: Uint8Array[]): VectorNetwor } } +const NON_VISUAL_TYPES = 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', +]) + +export function figmaNodesBounds( + nodeChanges: KiwiNodeChange[] +): { x: number; y: number; w: number; h: number } | null { + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + + const parentTypes = new Map() + for (const nc of nodeChanges) { + if (!nc.guid) continue + const id = `${nc.guid.sessionID}:${nc.guid.localID}` + parentTypes.set(id, nc.type ?? '') + } + + for (const nc of nodeChanges) { + if (!nc.guid || !nc.type || NON_VISUAL_TYPES.has(nc.type)) continue + const parentId = nc.parentIndex?.guid + ? `${nc.parentIndex.guid.sessionID}:${nc.parentIndex.guid.localID}` + : null + if (parentId && parentTypes.has(parentId) && !NON_VISUAL_TYPES.has(parentTypes.get(parentId)!)) + continue + + const x = nc.transform?.m02 ?? 0 + const y = nc.transform?.m12 ?? 0 + const w = nc.size?.x ?? 0 + const h = nc.size?.y ?? 0 + minX = Math.min(minX, x) + minY = Math.min(minY, y) + maxX = Math.max(maxX, x + w) + maxY = Math.max(maxY, y + h) + } + + if (minX === Infinity) return null + return { x: minX, y: minY, w: maxX - minX, h: maxY - minY } +} + export function importClipboardNodes( nodeChanges: KiwiNodeChange[], graph: SceneGraph, @@ -121,31 +165,11 @@ export function importClipboardNodes( } } - 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 + if (NON_VISUAL_TYPES.has(nc.type ?? '')) continue const parentId = parentMap.get(id) - if (!parentId || !guidMap.has(parentId) || skipTypes.has(guidMap.get(parentId)?.type ?? '')) { + if (!parentId || !guidMap.has(parentId) || NON_VISUAL_TYPES.has(guidMap.get(parentId)?.type ?? '')) { topLevel.push(id) } } @@ -243,7 +267,7 @@ export function importClipboardNodes( const children: string[] = [] for (const [childId, pid] of parentMap) { - if (pid === figmaId && !skipTypes.has(guidMap.get(childId)?.type ?? '')) { + if (pid === figmaId && !NON_VISUAL_TYPES.has(guidMap.get(childId)?.type ?? '')) { children.push(childId) } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 012e7fda9..f72f10d4e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -122,6 +122,7 @@ export { export { parseFigmaClipboard, importClipboardNodes, + figmaNodesBounds, parseOpenPencilClipboard, buildFigmaClipboardHTML, buildOpenPencilClipboardHTML, diff --git a/src/engine/clipboard.ts b/src/engine/clipboard.ts index b720b37bb..f5a792057 100644 --- a/src/engine/clipboard.ts +++ b/src/engine/clipboard.ts @@ -1,6 +1,7 @@ export { parseFigmaClipboard, importClipboardNodes, + figmaNodesBounds, parseOpenPencilClipboard, buildFigmaClipboardHTML, buildOpenPencilClipboardHTML, diff --git a/src/stores/editor.ts b/src/stores/editor.ts index 060e4f98f..9e4338319 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -12,6 +12,7 @@ import { import { parseFigmaClipboard, importClipboardNodes, + figmaNodesBounds, parseOpenPencilClipboard, buildFigmaClipboardHTML, buildOpenPencilClipboardHTML, @@ -1581,12 +1582,18 @@ export function createEditorStore() { parseFigmaClipboard(html).then((figma) => { if (figma) { + const bounds = figmaNodesBounds(figma.nodes) + const viewCenterX = (-state.panX + (window.innerWidth / 2)) / state.zoom + const viewCenterY = (-state.panY + (window.innerHeight / 2)) / state.zoom + const offsetX = bounds ? viewCenterX - (bounds.x + bounds.w / 2) : 0 + const offsetY = bounds ? viewCenterY - (bounds.y + bounds.h / 2) : 0 + const created = importClipboardNodes( figma.nodes, graph, state.currentPageId, - 20, - 20, + offsetX, + offsetY, figma.blobs ) if (created.length > 0) { diff --git a/tests/engine/clipboard.test.ts b/tests/engine/clipboard.test.ts index 1beca93a7..324157c8a 100644 --- a/tests/engine/clipboard.test.ts +++ b/tests/engine/clipboard.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'bun:test' import { parseFigmaClipboard, importClipboardNodes, + figmaNodesBounds, } from '../../packages/core/src/clipboard' import { SceneGraph } from '../../packages/core/src/scene-graph' @@ -127,3 +128,38 @@ describe('importClipboardNodes', () => { expect(node.strokes[0].weight).toBe(2) }) }) + +describe('figmaNodesBounds', () => { + it('computes bounds of top-level visual nodes', () => { + const nodes = [ + { 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: 'A', size: { x: 200, y: 100 }, transform: { m00: 1, m01: 0, m02: 500, m10: 0, m11: 1, m12: 300 } }, + { guid: { sessionID: 0, localID: 11 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '"' }, type: 'RECTANGLE', name: 'B', size: { x: 50, y: 50 }, transform: { m00: 1, m01: 0, m02: 800, m10: 0, m11: 1, m12: 400 } }, + ] as any[] + + const bounds = figmaNodesBounds(nodes) + expect(bounds).toEqual({ x: 500, y: 300, w: 350, h: 150 }) + }) + + it('ignores variables and non-visual types', () => { + const nodes = [ + { 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: 2 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, type: 'VARIABLE_SET', name: 'Vars' }, + { 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: 18000, m10: 0, m11: 1, m12: 45000 } }, + ] as any[] + + const bounds = figmaNodesBounds(nodes) + expect(bounds).toEqual({ x: 18000, y: 45000, w: 300, h: 200 }) + }) + + it('returns null for empty or all-non-visual nodes', () => { + expect(figmaNodesBounds([])).toBeNull() + const nodes = [ + { 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' }, + ] as any[] + expect(figmaNodesBounds(nodes)).toBeNull() + }) +})