refactor(fig): own Figma clipboard encoding and conversion
This commit is contained in:
parent
b6f015a9f6
commit
b436ee4d2b
|
|
@ -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`.
|
||||
|
|
|
|||
1
bun.lock
1
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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
30
packages/fig/src/clipboard/encode.ts
Normal file
30
packages/fig/src/clipboard/encode.ts
Normal 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>`
|
||||
}
|
||||
293
packages/fig/src/clipboard/import.ts
Normal file
293
packages/fig/src/clipboard/import.ts
Normal 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
|
||||
}
|
||||
2
packages/fig/src/clipboard/index.ts
Normal file
2
packages/fig/src/clipboard/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { parseFigmaClipboard, importClipboardNodes, figmaNodesBounds } from './import'
|
||||
export { encodeFigmaClipboard } from './encode'
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
26
tests/engine/clipboard/figma/codec.test.ts
Normal file
26
tests/engine/clipboard/figma/codec.test.ts
Normal 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)
|
||||
})
|
||||
Loading…
Reference in a new issue