import React, { useState, useEffect, useRef, useLayoutEffect, useCallback, useMemo } from 'react'; import { ChevronRight, ChevronDown, Check, CheckSquare, X, Trash2, MoreHorizontal, Code, Heading1, Heading2, Heading3, ListTodo, ListOrdered, Minus, MessageSquare, ExternalLink, Maximize2, Link as LinkIcon, Lock, AlertTriangle, Image as ImageIcon, Mic, AudioLines, FileOutput, Link2, Type, } from 'lucide-react'; import { DocumentBlock, BlockType } from '../../lib/types'; import { LinkModal } from './LinkModal'; import { CodeBlockEditor, resolveLanguageId, isUnambiguousLanguageShortcut } from './CodeBlockEditor'; import { ImagePicker } from './ImagePicker'; import { ImageBlock } from './ImageBlock'; import { AudioRecorderPicker } from './AudioRecorderPicker'; import { AudioBlock } from './AudioBlock'; import { DictationBlock } from './DictationBlock'; import { currentVoiceConfig } from '../../lib/voice'; import { LinkPreviewCard } from './LinkPreviewCard'; import { linkCardUrl, hidePreviewContent, linkHost, useLinkPreviewsEnabled } from '../../lib/linkPreview'; import { mediaContent, parseMediaContent } from '../../lib/media'; import { renderInlineMarkdown } from '../../lib/markdown'; import { api } from '../../lib/api'; import { safeHref } from '../../lib/safeUrl'; export interface SlashCommand { type: BlockType | 'link' | 'dictation' | 'extract'; label: string; desc: string; icon: React.ReactNode; /** Only offered in an empty bullet; the bullet turns into an inline picker first */ media?: boolean; /** Further names that find the command (e.g. German ones) */ aliases?: string[]; } // The command name counts most: "/li" finds /link before /number (whose description says "Liste") function slashRank(c: SlashCommand, q: string): number { if (!q) return 0; const name = c.label.slice(1).toLowerCase(); if (name.startsWith(q)) return 0; if ((c.aliases || []).some((a) => a.startsWith(q))) return 1; if (name.includes(q)) return 2; if ((c.aliases || []).some((a) => a.includes(q))) return 3; return 4; } const SLASH_COMMANDS: SlashCommand[] = [ { type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: }, { type: 'numbered', label: '/number', desc: 'Nummerierte Liste (1., a., i.)', icon: , aliases: ['nummer', 'liste', 'ol'] }, { type: 'link', label: '/link', desc: 'Notiz oder Weblink', icon: }, { type: 'bullet', label: '/text', desc: 'In Standardtext umwandeln', icon: , aliases: ['p', 'paragraph', 'bullet', 'standard'] }, { 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: }, { type: 'extract', label: '/extract', desc: 'Ast als eigene Notiz', icon: , aliases: ['notiz', 'auslagern'] }, { type: 'image', label: '/image', desc: 'Bilder hochladen oder verlinken', icon: , media: true, aliases: ['bild', 'foto'] }, { type: 'audio', label: '/audio', desc: 'Sprachnotiz oder Audiodatei', icon: , media: true, aliases: ['sprachnotiz'] }, { type: 'dictation', label: '/diktat', desc: 'Sprechen statt tippen', icon: , media: true, aliases: ['dictate'] }, ]; export interface OutlinerBlockProps { docId: string; block: DocumentBlock; index: number; isLast: boolean; maxAllowedLevel: number; hasChildren: boolean; isFocused: boolean; canEdit: boolean; levelOffset?: number; onZoomBlock?: (blockId: string) => void; /** Turns this bullet and its children into a note of its own */ onExtractBlock?: (blockId: string) => void; onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void; onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void; onMergeWithPrevious?: (blockId: string, content: string) => boolean; onIndentBlock: (blockId: string, delta: 1 | -1) => void; onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void; onFocusBlock?: (blockId: string) => void; onBlurBlock?: (blockId: string) => void; onOpenTask?: (taskId: string) => void; onTagClick?: (tag: string) => void; onWikiLinkClick?: (pageTitle: string, blockId?: string) => void; /** Copies a link to this bullet ([[Notiz#^id|…]]) */ onCopyBlockLink?: (blockId: 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; onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void; /** Number or letter of a numbered-list item ("1.", "b.", "iii.") */ listMarker?: string; /** Another bullet on the same level follows: Enter in this empty bullet adds a bullet instead of outdenting */ siblingFollows?: boolean; lockedBy?: string; // someone else is editing this block canAppendBelow?: boolean; // the user may edit the note, so Enter in a locked block still adds one below onResolveConflict?: (blockId: string, accept: boolean) => void; onUndo?: () => void; onRedo?: () => void; } // Rendered markdown only drops syntax characters (**, `, [..](url) …), so its text is a subsequence of the raw // text. Walking both strings in parallel maps a caret offset in the rendered text back to the raw text. export function mapRenderedOffsetToRaw(raw: string, rendered: string, renderedOffset: number): number { let j = 0; for (let i = 0; i < renderedOffset && i < rendered.length; i++) { const idx = raw.indexOf(rendered[i], j); if (idx === -1) break; j = idx + 1; } return Math.min(j, raw.length); } export const OutlinerBlock: React.FC = ({ docId, block, index, hasChildren, isFocused, canEdit, levelOffset = 0, onZoomBlock, onExtractBlock, onUpdateBlock, onCreateBlockBelow, onDeleteBlock, onMergeWithPrevious, onIndentBlock, onNavigateFocus, onFocusBlock, onBlurBlock, onOpenTask, onTagClick, onWikiLinkClick, onCopyBlockLink, onDragStartBlock, onDragOverBlock, onDropBlock, isDragTarget, isTargeted, registerTextareaRef, onPasteMultiLines, listMarker, siblingFollows = false, lockedBy, canAppendBelow, onResolveConflict, onUndo, onRedo, }) => { const [localContent, setLocalContent] = useState(block.content || ''); const [isLocallyFocused, setIsLocallyFocused] = useState(false); const activeFocus = isFocused || isLocallyFocused; // A block someone else is editing can still get the cursor (read-only), e.g. to press Enter at its end const canFocus = canEdit || (!!lockedBy && !!canAppendBelow); const [slashMatchInfo, setSlashMatchInfo] = useState<{ query: string; slashStart: number; cursor: number } | null>(null); const slashSearch = slashMatchInfo ? slashMatchInfo.query : null; const linkBeforeRef = useRef(''); const linkAfterRef = useRef(''); const dictationBeforeRef = useRef(''); const dictationAfterRef = useRef(''); const slashMenuRef = useRef(null); const todoMenuRef = useRef(null); const blurSlashTimerRef = useRef(null); // Inline picker an empty bullet turns into after /image (local only, nothing synced until done) const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null); // Card above bullets whose entire content is a plain https address (lib/linkPreview.ts) const previewsOn = useLinkPreviewsEnabled(); const previewUrl = previewsOn && !mediaPicker && ['bullet', 'text', 'paragraph', 'todo', 'h1', 'h2', 'h3'].includes(block.type || 'bullet') ? linkCardUrl(localContent) : null; const [slashIndex, setSlashIndex] = useState(0); const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([]); const [showLinkModal, setShowLinkModal] = useState(false); const [showTodoMenu, setShowTodoMenu] = 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 onPasteMultiLinesRef = useRef(onPasteMultiLines); onPasteMultiLinesRef.current = onPasteMultiLines; const onDeleteBlockRef = useRef(onDeleteBlock); onDeleteBlockRef.current = onDeleteBlock; const onMergeWithPreviousRef = useRef(onMergeWithPrevious); onMergeWithPreviousRef.current = onMergeWithPrevious; const onIndentBlockRef = useRef(onIndentBlock); onIndentBlockRef.current = onIndentBlock; const onNavigateFocusRef = useRef(onNavigateFocus); onNavigateFocusRef.current = onNavigateFocus; const isPastingRef = useRef(false); // 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); }, [] ); // Right after undo/redo the focused field must show the engine's text, not keep its own const acceptRemoteUntilRef = useRef(0); const onUndoRef = useRef(onUndo); onUndoRef.current = onUndo; const onRedoRef = useRef(onRedo); onRedoRef.current = onRedo; // 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; const acceptAfterUndo = Date.now() < acceptRemoteUntilRef.current; if ((!isActivelyEditing || acceptAfterUndo) && 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; } if (blurSlashTimerRef.current !== null) { window.clearTimeout(blurSlashTimerRef.current); blurSlashTimerRef.current = null; } }; }, []); // Close slash command menu on click outside useEffect(() => { if (!slashMatchInfo) return; const handleOutsideClick = (e: MouseEvent) => { const target = e.target as Node | null; if ( slashMenuRef.current && !slashMenuRef.current.contains(target) && textareaRef.current && !textareaRef.current.contains(target) ) { setSlashMatchInfo(null); } }; document.addEventListener('mousedown', handleOutsideClick); return () => { document.removeEventListener('mousedown', handleOutsideClick); }; }, [slashMatchInfo]); // Close todo menu on click outside useEffect(() => { if (!showTodoMenu) return; const handleOutsideClick = (e: MouseEvent) => { const target = e.target as Node | null; if (todoMenuRef.current && !todoMenuRef.current.contains(target)) { setShowTodoMenu(false); } }; document.addEventListener('mousedown', handleOutsideClick); return () => { document.removeEventListener('mousedown', handleOutsideClick); }; }, [showTodoMenu]); const renderedTextRef = useRef(null); // Focus the textarea and place the caret where the user clicked in the rendered (markdown) text layer const focusAtPoint = (clientX: number, clientY: number) => { const ta = textareaRef.current; if (!ta) return; const raw = ta.value; let pos = raw.length; const span = renderedTextRef.current; const doc = document as any; let node: Node | null = null; let offset = 0; if (typeof doc.caretPositionFromPoint === 'function') { const p = doc.caretPositionFromPoint(clientX, clientY); if (p) { node = p.offsetNode; offset = p.offset; } } else if (typeof doc.caretRangeFromPoint === 'function') { const r = doc.caretRangeFromPoint(clientX, clientY); if (r) { node = r.startContainer; offset = r.startOffset; } } if (span && node && span.contains(node)) { const range = document.createRange(); range.setStart(span, 0); range.setEnd(node, offset); pos = mapRenderedOffsetToRaw(raw, span.textContent || '', range.toString().length); } ta.focus(); ta.setSelectionRange(pos, pos); }; 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, activeFocus, adjustHeight]); // Turning the block into another type (slash menu, ``` or markdown shortcut) can swap the text field // for a new one (e.g. the code editor); the cursor must stay in the block so typing goes on there const refocusAfterTypeChangeRef = useRef(false); useLayoutEffect(() => { if (!refocusAfterTypeChangeRef.current) return; refocusAfterTypeChangeRef.current = false; const ta = textareaRef.current; if (ta && document.activeElement !== ta) { ta.focus(); const end = ta.value.length; ta.setSelectionRange(end, end); } }, [block.type]); useEffect(() => { window.addEventListener('resize', adjustHeight); // The field can be measured while hidden (phone layout shows the editor only after selecting a note); // measure again whenever its width changes const el = textareaRef.current; let lastWidth = el?.clientWidth ?? 0; const observer = el && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => { if (el.clientWidth !== lastWidth) { lastWidth = el.clientWidth; adjustHeight(); } }) : null; if (el) observer?.observe(el); return () => { window.removeEventListener('resize', adjustHeight); observer?.disconnect(); }; }, [adjustHeight, block.type]); const filteredSlashCommands = useMemo(() => { if (!slashMatchInfo) return []; const q = slashMatchInfo.query.toLowerCase(); const cmdQuery = q.startsWith('code ') ? 'code' : q; const textBeforeSlash = localContent.slice(0, slashMatchInfo.slashStart); const textAfterCursor = localContent.slice(slashMatchInfo.cursor); const restOfBlock = (textBeforeSlash + textAfterCursor).trim(); const isRestEmpty = restOfBlock === ''; const isAtStartOrEnd = textBeforeSlash.trim() === '' || textAfterCursor.trim() === ''; return SLASH_COMMANDS.filter((c) => { const matchesQuery = c.label.toLowerCase().includes(cmdQuery) || c.desc.toLowerCase().includes(cmdQuery) || (c.aliases || []).some((a) => a.includes(cmdQuery)); if (!matchesQuery) return false; // /link and /dictation everywhere if (c.type === 'link') return true; if (c.type === 'dictation') { return ['server', 'browser'].includes(currentVoiceConfig().mode); } // /divider, /image, /audio, /code only when rest of block is empty if (c.type === 'divider' || c.type === 'image' || c.type === 'audio' || c.type === 'code') { return isRestEmpty; } // /extract only when note has content and is at start/end if (c.type === 'extract') { return !isRestEmpty && isAtStartOrEnd && !!onExtractBlock; } // /h1, /h2, /h3, /todo, /callout, /text (type 'bullet') at start or at end if ( c.type === 'h1' || c.type === 'h2' || c.type === 'h3' || c.type === 'todo' || c.type === 'numbered' || c.type === 'callout' || c.type === 'bullet' ) { return isAtStartOrEnd; } return true; }).sort((a, b) => slashRank(a, cmdQuery) - slashRank(b, cmdQuery)); }, [slashMatchInfo, localContent, onExtractBlock]); useEffect(() => { if (slashSearch === null) return; slashItemRefs.current[slashIndex]?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }); }, [slashIndex, slashSearch]); const applySlashCommand = (cmd: SlashCommand) => { if (blurSlashTimerRef.current !== null) { window.clearTimeout(blurSlashTimerRef.current); blurSlashTimerRef.current = null; } const slashStart = slashMatchInfo ? slashMatchInfo.slashStart : 0; const slashEnd = slashMatchInfo ? slashMatchInfo.cursor : localContent.length; setSlashMatchInfo(null); const before = localContent.slice(0, slashStart); const after = localContent.slice(slashEnd); const clean = `${before}${after}`.replace(/\s{2,}/g, ' ').trim(); if (cmd.type === 'link') { linkBeforeRef.current = before; linkAfterRef.current = after; setLocalContent(clean); localContentRef.current = clean; setShowLinkModal(true); return; } if (cmd.type === 'dictation') { dictationBeforeRef.current = before; dictationAfterRef.current = after; setLocalContent(clean); localContentRef.current = clean; flushDebounce(clean); setMediaPicker('dictation'); return; } if (cmd.type === 'extract') { setLocalContent(clean); localContentRef.current = clean; flushDebounce(clean); onExtractBlock?.(block.id); return; } if (cmd.media) { // The typed "/image" must not stay in the synced text; the block type changes only after the upload setLocalContent(''); localContentRef.current = ''; flushDebounce(''); setMediaPicker(cmd.type as 'image' | 'audio'); return; } const codeLangMatch = slashMatchInfo?.query.match(/^code\s+([a-z0-9_+#.-]+)$/i); const langOverride = cmd.type === 'code' && codeLangMatch ? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase() : undefined; setLocalContent(clean); localContentRef.current = clean; if (cmd.type !== block.type) refocusAfterTypeChangeRef.current = true; flushDebounce(clean, { type: cmd.type as BlockType, ...(langOverride !== undefined ? { lang: langOverride } : {}), checked: cmd.type === 'todo' ? Boolean(block.checked) : false, ...(cmd.type === 'bullet' ? { taskId: '', checked: false } : {}), }); }; const handleInsertLink = (linkMarkdown: string) => { setShowLinkModal(false); const b = linkBeforeRef.current ?? ''; const a = linkAfterRef.current ?? ''; if (b.trim() === '' && a.trim() === '') { const webMatch = linkMarkdown.match(/^\[.*\]\((https?:\/\/[^\s)]+)\)$/); if (webMatch) { const bareUrl = webMatch[1]; setLocalContent(bareUrl); localContentRef.current = bareUrl; flushDebounce(bareUrl); return; } } const spaceBefore = b && !b.endsWith(' ') ? ' ' : ''; const spaceAfter = a && !a.startsWith(' ') && a.trim() !== '' ? ' ' : ''; const newContent = `${b}${spaceBefore}${linkMarkdown}${spaceAfter}${a}`.trim(); setLocalContent(newContent); localContentRef.current = newContent; flushDebounce(newContent); }; const applyFormattingChange = (newVal: string, selStart: number, selEnd: number) => { setLocalContent(newVal); localContentRef.current = newVal; if (debounceTimerRef.current !== null) { window.clearTimeout(debounceTimerRef.current); } debounceTimerRef.current = window.setTimeout(() => { debounceTimerRef.current = null; onUpdateBlockRef.current({ ...latestBlockRef.current, content: newVal, }); }, 150); const ta = textareaRef.current; if (ta) { ta.value = newVal; ta.setSelectionRange(selStart, selEnd); } setTimeout(() => { if (textareaRef.current) { textareaRef.current.setSelectionRange(selStart, selEnd); adjustHeight(); } }, 0); }; const handleContentChange = (e: React.ChangeEvent) => { if (!canEdit) return; let val = e.target.value; let typeOverride: BlockType | undefined; let checkedOverride: boolean | undefined; let langOverride: string | undefined; if (block.type !== 'code') { // Quick markdown shortcuts const fenceSpaceMatch = val.match(/^```([a-z0-9_+#.-]*)\s([\s\S]*)$/i); const fenceDirectMatch = val.match(/^```([a-z0-9_+#.-]+)$/i); const slashCodeSpaceMatch = val.match(/^\/code\s+([a-z0-9_+#.-]+)\s([\s\S]*)$/i); const slashCodeDirectMatch = val.match(/^\/code\s+([a-z0-9_+#.-]+)$/i); if (fenceSpaceMatch) { const rawLang = fenceSpaceMatch[1]; val = fenceSpaceMatch[2]; typeOverride = 'code'; langOverride = rawLang ? resolveLanguageId(rawLang) || rawLang.toLowerCase() : ''; } else if (fenceDirectMatch && isUnambiguousLanguageShortcut(fenceDirectMatch[1])) { val = ''; typeOverride = 'code'; langOverride = resolveLanguageId(fenceDirectMatch[1]) || fenceDirectMatch[1].toLowerCase(); } else if (slashCodeSpaceMatch) { const rawLang = slashCodeSpaceMatch[1]; val = slashCodeSpaceMatch[2]; typeOverride = 'code'; langOverride = resolveLanguageId(rawLang) || rawLang.toLowerCase(); } else if (slashCodeDirectMatch && isUnambiguousLanguageShortcut(slashCodeDirectMatch[1])) { val = ''; typeOverride = 'code'; langOverride = resolveLanguageId(slashCodeDirectMatch[1]) || slashCodeDirectMatch[1].toLowerCase(); } else 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 === '- ' || val === '* ' || val === '+ ') { // Markdown list marker typed into an empty block: it already is a bullet, drop the marker val = ''; if (!['text', 'bullet', 'paragraph'].includes(block.type || 'text')) typeOverride = 'bullet'; } else if ((val === '1. ' || val === '1) ') && block.type !== 'numbered') { // "1. " typed into an empty bullet starts a numbered list val = ''; typeOverride = 'numbered'; } 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'; } else if ( (val.startsWith('/text ') || val.startsWith('/p ') || val.startsWith('/paragraph ') || val.startsWith('/bullet ') || val.startsWith('/standard ')) && block.type !== 'bullet' ) { val = val.replace(/^\/(text|p|paragraph|bullet|standard)\s+/, ''); typeOverride = 'bullet'; } // Check for slash command invocation at cursor position const sel = e.currentTarget.selectionStart; const cursor = typeof sel === 'number' && sel > 0 ? sel : val.length; const textBefore = val.slice(0, cursor); const slashMatch = !typeOverride ? textBefore.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null; if (slashMatch) { const slashStart = slashMatch.index! + slashMatch[1].length; setSlashMatchInfo({ query: slashMatch[2], slashStart, cursor }); setSlashIndex(0); } else { setSlashMatchInfo(null); } } else { setSlashMatchInfo(null); } setLocalContent(val); localContentRef.current = val; if (debounceTimerRef.current !== null) { window.clearTimeout(debounceTimerRef.current); } if (typeOverride) { refocusAfterTypeChangeRef.current = true; flushDebounce(val, { type: typeOverride, ...(langOverride !== undefined ? { lang: langOverride } : {}), checked: checkedOverride ?? latestBlockRef.current.checked, ...(typeOverride === 'bullet' ? { taskId: '', checked: false } : {}), }); return; } debounceTimerRef.current = window.setTimeout(() => { debounceTimerRef.current = null; onUpdateBlockRef.current({ ...latestBlockRef.current, content: val, }); }, 150); }; const handleFocus = () => { if (blurSlashTimerRef.current !== null) { window.clearTimeout(blurSlashTimerRef.current); blurSlashTimerRef.current = null; } isFocusedRef.current = true; setIsLocallyFocused(true); onFocusBlock?.(block.id); setTimeout(adjustHeight, 0); }; const handleBlur = () => { isFocusedRef.current = false; setIsLocallyFocused(false); onBlurBlock?.(block.id); if (blurSlashTimerRef.current !== null) { window.clearTimeout(blurSlashTimerRef.current); } blurSlashTimerRef.current = window.setTimeout(() => { setSlashMatchInfo(null); }, 150); if (isPastingRef.current) { isPastingRef.current = false; return; } 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 handlePaste = (e: React.ClipboardEvent) => { if (!canEdit || block.type === 'code') return; const pastedText = e.clipboardData.getData('text'); if (!pastedText || !pastedText.includes('\n')) return; e.preventDefault(); const rawLines = pastedText.split(/\r?\n/); if (rawLines.length <= 1) return; const start = e.currentTarget.selectionStart ?? localContent.length; const end = e.currentTarget.selectionEnd ?? start; const textBefore = localContent.slice(0, start); const textAfter = localContent.slice(end); if (debounceTimerRef.current !== null) { window.clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; } isPastingRef.current = true; // Pasted text that stays in this block arrives as a prop change while the field has focus: take it acceptRemoteUntilRef.current = Date.now() + 1000; onPasteMultiLinesRef.current?.(block.id, textBefore, textAfter, rawLines); }; // Ctrl/Cmd+Z undoes the user's own changes in the whole note (not the browser's per-field undo) const runHistory = (redo: boolean) => { if (debounceTimerRef.current !== null) { flushDebounce(localContentRef.current); } acceptRemoteUntilRef.current = Date.now() + 500; if (redo) onRedoRef.current?.(); else onUndoRef.current?.(); }; useEffect(() => { const el = textareaRef.current; if (!el) return; // Mobile keyboards and the context menu undo via beforeinput instead of key events const onBeforeInput = (e: InputEvent) => { if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') { e.preventDefault(); runHistory(e.inputType === 'historyRedo'); } }; el.addEventListener('beforeinput', onBeforeInput); return () => el.removeEventListener('beforeinput', onBeforeInput); }); const handleKeyDown = (e: React.KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && !e.altKey && ['z', 'y'].includes(e.key.toLowerCase())) { e.preventDefault(); e.stopPropagation(); runHistory(e.key.toLowerCase() === 'y' || e.shiftKey); return; } // Enter in a block someone else is editing cannot split it (read-only), so it adds a new block below // without touching the locked one. (Chromium does not move the caret with End in read-only fields.) if (lockedBy && canAppendBelow && e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onCreateBlockBelowRef.current(block.id, ''); return; } if (!canEdit) return; // Formatting shortcuts (Ctrl/Cmd+B, Ctrl/Cmd+I, Ctrl/Cmd+K) - not in code blocks const isMod = (e.ctrlKey || e.metaKey) && !e.altKey; if (isMod && canEdit && block.type !== 'code' && ['b', 'i', 'k'].includes(e.key.toLowerCase())) { e.preventDefault(); e.stopPropagation(); const ta = textareaRef.current || e.currentTarget; const start = ta.selectionStart ?? 0; const end = ta.selectionEnd ?? 0; const text = ta.value; const hasSelection = start < end; if (e.key.toLowerCase() === 'k') { if (!hasSelection) { setShowLinkModal(true); return; } const selected = text.slice(start, end); const placeholder = '…'; const before = text.slice(0, start); const after = text.slice(end); const inserted = `[${selected}](${placeholder})`; const newVal = before + inserted + after; const selStart = start + 1 + selected.length + 2; const selEnd = selStart + placeholder.length; applyFormattingChange(newVal, selStart, selEnd); return; } const isBold = e.key.toLowerCase() === 'b'; const delim = isBold ? '**' : '*'; if (!hasSelection) { const pair = isBold ? '****' : '**'; const newVal = text.slice(0, start) + pair + text.slice(start); const newPos = start + delim.length; applyFormattingChange(newVal, newPos, newPos); return; } const selected = text.slice(start, end); if (isBold) { const isOutside = start >= 2 && end + 2 <= text.length && text.slice(start - 2, start) === '**' && text.slice(end, end + 2) === '**'; const isInside = selected.length >= 4 && selected.startsWith('**') && selected.endsWith('**'); if (isOutside) { const newVal = text.slice(0, start - 2) + selected + text.slice(end + 2); applyFormattingChange(newVal, start - 2, end - 2); return; } if (isInside) { const unwrapped = selected.slice(2, -2); const newVal = text.slice(0, start) + unwrapped + text.slice(end); applyFormattingChange(newVal, start, start + unwrapped.length); return; } const newVal = text.slice(0, start) + '**' + selected + '**' + text.slice(end); applyFormattingChange(newVal, start + 2, end + 2); return; } else { const isOutside = start >= 1 && end + 1 <= text.length && text[start - 1] === '*' && text[end] === '*' && (start < 2 || text[start - 2] !== '*') && (end + 1 >= text.length || text[end + 1] !== '*'); const isInside = selected.length >= 2 && selected.startsWith('*') && selected.endsWith('*') && (!selected.startsWith('**') || !selected.endsWith('**') || selected.length < 4); if (isOutside) { const newVal = text.slice(0, start - 1) + selected + text.slice(end + 1); applyFormattingChange(newVal, start - 1, end - 1); return; } if (isInside) { const unwrapped = selected.slice(1, -1); const newVal = text.slice(0, start) + unwrapped + text.slice(end); applyFormattingChange(newVal, start, start + unwrapped.length); return; } const newVal = text.slice(0, start) + '*' + selected + '*' + text.slice(end); applyFormattingChange(newVal, start + 1, end + 1); return; } } // Slash command navigation if (slashSearch !== null) { if (e.key === 'Escape') { e.preventDefault(); setSlashMatchInfo(null); return; } if (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; } } } // 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: // - If level > minLevel, outdent (Shift+Tab behaviour) // - Otherwise split paragraph at cursor and create new block below if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') { e.preventDefault(); e.stopPropagation(); setSlashMatchInfo(null); const fenceMatch = localContent.match(/^```([a-z0-9_+#.-]*)$/i); if (fenceMatch) { const rawLang = fenceMatch[1]; const resolvedLang = rawLang ? resolveLanguageId(rawLang) || rawLang.toLowerCase() : ''; setLocalContent(''); localContentRef.current = ''; refocusAfterTypeChangeRef.current = true; flushDebounce('', { type: 'code', lang: resolvedLang, checked: false, }); return; } const minLevel = levelOffset || 0; if (localContent === '') { if (Math.max(0, block.level || 0) > minLevel && !siblingFollows) { flushDebounce(localContent); onIndentBlockRef.current(block.id, -1); return; } // Enter in an empty last numbered item at the top ends the list if (block.type === 'numbered' && !siblingFollows) { flushDebounce('', { type: 'text' }); return; } } const start = e.currentTarget.selectionStart ?? localContent.length; const end = e.currentTarget.selectionEnd ?? start; let beforeText = localContent.slice(0, start); let afterText = localContent.slice(end); // Clean linebreaks at the split boundary if present if (beforeText.endsWith('\r\n')) { beforeText = beforeText.slice(0, -2); } else if (beforeText.endsWith('\n')) { beforeText = beforeText.slice(0, -1); } if (afterText.startsWith('\r\n')) { afterText = afterText.slice(2); } else if (afterText.startsWith('\n')) { afterText = afterText.slice(1); } setLocalContent(beforeText); localContentRef.current = beforeText; flushDebounce(beforeText); onCreateBlockBelowRef.current(block.id, afterText); 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(); setSlashMatchInfo(null); if (debounceTimerRef.current !== null) { window.clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; } onDeleteBlockRef.current(block.id); return; } // 3b. Backspace at the very start of a non-empty block -> append its text to the previous block if ( e.key === 'Backspace' && localContent !== '' && block.type !== 'code' && onMergeWithPreviousRef.current && e.currentTarget.selectionStart === 0 && e.currentTarget.selectionEnd === 0 ) { if (onMergeWithPreviousRef.current(block.id, localContent)) { // Text lives in the previous block afterwards, so drop this block's pending save if (debounceTimerRef.current !== null) { window.clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; } e.preventDefault(); e.stopPropagation(); setSlashMatchInfo(null); } 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)); const isEmpty = (!localContent || localContent.trim() === '') && !hasChildren; return (
onDragOverBlock?.(e, index)} onDrop={() => onDropBlock?.(index)} onClick={(e) => { if ((e.target as HTMLElement).closest('button, a, input, select')) return; if (!activeFocus && canFocus && textareaRef.current) { textareaRef.current.focus(); const len = textareaRef.current.value.length; textareaRef.current.setSelectionRange(len, len); } }} > {/* Bullet Controls: Collapse Toggle & Bullet (Drag-Handle & Zoom) / Checkbox */}
{hasChildren ? ( ) : ( )} {/* Bullet handle for dragging and zoom focus */} {/* If To-Do block: checkbox alongside bullet */} {block.type === 'todo' && ( )}
{/* Main Block Content */}
{previewUrl && ( { const content = hidePreviewContent(localContent, previewUrl, title); setLocalContent(content); localContentRef.current = content; flushDebounce(content); }} /> )} {mediaPicker === 'image' ? ( { setMediaPicker(null); const content = mediaContent('image', media.id); setLocalContent(content); localContentRef.current = content; flushDebounce(content, { type: 'image' }); }} onCancel={() => { setMediaPicker(null); requestAnimationFrame(() => textareaRef.current?.focus()); }} /> ) : mediaPicker === 'dictation' ? ( { setMediaPicker(null); const b = dictationBeforeRef.current ?? ''; const a = dictationAfterRef.current ?? ''; const spaceBefore = b && !b.endsWith(' ') ? ' ' : ''; const spaceAfter = a && !a.startsWith(' ') && a.trim() !== '' ? ' ' : ''; const newContent = `${b}${spaceBefore}${text}${spaceAfter}${a}`.trim(); setLocalContent(newContent); localContentRef.current = newContent; flushDebounce(newContent); requestAnimationFrame(() => { const ta = textareaRef.current; if (!ta) return; ta.focus(); const pos = (b + spaceBefore + text).length; ta.setSelectionRange(pos, pos); }); }} onCancel={() => { setMediaPicker(null); requestAnimationFrame(() => textareaRef.current?.focus()); }} /> ) : mediaPicker === 'audio' ? ( { setMediaPicker(null); const content = mediaContent('audio', media.id); setLocalContent(content); localContentRef.current = content; flushDebounce(content, { type: 'audio' }); }} onCancel={() => { setMediaPicker(null); requestAnimationFrame(() => textareaRef.current?.focus()); }} /> ) : block.type === 'audio' ? ( { const ref = parseMediaContent(localContent); if (!ref) return; const content = mediaContent('audio', ref.id, caption); setLocalContent(content); localContentRef.current = content; flushDebounce(content); }} /> ) : block.type === 'image' ? ( { const ref = parseMediaContent(localContent); if (!ref) return; const content = mediaContent('image', ref.id, caption); setLocalContent(content); localContentRef.current = content; flushDebounce(content); }} /> ) : block.type === 'divider' ? (
{ textareaRef.current?.focus(); }} >