feat: canvas UX fixes — scroll, fullscreen, InfoTip tooltips, answer stub system (#80)
* feat: Add TreeCanvasNode inline editor card component Replaces modal-based node editing with inline expand/collapse cards. Each card shows node type, title, and options in compact mode, then renders the full edit form inline on expand — no modal required. Local draft state with save/cancel prevents premature store writes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: Add TreeCanvas layout with visual branching and orchestration Replaces NodeList + TreePreviewPanel with a single full-width canvas. Decision nodes branch horizontally; action/solution nodes flow vertically. Inline type picker adds nodes without modal interruption. Handles pending link resolution, inbound reference cleanup on delete, and selection sync. CSS dot-grid background + connector lines for structure clarity. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: Update forms for inline safety, add MetadataSidePanel, update layout - NodeFormDecision: option reorder via onUpdate (no premature store writes) - NodePicker: add allowCreate prop (default true) to hide Create New options during inline canvas editing, preventing side-effect node creation - MetadataSidePanel: 320px right slide-in overlay wrapping TreeMetadataForm, closes on backdrop click, close button, and Escape key - TreeEditorLayout: Flow mode now renders full-width TreeCanvas + MetadataSidePanel overlay; Code mode unchanged (Monaco + preview split) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: Wire toolbar metadata toggle and integrate canvas layout - Add isMetadataOpen state in TreeEditorPage - Add Metadata toolbar button (visible in Flow mode only) - Auto-close metadata panel when switching to Code mode - Pass isMetadataOpen/onCloseMetadata props through TreeEditorLayout - Update Flow mode toggle tooltip to reflect new canvas editing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add canvas UX fixes design doc (scroll, tooltips, answer stubs) Captures approved design for three post-implementation UX improvements to the tree canvas editor: card scroll fix, info tooltip replacement for hint text, and the new 'answer' node type for sketching decision branches before assigning types. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add implementation plan for canvas UX fixes 12-task plan covering scroll fix, info tooltips, and answer stub node type. Each task has exact file paths, code, and build verification steps. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: make canvas card expanded area scrollable with sticky header Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add fullscreen toggle to Modal, enable in NodeEditorModal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add reusable InfoTip component for field-level help Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: replace hint paragraphs with InfoTip tooltips in NodeFormDecision Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: replace hint paragraphs with InfoTip tooltips in NodeFormAction Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: replace hint paragraphs with InfoTip tooltips in NodeFormResolution Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add 'answer' to NodeType union for branch placeholder stubs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add AnswerStubCard component for unresolved branch placeholders Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: guard NODE_TYPE_CONFIG lookup against 'answer' type Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: redesign NodeFormDecision to label-only options, remove NodePicker Users now type answer labels only. Stub nodes are created automatically by TreeCanvas when the decision node is saved. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: auto-create answer stubs on decision save, render AnswerStubCard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add answer type to all Record<NodeType> icon and color maps Fixes NodeList, ContinuationModal, NodePicker, and TreePreviewNode. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: allow 'answer' type in tree drafts, block on publish Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: block publish if unresolved answer stub nodes exist Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: serialize 'answer' stub nodes in markdown output Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add defensive guard for answer nodes in session navigation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add delete button with confirmation to AnswerStubCard Adds an inline delete flow to answer stub placeholder cards: - Trash icon button (top-right, subtle) visible in idle state - Click reveals "Delete this stub?" confirmation with Delete/Cancel - Confirmed delete calls onDelete(nodeId) wired to handleDelete in TreeCanvas Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: prevent category Cancel overflow and add Tab/Enter to create options - TreeMetadataForm: add min-w-0 + shrink-0 to keep Cancel button in-panel - NodeFormDecision: Tab or Enter on the last non-empty option input adds a new option and auto-focuses it; empty last input lets Tab pass through normally Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: re-sync draft from store when canvas card is opened When a decision node is saved with new options, stub next_node_id values are written back to the store. But the local draft was initialized once at mount and never refreshed, so reopening the card gave a stale draft with empty next_node_ids — causing duplicate stubs on every subsequent save. Fix: reset draft from the live node whenever isExpanded transitions to true. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix+feat: blank options, stub card dismiss, collapsible subtrees - TreeCanvas: strip blank-label options on save so they don't generate stubs; also filter them from the unlinked-option add-button list - AnswerStubCard: collapse type-picker when clicking outside the card - TreeCanvasNode: add subtree collapse toggle button (ChevronsDownUp icon) visible in compact mode when the node has children - TreeCanvas: track collapsedNodeIds; hide subtree behind a clickable "N nodes hidden" pill when collapsed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: stop connector fork line from overlapping child cards Replace the two-element approach (separate fork line + child lanes div with mismatched maxWidth values) with a single relative-positioned container. The fork line is absolutely positioned and its left/right are calculated from the number of children so it spans exactly from the center of the first lane to the center of the last lane. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: replace Show Drafts checkbox with Drafts tab in Flow Library - Remove the out-of-place checkbox; add 'Drafts' as a tab alongside All | Troubleshooting | Projects | Maintenance - Drafts tab sets showDrafts=true + typeFilter='all' so the API filter still works correctly via include_drafts - Move SortDropdown to the right side next to ViewToggle, so both secondary controls are grouped together Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #80.
This commit is contained in:
713
frontend/src/components/tree-editor/TreeCanvas.tsx
Normal file
713
frontend/src/components/tree-editor/TreeCanvas.tsx
Normal file
@@ -0,0 +1,713 @@
|
||||
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<TreeStructure>) => 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 (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-dashed border-primary/40 bg-card px-3 py-2 shadow-sm">
|
||||
<span className="text-xs text-muted-foreground shrink-0">Add:</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect('decision')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md px-2 py-1 text-xs font-label',
|
||||
'border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20'
|
||||
)}
|
||||
>
|
||||
<HelpCircle className="h-3 w-3" />
|
||||
Decision
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect('action')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md px-2 py-1 text-xs font-label',
|
||||
'border border-yellow-500/30 bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20'
|
||||
)}
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
Action
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect('solution')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md px-2 py-1 text-xs font-label',
|
||||
'border border-green-500/30 bg-green-500/10 text-green-400 hover:bg-green-500/20'
|
||||
)}
|
||||
>
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Solution
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="ml-1 rounded p-0.5 text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Add-node trigger button ──────────────────────────────────────────────────
|
||||
|
||||
interface AddNodeButtonProps {
|
||||
label?: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function AddNodeButton({ label = 'Add node', onClick }: AddNodeButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs font-label',
|
||||
'border border-dashed border-border text-muted-foreground',
|
||||
'hover:border-primary/40 hover:text-foreground hover:bg-accent/50',
|
||||
'transition-all duration-150'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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<string | null>(null)
|
||||
const [newNodeIds, setNewNodeIds] = useState<Set<string>>(new Set())
|
||||
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set())
|
||||
const [pendingAddKey, setPendingAddKey] = useState<string | null>(null)
|
||||
const [pendingLinks, setPendingLinks] = useState<Map<string, PendingLink>>(
|
||||
new Map()
|
||||
)
|
||||
const [dragState, setDragState] = useState<DragState | null>(null)
|
||||
const [dragOverTarget, setDragOverTarget] = useState<{
|
||||
parentId: string | null
|
||||
index: number
|
||||
} | null>(null)
|
||||
|
||||
// Node ref map for scroll-into-view
|
||||
const nodeRefs = useRef<Map<string, HTMLDivElement>>(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<TreeStructure>) => {
|
||||
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<string>()
|
||||
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 (
|
||||
<div
|
||||
key={node.id}
|
||||
className="flex flex-col items-center"
|
||||
ref={(el) => {
|
||||
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 && (
|
||||
<div className="mb-1 h-1 w-full rounded-full bg-primary" />
|
||||
)}
|
||||
|
||||
{/* Option label tag (above card, shown when this is a branch from a decision) */}
|
||||
{optionLabel && (
|
||||
<div className="mb-1 rounded bg-muted px-2 py-0.5 text-[10px] text-muted-foreground font-label">
|
||||
{optionLabel}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The node card — answer stubs get their own component */}
|
||||
{node.type === 'answer' ? (
|
||||
<AnswerStubCard
|
||||
node={node}
|
||||
fromOption={optionLabel}
|
||||
onSelectType={handleSelectAnswerType}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
) : (
|
||||
<TreeCanvasNode
|
||||
node={node}
|
||||
depth={0}
|
||||
fromOption={optionLabel}
|
||||
isExpanded={isExpanded}
|
||||
isNew={isNew}
|
||||
hasChildren={nodeChildren.length > 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 && (
|
||||
<div className="mt-3 flex flex-col items-center gap-2">
|
||||
{unlinkedOptions.map((opt) => {
|
||||
const key = addKey(node.id, opt.id)
|
||||
return (
|
||||
<div key={opt.id} className="flex flex-col items-center gap-1">
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<span className="text-[10px] text-muted-foreground font-label">
|
||||
{opt.label || '(unlabeled option)'}
|
||||
</span>
|
||||
{pendingAddKey === key ? (
|
||||
<AddNodePicker
|
||||
onSelect={(type) =>
|
||||
handleAddNodeSelect(type, node.id, opt.id)
|
||||
}
|
||||
onCancel={() => setPendingAddKey(null)}
|
||||
/>
|
||||
) : (
|
||||
<AddNodeButton
|
||||
label="Add child"
|
||||
onClick={() => setPendingAddKey(key)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single add button for action nodes without children */}
|
||||
{!isExpanded && showSingleAddButton && (
|
||||
<div className="mt-3 flex flex-col items-center gap-1">
|
||||
<div className="h-4 w-px bg-border" />
|
||||
{pendingAddKey === node.id ? (
|
||||
<AddNodePicker
|
||||
onSelect={(type) => handleAddNodeSelect(type, node.id)}
|
||||
onCancel={() => setPendingAddKey(null)}
|
||||
/>
|
||||
) : (
|
||||
<AddNodeButton
|
||||
label="Add next node"
|
||||
onClick={() => setPendingAddKey(node.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsed subtree pill */}
|
||||
{hasChildren && !isExpanded && isSubtreeCollapsed && (
|
||||
<div className="mt-3 flex flex-col items-center">
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleSubtreeCollapse(node.id)}
|
||||
className="rounded-full border border-dashed border-border bg-card px-3 py-1 text-[10px] text-muted-foreground font-label hover:border-primary/40 hover:text-foreground transition-colors"
|
||||
>
|
||||
{orderedChildren.length} node{orderedChildren.length !== 1 ? 's' : ''} hidden — click to expand
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connector + Children */}
|
||||
{hasChildren && !isExpanded && !isSubtreeCollapsed && (
|
||||
<div className="mt-3 flex flex-col items-center w-full">
|
||||
{/* Trunk line from card down */}
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
{orderedChildren.length === 1 ? (
|
||||
// Single child: straight vertical
|
||||
<div className="flex flex-col items-center">
|
||||
{renderNode(
|
||||
orderedChildren[0].child,
|
||||
node.id,
|
||||
orderedChildren[0].childIndex,
|
||||
orderedChildren[0].optionLabel
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 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.
|
||||
<div className="flex items-start justify-center gap-6 w-full relative"
|
||||
style={{ maxWidth: `${orderedChildren.length * 360}px` }}
|
||||
>
|
||||
{/* Horizontal fork line — absolutely positioned, aligned to child centers.
|
||||
Spans from center of first lane to center of last lane. */}
|
||||
<div
|
||||
className="absolute top-0 h-px bg-border pointer-events-none"
|
||||
style={{
|
||||
left: `calc(${100 / (orderedChildren.length * 2)}%)`,
|
||||
right: `calc(${100 / (orderedChildren.length * 2)}%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{orderedChildren.map(({ child, optionLabel: ol, childIndex }) => (
|
||||
<div
|
||||
key={child.id}
|
||||
className="flex flex-col items-center min-w-[260px]"
|
||||
>
|
||||
{/* Vertical stub into child lane */}
|
||||
<div className="h-4 w-px bg-border" />
|
||||
{renderNode(child, node.id, childIndex, ol)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[
|
||||
expandedNodeId,
|
||||
newNodeIds,
|
||||
collapsedNodeIds,
|
||||
dragOverTarget,
|
||||
handleToggleExpand,
|
||||
handleToggleSubtreeCollapse,
|
||||
handleSave,
|
||||
handleCancelNew,
|
||||
handleDelete,
|
||||
handleDuplicate,
|
||||
handleSelectAnswerType,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
pendingAddKey,
|
||||
handleAddNodeSelect,
|
||||
]
|
||||
)
|
||||
|
||||
// ── Empty state ──
|
||||
if (!treeStructure) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-muted-foreground text-sm">
|
||||
No tree structure. Start by saving a tree name.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full w-full overflow-auto"
|
||||
onDragEnd={handleDragEnd}
|
||||
style={{
|
||||
// Subtle dot grid background
|
||||
backgroundImage:
|
||||
'radial-gradient(circle, hsl(var(--border)) 1px, transparent 1px)',
|
||||
backgroundSize: '24px 24px',
|
||||
}}
|
||||
>
|
||||
<div className="flex min-h-full min-w-full items-start justify-center p-8 pb-24">
|
||||
<div className="flex flex-col items-center">
|
||||
{/* START badge above root */}
|
||||
<div className="mb-2 rounded-full border border-border bg-card px-3 py-1 text-xs font-label text-muted-foreground">
|
||||
START
|
||||
</div>
|
||||
<div className="mb-1 h-4 w-px bg-border" />
|
||||
|
||||
{renderNode(treeStructure, null, 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TreeCanvas
|
||||
Reference in New Issue
Block a user