diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/element/embeddable.ts | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/element/embeddable.ts')
| -rw-r--r-- | packages/excalidraw/element/embeddable.ts | 444 |
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); +}; |
