diff --git a/CHANGELOG.md b/CHANGELOG.md index 120575116..07b7fbebd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`) — `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) diff --git a/packages/core/src/fig-export-worker.ts b/packages/core/src/fig-export-worker.ts new file mode 100644 index 000000000..cce9582a2 --- /dev/null +++ b/packages/core/src/fig-export-worker.ts @@ -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) => { + 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] }) +} diff --git a/packages/core/src/fig-export.ts b/packages/core/src/fig-export.ts index 30e0e3ce7..3677da9e5 100644 --- a/packages/core/src/fig-export.ts +++ b/packages/core/src/fig-export.ts @@ -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 { + return new Promise((resolve, reject) => { + const worker = new Worker(new URL('./fig-export-worker.ts', import.meta.url), { type: 'module' }) + + worker.onmessage = (e: MessageEvent) => { + 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 { + if (canUseWorker()) { + return compressViaWorker(schemaDeflated, kiwiData, thumbnailPng, metaJson, imageEntries) + } + return Promise.resolve(compressFigDataSync(schemaDeflated, kiwiData, thumbnailPng, metaJson, imageEntries)) +} diff --git a/packages/core/src/fonts.ts b/packages/core/src/fonts.ts index 3eaade425..72364a667 100644 --- a/packages/core/src/fonts.ts +++ b/packages/core/src/fonts.ts @@ -72,7 +72,13 @@ async function fetchGoogleFontFiles(family: string): Promise { +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 { return importNodeChanges(nodeChanges, blobs, images) } -/** - * Read a .fig File object and parse it - */ +function parseViaWorker(buffer: ArrayBuffer): Promise { + return new Promise((resolve, reject) => { + const worker = new Worker(new URL('./fig-parse-worker.ts', import.meta.url), { type: 'module' }) + + worker.onmessage = (e: MessageEvent) => { + worker.terminate() + if (e.data.error) { + reject(new Error(e.data.error)) + return + } + const { nodeChanges, blobs, images: imageEntries } = e.data + const images = new Map(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 { + if (typeof Worker !== 'undefined' && typeof window !== 'undefined') { + return parseViaWorker(buffer) + } + return parseFigFileSync(buffer) +} + export async function readFigFile(file: File): Promise { const buffer = await file.arrayBuffer() return parseFigFile(buffer) diff --git a/packages/core/src/kiwi/fig-parse-worker.ts b/packages/core/src/kiwi/fig-parse-worker.ts new file mode 100644 index 000000000..cae33879a --- /dev/null +++ b/packages/core/src/kiwi/fig-parse-worker.ts @@ -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) => { + 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) }) + } +} diff --git a/packages/core/src/renderer/label-cache.ts b/packages/core/src/renderer/label-cache.ts new file mode 100644 index 000000000..169ba75a1 --- /dev/null +++ b/packages/core/src/renderer/label-cache.ts @@ -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) + } + } + } +} diff --git a/packages/core/src/renderer/labels.ts b/packages/core/src/renderer/labels.ts index a5593737d..11b37bec2 100644 --- a/packages/core/src/renderer/labels.ts +++ b/packages/core/src/renderer/labels.ts @@ -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,106 +24,89 @@ export function drawSectionTitles(r: SkiaRenderer, canvas: Canvas, graph: SceneG const ellipsisWidth = font.getGlyphWidths(ellipsisGlyphs)[0] for (const { node, absX, absY, nested } of sections) { - const screenX = absX * r.zoom + r.panX - const screenY = absY * r.zoom + r.panY - const screenW = node.width * r.zoom - const maxPillW = Math.max(screenW, 0) - - const glyphIds = font.getGlyphIDs(node.name) - const widths = font.getGlyphWidths(glyphIds) - - let fullTextWidth = 0 - for (const w of widths) fullTextWidth += w - - const maxTextW = maxPillW - SECTION_TITLE_PADDING_X * 2 - let displayText = node.name - let textWidth = fullTextWidth - - if (textWidth > maxTextW && maxTextW > ellipsisWidth) { - let truncW = 0 - let truncIdx = 0 - for (let i = 0; i < widths.length; i++) { - if (truncW + widths[i] + ellipsisWidth > maxTextW) break - truncW += widths[i] - truncIdx = i + 1 - } - displayText = node.name.slice(0, truncIdx) + ellipsis - textWidth = truncW + ellipsisWidth - } else if (maxTextW <= ellipsisWidth) { - displayText = ellipsis - textWidth = ellipsisWidth - } - - const pillW = Math.min(textWidth + SECTION_TITLE_PADDING_X * 2, maxPillW) - const pillH = SECTION_TITLE_HEIGHT - const pillX = screenX - const pillY = nested ? screenY + SECTION_TITLE_GAP : screenY - pillH - SECTION_TITLE_GAP - - if (node.fills.length > 0 && node.fills[0].visible) { - const c = node.fills[0].color - r.auxFill.setColor(r.ck.Color4f(c.r, c.g, c.b, node.fills[0].opacity)) - } else { - r.auxFill.setColor(r.ck.Color4f(0.37, 0.37, 0.37, 1)) - } - const pillRect = r.ck.LTRBRect(pillX, pillY, pillX + pillW, pillY + pillH) - canvas.drawRRect( - r.ck.RRectXY(pillRect, SECTION_TITLE_RADIUS, SECTION_TITLE_RADIUS), - r.auxFill - ) - - const pillColor = - node.fills.length > 0 && node.fills[0].visible - ? node.fills[0].color - : { r: 0.37, g: 0.37, b: 0.37 } - const lum = 0.299 * pillColor.r + 0.587 * pillColor.g + 0.114 * pillColor.b - r.auxFill.setColor(lum > 0.5 ? r.ck.BLACK : r.ck.WHITE) - const textY = pillY + pillH * 0.7 - canvas.drawText(displayText, pillX + SECTION_TITLE_PADDING_X, textY, r.auxFill, font) + 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 + const maxPillW = Math.max(screenW, 0) + + const glyphIds = font.getGlyphIDs(node.name) + const widths = font.getGlyphWidths(glyphIds) + + let fullTextWidth = 0 + for (const w of widths) fullTextWidth += w + + const maxTextW = maxPillW - SECTION_TITLE_PADDING_X * 2 + let displayText = node.name + let textWidth = fullTextWidth + + if (textWidth > maxTextW && maxTextW > ellipsisWidth) { + let truncW = 0 + let truncIdx = 0 + for (let i = 0; i < widths.length; i++) { + if (truncW + widths[i] + ellipsisWidth > maxTextW) break + truncW += widths[i] + truncIdx = i + 1 + } + displayText = node.name.slice(0, truncIdx) + ellipsis + textWidth = truncW + ellipsisWidth + } else if (maxTextW <= ellipsisWidth) { + displayText = ellipsis + textWidth = ellipsisWidth + } + + const pillW = Math.min(textWidth + SECTION_TITLE_PADDING_X * 2, maxPillW) + const pillH = SECTION_TITLE_HEIGHT + const pillX = screenX + const pillY = nested ? screenY + SECTION_TITLE_GAP : screenY - pillH - SECTION_TITLE_GAP + + if (node.fills.length > 0 && node.fills[0].visible) { + const c = node.fills[0].color + r.auxFill.setColor(r.ck.Color4f(c.r, c.g, c.b, node.fills[0].opacity)) + } else { + r.auxFill.setColor(r.ck.Color4f(0.37, 0.37, 0.37, 1)) + } + const pillRect = r.ck.LTRBRect(pillX, pillY, pillX + pillW, pillY + pillH) + canvas.drawRRect( + r.ck.RRectXY(pillRect, SECTION_TITLE_RADIUS, SECTION_TITLE_RADIUS), + r.auxFill + ) + + const pillColor = + node.fills.length > 0 && node.fills[0].visible + ? node.fills[0].color + : { r: 0.37, g: 0.37, b: 0.37 } + const lum = 0.299 * pillColor.r + 0.587 * pillColor.g + 0.114 * pillColor.b + r.auxFill.setColor(lum > 0.5 ? r.ck.BLACK : r.ck.WHITE) + 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 diff --git a/packages/core/src/renderer/renderer.ts b/packages/core/src/renderer/renderer.ts index 6d55b3e81..48be89be7 100644 --- a/packages/core/src/renderer/renderer.ts +++ b/packages/core/src/renderer/renderer.ts @@ -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() + 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') diff --git a/packages/core/src/scene-graph.ts b/packages/core/src/scene-graph.ts index 638cbca98..336a097ab 100644 --- a/packages/core/src/scene-graph.ts +++ b/packages/core/src/scene-graph.ts @@ -466,6 +466,7 @@ export class SceneGraph { rootId: string readonly emitter: Emitter = createNanoEvents() private absPosCache = new Map() + private instanceIndex = new Map>() 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 } diff --git a/src/components/FillPicker.vue b/src/components/FillPicker.vue index 6dff5ffc0..17457d0b4 100644 --- a/src/components/FillPicker.vue +++ b/src/components/FillPicker.vue @@ -1,5 +1,5 @@