diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index ae34102..a3c76ff 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -1072,22 +1072,60 @@ export const DocumentEditor: React.FC = ({ ); const handleMoveBlock = useCallback( - (blockId: string, newIndex: number, level?: number) => { + (blockId: string, targetIndex: number, newLevel?: number) => { if (!effectiveCanEdit) return; setBlocks((prev) => { const fromIdx = prev.findIndex((b) => b.id === blockId); if (fromIdx === -1) return prev; + + const parentBlock = prev[fromIdx]; + const parentLevel = Math.max(0, parentBlock.level || 0); + + // Calculate full subtree count (dragged block + all contiguous descendants) + let count = 1; + for (let i = fromIdx + 1; i < prev.length; i++) { + if (Math.max(0, prev[i].level || 0) > parentLevel) { + count++; + } else { + break; + } + } + + // Prevent dropping inside its own subtree + if (targetIndex >= fromIdx && targetIndex < fromIdx + count) { + return prev; + } + const copy = [...prev]; - const [moving] = copy.splice(fromIdx, 1); - const updatedMoving: DocumentBlock = { - ...moving, - level: level !== undefined ? level : moving.level, - }; - const boundedIdx = Math.max(0, Math.min(newIndex, copy.length)); - copy.splice(boundedIdx, 0, updatedMoving); + const subtree = copy.splice(fromIdx, count); + + // Adjust insertion index because removing elements from before shifts target index + let insertIndex = targetIndex; + if (fromIdx < targetIndex) { + insertIndex = targetIndex - count; + } + insertIndex = Math.max(0, Math.min(insertIndex, copy.length)); + + // Calculate level adjustments if moving to a constrained position + const prevBlockInPosition = insertIndex > 0 ? copy[insertIndex - 1] : null; + const maxAllowed = prevBlockInPosition + ? Math.max(0, prevBlockInPosition.level || 0) + 1 + : 0; + + const targetLevel = newLevel !== undefined ? newLevel : parentLevel; + const clampedLevel = Math.min(targetLevel, maxAllowed); + const levelDelta = clampedLevel - parentLevel; + + const updatedSubtree = subtree.map((b) => ({ + ...b, + level: Math.max(0, (b.level || 0) + levelDelta), + })); + + copy.splice(insertIndex, 0, ...updatedSubtree); const normalized = ensureNonEmptyBlocks(docId, copy); + if (isLiveEnabled) { - sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized); + sendBlockMoved(blockId, insertIndex, updatedSubtree[0].level, normalized); } return normalized; }); diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index 94a9cff..c3a2d6e 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -282,10 +282,29 @@ export const OutlinerTree: React.FC = ({ setDraggedIndex(index); }, []); - const handleDragOverBlock = useCallback((e: React.DragEvent, index: number) => { - e.preventDefault(); - setDragOverIndex(index); - }, []); + const handleDragOverBlock = useCallback( + (e: React.DragEvent, index: number) => { + e.preventDefault(); + if (draggedIndex !== null) { + const parentLevel = Math.max(0, blocks[draggedIndex]?.level || 0); + let count = 1; + for (let i = draggedIndex + 1; i < blocks.length; i++) { + if (Math.max(0, blocks[i].level || 0) > parentLevel) { + count++; + } else { + break; + } + } + // Do not allow dropping inside its own subtree (self or descendants) + if (index >= draggedIndex && index < draggedIndex + count) { + setDragOverIndex(null); + return; + } + } + setDragOverIndex(index); + }, + [blocks, draggedIndex] + ); const handleDropBlock = useCallback( (targetIndex: number) => { @@ -294,6 +313,20 @@ export const OutlinerTree: React.FC = ({ setDragOverIndex(null); return; } + const parentLevel = Math.max(0, blocks[draggedIndex]?.level || 0); + let count = 1; + for (let i = draggedIndex + 1; i < blocks.length; i++) { + if (Math.max(0, blocks[i].level || 0) > parentLevel) { + count++; + } else { + break; + } + } + if (targetIndex >= draggedIndex && targetIndex < draggedIndex + count) { + setDraggedIndex(null); + setDragOverIndex(null); + return; + } const movingBlock = blocks[draggedIndex]; if (movingBlock) { onMoveBlock(movingBlock.id, targetIndex, movingBlock.level); @@ -372,7 +405,18 @@ export const OutlinerTree: React.FC = ({ })} {canEdit && ( -
+
{ + e.preventDefault(); + if (draggedIndex !== null) { + setDragOverIndex(blocks.length); + } + }} + onDrop={() => handleDropBlock(blocks.length)} + >