Selaa lähdekoodia

fix: pasting not working in firefox (#9947)

David Luzar 4 päivää sitten
vanhempi
commit
b9d27d308e

+ 5 - 1
packages/common/src/constants.ts

@@ -259,13 +259,17 @@ export const IMAGE_MIME_TYPES = {
   jfif: "image/jfif",
 } as const;
 
-export const MIME_TYPES = {
+export const STRING_MIME_TYPES = {
   text: "text/plain",
   html: "text/html",
   json: "application/json",
   // excalidraw data
   excalidraw: "application/vnd.excalidraw+json",
   excalidrawlib: "application/vnd.excalidrawlib+json",
+} as const;
+
+export const MIME_TYPES = {
+  ...STRING_MIME_TYPES,
   // image-encoded excalidraw data
   "excalidraw.svg": "image/svg+xml",
   "excalidraw.png": "image/png",

+ 87 - 62
packages/excalidraw/clipboard.test.ts

@@ -1,6 +1,7 @@
 import {
   createPasteEvent,
   parseClipboard,
+  parseDataTransferEvent,
   serializeAsClipboardJSON,
 } from "./clipboard";
 import { API } from "./tests/helpers/api";
@@ -13,7 +14,9 @@ describe("parseClipboard()", () => {
 
     text = "123";
     clipboardData = await parseClipboard(
-      createPasteEvent({ types: { "text/plain": text } }),
+      await parseDataTransferEvent(
+        createPasteEvent({ types: { "text/plain": text } }),
+      ),
     );
     expect(clipboardData.text).toBe(text);
 
@@ -21,7 +24,9 @@ describe("parseClipboard()", () => {
 
     text = "[123]";
     clipboardData = await parseClipboard(
-      createPasteEvent({ types: { "text/plain": text } }),
+      await parseDataTransferEvent(
+        createPasteEvent({ types: { "text/plain": text } }),
+      ),
     );
     expect(clipboardData.text).toBe(text);
 
@@ -29,7 +34,9 @@ describe("parseClipboard()", () => {
 
     text = JSON.stringify({ val: 42 });
     clipboardData = await parseClipboard(
-      createPasteEvent({ types: { "text/plain": text } }),
+      await parseDataTransferEvent(
+        createPasteEvent({ types: { "text/plain": text } }),
+      ),
     );
     expect(clipboardData.text).toBe(text);
   });
@@ -39,11 +46,13 @@ describe("parseClipboard()", () => {
 
     const json = serializeAsClipboardJSON({ elements: [rect], files: null });
     const clipboardData = await parseClipboard(
-      createPasteEvent({
-        types: {
-          "text/plain": json,
-        },
-      }),
+      await parseDataTransferEvent(
+        createPasteEvent({
+          types: {
+            "text/plain": json,
+          },
+        }),
+      ),
     );
     expect(clipboardData.elements).toEqual([rect]);
   });
@@ -56,21 +65,25 @@ describe("parseClipboard()", () => {
     // -------------------------------------------------------------------------
     json = serializeAsClipboardJSON({ elements: [rect], files: null });
     clipboardData = await parseClipboard(
-      createPasteEvent({
-        types: {
-          "text/html": json,
-        },
-      }),
+      await parseDataTransferEvent(
+        createPasteEvent({
+          types: {
+            "text/html": json,
+          },
+        }),
+      ),
     );
     expect(clipboardData.elements).toEqual([rect]);
     // -------------------------------------------------------------------------
     json = serializeAsClipboardJSON({ elements: [rect], files: null });
     clipboardData = await parseClipboard(
-      createPasteEvent({
-        types: {
-          "text/html": `<div> ${json}</div>`,
-        },
-      }),
+      await parseDataTransferEvent(
+        createPasteEvent({
+          types: {
+            "text/html": `<div> ${json}</div>`,
+          },
+        }),
+      ),
     );
     expect(clipboardData.elements).toEqual([rect]);
     // -------------------------------------------------------------------------
@@ -80,11 +93,13 @@ describe("parseClipboard()", () => {
     let clipboardData;
     // -------------------------------------------------------------------------
     clipboardData = await parseClipboard(
-      createPasteEvent({
-        types: {
-          "text/html": `<img src="https://example.com/image.png" />`,
-        },
-      }),
+      await parseDataTransferEvent(
+        createPasteEvent({
+          types: {
+            "text/html": `<img src="https://example.com/image.png" />`,
+          },
+        }),
+      ),
     );
     expect(clipboardData.mixedContent).toEqual([
       {
@@ -94,11 +109,13 @@ describe("parseClipboard()", () => {
     ]);
     // -------------------------------------------------------------------------
     clipboardData = await parseClipboard(
-      createPasteEvent({
-        types: {
-          "text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
-        },
-      }),
+      await parseDataTransferEvent(
+        createPasteEvent({
+          types: {
+            "text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
+          },
+        }),
+      ),
     );
     expect(clipboardData.mixedContent).toEqual([
       {
@@ -114,11 +131,13 @@ describe("parseClipboard()", () => {
 
   it("should parse text content alongside <image> `src` urls out of text/html", async () => {
     const clipboardData = await parseClipboard(
-      createPasteEvent({
-        types: {
-          "text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
-        },
-      }),
+      await parseDataTransferEvent(
+        createPasteEvent({
+          types: {
+            "text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
+          },
+        }),
+      ),
     );
     expect(clipboardData.mixedContent).toEqual([
       {
@@ -141,14 +160,16 @@ describe("parseClipboard()", () => {
     let clipboardData;
     // -------------------------------------------------------------------------
     clipboardData = await parseClipboard(
-      createPasteEvent({
-        types: {
-          "text/plain": `a	b
-        1	2
-        4	5
-        7	10`,
-        },
-      }),
+      await parseDataTransferEvent(
+        createPasteEvent({
+          types: {
+            "text/plain": `a	b
+            1	2
+            4	5
+            7	10`,
+          },
+        }),
+      ),
     );
     expect(clipboardData.spreadsheet).toEqual({
       title: "b",
@@ -157,14 +178,16 @@ describe("parseClipboard()", () => {
     });
     // -------------------------------------------------------------------------
     clipboardData = await parseClipboard(
-      createPasteEvent({
-        types: {
-          "text/html": `a	b
-        1	2
-        4	5
-        7	10`,
-        },
-      }),
+      await parseDataTransferEvent(
+        createPasteEvent({
+          types: {
+            "text/html": `a	b
+            1	2
+            4	5
+            7	10`,
+          },
+        }),
+      ),
     );
     expect(clipboardData.spreadsheet).toEqual({
       title: "b",
@@ -173,19 +196,21 @@ describe("parseClipboard()", () => {
     });
     // -------------------------------------------------------------------------
     clipboardData = await parseClipboard(
-      createPasteEvent({
-        types: {
-          "text/html": `<html>
-        <body>
-        <!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;a&quot;}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;b&quot;}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:10}">10</td></tr></tbody></table><!--EndFragment-->
-        </body>
-        </html>`,
-          "text/plain": `a	b
-        1	2
-        4	5
-        7	10`,
-        },
-      }),
+      await parseDataTransferEvent(
+        createPasteEvent({
+          types: {
+            "text/html": `<html>
+            <body>
+            <!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;a&quot;}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;b&quot;}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:10}">10</td></tr></tbody></table><!--EndFragment-->
+            </body>
+            </html>`,
+            "text/plain": `a	b
+            1	2
+            4	5
+            7	10`,
+          },
+        }),
+      ),
     );
     expect(clipboardData.spreadsheet).toEqual({
       title: "b",

+ 154 - 17
packages/excalidraw/clipboard.ts

@@ -17,15 +17,25 @@ import {
 
 import { getContainingFrame } from "@excalidraw/element";
 
+import type { ValueOf } from "@excalidraw/common/utility-types";
+
+import type { IMAGE_MIME_TYPES, STRING_MIME_TYPES } from "@excalidraw/common";
 import type {
   ExcalidrawElement,
   NonDeletedExcalidrawElement,
 } from "@excalidraw/element/types";
 
 import { ExcalidrawError } from "./errors";
-import { createFile, isSupportedImageFileType } from "./data/blob";
+import {
+  createFile,
+  getFileHandle,
+  isSupportedImageFileType,
+} from "./data/blob";
+
 import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
 
+import type { FileSystemHandle } from "./data/filesystem";
+
 import type { Spreadsheet } from "./charts";
 
 import type { BinaryFiles } from "./types";
@@ -102,10 +112,11 @@ export const createPasteEvent = ({
       if (typeof value !== "string") {
         files = files || [];
         files.push(value);
+        event.clipboardData?.items.add(value);
         continue;
       }
       try {
-        event.clipboardData?.setData(type, value);
+        event.clipboardData?.items.add(value, type);
         if (event.clipboardData?.getData(type) !== value) {
           throw new Error(`Failed to set "${type}" as clipboardData item`);
         }
@@ -230,14 +241,10 @@ function parseHTMLTree(el: ChildNode) {
   return result;
 }
 
-const maybeParseHTMLPaste = (
-  event: ClipboardEvent,
+const maybeParseHTMLDataItem = (
+  dataItem: ParsedDataTransferItemType<typeof MIME_TYPES["html"]>,
 ): { type: "mixedContent"; value: PastedMixedContent } | null => {
-  const html = event.clipboardData?.getData(MIME_TYPES.html);
-
-  if (!html) {
-    return null;
-  }
+  const html = dataItem.value;
 
   try {
     const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
@@ -333,18 +340,21 @@ export const readSystemClipboard = async () => {
  * Parses "paste" ClipboardEvent.
  */
 const parseClipboardEventTextData = async (
-  event: ClipboardEvent,
+  dataList: ParsedDataTranferList,
   isPlainPaste = false,
 ): Promise<ParsedClipboardEventTextData> => {
   try {
-    const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
+    const htmlItem = dataList.findByType(MIME_TYPES.html);
+
+    const mixedContent =
+      !isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem);
 
     if (mixedContent) {
       if (mixedContent.value.every((item) => item.type === "text")) {
         return {
           type: "text",
           value:
-            event.clipboardData?.getData(MIME_TYPES.text) ||
+            dataList.getData(MIME_TYPES.text) ??
             mixedContent.value
               .map((item) => item.value)
               .join("\n")
@@ -355,23 +365,150 @@ const parseClipboardEventTextData = async (
       return mixedContent;
     }
 
-    const text = event.clipboardData?.getData(MIME_TYPES.text);
-
-    return { type: "text", value: (text || "").trim() };
+    return {
+      type: "text",
+      value: (dataList.getData(MIME_TYPES.text) || "").trim(),
+    };
   } catch {
     return { type: "text", value: "" };
   }
 };
 
+type AllowedParsedDataTransferItem =
+  | {
+      type: ValueOf<typeof IMAGE_MIME_TYPES>;
+      kind: "file";
+      file: File;
+      fileHandle: FileSystemHandle | null;
+    }
+  | { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
+
+type ParsedDataTransferItem =
+  | {
+      type: string;
+      kind: "file";
+      file: File;
+      fileHandle: FileSystemHandle | null;
+    }
+  | { type: string; kind: "string"; value: string };
+
+type ParsedDataTransferItemType<
+  T extends AllowedParsedDataTransferItem["type"],
+> = AllowedParsedDataTransferItem & { type: T };
+
+export type ParsedDataTransferFile = Extract<
+  AllowedParsedDataTransferItem,
+  { kind: "file" }
+>;
+
+type ParsedDataTranferList = ParsedDataTransferItem[] & {
+  /**
+   * Only allows filtering by known `string` data types, since `file`
+   * types can have multiple items of the same type (e.g. multiple image files)
+   * unlike `string` data transfer items.
+   */
+  findByType: typeof findDataTransferItemType;
+  /**
+   * Only allows filtering by known `string` data types, since `file`
+   * types can have multiple items of the same type (e.g. multiple image files)
+   * unlike `string` data transfer items.
+   */
+  getData: typeof getDataTransferItemData;
+  getFiles: typeof getDataTransferFiles;
+};
+
+const findDataTransferItemType = function <
+  T extends ValueOf<typeof STRING_MIME_TYPES>,
+>(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType<T> | null {
+  return (
+    this.find(
+      (item): item is ParsedDataTransferItemType<T> => item.type === type,
+    ) || null
+  );
+};
+const getDataTransferItemData = function <
+  T extends ValueOf<typeof STRING_MIME_TYPES>,
+>(
+  this: ParsedDataTranferList,
+  type: T,
+):
+  | ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>>["value"]
+  | null {
+  const item = this.find(
+    (
+      item,
+    ): item is ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>> =>
+      item.type === type,
+  );
+
+  return item?.value ?? null;
+};
+
+const getDataTransferFiles = function (
+  this: ParsedDataTranferList,
+): ParsedDataTransferFile[] {
+  return this.filter(
+    (item): item is ParsedDataTransferFile => item.kind === "file",
+  );
+};
+
+export const parseDataTransferEvent = async (
+  event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
+): Promise<ParsedDataTranferList> => {
+  let items: DataTransferItemList | undefined = undefined;
+
+  if (isClipboardEvent(event)) {
+    items = event.clipboardData?.items;
+  } else {
+    const dragEvent = event;
+    items = dragEvent.dataTransfer?.items;
+  }
+
+  const dataItems = (
+    await Promise.all(
+      Array.from(items || []).map(
+        async (item): Promise<ParsedDataTransferItem | null> => {
+          if (item.kind === "file") {
+            const file = item.getAsFile();
+            if (file) {
+              const fileHandle = await getFileHandle(item);
+              return { type: file.type, kind: "file", file, fileHandle };
+            }
+          } else if (item.kind === "string") {
+            const { type } = item;
+            let value: string;
+            if ("clipboardData" in event && event.clipboardData) {
+              value = event.clipboardData?.getData(type);
+            } else {
+              value = await new Promise<string>((resolve) => {
+                item.getAsString((str) => resolve(str));
+              });
+            }
+            return { type, kind: "string", value };
+          }
+
+          return null;
+        },
+      ),
+    )
+  ).filter((data): data is ParsedDataTransferItem => data != null);
+
+  return Object.assign(dataItems, {
+    findByType: findDataTransferItemType,
+    getData: getDataTransferItemData,
+    getFiles: getDataTransferFiles,
+  });
+};
+
 /**
  * Attempts to parse clipboard event.
  */
 export const parseClipboard = async (
-  event: ClipboardEvent,
+  dataList: ParsedDataTranferList,
   isPlainPaste = false,
 ): Promise<ClipboardData> => {
   const parsedEventData = await parseClipboardEventTextData(
-    event,
+    dataList,
     isPlainPaste,
   );
 

+ 30 - 20
packages/excalidraw/components/App.tsx

@@ -324,7 +324,13 @@ import {
   isEraserActive,
   isHandToolActive,
 } from "../appState";
-import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
+import {
+  copyTextToSystemClipboard,
+  parseClipboard,
+  parseDataTransferEvent,
+  type ParsedDataTransferFile,
+} from "../clipboard";
+
 import { exportCanvas, loadFromBlob } from "../data";
 import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
 import { restore, restoreElements } from "../data/restore";
@@ -346,7 +352,6 @@ import {
   generateIdFromFile,
   getDataURL,
   getDataURL_sync,
-  getFilesFromEvent,
   ImageURLToFile,
   isImageFileHandle,
   isSupportedImageFile,
@@ -3070,7 +3075,7 @@ class App extends React.Component<AppProps, AppState> {
   // TODO: Cover with tests
   private async insertClipboardContent(
     data: ClipboardData,
-    filesData: Awaited<ReturnType<typeof getFilesFromEvent>>,
+    dataTransferFiles: ParsedDataTransferFile[],
     isPlainPaste: boolean,
   ) {
     const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
@@ -3088,7 +3093,7 @@ class App extends React.Component<AppProps, AppState> {
     }
 
     // ------------------- Mixed content with no files -------------------
-    if (filesData.length === 0 && !isPlainPaste && data.mixedContent) {
+    if (dataTransferFiles.length === 0 && !isPlainPaste && data.mixedContent) {
       await this.addElementsFromMixedContentPaste(data.mixedContent, {
         isPlainPaste,
         sceneX,
@@ -3109,9 +3114,7 @@ class App extends React.Component<AppProps, AppState> {
     }
 
     // ------------------- Images or SVG code -------------------
-    const imageFiles = filesData
-      .map((data) => data.file)
-      .filter((file): file is File => isSupportedImageFile(file));
+    const imageFiles = dataTransferFiles.map((data) => data.file);
 
     if (imageFiles.length === 0 && data.text && !isPlainPaste) {
       const trimmedText = data.text.trim();
@@ -3256,8 +3259,11 @@ class App extends React.Component<AppProps, AppState> {
       // 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)
-      const filesData = await getFilesFromEvent(event);
-      const data = await parseClipboard(event, isPlainPaste);
+      const dataTransferList = await parseDataTransferEvent(event);
+
+      const filesList = dataTransferList.getFiles();
+
+      const data = await parseClipboard(dataTransferList, isPlainPaste);
 
       if (this.props.onPaste) {
         try {
@@ -3269,7 +3275,8 @@ class App extends React.Component<AppProps, AppState> {
         }
       }
 
-      await this.insertClipboardContent(data, filesData, isPlainPaste);
+      await this.insertClipboardContent(data, filesList, isPlainPaste);
+
       this.setActiveTool({ type: this.defaultSelectionTool }, true);
       event?.preventDefault();
     },
@@ -10479,12 +10486,13 @@ class App extends React.Component<AppProps, AppState> {
       event,
       this.state,
     );
+    const dataTransferList = await parseDataTransferEvent(event);
 
     // must be retrieved first, in the same frame
-    const filesData = await getFilesFromEvent(event);
+    const fileItems = dataTransferList.getFiles();
 
-    if (filesData.length === 1) {
-      const { file, fileHandle } = filesData[0];
+    if (fileItems.length === 1) {
+      const { file, fileHandle } = fileItems[0];
 
       if (
         file &&
@@ -10516,15 +10524,15 @@ class App extends React.Component<AppProps, AppState> {
       }
     }
 
-    const imageFiles = filesData
+    const imageFiles = fileItems
       .map((data) => data.file)
-      .filter((file): file is File => isSupportedImageFile(file));
+      .filter((file) => isSupportedImageFile(file));
 
     if (imageFiles.length > 0 && this.isToolSupported("image")) {
       return this.insertImages(imageFiles, sceneX, sceneY);
     }
 
-    const libraryJSON = event.dataTransfer.getData(MIME_TYPES.excalidrawlib);
+    const libraryJSON = dataTransferList.getData(MIME_TYPES.excalidrawlib);
     if (libraryJSON && typeof libraryJSON === "string") {
       try {
         const libraryItems = parseLibraryJSON(libraryJSON);
@@ -10539,16 +10547,18 @@ class App extends React.Component<AppProps, AppState> {
       return;
     }
 
-    if (filesData.length > 0) {
-      const { file, fileHandle } = filesData[0];
+    if (fileItems.length > 0) {
+      const { file, fileHandle } = fileItems[0];
       if (file) {
         // Attempt to parse an excalidraw/excalidrawlib file
         await this.loadFileToCanvas(file, fileHandle);
       }
     }
 
-    if (event.dataTransfer?.types?.includes("text/plain")) {
-      const text = event.dataTransfer?.getData("text");
+    const textItem = dataTransferList.findByType(MIME_TYPES.text);
+
+    if (textItem) {
+      const text = textItem.value;
       if (
         text &&
         embeddableURLValidator(text, this.props.validateEmbeddable) &&

+ 2 - 38
packages/excalidraw/data/blob.ts

@@ -18,8 +18,6 @@ import { CanvasError, ImageSceneDataError } from "../errors";
 import { calculateScrollCenter } from "../scene";
 import { decodeSvgBase64Payload } from "../scene/export";
 
-import { isClipboardEvent } from "../clipboard";
-
 import { base64ToString, stringToBase64, toByteString } from "./encode";
 import { nativeFileSystemSupported } from "./filesystem";
 import { isValidExcalidrawData, isValidLibrary } from "./json";
@@ -98,6 +96,8 @@ export const getMimeType = (blob: Blob | string): string => {
     return MIME_TYPES.jpg;
   } else if (/\.svg$/.test(name)) {
     return MIME_TYPES.svg;
+  } else if (/\.excalidrawlib$/.test(name)) {
+    return MIME_TYPES.excalidrawlib;
   }
   return "";
 };
@@ -391,42 +391,6 @@ export const ImageURLToFile = async (
   throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
 };
 
-export const getFilesFromEvent = async (
-  event: React.DragEvent<HTMLDivElement> | ClipboardEvent,
-) => {
-  let fileList: FileList | undefined = undefined;
-  let items: DataTransferItemList | undefined = undefined;
-
-  if (isClipboardEvent(event)) {
-    fileList = event.clipboardData?.files;
-    items = event.clipboardData?.items;
-  } else {
-    const dragEvent = event as React.DragEvent<HTMLDivElement>;
-    fileList = dragEvent.dataTransfer?.files;
-    items = dragEvent.dataTransfer?.items;
-  }
-
-  const files: (File | null)[] = Array.from(fileList || []);
-
-  return await Promise.all(
-    files.map(async (file, idx) => {
-      const dataTransferItem = items?.[idx];
-      const fileHandle = dataTransferItem
-        ? getFileHandle(dataTransferItem)
-        : null;
-      return file
-        ? {
-            file: await normalizeFile(file),
-            fileHandle: await fileHandle,
-          }
-        : {
-            file: null,
-            fileHandle: null,
-          };
-    }),
-  );
-};
-
 export const getFileHandle = async (
   event: DragEvent | React.DragEvent | DataTransferItem,
 ): Promise<FileSystemHandle | null> => {

+ 1 - 1
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap

@@ -12291,7 +12291,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
   "editingGroupId": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
-  "errorMessage": "Couldn't load invalid file",
+  "errorMessage": null,
   "exportBackground": true,
   "exportEmbedScene": false,
   "exportScale": 1,

+ 17 - 14
packages/excalidraw/tests/appState.test.tsx

@@ -35,20 +35,23 @@ describe("appState", () => {
       expect(h.state.viewBackgroundColor).toBe("#F00");
     });
 
-    await API.drop(
-      new Blob(
-        [
-          JSON.stringify({
-            type: EXPORT_DATA_TYPES.excalidraw,
-            appState: {
-              viewBackgroundColor: "#000",
-            },
-            elements: [API.createElement({ type: "rectangle", id: "A" })],
-          }),
-        ],
-        { type: MIME_TYPES.json },
-      ),
-    );
+    await API.drop([
+      {
+        kind: "file",
+        file: new Blob(
+          [
+            JSON.stringify({
+              type: EXPORT_DATA_TYPES.excalidraw,
+              appState: {
+                viewBackgroundColor: "#000",
+              },
+              elements: [API.createElement({ type: "rectangle", id: "A" })],
+            }),
+          ],
+          { type: MIME_TYPES.json },
+        ),
+      },
+    ]);
 
     await waitFor(() => {
       expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);

+ 25 - 5
packages/excalidraw/tests/export.test.tsx

@@ -57,7 +57,7 @@ describe("export", () => {
       blob: pngBlob,
       metadata: serializeAsJSON(testElements, h.state, {}, "local"),
     });
-    await API.drop(pngBlobEmbedded);
+    await API.drop([{ kind: "file", file: pngBlobEmbedded }]);
 
     await waitFor(() => {
       expect(h.elements).toEqual([
@@ -94,7 +94,12 @@ describe("export", () => {
   });
 
   it("import embedded png (legacy v1)", async () => {
-    await API.drop(await API.loadFile("./fixtures/test_embedded_v1.png"));
+    await API.drop([
+      {
+        kind: "file",
+        file: await API.loadFile("./fixtures/test_embedded_v1.png"),
+      },
+    ]);
     await waitFor(() => {
       expect(h.elements).toEqual([
         expect.objectContaining({ type: "text", text: "test" }),
@@ -103,7 +108,12 @@ describe("export", () => {
   });
 
   it("import embedded png (v2)", async () => {
-    await API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.png"));
+    await API.drop([
+      {
+        kind: "file",
+        file: await API.loadFile("./fixtures/smiley_embedded_v2.png"),
+      },
+    ]);
     await waitFor(() => {
       expect(h.elements).toEqual([
         expect.objectContaining({ type: "text", text: "😀" }),
@@ -112,7 +122,12 @@ describe("export", () => {
   });
 
   it("import embedded svg (legacy v1)", async () => {
-    await API.drop(await API.loadFile("./fixtures/test_embedded_v1.svg"));
+    await API.drop([
+      {
+        kind: "file",
+        file: await API.loadFile("./fixtures/test_embedded_v1.svg"),
+      },
+    ]);
     await waitFor(() => {
       expect(h.elements).toEqual([
         expect.objectContaining({ type: "text", text: "test" }),
@@ -121,7 +136,12 @@ describe("export", () => {
   });
 
   it("import embedded svg (v2)", async () => {
-    await API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.svg"));
+    await API.drop([
+      {
+        kind: "file",
+        file: await API.loadFile("./fixtures/smiley_embedded_v2.svg"),
+      },
+    ]);
     await waitFor(() => {
       expect(h.elements).toEqual([
         expect.objectContaining({ type: "text", text: "😀" }),

+ 28 - 28
packages/excalidraw/tests/helpers/api.ts

@@ -478,43 +478,43 @@ export class API {
     });
   };
 
-  static drop = async (_blobs: Blob[] | Blob) => {
-    const blobs = Array.isArray(_blobs) ? _blobs : [_blobs];
+  static drop = async (items: ({kind: "string", value: string, type: string} | {kind: "file", file: File | Blob, type?: string })[]) => {
+
     const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
-    const texts = await Promise.all(
-      blobs.map(
-        (blob) =>
-          new Promise<string>((resolve, reject) => {
-            try {
-              const reader = new FileReader();
-              reader.onload = () => {
-                resolve(reader.result as string);
-              };
-              reader.readAsText(blob);
-            } catch (error: any) {
-              reject(error);
-            }
-          }),
-      ),
-    );
 
-    const files = blobs as File[] & { item: (index: number) => File };
+    const dataTransferFileItems = items.filter(i => i.kind === "file") as {kind: "file", file: File | Blob, type: string }[];
+
+    const files = dataTransferFileItems.map(item => item.file) as File[] & { item: (index: number) => File };
+    // https://developer.mozilla.org/en-US/docs/Web/API/FileList/item
     files.item = (index: number) => files[index];
 
     Object.defineProperty(fileDropEvent, "dataTransfer", {
       value: {
+        // https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/files
         files,
-        getData: (type: string) => {
-          const idx = blobs.findIndex((b) => b.type === type);
-          if (idx >= 0) {
-            return texts[idx];
-          }
-          if (type === "text") {
-            return texts.join("\n");
+        // https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/items
+        items: items.map((item, idx) => {
+          if (item.kind === "string")  {
+            return {
+              kind: "string",
+              type: item.type,
+              // https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsString
+              getAsString: (cb: (text: string) => any) => cb(item.value),
+            };
           }
-          return "";
+          return {
+            kind: "file",
+            type: item.type || item.file.type,
+            // https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsFile
+            getAsFile: () => item.file,
+          };
+        }),
+        // https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/getData
+        getData: (type: string) => {
+          return items.find((item) => item.type === "string" && item.type === type) || "";
         },
-        types: Array.from(new Set(blobs.map((b) => b.type))),
+        // https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types
+        types: Array.from(new Set(items.map((item) => item.kind === "file" ? "Files" : item.type))),
       },
     });
     Object.defineProperty(fileDropEvent, "clientX", {

+ 13 - 12
packages/excalidraw/tests/helpers/polyfills.ts

@@ -47,42 +47,43 @@ class DataTransferItem {
   }
 }
 
-class DataTransferList {
-  items: DataTransferItem[] = [];
-
+class DataTransferItemList extends Array<DataTransferItem> {
   add(data: string | File, type: string = ""): void {
     if (typeof data === "string") {
-      this.items.push(new DataTransferItem("string", type, data));
+      this.push(new DataTransferItem("string", type, data));
     } else if (data instanceof File) {
-      this.items.push(new DataTransferItem("file", type, data));
+      this.push(new DataTransferItem("file", type, data));
     }
   }
 
   clear(): void {
-    this.items = [];
+    this.clear();
   }
 }
 
 class DataTransfer {
-  public items: DataTransferList = new DataTransferList();
-  private _types: Record<string, string> = {};
+  public items: DataTransferItemList = new DataTransferItemList();
 
   get files() {
-    return this.items.items
+    return this.items
       .filter((item) => item.kind === "file")
       .map((item) => item.getAsFile()!);
   }
 
   add(data: string | File, type: string = ""): void {
-    this.items.add(data, type);
+    if (typeof data === "string") {
+      this.items.add(data, type);
+    } else {
+      this.items.add(data);
+    }
   }
 
   setData(type: string, value: string) {
-    this._types[type] = value;
+    this.items.add(value, type);
   }
 
   getData(type: string) {
-    return this._types[type] || "";
+    return this.items.find((item) => item.type === type)?.data || "";
   }
 }
 

+ 33 - 23
packages/excalidraw/tests/history.test.tsx

@@ -568,21 +568,24 @@ describe("history", () => {
         expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]),
       );
 
-      await API.drop(
-        new Blob(
-          [
-            JSON.stringify({
-              type: EXPORT_DATA_TYPES.excalidraw,
-              appState: {
-                ...getDefaultAppState(),
-                viewBackgroundColor: "#000",
-              },
-              elements: [API.createElement({ type: "rectangle", id: "B" })],
-            }),
-          ],
-          { type: MIME_TYPES.json },
-        ),
-      );
+      await API.drop([
+        {
+          kind: "file",
+          file: new Blob(
+            [
+              JSON.stringify({
+                type: EXPORT_DATA_TYPES.excalidraw,
+                appState: {
+                  ...getDefaultAppState(),
+                  viewBackgroundColor: "#000",
+                },
+                elements: [API.createElement({ type: "rectangle", id: "B" })],
+              }),
+            ],
+            { type: MIME_TYPES.json },
+          ),
+        },
+      ]);
 
       await waitFor(() => expect(API.getUndoStack().length).toBe(1));
       expect(h.state.viewBackgroundColor).toBe("#000");
@@ -624,11 +627,13 @@ describe("history", () => {
       await render(<Excalidraw handleKeyboardGlobally={true} />);
 
       const link = "https://www.youtube.com/watch?v=gkGMXY0wekg";
-      await API.drop(
-        new Blob([link], {
+      await API.drop([
+        {
+          kind: "string",
+          value: link,
           type: MIME_TYPES.text,
-        }),
-      );
+        },
+      ]);
 
       await waitFor(() => {
         expect(API.getUndoStack().length).toBe(1);
@@ -726,10 +731,15 @@ describe("history", () => {
       await setupImageTest();
 
       await API.drop(
-        await Promise.all([
-          API.loadFile("./fixtures/deer.png"),
-          API.loadFile("./fixtures/smiley.png"),
-        ]),
+        (
+          await Promise.all([
+            API.loadFile("./fixtures/deer.png"),
+            API.loadFile("./fixtures/smiley.png"),
+          ])
+        ).map((file) => ({
+          kind: "file",
+          file,
+        })),
       );
 
       await assertImageTest();

+ 1 - 1
packages/excalidraw/tests/image.test.tsx

@@ -77,7 +77,7 @@ describe("image insertion", () => {
       API.loadFile("./fixtures/deer.png"),
       API.loadFile("./fixtures/smiley.png"),
     ]);
-    await API.drop(files);
+    await API.drop(files.map((file) => ({ kind: "file", file })));
 
     await assert();
   });

+ 39 - 32
packages/excalidraw/tests/library.test.tsx

@@ -56,9 +56,13 @@ describe("library", () => {
 
   it("import library via drag&drop", async () => {
     expect(await h.app.library.getLatestLibrary()).toEqual([]);
-    await API.drop(
-      await API.loadFile("./fixtures/fixture_library.excalidrawlib"),
-    );
+    await API.drop([
+      {
+        kind: "file",
+        type: MIME_TYPES.excalidrawlib,
+        file: await API.loadFile("./fixtures/fixture_library.excalidrawlib"),
+      },
+    ]);
     await waitFor(async () => {
       expect(await h.app.library.getLatestLibrary()).toEqual([
         {
@@ -75,11 +79,13 @@ describe("library", () => {
   it("drop library item onto canvas", async () => {
     expect(h.elements).toEqual([]);
     const libraryItems = parseLibraryJSON(await libraryJSONPromise);
-    await API.drop(
-      new Blob([serializeLibraryAsJSON(libraryItems)], {
+    await API.drop([
+      {
+        kind: "string",
+        value: serializeLibraryAsJSON(libraryItems),
         type: MIME_TYPES.excalidrawlib,
-      }),
-    );
+      },
+    ]);
     await waitFor(() => {
       expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
     });
@@ -111,23 +117,20 @@ describe("library", () => {
       },
     });
 
-    await API.drop(
-      new Blob(
-        [
-          serializeLibraryAsJSON([
-            {
-              id: "item1",
-              status: "published",
-              elements: [rectangle, text, arrow],
-              created: 1,
-            },
-          ]),
-        ],
-        {
-          type: MIME_TYPES.excalidrawlib,
-        },
-      ),
-    );
+    await API.drop([
+      {
+        kind: "string",
+        value: serializeLibraryAsJSON([
+          {
+            id: "item1",
+            status: "published",
+            elements: [rectangle, text, arrow],
+            created: 1,
+          },
+        ]),
+        type: MIME_TYPES.excalidrawlib,
+      },
+    ]);
 
     await waitFor(() => {
       expect(h.elements).toEqual(
@@ -170,11 +173,13 @@ describe("library", () => {
       created: 1,
     };
 
-    await API.drop(
-      new Blob([serializeLibraryAsJSON([item1, item1])], {
+    await API.drop([
+      {
+        kind: "string",
+        value: serializeLibraryAsJSON([item1, item1]),
         type: MIME_TYPES.excalidrawlib,
-      }),
-    );
+      },
+    ]);
 
     await waitFor(() => {
       expect(h.elements).toEqual([
@@ -193,11 +198,13 @@ describe("library", () => {
     UI.clickTool("rectangle");
     expect(h.elements).toEqual([]);
     const libraryItems = parseLibraryJSON(await libraryJSONPromise);
-    await API.drop(
-      new Blob([serializeLibraryAsJSON(libraryItems)], {
+    await API.drop([
+      {
+        kind: "string",
+        value: serializeLibraryAsJSON(libraryItems),
         type: MIME_TYPES.excalidrawlib,
-      }),
-    );
+      },
+    ]);
     await waitFor(() => {
       expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
     });

+ 9 - 6
packages/excalidraw/wysiwyg/textWysiwyg.tsx

@@ -7,6 +7,7 @@ import {
   getFontString,
   getFontFamilyString,
   isTestEnv,
+  MIME_TYPES,
 } from "@excalidraw/common";
 
 import {
@@ -45,7 +46,7 @@ import type {
 
 import { actionSaveToActiveFile } from "../actions";
 
-import { parseClipboard } from "../clipboard";
+import { parseDataTransferEvent } from "../clipboard";
 import {
   actionDecreaseFontSize,
   actionIncreaseFontSize,
@@ -332,12 +333,14 @@ export const textWysiwyg = ({
 
   if (onChange) {
     editable.onpaste = async (event) => {
-      const clipboardData = await parseClipboard(event, true);
-      if (!clipboardData.text) {
+      const textItem = (await parseDataTransferEvent(event)).findByType(
+        MIME_TYPES.text,
+      );
+      if (!textItem) {
         return;
       }
-      const data = normalizeText(clipboardData.text);
-      if (!data) {
+      const text = normalizeText(textItem.value);
+      if (!text) {
         return;
       }
       const container = getContainerElement(
@@ -355,7 +358,7 @@ export const textWysiwyg = ({
           app.scene.getNonDeletedElementsMap(),
         );
         const wrappedText = wrapText(
-          `${editable.value}${data}`,
+          `${editable.value}${text}`,
           font,
           getBoundTextMaxWidth(container, boundTextElement),
         );