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(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 [interactionMode, setInteractionMode] = useState('select') const canvasRef = useRef(null) const drawioImportRef = useRef(null) const [contextMenu, setContextMenu] = useState(null) const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState(null) useEffect(() => { isDirtyRef.current = isDirty }, [isDirty]) useEffect(() => { diagramIdRef.current = diagramId }, [diagramId]) // History const historyStack = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]) const historyIndex = useRef(-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 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) || {} 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) => { 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) => { 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) => { 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 (
) } return (
{ 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} />
{interactionMode === 'connect' && (
Connect mode: drag between device handles. Middle-click and drag to pan.
)} {nodes.length === 0 && !loading && ( )}
{nodes.length > 0 && ( 0} /> )}
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} />
{contextMenu && ( { 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 && (
Delete this device?
)}
) } export default function DiagramEditor() { return ( ) }