/* eslint-disable @typescript-eslint/no-unnecessary-condition */ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { Connection, Node, Edge, NodeTypes } from "@xyflow/react"; import { ReactFlow, Background, Controls, MiniMap, Panel, useNodesState, useEdgesState, addEdge, MarkerType, BackgroundVariant, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import type { GraphData, KnowledgeNode, KnowledgeEdge, EdgeCreateInput, } from "./hooks/useGraphData"; import { ConceptNode } from "./nodes/ConceptNode"; import { TaskNode } from "./nodes/TaskNode"; import { IdeaNode } from "./nodes/IdeaNode"; import { ProjectNode } from "./nodes/ProjectNode"; // Node type to color mapping const NODE_COLORS: Record = { concept: "#6366f1", // indigo idea: "#f59e0b", // amber task: "#10b981", // emerald project: "#3b82f6", // blue person: "#ec4899", // pink note: "#8b5cf6", // violet question: "#f97316", // orange }; // Relation type to label mapping const RELATION_LABELS: Record = { relates_to: "relates to", part_of: "part of", depends_on: "depends on", mentions: "mentions", blocks: "blocks", similar_to: "similar to", derived_from: "derived from", }; interface ReactFlowEditorProps { graphData: GraphData; onNodeSelect?: (node: KnowledgeNode | null) => void; onNodeUpdate?: (id: string, updates: Partial) => void; onNodeDelete?: (id: string) => void; onEdgeCreate?: (edge: EdgeCreateInput) => void; className?: string; readOnly?: boolean; } // Custom node types const nodeTypes: NodeTypes = { concept: ConceptNode, task: TaskNode, idea: IdeaNode, project: ProjectNode, default: ConceptNode, }; function convertToReactFlowNodes(nodes: KnowledgeNode[]): Node[] { // Simple grid layout for initial positioning const COLS = 4; const X_SPACING = 250; const Y_SPACING = 150; return nodes.map((node, index) => ({ id: node.id, type: node.node_type in nodeTypes ? node.node_type : "default", position: { x: (index % COLS) * X_SPACING + Math.random() * 50, y: Math.floor(index / COLS) * Y_SPACING + Math.random() * 30, }, data: { label: node.title, content: node.content, nodeType: node.node_type, tags: node.tags, domain: node.domain, id: node.id, metadata: node.metadata, created_at: node.created_at, updated_at: node.updated_at, }, style: { borderColor: NODE_COLORS[node.node_type] ?? NODE_COLORS.concept, }, })); } function convertToReactFlowEdges(edges: KnowledgeEdge[]): Edge[] { return edges.map( (edge): Edge => ({ // Use stable ID based on source, target, and relation type id: `${edge.source_id}-${edge.target_id}-${edge.relation_type}`, source: edge.source_id, target: edge.target_id, label: RELATION_LABELS[edge.relation_type] ?? edge.relation_type, type: "smoothstep", animated: edge.relation_type === "depends_on" || edge.relation_type === "blocks", markerEnd: { type: MarkerType.ArrowClosed, width: 20, height: 20, }, data: { relationType: edge.relation_type, weight: edge.weight, }, style: { strokeWidth: Math.max(1, edge.weight * 3), opacity: 0.6 + edge.weight * 0.4, }, }) ); } export function ReactFlowEditor({ graphData, onNodeSelect, onNodeUpdate, onNodeDelete, onEdgeCreate, className = "", readOnly = false, }: ReactFlowEditorProps): React.JSX.Element { const [selectedNode, setSelectedNode] = useState(null); const initialNodes = useMemo(() => convertToReactFlowNodes(graphData.nodes), [graphData.nodes]); const initialEdges = useMemo(() => convertToReactFlowEdges(graphData.edges), [graphData.edges]); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); // Update nodes/edges when graphData changes useEffect((): void => { setNodes(convertToReactFlowNodes(graphData.nodes)); setEdges(convertToReactFlowEdges(graphData.edges)); }, [graphData, setNodes, setEdges]); const onConnect = useCallback( (params: Connection): void => { if (readOnly || !params.source || !params.target) return; // Create edge in backend if (onEdgeCreate) { onEdgeCreate({ source_id: params.source, target_id: params.target, relation_type: "relates_to", weight: 1.0, metadata: {}, }); } setEdges((eds) => addEdge( { ...params, type: "smoothstep", markerEnd: { type: MarkerType.ArrowClosed }, }, eds ) ); }, [readOnly, onEdgeCreate, setEdges] ); const onNodeClick = useCallback( (_event: React.MouseEvent, node: Node): void => { setSelectedNode(node.id); if (onNodeSelect) { const knowledgeNode = graphData.nodes.find((n): boolean => n.id === node.id); onNodeSelect(knowledgeNode ?? null); } }, [graphData.nodes, onNodeSelect] ); const onPaneClick = useCallback((): void => { setSelectedNode(null); if (onNodeSelect) { onNodeSelect(null); } }, [onNodeSelect]); const onNodeDragStop = useCallback( (_event: React.MouseEvent, node: Node): void => { if (readOnly) return; // Could save position to metadata if needed if (onNodeUpdate) { onNodeUpdate(node.id, { metadata: { position: node.position }, }); } }, [readOnly, onNodeUpdate] ); const handleDeleteSelected = useCallback(() => { if (readOnly ?? !selectedNode) return; if (onNodeDelete) { onNodeDelete(selectedNode); } setNodes((nds) => nds.filter((n) => n.id !== selectedNode)); setEdges((eds) => eds.filter((e) => e.source !== selectedNode && e.target !== selectedNode)); setSelectedNode(null); }, [readOnly, selectedNode, onNodeDelete, setNodes, setEdges]); // Keyboard shortcuts useEffect((): (() => void) => { const handleKeyDown = (event: KeyboardEvent): void => { if (readOnly) return; if (event.key === "Delete" || event.key === "Backspace") { if (selectedNode && document.activeElement === document.body) { event.preventDefault(); handleDeleteSelected(); } } }; document.addEventListener("keydown", handleKeyDown); return (): void => { document.removeEventListener("keydown", handleKeyDown); }; }, [readOnly, selectedNode, handleDeleteSelected]); const isDark = typeof window !== "undefined" && document.documentElement.classList.contains("dark"); return (
NODE_COLORS[node.data.nodeType as string] ?? "#6366f1"} maskColor={isDark ? "rgba(0, 0, 0, 0.8)" : "rgba(255, 255, 255, 0.8)"} className="bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700" />
{graphData.nodes.length} nodes, {graphData.edges.length} edges
{selectedNode && !readOnly && ( )}
); }