feat(waf): 规则画布右键删除节点与连线

覆盖画布默认右键菜单;节点/连线右键弹出删除项,系统节点禁用。
This commit is contained in:
ryan
2026-07-19 11:47:04 +08:00
parent d47ceb9971
commit 8f3ff59567
3 changed files with 418 additions and 22 deletions
+1
View File
@@ -28,6 +28,7 @@ sidebar: false
### 改进
- WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。
- WAF 规则画布支持右键删除节点或连线,并屏蔽浏览器默认右键菜单。
## [v3.4.0] - 2026-07-19
@@ -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<FlowEdge>(
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 (
<section className='relative min-w-0 flex-1 bg-muted/20'>
<section
className='relative min-w-0 flex-1 bg-muted/20'
onContextMenu={suppressBrowserContextMenu}
>
<div className='absolute left-4 top-4 z-10 rounded-lg border bg-background/95 p-2 shadow-sm backdrop-blur'>
<NodeLibrary />
</div>
@@ -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'
>
<Background gap={20} size={1} />
<MiniMap pannable zoomable />
<Controls />
</ReactFlow>
{contextMenu && (
<div
className='fixed z-50 min-w-40 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md'
style={{ left: contextMenu.x, top: contextMenu.y }}
role='menu'
>
{contextMenu.kind === 'node' && (
<button
type='button'
role='menuitem'
disabled={!contextCanDeleteNode}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none',
contextCanDeleteNode
? 'text-destructive hover:bg-destructive/10'
: 'cursor-not-allowed text-muted-foreground opacity-60',
)}
onClick={() => {
deleteNodeById(contextMenu.id);
setContextMenu(null);
}}
>
<Trash2 className='size-4' />
{contextCanDeleteNode ? '删除节点' : '系统节点不可删除'}
</button>
)}
{contextMenu.kind === 'edge' && (
<button
type='button'
role='menuitem'
disabled={!contextCanDeleteEdge}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none',
contextCanDeleteEdge
? 'text-destructive hover:bg-destructive/10'
: 'cursor-not-allowed text-muted-foreground opacity-60',
)}
onClick={() => {
deleteEdgeById(contextMenu.id);
setContextMenu(null);
}}
>
<Trash2 className='size-4' />
删除连线
</button>
)}
</div>
)}
</section>
);
}
+252
View File
@@ -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<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot='context-menu' {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot='context-menu-trigger' {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot='context-menu-group' {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot='context-menu-portal' {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot='context-menu-sub' {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot='context-menu-radio-group'
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot='context-menu-sub-trigger'
data-inset={inset}
className={cn(
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className='ml-auto' />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot='context-menu-sub-content'
className={cn(
'z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className,
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot='context-menu-content'
className={cn(
'z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<ContextMenuPrimitive.Item
data-slot='context-menu-item'
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className,
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot='context-menu-checkbox-item'
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className='size-4' />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot='context-menu-radio-item'
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className='size-2 fill-current' />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot='context-menu-label'
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium text-foreground data-[inset]:pl-8',
className,
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot='context-menu-separator'
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='context-menu-shortcut'
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className,
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};