Files
MindSift/frontend/src/features/workspace/OutlinerBlock.tsx
T
sandrodachandClaude Opus 5.5 4c50d15915 chore(frontend): real ESLint instead of a placeholder; fix hook order (FLAW-19)
eslint.config.js with the recommended JS/TypeScript rules and the React
hooks rules; npm run lint runs ESLint and is part of the image build.
Errors fail the build, the 24 exhaustive-deps findings stay warnings.

It found two real bugs: TaskDetailModal and ManageColumnsModal called
their hooks after 'if (!isOpen) return null', so the hook count changed
whenever the dialog opened or closed. Both are now a thin wrapper plus a
content component. Also removes unused imports and dead code (old task
list share handlers, an unused realtime status, an unused rollup).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:02:52 +01:00

1131 lines
43 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,
} from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { LinkModal } from './LinkModal';
import { renderInlineMarkdown } from '../../lib/markdown';
import { api } from '../../lib/api';
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) {
const label = mdMatch[1];
const url = mdMatch[2];
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)) {
return (
<a
key={idx}
href={part}
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>
);
};