Files
resolutionflow/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx
2026-04-14 03:28:07 +00:00

967 lines
34 KiB
TypeScript

import { useState, useCallback, useEffect, useRef, useReducer } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
ReactFlowProvider,
useNodesState,
useEdgesState,
addEdge,
reconnectEdge,
useReactFlow,
getNodesBounds,
getViewportForBounds,
type Node,
type Edge,
type Connection,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { NetworkCanvas } from '@/components/network/NetworkCanvas'
import { ContextMenu, getNodeMenuActions, getCanvasMenuActions } from '@/components/network/ContextMenu'
import { useCanvasShortcuts } from '@/components/network/hooks/useCanvasShortcuts'
import { useDiagramCommands } from '@/components/network/hooks/useDiagramCommands'
import { DiagramHeader, type InteractionMode } from '@/components/network/DiagramHeader'
import { DeviceToolbar } from '@/components/network/panels/DeviceToolbar'
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel'
import { CanvasEmptyPrompt } from '@/components/network/CanvasEmptyPrompt'
import { networkDiagramsApi, deviceTypesApi } from '@/api'
import { toast } from '@/lib/toast'
import { exportToDrawio } from '@/lib/drawio-export'
import { parseDrawioXml } from '@/lib/drawio-import'
import type { DeviceTypeResponse, DeviceProperties, AIGenerateResponse, DiagramEdge, DiagramNode } from '@/types'
import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode'
function normalizeZOrder(nodes: Node[]): Node[] {
const sorted = [...nodes].sort((a, b) => ((a.zIndex ?? 0) - (b.zIndex ?? 0)))
return sorted.map((n, i) => ({ ...n, zIndex: i + 1 }))
}
type ContextMenuState = {
type: 'node' | 'canvas'
position: { x: number; y: number }
nodeId?: string
} | null
function DiagramEditorInner() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { getNodes, fitView, screenToFlowPosition } = useReactFlow()
const [diagramId, setDiagramId] = useState<string | null>(id || null)
const [name, setName] = useState('Untitled Diagram')
const [clientName, setClientName] = useState<string | null>(null)
const [assetName, setAssetName] = useState<string | null>(null)
const [description, setDescription] = useState<string | null>(null)
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([])
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([])
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
const selectedNode = selectedNodeId ? nodes.find(n => n.id === selectedNodeId) ?? null : null
const selectedEdge = selectedEdgeId ? edges.find(e => e.id === selectedEdgeId) ?? null : null
const [isDirty, setIsDirty] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
const isDirtyRef = useRef(false)
const diagramIdRef = useRef<string | null>(id || null)
const [deviceTypes, setDeviceTypes] = useState<DeviceTypeResponse[]>([])
const [loading, setLoading] = useState(!!id)
const [isDragOver, setIsDragOver] = useState(false)
const [interactionMode, setInteractionMode] = useState<InteractionMode>('select')
const canvasRef = useRef<HTMLDivElement | null>(null)
const drawioImportRef = useRef<HTMLInputElement>(null)
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>(null)
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 [, forceHistoryUpdate] = useReducer((x: number) => x + 1, 0)
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
}
forceHistoryUpdate()
}, [forceHistoryUpdate])
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)
forceHistoryUpdate()
}, [setNodes, setEdges, forceHistoryUpdate])
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)
forceHistoryUpdate()
}, [setNodes, setEdges, forceHistoryUpdate])
const canUndo = historyIndex.current > 0
const canRedo = historyIndex.current < historyStack.current.length - 1
const diagramCommands = useDiagramCommands({
nodes,
edges,
pushHistory,
setNodes,
})
const onNudge = useCallback((dx: number, dy: number) => {
const selected = nodes.filter(n => n.selected)
if (selected.length === 0) return
pushHistory(nodes, edges)
setNodes(prev => prev.map(n =>
n.selected
? { ...n, position: { x: n.position.x + dx, y: n.position.y + dy } }
: n
))
}, [nodes, edges, pushHistory, setNodes])
const {
copyNodes,
pasteNodes,
duplicateNodes,
selectAll,
deleteSelected,
hasClipboard,
} = useCanvasShortcuts({
nodes,
edges,
setNodes,
setEdges,
setIsDirty: (v: boolean) => setIsDirty(v),
canvasRef,
onUndo: undo,
onRedo: redo,
onNudge,
onSetMode: setInteractionMode,
})
const handleNodesChange: typeof onNodesChange = useCallback((changes) => {
onNodesChange(changes)
const hasRealChange = changes.some(c => c.type !== 'select')
if (hasRealChange) setIsDirty(true)
}, [onNodesChange])
const handleEdgesChange: typeof onEdgesChange = useCallback((changes) => {
onEdgesChange(changes)
const hasRealChange = changes.some(c => c.type !== 'select')
if (hasRealChange) setIsDirty(true)
}, [onEdgesChange])
const loadDeviceTypes = useCallback(async () => {
try {
const types = await deviceTypesApi.list()
setDeviceTypes(types)
} catch { /* ignore */ }
}, [])
useEffect(() => { loadDeviceTypes() }, [loadDeviceTypes])
useEffect(() => {
if (!id) return
let cancelled = false
;(async () => {
try {
const diagram = await networkDiagramsApi.get(id)
if (cancelled) return
setName(diagram.name)
setClientName(diagram.client_name)
setAssetName(diagram.asset_name)
setDescription(diagram.description)
setNodes(
diagram.nodes.map(n => {
if (n.nodeType === 'group') {
return {
id: n.id,
type: 'group',
position: n.position,
style: n.style || { width: 300, height: 200 },
data: {
label: n.label,
groupType: n.type,
},
}
}
return {
id: n.id,
type: 'device',
position: n.position,
style: n.style || { width: 120, height: 120 },
...(n.parentId ? { parentId: n.parentId, extent: 'parent' as const } : {}),
data: {
label: n.label,
deviceType: n.type,
properties: n.properties,
} satisfies DeviceNodeData,
}
})
)
setEdges(
diagram.edges.map(e => ({
id: e.id,
source: e.source,
target: e.target,
type: 'connection',
label: e.label || undefined,
data: {
connectionType: e.connectionType,
speed: e.speed,
notes: e.notes,
routing: e.routing ?? null,
},
}))
)
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 },
...(n.parentId ? { parentId: n.parentId, extent: 'parent' as const } : {}),
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')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => { cancelled = true }
}, [id, navigate, setNodes, setEdges, pushHistory])
const serializeNodes = useCallback((): DiagramNode[] => {
return getNodes().map(n => {
if (n.type === 'group') {
const data = n.data as Record<string, unknown>
const width = typeof n.style?.width === 'number' ? n.style.width : (n.measured?.width ?? 300)
const height = typeof n.style?.height === 'number' ? n.style.height : (n.measured?.height ?? 200)
return {
id: n.id,
type: (data.groupType as string) || 'subnet',
label: (data.label as string) || 'Group',
position: n.position,
properties: {} as DeviceProperties,
nodeType: 'group',
style: { width, height },
}
}
const data = n.data as unknown as DeviceNodeData
const dw = typeof n.style?.width === 'number' ? n.style.width : (n.measured?.width ?? 120)
const dh = typeof n.style?.height === 'number' ? n.style.height : (n.measured?.height ?? 120)
return {
id: n.id,
type: data.deviceType,
label: data.label,
position: n.position,
properties: data.properties,
style: { width: dw, height: dh },
...(n.parentId ? { parentId: n.parentId } : {}),
}
})
}, [getNodes])
const serializeEdges = useCallback((): DiagramEdge[] => {
return edges.map(e => {
const d = (e.data as Record<string, unknown>) || {}
return {
id: e.id,
source: e.source,
target: e.target,
label: (e.label as string) || null,
connectionType: d.connectionType as string || 'ethernet',
speed: d.speed as string || null,
notes: d.notes as string || null,
routing: (d.routing as DiagramEdge['routing']) || null,
}
})
}, [edges])
const handleSave = useCallback(async () => {
setIsSaving(true)
try {
const payload = {
name,
client_name: clientName,
asset_name: assetName,
description,
nodes: serializeNodes(),
edges: serializeEdges(),
}
let savedId: string | null = diagramIdRef.current
if (diagramIdRef.current) {
await networkDiagramsApi.update(diagramIdRef.current, payload)
} else {
const created = await networkDiagramsApi.create(payload)
savedId = created.id
setDiagramId(created.id)
navigate(`/network-diagrams/${created.id}`, { replace: true })
}
setIsDirty(false)
setLastSavedAt(new Date())
// Generate thumbnail in the background — don't block save UX on failure
if (savedId && nodes.length > 0) {
try {
const { toPng } = await import('html-to-image')
const THUMB_W = 480
const THUMB_H = 300
const bounds = getNodesBounds(nodes)
const viewport = getViewportForBounds(bounds, THUMB_W, THUMB_H, 0.5, 2, 0.1)
const flowEl = document.querySelector('.react-flow__viewport') as HTMLElement | null
if (flowEl) {
const dataUrl = await toPng(flowEl, {
backgroundColor: '#16181f',
width: THUMB_W,
height: THUMB_H,
style: {
width: `${THUMB_W}px`,
height: `${THUMB_H}px`,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
transformOrigin: 'top left',
},
})
await networkDiagramsApi.uploadThumbnail(savedId, dataUrl)
}
} catch {
// Thumbnail failure is silent — doesn't affect save success
}
}
} catch {
toast.error('Failed to save diagram')
} finally {
setIsSaving(false)
}
}, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate, nodes])
useEffect(() => {
const interval = setInterval(() => {
if (isDirtyRef.current && diagramIdRef.current) {
handleSave()
}
}, 30_000)
return () => clearInterval(interval)
}, [handleSave])
const onConnect = useCallback((connection: Connection) => {
pushHistory(nodes, edges)
setEdges(eds => addEdge({
...connection,
type: 'connection',
data: { connectionType: 'ethernet' },
}, eds))
setIsDirty(true)
}, [nodes, edges, pushHistory, setEdges])
const onReconnect = useCallback((oldEdge: Edge, newConnection: Connection) => {
pushHistory(nodes, edges)
setEdges(eds => reconnectEdge(oldEdge, newConnection, eds))
setSelectedEdgeId(oldEdge.id)
setIsDirty(true)
}, [nodes, edges, pushHistory, setEdges])
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
setIsDragOver(true)
}, [])
const onDragLeave = useCallback((event: React.DragEvent) => {
const relatedTarget = event.relatedTarget as HTMLElement | null
if (relatedTarget && (event.currentTarget as HTMLElement).contains(relatedTarget)) return
setIsDragOver(false)
}, [])
const handleNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => {
event.preventDefault()
const currentNodes = getNodes()
const isInSelection = currentNodes.find(n => n.id === node.id)?.selected
if (!isInSelection) {
setNodes(nds => nds.map(n => ({ ...n, selected: n.id === node.id })))
setSelectedNodeId(node.id)
setSelectedEdgeId(null)
}
setContextMenu({
type: 'node',
position: { x: event.clientX, y: event.clientY },
nodeId: node.id,
})
}, [getNodes, setNodes, setSelectedNodeId, setSelectedEdgeId])
const handlePaneContextMenu = useCallback((event: MouseEvent | React.MouseEvent) => {
event.preventDefault()
// Group nodes pass pointer events through to children, so right-clicking a group
// may fire onPaneContextMenu instead of onNodeContextMenu. If nodes are selected,
// show the node context menu so group/align/ungroup options are accessible.
const selected = getNodes().filter(n => n.selected)
if (selected.length > 0) {
setContextMenu({
type: 'node',
position: { x: event.clientX, y: event.clientY },
})
} else {
setContextMenu({
type: 'canvas',
position: { x: event.clientX, y: event.clientY },
})
}
}, [getNodes])
const closeContextMenu = useCallback(() => {
setContextMenu(null)
}, [])
const onDrop = useCallback((event: React.DragEvent) => {
event.preventDefault()
setIsDragOver(false)
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY })
// Handle device drops
const deviceRaw = event.dataTransfer.getData('application/reactflow-device')
if (deviceRaw) {
const { slug, label, category } = JSON.parse(deviceRaw) as { slug: string; label: string; category: string }
const newNode: Node = {
id: `device-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
type: 'device',
position,
style: { width: 120, height: 120 },
data: {
label,
deviceType: slug,
category,
properties: {
hostname: null,
ip: null,
subnet: null,
vendor: null,
model: null,
role: null,
vlan: null,
notes: null,
status: 'unknown',
} satisfies DeviceProperties,
} satisfies DeviceNodeData,
}
pushHistory(nodes, edges)
setNodes(nds => [...nds, newNode])
setIsDirty(true)
return
}
// Handle group drops
const groupRaw = event.dataTransfer.getData('application/reactflow-group')
if (groupRaw) {
const { slug, label } = JSON.parse(groupRaw) as { slug: string; label: string }
const newNode: Node = {
id: `group-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
type: 'group',
position,
style: { width: 300, height: 200 },
data: {
label,
groupType: slug,
},
}
pushHistory(nodes, edges)
setNodes(nds => [...nds, newNode])
setIsDirty(true)
}
}, [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)
}, [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 {
...e,
label: updates.label !== undefined ? (updates.label || undefined) : e.label,
data: {
...(e.data || {}),
...(updates.connectionType !== undefined ? { connectionType: updates.connectionType } : {}),
...(updates.speed !== undefined ? { speed: updates.speed } : {}),
...(updates.notes !== undefined ? { notes: updates.notes } : {}),
...(updates.routing !== undefined ? { routing: updates.routing } : {}),
},
}
}))
setIsDirty(true)
}, [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)
}, [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)
}, [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)
}, [nodes, edges, pushHistory, setEdges])
const handleBringToFront = useCallback((nodeId: string) => {
pushHistory(nodes, edges)
setNodes(prev => {
const maxZ = Math.max(0, ...prev.map(n => n.zIndex ?? 0))
return normalizeZOrder(
prev.map(n => n.id === nodeId ? { ...n, zIndex: maxZ + 1 } : n)
)
})
setIsDirty(true)
}, [nodes, edges, pushHistory, setNodes])
const handleSendToBack = useCallback((nodeId: string) => {
pushHistory(nodes, edges)
setNodes(prev => normalizeZOrder(
prev.map(n => n.id === nodeId ? { ...n, zIndex: 0 } : n)
))
setIsDirty(true)
}, [nodes, edges, pushHistory, setNodes])
const handleAIGenerate = useCallback((result: AIGenerateResponse, mode: 'replace' | 'merge') => {
const newNodes: Node[] = result.nodes.map(n => ({
id: n.id,
type: 'device',
position: n.position,
data: {
label: n.label,
deviceType: n.type,
properties: n.properties,
} satisfies DeviceNodeData,
}))
const newEdges: Edge[] = result.edges.map(e => ({
id: e.id,
source: e.source,
target: e.target,
type: 'connection',
label: e.label || undefined,
data: { connectionType: e.connectionType, speed: e.speed, notes: e.notes },
}))
pushHistory(nodes, edges)
if (mode === 'replace') {
setNodes(newNodes)
setEdges(newEdges)
} else {
setNodes(nds => [...nds, ...newNodes])
setEdges(eds => [...eds, ...newEdges])
}
if (result.suggestedName && !diagramId) {
setName(result.suggestedName)
toast.success(`Generated: ${result.suggestedName}`)
} else {
toast.success('Diagram generated')
}
if (result.notes) {
toast.info(result.notes)
}
setIsDirty(true)
setTimeout(() => fitView({ padding: 0.2 }), 100)
}, [nodes, edges, pushHistory, setNodes, setEdges, diagramId, fitView])
const getExistingBounds = useCallback(() => {
const currentNodes = getNodes()
if (currentNodes.length === 0) return null
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
for (const n of currentNodes) {
minX = Math.min(minX, n.position.x)
maxX = Math.max(maxX, n.position.x + 120)
minY = Math.min(minY, n.position.y)
maxY = Math.max(maxY, n.position.y + 80)
}
return { minX, maxX, minY, maxY }
}, [getNodes])
const handleExportPng = useCallback(async () => {
if (nodes.length === 0) {
toast.warning('Add some devices to the diagram before exporting')
return
}
try {
const { toPng } = await import('html-to-image')
const IMAGE_WIDTH = 1920
const IMAGE_HEIGHT = 1080
const bounds = getNodesBounds(nodes)
const viewport = getViewportForBounds(bounds, IMAGE_WIDTH, IMAGE_HEIGHT, 0.5, 2, 0.15)
const flowEl = document.querySelector('.react-flow__viewport') as HTMLElement | null
if (!flowEl) {
toast.error('Could not find canvas to export')
return
}
const dataUrl = await toPng(flowEl, {
backgroundColor: '#16181f',
width: IMAGE_WIDTH,
height: IMAGE_HEIGHT,
style: {
width: `${IMAGE_WIDTH}px`,
height: `${IMAGE_HEIGHT}px`,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
transformOrigin: 'top left',
},
})
const a = document.createElement('a')
a.download = `${name.replace(/[^a-zA-Z0-9-_ ]/g, '') || 'diagram'}.png`
a.href = dataUrl
a.click()
} catch {
toast.error('PNG export failed — try Print > Save as PDF instead')
}
}, [nodes, name])
const handleExportSvg = useCallback(async () => {
if (nodes.length === 0) {
toast.warning('Add some devices to the diagram before exporting')
return
}
try {
const { toSvg } = await import('html-to-image')
const IMAGE_WIDTH = 1920
const IMAGE_HEIGHT = 1080
const bounds = getNodesBounds(nodes)
const viewport = getViewportForBounds(bounds, IMAGE_WIDTH, IMAGE_HEIGHT, 0.5, 2, 0.15)
const flowEl = document.querySelector('.react-flow__viewport') as HTMLElement | null
if (!flowEl) {
toast.error('Could not find canvas to export')
return
}
const dataUrl = await toSvg(flowEl, {
backgroundColor: '#16181f',
width: IMAGE_WIDTH,
height: IMAGE_HEIGHT,
style: {
width: `${IMAGE_WIDTH}px`,
height: `${IMAGE_HEIGHT}px`,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
transformOrigin: 'top left',
},
})
const a = document.createElement('a')
a.download = `${name.replace(/[^a-zA-Z0-9-_ ]/g, '') || 'diagram'}.svg`
a.href = dataUrl
a.click()
} catch {
toast.error('SVG export failed')
}
}, [nodes, name])
const handleExportPdf = useCallback(() => {
window.print()
}, [])
const handleExportJson = useCallback(async () => {
if (!diagramId) return
try {
const data = await networkDiagramsApi.exportJson(diagramId)
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${name.replace(/[^a-zA-Z0-9-_ ]/g, '')}.json`
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error('Failed to export diagram')
}
}, [diagramId, name])
const handleExportDrawio = useCallback(() => {
if (nodes.length === 0) {
toast.warning('Add some devices to the diagram before exporting')
return
}
const xml = exportToDrawio(getNodes(), edges)
const blob = new Blob([xml], { type: 'application/xml' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${name.replace(/[^a-zA-Z0-9-_ ]/g, '') || 'diagram'}.drawio`
a.click()
URL.revokeObjectURL(url)
}, [nodes, edges, getNodes, name])
const handleImportDrawio = useCallback(() => {
drawioImportRef.current?.click()
}, [])
const handleDrawioFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
try {
const text = await file.text()
const { nodes: importedNodes, edges: importedEdges, warnings } = parseDrawioXml(text)
const importPayload = {
schemaVersion: 1 as const,
name: file.name.replace(/\.drawio$/i, '') || 'Imported Diagram',
client_name: null,
description: null,
nodes: importedNodes,
edges: importedEdges,
}
const result = await networkDiagramsApi.importJson(importPayload)
const allWarnings = [...warnings, ...result.warnings]
if (allWarnings.length > 0) {
toast.warning(`Imported with ${allWarnings.length} warning(s): ${allWarnings[0]}`)
} else {
toast.success('draw.io file imported successfully')
}
navigate(`/network-diagrams/${result.diagram.id}`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error'
toast.error(`Import failed: ${msg}`)
}
}, [navigate])
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent border-t-transparent" />
</div>
)
}
return (
<div className="flex h-full flex-col">
<DiagramHeader
name={name}
clientName={clientName}
isDirty={isDirty}
isSaving={isSaving}
lastSavedAt={lastSavedAt}
diagramId={diagramId}
onNameChange={(n: string) => { setName(n); setIsDirty(true) }}
onSave={handleSave}
onExportPng={handleExportPng}
onExportSvg={handleExportSvg}
onExportPdf={handleExportPdf}
onExportJson={handleExportJson}
onExportDrawio={handleExportDrawio}
onImportDrawio={handleImportDrawio}
onUndo={undo}
onRedo={redo}
canUndo={canUndo}
canRedo={canRedo}
interactionMode={interactionMode}
onModeChange={setInteractionMode}
/>
<div className="flex flex-1 min-h-0">
<DeviceToolbar deviceTypes={deviceTypes} onDeviceTypesChange={loadDeviceTypes} />
<div className="flex flex-1 flex-col min-h-0">
<div className="relative flex-1 min-h-0" ref={canvasRef}>
<NetworkCanvas
nodes={nodes}
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onReconnect={onReconnect}
onNodeSelect={setSelectedNodeId}
onEdgeSelect={setSelectedEdgeId}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
isDragOver={isDragOver}
onNodeContextMenu={handleNodeContextMenu}
onPaneContextMenu={handlePaneContextMenu}
onPaneClick={closeContextMenu}
interactionMode={interactionMode}
/>
{interactionMode === 'connect' && (
<div className="pointer-events-none absolute left-1/2 top-4 z-10 -translate-x-1/2 rounded-full border border-accent/30 bg-card/95 px-3 py-1.5 text-[11px] text-muted-foreground">
Connect mode: drag between device handles. Middle-click and drag to pan.
</div>
)}
{nodes.length === 0 && !loading && (
<CanvasEmptyPrompt onGenerate={handleAIGenerate} />
)}
</div>
{nodes.length > 0 && (
<AIAssistPanel
onGenerate={handleAIGenerate}
getExistingBounds={getExistingBounds}
hasNodes={nodes.length > 0}
/>
)}
</div>
<PropertiesPanel
selectedNode={selectedNode}
selectedEdge={selectedEdge}
onNodeUpdate={handleNodeUpdate}
onEdgeUpdate={handleEdgeUpdate}
onEdgeTypeChange={handleEdgeTypeChange}
onBringToFront={handleBringToFront}
onSendToBack={handleSendToBack}
onDeleteNode={handleDeleteNode}
onDeleteEdge={handleDeleteEdge}
selectedNodeCount={nodes.filter(n => n.selected).length}
onAlignLeft={diagramCommands.alignLeft}
onAlignRight={diagramCommands.alignRight}
onAlignCenterH={diagramCommands.alignCenterH}
onAlignTop={diagramCommands.alignTop}
onAlignBottom={diagramCommands.alignBottom}
onAlignCenterV={diagramCommands.alignCenterV}
onDistributeH={diagramCommands.distributeHorizontally}
onDistributeV={diagramCommands.distributeVertically}
canAlign={diagramCommands.canAlign}
canDistribute={diagramCommands.canDistribute}
canGroup={diagramCommands.canGroup}
canUngroup={diagramCommands.canUngroup}
onGroupSelection={diagramCommands.groupSelection}
onUngroupSelection={diagramCommands.ungroupSelection}
/>
</div>
{contextMenu && (
<ContextMenu
position={contextMenu.position}
actions={
contextMenu.type === 'node'
? getNodeMenuActions({
onCopy: copyNodes,
onDuplicate: duplicateNodes,
onBringToFront: () => { if (contextMenu.nodeId) handleBringToFront(contextMenu.nodeId) },
onSendToBack: () => { if (contextMenu.nodeId) handleSendToBack(contextMenu.nodeId) },
onDelete: () => {
const nodeId = contextMenu.nodeId
setContextMenu(null)
if (nodeId) setPendingDeleteNodeId(nodeId)
else deleteSelected()
},
})
: getCanvasMenuActions({
onPaste: pasteNodes,
onSelectAll: selectAll,
onFitView: () => fitView({ padding: 0.2 }),
hasClipboard: hasClipboard(),
})
}
onClose={closeContextMenu}
onAlignLeft={diagramCommands.alignLeft}
onAlignRight={diagramCommands.alignRight}
onAlignCenterH={diagramCommands.alignCenterH}
onAlignTop={diagramCommands.alignTop}
onAlignBottom={diagramCommands.alignBottom}
onAlignCenterV={diagramCommands.alignCenterV}
onDistributeH={diagramCommands.distributeHorizontally}
onDistributeV={diagramCommands.distributeVertically}
canAlign={contextMenu.type === 'node' ? diagramCommands.canAlign : false}
canDistribute={contextMenu.type === 'node' ? diagramCommands.canDistribute : false}
onGroupSelection={diagramCommands.groupSelection}
onUngroupSelection={diagramCommands.ungroupSelection}
canGroup={contextMenu?.type === 'node' ? diagramCommands.canGroup : false}
canUngroup={contextMenu?.type === 'node' ? diagramCommands.canUngroup : false}
/>
)}
{pendingDeleteNodeId && (
<div className="pointer-events-none absolute inset-x-0 bottom-4 z-50 flex justify-center">
<div className="pointer-events-auto flex items-center gap-3 rounded-lg border border-default bg-card px-4 py-2.5 shadow-lg">
<span className="text-xs text-muted-foreground">Delete this device?</span>
<button
onClick={() => setPendingDeleteNodeId(null)}
className="rounded border border-default px-3 py-1 text-xs text-primary hover:bg-elevated"
>
Cancel
</button>
<button
onClick={() => { handleDeleteNode(pendingDeleteNodeId); setPendingDeleteNodeId(null) }}
className="rounded bg-red-500/20 px-3 py-1 text-xs font-medium text-red-400 hover:bg-red-500/30"
>
Delete
</button>
</div>
</div>
)}
<input
ref={drawioImportRef}
type="file"
accept=".drawio,.xml"
className="hidden"
onChange={handleDrawioFileChange}
/>
</div>
)
}
export default function DiagramEditor() {
return (
<ReactFlowProvider>
<DiagramEditorInner />
</ReactFlowProvider>
)
}