- Add AI Assist panel toggle button to tree editor toolbar - Wire EditorAIPanel alongside TreeEditorLayout with single-panel rule - Thread onNodeContextMenu through TreeEditorLayout → FlowCanvas → FlowCanvasNode - Add right-click context menu with Generate Branch, Explain Node, Delete actions - Add ghost node detection (_suggestion flag) with dashed border + opacity styling - Add Accept/Dismiss overlay buttons on ghost nodes for future suggestion handling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
178 lines
6.1 KiB
TypeScript
178 lines
6.1 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
|
|
onNodeContextMenu?: (e: React.MouseEvent, nodeId: string) => void
|
|
}
|
|
|
|
function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onNodeContextMenu }: 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, onContextMenu: onNodeContextMenu },
|
|
}
|
|
}
|
|
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, onNodeContextMenu])
|
|
|
|
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>
|
|
)
|
|
}
|