Files
MindSift/frontend/src/features/workspace/OutlinerTree.tsx
T
sandrodachandClaude Opus 5.5 2bfe74e66b
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m5s
feat(workspace): numbered lists and reliable markdown paste
- block type 'numbered' (/number): 1., a., i. by nesting, continues on
  Enter, an empty item ends the list; markdown export/import as 1.
- pasting: parser moved to lib/pasteOutline with tests; blank lines split
  paragraphs, numbered lists keep their type, indentation levels follow
  the widths in use (2, 3 or 4 spaces, tabs)
- text that stays in the focused block after pasting (a paragraph with
  line breaks) was dropped; the block now takes it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:36:15 +01:00

690 lines
24 KiB
TypeScript

import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { OutlinerBlock } from './OutlinerBlock';
import { parsePastedLines } from '../../lib/pasteOutline';
import { numberedLabels } from '../../lib/outlineList';
export interface OutlinerTreeHandle {
focusFirstBlock: () => void;
focusBlock: (blockId: string, cursor?: 'start' | 'end') => void;
}
export interface OutlinerTreeProps {
docId: string;
/** Smaller click area below the last bullet (journal days) */
compact?: boolean;
blocks: DocumentBlock[];
canEdit: boolean;
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
onExtractBlock?: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
onInsertBlocks?: (afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => void;
onDeleteBlock: (blockId: string) => void;
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
onOpenTask?: (taskId: string) => void;
onTagClick?: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
initialFocusedBlockId?: string | null;
onFocusedBlockChange?: (blockId: string | null) => void;
lockedBlocks?: Map<string, string>; // blockId -> name of the user editing it (someone else)
onLockedAction?: (holderName: string) => void;
onResolveConflict?: (blockId: string, accept: boolean) => void;
onUndo?: () => void;
onRedo?: () => void;
}
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
docId,
compact = false,
blocks,
canEdit,
levelOffset = 0,
onZoomBlock,
onExtractBlock,
onUpdateBlock,
onUpdateBlocks,
onCreateBlock,
onInsertBlocks,
onDeleteBlock,
onMoveBlock,
onOpenTask,
onTagClick,
onWikiLinkClick,
initialFocusedBlockId,
onFocusedBlockChange,
lockedBlocks,
onLockedAction,
onResolveConflict,
onUndo,
onRedo,
}, ref) => {
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
const onFocusedBlockChangeRef = useRef(onFocusedBlockChange);
onFocusedBlockChangeRef.current = onFocusedBlockChange;
useEffect(() => {
onFocusedBlockChangeRef.current?.(focusedBlockId);
}, [focusedBlockId]);
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
initialFocusedBlockId || null
);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
// Leaving a block (click into empty space) must drop its focus highlight; a focus move to
// another block has already set the new id, so only clear if it's still this one
const handleBlurBlock = useCallback((blockId: string) => {
setFocusedBlockId((cur) => (cur === blockId ? null : cur));
}, []);
const textareaMapRef = useRef<Map<string, HTMLTextAreaElement>>(new Map());
const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' | number } | null>(null);
const focusBlockInput = useCallback(
(blockId: string, cursor: 'start' | 'end' | 'select' | number = 'end') => {
setFocusedBlockId(blockId);
const el = textareaMapRef.current.get(blockId);
if (el) {
const apply = () => {
el.focus();
try {
if (cursor === 'select') {
el.select();
} else {
const pos =
typeof cursor === 'number'
? Math.min(cursor, el.value.length)
: cursor === 'end'
? el.value.length
: 0;
el.setSelectionRange(pos, pos);
}
} catch {
// ignore
}
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
// A numeric position refers to content that was just changed, so wait one extra frame for it to render
requestAnimationFrame(() => (typeof cursor === 'number' ? requestAnimationFrame(apply) : apply()));
} else {
pendingFocusRef.current = { blockId, cursor };
}
},
[]
);
useImperativeHandle(
ref,
() => ({
focusFirstBlock: () => {
if (blocks.length > 0) {
focusBlockInput(blocks[0].id, 'end');
} else {
const firstBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_0`,
documentId: docId,
type: 'text',
content: '',
level: levelOffset || 0,
position: 1000,
};
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'end' };
onCreateBlock(firstBlock);
}
},
focusBlock: (blockId: string, cursor: 'start' | 'end' = 'end') => {
focusBlockInput(blockId, cursor);
},
}),
[blocks, docId, levelOffset, onCreateBlock, focusBlockInput]
);
const registerTextareaRef = useCallback((blockId: string, el: HTMLTextAreaElement | null) => {
if (el) {
textareaMapRef.current.set(blockId, el);
if (pendingFocusRef.current && pendingFocusRef.current.blockId === blockId) {
const target = pendingFocusRef.current;
pendingFocusRef.current = null;
// Focus right away (the element is already in the DOM): keystrokes typed directly after Enter
// must land in the new block, not in the previous one
setFocusedBlockId(blockId);
el.focus();
try {
if (target.cursor === 'select') {
el.select();
} else {
const pos =
typeof target.cursor === 'number'
? Math.min(target.cursor, el.value.length)
: target.cursor === 'end'
? el.value.length
: 0;
el.setSelectionRange(pos, pos);
}
} catch {
// ignore
}
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} else {
textareaMapRef.current.delete(blockId);
}
}, []);
useEffect(() => {
if (initialFocusedBlockId) {
setHighlightedBlockId(initialFocusedBlockId);
focusBlockInput(initialFocusedBlockId, 'select');
const timer = setTimeout(() => {
focusBlockInput(initialFocusedBlockId, 'select');
}, 70);
const timer2 = setTimeout(() => {
focusBlockInput(initialFocusedBlockId, 'select');
}, 200);
const fadeTimer = setTimeout(() => {
setHighlightedBlockId(null);
}, 2800);
return () => {
clearTimeout(timer);
clearTimeout(timer2);
clearTimeout(fadeTimer);
};
}
}, [initialFocusedBlockId, blocks.length, focusBlockInput]);
// Compute visibility based on collapsed ancestor blocks
const visibleEntries = useMemo(() => {
const result: {
block: DocumentBlock;
index: number;
hasChildren: boolean;
maxAllowedLevel: number;
}[] = [];
let collapsedAboveLevel: number | null = null;
for (let i = 0; i < blocks.length; i++) {
const b = blocks[i];
const lvl = Math.max(0, b.level || 0);
if (collapsedAboveLevel !== null) {
if (lvl > collapsedAboveLevel) {
continue;
} else {
collapsedAboveLevel = null;
}
}
const nextBlock = blocks[i + 1];
const nextLvl = nextBlock ? Math.max(0, nextBlock.level || 0) : 0;
const hasChildren = Boolean(nextBlock && nextLvl > lvl);
const prevBlock = i > 0 ? blocks[i - 1] : null;
const maxAllowedLevel = prevBlock ? Math.max(0, prevBlock.level || 0) + 1 : (levelOffset || 0);
result.push({
block: b,
index: i,
hasChildren,
maxAllowedLevel,
});
if (b.collapsed && hasChildren) {
collapsedAboveLevel = lvl;
}
}
return result;
}, [blocks, levelOffset]);
const listLabels = useMemo(() => numberedLabels(blocks), [blocks]);
const handleCreateBlockBelow = useCallback(
(afterBlockId: string, newContent: string = '') => {
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === afterBlockId);
const currentBlock = idx >= 0 ? blocks[idx] : null;
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
// Numbered lists continue on Enter (an empty item ends the list, see OutlinerBlock)
const inheritedType: BlockType = currentBlock?.type === 'numbered' ? 'numbered' : 'text';
const newBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
documentId: docId,
type: inheritedType,
content: newContent,
level,
checked: false,
collapsed: false,
position: ((idx >= 0 ? idx + 2 : blocks.length + 1) * 1000),
};
pendingFocusRef.current = { blockId: newBlock.id, cursor: 'start' };
setFocusedBlockId(newBlock.id);
onCreateBlock(newBlock, afterBlockId);
},
[blocks, canEdit, docId, onCreateBlock]
);
const handlePasteMultiLines = useCallback(
(blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => {
if (!canEdit || rawLines.length === 0) return;
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
const currentBlock = blocks[idx];
const baseLevel = Math.max(0, currentBlock.level || 0);
const parsed = parsePastedLines(rawLines);
if (parsed.length === 0) return;
const line0 = parsed[0];
const baseIndent = line0.indent;
const isSingle = parsed.length === 1;
const line0Content = textBefore + line0.content + (isSingle ? textAfter : '');
const isCurrentEmpty = (currentBlock.content || '').trim() === '';
const adoptedType = line0.type !== 'text' ? line0.type : (isCurrentEmpty ? 'text' : currentBlock.type);
const adoptedChecked = line0.type === 'todo' ? (line0.checked ?? false) : currentBlock.checked;
const updatedCurrentBlock: DocumentBlock = {
...currentBlock,
content: line0Content,
type: adoptedType,
checked: adoptedChecked,
};
const newBlocks: DocumentBlock[] = [];
const nowBase = Date.now().toString(36);
for (let j = 1; j < parsed.length; j++) {
const p = parsed[j];
const isLastLine = j === parsed.length - 1;
const lineContent = isLastLine ? (p.content + textAfter) : p.content;
const lineLevel = Math.max(0, baseLevel + (p.indent - baseIndent));
newBlocks.push({
id: `blk_${nowBase}_${j}_${Math.random().toString(36).slice(2, 6)}`,
documentId: docId,
type: p.type,
checked: p.checked || false,
content: lineContent,
level: lineLevel,
collapsed: false,
position: (currentBlock.position || 1000) + j * 10,
});
}
const lastTargetId = newBlocks.length > 0 ? newBlocks[newBlocks.length - 1].id : currentBlock.id;
const existingEl = textareaMapRef.current.get(lastTargetId);
if (existingEl) {
pendingFocusRef.current = null;
focusBlockInput(lastTargetId, 'end');
} else {
pendingFocusRef.current = { blockId: lastTargetId, cursor: 'end' };
}
setFocusedBlockId(lastTargetId);
if (onInsertBlocks) {
onInsertBlocks(currentBlock.id, updatedCurrentBlock, newBlocks);
} else {
onUpdateBlock(updatedCurrentBlock, true);
for (let k = 0; k < newBlocks.length; k++) {
const afterId = k === 0 ? currentBlock.id : newBlocks[k - 1].id;
onCreateBlock(newBlocks[k], afterId);
}
}
},
[blocks, canEdit, docId, focusBlockInput, onCreateBlock, onInsertBlocks, onUpdateBlock]
);
// Name of the other user holding the block (or, withBranch, any block below it); structural actions on
// such blocks are not started at all, so they cannot end up half applied
const lockHolder = useCallback(
(blockId: string, withBranch = false): string | undefined => {
if (!lockedBlocks || lockedBlocks.size === 0) return undefined;
const direct = lockedBlocks.get(blockId);
if (direct || !withBranch) return direct;
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx < 0) return undefined;
const level = blocks[idx].level || 0;
for (let i = idx + 1; i < blocks.length && (blocks[i].level || 0) > level; i++) {
const name = lockedBlocks.get(blocks[i].id);
if (name) return name;
}
return undefined;
},
[blocks, lockedBlocks]
);
const handleDeleteBlock = useCallback(
(blockId: string, deleteSubtree = false) => {
if (!canEdit) return;
const holder = lockHolder(blockId, true);
if (holder) {
onLockedAction?.(holder);
return;
}
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
// If this is the only block in the document (and not in an isolated zoom subtree), clear instead of deleting
if (blocks.length <= 1 && !levelOffset) {
const only = blocks[0];
onUpdateBlock(
{
...only,
content: '',
type: 'text',
level: 0,
checked: false,
},
true
);
return;
}
const targetFocusBlock = idx > 0 ? blocks[idx - 1] : blocks[idx + 1];
if (targetFocusBlock) {
focusBlockInput(targetFocusBlock.id, 'end');
}
if (deleteSubtree) {
const rootLevel = blocks[idx].level || 0;
const idsToDelete = [blockId];
for (let j = idx + 1; j < blocks.length; j++) {
if ((blocks[j].level || 0) > rootLevel) {
idsToDelete.push(blocks[j].id);
} else {
break;
}
}
for (const id of idsToDelete) {
onDeleteBlock(id);
}
} else {
onDeleteBlock(blockId);
}
},
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock, lockHolder, onLockedAction]
);
// Backspace at the start of a non-empty block: append its text to the previous visible block
const handleMergeWithPrevious = useCallback(
(blockId: string, content: string) => {
if (!canEdit) return false;
const notMergeable = ['code', 'divider', 'table', 'progress'];
const visIdx = visibleEntries.findIndex((e) => e.block.id === blockId);
if (visIdx <= 0) return false;
const prev = visibleEntries[visIdx - 1].block;
const idx = blocks.findIndex((b) => b.id === blockId);
const cur = blocks[idx];
if (!cur || notMergeable.includes(prev.type) || notMergeable.includes(cur.type)) return false;
const holder = lockHolder(cur.id, true) || lockHolder(prev.id);
if (holder) {
onLockedAction?.(holder);
return true;
}
// Children of the merged block move up so they become children of the previous block
const curLevel = Math.max(0, cur.level || 0);
const delta = Math.max(0, prev.level || 0) - curLevel;
const movedChildren: DocumentBlock[] = [];
for (let j = idx + 1; j < blocks.length; j++) {
const lvl = Math.max(0, blocks[j].level || 0);
if (lvl <= curLevel) break;
movedChildren.push({ ...blocks[j], level: Math.max(levelOffset || 0, lvl + delta) });
}
const joinPos = (prev.content || '').length;
const mergedPrev: DocumentBlock = { ...prev, content: (prev.content || '') + content };
if (movedChildren.length > 0 && onUpdateBlocks) {
onUpdateBlocks([mergedPrev, ...movedChildren]);
} else {
onUpdateBlock(mergedPrev, true);
movedChildren.forEach((c) => onUpdateBlock(c));
}
onDeleteBlock(cur.id);
focusBlockInput(prev.id, joinPos);
return true;
},
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock, onUpdateBlocks, visibleEntries, lockHolder, onLockedAction]
);
const handleIndentBlock = useCallback(
(blockId: string, delta: 1 | -1) => {
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
const holder = lockHolder(blockId);
if (holder) {
onLockedAction?.(holder);
return;
}
const current = blocks[idx];
const currentLevel = Math.max(0, current.level || 0);
const prevLevel = idx > 0 ? Math.max(0, blocks[idx - 1].level || 0) : 0;
const minLevel = levelOffset || 0;
const maxLevel = idx === 0 ? minLevel : prevLevel + 1;
const nextLevel =
delta === 1
? Math.min(maxLevel, currentLevel + 1)
: Math.max(minLevel, currentLevel - 1);
if (nextLevel === currentLevel) return;
const actualDelta = nextLevel - currentLevel;
// Find all contiguous children / descendants:
// contiguous blocks with level > currentLevel
let count = 1;
for (let i = idx + 1; i < blocks.length; i++) {
if (Math.max(0, blocks[i].level || 0) > currentLevel) {
count++;
} else {
break;
}
}
const updatedBlocks: DocumentBlock[] = [];
for (let i = idx; i < idx + count; i++) {
const b = blocks[i];
const newLvl = Math.max(minLevel, (b.level || 0) + actualDelta);
updatedBlocks.push({
...b,
level: newLvl,
});
}
if (onUpdateBlocks) {
onUpdateBlocks(updatedBlocks);
} else {
for (const b of updatedBlocks) {
onUpdateBlock(b, true);
}
}
},
[blocks, canEdit, levelOffset, onUpdateBlock, onUpdateBlocks, lockHolder, onLockedAction]
);
const handleNavigateFocus = useCallback(
(blockId: string, direction: 'up' | 'down') => {
const visIdx = visibleEntries.findIndex((e) => e.block.id === blockId);
if (visIdx === -1) return;
const targetEntry =
direction === 'up' ? visibleEntries[visIdx - 1] : visibleEntries[visIdx + 1];
if (targetEntry) {
focusBlockInput(
targetEntry.block.id,
direction === 'up' ? 'end' : 'start'
);
}
},
[focusBlockInput, visibleEntries]
);
const handleDragStartBlock = useCallback((index: number) => {
setDraggedIndex(index);
}, []);
const handleDragOverBlock = useCallback(
(e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex !== null) {
const parentLevel = Math.max(0, blocks[draggedIndex]?.level || 0);
let count = 1;
for (let i = draggedIndex + 1; i < blocks.length; i++) {
if (Math.max(0, blocks[i].level || 0) > parentLevel) {
count++;
} else {
break;
}
}
// Do not allow dropping inside its own subtree (self or descendants)
if (index >= draggedIndex && index < draggedIndex + count) {
setDragOverIndex(null);
return;
}
}
setDragOverIndex(index);
},
[blocks, draggedIndex]
);
const handleDropBlock = useCallback(
(targetIndex: number) => {
if (draggedIndex === null || draggedIndex === targetIndex) {
setDraggedIndex(null);
setDragOverIndex(null);
return;
}
const parentLevel = Math.max(0, blocks[draggedIndex]?.level || 0);
let count = 1;
for (let i = draggedIndex + 1; i < blocks.length; i++) {
if (Math.max(0, blocks[i].level || 0) > parentLevel) {
count++;
} else {
break;
}
}
if (targetIndex >= draggedIndex && targetIndex < draggedIndex + count) {
setDraggedIndex(null);
setDragOverIndex(null);
return;
}
const movingBlock = blocks[draggedIndex];
const holder = movingBlock ? lockHolder(movingBlock.id) : undefined;
if (holder) {
onLockedAction?.(holder);
} else if (movingBlock) {
onMoveBlock(movingBlock.id, targetIndex, movingBlock.level);
}
setDraggedIndex(null);
setDragOverIndex(null);
},
[blocks, draggedIndex, onMoveBlock, lockHolder, onLockedAction]
);
useEffect(() => {
const clearDrag = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
window.addEventListener('dragend', clearDrag);
return () => window.removeEventListener('dragend', clearDrag);
}, []);
return (
<div className="space-y-0.5">
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => {
const isLast = entryIdx === visibleEntries.length - 1;
return (
<OutlinerBlock
key={block.id}
docId={docId}
block={block}
index={index}
isLast={isLast}
maxAllowedLevel={maxAllowedLevel}
hasChildren={hasChildren}
isFocused={focusedBlockId === block.id}
canEdit={canEdit && !lockedBlocks?.has(block.id)}
lockedBy={lockedBlocks?.get(block.id)}
canAppendBelow={canEdit}
onResolveConflict={canEdit ? onResolveConflict : undefined}
onUndo={onUndo}
onRedo={onRedo}
levelOffset={levelOffset}
onZoomBlock={onZoomBlock}
onExtractBlock={onExtractBlock}
onUpdateBlock={onUpdateBlock}
onCreateBlockBelow={handleCreateBlockBelow}
onPasteMultiLines={handlePasteMultiLines}
listMarker={listLabels.get(block.id)}
onDeleteBlock={handleDeleteBlock}
onMergeWithPrevious={handleMergeWithPrevious}
onIndentBlock={handleIndentBlock}
onNavigateFocus={handleNavigateFocus}
onFocusBlock={setFocusedBlockId}
onBlurBlock={handleBlurBlock}
onOpenTask={onOpenTask}
onTagClick={onTagClick}
onWikiLinkClick={onWikiLinkClick}
onDragStartBlock={handleDragStartBlock}
onDragOverBlock={handleDragOverBlock}
onDropBlock={handleDropBlock}
isDragTarget={dragOverIndex === index && draggedIndex !== index}
isTargeted={highlightedBlockId === block.id}
registerTextareaRef={registerTextareaRef}
/>
);
})}
{canEdit && (
<div
className={`${compact ? 'min-h-[2.5rem]' : 'min-h-[14rem]'} pt-2 pl-2 transition-colors cursor-text ${
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
}`}
onClick={(e) => {
if (e.target !== e.currentTarget && (e.target as HTMLElement).closest('button, a, input, textarea, select')) {
return;
}
if (blocks.length === 0) {
const firstBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_0`,
documentId: docId,
type: 'text',
content: '',
level: levelOffset || 0,
position: 1000,
};
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'start' };
onCreateBlock(firstBlock);
} else {
const lastBlock = blocks[blocks.length - 1];
if (lastBlock.content && lastBlock.content.trim() !== '') {
handleCreateBlockBelow(lastBlock.id, '');
} else {
focusBlockInput(lastBlock.id, 'end');
}
}
}}
onDragOver={(e) => {
e.preventDefault();
if (draggedIndex !== null) {
setDragOverIndex(blocks.length);
}
}}
onDrop={() => handleDropBlock(blocks.length)}
/>
)}
</div>
);
});