perf(kiwi): lazily populate opened fig pages

This commit is contained in:
Danila Poyarkov 2026-04-25 21:24:55 +03:00
parent 04b425f64c
commit 4fbca976fd
13 changed files with 195 additions and 51 deletions

View file

@ -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<SceneGraph> {
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), {
type: 'module'
@ -38,23 +42,29 @@ function parseViaWorker(buffer: ArrayBuffer): Promise<SceneGraph> {
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<SceneGraph> {
export async function parseFigFile(
buffer: ArrayBuffer,
options: ParseFigFileOptions = {}
): Promise<SceneGraph> {
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<SceneGraph> {
return parseFigFile(await file.arrayBuffer())
export async function readFigFile(
file: File,
options: ParseFigFileOptions = {}
): Promise<SceneGraph> {
return parseFigFile(await file.arrayBuffer(), options)
}

View file

@ -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<string, Uint8Array>
images?: Map<string, Uint8Array>,
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<string>()
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<string, InstanceNodeChange>,
guidToNodeId,
blobs
blobs,
activeRootIds
)
setVariableColorResolver(null)

View file

@ -2,10 +2,16 @@ import { importNodeChanges } from './fig-import'
import { parseFigBuffer } from './fig-parse-core'
import { serializeSceneGraph } from './graph-transfer'
self.onmessage = (e: MessageEvent<ArrayBuffer>) => {
interface WorkerParseRequest {
buffer: ArrayBuffer
options?: { populate?: 'all' | 'first-page' }
}
self.onmessage = (e: MessageEvent<ArrayBuffer | WorkerParseRequest>) => {
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) {

View file

@ -14,6 +14,7 @@ export function applyConstraintScaling(ctx: OverrideContext): void {
const scaled = new Set<string>()
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<string>): void {
const { graph } = ctx
const clonesOf = buildClonesMap(graph)
const clonesOf = buildClonesMap(graph, ctx.activeNodeIds)
const queue = [...scaled]
const visited = new Set<string>()
@ -165,7 +195,8 @@ function propagateScaling(ctx: OverrideContext, scaled: Set<string>): 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)

View file

@ -153,7 +153,7 @@ function resolveDsdUpdates(ctx: OverrideContext): { modified: Set<string>; 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<string>()

View file

@ -62,10 +62,15 @@ function buildKiwiGeometryNodes(
return result
}
function propagateResolvedFills(graph: SceneGraph, protectedNodes: Set<string>): void {
function propagateResolvedFills(
graph: SceneGraph,
protectedNodes: Set<string>,
activeNodeIds?: Set<string>
): 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<string, InstanceNodeChange>,
guidToNodeId: Map<string, string>,
blobs: Uint8Array[]
blobs: Uint8Array[],
activeNodeIds?: Set<string>
): OverrideContext {
const overrideKeyToGuid = new Map<string, string>()
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<string, InstanceNodeChange>,
guidToNodeId: Map<string, string>,
blobs: Uint8Array[] = []
blobs: Uint8Array[] = [],
activeRootIds?: Iterable<string>
): 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)
}

View file

@ -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<string>): Set<string> {
const result = new Set<string>()
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<string>
): Set<string> | undefined {
const visiting = new Set<string>()
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<string>()
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)
}

View file

@ -125,6 +125,7 @@ function applyInstanceDirectAssignments(
modified: Set<string>
): 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

View file

@ -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<SceneNode>): void {
if (!updates.strokes) return
updates.strokes = updates.strokes.map((stroke, index) => {
@ -43,7 +47,7 @@ export function applySymbolOverrides(ctx: OverrideContext): Set<string> {
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<string> {
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)

View file

@ -80,9 +80,13 @@ export function syncChildrenDeep(
}
/** Build a map of componentId → list of clone node IDs. */
export function buildClonesMap(graph: SceneGraph): Map<string, string[]> {
export function buildClonesMap(
graph: SceneGraph,
activeNodeIds?: Set<string>
): Map<string, string[]> {
const clonesOf = new Map<string, string[]>()
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<string>,
swappedInstances: Set<string>,
componentIdRoot: Map<string, string>,
protect?: Set<string>
protect?: Set<string>,
activeNodeIds?: Set<string>
): 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)

View file

@ -83,4 +83,6 @@ export interface OverrideContext {
kiwiPropertyNodes: Set<string>
/** Nodes whose Figma-derived geometry should not be overwritten by clone propagation. */
geometryOverrideNodes: Set<string>
/** When set, apply/populate expensive instance work only inside these already-imported nodes. */
activeNodeIds?: Set<string>
}

View file

@ -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

View file

@ -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()