').html(
+ metadata.embedType === "youtube" ? "▶" : "🔗"
+ )
+ )
+ );
+ }
+
+ const $content = $('
');
+ if (metadata.title) $content.append($('
').text(metadata.title));
+ if (metadata.description) $content.append($('
').text(metadata.description));
+ $content.append($('
').text(metadata.siteName || safeHostname(metadata.url)));
+
+ $card.append($content);
+ $container.empty().append($card);
+}
+
+/**
+ * Renders the appropriate preview based on embedType.
+ * Used by ReadOnlyText and share renderer for persisted elements.
+ */
+export async function renderPreview(url: string, embedType: string, $container: JQuery
) {
+ if (embedType === "mention") {
+ await renderMention(url, $container);
+ } else {
+ await renderEmbed(url, embedType, $container);
+ }
+}
+
+export default {
+ fetchMetadata,
+ renderPreview,
+ renderMention,
+ renderEmbed,
+ detectEmbedType,
+ extractYouTubeVideoId,
+ safeHostname
+};
diff --git a/apps/client/src/services/utils.ts b/apps/client/src/services/utils.ts
index bc35a0bd3fc..f3e32b18499 100644
--- a/apps/client/src/services/utils.ts
+++ b/apps/client/src/services/utils.ts
@@ -292,6 +292,7 @@ export function isHtmlEmpty(html: string) {
return (
!html.includes("
").html(html).text().trim().length === 0
);
diff --git a/apps/client/src/widgets/dialogs/link_embed.tsx b/apps/client/src/widgets/dialogs/link_embed.tsx
new file mode 100644
index 00000000000..4731d26b991
--- /dev/null
+++ b/apps/client/src/widgets/dialogs/link_embed.tsx
@@ -0,0 +1,80 @@
+import { useRef, useState } from "preact/hooks";
+import linkEmbedService from "../../services/link_embed";
+import type { LinkPasteMode } from "../../services/link_embed";
+import FormGroup from "../react/FormGroup";
+import Modal from "../react/Modal";
+import Button from "../react/Button";
+import { useTriliumEvent } from "../react/hooks";
+import type { CKEditorApi } from "../type_widgets/text/CKEditorWithWatchdog";
+
+export interface LinkEmbedOpts {
+ editorApi: CKEditorApi;
+}
+
+export default function LinkEmbedDialog() {
+ const editorApiRef = useRef(null);
+ const [url, setUrl] = useState("");
+ const [mode, setMode] = useState("embed");
+ const [shown, setShown] = useState(false);
+ const inputRef = useRef(null);
+
+ useTriliumEvent("showLinkEmbedDialog", ({ editorApi }) => {
+ editorApiRef.current = editorApi;
+ setUrl("");
+ setMode("embed");
+ setShown(true);
+ });
+
+ const handleSubmit = () => {
+ const trimmedUrl = url.trim();
+ if (!trimmedUrl || !editorApiRef.current) return;
+
+ if (mode === "mention") {
+ editorApiRef.current.addLinkMention(trimmedUrl);
+ } else if (mode === "url") {
+ editorApiRef.current.addLinkToEditor(trimmedUrl, trimmedUrl);
+ } else {
+ const embedType = linkEmbedService.detectEmbedType(trimmedUrl);
+ editorApiRef.current.addLinkEmbed(trimmedUrl, embedType);
+ }
+ setShown(false);
+ };
+
+ return (
+ inputRef.current?.focus()}
+ onHidden={() => setShown(false)}
+ onSubmit={handleSubmit}
+ footer={}
+ show={shown}
+ >
+
+ setUrl((e.target as HTMLInputElement).value)}
+ />
+
+
+
+ {(["mention", "url", "embed"] as const).map((m) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx b/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx
index 2842c74dd68..8f3afdfee7f 100644
--- a/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx
+++ b/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx
@@ -4,6 +4,7 @@ import { HTMLProps, RefObject, useEffect, useImperativeHandle, useRef, useState
import froca from "../../../services/froca";
import link from "../../../services/link";
+import linkEmbedService from "../../../services/link_embed";
import { useKeyboardShortcuts, useLegacyImperativeHandlers, useNoteContext, useSyncedRef, useTriliumOption } from "../../react/hooks";
import { buildConfig, BuildEditorOptions } from "./config";
@@ -18,6 +19,8 @@ export interface CKEditorApi {
addHtmlToEditor(html: string): void;
addIncludeNote(noteId: string, boxSize?: BoxSize): void;
addImage(noteId: string): Promise;
+ addLinkEmbed(url: string, embedType: string): void;
+ addLinkMention(url: string): void;
}
interface CKEditorWithWatchdogProps extends Pick, "className" | "tabIndex"> {
@@ -140,11 +143,37 @@ export default function CKEditorWithWatchdog({ containerRef: externalContainerRe
editor?.execute("insertImage", { source: src });
});
},
+ addLinkEmbed(url: string, embedType: string) {
+ const editor = watchdogRef.current?.editor;
+ if (!editor) return;
+
+ editor.model.change((writer) => {
+ editor.model.insertContent(
+ writer.createElement("linkEmbed", { url, embedType })
+ );
+ });
+ },
+ addLinkMention(url: string) {
+ const editor = watchdogRef.current?.editor;
+ if (!editor) return;
+
+ editor.model.change((writer) => {
+ editor.model.insertContent(
+ writer.createElement("linkMention", { url })
+ );
+ });
+ },
}));
useLegacyImperativeHandlers({
async loadReferenceLinkTitle($el: JQuery, href: string | null = null) {
await link.loadReferenceLinkTitle($el, href);
+ },
+ loadLinkEmbedPreview(url: string, embedType: string, $el: JQuery) {
+ linkEmbedService.renderPreview(url, embedType, $el);
+ },
+ loadLinkMentionPreview(url: string, $el: JQuery) {
+ linkEmbedService.renderMention(url, $el);
}
});
diff --git a/apps/client/src/widgets/type_widgets/text/EditableText.tsx b/apps/client/src/widgets/type_widgets/text/EditableText.tsx
index fba7d6966e2..cfc46e6cd55 100644
--- a/apps/client/src/widgets/type_widgets/text/EditableText.tsx
+++ b/apps/client/src/widgets/type_widgets/text/EditableText.tsx
@@ -19,6 +19,7 @@ import TouchBar, { TouchBarButton, TouchBarGroup, TouchBarSegmentedControl } fro
import { TypeWidgetProps } from "../type_widget";
import CKEditorWithWatchdog, { CKEditorApi } from "./CKEditorWithWatchdog";
import getTemplates, { updateTemplateCache } from "./snippets.js";
+import linkEmbedService from "../../../services/link_embed";
import { loadIncludedNote, refreshIncludedNote, setupImageOpening } from "./utils";
/**
@@ -121,6 +122,19 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
});
},
loadIncludedNote,
+ // Link embed functionality
+ addLinkEmbedToTextCommand() {
+ if (!editorApiRef.current) return;
+ parentComponent?.triggerCommand("showLinkEmbedDialog", {
+ editorApi: editorApiRef.current,
+ });
+ },
+ loadLinkEmbedPreview(url: string, embedType: string, $el: JQuery) {
+ linkEmbedService.renderPreview(url, embedType, $el);
+ },
+ loadLinkMentionPreview(url: string, $el: JQuery) {
+ linkEmbedService.renderMention(url, $el);
+ },
// Creating notes in @-completion
async createNoteForReferenceLink(title: string) {
const notePath = noteContext?.notePath;
diff --git a/apps/client/src/widgets/type_widgets/text/LinkEmbed.css b/apps/client/src/widgets/type_widgets/text/LinkEmbed.css
new file mode 100644
index 00000000000..04f4e707f38
--- /dev/null
+++ b/apps/client/src/widgets/type_widgets/text/LinkEmbed.css
@@ -0,0 +1,256 @@
+/* ==========================================================================
+ Link Embed – paste popup, mention, embed/card, loading
+ ========================================================================== */
+
+/* ---------- Loading dots (bouncing) ---------- */
+
+.link-embed-loader {
+ display: inline-flex;
+ gap: 4px;
+ align-items: center;
+ padding: 8px 12px;
+}
+
+.link-embed-loader-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--muted-text-color, #999);
+ animation: link-embed-bounce 1.2s infinite ease-in-out;
+}
+
+.link-embed-loader-dot:nth-child(2) { animation-delay: 0.15s; }
+.link-embed-loader-dot:nth-child(3) { animation-delay: 0.3s; }
+
+@keyframes link-embed-bounce {
+ 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
+ 40% { transform: scale(1); opacity: 1; }
+}
+
+/* ---------- Paste popup ---------- */
+
+.link-paste-popup {
+ background: var(--main-background-color, #fff);
+ border: 1px solid var(--muted-text-color, #ddd);
+ border-radius: 8px;
+ padding: 4px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
+ display: flex;
+ flex-direction: column;
+ min-width: 150px;
+ opacity: 0;
+ transform: translateY(-4px);
+ transition: opacity 0.12s ease, transform 0.12s ease;
+}
+
+.link-paste-popup-visible {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.link-paste-popup-header {
+ font-size: 0.72em;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--muted-text-color, #999);
+ padding: 4px 10px 2px;
+}
+
+.link-paste-option {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ border: none;
+ background: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.88em;
+ color: var(--main-text-color, #333);
+ transition: background-color 0.1s;
+ width: 100%;
+ text-align: left;
+}
+
+.link-paste-option:hover,
+.link-paste-option-active {
+ background: var(--hover-item-background-color, rgba(0, 0, 0, 0.06));
+}
+
+.link-paste-option-icon {
+ width: 20px;
+ text-align: center;
+ font-size: 1em;
+ flex-shrink: 0;
+}
+
+.link-paste-option-label {
+ flex: 1;
+}
+
+/* ---------- Inline mention (favicon + title) ---------- */
+
+.link-mention {
+ display: inline;
+}
+
+.link-mention-inner {
+ display: inline;
+}
+
+.link-embed-mention {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 1px 6px 1px 4px;
+ border-radius: 4px;
+ background: var(--accented-background-color, rgba(0, 0, 0, 0.04));
+ text-decoration: none;
+ color: var(--main-text-color, #333);
+ font-size: 0.92em;
+ line-height: 1.5;
+ vertical-align: baseline;
+ transition: background-color 0.12s;
+ cursor: pointer;
+}
+
+.link-embed-mention:hover {
+ background: var(--hover-item-background-color, rgba(0, 0, 0, 0.08));
+ text-decoration: none;
+ color: var(--main-text-color, #333);
+}
+
+.link-embed-mention-favicon {
+ width: 16px;
+ height: 16px;
+ border-radius: 2px;
+ flex-shrink: 0;
+}
+
+.link-embed-mention-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--muted-text-color, #ccc);
+ flex-shrink: 0;
+}
+
+.link-embed-mention-title {
+ white-space: normal;
+ word-break: break-word;
+}
+
+/* ---------- Block embed wrapper ---------- */
+
+.link-embed {
+ margin: 8px 0;
+}
+
+.link-embed-preview-wrapper {
+ display: block;
+}
+
+/* ---------- Video embed (YouTube iframe) ---------- */
+
+.link-embed-video {
+ position: relative;
+ width: 100%;
+ max-width: 640px;
+ border-radius: 8px;
+ overflow: hidden;
+ background: #000;
+}
+
+.link-embed-video iframe {
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ border: none;
+ display: block;
+}
+
+/* ---------- Card embed (Notion-style) ---------- */
+
+.link-embed-card {
+ display: flex;
+ border: 1px solid var(--muted-text-color, #ccc);
+ border-radius: 4px;
+ overflow: hidden;
+ text-decoration: none;
+ color: inherit;
+ cursor: pointer;
+ transition: background-color 0.15s ease;
+ max-height: 120px;
+}
+
+.link-embed-card:hover {
+ background-color: var(--hover-item-background-color, rgba(0, 0, 0, 0.03));
+ text-decoration: none;
+ color: inherit;
+}
+
+.link-embed-card-image-wrapper {
+ width: 160px;
+ min-height: 100px;
+ flex-shrink: 0;
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--accented-background-color, #f5f5f5);
+}
+
+.link-embed-card-image {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.link-embed-card-image-placeholder {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ font-size: 28px;
+ opacity: 0.5;
+}
+
+.link-embed-card-content {
+ flex: 1;
+ padding: 12px 14px;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 3px;
+}
+
+.link-embed-card-title {
+ font-weight: 600;
+ font-size: 0.95em;
+ line-height: 1.3;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.link-embed-card-description {
+ font-size: 0.82em;
+ color: var(--muted-text-color, #888);
+ line-height: 1.4;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.link-embed-card-url {
+ font-size: 0.78em;
+ color: var(--muted-text-color, #999);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ margin-top: auto;
+}
diff --git a/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx b/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx
index 3352f621a08..89568ef8a67 100644
--- a/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx
+++ b/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx
@@ -17,6 +17,7 @@ import { useNoteBlob, useNoteLabel, useTriliumEvent, useTriliumOptionBool } from
import { RawHtmlBlock } from "../../react/RawHtml";
import TouchBar, { TouchBarButton, TouchBarSpacer } from "../../react/TouchBar";
import { TypeWidgetProps } from "../type_widget";
+import linkEmbedService from "../../../services/link_embed";
import { applyReferenceLinks } from "./read_only_helper";
import { loadIncludedNote, refreshIncludedNote, setupImageOpening } from "./utils";
@@ -36,6 +37,7 @@ export default function ReadOnlyText({ note, noteContext, ntxId }: TypeWidgetPro
rewriteMermaidDiagramsInContainer(container);
applyInlineMermaid(container);
applyIncludedNotes(container);
+ applyLinkEmbeds(container);
applyMath(container);
applyReferenceLinks(container);
formatCodeBlocks($(container));
@@ -98,6 +100,21 @@ function applyIncludedNotes(container: HTMLDivElement) {
}
}
+function applyLinkEmbeds(container: HTMLDivElement) {
+ for (const embed of container.querySelectorAll("section.link-embed")) {
+ const url = embed.dataset.url;
+ const embedType = embed.dataset.embedType;
+ if (!url) continue;
+ linkEmbedService.renderPreview(url, embedType || "opengraph", $(embed));
+ }
+
+ for (const mention of container.querySelectorAll("span.link-mention")) {
+ const url = mention.dataset.url;
+ if (!url) continue;
+ linkEmbedService.renderMention(url, $(mention));
+ }
+}
+
function applyMath(container: HTMLDivElement) {
const equations = container.querySelectorAll("span.math-tex");
for (const equation of equations) {
diff --git a/apps/client/src/widgets/type_widgets/text/toolbar.ts b/apps/client/src/widgets/type_widgets/text/toolbar.ts
index ae008d43deb..54b333b826a 100644
--- a/apps/client/src/widgets/type_widgets/text/toolbar.ts
+++ b/apps/client/src/widgets/type_widgets/text/toolbar.ts
@@ -76,7 +76,7 @@ export function buildClassicToolbar(multilineToolbar: boolean) {
{
label: "Insert",
icon: "plus",
- items: ["imageUpload", "|", "link", "bookmark", "internallink", "includeNote", "|", "specialCharacters", "emoji", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"]
+ items: ["imageUpload", "|", "link", "bookmark", "internallink", "includeNote", "linkEmbed", "|", "specialCharacters", "emoji", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"]
},
"|",
buildAlignmentToolbar(),
@@ -133,7 +133,7 @@ export function buildFloatingToolbar() {
{
label: "Insert",
icon: "plus",
- items: ["link", "bookmark", "internallink", "includeNote", "|", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"]
+ items: ["link", "bookmark", "internallink", "includeNote", "linkEmbed", "|", "math", "mermaid", "horizontalLine", "pageBreak", "dateTime"]
},
"|",
buildAlignmentToolbar(),
diff --git a/apps/server/src/routes/api/link_embed.ts b/apps/server/src/routes/api/link_embed.ts
new file mode 100644
index 00000000000..3e521603fd3
--- /dev/null
+++ b/apps/server/src/routes/api/link_embed.ts
@@ -0,0 +1,171 @@
+import type { Request } from "express";
+import axios from "axios";
+import { parse } from "node-html-parser";
+import type { LinkEmbedMetadata } from "@triliumnext/commons";
+import log from "../../services/log.js";
+import ValidationError from "../../errors/validation_error.js";
+
+const FETCH_TIMEOUT_MS = 5000;
+const MAX_RESPONSE_SIZE = 512 * 1024; // 512KB
+
+const YOUTUBE_REGEX = /(?:youtube\.com\/watch\?.*v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/;
+
+const BLOCKED_HOSTNAME_PATTERNS = [
+ /^localhost$/i,
+ /^127\./,
+ /^10\./,
+ /^172\.(1[6-9]|2\d|3[01])\./,
+ /^192\.168\./,
+ /^0\./,
+ /^169\.254\./,
+ /^\[::1\]$/,
+ /^\[fc/i,
+ /^\[fd/i,
+ /^\[fe80:/i
+];
+
+function extractYouTubeVideoId(url: string): string | null {
+ const match = url.match(YOUTUBE_REGEX);
+ return match ? match[1] : null;
+}
+
+function validateUrl(urlString: string): URL {
+ let parsed: URL;
+ try {
+ parsed = new URL(urlString);
+ } catch {
+ throw new ValidationError("Invalid URL");
+ }
+
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ throw new ValidationError("Only http and https URLs are supported");
+ }
+
+ for (const pattern of BLOCKED_HOSTNAME_PATTERNS) {
+ if (pattern.test(parsed.hostname)) {
+ throw new ValidationError("URLs pointing to private/internal networks are not allowed");
+ }
+ }
+
+ return parsed;
+}
+
+/**
+ * Fetches YouTube metadata via the public oEmbed endpoint.
+ * This works reliably unlike scraping youtube.com (which blocks bots).
+ */
+async function fetchYouTubeMetadata(url: string, videoId: string): Promise {
+ const metadata: LinkEmbedMetadata = {
+ url,
+ image: `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`,
+ favicon: "https://www.youtube.com/favicon.ico",
+ siteName: "YouTube",
+ embedType: "youtube"
+ };
+
+ try {
+ const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
+ const response = await axios.get(oembedUrl, {
+ timeout: FETCH_TIMEOUT_MS,
+ responseType: "json",
+ validateStatus: (status) => status >= 200 && status < 300
+ });
+
+ const data = response.data;
+ if (data.title) metadata.title = data.title;
+ if (data.author_name) metadata.description = data.author_name;
+ if (data.thumbnail_url) metadata.image = data.thumbnail_url;
+ } catch {
+ metadata.title = "YouTube Video";
+ }
+
+ return metadata;
+}
+
+async function fetchOpenGraphData(url: string) {
+ const response = await axios.get(url, {
+ timeout: FETCH_TIMEOUT_MS,
+ maxContentLength: MAX_RESPONSE_SIZE,
+ maxBodyLength: MAX_RESPONSE_SIZE,
+ maxRedirects: 3,
+ responseType: "text",
+ headers: {
+ "User-Agent": "TriliumBot/1.0 (Link Preview)",
+ "Accept": "text/html"
+ },
+ validateStatus: (status) => status >= 200 && status < 300
+ });
+
+ const html = typeof response.data === "string" ? response.data : String(response.data);
+ const document = parse(html);
+
+ const getMeta = (property: string): string | undefined => {
+ const ogEl = document.querySelector(`meta[property="${property}"]`);
+ if (ogEl) return ogEl.getAttribute("content") || undefined;
+
+ const nameEl = document.querySelector(`meta[name="${property}"]`);
+ if (nameEl) return nameEl.getAttribute("content") || undefined;
+
+ return undefined;
+ };
+
+ const faviconEl = document.querySelector('link[rel="icon"]')
+ || document.querySelector('link[rel="shortcut icon"]')
+ || document.querySelector('link[rel="apple-touch-icon"]');
+ let favicon: string | undefined;
+ if (faviconEl) {
+ const href = faviconEl.getAttribute("href");
+ if (href) {
+ try { favicon = new URL(href, url).toString(); } catch { /* ignore */ }
+ }
+ }
+ if (!favicon) {
+ try { favicon = `${new URL(url).origin}/favicon.ico`; } catch { /* ignore */ }
+ }
+
+ return {
+ title: getMeta("og:title") || document.querySelector("title")?.textContent || undefined,
+ description: getMeta("og:description") || getMeta("description") || undefined,
+ image: getMeta("og:image") || undefined,
+ siteName: getMeta("og:site_name") || undefined,
+ favicon
+ };
+}
+
+async function getMetadata(req: Request) {
+ const urlParam = req.query.url;
+
+ if (!urlParam || typeof urlParam !== "string") {
+ throw new ValidationError("'url' query parameter is required");
+ }
+
+ const validatedUrl = validateUrl(urlParam);
+ const url = validatedUrl.toString();
+ const videoId = extractYouTubeVideoId(url);
+
+ if (videoId) {
+ return await fetchYouTubeMetadata(url, videoId);
+ }
+
+ try {
+ const ogData = await fetchOpenGraphData(url);
+ return {
+ url,
+ title: ogData.title,
+ description: ogData.description,
+ image: ogData.image,
+ favicon: ogData.favicon,
+ siteName: ogData.siteName,
+ embedType: "opengraph"
+ } satisfies LinkEmbedMetadata;
+ } catch (e: unknown) {
+ log.info(`Failed to fetch metadata for ${url}: ${e}`);
+ return {
+ url,
+ title: validatedUrl.hostname,
+ embedType: "opengraph"
+ } satisfies LinkEmbedMetadata;
+ }
+}
+
+export default { getMetadata };
diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts
index ce9b84f0a9b..5b82b7d40d7 100644
--- a/apps/server/src/routes/routes.ts
+++ b/apps/server/src/routes/routes.ts
@@ -58,6 +58,7 @@ import syncApiRoute from "./api/sync.js";
import systemInfoRoute from "./api/system_info.js";
import totp from './api/totp.js';
// API routes
+import linkEmbedRoute from "./api/link_embed.js";
import treeApiRoute from "./api/tree.js";
import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js";
import * as indexRoute from "./index.js";
@@ -343,6 +344,8 @@ function register(app: express.Application) {
apiRoute(GET, "/api/other/icon-usage", otherRoute.getIconUsage);
apiRoute(PST, "/api/other/render-markdown", otherRoute.renderMarkdown);
apiRoute(PST, "/api/other/to-markdown", otherRoute.toMarkdown);
+ asyncApiRoute(GET, "/api/link-embed/metadata", linkEmbedRoute.getMetadata);
+
apiRoute(GET, "/api/recent-changes/:ancestorNoteId", recentChangesApiRoute.getRecentChanges);
apiRoute(GET, "/api/edited-notes/:date", revisionsApiRoute.getEditedNotesOnDate);
diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts
index 035f714b613..9a3f56bb22a 100644
--- a/apps/server/src/share/content_renderer.ts
+++ b/apps/server/src/share/content_renderer.ts
@@ -318,6 +318,36 @@ function renderText(result: Result, note: SNote | BNote) {
};
const document = parse(result.content || "", parseOpts);
+ // Process link mentions (inline).
+ for (const mentionEl of document.querySelectorAll("span.link-mention")) {
+ const url = mentionEl.getAttribute("data-url");
+ if (!url) continue;
+ const hostname = safeHostnameForShare(url);
+ const favicon = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(hostname)}&sz=32`;
+ mentionEl.innerHTML = `` +
+ `
` +
+ `${escapeHtml(hostname)}`;
+ }
+
+ // Process link embeds (block).
+ for (const embedEl of document.querySelectorAll("section.link-embed")) {
+ const url = embedEl.getAttribute("data-url");
+ const embedType = embedEl.getAttribute("data-embed-type");
+ if (!url) continue;
+
+ if (embedType === "youtube") {
+ const videoId = extractYouTubeVideoIdForShare(url);
+ if (videoId) {
+ embedEl.innerHTML = ``;
+ }
+ } else {
+ const hostname = safeHostnameForShare(url);
+ embedEl.innerHTML = `` +
+ `` +
+ `${escapeHtml(hostname)}
${escapeHtml(url)}
`;
+ }
+ }
+
// Process include notes.
for (const includeNoteEl of document.querySelectorAll("section.include-note")) {
const noteId = includeNoteEl.getAttribute("data-note-id");
@@ -505,6 +535,15 @@ function renderWebView(note: SNote | BNote, result: Result) {
result.content = ``;
}
+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;
+}
+
+function safeHostnameForShare(url: string): string {
+ try { return new URL(url).hostname; } catch { return url; }
+}
+
export default {
getContent
};
diff --git a/packages/ckeditor5/src/augmentation.ts b/packages/ckeditor5/src/augmentation.ts
index 6f05b9f2a9e..72b0ad14d75 100644
--- a/packages/ckeditor5/src/augmentation.ts
+++ b/packages/ckeditor5/src/augmentation.ts
@@ -9,6 +9,8 @@ declare global {
loadReferenceLinkTitle($el: JQuery, href: string): Promise;
createNoteForReferenceLink(title: string): Promise;
loadIncludedNote(noteId: string, $el: JQuery): void;
+ loadLinkEmbedPreview(url: string, embedType: string, $el: JQuery): void;
+ loadLinkMentionPreview(url: string, $el: JQuery): void;
}
var glob: {
diff --git a/packages/ckeditor5/src/icons/link-embed.svg b/packages/ckeditor5/src/icons/link-embed.svg
new file mode 100644
index 00000000000..fccfa49ea9b
--- /dev/null
+++ b/packages/ckeditor5/src/icons/link-embed.svg
@@ -0,0 +1 @@
+
diff --git a/packages/ckeditor5/src/plugins.ts b/packages/ckeditor5/src/plugins.ts
index abf51f08691..9f0bd85cff9 100644
--- a/packages/ckeditor5/src/plugins.ts
+++ b/packages/ckeditor5/src/plugins.ts
@@ -14,6 +14,7 @@ import IndentBlockShortcutPlugin from "./plugins/indent_block_shortcut.js";
import MarkdownImportPlugin from "./plugins/markdownimport.js";
import MentionCustomization from "./plugins/mention_customization.js";
import IncludeNote from "./plugins/includenote.js";
+import LinkEmbed from "./plugins/linkembed.js";
import Uploadfileplugin from "./plugins/file_upload/uploadfileplugin.js";
import SyntaxHighlighting from "./plugins/syntax_highlighting/index.js";
import { Kbd } from "@triliumnext/ckeditor5-keyboard-marker";
@@ -46,6 +47,7 @@ const TRILIUM_PLUGINS: typeof Plugin[] = [
IndentBlockShortcutPlugin,
MarkdownImportPlugin,
IncludeNote,
+ LinkEmbed,
Uploadfileplugin,
SyntaxHighlighting,
CodeBlockLanguageDropdown,
diff --git a/packages/ckeditor5/src/plugins/includenote.ts b/packages/ckeditor5/src/plugins/includenote.ts
index 1ce633f357d..8f9eda389c7 100644
--- a/packages/ckeditor5/src/plugins/includenote.ts
+++ b/packages/ckeditor5/src/plugins/includenote.ts
@@ -1,5 +1,6 @@
-import { ButtonView, Command, Plugin, toWidget, Widget, type Editor, type Observable } from 'ckeditor5';
+import { ButtonView, Command, Plugin, toWidget, Widget, type Observable } from 'ckeditor5';
import noteIcon from '../icons/note.svg?raw';
+import { preventCKEditorHandling } from './widget_utils.js';
export const COMMAND_NAME = 'insertIncludeNote';
@@ -154,25 +155,3 @@ class InsertIncludeNoteCommand extends Command {
}
}
-/**
- * Hack coming from https://github.com/ckeditor/ckeditor5/issues/4465
- * Source issue: https://github.com/zadam/trilium/issues/1117
- */
-function preventCKEditorHandling( domElement: HTMLElement, editor: Editor ) {
- // Prevent the editor from listening on below events in order to stop rendering selection.
-
- // commenting out click events to allow link click handler to still work
- //domElement.addEventListener( 'click', stopEventPropagationAndHackRendererFocus, { capture: true } );
- domElement.addEventListener( 'mousedown', stopEventPropagationAndHackRendererFocus, { capture: true } );
- domElement.addEventListener( 'focus', stopEventPropagationAndHackRendererFocus, { capture: true } );
-
- // Prevents TAB handling or other editor keys listeners which might be executed on editors selection.
- domElement.addEventListener( 'keydown', stopEventPropagationAndHackRendererFocus, { capture: true } );
-
- function stopEventPropagationAndHackRendererFocus( evt: Event ) {
- evt.stopPropagation();
- // This prevents rendering changed view selection thus preventing to changing DOM selection while inside a widget.
- //@ts-expect-error: We are accessing a private field.
- editor.editing.view._renderer.isFocused = false;
- }
-}
diff --git a/packages/ckeditor5/src/plugins/linkembed.ts b/packages/ckeditor5/src/plugins/linkembed.ts
new file mode 100644
index 00000000000..0d491658047
--- /dev/null
+++ b/packages/ckeditor5/src/plugins/linkembed.ts
@@ -0,0 +1,458 @@
+import { ButtonView, Command, Plugin, toWidget, Widget, type Observable } from 'ckeditor5';
+import type { Position } from 'ckeditor5';
+import linkEmbedIcon from '../icons/link-embed.svg?raw';
+import { preventCKEditorHandling } from './widget_utils.js';
+
+export const LINK_EMBED_COMMAND = 'insertLinkEmbed';
+
+const EMBEDDABLE_URL_REGEX = /^https?:\/\/\S+$/;
+const YOUTUBE_URL_REGEX = /(?:youtube\.com|youtu\.be)/;
+
+function isEmbeddableUrl(text: string): boolean {
+ return EMBEDDABLE_URL_REGEX.test(text.trim());
+}
+
+export default class LinkEmbed extends Plugin {
+ static get requires() {
+ return [LinkEmbedEditing, LinkEmbedUI, LinkEmbedPasteHandler];
+ }
+}
+
+class LinkEmbedUI extends Plugin {
+ init() {
+ const editor = this.editor;
+
+ editor.ui.componentFactory.add('linkEmbed', locale => {
+ const command = editor.commands.get(LINK_EMBED_COMMAND);
+ const buttonView = new ButtonView(locale);
+
+ buttonView.set({
+ label: editor.t('Link preview'),
+ icon: linkEmbedIcon,
+ tooltip: true
+ });
+
+ if (command) {
+ buttonView.bind('isOn', 'isEnabled').to(
+ command as Observable & { value: boolean } & { isEnabled: boolean },
+ 'value', 'isEnabled'
+ );
+ }
+
+ this.listenTo(buttonView, 'execute', () => editor.execute(LINK_EMBED_COMMAND));
+ return buttonView;
+ });
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Schema + converters for linkEmbed (block) and linkMention (inline)
+// ---------------------------------------------------------------------------
+
+class LinkEmbedEditing extends Plugin {
+ static get requires() {
+ return [Widget];
+ }
+
+ init() {
+ this._defineSchema();
+ this._defineConverters();
+ this.editor.commands.add(LINK_EMBED_COMMAND, new InsertLinkEmbedCommand(this.editor));
+ }
+
+ _defineSchema() {
+ const schema = this.editor.model.schema;
+
+ schema.register('linkEmbed', {
+ isObject: true,
+ allowAttributes: ['url', 'embedType'],
+ allowWhere: '$block'
+ });
+
+ schema.register('linkMention', {
+ isInline: true,
+ isObject: true,
+ allowAttributes: ['url'],
+ allowWhere: '$text'
+ });
+ }
+
+ _defineConverters() {
+ const editor = this.editor;
+ const conversion = editor.conversion;
+
+ // ===== linkEmbed (block) =====
+
+ conversion.for('upcast').elementToElement({
+ model: (viewElement, { writer }) => {
+ return writer.createElement('linkEmbed', {
+ url: viewElement.getAttribute('data-url'),
+ embedType: viewElement.getAttribute('data-embed-type')
+ });
+ },
+ view: { name: 'section', classes: 'link-embed' }
+ });
+
+ conversion.for('dataDowncast').elementToElement({
+ model: 'linkEmbed',
+ view: (modelElement, { writer }) => {
+ return writer.createContainerElement('section', {
+ class: 'link-embed',
+ 'data-url': modelElement.getAttribute('url'),
+ 'data-embed-type': modelElement.getAttribute('embedType')
+ });
+ }
+ });
+
+ conversion.for('editingDowncast').elementToElement({
+ model: 'linkEmbed',
+ view: (modelElement, { writer }) => {
+ const url = modelElement.getAttribute('url') as string;
+ const embedType = modelElement.getAttribute('embedType') as string;
+
+ const section = writer.createContainerElement('section', {
+ class: 'link-embed',
+ 'data-url': url,
+ 'data-embed-type': embedType
+ });
+
+ const preview = writer.createUIElement('div', {
+ class: 'link-embed-preview-wrapper',
+ 'data-cke-ignore-events': 'true'
+ }, function (domDocument) {
+ const domElement = this.toDomElement(domDocument);
+ const editorEl = editor.editing.view.getDomRoot();
+ const component = glob.getComponentByEl(editorEl);
+ component.loadLinkEmbedPreview(url, embedType, $(domElement));
+ preventCKEditorHandling(domElement, editor);
+ return domElement;
+ });
+
+ writer.insert(writer.createPositionAt(section, 0), preview);
+ return toWidget(section, writer, { label: 'link embed widget' });
+ }
+ });
+
+ // ===== linkMention (inline) =====
+
+ conversion.for('upcast').elementToElement({
+ model: (viewElement, { writer }) => {
+ return writer.createElement('linkMention', {
+ url: viewElement.getAttribute('data-url')
+ });
+ },
+ view: { name: 'span', classes: 'link-mention' }
+ });
+
+ conversion.for('dataDowncast').elementToElement({
+ model: 'linkMention',
+ view: (modelElement, { writer }) => {
+ return writer.createContainerElement('span', {
+ class: 'link-mention',
+ 'data-url': modelElement.getAttribute('url')
+ });
+ }
+ });
+
+ conversion.for('editingDowncast').elementToElement({
+ model: 'linkMention',
+ view: (modelElement, { writer }) => {
+ const url = modelElement.getAttribute('url') as string;
+
+ const span = writer.createContainerElement('span', {
+ class: 'link-mention',
+ 'data-url': url
+ });
+
+ const inner = writer.createUIElement('span', {
+ class: 'link-mention-inner',
+ 'data-cke-ignore-events': 'true'
+ }, function (domDocument) {
+ const domElement = this.toDomElement(domDocument);
+ const editorEl = editor.editing.view.getDomRoot();
+ const component = glob.getComponentByEl(editorEl);
+ component.loadLinkMentionPreview(url, $(domElement));
+ preventCKEditorHandling(domElement, editor);
+ return domElement;
+ });
+
+ writer.insert(writer.createPositionAt(span, 0), inner);
+ return toWidget(span, writer, { label: 'link mention widget', hasSelectionHandle: false });
+ }
+ });
+ }
+}
+
+class InsertLinkEmbedCommand extends Command {
+ override execute() {
+ const editorEl = this.editor.editing.view.getDomRoot();
+ const component = glob.getComponentByEl(editorEl);
+ component.triggerCommand('addLinkEmbedToText');
+ }
+
+ override refresh() {
+ const model = this.editor.model;
+ const selection = model.document.selection;
+ const firstPosition = selection.getFirstPosition();
+ const allowedIn = firstPosition && model.schema.findAllowedParent(firstPosition, 'linkEmbed');
+ this.isEnabled = allowedIn !== null;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Paste handler: URL is pasted normally, then a popup offers conversion
+// ---------------------------------------------------------------------------
+
+interface PasteLocation {
+ parentPath: number[];
+ childOffset: number;
+}
+
+class LinkEmbedPasteHandler extends Plugin {
+ private _popup: HTMLElement | null = null;
+ private _keyHandler: ((e: KeyboardEvent) => void) | null = null;
+ private _clickOutside: ((e: MouseEvent) => void) | null = null;
+
+ init() {
+ const editor = this.editor;
+
+ editor.model.document.on('change:data', () => {
+ const changes = editor.model.document.differ.getChanges();
+ if (changes.length === 0) return;
+
+ for (const change of changes) {
+ if (change.type !== 'insert' || change.name !== '$text') continue;
+
+ const pos: Position = change.position;
+ const parent = pos.parent;
+ if (!parent || !parent.is('element')) continue;
+
+ const textNode = parent.getChild(pos.offset);
+ if (!textNode || !textNode.is('$text')) continue;
+
+ const trimmed = textNode.data.trim();
+ if (!isEmbeddableUrl(trimmed) || trimmed.includes('\n')) continue;
+
+ const location: PasteLocation = {
+ parentPath: parent.getPath(),
+ childOffset: pos.offset
+ };
+
+ // Show popup after the current batch finishes and DOM stabilizes
+ Promise.resolve().then(() => this._showPastePopup(trimmed, location));
+ return;
+ }
+ });
+ }
+
+ private _showPastePopup(url: string, location: PasteLocation) {
+ this._removePopup();
+
+ const rect = this._getCaretRect();
+ if (!rect) return;
+
+ const MODES = [
+ { mode: 'mention', icon: '@', label: 'Mention' },
+ { mode: 'url', icon: '\u{1F517}', label: 'URL' },
+ { mode: 'embed', icon: '\u25B6', label: 'Embed' }
+ ] as const;
+
+ const popup = document.createElement('div');
+ popup.className = 'link-paste-popup';
+
+ const header = document.createElement('div');
+ header.className = 'link-paste-popup-header';
+ header.textContent = 'Convert to';
+ popup.appendChild(header);
+
+ for (const { mode, icon, label } of MODES) {
+ const btn = document.createElement('button');
+ btn.className = 'link-paste-option';
+ btn.dataset.mode = mode;
+ btn.innerHTML = `${icon}${label}`;
+ popup.appendChild(btn);
+ }
+
+ popup.style.position = 'fixed';
+ popup.style.left = `${rect.left}px`;
+ popup.style.top = `${rect.bottom + 6}px`;
+ popup.style.zIndex = '10000';
+ document.body.appendChild(popup);
+
+ let activeIndex = 0;
+ const options = popup.querySelectorAll('.link-paste-option');
+ options[0].classList.add('link-paste-option-active');
+
+ requestAnimationFrame(() => popup.classList.add('link-paste-popup-visible'));
+
+ const setActive = (index: number) => {
+ options[activeIndex].classList.remove('link-paste-option-active');
+ activeIndex = ((index % options.length) + options.length) % options.length;
+ options[activeIndex].classList.add('link-paste-option-active');
+ };
+
+ for (let i = 0; i < options.length; i++) {
+ options[i].addEventListener('mouseenter', () => setActive(i));
+ }
+
+ const confirm = () => {
+ const mode = options[activeIndex].dataset.mode as typeof MODES[number]['mode'];
+ this._removePopup();
+ if (mode !== 'url') {
+ this._convertPastedUrl(url, mode, location);
+ }
+ };
+
+ popup.addEventListener('click', (e: Event) => {
+ if ((e.target as HTMLElement).closest('.link-paste-option')) confirm();
+ });
+
+ const keyHandler = (e: KeyboardEvent) => {
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault();
+ e.stopPropagation();
+ setActive(activeIndex + 1);
+ break;
+ case 'ArrowUp':
+ e.preventDefault();
+ e.stopPropagation();
+ setActive(activeIndex - 1);
+ break;
+ case 'Enter':
+ e.preventDefault();
+ e.stopPropagation();
+ confirm();
+ break;
+ case 'Escape':
+ e.preventDefault();
+ e.stopPropagation();
+ this._removePopup();
+ break;
+ }
+ };
+
+ const clickOutside = (e: MouseEvent) => {
+ if (!popup.contains(e.target as Node)) {
+ this._removePopup();
+ }
+ };
+
+ setTimeout(() => {
+ document.addEventListener('keydown', keyHandler, true);
+ document.addEventListener('mousedown', clickOutside);
+ }, 0);
+
+ this._popup = popup;
+ this._keyHandler = keyHandler;
+ this._clickOutside = clickOutside;
+ }
+
+ /** Measures caret position via a temporary zero-width marker. */
+ private _getCaretRect(): DOMRect | null {
+ const sel = window.getSelection();
+ if (sel && sel.rangeCount > 0) {
+ const range = sel.getRangeAt(0).cloneRange();
+ range.collapse(false);
+
+ const marker = document.createElement('span');
+ marker.textContent = '\u200B';
+ range.insertNode(marker);
+
+ const rect = marker.getBoundingClientRect();
+ const result = new DOMRect(rect.left, rect.top, rect.width, rect.height);
+
+ marker.remove();
+ sel.removeAllRanges();
+ sel.addRange(range);
+
+ if (result.top > 0 || result.left > 0) return result;
+ }
+
+ const editorEl = this.editor.editing.view.getDomRoot();
+ if (editorEl) {
+ const elRect = editorEl.getBoundingClientRect();
+ return new DOMRect(elRect.left + 16, elRect.top + 16, 0, 20);
+ }
+
+ return null;
+ }
+
+ private _removePopup() {
+ if (this._popup) {
+ this._popup.remove();
+ this._popup = null;
+ }
+ if (this._keyHandler) {
+ document.removeEventListener('keydown', this._keyHandler, true);
+ this._keyHandler = null;
+ }
+ if (this._clickOutside) {
+ document.removeEventListener('mousedown', this._clickOutside);
+ this._clickOutside = null;
+ }
+ }
+
+ /**
+ * Replaces the pasted URL at the captured position with the chosen format.
+ * Searches only within the specific parent element and picks the match
+ * closest to the recorded child offset.
+ */
+ private _convertPastedUrl(url: string, mode: 'mention' | 'embed', location: PasteLocation) {
+ const editor = this.editor;
+
+ editor.model.change((writer) => {
+ const root = editor.model.document.getRoot();
+ if (!root) return;
+
+ // Resolve the parent element from the captured path
+ let parentEl = root as ReturnType;
+ 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;
+
+ // Find the URL text closest to the recorded offset
+ const parentRange = writer.createRangeIn(parentEl);
+ let bestStart: Position | null = null;
+ let bestEnd: Position | null = null;
+ let bestDistance = Infinity;
+
+ for (const item of parentRange.getWalker()) {
+ if (!item.item.is('$textProxy')) continue;
+
+ const text = item.item.data;
+ const idx = text.indexOf(url);
+ if (idx === -1) continue;
+
+ const startOff = item.item.startOffset! + idx;
+ const distance = Math.abs(startOff - location.childOffset);
+
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ bestStart = writer.createPositionAt(parentEl, startOff);
+ bestEnd = writer.createPositionAt(parentEl, startOff + url.length);
+ }
+ }
+
+ if (!bestStart || !bestEnd) return;
+
+ const urlRange = writer.createRange(bestStart, bestEnd);
+ writer.setSelection(urlRange);
+ editor.model.deleteContent(editor.model.document.selection);
+
+ if (mode === 'mention') {
+ editor.model.insertContent(writer.createElement('linkMention', { url }));
+ } else {
+ const embedType = YOUTUBE_URL_REGEX.test(url) ? 'youtube' : 'opengraph';
+ editor.model.insertContent(writer.createElement('linkEmbed', { url, embedType }));
+ }
+ });
+ }
+
+ override destroy() {
+ this._removePopup();
+ super.destroy();
+ }
+}
diff --git a/packages/ckeditor5/src/plugins/widget_utils.ts b/packages/ckeditor5/src/plugins/widget_utils.ts
new file mode 100644
index 00000000000..42ad64291d2
--- /dev/null
+++ b/packages/ckeditor5/src/plugins/widget_utils.ts
@@ -0,0 +1,18 @@
+import type { Editor } from "ckeditor5";
+
+/**
+ * Hack coming from https://github.com/ckeditor/ckeditor5/issues/4465
+ * Prevents CKEditor from handling events inside widget UI elements.
+ */
+export function preventCKEditorHandling(domElement: HTMLElement, editor: Editor) {
+ domElement.addEventListener("mousedown", stopEventPropagationAndHackRendererFocus, { capture: true });
+ domElement.addEventListener("focus", stopEventPropagationAndHackRendererFocus, { capture: true });
+ domElement.addEventListener("keydown", stopEventPropagationAndHackRendererFocus, { capture: true });
+
+ function stopEventPropagationAndHackRendererFocus(evt: Event) {
+ evt.stopPropagation();
+ // This prevents rendering changed view selection thus preventing to changing DOM selection while inside a widget.
+ //@ts-expect-error: We are accessing a private field.
+ editor.editing.view._renderer.isFocused = false;
+ }
+}
diff --git a/packages/commons/src/index.ts b/packages/commons/src/index.ts
index b208bfd3b67..d879349c031 100644
--- a/packages/commons/src/index.ts
+++ b/packages/commons/src/index.ts
@@ -16,3 +16,4 @@ export * from "./lib/notes.js";
export * from "./lib/week_utils.js";
export { default as BUILTIN_ATTRIBUTES } from "./lib/builtin_attributes.js";
export * from "./lib/spreadsheet/render_to_html.js";
+export * from "./lib/link_embed.js";
diff --git a/packages/commons/src/lib/link_embed.ts b/packages/commons/src/lib/link_embed.ts
new file mode 100644
index 00000000000..3e7ef635271
--- /dev/null
+++ b/packages/commons/src/lib/link_embed.ts
@@ -0,0 +1,9 @@
+export interface LinkEmbedMetadata {
+ url: string;
+ title?: string;
+ description?: string;
+ image?: string;
+ favicon?: string;
+ siteName?: string;
+ embedType: "youtube" | "opengraph";
+}