From b2652690248b264045846c952edc8120ecd1c7f5 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sun, 8 Feb 2026 23:58:48 -0500 Subject: [PATCH] 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 --- .../src/components/tree-editor/NodeList.tsx | 72 +++++++++++++++++-- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/tree-editor/NodeList.tsx b/frontend/src/components/tree-editor/NodeList.tsx index 0f924bbb..c3487b19 100644 --- a/frontend/src/components/tree-editor/NodeList.tsx +++ b/frontend/src/components/tree-editor/NodeList.tsx @@ -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 = { decision: , @@ -148,8 +157,19 @@ function NodeListItem({
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' && ( - + { gripInitiated.current = true }} + onMouseUp={() => { gripInitiated.current = false }} + /> )} {node.id === 'root' &&
} @@ -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 ( +
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 ( -
+

Nodes