MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
737 lines
26 KiB
TypeScript
737 lines
26 KiB
TypeScript
import React, { useState, useEffect, useRef, useLayoutEffect, useCallback, useMemo } from 'react';
|
|
import {
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Check,
|
|
X,
|
|
Code,
|
|
Heading1,
|
|
Heading2,
|
|
Heading3,
|
|
ListTodo,
|
|
Minus,
|
|
MessageSquare,
|
|
Hash,
|
|
ExternalLink,
|
|
Maximize2,
|
|
Link as LinkIcon,
|
|
FileText,
|
|
} from 'lucide-react';
|
|
import { DocumentBlock, BlockType } from '../../lib/types';
|
|
import { LinkModal } from './LinkModal';
|
|
|
|
export interface SlashCommand {
|
|
type: BlockType | 'link';
|
|
label: string;
|
|
desc: string;
|
|
icon: React.ReactNode;
|
|
}
|
|
|
|
const SLASH_COMMANDS: SlashCommand[] = [
|
|
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <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 {
|
|
block: DocumentBlock;
|
|
index: number;
|
|
maxAllowedLevel: number;
|
|
hasChildren: boolean;
|
|
isFocused: boolean;
|
|
canEdit: boolean;
|
|
levelOffset?: number;
|
|
onZoomBlock?: (blockId: string) => void;
|
|
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
|
onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
|
|
onDeleteBlock: (blockId: string) => void;
|
|
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
|
|
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
|
|
onTagClick?: (tag: string) => void;
|
|
onWikiLinkClick?: (pageTitle: string) => void;
|
|
onDragStartBlock?: (index: number) => void;
|
|
onDragOverBlock?: (e: React.DragEvent, index: number) => void;
|
|
onDropBlock?: (index: number) => void;
|
|
isDragTarget?: boolean;
|
|
isTargeted?: boolean;
|
|
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
|
}
|
|
|
|
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|
block,
|
|
index,
|
|
maxAllowedLevel,
|
|
hasChildren,
|
|
isFocused,
|
|
canEdit,
|
|
levelOffset = 0,
|
|
onZoomBlock,
|
|
onUpdateBlock,
|
|
onCreateBlockBelow,
|
|
onDeleteBlock,
|
|
onIndentBlock,
|
|
onNavigateFocus,
|
|
onTagClick,
|
|
onWikiLinkClick,
|
|
onDragStartBlock,
|
|
onDragOverBlock,
|
|
onDropBlock,
|
|
isDragTarget,
|
|
isTargeted,
|
|
registerTextareaRef,
|
|
}) => {
|
|
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
|
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
|
const [slashIndex, setSlashIndex] = useState(0);
|
|
const [showLinkModal, setShowLinkModal] = 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 onDeleteBlockRef = useRef(onDeleteBlock);
|
|
onDeleteBlockRef.current = onDeleteBlock;
|
|
const onIndentBlockRef = useRef(onIndentBlock);
|
|
onIndentBlockRef.current = onIndentBlock;
|
|
const onNavigateFocusRef = useRef(onNavigateFocus);
|
|
onNavigateFocusRef.current = onNavigateFocus;
|
|
|
|
// Track if this input currently has actual focus in an active browser window
|
|
const isFocusedRef = useRef<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);
|
|
},
|
|
[]
|
|
);
|
|
|
|
// Sync incoming remote changes ONLY when this textarea is NOT actively being edited
|
|
// Condition: isFocusedRef.current === true && document.hasFocus()
|
|
useEffect(() => {
|
|
const isActivelyEditing =
|
|
isFocusedRef.current === true &&
|
|
typeof document !== 'undefined' &&
|
|
document.hasFocus() &&
|
|
textareaRef.current !== null &&
|
|
document.activeElement === textareaRef.current;
|
|
|
|
if (!isActivelyEditing && block.content !== localContentRef.current) {
|
|
setLocalContent(block.content || '');
|
|
localContentRef.current = block.content || '';
|
|
}
|
|
}, [block.content]);
|
|
|
|
// Handle window blur / focus to instantly flush debounced input and clear zombie focus
|
|
useEffect(() => {
|
|
const handleWindowBlur = () => {
|
|
if (debounceTimerRef.current !== null) {
|
|
window.clearTimeout(debounceTimerRef.current);
|
|
debounceTimerRef.current = null;
|
|
flushDebounce(localContentRef.current);
|
|
} else if (localContentRef.current !== latestBlockRef.current.content) {
|
|
flushDebounce(localContentRef.current);
|
|
}
|
|
isFocusedRef.current = false;
|
|
};
|
|
|
|
const handleWindowFocus = () => {
|
|
const isThisActive =
|
|
textareaRef.current !== null &&
|
|
document.activeElement === textareaRef.current &&
|
|
document.hasFocus();
|
|
isFocusedRef.current = isThisActive;
|
|
};
|
|
|
|
window.addEventListener('blur', handleWindowBlur);
|
|
window.addEventListener('focus', handleWindowFocus);
|
|
return () => {
|
|
window.removeEventListener('blur', handleWindowBlur);
|
|
window.removeEventListener('focus', handleWindowFocus);
|
|
};
|
|
}, [flushDebounce]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceTimerRef.current !== null) {
|
|
window.clearTimeout(debounceTimerRef.current);
|
|
debounceTimerRef.current = null;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const adjustHeight = useCallback(() => {
|
|
const el = textareaRef.current;
|
|
if (!el) return;
|
|
el.style.height = 'auto';
|
|
el.style.height = `${Math.max(22, el.scrollHeight)}px`;
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
adjustHeight();
|
|
}, [localContent, block.type, block.level, adjustHeight]);
|
|
|
|
useEffect(() => {
|
|
window.addEventListener('resize', adjustHeight);
|
|
return () => window.removeEventListener('resize', adjustHeight);
|
|
}, [adjustHeight]);
|
|
|
|
const filteredSlashCommands = useMemo(() => {
|
|
if (slashSearch === null) return [];
|
|
const q = slashSearch.toLowerCase();
|
|
return SLASH_COMMANDS.filter(
|
|
(c) => c.label.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q)
|
|
);
|
|
}, [slashSearch]);
|
|
|
|
const applySlashCommand = (cmd: SlashCommand) => {
|
|
setSlashSearch(null);
|
|
const clean = localContent.replace(/\/([a-z0-9]*)$/i, '').trim();
|
|
if (cmd.type === 'link') {
|
|
setLocalContent(clean);
|
|
setShowLinkModal(true);
|
|
return;
|
|
}
|
|
setLocalContent(clean);
|
|
flushDebounce(clean, {
|
|
type: cmd.type,
|
|
checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
|
|
});
|
|
};
|
|
|
|
const handleInsertLink = (linkMarkdown: string) => {
|
|
setShowLinkModal(false);
|
|
const cursor = textareaRef.current?.selectionStart ?? localContent.length;
|
|
const before = localContent.slice(0, cursor);
|
|
const after = localContent.slice(cursor);
|
|
const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim();
|
|
setLocalContent(newContent);
|
|
flushDebounce(newContent);
|
|
};
|
|
|
|
const handleContentChange = (e: React.ChangeEvent<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;
|
|
};
|
|
|
|
const handleBlur = () => {
|
|
isFocusedRef.current = false;
|
|
if (debounceTimerRef.current !== null) {
|
|
window.clearTimeout(debounceTimerRef.current);
|
|
debounceTimerRef.current = null;
|
|
flushDebounce(localContentRef.current);
|
|
} else if (localContentRef.current !== latestBlockRef.current.content) {
|
|
flushDebounce(localContentRef.current);
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (!canEdit) return;
|
|
|
|
// Slash command navigation
|
|
if (slashSearch !== null && filteredSlashCommands.length > 0) {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length);
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setSlashIndex((prev) => (prev - 1 + filteredSlashCommands.length) % filteredSlashCommands.length);
|
|
return;
|
|
}
|
|
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
e.preventDefault();
|
|
const selected = filteredSlashCommands[slashIndex];
|
|
if (selected) {
|
|
applySlashCommand(selected);
|
|
}
|
|
return;
|
|
}
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
setSlashSearch(null);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 1. Tab / Shift+Tab -> Strict Indent / Outdent
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
flushDebounce(localContent);
|
|
onIndentBlockRef.current(block.id, e.shiftKey ? -1 : 1);
|
|
return;
|
|
}
|
|
|
|
// 2. Enter (without Shift) -> If block is completely empty and level > minLevel, outdent (Shift+Tab behaviour), otherwise create new block below
|
|
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setSlashSearch(null);
|
|
|
|
const minLevel = levelOffset || 0;
|
|
if (localContent === '' && Math.max(0, block.level || 0) > minLevel) {
|
|
flushDebounce(localContent);
|
|
onIndentBlockRef.current(block.id, -1);
|
|
return;
|
|
}
|
|
|
|
flushDebounce(localContent);
|
|
onCreateBlockBelowRef.current(block.id, localContent);
|
|
return;
|
|
}
|
|
|
|
// 3. Backspace on empty block -> IMMEDIATELY DELETE THE BLOCK (regardless of level)!
|
|
// Outdenting is strictly done via Shift+Tab.
|
|
if (e.key === 'Backspace' && localContent === '') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setSlashSearch(null);
|
|
if (debounceTimerRef.current !== null) {
|
|
window.clearTimeout(debounceTimerRef.current);
|
|
debounceTimerRef.current = null;
|
|
}
|
|
onDeleteBlockRef.current(block.id);
|
|
return;
|
|
}
|
|
|
|
// 4. Arrow navigation between blocks
|
|
if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) {
|
|
const el = textareaRef.current;
|
|
if (el && el.selectionStart === 0 && el.selectionEnd === 0) {
|
|
e.preventDefault();
|
|
flushDebounce(localContent);
|
|
onNavigateFocusRef.current(block.id, 'up');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'ArrowDown' && !e.shiftKey && !e.altKey) {
|
|
const el = textareaRef.current;
|
|
if (el && el.selectionStart === localContent.length && el.selectionEnd === localContent.length) {
|
|
e.preventDefault();
|
|
flushDebounce(localContent);
|
|
onNavigateFocusRef.current(block.id, 'down');
|
|
}
|
|
return;
|
|
}
|
|
};
|
|
|
|
const level = Math.max(0, block.level || 0);
|
|
const visualLevel = Math.max(0, level - (levelOffset || 0));
|
|
|
|
// Parse rich tokens (WikiLinks [[Page]], Markdown Links [Title](url), raw URLs, and #Tags)
|
|
const tokens = useMemo(() => {
|
|
const parts = (localContent || '').split(
|
|
/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/.*?\)|\bhttps?:\/\/[^\s]+|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g
|
|
);
|
|
return parts.filter(Boolean);
|
|
}, [localContent]);
|
|
|
|
const hasSpecialTokens = tokens.some(
|
|
(t) =>
|
|
t.startsWith('#') ||
|
|
(t.startsWith('[[') && t.endsWith(']]')) ||
|
|
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
|
|
/^https?:\/\//.test(t)
|
|
);
|
|
|
|
return (
|
|
<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-accent/15 ring-2 ring-accent'
|
|
: isFocused
|
|
? 'bg-panel-elevated'
|
|
: 'hover:bg-panel'
|
|
}`}
|
|
style={{
|
|
paddingLeft: `${visualLevel * 22 + 4}px`,
|
|
}}
|
|
onDragOver={(e) => onDragOverBlock?.(e, index)}
|
|
onDrop={() => onDropBlock?.(index)}
|
|
>
|
|
{/* Indentation Guide Line */}
|
|
{visualLevel > 0 &&
|
|
Array.from({ length: visualLevel }).map((_, lvlIdx) => (
|
|
<div
|
|
key={lvlIdx}
|
|
className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-border"
|
|
style={{ left: `${lvlIdx * 22 + 12}px` }}
|
|
/>
|
|
))}
|
|
|
|
{/* 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-colors"
|
|
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" />
|
|
)}
|
|
|
|
{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>
|
|
) : (
|
|
<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 hover:bg-panel-hover 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 ${
|
|
block.collapsed && hasChildren
|
|
? 'bg-accent ring-2 ring-accent/30'
|
|
: isFocused
|
|
? 'bg-accent scale-125'
|
|
: 'bg-faint group-hover/bullet:bg-accent group-hover/bullet:scale-125'
|
|
}`}
|
|
/>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Main Block Content */}
|
|
<div className="flex-1 min-w-0 pr-1 relative">
|
|
{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">
|
|
<textarea
|
|
ref={(el) => {
|
|
textareaRef.current = el;
|
|
registerTextareaRef(block.id, el);
|
|
}}
|
|
rows={1}
|
|
value={localContent}
|
|
readOnly={!canEdit}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
onChange={handleContentChange}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={
|
|
index === 0 && level === 0
|
|
? 'Schreibe einen Punkt… (Enter = Neu, Tab = Einrücken, Shift+Tab = Ausrücken, / für Befehle)'
|
|
: ''
|
|
}
|
|
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${
|
|
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>
|
|
)}
|
|
|
|
{/* 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.length > 1) {
|
|
const tag = part.slice(1);
|
|
return (
|
|
<button
|
|
key={idx}
|
|
type="button"
|
|
onClick={() => onTagClick?.(tag)}
|
|
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-accent bg-accent-dim hover:bg-accent/20 rounded-btn transition-colors"
|
|
>
|
|
<Hash className="w-2.5 h-2.5 opacity-80" />
|
|
<span>{tag}</span>
|
|
</button>
|
|
);
|
|
}
|
|
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>
|
|
|
|
{/* Delete button on hover */}
|
|
{canEdit && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onDeleteBlockRef.current(block.id)}
|
|
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"
|
|
title="Block löschen"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Link Insertion Modal */}
|
|
{showLinkModal && (
|
|
<LinkModal
|
|
isOpen={showLinkModal}
|
|
onClose={() => setShowLinkModal(false)}
|
|
onInsertLink={handleInsertLink}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|