fix(workspace): delete empty bulletpoint on first backspace, move descendants on indent, preserve subpoints on backspace, and prompt on x button
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s

This commit is contained in:
sandrodach committed 2026-10-25 21:45:10 +01:00
1 parent c420155dc0
commit f9ef03af7f
1 file changed
+252 -66
+252 -66
View File
@@ -175,6 +175,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [draggingBlockId, setDraggingBlockId] = useState<string | null>(null); const [draggingBlockId, setDraggingBlockId] = useState<string | null>(null);
const [dragOverBlockTarget, setDragOverBlockTarget] = useState<{ blockId: string; position: 'before' | 'after' | 'inside' } | null>(null); const [dragOverBlockTarget, setDragOverBlockTarget] = useState<{ blockId: string; position: 'before' | 'after' | 'inside' } | null>(null);
// Modal for deleting block with sub-bulletpoints (X button)
const [deleteSubpointsModal, setDeleteSubpointsModal] = useState<{
blockId: string;
count: number;
snippet: string;
} | null>(null);
// --- GLOBAL TO-DO ROLLUP STATE --- // --- GLOBAL TO-DO ROLLUP STATE ---
const [todoFilter, setTodoFilter] = useState<'all' | 'open' | 'done'>('open'); const [todoFilter, setTodoFilter] = useState<'all' | 'open' | 'done'>('open');
const [todoFolderDrilldown, setTodoFolderDrilldown] = useState<string | null>(null); const [todoFolderDrilldown, setTodoFolderDrilldown] = useState<string | null>(null);
@@ -1281,59 +1288,162 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}, 20); }, 20);
}; };
// Indent block (Tab): increase level by 1 // Indent block (Tab): increase level by 1 and move all descendant sub-bullets along
const indentBlock = (index: number) => { const indentBlock = (index: number) => {
if (isReadOnly || !selectedDoc || index === 0) return; if (isReadOnly || !selectedDoc || index === 0) return;
const block = selectedDoc.blocks[index]; const blocks = selectedDoc.blocks;
const prevBlock = selectedDoc.blocks[index - 1]; const block = blocks[index];
const prevBlock = blocks[index - 1];
if (!block || !prevBlock) return;
const currentLevel = block.level || 0; const currentLevel = block.level || 0;
const maxLevel = (prevBlock.level || 0) + 1; const maxLevel = (prevBlock.level || 0) + 1;
if (currentLevel < maxLevel) { if (currentLevel >= maxLevel) return;
updateBlock(block.id, { level: currentLevel + 1 });
}
};
// Outdent block (Shift+Tab): decrease level by 1 (without leaving active zoomed subtree)
const outdentBlock = (blockId: string) => {
if (isReadOnly || !selectedDoc) return;
const block = selectedDoc.blocks.find((b) => b.id === blockId);
if (!block) return;
const currentLevel = block.level || 0;
const minLevel = zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0;
if (currentLevel > minLevel) {
updateBlock(blockId, { level: currentLevel - 1 });
}
};
// Delete block and all its descendant sub-bullets with confirmation
const removeBlock = (blockId: string): boolean => {
if (isReadOnly || !selectedDoc) return false;
const blocks = selectedDoc.blocks;
const index = blocks.findIndex((b) => b.id === blockId);
if (index === -1) return false;
const targetBlock = blocks[index];
const targetLevel = targetBlock.level || 0;
// Find all descendant sub-bullets directly or indirectly nested under this block // Find all descendant sub-bullets directly or indirectly nested under this block
const descendantIds: string[] = []; const descendantIndices: number[] = [];
for (let i = index + 1; i < blocks.length; i++) { for (let i = index + 1; i < blocks.length; i++) {
const cur = blocks[i]; if ((blocks[i].level || 0) <= currentLevel) break;
const curLevel = cur.level || 0; descendantIndices.push(i);
if (curLevel <= targetLevel) break;
descendantIds.push(cur.id);
} }
if (descendantIds.length > 0) { const nextBlocks = blocks.map((b, idx) => {
const count = descendantIds.length; if (idx === index) {
const snippet = targetBlock.content?.trim() ? ` "${targetBlock.content.trim().slice(0, 30)}..."` : ''; return { ...b, level: currentLevel + 1 };
const confirmText =
count === 1
? `Dieser Aufzählungspunkt${snippet} enthält 1 Unterpunkt. Möchtest du ihn mitsamt dem Unterpunkt wirklich löschen?`
: `Dieser Aufzählungspunkt${snippet} enthält ${count} Unterpunkte. Möchtest du ihn mitsamt allen ${count} Unterpunkten wirklich löschen?`;
if (!window.confirm(confirmText)) {
return false;
} }
if (descendantIndices.includes(idx)) {
return { ...b, level: (b.level || 0) + 1 };
}
return b;
});
persistDocument({ ...selectedDoc, blocks: nextBlocks });
};
// Outdent block (Shift+Tab): decrease level by 1 and move all descendant sub-bullets along
const outdentBlock = (blockId: string) => {
if (isReadOnly || !selectedDoc) return;
const blocks = selectedDoc.blocks;
const index = blocks.findIndex((b) => b.id === blockId);
if (index === -1) return;
const block = blocks[index];
const currentLevel = block.level || 0;
const minLevel = zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0;
if (currentLevel <= minLevel) return;
// Find all descendant sub-bullets directly or indirectly nested under this block
const descendantIndices: number[] = [];
for (let i = index + 1; i < blocks.length; i++) {
if ((blocks[i].level || 0) <= currentLevel) break;
descendantIndices.push(i);
}
const nextBlocks = blocks.map((b, idx) => {
if (idx === index) {
return { ...b, level: currentLevel - 1 };
}
if (descendantIndices.includes(idx)) {
return { ...b, level: Math.max(minLevel, (b.level || 0) - 1) };
}
return b;
});
persistDocument({ ...selectedDoc, blocks: nextBlocks });
};
// Delete a single block without deleting its sub-bullets (sub-bullets are promoted 1 level up)
const deleteSingleBlock = (blockId: string, focusPrev = true) => {
if (isReadOnly || !selectedDoc) return;
const blocks = selectedDoc.blocks;
const index = blocks.findIndex((b) => b.id === blockId);
if (index === -1) return;
const targetBlock = blocks[index];
const targetLevel = targetBlock.level || 0;
const minLevel = zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0;
// Find all descendant sub-bullets directly or indirectly nested under this block
const descendantIndices: number[] = [];
for (let i = index + 1; i < blocks.length; i++) {
if ((blocks[i].level || 0) <= targetLevel) break;
descendantIndices.push(i);
}
triggerHaptic(10);
// Target block is removed, but descendants are PRESERVED!
// Descendants are shifted up by 1 level to adopt the target block's level
let nextBlocks: DocumentBlock[] = [];
for (let i = 0; i < blocks.length; i++) {
if (i === index) continue;
if (descendantIndices.includes(i)) {
const curLevel = blocks[i].level || 0;
nextBlocks.push({
...blocks[i],
level: Math.max(minLevel, curLevel - 1),
});
} else {
nextBlocks.push(blocks[i]);
}
}
if (nextBlocks.length === 0) {
nextBlocks = [
{
id: `blk_${Date.now()}_1`,
type: 'bullet',
content: '',
level: minLevel,
},
];
}
let prevBlock: DocumentBlock | undefined;
if (focusPrev) {
if (zoomedBlock) {
const zoomIdx = blocks.findIndex((b) => b.id === zoomedBlock.id);
if (index > zoomIdx + 1) {
prevBlock = blocks[index - 1];
} else {
const nextFirstChild = nextBlocks[zoomIdx + 1];
if (nextFirstChild && (nextFirstChild.level || 0) > (zoomedBlock.level || 0)) {
prevBlock = nextFirstChild;
}
}
} else {
prevBlock = index > 0 ? blocks[index - 1] : nextBlocks[0];
}
}
persistDocument({ ...selectedDoc, blocks: nextBlocks });
if (prevBlock) {
setLastFocusedBlockId(prevBlock.id);
setBlockCursor(prevBlock.id, (prevBlock.content || '').length);
setTimeout(() => {
const el = blockInputRefs.current[prevBlock.id];
if (el) {
el.focus();
if ('setSelectionRange' in el && typeof el.value === 'string') {
el.setSelectionRange(el.value.length, el.value.length);
}
}
}, 30);
}
};
// Delete a block AND all its descendant sub-bullets
const deleteBlockWithDescendants = (blockId: string) => {
if (isReadOnly || !selectedDoc) return;
const blocks = selectedDoc.blocks;
const index = blocks.findIndex((b) => b.id === blockId);
if (index === -1) return;
const targetBlock = blocks[index];
const targetLevel = targetBlock.level || 0;
const minLevel = zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0;
const descendantIds: string[] = [];
for (let i = index + 1; i < blocks.length; i++) {
if ((blocks[i].level || 0) <= targetLevel) break;
descendantIds.push(blocks[i].id);
} }
triggerHaptic(15); triggerHaptic(15);
@@ -1346,12 +1456,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
id: `blk_${Date.now()}_1`, id: `blk_${Date.now()}_1`,
type: 'bullet', type: 'bullet',
content: '', content: '',
level: 0, level: minLevel,
}, },
]; ];
} }
// Determine fallback block to focus
let prevBlock: DocumentBlock | undefined; let prevBlock: DocumentBlock | undefined;
if (zoomedBlock) { if (zoomedBlock) {
const zoomIdx = blocks.findIndex((b) => b.id === zoomedBlock.id); const zoomIdx = blocks.findIndex((b) => b.id === zoomedBlock.id);
@@ -1382,16 +1491,32 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
} }
}, 30); }, 30);
} }
return true;
}; };
// Delete block and focus the preceding one // Handler for delete click on the X icon: prompts if sub-bullets exist
const deleteBlockAndFocusPrev = (index: number) => { const handleDeleteButtonClick = (blockId: string) => {
if (isReadOnly || !selectedDoc) return; if (isReadOnly || !selectedDoc) return;
const targetBlock = selectedDoc.blocks[index]; const blocks = selectedDoc.blocks;
if (!targetBlock) return; const index = blocks.findIndex((b) => b.id === blockId);
removeBlock(targetBlock.id); if (index === -1) return;
const targetBlock = blocks[index];
const targetLevel = targetBlock.level || 0;
let count = 0;
for (let i = index + 1; i < blocks.length; i++) {
if ((blocks[i].level || 0) <= targetLevel) break;
count++;
}
if (count > 0) {
setDeleteSubpointsModal({
blockId,
count,
snippet: targetBlock.content?.trim() ? `"${targetBlock.content.trim().slice(0, 35)}..."` : '',
});
} else {
deleteSingleBlock(blockId, false);
}
}; };
const updateBlock = (blockId: string, patch: Partial<DocumentBlock>) => { const updateBlock = (blockId: string, patch: Partial<DocumentBlock>) => {
@@ -1538,19 +1663,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const target = e.target as HTMLTextAreaElement | HTMLInputElement; const target = e.target as HTMLTextAreaElement | HTMLInputElement;
const fullContent = block.content || ''; const fullContent = block.content || '';
if (!fullContent || fullContent === '') { if (!fullContent || fullContent === '') {
const minLevel = zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0;
if ((block.level || 0) > minLevel) {
e.preventDefault();
outdentBlock(block.id);
} else if (
zoomedBlock
? visibleBlocks.length > 1
: selectedDoc?.blocks && selectedDoc.blocks.length > 1
) {
e.preventDefault(); e.preventDefault();
setSlashBlockId(null); setSlashBlockId(null);
deleteBlockAndFocusPrev(index); deleteSingleBlock(block.id, true);
} return;
} else if ( } else if (
segStart > 0 && segStart > 0 &&
target && target &&
@@ -4134,7 +4250,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
insertNewBulletAfter(originalIndex, block.level || 0); insertNewBulletAfter(originalIndex, block.level || 0);
} else if (!isReadOnly && (e.key === 'Backspace' || e.key === 'Delete')) { } else if (!isReadOnly && (e.key === 'Backspace' || e.key === 'Delete')) {
e.preventDefault(); e.preventDefault();
deleteBlockAndFocusPrev(originalIndex); deleteSingleBlock(block.id, true);
} }
}} }}
onClick={() => { onClick={() => {
@@ -4202,8 +4318,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{!isReadOnly && ( {!isReadOnly && (
<button <button
type="button" type="button"
onClick={() => removeBlock(block.id)} onClick={() => handleDeleteButtonClick(block.id)}
className="opacity-0 group-hover:opacity-100 p-0.5 text-faint hover:text-status-err rounded transition-opacity" className="opacity-0 group-hover:opacity-100 p-0.5 text-faint hover:text-status-err rounded transition-opacity cursor-pointer"
title="Bullet löschen" title="Bullet löschen"
> >
<X className="w-3 h-3" /> <X className="w-3 h-3" />
@@ -5044,6 +5160,76 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</div> </div>
</div> </div>
)} )}
{/* DELETE SUBPOINTS CONFIRMATION MODAL */}
{deleteSubpointsModal && (
<div
className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
onClick={() => setDeleteSubpointsModal(null)}
>
<div
className="bg-panel border border-border rounded-xl shadow-panel w-full max-w-md overflow-hidden animate-in fade-in zoom-in-95 duration-150 p-5 space-y-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-full bg-status-err/15 text-status-err flex items-center justify-center shrink-0">
<Trash2 className="w-4 h-4" />
</div>
<div>
<h3 className="font-bold text-sm text-main">Aufzählungspunkt löschen</h3>
<p className="text-xs text-muted mt-0.5">
Dieser Punkt enthält <strong>{deleteSubpointsModal.count} {deleteSubpointsModal.count === 1 ? 'Unterpunkt' : 'Unterpunkte'}</strong>.
</p>
</div>
</div>
<button
type="button"
onClick={() => setDeleteSubpointsModal(null)}
className="p-1 text-muted hover:text-main rounded-btn transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
<p className="text-xs text-main">
Möchtest du alle Unterpunkte mit löschen oder nur diesen Aufzählungspunkt entfernen?
</p>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-end gap-2 pt-2 border-t border-border">
<button
type="button"
onClick={() => setDeleteSubpointsModal(null)}
className="btn-ghost h-8 px-3 text-xs"
>
Abbrechen
</button>
<button
type="button"
onClick={() => {
const bId = deleteSubpointsModal.blockId;
setDeleteSubpointsModal(null);
deleteSingleBlock(bId, false);
}}
className="btn-action h-8 px-3 text-xs font-medium"
>
Nur diesen Punkt löschen
</button>
<button
type="button"
onClick={() => {
const bId = deleteSubpointsModal.blockId;
setDeleteSubpointsModal(null);
deleteBlockWithDescendants(bId);
}}
className="btn-danger h-8 px-3 text-xs font-semibold"
>
Alle Unterpunkte mit löschen
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };