ReactFlow v12 requires a 'dark' CSS class on the component to activate dark theme variables. Without it, controls SVGs are invisible (dark on dark) and the minimap mask uses white (light theme default). Also fix library views (table, grid, list) to use getTreeEditorPath() instead of hardcoding /trees/:id/edit, which sent procedural/maintenance flows to the wrong editor. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
177 lines
5.9 KiB
TypeScript
177 lines
5.9 KiB
TypeScript
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="dark bg-accent/30"
|
|
>
|
|
<Background variant={BackgroundVariant.Dots} gap={20} size={1.5} color="hsl(var(--muted-foreground) / 0.25)" />
|
|
<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>
|
|
)
|
|
}
|