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:
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -34,7 +34,10 @@ interface NodeListItemProps {
|
|||||||
onDragStart: (e: React.DragEvent, nodeId: string, parentId: string | null, index: number) => void
|
onDragStart: (e: React.DragEvent, nodeId: string, parentId: string | null, index: number) => void
|
||||||
onDragOver: (e: React.DragEvent, 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
|
onDrop: (e: React.DragEvent, parentId: string | null, index: number) => void
|
||||||
|
onDragEnd: () => void
|
||||||
|
onDragLeave: (e: React.DragEvent) => void
|
||||||
dragOverTarget: { parentId: string | null; index: number } | null
|
dragOverTarget: { parentId: string | null; index: number } | null
|
||||||
|
dragSourceParentId: string | null | undefined
|
||||||
/** Array of booleans indicating which ancestor levels should show continuing lines */
|
/** Array of booleans indicating which ancestor levels should show continuing lines */
|
||||||
ancestorLines?: boolean[]
|
ancestorLines?: boolean[]
|
||||||
}
|
}
|
||||||
@@ -53,11 +56,15 @@ function NodeListItem({
|
|||||||
onDragStart,
|
onDragStart,
|
||||||
onDragOver,
|
onDragOver,
|
||||||
onDrop,
|
onDrop,
|
||||||
|
onDragEnd,
|
||||||
|
onDragLeave,
|
||||||
dragOverTarget,
|
dragOverTarget,
|
||||||
|
dragSourceParentId,
|
||||||
ancestorLines = []
|
ancestorLines = []
|
||||||
}: NodeListItemProps) {
|
}: NodeListItemProps) {
|
||||||
const { selectedNodeId, selectNode, validationErrors } = useTreeEditorStore()
|
const { selectedNodeId, selectNode, validationErrors } = useTreeEditorStore()
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||||
|
const gripInitiated = useRef(false)
|
||||||
const isSelected = selectedNodeId === node.id
|
const isSelected = selectedNodeId === node.id
|
||||||
const isRootNode = node.id === 'root'
|
const isRootNode = node.id === 'root'
|
||||||
const nodeErrors = validationErrors.filter(e => e.nodeId === node.id && e.severity === 'error')
|
const nodeErrors = validationErrors.filter(e => e.nodeId === node.id && e.severity === 'error')
|
||||||
@@ -79,7 +86,9 @@ function NodeListItem({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isDragTarget =
|
const isDragTarget =
|
||||||
dragOverTarget?.parentId === parentId && dragOverTarget?.index === index
|
dragOverTarget?.parentId === parentId &&
|
||||||
|
dragOverTarget?.index === index &&
|
||||||
|
(dragSourceParentId === undefined || dragSourceParentId === parentId)
|
||||||
|
|
||||||
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
|
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
|
||||||
decision: <HelpCircle className="h-4 w-4" />,
|
decision: <HelpCircle className="h-4 w-4" />,
|
||||||
@@ -148,8 +157,19 @@ function NodeListItem({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
draggable={node.id !== 'root'}
|
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)}
|
onDragOver={(e) => onDragOver(e, parentId, index)}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
onDrop={(e) => onDrop(e, parentId, index)}
|
onDrop={(e) => onDrop(e, parentId, index)}
|
||||||
onClick={() => selectNode(node.id)}
|
onClick={() => selectNode(node.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -187,7 +207,11 @@ function NodeListItem({
|
|||||||
|
|
||||||
{/* Drag handle */}
|
{/* Drag handle */}
|
||||||
{node.id !== 'root' && (
|
{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" />}
|
{node.id === 'root' && <div className="w-4" />}
|
||||||
|
|
||||||
@@ -336,11 +360,35 @@ function NodeListItem({
|
|||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
dragOverTarget={dragOverTarget}
|
dragOverTarget={dragOverTarget}
|
||||||
|
dragSourceParentId={dragSourceParentId}
|
||||||
ancestorLines={childAncestorLines}
|
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
|
// Don't allow dropping on itself or its descendants
|
||||||
if (dragState.nodeId === parentId) return
|
if (dragState.nodeId === parentId) return
|
||||||
|
|
||||||
|
// Suppress cross-parent drag indicator (not supported yet)
|
||||||
|
if (dragState.parentId !== parentId) return
|
||||||
|
|
||||||
setDragOverTarget({ parentId, index })
|
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 = (
|
const handleDrop = (
|
||||||
e: React.DragEvent,
|
e: React.DragEvent,
|
||||||
targetParentId: string | null,
|
targetParentId: string | null,
|
||||||
@@ -441,7 +500,7 @@ export function NodeList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center justify-between border-b border-border p-3">
|
||||||
<h2 className="text-sm font-semibold text-card-foreground">Nodes</h2>
|
<h2 className="text-sm font-semibold text-card-foreground">Nodes</h2>
|
||||||
<button
|
<button
|
||||||
@@ -471,7 +530,10 @@ export function NodeList() {
|
|||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
dragOverTarget={dragOverTarget}
|
dragOverTarget={dragOverTarget}
|
||||||
|
dragSourceParentId={dragState?.parentId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user