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 = ({ docId, blocks, canEdit, levelOffset = 0, onZoomBlock, onUpdateBlock, onUpdateBlocks, onCreateBlock, onDeleteBlock, onMoveBlock, onTagClick, onWikiLinkClick, initialFocusedBlockId, }) => { const [focusedBlockId, setFocusedBlockId] = useState(null); const [highlightedBlockId, setHighlightedBlockId] = useState( initialFocusedBlockId || null ); const [draggedIndex, setDraggedIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); const textareaMapRef = useRef>(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 (
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => { return ( ); })} {canEdit && (
{ e.preventDefault(); if (draggedIndex !== null) { setDragOverIndex(blocks.length); } }} onDrop={() => handleDropBlock(blocks.length)} >
)}
); };