feat(workspace): polish outliner slash commands, typography and actions overlay

This commit is contained in:
sandrodach committed 2026-10-30 19:26:52 +01:00
1 parent ad4829796c
commit fd72500d17
3 files changed
+559 -196

No files matched your search

+11
View File
@@ -6,7 +6,18 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [Unreleased]
### Added
- Outliner: Neuer Slash-Befehl `/text` (sowie Aliase `/p`, `/paragraph`, `/bullet`, `/standard`), um beliebige Blöcke (Überschriften, Callouts, Aufgaben, Trennlinien) in Standard-Text umzuwandeln; Aufgaben-Optionen und Block-Aktionsblase bieten ebenfalls die Rückumwandlung an.
### Changed
- Outliner: Schriftgrößen für H1 (`text-xl sm:text-2xl font-bold`), H2 (`text-lg sm:text-xl font-semibold`) und H3 (`text-base font-semibold`) deutlich angehoben bei synchroner Textarea und dezentem oberen Abstand.
- Outliner: Aktions-Buttons (Zoom, Auslagern, Löschen, Text-Umwandlung) als schwebende, kontrastreiche Blase (`backdrop-blur-sm`) positioniert, wodurch der Fließtext beim Einblenden nicht mehr ruckartig umbricht.
- Outliner: Aktiver Eintrag im Slash-Befehlsmenü dezent als `bg-accent-dim text-accent` hervorgehoben statt vollflächig deckend.
### Fixed ### Fixed
- Outliner: Slash-Befehle werden an der tatsächlichen Cursorposition im Text erkannt (`/link`, `/diktat`); `/divider`, `/image`, `/audio` und `/code` werden nur noch in leeren Blöcken angeboten; das Menü schließt bei Klick ins Leere und Blur.
- Outliner: Aufgaben-Badges und Drei-Punkte-Menü aus dem Fließtext-Container entkoppelt, um störende Zeilenumbrüche am Zeilenende zu beheben.
- Outliner: Trennlinien (`divider`) können fokussiert und per `Backspace` oder `Entf` direkt in einen leeren Textpunkt umgewandelt werden.
- Kontoeinstellungen: Der Bereich „Profil & Anmeldedaten“ nutzt die volle Breite wie die übrigen Bereiche. - Kontoeinstellungen: Der Bereich „Profil & Anmeldedaten“ nutzt die volle Breite wie die übrigen Bereiche.
- Light-Theme: Notizen stehen auf einem weißen Blatt, die Ordnerleiste ist dezent grau abgesetzt (bisher umgekehrt). Themes können das über die neuen Variablen `--bg-editor` und `--bg-sidebar` selbst festlegen. - Light-Theme: Notizen stehen auf einem weißen Blatt, die Ordnerleiste ist dezent grau abgesetzt (bisher umgekehrt). Themes können das über die neuen Variablen `--bg-editor` und `--bg-sidebar` selbst festlegen.
+443 -194
View File
@@ -24,6 +24,7 @@ import {
Mic, Mic,
AudioLines, AudioLines,
FileOutput, FileOutput,
Type,
} from 'lucide-react'; } from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types'; import { DocumentBlock, BlockType } from '../../lib/types';
import { LinkModal } from './LinkModal'; import { LinkModal } from './LinkModal';
@@ -55,6 +56,7 @@ export interface SlashCommand {
const SLASH_COMMANDS: SlashCommand[] = [ const SLASH_COMMANDS: SlashCommand[] = [
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> }, { type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
{ type: 'link', label: '/link', desc: 'Notiz oder Weblink', icon: <LinkIcon className="w-3.5 h-3.5" /> }, { type: 'link', label: '/link', desc: 'Notiz oder Weblink', icon: <LinkIcon className="w-3.5 h-3.5" /> },
{ type: 'bullet', label: '/text', desc: 'In Standardtext umwandeln', icon: <Type className="w-3.5 h-3.5" />, aliases: ['p', 'paragraph', 'bullet', 'standard'] },
{ type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: <Heading1 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: '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: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: <Heading3 className="w-3.5 h-3.5" /> },
@@ -156,7 +158,15 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const activeFocus = isFocused || isLocallyFocused; 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 // 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 canFocus = canEdit || (!!lockedBy && !!canAppendBelow);
const [slashSearch, setSlashSearch] = useState<string | null>(null); const [slashMatchInfo, setSlashMatchInfo] = useState<{ query: string; slashStart: number; cursor: number } | null>(null);
const slashSearch = slashMatchInfo ? slashMatchInfo.query : null;
const linkBeforeRef = useRef<string>('');
const linkAfterRef = useRef<string>('');
const dictationBeforeRef = useRef<string>('');
const dictationAfterRef = useRef<string>('');
const slashMenuRef = useRef<HTMLDivElement | null>(null);
const todoMenuRef = useRef<HTMLDivElement | null>(null);
const blurSlashTimerRef = useRef<number | null>(null);
// Inline picker an empty bullet turns into after /image (local only, nothing synced until done) // Inline picker an empty bullet turns into after /image (local only, nothing synced until done)
const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null); const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null);
// Card above bullets whose entire content is a plain https address (lib/linkPreview.ts) // Card above bullets whose entire content is a plain https address (lib/linkPreview.ts)
@@ -272,9 +282,48 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
window.clearTimeout(debounceTimerRef.current); window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null; debounceTimerRef.current = null;
} }
if (blurSlashTimerRef.current !== null) {
window.clearTimeout(blurSlashTimerRef.current);
blurSlashTimerRef.current = null;
}
}; };
}, []); }, []);
// Close slash command menu on click outside
useEffect(() => {
if (!slashMatchInfo) return;
const handleOutsideClick = (e: MouseEvent) => {
const target = e.target as Node | null;
if (
slashMenuRef.current &&
!slashMenuRef.current.contains(target) &&
textareaRef.current &&
!textareaRef.current.contains(target)
) {
setSlashMatchInfo(null);
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
}, [slashMatchInfo]);
// Close todo menu on click outside
useEffect(() => {
if (!showTodoMenu) return;
const handleOutsideClick = (e: MouseEvent) => {
const target = e.target as Node | null;
if (todoMenuRef.current && !todoMenuRef.current.contains(target)) {
setShowTodoMenu(false);
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
}, [showTodoMenu]);
const renderedTextRef = useRef<HTMLSpanElement | null>(null); const renderedTextRef = useRef<HTMLSpanElement | null>(null);
// Focus the textarea and place the caret where the user clicked in the rendered (markdown) text layer // Focus the textarea and place the caret where the user clicked in the rendered (markdown) text layer
@@ -358,22 +407,54 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}, [adjustHeight, block.type]); }, [adjustHeight, block.type]);
const filteredSlashCommands = useMemo(() => { const filteredSlashCommands = useMemo(() => {
if (slashSearch === null) return []; if (!slashMatchInfo) return [];
const q = slashSearch.toLowerCase(); const q = slashMatchInfo.query.toLowerCase();
const cmdQuery = q.startsWith('code ') ? 'code' : q; const cmdQuery = q.startsWith('code ') ? 'code' : q;
// Media only in an otherwise empty bullet
const emptyBullet = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === ''; const textBeforeSlash = localContent.slice(0, slashMatchInfo.slashStart);
return SLASH_COMMANDS.filter( const textAfterCursor = localContent.slice(slashMatchInfo.cursor);
(c) => const restOfBlock = (textBeforeSlash + textAfterCursor).trim();
(!c.media || emptyBullet) && const isRestEmpty = restOfBlock === '';
(c.type !== 'dictation' || ['server', 'browser'].includes(currentVoiceConfig().mode)) && const isAtStartOrEnd = textBeforeSlash.trim() === '' || textAfterCursor.trim() === '';
// The bullet's text becomes the title of the new note
(c.type !== 'extract' || (!emptyBullet && !!onExtractBlock)) && return SLASH_COMMANDS.filter((c) => {
(c.label.toLowerCase().includes(cmdQuery) || const matchesQuery =
c.desc.toLowerCase().includes(cmdQuery) || c.label.toLowerCase().includes(cmdQuery) ||
(c.aliases || []).some((a) => a.includes(cmdQuery))) c.desc.toLowerCase().includes(cmdQuery) ||
); (c.aliases || []).some((a) => a.includes(cmdQuery));
}, [slashSearch, localContent, onExtractBlock]); if (!matchesQuery) return false;
// /link and /dictation everywhere
if (c.type === 'link') return true;
if (c.type === 'dictation') {
return ['server', 'browser'].includes(currentVoiceConfig().mode);
}
// /divider, /image, /audio, /code only when rest of block is empty
if (c.type === 'divider' || c.type === 'image' || c.type === 'audio' || c.type === 'code') {
return isRestEmpty;
}
// /extract only when note has content and is at start/end
if (c.type === 'extract') {
return !isRestEmpty && isAtStartOrEnd && !!onExtractBlock;
}
// /h1, /h2, /h3, /todo, /callout, /text (type 'bullet') at start or at end
if (
c.type === 'h1' ||
c.type === 'h2' ||
c.type === 'h3' ||
c.type === 'todo' ||
c.type === 'callout' ||
c.type === 'bullet'
) {
return isAtStartOrEnd;
}
return true;
});
}, [slashMatchInfo, localContent, onExtractBlock]);
useEffect(() => { useEffect(() => {
if (slashSearch === null) return; if (slashSearch === null) return;
@@ -381,15 +462,37 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}, [slashIndex, slashSearch]); }, [slashIndex, slashSearch]);
const applySlashCommand = (cmd: SlashCommand) => { const applySlashCommand = (cmd: SlashCommand) => {
setSlashSearch(null); if (blurSlashTimerRef.current !== null) {
const codeLangMatch = localContent.match(/(^|\s)\/code\s+([a-z0-9_+#.-]+)$/i); window.clearTimeout(blurSlashTimerRef.current);
const clean = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim(); blurSlashTimerRef.current = null;
}
const slashStart = slashMatchInfo ? slashMatchInfo.slashStart : 0;
const slashEnd = slashMatchInfo ? slashMatchInfo.cursor : localContent.length;
setSlashMatchInfo(null);
const before = localContent.slice(0, slashStart);
const after = localContent.slice(slashEnd);
const clean = `${before}${after}`.replace(/\s{2,}/g, ' ').trim();
if (cmd.type === 'link') { if (cmd.type === 'link') {
linkBeforeRef.current = before;
linkAfterRef.current = after;
setLocalContent(clean); setLocalContent(clean);
localContentRef.current = clean; localContentRef.current = clean;
setShowLinkModal(true); setShowLinkModal(true);
return; return;
} }
if (cmd.type === 'dictation') {
dictationBeforeRef.current = before;
dictationAfterRef.current = after;
setLocalContent(clean);
localContentRef.current = clean;
flushDebounce(clean);
setMediaPicker('dictation');
return;
}
if (cmd.type === 'extract') { if (cmd.type === 'extract') {
setLocalContent(clean); setLocalContent(clean);
localContentRef.current = clean; localContentRef.current = clean;
@@ -397,31 +500,38 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onExtractBlock?.(block.id); onExtractBlock?.(block.id);
return; return;
} }
if (cmd.media) { if (cmd.media) {
// The typed "/image" must not stay in the synced text; the block type changes only after the upload // The typed "/image" must not stay in the synced text; the block type changes only after the upload
setLocalContent(''); setLocalContent('');
localContentRef.current = ''; localContentRef.current = '';
flushDebounce(''); flushDebounce('');
setMediaPicker(cmd.type as 'image' | 'audio' | 'dictation'); setMediaPicker(cmd.type as 'image' | 'audio');
return; return;
} }
const codeLangMatch = slashMatchInfo?.query.match(/^code\s+([a-z0-9_+#.-]+)$/i);
const langOverride = const langOverride =
cmd.type === 'code' && codeLangMatch cmd.type === 'code' && codeLangMatch
? resolveLanguageId(codeLangMatch[2]) || codeLangMatch[2].toLowerCase() ? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase()
: undefined; : undefined;
setLocalContent(clean); setLocalContent(clean);
localContentRef.current = clean; localContentRef.current = clean;
if (cmd.type !== block.type) refocusAfterTypeChangeRef.current = true; if (cmd.type !== block.type) refocusAfterTypeChangeRef.current = true;
flushDebounce(clean, { flushDebounce(clean, {
type: cmd.type as BlockType, // media commands returned above type: cmd.type as BlockType,
...(langOverride !== undefined ? { lang: langOverride } : {}), ...(langOverride !== undefined ? { lang: langOverride } : {}),
checked: cmd.type === 'todo' ? Boolean(block.checked) : false, checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
...(cmd.type === 'bullet' ? { taskId: '', checked: false } : {}),
}); });
}; };
const handleInsertLink = (linkMarkdown: string) => { const handleInsertLink = (linkMarkdown: string) => {
setShowLinkModal(false); setShowLinkModal(false);
if (localContent.trim() === '') { const b = linkBeforeRef.current ?? '';
const a = linkAfterRef.current ?? '';
if (b.trim() === '' && a.trim() === '') {
const webMatch = linkMarkdown.match(/^\[.*\]\((https?:\/\/[^\s)]+)\)$/); const webMatch = linkMarkdown.match(/^\[.*\]\((https?:\/\/[^\s)]+)\)$/);
if (webMatch) { if (webMatch) {
const bareUrl = webMatch[1]; const bareUrl = webMatch[1];
@@ -431,10 +541,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
return; return;
} }
} }
const cursor = textareaRef.current?.selectionStart ?? localContent.length; const spaceBefore = b && !b.endsWith(' ') ? ' ' : '';
const before = localContent.slice(0, cursor); const spaceAfter = a && !a.startsWith(' ') && a.trim() !== '' ? ' ' : '';
const after = localContent.slice(cursor); const newContent = `${b}${spaceBefore}${linkMarkdown}${spaceAfter}${a}`.trim();
const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim();
setLocalContent(newContent); setLocalContent(newContent);
localContentRef.current = newContent; localContentRef.current = newContent;
flushDebounce(newContent); flushDebounce(newContent);
@@ -516,18 +625,32 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
} else if (val.startsWith('### ') && block.type !== 'h3') { } else if (val.startsWith('### ') && block.type !== 'h3') {
val = val.slice(4); val = val.slice(4);
typeOverride = 'h3'; typeOverride = 'h3';
} else if (
(val.startsWith('/text ') ||
val.startsWith('/p ') ||
val.startsWith('/paragraph ') ||
val.startsWith('/bullet ') ||
val.startsWith('/standard ')) &&
block.type !== 'bullet'
) {
val = val.replace(/^\/(text|p|paragraph|bullet|standard)\s+/, '');
typeOverride = 'bullet';
} }
// Check for slash command invocation // Check for slash command invocation at cursor position
const slashMatch = !typeOverride ? val.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null; const sel = e.currentTarget.selectionStart;
const cursor = typeof sel === 'number' && sel > 0 ? sel : val.length;
const textBefore = val.slice(0, cursor);
const slashMatch = !typeOverride ? textBefore.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
if (slashMatch) { if (slashMatch) {
setSlashSearch(slashMatch[2]); const slashStart = slashMatch.index! + slashMatch[1].length;
setSlashMatchInfo({ query: slashMatch[2], slashStart, cursor });
setSlashIndex(0); setSlashIndex(0);
} else { } else {
setSlashSearch(null); setSlashMatchInfo(null);
} }
} else { } else {
setSlashSearch(null); setSlashMatchInfo(null);
} }
setLocalContent(val); setLocalContent(val);
@@ -543,6 +666,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
type: typeOverride, type: typeOverride,
...(langOverride !== undefined ? { lang: langOverride } : {}), ...(langOverride !== undefined ? { lang: langOverride } : {}),
checked: checkedOverride ?? latestBlockRef.current.checked, checked: checkedOverride ?? latestBlockRef.current.checked,
...(typeOverride === 'bullet' ? { taskId: '', checked: false } : {}),
}); });
return; return;
} }
@@ -557,6 +681,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}; };
const handleFocus = () => { const handleFocus = () => {
if (blurSlashTimerRef.current !== null) {
window.clearTimeout(blurSlashTimerRef.current);
blurSlashTimerRef.current = null;
}
isFocusedRef.current = true; isFocusedRef.current = true;
setIsLocallyFocused(true); setIsLocallyFocused(true);
onFocusBlock?.(block.id); onFocusBlock?.(block.id);
@@ -567,6 +695,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
isFocusedRef.current = false; isFocusedRef.current = false;
setIsLocallyFocused(false); setIsLocallyFocused(false);
onBlurBlock?.(block.id); onBlurBlock?.(block.id);
if (blurSlashTimerRef.current !== null) {
window.clearTimeout(blurSlashTimerRef.current);
}
blurSlashTimerRef.current = window.setTimeout(() => {
setSlashMatchInfo(null);
}, 150);
if (isPastingRef.current) { if (isPastingRef.current) {
isPastingRef.current = false; isPastingRef.current = false;
return; return;
@@ -749,7 +883,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
if (slashSearch !== null) { if (slashSearch !== null) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
setSlashSearch(null); setSlashMatchInfo(null);
return; return;
} }
if (filteredSlashCommands.length > 0) { if (filteredSlashCommands.length > 0) {
@@ -789,7 +923,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') { if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setSlashSearch(null); setSlashMatchInfo(null);
const fenceMatch = localContent.match(/^```([a-z0-9_+#.-]*)$/i); const fenceMatch = localContent.match(/^```([a-z0-9_+#.-]*)$/i);
if (fenceMatch) { if (fenceMatch) {
@@ -844,7 +978,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
if (e.key === 'Backspace' && localContent === '') { if (e.key === 'Backspace' && localContent === '') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setSlashSearch(null); setSlashMatchInfo(null);
if (debounceTimerRef.current !== null) { if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current); window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null; debounceTimerRef.current = null;
@@ -870,7 +1004,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
} }
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setSlashSearch(null); setSlashMatchInfo(null);
} }
return; return;
} }
@@ -920,6 +1054,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
return ( return (
<div <div
className={`group relative flex items-start gap-1 py-1 px-0.5 sm:px-1.5 rounded-card transition-all duration-300 ${ className={`group relative flex items-start gap-1 py-1 px-0.5 sm:px-1.5 rounded-card transition-all duration-300 ${
block.type === 'h1'
? 'mt-2.5 sm:mt-3'
: block.type === 'h2'
? 'mt-1.5 sm:mt-2'
: ''
} ${
isDragTarget isDragTarget
? 'border-t-2 border-accent bg-accent/10' ? 'border-t-2 border-accent bg-accent/10'
: isTargeted : isTargeted
@@ -1047,14 +1187,20 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
<DictationBlock <DictationBlock
onDone={(text) => { onDone={(text) => {
setMediaPicker(null); setMediaPicker(null);
setLocalContent(text); const b = dictationBeforeRef.current ?? '';
localContentRef.current = text; const a = dictationAfterRef.current ?? '';
flushDebounce(text); const spaceBefore = b && !b.endsWith(' ') ? ' ' : '';
const spaceAfter = a && !a.startsWith(' ') && a.trim() !== '' ? ' ' : '';
const newContent = `${b}${spaceBefore}${text}${spaceAfter}${a}`.trim();
setLocalContent(newContent);
localContentRef.current = newContent;
flushDebounce(newContent);
requestAnimationFrame(() => { requestAnimationFrame(() => {
const ta = textareaRef.current; const ta = textareaRef.current;
if (!ta) return; if (!ta) return;
ta.focus(); ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length); const pos = (b + spaceBefore + text).length;
ta.setSelectionRange(pos, pos);
}); });
}} }}
onCancel={() => { onCancel={() => {
@@ -1104,8 +1250,43 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}} }}
/> />
) : block.type === 'divider' ? ( ) : block.type === 'divider' ? (
<div className="py-2"> <div
<hr className="border-t border-border" /> className="relative py-2 group/divider cursor-pointer w-full"
onClick={() => {
textareaRef.current?.focus();
}}
>
<hr
className={`border-t transition-colors ${
activeFocus ? 'border-accent ring-1 ring-accent/30' : 'border-border'
}`}
/>
<textarea
ref={(el) => {
textareaRef.current = el;
registerTextareaRef(block.id, el);
}}
rows={1}
value=""
readOnly={!canEdit}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={() => {}}
onKeyDown={(e) => {
if (!canEdit) return;
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
refocusAfterTypeChangeRef.current = true;
flushDebounce('', { type: 'bullet' });
setLocalContent('');
localContentRef.current = '';
return;
}
handleKeyDown(e);
}}
className="sr-only"
aria-label="Trennlinie"
/>
</div> </div>
) : block.type === 'code' ? ( ) : block.type === 'code' ? (
<CodeBlockEditor <CodeBlockEditor
@@ -1145,7 +1326,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</div> </div>
) : ( ) : (
<div className="relative w-full min-h-[1.5rem]"> <div className="relative w-full min-h-[1.5rem]">
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */} {/* View Layer with Inline Markdown */}
<div <div
onClick={(e) => { onClick={(e) => {
if (!activeFocus && canFocus && textareaRef.current) { if (!activeFocus && canFocus && textareaRef.current) {
@@ -1158,11 +1339,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
activeFocus ? 'absolute inset-0 pointer-events-none' : 'relative cursor-text' activeFocus ? 'absolute inset-0 pointer-events-none' : 'relative cursor-text'
} ${ } ${
block.type === 'h1' block.type === 'h1'
? 'text-lg font-bold text-main tracking-tight' ? 'text-xl sm:text-2xl font-bold text-main tracking-tight'
: block.type === 'h2' : block.type === 'h2'
? 'text-base font-semibold text-main' ? 'text-lg sm:text-xl font-semibold text-main'
: block.type === 'h3' : block.type === 'h3'
? 'text-sm font-semibold text-main' ? 'text-base font-semibold text-main'
: block.type === 'todo' && block.checked : block.type === 'todo' && block.checked
? 'text-sm text-faint line-through' ? 'text-sm text-faint line-through'
: 'text-sm text-main' : 'text-sm text-main'
@@ -1172,104 +1353,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}> <span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
{localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'} {localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}
</span> </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> </div>
<textarea <textarea
@@ -1285,15 +1368,47 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onChange={handleContentChange} onChange={handleContentChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste} onPaste={handlePaste}
onClick={() => {
if (slashMatchInfo) {
const ta = textareaRef.current;
if (ta) {
const cursor = ta.selectionStart;
const textBefore = ta.value.slice(0, cursor);
const match = textBefore.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i);
if (match) {
const slashStart = match.index! + match[1].length;
setSlashMatchInfo({ query: match[2], slashStart, cursor });
} else {
setSlashMatchInfo(null);
}
}
}
}}
onKeyUp={(e) => {
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key) && slashMatchInfo) {
const ta = textareaRef.current;
if (ta) {
const cursor = ta.selectionStart;
const textBefore = ta.value.slice(0, cursor);
const match = textBefore.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i);
if (match) {
const slashStart = match.index! + match[1].length;
setSlashMatchInfo({ query: match[2], slashStart, cursor });
} else {
setSlashMatchInfo(null);
}
}
}
}}
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] ${ 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' activeFocus ? 'relative z-0 text-main' : 'absolute inset-0 text-transparent selection:text-transparent pointer-events-none'
} ${ } ${
block.type === 'h1' block.type === 'h1'
? 'text-lg font-bold text-main tracking-tight' ? 'text-xl sm:text-2xl font-bold text-main tracking-tight'
: block.type === 'h2' : block.type === 'h2'
? 'text-base font-semibold text-main' ? 'text-lg sm:text-xl font-semibold text-main'
: block.type === 'h3' : block.type === 'h3'
? 'text-sm font-semibold text-main' ? 'text-base font-semibold text-main'
: block.type === 'todo' && block.checked : block.type === 'todo' && block.checked
? 'text-sm text-faint line-through' ? 'text-sm text-faint line-through'
: 'text-sm text-main' : 'text-sm text-main'
@@ -1302,6 +1417,25 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</div> </div>
)} )}
{/* If To-Do is an enhanced task, render badge stably outside text layer */}
{block.type === 'todo' && block.taskId && (
<div className="flex items-center gap-1 mt-0.5 select-none">
<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.5 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>
</div>
)}
{/* Conflict copy: text that lost against someone else's change */} {/* Conflict copy: text that lost against someone else's change */}
{block.conflictOf && ( {block.conflictOf && (
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px] text-status-warn select-none"> <div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px] text-status-warn select-none">
@@ -1393,7 +1527,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{/* Floating Slash Commands Menu */} {/* Floating Slash Commands Menu */}
{slashSearch !== null && canEdit && ( {slashSearch !== null && canEdit && (
<div className="absolute left-0 top-8 z-40 w-72 max-w-[calc(100vw-2rem)] panel-surface shadow-panel border border-border rounded-card p-1"> <div
ref={slashMenuRef}
className="absolute left-0 top-8 z-40 w-72 max-w-[calc(100vw-2rem)] panel-surface shadow-panel border border-border rounded-card p-1"
>
<div className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-faint border-b border-border mb-1"> <div className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-faint border-b border-border mb-1">
Befehl auswählen Befehl auswählen
</div> </div>
@@ -1411,7 +1548,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}} }}
className={`w-full text-left px-2 py-1 rounded-btn text-xs flex items-center justify-between transition-colors ${ className={`w-full text-left px-2 py-1 rounded-btn text-xs flex items-center justify-between transition-colors ${
i === slashIndex i === slashIndex
? 'bg-accent text-on-accent' ? 'bg-accent-dim text-accent font-semibold'
: 'text-main hover:bg-panel-elevated' : 'text-main hover:bg-panel-elevated'
}`} }`}
> >
@@ -1432,29 +1569,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
)} )}
</div> </div>
{/* Branch into its own note (desktop; phones use /extract) */}
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
<button
type="button"
onClick={() => onExtractBlock(block.id)}
className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
title="In eigene Notiz umwandeln (mit allen Unterpunkten)"
>
<FileOutput className="w-3.5 h-3.5" />
</button>
)}
{/* Zoom / Focus button on hover */}
<button
type="button"
onClick={() => onZoomBlock?.(block.id)}
// Phones zoom by tapping the bullet; the button would only take width from the text there
className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
title="In diesen Ast hineinzoomen (Fokus-Modus)"
>
<Maximize2 className="w-3.5 h-3.5" />
</button>
{/* Someone else is editing this block */} {/* Someone else is editing this block */}
{lockedBy && ( {lockedBy && (
<span <span
@@ -1466,29 +1580,164 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</span> </span>
)} )}
{/* Delete button on hover (Trash2 icon); on phones only in the row being edited */} {/* Floating Actions Overlay Bubble (Zoom, Auslagern, Löschen, TODO-Menü, Text-Umwandlung) */}
{canEdit && ( <div
<button className={`absolute right-1 sm:right-2 top-0.5 sm:top-1 z-20 bg-panel/90 backdrop-blur-sm border border-border shadow-xs rounded-pill px-1 sm:px-1.5 py-0.5 flex items-center gap-0.5 sm:gap-1 transition-opacity ${
type="button" activeFocus
onClick={() => { ? 'opacity-100 pointer-events-auto'
if (hasChildren) { : 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-events-none group-hover:pointer-events-auto focus-within:pointer-events-auto'
if (window.confirm('Bist du sicher? Alle Unterpunkte dieses Asts werden ebenfalls gelöscht.')) { }`}
onDeleteBlockRef.current(block.id, true); >
{/* Convert back to bullet for headings, callouts, divider */}
{canEdit && ['callout', 'h1', 'h2', 'h3', 'divider'].includes(block.type) && (
<button
type="button"
onClick={() => {
refocusAfterTypeChangeRef.current = true;
flushDebounce(localContent, { type: 'bullet', checked: false });
}}
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
title="In normalen Text umwandeln"
>
<Type className="w-3.5 h-3.5" />
</button>
)}
{/* To-Do 3-dots Menu */}
{block.type === 'todo' && canEdit && (
<div className="relative inline-flex items-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowTodoMenu((prev) => !prev);
}}
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center 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
ref={todoMenuRef}
className="absolute right-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>
)}
<button
type="button"
onClick={() => {
setShowTodoMenu(false);
refocusAfterTypeChangeRef.current = true;
flushDebounce(localContent, { type: 'bullet', checked: false, taskId: '' });
}}
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 border-t border-border mt-0.5 pt-1.5"
>
<Type className="w-3.5 h-3.5 text-accent" />
<span>In normalen Text umwandeln</span>
</button>
</div>
)}
</div>
)}
{/* Branch into its own note */}
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
<button
type="button"
onClick={() => onExtractBlock(block.id)}
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
title="In eigene Notiz umwandeln (mit allen Unterpunkten)"
>
<FileOutput className="w-3.5 h-3.5" />
</button>
)}
{/* Zoom / Focus button */}
{onZoomBlock && (
<button
type="button"
onClick={() => onZoomBlock(block.id)}
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
title="In diesen Ast hineinzoomen (Fokus-Modus)"
>
<Maximize2 className="w-3.5 h-3.5" />
</button>
)}
{/* Delete button */}
{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);
} }
} else { }}
onDeleteBlockRef.current(block.id, false); className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-status-err rounded-btn transition-colors cursor-pointer"
} title="Block löschen"
}} >
className={ <Trash2 className="w-3.5 h-3.5" />
activeFocus </button>
? 'opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer' )}
: 'hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer' </div>
}
title="Block löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{/* Link Insertion Modal */} {/* Link Insertion Modal */}
{showLinkModal && ( {showLinkModal && (
@@ -129,9 +129,9 @@ describe('slash command menu and outliner fixes', () => {
fireEvent.change(ta, { target: { value: 'https://a.de/b/' } }); fireEvent.change(ta, { target: { value: 'https://a.de/b/' } });
expect(queryByText('Befehl auswählen')).toBeNull(); expect(queryByText('Befehl auswählen')).toBeNull();
fireEvent.change(ta, { target: { value: 'Text /co' } }); fireEvent.change(ta, { target: { value: 'Text /ca' } });
expect(queryByText('Befehl auswählen')).toBeTruthy(); expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/code')).toBeTruthy(); expect(queryByText('/callout')).toBeTruthy();
// Removing the slash closes the menu // Removing the slash closes the menu
fireEvent.change(ta, { target: { value: 'Text ' } }); fireEvent.change(ta, { target: { value: 'Text ' } });
@@ -375,3 +375,106 @@ describe('formatting shortcuts (Teil C)', () => {
expect(readerTa.value).toBe('text'); expect(readerTa.value).toBe('text');
}); });
}); });
describe('outliner polish (Teil A)', () => {
it('opens slash menu at cursor position in the middle of text and inserts link at cursor', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'b1', type: 'bullet', content: 'Vorher Nachher', level: 0 } as DocumentBlock;
const { container, queryByText, getByText, getByPlaceholderText, getByRole } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
// Type "/link" in the middle of the text (selectionStart at 12, right after "/link")
fireEvent.change(ta, { target: { value: 'Vorher /link Nachher', selectionStart: 12, selectionEnd: 12 } });
// Menu should be open with /link, but without /divider or /h1
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/link')).toBeTruthy();
expect(queryByText('/divider')).toBeNull();
expect(queryByText('/h1')).toBeNull();
// Select /link by pressing Enter
fireEvent.keyDown(ta, { key: 'Enter' });
// Link modal opens
expect(queryByText('Web-Link')).toBeTruthy();
fireEvent.click(getByText('Web-Link'));
fireEvent.change(getByPlaceholderText('z. B. MindSift Dokumentation'), { target: { value: 'Doku' } });
fireEvent.change(getByPlaceholderText('https://example.com'), { target: { value: 'https://example.com/test' } });
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
// Result has link inserted in the middle
expect(updates[updates.length - 1]?.content).toBe('Vorher [Doku](https://example.com/test) Nachher');
});
it('does not offer /divider at end of text, but offers it in empty block', () => {
const block = { id: 'b1', type: 'bullet', content: 'Ein Text', level: 0 } as DocumentBlock;
const { container, queryByText } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
// At end of non-empty text
fireEvent.change(ta, { target: { value: 'Ein Text /div' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/divider')).toBeNull();
// In an empty block
fireEvent.change(ta, { target: { value: '/div' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/divider')).toBeTruthy();
});
it('converts H1 block back to bullet via /text slash command', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'b1', type: 'h1', content: 'Meine Überschrift', level: 0 } as DocumentBlock;
const { container, queryByText } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: 'Meine Überschrift /text' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/text')).toBeTruthy();
fireEvent.keyDown(ta, { key: 'Enter' });
const last = updates[updates.length - 1];
expect(last?.type).toBe('bullet');
expect(last?.content).toBe('Meine Überschrift');
});
it('closes slash menu on mousedown outside', () => {
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
const { container, queryByText } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: '/' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
// Click outside on document body
fireEvent.mouseDown(document.body);
expect(queryByText('Befehl auswählen')).toBeNull();
});
it('converts focused divider to empty bullet on Backspace', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'd1', type: 'divider', content: '', level: 0 } as DocumentBlock;
const { container } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.keyDown(ta, { key: 'Backspace' });
const last = updates[updates.length - 1];
expect(last?.type).toBe('bullet');
expect(last?.content).toBe('');
});
});