import { computed, ref } from 'vue' import { defineStore } from 'pinia' import type { Connection } from '@vue-flow/core' import { ACTION_TYPE, APPROVAL_TYPE, END_TYPE, PARALLEL_TYPE, END_NODE_ID, edgeLabel, fromDocument, layoutFromNodes, toDocument, type DesignerEdge, type DesignerNode, validateGraph, } from '@/mapper/graph' import { dropCrossLaneEdges, expandLegacyParallel, fitParallelFrames, laneOf, materializeParallel, reflowLanes, settleLaneNodes, spawnLaneStep } from '@/mapper/parallel' import { applyLayeredLayout, assignEdgeHandles, normalizeHandleId } from '@/mapper/layout' import { defaultActionData, defaultApprovalData, defaultEndData } from '@/types/definition' import type { DesignerEdgeData, DesignerNodeData, ProcessDefinitionDocument } from '@/types/definition' let seq = 0 function nextId(prefix: string) { seq += 1 return `${prefix}-${Date.now().toString(36)}-${seq}` } interface GraphSnapshot { definitionId: string name: string startStepId: string nodes: DesignerNode[] edges: DesignerEdge[] } interface ClipboardPayload { nodes: DesignerNode[] edges: DesignerEdge[] } function slimNode(node: DesignerNode): DesignerNode { return { id: node.id, type: node.type, position: { x: node.position.x, y: node.position.y }, parentNode: node.parentNode, extent: node.extent, style: node.style ? { ...node.style } : undefined, zIndex: node.zIndex, data: JSON.parse(JSON.stringify(node.data)) as DesignerNodeData, } } function slimEdge(edge: DesignerEdge): DesignerEdge { return { id: edge.id, source: edge.source, target: edge.target, sourceHandle: edge.sourceHandle, targetHandle: edge.targetHandle, label: edge.label, data: { ...(edge.data ?? { priority: 0 }) }, } } export const useDesignerStore = defineStore('designer', () => { const definitionId = ref('') const name = ref('') const startStepId = ref('') const publishedVersion = ref(null) const loadedFromDraft = ref(false) const nodes = ref([]) const edges = ref([]) const selectedNodeId = ref(null) const selectedEdgeId = ref(null) const selectedNodeIds = ref([]) const selectedEdgeIds = ref([]) const baseline = ref('') const undoStack = ref([]) const redoStack = ref([]) let historyPaused = false let clipboard: ClipboardPayload | null = null const selectedNode = computed(() => nodes.value.find((n) => n.id === selectedNodeId.value), ) const selectedEdge = computed(() => edges.value.find((e) => e.id === selectedEdgeId.value), ) const graphErrors = computed(() => validateGraph(definitionId.value, name.value, startStepId.value, nodes.value, edges.value), ) const canUndo = computed(() => undoStack.value.length > 0) const canRedo = computed(() => redoStack.value.length > 0) const canPaste = computed(() => Boolean(clipboard?.nodes.length)) function snapshot() { return JSON.stringify({ id: definitionId.value, name: name.value, startStepId: startStepId.value, doc: toDocument(definitionId.value, name.value, startStepId.value, nodes.value, edges.value), layout: layoutFromNodes(nodes.value), }) } function captureBaseline() { baseline.value = snapshot() } const isDirty = computed(() => snapshot() !== baseline.value) function cloneGraph(): GraphSnapshot { return { definitionId: definitionId.value, name: name.value, startStepId: startStepId.value, nodes: nodes.value.map(slimNode), edges: edges.value.map(slimEdge), } } function resetHistory() { undoStack.value = [] redoStack.value = [] } function pushHistory() { if (historyPaused) { return } undoStack.value = [...undoStack.value, cloneGraph()].slice(-50) redoStack.value = [] } function applySnapshot(snap: GraphSnapshot) { historyPaused = true definitionId.value = snap.definitionId name.value = snap.name startStepId.value = snap.startStepId nodes.value = snap.nodes.map(slimNode) edges.value = snap.edges.map(slimEdge) selectedNodeId.value = null selectedEdgeId.value = null selectedNodeIds.value = [] selectedEdgeIds.value = [] historyPaused = false } function undo() { const prev = undoStack.value.at(-1) if (!prev) { return false } const current = cloneGraph() undoStack.value = undoStack.value.slice(0, -1) redoStack.value = [...redoStack.value, current] applySnapshot(prev) return true } function redo() { const next = redoStack.value.at(-1) if (!next) { return false } const current = cloneGraph() redoStack.value = redoStack.value.slice(0, -1) undoStack.value = [...undoStack.value, current] applySnapshot(next) return true } function syncStartFlags() { const current = nodes.value.find((node) => node.id === startStepId.value) if (current?.parentNode) { startStepId.value = current.parentNode } nodes.value = nodes.value.map((node) => { if (!node.data || node.data.kind === 'END') { return node } const isStart = !node.parentNode && node.id === startStepId.value return { ...node, data: { ...node.data, isStart }, } }) } function clearSelection() { selectedNodeId.value = null selectedEdgeId.value = null selectedNodeIds.value = [] selectedEdgeIds.value = [] } function clear() { definitionId.value = '' name.value = '' startStepId.value = '' publishedVersion.value = null loadedFromDraft.value = false nodes.value = [] edges.value = [] clearSelection() resetHistory() captureBaseline() } function resetEmpty(id = `process-${Date.now().toString(36)}`) { definitionId.value = id name.value = 'Untitled process' startStepId.value = '' publishedVersion.value = null loadedFromDraft.value = false nodes.value = [] edges.value = [] clearSelection() resetHistory() captureBaseline() } function loadDocument(doc: ProcessDefinitionDocument) { const mapped = fromDocument(doc) definitionId.value = doc.id name.value = doc.name startStepId.value = mapped.startStepId publishedVersion.value = doc.version ?? null loadedFromDraft.value = false const laid = applyLayeredLayout(mapped.nodes, mapped.edges, mapped.startStepId) nodes.value = laid.nodes edges.value = laid.edges clearSelection() resetHistory() captureBaseline() } function loadGraph(params: { id: string name: string startStepId: string nodes: DesignerNode[] edges: DesignerEdge[] version?: number | null fromDraft?: boolean }) { definitionId.value = params.id name.value = params.name startStepId.value = params.startStepId publishedVersion.value = params.version ?? null loadedFromDraft.value = Boolean(params.fromDraft) const expanded = expandLegacyParallel(params.nodes, params.edges) const parallels = expanded.nodes.filter((node) => node.data?.kind === 'PARALLEL').map((node) => node.id) nodes.value = parallels.reduce((current, id) => reflowLanes(current, id), expanded.nodes) edges.value = assignEdgeHandles(nodes.value, expanded.edges) syncStartFlags() clearSelection() resetHistory() captureBaseline() } function relayout() { pushHistory() const laid = applyLayeredLayout(nodes.value, edges.value, startStepId.value) nodes.value = laid.nodes edges.value = laid.edges } function syncEdgeHandles() { edges.value = assignEdgeHandles(nodes.value, edges.value) } function settleLanes() { const settled = settleLaneNodes(nodes.value) nodes.value = settled edges.value = assignEdgeHandles(settled, dropCrossLaneEdges(settled, edges.value)) } function addNode(type: string, position: { x: number; y: number }): 'ok' | 'duplicate-end' { let data: DesignerNodeData let nodeType = type if (type === APPROVAL_TYPE) { data = defaultApprovalData() } else if (type === ACTION_TYPE) { data = defaultActionData() } else if (type === PARALLEL_TYPE) { pushHistory() const id = nextId(nodeType) const built = materializeParallel(id, 'Parallel', position, [ { id: 'legal', steps: [{ id: nextId('legal'), name: 'Legal', candidates: [], policy: 'ANY' }], transitions: [] }, { id: 'finance', steps: [{ id: nextId('finance'), name: 'Finance', candidates: [], policy: 'ANY' }], transitions: [] }, ]) nodes.value = fitParallelFrames([...nodes.value, built.parent, ...built.children]) if (!startStepId.value) { startStepId.value = id syncStartFlags() } selectedNodeId.value = id selectedNodeIds.value = [id] selectedEdgeId.value = null selectedEdgeIds.value = [] return 'ok' } else { nodeType = END_TYPE if (nodes.value.some((n) => n.type === END_TYPE)) { return 'duplicate-end' } data = defaultEndData() } pushHistory() const id = nodeType === END_TYPE ? END_NODE_ID : nextId(nodeType) const node: DesignerNode = { id, type: nodeType, position, data } nodes.value = [...nodes.value, node] if (!startStepId.value && nodeType !== END_TYPE) { startStepId.value = id syncStartFlags() } selectedNodeId.value = id selectedNodeIds.value = [id] selectedEdgeId.value = null selectedEdgeIds.value = [] return 'ok' } function setStart(id: string) { const node = nodes.value.find((n) => n.id === id) if (!node || node.type === END_TYPE || node.parentNode) { return } pushHistory() startStepId.value = id syncStartFlags() } function renameNode(oldId: string, newId: string): boolean { const trimmed = newId.trim() if (!trimmed || trimmed === oldId) { return true } if (nodes.value.some((n) => n.id === trimmed)) { return false } pushHistory() nodes.value = nodes.value.map((n) => (n.id === oldId ? { ...n, id: trimmed } : n)) edges.value = edges.value.map((e) => ({ ...e, source: e.source === oldId ? trimmed : e.source, target: e.target === oldId ? trimmed : e.target, })) if (startStepId.value === oldId) { startStepId.value = trimmed } if (selectedNodeId.value === oldId) { selectedNodeId.value = trimmed } selectedNodeIds.value = selectedNodeIds.value.map((id) => (id === oldId ? trimmed : id)) return true } function patchNodeData(id: string, patch: Record) { pushHistory() nodes.value = nodes.value.map((n) => { if (n.id !== id) { return n } return { ...n, data: { ...n.data, ...patch } as DesignerNodeData } }) } function patchEdgeData(id: string, patch: Partial) { pushHistory() edges.value = edges.value.map((e) => { if (e.id !== id) { return e } const data = { ...(e.data ?? { priority: 0 }), ...patch } if ('when' in patch && patch.when === undefined) { delete data.when } return { ...e, data, label: edgeLabel(data) } }) } function onConnect(connection: Connection) { if (!connection.source || !connection.target) { return } const sourceNode = nodes.value.find((n) => n.id === connection.source) const targetNode = nodes.value.find((n) => n.id === connection.target) if (sourceNode?.type === END_TYPE || targetNode?.type === PARALLEL_TYPE) { return } const inner = Boolean(sourceNode?.parentNode || targetNode?.parentNode) if (inner && (sourceNode?.parentNode !== targetNode?.parentNode || laneOf(sourceNode?.data) !== laneOf(targetNode?.data))) { return } pushHistory() const siblings = edges.value.filter((e) => e.source === connection.source) const used = new Set(siblings.map((e) => e.data?.priority ?? 0)) let priority = 0 while (used.has(priority)) { priority += 1 } const data: DesignerEdgeData = { priority } const edge: DesignerEdge = { id: nextId('e'), source: connection.source, target: connection.target, sourceHandle: normalizeHandleId(connection.sourceHandle) ?? 'source-bottom', targetHandle: normalizeHandleId(connection.targetHandle) ?? 'target-top', label: edgeLabel(data), data, } edges.value = [...edges.value, edge] edges.value = assignEdgeHandles(nodes.value, edges.value) } function selectNode(id: string | null) { selectedNodeId.value = id selectedNodeIds.value = id ? [id] : [] if (id) { selectedEdgeId.value = null selectedEdgeIds.value = [] } } function selectEdge(id: string | null) { selectedEdgeId.value = id selectedEdgeIds.value = id ? [id] : [] if (id) { selectedNodeId.value = null selectedNodeIds.value = [] } } function setSelection(nodeIds: string[], edgeIds: string[]) { selectedNodeIds.value = nodeIds selectedEdgeIds.value = edgeIds selectedNodeId.value = nodeIds.length === 1 ? (nodeIds[0] ?? null) : nodeIds[0] ?? null if (nodeIds.length > 1) { selectedNodeId.value = nodeIds[0] ?? null } selectedEdgeId.value = nodeIds.length ? null : (edgeIds[0] ?? null) } function hasOutgoing(id: string) { return edges.value.some((e) => e.source === id) } function hasOutgoingAmong(ids: string[]) { return ids.some((id) => hasOutgoing(id)) } function removeNode(id: string) { const node = nodes.value.find((n) => n.id === id) if (!node) { return } const drop = new Set([id, ...nodes.value.filter((n) => n.parentNode === id).map((n) => n.id)]) pushHistory() nodes.value = nodes.value.filter((n) => !drop.has(n.id)) edges.value = edges.value.filter((e) => !drop.has(e.source) && !drop.has(e.target)) selectedNodeIds.value = selectedNodeIds.value.filter((n) => !drop.has(n)) if (selectedNodeId.value && drop.has(selectedNodeId.value)) { selectedNodeId.value = selectedNodeIds.value[0] ?? null } if (drop.has(startStepId.value)) { const next = nodes.value.find((n) => n.type !== END_TYPE && !n.parentNode) startStepId.value = next?.id ?? '' syncStartFlags() } } function removeEdge(id: string) { pushHistory() edges.value = edges.value.filter((e) => e.id !== id) selectedEdgeIds.value = selectedEdgeIds.value.filter((e) => e !== id) if (selectedEdgeId.value === id) { selectedEdgeId.value = selectedEdgeIds.value[0] ?? null } } function removeNodes(ids: string[]) { const set = new Set(ids) for (const node of nodes.value) { if (node.parentNode && set.has(node.parentNode)) { set.add(node.id) } } if (!set.size) { return } pushHistory() nodes.value = nodes.value.filter((n) => !set.has(n.id)) edges.value = edges.value.filter((e) => !set.has(e.source) && !set.has(e.target)) selectedNodeIds.value = [] selectedNodeId.value = null if (set.has(startStepId.value)) { const next = nodes.value.find((n) => n.type !== END_TYPE && !n.parentNode) startStepId.value = next?.id ?? '' syncStartFlags() } } function removeEdges(ids: string[]) { const set = new Set(ids) if (!set.size) { return } pushHistory() edges.value = edges.value.filter((e) => !set.has(e.id)) selectedEdgeIds.value = [] selectedEdgeId.value = null } function removeSelected() { if (selectedNodeIds.value.length) { removeNodes(selectedNodeIds.value) return } if (selectedNodeId.value) { removeNode(selectedNodeId.value) return } if (selectedEdgeIds.value.length) { removeEdges(selectedEdgeIds.value) return } if (selectedEdgeId.value) { removeEdge(selectedEdgeId.value) } } function withDescendants(ids: string[]) { const set = new Set(ids) let grew = true while (grew) { grew = false for (const node of nodes.value) { if (node.parentNode && set.has(node.parentNode) && !set.has(node.id)) { set.add(node.id) grew = true } } } return [...set] } function copySelection() { const ids = selectedNodeIds.value.length ? selectedNodeIds.value : selectedNodeId.value ? [selectedNodeId.value] : [] if (!ids.length) { clipboard = null return false } const idSet = new Set(withDescendants(ids)) clipboard = { nodes: nodes.value.filter((n) => idSet.has(n.id)).map(slimNode), edges: edges.value .filter((e) => idSet.has(e.source) && idSet.has(e.target)) .map(slimEdge), } return true } function pasteClipboard() { if (!clipboard?.nodes.length) { return false } pushHistory() const hasEnd = nodes.value.some((n) => n.type === END_TYPE) const idMap = new Map() const pastedNodes: DesignerNode[] = [] const ordered = [...clipboard.nodes].sort((a, b) => Number(Boolean(a.parentNode)) - Number(Boolean(b.parentNode))) for (const node of ordered) { if (node.type === END_TYPE && hasEnd) { continue } const id = node.type === END_TYPE ? END_NODE_ID : nextId(node.type) idMap.set(node.id, id) const parentMoved = Boolean(node.parentNode && idMap.has(node.parentNode)) pastedNodes.push({ ...slimNode(node), id, parentNode: node.parentNode ? (idMap.get(node.parentNode) ?? node.parentNode) : undefined, position: parentMoved || !node.parentNode ? node.parentNode ? { ...node.position } : { x: node.position.x + 40, y: node.position.y + 40 } : { x: node.position.x + 40, y: node.position.y }, }) } const pastedEdges: DesignerEdge[] = [] for (const edge of clipboard.edges) { const source = idMap.get(edge.source) const target = idMap.get(edge.target) if (!source || !target) { continue } const data = { ...(edge.data ?? { priority: 0 }) } pastedEdges.push({ ...slimEdge(edge), id: nextId('e'), source, target, data, label: edgeLabel(data), }) } nodes.value = fitParallelFrames([...nodes.value, ...pastedNodes]) edges.value = [...edges.value, ...pastedEdges] selectedNodeIds.value = pastedNodes.map((n) => n.id) selectedNodeId.value = pastedNodes[0]?.id ?? null selectedEdgeId.value = null selectedEdgeIds.value = [] return pastedNodes.length > 0 } function alignSelected(mode: 'left' | 'center' | 'top'): boolean { const ids = selectedNodeIds.value.length >= 2 ? selectedNodeIds.value : [] if (ids.length < 2) { return false } const set = new Set(ids) const selected = nodes.value.filter((n) => set.has(n.id)) pushHistory() if (mode === 'left') { const x = Math.min(...selected.map((n) => n.position.x)) for (const n of selected) { n.position = { ...n.position, x } } } else if (mode === 'center') { const xs = selected.map((n) => n.position.x) const x = (Math.min(...xs) + Math.max(...xs)) / 2 for (const n of selected) { n.position = { ...n.position, x } } } else { const y = Math.min(...selected.map((n) => n.position.y)) for (const n of selected) { n.position = { ...n.position, y } } } nodes.value = settleLaneNodes(nodes.value) return true } function laneName(lanes: string[]) { let index = lanes.length + 1 let id = `branch-${index}` while (lanes.includes(id)) { index += 1 id = `branch-${index}` } return id } function addLane(parallelId: string) { const parent = nodes.value.find((node) => node.id === parallelId) if (!parent || parent.data.kind !== 'PARALLEL') { return } pushHistory() const lane = laneName(parent.data.lanes) const step = spawnLaneStep(parallelId, lane, parent.data.lanes.length, 0, nextId(lane)) nodes.value = fitParallelFrames([ ...nodes.value.map((node) => node.id === parallelId && node.data.kind === 'PARALLEL' ? { ...node, data: { ...node.data, lanes: [...node.data.lanes, lane] } } : node, ), step, ]) } function removeLane(parallelId: string, lane: string) { const parent = nodes.value.find((node) => node.id === parallelId) if (!parent || parent.data.kind !== 'PARALLEL' || parent.data.lanes.length <= 2) { return } pushHistory() const drop = new Set( nodes.value.filter((node) => node.parentNode === parallelId && laneOf(node.data) === lane).map((node) => node.id), ) nodes.value = reflowLanes( nodes.value .filter((node) => !drop.has(node.id)) .map((node) => node.id === parallelId && node.data.kind === 'PARALLEL' ? { ...node, data: { ...node.data, lanes: node.data.lanes.filter((item: string) => item !== lane) } } : node, ), parallelId, ) edges.value = edges.value.filter((edge) => !drop.has(edge.source) && !drop.has(edge.target)) } function addLaneStep(parallelId: string, lane: string, kind: 'approval' | 'action' = 'approval') { const parent = nodes.value.find((node) => node.id === parallelId) if (!parent || parent.data.kind !== 'PARALLEL' || !parent.data.lanes.includes(lane)) { return } pushHistory() const laneIndex = parent.data.lanes.indexOf(lane) const stepIndex = nodes.value.filter((node) => node.parentNode === parallelId && laneOf(node.data) === lane).length nodes.value = reflowLanes( [...nodes.value, spawnLaneStep(parallelId, lane, laneIndex, stepIndex, nextId(lane), kind)], parallelId, ) } function renameLane(parallelId: string, from: string, to: string): boolean { const trimmed = to.trim() const parent = nodes.value.find((node) => node.id === parallelId) if (!parent || parent.data.kind !== 'PARALLEL' || !trimmed || trimmed === from) { return trimmed === from } if (parent.data.lanes.includes(trimmed)) { return false } pushHistory() nodes.value = nodes.value.map((node) => { if (node.id === parallelId && node.data.kind === 'PARALLEL') { return { ...node, data: { ...node.data, lanes: node.data.lanes.map((lane: string) => (lane === from ? trimmed : lane)) } } } if (node.parentNode === parallelId && laneOf(node.data) === from && (node.data.kind === 'APPROVAL' || node.data.kind === 'ACTION')) { return { ...node, data: { ...node.data, laneId: trimmed } } } return node }) return true } function document(): ProcessDefinitionDocument { return toDocument(definitionId.value, name.value, startStepId.value, nodes.value, edges.value) } function localErrors() { return graphErrors.value } return { definitionId, name, startStepId, publishedVersion, loadedFromDraft, nodes, edges, selectedNodeId, selectedEdgeId, selectedNodeIds, selectedEdgeIds, selectedNode, selectedEdge, graphErrors, isDirty, canUndo, canRedo, canPaste, clear, resetEmpty, loadDocument, loadGraph, addNode, setStart, renameNode, patchNodeData, patchEdgeData, onConnect, selectNode, selectEdge, setSelection, removeNode, removeEdge, removeNodes, removeEdges, removeSelected, hasOutgoing, hasOutgoingAmong, copySelection, pasteClipboard, undo, redo, pushHistory, alignSelected, document, localErrors, captureBaseline, relayout, syncEdgeHandles, settleLanes, syncStartFlags, addLane, removeLane, addLaneStep, renameLane, } })