fix: repair tree editor drag-to-reorder with 6 bug fixes

- Grip-only drag initiation (prevents conflict with click-to-select)
- onDragEnd on each draggable item (clears ghost state after failed drops)
- Trailing drop zone after last child (enables drop-to-last-position)
- Suppress cross-parent drag indicators (no misleading visual feedback)
- onDragLeave handler to clear drop indicators when cursor exits
- Source parent tracking threaded through component tree

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-08 23:58:48 -05:00
parent 7ab2ff1be2
commit b265269024

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import {
Plus,
Pencil,
@@ -34,7 +34,10 @@ 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
onDragEnd: () => void
onDragLeave: (e: React.DragEvent) => void
dragOverTarget: { parentId: string | null; index: number } | null
dragSourceParentId: string | null | undefined
/** Array of booleans indicating which ancestor levels should show continuing lines */
ancestorLines?: boolean[]
}
@@ -53,11 +56,15 @@ function NodeListItem({
onDragStart,
onDragOver,
onDrop,
onDragEnd,
onDragLeave,
dragOverTarget,
dragSourceParentId,
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 +86,9 @@ function NodeListItem({
}
const isDragTarget =
dragOverTarget?.parentId === parentId && dragOverTarget?.index === index
dragOverTarget?.parentId === parentId &&
dragOverTarget?.index === index &&
(dragSourceParentId === undefined || dragSourceParentId === parentId)
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
decision: <HelpCircle className="h-4 w-4" />,
@@ -148,8 +157,19 @@ function NodeListItem({
<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 +207,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,11 +360,35 @@ function NodeListItem({
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
onDragLeave={onDragLeave}
dragOverTarget={dragOverTarget}
dragSourceParentId={dragSourceParentId}
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 &&
(dragSourceParentId === undefined || dragSourceParentId === node.id)
return (
<div
className={cn(
'h-2 rounded-full mx-2 transition-colors',
isTrailingTarget ? 'bg-primary' : '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)}
/>
)
})()}
</>
)
}
@@ -402,9 +450,20 @@ export function NodeList() {
// Don't allow dropping on itself or its descendants
if (dragState.nodeId === parentId) return
// Suppress cross-parent drag indicator (not supported yet)
if (dragState.parentId !== parentId) return
setDragOverTarget({ parentId, index })
}
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 = (
e: React.DragEvent,
targetParentId: string | null,
@@ -441,7 +500,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,7 +530,10 @@ export function NodeList() {
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
onDragLeave={handleDragLeave}
dragOverTarget={dragOverTarget}
dragSourceParentId={dragState?.parentId}
/>
</div>