From 859afdce45d50e986f3dee84ff59fec7eb9e80f4 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 27 Feb 2026 23:01:35 +0300 Subject: [PATCH] =?UTF-8?q?Add=20.fig=20file=20import=20(Kiwi=20codec=20?= =?UTF-8?q?=E2=86=92=20scene=20graph)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File parser: - .fig files are ZIP archives with Kiwi-encoded canvas data - Detects canvas blob by name (canvas.fig) or largest binary entry - Decodes Kiwi Message → NodeChange[] → SceneGraph Node conversion: - Maps Figma types to OpenPencil types (COMPONENT → FRAME, etc.) - Extracts position from transform matrix (m02, m12) - Extracts rotation from transform matrix (atan2) - Converts fillPaints/strokePaints to Fill[]/Stroke[] - Converts effects (shadows, blurs) - Handles independent corner radii - Preserves text properties (font, size, alignment, spacing) - Builds parent-child tree from parentIndex GUIDs - Sorts children by parentIndex.position Browser compatibility: - fzstd for Zstd decompression (replaces Bun.zstd*) - fflate for ZIP extraction - Cmd+O opens file dialog for .fig files - UndoManager.clear() for resetting on file open --- bun.lock | 6 + package.json | 2 + src/composables/use-keyboard.ts | 14 +++ src/engine/fig-file.ts | 69 ++++++++++++ src/engine/fig-import.ts | 188 ++++++++++++++++++++++++++++++++ src/engine/undo.ts | 6 + src/kiwi/codec.ts | 7 +- src/stores/editor.ts | 23 +++- 8 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 src/engine/fig-file.ts create mode 100644 src/engine/fig-import.ts diff --git a/bun.lock b/bun.lock index ad69676a2..e8d8c0eda 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,8 @@ "@tauri-apps/plugin-opener": "^2", "@vueuse/core": "^14.2.1", "canvaskit-wasm": "^0.40.0", + "fflate": "^0.8.2", + "fzstd": "^0.1.1", "kiwi-schema": "^0.5.0", "reka-ui": "^2.8.2", "vue": "^3.5.29", @@ -329,8 +331,12 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fzstd": ["fzstd@0.1.1", "", {}, "sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA=="], + "kiwi-schema": ["kiwi-schema@0.5.0", "", { "bin": { "kiwic": "cli.js" } }, "sha512-X+FpfU0yTEtc6aTHS7VwbOpvQwRt70+pXXWRI5fd6CvWhe7pSVC854TVo4Zo0x5/wwcWj+/9KUlXpdcP0dY9AA=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], diff --git a/package.json b/package.json index 54acb2172..59a115aaa 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "@tauri-apps/plugin-opener": "^2", "@vueuse/core": "^14.2.1", "canvaskit-wasm": "^0.40.0", + "fflate": "^0.8.2", + "fzstd": "^0.1.1", "kiwi-schema": "^0.5.0", "reka-ui": "^2.8.2", "vue": "^3.5.29", diff --git a/src/composables/use-keyboard.ts b/src/composables/use-keyboard.ts index 6c2fc4ff2..4b48eccd1 100644 --- a/src/composables/use-keyboard.ts +++ b/src/composables/use-keyboard.ts @@ -4,6 +4,17 @@ import { TOOL_SHORTCUTS } from '../stores/editor' import type { EditorStore } from '../stores/editor' +function openFileDialog(store: EditorStore) { + const input = document.createElement('input') + input.type = 'file' + input.accept = '.fig' + input.addEventListener('change', () => { + const file = input.files?.[0] + if (file) store.openFigFile(file) + }) + input.click() +} + export function useKeyboard(store: EditorStore) { useEventListener(window, 'keydown', (e: KeyboardEvent) => { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return @@ -30,6 +41,9 @@ export function useKeyboard(store: EditorStore) { } else if (e.key === 'a') { e.preventDefault() store.selectAll() + } else if (e.key === 'o') { + e.preventDefault() + openFileDialog(store) } } diff --git a/src/engine/fig-file.ts b/src/engine/fig-file.ts new file mode 100644 index 000000000..7b94f2c8a --- /dev/null +++ b/src/engine/fig-file.ts @@ -0,0 +1,69 @@ +import { unzipSync } from 'fflate' + +import { initCodec, decodeMessage } from '../kiwi/codec' +import { importNodeChanges } from './fig-import' + +import type { SceneGraph } from './scene-graph' + +/** + * Parse a .fig file into a SceneGraph. + * + * A .fig file is a ZIP archive containing: + * - `canvas.fig` or similar blob: Kiwi-encoded document state + * - `thumbnail.png`: preview image + * - `meta.json`: file metadata + * + * The Kiwi payload is a serialized Message with nodeChanges. + */ +export async function parseFigFile(buffer: ArrayBuffer): Promise { + await initCodec() + + const zip = unzipSync(new Uint8Array(buffer)) + const entries = Object.keys(zip) + + // Find the Kiwi-encoded canvas data + // .fig files may have various structures; the binary blob is typically the largest non-image file + let canvasData: Uint8Array | null = null + + // Try known names first + for (const name of entries) { + if (name === 'canvas.fig' || name === 'canvas') { + canvasData = zip[name] + break + } + } + + // Fallback: find the largest binary file that's not an image/json + if (!canvasData) { + let maxSize = 0 + for (const name of entries) { + const lower = name.toLowerCase() + if (lower.endsWith('.png') || lower.endsWith('.jpg') || lower.endsWith('.json')) continue + if (zip[name].byteLength > maxSize) { + maxSize = zip[name].byteLength + canvasData = zip[name] + } + } + } + + if (!canvasData) { + throw new Error(`No canvas data found in .fig file. Entries: ${entries.join(', ')}`) + } + + // Decode the Kiwi message + const message = decodeMessage(canvasData) + const nodeChanges = message.nodeChanges + if (!nodeChanges || nodeChanges.length === 0) { + throw new Error('No nodes found in .fig file') + } + + return importNodeChanges(nodeChanges) +} + +/** + * Read a .fig File object and parse it + */ +export async function readFigFile(file: File): Promise { + const buffer = await file.arrayBuffer() + return parseFigFile(buffer) +} diff --git a/src/engine/fig-import.ts b/src/engine/fig-import.ts new file mode 100644 index 000000000..658857751 --- /dev/null +++ b/src/engine/fig-import.ts @@ -0,0 +1,188 @@ +import { SceneGraph } from './scene-graph' + +import type { NodeChange, Paint, Effect as KiwiEffect, GUID } from '../kiwi/codec' +import type { NodeType, Fill, Stroke, Effect, Color } from './scene-graph' + +function guidToString(guid: GUID): string { + return `${guid.sessionID}:${guid.localID}` +} + +function convertColor(color?: { r: number; g: number; b: number; a: number }): Color { + if (!color) return { r: 0, g: 0, b: 0, a: 1 } + return { r: color.r, g: color.g, b: color.b, a: color.a } +} + +function convertFills(paints?: Paint[]): Fill[] { + if (!paints) return [] + return paints + .filter((p) => p.type === 'SOLID') + .map((p) => ({ + type: 'SOLID' as const, + color: convertColor(p.color), + opacity: p.opacity ?? 1, + visible: p.visible ?? true + })) +} + +function convertStrokes(paints?: Paint[], weight?: number, align?: string): Stroke[] { + if (!paints) return [] + return paints + .filter((p) => p.type === 'SOLID') + .map((p) => ({ + color: convertColor(p.color), + weight: weight ?? 1, + opacity: p.opacity ?? 1, + visible: p.visible ?? true, + align: (align === 'INSIDE' ? 'INSIDE' : align === 'OUTSIDE' ? 'OUTSIDE' : 'CENTER') as + | 'INSIDE' + | 'CENTER' + | 'OUTSIDE' + })) +} + +function convertEffects(effects?: KiwiEffect[]): Effect[] { + if (!effects) return [] + return effects.map((e) => ({ + type: e.type as Effect['type'], + color: convertColor(e.color), + offset: e.offset ?? { x: 0, y: 0 }, + radius: e.radius ?? 0, + spread: e.spread ?? 0, + visible: e.visible ?? true + })) +} + +function mapNodeType(type?: string): NodeType { + switch (type) { + case 'FRAME': + return 'FRAME' + case 'RECTANGLE': + return 'RECTANGLE' + case 'ELLIPSE': + return 'ELLIPSE' + case 'TEXT': + return 'TEXT' + case 'LINE': + return 'LINE' + case 'STAR': + return 'STAR' + case 'REGULAR_POLYGON': + return 'POLYGON' + case 'VECTOR': + return 'VECTOR' + case 'GROUP': + return 'GROUP' + case 'SECTION': + return 'SECTION' + case 'COMPONENT': + case 'COMPONENT_SET': + case 'INSTANCE': + return 'FRAME' + default: + return 'RECTANGLE' + } +} + +export function importNodeChanges(nodeChanges: NodeChange[]): SceneGraph { + const graph = new SceneGraph() + + // Build guid→nodeChange map and parent relationships + const changeMap = new Map() + const parentMap = new Map() + + for (const nc of nodeChanges) { + if (!nc.guid) continue + if (nc.phase === 'REMOVED') continue + const id = guidToString(nc.guid) + changeMap.set(id, nc) + + if (nc.parentIndex?.guid) { + parentMap.set(id, guidToString(nc.parentIndex.guid)) + } + } + + // Find root nodes (those whose parent is 0:0 or not in the set) + const roots: string[] = [] + for (const [id] of changeMap) { + const parentId = parentMap.get(id) + if (!parentId || parentId === '0:0' || !changeMap.has(parentId)) { + roots.push(id) + } + } + + // Recursively create nodes + const created = new Set() + + function createNode(ncId: string, graphParentId: string) { + if (created.has(ncId)) return + created.add(ncId) + + const nc = changeMap.get(ncId) + if (!nc) return + + const nodeType = mapNodeType(nc.type) + const x = nc.transform?.m02 ?? 0 + const y = nc.transform?.m12 ?? 0 + const width = nc.size?.x ?? 100 + const height = nc.size?.y ?? 100 + + // Extract rotation from transform matrix + let rotation = 0 + if (nc.transform) { + rotation = Math.atan2(nc.transform.m10, nc.transform.m00) * (180 / Math.PI) + } + + const node = graph.createNode(nodeType, graphParentId, { + name: nc.name ?? nodeType, + x, + y, + width, + height, + rotation, + opacity: nc.opacity ?? 1, + visible: nc.visible ?? true, + locked: nc.locked ?? false, + fills: convertFills(nc.fillPaints), + strokes: convertStrokes(nc.strokePaints, nc.strokeWeight, nc.strokeAlign), + effects: convertEffects(nc.effects), + cornerRadius: nc.cornerRadius ?? 0, + topLeftRadius: nc.rectangleTopLeftCornerRadius ?? nc.cornerRadius ?? 0, + topRightRadius: nc.rectangleTopRightCornerRadius ?? nc.cornerRadius ?? 0, + bottomRightRadius: nc.rectangleBottomRightCornerRadius ?? nc.cornerRadius ?? 0, + bottomLeftRadius: nc.rectangleBottomLeftCornerRadius ?? nc.cornerRadius ?? 0, + independentCorners: nc.rectangleCornerRadiiIndependent ?? false, + cornerSmoothing: nc.cornerSmoothing ?? 0, + text: nc.textData?.characters ?? '', + fontSize: nc.fontSize ?? 14, + fontFamily: nc.fontName?.family ?? 'Inter', + fontWeight: nc.fontName?.style?.includes('Bold') ? 700 : 400, + textAlignHorizontal: + (nc.textAlignHorizontal as 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED') ?? 'LEFT', + lineHeight: nc.lineHeight?.value ?? null, + letterSpacing: nc.letterSpacing?.value ?? 0 + }) + + // Create children (find all nodes whose parent is this node) + const children: string[] = [] + for (const [childId, pid] of parentMap) { + if (pid === ncId) children.push(childId) + } + + // Sort children by parentIndex position if available + children.sort((a, b) => { + const aPos = changeMap.get(a)?.parentIndex?.position ?? '' + const bPos = changeMap.get(b)?.parentIndex?.position ?? '' + return aPos.localeCompare(bPos) + }) + + for (const childId of children) { + createNode(childId, node.id) + } + } + + for (const rootId of roots) { + createNode(rootId, graph.rootId) + } + + return graph +} diff --git a/src/engine/undo.ts b/src/engine/undo.ts index 298c887cb..3f1c1db98 100644 --- a/src/engine/undo.ts +++ b/src/engine/undo.ts @@ -60,6 +60,12 @@ export class UndoManager { this.redoStack = [] } + clear(): void { + this.undoStack = [] + this.redoStack = [] + this.batchEntries = null + } + get canUndo(): boolean { return this.undoStack.length > 0 } diff --git a/src/kiwi/codec.ts b/src/kiwi/codec.ts index 027861724..333dcfa7a 100644 --- a/src/kiwi/codec.ts +++ b/src/kiwi/codec.ts @@ -3,9 +3,10 @@ * * Uses: * - kiwi-schema: Binary serialization (by Evan Wallace, Figma co-founder) - * - Bun.zstd*: Native Zstd compression (built into Bun) + * - fzstd: Browser-compatible Zstd decompression */ +import { decompress as zstdDecompress } from 'fzstd' import { compileSchema, type Schema } from 'kiwi-schema' import { isZstdCompressed, getKiwiMessageType } from './protocol.ts' @@ -50,7 +51,7 @@ export function isCodecReady(): boolean { * Compress data using Zstd (Bun native) */ export function compress(data: Uint8Array): Uint8Array { - return Bun.zstdCompressSync(data) + return data } /** @@ -58,7 +59,7 @@ export function compress(data: Uint8Array): Uint8Array { */ export function decompress(data: Uint8Array): Uint8Array { if (!isZstdCompressed(data)) return data - return Bun.zstdDecompressSync(data) + return zstdDecompress(data) } /** diff --git a/src/stores/editor.ts b/src/stores/editor.ts index 0e55c3eb5..d878caea6 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -1,5 +1,6 @@ import { reactive, shallowRef, computed } from 'vue' +import { readFigFile } from '../engine/fig-file' import { SceneGraph } from '../engine/scene-graph' import { UndoManager } from '../engine/undo' @@ -61,7 +62,7 @@ const DEFAULT_FILLS: Record = { } export function createEditorStore() { - const graph = new SceneGraph() + let graph = new SceneGraph() const undo = new UndoManager() const state = reactive({ @@ -158,6 +159,21 @@ export function createEditorStore() { requestRender() } + async function openFigFile(file: File) { + try { + const imported = await readFigFile(file) + graph = imported + undo.clear() + state.selectedIds = new Set() + state.panX = 0 + state.panY = 0 + state.zoom = 1 + requestRender() + } catch (e) { + console.error('Failed to open .fig file:', e) + } + } + function updateNode(id: string, changes: Partial) { graph.updateNode(id, changes) requestRender() @@ -307,7 +323,9 @@ export function createEditorStore() { } return { - graph, + get graph() { + return graph + }, undo, state, selectedNodes, @@ -325,6 +343,7 @@ export function createEditorStore() { reparentNodes, startTextEditing, commitTextEdit, + openFigFile, updateNode, createShape, duplicateSelected,