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
+209 -48

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]
### 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
@@ -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<OutlinerBlockProps> = ({
docId,
block,
index,
isLast,
hasChildren,
isFocused,
canEdit,
@@ -160,13 +159,14 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const [slashSearch, setSlashSearch] = useState<string | 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 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<boolean>(false);
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
@@ -362,7 +362,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
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<OutlinerBlockProps> = ({
);
}, [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<OutlinerBlockProps> = ({
}
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<OutlinerBlockProps> = ({
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<OutlinerBlockProps> = ({
}
// 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<OutlinerBlockProps> = ({
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<OutlinerBlockProps> = ({
// 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<OutlinerBlockProps> = ({
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<OutlinerBlockProps> = ({
{filteredSlashCommands.map((cmd, i) => (
<button
key={cmd.type}
ref={(el) => {
slashItemRefs.current[i] = el;
}}
type="button"
onMouseDown={(e) => {
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 { firstBareUrl, hidePreviewContent } from '../linkPreview';
import { linkCardUrl, hidePreviewContent } from '../linkPreview';
describe('link preview helpers', () => {
it('finds the first plain https address', () => {
expect(firstBareUrl('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(firstBareUrl('siehe (https://x.de/p).')).toBeNull(); // inside brackets: not a plain address
expect(firstBareUrl('[Kochseite](https://koch.de)')).toBeNull();
expect(firstBareUrl('http://unsicher.de')).toBeNull();
expect(firstBareUrl('nur Text')).toBeNull();
it('returns the URL only when the entire trimmed content is a single https address', () => {
expect(linkCardUrl('https://example.com/a?b=1')).toBe('https://example.com/a?b=1');
expect(linkCardUrl(' https://example.com/page/ ')).toBe('https://example.com/page/');
expect(linkCardUrl('Rezept: https://koch.de/spaetzle, lecker!')).toBeNull();
expect(linkCardUrl('https://a.de https://b.de')).toBeNull();
expect(linkCardUrl('siehe (https://x.de/p).')).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', () => {
@@ -16,6 +18,6 @@ describe('link preview helpers', () => {
'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(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;
}
// First plain https address in a text (not inside [text](url), not right after "(" or "<")
const BARE_URL_RE = /(^|[\s])(https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"])/;
// Entire bullet content (trimmed) is a single plain https address
const CARD_URL_RE = /^https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"]$/;
export function firstBareUrl(content: string): string | null {
const m = BARE_URL_RE.exec(content || '');
return m ? m[2] : null;
export function linkCardUrl(content: string): string | null {
const trimmed = (content || '').trim();
return CARD_URL_RE.test(trimmed) ? trimmed : null;
}
/** Replaces the address by a named link, which shows no preview. */