Perf: 5.4× faster file open, worker-based parsing, instance index (#96)

* Perf: cache label collection, offload .fig compression to worker

Label cache: collect sections/components once per scene change,
filter by viewport on each frame. Eliminates full tree walk during
pan/zoom (~17ms/frame → <1ms on large files).

Export worker: move fflate compression off the main thread to prevent
frame drops during save (451ms+ → non-blocking).

* Perf: worker-based .fig parsing, instance index, non-blocking font loading

- Offload .fig parsing (unzip + Kiwi decode) to a Web Worker
- Add instance index (componentId → Set<nodeId>) for O(1) getInstances()
- Defer graph event subscription during file open to skip redundant syncs
- Make font loading non-blocking — render immediately, load fonts in background
- Copy image buffers before worker transfer to prevent detached ArrayBuffer crash
- Show toast on font load failure and file open errors
- Cache failed Google Fonts families to avoid repeated network requests
- Fix missing ref import in FillPicker
- Yield to UI between parse and layout for responsive loading spinner
This commit is contained in:
Danila Poyarkov 2026-03-12 14:49:28 +03:00 committed by GitHub
parent 6072ce57c1
commit 3b32b4d80c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 816 additions and 136 deletions

View file

@ -2,6 +2,15 @@
## Unreleased
### Performance
- Offload .fig parsing (unzip + Kiwi decode) to a Web Worker — main thread stays responsive during file open
- Offload .fig compression to a Web Worker during save (was blocking 450ms+)
- Add instance index (`componentId → Set<nodeId>`) — `getInstances()` is O(1) instead of scanning all nodes
- Defer graph event subscription until after layout computation during file open — eliminates redundant `syncInstances` calls
- Cache label collection (sections/components) per scene mutation instead of walking the full tree every frame
- Non-blocking font loading — files render immediately, fonts load in background
### Features
- Grid layout in AI chat — JSX renderer supports `grid`, `columns`, `rows`, `gap` props with child positioning (`colStart`, `rowStart`, `colSpan`, `rowSpan`) and auto-height grids
@ -11,6 +20,10 @@
### Fixes
- Fix detached ArrayBuffer crash when switching pages after saving — export worker now copies image buffers before transferring
- Show warning toast when fonts fail to load, error toast when file open fails
- Fix FillPicker crash when selecting image fills (missing `ref` import from #92)
- Fix Google Fonts TLS/network errors not cached — failed families no longer retry on every render
- Fix CJK text garbled when font is unavailable — fallback now renders through paragraph shaper instead of raw `drawText`, preserving CJK characters via the fallback font chain
- Fix auto-layout overflow in AI-generated designs — text wrapping, min/max constraints, absolute positioning, and FILL sizing now work correctly
- Fix `layoutAlignSelf` limited to STRETCH — full range supported (CENTER, MAX, MIN, BASELINE)

View file

@ -0,0 +1,28 @@
import { zipSync, type Zippable } from 'fflate'
import { buildFigKiwi } from './kiwi-serialize'
interface CompressMessage {
schemaDeflated: Uint8Array
kiwiData: Uint8Array
thumbnailPng: Uint8Array
metaJson: string
images: Array<{ name: string; data: Uint8Array }>
}
self.onmessage = (e: MessageEvent<CompressMessage>) => {
const { schemaDeflated, kiwiData, thumbnailPng, metaJson, images } = e.data
const canvasData = buildFigKiwi(schemaDeflated, kiwiData)
const zipEntries: Zippable = {
'canvas.fig': [canvasData, { level: 0 }],
'thumbnail.png': [thumbnailPng, { level: 0 }],
'meta.json': new TextEncoder().encode(metaJson)
}
for (const entry of images) {
zipEntries[entry.name] = [entry.data, { level: 0 }]
}
const result = zipSync(zipEntries)
self.postMessage(result, { transfer: [result.buffer] })
}

View file

@ -243,6 +243,16 @@ export async function exportFigFile(
)
}
return compressFigData(schemaDeflated, kiwiData, thumbnailPng, metaJson, imageEntries)
}
export function compressFigDataSync(
schemaDeflated: Uint8Array,
kiwiData: Uint8Array,
thumbnailPng: Uint8Array,
metaJson: string,
imageEntries: Array<{ name: string; data: Uint8Array }>
): Uint8Array {
const canvasData = buildFigKiwi(schemaDeflated, kiwiData)
const zipEntries: Zippable = {
'canvas.fig': [canvasData, { level: 0 }],
@ -254,3 +264,58 @@ export async function exportFigFile(
}
return zipSync(zipEntries)
}
function canUseWorker(): boolean {
return typeof Worker !== 'undefined' && typeof window !== 'undefined'
}
function compressViaWorker(
schemaDeflated: Uint8Array,
kiwiData: Uint8Array,
thumbnailPng: Uint8Array,
metaJson: string,
imageEntries: Array<{ name: string; data: Uint8Array }>
): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./fig-export-worker.ts', import.meta.url), { type: 'module' })
worker.onmessage = (e: MessageEvent<Uint8Array>) => {
resolve(e.data)
worker.terminate()
}
worker.onerror = (err) => {
reject(new Error(err.message))
worker.terminate()
}
const imgCopies = imageEntries.map((e) => ({
name: e.name,
data: new Uint8Array(e.data)
}))
const transferables = [
schemaDeflated.buffer,
kiwiData.buffer,
thumbnailPng.buffer,
...imgCopies.map((e) => e.data.buffer)
]
worker.postMessage(
{ schemaDeflated, kiwiData, thumbnailPng, metaJson, images: imgCopies },
transferables
)
})
}
export function compressFigData(
schemaDeflated: Uint8Array,
kiwiData: Uint8Array,
thumbnailPng: Uint8Array,
metaJson: string,
imageEntries: Array<{ name: string; data: Uint8Array }>
): Promise<Uint8Array> {
if (canUseWorker()) {
return compressViaWorker(schemaDeflated, kiwiData, thumbnailPng, metaJson, imageEntries)
}
return Promise.resolve(compressFigDataSync(schemaDeflated, kiwiData, thumbnailPng, metaJson, imageEntries))
}

