Add .fig file import (Kiwi codec → scene graph)

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
This commit is contained in:
Danila Poyarkov 2026-02-27 23:01:35 +03:00
parent 7139546cb5
commit 859afdce45
8 changed files with 310 additions and 5 deletions

View file

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

View file

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

View file

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

69
src/engine/fig-file.ts Normal file
View file

@ -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<SceneGraph> {
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<SceneGraph> {
const buffer = await file.arrayBuffer()
return parseFigFile(buffer)
}

188
src/engine/fig-import.ts Normal file
View file

@ -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<string, NodeChange>()
const parentMap = new Map<string, string>()
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<string>()
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
}

View file

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

View file

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

View file

@ -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<string, Fill> = {
}
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<SceneNode>) {
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,