import { useState, useCallback, useRef, useEffect } from 'react' import { HelpCircle, Zap, CheckCircle, Plus, X } from 'lucide-react' import { useTreeEditorStore, findNodeInTree } from '@/store/treeEditorStore' import { TreeCanvasNode } from './TreeCanvasNode' import { AnswerStubCard } from './AnswerStubCard' import type { TreeStructure, NodeType } from '@/types' import { cn } from '@/lib/utils' // ─── Types ─────────────────────────────────────────────────────────────────── interface PendingLink { parentId: string optionId?: string // For decision option linking } interface DragState { nodeId: string parentId: string | null index: number } // ─── Reference cleanup helper ───────────────────────────────────────────────── /** * Before deleting a node, clear all inbound references to it across the tree. * This prevents stale next_node_id / option.next_node_id references. */ function clearInboundReferences( nodeId: string, treeStructure: TreeStructure, updateNode: (id: string, updates: Partial) => void ) { function walk(node: TreeStructure) { // Clear decision option references if (node.type === 'decision' && node.options) { const needsUpdate = node.options.some((o) => o.next_node_id === nodeId) if (needsUpdate) { updateNode(node.id, { options: node.options.map((o) => o.next_node_id === nodeId ? { ...o, next_node_id: '' } : o ), }) } } // Clear action next_node_id references if (node.type === 'action' && node.next_node_id === nodeId) { updateNode(node.id, { next_node_id: '' }) } // Recurse node.children?.forEach(walk) } walk(treeStructure) } // ─── Add-node type picker ───────────────────────────────────────────────────── interface AddNodePickerProps { onSelect: (type: NodeType) => void onCancel: () => void } function AddNodePicker({ onSelect, onCancel }: AddNodePickerProps) { return (
Add:
) } // ─── Add-node trigger button ────────────────────────────────────────────────── interface AddNodeButtonProps { label?: string onClick: () => void } function AddNodeButton({ label = 'Add node', onClick }: AddNodeButtonProps) { return ( ) } // ─── Add-key builder ────────────────────────────────────────────────────────── /** Unique key for an add-target: "parentId" or "parentId:optionId" */ function addKey(parentId: string, optionId?: string) { return optionId ? `${parentId}:${optionId}` : parentId } // ─── TreeCanvas ─────────────────────────────────────────────────────────────── export function TreeCanvas() { const { treeStructure, addNode, updateNode, deleteNode, duplicateNode, reorderNodes, selectNode, selectedNodeId, } = useTreeEditorStore() // ── Local canvas state ── const [expandedNodeId, setExpandedNodeId] = useState(null) const [newNodeIds, setNewNodeIds] = useState>(new Set()) const [collapsedNodeIds, setCollapsedNodeIds] = useState>(new Set()) const [pendingAddKey, setPendingAddKey] = useState(null) const [pendingLinks, setPendingLinks] = useState>( new Map() ) const [dragState, setDragState] = useState(null) const [dragOverTarget, setDragOverTarget] = useState<{ parentId: string | null index: number } | null>(null) // Node ref map for scroll-into-view const nodeRefs = useRef>(new Map()) // ── Selection sync ── // When selectedNodeId changes externally (e.g. ValidationSummary click), // auto-expand that card and scroll it into view. useEffect(() => { if (selectedNodeId && selectedNodeId !== expandedNodeId) { setExpandedNodeId(selectedNodeId) const el = nodeRefs.current.get(selectedNodeId) if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedNodeId]) // ── Card expand/collapse ── const handleToggleExpand = useCallback( (nodeId: string) => { setExpandedNodeId((prev) => (prev === nodeId ? null : nodeId)) selectNode(nodeId) }, [selectNode] ) // ── Save inline edits ── const handleSave = useCallback( (nodeId: string, updates: Partial) => { updateNode(nodeId, updates) // For decision nodes: strip blank options, then create answer stubs for any // labelled option that doesn't yet have a linked child if (updates.options) { const options = updates.options.filter((o) => o.label.trim()) const stubsToCreate: Array<{ opt: typeof options[number]; stubId: string }> = [] options.forEach((opt) => { if (!opt.next_node_id) { const stubId = addNode(nodeId, 'answer') updateNode(stubId, { title: opt.label }) stubsToCreate.push({ opt, stubId }) } }) // Write back: filtered options + any newly assigned next_node_ids const updatedOptions = options.map((o) => { const stub = stubsToCreate.find((s) => s.opt.id === o.id) return stub ? { ...o, next_node_id: stub.stubId } : o }) updateNode(nodeId, { options: updatedOptions }) } // Resolve pending link for new nodes const link = pendingLinks.get(nodeId) if (link) { const parentNode = treeStructure ? findNodeInTree(link.parentId, treeStructure) : null if (parentNode) { if (link.optionId && parentNode.type === 'decision' && parentNode.options) { // Link the decision option to this new child node const updatedOptions = parentNode.options.map((o) => o.id === link.optionId ? { ...o, next_node_id: nodeId } : o ) updateNode(link.parentId, { options: updatedOptions }) } else if (parentNode.type === 'action') { // Link the action's next node updateNode(link.parentId, { next_node_id: nodeId }) } } setPendingLinks((prev) => { const next = new Map(prev) next.delete(nodeId) return next }) } setNewNodeIds((prev) => { const next = new Set(prev) next.delete(nodeId) return next }) setExpandedNodeId(null) }, [pendingLinks, treeStructure, updateNode] ) // ── Cancel new node ── const handleCancelNew = useCallback( (nodeId: string) => { deleteNode(nodeId) setNewNodeIds((prev) => { const next = new Set(prev) next.delete(nodeId) return next }) setPendingLinks((prev) => { const next = new Map(prev) next.delete(nodeId) return next }) if (expandedNodeId === nodeId) setExpandedNodeId(null) }, [deleteNode, expandedNodeId] ) // ── Delete node (with inbound reference cleanup) ── const handleDelete = useCallback( (nodeId: string) => { if (!treeStructure) return clearInboundReferences(nodeId, treeStructure, updateNode) deleteNode(nodeId) if (expandedNodeId === nodeId) setExpandedNodeId(null) }, [treeStructure, updateNode, deleteNode, expandedNodeId] ) // ── Duplicate node ── const handleDuplicate = useCallback( (nodeId: string) => { duplicateNode(nodeId) }, [duplicateNode] ) // ── Subtree collapse toggle ── const handleToggleSubtreeCollapse = useCallback((nodeId: string) => { setCollapsedNodeIds((prev) => { const next = new Set(prev) if (next.has(nodeId)) next.delete(nodeId) else next.add(nodeId) return next }) }, []) // ── Convert answer stub to a real node type ── const handleSelectAnswerType = useCallback( (nodeId: string, type: 'decision' | 'action' | 'solution') => { updateNode(nodeId, { type }) setExpandedNodeId(nodeId) selectNode(nodeId) }, [updateNode, selectNode] ) // ── Add node flow ── const handleAddNodeSelect = useCallback( (type: NodeType, parentId: string, optionId?: string) => { const newId = addNode(parentId, type) setNewNodeIds((prev) => new Set([...prev, newId])) setPendingLinks((prev) => { const next = new Map(prev) next.set(newId, { parentId, optionId }) return next }) setExpandedNodeId(newId) setPendingAddKey(null) }, [addNode] ) // ── Drag & drop ── const handleDragStart = useCallback( (e: React.DragEvent, nodeId: string) => { e.dataTransfer.effectAllowed = 'move' // Find parent and index for this node const findParentAndIndex = ( searchNode: TreeStructure, targetId: string, // eslint-disable-next-line @typescript-eslint/no-unused-vars _parentId: string | null ): { parentId: string | null; index: number } | null => { if (searchNode.children) { for (let i = 0; i < searchNode.children.length; i++) { if (searchNode.children[i].id === targetId) { return { parentId: searchNode.id, index: i } } const found = findParentAndIndex( searchNode.children[i], targetId, searchNode.id ) if (found) return found } } return null } if (!treeStructure) return const location = findParentAndIndex(treeStructure, nodeId, null) if (location) { setDragState({ nodeId, parentId: location.parentId, index: location.index, }) } }, [treeStructure] ) const handleDragOver = useCallback( (e: React.DragEvent, parentId: string | null, index: number) => { e.preventDefault() setDragOverTarget({ parentId, index }) }, [] ) const handleDrop = useCallback( (e: React.DragEvent, targetParentId: string | null, targetIndex: number) => { e.preventDefault() if (!dragState || !targetParentId) { setDragState(null) setDragOverTarget(null) return } const { parentId: sourceParentId, index: sourceIndex } = dragState if (sourceParentId === targetParentId) { const adjustedIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex if (sourceIndex !== adjustedIndex) { reorderNodes(sourceParentId!, sourceIndex, adjustedIndex) } } // Cross-parent move intentionally not supported in canvas (complex to handle safely) setDragState(null) setDragOverTarget(null) }, [dragState, reorderNodes] ) const handleDragEnd = useCallback(() => { setDragState(null) setDragOverTarget(null) }, []) // ── Recursive node renderer ── const renderNode = useCallback( ( node: TreeStructure, parentId: string | null, index: number, optionLabel?: string ): React.ReactNode => { const isExpanded = expandedNodeId === node.id const isNew = newNodeIds.has(node.id) const isSubtreeCollapsed = collapsedNodeIds.has(node.id) const nodeChildren = node.children || [] // For decision nodes, order children by option link order const orderedChildren: Array<{ child: TreeStructure optionLabel?: string optionId?: string childIndex: number }> = [] if (node.type === 'decision' && node.options && nodeChildren.length > 0) { // First: children linked by options (in option order) const linkedChildIds = new Set() node.options.forEach((opt) => { const linked = nodeChildren.find((c) => c.id === opt.next_node_id) if (linked) { orderedChildren.push({ child: linked, optionLabel: opt.label || undefined, optionId: opt.id, childIndex: nodeChildren.indexOf(linked), }) linkedChildIds.add(linked.id) } }) // Then: unlinked children nodeChildren.forEach((child, idx) => { if (!linkedChildIds.has(child.id)) { orderedChildren.push({ child, childIndex: idx, }) } }) } else { nodeChildren.forEach((child, idx) => { orderedChildren.push({ child, childIndex: idx }) }) } // Determine if this node has any children to render const hasChildren = orderedChildren.length > 0 // Determine "add" targets for this node // For decision nodes: one add-button per option (not-yet-linked options) // For action nodes: one add-button below // For solution: none const unlinkedOptions = node.type === 'decision' && node.options ? node.options.filter( (opt) => opt.label.trim() && (!opt.next_node_id || !nodeChildren.find((c) => c.id === opt.next_node_id)) ) : [] const showSingleAddButton = node.type === 'action' && !hasChildren return (
{ if (el) nodeRefs.current.set(node.id, el as HTMLDivElement) else nodeRefs.current.delete(node.id) }} > {/* Drop indicator above */} {dragOverTarget?.parentId === parentId && dragOverTarget.index === index && (
)} {/* Option label tag (above card, shown when this is a branch from a decision) */} {optionLabel && (
{optionLabel}
)} {/* The node card — answer stubs get their own component */} {node.type === 'answer' ? ( ) : ( 0} isSubtreeCollapsed={isSubtreeCollapsed} onToggleExpand={() => handleToggleExpand(node.id)} onToggleSubtreeCollapse={() => handleToggleSubtreeCollapse(node.id)} onSave={handleSave} onCancelNew={handleCancelNew} onDelete={handleDelete} onDuplicate={handleDuplicate} onDragStart={handleDragStart} onDragOver={(e) => handleDragOver(e, parentId, index)} onDrop={(e) => handleDrop(e, parentId, index)} /> )} {/* Unlinked option add buttons (decision nodes with unlinked options) */} {!isExpanded && unlinkedOptions.length > 0 && (
{unlinkedOptions.map((opt) => { const key = addKey(node.id, opt.id) return (
{opt.label || '(unlabeled option)'} {pendingAddKey === key ? ( handleAddNodeSelect(type, node.id, opt.id) } onCancel={() => setPendingAddKey(null)} /> ) : ( setPendingAddKey(key)} /> )}
) })}
)} {/* Single add button for action nodes without children */} {!isExpanded && showSingleAddButton && (
{pendingAddKey === node.id ? ( handleAddNodeSelect(type, node.id)} onCancel={() => setPendingAddKey(null)} /> ) : ( setPendingAddKey(node.id)} /> )}
)} {/* Collapsed subtree pill */} {hasChildren && !isExpanded && isSubtreeCollapsed && (
)} {/* Connector + Children */} {hasChildren && !isExpanded && !isSubtreeCollapsed && (
{/* Trunk line from card down */}
{orderedChildren.length === 1 ? ( // Single child: straight vertical
{renderNode( orderedChildren[0].child, node.id, orderedChildren[0].childIndex, orderedChildren[0].optionLabel )}
) : ( // Multiple children: horizontal branching // The fork line and child lanes share the same flex container so the // line is sized by the actual rendered children, not a hardcoded estimate.
{/* Horizontal fork line — absolutely positioned, aligned to child centers. Spans from center of first lane to center of last lane. */}
{orderedChildren.map(({ child, optionLabel: ol, childIndex }) => (
{/* Vertical stub into child lane */}
{renderNode(child, node.id, childIndex, ol)}
))}
)}
)}
) }, [ expandedNodeId, newNodeIds, collapsedNodeIds, dragOverTarget, handleToggleExpand, handleToggleSubtreeCollapse, handleSave, handleCancelNew, handleDelete, handleDuplicate, handleSelectAnswerType, handleDragStart, handleDragOver, handleDrop, pendingAddKey, handleAddNodeSelect, ] ) // ── Empty state ── if (!treeStructure) { return (
No tree structure. Start by saving a tree name.
) } return (
{/* START badge above root */}
START
{renderNode(treeStructure, null, 0)}
) } export default TreeCanvas