Files
MindSift/frontend/src/features/workspace/OutlinerBlock.tsx
T

1375 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef, useLayoutEffect, useCallback, useMemo } from 'react';
import {
ChevronRight,
ChevronDown,
Check,
CheckSquare,
X,
Trash2,
MoreHorizontal,
Code,
Heading1,
Heading2,
Heading3,
ListTodo,
Minus,
MessageSquare,
ExternalLink,
Maximize2,
Link as LinkIcon,
FileText,
Lock,
AlertTriangle,
Image as ImageIcon,
Mic,
AudioLines,
FileOutput,
} 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, 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[];
}
const SLASH_COMMANDS: SlashCommand[] = [
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
{ type: 'link', label: '/link', desc: 'Notiz oder Weblink', icon: <LinkIcon className="w-3.5 h-3.5" /> },
{ type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: <Heading1 className="w-3.5 h-3.5" /> },
{ type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: <Heading2 className="w-3.5 h-3.5" /> },
{ type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: <Heading3 className="w-3.5 h-3.5" /> },
{ type: 'code', label: '/code', desc: 'Code-Block mit Syntax', icon: <Code className="w-3.5 h-3.5" /> },
{ type: 'callout', label: '/callout', desc: 'Hinweisbox', icon: <MessageSquare className="w-3.5 h-3.5" /> },
{ type: 'divider', label: '/divider', desc: 'Trennlinie', icon: <Minus className="w-3.5 h-3.5" /> },
{ type: 'extract', label: '/extract', desc: 'Ast als eigene Notiz', icon: <FileOutput className="w-3.5 h-3.5" />, aliases: ['notiz', 'auslagern'] },
{ type: 'image', label: '/image', desc: 'Bilder hochladen oder verlinken', icon: <ImageIcon className="w-3.5 h-3.5" />, media: true, aliases: ['bild', 'foto'] },
{ type: 'audio', label: '/audio', desc: 'Sprachnotiz oder Audiodatei', icon: <Mic className="w-3.5 h-3.5" />, media: true, aliases: ['sprachnotiz'] },
{ type: 'dictation', label: '/diktat', desc: 'Sprechen statt tippen', icon: <AudioLines className="w-3.5 h-3.5" />, 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) => 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;
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<OutlinerBlockProps> = ({
docId,
block,
index,
hasChildren,
isFocused,
canEdit,
levelOffset = 0,
onZoomBlock,
onExtractBlock,
onUpdateBlock,
onCreateBlockBelow,
onDeleteBlock,
onMergeWithPrevious,
onIndentBlock,
onNavigateFocus,
onFocusBlock,
onBlurBlock,
onOpenTask,
onTagClick,
onWikiLinkClick,
onDragStartBlock,
onDragOverBlock,
onDropBlock,
isDragTarget,
isTargeted,
registerTextareaRef,
onPasteMultiLines,
lockedBy,
canAppendBelow,
onResolveConflict,
onUndo,
onRedo,
}) => {
const [localContent, setLocalContent] = useState<string>(block.content || '');
const [isLocallyFocused, setIsLocallyFocused] = useState<boolean>(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 [slashSearch, setSlashSearch] = useState<string | null>(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<boolean>(false);
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const debounceTimerRef = useRef<number | null>(null);
const latestBlockRef = useRef<DocumentBlock>(block);
latestBlockRef.current = block;
const localContentRef = useRef<string>(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<boolean>(false);
// Track if this input currently has actual focus in an active browser window
const isFocusedRef = useRef<boolean>(false);
const flushDebounce = useCallback(
(contentToFlush: string, overrides?: Partial<DocumentBlock>) => {
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<number>(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;
}
};
}, []);
const renderedTextRef = useRef<HTMLSpanElement | null>(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 (slashSearch === null) return [];
const q = slashSearch.toLowerCase();
const cmdQuery = q.startsWith('code ') ? 'code' : q;
// Media only in an otherwise empty bullet
const emptyBullet = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === '';
return SLASH_COMMANDS.filter(
(c) =>
(!c.media || emptyBullet) &&
(c.type !== 'dictation' || ['server', 'browser'].includes(currentVoiceConfig().mode)) &&
// The bullet's text becomes the title of the new note
(c.type !== 'extract' || (!emptyBullet && !!onExtractBlock)) &&
(c.label.toLowerCase().includes(cmdQuery) ||
c.desc.toLowerCase().includes(cmdQuery) ||
(c.aliases || []).some((a) => a.includes(cmdQuery)))
);
}, [slashSearch, localContent, onExtractBlock]);
useEffect(() => {
if (slashSearch === null) return;
slashItemRefs.current[slashIndex]?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
}, [slashIndex, slashSearch]);
const applySlashCommand = (cmd: SlashCommand) => {
setSlashSearch(null);
const codeLangMatch = localContent.match(/(^|\s)\/code\s+([a-z0-9_+#.-]+)$/i);
const clean = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim();
if (cmd.type === 'link') {
setLocalContent(clean);
localContentRef.current = clean;
setShowLinkModal(true);
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' | 'dictation');
return;
}
const langOverride =
cmd.type === 'code' && codeLangMatch
? resolveLanguageId(codeLangMatch[2]) || codeLangMatch[2].toLowerCase()
: undefined;
setLocalContent(clean);
localContentRef.current = clean;
if (cmd.type !== block.type) refocusAfterTypeChangeRef.current = true;
flushDebounce(clean, {
type: cmd.type as BlockType, // media commands returned above
...(langOverride !== undefined ? { lang: langOverride } : {}),
checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
});
};
const handleInsertLink = (linkMarkdown: string) => {
setShowLinkModal(false);
if (localContent.trim() === '') {
const webMatch = linkMarkdown.match(/^\[.*\]\((https?:\/\/[^\s)]+)\)$/);
if (webMatch) {
const bareUrl = webMatch[1];
setLocalContent(bareUrl);
localContentRef.current = bareUrl;
flushDebounce(bareUrl);
return;
}
}
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);
localContentRef.current = newContent;
flushDebounce(newContent);
};
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
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.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 = !typeOverride ? val.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
if (slashMatch) {
setSlashSearch(slashMatch[2]);
setSlashIndex(0);
} else {
setSlashSearch(null);
}
} else {
setSlashSearch(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,
});
return;
}
debounceTimerRef.current = window.setTimeout(() => {
debounceTimerRef.current = null;
onUpdateBlockRef.current({
...latestBlockRef.current,
content: val,
});
}, 150);
};
const handleFocus = () => {
isFocusedRef.current = true;
setIsLocallyFocused(true);
onFocusBlock?.(block.id);
setTimeout(adjustHeight, 0);
};
const handleBlur = () => {
isFocusedRef.current = false;
setIsLocallyFocused(false);
onBlurBlock?.(block.id);
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<HTMLTextAreaElement>) => {
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;
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<HTMLTextAreaElement>) => {
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;
// Slash command navigation
if (slashSearch !== null) {
if (e.key === 'Escape') {
e.preventDefault();
setSlashSearch(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();
setSlashSearch(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) {
flushDebounce(localContent);
onIndentBlockRef.current(block.id, -1);
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();
setSlashSearch(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();
setSlashSearch(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;
// 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]);
// #Hashtags are rendered inline inside the text, so only links need the badge bar below
const hasSpecialTokens = tokens.some(
(t) =>
(t.startsWith('[[') && t.endsWith(']]')) ||
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
(/^https?:\/\//.test(t) && t !== previewUrl)
);
return (
<div
className={`group relative flex items-start gap-1 py-1 px-0.5 sm:px-1.5 rounded-card transition-all duration-300 ${
isDragTarget
? 'border-t-2 border-accent bg-accent/10'
: isTargeted
? 'bg-panel-elevated'
: ''
} ${block.conflictOf ? 'border-l-2 border-status-warn bg-status-warn/5' : ''}`}
style={{
// Indent per level and room kept for text come from CSS (narrower on phones, see index.css);
// deep rows get wider than the screen and the note scrolls sideways instead of squeezing them
paddingLeft: `calc(var(--outline-indent) * ${visualLevel} + 4px)`,
minWidth: `calc(var(--outline-indent) * ${visualLevel} + var(--outline-min-text))`,
}}
onDragOver={(e) => 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 */}
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
{hasChildren ? (
<button
type="button"
onClick={() =>
flushDebounce(localContent, { collapsed: !block.collapsed })
}
className="p-0.5 text-muted hover:text-accent transition-[color,opacity] opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
title={block.collapsed ? 'Unterpunkte ausklappen' : 'Unterpunkte einklappen'}
>
{block.collapsed ? (
<ChevronRight className="w-3.5 h-3.5" />
) : (
<ChevronDown className="w-3.5 h-3.5 text-accent" />
)}
</button>
) : (
<span className="w-3.5" />
)}
{/* Bullet handle for dragging and zoom focus */}
<button
type="button"
draggable={canEdit}
onDragStart={() => canEdit && onDragStartBlock?.(index)}
onClick={() => onZoomBlock?.(block.id)}
className={`w-4 h-4 flex items-center justify-center rounded-btn group/bullet transition-transform ${
canEdit ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
}`}
title={
canEdit
? 'Ziehen zum Verschieben • Klick: Ast hineinzoomen (Fokus-Modus)'
: 'Klick: In diesen Ast hineinzoomen (Fokus-Modus)'
}
>
<span
className={`w-1.5 h-1.5 rounded-full transition-all duration-150 ${
block.collapsed && hasChildren
? 'bg-accent ring-2 ring-accent/30'
: activeFocus
? 'bg-main scale-125'
: 'bg-faint group-hover/bullet:bg-accent group-hover/bullet:scale-125'
} ${
isEmpty
? 'opacity-0 group-hover:opacity-100 hover:opacity-100'
: 'opacity-100'
}`}
/>
</button>
{/* If To-Do block: checkbox alongside bullet */}
{block.type === 'todo' && (
<button
type="button"
disabled={!canEdit}
onClick={() =>
flushDebounce(localContent, { checked: !block.checked })
}
className={`w-4 h-4 checkbox-square flex items-center justify-center transition-colors border ${
block.checked
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
title={block.checked ? 'Als offen markieren' : 'Als erledigt markieren'}
>
<Check className="w-3 h-3 stroke-[3]" />
</button>
)}
</div>
{/* Main Block Content */}
<div className="flex-1 min-w-0 pr-1 relative min-h-[1.5rem]">
{previewUrl && (
<LinkPreviewCard
url={previewUrl}
canEdit={canEdit}
onHide={(title) => {
const content = hidePreviewContent(localContent, previewUrl, title);
setLocalContent(content);
localContentRef.current = content;
flushDebounce(content);
}}
/>
)}
{mediaPicker === 'image' ? (
<ImagePicker
docId={docId}
onDone={(media) => {
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' ? (
<DictationBlock
onDone={(text) => {
setMediaPicker(null);
setLocalContent(text);
localContentRef.current = text;
flushDebounce(text);
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
});
}}
onCancel={() => {
setMediaPicker(null);
requestAnimationFrame(() => textareaRef.current?.focus());
}}
/>
) : mediaPicker === 'audio' ? (
<AudioRecorderPicker
docId={docId}
onDone={(media) => {
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' ? (
<AudioBlock
content={localContent}
canEdit={canEdit}
onCaptionChange={(caption) => {
const ref = parseMediaContent(localContent);
if (!ref) return;
const content = mediaContent('audio', ref.id, caption);
setLocalContent(content);
localContentRef.current = content;
flushDebounce(content);
}}
/>
) : block.type === 'image' ? (
<ImageBlock
content={localContent}
canEdit={canEdit}
onCaptionChange={(caption) => {
const ref = parseMediaContent(localContent);
if (!ref) return;
const content = mediaContent('image', ref.id, caption);
setLocalContent(content);
localContentRef.current = content;
flushDebounce(content);
}}
/>
) : block.type === 'divider' ? (
<div className="py-2">
<hr className="border-t border-border" />
</div>
) : block.type === 'code' ? (
<CodeBlockEditor
block={block}
content={localContent}
canEdit={canEdit}
textareaRef={textareaRef}
registerTextareaRef={registerTextareaRef}
onChangeContent={(newContent) => {
setLocalContent(newContent);
localContentRef.current = newContent;
flushDebounce(newContent);
}}
onChangeLang={(lang) => flushDebounce(localContent, { lang })}
onConvertToText={() => flushDebounce(localContent, { type: 'bullet' })}
handleFocus={handleFocus}
handleBlur={handleBlur}
handleContentChange={handleContentChange}
handleKeyDown={handleKeyDown}
/>
) : block.type === 'callout' ? (
<div className="bg-panel-elevated border-l-2 border-accent p-2 rounded-r-btn my-0.5">
<textarea
ref={(el) => {
textareaRef.current = el;
registerTextareaRef(block.id, el);
}}
rows={1}
value={localContent}
readOnly={!canEdit}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
className="w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic"
/>
</div>
) : (
<div className="relative w-full min-h-[1.5rem]">
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
<div
onClick={(e) => {
if (!activeFocus && canFocus && textareaRef.current) {
// Keep the row's click handler from moving the caret back to the end
e.stopPropagation();
focusAtPoint(e.clientX, e.clientY);
}
}}
className={`w-full min-h-[1.5rem] leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
activeFocus ? 'absolute inset-0 pointer-events-none' : 'relative cursor-text'
} ${
block.type === 'h1'
? 'text-lg font-bold text-main tracking-tight'
: block.type === 'h2'
? 'text-base font-semibold text-main'
: block.type === 'h3'
? 'text-sm font-semibold text-main'
: block.type === 'todo' && block.checked
? 'text-sm text-faint line-through'
: 'text-sm text-main'
}`}
>
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
{localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}
</span>
{/* Inline Badge and 3-Dots directly behind the text */}
{block.type === 'todo' && (
<span className="inline-flex items-center align-middle ml-1.5 gap-1 shrink-0 pointer-events-auto z-10 relative select-none">
{block.taskId && (
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onOpenTask?.(block.taskId as string);
}}
className="badge-accent text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.2 rounded font-medium shrink-0 cursor-pointer hover:opacity-80"
title="Erweiterte Aufgabe öffnen"
>
<CheckSquare className="w-2.5 h-2.5" />
<span>Erweitert</span>
</button>
)}
{canEdit && (
<span className="relative inline-flex items-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowTodoMenu((prev) => !prev);
}}
className="p-0.5 text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
title="Aufgaben-Optionen"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
{showTodoMenu && (
<div
className="absolute left-0 top-full mt-1 bg-panel border border-border shadow-lg rounded-card p-1 z-30 w-52 text-xs"
onClick={(e) => e.stopPropagation()}
>
{!block.taskId ? (
<button
type="button"
onClick={async () => {
setShowTodoMenu(false);
try {
const targetDocId = docId || block.documentId || block.docId || '';
const thought = await api.createEnhancedTask(targetDocId, block.id, {
content: localContent || 'Aufgabe',
checked: Boolean(block.checked),
});
const updatedBlock: DocumentBlock = {
...latestBlockRef.current,
content: localContent,
taskId: thought.id,
type: 'todo',
};
onUpdateBlockRef.current(updatedBlock, true);
onOpenTask?.(thought.id);
} catch (err: any) {
alert(err.message || 'Fehler beim Erstellen der erweiterten Aufgabe');
}
}}
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-accent flex items-center gap-1.5 text-main font-medium cursor-pointer"
>
<CheckSquare className="w-3.5 h-3.5 text-accent" />
<span>In erweiterte Aufgabe umwandeln</span>
</button>
) : (
<button
type="button"
onClick={async () => {
setShowTodoMenu(false);
try {
if (block.taskId) {
await api.unlinkEnhancedTask(block.taskId).catch(() => {});
}
const updatedBlock: DocumentBlock = {
...latestBlockRef.current,
content: localContent,
taskId: '',
type: 'todo',
};
onUpdateBlockRef.current(updatedBlock, true);
} catch (err: any) {
alert(err.message || 'Fehler beim Aufheben der Verknüpfung');
}
}}
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-status-warn flex items-center gap-1.5 text-main font-medium cursor-pointer"
>
<X className="w-3.5 h-3.5 text-status-warn" />
<span>Verknüpfung aufheben</span>
</button>
)}
</div>
)}
</span>
)}
</span>
)}
</div>
<textarea
ref={(el) => {
textareaRef.current = el;
registerTextareaRef(block.id, el);
}}
rows={1}
value={localContent}
readOnly={!canEdit}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
className={`w-full min-h-[1.5rem] resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
activeFocus ? 'relative z-0 text-main' : 'absolute inset-0 text-transparent selection:text-transparent pointer-events-none'
} ${
block.type === 'h1'
? 'text-lg font-bold text-main tracking-tight'
: block.type === 'h2'
? 'text-base font-semibold text-main'
: block.type === 'h3'
? 'text-sm font-semibold text-main'
: block.type === 'todo' && block.checked
? 'text-sm text-faint line-through'
: 'text-sm text-main'
}`}
/>
</div>
)}
{/* Conflict copy: text that lost against someone else's change */}
{block.conflictOf && (
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px] text-status-warn select-none">
<AlertTriangle className="w-3 h-3 shrink-0" />
<span>
{block.conflictOf === 'title' ? 'Konflikt beim Titel' : 'Konflikt'}
{block.conflictAuthor ? ` – Version von ${block.conflictAuthor}` : ''}
</span>
{onResolveConflict && (
<>
<button
type="button"
onClick={() => onResolveConflict(block.id, true)}
className="px-1.5 py-0.5 rounded-btn border border-status-warn/40 hover:bg-status-warn/15 cursor-pointer"
title={block.conflictOf === 'title' ? 'Diesen Text als Titel übernehmen' : 'Diesen Text in den ursprünglichen Punkt übernehmen'}
>
Übernehmen
</button>
<button
type="button"
onClick={() => onResolveConflict(block.id, false)}
className="px-1.5 py-0.5 rounded-btn border border-border text-muted hover:text-main hover:bg-panel cursor-pointer"
title="Diese Version verwerfen"
>
Verwerfen
</button>
</>
)}
</div>
)}
{/* Rich Tokens Bar (Wiki-Links & #Tags) if present */}
{hasSpecialTokens && !isFocused && (
<div className="flex flex-wrap items-center gap-1 mt-0.5">
{tokens.map((part, idx) => {
if (part.startsWith('[[') && part.endsWith(']]')) {
const page = part.slice(2, -2).trim();
return (
<button
key={idx}
type="button"
onClick={() => onWikiLinkClick?.(page)}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-info bg-status-info/10 hover:bg-status-info/20 rounded-btn transition-colors"
title={`Notiz öffnen: ${page}`}
>
<FileText className="w-2.5 h-2.5 opacity-80" />
<span>{page}</span>
</button>
);
}
const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/);
if (mdMatch && safeHref(mdMatch[2])) {
const label = mdMatch[1];
const url = safeHref(mdMatch[2]) as string;
return (
<a
key={idx}
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
title={url}
>
<ExternalLink className="w-2.5 h-2.5 opacity-80" />
<span>{label || url}</span>
</a>
);
}
// The preview card above already shows this address
if (/^https?:\/\//.test(part) && safeHref(part) && part !== previewUrl) {
return (
<a
key={idx}
href={safeHref(part) as string}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
title={part}
>
<ExternalLink className="w-2.5 h-2.5 opacity-80" />
<span className="truncate max-w-[160px]">{part}</span>
</a>
);
}
return null;
})}
</div>
)}
{/* Floating Slash Commands Menu */}
{slashSearch !== null && canEdit && (
<div className="absolute left-0 top-8 z-40 w-72 max-w-[calc(100vw-2rem)] panel-surface shadow-panel border border-border rounded-card p-1">
<div className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-faint border-b border-border mb-1">
Befehl auswählen
</div>
<div className="max-h-48 overflow-y-auto space-y-0.5">
{filteredSlashCommands.map((cmd, i) => (
<button
key={cmd.type}
ref={(el) => {
slashItemRefs.current[i] = el;
}}
type="button"
onMouseDown={(e) => {
e.preventDefault();
applySlashCommand(cmd);
}}
className={`w-full text-left px-2 py-1 rounded-btn text-xs flex items-center justify-between transition-colors ${
i === slashIndex
? 'bg-accent text-on-accent'
: 'text-main hover:bg-panel-elevated'
}`}
>
<div className="flex items-center gap-1.5 shrink-0">
{cmd.icon}
<span className="font-semibold whitespace-nowrap">{cmd.label}</span>
</div>
<span className="ml-3 min-w-0 truncate text-right text-[10px] opacity-75">{cmd.desc}</span>
</button>
))}
{filteredSlashCommands.length === 0 && (
<div className="p-2 text-center text-xs text-faint">
Kein Befehl gefunden
</div>
)}
</div>
</div>
)}
</div>
{/* Branch into its own note (desktop; phones use /extract) */}
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
<button
type="button"
onClick={() => onExtractBlock(block.id)}
className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
title="In eigene Notiz umwandeln (mit allen Unterpunkten)"
>
<FileOutput className="w-3.5 h-3.5" />
</button>
)}
{/* Zoom / Focus button on hover */}
<button
type="button"
onClick={() => onZoomBlock?.(block.id)}
// Phones zoom by tapping the bullet; the button would only take width from the text there
className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
title="In diesen Ast hineinzoomen (Fokus-Modus)"
>
<Maximize2 className="w-3.5 h-3.5" />
</button>
{/* Someone else is editing this block */}
{lockedBy && (
<span
className="shrink-0 self-start mt-1 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-btn text-[10px] text-muted bg-panel border border-border select-none"
title={`${lockedBy} bearbeitet diesen Punkt gerade`}
>
<Lock className="w-2.5 h-2.5" />
{lockedBy}
</span>
)}
{/* Delete button on hover (Trash2 icon); on phones only in the row being edited */}
{canEdit && (
<button
type="button"
onClick={() => {
if (hasChildren) {
if (window.confirm('Bist du sicher? Alle Unterpunkte dieses Asts werden ebenfalls gelöscht.')) {
onDeleteBlockRef.current(block.id, true);
}
} else {
onDeleteBlockRef.current(block.id, false);
}
}}
className={
activeFocus
? 'opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer'
: 'hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer'
}
title="Block löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{/* Link Insertion Modal */}
{showLinkModal && (
<LinkModal
isOpen={showLinkModal}
onClose={() => setShowLinkModal(false)}
onInsertLink={handleInsertLink}
/>
)}
</div>
);
};