Skip to content

feat: add link embed previews (mention, URL, embed)#9188

Open
stexz01 wants to merge 3 commits intoTriliumNext:mainfrom
stexz01:feat/link-embed-previews
Open

feat: add link embed previews (mention, URL, embed)#9188
stexz01 wants to merge 3 commits intoTriliumNext:mainfrom
stexz01:feat/link-embed-previews

Conversation

@stexz01
Copy link
Copy Markdown

@stexz01 stexz01 commented Mar 26, 2026

Summary

Adds Notion-style link embed previews to Trilium Notes. When a user pastes a URL into a text note, a floating popup appears offering three conversion options:

  • @ Mention — inline chip with favicon + page title (YouTube shows channel + video title)
  • 🔗 URL — plain clickable link (default, keeps the pasted link as-is)
  • ▶ Embed — YouTube: full iframe video player / Other URLs: OpenGraph card preview

How it works

  1. User pastes a URL → it gets inserted as a normal link
  2. A "Convert to" popup appears at the cursor with keyboard navigation (↑↓ + Enter)
  3. Selecting Mention or Embed replaces the pasted link at the exact cursor position
  4. Selecting URL or pressing Escape/clicking outside dismisses the popup, link stays

Technical details

  • Server endpoint (GET /api/link-embed/metadata): Fetches OpenGraph metadata + favicon. Uses YouTube oEmbed API for reliable video titles. SSRF protection (blocks private IPs).
  • CKEditor plugin (linkembed.ts): Two model elements — linkEmbed (block widget) and linkMention (inline widget). Paste detection via change:data listener with position tracking.
  • Rendering: Works in editable mode, read-only mode, and shared/published notes.
  • No sanitizer changes needed: Uses <section> and <span> with data-* attributes (already allowed).

Files changed

  • 9 new files, 12 modified files
  • 14 unit tests for URL parsing utilities

Test plan

  • Paste a YouTube URL → popup appears at cursor → select Mention → shows favicon + video title inline
  • Paste a YouTube URL → select Embed → shows playable iframe video
  • Paste a regular URL (e.g. github.com) → select Mention → shows favicon + page title
  • Paste a regular URL → select Embed → shows OpenGraph card with image/title/description
  • Paste a URL → press Escape → URL stays as plain link
  • Paste same URL twice in different locations → converting one doesn't affect the other
  • Arrow keys navigate popup options, Enter confirms, Escape dismisses
  • Switch note to read-only → embeds and mentions render correctly
  • Toolbar button (Insert > Link preview) → dialog opens → can insert all three modes
  • Run pnpm test:parallel → all 141 tests pass

When pasting a URL, a floating popup offers three conversion options:
- Mention: inline chip with favicon + page title
- URL: plain clickable link (default, no conversion needed)
- Embed: YouTube iframe player or OpenGraph card preview

Features:
- Server-side metadata API using YouTube oEmbed + OpenGraph scraping
- CKEditor plugin with paste detection and keyboard navigation (arrows + enter)
- Popup appears at cursor position, first option highlighted by default
- Converts the exact pasted URL instance (not first-found duplicate)
- Bouncing dots loading animation while fetching metadata
- Read-only and share/published note rendering
- SSRF protection on server endpoint
- 14 unit tests for URL parsing utilities

New files: 9 | Modified files: 12

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@dosubot dosubot bot added the size:XXL This PR changes 1000+ lines, ignoring generated files. label Mar 26, 2026
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive "Link Embed" feature, enabling users to insert links as inline mentions, standard URLs, or rich embeds like YouTube iframes and OpenGraph cards. The implementation includes a client-side service, a new server-side metadata fetching API with security validations, and a CKEditor 5 plugin featuring a paste-to-convert popup. Feedback highlights opportunities for code cleanup, such as removing unused parameters, refactoring complex JSX logic into mapping objects, improving error logging for metadata fetching, and centralizing duplicated regex patterns.

// Embed renderer (YouTube = iframe, otherwise = card)
// ---------------------------------------------------------------------------

