diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 069392a2d6..b439907e99 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -182,6 +182,7 @@ import { ExcalidrawIframeLikeElement, IframeData, ExcalidrawIframeElement, + ExcalidrawEmbeddableElement, } from "../element/types"; import { getCenter, getDistance } from "../gesture"; import { @@ -271,11 +272,12 @@ import { easeOut, updateStable, addEventListener, + normalizeEOL, } from "../utils"; import { createSrcDoc, embeddableURLValidator, - extractSrc, + maybeParseEmbedSrc, getEmbedLink, } from "../element/embeddable"; import { @@ -2924,21 +2926,49 @@ class App extends React.Component { retainSeed: isPlainPaste, }); } else if (data.text) { - const maybeUrl = extractSrc(data.text); + const nonEmptyLines = normalizeEOL(data.text) + .split(/\n+/) + .map((s) => s.trim()) + .filter(Boolean); + + const embbeddableUrls = nonEmptyLines + .map((str) => maybeParseEmbedSrc(str)) + .filter((string) => { + return ( + embeddableURLValidator(string, this.props.validateEmbeddable) && + (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) || + getEmbedLink(string)?.type === "video") + ); + }); if ( - !isPlainPaste && - embeddableURLValidator(maybeUrl, this.props.validateEmbeddable) && - (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(maybeUrl) || - getEmbedLink(maybeUrl)?.type === "video") + !IS_PLAIN_PASTE && + embbeddableUrls.length > 0 && + // if there were non-embeddable text (lines) mixed in with embeddable + // urls, ignore and paste as text + embbeddableUrls.length === nonEmptyLines.length ) { - const embeddable = this.insertEmbeddableElement({ - sceneX, - sceneY, - link: normalizeLink(maybeUrl), - }); - if (embeddable) { - this.setState({ selectedElementIds: { [embeddable.id]: true } }); + const embeddables: NonDeleted[] = []; + for (const url of embbeddableUrls) { + const prevEmbeddable: ExcalidrawEmbeddableElement | undefined = + embeddables[embeddables.length - 1]; + const embeddable = this.insertEmbeddableElement({ + sceneX: prevEmbeddable + ? prevEmbeddable.x + prevEmbeddable.width + 20 + : sceneX, + sceneY, + link: normalizeLink(url), + }); + if (embeddable) { + embeddables.push(embeddable); + } + } + if (embeddables.length) { + this.setState({ + selectedElementIds: Object.fromEntries( + embeddables.map((embeddable) => [embeddable.id, true]), + ), + }); } return; } diff --git a/packages/excalidraw/element/embeddable.ts b/packages/excalidraw/element/embeddable.ts index c129d39270..025ed4901e 100644 --- a/packages/excalidraw/element/embeddable.ts +++ b/packages/excalidraw/element/embeddable.ts @@ -32,9 +32,9 @@ const RE_GH_GIST_EMBED = /^ twitter embeds -const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?twitter.com/; +const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:twitter|x).com/; const RE_TWITTER_EMBED = - /^ { - const twitterMatch = htmlString.match(RE_TWITTER_EMBED); +export const maybeParseEmbedSrc = (str: string): string => { + const twitterMatch = str.match(RE_TWITTER_EMBED); if (twitterMatch && twitterMatch.length === 2) { return twitterMatch[1]; } - const gistMatch = htmlString.match(RE_GH_GIST_EMBED); + const gistMatch = str.match(RE_GH_GIST_EMBED); if (gistMatch && gistMatch.length === 2) { return gistMatch[1]; } - if (RE_GIPHY.test(htmlString)) { - return `https://giphy.com/embed/${RE_GIPHY.exec(htmlString)![1]}`; + if (RE_GIPHY.test(str)) { + return `https://giphy.com/embed/${RE_GIPHY.exec(str)![1]}`; } - const match = htmlString.match(RE_GENERIC_EMBED); + const match = str.match(RE_GENERIC_EMBED); if (match && match.length === 2) { return match[1]; } - return htmlString; + return str; }; export const embeddableURLValidator = ( diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index f812b85770..e084dfba3a 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -1,4 +1,4 @@ -import { getFontString, arrayToMap, isTestEnv } from "../utils"; +import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils"; import { ExcalidrawElement, ExcalidrawElementType, @@ -39,15 +39,13 @@ import { ExtractSetType } from "../utility-types"; export const normalizeText = (text: string) => { return ( - text + normalizeEOL(text) // replace tabs with spaces so they render and measure correctly .replace(/\t/g, " ") - // normalize newlines - .replace(/\r?\n|\r/g, "\n") ); }; -export const splitIntoLines = (text: string) => { +const splitIntoLines = (text: string) => { return normalizeText(text).split("\n"); }; diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index 8b39ba6bd0..1f0e317608 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -1071,3 +1071,7 @@ export function addEventListener( target?.removeEventListener?.(type, listener, options); }; } + +export const normalizeEOL = (str: string) => { + return str.replace(/\r?\n|\r/g, "\n"); +};