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(({ docId, blocks, canEdit, levelOffset = 0, onZoomBlock, onUpdateBlock, onUpdateBlocks, onCreateBlock, onInsertBlocks, onDeleteBlock, onMoveBlock, onOpenTask, onTagClick, onWikiLinkClick, initialFocusedBlockId, }, ref) => { 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 }; } }, [] ); 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 (
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => { const isLast = entryIdx === visibleEntries.length - 1; return ( ); })} {canEdit && (
{ 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)} /> )}
); });