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:
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
|
||||
import { useStore } from 'zustand'
|
||||
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList, BarChart3 } from 'lucide-react'
|
||||
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList, BarChart3, Settings } from 'lucide-react'
|
||||
import { getMonacoEditor } from '@/components/tree-editor/code-mode'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { treeMarkdownApi } from '@/api/treeMarkdown'
|
||||
import type { TreeCreate, TreeUpdate, TreeStatus } from '@/types'
|
||||
import type { TreeCreate, TreeUpdate, TreeStatus, TreeStructure } from '@/types'
|
||||
import { useTreeEditorStore, useTreeEditorTemporal } from '@/store/treeEditorStore'
|
||||
import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout'
|
||||
import { ValidationSummary } from '@/components/tree-editor/ValidationSummary'
|
||||
@@ -15,6 +15,12 @@ import { cn, safeGetItem } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { FlowAnalyticsPanel } from '@/components/analytics/FlowAnalyticsPanel'
|
||||
|
||||
/** Recursively check if any node in the tree has type 'answer' */
|
||||
function hasAnswerNodes(node: TreeStructure): boolean {
|
||||
if (node.type === 'answer') return true
|
||||
return (node.children || []).some(hasAnswerNodes)
|
||||
}
|
||||
|
||||
export function TreeEditorPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
@@ -48,6 +54,7 @@ export function TreeEditorPage() {
|
||||
const [showDraftPrompt, setShowDraftPrompt] = useState(false)
|
||||
const [treeStatus, setTreeStatus] = useState<TreeStatus>('draft')
|
||||
const [showAnalytics, setShowAnalytics] = useState(false)
|
||||
const [isMetadataOpen, setIsMetadataOpen] = useState(false)
|
||||
|
||||
// Mobile detection
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
@@ -291,6 +298,14 @@ export function TreeEditorPage() {
|
||||
return
|
||||
}
|
||||
|
||||
// Block publish if any answer placeholder nodes remain
|
||||
const currentStructure = useTreeEditorStore.getState().treeStructure
|
||||
if (currentStructure && hasAnswerNodes(currentStructure)) {
|
||||
toast.error('Resolve all answer placeholders before publishing. Click each dashed stub card to assign a type.')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate tree structure
|
||||
const errors = validate()
|
||||
const hasErrors = errors.some(e => e.severity === 'error')
|
||||
@@ -475,7 +490,7 @@ export function TreeEditorPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorMode('form')}
|
||||
title="Flow Mode — form-based editing"
|
||||
title="Flow Mode — visual canvas editing"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-l-md px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
editorMode === 'form'
|
||||
@@ -489,7 +504,10 @@ export function TreeEditorPage() {
|
||||
<div className="h-5 w-px bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorMode('code')}
|
||||
onClick={() => {
|
||||
setEditorMode('code')
|
||||
setIsMetadataOpen(false) // Auto-close metadata panel on Code mode
|
||||
}}
|
||||
title="Code Mode — markdown editing (Ctrl+Shift+M)"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-r-md px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
@@ -540,6 +558,24 @@ export function TreeEditorPage() {
|
||||
|
||||
<div className="mx-2 h-6 w-px bg-border" />
|
||||
|
||||
{/* Metadata panel toggle — Flow mode only */}
|
||||
{editorMode === 'form' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMetadataOpen(!isMetadataOpen)}
|
||||
title="Edit flow metadata (name, description, category, tags)"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors',
|
||||
isMetadataOpen
|
||||
? 'bg-accent text-foreground'
|
||||
: 'bg-card text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Metadata
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Analytics toggle (only for existing trees) */}
|
||||
{isEditMode && (
|
||||
<button
|
||||
@@ -612,7 +648,11 @@ export function TreeEditorPage() {
|
||||
)}
|
||||
|
||||
{/* Main Editor */}
|
||||
<TreeEditorLayout isMobile={isMobile} />
|
||||
<TreeEditorLayout
|
||||
isMobile={isMobile}
|
||||
isMetadataOpen={isMetadataOpen}
|
||||
onCloseMetadata={() => setIsMetadataOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Flow Analytics Panel (collapsible) */}
|
||||
{showAnalytics && id && (
|
||||
|
||||
@@ -326,35 +326,40 @@ export function TreeLibraryPage() {
|
||||
|
||||
{/* View Controls */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex rounded-lg border border-border p-0.5">
|
||||
{(['all', 'troubleshooting', 'procedural', 'maintenance'] as const).map((t) => (
|
||||
{/* Type filter tabs — includes Drafts as a first-class filter */}
|
||||
<div className="flex rounded-lg border border-border p-0.5">
|
||||
{(['all', 'troubleshooting', 'procedural', 'maintenance', 'drafts'] as const).map((t) => {
|
||||
const isActive = t === 'drafts' ? showDrafts && typeFilter === 'all' : !showDrafts && typeFilter === t
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTypeFilter(t)}
|
||||
onClick={() => {
|
||||
if (t === 'drafts') {
|
||||
setShowDrafts(true)
|
||||
setTypeFilter('all')
|
||||
} else {
|
||||
setShowDrafts(false)
|
||||
setTypeFilter(t)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
|
||||
typeFilter === t
|
||||
isActive
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : t === 'procedural' ? 'Projects' : 'Maintenance'}
|
||||
{t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : t === 'procedural' ? 'Projects' : t === 'maintenance' ? 'Maintenance' : 'Drafts'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SortDropdown value={treeLibrarySortBy} onChange={setTreeLibrarySortBy} />
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showDrafts}
|
||||
onChange={(e) => setShowDrafts(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border text-primary focus:ring-2 focus:ring-primary/20 focus:ring-offset-2"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">Show my drafts</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right controls: sort + view toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<SortDropdown value={treeLibrarySortBy} onChange={setTreeLibrarySortBy} />
|
||||
<ViewToggle view={treeLibraryView} onChange={setTreeLibraryView} />
|
||||
</div>
|
||||
<ViewToggle view={treeLibraryView} onChange={setTreeLibraryView} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -757,6 +757,15 @@ export function TreeNavigationPage() {
|
||||
|
||||
{/* Current Node */}
|
||||
<div className="bg-card border border-border rounded-xl p-6 shadow-sm">
|
||||
{/* Answer placeholder guard */}
|
||||
{currentNode && currentNode.type === 'answer' && (
|
||||
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-6 text-center">
|
||||
<p className="text-sm font-medium text-yellow-400">
|
||||
This tree contains an unresolved placeholder node. Please contact the tree author to complete it before use.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decision Node */}
|
||||
{currentNode && currentNode.type === 'decision' && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user