refactor(fig): own Figma clipboard encoding and conversion

This commit is contained in:
Danila Poyarkov 2026-09-11 00:36:03 +03:00
parent b6f015a9f6
commit b436ee4d2b
9 changed files with 370 additions and 319 deletions

View file

@ -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`.

View file

@ -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",

View file

@ -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<void> {
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<string, number> }>
}
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<string, string>): 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<string, string>()
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<string, KiwiNodeChange>
parentMap: Map<string, string>
childMap: Map<string, string[]>
}
function buildClipboardMaps(nodeChanges: KiwiNodeChange[]): ClipboardImportMaps {
const guidMap = new Map<string, KiwiNodeChange>()
const parentMap = new Map<string, string>()
const childMap = new Map<string, string[]>()
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<string, KiwiNodeChange>,
childMap: Map<string, string[]>
): { internalCanvasIds: Set<string>; internalFigmaIds: Set<string> } {
const internalCanvasIds = new Set<string>()
for (const [id, nc] of guidMap) {
if (nc.type === 'CANVAS' && nc.internalOnly) {
internalCanvasIds.add(id)
}
}
const internalFigmaIds = new Set<string>()
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<string, KiwiNodeChange>,
parentMap: Map<string, string>,
internalCanvasIds: Set<string>
): { 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<string, string>, 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<string, string>, 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<string, string>()
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<string, InstanceNodeChange>, 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<string | null> {
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<string, unknown> = {
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 (
`<meta charset='utf-8'>` +
`<span data-metadata="<!--(figmeta)${metaB64}(/figmeta)-->"></span>` +
`<span data-buffer="<!--(figma)${bufferB64}(/figma)-->"></span>`
)
return encodeFigmaClipboard(nodeChanges, blobs, randomInt())
}
export {

View file

@ -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",

View file

@ -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 `<meta charset='utf-8'><span data-metadata="<!--(figmeta)${meta}(/figmeta)-->"></span><span data-buffer="<!--(figma)${fromUint8Array(binary)}(/figma)-->"></span>`
}

View file

@ -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<string, number> }>
}
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<string, string>): 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<string, string>()
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<string, KiwiNodeChange>
parentMap: Map<string, string>
childMap: Map<string, string[]>
}
function buildClipboardMaps(nodeChanges: KiwiNodeChange[]): ClipboardImportMaps {
const guidMap = new Map<string, KiwiNodeChange>()
const parentMap = new Map<string, string>()
const childMap = new Map<string, string[]>()
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<string, KiwiNodeChange>,
childMap: Map<string, string[]>
): { internalCanvasIds: Set<string>; internalFigmaIds: Set<string> } {
const internalCanvasIds = new Set<string>()
for (const [id, nc] of guidMap) {
if (nc.type === 'CANVAS' && nc.internalOnly) {
internalCanvasIds.add(id)
}
}
const internalFigmaIds = new Set<string>()
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<string, KiwiNodeChange>,
parentMap: Map<string, string>,
internalCanvasIds: Set<string>
): { 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<string, string>, 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<string, string>, 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<string, string>()
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<string, InstanceNodeChange>, created, blobs)
})
for (const figmaId of internalTopLevel) {
const ourId = created.get(figmaId)
if (ourId) graph.deleteNode(ourId)
}
detachOrphanedInstances(created, graph)
return createdIds
}

View file

@ -0,0 +1,2 @@
export { parseFigmaClipboard, importClipboardNodes, figmaNodesBounds } from './import'
export { encodeFigmaClipboard } from './encode'

View file

@ -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',

View file

@ -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)
})