feat: add cross-parent drag-and-drop with validation feedback
Enable moving nodes between different parents in the tree editor. Drop targets show blue indicator for valid drops and red pulsing glow for invalid drops (e.g., dropping onto solution nodes or onto descendants of the dragged node). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #48.
This commit is contained in:
@@ -14,7 +14,7 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle
|
AlertTriangle
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
import { useTreeEditorStore, findNodeInTree } from '@/store/treeEditorStore'
|
||||||
import { NodeEditorModal } from './NodeEditorModal'
|
import { NodeEditorModal } from './NodeEditorModal'
|
||||||
import type { TreeStructure, NodeType } from '@/types'
|
import type { TreeStructure, NodeType } from '@/types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -36,8 +36,7 @@ interface NodeListItemProps {
|
|||||||
onDrop: (e: React.DragEvent, parentId: string | null, index: number) => void
|
onDrop: (e: React.DragEvent, parentId: string | null, index: number) => void
|
||||||
onDragEnd: () => void
|
onDragEnd: () => void
|
||||||
onDragLeave: (e: React.DragEvent) => void
|
onDragLeave: (e: React.DragEvent) => void
|
||||||
dragOverTarget: { parentId: string | null; index: number } | null
|
dragOverTarget: { parentId: string | null; index: number; isValid: boolean } | 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[]
|
||||||
}
|
}
|
||||||
@@ -59,7 +58,6 @@ function NodeListItem({
|
|||||||
onDragEnd,
|
onDragEnd,
|
||||||
onDragLeave,
|
onDragLeave,
|
||||||
dragOverTarget,
|
dragOverTarget,
|
||||||
dragSourceParentId,
|
|
||||||
ancestorLines = []
|
ancestorLines = []
|
||||||
}: NodeListItemProps) {
|
}: NodeListItemProps) {
|
||||||
const { selectedNodeId, selectNode, validationErrors } = useTreeEditorStore()
|
const { selectedNodeId, selectNode, validationErrors } = useTreeEditorStore()
|
||||||
@@ -87,8 +85,8 @@ function NodeListItem({
|
|||||||
|
|
||||||
const isDragTarget =
|
const isDragTarget =
|
||||||
dragOverTarget?.parentId === parentId &&
|
dragOverTarget?.parentId === parentId &&
|
||||||
dragOverTarget?.index === index &&
|
dragOverTarget?.index === index
|
||||||
(dragSourceParentId === undefined || dragSourceParentId === parentId)
|
const isDragValid = isDragTarget && dragOverTarget?.isValid
|
||||||
|
|
||||||
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" />,
|
||||||
@@ -152,7 +150,15 @@ function NodeListItem({
|
|||||||
<>
|
<>
|
||||||
{/* Drop indicator above */}
|
{/* Drop indicator above */}
|
||||||
{isDragTarget && (
|
{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
|
<div
|
||||||
@@ -363,7 +369,6 @@ function NodeListItem({
|
|||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
dragOverTarget={dragOverTarget}
|
dragOverTarget={dragOverTarget}
|
||||||
dragSourceParentId={dragSourceParentId}
|
|
||||||
ancestorLines={childAncestorLines}
|
ancestorLines={childAncestorLines}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -374,13 +379,17 @@ function NodeListItem({
|
|||||||
const trailingIndex = node.children!.length
|
const trailingIndex = node.children!.length
|
||||||
const isTrailingTarget =
|
const isTrailingTarget =
|
||||||
dragOverTarget?.parentId === node.id &&
|
dragOverTarget?.parentId === node.id &&
|
||||||
dragOverTarget?.index === trailingIndex &&
|
dragOverTarget?.index === trailingIndex
|
||||||
(dragSourceParentId === undefined || dragSourceParentId === node.id)
|
const isTrailingValid = isTrailingTarget && dragOverTarget?.isValid
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-2 rounded-full mx-2 transition-colors',
|
'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` }}
|
style={{ marginLeft: `${(depth + 1) * 20 + 8}px` }}
|
||||||
onDragOver={(e) => onDragOver(e, node.id, trailingIndex)}
|
onDragOver={(e) => onDragOver(e, node.id, trailingIndex)}
|
||||||
@@ -394,7 +403,7 @@ function NodeListItem({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NodeList() {
|
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 [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
||||||
const [isEditingNewNode, setIsEditingNewNode] = useState(false)
|
const [isEditingNewNode, setIsEditingNewNode] = useState(false)
|
||||||
const [addingToParent, setAddingToParent] = useState<string | null>(null)
|
const [addingToParent, setAddingToParent] = useState<string | null>(null)
|
||||||
@@ -409,6 +418,7 @@ export function NodeList() {
|
|||||||
const [dragOverTarget, setDragOverTarget] = useState<{
|
const [dragOverTarget, setDragOverTarget] = useState<{
|
||||||
parentId: string | null
|
parentId: string | null
|
||||||
index: number
|
index: number
|
||||||
|
isValid: boolean
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
const handleAddNode = (type: NodeType) => {
|
const handleAddNode = (type: NodeType) => {
|
||||||
@@ -439,6 +449,29 @@ export function NodeList() {
|
|||||||
setDragState({ nodeId, parentId, index })
|
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 = (
|
const handleDragOver = (
|
||||||
e: React.DragEvent,
|
e: React.DragEvent,
|
||||||
parentId: string | null,
|
parentId: string | null,
|
||||||
@@ -447,13 +480,11 @@ export function NodeList() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!dragState) return
|
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
|
if (dragState.nodeId === parentId) return
|
||||||
|
|
||||||
// Suppress cross-parent drag indicator (not supported yet)
|
const isValid = canDropAt(dragState.nodeId, parentId)
|
||||||
if (dragState.parentId !== parentId) return
|
setDragOverTarget({ parentId, index, isValid })
|
||||||
|
|
||||||
setDragOverTarget({ parentId, index })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent) => {
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
@@ -472,14 +503,20 @@ export function NodeList() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!dragState) return
|
if (!dragState) return
|
||||||
|
|
||||||
const { parentId: sourceParentId, index: sourceIndex } = dragState
|
const { parentId: sourceParentId, index: sourceIndex, nodeId } = dragState
|
||||||
|
|
||||||
// Only handle reordering within same parent for now
|
// Only execute valid drops
|
||||||
if (sourceParentId === targetParentId && sourceParentId) {
|
if (targetParentId && canDropAt(nodeId, targetParentId)) {
|
||||||
|
if (sourceParentId === targetParentId) {
|
||||||
|
// Same parent — use reorderNodes with index adjustment
|
||||||
const adjustedIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
|
const adjustedIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
|
||||||
if (sourceIndex !== adjustedIndex) {
|
if (sourceIndex !== adjustedIndex) {
|
||||||
reorderNodes(sourceParentId, sourceIndex, adjustedIndex)
|
reorderNodes(sourceParentId, sourceIndex, adjustedIndex)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Cross-parent — use moveNode
|
||||||
|
moveNode(nodeId, targetParentId, targetIndex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setDragState(null)
|
setDragState(null)
|
||||||
@@ -533,7 +570,6 @@ export function NodeList() {
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
dragOverTarget={dragOverTarget}
|
dragOverTarget={dragOverTarget}
|
||||||
dragSourceParentId={dragState?.parentId}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ const DRAFT_STORAGE_KEY = 'tree-editor-draft'
|
|||||||
// Helper to generate unique IDs
|
// Helper to generate unique IDs
|
||||||
const generateId = () => crypto.randomUUID()
|
const generateId = () => crypto.randomUUID()
|
||||||
|
|
||||||
// Helper to find a node in the tree structure
|
// Helper to find a node in the tree structure (exported for drag validation)
|
||||||
const findNodeInTree = (
|
export const findNodeInTree = (
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
structure: TreeStructure | null
|
structure: TreeStructure | null
|
||||||
): TreeStructure | null => {
|
): TreeStructure | null => {
|
||||||
@@ -144,6 +144,7 @@ interface TreeEditorState {
|
|||||||
|
|
||||||
// Actions - Node ordering
|
// Actions - Node ordering
|
||||||
reorderNodes: (parentId: string, fromIndex: number, toIndex: number) => void
|
reorderNodes: (parentId: string, fromIndex: number, toIndex: number) => void
|
||||||
|
moveNode: (nodeId: string, targetParentId: string, targetIndex: number) => void
|
||||||
reorderOptions: (nodeId: string, fromIndex: number, toIndex: number) => void
|
reorderOptions: (nodeId: string, fromIndex: number, toIndex: number) => void
|
||||||
|
|
||||||
// Actions - Selection
|
// Actions - Selection
|
||||||
@@ -496,6 +497,37 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
|||||||
get().autoSaveDraft()
|
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) => {
|
reorderOptions: (nodeId: string, fromIndex: number, toIndex: number) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const node = findNodeInTree(nodeId, state.treeStructure)
|
const node = findNodeInTree(nodeId, state.treeStructure)
|
||||||
|
|||||||
Reference in New Issue
Block a user