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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user