MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
434 lines
13 KiB
TypeScript
434 lines
13 KiB
TypeScript
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
|
import { Plus } from 'lucide-react';
|
|
import { DocumentBlock } from '../../lib/types';
|
|
import { OutlinerBlock } from './OutlinerBlock';
|
|
|
|
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;
|
|
onDeleteBlock: (blockId: string) => void;
|
|
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
|
|
onTagClick?: (tag: string) => void;
|
|
onWikiLinkClick?: (pageTitle: string) => void;
|
|
initialFocusedBlockId?: string | null;
|
|
}
|
|
|
|
export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|
docId,
|
|
blocks,
|
|
canEdit,
|
|
levelOffset = 0,
|
|
onZoomBlock,
|
|
onUpdateBlock,
|
|
onUpdateBlocks,
|
|
onCreateBlock,
|
|
onDeleteBlock,
|
|
onMoveBlock,
|
|
onTagClick,
|
|
onWikiLinkClick,
|
|
initialFocusedBlockId,
|
|
}) => {
|
|
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 };
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
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, _currentContent: 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 =
|
|
currentBlock && currentBlock.type === 'todo' ? 'todo' : 'text';
|
|
|
|
const newBlock: DocumentBlock = {
|
|
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
|
documentId: docId,
|
|
type: inheritedType,
|
|
content: '',
|
|
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 handleDeleteBlock = useCallback(
|
|
(blockId: string) => {
|
|
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');
|
|
}
|
|
onDeleteBlock(blockId);
|
|
},
|
|
[blocks, canEdit, focusBlockInput, 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 }) => {
|
|
return (
|
|
<OutlinerBlock
|
|
key={block.id}
|
|
block={block}
|
|
index={index}
|
|
maxAllowedLevel={maxAllowedLevel}
|
|
hasChildren={hasChildren}
|
|
isFocused={focusedBlockId === block.id}
|
|
canEdit={canEdit}
|
|
levelOffset={levelOffset}
|
|
onZoomBlock={onZoomBlock}
|
|
onUpdateBlock={onUpdateBlock}
|
|
onCreateBlockBelow={handleCreateBlockBelow}
|
|
onDeleteBlock={handleDeleteBlock}
|
|
onIndentBlock={handleIndentBlock}
|
|
onNavigateFocus={handleNavigateFocus}
|
|
onTagClick={onTagClick}
|
|
onWikiLinkClick={onWikiLinkClick}
|
|
onDragStartBlock={handleDragStartBlock}
|
|
onDragOverBlock={handleDragOverBlock}
|
|
onDropBlock={handleDropBlock}
|
|
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
|
isTargeted={highlightedBlockId === block.id}
|
|
registerTextareaRef={registerTextareaRef}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{canEdit && (
|
|
<div
|
|
className={`pt-2 pl-2 transition-colors ${
|
|
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
|
|
}`}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
if (draggedIndex !== null) {
|
|
setDragOverIndex(blocks.length);
|
|
}
|
|
}}
|
|
onDrop={() => handleDropBlock(blocks.length)}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const lastBlock = blocks[blocks.length - 1];
|
|
if (lastBlock) {
|
|
handleCreateBlockBelow(lastBlock.id, lastBlock.content);
|
|
} 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: 'start' };
|
|
onCreateBlock(firstBlock);
|
|
}
|
|
}}
|
|
className="btn-action inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-muted hover:text-main"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
<span>Neuer Punkt</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|