diff --git a/frontend/src/components/tree-editor/TreeCanvas.tsx b/frontend/src/components/tree-editor/TreeCanvas.tsx new file mode 100644 index 00000000..f268db79 --- /dev/null +++ b/frontend/src/components/tree-editor/TreeCanvas.tsx @@ -0,0 +1,636 @@ +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 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 [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) + + // 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] + ) + + // ── 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 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.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 itself */} + handleToggleExpand(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)} + /> + )} +
+ )} + + {/* Connector + Children */} + {hasChildren && !isExpanded && ( +
+ {/* 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 +
+ {/* Horizontal fork line */} +
+ + {/* Child lanes */} +
+ {orderedChildren.map(({ child, optionLabel: ol, childIndex }) => ( +
+ {/* Vertical stub into child lane */} +
+ {renderNode(child, node.id, childIndex, ol)} +
+ ))} +
+
+ )} +
+ )} +
+ ) + }, + [ + expandedNodeId, + newNodeIds, + dragOverTarget, + handleToggleExpand, + handleSave, + handleCancelNew, + handleDelete, + handleDuplicate, + 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