openpencil/src/stores/editor.ts
2026-04-25 21:24:55 +03:00

1349 lines
40 KiB
TypeScript

import { useDebounceFn } from '@vueuse/core'
import { shallowReactive, shallowRef, computed, watch, triggerRef } from 'vue'
import { IS_TAURI } from '@/constants'
import { loadFont } from '@/engine/fonts'
import { toast } from '@/utils/toast'
import {
breakAtVertex,
BUILTIN_IO_FORMATS,
cloneVectorNetwork,
computeAccurateBounds,
createDefaultEditorState,
createEditor,
deleteVertex,
exportFigFile,
findAllHandles,
findOppositeHandle,
IORegistry,
mirrorHandle,
nearestPointOnNetwork,
readFigFile,
removeVertex,
renderNodesToImage,
SceneGraph,
splitSegmentAt,
prefetchFigmaSchema
} from '@open-pencil/core'
import type {
EditorState,
ExportRequest,
Fill,
IOFormatAdapter,
RasterExportFormat,
Rect,
SceneNode,
Vector,
VectorNetwork,
VectorRegion,
VectorSegment,
VectorVertex,
Tool
} from '@open-pencil/core'
export type { Tool } from '@open-pencil/core'
export type { EditorToolDef as ToolDef } from '@open-pencil/core'
export { EDITOR_TOOLS as TOOLS, TOOL_SHORTCUTS } from '@open-pencil/core'
export function createEditorStore(initialGraph?: SceneGraph) {
const graph = initialGraph ?? new SceneGraph()
const state = shallowReactive<
Omit<EditorState, 'penState'> & {
penState: {
vertices: VectorVertex[]
segments: VectorSegment[]
dragTangent: Vector | null
oppositeDragTangent: Vector | null
closingToFirst: boolean
pendingClose?: boolean
resumingNodeId?: string
resumedFills?: Fill[]
resumedStrokes?: SceneNode['strokes']
} | null
showUI: boolean
showRulers: boolean
showRemoteCursors: boolean
activeRibbonTab: 'panels' | 'code' | 'ai'
panelMode: 'layers' | 'design'
actionToast: string | null
mobileDrawerSnap: 'closed' | 'half' | 'full'
clipboardHtml: string
autosaveEnabled: boolean
cursorCanvasX: number | null
cursorCanvasY: number | null
nodeEditState: {
nodeId: string
origNetwork: VectorNetwork
origBounds: Rect
vertices: VectorVertex[]
segments: VectorSegment[]
regions: VectorRegion[]
selectedVertexIndices: Set<number>
draggedHandleInfo: {
vertexIndex: number
handleType: 'tangentStart' | 'tangentEnd'
segmentIndex: number
} | null
/** Set of selected handles as "segIdx:tangentField" strings */
selectedHandles: Set<string>
hoveredHandleInfo: {
segmentIndex: number
tangentField: 'tangentStart' | 'tangentEnd'
} | null
} | null
scrubInputFocused: boolean
}
>({
...createDefaultEditorState(graph.getPages()[0].id),
showUI: true,
showRulers: true,
showRemoteCursors: true,
activeRibbonTab: 'panels',
panelMode: 'design',
actionToast: null,
mobileDrawerSnap: 'closed',
clipboardHtml: '',
autosaveEnabled: false,
cursorCanvasX: null,
cursorCanvasY: null,
nodeEditState: null,
scrubInputFocused: false
})
const viewportSize = { width: 0, height: 0 }
const editor = createEditor({
graph,
state,
loadFont,
skipInitialGraphSetup: !!initialGraph,
getViewportSize: () =>
viewportSize.width > 0 && viewportSize.height > 0
? viewportSize
: { width: window.innerWidth, height: window.innerHeight }
})
const io = new IORegistry(BUILTIN_IO_FORMATS)
if (initialGraph) {
editor.subscribeToGraph()
}
// ─── Vue computed refs ────────────────────────────────────────
const selectedNodes = computed(() => {
void state.sceneVersion
return editor.getSelectedNodes()
})
const selectedNode = computed(() =>
selectedNodes.value.length === 1 ? selectedNodes.value[0] : undefined
)
const layerTree = computed(() => {
void state.sceneVersion
return editor.getLayerTree()
})
// ─── File I/O state ───────────────────────────────────────────
let fileHandle: FileSystemFileHandle | null = null
let filePath: string | null = null
let downloadName: string | null = null
let savedVersion = 0
let lastWriteTime = 0
let unwatchFile: (() => void) | 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 AUTOSAVE_DELAY = 3000
const debouncedAutosave = useDebounceFn(async () => {
if (state.sceneVersion === savedVersion) return
if (!state.autosaveEnabled) return
try {
await writeFile(await buildFigFile())
} catch (e) {
console.warn('Autosave failed:', e)
}
}, AUTOSAVE_DELAY)
watch(
() => state.sceneVersion,
(version) => {
if (version === savedVersion) return
if (!state.autosaveEnabled) return
if (!fileHandle && !filePath) return
void debouncedAutosave()
}
)
// ─── Flash nodes (renderer-specific) ─────────────────────────
let flashRafId = 0
function flashNodes(nodeIds: string[]) {
const renderer = editor.renderer
if (!renderer) return
for (const id of nodeIds) renderer.flashNode(id)
if (!flashRafId) pumpFlashes()
}
function aiMarkActive(nodeIds: string[]) {
if (!editor.renderer) return
editor.renderer.aiMarkActive(nodeIds)
if (!flashRafId) pumpFlashes()
}
function aiMarkDone(nodeIds: string[]) {
if (!editor.renderer) return
editor.renderer.aiMarkDone(nodeIds)
if (!flashRafId) pumpFlashes()
}
function aiFlashDone(nodeIds: string[]) {
if (!editor.renderer) return
editor.renderer.aiFlashDone(nodeIds)
if (!flashRafId) pumpFlashes()
}
function aiClearAll() {
editor.renderer?.aiClearAll()
}
function pumpFlashes() {
if (!editor.renderer?.hasActiveFlashes) {
flashRafId = 0
return
}
state.renderVersion++
flashRafId = requestAnimationFrame(pumpFlashes)
}
// ─── Tool override (commit pen on tool switch) ────────────────
function setTool(tool: Tool) {
// If switching away from PEN while drawing, commit the open path
// except when switching to HAND (e.g. holding Space to pan)
if (state.penState && tool !== 'PEN' && tool !== 'HAND') {
editor.penCommit(false)
}
state.activeTool = tool
}
// ─── Pen resume (vector editor) ────────────────────────────────
function penResumeOnPath(nodeId: string) {
const node = graph.getNode(nodeId)
if (node?.type !== 'VECTOR' || !node.vectorNetwork) return
const vn = node.vectorNetwork
// Convert to absolute coords
const absVertices: VectorVertex[] = vn.vertices.map((v) => ({
...v,
x: v.x + node.x,
y: v.y + node.y
}))
state.penState = {
vertices: absVertices,
segments: vn.segments.map((s) => ({
...s,
tangentStart: { ...s.tangentStart },
tangentEnd: { ...s.tangentEnd }
})),
dragTangent: null,
oppositeDragTangent: null,
closingToFirst: false,
pendingClose: false,
resumingNodeId: nodeId,
resumedFills: [...node.fills],
resumedStrokes: [...node.strokes]
}
// Remove the original node (will be recreated on commit)
graph.deleteNode(nodeId)
state.selectedIds = new Set()
state.activeTool = 'PEN'
editor.requestRender()
}
/** Walk a chain from `start` and return the last vertex reached. */
function walkChainToEnd(segments: { start: number; end: number }[], start: number): number {
let current = start
const visited = new Set<number>([start])
for (;;) {
let found = false
for (const seg of segments) {
let next = -1
if (seg.start === current && !visited.has(seg.end)) next = seg.end
else if (seg.end === current && !visited.has(seg.start)) next = seg.start
if (next === -1) continue
visited.add(next)
current = next
found = true
break
}
if (!found) break
}
return current
}
/** Walk from `start` and return ordered vertices/segments (remapped to 0-based indices). */
function walkChainOrdered(
absVertices: VectorVertex[],
absSegments: VectorSegment[],
start: number
): { orderedVertices: VectorVertex[]; orderedSegments: VectorSegment[] } {
const orderedVertices: VectorVertex[] = []
const orderedSegments: VectorSegment[] = []
const visited = new Set<number>()
let current = start
orderedVertices.push(absVertices[current])
visited.add(current)
for (;;) {
let foundSeg = false
for (const seg of absSegments) {
let next = -1
let isForward = false
if (seg.start === current && !visited.has(seg.end)) {
next = seg.end
isForward = true
} else if (seg.end === current && !visited.has(seg.start)) {
next = seg.start
isForward = false
}
if (next === -1) continue
const fromIdx = orderedVertices.length - 1
orderedVertices.push(absVertices[next])
const toIdx = orderedVertices.length - 1
orderedSegments.push({
start: fromIdx,
end: toIdx,
tangentStart: isForward ? { ...seg.tangentStart } : { ...seg.tangentEnd },
tangentEnd: isForward ? { ...seg.tangentEnd } : { ...seg.tangentStart }
})
visited.add(next)
current = next
foundSeg = true
break
}
if (!foundSeg) break
}
return { orderedVertices, orderedSegments }
}
/**
* Resume pen drawing from an endpoint of an existing VECTOR node.
* Reorders vertices/segments so the endpoint is the last vertex,
* then sets up penState for continuing the drawing.
*/
function penResumeFromEndpoint(nodeId: string, endpointVertexIndex: number) {
const node = graph.getNode(nodeId)
if (node?.type !== 'VECTOR' || !node.vectorNetwork) return
const vn = node.vectorNetwork
// Convert to absolute coords
const absVertices: VectorVertex[] = vn.vertices.map((v) => ({
...v,
x: v.x + node.x,
y: v.y + node.y
}))
const absSegments: VectorSegment[] = vn.segments.map((s) => ({
...s,
tangentStart: { ...s.tangentStart },
tangentEnd: { ...s.tangentEnd }
}))
// Find the OTHER endpoint, then walk from it so the clicked one ends up last.
const otherEnd = walkChainToEnd(absSegments, endpointVertexIndex)
const { orderedVertices, orderedSegments } = walkChainOrdered(
absVertices,
absSegments,
otherEnd
)
state.penState = {
vertices: orderedVertices,
segments: orderedSegments,
dragTangent: null,
oppositeDragTangent: null,
closingToFirst: false,
pendingClose: false,
resumingNodeId: nodeId,
resumedFills: [...node.fills],
resumedStrokes: [...node.strokes]
}
graph.deleteNode(nodeId)
state.selectedIds = new Set()
state.activeTool = 'PEN'
editor.requestRender()
}
// ─── Node edit mode (vector geometry) ──────────────────────────
const NODE_EDIT_HIT_THRESHOLD = 8
function getNodeEditState() {
return state.nodeEditState
}
function setNodeEditNetwork(es: NonNullable<typeof state.nodeEditState>, network: VectorNetwork) {
es.vertices = network.vertices.map((v) => ({ ...v }))
es.segments = network.segments.map((s) => ({
...s,
tangentStart: { ...s.tangentStart },
tangentEnd: { ...s.tangentEnd }
}))
es.regions = network.regions.map((r) => ({
windingRule: r.windingRule,
loops: r.loops.map((l) => [...l])
}))
}
function getLiveNetwork(es: NonNullable<typeof state.nodeEditState>): VectorNetwork {
return {
vertices: es.vertices.map((v) => ({ ...v })),
segments: es.segments.map((s) => ({
...s,
tangentStart: { ...s.tangentStart },
tangentEnd: { ...s.tangentEnd }
})),
regions: es.regions.map((r) => ({
windingRule: r.windingRule,
loops: r.loops.map((l) => [...l])
}))
}
}
function applyNodeEditToNode(es: NonNullable<typeof state.nodeEditState>) {
const node = graph.getNode(es.nodeId)
if (node?.type !== 'VECTOR') return
const live = getLiveNetwork(es)
const bounds = computeAccurateBounds(live)
const relativeNetwork: VectorNetwork = {
vertices: live.vertices.map((v) => ({
...v,
x: v.x - bounds.x,
y: v.y - bounds.y
})),
segments: live.segments,
regions: live.regions
}
graph.updateNode(node.id, {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
vectorNetwork: relativeNetwork
})
editor.requestRender()
}
function enterNodeEditMode(nodeId: string) {
const node = graph.getNode(nodeId)
if (node?.type !== 'VECTOR' || !node.vectorNetwork) return
const absVertices = node.vectorNetwork.vertices.map((v) => ({
...v,
x: v.x + node.x,
y: v.y + node.y
}))
state.nodeEditState = {
nodeId,
origNetwork: cloneVectorNetwork(node.vectorNetwork),
origBounds: { x: node.x, y: node.y, width: node.width, height: node.height },
vertices: absVertices,
segments: node.vectorNetwork.segments.map((s) => ({
...s,
tangentStart: { ...s.tangentStart },
tangentEnd: { ...s.tangentEnd }
})),
regions: node.vectorNetwork.regions.map((r) => ({
windingRule: r.windingRule,
loops: r.loops.map((l) => [...l])
})),
selectedVertexIndices: new Set(),
draggedHandleInfo: null,
selectedHandles: new Set(),
hoveredHandleInfo: null
}
state.selectedIds = new Set([nodeId])
editor.requestRender()
}
function exitNodeEditMode(commit: boolean) {
const es = getNodeEditState()
if (!es) return
const node = graph.getNode(es.nodeId)
if (node?.type !== 'VECTOR') {
state.nodeEditState = null
editor.requestRender()
return
}
if (commit) {
applyNodeEditToNode(es)
} else {
graph.updateNode(es.nodeId, {
x: es.origBounds.x,
y: es.origBounds.y,
width: es.origBounds.width,
height: es.origBounds.height,
vectorNetwork: cloneVectorNetwork(es.origNetwork)
})
editor.requestRender()
}
state.nodeEditState = null
}
function nodeEditSelectVertex(vertexIndex: number, addToSelection: boolean) {
const es = getNodeEditState()
if (!es) return
if (addToSelection) {
const next = new Set(es.selectedVertexIndices)
if (next.has(vertexIndex)) next.delete(vertexIndex)
else next.add(vertexIndex)
es.selectedVertexIndices = next
} else {
es.selectedVertexIndices = new Set([vertexIndex])
}
editor.requestRepaint()
}
type HandleInfo = {
segmentIndex: number
tangentField: 'tangentStart' | 'tangentEnd'
neighborIndex: number
}
/** Resolve the base direction vector for a handle, falling back to neighbor direction. */
function handleBaseVector(tangent: Vector, neighbor: Vector, origin: Vector): Vector {
return Math.hypot(tangent.x, tangent.y) > 1e-6
? tangent
: { x: neighbor.x - origin.x, y: neighbor.y - origin.y }
}
/** Among sibling handles, find the one most opposite to the active handle direction. */
function findSisterHandle(
es: NonNullable<typeof state.nodeEditState>,
siblings: HandleInfo[],
activeBase: Vector,
vertexIndex: number
): HandleInfo {
let sister = siblings[0]
const activeBaseLen = Math.hypot(activeBase.x, activeBase.y)
if (activeBaseLen <= 1e-6) return sister
const activeDir = { x: activeBase.x / activeBaseLen, y: activeBase.y / activeBaseLen }
let bestDot = Infinity
for (const s of siblings) {
const sSeg = es.segments[s.segmentIndex]
const sVertex = es.vertices[vertexIndex]
const sNeighbor = es.vertices[s.neighborIndex]
const sBase = handleBaseVector(sSeg[s.tangentField], sNeighbor, sVertex)
const sLen = Math.hypot(sBase.x, sBase.y)
if (sLen < 1e-6) continue
const sDir = { x: sBase.x / sLen, y: sBase.y / sLen }
const dot = activeDir.x * sDir.x + activeDir.y * sDir.y
if (dot < bestDot) {
bestDot = dot
sister = s
}
}
return sister
}
/** Constrain a tangent to be continuous with the sister handle. Returns the constrained vector, or null if no constraint applied. */
function constrainContinuousTangent(
es: NonNullable<typeof state.nodeEditState>,
newTangent: Vector,
active: HandleInfo,
all: HandleInfo[],
seg: VectorSegment,
tangentField: 'tangentStart' | 'tangentEnd',
vertexIndex: number,
vertex: VectorVertex
): Vector | null {
const siblings = all.filter(
(h) => !(h.segmentIndex === active.segmentIndex && h.tangentField === active.tangentField)
)
if (siblings.length === 0) return null
const activeNeighbor = es.vertices[active.neighborIndex]
const activeBase = handleBaseVector(seg[tangentField], activeNeighbor, vertex)
const sister = findSisterHandle(es, siblings, activeBase, vertexIndex)
const sisterSeg = es.segments[sister.segmentIndex]
const sisterNeighbor = es.vertices[sister.neighborIndex]
const sisterBase = handleBaseVector(sisterSeg[sister.tangentField], sisterNeighbor, vertex)
const sisterLen = Math.hypot(sisterBase.x, sisterBase.y)
if (sisterLen <= 1e-6) return null
const desiredDir = { x: -sisterBase.x / sisterLen, y: -sisterBase.y / sisterLen }
const len = Math.max(0, newTangent.x * desiredDir.x + newTangent.y * desiredDir.y)
vertex.handleMirroring = 'ANGLE'
return { x: desiredDir.x * len, y: desiredDir.y * len }
}
function nodeEditSetHandle(
segmentIndex: number,
tangentField: 'tangentStart' | 'tangentEnd',
newTangent: Vector,
options?: {
breakMirroring?: boolean
continuous?: boolean
lockDirection?: boolean
}
) {
const es = getNodeEditState()
if (!es) return
const seg = es.segments[segmentIndex]
const breakMirroring = options?.breakMirroring ?? false
const continuous = options?.continuous ?? false
const lockDirection = options?.lockDirection ?? false
const vertexIndex = tangentField === 'tangentStart' ? seg.start : seg.end
const vertex = es.vertices[vertexIndex]
const live = getLiveNetwork(es)
const all = findAllHandles(live, vertexIndex)
const active = all.find(
(h) => h.segmentIndex === segmentIndex && h.tangentField === tangentField
)
let applied = { x: newTangent.x, y: newTangent.y }
if (continuous && active) {
applied =
constrainContinuousTangent(
es,
newTangent,
active,
all,
seg,
tangentField,
vertexIndex,
vertex
) ?? applied
}
seg[tangentField] = applied
const mode = vertex.handleMirroring ?? 'NONE'
if (lockDirection && mode === 'NONE') {
seg[tangentField] = { x: newTangent.x, y: newTangent.y }
editor.requestRepaint()
return
}
if (breakMirroring) {
vertex.handleMirroring = 'NONE'
editor.requestRepaint()
return
}
if (mode === 'NONE') {
editor.requestRepaint()
return
}
const opposite = findOppositeHandle(live, vertexIndex, segmentIndex)
if (!opposite) {
editor.requestRepaint()
return
}
const oppositeSeg = es.segments[opposite.segmentIndex]
const oppositeCurrent = oppositeSeg[opposite.tangentField]
const oppositeLength =
mode === 'ANGLE' ? Math.hypot(oppositeCurrent.x, oppositeCurrent.y) : undefined
const mirrored = mirrorHandle(applied, mode, oppositeLength)
if (mirrored) {
oppositeSeg[opposite.tangentField] = mirrored
}
editor.requestRepaint()
}
function nodeEditBendHandle(
vertexIndex: number,
dx: number,
dy: number,
independent: boolean,
targetSegmentIndex: number | null,
targetTangentField: 'tangentStart' | 'tangentEnd' | null
) {
const es = getNodeEditState()
if (!es) return
if (targetSegmentIndex == null || targetTangentField == null) return
const live = getLiveNetwork(es)
const handles = findAllHandles(live, vertexIndex)
if (handles.length === 0) return
const effectiveTargets = handles.filter(
(h) => h.segmentIndex === targetSegmentIndex && h.tangentField === targetTangentField
)
if (effectiveTargets.length === 0) return
const primary = { x: dx, y: dy }
const opposite = independent ? { x: dx, y: dy } : { x: -dx, y: -dy }
const first = effectiveTargets[0]
es.segments[first.segmentIndex][first.tangentField] = primary
for (let i = 1; i < effectiveTargets.length; i++) {
const h = effectiveTargets[i]
es.segments[h.segmentIndex][h.tangentField] = primary
}
if (!independent) {
for (const h of handles) {
if (effectiveTargets.includes(h)) continue
es.segments[h.segmentIndex][h.tangentField] = opposite
}
}
es.vertices[vertexIndex].handleMirroring = independent ? 'NONE' : 'ANGLE_AND_LENGTH'
editor.requestRepaint()
}
function nodeEditZeroVertexHandles(vertexIndex: number) {
const es = getNodeEditState()
if (!es) return
const live = getLiveNetwork(es)
const handles = findAllHandles(live, vertexIndex)
for (const h of handles) {
es.segments[h.segmentIndex][h.tangentField] = { x: 0, y: 0 }
}
es.vertices[vertexIndex].handleMirroring = 'NONE'
editor.requestRepaint()
}
function nodeEditConnectEndpoints(a: number, b: number) {
const es = getNodeEditState()
if (!es || a === b) return
if (a < 0 || b < 0 || a >= es.vertices.length || b >= es.vertices.length) return
const removeIndex = a
const keepIndex = b
const remap = (idx: number): number => {
if (idx === removeIndex) return keepIndex
return idx > removeIndex ? idx - 1 : idx
}
const nextVertices = es.vertices.filter((_, idx) => idx !== removeIndex)
const nextSegments = es.segments
.map((seg) => ({
...seg,
tangentStart: { ...seg.tangentStart },
tangentEnd: { ...seg.tangentEnd },
start: remap(seg.start),
end: remap(seg.end)
}))
.filter((seg) => seg.start !== seg.end)
setNodeEditNetwork(es, { vertices: nextVertices, segments: nextSegments, regions: [] })
es.selectedVertexIndices = new Set([remap(keepIndex)])
es.selectedHandles = new Set()
editor.requestRender()
}
function nodeEditAddVertex(cx: number, cy: number) {
const es = getNodeEditState()
if (!es) return
const live = getLiveNetwork(es)
const nearest = nearestPointOnNetwork(cx, cy, live, NODE_EDIT_HIT_THRESHOLD / state.zoom)
if (!nearest) return
const split = splitSegmentAt(live, nearest.segmentIndex, nearest.t)
setNodeEditNetwork(es, split.network)
es.selectedVertexIndices = new Set([split.newVertexIndex])
es.selectedHandles = new Set()
editor.requestRender()
}
function nodeEditRemoveVertex(vertexIndex: number) {
const es = getNodeEditState()
if (!es) return
const live = getLiveNetwork(es)
const next = removeVertex(live, vertexIndex)
if (!next) return
setNodeEditNetwork(es, next)
es.selectedVertexIndices = new Set()
es.selectedHandles = new Set()
editor.requestRender()
}
function nodeEditAlignVertices(axis: 'horizontal' | 'vertical', align: 'min' | 'center' | 'max') {
const es = getNodeEditState()
if (!es || es.selectedVertexIndices.size < 2) return
const indices = [...es.selectedVertexIndices]
const prop = axis === 'horizontal' ? 'x' : 'y'
let lo = Infinity
let hi = -Infinity
for (const i of indices) {
const v = es.vertices[i][prop]
if (v < lo) lo = v
if (v > hi) hi = v
}
let target = (lo + hi) / 2
if (align === 'min') target = lo
else if (align === 'max') target = hi
for (const i of indices) {
es.vertices[i] = { ...es.vertices[i], [prop]: target }
}
editor.requestRepaint()
}
function nodeEditDeleteSelected() {
const es = getNodeEditState()
if (!es) return
let live = getLiveNetwork(es)
for (const key of es.selectedHandles) {
const [siStr, tf] = key.split(':')
const si = Number(siStr)
const seg = live.segments[si]
if (tf === 'tangentStart') seg.tangentStart = { x: 0, y: 0 }
else seg.tangentEnd = { x: 0, y: 0 }
}
const verticesToDelete = [...es.selectedVertexIndices].sort((a, b) => b - a)
for (const vi of verticesToDelete) {
const next = deleteVertex(live, vi)
if (!next) break
live = next
}
setNodeEditNetwork(es, live)
es.selectedVertexIndices = new Set()
es.selectedHandles = new Set()
editor.requestRender()
}
function nodeEditBreakAtVertex() {
const es = getNodeEditState()
if (!es || es.selectedVertexIndices.size === 0) return
const [vertexIndex] = es.selectedVertexIndices
const live = getLiveNetwork(es)
const next = breakAtVertex(live, vertexIndex)
setNodeEditNetwork(es, next)
es.selectedHandles = new Set()
es.selectedVertexIndices = new Set([vertexIndex])
editor.requestRender()
}
// ─── File I/O ──────────────────────────────────────────────────
function yieldToUI(): Promise<void> {
return new Promise((r) => requestAnimationFrame(() => r()))
}
function setViewportSize(width: number, height: number) {
viewportSize.width = width
viewportSize.height = height
}
async function fitCurrentPageToViewport() {
await yieldToUI()
editor.zoomToFit()
}
function setDocumentSource(
fileName: string,
sourceFormat: string,
handle?: FileSystemFileHandle,
path?: string
) {
stopWatchingFile()
fileHandle = sourceFormat === 'fig' ? (handle ?? null) : null
filePath = sourceFormat === 'fig' ? (path ?? null) : null
downloadName = sourceFormat === 'fig' ? fileName : fileName.replace(/\.[^.]+$/i, '.fig')
savedVersion = state.sceneVersion
if (sourceFormat === 'fig' && (fileHandle || filePath)) {
void startWatchingFile()
}
}
function setPlannedFilePath(path: string) {
stopWatchingFile()
fileHandle = null
filePath = path
downloadName = path.split(/[\\/]/).pop() ?? 'Untitled.fig'
state.documentName = downloadName.replace(/\.fig$/i, '')
}
function startWatchingCurrentFile() {
void startWatchingFile()
}
function dispose() {
stopWatchingFile()
;(debouncedAutosave as typeof debouncedAutosave & { cancel?: () => void }).cancel?.()
}
async function openFigFile(file: File, handle?: FileSystemFileHandle, path?: string) {
try {
state.loading = true
await yieldToUI()
const imported = await readFigFile(file, { populate: 'first-page' })
await yieldToUI()
editor.replaceGraph(imported)
editor.undo.clear()
state.documentName = file.name.replace(/\.fig$/i, '')
setDocumentSource(file.name, 'fig', handle, path)
state.selectedIds = new Set()
const firstPage = editor.graph.getPages()[0] as SceneNode | undefined
const pageId = firstPage?.id ?? editor.graph.rootId
await editor.switchPage(pageId)
await fitCurrentPageToViewport()
editor.requestRender()
} catch (e) {
console.error('Failed to open .fig file:', e)
toast.error(`Failed to open file: ${e instanceof Error ? e.message : String(e)}`)
} finally {
state.loading = false
}
}
function buildFigFile() {
return exportFigFile(editor.graph, undefined, editor.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, { populate: 'first-page' })
editor.replaceGraph(imported)
} else if (fileHandle) {
const file = await fileHandle.getFile()
const imported = await readFigFile(file, { populate: 'first-page' })
editor.replaceGraph(imported)
} else {
return
}
editor.undo.clear()
savedVersion = state.sceneVersion
state.selectedIds = new Set()
if (editor.graph.getNode(pageId)) {
state.currentPageId = pageId
} else {
state.currentPageId = editor.graph.getPages()[0]?.id ?? editor.graph.rootId
}
state.panX = viewport.panX
state.panY = viewport.panY
state.zoom = viewport.zoom
editor.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)
}
}
// ─── Export ───────────────────────────────────────────────────
async function renderExportImage(
nodeIds: string[],
scale: number,
format: RasterExportFormat
): Promise<Uint8Array | null> {
const renderer = editor.renderer
if (!renderer) return null
const ids =
nodeIds.length > 0 ? nodeIds : editor.graph.getChildren(state.currentPageId).map((n) => n.id)
if (ids.length === 0) return null
return renderNodesToImage(renderer.ck, renderer, editor.graph, state.currentPageId, ids, {
scale,
format
})
}
function getExportBaseName(target: ExportRequest['target']): string {
if (target.scope === 'node') {
return editor.graph.getNode(target.nodeId)?.name ?? 'Export'
}
if (target.scope === 'selection' && target.nodeIds.length === 1) {
return editor.graph.getNode(target.nodeIds[0])?.name ?? 'Export'
}
if (target.scope === 'page') {
return editor.graph.getNode(target.pageId)?.name ?? 'Page'
}
return 'Export'
}
function getSelectionExportTarget(): ExportRequest['target'] {
const ids = [...state.selectedIds]
if (ids.length > 0) return { scope: 'selection', nodeIds: ids }
return { scope: 'page', pageId: state.currentPageId }
}
function listSelectionExportFormats(): IOFormatAdapter[] {
return io.listExportFormats(state.selectedIds.size > 0 ? 'selection' : 'page')
}
async function exportTarget(
target: ExportRequest['target'],
formatId: string,
options?: { scale?: number; quality?: number; jsxFormat?: 'openpencil' | 'tailwind' }
) {
const format = io.getFormat(formatId)
if (!format) throw new Error(`Unknown export format: ${formatId}`)
let exportOptions: unknown
if (formatId === 'png' || formatId === 'jpg' || formatId === 'webp') {
exportOptions = {
format: formatId.toUpperCase(),
scale: options?.scale ?? 1,
quality: options?.quality
}
} else if (formatId === 'jsx') {
exportOptions = { format: options?.jsxFormat ?? 'openpencil' }
}
const result = await io.exportContent(
formatId,
{ graph: editor.graph, target },
exportOptions,
editor.renderer ? { canvasKit: editor.renderer.ck, renderer: editor.renderer } : undefined
)
const baseName = getExportBaseName(target)
const fileName =
formatId === 'png' || formatId === 'jpg' || formatId === 'webp'
? `${baseName}@${options?.scale ?? 1}x.${result.extension}`
: `${baseName}.${result.extension}`
const bytes =
typeof result.data === 'string'
? new TextEncoder().encode(result.data)
: new Uint8Array(result.data)
await saveExportedFile(bytes, fileName, format.label, `.${result.extension}`, result.mimeType)
}
async function exportSelection(scale: number, formatId: 'png' | 'jpg' | 'webp' | 'svg' | 'fig') {
await exportTarget(getSelectionExportTarget(), formatId, { scale })
}
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)
}
// ─── Mobile clipboard ─────────────────────────────────────────
async function mobileCopy() {
const transfer = new DataTransfer()
await editor.writeCopyData(transfer)
state.clipboardHtml = transfer.getData('text/html')
}
async function mobileCut() {
await mobileCopy()
editor.deleteSelected()
}
function mobilePaste() {
if (state.clipboardHtml) {
editor.pasteFromHTML(state.clipboardHtml)
}
}
// ─── Profiler toggle ─────────────────────────────────────────
function viewportScreenCenter() {
const canvas = document.querySelector<HTMLCanvasElement>('[data-test-id="canvas-element"]')
if (canvas) {
const rect = canvas.getBoundingClientRect()
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
}
return { x: window.innerWidth / 2, y: window.innerHeight / 2 }
}
function toggleProfiler() {
editor.renderer?.profiler.toggle()
editor.requestRepaint()
}
// ─── Public API ───────────────────────────────────────────────
// Spread all core Editor methods, then override getters and add app-specific.
const store = {
...editor,
state,
selectedNodes,
selectedNode,
layerTree,
// App-specific overrides and additions
flashNodes,
aiMarkActive,
aiMarkDone,
aiFlashDone,
aiClearAll,
setTool,
penResumeOnPath,
penResumeFromEndpoint,
enterNodeEditMode,
exitNodeEditMode,
nodeEditSelectVertex,
nodeEditSetHandle,
nodeEditBendHandle,
nodeEditZeroVertexHandles,
nodeEditConnectEndpoints,
nodeEditAddVertex,
nodeEditRemoveVertex,
nodeEditAlignVertices,
nodeEditDeleteSelected,
nodeEditBreakAtVertex,
openFigFile,
setViewportSize,
fitCurrentPageToViewport,
saveFigFile,
saveFigFileAs,
setDocumentSource,
setPlannedFilePath,
startWatchingCurrentFile,
dispose,
renderExportImage,
listSelectionExportFormats,
exportTarget,
exportSelection,
mobileCopy,
mobileCut,
mobilePaste,
viewportScreenCenter,
toggleProfiler
}
Object.defineProperties(store, {
graph: {
enumerable: true,
get: () => editor.graph
},
renderer: {
enumerable: true,
get: () => editor.renderer
},
textEditor: {
enumerable: true,
get: () => editor.textEditor
}
})
return store
}
export type EditorStore = ReturnType<typeof createEditorStore>
const storeRef = shallowRef<EditorStore>()
export function setActiveEditorStore(store: EditorStore) {
storeRef.value = store
triggerRef(storeRef)
}
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
}