From b436ee4d2baddd6b6646688deb6a0eb061cc421d Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 11 Sep 2026 00:36:03 +0300 Subject: [PATCH] refactor(fig): own Figma clipboard encoding and conversion --- AGENTS.md | 1 + bun.lock | 1 + packages/core/src/clipboard.ts | 326 +-------------------- packages/fig/package.json | 9 +- packages/fig/src/clipboard/encode.ts | 30 ++ packages/fig/src/clipboard/import.ts | 293 ++++++++++++++++++ packages/fig/src/clipboard/index.ts | 2 + packages/fig/tsdown.config.ts | 1 + tests/engine/clipboard/figma/codec.test.ts | 26 ++ 9 files changed, 370 insertions(+), 319 deletions(-) create mode 100644 packages/fig/src/clipboard/encode.ts create mode 100644 packages/fig/src/clipboard/import.ts create mode 100644 packages/fig/src/clipboard/index.ts create mode 100644 tests/engine/clipboard/figma/codec.test.ts diff --git a/AGENTS.md b/AGENTS.md index 35305a013..72b76bbac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -242,6 +242,7 @@ Motion policy lives in `src/app/shell/motion/`: resolve persisted System/Off pre ## File format +- Figma clipboard envelope encoding, decoding, bounds, and SceneGraph import conversion belong to `@open-pencil/fig/clipboard`. Core prepares runtime fonts/text and owns editor placement/history; browser/Tauri adapters own system clipboard I/O. Do not add platform clipboard APIs to Fig. - Kiwi schema/runtime/codec/container helpers live in `@open-pencil/kiwi`; complete archive parsing and SceneGraph conversion live in `@open-pencil/fig`; Core owns format-neutral orchestration, runtime fonts/workers, and thumbnails. - Vector networks use the reverse-engineered `vectorNetworkBlob`; codecs live under `packages/core/src/vector/` and types in Scene Graph. - File System Access APIs are browser APIs, not Tauri-only. Keep Safari download fallback and defer `revokeObjectURL`. diff --git a/bun.lock b/bun.lock index 37a26e26f..40aac3cb2 100644 --- a/bun.lock +++ b/bun.lock @@ -252,6 +252,7 @@ "dependencies": { "es-toolkit": "^1.51.0", "fflate": "^0.8.3", + "js-base64": "^3.9.3", }, "devDependencies": { "@types/bun": "^1.3.14", diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index 4ea2b5dc0..b4efb261c 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -1,24 +1,16 @@ -import { inflateSync, deflateSync } from 'fflate' - -import { populateAndApplyOverrides } from '@open-pencil/fig/instance-overrides' -import type { InstanceNodeChange } from '@open-pencil/fig/instance-overrides' -import { - nodeChangeToProps, - shouldImportTextAsAutoSize, - sortChildren -} from '@open-pencil/fig/node-change' -import { initCodec, getCompiledSchema, getSchemaBytes } from '@open-pencil/kiwi/fig/codec' +import { encodeFigmaClipboard } from '@open-pencil/fig/clipboard' +export { + parseFigmaClipboard, + importClipboardNodes, + figmaNodesBounds +} from '@open-pencil/fig/clipboard' +import { initCodec } from '@open-pencil/kiwi/fig/codec' import type { GUID, NodeChange as KiwiNodeChange } from '@open-pencil/kiwi/fig/codec' -import { decodeBinarySchema, compileSchema, ByteBuffer } from '@open-pencil/kiwi/schema-runtime' import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' -import { decodeBase64, decodeBase64Text, encodeBase64, encodeBase64Text } from './bytes' import { shapeTextForClipboard } from './canvas/text/clipboard' import { sceneNodeToKiwi, - buildFigKiwi, - parseFigKiwiChunks, - decompressFigKiwiDataAsync, makeDocumentNodeChange, makeCanvasNodeChange, buildFontDigestMap @@ -26,287 +18,14 @@ import { import { randomInt } from './random' import { buildDerivedTextDataV4 } from './text/derived-text/clipboard' -interface FigmaClipboardMeta { - fileKey: string - pasteID: number - dataType: string -} - export async function prefetchFigmaSchema(): Promise { await initCodec() } -// --- Paste from Figma --- - -export async function parseFigmaClipboard( - html: string -): Promise<{ nodes: KiwiNodeChange[]; meta: FigmaClipboardMeta; blobs: Uint8Array[] } | null> { - const metaMatch = html.match(/\(figmeta\)(.*?)\(\/figmeta\)/) - const bufMatch = html.match(/\(figma\)(.*?)\(\/figma\)/s) - if (!metaMatch || !bufMatch) return null - - const meta: FigmaClipboardMeta = JSON.parse(decodeBase64Text(metaMatch[1])) - const binary = decodeBase64(bufMatch[1]) - - try { - const chunks = parseFigKiwiChunks(binary) - if (!chunks) return null - - const schemaBytes = inflateSync(chunks[0]) - const schema = decodeBinarySchema(new ByteBuffer(schemaBytes)) - const compiled = compileSchema(schema) - if (!compiled.decodeMessage) return null - const dataRaw = await decompressFigKiwiDataAsync(chunks[1]) - const msg = compiled.decodeMessage(dataRaw) as { - nodeChanges?: KiwiNodeChange[] - blobs?: Array<{ bytes: Uint8Array | Record }> - } - - const blobs: Uint8Array[] = (msg.blobs ?? []).map((b) => - b.bytes instanceof Uint8Array ? b.bytes : new Uint8Array(Object.values(b.bytes)) - ) - - return { nodes: msg.nodeChanges ?? [], meta, blobs } - } catch { - return null - } -} - -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' -]) - -function isChildOfVisualNode(nc: KiwiNodeChange, parentTypes: Map): boolean { - const parentId = nc.parentIndex?.guid - ? `${nc.parentIndex.guid.sessionID}:${nc.parentIndex.guid.localID}` - : null - return ( - !!parentId && - parentTypes.has(parentId) && - !NON_VISUAL_TYPES.has(parentTypes.get(parentId) ?? '') - ) -} - -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.type || NON_VISUAL_TYPES.has(nc.type)) continue - if (isChildOfVisualNode(nc, parentTypes)) 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 } -} - -interface ClipboardImportMaps { - guidMap: Map - parentMap: Map - childMap: Map -} - -function buildClipboardMaps(nodeChanges: KiwiNodeChange[]): ClipboardImportMaps { - const guidMap = new Map() - const parentMap = new Map() - const childMap = new Map() - for (const nc of nodeChanges) { - if (!nc.guid) continue - const id = `${nc.guid.sessionID}:${nc.guid.localID}` - guidMap.set(id, nc) - if (nc.parentIndex?.guid) { - const parentId = `${nc.parentIndex.guid.sessionID}:${nc.parentIndex.guid.localID}` - parentMap.set(id, parentId) - const siblings = childMap.get(parentId) - if (siblings) siblings.push(id) - else childMap.set(parentId, [id]) - } - } - return { guidMap, parentMap, childMap } -} - -function findInternalNodeIds( - guidMap: Map, - childMap: Map -): { internalCanvasIds: Set; internalFigmaIds: Set } { - const internalCanvasIds = new Set() - for (const [id, nc] of guidMap) { - if (nc.type === 'CANVAS' && nc.internalOnly) { - internalCanvasIds.add(id) - } - } - - const internalFigmaIds = new Set() - function markInternal(id: string) { - internalFigmaIds.add(id) - for (const childId of childMap.get(id) ?? []) { - if (!internalFigmaIds.has(childId)) markInternal(childId) - } - } - for (const canvasId of internalCanvasIds) markInternal(canvasId) - - return { internalCanvasIds, internalFigmaIds } -} - -function classifyTopLevelNodes( - guidMap: Map, - parentMap: Map, - internalCanvasIds: Set -): { topLevel: string[]; internalTopLevel: string[] } { - 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) - if ( - !parentId || - !guidMap.has(parentId) || - NON_VISUAL_TYPES.has(guidMap.get(parentId)?.type ?? '') - ) { - if (parentId && internalCanvasIds.has(parentId)) { - internalTopLevel.push(id) - } else { - topLevel.push(id) - } - } - } - return { topLevel, internalTopLevel } -} - -function remapComponentIds(created: Map, graph: SceneGraph): void { - for (const [, ourId] of created) { - const node = graph.getNode(ourId) - if (node?.type !== 'INSTANCE' || !node.componentId) continue - const ourComponentId = created.get(node.componentId) - if (ourComponentId) graph.updateNode(ourId, { componentId: ourComponentId }) - } -} - -function detachOrphanedInstances(created: Map, graph: SceneGraph): void { - for (const [, ourId] of created) { - const node = graph.getNode(ourId) - if (node?.type !== 'INSTANCE') continue - if (node.childIds.length === 0 && (!node.componentId || !graph.getNode(node.componentId))) { - graph.updateNode(ourId, { type: 'FRAME', componentId: '' }) - } - } -} - -export function importClipboardNodes( - nodeChanges: KiwiNodeChange[], - graph: SceneGraph, - targetParentId: string, - offsetX = 0, - offsetY = 0, - blobs: Uint8Array[] = [] -): string[] { - const { guidMap, parentMap, childMap } = buildClipboardMaps(nodeChanges) - const { internalCanvasIds, internalFigmaIds } = findInternalNodeIds(guidMap, childMap) - const { topLevel, internalTopLevel } = classifyTopLevelNodes( - guidMap, - parentMap, - internalCanvasIds - ) - - const created = new Map() - const createdIds: string[] = [] - - function createNode(figmaId: string, ourParentId: string) { - if (created.has(figmaId)) return - const nc = guidMap.get(figmaId) - if (!nc) return - - const { nodeType, ...props } = nodeChangeToProps(nc, blobs) - if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE') return - if (shouldImportTextAsAutoSize(nc, guidMap.get(parentMap.get(figmaId) ?? ''))) { - props.textAutoResize = 'WIDTH_AND_HEIGHT' - } - - if (ourParentId === targetParentId) { - props.x = (props.x ?? 0) + offsetX - props.y = (props.y ?? 0) + offsetY - } - - const node = graph.createNode(nodeType, ourParentId, props) - - created.set(figmaId, node.id) - if (ourParentId === targetParentId && !internalFigmaIds.has(figmaId)) createdIds.push(node.id) - - const children = (childMap.get(figmaId) ?? []).filter( - (childId) => !NON_VISUAL_TYPES.has(guidMap.get(childId)?.type ?? '') - ) - sortChildren(children, nc, guidMap) - for (const childId of children) { - createNode(childId, node.id) - } - } - - for (const id of internalTopLevel) { - createNode(id, targetParentId) - } - for (const id of topLevel) { - createNode(id, targetParentId) - } - - remapComponentIds(created, graph) - - graph.preserveSourceMetadataDuring(() => { - populateAndApplyOverrides(graph, guidMap as Map, created, blobs) - }) - - for (const figmaId of internalTopLevel) { - const ourId = created.get(figmaId) - if (ourId) graph.deleteNode(ourId) - } - - detachOrphanedInstances(created, graph) - - return createdIds -} - export async function buildFigmaClipboardHTML( nodes: SceneNode[], graph: SceneGraph ): Promise { - const compiled = getCompiledSchema() - const schemaDeflated = deflateSync(getSchemaBytes()) const fontDigestMap = await buildFontDigestMap(graph) const docGuid = { sessionID: 0, localID: 0 } @@ -366,36 +85,7 @@ export async function buildFigmaClipboardHTML( change.derivedTextData = await buildDerivedTextDataV4(source, fontDigestMap, shaped, blobs) }) ) - - const msg: Record = { - type: 'NODE_CHANGES', - sessionID: 0, - ackID: 0, - pasteID: randomInt(), - pasteFileKey: 'openpencil', - nodeChanges - } - - if (blobs.length > 0) { - msg.blobs = blobs.map((bytes) => ({ bytes })) - } - - const dataRaw = compiled.encodeMessage(msg) - const figKiwiBinary = buildFigKiwi(schemaDeflated, dataRaw) - const bufferB64 = encodeBase64(figKiwiBinary) - - const meta: FigmaClipboardMeta = { - fileKey: 'openpencil', - pasteID: msg.pasteID as number, - dataType: 'scene' - } - const metaB64 = encodeBase64Text(JSON.stringify(meta)) - - return ( - `` + - `` + - `` - ) + return encodeFigmaClipboard(nodeChanges, blobs, randomInt()) } export { diff --git a/packages/fig/package.json b/packages/fig/package.json index d8f7525f9..019524ba1 100644 --- a/packages/fig/package.json +++ b/packages/fig/package.json @@ -10,6 +10,12 @@ "import": "./dist/index.js", "default": "./dist/index.js" }, + "./clipboard": { + "types": "./dist/clipboard.d.ts", + "bun": "./src/clipboard/index.ts", + "import": "./dist/clipboard.js", + "default": "./dist/clipboard.js" + }, "./node-change": { "types": "./dist/node-change.d.ts", "bun": "./src/node-change/index.ts", @@ -54,7 +60,8 @@ }, "dependencies": { "es-toolkit": "^1.51.0", - "fflate": "^0.8.3" + "fflate": "^0.8.3", + "js-base64": "^3.9.3" }, "devDependencies": { "@types/bun": "^1.3.14", diff --git a/packages/fig/src/clipboard/encode.ts b/packages/fig/src/clipboard/encode.ts new file mode 100644 index 000000000..a53c6a6e0 --- /dev/null +++ b/packages/fig/src/clipboard/encode.ts @@ -0,0 +1,30 @@ +import { deflateSync } from 'fflate' +import { encode, fromUint8Array } from 'js-base64' + +import { getCompiledSchema, getSchemaBytes } from '@open-pencil/kiwi/fig/codec' +import type { NodeChange } from '@open-pencil/kiwi/fig/codec' + +import { buildFigKiwi } from '../node-change' + +/** Encode prepared node changes; runtime shaping and font loading belong to the caller. */ +export function encodeFigmaClipboard( + nodeChanges: NodeChange[], + blobs: Uint8Array[], + pasteID: number +): string { + const message = { + type: 'NODE_CHANGES', + sessionID: 0, + ackID: 0, + pasteID, + pasteFileKey: 'openpencil', + nodeChanges, + blobs: blobs.map((bytes) => ({ bytes })) + } + const binary = buildFigKiwi( + deflateSync(getSchemaBytes()), + getCompiledSchema().encodeMessage(message) + ) + const meta = encode(JSON.stringify({ fileKey: 'openpencil', pasteID, dataType: 'scene' })) + return `` +} diff --git a/packages/fig/src/clipboard/import.ts b/packages/fig/src/clipboard/import.ts new file mode 100644 index 000000000..75de28884 --- /dev/null +++ b/packages/fig/src/clipboard/import.ts @@ -0,0 +1,293 @@ +import { inflateSync } from 'fflate' +import { decode as decodeText, toUint8Array, isValid } from 'js-base64' + +import type { NodeChange as KiwiNodeChange } from '@open-pencil/kiwi/fig/codec' +import { decodeBinarySchema, compileSchema, ByteBuffer } from '@open-pencil/kiwi/schema-runtime' +import type { SceneGraph } from '@open-pencil/scene-graph' + +import { populateAndApplyOverrides, type InstanceNodeChange } from '../instance-overrides' +import { + nodeChangeToProps, + shouldImportTextAsAutoSize, + sortChildren, + parseFigKiwiChunks, + decompressFigKiwiDataAsync +} from '../node-change' + +function decodeBase64(value: string): Uint8Array { + if (!isValid(value)) throw new TypeError('Invalid Base64 string') + return toUint8Array(value) +} +function decodeBase64Text(value: string): string { + if (!isValid(value)) throw new TypeError('Invalid Base64 string') + return decodeText(value) +} + +interface FigmaClipboardMeta { + fileKey: string + pasteID: number + dataType: string +} + +export async function parseFigmaClipboard( + html: string +): Promise<{ nodes: KiwiNodeChange[]; meta: FigmaClipboardMeta; blobs: Uint8Array[] } | null> { + const metaMatch = html.match(/\(figmeta\)(.*?)\(\/figmeta\)/) + const bufMatch = html.match(/\(figma\)(.*?)\(\/figma\)/s) + if (!metaMatch || !bufMatch) return null + + const meta: FigmaClipboardMeta = JSON.parse(decodeBase64Text(metaMatch[1])) + const binary = decodeBase64(bufMatch[1]) + + try { + const chunks = parseFigKiwiChunks(binary) + if (!chunks) return null + + const schemaBytes = inflateSync(chunks[0]) + const schema = decodeBinarySchema(new ByteBuffer(schemaBytes)) + const compiled = compileSchema(schema) + if (!compiled.decodeMessage) return null + const dataRaw = await decompressFigKiwiDataAsync(chunks[1]) + const msg = compiled.decodeMessage(dataRaw) as { + nodeChanges?: KiwiNodeChange[] + blobs?: Array<{ bytes: Uint8Array | Record }> + } + + const blobs: Uint8Array[] = (msg.blobs ?? []).map((b) => + b.bytes instanceof Uint8Array ? b.bytes : new Uint8Array(Object.values(b.bytes)) + ) + + return { nodes: msg.nodeChanges ?? [], meta, blobs } + } catch { + return null + } +} + +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' +]) + +function isChildOfVisualNode(nc: KiwiNodeChange, parentTypes: Map): boolean { + const parentId = nc.parentIndex?.guid + ? `${nc.parentIndex.guid.sessionID}:${nc.parentIndex.guid.localID}` + : null + return ( + !!parentId && + parentTypes.has(parentId) && + !NON_VISUAL_TYPES.has(parentTypes.get(parentId) ?? '') + ) +} + +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.type || NON_VISUAL_TYPES.has(nc.type)) continue + if (isChildOfVisualNode(nc, parentTypes)) 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 } +} + +interface ClipboardImportMaps { + guidMap: Map + parentMap: Map + childMap: Map +} + +function buildClipboardMaps(nodeChanges: KiwiNodeChange[]): ClipboardImportMaps { + const guidMap = new Map() + const parentMap = new Map() + const childMap = new Map() + for (const nc of nodeChanges) { + if (!nc.guid) continue + const id = `${nc.guid.sessionID}:${nc.guid.localID}` + guidMap.set(id, nc) + if (nc.parentIndex?.guid) { + const parentId = `${nc.parentIndex.guid.sessionID}:${nc.parentIndex.guid.localID}` + parentMap.set(id, parentId) + const siblings = childMap.get(parentId) + if (siblings) siblings.push(id) + else childMap.set(parentId, [id]) + } + } + return { guidMap, parentMap, childMap } +} + +function findInternalNodeIds( + guidMap: Map, + childMap: Map +): { internalCanvasIds: Set; internalFigmaIds: Set } { + const internalCanvasIds = new Set() + for (const [id, nc] of guidMap) { + if (nc.type === 'CANVAS' && nc.internalOnly) { + internalCanvasIds.add(id) + } + } + + const internalFigmaIds = new Set() + function markInternal(id: string) { + internalFigmaIds.add(id) + for (const childId of childMap.get(id) ?? []) { + if (!internalFigmaIds.has(childId)) markInternal(childId) + } + } + for (const canvasId of internalCanvasIds) markInternal(canvasId) + + return { internalCanvasIds, internalFigmaIds } +} + +function classifyTopLevelNodes( + guidMap: Map, + parentMap: Map, + internalCanvasIds: Set +): { topLevel: string[]; internalTopLevel: string[] } { + 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) + if ( + !parentId || + !guidMap.has(parentId) || + NON_VISUAL_TYPES.has(guidMap.get(parentId)?.type ?? '') + ) { + if (parentId && internalCanvasIds.has(parentId)) { + internalTopLevel.push(id) + } else { + topLevel.push(id) + } + } + } + return { topLevel, internalTopLevel } +} + +function remapComponentIds(created: Map, graph: SceneGraph): void { + for (const [, ourId] of created) { + const node = graph.getNode(ourId) + if (node?.type !== 'INSTANCE' || !node.componentId) continue + const ourComponentId = created.get(node.componentId) + if (ourComponentId) graph.updateNode(ourId, { componentId: ourComponentId }) + } +} + +function detachOrphanedInstances(created: Map, graph: SceneGraph): void { + for (const [, ourId] of created) { + const node = graph.getNode(ourId) + if (node?.type !== 'INSTANCE') continue + if (node.childIds.length === 0 && (!node.componentId || !graph.getNode(node.componentId))) { + graph.updateNode(ourId, { type: 'FRAME', componentId: '' }) + } + } +} + +export function importClipboardNodes( + nodeChanges: KiwiNodeChange[], + graph: SceneGraph, + targetParentId: string, + offsetX = 0, + offsetY = 0, + blobs: Uint8Array[] = [] +): string[] { + const { guidMap, parentMap, childMap } = buildClipboardMaps(nodeChanges) + const { internalCanvasIds, internalFigmaIds } = findInternalNodeIds(guidMap, childMap) + const { topLevel, internalTopLevel } = classifyTopLevelNodes( + guidMap, + parentMap, + internalCanvasIds + ) + + const created = new Map() + const createdIds: string[] = [] + + function createNode(figmaId: string, ourParentId: string) { + if (created.has(figmaId)) return + const nc = guidMap.get(figmaId) + if (!nc) return + + const { nodeType, ...props } = nodeChangeToProps(nc, blobs) + if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE') return + if (shouldImportTextAsAutoSize(nc, guidMap.get(parentMap.get(figmaId) ?? ''))) { + props.textAutoResize = 'WIDTH_AND_HEIGHT' + } + + if (ourParentId === targetParentId) { + props.x = (props.x ?? 0) + offsetX + props.y = (props.y ?? 0) + offsetY + } + + const node = graph.createNode(nodeType, ourParentId, props) + + created.set(figmaId, node.id) + if (ourParentId === targetParentId && !internalFigmaIds.has(figmaId)) createdIds.push(node.id) + + const children = (childMap.get(figmaId) ?? []).filter( + (childId) => !NON_VISUAL_TYPES.has(guidMap.get(childId)?.type ?? '') + ) + sortChildren(children, nc, guidMap) + for (const childId of children) { + createNode(childId, node.id) + } + } + + for (const id of internalTopLevel) { + createNode(id, targetParentId) + } + for (const id of topLevel) { + createNode(id, targetParentId) + } + + remapComponentIds(created, graph) + + graph.preserveSourceMetadataDuring(() => { + populateAndApplyOverrides(graph, guidMap as Map, created, blobs) + }) + + for (const figmaId of internalTopLevel) { + const ourId = created.get(figmaId) + if (ourId) graph.deleteNode(ourId) + } + + detachOrphanedInstances(created, graph) + + return createdIds +} diff --git a/packages/fig/src/clipboard/index.ts b/packages/fig/src/clipboard/index.ts new file mode 100644 index 000000000..b64fec952 --- /dev/null +++ b/packages/fig/src/clipboard/index.ts @@ -0,0 +1,2 @@ +export { parseFigmaClipboard, importClipboardNodes, figmaNodesBounds } from './import' +export { encodeFigmaClipboard } from './encode' diff --git a/packages/fig/tsdown.config.ts b/packages/fig/tsdown.config.ts index 23f4ef7dd..cc45c21e2 100644 --- a/packages/fig/tsdown.config.ts +++ b/packages/fig/tsdown.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: { index: './src/index.ts', 'instance-overrides': './src/instance-overrides/index.ts', + clipboard: './src/clipboard/index.ts', 'node-change': './src/node-change/index.ts' }, platform: 'neutral', diff --git a/tests/engine/clipboard/figma/codec.test.ts b/tests/engine/clipboard/figma/codec.test.ts new file mode 100644 index 000000000..1e1c57700 --- /dev/null +++ b/tests/engine/clipboard/figma/codec.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from 'bun:test' + +import { encodeFigmaClipboard, parseFigmaClipboard } from '@open-pencil/fig/clipboard' +import { initCodec } from '@open-pencil/kiwi/fig/codec' + +test('Fig clipboard codec preserves prepared node changes and binary blobs', async () => { + await initCodec() + const bytes = new Uint8Array([0, 1, 127, 255]) + const html = encodeFigmaClipboard( + [ + { + guid: { sessionID: 0, localID: 10 }, + type: 'RECTANGLE', + name: 'Prepared rectangle', + size: { x: 100, y: 80 } + } + ], + [bytes], + 123 + ) + const parsed = await parseFigmaClipboard(html) + expect(parsed?.meta).toEqual({ fileKey: 'openpencil', pasteID: 123, dataType: 'scene' }) + expect(parsed?.nodes[0]).toMatchObject({ name: 'Prepared rectangle', size: { x: 100, y: 80 } }) + expect(parsed?.blobs[0]).toEqual(bytes) + expect(parsed?.blobs[0]).toBeInstanceOf(Uint8Array) +})