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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
This commit is contained in:
1 parent
c420155dc0
commit
f9ef03af7f
1 file changed
+252
-66
@@ -175,6 +175,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const [draggingBlockId, setDraggingBlockId] = useState<string | 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 ---
|
||||
const [todoFilter, setTodoFilter] = useState<'all' | 'open' | 'done'>('open');
|
||||
const [todoFolderDrilldown, setTodoFolderDrilldown] = useState<string | null>(null);
|
||||
@@ -1281,59 +1288,162 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}, 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<WorkspaceViewProps> = ({
|
||||
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<WorkspaceViewProps> = ({
|
||||
}
|
||||
}, 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<DocumentBlock>) => {
|
||||
@@ -1538,19 +1663,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
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);
|
||||
}
|
||||
deleteSingleBlock(block.id, true);
|
||||
return;
|
||||
} else if (
|
||||
segStart > 0 &&
|
||||
target &&
|
||||
@@ -4134,7 +4250,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
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<WorkspaceViewProps> = ({
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeBlock(block.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 text-faint hover:text-status-err rounded transition-opacity"
|
||||
onClick={() => handleDeleteButtonClick(block.id)}
|
||||
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"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
@@ -5044,6 +5160,76 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user