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:
400
frontend/src/components/tree-editor/TreeCanvasNode.tsx
Normal file
400
frontend/src/components/tree-editor/TreeCanvasNode.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
HelpCircle,
|
||||
Zap,
|
||||
CheckCircle,
|
||||
Play,
|
||||
Check,
|
||||
X,
|
||||
Copy,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
} from 'lucide-react'
|
||||
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||
import { NodeFormDecision } from './NodeFormDecision'
|
||||
import { NodeFormAction } from './NodeFormAction'
|
||||
import { NodeFormResolution } from './NodeFormResolution'
|
||||
import type { TreeStructure } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TreeCanvasNodeProps {
|
||||
node: TreeStructure
|
||||
depth: number
|
||||
fromOption?: string
|
||||
isExpanded: boolean
|
||||
isNew: boolean
|
||||
hasChildren?: boolean
|
||||
isSubtreeCollapsed?: boolean
|
||||
onToggleExpand: () => void
|
||||
onToggleSubtreeCollapse?: () => void
|
||||
onSave: (nodeId: string, updates: Partial<TreeStructure>) => void
|
||||
onCancelNew: (nodeId: string) => void
|
||||
onDelete: (nodeId: string) => void
|
||||
onDuplicate: (nodeId: string) => void
|
||||
onDragStart: (e: React.DragEvent, nodeId: string) => void
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
}
|
||||
|
||||
/** Clone a node without its children (for local draft state) */
|
||||
function cloneNodeWithoutChildren(node: TreeStructure): TreeStructure {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { children, ...rest } = node
|
||||
return structuredClone(rest) as TreeStructure
|
||||
}
|
||||
|
||||
const NODE_TYPE_CONFIG = {
|
||||
decision: {
|
||||
icon: HelpCircle,
|
||||
label: 'Decision',
|
||||
borderClass: 'border-l-4 border-l-blue-500',
|
||||
badgeClass: 'bg-blue-500/20 text-blue-400',
|
||||
},
|
||||
action: {
|
||||
icon: Zap,
|
||||
label: 'Action',
|
||||
borderClass: 'border-l-4 border-l-yellow-500',
|
||||
badgeClass: 'bg-yellow-500/20 text-yellow-400',
|
||||
},
|
||||
solution: {
|
||||
icon: CheckCircle,
|
||||
label: 'Solution',
|
||||
borderClass: 'border-l-4 border-l-green-500',
|
||||
badgeClass: 'bg-green-500/20 text-green-400',
|
||||
},
|
||||
} as const
|
||||
|
||||
export function TreeCanvasNode({
|
||||
node,
|
||||
fromOption,
|
||||
isExpanded,
|
||||
isNew,
|
||||
hasChildren = false,
|
||||
isSubtreeCollapsed = false,
|
||||
onToggleExpand,
|
||||
onToggleSubtreeCollapse,
|
||||
onSave,
|
||||
onCancelNew,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
}: TreeCanvasNodeProps) {
|
||||
const { validationErrors, selectedNodeId, selectNode } = useTreeEditorStore()
|
||||
const isRoot = node.id === 'root'
|
||||
const isSelected = selectedNodeId === node.id
|
||||
|
||||
const nodeErrors = validationErrors.filter(
|
||||
(e) => e.nodeId === node.id && e.severity === 'error'
|
||||
)
|
||||
const nodeWarnings = validationErrors.filter(
|
||||
(e) => e.nodeId === node.id && e.severity === 'warning'
|
||||
)
|
||||
const hasError = nodeErrors.length > 0
|
||||
const hasWarning = nodeWarnings.length > 0
|
||||
|
||||
// Local draft state for inline editing
|
||||
const [draft, setDraft] = useState<TreeStructure>(() =>
|
||||
cloneNodeWithoutChildren(node)
|
||||
)
|
||||
|
||||
// Reset draft if node ID changes (e.g. navigating between nodes)
|
||||
const [lastNodeId, setLastNodeId] = useState(node.id)
|
||||
if (node.id !== lastNodeId) {
|
||||
setDraft(cloneNodeWithoutChildren(node))
|
||||
setLastNodeId(node.id)
|
||||
}
|
||||
|
||||
// Re-sync draft from store whenever the card is opened, so stale next_node_id
|
||||
// values (written back after stub creation) don't cause duplicate stubs on re-save
|
||||
useEffect(() => {
|
||||
if (isExpanded) {
|
||||
setDraft(cloneNodeWithoutChildren(node))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isExpanded])
|
||||
|
||||
const handleDraftUpdate = useCallback((updates: Partial<TreeStructure>) => {
|
||||
setDraft((prev) => ({ ...prev, ...updates }))
|
||||
}, [])
|
||||
|
||||
const handleSave = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
// Strip children from draft before passing to onSave
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { children, ...draftWithoutChildren } = draft
|
||||
onSave(node.id, draftWithoutChildren)
|
||||
}
|
||||
|
||||
const handleCancel = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isNew) {
|
||||
onCancelNew(node.id)
|
||||
} else {
|
||||
// Discard draft changes and collapse
|
||||
setDraft(cloneNodeWithoutChildren(node))
|
||||
onToggleExpand()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCardClick = () => {
|
||||
selectNode(node.id)
|
||||
onToggleExpand()
|
||||
}
|
||||
|
||||
const config = node.type in NODE_TYPE_CONFIG
|
||||
? NODE_TYPE_CONFIG[node.type as keyof typeof NODE_TYPE_CONFIG]
|
||||
: NODE_TYPE_CONFIG.decision // fallback for 'answer' (rendered by AnswerStubCard)
|
||||
const TypeIcon = config.icon
|
||||
|
||||
const getTitle = () => {
|
||||
if (node.type === 'decision') return node.question || 'Untitled Question'
|
||||
return node.title || `Untitled ${node.type}`
|
||||
}
|
||||
|
||||
const getOptionsSummary = () => {
|
||||
if (node.type !== 'decision' || !node.options?.length) return null
|
||||
const count = node.options.length
|
||||
return `${count} option${count !== 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-xl border border-border bg-card shadow-sm transition-all duration-150',
|
||||
config.borderClass,
|
||||
isExpanded && 'ring-1 ring-primary shadow-md',
|
||||
isSelected && !isExpanded && 'ring-1 ring-primary/50',
|
||||
hasError && 'ring-1 ring-destructive',
|
||||
hasWarning && !hasError && 'ring-1 ring-yellow-500/70',
|
||||
isNew && 'ring-1 ring-yellow-400/60',
|
||||
'min-w-[240px] max-w-[340px]'
|
||||
)}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{/* Card Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2.5',
|
||||
!isExpanded && 'cursor-pointer hover:bg-accent/50 rounded-t-xl',
|
||||
!isExpanded && 'rounded-xl',
|
||||
isExpanded && 'sticky top-0 z-10 bg-card rounded-t-xl'
|
||||
)}
|
||||
onClick={!isExpanded ? handleCardClick : undefined}
|
||||
>
|
||||
{/* Drag handle (hide for root) */}
|
||||
{!isRoot && (
|
||||
<span
|
||||
className="cursor-grab shrink-0"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation()
|
||||
onDragStart(e, node.id)
|
||||
}}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground/50" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Node type badge */}
|
||||
{isRoot ? (
|
||||
<span className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-semibold bg-blue-500/30 text-blue-400 font-label shrink-0">
|
||||
<Play className="h-3 w-3" />
|
||||
START
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-label shrink-0',
|
||||
config.badgeClass
|
||||
)}
|
||||
>
|
||||
<TypeIcon className="h-3 w-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* From-option label */}
|
||||
{fromOption && (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground truncate max-w-[80px]">
|
||||
{fromOption}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Title text (compact mode) */}
|
||||
{!isExpanded && (
|
||||
<span className="flex-1 truncate text-sm font-heading font-medium text-foreground">
|
||||
{getTitle()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Options count badge */}
|
||||
{!isExpanded && getOptionsSummary() && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0 font-label">
|
||||
{getOptionsSummary()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Validation badges (compact mode) */}
|
||||
{!isExpanded && hasError && (
|
||||
<span
|
||||
className="flex items-center gap-0.5 rounded bg-destructive/20 px-1.5 py-0.5 text-[10px] text-destructive shrink-0"
|
||||
title={nodeErrors.map((e) => e.message).join('\n')}
|
||||
>
|
||||
<AlertCircle className="h-2.5 w-2.5" />
|
||||
{nodeErrors.length}
|
||||
</span>
|
||||
)}
|
||||
{!isExpanded && !hasError && hasWarning && (
|
||||
<span
|
||||
className="flex items-center gap-0.5 rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] text-yellow-500 shrink-0"
|
||||
title={nodeWarnings.map((e) => e.message).join('\n')}
|
||||
>
|
||||
<AlertTriangle className="h-2.5 w-2.5" />
|
||||
{nodeWarnings.length}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Unsaved badge */}
|
||||
{!isExpanded && isNew && (
|
||||
<span className="rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] text-yellow-500 font-label shrink-0">
|
||||
Unsaved
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Subtree collapse toggle — only in compact mode when node has children */}
|
||||
{!isExpanded && hasChildren && onToggleSubtreeCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onToggleSubtreeCollapse() }}
|
||||
title={isSubtreeCollapsed ? 'Expand subtree' : 'Collapse subtree'}
|
||||
className="rounded p-0.5 text-muted-foreground/50 hover:bg-accent hover:text-foreground shrink-0"
|
||||
>
|
||||
{isSubtreeCollapsed
|
||||
? <ChevronsUpDown className="h-3.5 w-3.5" />
|
||||
: <ChevronsDownUp className="h-3.5 w-3.5" />
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Expand/collapse chevron */}
|
||||
{!isExpanded ? (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Editing action buttons (expanded state) */}
|
||||
{isExpanded && (
|
||||
<div className="ml-auto flex items-center gap-1 shrink-0">
|
||||
{/* New badge */}
|
||||
{isNew && (
|
||||
<span className="rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] text-yellow-500 font-label">
|
||||
Unsaved
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Duplicate (hide for root) */}
|
||||
{!isRoot && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDuplicate(node.id)
|
||||
}}
|
||||
title="Duplicate node"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Delete (hide for root) */}
|
||||
{!isRoot && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(node.id)
|
||||
}}
|
||||
title="Delete node"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-destructive/20 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Cancel */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
title={isNew ? 'Cancel (deletes this node)' : 'Cancel changes'}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Save */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
title="Save changes"
|
||||
className="rounded p-1 bg-gradient-brand text-white hover:opacity-90"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded editing area */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border px-3 pb-3 pt-3 max-h-[70vh] overflow-y-auto">
|
||||
{/* Validation errors */}
|
||||
{(hasError || hasWarning) && (
|
||||
<div className="mb-3 space-y-1">
|
||||
{nodeErrors.map((error, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-md bg-red-400/10 px-3 py-2 text-xs text-red-400"
|
||||
>
|
||||
{error.message}
|
||||
</div>
|
||||
))}
|
||||
{!hasError &&
|
||||
nodeWarnings.map((warning, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-md bg-yellow-400/10 px-3 py-2 text-xs text-yellow-400"
|
||||
>
|
||||
{warning.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type-specific form — uses draft, not live node */}
|
||||
{draft.type === 'decision' && (
|
||||
<NodeFormDecision node={draft} onUpdate={handleDraftUpdate} />
|
||||
)}
|
||||
{draft.type === 'action' && (
|
||||
<NodeFormAction node={draft} onUpdate={handleDraftUpdate} />
|
||||
)}
|
||||
{draft.type === 'solution' && (
|
||||
<NodeFormResolution node={draft} onUpdate={handleDraftUpdate} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TreeCanvasNode
|
||||
Reference in New Issue
Block a user