fix: repair tree editor drag-to-reorder (6 bugs) #48
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user