From b5e27fabde771a54a01838068644e05d59047c53 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 19 Jul 2026 11:01:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(waf):=20=E8=A7=84=E5=88=99=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E8=8A=82=E7=82=B9=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E4=B8=8E=E6=8B=96=E6=94=BE=E6=B7=BB=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对齐后端 label 字段;属性栏可编辑显示名称;节点库改为拖到画布落点创建。 --- docs/changelog/index.md | 4 + .../2026-07-19-waf-editor-node-label-drag.md | 388 ++++++++++++++++++ .../editor/components/node-factory.test.ts | 54 +++ .../rules/editor/components/node-factory.ts | 69 ++++ .../rules/editor/components/node-library.tsx | 38 +- .../components/node-properties.test.tsx | 29 ++ .../editor/components/node-properties.tsx | 25 ++ .../editor/components/rule-flow-canvas.tsx | 71 ++-- .../waf/rules/editor/components/rule-node.tsx | 19 +- frontend/lib/services/openflare/types.ts | 13 +- 10 files changed, 653 insertions(+), 57 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-19-waf-editor-node-label-drag.md create mode 100644 frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts create mode 100644 frontend/app/(main)/waf/rules/editor/components/node-factory.ts diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 6f4738e2..0e60deea 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -21,6 +21,10 @@ sidebar: false ## [unreleased] +### 改进 + +- WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。 + ## [v3.4.0] - 2026-07-19 ### 新增 diff --git a/docs/superpowers/plans/2026-07-19-waf-editor-node-label-drag.md b/docs/superpowers/plans/2026-07-19-waf-editor-node-label-drag.md new file mode 100644 index 00000000..23082a98 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-waf-editor-node-label-drag.md @@ -0,0 +1,388 @@ +# WAF Editor Node Label + Drag-Add Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let users rename WAF rule nodes via optional `label`, and add nodes by dragging from the library onto the canvas drop position (no click-to-fixed-offset). + +**Architecture:** Frontend-only. Align TS `WAFRuleNode` with backend `label`. Pure helpers for display name and default node factory. Node library is drag source; React Flow pane handles drop with `screenToFlowPosition`. Properties panel edits `label` for non-system nodes. + +**Tech Stack:** Next.js App Router, React, TypeScript, `@xyflow/react`, Vitest + Testing Library, shadcn/ui. + +**Spec:** `docs/superpowers/specs/2026-07-19-waf-editor-node-label-drag-design.md` + +## Global Constraints + +- No backend / schema_version / note field changes. +- System nodes `start` / `allow`: no rename UI. +- New nodes: no default `label` (type name shown). +- Drag-only add; remove click-add. +- After code: relevant vitest pass; run `make prettier` / `make code-check` if touching repo gates. + +## File Map + +| File | Role | +|------|------| +| `frontend/lib/services/openflare/types.ts` | Add `label?: string` to all `WAFRuleNode` variants | +| `frontend/app/(main)/waf/rules/editor/components/node-factory.ts` | `NODE_TYPE_LABELS`, `displayNodeTitle`, `createRuleNode`, drag MIME constant | +| `frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts` | Unit tests for title + factory | +| `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx` | Use `displayNodeTitle` | +| `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx` | 「显示名称」Input | +| `frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx` | Label edit + system node | +| `frontend/app/(main)/waf/rules/editor/components/node-library.tsx` | Draggable items, no onClick | +| `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx` | Drop handler + position-aware create | + +--- + +### Task 1: Types + pure helpers + +**Files:** +- Modify: `frontend/lib/services/openflare/types.ts` +- Create: `frontend/app/(main)/waf/rules/editor/components/node-factory.ts` +- Create: `frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts` + +**Interfaces:** +- Produces: `WAF_NODE_DRAG_MIME`, `AddableNodeType`, `NODE_TYPE_LABELS`, `displayNodeTitle(node)`, `createRuleNode(type, position)` + +- [ ] **Step 1: Add `label?: string` to every `WAFRuleNode` union member** in `types.ts`. + +- [ ] **Step 2: Write failing tests** in `node-factory.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { + createRuleNode, + displayNodeTitle, + NODE_TYPE_LABELS, +} from './node-factory'; + +describe('displayNodeTitle', () => { + it('uses trimmed label when present', () => { + expect( + displayNodeTitle({ + id: 'x', + type: 'ip_match', + label: ' 办公室 ', + position: { x: 0, y: 0 }, + config: { ips: [], cidrs: [], ip_group_ids: [] }, + }), + ).toBe('办公室'); + }); + + it('falls back to type default when label empty', () => { + expect( + displayNodeTitle({ + id: 'x', + type: 'block', + label: ' ', + position: { x: 0, y: 0 }, + config: { status_code: 403, response_body: '' }, + }), + ).toBe(NODE_TYPE_LABELS.block); + }); +}); + +describe('createRuleNode', () => { + it('creates typed node at position without label', () => { + const node = createRuleNode('pow', { x: 12, y: 34 }); + expect(node.type).toBe('pow'); + expect(node.position).toEqual({ x: 12, y: 34 }); + expect(node.label).toBeUndefined(); + expect(node.id.startsWith('pow-')).toBe(true); + if (node.type === 'pow') { + expect(node.config).toEqual({ + algorithm: 'fast', + difficulty: 4, + session_ttl: 3600, + challenge_ttl: 300, + }); + } + }); +}); +``` + +- [ ] **Step 3: Implement `node-factory.ts`** + +```ts +import type { WAFRuleNode } from '@/lib/services/openflare'; + +export const WAF_NODE_DRAG_MIME = 'application/openflare-waf-node'; + +export type AddableNodeType = Extract< + WAFRuleNode['type'], + 'ip_match' | 'geo_match' | 'pow' | 'block' +>; + +export const NODE_TYPE_LABELS: Record = { + start: '开始', + ip_match: 'IP 匹配', + geo_match: '地域匹配', + pow: 'PoW 挑战', + allow: '通过', + block: '阻止', +}; + +export function displayNodeTitle( + node: Pick, +): string { + const custom = node.label?.trim(); + return custom || NODE_TYPE_LABELS[node.type]; +} + +export function createRuleNode( + type: AddableNodeType, + position: { x: number; y: number }, +): WAFRuleNode { + const id = `${type}-${crypto.randomUUID().slice(0, 8)}`; + if (type === 'ip_match') + return { + id, + type, + position, + config: { ips: [], cidrs: [], ip_group_ids: [] }, + }; + if (type === 'geo_match') + return { id, type, position, config: { countries: [], regions: [] } }; + if (type === 'pow') + return { + id, + type, + position, + config: { + algorithm: 'fast', + difficulty: 4, + session_ttl: 3600, + challenge_ttl: 300, + }, + }; + return { + id, + type: 'block', + position, + config: { status_code: 403, response_body: '' }, + }; +} + +export function parseAddableNodeType(value: string): AddableNodeType | null { + if ( + value === 'ip_match' || + value === 'geo_match' || + value === 'pow' || + value === 'block' + ) + return value; + return null; +} +``` + +- [ ] **Step 4: Run tests** + +```bash +cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor/components/node-factory.test.ts' +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add frontend/lib/services/openflare/types.ts \ + frontend/app/(main)/waf/rules/editor/components/node-factory.ts \ + frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts +git commit -m "feat(waf): add node label type and factory helpers" +``` + +--- + +### Task 2: Canvas title + properties label field + +**Files:** +- Modify: `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx` +- Modify: `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx` +- Modify: `frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx` + +- [ ] **Step 1: Tests for properties** + +Add to `node-properties.test.tsx`: + +```ts +it('edits display name for configurable nodes', () => { + const node: WAFRuleNode = { + id: 'match', + type: 'ip_match', + position: { x: 0, y: 0 }, + config: { ips: [], cidrs: [], ip_group_ids: [] }, + }; + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByLabelText('显示名称'), { + target: { value: '内网放行' }, + }); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ label: '内网放行' }), + ); +}); + +it('hides display name for system nodes', () => { + const node: WAFRuleNode = { + id: 'start', + type: 'start', + position: { x: 0, y: 0 }, + config: {}, + }; + render(); + expect(screen.queryByLabelText('显示名称')).not.toBeInTheDocument(); + expect(screen.getByText('系统节点无需配置。')).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Implement properties field** — at start of each configurable `FieldGroup` (or wrap once before type switch for non-system): + +Prefer extract: + +```tsx +function DisplayNameField({ + node, + onChange, +}: { + node: WAFRuleNode; + onChange: (node: WAFRuleNode) => void; +}) { + return ( + + 显示名称 + onChange({ ...node, label: e.target.value })} + /> + + ); +} +``` + +Insert `` as first child inside each non-system `FieldGroup`. + +- [ ] **Step 3: `rule-node.tsx`** — use `displayNodeTitle(rule)` for main title; keep icon from meta; keep id subtitle. + +- [ ] **Step 4: Run tests** + +```bash +cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor/components/node-properties.test.tsx' 'app/(main)/waf/rules/editor/components/node-factory.test.ts' +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add frontend/app/(main)/waf/rules/editor/components/rule-node.tsx \ + frontend/app/(main)/waf/rules/editor/components/node-properties.tsx \ + frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx +git commit -m "feat(waf): show and edit rule node display names" +``` + +--- + +### Task 3: Drag library + canvas drop + +**Files:** +- Modify: `frontend/app/(main)/waf/rules/editor/components/node-library.tsx` +- Modify: `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx` +- Create (optional pure tests): extend `node-factory.test.ts` for `parseAddableNodeType` + +- [ ] **Step 1: Node library** — remove `onAdd` prop; make each button `draggable` with: + +```tsx +onDragStart={(e) => { + e.dataTransfer.setData(WAF_NODE_DRAG_MIME, type); + e.dataTransfer.setData('text/plain', type); + e.dataTransfer.effectAllowed = 'copy'; +}} +``` + +Use `type='button'` + cursor `cursor-grab active:cursor-grabbing`. No `onClick` that adds nodes. + +- [ ] **Step 2: Canvas** — replace `addNode(type)` fixed position with: + +```ts +const addNodeAt = useCallback( + (type: AddableNodeType, position: { x: number; y: number }) => { + const node = createRuleNode(type, position); + onGraphChange({ ...graph, nodes: [...graph.nodes, node] }); + onSelectEdge(undefined); + onSelect(node.id); + }, + [graph, onGraphChange, onSelect, onSelectEdge], +); + +const onDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; +}, []); + +const onDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + const raw = + e.dataTransfer.getData(WAF_NODE_DRAG_MIME) || + e.dataTransfer.getData('text/plain'); + const type = parseAddableNodeType(raw); + if (!type || !instance.current) return; + const position = instance.current.screenToFlowPosition({ + x: e.clientX, + y: e.clientY, + }); + addNodeAt(type, position); + }, + [addNodeAt], +); +``` + +Pass `onDragOver` / `onDrop` to `` (xyflow supports these on the component). + +Update `` — no `onAdd`. + +- [ ] **Step 3: Run editor-related tests** + +```bash +cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor' +``` + +Expected: PASS (update any tests that assumed click-add) + +- [ ] **Step 4: Format + commit** + +```bash +make prettier +git add frontend/app/(main)/waf/rules/editor +git commit -m "feat(waf): drag-drop nodes onto rule canvas at cursor" +``` + +- [ ] **Step 5: Changelog** — under `docs/changelog/index.md` `[Unreleased]`: + +```md +### 改进 +- WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。 +``` + +```bash +git add docs/changelog/index.md +git commit -m "docs(changelog): WAF 编辑器节点命名与拖放添加" +``` + +--- + +## Spec coverage + +| Spec item | Task | +|-----------|------| +| `label?` on TS types | 1 | +| Display title fallback | 1–2 | +| Properties 显示名称 | 2 | +| System nodes no rename | 2 | +| Drag-only library | 3 | +| Drop at cursor | 3 | +| No note / backend | N/A (omitted) | +| Tests | 1–3 | +| Changelog | 3 | diff --git a/frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts b/frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts new file mode 100644 index 00000000..1e1b422e --- /dev/null +++ b/frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { + createRuleNode, + displayNodeTitle, + NODE_TYPE_LABELS, + parseAddableNodeType, +} from './node-factory'; + +describe('displayNodeTitle', () => { + it('uses trimmed label when present', () => { + expect( + displayNodeTitle({ + type: 'ip_match', + label: ' 办公室 ', + }), + ).toBe('办公室'); + }); + + it('falls back to type default when label empty', () => { + expect( + displayNodeTitle({ + type: 'block', + label: ' ', + }), + ).toBe(NODE_TYPE_LABELS.block); + }); +}); + +describe('createRuleNode', () => { + it('creates typed node at position without label', () => { + const node = createRuleNode('pow', { x: 12, y: 34 }); + expect(node.type).toBe('pow'); + expect(node.position).toEqual({ x: 12, y: 34 }); + expect(node.label).toBeUndefined(); + expect(node.id.startsWith('pow-')).toBe(true); + if (node.type === 'pow') { + expect(node.config).toEqual({ + algorithm: 'fast', + difficulty: 4, + session_ttl: 3600, + challenge_ttl: 300, + }); + } + }); +}); + +describe('parseAddableNodeType', () => { + it('accepts addable types and rejects others', () => { + expect(parseAddableNodeType('ip_match')).toBe('ip_match'); + expect(parseAddableNodeType('start')).toBeNull(); + expect(parseAddableNodeType('')).toBeNull(); + }); +}); diff --git a/frontend/app/(main)/waf/rules/editor/components/node-factory.ts b/frontend/app/(main)/waf/rules/editor/components/node-factory.ts new file mode 100644 index 00000000..aa04b7cd --- /dev/null +++ b/frontend/app/(main)/waf/rules/editor/components/node-factory.ts @@ -0,0 +1,69 @@ +import type { WAFRuleNode } from '@/lib/services/openflare'; + +export const WAF_NODE_DRAG_MIME = 'application/openflare-waf-node'; + +export type AddableNodeType = Extract< + WAFRuleNode['type'], + 'ip_match' | 'geo_match' | 'pow' | 'block' +>; + +export const NODE_TYPE_LABELS: Record = { + start: '开始', + ip_match: 'IP 匹配', + geo_match: '地域匹配', + pow: 'PoW 挑战', + allow: '通过', + block: '阻止', +}; + +export function displayNodeTitle( + node: Pick, +): string { + const custom = node.label?.trim(); + return custom || NODE_TYPE_LABELS[node.type]; +} + +export function createRuleNode( + type: AddableNodeType, + position: { x: number; y: number }, +): WAFRuleNode { + const id = `${type}-${crypto.randomUUID().slice(0, 8)}`; + if (type === 'ip_match') + return { + id, + type, + position, + config: { ips: [], cidrs: [], ip_group_ids: [] }, + }; + if (type === 'geo_match') + return { id, type, position, config: { countries: [], regions: [] } }; + if (type === 'pow') + return { + id, + type, + position, + config: { + algorithm: 'fast', + difficulty: 4, + session_ttl: 3600, + challenge_ttl: 300, + }, + }; + return { + id, + type: 'block', + position, + config: { status_code: 403, response_body: '' }, + }; +} + +export function parseAddableNodeType(value: string): AddableNodeType | null { + if ( + value === 'ip_match' || + value === 'geo_match' || + value === 'pow' || + value === 'block' + ) + return value; + return null; +} diff --git a/frontend/app/(main)/waf/rules/editor/components/node-library.tsx b/frontend/app/(main)/waf/rules/editor/components/node-library.tsx index fcc1336a..a6bc8fa6 100644 --- a/frontend/app/(main)/waf/rules/editor/components/node-library.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/node-library.tsx @@ -1,31 +1,39 @@ -import { Ban, Fingerprint, Globe2, Plus, ShieldCheck } from 'lucide-react'; +import { Ban, Fingerprint, Globe2, ShieldCheck } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import type { WAFRuleNode } from '@/lib/services/openflare'; -type AddableType = Extract< - WAFRuleNode['type'], - 'ip_match' | 'geo_match' | 'pow' | 'block' ->; +import { + NODE_TYPE_LABELS, + WAF_NODE_DRAG_MIME, + type AddableNodeType, +} from './node-factory'; + const items = [ - { type: 'ip_match', label: 'IP 匹配', icon: Fingerprint }, - { type: 'geo_match', label: '地域匹配', icon: Globe2 }, - { type: 'pow', label: 'PoW 挑战', icon: ShieldCheck }, - { type: 'block', label: '阻止', icon: Ban }, -] satisfies { type: AddableType; label: string; icon: typeof Plus }[]; + { type: 'ip_match' as const, icon: Fingerprint }, + { type: 'geo_match' as const, icon: Globe2 }, + { type: 'pow' as const, icon: ShieldCheck }, + { type: 'block' as const, icon: Ban }, +] satisfies { type: AddableNodeType; icon: typeof Fingerprint }[]; -export function NodeLibrary({ onAdd }: { onAdd: (type: AddableType) => void }) { +export function NodeLibrary() { return (
- {items.map(({ type, label, icon: Icon }) => ( + {items.map(({ type, icon: Icon }) => ( ))}
diff --git a/frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx b/frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx index 936dabf8..d07a2273 100644 --- a/frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx @@ -5,6 +5,35 @@ import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare'; import { NodeProperties } from './node-properties'; +it('edits display name for configurable nodes', () => { + const node: WAFRuleNode = { + id: 'match', + type: 'ip_match', + position: { x: 0, y: 0 }, + config: { ips: [], cidrs: [], ip_group_ids: [] }, + }; + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByLabelText('显示名称'), { + target: { value: '内网放行' }, + }); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ label: '内网放行' }), + ); +}); + +it('hides display name for system nodes', () => { + const node: WAFRuleNode = { + id: 'start', + type: 'start', + position: { x: 0, y: 0 }, + config: {}, + }; + render(); + expect(screen.queryByLabelText('显示名称')).not.toBeInTheDocument(); + expect(screen.getByText('系统节点无需配置。')).toBeInTheDocument(); +}); + it('edits IP group config through a typed multi-select', async () => { const node: WAFRuleNode = { id: 'match', diff --git a/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx b/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx index 60100aea..35e8c4c9 100644 --- a/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx @@ -29,6 +29,7 @@ import { Textarea } from '@/components/ui/textarea'; import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare'; import { countryOptions, regionOptions, type GeoOption } from './geo-options'; +import { NODE_TYPE_LABELS } from './node-factory'; export function NodeProperties({ node, @@ -72,6 +73,7 @@ function PropertyFields({ if (node.type === 'ip_match') return ( + + + 算法 onChange({ ...node, label: event.target.value })} + /> + + ); +} + function CsvField({ id, label, 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 0f74db54..0f1a643a 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,6 @@ 'use client'; -import { useCallback, useEffect, useRef } from 'react'; +import { type DragEvent, useCallback, useEffect, useRef } from 'react'; import { addEdge, applyEdgeChanges, @@ -22,11 +22,7 @@ import { Trash2 } from 'lucide-react'; import '@xyflow/react/dist/style.css'; import { Button } from '@/components/ui/button'; -import type { - WAFRuleEdge, - WAFRuleGraph, - WAFRuleNode, -} from '@/lib/services/openflare'; +import type { WAFRuleEdge, WAFRuleGraph } from '@/lib/services/openflare'; import { acceptedNodeChanges, @@ -40,6 +36,12 @@ import { removeEdgeFromGraph, removeNodeFromGraph, } from './graph-validation'; +import { + createRuleNode, + parseAddableNodeType, + WAF_NODE_DRAG_MIME, + type AddableNodeType, +} from './node-factory'; import { NodeLibrary } from './node-library'; import { RuleNode, type RuleFlowNodeData } from './rule-node'; @@ -201,40 +203,41 @@ export function RuleFlowCanvas({ [edges, graph, isValidConnection, onGraphChange, setEdges], ); - const addNode = useCallback( - (type: 'ip_match' | 'geo_match' | 'pow' | 'block') => { - const id = `${type}-${crypto.randomUUID().slice(0, 8)}`; - const config = - type === 'ip_match' - ? { ips: [], cidrs: [], ip_group_ids: [] } - : type === 'geo_match' - ? { countries: [], regions: [] } - : type === 'pow' - ? { - algorithm: 'fast' as const, - difficulty: 4, - session_ttl: 3600, - challenge_ttl: 300, - } - : { status_code: 403, response_body: '' }; + const addNodeAt = useCallback( + (type: AddableNodeType, position: { x: number; y: number }) => { + const node = createRuleNode(type, position); onGraphChange({ ...graph, - nodes: [ - ...graph.nodes, - { - id, - type, - position: { x: 240, y: 140 + graph.nodes.length * 24 }, - config, - } as WAFRuleNode, - ], + nodes: [...graph.nodes, node], }); onSelectEdge(undefined); - onSelect(id); + onSelect(node.id); }, [graph, onGraphChange, onSelect, onSelectEdge], ); + const onDragOver = useCallback((event: DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + }, []); + + const onDrop = useCallback( + (event: DragEvent) => { + event.preventDefault(); + const raw = + event.dataTransfer.getData(WAF_NODE_DRAG_MIME) || + event.dataTransfer.getData('text/plain'); + const type = parseAddableNodeType(raw); + if (!type || !instance.current) return; + const position = instance.current.screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + addNodeAt(type, position); + }, + [addNodeAt], + ); + const selectedNode = graph.nodes.find((node) => node.id === selectedId); const canDeleteNode = Boolean( selectedNode && !['start', 'allow'].includes(selectedNode.type), @@ -286,7 +289,7 @@ export function RuleFlowCanvas({ return (
- +