From 446f8e46498e963150a1f94e0822ad4f5ef343d1 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 09:21:29 +0100 Subject: [PATCH] fix(workspace): tighten link preview card, slash menu matching/scrolling, and Enter on last empty bullet --- CHANGELOG.md | 5 + .../src/features/workspace/OutlinerBlock.tsx | 85 ++++++----- .../__tests__/OutlinerBlock.caret.test.tsx | 137 ++++++++++++++++++ .../src/lib/__tests__/linkPreview.test.ts | 20 +-- frontend/src/lib/linkPreview.ts | 10 +- 5 files changed, 209 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f9b5a4..5537a78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +### Fixed +- Notizen: Link-Vorschau-Karten erscheinen nur noch, wenn der gesamte Punkt aus einer einzigen `https`-Adresse besteht (oder über `/link` in einem leeren Punkt eingefügt wurde), nicht mehr bei Links mitten im Fließtext. +- Notizen: Das Slash-Menü (`/`) öffnet sich nur noch am Textanfang oder nach einem Leerzeichen (nicht mehr beim Einfügen von Web-Adressen mit Schrägstrichen), schließt zuverlässig mit `Esc` oder `Backspace` und scrollt bei Pfeiltasten-Navigation automatisch mit. +- Notizen: `Enter` in einem leeren Punkt auf oberster Ebene legt auch am Ende der Notiz einen neuen Punkt darunter an. + ## [0.7.0] - 2026-10-30 ### Added diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 08f29ef..03734fc 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -35,7 +35,7 @@ import { AudioBlock } from './AudioBlock'; import { DictationBlock } from './DictationBlock'; import { currentVoiceConfig } from '../../lib/voice'; import { LinkPreviewCard } from './LinkPreviewCard'; -import { firstBareUrl, hidePreviewContent, useLinkPreviewsEnabled } from '../../lib/linkPreview'; +import { linkCardUrl, hidePreviewContent, useLinkPreviewsEnabled } from '../../lib/linkPreview'; import { mediaContent, parseMediaContent } from '../../lib/media'; import { renderInlineMarkdown } from '../../lib/markdown'; import { api } from '../../lib/api'; @@ -121,7 +121,6 @@ export const OutlinerBlock: React.FC = ({ docId, block, index, - isLast, hasChildren, isFocused, canEdit, @@ -160,13 +159,14 @@ export const OutlinerBlock: React.FC = ({ const [slashSearch, setSlashSearch] = useState(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 with a plain web address (lib/linkPreview.ts) + // Card above bullets whose entire content is a plain https address (lib/linkPreview.ts) const previewsOn = useLinkPreviewsEnabled(); const previewUrl = previewsOn && !mediaPicker && ['bullet', 'text', 'paragraph', 'todo', 'h1', 'h2', 'h3'].includes(block.type || 'bullet') - ? firstBareUrl(localContent) + ? linkCardUrl(localContent) : null; const [slashIndex, setSlashIndex] = useState(0); + const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([]); const [showLinkModal, setShowLinkModal] = useState(false); const [showTodoMenu, setShowTodoMenu] = useState(false); @@ -362,7 +362,7 @@ export const OutlinerBlock: React.FC = ({ const q = slashSearch.toLowerCase(); const cmdQuery = q.startsWith('code ') ? 'code' : q; // Media only in an otherwise empty bullet - const emptyBullet = localContent.replace(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === ''; + const emptyBullet = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === ''; return SLASH_COMMANDS.filter( (c) => (!c.media || emptyBullet) && @@ -375,12 +375,18 @@ export const OutlinerBlock: React.FC = ({ ); }, [slashSearch, localContent, onExtractBlock]); + useEffect(() => { + if (slashSearch === null) return; + slashItemRefs.current[slashIndex]?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }); + }, [slashIndex, slashSearch]); + const applySlashCommand = (cmd: SlashCommand) => { setSlashSearch(null); - const codeLangMatch = localContent.match(/\/code\s+([a-z0-9_+#.-]+)$/i); - const clean = localContent.replace(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim(); + 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 (cmd.type === 'link') { setLocalContent(clean); + localContentRef.current = clean; setShowLinkModal(true); return; } @@ -401,7 +407,7 @@ export const OutlinerBlock: React.FC = ({ } const langOverride = cmd.type === 'code' && codeLangMatch - ? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase() + ? resolveLanguageId(codeLangMatch[2]) || codeLangMatch[2].toLowerCase() : undefined; setLocalContent(clean); localContentRef.current = clean; @@ -415,11 +421,22 @@ export const OutlinerBlock: React.FC = ({ const handleInsertLink = (linkMarkdown: string) => { setShowLinkModal(false); + if (localContent.trim() === '') { + const webMatch = linkMarkdown.match(/^\[.*\]\((https?:\/\/[^\s)]+)\)$/); + if (webMatch) { + const bareUrl = webMatch[1]; + setLocalContent(bareUrl); + localContentRef.current = bareUrl; + flushDebounce(bareUrl); + 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(); setLocalContent(newContent); + localContentRef.current = newContent; flushDebounce(newContent); }; @@ -475,9 +492,9 @@ export const OutlinerBlock: React.FC = ({ } // Check for slash command invocation - const slashMatch = !typeOverride ? val.match(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null; + const slashMatch = !typeOverride ? val.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null; if (slashMatch) { - setSlashSearch(slashMatch[1]); + setSlashSearch(slashMatch[2]); setSlashIndex(0); } else { setSlashSearch(null); @@ -600,30 +617,32 @@ export const OutlinerBlock: React.FC = ({ if (!canEdit) return; // Slash command navigation - if (slashSearch !== null && filteredSlashCommands.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length); - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - setSlashIndex((prev) => (prev - 1 + filteredSlashCommands.length) % filteredSlashCommands.length); - return; - } - if (e.key === 'Enter' || e.key === 'Tab') { - e.preventDefault(); - const selected = filteredSlashCommands[slashIndex]; - if (selected) { - applySlashCommand(selected); - } - return; - } + if (slashSearch !== null) { if (e.key === 'Escape') { e.preventDefault(); setSlashSearch(null); return; } + if (filteredSlashCommands.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSlashIndex((prev) => (prev - 1 + filteredSlashCommands.length) % filteredSlashCommands.length); + return; + } + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + const selected = filteredSlashCommands[slashIndex]; + if (selected) { + applySlashCommand(selected); + } + return; + } + } } // 1. Tab / Shift+Tab -> Strict Indent / Outdent @@ -637,7 +656,6 @@ export const OutlinerBlock: React.FC = ({ // 2. Enter (without Shift) -> If block is completely empty: // - If level > minLevel, outdent (Shift+Tab behaviour) - // - If already at minLevel and is the last block, do NOT create another empty block below // - Otherwise split paragraph at cursor and create new block below if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') { e.preventDefault(); @@ -666,10 +684,6 @@ export const OutlinerBlock: React.FC = ({ onIndentBlockRef.current(block.id, -1); return; } - if (isLast) { - // Bereits auf oberster Ebene und der letzte Block: kein weiterer leerer Block - return; - } } const start = e.currentTarget.selectionStart ?? localContent.length; @@ -1258,6 +1272,9 @@ export const OutlinerBlock: React.FC = ({ {filteredSlashCommands.map((cmd, i) => (