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 [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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user