fix: repair tree editor drag-to-reorder (6 bugs) #48

Merged
chihlasm merged 2 commits from fix/tree-editor-drag-reorder into main 2026-02-09 14:50:33 +00:00
2 changed files with 149 additions and 19 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import {
Plus,
Pencil,
@@ -14,7 +14,7 @@ import {
AlertCircle,
AlertTriangle
} from 'lucide-react'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { useTreeEditorStore, findNodeInTree } from '@/store/treeEditorStore'
import { NodeEditorModal } from './NodeEditorModal'
import type { TreeStructure, NodeType } from '@/types'
import { cn } from '@/lib/utils'
@@ -34,7 +34,9 @@ interface NodeListItemProps {
onDragStart: (e: React.DragEvent, nodeId: string, parentId: string | null, index: number) => void
onDragOver: (e: React.DragEvent, parentId: string | null, index: number) => void
onDrop: (e: React.DragEvent, parentId: string | null, index: number) => void
dragOverTarget: { parentId: string | null; index: number } | null
onDragEnd: () => void
onDragLeave: (e: React.DragEvent) => void
dragOverTarget: { parentId: string | null; index: number; isValid: boolean } | null
/** Array of booleans indicating which ancestor levels should show continuing lines */
ancestorLines?: boolean[]
}
@@ -53,11 +55,14 @@ function NodeListItem({
onDragStart,
onDragOver,
onDrop,
onDragEnd,
onDragLeave,
dragOverTarget,
ancestorLines = []
}: NodeListItemProps) {
const { selectedNodeId, selectNode, validationErrors } = useTreeEditorStore()
const [isCollapsed, setIsCollapsed] = useState(false)
const gripInitiated = useRef(false)
const isSelected = selectedNodeId === node.id
const isRootNode = node.id === 'root'
const nodeErrors = validationErrors.filter(e => e.nodeId === node.id && e.severity === 'error')
@@ -79,7 +84,9 @@ function NodeListItem({
}
const isDragTarget =
dragOverTarget?.parentId === parentId && dragOverTarget?.index === index
dragOverTarget?.parentId === parentId &&
dragOverTarget?.index === index
const isDragValid = isDragTarget && dragOverTarget?.isValid
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
decision: <HelpCircle className="h-4 w-4" />,
@@ -143,13 +150,32 @@ function NodeListItem({
<>
{/* Drop indicator above */}
{isDragTarget && (
<div className="h-1 bg-primary rounded-full mx-2" style={{ marginLeft: `${depth * 20 + 8}px` }} />
<div
className={cn(
'h-1 rounded-full mx-2 transition-colors',
isDragValid
? 'bg-primary'
: 'bg-destructive animate-pulse shadow-[0_0_8px_rgba(239,68,68,0.6)]'
)}
style={{ marginLeft: `${depth * 20 + 8}px` }}
/>
)}
<div
draggable={node.id !== 'root'}
onDragStart={(e) => onDragStart(e, node.id, parentId, index)}
onDragStart={(e) => {
if (!gripInitiated.current) {
e.preventDefault()
return
}
onDragStart(e, node.id, parentId, index)
}}
onDragEnd={() => {
gripInitiated.current = false
onDragEnd()
}}
onDragOver={(e) => onDragOver(e, parentId, index)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, parentId, index)}
onClick={() => selectNode(node.id)}
className={cn(
@@ -187,7 +213,11 @@ function NodeListItem({
{/* Drag handle */}
{node.id !== 'root' && (
<GripVertical className="h-4 w-4 cursor-grab text-muted-foreground opacity-0 group-hover:opacity-100" />
<GripVertical
className="h-4 w-4 cursor-grab text-muted-foreground opacity-0 group-hover:opacity-100"
onMouseDown={() => { gripInitiated.current = true }}
onMouseUp={() => { gripInitiated.current = false }}
/>
)}
{node.id === 'root' && <div className="w-4" />}
@@ -336,17 +366,44 @@ function NodeListItem({
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
onDragLeave={onDragLeave}
dragOverTarget={dragOverTarget}
ancestorLines={childAncestorLines}
/>
)
})}
{/* Trailing drop zone — allows dropping after the last child */}
{!isCollapsed && hasChildren && (() => {
const trailingIndex = node.children!.length
const isTrailingTarget =
dragOverTarget?.parentId === node.id &&
dragOverTarget?.index === trailingIndex
const isTrailingValid = isTrailingTarget && dragOverTarget?.isValid
return (
<div
className={cn(
'h-2 rounded-full mx-2 transition-colors',
isTrailingTarget
? isTrailingValid
? 'bg-primary'
: 'bg-destructive animate-pulse shadow-[0_0_8px_rgba(239,68,68,0.6)]'
: 'bg-transparent'
)}
style={{ marginLeft: `${(depth + 1) * 20 + 8}px` }}
onDragOver={(e) => onDragOver(e, node.id, trailingIndex)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, node.id, trailingIndex)}
/>
)
})()}
</>
)
}
export function NodeList() {
const { treeStructure, addNode, deleteNode, duplicateNode, reorderNodes, findNode } = useTreeEditorStore()
const { treeStructure, addNode, deleteNode, duplicateNode, reorderNodes, moveNode, findNode } = useTreeEditorStore()
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
const [isEditingNewNode, setIsEditingNewNode] = useState(false)
const [addingToParent, setAddingToParent] = useState<string | null>(null)
@@ -361,6 +418,7 @@ export function NodeList() {
const [dragOverTarget, setDragOverTarget] = useState<{
parentId: string | null
index: number
isValid: boolean
} | null>(null)
const handleAddNode = (type: NodeType) => {
@@ -391,6 +449,29 @@ export function NodeList() {
setDragState({ nodeId, parentId, index })
}
// Check if a node can be dropped at a target location
const canDropAt = (draggedNodeId: string, targetParentId: string | null): boolean => {
if (!treeStructure) return false
// Can't drop at root level (no parent)
if (targetParentId === null) return false
// Can't drop onto itself
if (draggedNodeId === targetParentId) return false
// Can't drop onto a descendant of the dragged node
const draggedNode = findNodeInTree(draggedNodeId, treeStructure)
if (!draggedNode) return false
if (findNodeInTree(targetParentId, draggedNode)) return false
// Target parent must be able to have children (solution nodes are terminal)
const targetParent = findNodeInTree(targetParentId, treeStructure)
if (!targetParent) return false
if (targetParent.type === 'solution') return false
return true
}
const handleDragOver = (
e: React.DragEvent,
parentId: string | null,
@@ -399,10 +480,19 @@ export function NodeList() {
e.preventDefault()
if (!dragState) return
// Don't allow dropping on itself or its descendants
// Don't show indicator when hovering over self
if (dragState.nodeId === parentId) return
setDragOverTarget({ parentId, index })
const isValid = canDropAt(dragState.nodeId, parentId)
setDragOverTarget({ parentId, index, isValid })
}
const handleDragLeave = (e: React.DragEvent) => {
// Only clear if leaving to an element outside the current target
const relatedTarget = e.relatedTarget as HTMLElement | null
if (!relatedTarget || !e.currentTarget.contains(relatedTarget)) {
setDragOverTarget(null)
}
}
const handleDrop = (
@@ -413,13 +503,19 @@ export function NodeList() {
e.preventDefault()
if (!dragState) return
const { parentId: sourceParentId, index: sourceIndex } = dragState
const { parentId: sourceParentId, index: sourceIndex, nodeId } = dragState
// Only handle reordering within same parent for now
if (sourceParentId === targetParentId && sourceParentId) {
const adjustedIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
if (sourceIndex !== adjustedIndex) {
reorderNodes(sourceParentId, sourceIndex, adjustedIndex)
// Only execute valid drops
if (targetParentId && canDropAt(nodeId, targetParentId)) {
if (sourceParentId === targetParentId) {
// Same parent — use reorderNodes with index adjustment
const adjustedIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
if (sourceIndex !== adjustedIndex) {
reorderNodes(sourceParentId, sourceIndex, adjustedIndex)
}
} else {
// Cross-parent — use moveNode
moveNode(nodeId, targetParentId, targetIndex)
}
}
@@ -441,7 +537,7 @@ export function NodeList() {
}
return (
<div className="rounded-lg border border-border bg-card" onDragEnd={handleDragEnd}>
<div className="rounded-lg border border-border bg-card">
<div className="flex items-center justify-between border-b border-border p-3">
<h2 className="text-sm font-semibold text-card-foreground">Nodes</h2>
<button
@@ -471,6 +567,8 @@ export function NodeList() {
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
onDragLeave={handleDragLeave}
dragOverTarget={dragOverTarget}
/>
</div>

View File

@@ -17,8 +17,8 @@ const DRAFT_STORAGE_KEY = 'tree-editor-draft'
// Helper to generate unique IDs
const generateId = () => crypto.randomUUID()
// Helper to find a node in the tree structure
const findNodeInTree = (
// Helper to find a node in the tree structure (exported for drag validation)
export const findNodeInTree = (
nodeId: string,
structure: TreeStructure | null
): TreeStructure | null => {
@@ -144,6 +144,7 @@ interface TreeEditorState {
// Actions - Node ordering
reorderNodes: (parentId: string, fromIndex: number, toIndex: number) => void
moveNode: (nodeId: string, targetParentId: string, targetIndex: number) => void
reorderOptions: (nodeId: string, fromIndex: number, toIndex: number) => void
// Actions - Selection
@@ -496,6 +497,37 @@ export const useTreeEditorStore = create<TreeEditorState>()(
get().autoSaveDraft()
},
moveNode: (nodeId: string, targetParentId: string, targetIndex: number) => {
set((state) => {
// Find and remove from current parent
const currentParent = findParentNode(nodeId, state.treeStructure)
if (!currentParent?.children) return
const sourceIndex = currentParent.children.findIndex(c => c.id === nodeId)
if (sourceIndex === -1) return
const [movedNode] = currentParent.children.splice(sourceIndex, 1)
// Find target parent and insert
const targetParent = findNodeInTree(targetParentId, state.treeStructure)
if (!targetParent) return
if (!targetParent.children) {
targetParent.children = []
}
// Adjust index if moving within same parent and source was before target
let adjustedIndex = targetIndex
if (currentParent.id === targetParent.id && sourceIndex < targetIndex) {
adjustedIndex = targetIndex - 1
}
targetParent.children.splice(adjustedIndex, 0, movedNode)
state.isDirty = true
})
get().autoSaveDraft()
},
reorderOptions: (nodeId: string, fromIndex: number, toIndex: number) => {
set((state) => {
const node = findNodeInTree(nodeId, state.treeStructure)