openpencil/packages/core/src/renderer/labels.ts
Danila Poyarkov 3b32b4d80c
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
2026-03-12 14:49:28 +03:00

169 lines
4.9 KiB
TypeScript

import {
SECTION_TITLE_HEIGHT,
SECTION_TITLE_PADDING_X,
SECTION_TITLE_RADIUS,
SECTION_TITLE_GAP,
COMPONENT_LABEL_FONT_SIZE,
COMPONENT_LABEL_GAP,
COMPONENT_LABEL_ICON_SIZE,
COMPONENT_LABEL_ICON_GAP
} from '../constants'
import type { SceneNode, SceneGraph } from '../scene-graph'
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 sections = r.labelCache.getSections(graph, r.worldViewport)
if (sections.length === 0) return
const font = r.sectionTitleFont
const ellipsis = '…'
const ellipsisGlyphs = font.getGlyphIDs(ellipsis)
const ellipsisWidth = font.getGlyphWidths(ellipsisGlyphs)[0]
for (const { node, absX, absY, nested } of sections) {
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 components = r.labelCache.getComponents(graph, r.worldViewport)
if (components.length === 0) return
const font = r.componentLabelFont
const compColor = r.compColor()
const iconS = COMPONENT_LABEL_ICON_SIZE
for (const { node, absX, absY, inside } of components) {
const screenX = absX * r.zoom + r.panX
const screenY = absY * r.zoom + r.panY
const labelX = screenX
let labelY: number
if (inside) {
labelY = screenY + COMPONENT_LABEL_GAP + COMPONENT_LABEL_FONT_SIZE
} else {
labelY = screenY - COMPONENT_LABEL_GAP
}
const iconX = labelX
const iconY = labelY - COMPONENT_LABEL_FONT_SIZE * 0.75
const iconCx = iconX + iconS / 2
const iconCy = iconY + iconS / 2
const iconR = iconS / 2
r.auxFill.setColor(compColor)
if (node.type === 'COMPONENT_SET') {
const s = iconR * 0.45
const gap = iconR * 0.2
const path = new r.ck.Path()
for (const [dx, dy] of [
[-1, -1],
[1, -1],
[-1, 1],
[1, 1]
]) {
const cx = iconCx + dx * (s + gap)
const cy = iconCy + dy * (s + gap)
path.moveTo(cx, cy - s)
path.lineTo(cx + s, cy)
path.lineTo(cx, cy + s)
path.lineTo(cx - s, cy)
path.close()
}
canvas.drawPath(path, r.auxFill)
path.delete()
} else {
const path = new r.ck.Path()
path.moveTo(iconCx, iconCy - iconR)
path.lineTo(iconCx + iconR, iconCy)
path.lineTo(iconCx, iconCy + iconR)
path.lineTo(iconCx - iconR, iconCy)
path.close()
canvas.drawPath(path, r.auxFill)
path.delete()
}
canvas.drawText(
node.name,
labelX + iconS + COMPONENT_LABEL_ICON_GAP,
labelY,
r.auxFill,
font
)
}
}