View file

@ -72,7 +72,13 @@ async function fetchGoogleFontFiles(family: string): Promise<Record<string, stri
if (googleFontsFailed.has(family)) return null
const url = `https://www.googleapis.com/webfonts/v1/webfonts?family=${encodeURIComponent(family)}&key=${GOOGLE_FONTS_API_KEY}`
const response = await fetch(url)
let response: Response
try {
response = await fetch(url)
} catch {
googleFontsFailed.add(family)
return null
}
if (!response.ok) {
const normalized = normalizeFontFamily(family)
if (normalized !== family) {

View file

@ -67,6 +67,7 @@ export type {
AnalyzeClustersArgs, AnalyzeClustersResult, TypographyStyle
} from './rpc'
export { SkiaRenderer, type RenderOverlays } from './renderer/index'
export { LabelCache, type CachedSection, type CachedComponent } from './renderer/label-cache'
export {
RenderProfiler,
FrameStats,
@ -138,7 +139,7 @@ export {
type SVGExportOptions
} from './svg-export'
export { svg, renderSVGNode, type SVGNode } from './svg-node'
export { exportFigFile } from './fig-export'
export { exportFigFile, compressFigData, compressFigDataSync } from './fig-export'
export {
FIG_KIWI_VERSION,
buildFigKiwi,

View file

@ -7,6 +7,7 @@ import { isZstdCompressed } from './protocol'
import type { SceneGraph } from '../scene-graph'
import type { FigmaMessage } from './codec'
import type { FigParseResult } from './fig-parse-worker'
interface FigKiwiPayload {
schemaDeflated: Uint8Array
@ -44,7 +45,7 @@ function parseFigKiwiContainer(data: Uint8Array): FigKiwiPayload | null {
return { schemaDeflated: chunks[0], dataRaw }
}
export async function parseFigFile(buffer: ArrayBuffer): Promise<SceneGraph> {
function parseFigFileSync(buffer: ArrayBuffer): SceneGraph {
const zip = unzipSync(new Uint8Array(buffer), {
filter: (file) =>
file.name === 'canvas.fig' ||
@ -103,9 +104,37 @@ export async function parseFigFile(buffer: ArrayBuffer): Promise<SceneGraph> {
return importNodeChanges(nodeChanges, blobs, images)
}
/**
* Read a .fig File object and parse it
*/
function parseViaWorker(buffer: ArrayBuffer): Promise<SceneGraph> {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./fig-parse-worker.ts', import.meta.url), { type: 'module' })
worker.onmessage = (e: MessageEvent<FigParseResult & { error?: string }>) => {
worker.terminate()
if (e.data.error) {
reject(new Error(e.data.error))
return
}
const { nodeChanges, blobs, images: imageEntries } = e.data
const images = new Map<string, Uint8Array>(imageEntries)
resolve(importNodeChanges(nodeChanges, blobs, images))
}
worker.onerror = (err) => {
worker.terminate()
reject(new Error(err.message))
}
worker.postMessage(buffer, [buffer])
})
}
export async function parseFigFile(buffer: ArrayBuffer): Promise<SceneGraph> {
if (typeof Worker !== 'undefined' && typeof window !== 'undefined') {
return parseViaWorker(buffer)
}
return parseFigFileSync(buffer)
}
export async function readFigFile(file: File): Promise<SceneGraph> {
const buffer = await file.arrayBuffer()
return parseFigFile(buffer)

View file

@ -0,0 +1,115 @@
import { unzipSync, inflateSync } from 'fflate'
import { decompress as zstdDecompress } from 'fzstd'
import { decodeBinarySchema, compileSchema, ByteBuffer } from './kiwi-schema'
import { isZstdCompressed } from './protocol'
import type { FigmaMessage, NodeChange } from './codec'
interface FigKiwiPayload {
schemaDeflated: Uint8Array
dataRaw: Uint8Array
}
function parseFigKiwiContainer(data: Uint8Array): FigKiwiPayload | null {
const header = new TextDecoder().decode(data.slice(0, 8))
if (header !== 'fig-kiwi') return null
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
let offset = 12
const chunks: Uint8Array[] = []
while (offset < data.length) {
const len = view.getUint32(offset, true)
offset += 4
chunks.push(data.slice(offset, offset + len))
offset += len
}
if (chunks.length < 2) return null
const compressed = chunks[1]
let dataRaw: Uint8Array
if (isZstdCompressed(compressed)) {
dataRaw = zstdDecompress(compressed)
} else {
try {
dataRaw = inflateSync(compressed)
} catch {
dataRaw = compressed
}
}
return { schemaDeflated: chunks[0], dataRaw }
}
export interface FigParseResult {
nodeChanges: NodeChange[]
blobs: Uint8Array[]
images: Array<[string, Uint8Array]>
}
self.onmessage = (e: MessageEvent<ArrayBuffer>) => {
try {
const buffer = e.data
const zip = unzipSync(new Uint8Array(buffer), {
filter: (file) =>
file.name === 'canvas.fig' ||
file.name === 'canvas' ||
(file.name.startsWith('images/') && file.name !== 'images/'),
})
const entries = Object.keys(zip)
let canvasData: Uint8Array | null = null
for (const name of entries) {
if (name === 'canvas.fig' || name === 'canvas') {
canvasData = zip[name]
break
}
}
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(', ')}`)
}
const payload = parseFigKiwiContainer(canvasData)
if (!payload) throw new Error('Invalid fig-kiwi container')
const schemaBytes = inflateSync(payload.schemaDeflated)
const schema = decodeBinarySchema(new ByteBuffer(schemaBytes))
const compiled = compileSchema(schema) as { decodeMessage(data: Uint8Array): unknown }
const message = compiled.decodeMessage(payload.dataRaw) as FigmaMessage
const nodeChanges = message.nodeChanges
if (!nodeChanges || nodeChanges.length === 0) {
throw new Error('No nodes found in .fig file')
}
const blobs: Uint8Array[] = (message.blobs ?? []).map((b) =>
b.bytes instanceof Uint8Array ? b.bytes : new Uint8Array(Object.values(b.bytes))
)
const images: Array<[string, Uint8Array]> = []
for (const name of entries) {
if (name.startsWith('images/') && name !== 'images/') {
images.push([name.replace('images/', ''), zip[name]])
}
}
const result: FigParseResult = { nodeChanges, blobs, images }
self.postMessage(result)
} catch (err) {
self.postMessage({ error: err instanceof Error ? err.message : String(err) })
}
}

View file

@ -0,0 +1,114 @@
import type { SceneGraph, SceneNode } from '../scene-graph'
export interface CachedSection {
nodeId: string
absX: number
absY: number
nested: boolean
}
export interface CachedComponent {
nodeId: string
absX: number
absY: number
parentType: string
}
interface Viewport {
x: number
y: number
w: number
h: number
}
const LABEL_TYPES = new Set(['COMPONENT', 'COMPONENT_SET'])
export class LabelCache {
private sections: CachedSection[] = []
private components: CachedComponent[] = []
private cachedSceneVersion = -1
private cachedPageId: string | null = null
update(graph: SceneGraph, pageId: string | null, sceneVersion: number): void {
if (sceneVersion === this.cachedSceneVersion && pageId === this.cachedPageId) return
this.rebuild(graph, pageId)
this.cachedSceneVersion = sceneVersion
this.cachedPageId = pageId
}
invalidate(): void {
this.cachedSceneVersion = -1
this.cachedPageId = null
this.sections = []
this.components = []
}
getSections(graph: SceneGraph, viewport: Viewport): Array<{ node: SceneNode; absX: number; absY: number; nested: boolean }> {
const result: Array<{ node: SceneNode; absX: number; absY: number; nested: boolean }> = []
for (const cached of this.sections) {
const node = graph.getNode(cached.nodeId)
if (!node) continue
if (
cached.absX + node.width >= viewport.x &&
cached.absY + node.height >= viewport.y &&
cached.absX <= viewport.x + viewport.w &&
cached.absY <= viewport.y + viewport.h
) {
result.push({ node, absX: cached.absX, absY: cached.absY, nested: cached.nested })
}
}
return result
}
getComponents(graph: SceneGraph, viewport: Viewport): Array<{ node: SceneNode; absX: number; absY: number; inside: boolean }> {
const result: Array<{ node: SceneNode; absX: number; absY: number; inside: boolean }> = []
for (const cached of this.components) {
const node = graph.getNode(cached.nodeId)
if (!node) continue
if (
cached.absX + node.width >= viewport.x &&
cached.absY + node.height >= viewport.y &&
cached.absX <= viewport.x + viewport.w &&
cached.absY <= viewport.y + viewport.h
) {
result.push({ node, absX: cached.absX, absY: cached.absY, inside: cached.parentType === 'COMPONENT_SET' })
}
}
return result
}
private rebuild(graph: SceneGraph, pageId: string | null): void {
this.sections = []
this.components = []
const pageNode = graph.getNode(pageId ?? graph.rootId)
if (!pageNode) return
this.walkChildren(graph, pageNode.id, 0, 0, false)
}
private walkChildren(graph: SceneGraph, parentId: string, ox: number, oy: number, insideSection: boolean): void {
const parent = graph.getNode(parentId)
if (!parent) return
const parentType = parent.type
for (const childId of parent.childIds) {
const child = graph.getNode(childId)
if (!child || !child.visible) continue
const ax = ox + child.x
const ay = oy + child.y
if (child.type === 'SECTION') {
this.sections.push({ nodeId: childId, absX: ax, absY: ay, nested: insideSection })
this.walkChildren(graph, childId, ax, ay, true)
} else if (LABEL_TYPES.has(child.type)) {
this.components.push({ nodeId: childId, absX: ax, absY: ay, parentType })
if (child.childIds.length > 0) {
this.walkChildren(graph, childId, ax, ay, insideSection)
}
} else if (child.childIds.length > 0) {
this.walkChildren(graph, childId, ax, ay, insideSection)
}
}
}
}

View file

@ -9,41 +9,14 @@ import {
COMPONENT_LABEL_ICON_GAP
} from '../constants'
import type { SceneNode, SceneGraph } from '../scene-graph'
import type { Canvas } from 'canvaskit-wasm'
import type { Canvas, Font } from 'canvaskit-wasm'
import type { SkiaRenderer } from './renderer'
export function drawSectionTitles(r: SkiaRenderer, canvas: Canvas, graph: SceneGraph): void {
if (!r.sectionTitleFont) return
const pageNode = graph.getNode(r.pageId ?? graph.rootId)
if (!pageNode) return
const sections: { node: SceneNode; absX: number; absY: number; nested: boolean }[] = []
const collectSections = (parentId: string, ox: number, oy: number, insideSection: boolean) => {
const parent = graph.getNode(parentId)
if (!parent) return
for (const childId of parent.childIds) {
const child = graph.getNode(childId)
if (!child || !child.visible) continue
const ax = ox + child.x
const ay = oy + child.y
if (child.type === 'SECTION') {
const vp = r.worldViewport
if (
ax + child.width >= vp.x &&
ay + child.height >= vp.y &&
ax <= vp.x + vp.w &&
ay <= vp.y + vp.h
) {
sections.push({ node: child, absX: ax, absY: ay, nested: insideSection })
}
collectSections(childId, ax, ay, true)
} else if (child.childIds.length > 0) {
collectSections(childId, ax, ay, insideSection)
}
}
}
collectSections(pageNode.id, 0, 0, false)
const sections = r.labelCache.getSections(graph, r.worldViewport)
if (sections.length === 0) return
const font = r.sectionTitleFont
const ellipsis = '…'
@ -51,6 +24,21 @@ export function drawSectionTitles(r: SkiaRenderer, canvas: Canvas, graph: SceneG
const ellipsisWidth = font.getGlyphWidths(ellipsisGlyphs)[0]
for (const { node, absX, absY, nested } of sections) {
drawSectionTitle(r, canvas, font, node, absX, absY, nested, ellipsis, ellipsisWidth)
}
}
function drawSectionTitle(
r: SkiaRenderer,
canvas: Canvas,
font: Font,
node: SceneNode,
absX: number,
absY: number,
nested: boolean,
ellipsis: string,
ellipsisWidth: number
): void {
const screenX = absX * r.zoom + r.panX
const screenY = absY * r.zoom + r.panY
const screenW = node.width * r.zoom
@ -107,50 +95,18 @@ export function drawSectionTitles(r: SkiaRenderer, canvas: Canvas, graph: SceneG
const textY = pillY + pillH * 0.7
canvas.drawText(displayText, pillX + SECTION_TITLE_PADDING_X, textY, r.auxFill, font)
}
}
export function drawComponentLabels(r: SkiaRenderer, canvas: Canvas, graph: SceneGraph): void {
if (!r.componentLabelFont) return
const pageNode = graph.getNode(r.pageId ?? graph.rootId)
if (!pageNode) return
const components = r.labelCache.getComponents(graph, r.worldViewport)
if (components.length === 0) return
const font = r.componentLabelFont
const LABEL_TYPES = new Set(['COMPONENT', 'COMPONENT_SET'])
const nodes: { node: SceneNode; absX: number; absY: number; inside: boolean }[] = []
const collect = (parentId: string, ox: number, oy: number) => {
const parent = graph.getNode(parentId)
if (!parent) return
for (const childId of parent.childIds) {
const child = graph.getNode(childId)
if (!child || !child.visible) continue
const ax = ox + child.x
const ay = oy + child.y
if (LABEL_TYPES.has(child.type)) {
const vp = r.worldViewport
if (
ax + child.width >= vp.x &&
ay + child.height >= vp.y &&
ax <= vp.x + vp.w &&
ay <= vp.y + vp.h
) {
const isInsideSet = parent.type === 'COMPONENT_SET'
nodes.push({ node: child, absX: ax, absY: ay, inside: isInsideSet })
}
}
if (child.childIds.length > 0) {
collect(childId, ax, ay)
}
}
}
collect(pageNode.id, 0, 0)
const compColor = r.compColor()
const iconS = COMPONENT_LABEL_ICON_SIZE
for (const { node, absX, absY, inside } of nodes) {
for (const { node, absX, absY, inside } of components) {
const screenX = absX * r.zoom + r.panX
const screenY = absY * r.zoom + r.panY

View file

@ -73,6 +73,7 @@ import {
drawSectionTitles as drawSectionTitlesFn,
drawComponentLabels as drawComponentLabelsFn
} from './labels'
import { LabelCache } from './label-cache'
import {
renderNode as renderNodeFn,
renderSection as renderSectionFn,
@ -183,6 +184,7 @@ export class SkiaRenderer {
scenePictureVersion = -1
scenePicturePageId: string | null = null
nodePictureCache = new Map<string, SkPicture>()
readonly labelCache = new LabelCache()
readonly profiler: RenderProfiler
rulerBgPaint: Paint
@ -677,6 +679,7 @@ export class SkiaRenderer {
canvas.save()
canvas.scale(this.dpr, this.dpr)
this.labelCache.update(graph, this.pageId, sceneVersion)
p.beginPhase('render:sectionTitles')
this.drawSectionTitles(canvas, graph)
p.endPhase('render:sectionTitles')

View file

@ -466,6 +466,7 @@ export class SceneGraph {
rootId: string
readonly emitter: Emitter<SceneGraphEvents> = createNanoEvents()
private absPosCache = new Map<string, Vector>()
private instanceIndex = new Map<string, Set<string>>()
constructor() {
const root = createDefaultNode('FRAME', {
@ -718,6 +719,15 @@ export class SceneGraph {
parent.childIds.push(node.id)
}
if (node.type === 'INSTANCE' && node.componentId) {
let set = this.instanceIndex.get(node.componentId)
if (!set) {
set = new Set()
this.instanceIndex.set(node.componentId, set)
}
set.add(node.id)
}
this.emitter.emit('node:created', node)
return node
}
@ -726,6 +736,17 @@ export class SceneGraph {
const node = this.nodes.get(id)
if (!node) return
this.absPosCache.clear()
if (node.type === 'INSTANCE' && 'componentId' in changes && changes.componentId !== node.componentId) {
if (node.componentId) this.instanceIndex.get(node.componentId)?.delete(id)
if (changes.componentId) {
let set = this.instanceIndex.get(changes.componentId)
if (!set) {
set = new Set()
this.instanceIndex.set(changes.componentId, set)
}
set.add(id)
}
}
Object.assign(node, changes)
this.emitter.emit('node:updated', id, changes)
}
@ -811,6 +832,9 @@ export class SceneGraph {
this.deleteNode(childId)
}
if (node.type === 'INSTANCE' && node.componentId) {
this.instanceIndex.get(node.componentId)?.delete(id)
}
this.nodes.delete(id)
this.emitter.emit('node:deleted', id)
}
@ -1166,6 +1190,9 @@ export class SceneGraph {
detachInstance(instanceId: string): void {
const node = this.nodes.get(instanceId)
if (node?.type !== 'INSTANCE') return
if (node.componentId) {
this.instanceIndex.get(node.componentId)?.delete(instanceId)
}
node.type = 'FRAME'
node.componentId = null
node.overrides = {}
@ -1178,11 +1205,12 @@ export class SceneGraph {
}
getInstances(componentId: string): SceneNode[] {
const ids = this.instanceIndex.get(componentId)
if (!ids) return []
const instances: SceneNode[] = []
for (const node of this.nodes.values()) {
if (node.type === 'INSTANCE' && node.componentId === componentId) {
instances.push(node)
}
for (const id of ids) {
const node = this.nodes.get(id)
if (node) instances.push(node)
}
return instances
}

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, shallowRef, watch } from 'vue'
import { computed, ref, shallowRef, watch } from 'vue'
import { useFileDialog, useObjectUrl } from '@vueuse/core'
import {
PopoverRoot,

View file

@ -651,14 +651,19 @@ export function createEditorStore() {
}
}
function yieldToUI(): Promise<void> {
return new Promise((r) => requestAnimationFrame(() => r()))
}
async function openFigFile(file: File, handle?: FileSystemFileHandle, path?: string) {
try {
state.loading = true
await new Promise((r) => requestAnimationFrame(r))
await yieldToUI()
const imported = await readFigFile(file)
await yieldToUI()
graph = imported
subscribeToGraph()
computeAllLayouts(graph)
subscribeToGraph()
undo.clear()
pageViewports.clear()
fileHandle = handle ?? null
@ -673,11 +678,12 @@ export function createEditorStore() {
state.panY = 0
state.zoom = 1
state.pageColor = { ...CANVAS_BG_COLOR }
await loadFontsForNodes(graph.getChildren(pageId).map((n) => n.id))
requestRender()
void loadFontsForNodes(graph.getChildren(pageId).map((n) => n.id))
void startWatchingFile()
} catch (e) {
console.error('Failed to open .fig file:', e)
toast.show(`Failed to open file: ${e instanceof Error ? e.message : String(e)}`, 'error')
} finally {
state.loading = false
}
@ -784,14 +790,14 @@ export function createEditorStore() {
const file = new File([blob], state.documentName + '.fig')
const imported = await readFigFile(file)
graph = imported
subscribeToGraph()
computeAllLayouts(graph)
subscribeToGraph()
} else if (fileHandle) {
const file = await fileHandle.getFile()
const imported = await readFigFile(file)
graph = imported
subscribeToGraph()
computeAllLayouts(graph)
subscribeToGraph()
} else {
return
}
@ -1966,7 +1972,17 @@ export function createEditorStore() {
const toLoad = collectFontKeys(graph, nodeIds)
if (toLoad.length === 0) return
await Promise.all(toLoad.map(([family, style]) => loadFont(family, style)))
const results = await Promise.all(toLoad.map(([family, style]) => loadFont(family, style)))
const failed = toLoad.filter((_, i) => results[i] === null)
if (failed.length > 0) {
const families = [...new Set(failed.map(([family]) => family))]
toast.show(
families.length === 1
? `Font "${families[0]}" could not be loaded`
: `${families.length} fonts could not be loaded: ${families.join(', ')}`,
'warning'
)
}
computeAllLayouts(graph, state.currentPageId)
requestRender()
}

View file

@ -0,0 +1,90 @@
import { describe, test, expect, beforeAll, setDefaultTimeout } from 'bun:test'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import {
parseFigFile,
exportFigFile,
compressFigDataSync,
initCodec,
SceneGraph
} from '@open-pencil/core'
import { heavy } from '../helpers/test-utils'
setDefaultTimeout(30_000)
const FIXTURES = resolve(import.meta.dir, '../fixtures')
describe('fig export compression', () => {
test('compressFigDataSync produces valid zip', async () => {
await initCodec()
const schemaDeflated = new Uint8Array([0x78, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01])
const kiwiData = new Uint8Array([1, 2, 3, 4, 5])
const thumbnailPng = new Uint8Array([0x89, 0x50, 0x4e, 0x47])
const metaJson = JSON.stringify({ version: 1, app: 'test' })
const result = compressFigDataSync(
schemaDeflated,
kiwiData,
thumbnailPng,
metaJson,
[]
)
expect(result).toBeInstanceOf(Uint8Array)
expect(result.length).toBeGreaterThan(0)
expect(result[0]).toBe(0x50)
expect(result[1]).toBe(0x4b)
})
test('compressFigDataSync with images produces valid zip', async () => {
await initCodec()
const schemaDeflated = new Uint8Array([0x78, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01])
const kiwiData = new Uint8Array([10, 20, 30])
const thumbnailPng = new Uint8Array([0x89, 0x50])
const metaJson = JSON.stringify({ version: 1, app: 'test' })
const images = [
{ name: 'images/abc123', data: new Uint8Array([0xff, 0xd8, 0xff, 0xe0]) }
]
const result = compressFigDataSync(
schemaDeflated,
kiwiData,
thumbnailPng,
metaJson,
images
)
expect(result).toBeInstanceOf(Uint8Array)
expect(result.length).toBeGreaterThan(0)
expect(result[0]).toBe(0x50)
expect(result[1]).toBe(0x4b)
})
})
heavy('fig export roundtrip', () => {
let parsed: SceneGraph
beforeAll(async () => {
const buf = readFileSync(resolve(FIXTURES, 'gold-preview.fig'))
parsed = await parseFigFile(buf.buffer as ArrayBuffer)
})
test('exportFigFile produces valid .fig', async () => {
const exported = await exportFigFile(parsed)
expect(exported).toBeInstanceOf(Uint8Array)
expect(exported.length).toBeGreaterThan(100)
expect(exported[0]).toBe(0x50)
expect(exported[1]).toBe(0x4b)
})
test('exported file can be parsed back', async () => {
const exported = await exportFigFile(parsed)
const reparsed = await parseFigFile(exported.buffer as ArrayBuffer)
expect(reparsed).toBeInstanceOf(SceneGraph)
expect(reparsed.getPages().length).toBeGreaterThan(0)
})
})

View file

@ -0,0 +1,216 @@
import { describe, it, expect } from 'bun:test'
import { SceneGraph, LabelCache } from '@open-pencil/core'
function buildGraph() {
const g = new SceneGraph()
const pageId = g.getPages()[0].id
const sectionId = g.createNode('SECTION', pageId, {
name: 'Section 1',
x: 0,
y: 0,
width: 200,
height: 200
}).id
const nestedSectionId = g.createNode('SECTION', sectionId, {
name: 'Nested Section',
x: 10,
y: 10,
width: 100,
height: 100
}).id
const compSetId = g.createNode('COMPONENT_SET', pageId, {
name: 'Button Set',
x: 300,
y: 0,
width: 200,
height: 200
}).id
const compId = g.createNode('COMPONENT', compSetId, {
name: 'Button',
x: 10,
y: 10,
width: 80,
height: 40
}).id
const standaloneCompId = g.createNode('COMPONENT', pageId, {
name: 'Standalone',
x: 600,
y: 0,
width: 100,
height: 50
}).id
return { g, pageId, sectionId, nestedSectionId, compSetId, compId, standaloneCompId }
}
describe('LabelCache', () => {
it('collects sections on first call', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
const sections = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
expect(sections.length).toBe(2)
expect(sections.map((s) => s.node.name).sort()).toEqual(['Nested Section', 'Section 1'])
})
it('returns cached data when sceneVersion unchanged', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
const first = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
cache.update(g, pageId, 1)
const second = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
expect(first.length).toBe(second.length)
expect(first.map((s) => s.node.id)).toEqual(second.map((s) => s.node.id))
})
it('invalidates when sceneVersion changes', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
const before = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
g.createNode('SECTION', pageId, { name: 'New Section', x: 500, y: 500, width: 100, height: 100 })
cache.update(g, pageId, 2)
const after = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
expect(after.length).toBe(before.length + 1)
})
it('invalidates when pageId changes', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
expect(cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 }).length).toBe(2)
const page2 = g.addPage('Page 2')
cache.update(g, page2.id, 1)
expect(cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 }).length).toBe(0)
})
it('filters sections by viewport', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
const all = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
expect(all.length).toBe(2)
const visible = cache.getSections(g, { x: 0, y: 0, w: 50, h: 50 })
expect(visible.length).toBeGreaterThan(0)
const offscreen = cache.getSections(g, { x: 5000, y: 5000, w: 100, h: 100 })
expect(offscreen.length).toBe(0)
})
it('tracks nested sections', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
const sections = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
const topLevel = sections.find((s) => s.node.name === 'Section 1')
const nested = sections.find((s) => s.node.name === 'Nested Section')
expect(topLevel?.nested).toBe(false)
expect(nested?.nested).toBe(true)
})
it('collects components and component sets', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
const components = cache.getComponents(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
expect(components.length).toBe(3)
const names = components.map((c) => c.node.name).sort()
expect(names).toEqual(['Button', 'Button Set', 'Standalone'])
})
it('identifies components inside component sets', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
const components = cache.getComponents(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
const buttonSet = components.find((c) => c.node.name === 'Button Set')
const button = components.find((c) => c.node.name === 'Button')
const standalone = components.find((c) => c.node.name === 'Standalone')
expect(buttonSet?.inside).toBe(false)
expect(button?.inside).toBe(true)
expect(standalone?.inside).toBe(false)
})
it('skips invisible nodes', () => {
const { g, pageId, sectionId } = buildGraph()
const cache = new LabelCache()
g.updateNode(sectionId, { visible: false })
cache.update(g, pageId, 1)
const sections = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
const names = sections.map((s) => s.node.name)
expect(names).not.toContain('Section 1')
expect(names).not.toContain('Nested Section')
})
it('handles empty graph', () => {
const g = new SceneGraph()
const pageId = g.getPages()[0].id
const cache = new LabelCache()
cache.update(g, pageId, 0)
expect(cache.getSections(g, { x: 0, y: 0, w: 1000, h: 1000 })).toEqual([])
expect(cache.getComponents(g, { x: 0, y: 0, w: 1000, h: 1000 })).toEqual([])
})
it('explicit invalidate() forces recollection', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
expect(cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 }).length).toBe(2)
g.createNode('SECTION', pageId, { name: 'Sneaky', x: 800, y: 0, width: 50, height: 50 })
cache.update(g, pageId, 1)
expect(cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 }).length).toBe(2)
cache.invalidate()
cache.update(g, pageId, 1)
expect(cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 }).length).toBe(3)
})
it('computes correct absolute positions for nested nodes', () => {
const { g, pageId } = buildGraph()
const cache = new LabelCache()
cache.update(g, pageId, 1)
const sections = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
const nested = sections.find((s) => s.node.name === 'Nested Section')
expect(nested?.absX).toBe(10)
expect(nested?.absY).toBe(10)
const components = cache.getComponents(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
const button = components.find((c) => c.node.name === 'Button')
expect(button?.absX).toBe(310)
expect(button?.absY).toBe(10)
})
})