MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
732 lines
23 KiB
TypeScript
732 lines
23 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;
|
|
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;
|
|
}
|
|
|
|
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|
docId,
|
|
blocks,
|
|
canEdit,
|
|
levelOffset = 0,
|
|
onZoomBlock,
|
|
onUpdateBlock,
|
|
onUpdateBlocks,
|
|
onCreateBlock,
|
|
onInsertBlocks,
|
|
onDeleteBlock,
|
|
onMoveBlock,
|
|
onOpenTask,
|
|
onTagClick,
|
|
onWikiLinkClick,
|
|
initialFocusedBlockId,
|
|
}, ref) => {
|
|
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
|
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
|
|
initialFocusedBlockId || null
|
|
);
|
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
|
|
|
const textareaMapRef = useRef<Map<string, HTMLTextAreaElement>>(new Map());
|
|
const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' } | null>(null);
|
|
|
|
const focusBlockInput = useCallback(
|
|
(blockId: string, cursor: 'start' | 'end' | 'select' = 'end') => {
|
|
setFocusedBlockId(blockId);
|
|
const el = textareaMapRef.current.get(blockId);
|
|
if (el) {
|
|
requestAnimationFrame(() => {
|
|
el.focus();
|
|
try {
|
|
if (cursor === 'select') {
|
|
el.select();
|
|
} else {
|
|
const pos = cursor === 'end' ? el.value.length : 0;
|
|
el.setSelectionRange(pos, pos);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
});
|
|
} 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;
|
|
requestAnimationFrame(() => {
|
|
setFocusedBlockId(blockId);
|
|
el.focus();
|
|
try {
|
|
if (target.cursor === 'select') {
|
|
el.select();
|
|
} else {
|
|
const pos = 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]
|
|
);
|
|
|
|
const handleDeleteBlock = useCallback(
|
|
(blockId: string, deleteSubtree = false) => {
|
|
if (!canEdit) 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]
|
|
);
|
|
|
|
const handleIndentBlock = useCallback(
|
|
(blockId: string, delta: 1 | -1) => {
|
|
if (!canEdit) return;
|
|
const idx = blocks.findIndex((b) => b.id === blockId);
|
|
if (idx === -1) 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]
|
|
);
|
|
|
|
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];
|
|
if (movingBlock) {
|
|
onMoveBlock(movingBlock.id, targetIndex, movingBlock.level);
|
|
}
|
|
setDraggedIndex(null);
|
|
setDragOverIndex(null);
|
|
},
|
|
[blocks, draggedIndex, onMoveBlock]
|
|
);
|
|
|
|
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}
|
|
levelOffset={levelOffset}
|
|
onZoomBlock={onZoomBlock}
|
|
onUpdateBlock={onUpdateBlock}
|
|
onCreateBlockBelow={handleCreateBlockBelow}
|
|
onPasteMultiLines={handlePasteMultiLines}
|
|
onDeleteBlock={handleDeleteBlock}
|
|
onIndentBlock={handleIndentBlock}
|
|
onNavigateFocus={handleNavigateFocus}
|
|
onFocusBlock={setFocusedBlockId}
|
|
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>
|
|
);
|
|
});
|