perf(fig): show pages before full document decode
This commit is contained in:
parent
15bd0ba19f
commit
2544b3e4db
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<SceneGraph> {
|
||||
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<WorkerParseResult>) => {
|
||||
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])
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,8 @@ self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
|||
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<WorkerRequest>) => {
|
|||
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 },
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Record<string, Uint8Array>>): 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<typeof decodeFigKiwiCanvas>
|
||||
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/')
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export { default as figmaSchema } from './schema'
|
||||
export * from './page-manifest'
|
||||
export * from './protocol'
|
||||
export * from './types'
|
||||
export * from './variable-bindings'
|
||||
|
|
|
|||
246
packages/kiwi/src/fig/page-manifest.ts
Normal file
246
packages/kiwi/src/fig/page-manifest.ts
Normal file
|
|
@ -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<string, Definition>
|
||||
interface ScanContext {
|
||||
definitions: DefinitionMap
|
||||
fields: Map<string, Map<number, Field>>
|
||||
}
|
||||
|
||||
function fieldMap(definition: Definition): Map<number, Field> {
|
||||
return new Map(definition.fields.map((field) => [field.value, field]))
|
||||
}
|
||||
|
||||
function cachedFieldMap(context: ScanContext, definition: Definition): Map<number, Field> {
|
||||
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
|
||||
}))
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
29
src/app/document/io/fig-page-manifest.ts
Normal file
29
src/app/document/io/fig-page-manifest.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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, '')
|
||||
|
|
|
|||
|
|
@ -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<SceneGraph> {
|
||||
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<void> {
|
|||
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
|
||||
|
|
|
|||
39
tests/engine/io/fig/page-manifest.test.ts
Normal file
39
tests/engine/io/fig/page-manifest.test.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
])
|
||||
})
|
||||
Loading…
Reference in a new issue