Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/client/src/components/app_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { AddLinkOpts } from "../widgets/dialogs/add_link.jsx";
import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js";
import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js";
import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx";
import { LinkEmbedOpts } from "../widgets/dialogs/link_embed.jsx";
import type { InfoProps } from "../widgets/dialogs/info.jsx";
import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx";
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
Expand Down Expand Up @@ -224,6 +225,7 @@ export type CommandMappings = {
showProtectedSessionPasswordDialog: CommandData;
showUploadAttachmentsDialog: CommandData & { noteId: string };
showIncludeNoteDialog: CommandData & IncludeNoteOpts;
showLinkEmbedDialog: CommandData & LinkEmbedOpts;
showAddLinkDialog: CommandData & AddLinkOpts;
showPasteMarkdownDialog: CommandData & MarkdownImportOpts;
closeProtectedSessionPasswordDialog: CommandData;
Expand Down
2 changes: 2 additions & 0 deletions apps/client/src/layouts/layout_commons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import RecentChangesDialog from "../widgets/dialogs/recent_changes.js";
import PromptDialog from "../widgets/dialogs/prompt.js";
import AddLinkDialog from "../widgets/dialogs/add_link.js";
import IncludeNoteDialog from "../widgets/dialogs/include_note.js";
import LinkEmbedDialog from "../widgets/dialogs/link_embed.js";
import BulkActionsDialog from "../widgets/dialogs/bulk_actions.js";
import BranchPrefixDialog from "../widgets/dialogs/branch_prefix.js";
import SortChildNotesDialog from "../widgets/dialogs/sort_child_notes.js";
Expand Down Expand Up @@ -35,6 +36,7 @@ export function applyModals(rootContainer: RootContainer) {
.child(<BranchPrefixDialog />)
.child(<SortChildNotesDialog />)
.child(<IncludeNoteDialog />)
.child(<LinkEmbedDialog />)
.child(<NoteTypeChooserDialog />)
.child(<JumpToNoteDialog />)
.child(<AddLinkDialog />)
Expand Down
66 changes: 66 additions & 0 deletions apps/client/src/services/link_embed.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { extractYouTubeVideoId, detectEmbedType, safeHostname } from "./link_embed.js";

describe("extractYouTubeVideoId", () => {
it("extracts from standard watch URL", () => {
expect(extractYouTubeVideoId("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ");
});

it("extracts from youtu.be short URL", () => {
expect(extractYouTubeVideoId("https://youtu.be/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ");
});

it("extracts from youtu.be with query params", () => {
expect(extractYouTubeVideoId("https://youtu.be/fV16ck4Bgc0?si=bxl0pGUK2VIfUHkw")).toBe("fV16ck4Bgc0");
});

it("extracts from embed URL", () => {
expect(extractYouTubeVideoId("https://www.youtube.com/embed/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ");
});

it("extracts from shorts URL", () => {
expect(extractYouTubeVideoId("https://www.youtube.com/shorts/abcde_-FGHI")).toBe("abcde_-FGHI");
});

it("extracts from watch URL with extra params", () => {
expect(extractYouTubeVideoId("https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf")).toBe("dQw4w9WgXcQ");
});

it("returns null for non-YouTube URLs", () => {
expect(extractYouTubeVideoId("https://example.com")).toBeNull();
});

it("returns null for empty string", () => {
expect(extractYouTubeVideoId("")).toBeNull();
});

it("returns null for YouTube URL without video ID", () => {
expect(extractYouTubeVideoId("https://www.youtube.com/")).toBeNull();
});
});

describe("detectEmbedType", () => {
it("detects YouTube URLs", () => {
expect(detectEmbedType("https://youtu.be/fV16ck4Bgc0")).toBe("youtube");
expect(detectEmbedType("https://www.youtube.com/watch?v=abc12345678")).toBe("youtube");
});

it("returns opengraph for non-YouTube URLs", () => {
expect(detectEmbedType("https://example.com")).toBe("opengraph");
expect(detectEmbedType("https://github.com/TriliumNext/Notes")).toBe("opengraph");
});
});

describe("safeHostname", () => {
it("extracts hostname from valid URL", () => {
expect(safeHostname("https://www.example.com/page")).toBe("www.example.com");
});

it("returns raw string for invalid URL", () => {
expect(safeHostname("not-a-url")).toBe("not-a-url");
});

it("handles URLs with ports", () => {
expect(safeHostname("http://localhost:8080/api")).toBe("localhost");
});
});
193 changes: 193 additions & 0 deletions apps/client/src/services/link_embed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import "../widgets/type_widgets/text/LinkEmbed.css";

import type { LinkEmbedMetadata } from "@triliumnext/commons";
import server from "./server.js";

/** Paste mode chosen by user from the floating popup. */
export type LinkPasteMode = "mention" | "url" | "embed";

const metadataCache = new Map<string, LinkEmbedMetadata>();
const pendingRequests = new Map<string, Promise<LinkEmbedMetadata>>();

const YOUTUBE_REGEX = /(?:youtube\.com\/watch\?.*v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/;

export function extractYouTubeVideoId(url: string): string | null {
const match = url.match(YOUTUBE_REGEX);
return match ? match[1] : null;
}

export function detectEmbedType(url: string): "youtube" | "opengraph" {
return extractYouTubeVideoId(url) ? "youtube" : "opengraph";
}

export function safeHostname(url: string): string {
try {
return new URL(url).hostname;
} catch {
return url;
}
}

export async function fetchMetadata(url: string): Promise<LinkEmbedMetadata> {
const cached = metadataCache.get(url);
if (cached) return cached;

const pending = pendingRequests.get(url);
if (pending) return pending;

const request = server.get<LinkEmbedMetadata>(`link-embed/metadata?url=${encodeURIComponent(url)}`)
.then((metadata) => {
metadataCache.set(url, metadata);
pendingRequests.delete(url);
return metadata;
})
.catch(() => {
pendingRequests.delete(url);
const fallback: LinkEmbedMetadata = {
url,
title: safeHostname(url),
embedType: detectEmbedType(url)
};
metadataCache.set(url, fallback);
return fallback;
});

pendingRequests.set(url, request);
return request;
}

// ---------------------------------------------------------------------------
// Loading spinner (shared by all renderers)
// ---------------------------------------------------------------------------

function createLoadingEl(): JQuery<HTMLElement> {
return $('<div class="link-embed-loader">').append(
$('<div class="link-embed-loader-dot">'),
$('<div class="link-embed-loader-dot">'),
$('<div class="link-embed-loader-dot">')
);
}

// ---------------------------------------------------------------------------
// Mention renderer (inline: favicon + title)
// ---------------------------------------------------------------------------

export async function renderMention(url: string, $container: JQuery<HTMLElement>) {
$container.empty().append(createLoadingEl());

const metadata = await fetchMetadata(url);
const $link = $('<a class="link-embed-mention">')
.attr("href", metadata.url)
.attr("target", "_blank")
.attr("rel", "noopener noreferrer");

const faviconSrc = metadata.favicon
|| `https://www.google.com/s2/favicons?domain=${encodeURIComponent(safeHostname(url))}&sz=32`;

$link.append(
$('<img class="link-embed-mention-favicon">')
.attr("src", faviconSrc)
.attr("width", "16")
.attr("height", "16")
.on("error", function () { $(this).replaceWith($('<span class="link-embed-mention-dot">')); })
);

const displayTitle = metadata.embedType === "youtube" && metadata.siteName
? `${metadata.siteName} - ${metadata.title || "Video"}`
: metadata.title || safeHostname(url);

$link.append($('<span class="link-embed-mention-title">').text(displayTitle));
$container.empty().append($link);
}

// ---------------------------------------------------------------------------
// 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>) {

const videoId = extractYouTubeVideoId(url);
if (videoId) {
const origin = window.location.origin;
$container.empty().append(
$('<div class="link-embed-video">').append(
$('<iframe>')
.attr("src", `https://www.youtube-nocookie.com/embed/${videoId}?origin=${encodeURIComponent(origin)}&rel=0`)
.attr("frameborder", "0")
.attr("allowfullscreen", "true")
.attr("allow", "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share")
.attr("referrerpolicy", "strict-origin-when-cross-origin")
.attr("loading", "lazy")
)
);
return;
}

// Non-YouTube: card preview
$container.empty().append(createLoadingEl());

const metadata = await fetchMetadata(url);
renderCard(metadata, $container);
}

// ---------------------------------------------------------------------------
// Card renderer (Notion-style: image left, content right)
// ---------------------------------------------------------------------------

function renderCard(metadata: LinkEmbedMetadata, $container: JQuery<HTMLElement>) {
const $card = $('<a class="link-embed-card">')
.attr("href", metadata.url)
.attr("target", "_blank")
.attr("rel", "noopener noreferrer");

if (metadata.image) {
const $imgWrap = $('<div class="link-embed-card-image-wrapper">');
$imgWrap.append(
$('<img class="link-embed-card-image">')
.attr("src", metadata.image)
.attr("alt", "")
.attr("loading", "lazy")
.on("error", function () {
$imgWrap.empty().append($('<div class="link-embed-card-image-placeholder">').html("&#128279;"));
})
);
$card.append($imgWrap);
} else {
$card.append(
$('<div class="link-embed-card-image-wrapper">').append(
$('<div class="link-embed-card-image-placeholder">').html(
metadata.embedType === "youtube" ? "&#9654;" : "&#128279;"
)
)
);
}

const $content = $('<div class="link-embed-card-content">');
if (metadata.title) $content.append($('<div class="link-embed-card-title">').text(metadata.title));
if (metadata.description) $content.append($('<div class="link-embed-card-description">').text(metadata.description));
$content.append($('<div class="link-embed-card-url">').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<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);

}
}

export default {
fetchMetadata,
renderPreview,
renderMention,
renderEmbed,
detectEmbedType,
extractYouTubeVideoId,
safeHostname
};
80 changes: 80 additions & 0 deletions apps/client/src/widgets/dialogs/link_embed.tsx
Original file line number Diff line number Diff line change
@@ -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<CKEditorApi>(null);
const [url, setUrl] = useState("");
const [mode, setMode] = useState<LinkPasteMode>("embed");
const [shown, setShown] = useState(false);
const inputRef = useRef<HTMLInputElement>(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 (
<Modal
className="link-embed-dialog"
title="Insert link"
size="lg"
onShown={() => inputRef.current?.focus()}
onHidden={() => setShown(false)}
onSubmit={handleSubmit}
footer={<Button text="Insert" keyboardShortcut="Enter" />}
show={shown}
>
<FormGroup name="url" label="URL">
<input
ref={inputRef}
type="url"
className="form-control"
placeholder="https://example.com or https://youtube.com/watch?v=..."
value={url}
onInput={(e) => setUrl((e.target as HTMLInputElement).value)}
/>
</FormGroup>
<FormGroup name="mode" label="Paste as">
<div className="btn-group w-100">
{(["mention", "url", "embed"] as const).map((m) => (
<button
key={m}
type="button"
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]}

</button>
))}
</div>
</FormGroup>
</Modal>
);
}
Loading