summaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/element/embeddable.ts
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/element/embeddable.ts
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/element/embeddable.ts')
-rw-r--r--packages/excalidraw/element/embeddable.ts444
1 files changed, 444 insertions, 0 deletions
diff --git a/packages/excalidraw/element/embeddable.ts b/packages/excalidraw/element/embeddable.ts
new file mode 100644
index 0000000..8265a0b
--- /dev/null
+++ b/packages/excalidraw/element/embeddable.ts
@@ -0,0 +1,444 @@
+import { register } from "../actions/register";
+import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
+import type { ExcalidrawProps } from "../types";
+import { escapeDoubleQuotes, getFontString, updateActiveTool } from "../utils";
+import { setCursorForShape } from "../cursor";
+import { newTextElement } from "./newElement";
+import { wrapText } from "./textWrapping";
+import { isIframeElement } from "./typeChecks";
+import type {
+ ExcalidrawElement,
+ ExcalidrawIframeLikeElement,
+ IframeData,
+} from "./types";
+import type { MarkRequired } from "../utility-types";
+import { CaptureUpdateAction } from "../store";
+
+type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
+
+const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
+
+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]*$/;
+
+const RE_VIMEO =
+ /^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
+const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
+
+const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/;
+const RE_GH_GIST_EMBED =
+ /^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
+
+// not anchored to start to allow <blockquote> twitter embeds
+const RE_TWITTER =
+ /(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
+const RE_TWITTER_EMBED =
+ /^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:twitter|x)\.com\/[^"']*)/i;
+
+const RE_VALTOWN =
+ /^https:\/\/(?:www\.)?val\.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
+
+const RE_GENERIC_EMBED =
+ /^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
+
+const RE_GIPHY =
+ /giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/;
+
+const RE_REDDIT =
+ /^(?:http(?:s)?:\/\/)?(?:www\.)?reddit\.com\/r\/([a-zA-Z0-9_]+)\/comments\/([a-zA-Z0-9_]+)\/([a-zA-Z0-9_]+)\/?(?:\?[^#\s]*)?(?:#[^\s]*)?$/;
+
+const RE_REDDIT_EMBED =
+ /^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
+
+const ALLOWED_DOMAINS = new Set([
+ "youtube.com",
+ "youtu.be",
+ "vimeo.com",
+ "player.vimeo.com",
+ "figma.com",
+ "link.excalidraw.com",
+ "gist.github.com",
+ "twitter.com",
+ "x.com",
+ "*.simplepdf.eu",
+ "stackblitz.com",
+ "val.town",
+ "giphy.com",
+ "reddit.com",
+]);
+
+const ALLOW_SAME_ORIGIN = new Set([
+ "youtube.com",
+ "youtu.be",
+ "vimeo.com",
+ "player.vimeo.com",
+ "figma.com",
+ "twitter.com",
+ "x.com",
+ "*.simplepdf.eu",
+ "stackblitz.com",
+ "reddit.com",
+]);
+
+export const createSrcDoc = (body: string) => {
+ return `<html><body>${body}</body></html>`;
+};
+
+export const getEmbedLink = (
+ link: string | null | undefined,
+): IframeDataWithSandbox | null => {
+ if (!link) {
+ return null;
+ }
+
+ if (embeddedLinkCache.has(link)) {
+ return embeddedLinkCache.get(link)!;
+ }
+
+ const originalLink = link;
+
+ const allowSameOrigin = ALLOW_SAME_ORIGIN.has(
+ matchHostname(link, ALLOW_SAME_ORIGIN) || "",
+ );
+
+ let type: "video" | "generic" = "generic";
+ let aspectRatio = { w: 560, h: 840 };
+ const ytLink = link.match(RE_YOUTUBE);
+ if (ytLink?.[2]) {
+ const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
+ const isPortrait = link.includes("shorts");
+ type = "video";
+ switch (ytLink[1]) {
+ case "embed/":
+ case "watch?v=":
+ case "shorts/":
+ link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`;
+ break;
+ case "playlist?list=":
+ case "embed/videoseries?list=":
+ link = `https://www.youtube.com/embed/videoseries?list=${ytLink[2]}&enablejsapi=1${time}`;
+ break;
+ default:
+ link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`;
+ break;
+ }
+ aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 };
+ embeddedLinkCache.set(originalLink, {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ });
+ return {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ };
+ }
+
+ const vimeoLink = link.match(RE_VIMEO);
+ if (vimeoLink?.[1]) {
+ const target = vimeoLink?.[1];
+ const error = !/^\d+$/.test(target)
+ ? new URIError("Invalid embed link format")
+ : undefined;
+ type = "video";
+ link = `https://player.vimeo.com/video/${target}?api=1`;
+ aspectRatio = { w: 560, h: 315 };
+ //warning deliberately ommited so it is displayed only once per link
+ //same link next time will be served from cache
+ embeddedLinkCache.set(originalLink, {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ });
+ return {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ error,
+ sandbox: { allowSameOrigin },
+ };
+ }
+
+ const figmaLink = link.match(RE_FIGMA);
+ if (figmaLink) {
+ type = "generic";
+ link = `https://www.figma.com/embed?embed_host=share&url=${encodeURIComponent(
+ link,
+ )}`;
+ aspectRatio = { w: 550, h: 550 };
+ embeddedLinkCache.set(originalLink, {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ });
+ return {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ };
+ }
+
+ const valLink = link.match(RE_VALTOWN);
+ if (valLink) {
+ link =
+ valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed");
+ embeddedLinkCache.set(originalLink, {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ });
+ return {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ };
+ }
+
+ if (RE_TWITTER.test(link)) {
+ const postId = link.match(RE_TWITTER)![1];
+ // the embed srcdoc still supports twitter.com domain only.
+ // Note that we don't attempt to parse the username as it can consist of
+ // non-latin1 characters, and the username in the url can be set to anything
+ // without affecting the embed.
+ const safeURL = escapeDoubleQuotes(
+ `https://twitter.com/x/status/${postId}`,
+ );
+
+ const ret: IframeDataWithSandbox = {
+ type: "document",
+ srcdoc: (theme: string) =>
+ createSrcDoc(
+ `<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${safeURL}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
+ ),
+ intrinsicSize: { w: 480, h: 480 },
+ sandbox: { allowSameOrigin },
+ };
+ embeddedLinkCache.set(originalLink, ret);
+ return ret;
+ }
+
+ if (RE_REDDIT.test(link)) {
+ const [, page, postId, title] = link.match(RE_REDDIT)!;
+ const safeURL = escapeDoubleQuotes(
+ `https://reddit.com/r/${page}/comments/${postId}/${title}`,
+ );
+ const ret: IframeDataWithSandbox = {
+ type: "document",
+ srcdoc: (theme: string) =>
+ createSrcDoc(
+ `<blockquote class="reddit-embed-bq" data-embed-theme="${theme}"><a href="${safeURL}"></a><br></blockquote><script async="" src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script>`,
+ ),
+ intrinsicSize: { w: 480, h: 480 },
+ sandbox: { allowSameOrigin },
+ };
+ embeddedLinkCache.set(originalLink, ret);
+ return ret;
+ }
+
+ if (RE_GH_GIST.test(link)) {
+ const [, user, gistId] = link.match(RE_GH_GIST)!;
+ const safeURL = escapeDoubleQuotes(
+ `https://gist.github.com/${user}/${gistId}`,
+ );
+ const ret: IframeDataWithSandbox = {
+ type: "document",
+ srcdoc: () =>
+ createSrcDoc(`
+ <script src="${safeURL}.js"></script>
+ <style type="text/css">
+ * { margin: 0px; }
+ table, .gist { height: 100%; }
+ .gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
+ </style>
+ `),
+ intrinsicSize: { w: 550, h: 720 },
+ sandbox: { allowSameOrigin },
+ };
+ embeddedLinkCache.set(link, ret);
+ return ret;
+ }
+
+ embeddedLinkCache.set(link, {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ });
+ return {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ };
+};
+
+export const createPlaceholderEmbeddableLabel = (
+ element: ExcalidrawIframeLikeElement,
+): ExcalidrawElement => {
+ let text: string;
+ if (isIframeElement(element)) {
+ text = "IFrame element";
+ } else {
+ text =
+ !element.link || element?.link === "" ? "Empty Web-Embed" : element.link;
+ }
+
+ const fontSize = Math.max(
+ Math.min(element.width / 2, element.width / text.length),
+ element.width / 30,
+ );
+ const fontFamily = FONT_FAMILY.Helvetica;
+
+ const fontString = getFontString({
+ fontSize,
+ fontFamily,
+ });
+
+ return newTextElement({
+ x: element.x + element.width / 2,
+ y: element.y + element.height / 2,
+ strokeColor:
+ element.strokeColor !== "transparent" ? element.strokeColor : "black",
+ backgroundColor: "transparent",
+ fontFamily,
+ fontSize,
+ text: wrapText(text, fontString, element.width - 20),
+ textAlign: "center",
+ verticalAlign: VERTICAL_ALIGN.MIDDLE,
+ angle: element.angle ?? 0,
+ });
+};
+
+export const actionSetEmbeddableAsActiveTool = register({
+ name: "setEmbeddableAsActiveTool",
+ trackEvent: { category: "toolbar" },
+ target: "Tool",
+ label: "toolBar.embeddable",
+ perform: (elements, appState, _, app) => {
+ const nextActiveTool = updateActiveTool(appState, {
+ type: "embeddable",
+ });
+
+ setCursorForShape(app.canvas, {
+ ...appState,
+ activeTool: nextActiveTool,
+ });
+
+ return {
+ elements,
+ appState: {
+ ...appState,
+ activeTool: updateActiveTool(appState, {
+ type: "embeddable",
+ }),
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+});
+
+const matchHostname = (
+ url: string,
+ /** using a Set assumes it already contains normalized bare domains */
+ allowedHostnames: Set<string> | string,
+): string | null => {
+ try {
+ const { hostname } = new URL(url);
+
+ const bareDomain = hostname.replace(/^www\./, "");
+
+ if (allowedHostnames instanceof Set) {
+ if (ALLOWED_DOMAINS.has(bareDomain)) {
+ return bareDomain;
+ }
+
+ const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
+ /^([^.]+)/,
+ "*",
+ );
+ if (ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)) {
+ return bareDomainWithFirstSubdomainWildcarded;
+ }
+ return null;
+ }
+
+ const bareAllowedHostname = allowedHostnames.replace(/^www\./, "");
+ if (bareDomain === bareAllowedHostname) {
+ return bareAllowedHostname;
+ }
+ } catch (error) {
+ // ignore
+ }
+ return null;
+};
+
+export const maybeParseEmbedSrc = (str: string): string => {
+ const twitterMatch = str.match(RE_TWITTER_EMBED);
+ if (twitterMatch && twitterMatch.length === 2) {
+ return twitterMatch[1];
+ }
+
+ const redditMatch = str.match(RE_REDDIT_EMBED);
+ if (redditMatch && redditMatch.length === 2) {
+ return redditMatch[1];
+ }
+
+ const gistMatch = str.match(RE_GH_GIST_EMBED);
+ if (gistMatch && gistMatch.length === 2) {
+ return gistMatch[1];
+ }
+
+ if (RE_GIPHY.test(str)) {
+ return `https://giphy.com/embed/${RE_GIPHY.exec(str)![1]}`;
+ }
+
+ const match = str.match(RE_GENERIC_EMBED);
+ if (match && match.length === 2) {
+ return match[1];
+ }
+
+ return str;
+};
+
+export const embeddableURLValidator = (
+ url: string | null | undefined,
+ validateEmbeddable: ExcalidrawProps["validateEmbeddable"],
+): boolean => {
+ if (!url) {
+ return false;
+ }
+ if (validateEmbeddable != null) {
+ if (typeof validateEmbeddable === "function") {
+ const ret = validateEmbeddable(url);
+ // if return value is undefined, leave validation to default
+ if (typeof ret === "boolean") {
+ return ret;
+ }
+ } else if (typeof validateEmbeddable === "boolean") {
+ return validateEmbeddable;
+ } else if (validateEmbeddable instanceof RegExp) {
+ return validateEmbeddable.test(url);
+ } else if (Array.isArray(validateEmbeddable)) {
+ for (const domain of validateEmbeddable) {
+ if (domain instanceof RegExp) {
+ if (url.match(domain)) {
+ return true;
+ }
+ } else if (matchHostname(url, domain)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+
+ return !!matchHostname(url, ALLOWED_DOMAINS);
+};