feat: add FlowCanvas main React Flow component with zoom/pan/minimap
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
176
frontend/src/components/tree-editor/FlowCanvas.tsx
Normal file
176
frontend/src/components/tree-editor/FlowCanvas.tsx
Normal file
@@ -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 (
|
||||
<div className="relative h-full w-full">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodeClick={handleNodeClick}
|
||||
onPaneClick={handlePaneClick}
|
||||
fitView
|
||||
minZoom={0.25}
|
||||
maxZoom={2}
|
||||
zoomOnScroll={false}
|
||||
zoomOnPinch={true}
|
||||
panOnScroll={true}
|
||||
panOnScrollMode={PanOnScrollMode.Vertical}
|
||||
selectionOnDrag={false}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
className="bg-background"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={24} size={1} color="hsl(var(--border))" />
|
||||
<Controls showInteractive={false} className="!bg-card !border-border !shadow-lg" />
|
||||
{minimapVisible && (
|
||||
<MiniMap
|
||||
pannable
|
||||
zoomable
|
||||
nodeColor={minimapNodeColor}
|
||||
className="!bg-card !border-border"
|
||||
nodeStrokeWidth={2}
|
||||
/>
|
||||
)}
|
||||
</ReactFlow>
|
||||
|
||||
{/* Minimap toggle button */}
|
||||
<button
|
||||
onClick={() => setMinimapVisible(v => !v)}
|
||||
className={cn(
|
||||
'absolute bottom-2 right-2 z-10 rounded-lg border border-border bg-card p-2 text-muted-foreground shadow-lg hover:bg-accent hover:text-foreground transition-colors',
|
||||
minimapVisible && 'bottom-[170px]'
|
||||
)}
|
||||
title={minimapVisible ? 'Hide minimap' : 'Show minimap'}
|
||||
>
|
||||
{minimapVisible ? <MapPinOff className="h-4 w-4" /> : <MapIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Wrap in ReactFlowProvider (required by useReactFlow hook)
|
||||
export function FlowCanvas(props: FlowCanvasProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<FlowCanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user