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]
### 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
- 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.
- 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,
AudioLines,
FileOutput,
Type,
} from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { LinkModal } from './LinkModal';
@@ -55,6 +56,7 @@ export interface SlashCommand {
const SLASH_COMMANDS: SlashCommand[] = [
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
{ type: 'link', label: '/link', desc: 'Notiz oder Weblink', icon: <LinkIcon className="w-3.5 h-3.5" /> },
{ type: '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: '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" /> },
@@ -156,7 +158,15 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
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 [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)
const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null);
// 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);
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);
// 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]);
const filteredSlashCommands = useMemo(() => {
if (slashSearch === null) return [];
const q = slashSearch.toLowerCase();
if (!slashMatchInfo) return [];
const q = slashMatchInfo.query.toLowerCase();
const cmdQuery = q.startsWith('code ') ? 'code' : q;
// Media only in an otherwise empty bullet
const emptyBullet = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === '';
return SLASH_COMMANDS.filter(
(c) =>
(!c.media || emptyBullet) &&
(c.type !== 'dictation' || ['server', 'browser'].includes(currentVoiceConfig().mode)) &&
// The bullet's text becomes the title of the new note
(c.type !== 'extract' || (!emptyBullet && !!onExtractBlock)) &&
(c.label.toLowerCase().includes(cmdQuery) ||
c.desc.toLowerCase().includes(cmdQuery) ||
(c.aliases || []).some((a) => a.includes(cmdQuery)))
);
}, [slashSearch, localContent, onExtractBlock]);
const textBeforeSlash = localContent.slice(0, slashMatchInfo.slashStart);
const textAfterCursor = localContent.slice(slashMatchInfo.cursor);
const restOfBlock = (textBeforeSlash + textAfterCursor).trim();
const isRestEmpty = restOfBlock === '';
const isAtStartOrEnd = textBeforeSlash.trim() === '' || textAfterCursor.trim() === '';
return SLASH_COMMANDS.filter((c) => {
const matchesQuery =
c.label.toLowerCase().includes(cmdQuery) ||
c.desc.toLowerCase().includes(cmdQuery) ||
(c.aliases || []).some((a) => a.includes(cmdQuery));
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(() => {
if (slashSearch === null) return;
@@ -381,15 +462,37 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}, [slashIndex, slashSearch]);
const applySlashCommand = (cmd: SlashCommand) => {
setSlashSearch(null);
const codeLangMatch = localContent.match(/(^|\s)\/code\s+([a-z0-9_+#.-]+)$/i);
const clean = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim();
if (blurSlashTimerRef.current !== null) {
window.clearTimeout(blurSlashTimerRef.current);
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') {
linkBeforeRef.current = before;
linkAfterRef.current = after;
setLocalContent(clean);
localContentRef.current = clean;
setShowLinkModal(true);
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') {
setLocalContent(clean);
localContentRef.current = clean;
@@ -397,31 +500,38 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onExtractBlock?.(block.id);
return;
}
if (cmd.media) {
// The typed "/image" must not stay in the synced text; the block type changes only after the upload
setLocalContent('');
localContentRef.current = '';
flushDebounce('');
setMediaPicker(cmd.type as 'image' | 'audio' | 'dictation');
setMediaPicker(cmd.type as 'image' | 'audio');
return;
}
const codeLangMatch = slashMatchInfo?.query.match(/^code\s+([a-z0-9_+#.-]+)$/i);
const langOverride =
cmd.type === 'code' && codeLangMatch
? resolveLanguageId(codeLangMatch[2]) || codeLangMatch[2].toLowerCase()
? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase()
: undefined;
setLocalContent(clean);
localContentRef.current = clean;
if (cmd.type !== block.type) refocusAfterTypeChangeRef.current = true;
flushDebounce(clean, {
type: cmd.type as BlockType, // media commands returned above
type: cmd.type as BlockType,
...(langOverride !== undefined ? { lang: langOverride } : {}),
checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
...(cmd.type === 'bullet' ? { taskId: '', checked: false } : {}),
});
};
const handleInsertLink = (linkMarkdown: string) => {
setShowLinkModal(false);
if (localContent.trim() === '') {
const b = linkBeforeRef.current ?? '';
const a = linkAfterRef.current ?? '';
if (b.trim() === '' && a.trim() === '') {
const webMatch = linkMarkdown.match(/^\[.*\]\((https?:\/\/[^\s)]+)\)$/);
if (webMatch) {
const bareUrl = webMatch[1];
@@ -431,10 +541,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
return;
}
}
const cursor = textareaRef.current?.selectionStart ?? localContent.length;
const before = localContent.slice(0, cursor);
const after = localContent.slice(cursor);
const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim();
const spaceBefore = b && !b.endsWith(' ') ? ' ' : '';
const spaceAfter = a && !a.startsWith(' ') && a.trim() !== '' ? ' ' : '';
const newContent = `${b}${spaceBefore}${linkMarkdown}${spaceAfter}${a}`.trim();
setLocalContent(newContent);
localContentRef.current = newContent;
flushDebounce(newContent);
@@ -516,18 +625,32 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
} else if (val.startsWith('### ') && block.type !== 'h3') {
val = val.slice(4);
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
const slashMatch = !typeOverride ? val.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
// Check for slash command invocation at cursor position
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) {
setSlashSearch(slashMatch[2]);
const slashStart = slashMatch.index! + slashMatch[1].length;
setSlashMatchInfo({ query: slashMatch[2], slashStart, cursor });
setSlashIndex(0);
} else {
setSlashSearch(null);
setSlashMatchInfo(null);
}
} else {
setSlashSearch(null);
setSlashMatchInfo(null);
}
setLocalContent(val);
@@ -543,6 +666,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
type: typeOverride,
...(langOverride !== undefined ? { lang: langOverride } : {}),
checked: checkedOverride ?? latestBlockRef.current.checked,
...(typeOverride === 'bullet' ? { taskId: '', checked: false } : {}),
});
return;
}
@@ -557,6 +681,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
};
const handleFocus = () => {
if (blurSlashTimerRef.current !== null) {
window.clearTimeout(blurSlashTimerRef.current);
blurSlashTimerRef.current = null;
}
isFocusedRef.current = true;
setIsLocallyFocused(true);
onFocusBlock?.(block.id);
@@ -567,6 +695,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
isFocusedRef.current = false;
setIsLocallyFocused(false);
onBlurBlock?.(block.id);
if (blurSlashTimerRef.current !== null) {
window.clearTimeout(blurSlashTimerRef.current);
}
blurSlashTimerRef.current = window.setTimeout(() => {
setSlashMatchInfo(null);
}, 150);
if (isPastingRef.current) {
isPastingRef.current = false;
return;
@@ -749,7 +883,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
if (slashSearch !== null) {
if (e.key === 'Escape') {
e.preventDefault();
setSlashSearch(null);
setSlashMatchInfo(null);
return;
}
if (filteredSlashCommands.length > 0) {
@@ -789,7 +923,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
e.preventDefault();
e.stopPropagation();
setSlashSearch(null);
setSlashMatchInfo(null);
const fenceMatch = localContent.match(/^```([a-z0-9_+#.-]*)$/i);
if (fenceMatch) {
@@ -844,7 +978,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
if (e.key === 'Backspace' && localContent === '') {
e.preventDefault();
e.stopPropagation();
setSlashSearch(null);
setSlashMatchInfo(null);
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
@@ -870,7 +1004,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}
e.preventDefault();
e.stopPropagation();
setSlashSearch(null);
setSlashMatchInfo(null);
}
return;
}
@@ -920,6 +1054,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
return (
<div
className={`group relative flex items-start gap-1 py-1 px-0.5 sm:px-1.5 rounded-card transition-all duration-300 ${
block.type === 'h1'
? 'mt-2.5 sm:mt-3'
: block.type === 'h2'
? 'mt-1.5 sm:mt-2'
: ''
} ${
isDragTarget
? 'border-t-2 border-accent bg-accent/10'
: isTargeted
@@ -1047,14 +1187,20 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
<DictationBlock
onDone={(text) => {
setMediaPicker(null);
setLocalContent(text);
localContentRef.current = text;
flushDebounce(text);
const b = dictationBeforeRef.current ?? '';
const a = dictationAfterRef.current ?? '';
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(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
const pos = (b + spaceBefore + text).length;
ta.setSelectionRange(pos, pos);
});
}}
onCancel={() => {
@@ -1104,8 +1250,43 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}}
/>
) : block.type === 'divider' ? (
<div className="py-2">
<hr className="border-t border-border" />
<div
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>
) : block.type === 'code' ? (
<CodeBlockEditor
@@ -1145,7 +1326,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</div>
) : (
<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
onClick={(e) => {
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'
} ${
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'
? 'text-base font-semibold text-main'
? 'text-lg sm:text-xl font-semibold text-main'
: block.type === 'h3'
? 'text-sm font-semibold text-main'
? 'text-base font-semibold text-main'
: block.type === 'todo' && block.checked
? 'text-sm text-faint line-through'
: 'text-sm text-main'
@@ -1172,104 +1353,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
<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
@@ -1285,15 +1368,47 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onChange={handleContentChange}
onKeyDown={handleKeyDown}
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] ${
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'
? 'text-xl sm:text-2xl font-bold text-main tracking-tight'
: block.type === 'h2'
? 'text-base font-semibold text-main'
? 'text-lg sm:text-xl font-semibold text-main'
: block.type === 'h3'
? 'text-sm font-semibold text-main'
? 'text-base font-semibold text-main'
: block.type === 'todo' && block.checked
? 'text-sm text-faint line-through'
: 'text-sm text-main'
@@ -1302,6 +1417,25 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</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 */}
{block.conflictOf && (
<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 */}
{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">
Befehl auswählen
</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 ${
i === slashIndex
? 'bg-accent text-on-accent'
? 'bg-accent-dim text-accent font-semibold'
: 'text-main hover:bg-panel-elevated'
}`}
>
@@ -1432,29 +1569,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
)}
</div>
{/* Branch into its own note (desktop; phones use /extract) */}
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
<button
type="button"
onClick={() => onExtractBlock(block.id)}
className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
title="In eigene Notiz umwandeln (mit allen Unterpunkten)"
>
<FileOutput className="w-3.5 h-3.5" />
</button>
)}
{/* Zoom / Focus button on hover */}
<button
type="button"
onClick={() => onZoomBlock?.(block.id)}
// Phones zoom by tapping the bullet; the button would only take width from the text there
className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
title="In diesen Ast hineinzoomen (Fokus-Modus)"
>
<Maximize2 className="w-3.5 h-3.5" />
</button>
{/* Someone else is editing this block */}
{lockedBy && (
<span
@@ -1466,29 +1580,164 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</span>
)}
{/* Delete button on hover (Trash2 icon); on phones only in the row being edited */}
{canEdit && (
<button
type="button"
onClick={() => {
if (hasChildren) {
if (window.confirm('Bist du sicher? Alle Unterpunkte dieses Asts werden ebenfalls gelöscht.')) {
onDeleteBlockRef.current(block.id, true);
{/* Floating Actions Overlay Bubble (Zoom, Auslagern, Löschen, TODO-Menü, Text-Umwandlung) */}
<div
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 ${
activeFocus
? 'opacity-100 pointer-events-auto'
: 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-events-none group-hover:pointer-events-auto focus-within:pointer-events-auto'
}`}
>
{/* 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={
activeFocus
? 'opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer'
: 'hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer'
}
title="Block löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
}}
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"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Link Insertion Modal */}
{showLinkModal && (
@@ -129,9 +129,9 @@ describe('slash command menu and outliner fixes', () => {
fireEvent.change(ta, { target: { value: 'https://a.de/b/' } });
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('/code')).toBeTruthy();
expect(queryByText('/callout')).toBeTruthy();
// Removing the slash closes the menu
fireEvent.change(ta, { target: { value: 'Text ' } });
@@ -375,3 +375,106 @@ describe('formatting shortcuts (Teil C)', () => {
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('');
});
});