From b9d27d308ead33419ebada746933b6081ebe88ad Mon Sep 17 00:00:00 2001
From: David Luzar <5153846+dwelle@users.noreply.github.com>
Date: Sat, 6 Sep 2025 22:51:23 +0200
Subject: [PATCH] fix: pasting not working in firefox (#9947)
---
packages/common/src/constants.ts | 6 +-
packages/excalidraw/clipboard.test.ts | 149 ++++++++-------
packages/excalidraw/clipboard.ts | 171 ++++++++++++++++--
packages/excalidraw/components/App.tsx | 50 +++--
packages/excalidraw/data/blob.ts | 40 +---
.../tests/__snapshots__/history.test.tsx.snap | 2 +-
packages/excalidraw/tests/appState.test.tsx | 31 ++--
packages/excalidraw/tests/export.test.tsx | 30 ++-
packages/excalidraw/tests/helpers/api.ts | 58 +++---
.../excalidraw/tests/helpers/polyfills.ts | 25 +--
packages/excalidraw/tests/history.test.tsx | 56 +++---
packages/excalidraw/tests/image.test.tsx | 2 +-
packages/excalidraw/tests/library.test.tsx | 71 ++++----
packages/excalidraw/wysiwyg/textWysiwyg.tsx | 15 +-
14 files changed, 445 insertions(+), 261 deletions(-)
diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts
index aef2fda9f5..88a1027720 100644
--- a/packages/common/src/constants.ts
+++ b/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",
diff --git a/packages/excalidraw/clipboard.test.ts b/packages/excalidraw/clipboard.test.ts
index 770bcc90e7..2115c3eff2 100644
--- a/packages/excalidraw/clipboard.test.ts
+++ b/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": `
${json}
`,
- },
- }),
+ await parseDataTransferEvent(
+ createPasteEvent({
+ types: {
+ "text/html": ` ${json}
`,
+ },
+ }),
+ ),
);
expect(clipboardData.elements).toEqual([rect]);
// -------------------------------------------------------------------------
@@ -80,11 +93,13 @@ describe("parseClipboard()", () => {
let clipboardData;
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
- createPasteEvent({
- types: {
- "text/html": `
`,
- },
- }),
+ await parseDataTransferEvent(
+ createPasteEvent({
+ types: {
+ "text/html": `
`,
+ },
+ }),
+ ),
);
expect(clipboardData.mixedContent).toEqual([
{
@@ -94,11 +109,13 @@ describe("parseClipboard()", () => {
]);
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
- createPasteEvent({
- types: {
- "text/html": `
`,
- },
- }),
+ await parseDataTransferEvent(
+ createPasteEvent({
+ types: {
+ "text/html": `
`,
+ },
+ }),
+ ),
);
expect(clipboardData.mixedContent).toEqual([
{
@@ -114,11 +131,13 @@ describe("parseClipboard()", () => {
it("should parse text content alongside `src` urls out of text/html", async () => {
const clipboardData = await parseClipboard(
- createPasteEvent({
- types: {
- "text/html": `hello my friend!`,
- },
- }),
+ await parseDataTransferEvent(
+ createPasteEvent({
+ types: {
+ "text/html": `hello my friend!`,
+ },
+ }),
+ ),
);
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": `
-
-
-
- `,
- "text/plain": `a b
- 1 2
- 4 5
- 7 10`,
- },
- }),
+ await parseDataTransferEvent(
+ createPasteEvent({
+ types: {
+ "text/html": `
+
+
+
+ `,
+ "text/plain": `a b
+ 1 2
+ 4 5
+ 7 10`,
+ },
+ }),
+ ),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts
index ceaee38c80..9eb1014223 100644
--- a/packages/excalidraw/clipboard.ts
+++ b/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,
): { 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 => {
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;
+ kind: "file";
+ file: File;
+ fileHandle: FileSystemHandle | null;
+ }
+ | { type: ValueOf; 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,
+>(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType | null {
+ return (
+ this.find(
+ (item): item is ParsedDataTransferItemType => item.type === type,
+ ) || null
+ );
+};
+const getDataTransferItemData = function <
+ T extends ValueOf,
+>(
+ this: ParsedDataTranferList,
+ type: T,
+):
+ | ParsedDataTransferItemType>["value"]
+ | null {
+ const item = this.find(
+ (
+ item,
+ ): item is ParsedDataTransferItemType> =>
+ 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,
+): Promise => {
+ 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 => {
+ 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((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 => {
const parsedEventData = await parseClipboardEventTextData(
- event,
+ dataList,
isPlainPaste,
);
diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx
index bf838b1c39..788600749d 100644
--- a/packages/excalidraw/components/App.tsx
+++ b/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 {
// TODO: Cover with tests
private async insertClipboardContent(
data: ClipboardData,
- filesData: Awaited>,
+ dataTransferFiles: ParsedDataTransferFile[],
isPlainPaste: boolean,
) {
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
@@ -3088,7 +3093,7 @@ class App extends React.Component {
}
// ------------------- 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 {
}
// ------------------- 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 {
// 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 {
}
}
- 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 {
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 {
}
}
- 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 {
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) &&
diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts
index dc65cf0d3b..2b6829a938 100644
--- a/packages/excalidraw/data/blob.ts
+++ b/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 | 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;
- 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 => {
diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
index dbe5e38584..c2284567ce 100644
--- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
+++ b/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,
diff --git a/packages/excalidraw/tests/appState.test.tsx b/packages/excalidraw/tests/appState.test.tsx
index abb7ac1762..39b3f238a2 100644
--- a/packages/excalidraw/tests/appState.test.tsx
+++ b/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" })]);
diff --git a/packages/excalidraw/tests/export.test.tsx b/packages/excalidraw/tests/export.test.tsx
index a42e56b90c..365db0c6b1 100644
--- a/packages/excalidraw/tests/export.test.tsx
+++ b/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: "😀" }),
diff --git a/packages/excalidraw/tests/helpers/api.ts b/packages/excalidraw/tests/helpers/api.ts
index 2de2a2890f..68b0813160 100644
--- a/packages/excalidraw/tests/helpers/api.ts
+++ b/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];
- const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
- const texts = await Promise.all(
- blobs.map(
- (blob) =>
- new Promise((resolve, reject) => {
- try {
- const reader = new FileReader();
- reader.onload = () => {
- resolve(reader.result as string);
- };
- reader.readAsText(blob);
- } catch (error: any) {
- reject(error);
- }
- }),
- ),
- );
+ static drop = async (items: ({kind: "string", value: string, type: string} | {kind: "file", file: File | Blob, type?: string })[]) => {
- const files = blobs as File[] & { item: (index: number) => File };
+ const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
+
+ 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,
+ // 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 {
+ 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) => {
- const idx = blobs.findIndex((b) => b.type === type);
- if (idx >= 0) {
- return texts[idx];
- }
- if (type === "text") {
- return texts.join("\n");
- }
- return "";
+ 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", {
diff --git a/packages/excalidraw/tests/helpers/polyfills.ts b/packages/excalidraw/tests/helpers/polyfills.ts
index 967e8d4e82..d02c7290be 100644
--- a/packages/excalidraw/tests/helpers/polyfills.ts
+++ b/packages/excalidraw/tests/helpers/polyfills.ts
@@ -47,42 +47,43 @@ class DataTransferItem {
}
}
-class DataTransferList {
- items: DataTransferItem[] = [];
-
+class DataTransferItemList extends Array {
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 = {};
+ 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 || "";
}
}
diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx
index ed9d5137a2..aca5530d4c 100644
--- a/packages/excalidraw/tests/history.test.tsx
+++ b/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();
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();
diff --git a/packages/excalidraw/tests/image.test.tsx b/packages/excalidraw/tests/image.test.tsx
index f9a372ed63..23b4fda6fc 100644
--- a/packages/excalidraw/tests/image.test.tsx
+++ b/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();
});
diff --git a/packages/excalidraw/tests/library.test.tsx b/packages/excalidraw/tests/library.test.tsx
index 1c9b7a53ac..f1c0f0a457 100644
--- a/packages/excalidraw/tests/library.test.tsx
+++ b/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" })]);
});
diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.tsx
index 73f3d7171b..054be43e99 100644
--- a/packages/excalidraw/wysiwyg/textWysiwyg.tsx
+++ b/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),
);