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>
265 lines
10 KiB
TypeScript
265 lines
10 KiB
TypeScript
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, ...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
|
|
|
|
// Escape to close
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
handleClose()
|
|
}
|
|
}
|
|
document.addEventListener('keydown', handleKeyDown)
|
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
}, [isDirty]) // 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, ...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])
|
|
|
|
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-full min-h-0 w-[400px] shrink-0 flex-col border-l border-border bg-card overflow-hidden">
|
|
{/* 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)
|
|
}
|