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) <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-04 17:33:27 +00:00
parent e6a4c93203
commit 6e5614e7b4
2 changed files with 98 additions and 3 deletions

View File

@@ -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' }}

View File

@@ -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<HTMLDivElement | null>(null)
const [contextMenu, setContextMenu] = useState<ContextMenuState>(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<string, unknown>
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() {
<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="flex-1 min-h-0">
<div className="flex-1 min-h-0" ref={canvasRef}>
<NetworkCanvas
nodes={nodes}
edges={edges}
@@ -426,6 +493,8 @@ function DiagramEditorInner() {
onDragOver={onDragOver}
onDragLeave={onDragLeave}
isDragOver={isDragOver}
onNodeContextMenu={handleNodeContextMenu}
onPaneContextMenu={handlePaneContextMenu}
/>
</div>
<AIAssistPanel
@@ -444,6 +513,26 @@ function DiagramEditorInner() {
onDeleteEdge={handleDeleteEdge}
/>
</div>
{contextMenu && (
<ContextMenu
position={contextMenu.position}
actions={
contextMenu.type === 'node'
? getNodeMenuActions({
onCopy: copyNodes,
onDuplicate: duplicateNodes,
onDelete: deleteSelected,
})
: getCanvasMenuActions({
onPaste: pasteNodes,
onSelectAll: selectAll,
onFitView: () => fitView({ padding: 0.2 }),
hasClipboard: hasClipboard(),
})
}
onClose={closeContextMenu}
/>
)}
</div>
)
}