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 94 additions and 26 deletions
Showing only changes of commit 1381aaae99 - Show all commits

View File

@@ -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'
@@ -36,8 +36,7 @@ interface NodeListItemProps {
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
dragOverTarget: { parentId: string | null; index: number; isValid: boolean } | null
/** Array of booleans indicating which ancestor levels should show continuing lines */
ancestorLines?: boolean[]
}
@@ -59,7 +58,6 @@ function NodeListItem({
onDragEnd,
onDragLeave,
dragOverTarget,
dragSourceParentId,
ancestorLines = []
}: NodeListItemProps) {
const { selectedNodeId, selectNode, validationErrors } = useTreeEditorStore()
@@ -87,8 +85,8 @@ function NodeListItem({
const isDragTarget =
dragOverTarget?.parentId === parentId &&
dragOverTarget?.index === index &&
(dragSourceParentId === undefined || dragSourceParentId === parentId)
dragOverTarget?.index === index
const isDragValid = isDragTarget && dragOverTarget?.isValid
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
decision: <HelpCircle className="h-4 w-4" />,
@@ -152,7 +150,15 @@ 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
@@ -363,7 +369,6 @@ function NodeListItem({
onDragEnd={onDragEnd}
onDragLeave={onDragLeave}
dragOverTarget={dragOverTarget}
dragSourceParentId={dragSourceParentId}
ancestorLines={childAncestorLines}
/>
)
@@ -374,13 +379,17 @@ function NodeListItem({
const trailingIndex = node.children!.length
const isTrailingTarget =
dragOverTarget?.parentId === node.id &&
dragOverTarget?.index === trailingIndex &&
(dragSourceParentId === undefined || dragSourceParentId === node.id)
dragOverTarget?.index === trailingIndex
const isTrailingValid = isTrailingTarget && dragOverTarget?.isValid
return (
<div
className={cn(
'h-2 rounded-full mx-2 transition-colors',
isTrailingTarget ? 'bg-primary' : 'bg-transparent'
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)}
@@ -394,7 +403,7 @@ function NodeListItem({
}
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)
@@ -409,6 +418,7 @@ export function NodeList() {
const [dragOverTarget, setDragOverTarget] = useState<{
parentId: string | null
index: number
isValid: boolean
} | null>(null)
const handleAddNode = (type: NodeType) => {
@@ -439,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,
@@ -447,13 +480,11 @@ 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
// Suppress cross-parent drag indicator (not supported yet)
if (dragState.parentId !== parentId) return
setDragOverTarget({ parentId, index })
const isValid = canDropAt(dragState.nodeId, parentId)
setDragOverTarget({ parentId, index, isValid })
}
const handleDragLeave = (e: React.DragEvent) => {
@@ -472,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)
}
}
@@ -533,7 +570,6 @@ export function NodeList() {
onDragEnd={handleDragEnd}
onDragLeave={handleDragLeave}
dragOverTarget={dragOverTarget}
dragSourceParentId={dragState?.parentId}
/>
</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)