feat: React Flow migration for flow editor canvas (#82)

* docs: add React Flow migration design for flow editor canvas

Replaces hand-built CSS flexbox canvas with @xyflow/react for zoom/pan,
dagre auto-layout, collapsible minimap, and side-panel editing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add React Flow migration implementation plan

12 tasks across 8 phases covering dagre layout, custom nodes,
side panel editor, and full canvas integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: install @xyflow/react and @dagrejs/dagre

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add dagre layout utility for React Flow node positioning

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add FlowCanvasNode compact card for React Flow canvas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add FlowCanvasAnswerNode stub card for React Flow canvas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add useTreeLayout hook for tree-to-ReactFlow conversion with dagre

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add NodeEditorPanel side panel for React Flow canvas editing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add FlowCanvas main React Flow component with zoom/pan/minimap

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: wire FlowCanvas and NodeEditorPanel into TreeEditorLayout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add panel state management for node editor in TreeEditorPage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: add React Flow dark theme overrides for canvas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: export new React Flow canvas components from barrel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: enable scrolling in node editor panel sidebar

Add min-h-0 to flex containers in the ancestor chain so overflow-y-auto
actually triggers instead of content overflowing off-screen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: constrain tree editor page height to prevent panel overflow

Add overflow-hidden to TreeEditorPage root and NodeEditorPanel container
so the flex height chain is properly constrained by the CSS Grid cell,
preventing the node editor sidebar from growing beyond the viewport.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve lint errors in NodeEditorPanel and useTreeLayout

- Fix unused 'children' destructuring with _children prefix
- Move handleClose declaration above the useEffect that references it
- Use handleClose as proper dependency instead of eslint-disable
- Fix unused _parentId parameter type in useTreeLayout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use viewport-based height for node editor panel

Replace h-full with calc(100vh - 105px) to bypass the CSS height chain
that fails to constrain the panel across browsers. The 105px accounts
for the topbar (56px) and editor toolbar (49px).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: fix canvas controls visibility and enhance dot grid background

- Add !important to all React Flow dark theme overrides to ensure they
  win over library default styles (fixes white controls rectangle)
- Add SVG fill inheritance for control button icons
- Use slightly lighter canvas background (bg-accent/30) so dot grid
  is more visible
- Increase dot size and use muted-foreground color for better contrast

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: collapse sidebar categories with show more/less toggle

Show only the first 4 categories by default with a "N more" button
to expand the full list. Reduces sidebar clutter when many categories
exist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #82.
This commit is contained in:
chihlasm
2026-02-19 00:43:34 -05:00
committed by GitHub
parent da2807b14b
commit 50d9ff59d0
15 changed files with 2948 additions and 12 deletions

View File

@@ -0,0 +1,264 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { HelpCircle, Zap, CheckCircle, X, Trash2, Copy, Save } from 'lucide-react'
import { useTreeEditorStore, findNodeInTree } from '@/store/treeEditorStore'
import { NodeFormDecision } from './NodeFormDecision'
import { NodeFormAction } from './NodeFormAction'
import { NodeFormResolution } from './NodeFormResolution'
import { cn } from '@/lib/utils'
import type { TreeStructure, NodeType } from '@/types'
interface NodeEditorPanelProps {
nodeId: string
onClose: () => void
onSelectType?: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
}
const TYPE_CONFIG: Record<Exclude<NodeType, 'answer'>, { icon: typeof HelpCircle; label: string; badgeClass: string }> = {
decision: { icon: HelpCircle, label: 'Decision', badgeClass: 'bg-blue-500/20 text-blue-400' },
action: { icon: Zap, label: 'Action', badgeClass: 'bg-yellow-500/20 text-yellow-400' },
solution: { icon: CheckCircle, label: 'Solution', badgeClass: 'bg-green-500/20 text-green-400' },
}
function cloneWithoutChildren(node: TreeStructure): TreeStructure {
const { children: _children, ...rest } = node
return structuredClone(rest) as TreeStructure
}
export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPanelProps) {
const treeStructure = useTreeEditorStore(s => s.treeStructure)
const updateNode = useTreeEditorStore(s => s.updateNode)
const deleteNode = useTreeEditorStore(s => s.deleteNode)
const duplicateNode = useTreeEditorStore(s => s.duplicateNode)
const addNode = useTreeEditorStore(s => s.addNode)
const selectNode = useTreeEditorStore(s => s.selectNode)
const node = treeStructure ? findNodeInTree(nodeId, treeStructure) : null
const [draft, setDraft] = useState<TreeStructure | null>(null)
const [isDirty, setIsDirty] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const panelRef = useRef<HTMLDivElement>(null)
// Initialize/reset draft when nodeId changes
useEffect(() => {
if (node) {
setDraft(cloneWithoutChildren(node))
setIsDirty(false)
setShowDeleteConfirm(false)
}
}, [nodeId]) // eslint-disable-line react-hooks/exhaustive-deps
const handleDraftUpdate = useCallback((updates: Partial<TreeStructure>) => {
setDraft(prev => prev ? { ...prev, ...updates } : prev)
setIsDirty(true)
}, [])
const handleSave = useCallback(() => {
if (!draft || !node) return
const { children: _children, ...draftWithoutChildren } = draft
updateNode(nodeId, draftWithoutChildren)
// Auto-create answer stubs for new decision options without next_node_id
if (draft.options) {
const options = draft.options.filter(o => o.label.trim())
const stubsCreated: Array<{ optId: string; stubId: string }> = []
options.forEach(opt => {
if (!opt.next_node_id) {
const stubId = addNode(nodeId, 'answer')
updateNode(stubId, { title: opt.label })
stubsCreated.push({ optId: opt.id, stubId })
}
})
if (stubsCreated.length > 0) {
const updatedOptions = options.map(o => {
const stub = stubsCreated.find(s => s.optId === o.id)
return stub ? { ...o, next_node_id: stub.stubId } : o
})
updateNode(nodeId, { options: updatedOptions })
}
}
setIsDirty(false)
}, [draft, node, nodeId, updateNode, addNode])
const handleClose = useCallback(() => {
if (isDirty) {
if (!window.confirm('You have unsaved changes. Discard them?')) return
}
onClose()
}, [isDirty, onClose])
// Escape to close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleClose])
const handleDelete = useCallback(() => {
if (!treeStructure) return
clearInboundReferences(nodeId, treeStructure, updateNode)
deleteNode(nodeId)
onClose()
}, [nodeId, treeStructure, updateNode, deleteNode, onClose])
const handleDuplicate = useCallback(() => {
const newId = duplicateNode(nodeId)
if (newId) {
selectNode(newId)
}
}, [nodeId, duplicateNode, selectNode])
if (!node || !draft) return null
// Answer stub: show type picker instead of form
if (node.type === 'answer') {
return (
<div ref={panelRef} className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<span className="text-sm font-heading font-medium text-foreground">
{node.title || 'Answer Placeholder'}
</span>
<button onClick={handleClose} className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-4">
<p className="text-sm text-muted-foreground text-center">Choose a type for this node:</p>
<div className="flex gap-2">
{(['decision', 'action', 'solution'] as const).map(type => {
const cfg = TYPE_CONFIG[type]
const TypeIcon = cfg.icon
return (
<button
key={type}
type="button"
onClick={() => onSelectType?.(nodeId, type)}
className={cn(
'flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-label border transition-colors',
type === 'decision' && 'border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20',
type === 'action' && 'border-yellow-500/30 bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20',
type === 'solution' && 'border-green-500/30 bg-green-500/10 text-green-400 hover:bg-green-500/20',
)}
>
<TypeIcon className="h-4 w-4" /> {cfg.label}
</button>
)
})}
</div>
</div>
</div>
)
}
const config = TYPE_CONFIG[node.type as Exclude<NodeType, 'answer'>] ?? TYPE_CONFIG.decision
const TypeIcon = config.icon
const title = node.type === 'decision' ? (node.question || 'Untitled Decision') : (node.title || `Untitled ${config.label}`)
const isRoot = treeStructure?.id === nodeId
return (
<div ref={panelRef} className="flex h-[calc(100vh-105px)] w-[400px] shrink-0 flex-col border-l border-border bg-card">
{/* Header */}
<div className="flex items-center gap-2 border-b border-border px-4 py-3 shrink-0">
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded', config.badgeClass)}>
<TypeIcon className="h-3 w-3" />
</span>
<span className="flex-1 truncate text-sm font-heading font-medium text-foreground">{title}</span>
<button onClick={handleClose} className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
{/* Body — scrollable form area */}
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
{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>
{/* Footer */}
<div className="flex items-center gap-2 border-t border-border px-4 py-3 shrink-0">
<button
onClick={handleSave}
disabled={!isDirty}
className={cn(
'flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 transition-opacity',
isDirty ? 'bg-gradient-brand hover:opacity-90' : 'bg-gradient-brand opacity-50 cursor-not-allowed'
)}
>
<Save className="h-3.5 w-3.5" /> Save
</button>
<button
onClick={handleClose}
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
Cancel
</button>
<div className="flex-1" />
{!isRoot && (
<>
<button
onClick={handleDuplicate}
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
title="Duplicate"
>
<Copy className="h-4 w-4" />
</button>
{showDeleteConfirm ? (
<div className="flex items-center gap-1">
<button
onClick={handleDelete}
className="rounded-md bg-red-500/20 px-2 py-1 text-xs text-red-400 hover:bg-red-500/30"
>
Confirm
</button>
<button
onClick={() => setShowDeleteConfirm(false)}
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setShowDeleteConfirm(true)}
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-red-400"
title="Delete"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</>
)}
</div>
</div>
)
}
// Clear all next_node_id references to a node before deleting
function clearInboundReferences(
nodeId: string,
treeStructure: TreeStructure,
updateNode: (id: string, updates: Partial<TreeStructure>) => void
) {
function walk(node: TreeStructure) {
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),
})
}
}
if (node.type === 'action' && node.next_node_id === nodeId) {
updateNode(node.id, { next_node_id: '' })
}
node.children?.forEach(walk)
}
walk(treeStructure)
}