1349 lines
40 KiB
TypeScript
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
|
|
}
|