feat: Added Copy/Paste from Google Docs (#7136)
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
dde3dac931
commit
63650f82d1
@ -1,26 +1,21 @@
|
|||||||
import { parseClipboard } from "./clipboard";
|
import { parseClipboard } from "./clipboard";
|
||||||
|
import { createPasteEvent } from "./tests/test-utils";
|
||||||
|
|
||||||
describe("Test parseClipboard", () => {
|
describe("Test parseClipboard", () => {
|
||||||
it("should parse valid json correctly", async () => {
|
it("should parse valid json correctly", async () => {
|
||||||
let text = "123";
|
let text = "123";
|
||||||
|
|
||||||
let clipboardData = await parseClipboard({
|
let clipboardData = await parseClipboard(
|
||||||
//@ts-ignore
|
createPasteEvent({ "text/plain": text }),
|
||||||
clipboardData: {
|
);
|
||||||
getData: () => text,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
|
|
||||||
text = "[123]";
|
text = "[123]";
|
||||||
|
|
||||||
clipboardData = await parseClipboard({
|
clipboardData = await parseClipboard(
|
||||||
//@ts-ignore
|
createPasteEvent({ "text/plain": text }),
|
||||||
clipboardData: {
|
);
|
||||||
getData: () => text,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
});
|
});
|
||||||
|
@ -18,11 +18,14 @@ type ElementsClipboard = {
|
|||||||
files: BinaryFiles | undefined;
|
files: BinaryFiles | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PastedMixedContent = { type: "text" | "imageUrl"; value: string }[];
|
||||||
|
|
||||||
export interface ClipboardData {
|
export interface ClipboardData {
|
||||||
spreadsheet?: Spreadsheet;
|
spreadsheet?: Spreadsheet;
|
||||||
elements?: readonly ExcalidrawElement[];
|
elements?: readonly ExcalidrawElement[];
|
||||||
files?: BinaryFiles;
|
files?: BinaryFiles;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
mixedContent?: PastedMixedContent;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
programmaticAPI?: boolean;
|
programmaticAPI?: boolean;
|
||||||
}
|
}
|
||||||
@ -142,22 +145,74 @@ const parsePotentialSpreadsheet = (
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** internal, specific to parsing paste events. Do not reuse. */
|
||||||
|
function parseHTMLTree(el: ChildNode) {
|
||||||
|
let result: PastedMixedContent = [];
|
||||||
|
for (const node of el.childNodes) {
|
||||||
|
if (node.nodeType === 3) {
|
||||||
|
const text = node.textContent?.trim();
|
||||||
|
if (text) {
|
||||||
|
result.push({ type: "text", value: text });
|
||||||
|
}
|
||||||
|
} else if (node instanceof HTMLImageElement) {
|
||||||
|
const url = node.getAttribute("src");
|
||||||
|
if (url && url.startsWith("http")) {
|
||||||
|
result.push({ type: "imageUrl", value: url });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result = result.concat(parseHTMLTree(node));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeParseHTMLPaste = (event: ClipboardEvent) => {
|
||||||
|
const html = event.clipboardData?.getData("text/html");
|
||||||
|
|
||||||
|
if (!html) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
|
||||||
|
const content = parseHTMLTree(doc.body);
|
||||||
|
|
||||||
|
if (content.length) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`error in parseHTMLFromPaste: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves content from system clipboard (either from ClipboardEvent or
|
* Retrieves content from system clipboard (either from ClipboardEvent or
|
||||||
* via async clipboard API if supported)
|
* via async clipboard API if supported)
|
||||||
*/
|
*/
|
||||||
export const getSystemClipboard = async (
|
const getSystemClipboard = async (
|
||||||
event: ClipboardEvent | null,
|
event: ClipboardEvent | null,
|
||||||
): Promise<string> => {
|
isPlainPaste = false,
|
||||||
|
): Promise<
|
||||||
|
| { type: "text"; value: string }
|
||||||
|
| { type: "mixedContent"; value: PastedMixedContent }
|
||||||
|
> => {
|
||||||
try {
|
try {
|
||||||
|
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
|
||||||
|
if (mixedContent) {
|
||||||
|
return { type: "mixedContent", value: mixedContent };
|
||||||
|
}
|
||||||
|
|
||||||
const text = event
|
const text = event
|
||||||
? event.clipboardData?.getData("text/plain")
|
? event.clipboardData?.getData("text/plain")
|
||||||
: probablySupportsClipboardReadText &&
|
: probablySupportsClipboardReadText &&
|
||||||
(await navigator.clipboard.readText());
|
(await navigator.clipboard.readText());
|
||||||
|
|
||||||
return (text || "").trim();
|
return { type: "text", value: (text || "").trim() };
|
||||||
} catch {
|
} catch {
|
||||||
return "";
|
return { type: "text", value: "" };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -168,14 +223,20 @@ export const parseClipboard = async (
|
|||||||
event: ClipboardEvent | null,
|
event: ClipboardEvent | null,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ClipboardData> => {
|
): Promise<ClipboardData> => {
|
||||||
const systemClipboard = await getSystemClipboard(event);
|
const systemClipboard = await getSystemClipboard(event, isPlainPaste);
|
||||||
|
|
||||||
|
if (systemClipboard.type === "mixedContent") {
|
||||||
|
return {
|
||||||
|
mixedContent: systemClipboard.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// if system clipboard empty, couldn't be resolved, or contains previously
|
// if system clipboard empty, couldn't be resolved, or contains previously
|
||||||
// copied excalidraw scene as SVG, fall back to previously copied excalidraw
|
// copied excalidraw scene as SVG, fall back to previously copied excalidraw
|
||||||
// elements
|
// elements
|
||||||
if (
|
if (
|
||||||
!systemClipboard ||
|
!systemClipboard ||
|
||||||
(!isPlainPaste && systemClipboard.includes(SVG_EXPORT_TAG))
|
(!isPlainPaste && systemClipboard.value.includes(SVG_EXPORT_TAG))
|
||||||
) {
|
) {
|
||||||
return getAppClipboard();
|
return getAppClipboard();
|
||||||
}
|
}
|
||||||
@ -183,7 +244,7 @@ export const parseClipboard = async (
|
|||||||
// if system clipboard contains spreadsheet, use it even though it's
|
// if system clipboard contains spreadsheet, use it even though it's
|
||||||
// technically possible it's staler than in-app clipboard
|
// technically possible it's staler than in-app clipboard
|
||||||
const spreadsheetResult =
|
const spreadsheetResult =
|
||||||
!isPlainPaste && parsePotentialSpreadsheet(systemClipboard);
|
!isPlainPaste && parsePotentialSpreadsheet(systemClipboard.value);
|
||||||
|
|
||||||
if (spreadsheetResult) {
|
if (spreadsheetResult) {
|
||||||
return spreadsheetResult;
|
return spreadsheetResult;
|
||||||
@ -192,7 +253,7 @@ export const parseClipboard = async (
|
|||||||
const appClipboardData = getAppClipboard();
|
const appClipboardData = getAppClipboard();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const systemClipboardData = JSON.parse(systemClipboard);
|
const systemClipboardData = JSON.parse(systemClipboard.value);
|
||||||
const programmaticAPI =
|
const programmaticAPI =
|
||||||
systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
|
systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
|
||||||
if (clipboardContainsElements(systemClipboardData)) {
|
if (clipboardContainsElements(systemClipboardData)) {
|
||||||
@ -216,7 +277,7 @@ export const parseClipboard = async (
|
|||||||
? JSON.stringify(appClipboardData.elements, null, 2)
|
? JSON.stringify(appClipboardData.elements, null, 2)
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
: { text: systemClipboard };
|
: { text: systemClipboard.value };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
|
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
|
||||||
|
@ -47,7 +47,7 @@ import {
|
|||||||
isEraserActive,
|
isEraserActive,
|
||||||
isHandToolActive,
|
isHandToolActive,
|
||||||
} from "../appState";
|
} from "../appState";
|
||||||
import { parseClipboard } from "../clipboard";
|
import { PastedMixedContent, parseClipboard } from "../clipboard";
|
||||||
import {
|
import {
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
CURSOR_TYPE,
|
CURSOR_TYPE,
|
||||||
@ -275,6 +275,7 @@ import {
|
|||||||
generateIdFromFile,
|
generateIdFromFile,
|
||||||
getDataURL,
|
getDataURL,
|
||||||
getFileFromEvent,
|
getFileFromEvent,
|
||||||
|
ImageURLToFile,
|
||||||
isImageFileHandle,
|
isImageFileHandle,
|
||||||
isSupportedImageFile,
|
isSupportedImageFile,
|
||||||
loadSceneOrLibraryFromBlob,
|
loadSceneOrLibraryFromBlob,
|
||||||
@ -2183,21 +2184,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// must be called in the same frame (thus before any awaits) as the paste
|
|
||||||
// event else some browsers (FF...) will clear the clipboardData
|
|
||||||
// (something something security)
|
|
||||||
let file = event?.clipboardData?.files[0];
|
|
||||||
|
|
||||||
const data = await parseClipboard(event, isPlainPaste);
|
|
||||||
if (!file && data.text && !isPlainPaste) {
|
|
||||||
const string = data.text.trim();
|
|
||||||
if (string.startsWith("<svg") && string.endsWith("</svg>")) {
|
|
||||||
// ignore SVG validation/normalization which will be done during image
|
|
||||||
// initialization
|
|
||||||
file = SVGStringToFile(string);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||||
{
|
{
|
||||||
clientX: this.lastViewportPosition.x,
|
clientX: this.lastViewportPosition.x,
|
||||||
@ -2206,6 +2192,29 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state,
|
this.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// must be called in the same frame (thus before any awaits) as the paste
|
||||||
|
// event else some browsers (FF...) will clear the clipboardData
|
||||||
|
// (something something security)
|
||||||
|
let file = event?.clipboardData?.files[0];
|
||||||
|
|
||||||
|
const data = await parseClipboard(event, isPlainPaste);
|
||||||
|
if (!file && !isPlainPaste) {
|
||||||
|
if (data.mixedContent) {
|
||||||
|
return this.addElementsFromMixedContentPaste(data.mixedContent, {
|
||||||
|
isPlainPaste,
|
||||||
|
sceneX,
|
||||||
|
sceneY,
|
||||||
|
});
|
||||||
|
} else if (data.text) {
|
||||||
|
const string = data.text.trim();
|
||||||
|
if (string.startsWith("<svg") && string.endsWith("</svg>")) {
|
||||||
|
// ignore SVG validation/normalization which will be done during image
|
||||||
|
// initialization
|
||||||
|
file = SVGStringToFile(string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// prefer spreadsheet data over image file (MS Office/Libre Office)
|
// prefer spreadsheet data over image file (MS Office/Libre Office)
|
||||||
if (isSupportedImageFile(file) && !data.spreadsheet) {
|
if (isSupportedImageFile(file) && !data.spreadsheet) {
|
||||||
const imageElement = this.createImageElement({ sceneX, sceneY });
|
const imageElement = this.createImageElement({ sceneX, sceneY });
|
||||||
@ -2259,6 +2268,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
} else if (data.text) {
|
} else if (data.text) {
|
||||||
const maybeUrl = extractSrc(data.text);
|
const maybeUrl = extractSrc(data.text);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!isPlainPaste &&
|
!isPlainPaste &&
|
||||||
embeddableURLValidator(maybeUrl, this.props.validateEmbeddable) &&
|
embeddableURLValidator(maybeUrl, this.props.validateEmbeddable) &&
|
||||||
@ -2393,6 +2403,85 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setActiveTool({ type: "selection" });
|
this.setActiveTool({ type: "selection" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO rewrite this to paste both text & images at the same time if
|
||||||
|
// pasted data contains both
|
||||||
|
private async addElementsFromMixedContentPaste(
|
||||||
|
mixedContent: PastedMixedContent,
|
||||||
|
{
|
||||||
|
isPlainPaste,
|
||||||
|
sceneX,
|
||||||
|
sceneY,
|
||||||
|
}: { isPlainPaste: boolean; sceneX: number; sceneY: number },
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!isPlainPaste &&
|
||||||
|
mixedContent.some((node) => node.type === "imageUrl")
|
||||||
|
) {
|
||||||
|
const imageURLs = mixedContent
|
||||||
|
.filter((node) => node.type === "imageUrl")
|
||||||
|
.map((node) => node.value);
|
||||||
|
const responses = await Promise.all(
|
||||||
|
imageURLs.map(async (url) => {
|
||||||
|
try {
|
||||||
|
return { file: await ImageURLToFile(url) };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { errorMessage: error.message as string };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let y = sceneY;
|
||||||
|
let firstImageYOffsetDone = false;
|
||||||
|
const nextSelectedIds: Record<ExcalidrawElement["id"], true> = {};
|
||||||
|
for (const response of responses) {
|
||||||
|
if (response.file) {
|
||||||
|
const imageElement = this.createImageElement({
|
||||||
|
sceneX,
|
||||||
|
sceneY: y,
|
||||||
|
});
|
||||||
|
|
||||||
|
const initializedImageElement = await this.insertImageElement(
|
||||||
|
imageElement,
|
||||||
|
response.file,
|
||||||
|
);
|
||||||
|
if (initializedImageElement) {
|
||||||
|
// vertically center first image in the batch
|
||||||
|
if (!firstImageYOffsetDone) {
|
||||||
|
firstImageYOffsetDone = true;
|
||||||
|
y -= initializedImageElement.height / 2;
|
||||||
|
}
|
||||||
|
// hack to reset the `y` coord because we vertically center during
|
||||||
|
// insertImageElement
|
||||||
|
mutateElement(initializedImageElement, { y }, false);
|
||||||
|
|
||||||
|
y = imageElement.y + imageElement.height + 25;
|
||||||
|
|
||||||
|
nextSelectedIds[imageElement.id] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
selectedElementIds: makeNextSelectedElementIds(
|
||||||
|
nextSelectedIds,
|
||||||
|
this.state,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = responses.find((response) => !!response.errorMessage);
|
||||||
|
if (error && error.errorMessage) {
|
||||||
|
this.setState({ errorMessage: error.errorMessage });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const textNodes = mixedContent.filter((node) => node.type === "text");
|
||||||
|
if (textNodes.length) {
|
||||||
|
this.addTextFromPaste(
|
||||||
|
textNodes.map((node) => node.value).join("\n\n"),
|
||||||
|
isPlainPaste,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private addTextFromPaste(text: string, isPlainPaste = false) {
|
private addTextFromPaste(text: string, isPlainPaste = false) {
|
||||||
const { x, y } = viewportCoordsToSceneCoords(
|
const { x, y } = viewportCoordsToSceneCoords(
|
||||||
{
|
{
|
||||||
@ -4401,6 +4490,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleCanvasPointerDown = (
|
private handleCanvasPointerDown = (
|
||||||
event: React.PointerEvent<HTMLElement>,
|
event: React.PointerEvent<HTMLElement>,
|
||||||
) => {
|
) => {
|
||||||
@ -7302,7 +7392,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.scene.addNewElement(imageElement);
|
this.scene.addNewElement(imageElement);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.initializeImage({
|
return await this.initializeImage({
|
||||||
imageFile,
|
imageFile,
|
||||||
imageElement,
|
imageElement,
|
||||||
showCursorImagePreview,
|
showCursorImagePreview,
|
||||||
@ -7315,6 +7405,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState({
|
this.setState({
|
||||||
errorMessage: error.message || t("errors.imageInsertError"),
|
errorMessage: error.message || t("errors.imageInsertError"),
|
||||||
});
|
});
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -327,6 +327,31 @@ export const SVGStringToFile = (SVGString: string, filename: string = "") => {
|
|||||||
}) as File & { type: typeof MIME_TYPES.svg };
|
}) as File & { type: typeof MIME_TYPES.svg };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ImageURLToFile = async (
|
||||||
|
imageUrl: string,
|
||||||
|
filename: string = "",
|
||||||
|
): Promise<File | undefined> => {
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(imageUrl);
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(t("errors.failedToFetchImage"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(t("errors.failedToFetchImage"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
if (blob.type && isSupportedImageFile(blob)) {
|
||||||
|
const name = filename || blob.name || "";
|
||||||
|
return new File([blob], name, { type: blob.type });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(t("errors.unsupportedFileType"));
|
||||||
|
};
|
||||||
|
|
||||||
export const getFileFromEvent = async (
|
export const getFileFromEvent = async (
|
||||||
event: React.DragEvent<HTMLDivElement>,
|
event: React.DragEvent<HTMLDivElement>,
|
||||||
) => {
|
) => {
|
||||||
|
@ -28,6 +28,7 @@ const embeddedLinkCache = new Map<string, EmbeddedLink>();
|
|||||||
|
|
||||||
const RE_YOUTUBE =
|
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]*$/;
|
/^(?: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 =
|
const RE_VIMEO =
|
||||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||||
const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
|
const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
|
||||||
|
@ -203,6 +203,7 @@
|
|||||||
"imageInsertError": "Couldn't insert image. Try again later...",
|
"imageInsertError": "Couldn't insert image. Try again later...",
|
||||||
"fileTooBig": "File is too big. Maximum allowed size is {{maxSize}}.",
|
"fileTooBig": "File is too big. Maximum allowed size is {{maxSize}}.",
|
||||||
"svgImageInsertError": "Couldn't insert SVG image. The SVG markup looks invalid.",
|
"svgImageInsertError": "Couldn't insert SVG image. The SVG markup looks invalid.",
|
||||||
|
"failedToFetchImage": "Failed to fetch image.",
|
||||||
"invalidSVGString": "Invalid SVG.",
|
"invalidSVGString": "Invalid SVG.",
|
||||||
"cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.",
|
"cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.",
|
||||||
"importLibraryError": "Couldn't load library",
|
"importLibraryError": "Couldn't load library",
|
||||||
|
@ -35,22 +35,14 @@ vi.mock("../keys.ts", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const setClipboardText = (text: string) => {
|
const sendPasteEvent = (text: string) => {
|
||||||
Object.assign(navigator, {
|
const clipboardEvent = createPasteEvent({
|
||||||
clipboard: {
|
"text/plain": text,
|
||||||
readText: () => text,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const sendPasteEvent = (text?: string) => {
|
|
||||||
const clipboardEvent = createPasteEvent(
|
|
||||||
text || (() => window.navigator.clipboard.readText()),
|
|
||||||
);
|
|
||||||
document.dispatchEvent(clipboardEvent);
|
document.dispatchEvent(clipboardEvent);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pasteWithCtrlCmdShiftV = (text?: string) => {
|
const pasteWithCtrlCmdShiftV = (text: string) => {
|
||||||
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
||||||
//triggering keydown with an empty clipboard
|
//triggering keydown with an empty clipboard
|
||||||
Keyboard.keyPress(KEYS.V);
|
Keyboard.keyPress(KEYS.V);
|
||||||
@ -59,7 +51,7 @@ const pasteWithCtrlCmdShiftV = (text?: string) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const pasteWithCtrlCmdV = (text?: string) => {
|
const pasteWithCtrlCmdV = (text: string) => {
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
//triggering keydown with an empty clipboard
|
//triggering keydown with an empty clipboard
|
||||||
Keyboard.keyPress(KEYS.V);
|
Keyboard.keyPress(KEYS.V);
|
||||||
@ -86,7 +78,6 @@ beforeEach(async () => {
|
|||||||
initialData={{ appState: { zoom: { value: 1 as NormalizedZoomValue } } }}
|
initialData={{ appState: { zoom: { value: 1 as NormalizedZoomValue } } }}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
setClipboardText("");
|
|
||||||
Object.assign(document, {
|
Object.assign(document, {
|
||||||
elementFromPoint: () => GlobalTestState.canvas,
|
elementFromPoint: () => GlobalTestState.canvas,
|
||||||
});
|
});
|
||||||
@ -120,8 +111,7 @@ describe("general paste behavior", () => {
|
|||||||
describe("paste text as single lines", () => {
|
describe("paste text as single lines", () => {
|
||||||
it("should create an element for each line when copying with Ctrl/Cmd+V", async () => {
|
it("should create an element for each line when copying with Ctrl/Cmd+V", async () => {
|
||||||
const text = "sajgfakfn\naaksfnknas\nakefnkasf";
|
const text = "sajgfakfn\naaksfnknas\nakefnkasf";
|
||||||
setClipboardText(text);
|
pasteWithCtrlCmdV(text);
|
||||||
pasteWithCtrlCmdV();
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements.length).toEqual(text.split("\n").length);
|
expect(h.elements.length).toEqual(text.split("\n").length);
|
||||||
});
|
});
|
||||||
@ -129,8 +119,7 @@ describe("paste text as single lines", () => {
|
|||||||
|
|
||||||
it("should ignore empty lines when creating an element for each line", async () => {
|
it("should ignore empty lines when creating an element for each line", async () => {
|
||||||
const text = "\n\nsajgfakfn\n\n\naaksfnknas\n\nakefnkasf\n\n\n";
|
const text = "\n\nsajgfakfn\n\n\naaksfnknas\n\nakefnkasf\n\n\n";
|
||||||
setClipboardText(text);
|
pasteWithCtrlCmdV(text);
|
||||||
pasteWithCtrlCmdV();
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements.length).toEqual(3);
|
expect(h.elements.length).toEqual(3);
|
||||||
});
|
});
|
||||||
@ -138,8 +127,7 @@ describe("paste text as single lines", () => {
|
|||||||
|
|
||||||
it("should not create any element if clipboard has only new lines", async () => {
|
it("should not create any element if clipboard has only new lines", async () => {
|
||||||
const text = "\n\n\n\n\n";
|
const text = "\n\n\n\n\n";
|
||||||
setClipboardText(text);
|
pasteWithCtrlCmdV(text);
|
||||||
pasteWithCtrlCmdV();
|
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
await sleep(50); // elements lenght will always be zero if we don't wait, since paste is async
|
await sleep(50); // elements lenght will always be zero if we don't wait, since paste is async
|
||||||
expect(h.elements.length).toEqual(0);
|
expect(h.elements.length).toEqual(0);
|
||||||
@ -155,8 +143,7 @@ describe("paste text as single lines", () => {
|
|||||||
) +
|
) +
|
||||||
10 / h.app.state.zoom.value;
|
10 / h.app.state.zoom.value;
|
||||||
mouse.moveTo(100, 100);
|
mouse.moveTo(100, 100);
|
||||||
setClipboardText(text);
|
pasteWithCtrlCmdV(text);
|
||||||
pasteWithCtrlCmdV();
|
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const [fx, firstElY] = getElementBounds(h.elements[0]);
|
const [fx, firstElY] = getElementBounds(h.elements[0]);
|
||||||
@ -177,8 +164,7 @@ describe("paste text as single lines", () => {
|
|||||||
) +
|
) +
|
||||||
10 / h.app.state.zoom.value;
|
10 / h.app.state.zoom.value;
|
||||||
mouse.moveTo(100, 100);
|
mouse.moveTo(100, 100);
|
||||||
setClipboardText(text);
|
pasteWithCtrlCmdV(text);
|
||||||
pasteWithCtrlCmdV();
|
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const [fx, firstElY] = getElementBounds(h.elements[0]);
|
const [fx, firstElY] = getElementBounds(h.elements[0]);
|
||||||
@ -192,16 +178,14 @@ describe("paste text as single lines", () => {
|
|||||||
describe("paste text as a single element", () => {
|
describe("paste text as a single element", () => {
|
||||||
it("should create single text element when copying text with Ctrl/Cmd+Shift+V", async () => {
|
it("should create single text element when copying text with Ctrl/Cmd+Shift+V", async () => {
|
||||||
const text = "sajgfakfn\naaksfnknas\nakefnkasf";
|
const text = "sajgfakfn\naaksfnknas\nakefnkasf";
|
||||||
setClipboardText(text);
|
pasteWithCtrlCmdShiftV(text);
|
||||||
pasteWithCtrlCmdShiftV();
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements.length).toEqual(1);
|
expect(h.elements.length).toEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("should not create any element when only new lines in clipboard", async () => {
|
it("should not create any element when only new lines in clipboard", async () => {
|
||||||
const text = "\n\n\n\n";
|
const text = "\n\n\n\n";
|
||||||
setClipboardText(text);
|
pasteWithCtrlCmdShiftV(text);
|
||||||
pasteWithCtrlCmdShiftV();
|
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
await sleep(50);
|
await sleep(50);
|
||||||
expect(h.elements.length).toEqual(0);
|
expect(h.elements.length).toEqual(0);
|
||||||
@ -243,8 +227,7 @@ describe("Paste bound text container", () => {
|
|||||||
type: "excalidraw/clipboard",
|
type: "excalidraw/clipboard",
|
||||||
elements: [container, textElement],
|
elements: [container, textElement],
|
||||||
});
|
});
|
||||||
setClipboardText(data);
|
pasteWithCtrlCmdShiftV(data);
|
||||||
pasteWithCtrlCmdShiftV();
|
|
||||||
|
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
await sleep(1);
|
await sleep(1);
|
||||||
@ -266,8 +249,7 @@ describe("Paste bound text container", () => {
|
|||||||
textElement,
|
textElement,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
setClipboardText(data);
|
pasteWithCtrlCmdShiftV(data);
|
||||||
pasteWithCtrlCmdShiftV();
|
|
||||||
|
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
await sleep(1);
|
await sleep(1);
|
||||||
|
@ -727,7 +727,7 @@ describe("freedraw", () => {
|
|||||||
describe("image", () => {
|
describe("image", () => {
|
||||||
const createImage = async () => {
|
const createImage = async () => {
|
||||||
const sendPasteEvent = (file?: File) => {
|
const sendPasteEvent = (file?: File) => {
|
||||||
const clipboardEvent = createPasteEvent("", file ? [file] : []);
|
const clipboardEvent = createPasteEvent({}, file ? [file] : []);
|
||||||
document.dispatchEvent(clipboardEvent);
|
document.dispatchEvent(clipboardEvent);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -208,10 +208,8 @@ export const assertSelectedElements = (
|
|||||||
expect(selectedElementIds).toEqual(expect.arrayContaining(ids));
|
expect(selectedElementIds).toEqual(expect.arrayContaining(ids));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createPasteEvent = (
|
export const createPasteEvent = <T extends "text/plain" | "text/html">(
|
||||||
text:
|
items: Record<T, string>,
|
||||||
| string
|
|
||||||
| /* getData function */ ((type: string) => string | Promise<string>),
|
|
||||||
files?: File[],
|
files?: File[],
|
||||||
) => {
|
) => {
|
||||||
return Object.assign(
|
return Object.assign(
|
||||||
@ -222,11 +220,12 @@ export const createPasteEvent = (
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
clipboardData: {
|
clipboardData: {
|
||||||
getData: typeof text === "string" ? () => text : text,
|
getData: (type: string) =>
|
||||||
|
(items as Record<string, string>)[type] || "",
|
||||||
files: files || [],
|
files: files || [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
) as any as ClipboardEvent;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toggleMenu = (container: HTMLElement) => {
|
export const toggleMenu = (container: HTMLElement) => {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user