From e3b2f73f3816eceeaad3844d1b686c96e168747c Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 4 Apr 2026 17:33:27 +0000 Subject: [PATCH] feat: wire context menu and keyboard shortcuts into diagram editor Right-click context menus for nodes (copy/duplicate/delete) and canvas (paste/select-all/fit-view). Right-click selects the node per spec. serializeNodes now handles group nodes correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/network/NetworkCanvas.tsx | 6 ++ .../pages/NetworkDiagrams/DiagramEditor.tsx | 95 ++++++++++++++++++- 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/network/NetworkCanvas.tsx b/frontend/src/components/network/NetworkCanvas.tsx index 7f19361a..4d9ce5b2 100644 --- a/frontend/src/components/network/NetworkCanvas.tsx +++ b/frontend/src/components/network/NetworkCanvas.tsx @@ -26,6 +26,8 @@ interface NetworkCanvasProps { onDragOver: (event: React.DragEvent) => void onDragLeave?: (event: React.DragEvent) => void isDragOver?: boolean + onNodeContextMenu?: (event: React.MouseEvent, node: Node) => void + onPaneContextMenu?: (event: MouseEvent | React.MouseEvent) => void } export function NetworkCanvas({ @@ -40,6 +42,8 @@ export function NetworkCanvas({ onDragOver, onDragLeave, isDragOver, + onNodeContextMenu, + onPaneContextMenu, }: NetworkCanvasProps) { const handleSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => { if (selectedNodes.length === 1) { @@ -71,6 +75,8 @@ export function NetworkCanvas({ onPaneClick={handlePaneClick} onDrop={onDrop} onDragOver={onDragOver} + onNodeContextMenu={onNodeContextMenu} + onPaneContextMenu={onPaneContextMenu} nodeTypes={nodeTypes} edgeTypes={edgeTypes} defaultEdgeOptions={{ type: 'connection' }} diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index 88d71b74..d5c5b9ad 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -13,15 +13,23 @@ import { 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 } from '@/types' +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() @@ -52,9 +60,28 @@ function DiagramEditorInner() { 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') @@ -124,8 +151,20 @@ function DiagramEditorInner() { return () => { cancelled = true } }, [id, navigate, setNodes, setEdges]) - const serializeNodes = useCallback(() => { + const serializeNodes = useCallback((): DiagramNode[] => { return getNodes().map(n => { + if (n.type === 'group') { + const data = n.data as Record + 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: n.style || null, + } + } const data = n.data as unknown as DeviceNodeData return { id: n.id, @@ -206,6 +245,34 @@ function DiagramEditorInner() { 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) @@ -413,7 +480,7 @@ function DiagramEditorInner() {
-
+
+ {contextMenu && ( + fitView({ padding: 0.2 }), + hasClipboard: hasClipboard(), + }) + } + onClose={closeContextMenu} + /> + )}
) }