fix(outliner): move entire subtree with all children during drag and drop
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s

This commit is contained in:
sandrodach committed 2026-10-27 16:00:03 +01:00
1 parent 266026da4c
commit 1bdd7d245d
2 files changed
+94 -12

No files matched your search

@@ -1072,22 +1072,60 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
); );
const handleMoveBlock = useCallback( const handleMoveBlock = useCallback(
(blockId: string, newIndex: number, level?: number) => { (blockId: string, targetIndex: number, newLevel?: number) => {
if (!effectiveCanEdit) return; if (!effectiveCanEdit) return;
setBlocks((prev) => { setBlocks((prev) => {
const fromIdx = prev.findIndex((b) => b.id === blockId); const fromIdx = prev.findIndex((b) => b.id === blockId);
if (fromIdx === -1) return prev; 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 copy = [...prev];
const [moving] = copy.splice(fromIdx, 1); const subtree = copy.splice(fromIdx, count);
const updatedMoving: DocumentBlock = {
...moving, // Adjust insertion index because removing elements from before shifts target index
level: level !== undefined ? level : moving.level, let insertIndex = targetIndex;
}; if (fromIdx < targetIndex) {
const boundedIdx = Math.max(0, Math.min(newIndex, copy.length)); insertIndex = targetIndex - count;
copy.splice(boundedIdx, 0, updatedMoving); }
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); const normalized = ensureNonEmptyBlocks(docId, copy);
if (isLiveEnabled) { if (isLiveEnabled) {
sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized); sendBlockMoved(blockId, insertIndex, updatedSubtree[0].level, normalized);
} }
return normalized; return normalized;
}); });
@@ -282,10 +282,29 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
setDraggedIndex(index); setDraggedIndex(index);
}, []); }, []);
const handleDragOverBlock = useCallback((e: React.DragEvent, index: number) => { const handleDragOverBlock = useCallback(
(e: React.DragEvent, index: number) => {
e.preventDefault(); 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); setDragOverIndex(index);
}, []); },
[blocks, draggedIndex]
);
const handleDropBlock = useCallback( const handleDropBlock = useCallback(
(targetIndex: number) => { (targetIndex: number) => {
@@ -294,6 +313,20 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
setDragOverIndex(null); setDragOverIndex(null);
return; 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]; const movingBlock = blocks[draggedIndex];
if (movingBlock) { if (movingBlock) {
onMoveBlock(movingBlock.id, targetIndex, movingBlock.level); onMoveBlock(movingBlock.id, targetIndex, movingBlock.level);
@@ -372,7 +405,18 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
})} })}
{canEdit && ( {canEdit && (
<div className="pt-2 pl-2"> <div
className={`pt-2 pl-2 transition-colors ${
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
}`}
onDragOver={(e) => {
e.preventDefault();
if (draggedIndex !== null) {
setDragOverIndex(blocks.length);
}
}}
onDrop={() => handleDropBlock(blocks.length)}
>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {