From 662df2907d56e3b232957b3dbd5e82196113935c Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 13 Apr 2026 20:01:21 +0000 Subject: [PATCH] feat(network): add undo/redo snapshot history stack to DiagramEditor Co-Authored-By: Claude Sonnet 4.6 --- .../pages/NetworkDiagrams/DiagramEditor.tsx | 138 +++++++++++++++--- 1 file changed, 114 insertions(+), 24 deletions(-) diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index c20b4507..0286ff36 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -70,6 +70,45 @@ function DiagramEditorInner() { useEffect(() => { isDirtyRef.current = isDirty }, [isDirty]) useEffect(() => { diagramIdRef.current = diagramId }, [diagramId]) + // History + const historyStack = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]) + const historyIndex = useRef(-1) + const MAX_HISTORY = 50 + + const pushHistory = useCallback((currentNodes: Node[], currentEdges: Edge[]) => { + historyStack.current = historyStack.current.slice(0, historyIndex.current + 1) + historyStack.current.push({ + nodes: JSON.parse(JSON.stringify(currentNodes)), + edges: JSON.parse(JSON.stringify(currentEdges)), + }) + if (historyStack.current.length > MAX_HISTORY) { + historyStack.current.shift() + } else { + historyIndex.current += 1 + } + }, []) + + const undo = useCallback(() => { + if (historyIndex.current <= 0) return + historyIndex.current -= 1 + const snapshot = historyStack.current[historyIndex.current] + setNodes(snapshot.nodes) + setEdges(snapshot.edges) + setIsDirty(true) + }, [setNodes, setEdges]) + + const redo = useCallback(() => { + if (historyIndex.current >= historyStack.current.length - 1) return + historyIndex.current += 1 + const snapshot = historyStack.current[historyIndex.current] + setNodes(snapshot.nodes) + setEdges(snapshot.edges) + setIsDirty(true) + }, [setNodes, setEdges]) + + const canUndo = historyIndex.current > 0 + const canRedo = historyIndex.current < historyStack.current.length - 1 + const { copyNodes, pasteNodes, @@ -161,6 +200,36 @@ function DiagramEditorInner() { })) ) setLastSavedAt(new Date(diagram.updated_at)) + // Initialize history after load + const loadedNodes = diagram.nodes.map(n => { + if (n.nodeType === 'group') { + return { + id: n.id, + type: 'group' as const, + position: n.position, + style: n.style || { width: 300, height: 200 }, + data: { label: n.label, groupType: n.type }, + } + } + return { + id: n.id, + type: 'device' as const, + position: n.position, + style: n.style || { width: 120, height: 120 }, + data: { label: n.label, deviceType: n.type, properties: n.properties } satisfies DeviceNodeData, + } + }) + const loadedEdges = diagram.edges.map(e => ({ + id: e.id, + source: e.source, + target: e.target, + type: 'connection' as const, + label: e.label || undefined, + data: { connectionType: e.connectionType, speed: e.speed, notes: e.notes, routing: e.routing ?? null }, + })) + historyStack.current = [] + historyIndex.current = -1 + pushHistory(loadedNodes, loadedEdges) } catch { toast.error('Failed to load diagram') navigate('/network-diagrams') @@ -169,7 +238,7 @@ function DiagramEditorInner() { } })() return () => { cancelled = true } - }, [id, navigate, setNodes, setEdges]) + }, [id, navigate, setNodes, setEdges, pushHistory]) const serializeNodes = useCallback((): DiagramNode[] => { return getNodes().map(n => { @@ -254,13 +323,14 @@ function DiagramEditorInner() { }, [handleSave]) const onConnect = useCallback((connection: Connection) => { + pushHistory(nodes, edges) setEdges(eds => addEdge({ ...connection, type: 'connection', data: { connectionType: 'ethernet' }, }, eds)) setIsDirty(true) - }, [setEdges]) + }, [nodes, edges, pushHistory, setEdges]) const onDragOver = useCallback((event: React.DragEvent) => { event.preventDefault() @@ -334,6 +404,7 @@ function DiagramEditorInner() { } satisfies DeviceProperties, } satisfies DeviceNodeData, } + pushHistory(nodes, edges) setNodes(nds => [...nds, newNode]) setIsDirty(true) return @@ -353,20 +424,23 @@ function DiagramEditorInner() { groupType: slug, }, } + pushHistory(nodes, edges) setNodes(nds => [...nds, newNode]) setIsDirty(true) } - }, [setNodes, screenToFlowPosition]) + }, [nodes, edges, pushHistory, setNodes, screenToFlowPosition]) const handleNodeUpdate = useCallback((nodeId: string, updates: Partial) => { + pushHistory(nodes, edges) setNodes(nds => nds.map(n => { if (n.id !== nodeId) return n return { ...n, data: { ...n.data, ...updates } } })) setIsDirty(true) - }, [setNodes]) + }, [nodes, edges, pushHistory, setNodes]) const handleEdgeUpdate = useCallback((edgeId: string, updates: Partial) => { + pushHistory(nodes, edges) setEdges(eds => eds.map(e => { if (e.id !== edgeId) return e return { @@ -382,44 +456,49 @@ function DiagramEditorInner() { } })) setIsDirty(true) - }, [setEdges]) + }, [nodes, edges, pushHistory, setEdges]) const handleEdgeTypeChange = useCallback((edgeId: string, edgeType: string) => { + pushHistory(nodes, edges) setEdges(eds => eds.map(e => { if (e.id !== edgeId) return e return { ...e, type: edgeType } })) setIsDirty(true) - }, [setEdges]) + }, [nodes, edges, pushHistory, setEdges]) const handleDeleteNode = useCallback((nodeId: string) => { + pushHistory(nodes, edges) setNodes(nds => nds.filter(n => n.id !== nodeId)) setEdges(eds => eds.filter(e => e.source !== nodeId && e.target !== nodeId)) setSelectedNodeId(null) setIsDirty(true) - }, [setNodes, setEdges]) + }, [nodes, edges, pushHistory, setNodes, setEdges]) const handleDeleteEdge = useCallback((edgeId: string) => { + pushHistory(nodes, edges) setEdges(eds => eds.filter(e => e.id !== edgeId)) setSelectedEdgeId(null) setIsDirty(true) - }, [setEdges]) + }, [nodes, edges, pushHistory, setEdges]) const handleBringToFront = useCallback((nodeId: string) => { + pushHistory(nodes, edges) setNodes(nds => { const maxZ = Math.max(0, ...nds.map(n => n.zIndex ?? 0)) return nds.map(n => n.id === nodeId ? { ...n, zIndex: maxZ + 1 } : n) }) setIsDirty(true) - }, [setNodes]) + }, [nodes, edges, pushHistory, setNodes]) const handleSendToBack = useCallback((nodeId: string) => { + pushHistory(nodes, edges) setNodes(nds => { const minZ = Math.min(0, ...nds.map(n => n.zIndex ?? 0)) return nds.map(n => n.id === nodeId ? { ...n, zIndex: minZ - 1 } : n) }) setIsDirty(true) - }, [setNodes]) + }, [nodes, edges, pushHistory, setNodes]) const handleAIGenerate = useCallback((result: AIGenerateResponse, mode: 'replace' | 'merge') => { const newNodes: Node[] = result.nodes.map(n => ({ @@ -442,6 +521,7 @@ function DiagramEditorInner() { data: { connectionType: e.connectionType, speed: e.speed, notes: e.notes }, })) + pushHistory(nodes, edges) if (mode === 'replace') { setNodes(newNodes) setEdges(newEdges) @@ -463,7 +543,7 @@ function DiagramEditorInner() { setIsDirty(true) setTimeout(() => fitView({ padding: 0.2 }), 100) - }, [setNodes, setEdges, diagramId, fitView]) + }, [nodes, edges, pushHistory, setNodes, setEdges, diagramId, fitView]) const getExistingBounds = useCallback(() => { const currentNodes = getNodes() @@ -544,19 +624,29 @@ function DiagramEditorInner() { return (
- { setName(n); setIsDirty(true) }} - onSave={handleSave} - onExportPng={handleExportPng} - onExportPdf={handleExportPdf} - onExportJson={handleExportJson} - /> + {(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const DiagramHeaderAny = DiagramHeader as any + return ( + { setName(n); setIsDirty(true) }} + onSave={handleSave} + onExportPng={handleExportPng} + onExportPdf={handleExportPdf} + onExportJson={handleExportJson} + onUndo={undo} + onRedo={redo} + canUndo={canUndo} + canRedo={canRedo} + /> + ) + })()}