import React, { useState, useEffect, useRef, useLayoutEffect, useCallback, useMemo } from 'react'; import { ChevronRight, ChevronDown, Check, X, Code, Heading1, Heading2, Heading3, ListTodo, Minus, MessageSquare, Hash, ExternalLink, Maximize2, Link as LinkIcon, FileText, } from 'lucide-react'; import { DocumentBlock, BlockType } from '../../lib/types'; import { LinkModal } from './LinkModal'; export interface SlashCommand { type: BlockType | 'link'; label: string; desc: string; icon: React.ReactNode; } const SLASH_COMMANDS: SlashCommand[] = [ { type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: }, { type: 'link', label: '/link', desc: 'Notiz verknüpfen oder Web-Link einfügen', icon: }, { type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: }, { type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: }, { type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: }, { type: 'code', label: '/code', desc: 'Code-Block mit Syntax', icon: }, { type: 'callout', label: '/callout', desc: 'Hinweisbox', icon: }, { type: 'divider', label: '/divider', desc: 'Trennlinie', icon: }, ]; export interface OutlinerBlockProps { block: DocumentBlock; index: number; maxAllowedLevel: number; hasChildren: boolean; isFocused: boolean; canEdit: boolean; levelOffset?: number; onZoomBlock?: (blockId: string) => void; onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void; onDeleteBlock: (blockId: string) => void; onIndentBlock: (blockId: string, delta: 1 | -1) => void; onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void; onTagClick?: (tag: string) => void; onWikiLinkClick?: (pageTitle: string) => void; onDragStartBlock?: (index: number) => void; onDragOverBlock?: (e: React.DragEvent, index: number) => void; onDropBlock?: (index: number) => void; isDragTarget?: boolean; isTargeted?: boolean; registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void; } export const OutlinerBlock: React.FC = ({ block, index, maxAllowedLevel, hasChildren, isFocused, canEdit, levelOffset = 0, onZoomBlock, onUpdateBlock, onCreateBlockBelow, onDeleteBlock, onIndentBlock, onNavigateFocus, onTagClick, onWikiLinkClick, onDragStartBlock, onDragOverBlock, onDropBlock, isDragTarget, isTargeted, registerTextareaRef, }) => { const [localContent, setLocalContent] = useState(block.content || ''); const [slashSearch, setSlashSearch] = useState(null); const [slashIndex, setSlashIndex] = useState(0); const [showLinkModal, setShowLinkModal] = useState(false); const textareaRef = useRef(null); const debounceTimerRef = useRef(null); const latestBlockRef = useRef(block); latestBlockRef.current = block; const localContentRef = useRef(localContent); localContentRef.current = localContent; // Callback refs to avoid stale closures in setTimeout, debounce flush, and event listeners const onUpdateBlockRef = useRef(onUpdateBlock); onUpdateBlockRef.current = onUpdateBlock; const onCreateBlockBelowRef = useRef(onCreateBlockBelow); onCreateBlockBelowRef.current = onCreateBlockBelow; const onDeleteBlockRef = useRef(onDeleteBlock); onDeleteBlockRef.current = onDeleteBlock; const onIndentBlockRef = useRef(onIndentBlock); onIndentBlockRef.current = onIndentBlock; const onNavigateFocusRef = useRef(onNavigateFocus); onNavigateFocusRef.current = onNavigateFocus; // Track if this input currently has actual focus in an active browser window const isFocusedRef = useRef(false); const flushDebounce = useCallback( (contentToFlush: string, overrides?: Partial) => { if (debounceTimerRef.current !== null) { window.clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; } const nextBlock: DocumentBlock = { ...latestBlockRef.current, content: contentToFlush, ...overrides, }; onUpdateBlockRef.current(nextBlock, true); }, [] ); // Sync incoming remote changes ONLY when this textarea is NOT actively being edited // Condition: isFocusedRef.current === true && document.hasFocus() useEffect(() => { const isActivelyEditing = isFocusedRef.current === true && typeof document !== 'undefined' && document.hasFocus() && textareaRef.current !== null && document.activeElement === textareaRef.current; if (!isActivelyEditing && block.content !== localContentRef.current) { setLocalContent(block.content || ''); localContentRef.current = block.content || ''; } }, [block.content]); // Handle window blur / focus to instantly flush debounced input and clear zombie focus useEffect(() => { const handleWindowBlur = () => { if (debounceTimerRef.current !== null) { window.clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; flushDebounce(localContentRef.current); } else if (localContentRef.current !== latestBlockRef.current.content) { flushDebounce(localContentRef.current); } isFocusedRef.current = false; }; const handleWindowFocus = () => { const isThisActive = textareaRef.current !== null && document.activeElement === textareaRef.current && document.hasFocus(); isFocusedRef.current = isThisActive; }; window.addEventListener('blur', handleWindowBlur); window.addEventListener('focus', handleWindowFocus); return () => { window.removeEventListener('blur', handleWindowBlur); window.removeEventListener('focus', handleWindowFocus); }; }, [flushDebounce]); useEffect(() => { return () => { if (debounceTimerRef.current !== null) { window.clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; } }; }, []); const adjustHeight = useCallback(() => { const el = textareaRef.current; if (!el) return; el.style.height = 'auto'; el.style.height = `${Math.max(22, el.scrollHeight)}px`; }, []); useLayoutEffect(() => { adjustHeight(); }, [localContent, block.type, block.level, adjustHeight]); useEffect(() => { window.addEventListener('resize', adjustHeight); return () => window.removeEventListener('resize', adjustHeight); }, [adjustHeight]); const filteredSlashCommands = useMemo(() => { if (slashSearch === null) return []; const q = slashSearch.toLowerCase(); return SLASH_COMMANDS.filter( (c) => c.label.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q) ); }, [slashSearch]); const applySlashCommand = (cmd: SlashCommand) => { setSlashSearch(null); const clean = localContent.replace(/\/([a-z0-9]*)$/i, '').trim(); if (cmd.type === 'link') { setLocalContent(clean); setShowLinkModal(true); return; } setLocalContent(clean); flushDebounce(clean, { type: cmd.type, checked: cmd.type === 'todo' ? Boolean(block.checked) : false, }); }; const handleInsertLink = (linkMarkdown: string) => { setShowLinkModal(false); const cursor = textareaRef.current?.selectionStart ?? localContent.length; const before = localContent.slice(0, cursor); const after = localContent.slice(cursor); const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim(); setLocalContent(newContent); flushDebounce(newContent); }; const handleContentChange = (e: React.ChangeEvent) => { if (!canEdit) return; let val = e.target.value; let typeOverride: BlockType | undefined; let checkedOverride: boolean | undefined; // Quick markdown shortcuts if (val.startsWith('[] ') || val.startsWith('[ ] ')) { val = val.replace(/^\[\s?\]\s/, ''); typeOverride = 'todo'; checkedOverride = false; } else if (val.startsWith('[x] ') || val.startsWith('[X] ')) { val = val.replace(/^\[[xX]\]\s/, ''); typeOverride = 'todo'; checkedOverride = true; } else if (val.startsWith('# ') && block.type !== 'h1') { val = val.slice(2); typeOverride = 'h1'; } else if (val.startsWith('## ') && block.type !== 'h2') { val = val.slice(3); typeOverride = 'h2'; } else if (val.startsWith('### ') && block.type !== 'h3') { val = val.slice(4); typeOverride = 'h3'; } // Check for slash command invocation const slashMatch = val.match(/\/([a-z0-9]*)$/i); if (slashMatch) { setSlashSearch(slashMatch[1]); setSlashIndex(0); } else { setSlashSearch(null); } setLocalContent(val); if (debounceTimerRef.current !== null) { window.clearTimeout(debounceTimerRef.current); } if (typeOverride) { flushDebounce(val, { type: typeOverride, checked: checkedOverride ?? latestBlockRef.current.checked, }); return; } debounceTimerRef.current = window.setTimeout(() => { debounceTimerRef.current = null; onUpdateBlockRef.current({ ...latestBlockRef.current, content: val, }); }, 150); }; const handleFocus = () => { isFocusedRef.current = true; }; const handleBlur = () => { isFocusedRef.current = false; if (debounceTimerRef.current !== null) { window.clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; flushDebounce(localContentRef.current); } else if (localContentRef.current !== latestBlockRef.current.content) { flushDebounce(localContentRef.current); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (!canEdit) return; // Slash command navigation if (slashSearch !== null && filteredSlashCommands.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setSlashIndex((prev) => (prev - 1 + filteredSlashCommands.length) % filteredSlashCommands.length); return; } if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); const selected = filteredSlashCommands[slashIndex]; if (selected) { applySlashCommand(selected); } return; } if (e.key === 'Escape') { e.preventDefault(); setSlashSearch(null); return; } } // 1. Tab / Shift+Tab -> Strict Indent / Outdent if (e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); flushDebounce(localContent); onIndentBlockRef.current(block.id, e.shiftKey ? -1 : 1); return; } // 2. Enter (without Shift) -> If block is completely empty and level > minLevel, outdent (Shift+Tab behaviour), otherwise create new block below if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') { e.preventDefault(); e.stopPropagation(); setSlashSearch(null); const minLevel = levelOffset || 0; if (localContent === '' && Math.max(0, block.level || 0) > minLevel) { flushDebounce(localContent); onIndentBlockRef.current(block.id, -1); return; } flushDebounce(localContent); onCreateBlockBelowRef.current(block.id, localContent); return; } // 3. Backspace on empty block -> IMMEDIATELY DELETE THE BLOCK (regardless of level)! // Outdenting is strictly done via Shift+Tab. if (e.key === 'Backspace' && localContent === '') { e.preventDefault(); e.stopPropagation(); setSlashSearch(null); if (debounceTimerRef.current !== null) { window.clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; } onDeleteBlockRef.current(block.id); return; } // 4. Arrow navigation between blocks if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) { const el = textareaRef.current; if (el && el.selectionStart === 0 && el.selectionEnd === 0) { e.preventDefault(); flushDebounce(localContent); onNavigateFocusRef.current(block.id, 'up'); } return; } if (e.key === 'ArrowDown' && !e.shiftKey && !e.altKey) { const el = textareaRef.current; if (el && el.selectionStart === localContent.length && el.selectionEnd === localContent.length) { e.preventDefault(); flushDebounce(localContent); onNavigateFocusRef.current(block.id, 'down'); } return; } }; const level = Math.max(0, block.level || 0); const visualLevel = Math.max(0, level - (levelOffset || 0)); // Parse rich tokens (WikiLinks [[Page]], Markdown Links [Title](url), raw URLs, and #Tags) const tokens = useMemo(() => { const parts = (localContent || '').split( /(\[\[.*?\]\]|\[.*?\]\(https?:\/\/.*?\)|\bhttps?:\/\/[^\s]+|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g ); return parts.filter(Boolean); }, [localContent]); const hasSpecialTokens = tokens.some( (t) => t.startsWith('#') || (t.startsWith('[[') && t.endsWith(']]')) || /^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) || /^https?:\/\//.test(t) ); return (
onDragOverBlock?.(e, index)} onDrop={() => onDropBlock?.(index)} > {/* Indentation Guide Line */} {visualLevel > 0 && Array.from({ length: visualLevel }).map((_, lvlIdx) => (
))} {/* Bullet Controls: Collapse Toggle & Bullet (Drag-Handle & Zoom) / Checkbox */}
{hasChildren ? ( ) : ( )} {block.type === 'todo' ? ( ) : ( )}
{/* Main Block Content */}
{block.type === 'divider' ? (

) : block.type === 'code' ? (
Code