feat(workspace): slash commands ranked by name, taller link cards with a link marker
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m22s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m22s
- /li lists /link first: name prefix, aliases, name, then description - link cards about twice as tall; the bare address below becomes an accent link marker; the link chip bar under bullets is gone, bare web addresses in text are links themselves chore(release): v0.14.3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
08ae91fcdb
commit
6a855fc32c
10 files changed
+109
-72
No files matched your search
+9
-1
@@ -6,6 +6,13 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.14.3] - 2026-10-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Slash-Menü: Befehle, deren Name mit dem Getippten beginnt, stehen oben (`/li` → zuerst `/link`), danach Aliase, dann Treffer im Namen, zuletzt in der Beschreibung.
|
||||||
|
- Link-Vorschau: Karte und Bild etwa doppelt so hoch; statt der nackten Adresse steht darunter eine kleine Link-Markierung (Domain und Pfad) in der Akzentfarbe des Themes.
|
||||||
|
- Links in normalen Punkten: Die grüne Markierungsleiste unter dem Punkt entfällt; Web-Adressen im Text sind jetzt direkt anklickbar (Satzzeichen am Ende gehören nicht zum Link).
|
||||||
|
|
||||||
## [0.14.2] - 2026-10-31
|
## [0.14.2] - 2026-10-31
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -345,7 +352,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||||
|
|
||||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.2...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.3...dev
|
||||||
|
[0.14.3]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.2...v0.14.3
|
||||||
[0.14.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.1...v0.14.2
|
[0.14.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.1...v0.14.2
|
||||||
[0.14.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.0...v0.14.1
|
[0.14.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.0...v0.14.1
|
||||||
[0.14.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.13.0...v0.14.0
|
[0.14.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.13.0...v0.14.0
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.14.2",
|
"version": "0.14.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.14.2",
|
"version": "0.14.3",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.14.2",
|
"version": "0.14.3",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ export const LinkPreviewCard: React.FC<LinkPreviewCardProps> = ({ url, canEdit,
|
|||||||
href={href}
|
href={href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-stretch gap-3 rounded-card border border-border bg-panel hover:bg-panel-elevated transition-colors overflow-hidden no-underline"
|
className="flex items-stretch gap-3 min-h-[6.5rem] rounded-card border border-border bg-panel hover:bg-panel-elevated transition-colors overflow-hidden no-underline"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1 py-2 pl-3 pr-11 sm:pr-8 space-y-0.5">
|
<div className="min-w-0 flex-1 py-3 pl-3 pr-11 sm:pr-8 space-y-1 flex flex-col justify-center">
|
||||||
<div className="flex items-center gap-1.5 text-[11px] text-faint min-w-0">
|
<div className="flex items-center gap-1.5 text-[11px] text-faint min-w-0">
|
||||||
{icon ? (
|
{icon ? (
|
||||||
<img src={icon} alt="" className="w-3.5 h-3.5 rounded-sm shrink-0" />
|
<img src={icon} alt="" className="w-3.5 h-3.5 rounded-sm shrink-0" />
|
||||||
@@ -64,9 +64,9 @@ export const LinkPreviewCard: React.FC<LinkPreviewCardProps> = ({ url, canEdit,
|
|||||||
<span className="truncate">{preview.siteName}</span>
|
<span className="truncate">{preview.siteName}</span>
|
||||||
</div>
|
</div>
|
||||||
{preview.title && <div className="text-sm font-semibold text-main line-clamp-2 leading-snug">{preview.title}</div>}
|
{preview.title && <div className="text-sm font-semibold text-main line-clamp-2 leading-snug">{preview.title}</div>}
|
||||||
{preview.description && <div className="text-xs text-muted truncate">{preview.description}</div>}
|
{preview.description && <div className="text-xs text-muted line-clamp-2">{preview.description}</div>}
|
||||||
</div>
|
</div>
|
||||||
{image && <img src={image} alt="" className="w-16 sm:w-20 object-cover shrink-0 border-l border-border" />}
|
{image && <img src={image} alt="" className="w-28 sm:w-40 object-cover shrink-0 border-l border-border" />}
|
||||||
</a>
|
</a>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -37,7 +37,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 { linkCardUrl, hidePreviewContent, useLinkPreviewsEnabled } from '../../lib/linkPreview';
|
import { linkCardUrl, hidePreviewContent, linkHost, 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';
|
||||||
@@ -54,6 +54,17 @@ export interface SlashCommand {
|
|||||||
aliases?: string[];
|
aliases?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The command name counts most: "/li" finds /link before /number (whose description says "Liste")
|
||||||
|
function slashRank(c: SlashCommand, q: string): number {
|
||||||
|
if (!q) return 0;
|
||||||
|
const name = c.label.slice(1).toLowerCase();
|
||||||
|
if (name.startsWith(q)) return 0;
|
||||||
|
if ((c.aliases || []).some((a) => a.startsWith(q))) return 1;
|
||||||
|
if (name.includes(q)) return 2;
|
||||||
|
if ((c.aliases || []).some((a) => a.includes(q))) return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
const SLASH_COMMANDS: SlashCommand[] = [
|
const SLASH_COMMANDS: SlashCommand[] = [
|
||||||
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
|
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
|
||||||
{ type: 'numbered', label: '/number', desc: 'Nummerierte Liste (1., a., i.)', icon: <ListOrdered className="w-3.5 h-3.5" />, aliases: ['nummer', 'liste', 'ol'] },
|
{ type: 'numbered', label: '/number', desc: 'Nummerierte Liste (1., a., i.)', icon: <ListOrdered className="w-3.5 h-3.5" />, aliases: ['nummer', 'liste', 'ol'] },
|
||||||
@@ -465,7 +476,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
}).sort((a, b) => slashRank(a, cmdQuery) - slashRank(b, cmdQuery));
|
||||||
}, [slashMatchInfo, localContent, onExtractBlock]);
|
}, [slashMatchInfo, localContent, onExtractBlock]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1062,21 +1073,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
const visualLevel = Math.max(0, level - (levelOffset || 0));
|
const visualLevel = Math.max(0, level - (levelOffset || 0));
|
||||||
const isEmpty = (!localContent || localContent.trim() === '') && !hasChildren;
|
const isEmpty = (!localContent || localContent.trim() === '') && !hasChildren;
|
||||||
|
|
||||||
// Parse rich tokens (WikiLinks [[Page]], Markdown Links [Title](url), raw URLs, and #Tags)
|
|
||||||
const tokens = useMemo(() => {
|
|
||||||
const parts = (localContent || '').split(
|
|
||||||
/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/.*?\)|\bhttps?:\/\/[^\s]+|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g
|
|
||||||
);
|
|
||||||
return parts.filter(Boolean);
|
|
||||||
}, [localContent]);
|
|
||||||
|
|
||||||
// #Hashtags are rendered inline inside the text, so only links need the badge bar below
|
|
||||||
// Wiki links are readable and clickable in the text itself, so only web links get a chip
|
|
||||||
const hasSpecialTokens = tokens.some(
|
|
||||||
(t) =>
|
|
||||||
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
|
|
||||||
(/^https?:\/\//.test(t) && t !== previewUrl)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1390,7 +1386,24 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
>
|
>
|
||||||
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
|
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
|
||||||
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
|
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
|
||||||
{localContent ? renderInlineMarkdown(localContent, { onTagClick, onWikiLinkClick }) : '\u200b'}
|
{previewUrl && !activeFocus ? (
|
||||||
|
// The card above shows the page; the address itself becomes a small link marker
|
||||||
|
<a
|
||||||
|
href={safeHref(previewUrl) || undefined}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium text-accent bg-accent-dim hover:bg-accent/20 no-underline transition-colors"
|
||||||
|
title={previewUrl}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
<span className="truncate max-w-[16rem]">{linkHost(previewUrl)}</span>
|
||||||
|
</a>
|
||||||
|
) : localContent ? (
|
||||||
|
renderInlineMarkdown(localContent, { onTagClick, onWikiLinkClick })
|
||||||
|
) : (
|
||||||
|
'\u200b'
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1506,50 +1519,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rich Tokens Bar (Wiki-Links & #Tags) if present */}
|
|
||||||
{hasSpecialTokens && !isFocused && (
|
|
||||||
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
|
||||||
{tokens.map((part, idx) => {
|
|
||||||
if (part.startsWith('[[')) return null;
|
|
||||||
const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/);
|
|
||||||
if (mdMatch && safeHref(mdMatch[2])) {
|
|
||||||
const label = mdMatch[1];
|
|
||||||
const url = safeHref(mdMatch[2]) as string;
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={idx}
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
|
|
||||||
title={url}
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-2.5 h-2.5 opacity-80" />
|
|
||||||
<span>{label || url}</span>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// The preview card above already shows this address
|
|
||||||
if (/^https?:\/\//.test(part) && safeHref(part) && part !== previewUrl) {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={idx}
|
|
||||||
href={safeHref(part) as string}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
|
|
||||||
title={part}
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-2.5 h-2.5 opacity-80" />
|
|
||||||
<span className="truncate max-w-[160px]">{part}</span>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Floating Slash Commands Menu */}
|
{/* Floating Slash Commands Menu */}
|
||||||
{slashSearch !== null && canEdit && (
|
{slashSearch !== null && canEdit && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -207,6 +207,16 @@ describe('slash command menu and outliner fixes', () => {
|
|||||||
expect(onCreateBelow).toHaveBeenCalledWith('b1', '');
|
expect(onCreateBelow).toHaveBeenCalledWith('b1', '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lists commands whose name starts with the typed text first (/li → /link before /number)', () => {
|
||||||
|
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
|
||||||
|
const { container } = renderBlock(block);
|
||||||
|
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
fireEvent.change(ta, { target: { value: '/li', selectionStart: 3, selectionEnd: 3 } });
|
||||||
|
const labels = [...container.querySelectorAll('button')].map((b) => b.textContent || '').filter((t) => t.startsWith('/'));
|
||||||
|
expect(labels[0]).toMatch(/^\/link/);
|
||||||
|
expect(labels.findIndex((l) => l.startsWith('/number'))).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps an empty indented bullet on its level when another bullet on that level follows', () => {
|
it('keeps an empty indented bullet on its level when another bullet on that level follows', () => {
|
||||||
const onIndent = vi.fn();
|
const onIndent = vi.fn();
|
||||||
const onCreateBelow = vi.fn();
|
const onCreateBelow = vi.fn();
|
||||||
|
|||||||
@@ -21,3 +21,11 @@ describe('link preview helpers', () => {
|
|||||||
expect(linkCardUrl(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', 'T'))).toBeNull();
|
expect(linkCardUrl(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', 'T'))).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('linkHost', () => {
|
||||||
|
it('shows host and path without scheme and www', async () => {
|
||||||
|
const { linkHost } = await import('../linkPreview');
|
||||||
|
expect(linkHost('https://www.example.com/')).toBe('example.com');
|
||||||
|
expect(linkHost('https://example.com/blog/post')).toBe('example.com/blog/post');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,19 @@ describe('renderInlineMarkdown', () => {
|
|||||||
expect(container.textContent).toBe('Der Block services startet');
|
expect(container.textContent).toBe('Der Block services startet');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('links bare web addresses without the trailing punctuation and keeps markdown links intact', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<div>{renderInlineMarkdown('Siehe https://example.com/a_b_c. und [Doku](https://example.org/x) oder https://x.de/#frag')}</div>
|
||||||
|
);
|
||||||
|
const links = [...container.querySelectorAll('a')].map((a) => [a.textContent, a.getAttribute('href')]);
|
||||||
|
expect(links).toEqual([
|
||||||
|
['https://example.com/a_b_c', 'https://example.com/a_b_c'],
|
||||||
|
['Doku', 'https://example.org/x'],
|
||||||
|
['https://x.de/#frag', 'https://x.de/#frag'],
|
||||||
|
]);
|
||||||
|
expect(container.textContent).toBe('Siehe https://example.com/a_b_c. und Doku oder https://x.de/#frag');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders italic text with <em> tag', () => {
|
it('renders italic text with <em> tag', () => {
|
||||||
const { container } = render(<div>{renderInlineMarkdown('Text mit *kursivem* Wort')}</div>);
|
const { container } = render(<div>{renderInlineMarkdown('Text mit *kursivem* Wort')}</div>);
|
||||||
const em = container.querySelector('em');
|
const em = container.querySelector('em');
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ export function linkCardUrl(content: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Replaces the address by a named link, which shows no preview. */
|
/** Replaces the address by a named link, which shows no preview. */
|
||||||
|
/** "example.com/pfad" for a short link marker (no scheme, no www.) */
|
||||||
|
export function linkHost(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
const path = u.pathname === '/' ? '' : u.pathname;
|
||||||
|
return u.host.replace(/^www\./, '') + path;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function hidePreviewContent(content: string, url: string, title: string): string {
|
export function hidePreviewContent(content: string, url: string, title: string): string {
|
||||||
const label = (title || new URL(url).hostname).replace(/[[\]]/g, '').trim() || url;
|
const label = (title || new URL(url).hostname).replace(/[[\]]/g, '').trim() || url;
|
||||||
return content.replace(url, `[${label}](${url})`);
|
return content.replace(url, `[${label}](${url})`);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
|
|||||||
|
|
||||||
const tagAlt = options?.onTagClick ? '|#[a-zA-Z0-9äöüÄÖÜß_-]+' : '';
|
const tagAlt = options?.onTagClick ? '|#[a-zA-Z0-9äöüÄÖÜß_-]+' : '';
|
||||||
const regex = new RegExp(
|
const regex = new RegExp(
|
||||||
'(\\[\\[[^\\]]+\\]\\]|`[^`]+`|\\*\\*[^*]+\\*\\*|__[^_]+__|~~[^~]+~~|\\*[^*]+\\*|_[^_]+_|\\[([^\\]]+)\\]\\(([^)]+)\\)' + tagAlt + ')',
|
'(\\[\\[[^\\]]+\\]\\]|https?:\\/\\/[^\\s<>()\\[\\]]+[^\\s<>()\\[\\].,;:!?"\']|`[^`]+`|\\*\\*[^*]+\\*\\*|__[^_]+__|~~[^~]+~~|\\*[^*]+\\*|_[^_]+_|\\[([^\\]]+)\\]\\(([^)]+)\\)' + tagAlt + ')',
|
||||||
'g'
|
'g'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -37,7 +37,25 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
|
|||||||
const token = match[0];
|
const token = match[0];
|
||||||
const key = `${match.index}-${token}`;
|
const key = `${match.index}-${token}`;
|
||||||
|
|
||||||
if (token.startsWith('[[') && token.endsWith(']]')) {
|
if (/^https?:\/\//.test(token)) {
|
||||||
|
const href = safeHref(token);
|
||||||
|
elements.push(
|
||||||
|
href ? (
|
||||||
|
<a
|
||||||
|
key={key}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-accent underline underline-offset-2 decoration-accent/40 hover:decoration-accent break-all"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{token}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
token
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (token.startsWith('[[') && token.endsWith(']]')) {
|
||||||
const link = parseWikiLink(token.slice(2, -2));
|
const link = parseWikiLink(token.slice(2, -2));
|
||||||
const onClick = options?.onWikiLinkClick;
|
const onClick = options?.onWikiLinkClick;
|
||||||
elements.push(
|
elements.push(
|
||||||
|
|||||||
Reference in new issue
Block a user