openpencil/src/stores/editor.ts
Danila Poyarkov 353c867d03 Add no-silent-catch lint rule, fix silent failures
- New oxlint rule: open-pencil/no-silent-catch — errors on empty catch blocks
- Replace all 8 empty catch blocks with console.warn() logging
- Add worker timeout (30s) and main-thread fallback for .fig parsing
- Fix null crash in renderer filter/picture cache cleanup
- Buffer copy before worker transfer for safe fallback
2026-03-13 10:03:12 +03:00

2449 lines
69 KiB
TypeScript

import { shallowReactive, shallowRef, computed, watch } from 'vue'
import { toast } from '@/composables/use-toast'
import {
IS_TAURI,
DEFAULT_SHAPE_FILL,
DEFAULT_FRAME_FILL,
SECTION_DEFAULT_FILL,
SECTION_DEFAULT_STROKE,
CANVAS_BG_COLOR,
ZOOM_DIVISOR,
ZOOM_SCALE_MIN,
ZOOM_SCALE_MAX
} from '@/constants'
import { loadFont } from '@/engine/fonts'
import {
collectFontKeys,
computeLayout,
computeAllLayouts,
computeVectorBounds,
exportFigFile,
importClipboardNodes,
parseFigmaClipboard,
buildFigmaClipboardHTML,
prefetchFigmaSchema,
readFigFile,
computeImageHash,
renderNodesToImage,
renderNodesToSVG,
SceneGraph,
setTextMeasurer,
TextEditor,
UndoManager
} from '@open-pencil/core'
import type {
Color,
ExportFormat,
Fill,
LayoutMode,
NodeType,
Rect,
SceneNode,
SkiaRenderer,
SnapGuide,
UndoEntry,
VectorNetwork,
VectorRegion,
VectorSegment,
Vector,
VectorVertex
} from '@open-pencil/core'
import type { CanvasKit } from 'canvaskit-wasm'
export type Tool =
| 'SELECT'
| 'FRAME'
| 'SECTION'
| 'RECTANGLE'
| 'ELLIPSE'
| 'LINE'
| 'POLYGON'
| 'STAR'
| 'TEXT'
| 'PEN'
| 'HAND'
export interface ToolDef {
key: Tool
label: string
shortcut: string
flyout?: Tool[]
}
export const TOOLS: ToolDef[] = [
{ key: 'SELECT', label: 'Move', shortcut: 'V' },
{ key: 'FRAME', label: 'Frame', shortcut: 'F', flyout: ['FRAME', 'SECTION'] },
{
key: 'RECTANGLE',
label: 'Rectangle',
shortcut: 'R',
flyout: ['RECTANGLE', 'LINE', 'ELLIPSE', 'POLYGON', 'STAR']
},
{ key: 'PEN', label: 'Pen', shortcut: 'P' },
{ key: 'TEXT', label: 'Text', shortcut: 'T' },
{ key: 'HAND', label: 'Hand', shortcut: 'H' }
]
export const TOOL_SHORTCUTS: Partial<Record<string, Tool>> = {
v: 'SELECT',
f: 'FRAME',
s: 'SECTION',
r: 'RECTANGLE',
o: 'ELLIPSE',
l: 'LINE',
t: 'TEXT',
p: 'PEN',
h: 'HAND'
}
const BLACK_FILL: Fill = {
type: 'SOLID',
color: { r: 0, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true
}
const DEFAULT_FILLS: Record<string, Fill> = {
FRAME: DEFAULT_FRAME_FILL,
SECTION: SECTION_DEFAULT_FILL,
RECTANGLE: DEFAULT_SHAPE_FILL,
ELLIPSE: DEFAULT_SHAPE_FILL,
POLYGON: DEFAULT_SHAPE_FILL,
STAR: DEFAULT_SHAPE_FILL,
LINE: BLACK_FILL,
TEXT: BLACK_FILL
}
interface PageViewport {
panX: number
panY: number
zoom: number
pageColor: Color
}
export function createEditorStore() {
let graph = new SceneGraph()
const undo = new UndoManager()
const pageViewports = new Map<string, PageViewport>()
let fileHandle: FileSystemFileHandle | null = null
let filePath: string | null = null
let downloadName: string | null = null
let savedVersion = 0
let autosaveTimer: ReturnType<typeof setTimeout> | undefined
let lastWriteTime = 0
let unwatchFile: (() => void) | null = null
let _ck: CanvasKit | null = null
let _renderer: SkiaRenderer | null = null
let _textEditor: TextEditor | null = null
void prefetchFigmaSchema()
function downloadBlob(data: Uint8Array, filename: string, mime: string) {
const blob = new Blob([data.buffer as ArrayBuffer], { type: mime })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.style.display = 'none'
document.body.appendChild(a)
a.click()
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 100)
}
const state = shallowReactive({
activeTool: 'SELECT' as Tool,
currentPageId: graph.getPages()[0].id,
selectedIds: new Set<string>(),
marquee: null as Rect | null,
snapGuides: [] as SnapGuide[],
rotationPreview: null as { nodeId: string; angle: number } | null,
dropTargetId: null as string | null,
layoutInsertIndicator: null as {
parentId: string
index: number
x: number
y: number
length: number
direction: 'HORIZONTAL' | 'VERTICAL'
} | null,
hoveredNodeId: null as string | null,
editingTextId: null as string | null,
penState: null as {
vertices: VectorVertex[]
segments: VectorSegment[]
dragTangent: Vector | null
closingToFirst: boolean
} | null,
penCursorX: null as number | null,
penCursorY: null as number | null,
cursorCanvasX: null as number | null,
cursorCanvasY: null as number | null,
remoteCursors: [] as Array<{
name: string
color: Color
x: number
y: number
selection?: string[]
}>,
showUI: true,
documentName: 'Untitled' as string,
panX: 0,
pageColor: { ...CANVAS_BG_COLOR } as Color,
panY: 0,
zoom: 1,
renderVersion: 0,
sceneVersion: 0,
loading: false,
activeRibbonTab: 'panels' as 'panels' | 'code' | 'ai',
panelMode: 'design' as 'layers' | 'design',
actionToast: null as string | null,
mobileDrawerSnap: 'closed' as 'closed' | 'half' | 'full',
clipboardHtml: '',
autosaveEnabled: true
})
const AUTOSAVE_DELAY = 3000
watch(
() => state.sceneVersion,
(version) => {
if (version === savedVersion) return
if (!state.autosaveEnabled) return
if (!fileHandle && !filePath) return
clearTimeout(autosaveTimer)
// oxlint-disable-next-line typescript/no-misused-promises
autosaveTimer = setTimeout(async () => {
if (state.sceneVersion === savedVersion) return
if (!state.autosaveEnabled) return
try {
await writeFile(await buildFigFile())
} catch (e) {
console.warn('Autosave failed:', e)
}
}, AUTOSAVE_DELAY)
}
)
const selectedNodes = computed(() => {
void state.sceneVersion
const nodes: SceneNode[] = []
for (const id of state.selectedIds) {
const n = graph.getNode(id)
if (n) nodes.push({ ...n })
}
return nodes
})
const selectedNode = computed(() =>
selectedNodes.value.length === 1 ? selectedNodes.value[0] : undefined
)
const layerTree = computed(() => {
void state.sceneVersion
return graph.flattenTree(state.currentPageId)
})
function requestRender() {
state.renderVersion++
state.sceneVersion++
}
function requestRepaint() {
state.renderVersion++
}
let flashRafId = 0
function flashNodes(nodeIds: string[]) {
if (!_renderer) return
for (const id of nodeIds) _renderer.flashNode(id)
if (!flashRafId) pumpFlashes()
}
function pumpFlashes() {
if (!_renderer?.hasActiveFlashes) {
flashRafId = 0
return
}
state.renderVersion++
flashRafId = requestAnimationFrame(pumpFlashes)
}
function isTopLevel(parentId: string | null): boolean {
return !parentId || parentId === graph.rootId || parentId === state.currentPageId
}
async function switchPage(pageId: string) {
const page = graph.getNode(pageId)
if (page?.type !== 'CANVAS') return
// Save current viewport
pageViewports.set(state.currentPageId, {
panX: state.panX,
panY: state.panY,
zoom: state.zoom,
pageColor: { ...state.pageColor }
})
// Switch
state.currentPageId = pageId
clearSelection()
// Restore viewport
const vp = pageViewports.get(pageId)
if (vp) {
state.panX = vp.panX
state.panY = vp.panY
state.zoom = vp.zoom
state.pageColor = { ...vp.pageColor }
} else {
state.panX = 0
state.panY = 0
state.zoom = 1
state.pageColor = { ...CANVAS_BG_COLOR }
}
await loadFontsForNodes(graph.getChildren(pageId).map((n) => n.id))
requestRender()
}
function addPage(name?: string) {
const pages = graph.getPages()
const pageName = name ?? `Page ${pages.length + 1}`
const page = graph.addPage(pageName)
void switchPage(page.id)
return page.id
}
function deletePage(pageId: string) {
const pages = graph.getPages()
if (pages.length <= 1) return
const idx = pages.findIndex((p) => p.id === pageId)
graph.deleteNode(pageId)
pageViewports.delete(pageId)
if (state.currentPageId === pageId) {
const newIdx = Math.min(idx, pages.length - 2)
const remaining = graph.getPages()
void switchPage(remaining[newIdx].id)
}
}
function renamePage(pageId: string, name: string) {
graph.updateNode(pageId, { name })
}
function setTool(tool: Tool) {
state.activeTool = tool
}
function select(ids: string[], additive = false) {
if (additive) {
const next = new Set(state.selectedIds)
for (const id of ids) {
if (next.has(id)) next.delete(id)
else next.add(id)
}
state.selectedIds = next
} else {
state.selectedIds = new Set(ids)
}
}
function clearSelection() {
state.selectedIds = new Set()
}
function setMarquee(rect: Rect | null) {
state.marquee = rect
requestRepaint()
}
function setSnapGuides(guides: SnapGuide[]) {
state.snapGuides = guides
requestRepaint()
}
function setRotationPreview(preview: { nodeId: string; angle: number } | null) {
state.rotationPreview = preview
requestRepaint()
}
function setHoveredNode(id: string | null) {
if (state.hoveredNodeId === id) return
state.hoveredNodeId = id
requestRepaint()
}
function setDropTarget(id: string | null) {
state.dropTargetId = id
requestRepaint()
}
function setLayoutInsertIndicator(indicator: typeof state.layoutInsertIndicator) {
state.layoutInsertIndicator = indicator
requestRepaint()
}
function doReorderChild(nodeId: string, parentId: string, insertIndex: number) {
const node = graph.getNode(nodeId)
if (!node) return
if (node.parentId !== parentId) {
const absPos = graph.getAbsolutePosition(nodeId)
const parentAbs = graph.getAbsolutePosition(parentId)
graph.updateNode(nodeId, { x: absPos.x - parentAbs.x, y: absPos.y - parentAbs.y })
}
graph.reorderChild(nodeId, parentId, insertIndex)
computeLayout(graph, parentId)
runLayoutForNode(parentId)
}
function reorderInAutoLayout(nodeId: string, parentId: string, insertIndex: number) {
const parent = graph.getNode(parentId)
if (!parent || parent.layoutMode === 'NONE') return
const node = graph.getNode(nodeId)
if (!node) return
const origParentId = node.parentId ?? state.currentPageId
const origX = node.x
const origY = node.y
const origIndex = graph.getNode(origParentId)?.childIds.indexOf(nodeId) ?? -1
doReorderChild(nodeId, parentId, insertIndex)
undo.push({
label: 'Reorder',
forward: () => {
doReorderChild(nodeId, parentId, insertIndex)
},
inverse: () => {
graph.reorderChild(nodeId, origParentId, origIndex >= 0 ? origIndex : 0)
graph.updateNode(nodeId, { x: origX, y: origY })
computeLayout(graph, origParentId)
runLayoutForNode(origParentId)
if (origParentId !== parentId) {
computeLayout(graph, parentId)
runLayoutForNode(parentId)
}
}
})
}
function reorderChildWithUndo(nodeId: string, newParentId: string, insertIndex: number) {
const node = graph.getNode(nodeId)
if (!node) return
const origParentId = node.parentId ?? state.currentPageId
const origIndex = graph.getNode(origParentId)?.childIds.indexOf(nodeId) ?? 0
const origX = node.x
const origY = node.y
graph.reorderChild(nodeId, newParentId, insertIndex)
runLayoutForNode(newParentId)
if (origParentId !== newParentId) runLayoutForNode(origParentId)
undo.push({
label: 'Reorder',
forward: () => {
graph.reorderChild(nodeId, newParentId, insertIndex)
runLayoutForNode(newParentId)
if (origParentId !== newParentId) runLayoutForNode(origParentId)
},
inverse: () => {
graph.reorderChild(nodeId, origParentId, origIndex)
graph.updateNode(nodeId, { x: origX, y: origY })
runLayoutForNode(origParentId)
if (origParentId !== newParentId) runLayoutForNode(newParentId)
}
})
}
function reparentNodes(nodeIds: string[], newParentId: string) {
const parent = graph.getNode(newParentId)
for (const id of nodeIds) {
const node = graph.getNode(id)
// Sections can only live in pages (CANVAS) or other sections
if (
node?.type === 'SECTION' &&
parent &&
parent.type !== 'CANVAS' &&
parent.type !== 'SECTION'
)
continue
graph.reparentNode(id, newParentId)
}
}
function penAddVertex(x: number, y: number) {
if (!state.penState) {
state.penState = {
vertices: [{ x, y }],
segments: [],
dragTangent: null,
closingToFirst: false
}
requestRender()
return
}
const ps = state.penState
const prevIdx = ps.vertices.length - 1
// Check if clicking near first vertex to close
const first = ps.vertices[0]
const dist = Math.hypot(x - first.x, y - first.y)
if (ps.vertices.length > 2 && dist < 8) {
// Close the path
ps.segments.push({
start: prevIdx,
end: 0,
tangentStart: ps.dragTangent ?? { x: 0, y: 0 },
tangentEnd: { x: 0, y: 0 }
})
penCommit(true)
return
}
// Add new vertex and segment from previous
ps.vertices.push({ x, y })
const newIdx = ps.vertices.length - 1
ps.segments.push({
start: prevIdx,
end: newIdx,
tangentStart: ps.dragTangent ?? { x: 0, y: 0 },
tangentEnd: { x: 0, y: 0 }
})
ps.dragTangent = null
requestRender()
}
function penSetDragTangent(tx: number, ty: number) {
if (!state.penState) return
state.penState.dragTangent = { x: tx, y: ty }
// Also set the tangentStart on the last segment (the one being dragged)
const ps = state.penState
if (ps.segments.length > 0) {
const lastSeg = ps.segments[ps.segments.length - 1]
lastSeg.tangentEnd = { x: -tx, y: -ty }
}
requestRender()
}
function penSetClosingToFirst(closing: boolean) {
if (!state.penState) return
state.penState.closingToFirst = closing
requestRender()
}
function penCommit(closed: boolean) {
const ps = state.penState
if (!ps || ps.vertices.length < 2) {
state.penState = null
state.penCursorX = null
state.penCursorY = null
return
}
const regions: VectorRegion[] = closed
? [{ windingRule: 'NONZERO', loops: [ps.segments.map((_, i) => i)] }]
: []
const network: VectorNetwork = {
vertices: ps.vertices,
segments: ps.segments,
regions
}
const bounds = computeVectorBounds(network)
// Normalize vertices relative to bounds origin
const normalizedVertices = network.vertices.map((v) => ({
...v,
x: v.x - bounds.x,
y: v.y - bounds.y
}))
const normalizedNetwork: VectorNetwork = {
vertices: normalizedVertices,
segments: network.segments,
regions: network.regions
}
const nodeId = createShape('VECTOR', bounds.x, bounds.y, bounds.width, bounds.height)
updateNode(nodeId, {
vectorNetwork: normalizedNetwork,
name: 'Vector',
fills: closed ? [{ ...DEFAULT_SHAPE_FILL }] : [],
strokes: closed
? []
: [
{
color: { r: 0, g: 0, b: 0, a: 1 },
weight: 2,
opacity: 1,
visible: true,
align: 'CENTER' as const
}
]
})
select([nodeId])
state.penState = null
state.penCursorX = null
state.penCursorY = null
state.activeTool = 'SELECT'
requestRender()
}
function penCancel() {
state.penState = null
state.penCursorX = null
state.penCursorY = null
state.activeTool = 'SELECT'
requestRender()
}
function startTextEditing(nodeId: string) {
if (state.editingTextId) commitTextEdit()
const node = graph.getNode(nodeId)
if (!node) return
state.editingTextId = nodeId
if (_textEditor) {
_textEditor.setRenderer(_renderer)
_textEditor.start(node)
}
requestRender()
}
function commitTextEdit() {
if (!_textEditor?.isActive) {
state.editingTextId = null
return
}
const result = _textEditor.stop()
if (!result) {
state.editingTextId = null
requestRender()
return
}
const node = graph.getNode(result.nodeId)
const prevText = node?.text ?? ''
const newText = result.text
graph.updateNode(result.nodeId, { text: newText })
state.editingTextId = null
if (prevText !== newText) {
undo.push({
label: 'Edit text',
forward: () => {
graph.updateNode(result.nodeId, { text: newText })
},
inverse: () => {
graph.updateNode(result.nodeId, { text: prevText })
}
})
}
}
function yieldToUI(): Promise<void> {
return new Promise((r) => requestAnimationFrame(() => r()))
}
async function openFigFile(file: File, handle?: FileSystemFileHandle, path?: string) {
try {
state.loading = true
await yieldToUI()
const imported = await readFigFile(file)
await yieldToUI()
graph = imported
computeAllLayouts(graph)
subscribeToGraph()
undo.clear()
pageViewports.clear()
fileHandle = handle ?? null
filePath = path ?? null
state.documentName = file.name.replace(/\.fig$/i, '')
downloadName = file.name
state.selectedIds = new Set()
const firstPage = graph.getPages()[0] as SceneNode | undefined
const pageId = firstPage?.id ?? graph.rootId
state.currentPageId = pageId
state.panX = 0
state.panY = 0
state.zoom = 1
state.pageColor = { ...CANVAS_BG_COLOR }
await loadFontsForNodes(graph.getChildren(pageId).map((n) => n.id))
requestRender()
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
}
}
function setCanvasKit(ck: CanvasKit, renderer: SkiaRenderer) {
_ck = ck
_renderer = renderer
_textEditor = new TextEditor(ck)
setTextMeasurer((node, maxWidth) => renderer.measureTextNode(node, maxWidth))
}
function buildFigFile() {
return exportFigFile(graph, _ck ?? undefined, _renderer ?? undefined, state.currentPageId)
}
async function saveFigFile() {
if (filePath || fileHandle) {
await writeFile(await buildFigFile())
} else if (downloadName) {
downloadBlob(new Uint8Array(await buildFigFile()), downloadName, 'application/octet-stream')
} else {
await saveFigFileAs()
}
}
async function saveFigFileAs() {
const data = await buildFigFile()
if (IS_TAURI) {
const { save } = await import('@tauri-apps/plugin-dialog')
const path = await save({
defaultPath: 'Untitled.fig',
filters: [{ name: 'Figma file', extensions: ['fig'] }]
})
if (!path) return
filePath = path
fileHandle = null
state.documentName =
path
.split('/')
.pop()
?.replace(/\.fig$/i, '') ?? 'Untitled'
await writeFile(data)
void startWatchingFile()
return
}
if (window.showSaveFilePicker) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: 'Untitled.fig',
types: [
{
description: 'Figma file',
accept: { 'application/octet-stream': ['.fig'] }
}
]
})
fileHandle = handle
filePath = null
state.documentName = handle.name.replace(/\.fig$/i, '')
await writeFile(data)
void startWatchingFile()
return
} catch (e) {
if ((e as Error).name === 'AbortError') return
}
}
const filename = prompt('Save as:', downloadName ?? 'Untitled.fig')
if (!filename) return
downloadName = filename
state.documentName = filename.replace(/\.fig$/i, '')
downloadBlob(new Uint8Array(data), filename, 'application/octet-stream')
}
async function writeFile(data: Uint8Array) {
lastWriteTime = Date.now()
if (filePath && IS_TAURI) {
const { writeFile: tauriWrite } = await import('@tauri-apps/plugin-fs')
await tauriWrite(filePath, data)
savedVersion = state.sceneVersion
return
}
if (fileHandle) {
const writable = await fileHandle.createWritable()
await writable.write(new Uint8Array(data))
await writable.close()
savedVersion = state.sceneVersion
}
}
const WATCH_DEBOUNCE_MS = 1000
async function reloadFromDisk() {
const viewport = { panX: state.panX, panY: state.panY, zoom: state.zoom }
const pageId = state.currentPageId
if (filePath && IS_TAURI) {
const { readFile: tauriRead } = await import('@tauri-apps/plugin-fs')
const bytes = await tauriRead(filePath)
const blob = new Blob([bytes])
const file = new File([blob], state.documentName + '.fig')
const imported = await readFigFile(file)
graph = imported
computeAllLayouts(graph)
subscribeToGraph()
} else if (fileHandle) {
const file = await fileHandle.getFile()
const imported = await readFigFile(file)
graph = imported
computeAllLayouts(graph)
subscribeToGraph()
} else {
return
}
undo.clear()
savedVersion = state.sceneVersion
state.selectedIds = new Set()
if (graph.getNode(pageId)) {
state.currentPageId = pageId
} else {
state.currentPageId = graph.getPages()[0]?.id ?? graph.rootId
}
state.panX = viewport.panX
state.panY = viewport.panY
state.zoom = viewport.zoom
requestRender()
}
function stopWatchingFile() {
if (unwatchFile) {
unwatchFile()
unwatchFile = null
}
}
async function startWatchingFile() {
stopWatchingFile()
if (filePath && IS_TAURI) {
const { watch: tauriWatch } = await import('@tauri-apps/plugin-fs')
const path = filePath
const unwatch = await tauriWatch(
path,
(event) => {
if (typeof event.type !== 'object' || !('modify' in event.type)) return
if (Date.now() - lastWriteTime < WATCH_DEBOUNCE_MS) return
void reloadFromDisk()
},
{ delayMs: 500 }
)
unwatchFile = () => unwatch()
} else if (fileHandle) {
let lastModified = (await fileHandle.getFile()).lastModified
// oxlint-disable-next-line typescript/no-misused-promises
const interval = setInterval(async () => {
if (!fileHandle) {
clearInterval(interval)
return
}
try {
const file = await fileHandle.getFile()
if (file.lastModified > lastModified) {
lastModified = file.lastModified
if (Date.now() - lastWriteTime < WATCH_DEBOUNCE_MS) return
void reloadFromDisk()
}
} catch {
clearInterval(interval)
}
}, 2000)
unwatchFile = () => clearInterval(interval)
}
}
async function renderExportImage(
nodeIds: string[],
scale: number,
format: ExportFormat
): Promise<Uint8Array | null> {
if (!_ck || !_renderer) return null
const ids =
nodeIds.length > 0 ? nodeIds : graph.getChildren(state.currentPageId).map((n) => n.id)
if (ids.length === 0) return null
return renderNodesToImage(_ck, _renderer, graph, state.currentPageId, ids, { scale, format })
}
function exportImageExtension(format: ExportFormat): string {
switch (format) {
case 'JPG':
return '.jpg'
case 'WEBP':
return '.webp'
default:
return '.png'
}
}
function exportImageMime(format: ExportFormat): string {
switch (format) {
case 'JPG':
return 'image/jpeg'
case 'WEBP':
return 'image/webp'
default:
return 'image/png'
}
}
async function exportSelection(scale: number, format: ExportFormat) {
const ids = [...state.selectedIds]
if (format === 'SVG') {
const nodeIds = ids.length > 0 ? ids : graph.getChildren(state.currentPageId).map((n) => n.id)
const svgStr = renderNodesToSVG(graph, state.currentPageId, nodeIds)
if (!svgStr) {
console.error('Export failed: renderNodesToSVG returned null')
return
}
const svgData = new TextEncoder().encode(svgStr)
const node = ids.length === 1 ? graph.getNode(ids[0]) : undefined
const fileName = `${node?.name ?? 'Export'}.svg`
await saveExportedFile(svgData, fileName, 'SVG', '.svg', 'image/svg+xml')
return
}
const data = await renderExportImage(ids, scale, format)
if (!data) {
console.error(
`Export failed: renderExportImage returned null for format=${format} scale=${scale}`
)
return
}
const node = ids.length === 1 ? graph.getNode(ids[0]) : undefined
const baseName = node?.name ?? 'Export'
const ext = exportImageExtension(format)
const fileName = `${baseName}@${scale}x${ext}`
await saveExportedFile(new Uint8Array(data), fileName, format, ext, exportImageMime(format))
}
async function saveExportedFile(
data: Uint8Array,
fileName: string,
format: string,
ext: string,
mime: string
) {
if (IS_TAURI) {
const { save } = await import('@tauri-apps/plugin-dialog')
const path = await save({
defaultPath: fileName,
filters: [{ name: format, extensions: [ext.slice(1)] }]
})
if (!path) return
const { writeFile: tauriWrite } = await import('@tauri-apps/plugin-fs')
await tauriWrite(path, data)
return
}
if (window.showSaveFilePicker) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: fileName,
types: [
{
description: `${format} file`,
accept: { [mime]: [ext] }
}
]
})
const writable = await handle.createWritable()
await writable.write(new Uint8Array(data))
await writable.close()
return
} catch (e) {
if ((e as Error).name === 'AbortError') return
}
}
downloadBlob(data, fileName, mime)
}
function runLayoutForNode(id: string) {
const node = graph.getNode(id)
if (!node) return
if (node.layoutMode !== 'NONE') {
computeLayout(graph, id)
}
let parent = node.parentId ? graph.getNode(node.parentId) : undefined
while (parent) {
if (parent.layoutMode !== 'NONE') {
computeLayout(graph, parent.id)
}
parent = parent.parentId ? graph.getNode(parent.parentId) : undefined
}
}
// ─── Graph event subscriptions ────────────────────────────────
// Microtask-batched component sync: collects mutated node IDs during a
// synchronous block, deduplicates to unique ancestor components, then
// calls syncInstances once per component in one microtask.
let pendingComponentSync: Set<string> | null = null
function flushComponentSync() {
const ids = pendingComponentSync
if (!ids) return
pendingComponentSync = null
const componentIds = new Set<string>()
for (const id of ids) {
let current = graph.getNode(id)
while (current) {
if (current.type === 'COMPONENT') {
componentIds.add(current.id)
break
}
current = current.parentId ? graph.getNode(current.parentId) : undefined
}
}
for (const compId of componentIds) {
graph.syncInstances(compId)
}
if (componentIds.size > 0) requestRender()
}
function scheduleComponentSync(nodeId: string) {
if (!pendingComponentSync) {
pendingComponentSync = new Set()
queueMicrotask(flushComponentSync)
}
pendingComponentSync.add(nodeId)
}
function onNodeUpdated(id: string, changes: Partial<SceneNode>) {
if ('vectorNetwork' in changes) {
_renderer?.invalidateVectorPath(id)
}
_renderer?.invalidateNodePicture(id)
scheduleComponentSync(id)
requestRender()
}
function onNodeStructureChanged(nodeId: string) {
scheduleComponentSync(nodeId)
requestRender()
}
function subscribeToGraph() {
graph.emitter.on('node:updated', onNodeUpdated)
graph.emitter.on('node:created', (node) => onNodeStructureChanged(node.id))
graph.emitter.on('node:deleted', onNodeStructureChanged)
graph.emitter.on('node:reparented', onNodeStructureChanged)
graph.emitter.on('node:reordered', onNodeStructureChanged)
}
subscribeToGraph()
function updateNode(id: string, changes: Partial<SceneNode>) {
graph.updateNode(id, changes)
runLayoutForNode(id)
}
function updateNodeWithUndo(id: string, changes: Partial<SceneNode>, label = 'Update') {
const node = graph.getNode(id)
if (!node) return
const previous = Object.fromEntries(
(Object.keys(changes) as (keyof SceneNode)[]).map((key) => [key, node[key]])
) as Partial<SceneNode>
graph.updateNode(id, changes)
runLayoutForNode(id)
undo.push({
label,
forward: () => {
graph.updateNode(id, changes)
runLayoutForNode(id)
},
inverse: () => {
graph.updateNode(id, previous)
runLayoutForNode(id)
}
})
}
function setLayoutMode(id: string, mode: LayoutMode) {
const node = graph.getNode(id)
if (!node) return
const previous: Partial<SceneNode> = {
layoutMode: node.layoutMode,
itemSpacing: node.itemSpacing,
paddingTop: node.paddingTop,
paddingRight: node.paddingRight,
paddingBottom: node.paddingBottom,
paddingLeft: node.paddingLeft,
primaryAxisSizing: node.primaryAxisSizing,
counterAxisSizing: node.counterAxisSizing,
primaryAxisAlign: node.primaryAxisAlign,
counterAxisAlign: node.counterAxisAlign,
gridTemplateColumns: node.gridTemplateColumns,
gridTemplateRows: node.gridTemplateRows,
gridColumnGap: node.gridColumnGap,
gridRowGap: node.gridRowGap,
width: node.width,
height: node.height
}
const updates: Partial<SceneNode> = { layoutMode: mode }
if (mode === 'GRID' && node.layoutMode !== 'GRID') {
const children = graph.getChildren(id)
const cols = Math.max(2, Math.ceil(Math.sqrt(children.length)))
const rows = Math.max(1, Math.ceil(children.length / cols))
updates.gridTemplateColumns = Array.from({ length: cols }, () => ({
sizing: 'FR' as const,
value: 1
}))
updates.gridTemplateRows = Array.from({ length: rows }, () => ({
sizing: 'FR' as const,
value: 1
}))
updates.gridColumnGap = 0
updates.gridRowGap = 0
updates.primaryAxisSizing = 'FIXED'
updates.counterAxisSizing = 'FIXED'
if (node.primaryAxisSizing === 'HUG' || node.counterAxisSizing === 'HUG') {
const maxChildW = Math.max(...children.map((c) => c.width), 100)
const maxChildH = Math.max(...children.map((c) => c.height), 100)
updates.width = maxChildW * cols
updates.height = maxChildH * rows
}
updates.paddingTop = 0
updates.paddingRight = 0
updates.paddingBottom = 0
updates.paddingLeft = 0
} else if (mode !== 'NONE' && node.layoutMode === 'NONE') {
updates.itemSpacing = 0
updates.paddingTop = 0
updates.paddingRight = 0
updates.paddingBottom = 0
updates.paddingLeft = 0
updates.primaryAxisSizing = 'HUG'
updates.counterAxisSizing = 'HUG'
updates.primaryAxisAlign = 'MIN'
updates.counterAxisAlign = 'MIN'
}
graph.updateNode(id, updates)
if (mode !== 'NONE') computeLayout(graph, id)
runLayoutForNode(id)
const updated = graph.getNode(id)
if (!updated) return
const finalState = Object.fromEntries(
(Object.keys(previous) as (keyof SceneNode)[]).map((key) => [key, updated[key]])
) as Partial<SceneNode>
undo.push({
label: mode === 'NONE' ? 'Remove auto layout' : 'Add auto layout',
forward: () => {
graph.updateNode(id, finalState)
if (mode !== 'NONE') computeLayout(graph, id)
runLayoutForNode(id)
},
inverse: () => {
graph.updateNode(id, previous)
runLayoutForNode(id)
}
})
}
function wrapInAutoLayout() {
const nodes = selectedNodes.value
if (nodes.length === 0) return
const parentId = nodes[0].parentId ?? state.currentPageId
const sameParent = nodes.every((n) => (n.parentId ?? state.currentPageId) === parentId)
if (!sameParent) return
const prevSelection = new Set(state.selectedIds)
const origPositions = nodes.map((n) => ({ id: n.id, x: n.x, y: n.y, parentId }))
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const n of nodes) {
const abs = graph.getAbsolutePosition(n.id)
minX = Math.min(minX, abs.x)
minY = Math.min(minY, abs.y)
maxX = Math.max(maxX, abs.x + n.width)
maxY = Math.max(maxY, abs.y + n.height)
}
const parentAbs = isTopLevel(parentId) ? { x: 0, y: 0 } : graph.getAbsolutePosition(parentId)
const direction: LayoutMode =
nodes.length <= 1 || maxY - minY > maxX - minX ? 'VERTICAL' : 'HORIZONTAL'
const frame = graph.createNode('FRAME', parentId, {
name: 'Frame',
x: minX - parentAbs.x,
y: minY - parentAbs.y,
width: maxX - minX,
height: maxY - minY,
layoutMode: direction,
primaryAxisSizing: 'HUG',
counterAxisSizing: 'HUG',
primaryAxisAlign: 'MIN',
counterAxisAlign: 'MIN',
fills: []
})
const frameId = frame.id
const sortedIds = nodes
.map((n) => ({ id: n.id, pos: graph.getAbsolutePosition(n.id) }))
.sort((a, b) => a.pos.y - b.pos.y || a.pos.x - b.pos.x)
.map((n) => n.id)
for (const id of sortedIds) {
graph.reparentNode(id, frameId)
}
computeLayout(graph, frameId)
runLayoutForNode(frameId)
state.selectedIds = new Set([frameId])
undo.push({
label: 'Wrap in auto layout',
forward: () => {
// Re-create frame and reparent
const f = graph.createNode('FRAME', parentId, { ...frame })
for (const n of origPositions) graph.reparentNode(n.id, f.id)
computeLayout(graph, f.id)
runLayoutForNode(f.id)
state.selectedIds = new Set([f.id])
},
inverse: () => {
// Move children back to original parent and delete frame
for (const orig of origPositions) {
graph.reparentNode(orig.id, orig.parentId)
graph.updateNode(orig.id, { x: orig.x, y: orig.y })
}
graph.deleteNode(frameId)
state.selectedIds = prevSelection
}
})
}
function groupSelected() {
const nodes = selectedNodes.value
if (nodes.length === 0) return
const parentId = nodes[0].parentId ?? state.currentPageId
const sameParent = nodes.every((n) => (n.parentId ?? state.currentPageId) === parentId)
if (!sameParent) return
const parent = graph.getNode(parentId)
if (!parent) return
const prevSelection = new Set(state.selectedIds)
const nodeIds = nodes.map((n) => n.id)
const origPositions = nodes.map((n) => ({ id: n.id, x: n.x, y: n.y }))
// Bounding box from absolute positions
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const n of nodes) {
const abs = graph.getAbsolutePosition(n.id)
minX = Math.min(minX, abs.x)
minY = Math.min(minY, abs.y)
maxX = Math.max(maxX, abs.x + n.width)
maxY = Math.max(maxY, abs.y + n.height)
}
const parentAbs = isTopLevel(parentId) ? { x: 0, y: 0 } : graph.getAbsolutePosition(parentId)
// Insert group at the position of the topmost selected node
const firstIndex = Math.min(...nodeIds.map((id) => parent.childIds.indexOf(id)))
const group = graph.createNode('GROUP', parentId, {
name: 'Group',
x: minX - parentAbs.x,
y: minY - parentAbs.y,
width: maxX - minX,
height: maxY - minY,
fills: []
})
const groupId = group.id
// Move group to the correct z-order position
parent.childIds = parent.childIds.filter((id) => id !== groupId)
parent.childIds.splice(firstIndex, 0, groupId)
for (const n of nodes) {
graph.reparentNode(n.id, groupId)
}
state.selectedIds = new Set([groupId])
undo.push({
label: 'Group',
forward: () => {
const g = graph.createNode('GROUP', parentId, { ...group })
parent.childIds = parent.childIds.filter((id) => id !== g.id)
parent.childIds.splice(firstIndex, 0, g.id)
for (const n of origPositions) graph.reparentNode(n.id, g.id)
state.selectedIds = new Set([g.id])
},
inverse: () => {
for (const orig of origPositions) {
graph.reparentNode(orig.id, parentId)
graph.updateNode(orig.id, { x: orig.x, y: orig.y })
}
graph.deleteNode(groupId)
state.selectedIds = prevSelection
}
})
}
function createComponentFromSelection() {
const nodes = selectedNodes.value
if (nodes.length === 0) return
const prevSelection = new Set(state.selectedIds)
if (nodes.length === 1) {
const node = nodes[0]
const prevType = node.type
if (node.type === 'COMPONENT') return
if (node.type === 'FRAME' || node.type === 'GROUP') {
graph.updateNode(node.id, { type: 'COMPONENT' })
state.selectedIds = new Set([node.id])
undo.push({
label: 'Create component',
forward: () => {
graph.updateNode(node.id, { type: 'COMPONENT' })
state.selectedIds = new Set([node.id])
},
inverse: () => {
graph.updateNode(node.id, { type: prevType })
state.selectedIds = prevSelection
}
})
return
}
}
const parentId = nodes[0].parentId ?? state.currentPageId
const sameParent = nodes.every((n) => (n.parentId ?? state.currentPageId) === parentId)
if (!sameParent) return
const parent = graph.getNode(parentId)
if (!parent) return
const nodeIds = nodes.map((n) => n.id)
const origPositions = nodes.map((n) => ({ id: n.id, x: n.x, y: n.y }))
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const n of nodes) {
const abs = graph.getAbsolutePosition(n.id)
minX = Math.min(minX, abs.x)
minY = Math.min(minY, abs.y)
maxX = Math.max(maxX, abs.x + n.width)
maxY = Math.max(maxY, abs.y + n.height)
}
const parentAbs = isTopLevel(parentId) ? { x: 0, y: 0 } : graph.getAbsolutePosition(parentId)
const firstIndex = Math.min(...nodeIds.map((id) => parent.childIds.indexOf(id)))
const component = graph.createNode('COMPONENT', parentId, {
name: 'Component',
x: minX - parentAbs.x,
y: minY - parentAbs.y,
width: maxX - minX,
height: maxY - minY,
fills: []
})
const componentId = component.id
parent.childIds = parent.childIds.filter((id) => id !== componentId)
parent.childIds.splice(firstIndex, 0, componentId)
for (const n of nodes) {
graph.reparentNode(n.id, componentId)
}
state.selectedIds = new Set([componentId])
undo.push({
label: 'Create component',
forward: () => {
const c = graph.createNode('COMPONENT', parentId, { ...component })
parent.childIds = parent.childIds.filter((id) => id !== c.id)
parent.childIds.splice(firstIndex, 0, c.id)
for (const n of origPositions) graph.reparentNode(n.id, c.id)
state.selectedIds = new Set([c.id])
},
inverse: () => {
for (const orig of origPositions) {
graph.reparentNode(orig.id, parentId)
graph.updateNode(orig.id, { x: orig.x, y: orig.y })
}
graph.deleteNode(componentId)
state.selectedIds = prevSelection
}
})
}
function createComponentSetFromComponents() {
const nodes = selectedNodes.value
if (nodes.length < 2) return
if (!nodes.every((n) => n.type === 'COMPONENT')) return
const parentId = nodes[0].parentId ?? state.currentPageId
const sameParent = nodes.every((n) => (n.parentId ?? state.currentPageId) === parentId)
if (!sameParent) return
const parent = graph.getNode(parentId)
if (!parent) return
const prevSelection = new Set(state.selectedIds)
const nodeIds = nodes.map((n) => n.id)
const origPositions = nodes.map((n) => ({ id: n.id, x: n.x, y: n.y }))
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const n of nodes) {
const abs = graph.getAbsolutePosition(n.id)
minX = Math.min(minX, abs.x)
minY = Math.min(minY, abs.y)
maxX = Math.max(maxX, abs.x + n.width)
maxY = Math.max(maxY, abs.y + n.height)
}
const padding = 40
const parentAbs = isTopLevel(parentId) ? { x: 0, y: 0 } : graph.getAbsolutePosition(parentId)
const firstIndex = Math.min(...nodeIds.map((id) => parent.childIds.indexOf(id)))
const componentSet = graph.createNode('COMPONENT_SET', parentId, {
name: nodes[0].name.split('/')[0]?.trim() || 'Component Set',
x: minX - parentAbs.x - padding,
y: minY - parentAbs.y - padding,
width: maxX - minX + padding * 2,
height: maxY - minY + padding * 2,
fills: [
{ type: 'SOLID', color: { r: 0.96, g: 0.96, b: 0.96, a: 1 }, opacity: 1, visible: true }
]
})
const setId = componentSet.id
parent.childIds = parent.childIds.filter((id) => id !== setId)
parent.childIds.splice(firstIndex, 0, setId)
for (const n of nodes) {
graph.reparentNode(n.id, setId)
}
state.selectedIds = new Set([setId])
undo.push({
label: 'Create component set',
forward: () => {
const cs = graph.createNode('COMPONENT_SET', parentId, { ...componentSet })
parent.childIds = parent.childIds.filter((id) => id !== cs.id)
parent.childIds.splice(firstIndex, 0, cs.id)
for (const n of origPositions) graph.reparentNode(n.id, cs.id)
state.selectedIds = new Set([cs.id])
},
inverse: () => {
for (const orig of origPositions) {
graph.reparentNode(orig.id, parentId)
graph.updateNode(orig.id, { x: orig.x, y: orig.y })
}
graph.deleteNode(setId)
state.selectedIds = prevSelection
}
})
}
function createInstanceFromComponent(componentId: string, x?: number, y?: number) {
const component = graph.getNode(componentId)
if (component?.type !== 'COMPONENT') return null
const parentId = component.parentId ?? state.currentPageId
const instance = graph.createInstance(componentId, parentId, {
x: x ?? component.x + component.width + 40,
y: y ?? component.y
})
if (!instance) return null
const instanceId = instance.id
state.selectedIds = new Set([instanceId])
undo.push({
label: 'Create instance',
forward: () => {
graph.createInstance(componentId, parentId, { ...instance })
state.selectedIds = new Set([instanceId])
},
inverse: () => {
graph.deleteNode(instanceId)
state.selectedIds = new Set([componentId])
}
})
return instanceId
}
function detachInstance() {
const node = selectedNode.value
if (node?.type !== 'INSTANCE') return
const prevComponentId = node.componentId
graph.detachInstance(node.id)
state.selectedIds = new Set([node.id])
undo.push({
label: 'Detach instance',
forward: () => {
graph.detachInstance(node.id)
requestRender()
},
inverse: () => {
graph.updateNode(node.id, { type: 'INSTANCE', componentId: prevComponentId, overrides: {} })
}
})
}
function goToMainComponent() {
const node = selectedNode.value
if (!node?.componentId) return
const main = graph.getMainComponent(node.id)
if (!main) return
// Find which page the main component is on
let current: SceneNode | undefined = main
while (current && current.type !== 'CANVAS') {
current = current.parentId ? graph.getNode(current.parentId) : undefined
}
if (current && current.id !== state.currentPageId) {
void switchPage(current.id)
}
state.selectedIds = new Set([main.id])
const abs = graph.getAbsolutePosition(main.id)
const viewW = 800
const viewH = 600
state.panX = viewW / 2 - (abs.x + main.width / 2) * state.zoom
state.panY = viewH / 2 - (abs.y + main.height / 2) * state.zoom
requestRender()
}
function ungroupSelected() {
const node = selectedNode.value
if (node?.type !== 'GROUP') return
const parentId = node.parentId ?? state.currentPageId
const parent = graph.getNode(parentId)
if (!parent) return
const groupIndex = parent.childIds.indexOf(node.id)
const childIds = [...node.childIds]
const prevSelection = new Set(state.selectedIds)
const origPositions = childIds.map((id) => {
const child = graph.getNode(id)
if (!child) return { id, x: 0, y: 0 }
return { id, x: child.x, y: child.y }
})
const groupSnapshot = { ...node, childIds: [...node.childIds] }
// Reparent children to the group's parent, preserving visual position
for (let i = 0; i < childIds.length; i++) {
graph.reparentNode(childIds[i], parentId)
// Move to correct z-order (where the group was)
parent.childIds = parent.childIds.filter((id) => id !== childIds[i])
parent.childIds.splice(groupIndex + i, 0, childIds[i])
}
graph.deleteNode(node.id)
state.selectedIds = new Set(childIds)
undo.push({
label: 'Ungroup',
forward: () => {
for (let i = 0; i < childIds.length; i++) {
graph.reparentNode(childIds[i], parentId)
parent.childIds = parent.childIds.filter((id) => id !== childIds[i])
parent.childIds.splice(groupIndex + i, 0, childIds[i])
}
graph.deleteNode(node.id)
state.selectedIds = new Set(childIds)
},
inverse: () => {
const g = graph.createNode('GROUP', parentId, { ...groupSnapshot, childIds: [] })
parent.childIds = parent.childIds.filter((id) => id !== g.id)
parent.childIds.splice(groupIndex, 0, g.id)
for (const orig of origPositions) {
graph.reparentNode(orig.id, g.id)
graph.updateNode(orig.id, { x: orig.x, y: orig.y })
}
state.selectedIds = prevSelection
}
})
}
function bringToFront() {
for (const id of state.selectedIds) {
const node = graph.getNode(id)
if (!node?.parentId) continue
const parent = graph.getNode(node.parentId)
if (!parent) continue
const idx = parent.childIds.indexOf(id)
if (idx === parent.childIds.length - 1) continue
parent.childIds = parent.childIds.filter((cid) => cid !== id)
parent.childIds.push(id)
}
requestRender()
}
function sendToBack() {
for (const id of state.selectedIds) {
const node = graph.getNode(id)
if (!node?.parentId) continue
const parent = graph.getNode(node.parentId)
if (!parent) continue
const idx = parent.childIds.indexOf(id)
if (idx === 0) continue
parent.childIds = parent.childIds.filter((cid) => cid !== id)
parent.childIds.unshift(id)
}
requestRender()
}
function toggleProfiler() {
_renderer?.profiler.toggle()
requestRepaint()
}
function toggleVisibility() {
for (const id of state.selectedIds) {
const node = graph.getNode(id)
if (!node) continue
graph.updateNode(id, { visible: !node.visible })
}
}
function toggleLock() {
for (const id of state.selectedIds) {
const node = graph.getNode(id)
if (!node) continue
graph.updateNode(id, { locked: !node.locked })
}
}
function moveToPage(pageId: string) {
const targetPage = graph.getNode(pageId)
if (targetPage?.type !== 'CANVAS') return
const ids = [...state.selectedIds]
for (const id of ids) {
graph.reparentNode(id, pageId)
}
clearSelection()
}
function renameNode(id: string, name: string) {
graph.updateNode(id, { name })
}
function createShape(
type: NodeType,
x: number,
y: number,
w: number,
h: number,
parentId?: string
): string {
const fill = DEFAULT_FILLS[type] ?? DEFAULT_FILLS.RECTANGLE
const pid = parentId ?? state.currentPageId
const overrides: Partial<SceneNode> = {
x,
y,
width: w,
height: h,
fills: [{ ...fill }]
}
if (type === 'SECTION') {
overrides.strokes = [{ ...SECTION_DEFAULT_STROKE }]
overrides.cornerRadius = 5
}
if (type === 'POLYGON') {
overrides.pointCount = 3
}
if (type === 'STAR') {
overrides.pointCount = 5
overrides.starInnerRadius = 0.38
}
const node = graph.createNode(type, pid, overrides)
const id = node.id
const snapshot = { ...node }
undo.push({
label: `Create ${type.toLowerCase()}`,
forward: () => {
graph.createNode(snapshot.type, pid, snapshot)
},
inverse: () => {
graph.deleteNode(id)
const next = new Set(state.selectedIds)
next.delete(id)
state.selectedIds = next
}
})
return id
}
const IMAGE_MAX_DIMENSION = 4096
const IMAGE_GAP = 20
async function placeImageFiles(files: File[], cx: number, cy: number) {
if (!_ck) return
const prepared: Array<{ bytes: Uint8Array; name: string; w: number; h: number }> = []
for (const file of files) {
const bytes = new Uint8Array(await file.arrayBuffer())
const dims = decodeImageDimensions(bytes)
if (dims) prepared.push({ bytes, name: file.name, ...dims })
}
if (!prepared.length) return
let totalW = 0
for (const p of prepared) totalW += p.w
totalW += IMAGE_GAP * (prepared.length - 1)
const maxH = Math.max(...prepared.map((p) => p.h))
let curX = cx - totalW / 2
const topY = cy - maxH / 2
const ids: string[] = []
for (const p of prepared) {
const id = await placeImageNode(p.bytes, curX, topY, p.w, p.h, p.name)
if (id) ids.push(id)
curX += p.w + IMAGE_GAP
}
if (ids.length) {
select(ids)
requestRender()
}
}
function decodeImageDimensions(bytes: Uint8Array): { w: number; h: number } | null {
if (!_ck) return null
const skImg = _ck.MakeImageFromEncoded(bytes)
if (!skImg) return null
let w = skImg.width()
let h = skImg.height()
skImg.delete()
if (w > IMAGE_MAX_DIMENSION || h > IMAGE_MAX_DIMENSION) {
const ratio = Math.min(IMAGE_MAX_DIMENSION / w, IMAGE_MAX_DIMENSION / h)
w = Math.round(w * ratio)
h = Math.round(h * ratio)
}
return { w, h }
}
function storeImage(bytes: Uint8Array): string {
const hash = computeImageHash(bytes)
graph.images.set(hash, bytes)
return hash
}
async function placeImageNode(
bytes: Uint8Array,
x: number,
y: number,
w: number,
h: number,
name = 'Image'
): Promise<string | null> {
const hash = storeImage(bytes)
const displayName = name.replace(/\.[^.]+$/, '')
const pid = state.currentPageId
const fill: Fill = {
type: 'IMAGE',
imageHash: hash,
imageScaleMode: 'FILL',
color: { r: 0, g: 0, b: 0, a: 0 },
opacity: 1,
visible: true
}
const node = graph.createNode('RECTANGLE', pid, {
name: displayName,
x,
y,
width: w,
height: h,
fills: [fill]
})
const id = node.id
const snapshot = { ...node }
undo.push({
label: 'Place image',
forward: () => {
graph.images.set(hash, bytes)
graph.createNode(snapshot.type, pid, snapshot)
},
inverse: () => {
graph.deleteNode(id)
graph.images.delete(hash)
const next = new Set(state.selectedIds)
next.delete(id)
state.selectedIds = next
}
})
return id
}
function adoptNodesIntoSection(sectionId: string) {
const section = graph.getNode(sectionId)
if (section?.type !== 'SECTION') return
const parentId = section.parentId ?? state.currentPageId
const siblings = graph.getChildren(parentId)
const sx = section.x
const sy = section.y
const sx2 = sx + section.width
const sy2 = sy + section.height
const toAdopt: string[] = []
for (const sibling of siblings) {
if (sibling.id === sectionId) continue
const nx = sibling.x
const ny = sibling.y
const nx2 = nx + sibling.width
const ny2 = ny + sibling.height
if (nx >= sx && ny >= sy && nx2 <= sx2 && ny2 <= sy2) {
toAdopt.push(sibling.id)
}
}
if (toAdopt.length === 0) return
const undoOps: Array<{
id: string
oldParent: string
oldX: number
oldY: number
newX: number
newY: number
}> = []
for (const id of toAdopt) {
const node = graph.getNode(id)
if (!node) continue
const newX = node.x - sx
const newY = node.y - sy
undoOps.push({ id, oldParent: parentId, oldX: node.x, oldY: node.y, newX, newY })
graph.reparentNode(id, sectionId)
graph.updateNode(id, { x: newX, y: newY })
}
undo.push({
label: 'Adopt into section',
forward: () => {
for (const op of undoOps) {
graph.reparentNode(op.id, sectionId)
graph.updateNode(op.id, { x: op.newX, y: op.newY })
}
},
inverse: () => {
for (const op of undoOps) {
graph.reparentNode(op.id, op.oldParent)
graph.updateNode(op.id, { x: op.oldX, y: op.oldY })
}
}
})
}
function selectAll() {
const children = graph.getChildren(state.currentPageId)
state.selectedIds = new Set(children.map((n) => n.id))
}
function duplicateSelected() {
const prevSelection = new Set(state.selectedIds)
const newIds: string[] = []
const snapshots: Array<{ id: string; parentId: string; snapshot: SceneNode }> = []
for (const id of state.selectedIds) {
const src = graph.getNode(id)
if (!src) continue
const parentId = src.parentId ?? state.currentPageId
const { id: _srcId, parentId: _srcParent, childIds: _srcChildren, ...srcRest } = src
const node = graph.createNode(src.type, parentId, {
...srcRest,
name: src.name + ' copy',
x: src.x + 20,
y: src.y + 20
})
newIds.push(node.id)
snapshots.push({ id: node.id, parentId, snapshot: { ...node } })
}
if (newIds.length > 0) {
state.selectedIds = new Set(newIds)
undo.push({
label: 'Duplicate',
forward: () => {
for (const { snapshot, parentId } of snapshots) {
graph.createNode(snapshot.type, parentId, snapshot)
}
state.selectedIds = new Set(newIds)
},
inverse: () => {
for (const { id } of snapshots) graph.deleteNode(id)
state.selectedIds = prevSelection
}
})
}
}
function writeCopyData(clipboardData: DataTransfer) {
const nodes = selectedNodes.value
if (nodes.length === 0) return
const names = nodes.map((n) => n.name).join('\n')
const html = buildFigmaClipboardHTML(nodes, graph)
if (html) clipboardData.setData('text/html', html)
clipboardData.setData('text/plain', names)
}
function centerNodesAt(nodeIds: string[], cx: number, cy: number) {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const id of nodeIds) {
const n = graph.getNode(id)
if (!n) continue
minX = Math.min(minX, n.x)
minY = Math.min(minY, n.y)
maxX = Math.max(maxX, n.x + n.width)
maxY = Math.max(maxY, n.y + n.height)
}
if (minX === Infinity) return
const dx = cx - (minX + maxX) / 2
const dy = cy - (minY + maxY) / 2
for (const id of nodeIds) {
const n = graph.getNode(id)
if (n) graph.updateNode(id, { x: n.x + dx, y: n.y + dy })
}
}
function collectSubtrees(g: SceneGraph, rootIds: string[]): SceneNode[] {
const result: SceneNode[] = []
function walk(id: string) {
const node = g.getNode(id)
if (!node) return
result.push({ ...node })
for (const childId of node.childIds) walk(childId)
}
for (const id of rootIds) walk(id)
return result
}
async function loadFontsForNodes(nodeIds: string[]) {
const toLoad = collectFontKeys(graph, nodeIds)
if (toLoad.length === 0) return
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)
}
function pasteFromHTML(html: string, cursorPos?: Vector) {
void parseFigmaClipboard(html).then((figma) => {
if (figma) {
const prevSelection = new Set(state.selectedIds)
const created = importClipboardNodes(
figma.nodes,
graph,
state.currentPageId,
0,
0,
figma.blobs
)
if (created.length > 0) {
const cx = cursorPos?.x ?? (-state.panX + window.innerWidth / 2) / state.zoom
const cy = cursorPos?.y ?? (-state.panY + window.innerHeight / 2) / state.zoom
centerNodesAt(created, cx, cy)
computeAllLayouts(graph, state.currentPageId)
state.selectedIds = new Set(created)
const allNodes = collectSubtrees(graph, created)
const pageId = state.currentPageId
undo.push({
label: 'Paste',
forward: () => {
for (const snapshot of allNodes) {
graph.createNode(snapshot.type, snapshot.parentId ?? pageId, {
...snapshot,
childIds: []
})
}
computeAllLayouts(graph, pageId)
state.selectedIds = new Set(created)
},
inverse: () => {
for (const id of [...created].reverse()) graph.deleteNode(id)
computeAllLayouts(graph, pageId)
state.selectedIds = prevSelection
}
})
void loadFontsForNodes(created)
warnMissingImages(created)
}
}
})
}
function warnMissingImages(nodeIds: string[]) {
const allNodes = collectSubtrees(graph, nodeIds)
const hasMissing = allNodes.some((n) =>
n.fills.some((f) => f.type === 'IMAGE' && f.imageHash && !graph.images.has(f.imageHash))
)
if (hasMissing) {
toast.show(
"Some images couldn't be pasted — Figma doesn't include image data in clipboard",
'warning'
)
}
}
function deleteSelected() {
const entries: Array<{ id: string; parentId: string; snapshot: SceneNode; index: number }> = []
for (const id of state.selectedIds) {
const node = graph.getNode(id)
if (!node) continue
const parentId = node.parentId ?? state.currentPageId
const parent = graph.getNode(parentId)
const index = parent?.childIds.indexOf(id) ?? -1
entries.push({ id, parentId, snapshot: { ...node }, index })
}
if (entries.length === 0) return
const prevSelection = new Set(state.selectedIds)
for (const { id } of entries) graph.deleteNode(id)
undo.push({
label: 'Delete',
forward: () => {
for (const { id } of entries) graph.deleteNode(id)
clearSelection()
},
inverse: () => {
for (const { snapshot, parentId, index } of [...entries].reverse()) {
graph.createNode(snapshot.type, parentId, snapshot)
if (index >= 0) {
graph.reorderChild(snapshot.id, parentId, index)
}
}
state.selectedIds = prevSelection
}
})
clearSelection()
}
function mobileCopy() {
const transfer = new DataTransfer()
writeCopyData(transfer)
state.clipboardHtml = transfer.getData('text/html')
}
function mobileCut() {
mobileCopy()
deleteSelected()
}
function mobilePaste() {
if (state.clipboardHtml) {
pasteFromHTML(state.clipboardHtml)
}
}
function commitMove(originals: Map<string, Vector>) {
const finals = new Map<string, Vector>()
for (const [id] of originals) {
const n = graph.getNode(id)
if (n) finals.set(id, { x: n.x, y: n.y })
}
undo.push({
label: 'Move',
forward: () => {
for (const [id, pos] of finals) {
graph.updateNode(id, pos)
runLayoutForNode(id)
}
},
inverse: () => {
for (const [id, pos] of originals) {
graph.updateNode(id, pos)
runLayoutForNode(id)
}
}
})
}
function commitMoveWithReparent(
originals: Map<string, { x: number; y: number; parentId: string }>
) {
const finals = new Map<string, { x: number; y: number; parentId: string }>()
for (const [id] of originals) {
const n = graph.getNode(id)
if (n) finals.set(id, { x: n.x, y: n.y, parentId: n.parentId ?? state.currentPageId })
}
undo.push({
label: 'Move',
forward: () => {
for (const [id, pos] of finals) {
graph.reparentNode(id, pos.parentId)
graph.updateNode(id, { x: pos.x, y: pos.y })
runLayoutForNode(id)
}
},
inverse: () => {
for (const [id, pos] of originals) {
graph.reparentNode(id, pos.parentId)
graph.updateNode(id, { x: pos.x, y: pos.y })
runLayoutForNode(id)
}
}
})
}
function snapshotPage(): Map<string, SceneNode> {
const snapshot = new Map<string, SceneNode>()
const walk = (id: string) => {
const node = graph.getNode(id)
if (!node) return
snapshot.set(id, structuredClone(node))
for (const childId of node.childIds) walk(childId)
}
walk(state.currentPageId)
return snapshot
}
function restorePageFromSnapshot(snapshot: Map<string, SceneNode>) {
const page = graph.getNode(state.currentPageId)
if (!page) return
for (const childId of page.childIds.slice()) {
graph.deleteNode(childId)
}
const pageSnap = snapshot.get(state.currentPageId)
if (pageSnap) page.childIds = [...pageSnap.childIds]
for (const [id, snap] of snapshot) {
if (id === state.currentPageId) continue
graph.nodes.set(id, structuredClone(snap))
}
graph.clearAbsPosCache()
computeAllLayouts(graph, state.currentPageId)
state.selectedIds = new Set()
state.hoveredNodeId = null
requestRender()
}
function pushUndoEntry(entry: UndoEntry) {
undo.push(entry)
}
function commitResize(nodeId: string, origRect: Rect) {
const node = graph.getNode(nodeId)
if (!node) return
const finalRect = { x: node.x, y: node.y, width: node.width, height: node.height }
undo.push({
label: 'Resize',
forward: () => {
graph.updateNode(nodeId, finalRect)
runLayoutForNode(nodeId)
},
inverse: () => {
graph.updateNode(nodeId, origRect)
runLayoutForNode(nodeId)
}
})
}
function commitRotation(nodeId: string, origRotation: number) {
const node = graph.getNode(nodeId)
if (!node) return
const finalRotation = node.rotation
undo.push({
label: 'Rotate',
forward: () => {
graph.updateNode(nodeId, { rotation: finalRotation })
},
inverse: () => {
graph.updateNode(nodeId, { rotation: origRotation })
}
})
}
function commitNodeUpdate(nodeId: string, previous: Partial<SceneNode>, label = 'Update') {
const node = graph.getNode(nodeId)
if (!node) return
const current = Object.fromEntries(
(Object.keys(previous) as (keyof SceneNode)[]).map((key) => [key, node[key]])
) as Partial<SceneNode>
undo.push({
label,
forward: () => {
graph.updateNode(nodeId, current)
runLayoutForNode(nodeId)
},
inverse: () => {
graph.updateNode(nodeId, previous)
runLayoutForNode(nodeId)
}
})
}
function undoAction() {
undo.undo()
}
function redoAction() {
undo.redo()
requestRender()
}
function screenToCanvas(sx: number, sy: number) {
return {
x: (sx - state.panX) / state.zoom,
y: (sy - state.panY) / state.zoom
}
}
function applyZoom(delta: number, centerX: number, centerY: number) {
const factor = Math.min(
ZOOM_SCALE_MAX,
Math.max(ZOOM_SCALE_MIN, Math.exp(-delta / ZOOM_DIVISOR))
)
const newZoom = Math.max(0.02, Math.min(256, state.zoom * factor))
state.panX = centerX - (centerX - state.panX) * (newZoom / state.zoom)
state.panY = centerY - (centerY - state.panY) * (newZoom / state.zoom)
state.zoom = newZoom
requestRepaint()
}
function pan(dx: number, dy: number) {
state.panX += dx
state.panY += dy
requestRepaint()
}
function zoomToBounds(minX: number, minY: number, maxX: number, maxY: number) {
const padding = 80
const w = maxX - minX + padding * 2
const h = maxY - minY + padding * 2
const viewW = window.innerWidth
const viewH = window.innerHeight
const zoom = Math.min(viewW / w, viewH / h, 1)
state.zoom = zoom
state.panX = (viewW - w * zoom) / 2 - minX * zoom + padding * zoom
state.panY = (viewH - h * zoom) / 2 - minY * zoom + padding * zoom
requestRepaint()
}
function zoomToFit() {
const nodes = graph.getChildren(state.currentPageId)
if (nodes.length === 0) return
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const n of nodes) {
minX = Math.min(minX, n.x)
minY = Math.min(minY, n.y)
maxX = Math.max(maxX, n.x + n.width)
maxY = Math.max(maxY, n.y + n.height)
}
zoomToBounds(minX, minY, maxX, maxY)
}
function zoomTo100() {
const viewW = window.innerWidth
const viewH = window.innerHeight
const centerX = (-state.panX + viewW / 2) / state.zoom
const centerY = (-state.panY + viewH / 2) / state.zoom
state.zoom = 1
state.panX = viewW / 2 - centerX
state.panY = viewH / 2 - centerY
requestRepaint()
}
function zoomToSelection() {
if (state.selectedIds.size === 0) return
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const id of state.selectedIds) {
const n = graph.getNode(id)
if (!n) continue
const abs = graph.getAbsolutePosition(id)
minX = Math.min(minX, abs.x)
minY = Math.min(minY, abs.y)
maxX = Math.max(maxX, abs.x + n.width)
maxY = Math.max(maxY, abs.y + n.height)
}
if (minX === Infinity) return
zoomToBounds(minX, minY, maxX, maxY)
}
return {
get graph() {
return graph
},
get renderer() {
return _renderer
},
get textEditor() {
return _textEditor
},
undo,
state,
selectedNodes,
selectedNode,
layerTree,
requestRender,
requestRepaint,
flashNodes,
setTool,
select,
clearSelection,
selectAll,
setMarquee,
setSnapGuides,
setRotationPreview,
setHoveredNode,
setDropTarget,
setLayoutInsertIndicator,
reorderInAutoLayout,
reparentNodes,
reorderChildWithUndo,
penAddVertex,
penSetDragTangent,
penSetClosingToFirst,
penCommit,
penCancel,
startTextEditing,
commitTextEdit,
openFigFile,
saveFigFile,
setCanvasKit,
saveFigFileAs,
renderExportImage,
exportSelection,
updateNode,
setLayoutMode,
wrapInAutoLayout,
groupSelected,
ungroupSelected,
createComponentFromSelection,
createComponentSetFromComponents,
createInstanceFromComponent,
detachInstance,
goToMainComponent,
bringToFront,
sendToBack,
toggleProfiler,
toggleVisibility,
toggleLock,
moveToPage,
renameNode,
createShape,
storeImage,
placeImageFiles,
adoptNodesIntoSection,
duplicateSelected,
writeCopyData,
pasteFromHTML,
mobileCopy,
mobileCut,
mobilePaste,
deleteSelected,
commitMove,
commitMoveWithReparent,
commitResize,
commitRotation,
commitNodeUpdate,
updateNodeWithUndo,
undoAction,
redoAction,
snapshotPage,
restorePageFromSnapshot,
pushUndoEntry,
screenToCanvas,
applyZoom,
pan,
zoomToFit,
zoomTo100,
zoomToSelection,
isTopLevel,
switchPage,
addPage,
deletePage,
renamePage
}
}
export type EditorStore = ReturnType<typeof createEditorStore>
const storeRef = shallowRef<EditorStore>()
export function setActiveEditorStore(store: EditorStore) {
storeRef.value = store
}
export function getActiveEditorStore(): EditorStore {
if (!storeRef.value) throw new Error('Editor store not provided')
return storeRef.value
}
const storeProxy = new Proxy({} as EditorStore, {
get(_, prop) {
return Reflect.get(getActiveEditorStore(), prop)
}
})
export function useEditorStore(): EditorStore {
return storeProxy
}