diff --git a/frontend/src/components/tree-editor/NodeList.tsx b/frontend/src/components/tree-editor/NodeList.tsx index c3487b19..3ac0e314 100644 --- a/frontend/src/components/tree-editor/NodeList.tsx +++ b/frontend/src/components/tree-editor/NodeList.tsx @@ -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 = { decision: , @@ -152,7 +150,15 @@ function NodeListItem({ <> {/* Drop indicator above */} {isDragTarget && ( -
+
)}
) @@ -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 (
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(null) const [isEditingNewNode, setIsEditingNewNode] = useState(false) const [addingToParent, setAddingToParent] = useState(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} />
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)