967 lines
34 KiB
TypeScript
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>
|
|
)
|
|
}
|