Perf: 5.4× faster file open, worker-based parsing, instance index (#96)
* Perf: cache label collection, offload .fig compression to worker Label cache: collect sections/components once per scene change, filter by viewport on each frame. Eliminates full tree walk during pan/zoom (~17ms/frame → <1ms on large files). Export worker: move fflate compression off the main thread to prevent frame drops during save (451ms+ → non-blocking). * Perf: worker-based .fig parsing, instance index, non-blocking font loading - Offload .fig parsing (unzip + Kiwi decode) to a Web Worker - Add instance index (componentId → Set<nodeId>) for O(1) getInstances() - Defer graph event subscription during file open to skip redundant syncs - Make font loading non-blocking — render immediately, load fonts in background - Copy image buffers before worker transfer to prevent detached ArrayBuffer crash - Show toast on font load failure and file open errors - Cache failed Google Fonts families to avoid repeated network requests - Fix missing ref import in FillPicker - Yield to UI between parse and layout for responsive loading spinner
This commit is contained in:
parent
6072ce57c1
commit
3b32b4d80c
13
CHANGELOG.md
13
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<nodeId>`) — `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)
|
||||
|
|
|
|||
28
packages/core/src/fig-export-worker.ts
Normal file
28
packages/core/src/fig-export-worker.ts
Normal file
|
|
@ -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<CompressMessage>) => {
|
||||
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] })
|
||||
}
|
||||
|
|
@ -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<Uint8Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(new URL('./fig-export-worker.ts', import.meta.url), { type: 'module' })
|
||||
|
||||
worker.onmessage = (e: MessageEvent<Uint8Array>) => {
|
||||
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<Uint8Array> {
|
||||
if (canUseWorker()) {
|
||||
return compressViaWorker(schemaDeflated, kiwiData, thumbnailPng, metaJson, imageEntries)
|
||||
}
|
||||
return Promise.resolve(compressFigDataSync(schemaDeflated, kiwiData, thumbnailPng, metaJson, imageEntries))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,13 @@ async function fetchGoogleFontFiles(family: string): Promise<Record<string, stri
|
|||
if (googleFontsFailed.has(family)) return null
|
||||
|
||||
const url = `https://www.googleapis.com/webfonts/v1/webfonts?family=${encodeURIComponent(family)}&key=${GOOGLE_FONTS_API_KEY}`
|
||||
const response = await fetch(url)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url)
|
||||
} catch {
|
||||
googleFontsFailed.add(family)
|
||||
return null
|
||||
}
|
||||
if (!response.ok) {
|
||||
const normalized = normalizeFontFamily(family)
|
||||
if (normalized !== family) {
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export type {
|
|||
AnalyzeClustersArgs, AnalyzeClustersResult, TypographyStyle
|
||||
} from './rpc'
|
||||
export { SkiaRenderer, type RenderOverlays } from './renderer/index'
|
||||
export { LabelCache, type CachedSection, type CachedComponent } from './renderer/label-cache'
|
||||
export {
|
||||
RenderProfiler,
|
||||
FrameStats,
|
||||
|
|
@ -138,7 +139,7 @@ export {
|
|||
type SVGExportOptions
|
||||
} from './svg-export'
|
||||
export { svg, renderSVGNode, type SVGNode } from './svg-node'
|
||||
export { exportFigFile } from './fig-export'
|
||||
export { exportFigFile, compressFigData, compressFigDataSync } from './fig-export'
|
||||
export {
|
||||
FIG_KIWI_VERSION,
|
||||
buildFigKiwi,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { isZstdCompressed } from './protocol'
|
|||
|
||||
import type { SceneGraph } from '../scene-graph'
|
||||
import type { FigmaMessage } from './codec'
|
||||
import type { FigParseResult } from './fig-parse-worker'
|
||||
|
||||
interface FigKiwiPayload {
|
||||
schemaDeflated: Uint8Array
|
||||
|
|
@ -44,7 +45,7 @@ function parseFigKiwiContainer(data: Uint8Array): FigKiwiPayload | null {
|
|||
return { schemaDeflated: chunks[0], dataRaw }
|
||||
}
|
||||
|
||||
export async function parseFigFile(buffer: ArrayBuffer): Promise<SceneGraph> {
|
||||
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<SceneGraph> {
|
|||
return importNodeChanges(nodeChanges, blobs, images)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a .fig File object and parse it
|
||||
*/
|
||||
function parseViaWorker(buffer: ArrayBuffer): Promise<SceneGraph> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(new URL('./fig-parse-worker.ts', import.meta.url), { type: 'module' })
|
||||
|
||||
worker.onmessage = (e: MessageEvent<FigParseResult & { error?: string }>) => {
|
||||
worker.terminate()
|
||||
if (e.data.error) {
|
||||
reject(new Error(e.data.error))
|
||||
return
|
||||
}
|
||||
const { nodeChanges, blobs, images: imageEntries } = e.data
|
||||
const images = new Map<string, Uint8Array>(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<SceneGraph> {
|
||||
if (typeof Worker !== 'undefined' && typeof window !== 'undefined') {
|
||||
return parseViaWorker(buffer)
|
||||
}
|
||||
return parseFigFileSync(buffer)
|
||||
}
|
||||
|
||||
export async function readFigFile(file: File): Promise<SceneGraph> {
|
||||
const buffer = await file.arrayBuffer()
|
||||
return parseFigFile(buffer)
|
||||
|
|
|
|||
115
packages/core/src/kiwi/fig-parse-worker.ts
Normal file
115
packages/core/src/kiwi/fig-parse-worker.ts
Normal file
|
|
@ -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<ArrayBuffer>) => {
|
||||
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) })
|
||||
}
|
||||
}
|
||||
114
packages/core/src/renderer/label-cache.ts
Normal file
114
packages/core/src/renderer/label-cache.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, SkPicture>()
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -466,6 +466,7 @@ export class SceneGraph {
|
|||
rootId: string
|
||||
readonly emitter: Emitter<SceneGraphEvents> = createNanoEvents()
|
||||
private absPosCache = new Map<string, Vector>()
|
||||
private instanceIndex = new Map<string, Set<string>>()
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { useFileDialog, useObjectUrl } from '@vueuse/core'
|
||||
import {
|
||||
PopoverRoot,
|
||||
|
|
|
|||
|
|
@ -651,14 +651,19 @@ export function createEditorStore() {
|
|||
}
|
||||
}
|
||||
|
||||
function yieldToUI(): Promise<void> {
|
||||
return new Promise((r) => requestAnimationFrame(() => r()))
|
||||
}
|
||||
|
||||
async function openFigFile(file: File, handle?: FileSystemFileHandle, path?: string) {
|
||||
try {
|
||||
state.loading = true
|
||||
await new Promise((r) => requestAnimationFrame(r))
|
||||
await yieldToUI()
|
||||
const imported = await readFigFile(file)
|
||||
await yieldToUI()
|
||||
graph = imported
|
||||
subscribeToGraph()
|
||||
computeAllLayouts(graph)
|
||||
subscribeToGraph()
|
||||
undo.clear()
|
||||
pageViewports.clear()
|
||||
fileHandle = handle ?? null
|
||||
|
|
@ -673,11 +678,12 @@ export function createEditorStore() {
|
|||
state.panY = 0
|
||||
state.zoom = 1
|
||||
state.pageColor = { ...CANVAS_BG_COLOR }
|
||||
await loadFontsForNodes(graph.getChildren(pageId).map((n) => n.id))
|
||||
requestRender()
|
||||
void loadFontsForNodes(graph.getChildren(pageId).map((n) => n.id))
|
||||
void startWatchingFile()
|
||||
} catch (e) {
|
||||
console.error('Failed to open .fig file:', e)
|
||||
toast.show(`Failed to open file: ${e instanceof Error ? e.message : String(e)}`, 'error')
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
|
|
@ -784,14 +790,14 @@ export function createEditorStore() {
|
|||
const file = new File([blob], state.documentName + '.fig')
|
||||
const imported = await readFigFile(file)
|
||||
graph = imported
|
||||
subscribeToGraph()
|
||||
computeAllLayouts(graph)
|
||||
subscribeToGraph()
|
||||
} else if (fileHandle) {
|
||||
const file = await fileHandle.getFile()
|
||||
const imported = await readFigFile(file)
|
||||
graph = imported
|
||||
subscribeToGraph()
|
||||
computeAllLayouts(graph)
|
||||
subscribeToGraph()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
|
@ -1966,7 +1972,17 @@ export function createEditorStore() {
|
|||
const toLoad = collectFontKeys(graph, nodeIds)
|
||||
if (toLoad.length === 0) return
|
||||
|
||||
await Promise.all(toLoad.map(([family, style]) => loadFont(family, style)))
|
||||
const results = await Promise.all(toLoad.map(([family, style]) => loadFont(family, style)))
|
||||
const failed = toLoad.filter((_, i) => results[i] === null)
|
||||
if (failed.length > 0) {
|
||||
const families = [...new Set(failed.map(([family]) => family))]
|
||||
toast.show(
|
||||
families.length === 1
|
||||
? `Font "${families[0]}" could not be loaded`
|
||||
: `${families.length} fonts could not be loaded: ${families.join(', ')}`,
|
||||
'warning'
|
||||
)
|
||||
}
|
||||
computeAllLayouts(graph, state.currentPageId)
|
||||
requestRender()
|
||||
}
|
||||
|
|
|
|||
90
tests/engine/fig-export-worker.test.ts
Normal file
90
tests/engine/fig-export-worker.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { describe, test, expect, beforeAll, setDefaultTimeout } from 'bun:test'
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
import {
|
||||
parseFigFile,
|
||||
exportFigFile,
|
||||
compressFigDataSync,
|
||||
initCodec,
|
||||
SceneGraph
|
||||
} from '@open-pencil/core'
|
||||
import { heavy } from '../helpers/test-utils'
|
||||
|
||||
setDefaultTimeout(30_000)
|
||||
|
||||
const FIXTURES = resolve(import.meta.dir, '../fixtures')
|
||||
|
||||
describe('fig export compression', () => {
|
||||
test('compressFigDataSync produces valid zip', async () => {
|
||||
await initCodec()
|
||||
|
||||
const schemaDeflated = new Uint8Array([0x78, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01])
|
||||
const kiwiData = new Uint8Array([1, 2, 3, 4, 5])
|
||||
const thumbnailPng = new Uint8Array([0x89, 0x50, 0x4e, 0x47])
|
||||
const metaJson = JSON.stringify({ version: 1, app: 'test' })
|
||||
|
||||
const result = compressFigDataSync(
|
||||
schemaDeflated,
|
||||
kiwiData,
|
||||
thumbnailPng,
|
||||
metaJson,
|
||||
[]
|
||||
)
|
||||
|
||||
expect(result).toBeInstanceOf(Uint8Array)
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(result[0]).toBe(0x50)
|
||||
expect(result[1]).toBe(0x4b)
|
||||
})
|
||||
|
||||
test('compressFigDataSync with images produces valid zip', async () => {
|
||||
await initCodec()
|
||||
|
||||
const schemaDeflated = new Uint8Array([0x78, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01])
|
||||
const kiwiData = new Uint8Array([10, 20, 30])
|
||||
const thumbnailPng = new Uint8Array([0x89, 0x50])
|
||||
const metaJson = JSON.stringify({ version: 1, app: 'test' })
|
||||
const images = [
|
||||
{ name: 'images/abc123', data: new Uint8Array([0xff, 0xd8, 0xff, 0xe0]) }
|
||||
]
|
||||
|
||||
const result = compressFigDataSync(
|
||||
schemaDeflated,
|
||||
kiwiData,
|
||||
thumbnailPng,
|
||||
metaJson,
|
||||
images
|
||||
)
|
||||
|
||||
expect(result).toBeInstanceOf(Uint8Array)
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(result[0]).toBe(0x50)
|
||||
expect(result[1]).toBe(0x4b)
|
||||
})
|
||||
})
|
||||
|
||||
heavy('fig export roundtrip', () => {
|
||||
let parsed: SceneGraph
|
||||
|
||||
beforeAll(async () => {
|
||||
const buf = readFileSync(resolve(FIXTURES, 'gold-preview.fig'))
|
||||
parsed = await parseFigFile(buf.buffer as ArrayBuffer)
|
||||
})
|
||||
|
||||
test('exportFigFile produces valid .fig', async () => {
|
||||
const exported = await exportFigFile(parsed)
|
||||
expect(exported).toBeInstanceOf(Uint8Array)
|
||||
expect(exported.length).toBeGreaterThan(100)
|
||||
|
||||
expect(exported[0]).toBe(0x50)
|
||||
expect(exported[1]).toBe(0x4b)
|
||||
})
|
||||
|
||||
test('exported file can be parsed back', async () => {
|
||||
const exported = await exportFigFile(parsed)
|
||||
const reparsed = await parseFigFile(exported.buffer as ArrayBuffer)
|
||||
expect(reparsed).toBeInstanceOf(SceneGraph)
|
||||
expect(reparsed.getPages().length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
216
tests/engine/label-cache.test.ts
Normal file
216
tests/engine/label-cache.test.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import { describe, it, expect } from 'bun:test'
|
||||
import { SceneGraph, LabelCache } from '@open-pencil/core'
|
||||
|
||||
function buildGraph() {
|
||||
const g = new SceneGraph()
|
||||
const pageId = g.getPages()[0].id
|
||||
|
||||
const sectionId = g.createNode('SECTION', pageId, {
|
||||
name: 'Section 1',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 200
|
||||
}).id
|
||||
|
||||
const nestedSectionId = g.createNode('SECTION', sectionId, {
|
||||
name: 'Nested Section',
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 100,
|
||||
height: 100
|
||||
}).id
|
||||
|
||||
const compSetId = g.createNode('COMPONENT_SET', pageId, {
|
||||
name: 'Button Set',
|
||||
x: 300,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 200
|
||||
}).id
|
||||
|
||||
const compId = g.createNode('COMPONENT', compSetId, {
|
||||
name: 'Button',
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 80,
|
||||
height: 40
|
||||
}).id
|
||||
|
||||
const standaloneCompId = g.createNode('COMPONENT', pageId, {
|
||||
name: 'Standalone',
|
||||
x: 600,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 50
|
||||
}).id
|
||||
|
||||
return { g, pageId, sectionId, nestedSectionId, compSetId, compId, standaloneCompId }
|
||||
}
|
||||
|
||||
describe('LabelCache', () => {
|
||||
it('collects sections on first call', () => {
|
||||
const { g, pageId } = buildGraph()
|
||||
const cache = new LabelCache()
|
||||
|
||||
cache.update(g, pageId, 1)
|
||||
const sections = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
|
||||
expect(sections.length).toBe(2)
|
||||
expect(sections.map((s) => s.node.name).sort()).toEqual(['Nested Section', 'Section 1'])
|
||||
})
|
||||
|
||||
it('returns cached data when sceneVersion unchanged', () => {
|
||||
const { g, pageId } = buildGraph()
|
||||
const cache = new LabelCache()
|
||||
|
||||
cache.update(g, pageId, 1)
|
||||
const first = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
|
||||
|
||||
cache.update(g, pageId, 1)
|
||||
const second = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
|
||||
|
||||
expect(first.length).toBe(second.length)
|
||||
expect(first.map((s) => s.node.id)).toEqual(second.map((s) => s.node.id))
|
||||
})
|
||||
|
||||
it('invalidates when sceneVersion changes', () => {
|
||||
const { g, pageId } = buildGraph()
|
||||
const cache = new LabelCache()
|
||||
|
||||
cache.update(g, pageId, 1)
|
||||
const before = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
|
||||
|
||||
g.createNode('SECTION', pageId, { name: 'New Section', x: 500, y: 500, width: 100, height: 100 })
|
||||
cache.update(g, pageId, 2)
|
||||
const after = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
|
||||
|
||||
expect(after.length).toBe(before.length + 1)
|
||||
})
|
||||
|
||||
it('invalidates when pageId changes', () => {
|
||||
const { g, pageId } = buildGraph()
|
||||
const cache = new LabelCache()
|
||||
|
||||
cache.update(g, pageId, 1)
|
||||
expect(cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 }).length).toBe(2)
|
||||
|
||||
const page2 = g.addPage('Page 2')
|
||||
cache.update(g, page2.id, 1)
|
||||
expect(cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 }).length).toBe(0)
|
||||
})
|
||||
|
||||
it('filters sections by viewport', () => {
|
||||
const { g, pageId } = buildGraph()
|
||||
const cache = new LabelCache()
|
||||
|
||||
cache.update(g, pageId, 1)
|
||||
|
||||
const all = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
|
||||
expect(all.length).toBe(2)
|
||||
|
||||
const visible = cache.getSections(g, { x: 0, y: 0, w: 50, h: 50 })
|
||||
expect(visible.length).toBeGreaterThan(0)
|
||||
|
||||
const offscreen = cache.getSections(g, { x: 5000, y: 5000, w: 100, h: 100 })
|
||||
expect(offscreen.length).toBe(0)
|
||||
})
|
||||
|
||||
it('tracks nested sections', () => {
|
||||
const { g, pageId } = buildGraph()
|
||||
const cache = new LabelCache()
|
||||
|
||||
cache.update(g, pageId, 1)
|
||||
const sections = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
|
||||
|
||||
const topLevel = sections.find((s) => s.node.name === 'Section 1')
|
||||
const nested = sections.find((s) => s.node.name === 'Nested Section')
|
||||
|
||||
expect(topLevel?.nested).toBe(false)
|
||||
expect(nested?.nested).toBe(true)
|
||||
})
|
||||
|
||||
it('collects components and component sets', () => {
|
||||
const { g, pageId } = buildGraph()
|
||||
const cache = new LabelCache()
|
||||
|
||||
cache.update(g, pageId, 1)
|
||||
const components = cache.getComponents(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
|
||||
|
||||
expect(components.length).toBe(3)
|
||||
const names = components.map((c) => c.node.name).sort()
|
||||
expect(names).toEqual(['Button', 'Button Set', 'Standalone'])
|
||||
})
|
||||
|
||||
it('identifies components inside component sets', () => {
|
||||
const { g, pageId } = buildGraph()
|
||||
const cache = new LabelCache()
|
||||
|
||||
cache.update(g, pageId, 1)
|
||||
const components = cache.getComponents(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
|
||||
|
||||
const buttonSet = components.find((c) => c.node.name === 'Button Set')
|
||||
const button = components.find((c) => c.node.name === 'Button')
|
||||
const standalone = components.find((c) => c.node.name === 'Standalone')
|
||||
|
||||
expect(buttonSet?.inside).toBe(false)
|
||||
expect(button?.inside).toBe(true)
|
||||
expect(standalone?.inside).toBe(false)
|
||||
})
|
||||
|
||||
it('skips invisible nodes', () => {
|
||||
const { g, pageId, sectionId } = buildGraph()
|
||||
const cache = new LabelCache()
|
||||
|
||||
g.updateNode(sectionId, { visible: false })
|
||||
cache.update(g, pageId, 1)
|
||||
|
||||
const sections = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
|
||||
const names = sections.map((s) => s.node.name)
|
||||
expect(names).not.toContain('Section 1')
|
||||
expect(names).not.toContain('Nested Section')
|
||||
})
|
||||
|
||||
it('handles empty graph', () => {
|
||||
const g = new SceneGraph()
|
||||
const pageId = g.getPages()[0].id
|
||||
const cache = new LabelCache()
|
||||
|
||||
cache.update(g, pageId, 0)
|
||||
expect(cache.getSections(g, { x: 0, y: 0, w: 1000, h: 1000 })).toEqual([])
|
||||
expect(cache.getComponents(g, { x: 0, y: 0, w: 1000, h: 1000 })).toEqual([])
|
||||
})
|
||||
|
||||
it('explicit invalidate() forces recollection', () => {
|
||||
const { g, pageId } = buildGraph()
|
||||
const cache = new LabelCache()
|
||||
|
||||
cache.update(g, pageId, 1)
|
||||
expect(cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 }).length).toBe(2)
|
||||
|
||||
g.createNode('SECTION', pageId, { name: 'Sneaky', x: 800, y: 0, width: 50, height: 50 })
|
||||
|
||||
cache.update(g, pageId, 1)
|
||||
expect(cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 }).length).toBe(2)
|
||||
|
||||
cache.invalidate()
|
||||
cache.update(g, pageId, 1)
|
||||
expect(cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 }).length).toBe(3)
|
||||
})
|
||||
|
||||
it('computes correct absolute positions for nested nodes', () => {
|
||||
const { g, pageId } = buildGraph()
|
||||
const cache = new LabelCache()
|
||||
|
||||
cache.update(g, pageId, 1)
|
||||
const sections = cache.getSections(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
|
||||
|
||||
const nested = sections.find((s) => s.node.name === 'Nested Section')
|
||||
expect(nested?.absX).toBe(10)
|
||||
expect(nested?.absY).toBe(10)
|
||||
|
||||
const components = cache.getComponents(g, { x: -1000, y: -1000, w: 3000, h: 3000 })
|
||||
const button = components.find((c) => c.node.name === 'Button')
|
||||
expect(button?.absX).toBe(310)
|
||||
expect(button?.absY).toBe(10)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue