-
-
Notifications
You must be signed in to change notification settings - Fork 2.4k
feat: add link embed previews (mention, URL, embed) #9188
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"); | ||
| }); | ||
| }); |
| 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>) { | ||
| 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("🔗")); | ||
| }) | ||
| ); | ||
| $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" ? "▶" : "🔗" | ||
| ) | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
| } | ||
|
|
||
| export default { | ||
| fetchMetadata, | ||
| renderPreview, | ||
| renderMention, | ||
| renderEmbed, | ||
| detectEmbedType, | ||
| extractYouTubeVideoId, | ||
| safeHostname | ||
| }; | ||
| 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"} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
embedTypeparameter is unused within therenderEmbedfunction. The function re-determines if the URL is for a YouTube video by callingextractYouTubeVideoId. To simplify the code, you can remove theembedTypeparameter from this function's signature. You will also need to update its call site inrenderPreview.