mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
feat: support timestamps for youtube video emebds (#9737)
This commit is contained in:
@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
|||||||
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
||||||
|
|
||||||
const RE_YOUTUBE =
|
const RE_YOUTUBE =
|
||||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
|
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
|
||||||
|
|
||||||
const RE_VIMEO =
|
const RE_VIMEO =
|
||||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||||
@ -56,6 +56,35 @@ const RE_REDDIT =
|
|||||||
const RE_REDDIT_EMBED =
|
const RE_REDDIT_EMBED =
|
||||||
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
|
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
|
||||||
|
|
||||||
|
const parseYouTubeTimestamp = (url: string): number => {
|
||||||
|
let timeParam: string | null | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
|
||||||
|
timeParam =
|
||||||
|
urlObj.searchParams.get("t") || urlObj.searchParams.get("start");
|
||||||
|
} catch (error) {
|
||||||
|
const timeMatch = url.match(/[?&#](?:t|start)=([^&#\s]+)/);
|
||||||
|
timeParam = timeMatch?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timeParam) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+$/.test(timeParam)) {
|
||||||
|
return parseInt(timeParam, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeMatch = timeParam.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
|
||||||
|
if (!timeMatch) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, hours = "0", minutes = "0", seconds = "0"] = timeMatch;
|
||||||
|
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
|
||||||
|
};
|
||||||
|
|
||||||
const ALLOWED_DOMAINS = new Set([
|
const ALLOWED_DOMAINS = new Set([
|
||||||
"youtube.com",
|
"youtube.com",
|
||||||
"youtu.be",
|
"youtu.be",
|
||||||
@ -113,7 +142,8 @@ export const getEmbedLink = (
|
|||||||
let aspectRatio = { w: 560, h: 840 };
|
let aspectRatio = { w: 560, h: 840 };
|
||||||
const ytLink = link.match(RE_YOUTUBE);
|
const ytLink = link.match(RE_YOUTUBE);
|
||||||
if (ytLink?.[2]) {
|
if (ytLink?.[2]) {
|
||||||
const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
|
const startTime = parseYouTubeTimestamp(originalLink);
|
||||||
|
const time = startTime > 0 ? `&start=${startTime}` : ``;
|
||||||
const isPortrait = link.includes("shorts");
|
const isPortrait = link.includes("shorts");
|
||||||
type = "video";
|
type = "video";
|
||||||
switch (ytLink[1]) {
|
switch (ytLink[1]) {
|
||||||
|
153
packages/element/tests/embeddable.test.ts
Normal file
153
packages/element/tests/embeddable.test.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { getEmbedLink } from "../src/embeddable";
|
||||||
|
|
||||||
|
describe("YouTube timestamp parsing", () => {
|
||||||
|
it("should parse YouTube URLs with timestamp in seconds", () => {
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90",
|
||||||
|
expectedStart: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://youtu.be/dQw4w9WgXcQ?t=120",
|
||||||
|
expectedStart: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=150",
|
||||||
|
expectedStart: 150,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.forEach(({ url, expectedStart }) => {
|
||||||
|
const result = getEmbedLink(url);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result?.type).toBe("video");
|
||||||
|
if (result?.type === "video" || result?.type === "generic") {
|
||||||
|
expect(result.link).toContain(`start=${expectedStart}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse YouTube URLs with timestamp in time format", () => {
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1m30s",
|
||||||
|
expectedStart: 90, // 1*60 + 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://youtu.be/dQw4w9WgXcQ?t=2m45s",
|
||||||
|
expectedStart: 165, // 2*60 + 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s",
|
||||||
|
expectedStart: 3723, // 1*3600 + 2*60 + 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=45s",
|
||||||
|
expectedStart: 45,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5m",
|
||||||
|
expectedStart: 300, // 5*60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=2h",
|
||||||
|
expectedStart: 7200, // 2*3600
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.forEach(({ url, expectedStart }) => {
|
||||||
|
const result = getEmbedLink(url);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result?.type).toBe("video");
|
||||||
|
if (result?.type === "video" || result?.type === "generic") {
|
||||||
|
expect(result.link).toContain(`start=${expectedStart}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle YouTube URLs without timestamps", () => {
|
||||||
|
const testCases = [
|
||||||
|
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||||
|
"https://youtu.be/dQw4w9WgXcQ",
|
||||||
|
"https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.forEach((url) => {
|
||||||
|
const result = getEmbedLink(url);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result?.type).toBe("video");
|
||||||
|
if (result?.type === "video" || result?.type === "generic") {
|
||||||
|
expect(result.link).not.toContain("start=");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle YouTube shorts URLs with timestamps", () => {
|
||||||
|
const url = "https://www.youtube.com/shorts/dQw4w9WgXcQ?t=30";
|
||||||
|
const result = getEmbedLink(url);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result?.type).toBe("video");
|
||||||
|
if (result?.type === "video" || result?.type === "generic") {
|
||||||
|
expect(result.link).toContain("start=30");
|
||||||
|
}
|
||||||
|
// Shorts should have portrait aspect ratio
|
||||||
|
expect(result?.intrinsicSize).toEqual({ w: 315, h: 560 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle playlist URLs with timestamps", () => {
|
||||||
|
const url =
|
||||||
|
"https://www.youtube.com/playlist?list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ&t=60";
|
||||||
|
const result = getEmbedLink(url);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result?.type).toBe("video");
|
||||||
|
if (result?.type === "video" || result?.type === "generic") {
|
||||||
|
expect(result.link).toContain("start=60");
|
||||||
|
expect(result.link).toContain("list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle malformed or edge case timestamps", () => {
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=abc",
|
||||||
|
expectedStart: 0, // Invalid timestamp should default to 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=",
|
||||||
|
expectedStart: 0, // Empty timestamp should default to 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=0",
|
||||||
|
expectedStart: 0, // Zero timestamp should be handled
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.forEach(({ url, expectedStart }) => {
|
||||||
|
const result = getEmbedLink(url);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result?.type).toBe("video");
|
||||||
|
if (result?.type === "video" || result?.type === "generic") {
|
||||||
|
if (expectedStart === 0) {
|
||||||
|
expect(result.link).not.toContain("start=");
|
||||||
|
} else {
|
||||||
|
expect(result.link).toContain(`start=${expectedStart}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve other URL parameters", () => {
|
||||||
|
const url =
|
||||||
|
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90&feature=youtu.be&list=PLtest";
|
||||||
|
const result = getEmbedLink(url);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result?.type).toBe("video");
|
||||||
|
if (result?.type === "video" || result?.type === "generic") {
|
||||||
|
expect(result.link).toContain("start=90");
|
||||||
|
expect(result.link).toContain("enablejsapi=1");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
Reference in New Issue
Block a user