From fd72500d17313ecc9b3fb24838ac50c7aefbf471 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 19:26:52 +0100 Subject: [PATCH] feat(workspace): polish outliner slash commands, typography and actions overlay --- CHANGELOG.md | 11 + .../src/features/workspace/OutlinerBlock.tsx | 637 ++++++++++++------ .../__tests__/OutlinerBlock.caret.test.tsx | 107 ++- 3 files changed, 559 insertions(+), 196 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c2ae3..3d2953b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 9a22cd0..2e7bfce 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -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: }, { type: 'link', label: '/link', desc: 'Notiz oder Weblink', icon: }, + { type: 'bullet', label: '/text', desc: 'In Standardtext umwandeln', icon: , aliases: ['p', 'paragraph', 'bullet', 'standard'] }, { type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: }, { type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: }, { type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: }, @@ -156,7 +158,15 @@ export const OutlinerBlock: React.FC = ({ 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(null); + const [slashMatchInfo, setSlashMatchInfo] = useState<{ query: string; slashStart: number; cursor: number } | null>(null); + const slashSearch = slashMatchInfo ? slashMatchInfo.query : null; + const linkBeforeRef = useRef(''); + const linkAfterRef = useRef(''); + const dictationBeforeRef = useRef(''); + const dictationAfterRef = useRef(''); + const slashMenuRef = useRef(null); + const todoMenuRef = useRef(null); + const blurSlashTimerRef = useRef(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 = ({ 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(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 = ({ }, [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 = ({ }, [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 = ({ 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 = ({ 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 = ({ } 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 = ({ 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 = ({ }; 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ } e.preventDefault(); e.stopPropagation(); - setSlashSearch(null); + setSlashMatchInfo(null); } return; } @@ -920,6 +1054,12 @@ export const OutlinerBlock: React.FC = ({ return (
= ({ { 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 = ({ }} /> ) : block.type === 'divider' ? ( -
-
+
{ + textareaRef.current?.focus(); + }} + > +
+