MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
- Extract: button on the bullet (desktop) or /extract (/notiz). The bullet becomes the title (hashtags stay at the link), its children the content of a new note in the same folder with the source's shares (a foreign source owner gets write access); the branch becomes [[title]] through the operation log, so open editors follow. - Merge: drop a note onto another in the sidebar or use "Mit anderer Notiz zusammenführen…" in the note header. The own source is appended as an h2 section with its bullets below and goes to the trash; the toast offers a complete undo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
852 lines
28 KiB
TypeScript
852 lines
28 KiB
TypeScript
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
|
import { DocumentBlock, BlockType } from '../../lib/types';
|
|
import { OutlinerBlock } from './OutlinerBlock';
|
|
|
|
export interface OutlinerTreeHandle {
|
|
focusFirstBlock: () => void;
|
|
focusBlock: (blockId: string, cursor?: 'start' | 'end') => void;
|
|
}
|
|
|
|
export interface OutlinerTreeProps {
|
|
docId: string;
|
|
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,
|
|
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 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;
|
|
const inheritedType: BlockType = '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);
|
|
|
|
// Detect indentation style (2-space vs 4-space tabs)
|
|
let detectedSpaceStep = 2;
|
|
const leadingSpacesList: number[] = [];
|
|
for (const line of rawLines) {
|
|
if (!line.trim()) continue;
|
|
let sp = 0;
|
|
while (sp < line.length && line[sp] === ' ') sp++;
|
|
if (sp > 0) leadingSpacesList.push(sp);
|
|
}
|
|
if (leadingSpacesList.length > 0 && leadingSpacesList.every((s) => s % 4 === 0)) {
|
|
detectedSpaceStep = 4;
|
|
}
|
|
|
|
// Parse lines into structured blocks
|
|
interface ParsedPasteItem {
|
|
type: BlockType;
|
|
checked?: boolean;
|
|
content: string;
|
|
indent: number;
|
|
}
|
|
|
|
const parsed: ParsedPasteItem[] = [];
|
|
let currentItem: ParsedPasteItem | null = null;
|
|
let inCodeBlock = false;
|
|
let afterHeading = false;
|
|
|
|
const pushCurrentItem = () => {
|
|
if (currentItem) {
|
|
currentItem.content = currentItem.content.trimEnd();
|
|
parsed.push(currentItem);
|
|
currentItem = null;
|
|
}
|
|
};
|
|
|
|
for (let i = 0; i < rawLines.length; i++) {
|
|
const rawLine = rawLines[i];
|
|
|
|
// 1. Inside a fenced code block
|
|
if (inCodeBlock) {
|
|
if (currentItem) {
|
|
currentItem.content += '\n' + rawLine;
|
|
}
|
|
if (rawLine.trim().startsWith('```')) {
|
|
inCodeBlock = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// 2. Starts a code fence
|
|
if (rawLine.trim().startsWith('```')) {
|
|
inCodeBlock = true;
|
|
if (afterHeading) {
|
|
afterHeading = false;
|
|
pushCurrentItem();
|
|
currentItem = {
|
|
type: 'text',
|
|
content: rawLine,
|
|
indent: 0,
|
|
};
|
|
} else if (currentItem) {
|
|
currentItem.content += (currentItem.content.endsWith('\n') ? '' : '\n') + rawLine;
|
|
} else {
|
|
currentItem = {
|
|
type: 'text',
|
|
content: rawLine,
|
|
indent: 0,
|
|
};
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Measure leading indent
|
|
let tabs = 0;
|
|
let spaces = 0;
|
|
let charIdx = 0;
|
|
while (charIdx < rawLine.length) {
|
|
if (rawLine[charIdx] === '\t') {
|
|
tabs++;
|
|
charIdx++;
|
|
} else if (rawLine[charIdx] === ' ') {
|
|
spaces++;
|
|
charIdx++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
const strippedLine = rawLine.slice(charIdx);
|
|
const lineIndent = tabs + Math.floor(spaces / detectedSpaceStep);
|
|
|
|
// Blank lines
|
|
if (strippedLine.trim() === '') {
|
|
if (currentItem) {
|
|
currentItem.content += '\n';
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Headings: #, ##, ###
|
|
const h3Match = strippedLine.match(/^###\s+(.*)$/);
|
|
const h2Match = strippedLine.match(/^##\s+(.*)$/);
|
|
const h1Match = strippedLine.match(/^#\s+(.*)$/);
|
|
if (h1Match || h2Match || h3Match) {
|
|
pushCurrentItem();
|
|
afterHeading = true;
|
|
const type: BlockType = h1Match ? 'h1' : h2Match ? 'h2' : 'h3';
|
|
const content = (h1Match || h2Match || h3Match)![1];
|
|
parsed.push({ type, content, indent: lineIndent });
|
|
continue;
|
|
}
|
|
|
|
// Dividers: ---, ***, ___
|
|
if (/^(?:---|---|\*\*\*|___)\s*$/.test(strippedLine.trim())) {
|
|
pushCurrentItem();
|
|
afterHeading = false;
|
|
parsed.push({ type: 'divider', content: '---', indent: lineIndent });
|
|
continue;
|
|
}
|
|
|
|
// Todo items: - [ ], - [x], * [ ], * [x]
|
|
const todoMatch = strippedLine.match(/^[-*]\s*\[([ xX])\]\s*(.*)$/);
|
|
if (todoMatch) {
|
|
pushCurrentItem();
|
|
afterHeading = false;
|
|
parsed.push({
|
|
type: 'todo',
|
|
checked: todoMatch[1].toLowerCase() === 'x',
|
|
content: todoMatch[2],
|
|
indent: lineIndent,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item
|
|
const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/);
|
|
const numMatch = strippedLine.match(/^\d+[.)]\s+(.*)$/);
|
|
if (bulletMatch || numMatch) {
|
|
pushCurrentItem();
|
|
afterHeading = false;
|
|
parsed.push({
|
|
type: 'text',
|
|
content: (bulletMatch ? bulletMatch[1] : numMatch![1]),
|
|
indent: lineIndent,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Regular text line
|
|
if (afterHeading) {
|
|
// This starts the single bullet point after the heading
|
|
afterHeading = false;
|
|
pushCurrentItem();
|
|
currentItem = {
|
|
type: 'text',
|
|
content: strippedLine,
|
|
indent: lineIndent,
|
|
};
|
|
} else if (currentItem) {
|
|
if (currentItem.content.endsWith('\n')) {
|
|
currentItem.content += strippedLine;
|
|
} else {
|
|
currentItem.content += '\n' + strippedLine;
|
|
}
|
|
} else {
|
|
currentItem = {
|
|
type: 'text',
|
|
content: strippedLine,
|
|
indent: lineIndent,
|
|
};
|
|
}
|
|
}
|
|
|
|
pushCurrentItem();
|
|
|
|
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}
|
|
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={`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>
|
|
);
|
|
});
|