import { register } from "../actions/register"; import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants"; import type { ExcalidrawProps } from "../types"; import { getFontString, updateActiveTool } from "../utils"; import { setCursorForShape } from "../cursor"; import { newTextElement } from "./newElement"; import { wrapText } from "./textElement"; import { isIframeElement } from "./typeChecks"; import type { ExcalidrawElement, ExcalidrawIframeLikeElement, IframeData, } from "./types"; import { sanitizeHTMLAttribute } from "../data/url"; import type { MarkRequired } from "../utility-types"; import { StoreAction } from "../store"; type IframeDataWithSandbox = MarkRequired; const embeddedLinkCache = new Map(); 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 = /^ twitter embeds const RE_TWITTER = /(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/; const RE_TWITTER_EMBED = /^$/i; const RE_GIPHY = /giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/; 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", ]); 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", ]); export const createSrcDoc = (body: string) => { return `${body}`; }; 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 = sanitizeHTMLAttribute( `https://twitter.com/x/status/${postId}`, ); const ret: IframeDataWithSandbox = { type: "document", srcdoc: (theme: string) => createSrcDoc( ` `, ), 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 = sanitizeHTMLAttribute( `https://gist.github.com/${user}/${gistId}`, ); const ret: IframeDataWithSandbox = { type: "document", srcdoc: () => createSrcDoc(` `), 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", }), }, storeAction: StoreAction.NONE, }; }, }); const matchHostname = ( url: string, /** using a Set assumes it already contains normalized bare domains */ allowedHostnames: Set | 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 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); };