From 586c06be485d9fd48f9030c89c8275e71afb8830 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 21:19:22 -0500 Subject: [PATCH] feat: add FlowCanvas main React Flow component with zoom/pan/minimap Co-Authored-By: Claude Opus 4.6 --- .../src/components/tree-editor/FlowCanvas.tsx | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 frontend/src/components/tree-editor/FlowCanvas.tsx diff --git a/frontend/src/components/tree-editor/FlowCanvas.tsx b/frontend/src/components/tree-editor/FlowCanvas.tsx new file mode 100644 index 00000000..30e89afc --- /dev/null +++ b/frontend/src/components/tree-editor/FlowCanvas.tsx @@ -0,0 +1,176 @@ +import { useCallback, useMemo, useState, useEffect } from 'react' +import { + ReactFlow, + Background, + Controls, + MiniMap, + BackgroundVariant, + useReactFlow, + useNodesState, + useEdgesState, + ReactFlowProvider, + PanOnScrollMode, + type NodeMouseHandler, +} from '@xyflow/react' +import '@xyflow/react/dist/style.css' + +import { FlowCanvasNode, NODE_TYPE_CONFIG } from './FlowCanvasNode' +import { FlowCanvasAnswerNode } from './FlowCanvasAnswerNode' +import { useTreeLayout } from './useTreeLayout' +import { cn } from '@/lib/utils' +import { Map as MapIcon, MapPinOff } from 'lucide-react' +import type { FlowCanvasNodeData } from './FlowCanvasNode' +import type { FlowCanvasAnswerNodeData } from './FlowCanvasAnswerNode' + +const nodeTypes = { + flowNode: FlowCanvasNode, + answerStub: FlowCanvasAnswerNode, +} + +interface FlowCanvasProps { + selectedNodeId: string | null + onNodeSelect: (nodeId: string | null) => void + onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void +} + +function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType }: FlowCanvasProps) { + const { fitView, setCenter } = useReactFlow() + const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout() + const [minimapVisible, setMinimapVisible] = useState(true) + + // Inject callbacks into node data (because useTreeLayout creates placeholder functions) + const nodesWithCallbacks = useMemo(() => { + return layoutNodes.map(n => { + if (n.type === 'flowNode') { + const data = n.data as unknown as FlowCanvasNodeData + return { + ...n, + selected: n.id === selectedNodeId, + data: { ...data, onToggleCollapse: toggleCollapse }, + } + } + if (n.type === 'answerStub') { + const data = n.data as unknown as FlowCanvasAnswerNodeData + return { + ...n, + selected: n.id === selectedNodeId, + data: { ...data, onSelectType: onSelectAnswerType }, + } + } + return n + }) + }, [layoutNodes, selectedNodeId, toggleCollapse, onSelectAnswerType]) + + const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithCallbacks) + const [edges, setEdges, onEdgesChange] = useEdgesState(layoutEdges) + + // Sync layout changes into React Flow state + useEffect(() => { + setNodes(nodesWithCallbacks) + setEdges(layoutEdges) + }, [nodesWithCallbacks, layoutEdges, setNodes, setEdges]) + + // Fit view after layout changes + useEffect(() => { + // Small delay to let React Flow process the node updates + const timer = setTimeout(() => { + fitView({ padding: 0.1, duration: 200 }) + }, 50) + return () => clearTimeout(timer) + }, [layoutNodes.length, collapsedNodeIds.size]) // eslint-disable-line react-hooks/exhaustive-deps + + // Auto-center on selected node when panel opens + useEffect(() => { + if (!selectedNodeId) return + const node = nodes.find(n => n.id === selectedNodeId) + if (node) { + const x = node.position.x + 140 // center of 280px node + const y = node.position.y + 50 + setCenter(x, y, { duration: 300, zoom: 1 }) + } + }, [selectedNodeId]) // eslint-disable-line react-hooks/exhaustive-deps + + // Height measurement correction + useEffect(() => { + if (nodes.length > 0 && nodes.some(n => n.measured?.height)) { + onNodesMeasured(nodes) + } + }, [nodes]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleNodeClick: NodeMouseHandler = useCallback((_event, node) => { + onNodeSelect(node.id) + }, [onNodeSelect]) + + const handlePaneClick = useCallback(() => { + onNodeSelect(null) + }, [onNodeSelect]) + + // Custom minimap node color based on actual tree node type + const minimapNodeColor = useCallback((rfNode: { data?: unknown }) => { + const data = rfNode.data as (FlowCanvasNodeData & FlowCanvasAnswerNodeData) | undefined + if (!data || !('node' in data)) return '#6b7280' + const treeNode = data.node + if (treeNode.type === 'answer') return '#6b7280' + const config = NODE_TYPE_CONFIG[treeNode.type as keyof typeof NODE_TYPE_CONFIG] + return config?.minimapColor ?? '#6b7280' + }, []) + + return ( +
+ + + + {minimapVisible && ( + + )} + + + {/* Minimap toggle button */} + +
+ ) +} + +// Wrap in ReactFlowProvider (required by useReactFlow hook) +export function FlowCanvas(props: FlowCanvasProps) { + return ( + + + + ) +}