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:
@@ -26,6 +26,8 @@ interface NetworkCanvasProps {
|
|||||||
onDragOver: (event: React.DragEvent) => void
|
onDragOver: (event: React.DragEvent) => void
|
||||||
onDragLeave?: (event: React.DragEvent) => void
|
onDragLeave?: (event: React.DragEvent) => void
|
||||||
isDragOver?: boolean
|
isDragOver?: boolean
|
||||||
|
onNodeContextMenu?: (event: React.MouseEvent, node: Node) => void
|
||||||
|
onPaneContextMenu?: (event: MouseEvent | React.MouseEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NetworkCanvas({
|
export function NetworkCanvas({
|
||||||
@@ -40,6 +42,8 @@ export function NetworkCanvas({
|
|||||||
onDragOver,
|
onDragOver,
|
||||||
onDragLeave,
|
onDragLeave,
|
||||||
isDragOver,
|
isDragOver,
|
||||||
|
onNodeContextMenu,
|
||||||
|
onPaneContextMenu,
|
||||||
}: NetworkCanvasProps) {
|
}: NetworkCanvasProps) {
|
||||||
const handleSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
|
const handleSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
|
||||||
if (selectedNodes.length === 1) {
|
if (selectedNodes.length === 1) {
|
||||||
@@ -71,6 +75,8 @@ export function NetworkCanvas({
|
|||||||
onPaneClick={handlePaneClick}
|
onPaneClick={handlePaneClick}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
|
onNodeContextMenu={onNodeContextMenu}
|
||||||
|
onPaneContextMenu={onPaneContextMenu}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
defaultEdgeOptions={{ type: 'connection' }}
|
defaultEdgeOptions={{ type: 'connection' }}
|
||||||
|
|||||||
@@ -13,15 +13,23 @@ import {
|
|||||||
import '@xyflow/react/dist/style.css'
|
import '@xyflow/react/dist/style.css'
|
||||||
|
|
||||||
import { NetworkCanvas } from '@/components/network/NetworkCanvas'
|
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 { DiagramHeader } from '@/components/network/DiagramHeader'
|
||||||
import { DeviceToolbar } from '@/components/network/panels/DeviceToolbar'
|
import { DeviceToolbar } from '@/components/network/panels/DeviceToolbar'
|
||||||
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
|
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
|
||||||
import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel'
|
import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel'
|
||||||
import { networkDiagramsApi, deviceTypesApi } from '@/api'
|
import { networkDiagramsApi, deviceTypesApi } from '@/api'
|
||||||
import { toast } from '@/lib/toast'
|
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'
|
import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode'
|
||||||
|
|
||||||
|
type ContextMenuState = {
|
||||||
|
type: 'node' | 'canvas'
|
||||||
|
position: { x: number; y: number }
|
||||||
|
nodeId?: string
|
||||||
|
} | null
|
||||||
|
|
||||||
function DiagramEditorInner() {
|
function DiagramEditorInner() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -52,9 +60,28 @@ function DiagramEditorInner() {
|
|||||||
const [loading, setLoading] = useState(!!id)
|
const [loading, setLoading] = useState(!!id)
|
||||||
const [isDragOver, setIsDragOver] = useState(false)
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
|
|
||||||
|
const canvasRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
|
||||||
|
|
||||||
useEffect(() => { isDirtyRef.current = isDirty }, [isDirty])
|
useEffect(() => { isDirtyRef.current = isDirty }, [isDirty])
|
||||||
useEffect(() => { diagramIdRef.current = diagramId }, [diagramId])
|
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) => {
|
const handleNodesChange: typeof onNodesChange = useCallback((changes) => {
|
||||||
onNodesChange(changes)
|
onNodesChange(changes)
|
||||||
const hasRealChange = changes.some(c => c.type !== 'select')
|
const hasRealChange = changes.some(c => c.type !== 'select')
|
||||||
@@ -124,8 +151,20 @@ function DiagramEditorInner() {
|
|||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [id, navigate, setNodes, setEdges])
|
}, [id, navigate, setNodes, setEdges])
|
||||||
|
|
||||||
const serializeNodes = useCallback(() => {
|
const serializeNodes = useCallback((): DiagramNode[] => {
|
||||||
return getNodes().map(n => {
|
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
|
const data = n.data as unknown as DeviceNodeData
|
||||||
return {
|
return {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
@@ -206,6 +245,34 @@ function DiagramEditorInner() {
|
|||||||
setIsDragOver(false)
|
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) => {
|
const onDrop = useCallback((event: React.DragEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setIsDragOver(false)
|
setIsDragOver(false)
|
||||||
@@ -413,7 +480,7 @@ function DiagramEditorInner() {
|
|||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<DeviceToolbar deviceTypes={deviceTypes} onDeviceTypesChange={loadDeviceTypes} />
|
<DeviceToolbar deviceTypes={deviceTypes} onDeviceTypesChange={loadDeviceTypes} />
|
||||||
<div className="flex flex-1 flex-col min-h-0">
|
<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
|
<NetworkCanvas
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
@@ -426,6 +493,8 @@ function DiagramEditorInner() {
|
|||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
isDragOver={isDragOver}
|
isDragOver={isDragOver}
|
||||||
|
onNodeContextMenu={handleNodeContextMenu}
|
||||||
|
onPaneContextMenu={handlePaneContextMenu}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<AIAssistPanel
|
<AIAssistPanel
|
||||||
@@ -444,6 +513,26 @@ function DiagramEditorInner() {
|
|||||||
onDeleteEdge={handleDeleteEdge}
|
onDeleteEdge={handleDeleteEdge}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user