From f9ef03af7fea3e957b41b75fbf6b05874cbd457c Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 21:45:10 +0100 Subject: [PATCH] fix(workspace): delete empty bulletpoint on first backspace, move descendants on indent, preserve subpoints on backspace, and prompt on x button --- .../src/features/workspace/WorkspaceView.tsx | 322 ++++++++++++++---- 1 file changed, 254 insertions(+), 68 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index cd6eb30..37d61f2 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -175,6 +175,13 @@ export const WorkspaceView: React.FC = ({ const [draggingBlockId, setDraggingBlockId] = useState(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 --- const [todoFilter, setTodoFilter] = useState<'all' | 'open' | 'done'>('open'); const [todoFolderDrilldown, setTodoFolderDrilldown] = useState(null); @@ -1281,59 +1288,162 @@ export const WorkspaceView: React.FC = ({ }, 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) => { if (isReadOnly || !selectedDoc || index === 0) return; - const block = selectedDoc.blocks[index]; - const prevBlock = selectedDoc.blocks[index - 1]; + const blocks = selectedDoc.blocks; + const block = blocks[index]; + const prevBlock = blocks[index - 1]; + if (!block || !prevBlock) return; const currentLevel = block.level || 0; const maxLevel = (prevBlock.level || 0) + 1; - if (currentLevel < maxLevel) { - 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; + if (currentLevel >= maxLevel) return; // 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++) { - const cur = blocks[i]; - const curLevel = cur.level || 0; - if (curLevel <= targetLevel) break; - descendantIds.push(cur.id); + if ((blocks[i].level || 0) <= currentLevel) break; + descendantIndices.push(i); } - if (descendantIds.length > 0) { - const count = descendantIds.length; - const snippet = targetBlock.content?.trim() ? ` "${targetBlock.content.trim().slice(0, 30)}..."` : ''; - 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; + const nextBlocks = blocks.map((b, idx) => { + if (idx === index) { + return { ...b, level: currentLevel + 1 }; } + 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); @@ -1346,12 +1456,11 @@ export const WorkspaceView: React.FC = ({ id: `blk_${Date.now()}_1`, type: 'bullet', content: '', - level: 0, + level: minLevel, }, ]; } - // Determine fallback block to focus let prevBlock: DocumentBlock | undefined; if (zoomedBlock) { const zoomIdx = blocks.findIndex((b) => b.id === zoomedBlock.id); @@ -1382,16 +1491,32 @@ export const WorkspaceView: React.FC = ({ } }, 30); } - - return true; }; - // Delete block and focus the preceding one - const deleteBlockAndFocusPrev = (index: number) => { + // Handler for delete click on the X icon: prompts if sub-bullets exist + const handleDeleteButtonClick = (blockId: string) => { if (isReadOnly || !selectedDoc) return; - const targetBlock = selectedDoc.blocks[index]; - if (!targetBlock) return; - removeBlock(targetBlock.id); + 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; + + 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) => { @@ -1538,19 +1663,10 @@ export const WorkspaceView: React.FC = ({ const target = e.target as HTMLTextAreaElement | HTMLInputElement; const fullContent = block.content || ''; 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(); - setSlashBlockId(null); - deleteBlockAndFocusPrev(index); - } + e.preventDefault(); + setSlashBlockId(null); + deleteSingleBlock(block.id, true); + return; } else if ( segStart > 0 && target && @@ -4134,7 +4250,7 @@ export const WorkspaceView: React.FC = ({ insertNewBulletAfter(originalIndex, block.level || 0); } else if (!isReadOnly && (e.key === 'Backspace' || e.key === 'Delete')) { e.preventDefault(); - deleteBlockAndFocusPrev(originalIndex); + deleteSingleBlock(block.id, true); } }} onClick={() => { @@ -4202,8 +4318,8 @@ export const WorkspaceView: React.FC = ({ {!isReadOnly && ( + + +

+ Möchtest du alle Unterpunkte mit löschen oder nur diesen Aufzählungspunkt entfernen? +

+ +
+ + + +
+ + + )} ); };