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": ` - -
ab
12
45
710
- - `, - "text/plain": `a b - 1 2 - 4 5 - 7 10`, - }, - }), + await parseDataTransferEvent( + createPasteEvent({ + types: { + "text/html": ` + +
ab
12
45
710
+ + `, + "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), );