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(() => { 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">
|
||||||
|
|||||||
Reference in New Issue
Block a user