MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m48s
safeHref lets only http, https and mailto become links (Markdown viewer, outliner, legal links); javascript: and friends show as text. A middleware sets a Content-Security-Policy (scripts only from MindSift, no framing, connections only to this server), X-Frame-Options, nosniff, Referrer-Policy and a Permissions-Policy. The two inline scripts of index.html moved to files so script-src stays 'self'. The second inline script also deleted every cache on each page load, which undermined the offline start; it is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1132 lines
43 KiB
TypeScript
1132 lines
43 KiB
TypeScript
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,
|
||
} from 'lucide-react';
|
||
import { DocumentBlock, BlockType } from '../../lib/types';
|
||
import { LinkModal } from './LinkModal';
|
||
import { renderInlineMarkdown } from '../../lib/markdown';
|
||
import { api } from '../../lib/api';
|
||
import { safeHref } from '../../lib/safeUrl';
|
||
|
||
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: <ListTodo className="w-3.5 h-3.5" /> },
|
||
{ type: 'link', label: '/link', desc: 'Notiz verknüpfen oder Web-Link einfügen', 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" /> },
|
||
];
|
||
|
||
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;
|
||
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,
|
||
isLast,
|
||
hasChildren,
|
||
isFocused,
|
||
canEdit,
|
||
levelOffset = 0,
|
||
onZoomBlock,
|
||
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);
|
||
const [slashIndex, setSlashIndex] = useState(0);
|
||
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]);
|
||
|
||
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();
|
||
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<HTMLTextAreaElement>) => {
|
||
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;
|
||
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 && 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:
|
||
// - If level > minLevel, outdent (Shift+Tab behaviour)
|
||
// - If already at minLevel and is the last block, do NOT create another empty block below
|
||
// - 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 minLevel = levelOffset || 0;
|
||
if (localContent === '') {
|
||
if (Math.max(0, block.level || 0) > minLevel) {
|
||
flushDebounce(localContent);
|
||
onIndentBlockRef.current(block.id, -1);
|
||
return;
|
||
}
|
||
if (isLast) {
|
||
// Bereits auf oberster Ebene und der letzte Block: kein weiterer leerer Block
|
||
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)
|
||
);
|
||
|
||
return (
|
||
<div
|
||
className={`group relative flex items-start gap-1 py-1 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={{
|
||
paddingLeft: `${visualLevel * 22 + 4}px`,
|
||
}}
|
||
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]">
|
||
{block.type === 'divider' ? (
|
||
<div className="py-2">
|
||
<hr className="border-t border-border" />
|
||
</div>
|
||
) : block.type === 'code' ? (
|
||
<div className="bg-code border border-border rounded-btn p-2 my-0.5">
|
||
<div className="text-[10px] text-faint font-mono uppercase pb-1 flex justify-between">
|
||
<span>Code</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => flushDebounce(localContent, { type: 'bullet' })}
|
||
className="hover:text-accent"
|
||
>
|
||
Als Text
|
||
</button>
|
||
</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}
|
||
className="w-full resize-none font-mono text-xs bg-transparent text-status-ok border-0 focus:outline-none focus:ring-0 leading-relaxed"
|
||
/>
|
||
</div>
|
||
) : 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>
|
||
);
|
||
}
|
||
if (/^https?:\/\//.test(part) && safeHref(part)) {
|
||
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-56 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}
|
||
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">
|
||
{cmd.icon}
|
||
<span className="font-semibold">{cmd.label}</span>
|
||
</div>
|
||
<span className="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>
|
||
|
||
{/* Zoom / Focus button on hover */}
|
||
<button
|
||
type="button"
|
||
onClick={() => onZoomBlock?.(block.id)}
|
||
className="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) */}
|
||
{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="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>
|
||
);
|
||
};
|