mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
feat(waf): 规则画布右键删除节点与连线
覆盖画布默认右键菜单;节点/连线右键弹出删除项,系统节点禁用。
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user