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
+195
-34
No files matched your search
@@ -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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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. */
|
||||||
|
|||||||
Reference in new issue
Block a user