openpencil/src/stores/editor.ts
Danila Poyarkov 45df90c5c7
CSS Grid layout mode (#73)
* Switch to @open-pencil/yoga-layout with CSS Grid support

Use our fork (open-pencil/yoga, grid branch) which cherry-picks the
upstream CSS Grid PRs (#1893–#1898) onto current main. The JS bindings
are ported from the old embind approach to the new wasm_bridge.c API.

npm:@open-pencil/yoga-layout alias keeps all imports as 'yoga-layout'.

Also handle FinalizationRegistry change (upstream #1908) — node.free()
no longer exists, nodes are garbage collected automatically.

* Add CSS Grid layout mode

Scene graph:
- LayoutMode gains 'GRID' option alongside HORIZONTAL/VERTICAL
- GridTrack type (sizing: FIXED/FR/AUTO, value) for track definitions
- GridPosition type (column, row, columnSpan, rowSpan) for children
- New node props: gridTemplateColumns/Rows, gridColumnGap, gridRowGap,
  gridPosition

Layout engine:
- buildGridTree() sets Display.Grid and maps GridTrack[] to Yoga's
  setGridTemplateColumns/Rows API with FR/Points/Auto track types
- Grid children use gridPosition for column/row placement with span
- Flex layout path unchanged

Store:
- setLayoutMode('GRID') auto-creates NxM track grid based on child
  count (sqrt heuristic), defaults to 1fr tracks

UI (LayoutSection.vue):
- Grid button (grid-2x2 icon) added to flow direction row
- Columns/Rows track editors: ScrubInput for value + AppSelect for
  sizing mode (Fill fr / Fixed px / Auto), add/remove buttons
- Separate column gap and row gap ScrubInputs
- Wrap button hidden when grid is active
- Flex alignment grid hidden when grid is active

Kiwi serialization skips GRID mode for now (no .fig codec support).

* Add grid layout integration tests

12 new test scenarios covering:
- Basic 2x2 grid, fixed columns, mixed fr/fixed, unequal fr weights
- Column gap, row gap, both gaps combined
- Padding offsets
- Explicit gridPosition placement, column span, row span
- Absolute children skipped
- Hidden children collapsed
- Nested grid inside flex parent (computeAllLayouts)

Also fix: grid frames as children of flex parents now correctly use
Display.Grid via configureChildAsGrid() instead of falling through
to configureChildAsAutoLayout() which set FlexDirection.

* Deduplicate grid yoga configuration

Extract configureAsGrid() and createGridChildNode() shared by
buildGridTree (root-level grid) and configureChildAsGrid (grid
nested inside flex parent).

* Add grid layout to changelog

* Add grid support to JSX and Tailwind CSS export

OpenPencil format: grid → columns/rows/columnGap/rowGap props,
child colStart/rowStart/colSpan/rowSpan.

Tailwind format: grid grid-cols-N grid-rows-N gap-x-* gap-y-*,
mixed tracks use arbitrary values (grid-cols-[200px_1fr_auto]),
child col-start-*/row-start-*/col-span-*/row-span-*.

Both formats: padding emitted for grid frames (shared with flex),
flex-only props (justify/items/wrap/gap) scoped to isFlex.

* Remove duplicate gridTrackToTw, use shared formatTrack

Also drop unused GridTrackSizing import from LayoutSection.vue.

* Fix grid icon: use layout-grid instead of grid-2x2

unplugin-icons can't resolve icon names with digits after hyphens
(grid-2x2 → grid2x2 which doesn't exist).

* Polish layout UI and fix auto-layout behavior

- Replace text labels with compact icons: ↔/↕ for gap, ☐ for uniform padding, T/R/B/L for per-side padding
- Direction-aware gap icon: ↕ for vertical, ↔ for horizontal
- Pin +/− padding toggle button right of gap input
- Fix alignment grid axes for vertical layout (transpose primary/counter)
- Fix grid switch: set FIXED sizing, compute frame size from children
- Remove hardcoded white fill from Shift+A wrap
- Auto-detect horizontal vs vertical from selection bounds

* Add flex-to-grid switch integration test

Verify HUG frame expands and children are placed in 2x2 grid
when switching from vertical flex to grid layout.

* Update changelog for grid layout
2026-03-08 18:23:53 +03:00

2218 lines
62 KiB
TypeScript

import { shallowReactive, shallowRef, computed, watch } from 'vue'
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,
figmaNodesBounds,
parseFigmaClipboard,
parseOpenPencilClipboard,
buildFigmaClipboardHTML,
buildOpenPencilClipboardHTML,
prefetchFigmaSchema,
renderNodesToImage,
renderNodesToSVG,
SceneGraph,
setTextMeasurer,
TextEditor,
UndoManager
} from '@open-pencil/core'
import { readFigFile } from '@open-pencil/core'
import type { Color, Rect } from '@/types'
import type {
ExportFormat,
Fill,
LayoutMode,
NodeType,
SceneNode,
SnapGuide,
VectorNetwork,
VectorRegion,
VectorSegment,
VectorVertex
} from '@open-pencil/core'
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: 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: import('canvaskit-wasm').CanvasKit | null = null
let _renderer: import('@open-pencil/core').SkiaRenderer | null = null
let _textEditor: TextEditor | null = null
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: { x: number; y: number } | null
closingToFirst: boolean
} | null,
penCursorX: null as number | null,
penCursorY: 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)
autosaveTimer = setTimeout(async () => {
if (state.sceneVersion === savedVersion) return
if (!state.autosaveEnabled) return
try {
await writeFile(await buildFigFile())
} catch {
// silently fail — user can still save manually
}
}, 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
}
function switchPage(pageId: string) {
const page = graph.getNode(pageId)
if (!page || 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 }
}
void 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)
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()
switchPage(remaining[newIdx].id)
}
requestRender()
}
function renamePage(pageId: string, name: string) {
graph.updateNode(pageId, { name })
requestRender()
}
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 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
// Convert position if coming from different parent
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)
requestRender()
}
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)
}
requestRender()
}
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 })
requestRender()
},
inverse: () => {
graph.updateNode(result.nodeId, { text: prevText })
requestRender()
}
})
}
requestRender()
}
async function openFigFile(file: File, handle?: FileSystemFileHandle, path?: string) {
try {
state.loading = true
await new Promise((r) => requestAnimationFrame(r))
const imported = await readFigFile(file)
graph = imported
computeAllLayouts(graph)
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]
state.currentPageId = firstPage?.id ?? graph.rootId
state.panX = 0
state.panY = 0
state.zoom = 1
state.pageColor = { ...CANVAS_BG_COLOR }
await loadFontsForNodes(graph.getChildren(firstPage?.id ?? graph.rootId).map((n) => n.id))
requestRender()
startWatchingFile()
} catch (e) {
console.error('Failed to open .fig file:', e)
} finally {
state.loading = false
}
}
function setCanvasKit(
ck: import('canvaskit-wasm').CanvasKit,
renderer: import('@open-pencil/core').SkiaRenderer
) {
_ck = ck
_renderer = renderer
_textEditor = new TextEditor(ck)
setTextMeasurer((node) => renderer.measureTextNode(node))
}
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)
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)
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)
} else if (fileHandle) {
const file = await fileHandle.getFile()
const imported = await readFigFile(file)
graph = imported
computeAllLayouts(graph)
} 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
reloadFromDisk()
},
{ delayMs: 500 }
)
unwatchFile = () => unwatch()
} else if (fileHandle) {
let lastModified = (await fileHandle.getFile()).lastModified
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
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
}
}
function updateNode(id: string, changes: Partial<SceneNode>) {
graph.updateNode(id, changes)
if ('vectorNetwork' in changes) {
_renderer?.invalidateVectorPath(id)
}
_renderer?.invalidateNodePicture(id)
runLayoutForNode(id)
syncIfInsideComponent(id)
requestRender()
}
function syncIfInsideComponent(nodeId: string) {
let current = graph.getNode(nodeId)
while (current) {
if (current.type === 'COMPONENT') {
graph.syncInstances(current.id)
return
}
current = current.parentId ? graph.getNode(current.parentId) : undefined
}
}
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)
syncIfInsideComponent(id)
undo.push({
label,
forward: () => {
graph.updateNode(id, changes)
runLayoutForNode(id)
syncIfInsideComponent(id)
requestRender()
},
inverse: () => {
graph.updateNode(id, previous)
runLayoutForNode(id)
syncIfInsideComponent(id)
requestRender()
}
})
requestRender()
}
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)
requestRender()
},
inverse: () => {
graph.updateNode(id, previous)
runLayoutForNode(id)
requestRender()
}
})
requestRender()
}
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 ? 'VERTICAL' : maxX - minX >= maxY - minY ? 'HORIZONTAL' : 'VERTICAL'
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])
requestRender()
},
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
requestRender()
}
})
requestRender()
}
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])
requestRender()
},
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
requestRender()
}
})
requestRender()
}
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])
requestRender()
},
inverse: () => {
graph.updateNode(node.id, { type: prevType })
state.selectedIds = prevSelection
requestRender()
}
})
requestRender()
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])
requestRender()
},
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
requestRender()
}
})
requestRender()
}
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])
requestRender()
},
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
requestRender()
}
})
requestRender()
}
function createInstanceFromComponent(componentId: string, x?: number, y?: number) {
const component = graph.getNode(componentId)
if (!component || 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])
requestRender()
},
inverse: () => {
graph.deleteNode(instanceId)
state.selectedIds = new Set([componentId])
requestRender()
}
})
requestRender()
return instanceId
}
function detachInstance() {
const node = selectedNode.value
if (!node || 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: {} })
requestRender()
}
})
requestRender()
}
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) {
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 || 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)
requestRender()
},
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
requestRender()
}
})
requestRender()
}
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 })
}
requestRender()
}
function toggleLock() {
for (const id of state.selectedIds) {
const node = graph.getNode(id)
if (!node) continue
graph.updateNode(id, { locked: !node.locked })
}
requestRender()
}
function moveToPage(pageId: string) {
const targetPage = graph.getNode(pageId)
if (!targetPage || targetPage.type !== 'CANVAS') return
const ids = [...state.selectedIds]
for (const id of ids) {
graph.reparentNode(id, pageId)
}
clearSelection()
requestRender()
}
function renameNode(id: string, name: string) {
graph.updateNode(id, { name })
requestRender()
}
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)
requestRender()
},
inverse: () => {
graph.deleteNode(id)
const next = new Set(state.selectedIds)
next.delete(id)
state.selectedIds = next
requestRender()
}
})
requestRender()
return id
}
function adoptNodesIntoSection(sectionId: string) {
const section = graph.getNode(sectionId)
if (!section || 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 })
}
requestRender()
},
inverse: () => {
for (const op of undoOps) {
graph.reparentNode(op.id, op.oldParent)
graph.updateNode(op.id, { x: op.oldX, y: op.oldY })
}
requestRender()
}
})
requestRender()
}
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)
requestRender()
},
inverse: () => {
for (const { id } of snapshots) graph.deleteNode(id)
state.selectedIds = prevSelection
requestRender()
}
})
requestRender()
}
}
function writeCopyData(clipboardData: DataTransfer) {
const nodes = selectedNodes.value
if (nodes.length === 0) return
const names = nodes.map((n) => n.name).join('\n')
const renderer = _renderer
const textPicBuilder = renderer
? (node: SceneNode) => renderer.buildTextPicture(node)
: undefined
const internalHtml = buildOpenPencilClipboardHTML(nodes, graph, textPicBuilder)
const figmaHtml = buildFigmaClipboardHTML(nodes, graph)
const html = figmaHtml ? figmaHtml + internalHtml : internalHtml
clipboardData.setData('text/html', html)
clipboardData.setData('text/plain', names)
}
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
await Promise.all(toLoad.map(([family, style]) => loadFont(family, style)))
computeAllLayouts(graph, state.currentPageId)
requestRender()
}
function pasteFromHTML(html: string) {
const ownNodes = parseOpenPencilClipboard(html)
if (ownNodes) {
pasteOpenPencilNodes(ownNodes)
return
}
parseFigmaClipboard(html).then((figma) => {
if (figma) {
const bounds = figmaNodesBounds(figma.nodes)
const viewCenterX = (-state.panX + window.innerWidth / 2) / state.zoom
const viewCenterY = (-state.panY + window.innerHeight / 2) / state.zoom
const offsetX = bounds ? viewCenterX - (bounds.x + bounds.w / 2) : 0
const offsetY = bounds ? viewCenterY - (bounds.y + bounds.h / 2) : 0
const prevSelection = new Set(state.selectedIds)
const created = importClipboardNodes(
figma.nodes,
graph,
state.currentPageId,
offsetX,
offsetY,
figma.blobs
)
if (created.length > 0) {
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)
requestRender()
},
inverse: () => {
for (const id of [...created].reverse()) graph.deleteNode(id)
computeAllLayouts(graph, pageId)
state.selectedIds = prevSelection
requestRender()
}
})
void loadFontsForNodes(created)
requestRender()
}
}
})
}
function pasteOpenPencilNodes(
nodes: Array<SceneNode & { children?: SceneNode[] }>,
parentId?: string
) {
const target = parentId ?? state.currentPageId
const prevSelection = new Set(state.selectedIds)
const newIds: string[] = []
const created: Array<{ id: string; parentId: string; snapshot: SceneNode }> = []
function createTree(src: SceneNode & { children?: SceneNode[] }, pid: string, isTop: boolean) {
const { id: _srcId, parentId: _srcParent, childIds: _srcChildren, ...rest } = src
const node = graph.createNode(src.type, pid, {
...rest,
x: src.x + (isTop ? 20 : 0),
y: src.y + (isTop ? 20 : 0)
})
created.push({ id: node.id, parentId: pid, snapshot: { ...node } })
if (isTop) newIds.push(node.id)
if (src.children) {
for (const child of src.children) {
createTree(child, node.id, false)
}
}
}
for (const src of nodes) {
createTree(src, target, true)
}
if (newIds.length > 0) {
state.selectedIds = new Set(newIds)
undo.push({
label: 'Paste',
forward: () => {
for (const { snapshot, parentId: pid } of created) {
graph.createNode(snapshot.type, pid, snapshot)
}
state.selectedIds = new Set(newIds)
requestRender()
},
inverse: () => {
for (const { id } of [...created].reverse()) graph.deleteNode(id)
state.selectedIds = prevSelection
requestRender()
}
})
requestRender()
}
}
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()
requestRender()
},
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
requestRender()
}
})
clearSelection()
requestRender()
}
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, { x: number; y: number }>) {
const finals = new Map<string, { x: number; y: number }>()
for (const [id] of originals) {
const n = graph.getNode(id)
if (n) finals.set(id, { x: n.x, y: n.y })
}
for (const [id] of finals) syncIfInsideComponent(id)
undo.push({
label: 'Move',
forward: () => {
for (const [id, pos] of finals) {
graph.updateNode(id, pos)
runLayoutForNode(id)
}
for (const [id] of finals) syncIfInsideComponent(id)
requestRender()
},
inverse: () => {
for (const [id, pos] of originals) {
graph.updateNode(id, pos)
runLayoutForNode(id)
}
for (const [id] of originals) syncIfInsideComponent(id)
requestRender()
}
})
}
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 }
syncIfInsideComponent(nodeId)
undo.push({
label: 'Resize',
forward: () => {
graph.updateNode(nodeId, finalRect)
runLayoutForNode(nodeId)
syncIfInsideComponent(nodeId)
requestRender()
},
inverse: () => {
graph.updateNode(nodeId, origRect)
runLayoutForNode(nodeId)
syncIfInsideComponent(nodeId)
requestRender()
}
})
}
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 })
requestRender()
},
inverse: () => {
graph.updateNode(nodeId, { rotation: origRotation })
requestRender()
}
})
}
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)
requestRender()
},
inverse: () => {
graph.updateNode(nodeId, previous)
runLayoutForNode(nodeId)
requestRender()
}
})
}
function undoAction() {
undo.undo()
requestRender()
}
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 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)
}
const padding = 80
const w = maxX - minX + padding * 2
const h = maxY - minY + padding * 2
// Will be set by canvas composable
const viewW = 800
const viewH = 600
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()
}
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,
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,
adoptNodesIntoSection,
duplicateSelected,
writeCopyData,
pasteFromHTML,
mobileCopy,
mobileCut,
mobilePaste,
deleteSelected,
commitMove,
commitResize,
commitRotation,
commitNodeUpdate,
updateNodeWithUndo,
undoAction,
redoAction,
screenToCanvas,
applyZoom,
pan,
zoomToFit,
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
}