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:
parent
7139546cb5
commit
859afdce45
6
bun.lock
6
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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
69
src/engine/fig-file.ts
Normal 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
188
src/engine/fig-import.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue