feat(network): add undo/redo snapshot history stack to DiagramEditor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-13 20:01:21 +00:00
parent b9547e6ce1
commit 662df2907d

View File

@@ -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<number>(-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<DeviceNodeData>) => {
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<DiagramEdge>) => {
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 (
<div className="flex h-full flex-col">
<DiagramHeader
name={name}
clientName={clientName}
isDirty={isDirty}
isSaving={isSaving}
lastSavedAt={lastSavedAt}
diagramId={diagramId}
onNameChange={n => { 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 (
<DiagramHeaderAny
name={name}
clientName={clientName}
isDirty={isDirty}
isSaving={isSaving}
lastSavedAt={lastSavedAt}
diagramId={diagramId}
onNameChange={(n: string) => { setName(n); setIsDirty(true) }}
onSave={handleSave}
onExportPng={handleExportPng}
onExportPdf={handleExportPdf}
onExportJson={handleExportJson}
onUndo={undo}
onRedo={redo}
canUndo={canUndo}
canRedo={canRedo}
/>
)
})()}
<div className="flex flex-1 min-h-0">
<DeviceToolbar deviceTypes={deviceTypes} onDeviceTypesChange={loadDeviceTypes} />
<div className="flex flex-1 flex-col min-h-0">