diff --git a/frontend/src/components/tree-editor/NodeList.tsx b/frontend/src/components/tree-editor/NodeList.tsx index 0f924bbb..3ac0e314 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, @@ -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 = { decision: , @@ -143,13 +150,32 @@ function NodeListItem({ <> {/* Drop indicator above */} {isDragTarget && ( -
+
)}
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' && ( - + { gripInitiated.current = true }} + onMouseUp={() => { gripInitiated.current = false }} + /> )} {node.id === 'root' &&
} @@ -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 ( +
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(null) const [isEditingNewNode, setIsEditingNewNode] = useState(false) const [addingToParent, setAddingToParent] = useState(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 ( -
+

Nodes

diff --git a/frontend/src/store/treeEditorStore.ts b/frontend/src/store/treeEditorStore.ts index 495e5091..92d310a7 100644 --- a/frontend/src/store/treeEditorStore.ts +++ b/frontend/src/store/treeEditorStore.ts @@ -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()( 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)