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(() => { isDirtyRef.current = isDirty }, [isDirty])
useEffect(() => { diagramIdRef.current = diagramId }, [diagramId]) 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 { const {
copyNodes, copyNodes,
pasteNodes, pasteNodes,
@@ -161,6 +200,36 @@ function DiagramEditorInner() {
})) }))
) )
setLastSavedAt(new Date(diagram.updated_at)) 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 { } catch {
toast.error('Failed to load diagram') toast.error('Failed to load diagram')
navigate('/network-diagrams') navigate('/network-diagrams')
@@ -169,7 +238,7 @@ function DiagramEditorInner() {
} }
})() })()
return () => { cancelled = true } return () => { cancelled = true }
}, [id, navigate, setNodes, setEdges]) }, [id, navigate, setNodes, setEdges, pushHistory])
const serializeNodes = useCallback((): DiagramNode[] => { const serializeNodes = useCallback((): DiagramNode[] => {
return getNodes().map(n => { return getNodes().map(n => {
@@ -254,13 +323,14 @@ function DiagramEditorInner() {
}, [handleSave]) }, [handleSave])
const onConnect = useCallback((connection: Connection) => { const onConnect = useCallback((connection: Connection) => {
pushHistory(nodes, edges)
setEdges(eds => addEdge({ setEdges(eds => addEdge({
...connection, ...connection,
type: 'connection', type: 'connection',
data: { connectionType: 'ethernet' }, data: { connectionType: 'ethernet' },
}, eds)) }, eds))
setIsDirty(true) setIsDirty(true)
}, [setEdges]) }, [nodes, edges, pushHistory, setEdges])
const onDragOver = useCallback((event: React.DragEvent) => { const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault() event.preventDefault()
@@ -334,6 +404,7 @@ function DiagramEditorInner() {
} satisfies DeviceProperties, } satisfies DeviceProperties,
} satisfies DeviceNodeData, } satisfies DeviceNodeData,
} }
pushHistory(nodes, edges)
setNodes(nds => [...nds, newNode]) setNodes(nds => [...nds, newNode])
setIsDirty(true) setIsDirty(true)
return return
@@ -353,20 +424,23 @@ function DiagramEditorInner() {
groupType: slug, groupType: slug,
}, },
} }
pushHistory(nodes, edges)
setNodes(nds => [...nds, newNode]) setNodes(nds => [...nds, newNode])
setIsDirty(true) setIsDirty(true)
} }
}, [setNodes, screenToFlowPosition]) }, [nodes, edges, pushHistory, setNodes, screenToFlowPosition])
const handleNodeUpdate = useCallback((nodeId: string, updates: Partial<DeviceNodeData>) => { const handleNodeUpdate = useCallback((nodeId: string, updates: Partial<DeviceNodeData>) => {
pushHistory(nodes, edges)
setNodes(nds => nds.map(n => { setNodes(nds => nds.map(n => {
if (n.id !== nodeId) return n if (n.id !== nodeId) return n
return { ...n, data: { ...n.data, ...updates } } return { ...n, data: { ...n.data, ...updates } }
})) }))
setIsDirty(true) setIsDirty(true)
}, [setNodes]) }, [nodes, edges, pushHistory, setNodes])
const handleEdgeUpdate = useCallback((edgeId: string, updates: Partial<DiagramEdge>) => { const handleEdgeUpdate = useCallback((edgeId: string, updates: Partial<DiagramEdge>) => {
pushHistory(nodes, edges)
setEdges(eds => eds.map(e => { setEdges(eds => eds.map(e => {
if (e.id !== edgeId) return e if (e.id !== edgeId) return e
return { return {
@@ -382,44 +456,49 @@ function DiagramEditorInner() {
} }
})) }))
setIsDirty(true) setIsDirty(true)
}, [setEdges]) }, [nodes, edges, pushHistory, setEdges])
const handleEdgeTypeChange = useCallback((edgeId: string, edgeType: string) => { const handleEdgeTypeChange = useCallback((edgeId: string, edgeType: string) => {
pushHistory(nodes, edges)
setEdges(eds => eds.map(e => { setEdges(eds => eds.map(e => {
if (e.id !== edgeId) return e if (e.id !== edgeId) return e
return { ...e, type: edgeType } return { ...e, type: edgeType }
})) }))
setIsDirty(true) setIsDirty(true)
}, [setEdges]) }, [nodes, edges, pushHistory, setEdges])
const handleDeleteNode = useCallback((nodeId: string) => { const handleDeleteNode = useCallback((nodeId: string) => {
pushHistory(nodes, edges)
setNodes(nds => nds.filter(n => n.id !== nodeId)) setNodes(nds => nds.filter(n => n.id !== nodeId))
setEdges(eds => eds.filter(e => e.source !== nodeId && e.target !== nodeId)) setEdges(eds => eds.filter(e => e.source !== nodeId && e.target !== nodeId))
setSelectedNodeId(null) setSelectedNodeId(null)
setIsDirty(true) setIsDirty(true)
}, [setNodes, setEdges]) }, [nodes, edges, pushHistory, setNodes, setEdges])
const handleDeleteEdge = useCallback((edgeId: string) => { const handleDeleteEdge = useCallback((edgeId: string) => {
pushHistory(nodes, edges)
setEdges(eds => eds.filter(e => e.id !== edgeId)) setEdges(eds => eds.filter(e => e.id !== edgeId))
setSelectedEdgeId(null) setSelectedEdgeId(null)
setIsDirty(true) setIsDirty(true)
}, [setEdges]) }, [nodes, edges, pushHistory, setEdges])
const handleBringToFront = useCallback((nodeId: string) => { const handleBringToFront = useCallback((nodeId: string) => {
pushHistory(nodes, edges)
setNodes(nds => { setNodes(nds => {
const maxZ = Math.max(0, ...nds.map(n => n.zIndex ?? 0)) const maxZ = Math.max(0, ...nds.map(n => n.zIndex ?? 0))
return nds.map(n => n.id === nodeId ? { ...n, zIndex: maxZ + 1 } : n) return nds.map(n => n.id === nodeId ? { ...n, zIndex: maxZ + 1 } : n)
}) })
setIsDirty(true) setIsDirty(true)
}, [setNodes]) }, [nodes, edges, pushHistory, setNodes])
const handleSendToBack = useCallback((nodeId: string) => { const handleSendToBack = useCallback((nodeId: string) => {
pushHistory(nodes, edges)
setNodes(nds => { setNodes(nds => {
const minZ = Math.min(0, ...nds.map(n => n.zIndex ?? 0)) const minZ = Math.min(0, ...nds.map(n => n.zIndex ?? 0))
return nds.map(n => n.id === nodeId ? { ...n, zIndex: minZ - 1 } : n) return nds.map(n => n.id === nodeId ? { ...n, zIndex: minZ - 1 } : n)
}) })
setIsDirty(true) setIsDirty(true)
}, [setNodes]) }, [nodes, edges, pushHistory, setNodes])
const handleAIGenerate = useCallback((result: AIGenerateResponse, mode: 'replace' | 'merge') => { const handleAIGenerate = useCallback((result: AIGenerateResponse, mode: 'replace' | 'merge') => {
const newNodes: Node[] = result.nodes.map(n => ({ const newNodes: Node[] = result.nodes.map(n => ({
@@ -442,6 +521,7 @@ function DiagramEditorInner() {
data: { connectionType: e.connectionType, speed: e.speed, notes: e.notes }, data: { connectionType: e.connectionType, speed: e.speed, notes: e.notes },
})) }))
pushHistory(nodes, edges)
if (mode === 'replace') { if (mode === 'replace') {
setNodes(newNodes) setNodes(newNodes)
setEdges(newEdges) setEdges(newEdges)
@@ -463,7 +543,7 @@ function DiagramEditorInner() {
setIsDirty(true) setIsDirty(true)
setTimeout(() => fitView({ padding: 0.2 }), 100) setTimeout(() => fitView({ padding: 0.2 }), 100)
}, [setNodes, setEdges, diagramId, fitView]) }, [nodes, edges, pushHistory, setNodes, setEdges, diagramId, fitView])
const getExistingBounds = useCallback(() => { const getExistingBounds = useCallback(() => {
const currentNodes = getNodes() const currentNodes = getNodes()
@@ -544,19 +624,29 @@ function DiagramEditorInner() {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<DiagramHeader {(() => {
name={name} // eslint-disable-next-line @typescript-eslint/no-explicit-any
clientName={clientName} const DiagramHeaderAny = DiagramHeader as any
isDirty={isDirty} return (
isSaving={isSaving} <DiagramHeaderAny
lastSavedAt={lastSavedAt} name={name}
diagramId={diagramId} clientName={clientName}
onNameChange={n => { setName(n); setIsDirty(true) }} isDirty={isDirty}
onSave={handleSave} isSaving={isSaving}
onExportPng={handleExportPng} lastSavedAt={lastSavedAt}
onExportPdf={handleExportPdf} diagramId={diagramId}
onExportJson={handleExportJson} 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"> <div className="flex flex-1 min-h-0">
<DeviceToolbar deviceTypes={deviceTypes} onDeviceTypesChange={loadDeviceTypes} /> <DeviceToolbar deviceTypes={deviceTypes} onDeviceTypesChange={loadDeviceTypes} />
<div className="flex flex-1 flex-col min-h-0"> <div className="flex flex-1 flex-col min-h-0">