import { create } from 'zustand' import { type Node, type Edge, type NodeChange, type EdgeChange, type Connection, applyNodeChanges, applyEdgeChanges, addEdge, } from '@xyflow/react' import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef } from '@/types' import { generateUUID } from '@/utils/uuid' import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils' import { applyOpacity } from '@/utils/colorUtils' type HistoryEntry = { nodes: Node[]; edges: Edge[] } interface CanvasState { nodes: Node[] edges: Edge[] hasUnsavedChanges: boolean selectedNodeId: string | null selectedNodeIds: string[] scanEventTs: number // History past: HistoryEntry[] future: HistoryEntry[] snapshotHistory: () => void undo: () => void redo: () => void // Clipboard clipboard: Node[] copySelectedNodes: () => void pasteNodes: () => void onNodesChange: (changes: NodeChange>[]) => void onEdgesChange: (changes: EdgeChange>[]) => void onConnect: (connection: Connection) => void setSelectedNode: (id: string | null) => void addNode: (node: Node) => void updateNode: (id: string, data: Partial) => void deleteNode: (id: string) => void updateEdge: (id: string, data: Partial) => void reconnectEdge: (id: string, connection: Connection) => void deleteEdge: (id: string) => void setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void setNodeZIndex: (id: string, zIndex: number) => void editingGroupRectId: string | null setEditingGroupRectId: (id: string | null) => void editingTextId: string | null setEditingTextId: (id: string | null) => void createGroup: (nodeIds: string[], name: string) => void ungroup: (groupId: string) => void markSaved: () => void markUnsaved: () => void loadCanvas: (nodes: Node[], edges: Edge[]) => void fitViewPending: boolean clearFitViewPending: () => void notifyScanDeviceFound: () => void hideIp: boolean toggleHideIp: () => void applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void applyAllCustomStyles: (def: CustomStyleDef) => void } export const useCanvasStore = create((set) => ({ nodes: [], edges: [], hasUnsavedChanges: false, selectedNodeId: null, selectedNodeIds: [], editingGroupRectId: null, editingTextId: null, hideIp: false, scanEventTs: 0, fitViewPending: false, past: [], future: [], clipboard: [], snapshotHistory: () => set((state) => ({ past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }], future: [], })), undo: () => set((state) => { if (state.past.length === 0) return state const previous = state.past[state.past.length - 1] return { nodes: previous.nodes, edges: previous.edges, past: state.past.slice(0, -1), future: [{ nodes: state.nodes, edges: state.edges }, ...state.future.slice(0, 49)], hasUnsavedChanges: true, } }), redo: () => set((state) => { if (state.future.length === 0) return state const next = state.future[0] return { nodes: next.nodes, edges: next.edges, past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }], future: state.future.slice(1), hasUnsavedChanges: true, } }), copySelectedNodes: () => set((state) => ({ clipboard: state.nodes.filter((n) => n.selected), })), pasteNodes: () => set((state) => { if (state.clipboard.length === 0) return state const newNodes = state.clipboard.map((n) => ({ ...n, id: generateUUID(), position: { x: n.position.x + 50, y: n.position.y + 50 }, selected: false, parentId: undefined, extent: undefined, data: { ...n.data, parent_id: undefined }, })) return { nodes: [...state.nodes, ...newNodes], past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }], future: [], hasUnsavedChanges: true, } }), onNodesChange: (changes) => set((state) => { const nodes = applyNodeChanges(changes, state.nodes) const selectedNodeIds = nodes.filter((n) => n.selected).map((n) => n.id) return { nodes, selectedNodeIds, hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'), } }), onEdgesChange: (changes) => set((state) => ({ edges: applyEdgeChanges(changes, state.edges), hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'), })), onConnect: (connection) => set((state) => { const extra = connection as Connection & Partial const edgeType = extra.type ?? 'ethernet' return { edges: addEdge({ ...connection, sourceHandle: normalizeHandle(extra.sourceHandle), targetHandle: normalizeHandle(extra.targetHandle), type: edgeType, data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style, animated: extra.animated }, }, state.edges), hasUnsavedChanges: true, } }), setSelectedNode: (id) => set({ selectedNodeId: id, selectedNodeIds: id ? [id] : [], }), addNode: (node) => set((state) => { const parent = node.data.parent_id ? state.nodes.find((n) => n.id === node.data.parent_id) : null const shouldNestInParent = !!(parent?.data.container_mode) const enriched = node.data.parent_id && shouldNestInParent ? { ...node, parentId: node.data.parent_id, extent: 'parent' as const, position: { x: Math.max(10, node.position.x - parent.position.x), y: Math.max(10, node.position.y - parent.position.y), }, } : node // Parents must come before children in the array (React Flow requirement) const withoutNew = state.nodes.filter((n) => n.id !== node.id) if (enriched.parentId) { const parentIdx = withoutNew.findIndex((n) => n.id === enriched.parentId) const insertAt = parentIdx >= 0 ? parentIdx + 1 : withoutNew.length const nodes = [...withoutNew.slice(0, insertAt), enriched, ...withoutNew.slice(insertAt)] return { nodes, hasUnsavedChanges: true } } return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true } }), updateNode: (id, data) => set((state) => { let nodes = state.nodes.map((n) => { if (n.id !== id) return n const updated: Node = { ...n, data: { ...n.data, ...data } } // When properties change, clear stored height so the node auto-sizes to fit new content if ('properties' in data && n.data.type !== 'proxmox' && n.data.type !== 'groupRect' && n.data.type !== 'group') { updated.height = undefined } if ('parent_id' in data) { const newParentId = data.parent_id ?? undefined if (!newParentId && n.parentId) { // Detaching from a container: convert position back to absolute canvas coords const parent = state.nodes.find((p) => p.id === n.parentId) if (parent) { updated.position = { x: parent.position.x + n.position.x, y: parent.position.y + n.position.y, } } updated.parentId = undefined updated.extent = undefined } else if (newParentId && newParentId !== n.parentId) { const parent = state.nodes.find((p) => p.id === newParentId) if (parent?.data.container_mode) { // Attaching to a container-mode Proxmox: nest visually updated.parentId = newParentId updated.extent = 'parent' as const // Convert absolute position to parent-relative (keep node visible inside) updated.position = { x: Math.max(10, n.position.x - parent.position.x), y: Math.max(10, n.position.y - parent.position.y), } } } } return updated }) // React Flow requires parent nodes to precede their children in the array if ('parent_id' in data) { const parents = nodes.filter((n) => !n.parentId) const children = nodes.filter((n) => !!n.parentId) nodes = [...parents, ...children] } // Remap edges when bottom_handles is reduced so no edge disappears let edges = state.edges if ('bottom_handles' in data && data.bottom_handles != null) { const currentNode = state.nodes.find((n) => n.id === id) const oldCount = currentNode?.data.bottom_handles ?? 1 const newCount = data.bottom_handles if (newCount < oldCount) { const removed = removedBottomHandleIds(oldCount, newCount) edges = state.edges.map((e) => { if (e.source === id && e.sourceHandle && removed.has(e.sourceHandle)) return { ...e, sourceHandle: 'bottom' } if (e.target === id && e.targetHandle && removed.has(e.targetHandle)) return { ...e, targetHandle: 'bottom' } return e }) } } return { nodes, edges, hasUnsavedChanges: true } }), deleteNode: (id) => set((state) => { const idsToRemove = new Set() const collect = (nodeId: string) => { idsToRemove.add(nodeId) state.nodes.filter((n) => n.parentId === nodeId).forEach((n) => collect(n.id)) } collect(id) return { nodes: state.nodes.filter((n) => !idsToRemove.has(n.id)), edges: state.edges.filter((e) => !idsToRemove.has(e.source) && !idsToRemove.has(e.target)), selectedNodeId: idsToRemove.has(state.selectedNodeId ?? '') ? null : state.selectedNodeId, hasUnsavedChanges: true, } }), updateEdge: (id, data) => set((state) => ({ edges: state.edges.map((e) => e.id === id ? { ...e, type: data.type ?? e.type, data: { ...e.data, ...data } as EdgeData } : e ), hasUnsavedChanges: true, })), reconnectEdge: (id, connection) => set((state) => ({ edges: state.edges.map((e) => e.id === id ? { ...e, source: connection.source ?? e.source, target: connection.target ?? e.target, sourceHandle: normalizeHandle(connection.sourceHandle), targetHandle: normalizeHandle(connection.targetHandle), } : e ), past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }], future: [], hasUnsavedChanges: true, })), deleteEdge: (id) => set((state) => ({ edges: state.edges.filter((e) => e.id !== id), hasUnsavedChanges: true, })), setProxmoxContainerMode: (proxmoxId, enabled) => set((state) => { const parentNode = state.nodes.find((n) => n.id === proxmoxId) let nodes = state.nodes.map((n) => { if (n.id === proxmoxId) { const withMode = { ...n, data: { ...n.data, container_mode: enabled } } return enabled ? { ...withMode, width: n.width ?? 300, height: n.height ?? 200 } : { ...withMode, width: undefined, height: undefined } } if (n.data.parent_id === proxmoxId) { if (enabled && parentNode) { return { ...n, parentId: proxmoxId, extent: 'parent' as const, position: { x: Math.max(10, n.position.x - parentNode.position.x), y: Math.max(10, n.position.y - parentNode.position.y), }, } } if (!enabled && parentNode) { return { ...n, parentId: undefined, extent: undefined, position: { x: parentNode.position.x + n.position.x, y: parentNode.position.y + n.position.y, }, } } return enabled ? { ...n, parentId: proxmoxId, extent: 'parent' as const } : { ...n, parentId: undefined, extent: undefined } } return n }) if (enabled) { const parents = nodes.filter((n) => !n.parentId) const children = nodes.filter((n) => !!n.parentId) nodes = [...parents, ...children] } return { nodes, hasUnsavedChanges: true } }), setNodeZIndex: (id, zIndex) => set((state) => ({ nodes: state.nodes.map((n) => n.id === id ? { ...n, zIndex } : n), hasUnsavedChanges: true, })), setEditingGroupRectId: (id) => set({ editingGroupRectId: id }), setEditingTextId: (id) => set({ editingTextId: id }), createGroup: (nodeIds, name) => set((state) => { const PADDING_H = 24 const PADDING_TOP = 48 const PADDING_BOTTOM = 24 const targets = state.nodes.filter((n) => nodeIds.includes(n.id)) if (targets.length === 0) return state // Bounding box in absolute coordinates let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity for (const n of targets) { const w = n.width ?? 200 const h = n.height ?? 80 minX = Math.min(minX, n.position.x) minY = Math.min(minY, n.position.y) maxX = Math.max(maxX, n.position.x + w) maxY = Math.max(maxY, n.position.y + h) } const groupX = minX - PADDING_H const groupY = minY - PADDING_TOP const groupW = maxX - minX + PADDING_H * 2 const groupH = maxY - minY + PADDING_TOP + PADDING_BOTTOM const groupId = generateUUID() const groupNode: Node = { id: groupId, type: 'group', position: { x: groupX, y: groupY }, width: groupW, height: groupH, data: { label: name, type: 'group', status: 'unknown', services: [], custom_colors: { show_border: true }, }, selected: false, } // Convert children to relative positions and assign parentId const updatedNodes = state.nodes.map((n) => { if (!nodeIds.includes(n.id)) return n return { ...n, parentId: groupId, extent: 'parent' as const, position: { x: n.position.x - groupX, y: n.position.y - groupY, }, selected: false, data: { ...n.data, parent_id: groupId }, } }) // Group node must come before its children const withoutTargets = updatedNodes.filter((n) => !nodeIds.includes(n.id)) const children = updatedNodes.filter((n) => nodeIds.includes(n.id)) const nodes = [...withoutTargets, groupNode, ...children] return { nodes, selectedNodeIds: [], selectedNodeId: null, hasUnsavedChanges: true, past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }], future: [], } }), ungroup: (groupId) => set((state) => { const group = state.nodes.find((n) => n.id === groupId) if (!group) return state const groupAbsX = group.position.x const groupAbsY = group.position.y const nodes = state.nodes .filter((n) => n.id !== groupId) .map((n) => { if (n.parentId !== groupId) return n return { ...n, parentId: undefined, extent: undefined, position: { x: n.position.x + groupAbsX, y: n.position.y + groupAbsY, }, data: { ...n.data, parent_id: undefined }, } }) return { nodes, selectedNodeId: null, selectedNodeIds: [], hasUnsavedChanges: true, past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }], future: [], } }), markSaved: () => set({ hasUnsavedChanges: false }), markUnsaved: () => set({ hasUnsavedChanges: true }), notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }), toggleHideIp: () => set((s) => ({ hideIp: !s.hideIp })), loadCanvas: (nodes, edges) => { // React Flow requires parents before children in the array const parents = nodes.filter((n) => !n.parentId) const children = nodes.filter((n) => !!n.parentId) set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [], fitViewPending: true }) }, clearFitViewPending: () => set({ fitViewPending: false }), applyTypeNodeStyle: (nodeType, style) => set((state) => ({ nodes: state.nodes.map((n) => { if (n.data.type !== nodeType) return n return { ...n, width: style.width > 0 ? style.width : n.width, height: style.height > 0 ? style.height : n.height, data: { ...n.data, custom_colors: { ...n.data.custom_colors, border: applyOpacity(style.borderColor, style.borderOpacity), background: applyOpacity(style.bgColor, style.bgOpacity), icon: applyOpacity(style.iconColor, style.iconOpacity), }, }, } }), hasUnsavedChanges: true, })), applyTypeEdgeStyle: (edgeType, style) => set((state) => ({ edges: state.edges.map((e) => { if ((e.data?.type ?? 'ethernet') !== edgeType) return e return { ...e, data: { ...e.data, type: edgeType, custom_color: applyOpacity(style.color, style.opacity), path_style: style.pathStyle, animated: style.animated, } as EdgeData, } }), hasUnsavedChanges: true, })), applyAllCustomStyles: (def) => set((state) => { const nodes = state.nodes.map((n) => { const style = def.nodes[n.data.type] if (!style) return n return { ...n, width: style.width > 0 ? style.width : n.width, height: style.height > 0 ? style.height : n.height, data: { ...n.data, custom_colors: { ...n.data.custom_colors, border: applyOpacity(style.borderColor, style.borderOpacity), background: applyOpacity(style.bgColor, style.bgOpacity), icon: applyOpacity(style.iconColor, style.iconOpacity), }, }, } }) const edges = state.edges.map((e) => { const edgeType = (e.data?.type ?? 'ethernet') as EdgeType const style = def.edges[edgeType] if (!style) return e return { ...e, data: { ...e.data, type: edgeType, custom_color: applyOpacity(style.color, style.opacity), path_style: style.pathStyle, animated: style.animated, } as EdgeData, } }) return { nodes, edges, hasUnsavedChanges: true } }), }))