diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 3dd21af1..fec2320f 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -28,6 +28,7 @@ sidebar: false ### 改进 - WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。 +- WAF 规则画布支持右键删除节点或连线,并屏蔽浏览器默认右键菜单。 ## [v3.4.0] - 2026-07-19 diff --git a/frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx b/frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx index 0f1a643a..3ed55312 100644 --- a/frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx @@ -1,6 +1,13 @@ 'use client'; -import { type DragEvent, useCallback, useEffect, useRef } from 'react'; +import { + type DragEvent, + type MouseEvent as ReactMouseEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { addEdge, applyEdgeChanges, @@ -23,6 +30,7 @@ import '@xyflow/react/dist/style.css'; import { Button } from '@/components/ui/button'; import type { WAFRuleEdge, WAFRuleGraph } from '@/lib/services/openflare'; +import { cn } from '@/lib/utils'; import { acceptedNodeChanges, @@ -82,6 +90,11 @@ export function RuleFlowCanvas({ const [edges, setEdges, applyFlowEdgeChanges] = useEdgesState( buildFlowEdges(graph, selectedId, selectedEdgeId), ); + const [contextMenu, setContextMenu] = useState< + | { kind: 'node'; id: string; x: number; y: number } + | { kind: 'edge'; id: string; x: number; y: number } + | null + >(null); useEffect(() => { setNodes((current) => buildFlowNodes(graph, issues, selectedId, current)); @@ -254,40 +267,114 @@ export function RuleFlowCanvas({ ? '系统节点不可删除' : '请选择可删除项'; - const deleteSelection = useCallback(() => { - if (canDeleteEdge && selectedEdgeId) { - setEdges((current) => - current.filter((edge) => edge.id !== selectedEdgeId), - ); - onGraphChange(removeEdgeFromGraph(graph, selectedEdgeId)); - onSelectEdge(undefined); - return; - } - if (canDeleteNode && selectedId) { - setNodes((current) => current.filter((node) => node.id !== selectedId)); + const deleteNodeById = useCallback( + (nodeId: string) => { + const target = graph.nodes.find((node) => node.id === nodeId); + if (!target || ['start', 'allow'].includes(target.type)) return; + setNodes((current) => current.filter((node) => node.id !== nodeId)); setEdges((current) => current.filter( - (edge) => edge.source !== selectedId && edge.target !== selectedId, + (edge) => edge.source !== nodeId && edge.target !== nodeId, ), ); - onGraphChange(removeNodeFromGraph(graph, selectedId)); - onSelect(undefined); + onGraphChange(removeNodeFromGraph(graph, nodeId)); + if (selectedId === nodeId) onSelect(undefined); + if (selectedEdgeId) onSelectEdge(undefined); + }, + [ + graph, + onGraphChange, + onSelect, + onSelectEdge, + selectedEdgeId, + selectedId, + setEdges, + setNodes, + ], + ); + + const deleteEdgeById = useCallback( + (edgeId: string) => { + if (!graph.edges.some((edge) => edge.id === edgeId)) return; + setEdges((current) => current.filter((edge) => edge.id !== edgeId)); + onGraphChange(removeEdgeFromGraph(graph, edgeId)); + if (selectedEdgeId === edgeId) onSelectEdge(undefined); + }, + [graph, onGraphChange, onSelectEdge, selectedEdgeId, setEdges], + ); + + const deleteSelection = useCallback(() => { + if (canDeleteEdge && selectedEdgeId) { + deleteEdgeById(selectedEdgeId); + return; } + if (canDeleteNode && selectedId) deleteNodeById(selectedId); }, [ canDeleteEdge, canDeleteNode, - graph, - onGraphChange, - onSelect, - onSelectEdge, + deleteEdgeById, + deleteNodeById, selectedEdgeId, selectedId, - setEdges, - setNodes, ]); + const openNodeContextMenu = useCallback( + (event: ReactMouseEvent, node: FlowNode) => { + event.preventDefault(); + event.stopPropagation(); + onSelectEdge(undefined); + onSelect(node.id); + setContextMenu({ + kind: 'node', + id: node.id, + x: event.clientX, + y: event.clientY, + }); + }, + [onSelect, onSelectEdge], + ); + + const openEdgeContextMenu = useCallback( + (event: ReactMouseEvent, edge: FlowEdge) => { + event.preventDefault(); + event.stopPropagation(); + onSelect(undefined); + onSelectEdge(edge.id); + setContextMenu({ + kind: 'edge', + id: edge.id, + x: event.clientX, + y: event.clientY, + }); + }, + [onSelect, onSelectEdge], + ); + + const suppressBrowserContextMenu = useCallback( + (event: ReactMouseEvent | MouseEvent) => { + event.preventDefault(); + setContextMenu(null); + }, + [], + ); + + const contextNode = + contextMenu?.kind === 'node' + ? graph.nodes.find((node) => node.id === contextMenu.id) + : undefined; + const contextCanDeleteNode = Boolean( + contextNode && !['start', 'allow'].includes(contextNode.type), + ); + const contextCanDeleteEdge = Boolean( + contextMenu?.kind === 'edge' && + graph.edges.some((edge) => edge.id === contextMenu.id), + ); + return ( -
+
@@ -321,25 +408,81 @@ export function RuleFlowCanvas({ onNodeClick={(_, node) => { onSelectEdge(undefined); onSelect(node.id); + setContextMenu(null); }} onEdgeClick={(_, edge) => { onSelect(undefined); onSelectEdge(edge.id); + setContextMenu(null); }} onPaneClick={() => { onSelect(undefined); onSelectEdge(undefined); + setContextMenu(null); }} + onMoveStart={() => setContextMenu(null)} + onNodeContextMenu={openNodeContextMenu} + onEdgeContextMenu={openEdgeContextMenu} + onPaneContextMenu={suppressBrowserContextMenu} onDragOver={onDragOver} onDrop={onDrop} fitView fitViewOptions={initialFitViewOptions} deleteKeyCode={['Backspace', 'Delete']} + className='h-full w-full' > + {contextMenu && ( +
+ {contextMenu.kind === 'node' && ( + + )} + {contextMenu.kind === 'edge' && ( + + )} +
+ )}
); } diff --git a/frontend/components/ui/context-menu.tsx b/frontend/components/ui/context-menu.tsx new file mode 100644 index 00000000..8b341438 --- /dev/null +++ b/frontend/components/ui/context-menu.tsx @@ -0,0 +1,252 @@ +'use client'; + +import * as React from 'react'; +import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'; +import { ContextMenu as ContextMenuPrimitive } from 'radix-ui'; + +import { cn } from '@/lib/utils'; + +function ContextMenu({ + ...props +}: React.ComponentProps) { + return ; +} + +function ContextMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuSub({ + ...props +}: React.ComponentProps) { + return ; +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function ContextMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: 'default' | 'destructive'; +}) { + return ( + + ); +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function ContextMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<'span'>) { + return ( + + ); +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +};