From 4fbca976fd3c80c2f9c85ad50bd8e550d9cba934 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 25 Apr 2026 21:24:55 +0300 Subject: [PATCH] perf(kiwi): lazily populate opened fig pages --- packages/core/src/io/formats/fig/read.ts | 30 +++++++---- packages/core/src/kiwi/fig-import.ts | 23 +++++++- packages/core/src/kiwi/fig-parse-worker.ts | 12 +++-- .../kiwi/instance-overrides/constraints.ts | 37 +++++++++++-- .../core/src/kiwi/instance-overrides/dsd.ts | 4 +- .../core/src/kiwi/instance-overrides/index.ts | 54 +++++++++++++++---- .../src/kiwi/instance-overrides/populate.ts | 39 ++++++++++++-- .../core/src/kiwi/instance-overrides/props.ts | 3 +- .../instance-overrides/symbol-overrides.ts | 9 ++-- .../core/src/kiwi/instance-overrides/sync.ts | 11 ++-- .../core/src/kiwi/instance-overrides/types.ts | 2 + src/stores/editor.ts | 6 +-- src/stores/tabs.ts | 16 +++--- 13 files changed, 195 insertions(+), 51 deletions(-) diff --git a/packages/core/src/io/formats/fig/read.ts b/packages/core/src/io/formats/fig/read.ts index 2a0ed82f5..05b9ceb55 100644 --- a/packages/core/src/io/formats/fig/read.ts +++ b/packages/core/src/io/formats/fig/read.ts @@ -6,9 +6,13 @@ import { deserializeSceneGraph } from '../../../kiwi/graph-transfer' import type { SerializedSceneGraph } from '../../../kiwi/graph-transfer' import type { SceneGraph } from '../../../scene-graph' -function parseFigFileSync(buffer: ArrayBuffer): SceneGraph { +export interface ParseFigFileOptions { + populate?: 'all' | 'first-page' +} + +function parseFigFileSync(buffer: ArrayBuffer, options: ParseFigFileOptions = {}): SceneGraph { const { nodeChanges, blobs, images: imageEntries, figKiwiVersion } = parseFigBuffer(buffer) - const graph = importNodeChanges(nodeChanges, blobs, new Map(imageEntries)) + const graph = importNodeChanges(nodeChanges, blobs, new Map(imageEntries), options) graph.figKiwiVersion = figKiwiVersion return graph } @@ -18,7 +22,7 @@ interface WorkerParseResult { error?: string } -function parseViaWorker(buffer: ArrayBuffer): Promise { +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), { type: 'module' @@ -38,23 +42,29 @@ function parseViaWorker(buffer: ArrayBuffer): Promise { reject(new Error(err.message || 'Worker failed to parse .fig file')) } - worker.postMessage(buffer, [buffer]) + worker.postMessage({ buffer, options }, [buffer]) }) } -export async function parseFigFile(buffer: ArrayBuffer): Promise { +export async function parseFigFile( + buffer: ArrayBuffer, + options: ParseFigFileOptions = {} +): Promise { if (typeof Worker !== 'undefined' && IS_BROWSER) { const copy = buffer.slice(0) try { - return await parseViaWorker(buffer) + return await parseViaWorker(buffer, options) } catch (error) { console.warn('Worker parsing failed, falling back to main thread:', error) - return parseFigFileSync(copy) + return parseFigFileSync(copy, options) } } - return parseFigFileSync(buffer) + return parseFigFileSync(buffer, options) } -export async function readFigFile(file: File): Promise { - return parseFigFile(await file.arrayBuffer()) +export async function readFigFile( + file: File, + options: ParseFigFileOptions = {} +): Promise { + return parseFigFile(await file.arrayBuffer(), options) } diff --git a/packages/core/src/kiwi/fig-import.ts b/packages/core/src/kiwi/fig-import.ts index 3c6af9a19..b9133109a 100644 --- a/packages/core/src/kiwi/fig-import.ts +++ b/packages/core/src/kiwi/fig-import.ts @@ -336,10 +336,15 @@ function parseDocumentColorSpace(nodeChanges: NodeChange[]): 'srgb' | 'display-p return documentNode?.documentColorProfile === 'DISPLAY_P3' ? 'display-p3' : 'srgb' } +export interface FigImportOptions { + populate?: 'all' | 'first-page' +} + export function importNodeChanges( nodeChanges: NodeChange[], blobs: Uint8Array[] = [], - images?: Map + images?: Map, + options: FigImportOptions = {} ): SceneGraph { const graph = new SceneGraph() graph.documentColorSpace = parseDocumentColorSpace(nodeChanges) @@ -389,11 +394,25 @@ export function importNodeChanges( importVariableBindings(changeMap, guidToNodeId, graph) remapComponentIds(graph, guidToNodeId) + const firstPageId = graph.getPages()[0]?.id + const componentPageIds = new Set() + for (const node of graph.getAllNodes()) { + if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET') continue + let current = node.parentId ? graph.getNode(node.parentId) : undefined + while (current?.parentId && current.type !== 'CANVAS') current = graph.getNode(current.parentId) + if (current?.type === 'CANVAS') componentPageIds.add(current.id) + } + const activeRootIds = + options.populate === 'first-page' + ? [firstPageId, ...componentPageIds].filter(Boolean) + : undefined + populateAndApplyOverrides( graph, changeMap as unknown as Map, guidToNodeId, - blobs + blobs, + activeRootIds ) setVariableColorResolver(null) diff --git a/packages/core/src/kiwi/fig-parse-worker.ts b/packages/core/src/kiwi/fig-parse-worker.ts index c2662b5ae..8e9562514 100644 --- a/packages/core/src/kiwi/fig-parse-worker.ts +++ b/packages/core/src/kiwi/fig-parse-worker.ts @@ -2,10 +2,16 @@ import { importNodeChanges } from './fig-import' import { parseFigBuffer } from './fig-parse-core' import { serializeSceneGraph } from './graph-transfer' -self.onmessage = (e: MessageEvent) => { +interface WorkerParseRequest { + buffer: ArrayBuffer + options?: { populate?: 'all' | 'first-page' } +} + +self.onmessage = (e: MessageEvent) => { try { - const { nodeChanges, blobs, images, figKiwiVersion } = parseFigBuffer(e.data) - const graph = importNodeChanges(nodeChanges, blobs, new Map(images)) + const request = e.data instanceof ArrayBuffer ? { buffer: e.data } : e.data + const { nodeChanges, blobs, images, figKiwiVersion } = parseFigBuffer(request.buffer) + const graph = importNodeChanges(nodeChanges, blobs, new Map(images), request.options) graph.figKiwiVersion = figKiwiVersion self.postMessage({ graph: serializeSceneGraph(graph) }) } catch (err) { diff --git a/packages/core/src/kiwi/instance-overrides/constraints.ts b/packages/core/src/kiwi/instance-overrides/constraints.ts index 321016e0e..dfc9d52bf 100644 --- a/packages/core/src/kiwi/instance-overrides/constraints.ts +++ b/packages/core/src/kiwi/instance-overrides/constraints.ts @@ -14,6 +14,7 @@ export function applyConstraintScaling(ctx: OverrideContext): void { const scaled = new Set() for (const node of graph.getAllNodes()) { + if (ctx.activeNodeIds && !ctx.activeNodeIds.has(node.id)) continue if (node.type !== 'INSTANCE' || !node.componentId) continue const comp = graph.getNode(node.componentId) if (!comp || comp.width <= 0 || comp.height <= 0) continue @@ -31,6 +32,7 @@ export function applyConstraintScaling(ctx: OverrideContext): void { } if (scaled.size > 0) propagateScaling(ctx, scaled) + normalizeOutOfBoundsSingleChildren(ctx) } function resolveScaleBasis( @@ -72,7 +74,11 @@ function scaleGeometryBlobs(geom: GeometryPath[], sx: number, sy: number): Geome }) } -function scaleVectorNetwork(network: VectorNetwork | null, sx: number, sy: number): VectorNetwork | null { +function scaleVectorNetwork( + network: VectorNetwork | null, + sx: number, + sy: number +): VectorNetwork | null { if (!network) return null return { vertices: network.vertices.map((vertex) => ({ ...vertex, x: vertex.x * sx, y: vertex.y * sy })), @@ -142,9 +148,33 @@ function scaleChildren( } } +function normalizeOutOfBoundsSingleChildren(ctx: OverrideContext): void { + const { graph } = ctx + for (const parent of graph.getAllNodes()) { + if (ctx.activeNodeIds && !ctx.activeNodeIds.has(parent.id)) continue + if (parent.childIds.length !== 1) continue + const child = graph.getNode(parent.childIds[0]) + if (!child?.visible || !child.componentId) continue + if (ctx.geometryOverrideNodes.has(child.id) || child.figmaDerivedLayout?.x !== undefined) + continue + const outsideParent = + child.x < -0.01 || + child.y < -0.01 || + child.x + child.width > parent.width + 0.01 || + child.y + child.height > parent.height + 0.01 + if (outsideParent) { + graph.updateNode(child.id, { + x: 0, + y: 0, + figmaDerivedLayout: { ...child.figmaDerivedLayout, x: 0, y: 0 } + }) + } + } +} + function propagateScaling(ctx: OverrideContext, scaled: Set): void { const { graph } = ctx - const clonesOf = buildClonesMap(graph) + const clonesOf = buildClonesMap(graph, ctx.activeNodeIds) const queue = [...scaled] const visited = new Set() @@ -165,7 +195,8 @@ function propagateScaling(ctx: OverrideContext, scaled: Set): void { if (clone.y !== source.y) cu.y = source.y if (!ctx.geometryOverrideNodes.has(cloneId)) { if (source.fillGeometry.length > 0) cu.fillGeometry = copyGeometryPaths(source.fillGeometry) - if (source.strokeGeometry.length > 0) cu.strokeGeometry = copyGeometryPaths(source.strokeGeometry) + if (source.strokeGeometry.length > 0) + cu.strokeGeometry = copyGeometryPaths(source.strokeGeometry) if (source.vectorNetwork) cu.vectorNetwork = structuredClone(source.vectorNetwork) } if (Object.keys(cu).length > 0) graph.updateNode(cloneId, cu) diff --git a/packages/core/src/kiwi/instance-overrides/dsd.ts b/packages/core/src/kiwi/instance-overrides/dsd.ts index 5eefcc37b..9685cd57f 100644 --- a/packages/core/src/kiwi/instance-overrides/dsd.ts +++ b/packages/core/src/kiwi/instance-overrides/dsd.ts @@ -153,7 +153,7 @@ function resolveDsdUpdates(ctx: OverrideContext): { modified: Set; sizeS if (!derived?.length) continue const nodeId = ctx.guidToNodeId.get(ncId) - if (!nodeId) continue + if (!nodeId || (ctx.activeNodeIds && !ctx.activeNodeIds.has(nodeId))) continue for (const d of derived) { const guids = d.guidPath?.guids @@ -188,7 +188,7 @@ function propagateDsdChanges( ): void { if (modified.size === 0) return - const clonesOf = buildClonesMap(ctx.graph) + const clonesOf = buildClonesMap(ctx.graph, ctx.activeNodeIds) const queue = [...modified] const visited = new Set() diff --git a/packages/core/src/kiwi/instance-overrides/index.ts b/packages/core/src/kiwi/instance-overrides/index.ts index fb86eb508..71aa9ea84 100644 --- a/packages/core/src/kiwi/instance-overrides/index.ts +++ b/packages/core/src/kiwi/instance-overrides/index.ts @@ -62,10 +62,15 @@ function buildKiwiGeometryNodes( return result } -function propagateResolvedFills(graph: SceneGraph, protectedNodes: Set): void { +function propagateResolvedFills( + graph: SceneGraph, + protectedNodes: Set, + activeNodeIds?: Set +): void { for (let pass = 0; pass < 10; pass++) { let changed = false for (const node of graph.getAllNodes()) { + if (activeNodeIds && !activeNodeIds.has(node.id)) continue if (!node.componentId) continue const source = graph.getNode(node.componentId) if (!source || source.fills === node.fills) continue @@ -81,7 +86,8 @@ function buildOverrideContext( graph: SceneGraph, changeMap: Map, guidToNodeId: Map, - blobs: Uint8Array[] + blobs: Uint8Array[], + activeNodeIds?: Set ): OverrideContext { const overrideKeyToGuid = new Map() for (const [id, nc] of changeMap) { @@ -118,7 +124,8 @@ function buildOverrideContext( componentIdRoot: new Map(), swappedInstances: new Set(), kiwiPropertyNodes, - geometryOverrideNodes + geometryOverrideNodes, + activeNodeIds } } @@ -142,11 +149,12 @@ export function populateAndApplyOverrides( graph: SceneGraph, changeMap: Map, guidToNodeId: Map, - blobs: Uint8Array[] = [] + blobs: Uint8Array[] = [], + activeRootIds?: Iterable ): void { - populateInstances(graph) + const activeNodeIds = populateInstances(graph, activeRootIds) - const ctx = buildOverrideContext(graph, changeMap, guidToNodeId, blobs) + const ctx = buildOverrideContext(graph, changeMap, guidToNodeId, blobs, activeNodeIds) preComputeRoots(ctx) const overriddenNodes = applySymbolOverrides(ctx) @@ -155,7 +163,14 @@ export function populateAndApplyOverrides( // synced with the correct values) AND protected (so sync doesn't overwrite // them with component defaults). for (const id of ctx.kiwiPropertyNodes) overriddenNodes.add(id) - propagateOverridesTransitively(graph, overriddenNodes, ctx.swappedInstances, ctx.componentIdRoot) + propagateOverridesTransitively( + graph, + overriddenNodes, + ctx.swappedInstances, + ctx.componentIdRoot, + undefined, + ctx.activeNodeIds + ) const propModified = applyComponentProperties(ctx) if (propModified.size > 0) { @@ -164,11 +179,32 @@ export function populateAndApplyOverrides( propModified, ctx.swappedInstances, ctx.componentIdRoot, - overriddenNodes + overriddenNodes, + ctx.activeNodeIds ) } + if (activeRootIds) { + const populated = populateInstances(graph, activeRootIds) + if (populated) ctx.activeNodeIds = populated + const latePropModified = applyComponentProperties(ctx) + if (latePropModified.size > 0) { + propagateOverridesTransitively( + graph, + latePropModified, + ctx.swappedInstances, + ctx.componentIdRoot, + overriddenNodes, + ctx.activeNodeIds + ) + } + } + applyDerivedSymbolData(ctx) - propagateResolvedFills(graph, new Set([...ctx.kiwiPropertyNodes, ...overriddenNodes])) + propagateResolvedFills( + graph, + new Set([...ctx.kiwiPropertyNodes, ...overriddenNodes]), + ctx.activeNodeIds + ) applyConstraintScaling(ctx) } diff --git a/packages/core/src/kiwi/instance-overrides/populate.ts b/packages/core/src/kiwi/instance-overrides/populate.ts index ff011dfe8..28937358a 100644 --- a/packages/core/src/kiwi/instance-overrides/populate.ts +++ b/packages/core/src/kiwi/instance-overrides/populate.ts @@ -7,7 +7,22 @@ import type { SceneGraph } from '../../scene-graph' * itself an unpopulated instance, populate the source first so cloned * children are complete. */ -export function populateInstances(graph: SceneGraph): void { +function collectSubtreeIds(graph: SceneGraph, rootIds: Iterable): Set { + const result = new Set() + const queue = [...rootIds] + for (let id = queue.shift(); id !== undefined; id = queue.shift()) { + if (result.has(id)) continue + result.add(id) + const node = graph.getNode(id) + if (node) queue.push(...node.childIds) + } + return result +} + +export function populateInstances( + graph: SceneGraph, + rootIds?: Iterable +): Set | undefined { const visiting = new Set() function ensurePopulated(nodeId: string): void { @@ -34,9 +49,25 @@ export function populateInstances(graph: SceneGraph): void { } } - for (const node of graph.nodes.values()) { - if (node.type === 'INSTANCE' && node.componentId && node.childIds.length === 0) { - ensurePopulated(node.id) + if (!rootIds) { + for (const node of graph.nodes.values()) { + if (node.type === 'INSTANCE' && node.componentId && node.childIds.length === 0) { + ensurePopulated(node.id) + } } + return undefined } + + const queue = [...rootIds] + const visited = new Set() + while (queue.length > 0) { + const nodeId = queue.shift() + if (!nodeId || visited.has(nodeId)) continue + visited.add(nodeId) + ensurePopulated(nodeId) + const node = graph.getNode(nodeId) + if (!node) continue + queue.push(...node.childIds) + } + return collectSubtreeIds(graph, rootIds) } diff --git a/packages/core/src/kiwi/instance-overrides/props.ts b/packages/core/src/kiwi/instance-overrides/props.ts index 0836f2fe0..af7794a9e 100644 --- a/packages/core/src/kiwi/instance-overrides/props.ts +++ b/packages/core/src/kiwi/instance-overrides/props.ts @@ -125,6 +125,7 @@ function applyInstanceDirectAssignments( modified: Set ): void { for (const node of ctx.graph.getAllNodes()) { + if (ctx.activeNodeIds && !ctx.activeNodeIds.has(node.id)) continue if (node.type !== 'INSTANCE') continue const ownFigmaId = ctx.nodeIdToGuid.get(node.id) if (!ownFigmaId) continue @@ -154,7 +155,7 @@ function applyOverrideAssignments( ): void { for (const [figmaId, nc] of ctx.changeMap) { const instanceNodeId = ctx.guidToNodeId.get(figmaId) - if (!instanceNodeId) continue + if (!instanceNodeId || (ctx.activeNodeIds && !ctx.activeNodeIds.has(instanceNodeId))) continue if (ctx.graph.getNode(instanceNodeId)?.type !== 'INSTANCE') continue const overrides = nc.symbolData?.symbolOverrides diff --git a/packages/core/src/kiwi/instance-overrides/symbol-overrides.ts b/packages/core/src/kiwi/instance-overrides/symbol-overrides.ts index b70b94153..9214bf249 100644 --- a/packages/core/src/kiwi/instance-overrides/symbol-overrides.ts +++ b/packages/core/src/kiwi/instance-overrides/symbol-overrides.ts @@ -5,6 +5,10 @@ import { resolveOverrideTarget, repopulateInstance } from './resolve' import type { SceneNode } from '../../scene-graph' import type { OverrideContext } from './types' +function isActiveInstance(ctx: OverrideContext, nodeId: string | undefined): nodeId is string { + return nodeId !== undefined && (!ctx.activeNodeIds || ctx.activeNodeIds.has(nodeId)) +} + function preserveStrokeShapeProps(target: SceneNode, updates: Partial): void { if (!updates.strokes) return updates.strokes = updates.strokes.map((stroke, index) => { @@ -43,7 +47,7 @@ export function applySymbolOverrides(ctx: OverrideContext): Set { if (!overrides?.length) continue const nodeId = ctx.guidToNodeId.get(ncId) - if (!nodeId) continue + if (!isActiveInstance(ctx, nodeId)) continue for (const ov of overrides) { const guids = ov.guidPath?.guids @@ -52,9 +56,6 @@ export function applySymbolOverrides(ctx: OverrideContext): Set { const targetId = resolveOverrideTarget(ctx, nodeId, guids) if (!targetId) continue - // When a symbolOverride resolves to the instance itself (self-reference - // to the component shell), skip it if the instance has explicit kiwi NC - // properties — the instance's own values take precedence. if (targetId === nodeId && ctx.kiwiPropertyNodes.has(nodeId)) continue overriddenNodes.add(targetId) diff --git a/packages/core/src/kiwi/instance-overrides/sync.ts b/packages/core/src/kiwi/instance-overrides/sync.ts index e5912eaf4..e006c8205 100644 --- a/packages/core/src/kiwi/instance-overrides/sync.ts +++ b/packages/core/src/kiwi/instance-overrides/sync.ts @@ -80,9 +80,13 @@ export function syncChildrenDeep( } /** Build a map of componentId → list of clone node IDs. */ -export function buildClonesMap(graph: SceneGraph): Map { +export function buildClonesMap( + graph: SceneGraph, + activeNodeIds?: Set +): Map { const clonesOf = new Map() for (const node of graph.getAllNodes()) { + if (activeNodeIds && !activeNodeIds.has(node.id)) continue if (!node.componentId) continue let arr = clonesOf.get(node.componentId) if (!arr) { @@ -141,12 +145,13 @@ export function propagateOverridesTransitively( seeds: Set, swappedInstances: Set, componentIdRoot: Map, - protect?: Set + protect?: Set, + activeNodeIds?: Set ): void { if (seeds.size === 0) return componentIdRoot.clear() - const clonesOf = buildClonesMap(graph) + const clonesOf = buildClonesMap(graph, activeNodeIds) const expandedSeeds = expandSeedsToParents(graph, seeds) const needsSync = buildNeedsSyncSet(expandedSeeds, clonesOf) diff --git a/packages/core/src/kiwi/instance-overrides/types.ts b/packages/core/src/kiwi/instance-overrides/types.ts index abd134c05..a2aed0dce 100644 --- a/packages/core/src/kiwi/instance-overrides/types.ts +++ b/packages/core/src/kiwi/instance-overrides/types.ts @@ -83,4 +83,6 @@ export interface OverrideContext { kiwiPropertyNodes: Set /** Nodes whose Figma-derived geometry should not be overwritten by clone propagation. */ geometryOverrideNodes: Set + /** When set, apply/populate expensive instance work only inside these already-imported nodes. */ + activeNodeIds?: Set } diff --git a/src/stores/editor.ts b/src/stores/editor.ts index d8c9d0c85..2f70f1376 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -910,7 +910,7 @@ export function createEditorStore(initialGraph?: SceneGraph) { try { state.loading = true await yieldToUI() - const imported = await readFigFile(file) + const imported = await readFigFile(file, { populate: 'first-page' }) await yieldToUI() editor.replaceGraph(imported) editor.undo.clear() @@ -1022,11 +1022,11 @@ export function createEditorStore(initialGraph?: SceneGraph) { const bytes = await tauriRead(filePath) const blob = new Blob([bytes]) const file = new File([blob], state.documentName + '.fig') - const imported = await readFigFile(file) + const imported = await readFigFile(file, { populate: 'first-page' }) editor.replaceGraph(imported) } else if (fileHandle) { const file = await fileHandle.getFile() - const imported = await readFigFile(file) + const imported = await readFigFile(file, { populate: 'first-page' }) editor.replaceGraph(imported) } else { return diff --git a/src/stores/tabs.ts b/src/stores/tabs.ts index 505988c97..0afe51bf1 100644 --- a/src/stores/tabs.ts +++ b/src/stores/tabs.ts @@ -1,6 +1,6 @@ import { shallowRef, computed, triggerRef } from 'vue' -import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core' +import { BUILTIN_IO_FORMATS, IORegistry, readFigFile } from '@open-pencil/core' import { createEditorStore, setActiveEditorStore } from './editor' @@ -102,12 +102,14 @@ export async function openFileInNewTab( await yieldToUI() try { - const bytes = new Uint8Array(await file.arrayBuffer()) - const { graph: imported, sourceFormat } = await io.readDocument({ - name: file.name, - mimeType: file.type || undefined, - data: bytes - }) + const isFig = file.name.toLowerCase().endsWith('.fig') + const { graph: imported, sourceFormat } = isFig + ? { graph: await readFigFile(file, { populate: 'first-page' }), sourceFormat: 'fig' } + : await io.readDocument({ + name: file.name, + mimeType: file.type || undefined, + data: new Uint8Array(await file.arrayBuffer()) + }) store.replaceGraph(imported) store.undo.clear()