feat: select text across bullets and delete it like a text editor; Ctrl+Z in tasks; notes and lists without emoji; custom done columns complete tasks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m33s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 16:27:43 +01:00
1 parent e2b9daba75
commit 6fb98d4002
27 files changed
+535 -64

No files matched your search

@@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { OutlinerBlock } from './OutlinerBlock';
import { OutlinerBlock, mapRenderedOffsetToRaw } from './OutlinerBlock';
import { parsePastedLines } from '../../lib/pasteOutline';
import { numberedLabels } from '../../lib/outlineList';
import { flushSync } from 'react-dom';
@@ -39,6 +39,9 @@ export interface OutlinerTreeProps {
onResolveConflict?: (blockId: string, accept: boolean) => void;
onUndo?: () => void;
onRedo?: () => void;
/** Deleting a selection over several bullets in one step: the first bullet with its new text, the
* bullets to delete, and children of the last bullet that move under the first */
onReplaceRange?: (first: DocumentBlock, deleteIds: string[], movedChildren: DocumentBlock[]) => void;
}
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
@@ -67,6 +70,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
onResolveConflict,
onUndo,
onRedo,
onReplaceRange,
}, ref) => {
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
@@ -665,8 +669,245 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
return () => window.removeEventListener('dragend', clearDrag);
}, []);
// --- Selection over several bullets ---
// Each bullet is a text field of its own, so the browser cannot select across them. Dragging from one
// bullet into another is tracked here instead: start (anchor) and end (focus) as bullet + position in
// its raw text. The start bullet keeps the browser's own selection; the others are highlighted with the
// CSS Highlight API (rows are tinted where it is missing). Delete/Backspace removes the selected text
// like a text editor in one step (Strg+Z brings it back), a typed character replaces it, Strg+C copies.
type RangeSel = { anchorId: string; anchorOffset: number; focusId: string; focusOffset: number; focusNode?: Node; focusNodeOffset?: number };
const [rangeSel, setRangeSel] = useState<RangeSel | null>(null);
const rangeSelRef = useRef<RangeSel | null>(null);
rangeSelRef.current = rangeSel;
const treeRootRef = useRef<HTMLDivElement>(null);
const dragAnchorRef = useRef<{ id: string; offset: number } | null>(null);
const highlightSupported = typeof CSS !== 'undefined' && 'highlights' in CSS && typeof (window as any).Highlight === 'function';
const caretAtPoint = (x: number, y: number): { node: Node; offset: number } | null => {
const doc = document as any;
if (typeof doc.caretPositionFromPoint === 'function') {
const p = doc.caretPositionFromPoint(x, y);
return p ? { node: p.offsetNode, offset: p.offset } : null;
}
if (typeof doc.caretRangeFromPoint === 'function') {
const r = doc.caretRangeFromPoint(x, y);
return r ? { node: r.startContainer, offset: r.startOffset } : null;
}
return null;
};
// Position in the raw text of a bullet for a point in its rendered text (outside it: start or end)
const rawOffsetAtPoint = (row: HTMLElement, id: string, x: number, y: number) => {
const raw = textareaMapRef.current.get(id)?.value ?? blocks.find((b) => b.id === id)?.content ?? '';
const span = row.querySelector<HTMLElement>('[data-outline-text]');
const caret = caretAtPoint(x, y);
if (span && caret && span.contains(caret.node)) {
const range = document.createRange();
range.setStart(span, 0);
range.setEnd(caret.node, caret.offset);
return { offset: mapRenderedOffsetToRaw(raw, span.textContent || '', range.toString().length), node: caret.node, nodeOffset: caret.offset };
}
const rect = (span || row).getBoundingClientRect();
const before = y < rect.top || (y <= rect.bottom && x < rect.left);
return { offset: before ? 0 : raw.length };
};
const clearRangeSel = useCallback(() => {
dragAnchorRef.current = null;
setRangeSel(null);
}, []);
// Highlight the selected text of the bullets other than the start one
useEffect(() => {
if (!highlightSupported) return;
const registry = (CSS as any).highlights;
if (!rangeSel || !treeRootRef.current) {
registry.delete('outline-range');
return;
}
const order = visibleEntries.map((e) => e.block.id);
const a = order.indexOf(rangeSel.anchorId);
const f = order.indexOf(rangeSel.focusId);
if (a < 0 || f < 0) {
registry.delete('outline-range');
return;
}
const ranges: Range[] = [];
const spanOf = (id: string) =>
treeRootRef.current!.querySelector<HTMLElement>(`[data-outline-block="${id}"] [data-outline-text]`);
for (let i = Math.min(a, f); i <= Math.max(a, f); i++) {
const id = order[i];
if (id === rangeSel.anchorId) continue; // shown by the browser in its text field
const span = spanOf(id);
if (!span) continue;
const r = document.createRange();
r.selectNodeContents(span);
if (id === rangeSel.focusId && rangeSel.focusNode && span.contains(rangeSel.focusNode)) {
if (f > a) r.setEnd(rangeSel.focusNode, rangeSel.focusNodeOffset ?? 0);
else r.setStart(rangeSel.focusNode, rangeSel.focusNodeOffset ?? 0);
}
ranges.push(r);
}
registry.set('outline-range', new (window as any).Highlight(...ranges));
return () => registry.delete('outline-range');
}, [rangeSel, visibleEntries, highlightSupported]);
// Mouse: a drag that leaves its bullet becomes a selection over several bullets
useEffect(() => {
const onMove = (e: MouseEvent) => {
const start = dragAnchorRef.current;
if (!start || !(e.buttons & 1)) return;
const row = (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest<HTMLElement>('[data-outline-block]');
if (!row || !treeRootRef.current?.contains(row)) return;
const id = row.dataset.outlineBlock!;
if (id === start.id) {
if (rangeSelRef.current) setRangeSel(null);
return;
}
const at = rawOffsetAtPoint(row, id, e.clientX, e.clientY);
setRangeSel({ anchorId: start.id, anchorOffset: start.offset, focusId: id, focusOffset: at.offset, focusNode: at.node, focusNodeOffset: at.nodeOffset });
};
const onUp = () => {
dragAnchorRef.current = null;
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
// rawOffsetAtPoint only reads refs and blocks
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blocks]);
const handleTreeMouseDownCapture = (e: React.MouseEvent) => {
if (e.button !== 0) return;
setRangeSel(null);
const target = e.target as HTMLElement;
const row = target.closest<HTMLElement>('[data-outline-block]');
if (!row || target.closest('button, a, input, select, [contenteditable="true"]')) {
dragAnchorRef.current = null;
return;
}
const id = row.dataset.outlineBlock!;
// The bullet's own handler places the caret first; read it right after
dragAnchorRef.current = { id, offset: rawOffsetAtPoint(row, id, e.clientX, e.clientY).offset };
requestAnimationFrame(() => {
const ta = textareaMapRef.current.get(id);
if (dragAnchorRef.current?.id === id && ta && document.activeElement === ta) {
dragAnchorRef.current = { id, offset: ta.selectionStart };
}
});
};
// The selection in text order: first bullet + offset, last bullet + offset, bullets in between
const resolveRangeSel = (sel: RangeSel) => {
const order = visibleEntries.map((en) => en.block.id);
const a = order.indexOf(sel.anchorId);
const f = order.indexOf(sel.focusId);
if (a < 0 || f < 0 || a === f) return null;
const [firstId, firstOffset, lastId, lastOffset] =
a < f ? [sel.anchorId, sel.anchorOffset, sel.focusId, sel.focusOffset] : [sel.focusId, sel.focusOffset, sel.anchorId, sel.anchorOffset];
const fIdx = blocks.findIndex((b) => b.id === firstId);
const lIdx = blocks.findIndex((b) => b.id === lastId);
if (fIdx < 0 || lIdx < 0 || lIdx <= fIdx) return null;
return { first: blocks[fIdx], firstOffset, last: blocks[lIdx], lastOffset, fIdx, lIdx };
};
const selectedText = (sel: RangeSel) => {
const r = resolveRangeSel(sel);
if (!r) return '';
const lines = [(r.first.content || '').slice(r.firstOffset)];
for (let i = r.fIdx + 1; i < r.lIdx; i++) lines.push(blocks[i].content || '');
lines.push((r.last.content || '').slice(0, r.lastOffset));
return lines.join('\n');
};
const deleteRangeSel = (sel: RangeSel, insert = '') => {
const r = resolveRangeSel(sel);
if (!r || !canEdit) return false;
const notMergeable = ['code', 'divider', 'table', 'progress'];
if (notMergeable.includes(r.first.type) || notMergeable.includes(r.last.type)) return false;
const deleted = blocks.slice(r.fIdx + 1, r.lIdx + 1);
const holder = [r.first, ...deleted].map((b) => lockHolder(b.id)).find(Boolean);
if (holder) {
onLockedAction?.(holder);
return true;
}
// Children of the last bullet stay and move under the first, like merging two bullets
const lastLevel = Math.max(0, r.last.level || 0);
const delta = Math.max(0, r.first.level || 0) - lastLevel;
const moved: DocumentBlock[] = [];
for (let j = r.lIdx + 1; j < blocks.length; j++) {
const lvl = Math.max(0, blocks[j].level || 0);
if (lvl <= lastLevel) break;
moved.push({ ...blocks[j], level: Math.max(levelOffset || 0, lvl + delta) });
}
const merged: DocumentBlock = {
...r.first,
content: (r.first.content || '').slice(0, r.firstOffset) + insert + (r.last.content || '').slice(r.lastOffset),
};
const deleteIds = deleted.map((b) => b.id);
if (onReplaceRange) {
onReplaceRange(merged, deleteIds, moved);
} else {
if (onUpdateBlocks) onUpdateBlocks([merged, ...moved]);
else [merged, ...moved].forEach((b) => onUpdateBlock(b, true));
deleteIds.forEach((id) => onDeleteBlock(id));
}
clearRangeSel();
focusBlockInput(r.first.id, r.firstOffset + insert.length);
return true;
};
// Keys while a selection over several bullets exists
const deleteRangeSelRef = useRef(deleteRangeSel);
deleteRangeSelRef.current = deleteRangeSel;
const selectedTextRef = useRef(selectedText);
selectedTextRef.current = selectedText;
useEffect(() => {
if (!rangeSel) return;
const onKey = (e: KeyboardEvent) => {
const sel = rangeSelRef.current;
if (!sel) return;
const mod = e.ctrlKey || e.metaKey;
const stop = () => {
e.preventDefault();
e.stopPropagation();
};
if (mod && (e.key === 'c' || e.key === 'x')) {
stop();
void navigator.clipboard?.writeText(selectedTextRef.current(sel)).catch(() => {});
if (e.key === 'x') deleteRangeSelRef.current(sel);
return;
}
if (e.key === 'Backspace' || e.key === 'Delete') {
stop();
deleteRangeSelRef.current(sel);
return;
}
if (!mod && !e.altKey && e.key.length === 1) {
stop();
deleteRangeSelRef.current(sel, e.key);
return;
}
if (!['Shift', 'Control', 'Meta', 'Alt'].includes(e.key)) clearRangeSel();
};
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, [rangeSel, clearRangeSel]);
const rangeRowIds = useMemo(() => {
if (!rangeSel || highlightSupported) return null;
const order = visibleEntries.map((e) => e.block.id);
const a = order.indexOf(rangeSel.anchorId);
const f = order.indexOf(rangeSel.focusId);
if (a < 0 || f < 0) return null;
return new Set(order.slice(Math.min(a, f), Math.max(a, f) + 1));
}, [rangeSel, visibleEntries, highlightSupported]);
return (
<div className="space-y-0.5">
<div className="space-y-0.5" ref={treeRootRef} onMouseDownCapture={handleTreeMouseDownCapture}>
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => {
const isLast = entryIdx === visibleEntries.length - 1;
return (
@@ -709,6 +950,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
isDragTarget={dragOverIndex === index && draggedIndex !== index}
isTargeted={highlightedBlockId === block.id}
registerTextareaRef={registerTextareaRef}
inRangeSelection={rangeRowIds?.has(block.id)}
/>
);
})}