From 2544b3e4db0be5e23362ff932f77e9a2dc110a0b Mon Sep 17 00:00:00 2001 From: Victor Wads Date: Mon, 17 Aug 2026 19:05:39 -0300 Subject: [PATCH] perf(fig): show pages before full document decode --- CHANGELOG.md | 1 + packages/core/src/io/formats/fig/read.ts | 20 +- packages/core/src/kiwi/fig/parse/worker.ts | 7 +- packages/fig/src/archive.ts | 36 ++- packages/kiwi/src/fig/index.ts | 1 + packages/kiwi/src/fig/page-manifest.ts | 246 +++++++++++++++++++++ packages/kiwi/src/fig/parse.ts | 14 +- packages/kiwi/src/schema-runtime/bb.ts | 19 ++ src/app/document/io/fig-page-manifest.ts | 29 +++ src/app/document/io/read.ts | 4 +- src/app/tabs/index.ts | 26 ++- tests/engine/io/fig/page-manifest.test.ts | 39 ++++ 12 files changed, 414 insertions(+), 28 deletions(-) create mode 100644 packages/kiwi/src/fig/page-manifest.ts create mode 100644 src/app/document/io/fig-page-manifest.ts create mode 100644 tests/engine/io/fig/page-manifest.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index adff3b329..a837b94fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ ### Performance +- Show the FIG page list from a lightweight Kiwi scan before materializing the full document, making large files feel responsive sooner. - Use cached axis-aligned world positions for hit testing untransformed layer chains and add representative 500/2,000-node interaction profiles. (#527) - Coalesce writable-document autosaves that overlap an active `.fig` export while preserving a trailing save for newer edits. (#528) - Defer JSX generation and syntax highlighting until the Code panel is active, keeping large canvas selections responsive. (#500) diff --git a/packages/core/src/io/formats/fig/read.ts b/packages/core/src/io/formats/fig/read.ts index 81aff70a0..71e760c6d 100644 --- a/packages/core/src/io/formats/fig/read.ts +++ b/packages/core/src/io/formats/fig/read.ts @@ -1,4 +1,5 @@ import { parseFigBuffer } from '@open-pencil/fig' +import type { FigPageManifestEntry } from '@open-pencil/kiwi/fig' import type { SceneGraph } from '@open-pencil/scene-graph' import { IS_BROWSER } from '#core/constants' @@ -9,6 +10,7 @@ import { registerFigPopulationWorker } from '#core/kiwi/fig/population/client' export interface ParseFigFileOptions { populate?: 'all' | 'first-page' | 'none' + onPages?: (pages: readonly FigPageManifestEntry[]) => void } function parseFigFileSync(buffer: ArrayBuffer, options: ParseFigFileOptions = {}): SceneGraph { @@ -18,18 +20,26 @@ function parseFigFileSync(buffer: ArrayBuffer, options: ParseFigFileOptions = {} images: imageEntries, figKiwiVersion, figSchemaDeflated - } = parseFigBuffer(buffer) + } = parseFigBuffer(buffer, options.onPages) const graph = importNodeChanges(nodeChanges, blobs, new Map(imageEntries), options) graph.figKiwiVersion = figKiwiVersion graph.figSchemaDeflated = figSchemaDeflated return graph } -interface WorkerParseResult { +interface WorkerGraphResult { + type: 'graph' graph?: SerializedSceneGraph error?: string } +interface WorkerPageManifestResult { + type: 'page-manifest' + pages: FigPageManifestEntry[] +} + +type WorkerParseResult = WorkerGraphResult | WorkerPageManifestResult + function parseViaWorker(buffer: ArrayBuffer, options: ParseFigFileOptions): Promise { return new Promise((resolve, reject) => { const worker = new Worker(new URL('../../../kiwi/fig/parse/worker.ts', import.meta.url), { @@ -37,6 +47,10 @@ function parseViaWorker(buffer: ArrayBuffer, options: ParseFigFileOptions): Prom }) worker.onmessage = (e: MessageEvent) => { + if (e.data.type === 'page-manifest') { + options.onPages?.(e.data.pages) + return + } if (e.data.error || !e.data.graph) { worker.terminate() reject(new Error(e.data.error ?? 'Worker failed to parse .fig file')) @@ -61,7 +75,7 @@ function parseViaWorker(buffer: ArrayBuffer, options: ParseFigFileOptions): Prom reject(new Error(err.message || 'Worker failed to parse .fig file')) } - worker.postMessage({ buffer, options }, [buffer]) + worker.postMessage({ buffer, options: { populate: options.populate } }, [buffer]) }) } diff --git a/packages/core/src/kiwi/fig/parse/worker.ts b/packages/core/src/kiwi/fig/parse/worker.ts index 9826b3018..580279b68 100644 --- a/packages/core/src/kiwi/fig/parse/worker.ts +++ b/packages/core/src/kiwi/fig/parse/worker.ts @@ -58,7 +58,8 @@ self.onmessage = (event: MessageEvent) => { const parseRequest: WorkerParseRequest = request instanceof ArrayBuffer ? { buffer: request } : request const { nodeChanges, blobs, images, figKiwiVersion, figSchemaDeflated } = parseFigBuffer( - parseRequest.buffer + parseRequest.buffer, + (pages) => postWorkerMessage({ type: 'page-manifest', pages }, []) ) const parsedGraph = importNodeChanges(nodeChanges, blobs, new Map(images), parseRequest.options) parsedGraph.figKiwiVersion = figKiwiVersion @@ -69,13 +70,13 @@ self.onmessage = (event: MessageEvent) => { parseRequest.options?.populate === 'first-page' ? [] : serializedSceneGraphTransferList(serialized) - postWorkerMessage({ graph: serialized }, transfer) + postWorkerMessage({ type: 'graph', graph: serialized }, transfer) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) postWorkerMessage( isPopulateRequest(request) ? { type: 'population-error', error: errorMessage } - : { error: errorMessage }, + : { type: 'graph', error: errorMessage }, [] ) } diff --git a/packages/fig/src/archive.ts b/packages/fig/src/archive.ts index cc5e7dc39..f24adcb65 100644 --- a/packages/fig/src/archive.ts +++ b/packages/fig/src/archive.ts @@ -1,5 +1,6 @@ -import { unzipSync, zipSync, type Zippable } from 'fflate' +import { unzipSync, zipSync, type Unzipped, type Zippable } from 'fflate' +import type { FigPageManifestEntry } from '@open-pencil/kiwi/fig' import type { NodeChange } from '@open-pencil/kiwi/fig/codec' import { buildFigKiwi } from '@open-pencil/kiwi/fig/container' import { decodeFigKiwiCanvas } from '@open-pencil/kiwi/fig/parse' @@ -46,17 +47,34 @@ function findCanvasData(entries: Partial>): Uint8Arra return largest } +function isCanonicalCanvasEntry(name: string): boolean { + return name === 'canvas.fig' || name === 'canvas' +} + /** Parse a complete zipped `.fig` file into its Figma protocol payload and binary resources. */ -export function parseFigBuffer(buffer: ArrayBuffer): FigParseResult { - const archive = unzipSync(new Uint8Array(buffer)) - const canvasData = findCanvasData(archive) - if (!canvasData) { - throw new Error( - `No canvas data found in .fig file. Entries: ${Object.keys(archive).join(', ')}` - ) +export function parseFigBuffer( + buffer: ArrayBuffer, + onPages?: (pages: FigPageManifestEntry[]) => void +): FigParseResult { + const bytes = new Uint8Array(buffer) + const canvasArchive = unzipSync(bytes, { filter: ({ name }) => isCanonicalCanvasEntry(name) }) + let canvasData = findCanvasData(canvasArchive) + let archive: Unzipped + let decoded: ReturnType + if (canvasData) { + decoded = decodeFigKiwiCanvas(canvasData, onPages) + archive = unzipSync(bytes, { filter: ({ name }) => !isCanonicalCanvasEntry(name) }) + } else { + archive = unzipSync(bytes) + canvasData = findCanvasData(archive) + if (!canvasData) { + throw new Error( + `No canvas data found in .fig file. Entries: ${Object.keys(archive).join(', ')}` + ) + } + decoded = decodeFigKiwiCanvas(canvasData, onPages) } - const decoded = decodeFigKiwiCanvas(canvasData) const metaBytes = archive['meta.json'] const images = Object.entries(archive) .filter(([name]) => name.startsWith('images/') && name !== 'images/') diff --git a/packages/kiwi/src/fig/index.ts b/packages/kiwi/src/fig/index.ts index fab295d33..a7a3c5ab6 100644 --- a/packages/kiwi/src/fig/index.ts +++ b/packages/kiwi/src/fig/index.ts @@ -1,4 +1,5 @@ export { default as figmaSchema } from './schema' +export * from './page-manifest' export * from './protocol' export * from './types' export * from './variable-bindings' diff --git a/packages/kiwi/src/fig/page-manifest.ts b/packages/kiwi/src/fig/page-manifest.ts new file mode 100644 index 000000000..084594d1a --- /dev/null +++ b/packages/kiwi/src/fig/page-manifest.ts @@ -0,0 +1,246 @@ +import { ByteBuffer } from '../schema-runtime/bb' +import type { Definition, Field, Schema } from '../schema-runtime/schema' + +export interface FigPageManifestEntry { + sourceId: string + name: string + position: string | null + internalOnly: boolean +} + +interface PageCandidate extends FigPageManifestEntry { + parentId: string | null + phase: string | null + type: string | null +} + +type DefinitionMap = Map +interface ScanContext { + definitions: DefinitionMap + fields: Map> +} + +function fieldMap(definition: Definition): Map { + return new Map(definition.fields.map((field) => [field.value, field])) +} + +function cachedFieldMap(context: ScanContext, definition: Definition): Map { + let fields = context.fields.get(definition.name) + if (!fields) { + fields = fieldMap(definition) + context.fields.set(definition.name, fields) + } + return fields +} + +function skipSingleValue(bb: ByteBuffer, field: Field, context: ScanContext): void { + switch (field.type) { + case 'bool': + case 'byte': + bb.readByte() + return + case 'int': + bb.readVarInt() + return + case 'uint': + bb.readVarUint() + return + case 'float': + bb.readVarFloat() + return + case 'string': + bb.skipString() + return + case 'int64': + bb.readVarInt64() + return + case 'uint64': + bb.readVarUint64() + return + } + + const definition = field.type ? context.definitions.get(field.type) : undefined + if (!definition) throw new Error(`Invalid Kiwi field type: ${String(field.type)}`) + if (definition.kind === 'ENUM') { + bb.readVarUint() + return + } + skipDefinition(bb, definition, context) +} + +function skipFieldValue(bb: ByteBuffer, field: Field, context: ScanContext): void { + if (!field.isArray) { + skipSingleValue(bb, field, context) + return + } + if (field.type === 'byte') { + bb.skipByteArray() + return + } + let length = bb.readVarUint() + while (length-- > 0) skipSingleValue(bb, field, context) +} + +function skipDefinition(bb: ByteBuffer, definition: Definition, context: ScanContext): void { + if (definition.kind === 'STRUCT') { + for (const field of definition.fields) skipFieldValue(bb, field, context) + return + } + + const fields = cachedFieldMap(context, definition) + for (;;) { + const fieldId = bb.readVarUint() + if (fieldId === 0) return + const field = fields.get(fieldId) + if (!field) throw new Error(`Invalid field ${fieldId} in Kiwi ${definition.name}`) + skipFieldValue(bb, field, context) + } +} + +function enumName(bb: ByteBuffer, field: Field, context: ScanContext): string | null { + const value = bb.readVarUint() + const definition = field.type ? context.definitions.get(field.type) : undefined + return definition?.kind === 'ENUM' + ? (definition.fields.find((candidate) => candidate.value === value)?.name ?? null) + : null +} + +function scanNodeChange( + bb: ByteBuffer, + definition: Definition, + context: ScanContext +): PageCandidate | null { + const fields = cachedFieldMap(context, definition) + let sourceSessionId: number | null = null + let sourceLocalId: number | null = null + let parentSessionId: number | null = null + let parentLocalId: number | null = null + let positionOffset: number | null = null + let phase: string | null = null + let type: string | null = null + let name: string | null = null + let internalOnly = false + + for (;;) { + const fieldId = bb.readVarUint() + if (fieldId === 0) break + const field = fields.get(fieldId) + if (!field) throw new Error(`Invalid field ${fieldId} in Kiwi NodeChange`) + switch (field.name) { + case 'guid': + sourceSessionId = bb.readVarUint() + sourceLocalId = bb.readVarUint() + break + case 'parentIndex': + parentSessionId = bb.readVarUint() + parentLocalId = bb.readVarUint() + positionOffset = bb.offset + bb.skipString() + break + case 'phase': + phase = enumName(bb, field, context) + break + case 'type': + type = enumName(bb, field, context) + break + case 'name': + if (type === 'DOCUMENT' || type === 'CANVAS') name = bb.readString() + else bb.skipString() + break + case 'internalOnly': + internalOnly = !!bb.readByte() + break + default: + skipFieldValue(bb, field, context) + } + } + + if ( + (type !== 'DOCUMENT' && type !== 'CANVAS') || + sourceSessionId === null || + sourceLocalId === null + ) { + return null + } + let position: string | null = null + if (positionOffset !== null) { + const endOffset = bb.offset + bb.offset = positionOffset + position = bb.readString() + bb.offset = endOffset + } + const parentId = + parentSessionId === null || parentLocalId === null + ? null + : `${parentSessionId}:${parentLocalId}` + return { + sourceId: `${sourceSessionId}:${sourceLocalId}`, + parentId, + position, + phase, + type, + name: name ?? 'Page', + internalOnly + } +} + +/** + * Scans decoded Kiwi bytes without materializing NodeChange objects. Kiwi messages are not + * length-prefixed, so every value is visited, but only document/page fields are allocated. + */ +export function extractFigPageManifest(schema: Schema, data: Uint8Array): FigPageManifestEntry[] { + const definitions: DefinitionMap = new Map( + schema.definitions.map((definition) => [definition.name, definition]) + ) + const context: ScanContext = { definitions, fields: new Map() } + const message = definitions.get('Message') + const nodeChange = definitions.get('NodeChange') + if (message?.kind !== 'MESSAGE' || nodeChange?.kind !== 'MESSAGE') { + return [] + } + + const bb = new ByteBuffer(data) + const messageFields = cachedFieldMap(context, message) + const candidates: PageCandidate[] = [] + for (;;) { + const fieldId = bb.readVarUint() + if (fieldId === 0) break + const field = messageFields.get(fieldId) + if (!field) throw new Error(`Invalid field ${fieldId} in Kiwi Message`) + if (field.name !== 'nodeChanges' || !field.isArray) { + skipFieldValue(bb, field, context) + continue + } + + let length = bb.readVarUint() + while (length-- > 0) { + const candidate = scanNodeChange(bb, nodeChange, context) + if (candidate) candidates.push(candidate) + } + break + } + + const documentId = candidates.find( + (candidate) => candidate.type === 'DOCUMENT' && candidate.phase !== 'REMOVED' + )?.sourceId + return candidates + .filter( + (candidate) => + candidate.type === 'CANVAS' && + candidate.phase !== 'REMOVED' && + (!documentId || candidate.parentId === documentId) + ) + .sort((a, b) => { + const aPosition = a.position ?? '' + const bPosition = b.position ?? '' + if (aPosition < bPosition) return -1 + if (aPosition > bPosition) return 1 + return 0 + }) + .map(({ sourceId, name, position, internalOnly }) => ({ + sourceId, + name, + position, + internalOnly + })) +} diff --git a/packages/kiwi/src/fig/parse.ts b/packages/kiwi/src/fig/parse.ts index d12e5cb5c..1113035e8 100644 --- a/packages/kiwi/src/fig/parse.ts +++ b/packages/kiwi/src/fig/parse.ts @@ -3,6 +3,7 @@ import { decompress as zstdDecompress } from 'fzstd' import { decodeBinarySchema, compileSchema, ByteBuffer } from '../schema-runtime' import type { FigmaMessage, NodeChange } from './codec' +import { extractFigPageManifest, type FigPageManifestEntry } from './page-manifest' import { isZstdCompressed } from './protocol' export type { NodeChange } from './codec' @@ -93,12 +94,23 @@ export interface FigKiwiDecodeResult { } /** Decode one raw `fig-kiwi` canvas payload. Outer `.fig` archive handling lives in `@open-pencil/fig`. */ -export function decodeFigKiwiCanvas(data: Uint8Array): FigKiwiDecodeResult { +export function decodeFigKiwiCanvas( + data: Uint8Array, + onPages?: (pages: FigPageManifestEntry[]) => void +): FigKiwiDecodeResult { const payload = parseFigKiwiContainer(data) if (!payload) throw new Error('Invalid fig-kiwi container') const schemaBytes = inflateSync(payload.schemaDeflated) const schema = decodeBinarySchema(new ByteBuffer(schemaBytes)) + if (onPages) { + try { + const pages = extractFigPageManifest(schema, payload.dataRaw) + if (pages.length > 0) onPages(pages) + } catch (error) { + console.warn('Failed to scan FIG page manifest; continuing with full decode:', error) + } + } const compiled = compileSchema(schema) as CompiledKiwiSchema const message = compiled.decodeMessage(payload.dataRaw) as FigmaMessage diff --git a/packages/kiwi/src/schema-runtime/bb.ts b/packages/kiwi/src/schema-runtime/bb.ts index e99b89e69..0c02d9153 100644 --- a/packages/kiwi/src/schema-runtime/bb.ts +++ b/packages/kiwi/src/schema-runtime/bb.ts @@ -16,6 +16,14 @@ export class ByteBuffer { this.length = data ? data.length : 0 } + get offset(): number { + return this._index + } + + set offset(value: number) { + this._index = value + } + /** * Returns a view into the internal buffer, not a copy. * @@ -39,6 +47,11 @@ export class ByteBuffer { return this._data.slice(start, start + length) } + skipByteArray(): void { + const length = this.readVarUint() + this._index += length + } + readVarFloat(): number { const index = this._index const data = this._data @@ -123,6 +136,12 @@ export class ByteBuffer { return textDecoder.decode(data.subarray(start, i)) } + skipString(): void { + while (this._data[this._index++] !== 0) { + // Strings are null-terminated in Kiwi messages. + } + } + private _growBy(amount: number): void { if (this.length + amount > this._data.length) { let data = new Uint8Array((this.length + amount) << 1) diff --git a/src/app/document/io/fig-page-manifest.ts b/src/app/document/io/fig-page-manifest.ts new file mode 100644 index 000000000..03e0f793d --- /dev/null +++ b/src/app/document/io/fig-page-manifest.ts @@ -0,0 +1,29 @@ +import type { Editor } from '@open-pencil/core/editor' +import { readFigFile } from '@open-pencil/core/io/formats/fig' +import type { FigPageManifestEntry } from '@open-pencil/kiwi/fig' +import { SceneGraph } from '@open-pencil/scene-graph' + +/** Show lightweight page shells while the FIG worker continues decoding the full document. */ +export function showFigPageManifest(editor: Editor, pages: readonly FigPageManifestEntry[]): void { + if (pages.length === 0) return + + const graph = new SceneGraph() + for (const page of graph.getPages(true)) graph.deleteNode(page.id) + for (const entry of pages) { + const page = graph.addPage(entry.name) + page.internalOnly = entry.internalOnly + page.source.format = 'fig' + page.source.id = entry.sourceId + page.source.orderKey = entry.position + } + + editor.replaceGraph(graph) + editor.state.loading = true +} + +export function readFigFileProgressively(file: File, editor: Editor) { + return readFigFile(file, { + populate: 'first-page', + onPages: (pages) => showFigPageManifest(editor, pages) + }) +} diff --git a/src/app/document/io/read.ts b/src/app/document/io/read.ts index 1349afed8..44a642887 100644 --- a/src/app/document/io/read.ts +++ b/src/app/document/io/read.ts @@ -1,8 +1,8 @@ import type { Editor, EditorState } from '@open-pencil/core/editor' -import { readFigFile } from '@open-pencil/core/io/formats/fig' import { computeAllLayouts } from '@open-pencil/core/layout' import { yieldToUI } from '@/app/document/io/browser' +import { readFigFileProgressively } from '@/app/document/io/fig-page-manifest' import { applyImportedDocument } from '@/app/document/io/imported-document' import { readReloadSource } from '@/app/document/io/reload-source' import { captureReloadState, restoreReloadState } from '@/app/document/io/reload-state' @@ -45,7 +45,7 @@ export function createOpenActions({ try { state.loading = true await yieldToUI() - const imported = await readFigFile(file, { populate: 'first-page' }) + const imported = await readFigFileProgressively(file, editor) await yieldToUI() await applyImportedDocument(editor, imported) state.documentName = file.name.replace(/\.fig$/i, '') diff --git a/src/app/tabs/index.ts b/src/app/tabs/index.ts index cd6953b7d..fc3cd23e1 100644 --- a/src/app/tabs/index.ts +++ b/src/app/tabs/index.ts @@ -1,11 +1,11 @@ import { shallowRef, computed, triggerRef } from 'vue' import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io' -import { readFigFile } from '@open-pencil/core/io/formats/fig' import { computeAllLayouts } from '@open-pencil/core/layout' import type { SceneGraph } from '@open-pencil/scene-graph' import { setOpenPencilStore } from '@/app/browser-bridge' +import { readFigFileProgressively } from '@/app/document/io/fig-page-manifest' import type { DocumentSourceIdentity } from '@/app/document/io/types' import { getRecoveryStore, type RecoverySnapshotMeta } from '@/app/document/recovery' import { setActiveEditorStore } from '@/app/editor/active-store' @@ -129,6 +129,13 @@ function reusableTabStore(): EditorStore { return isUntouched ? current.store : createTab().store } +async function readFigForTab(file: File, store: EditorStore): Promise { + const imported = await readFigFileProgressively(file, store) + const firstPageId = imported.getPages()[0]?.id + if (firstPageId) computeAllLayouts(imported, firstPageId) + return imported +} + function findStorageTab(providerId: string, documentId: string): Tab | undefined { return tabsRef.value.find((tab) => { const binding = tab.store.getStorageBinding() @@ -173,9 +180,7 @@ export async function openStorageDocumentInNewTab(document: StorageDocument): Pr const file = new File([fileBytes.buffer], `${document.name}.fig`, { type: 'application/octet-stream' }) - const imported = await readFigFile(file, { populate: 'first-page' }) - const firstPageId = imported.getPages()[0]?.id - if (firstPageId) computeAllLayouts(imported, firstPageId) + const imported = await readFigForTab(file, store) store.replaceGraph(imported) store.undo.clear() store.setStorageDocumentSource({ providerId, documentId: document.id }, document.name) @@ -239,7 +244,10 @@ export async function openFileInNewTab( await yieldToUI() const isFig = file.name.toLowerCase().endsWith('.fig') const { graph: imported, sourceFormat } = isFig - ? { graph: await readFigFile(file, { populate: 'first-page' }), sourceFormat: 'fig' } + ? { + graph: await readFigForTab(file, store), + sourceFormat: 'fig' + } : await io.readDocument({ name: file.name, mimeType: file.type || undefined, @@ -247,7 +255,7 @@ export async function openFileInNewTab( }) const firstPageId = imported.getPages()[0]?.id - if (firstPageId) computeAllLayouts(imported, firstPageId) + if (!isFig && firstPageId) computeAllLayouts(imported, firstPageId) store.replaceGraph(imported) store.undo.clear() store.setDocumentSource(file.name, sourceFormat, handle, path) @@ -277,15 +285,13 @@ export async function restoreRecoverySnapshot(id: string): Promise { const snapshot = await getRecoveryStore().read(id) if (!snapshot) throw new Error('Recovery snapshot is no longer available') + const store = reusableTabStore() const fileBytes = new Uint8Array(snapshot.figBytes) const file = new File([fileBytes.buffer], `${snapshot.documentName}.fig`, { type: 'application/octet-stream' }) - const imported = await readFigFile(file, { populate: 'first-page' }) - const firstPageId = imported.getPages()[0]?.id - if (firstPageId) computeAllLayouts(imported, firstPageId) + const imported = await readFigForTab(file, store) - const store = reusableTabStore() store.replaceGraph(imported) store.undo.clear() store.state.documentName = snapshot.documentName diff --git a/tests/engine/io/fig/page-manifest.test.ts b/tests/engine/io/fig/page-manifest.test.ts new file mode 100644 index 000000000..479214771 --- /dev/null +++ b/tests/engine/io/fig/page-manifest.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import { parseFigBuffer } from '@open-pencil/fig' +import type { FigPageManifestEntry } from '@open-pencil/kiwi/fig' + +const fixturePath = resolve(import.meta.dir, '../../../fixtures/gold-preview.fig') + +test('reports FIG pages before materializing NodeChange objects', () => { + const bytes = readFileSync(fixturePath) + let parseCompleted = false + let manifest: FigPageManifestEntry[] = [] + + const parsed = parseFigBuffer( + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + (pages) => { + expect(parseCompleted).toBe(false) + manifest = pages + } + ) + parseCompleted = true + + expect(parsed.nodeChanges.length).toBeGreaterThan(0) + expect(manifest).toEqual([ + { + sourceId: '0:1', + name: 'Page 1', + position: '!', + internalOnly: false + }, + { + sourceId: '0:2', + name: 'Internal Only Canvas', + position: '~', + internalOnly: true + } + ]) +})