Files
resolutionflow/frontend/src/components/tree-editor/FlowCanvas.tsx
chihlasm 757ce6306c fix: add dark class to ReactFlow and fix editor routing for procedural flows
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>
2026-02-19 01:44:51 -05:00

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>
)
}