'use client'; import { type DragEvent, type MouseEvent as ReactMouseEvent, useCallback, useEffect, useRef, useState, } from 'react'; import { addEdge, applyEdgeChanges, Background, Controls, MiniMap, ReactFlow, useEdgesState, useNodesState, type Connection, type Edge, type EdgeChange, type FitViewOptions, type Node, type NodeChange, type ReactFlowInstance, } from '@xyflow/react'; import { Trash2 } from 'lucide-react'; import '@xyflow/react/dist/style.css'; import type { WAFRuleEdge, WAFRuleGraph } from '@/lib/services/openflare'; import { cn } from '@/lib/utils'; import { acceptedNodeChanges, filterRemovableNodeIds, type GraphErrorTarget, isConnectionAllowed, isPersistentEdgeChange, } from './editor-behavior'; import { type GraphIssue, 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'; const nodeTypes = { rule: RuleNode }; const initialFitViewOptions = { padding: 0.3, maxZoom: 0.85, } satisfies FitViewOptions; type FlowNode = Node; type FlowEdge = Edge; interface RuleFlowCanvasProps { graph: WAFRuleGraph; issues: GraphIssue[]; selectedId?: string; selectedEdgeId?: string; focusTarget?: GraphErrorTarget; onGraphChange: (graph: WAFRuleGraph, persistent?: boolean) => void; onSelect: (id?: string) => void; onSelectEdge: (id?: string) => void; } export function RuleFlowCanvas({ graph, issues, selectedId, selectedEdgeId, focusTarget, onGraphChange, onSelect, onSelectEdge, }: RuleFlowCanvasProps) { const instance = useRef | null>(null); const [nodes, setNodes, applyFlowNodeChanges] = useNodesState( buildFlowNodes(graph, issues, selectedId), ); 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)); }, [graph, issues, selectedId, setNodes]); useEffect(() => { setEdges((current) => buildFlowEdges(graph, selectedId, selectedEdgeId, current), ); }, [graph, selectedEdgeId, selectedId, setEdges]); useEffect(() => { if (!focusTarget || !instance.current) return; if (focusTarget.kind === 'node') { void instance.current.fitView({ nodes: [{ id: focusTarget.id }], duration: 350, maxZoom: 1.4, }); return; } const edge = graph.edges.find((item) => item.id === focusTarget.id); if (edge) void instance.current.fitView({ nodes: [{ id: edge.source }, { id: edge.target }], duration: 350, maxZoom: 1.4, }); }, [focusTarget, graph.edges]); const onNodesChange = useCallback( (changes: NodeChange[]) => { const accepted = acceptedNodeChanges(graph.nodes, changes); if (accepted.changes.length === 0) return; applyFlowNodeChanges(accepted.changes); if (!accepted.persistent) return; const removals = filterRemovableNodeIds( graph.nodes, accepted.changes .filter((change) => change.type === 'remove') .map((change) => change.id), ); let next = graph; for (const id of removals) next = removeNodeFromGraph(next, id); const positions = new Map(); for (const change of accepted.changes) { if ( change.type === 'position' && change.dragging === false && change.position ) { positions.set(change.id, change.position); } } next = { ...next, nodes: next.nodes.map((node) => ({ ...node, position: positions.get(node.id) ?? node.position, })), }; onGraphChange(next); if (selectedId && removals.includes(selectedId)) onSelect(undefined); }, [applyFlowNodeChanges, graph, onGraphChange, onSelect, selectedId], ); const onEdgesChange = useCallback( (changes: EdgeChange[]) => { applyFlowEdgeChanges(changes); const persistent = changes.filter(isPersistentEdgeChange); if (persistent.length === 0) return; const next = applyEdgeChanges(persistent, edges); onGraphChange({ ...graph, edges: next.map(toRuleEdge) }); if ( selectedEdgeId && persistent.some( (change) => change.type === 'remove' && change.id === selectedEdgeId, ) ) { onSelectEdge(undefined); } }, [ applyFlowEdgeChanges, edges, graph, onGraphChange, onSelectEdge, selectedEdgeId, ], ); const isValidConnection = useCallback( (connection: Edge | Connection) => { return isConnectionAllowed(graph, connection); }, [graph], ); const onConnect = useCallback( (connection: Connection) => { if (!isValidConnection(connection)) return; const next = addEdge( { ...connection, id: `${connection.source}-${connection.sourceHandle}-${connection.target}`, }, edges, ); setEdges(next); onGraphChange({ ...graph, edges: next.map(toRuleEdge) }); }, [edges, graph, isValidConnection, onGraphChange, setEdges], ); 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((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 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 !== nodeId && edge.target !== nodeId, ), ); 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 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 (
{ instance.current = value; }} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} isValidConnection={isValidConnection} onBeforeDelete={async ({ nodes: deletingNodes }) => !deletingNodes.some((node) => ['start', 'allow'].includes(node.data.rule.type), ) } 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' && ( )}
)}
); } function buildFlowNodes( graph: WAFRuleGraph, issues: GraphIssue[], selectedId?: string, current: FlowNode[] = [], ): FlowNode[] { const existing = new Map(current.map((node) => [node.id, node])); const issueCounts = new Map(); for (const issue of issues) { if (issue.nodeId) issueCounts.set(issue.nodeId, (issueCounts.get(issue.nodeId) ?? 0) + 1); } return graph.nodes.map((rule) => ({ ...existing.get(rule.id), id: rule.id, type: 'rule', position: rule.position, selected: rule.id === selectedId, data: { rule, issues: issueCounts.get(rule.id) ?? 0 }, })); } function buildFlowEdges( graph: WAFRuleGraph, selectedId?: string, selectedEdgeId?: string, current: FlowEdge[] = [], ): FlowEdge[] { const existing = new Map(current.map((edge) => [edge.id, edge])); return graph.edges.map((edge) => ({ ...existing.get(edge.id), id: edge.id, source: edge.source, sourceHandle: edge.source_handle, target: edge.target, selected: edge.id === selectedEdgeId, animated: selectedId === edge.source || selectedId === edge.target, })); } function toRuleEdge(edge: Edge): WAFRuleEdge { return { id: edge.id, source: edge.source, source_handle: edge.sourceHandle ?? '', target: edge.target, }; }