fix(workspace): tighten link preview card, slash menu matching/scrolling, and Enter on last empty bullet
This commit is contained in:
1 parent
02ce554c02
commit
446f8e4649
5 files changed
+209
-48
No files matched your search
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user