fix(workspace): tighten link preview card, slash menu matching/scrolling, and Enter on last empty bullet

This commit is contained in:
sandrodach committed 2026-10-30 09:21:29 +01:00
1 parent 02ce554c02
commit 446f8e4649
5 files changed
+195 -34

No files matched your search

+5
View File
@@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [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 ## [0.7.0] - 2026-10-30
### Added ### Added
@@ -35,7 +35,7 @@ import { AudioBlock } from './AudioBlock';
import { DictationBlock } from './DictationBlock'; import { DictationBlock } from './DictationBlock';
import { currentVoiceConfig } from '../../lib/voice'; import { currentVoiceConfig } from '../../lib/voice';
import { LinkPreviewCard } from './LinkPreviewCard'; 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 { mediaContent, parseMediaContent } from '../../lib/media';
import { renderInlineMarkdown } from '../../lib/markdown'; import { renderInlineMarkdown } from '../../lib/markdown';
import { api } from '../../lib/api'; import { api } from '../../lib/api';
@@ -121,7 +121,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
docId, docId,
block, block,
index, index,
isLast,
hasChildren, hasChildren,
isFocused, isFocused,
canEdit, canEdit,
@@ -160,13 +159,14 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const [slashSearch, setSlashSearch] = useState<string | null>(null); const [slashSearch, setSlashSearch] = useState<string | 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 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 previewsOn = useLinkPreviewsEnabled();
const previewUrl = const previewUrl =
previewsOn && !mediaPicker && ['bullet', 'text', 'paragraph', 'todo', 'h1', 'h2', 'h3'].includes(block.type || 'bullet') previewsOn && !mediaPicker && ['bullet', 'text', 'paragraph', 'todo', 'h1', 'h2', 'h3'].includes(block.type || 'bullet')
? firstBareUrl(localContent) ? linkCardUrl(localContent)
: null; : null;
const [slashIndex, setSlashIndex] = useState(0); const [slashIndex, setSlashIndex] = useState(0);
const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [showLinkModal, setShowLinkModal] = useState<boolean>(false); const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false); const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
@@ -362,7 +362,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const q = slashSearch.toLowerCase(); const q = slashSearch.toLowerCase();
const cmdQuery = q.startsWith('code ') ? 'code' : q; const cmdQuery = q.startsWith('code ') ? 'code' : q;
// Media only in an otherwise empty bullet // 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( return SLASH_COMMANDS.filter(
(c) => (c) =>
(!c.media || emptyBullet) && (!c.media || emptyBullet) &&
@@ -375,12 +375,18 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
); );
}, [slashSearch, localContent, onExtractBlock]); }, [slashSearch, localContent, onExtractBlock]);
useEffect(() => {
if (slashSearch === null) return;
slashItemRefs.current[slashIndex]?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
}, [slashIndex, slashSearch]);
const applySlashCommand = (cmd: SlashCommand) => { const applySlashCommand = (cmd: SlashCommand) => {
setSlashSearch(null); setSlashSearch(null);
const codeLangMatch = localContent.match(/\/code\s+([a-z0-9_+#.-]+)$/i); const codeLangMatch = localContent.match(/(^|\s)\/code\s+([a-z0-9_+#.-]+)$/i);
const clean = localContent.replace(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim(); const clean = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim();
if (cmd.type === 'link') { if (cmd.type === 'link') {
setLocalContent(clean); setLocalContent(clean);
localContentRef.current = clean;
setShowLinkModal(true); setShowLinkModal(true);
return; return;
} }
@@ -401,7 +407,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
} }
const langOverride = const langOverride =
cmd.type === 'code' && codeLangMatch cmd.type === 'code' && codeLangMatch
? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase() ? resolveLanguageId(codeLangMatch[2]) || codeLangMatch[2].toLowerCase()
: undefined; : undefined;
setLocalContent(clean); setLocalContent(clean);
localContentRef.current = clean; localContentRef.current = clean;
@@ -415,11 +421,22 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const handleInsertLink = (linkMarkdown: string) => { const handleInsertLink = (linkMarkdown: string) => {
setShowLinkModal(false); 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 cursor = textareaRef.current?.selectionStart ?? localContent.length;
const before = localContent.slice(0, cursor); const before = localContent.slice(0, cursor);
const after = localContent.slice(cursor); const after = localContent.slice(cursor);
const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim(); const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim();
setLocalContent(newContent); setLocalContent(newContent);
localContentRef.current = newContent;
flushDebounce(newContent); flushDebounce(newContent);
}; };
@@ -475,9 +492,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
} }
// Check for slash command invocation // 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) { if (slashMatch) {
setSlashSearch(slashMatch[1]); setSlashSearch(slashMatch[2]);
setSlashIndex(0); setSlashIndex(0);
} else { } else {
setSlashSearch(null); setSlashSearch(null);
@@ -600,7 +617,13 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
if (!canEdit) return; if (!canEdit) return;
// Slash command navigation // Slash command navigation
if (slashSearch !== null && filteredSlashCommands.length > 0) { if (slashSearch !== null) {
if (e.key === 'Escape') {
e.preventDefault();
setSlashSearch(null);
return;
}
if (filteredSlashCommands.length > 0) {
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length); setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length);
@@ -619,10 +642,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
} }
return; return;
} }
if (e.key === 'Escape') {
e.preventDefault();
setSlashSearch(null);
return;
} }
} }
@@ -637,7 +656,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
// 2. Enter (without Shift) -> If block is completely empty: // 2. Enter (without Shift) -> If block is completely empty:
// - If level > minLevel, outdent (Shift+Tab behaviour) // - 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 // - Otherwise split paragraph at cursor and create new block below
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') { if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
e.preventDefault(); e.preventDefault();
@@ -666,10 +684,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onIndentBlockRef.current(block.id, -1); onIndentBlockRef.current(block.id, -1);
return; return;
} }
if (isLast) {
// Bereits auf oberster Ebene und der letzte Block: kein weiterer leerer Block
return;
}
} }
const start = e.currentTarget.selectionStart ?? localContent.length; const start = e.currentTarget.selectionStart ?? localContent.length;
@@ -1258,6 +1272,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{filteredSlashCommands.map((cmd, i) => ( {filteredSlashCommands.map((cmd, i) => (
<button <button
key={cmd.type} key={cmd.type}
ref={(el) => {
slashItemRefs.current[i] = el;
}}
type="button" type="button"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
@@ -118,3 +118,140 @@ describe('conflict copies', () => {
]); ]);
}); });
}); });
describe('slash command menu and outliner fixes', () => {
it('does not open the slash menu for URLs like https://a.de/b/ but opens for Text /co and closes on Escape or Backspace', () => {
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: 'https://a.de/b/' } });
expect(queryByText('Befehl auswählen')).toBeNull();
fireEvent.change(ta, { target: { value: 'Text /co' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/code')).toBeTruthy();
// Removing the slash closes the menu
fireEvent.change(ta, { target: { value: 'Text ' } });
expect(queryByText('Befehl auswählen')).toBeNull();
// Even when no command matches, Escape closes the menu
fireEvent.change(ta, { target: { value: '/zzzznotfound' } });
expect(queryByText('Kein Befehl gefunden')).toBeTruthy();
fireEvent.keyDown(ta, { key: 'Escape' });
expect(queryByText('Befehl auswählen')).toBeNull();
});
it('scrolls the active slash command button into view on ArrowDown', () => {
const scrollSpy = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollSpy;
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
const { container } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: '/' } });
scrollSpy.mockClear();
fireEvent.keyDown(ta, { key: 'ArrowDown' });
expect(scrollSpy).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' });
});
it('creates a new block on Enter at the last empty top-level bullet, after outdenting if indented', () => {
const onIndent = vi.fn();
const onCreateBelow = vi.fn();
const indented = { id: 'b1', type: 'bullet', content: '', level: 1 } as DocumentBlock;
const { container, rerender } = render(
<OutlinerBlock
docId="d"
block={indented}
index={0}
isLast
maxAllowedLevel={2}
hasChildren={false}
isFocused
canEdit
onUpdateBlock={() => {}}
onCreateBlockBelow={onCreateBelow}
onDeleteBlock={() => {}}
onIndentBlock={onIndent}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
/>
);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
fireEvent.keyDown(ta, { key: 'Enter' });
expect(onIndent).toHaveBeenCalledWith('b1', -1);
expect(onCreateBelow).not.toHaveBeenCalled();
rerender(
<OutlinerBlock
docId="d"
block={{ ...indented, level: 0 }}
index={0}
isLast
maxAllowedLevel={1}
hasChildren={false}
isFocused
canEdit
onUpdateBlock={() => {}}
onCreateBlockBelow={onCreateBelow}
onDeleteBlock={() => {}}
onIndentBlock={onIndent}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
/>
);
fireEvent.keyDown(ta, { key: 'Enter' });
expect(onCreateBelow).toHaveBeenCalledWith('b1', '');
});
it('inserts a bare URL for /link with a web address in an empty bullet, and [Text](url) otherwise', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
const { container, getByText, getByRole, getByPlaceholderText, rerender } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: '/link' } });
fireEvent.keyDown(ta, { key: 'Enter' });
fireEvent.click(getByText('Web-Link'));
fireEvent.change(getByPlaceholderText('https://example.com'), { target: { value: 'https://example.com/test' } });
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
expect(updates[updates.length - 1]?.content).toBe('https://example.com/test');
// Non-empty bullet keeps [Text](url)
rerender(
<OutlinerBlock
docId="d"
block={{ ...block, content: 'Siehe' }}
index={0}
isLast
maxAllowedLevel={1}
hasChildren={false}
isFocused={false}
canEdit
onUpdateBlock={(u: DocumentBlock) => updates.push(u)}
onCreateBlockBelow={() => {}}
onDeleteBlock={() => {}}
onIndentBlock={() => {}}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
/>
);
ta.focus();
fireEvent.change(ta, { target: { value: 'Siehe /link' } });
fireEvent.keyDown(ta, { key: 'Enter' });
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/doku' } });
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
expect(updates[updates.length - 1]?.content).toBe('Siehe [Doku](https://example.com/doku)');
});
});
+11 -9
View File
@@ -1,14 +1,16 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { firstBareUrl, hidePreviewContent } from '../linkPreview'; import { linkCardUrl, hidePreviewContent } from '../linkPreview';
describe('link preview helpers', () => { describe('link preview helpers', () => {
it('finds the first plain https address', () => { it('returns the URL only when the entire trimmed content is a single https address', () => {
expect(firstBareUrl('https://example.com/a?b=1')).toBe('https://example.com/a?b=1'); expect(linkCardUrl('https://example.com/a?b=1')).toBe('https://example.com/a?b=1');
expect(firstBareUrl('Rezept: https://koch.de/spaetzle, lecker!')).toBe('https://koch.de/spaetzle'); expect(linkCardUrl(' https://example.com/page/ ')).toBe('https://example.com/page/');
expect(firstBareUrl('siehe (https://x.de/p).')).toBeNull(); // inside brackets: not a plain address expect(linkCardUrl('Rezept: https://koch.de/spaetzle, lecker!')).toBeNull();
expect(firstBareUrl('[Kochseite](https://koch.de)')).toBeNull(); expect(linkCardUrl('https://a.de https://b.de')).toBeNull();
expect(firstBareUrl('http://unsicher.de')).toBeNull(); expect(linkCardUrl('siehe (https://x.de/p).')).toBeNull();
expect(firstBareUrl('nur Text')).toBeNull(); expect(linkCardUrl('[Kochseite](https://koch.de)')).toBeNull();
expect(linkCardUrl('http://unsicher.de')).toBeNull();
expect(linkCardUrl('nur Text')).toBeNull();
}); });
it('hides a preview by turning the address into a named link', () => { it('hides a preview by turning the address into a named link', () => {
@@ -16,6 +18,6 @@ describe('link preview helpers', () => {
'Lesen: [Käsespätzle Rezept](https://koch.de/x)' 'Lesen: [Käsespätzle Rezept](https://koch.de/x)'
); );
expect(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', '')).toBe('[koch.de](https://koch.de/x)'); expect(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', '')).toBe('[koch.de](https://koch.de/x)');
expect(firstBareUrl(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', 'T'))).toBeNull(); expect(linkCardUrl(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', 'T'))).toBeNull();
}); });
}); });
+5 -5
View File
@@ -16,12 +16,12 @@ export interface LinkPreview {
ok: boolean; ok: boolean;
} }
// First plain https address in a text (not inside [text](url), not right after "(" or "<") // Entire bullet content (trimmed) is a single plain https address
const BARE_URL_RE = /(^|[\s])(https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"])/; const CARD_URL_RE = /^https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"]$/;
export function firstBareUrl(content: string): string | null { export function linkCardUrl(content: string): string | null {
const m = BARE_URL_RE.exec(content || ''); const trimmed = (content || '').trim();
return m ? m[2] : null; return CARD_URL_RE.test(trimmed) ? trimmed : null;
} }
/** Replaces the address by a named link, which shows no preview. */ /** Replaces the address by a named link, which shows no preview. */