export async function renderEmbed(url: string, embedType: string, $container: JQuery<HTMLElement>) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The embedType parameter is unused within the renderEmbed function. The function re-determines if the URL is for a YouTube video by calling extractYouTubeVideoId. To simplify the code, you can remove the embedType parameter from this function's signature. You will also need to update its call site in renderPreview.

Suggested change
export async function renderEmbed(url: string, embedType: string, $container: JQuery<HTMLElement>) {
export async function renderEmbed(url: string, $container: JQuery<HTMLElement>) {

if (embedType === "mention") {
await renderMention(url, $container);
} else {
await renderEmbed(url, embedType, $container);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To accompany the removal of the unused embedType parameter from renderEmbed, update this call to no longer pass it.

Suggested change
await renderEmbed(url, embedType, $container);
await renderEmbed(url, $container);

className={`btn btn-sm ${mode === m ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => setMode(m)}
>
{m === "mention" ? "@ Mention" : m === "url" ? "URL" : "Embed"}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This chain of ternary operators for labels can be hard to read and maintain. Consider defining a mapping object from mode to label outside the JSX for better clarity and scalability.

For example:

const MODE_LABELS: Record<LinkPasteMode, string> = {
    mention: "@ Mention",
    url: "URL",
    embed: "Embed"
};

// ... then in your JSX
{MODE_LABELS[m]}

if (faviconEl) {
const href = faviconEl.getAttribute("href");
if (href) {
try { favicon = new URL(href, url).toString(); } catch { /* ignore */ }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Silently ignoring errors in a catch block can hide potential issues. While a failure to construct a favicon URL might not be critical, logging the error would be beneficial for debugging.

Suggested change
try { favicon = new URL(href, url).toString(); } catch { /* ignore */ }
try { favicon = new URL(href, url).toString(); } catch (e) { log.info(`Could not construct favicon URL from href '${href}' with base '${url}': ${e}`); }

}
}
if (!favicon) {
try { favicon = `${new URL(url).origin}/favicon.ico`; } catch { /* ignore */ }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the previous point, silently ignoring errors here can hide issues. It would be better to log the error for debugging purposes.

Suggested change
try { favicon = `${new URL(url).origin}/favicon.ico`; } catch { /* ignore */ }
try { favicon = `${new URL(url).origin}/favicon.ico`; } catch (e) { log.info(`Could not construct fallback favicon URL for '${url}': ${e}`); }

Comment on lines +538 to +541
function extractYouTubeVideoIdForShare(url: string): string | null {
const match = url.match(/(?:youtube\.com\/watch\?.*v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/);
return match ? match[1] : null;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The YouTube regular expression is duplicated in several places (apps/client/src/services/link_embed.ts, apps/server/src/routes/api/link_embed.ts, and here). To improve maintainability and ensure consistency, consider defining this regex in a shared location, for example, in a constants file within the @triliumnext/commons package, and importing it where needed.

Comment on lines +409 to +414
let parentEl = root as ReturnType<typeof root.getChild>;
for (const idx of location.parentPath) {
if (!parentEl || typeof (parentEl as any).getChild !== 'function') return;
parentEl = (parentEl as any).getChild(idx);
}
if (!parentEl || !parentEl.is('element')) return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This loop to resolve the parent element is a bit hard to follow due to type assertions (as any) and late validation. You can make it more robust and readable by performing checks inside the loop.

Suggested change
let parentEl = root as ReturnType<typeof root.getChild>;
for (const idx of location.parentPath) {
if (!parentEl || typeof (parentEl as any).getChild !== 'function') return;
parentEl = (parentEl as any).getChild(idx);
}
if (!parentEl || !parentEl.is('element')) return;
let parentEl: Element | RootElement = root;
for (const idx of location.parentPath) {
const child = parentEl.getChild(idx);
// The path should always point to an element.
if (!child || !child.is('element')) {
return;
}
parentEl = child;
}

@stexz01
Copy link
Copy Markdown
Author

stexz01 commented Mar 26, 2026

triliumembed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-conflicts size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants