feat: Added Copy/Paste from Google Docs (#7136)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Lakshya Satpal 2023-10-19 15:44:23 +05:30 committed by GitHub
parent dde3dac931
commit 63650f82d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 232 additions and 77 deletions

View File

@ -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);
}); });

View File

@ -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>) => {

View File

@ -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;
} }
}; };

View File

@ -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>,
) => { ) => {

View File

@ -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/;

View File

@ -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",

View File

@ -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);

View File

@ -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);
}; };

View File

@ -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) => {