mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
refactor: separate elements logic into a standalone package (#9285)
Some checks failed
Auto release excalidraw next / Auto-release-excalidraw-next (push) Failing after 2m36s
Build Docker image / build-docker (push) Failing after 6s
Cancel previous runs / cancel (push) Failing after 1s
Publish Docker / publish-docker (push) Failing after 31s
New Sentry production release / sentry (push) Failing after 2m3s
Some checks failed
Auto release excalidraw next / Auto-release-excalidraw-next (push) Failing after 2m36s
Build Docker image / build-docker (push) Failing after 6s
Cancel previous runs / cancel (push) Failing after 1s
Publish Docker / publish-docker (push) Failing after 31s
New Sentry production release / sentry (push) Failing after 2m3s
This commit is contained in:
parent
a18f059188
commit
432a46ef9e
372 changed files with 3466 additions and 2466 deletions
420
packages/element/src/embeddable.ts
Normal file
420
packages/element/src/embeddable.ts
Normal file
|
@ -0,0 +1,420 @@
|
|||
import {
|
||||
FONT_FAMILY,
|
||||
VERTICAL_ALIGN,
|
||||
escapeDoubleQuotes,
|
||||
getFontString,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawProps } from "@excalidraw/excalidraw/types";
|
||||
import type { MarkRequired } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { newTextElement } from "./newElement";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import { isIframeElement } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawIframeLikeElement,
|
||||
IframeData,
|
||||
} from "./types";
|
||||
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue