import { useState, useCallback, useEffect, useRef } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { ReactFlowProvider, useNodesState, useEdgesState, addEdge, useReactFlow, 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 { DiagramHeader } 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 { networkDiagramsApi, deviceTypesApi } from '@/api' import { toast } from '@/lib/toast' import type { DeviceTypeResponse, DeviceProperties, AIGenerateResponse, DiagramEdge, DiagramNode } from '@/types' import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode' 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(id || null) const [name, setName] = useState('Untitled Diagram') const [clientName, setClientName] = useState(null) const [assetName, setAssetName] = useState(null) const [description, setDescription] = useState(null) const [nodes, setNodes, onNodesChange] = useNodesState([]) const [edges, setEdges, onEdgesChange] = useEdgesState([]) const [selectedNodeId, setSelectedNodeId] = useState(null) const [selectedEdgeId, setSelectedEdgeId] = useState(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(null) const isDirtyRef = useRef(false) const diagramIdRef = useRef(id || null) const [deviceTypes, setDeviceTypes] = useState([]) const [loading, setLoading] = useState(!!id) const [isDragOver, setIsDragOver] = useState(false) const canvasRef = useRef(null) const [contextMenu, setContextMenu] = useState(null) useEffect(() => { isDirtyRef.current = isDirty }, [isDirty]) useEffect(() => { diagramIdRef.current = diagramId }, [diagramId]) const { copyNodes, pasteNodes, duplicateNodes, selectAll, deleteSelected, hasClipboard, } = useCanvasShortcuts({ nodes, edges, setNodes, setEdges, setIsDirty: (v: boolean) => setIsDirty(v), canvasRef, }) 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, 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, }, })) ) setLastSavedAt(new Date(diagram.updated_at)) } catch { toast.error('Failed to load diagram') navigate('/network-diagrams') } finally { if (!cancelled) setLoading(false) } })() return () => { cancelled = true } }, [id, navigate, setNodes, setEdges]) const serializeNodes = useCallback((): DiagramNode[] => { return getNodes().map(n => { if (n.type === 'group') { const data = n.data as Record 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 return { id: n.id, type: data.deviceType, label: data.label, position: n.position, properties: data.properties, } }) }, [getNodes]) const serializeEdges = useCallback((): DiagramEdge[] => { return edges.map(e => ({ id: e.id, source: e.source, target: e.target, label: (e.label as string) || null, connectionType: (e.data as Record)?.connectionType as string || 'ethernet', speed: (e.data as Record)?.speed as string || null, notes: (e.data as Record)?.notes as string || null, })) }, [edges]) const handleSave = useCallback(async () => { setIsSaving(true) try { const payload = { name, client_name: clientName, asset_name: assetName, description, nodes: serializeNodes(), edges: serializeEdges(), } if (diagramIdRef.current) { await networkDiagramsApi.update(diagramIdRef.current, payload) } else { const created = await networkDiagramsApi.create(payload) setDiagramId(created.id) navigate(`/network-diagrams/${created.id}`, { replace: true }) } setIsDirty(false) setLastSavedAt(new Date()) } catch { toast.error('Failed to save diagram') } finally { setIsSaving(false) } }, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate]) useEffect(() => { const interval = setInterval(() => { if (isDirtyRef.current && diagramIdRef.current) { handleSave() } }, 30_000) return () => clearInterval(interval) }, [handleSave]) const onConnect = useCallback((connection: Connection) => { setEdges(eds => addEdge({ ...connection, type: 'connection', data: { connectionType: 'ethernet' }, }, eds)) setIsDirty(true) }, [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() setContextMenu({ type: 'canvas', position: { x: event.clientX, y: event.clientY }, }) }, []) 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, 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, } 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, }, } setNodes(nds => [...nds, newNode]) setIsDirty(true) } }, [setNodes, screenToFlowPosition]) const handleNodeUpdate = useCallback((nodeId: string, updates: Partial) => { setNodes(nds => nds.map(n => { if (n.id !== nodeId) return n return { ...n, data: { ...n.data, ...updates } } })) setIsDirty(true) }, [setNodes]) const handleEdgeUpdate = useCallback((edgeId: string, updates: Partial) => { 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 } : {}), }, } })) setIsDirty(true) }, [setEdges]) const handleEdgeTypeChange = useCallback((edgeId: string, edgeType: string) => { setEdges(eds => eds.map(e => { if (e.id !== edgeId) return e return { ...e, type: edgeType } })) setIsDirty(true) }, [setEdges]) const handleDeleteNode = useCallback((nodeId: string) => { 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]) const handleDeleteEdge = useCallback((edgeId: string) => { setEdges(eds => eds.filter(e => e.id !== edgeId)) setSelectedEdgeId(null) setIsDirty(true) }, [setEdges]) 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 }, })) 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) }, [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(() => { toast.info('PNG export — use your browser\'s screenshot tool or Print > Save as Image for now') }, []) 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]) if (loading) { return (
) } return (
{ setName(n); setIsDirty(true) }} onSave={handleSave} onExportPng={handleExportPng} onExportPdf={handleExportPdf} onExportJson={handleExportJson} />
0} />
{contextMenu && ( fitView({ padding: 0.2 }), hasClipboard: hasClipboard(), }) } onClose={closeContextMenu} /> )}
) } export default function DiagramEditor() { return ( ) }