fix: pasting not working in firefox (#9947)

This commit is contained in:
David Luzar 2025-09-06 22:51:23 +02:00 committed by GitHub
parent 3bdaafe4b5
commit b9d27d308e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 445 additions and 261 deletions

View File

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

View File

@ -1,6 +1,7 @@
import { import {
createPasteEvent, createPasteEvent,
parseClipboard, parseClipboard,
parseDataTransferEvent,
serializeAsClipboardJSON, serializeAsClipboardJSON,
} from "./clipboard"; } from "./clipboard";
import { API } from "./tests/helpers/api"; import { API } from "./tests/helpers/api";
@ -13,7 +14,9 @@ describe("parseClipboard()", () => {
text = "123"; text = "123";
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }), createPasteEvent({ types: { "text/plain": text } }),
),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
@ -21,7 +24,9 @@ describe("parseClipboard()", () => {
text = "[123]"; text = "[123]";
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }), createPasteEvent({ types: { "text/plain": text } }),
),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
@ -29,7 +34,9 @@ describe("parseClipboard()", () => {
text = JSON.stringify({ val: 42 }); text = JSON.stringify({ val: 42 });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }), createPasteEvent({ types: { "text/plain": text } }),
),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
}); });
@ -39,11 +46,13 @@ describe("parseClipboard()", () => {
const json = serializeAsClipboardJSON({ elements: [rect], files: null }); const json = serializeAsClipboardJSON({ elements: [rect], files: null });
const clipboardData = await parseClipboard( const clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ createPasteEvent({
types: { types: {
"text/plain": json, "text/plain": json,
}, },
}), }),
),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([rect]);
}); });
@ -56,21 +65,25 @@ describe("parseClipboard()", () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null }); json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ createPasteEvent({
types: { types: {
"text/html": json, "text/html": json,
}, },
}), }),
),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([rect]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null }); json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ createPasteEvent({
types: { types: {
"text/html": `<div> ${json}</div>`, "text/html": `<div> ${json}</div>`,
}, },
}), }),
),
); );
expect(clipboardData.elements).toEqual([rect]); expect(clipboardData.elements).toEqual([rect]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -80,11 +93,13 @@ describe("parseClipboard()", () => {
let clipboardData; let clipboardData;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ createPasteEvent({
types: { types: {
"text/html": `<img src="https://example.com/image.png" />`, "text/html": `<img src="https://example.com/image.png" />`,
}, },
}), }),
),
); );
expect(clipboardData.mixedContent).toEqual([ expect(clipboardData.mixedContent).toEqual([
{ {
@ -94,11 +109,13 @@ describe("parseClipboard()", () => {
]); ]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ createPasteEvent({
types: { types: {
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`, "text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
}, },
}), }),
),
); );
expect(clipboardData.mixedContent).toEqual([ expect(clipboardData.mixedContent).toEqual([
{ {
@ -114,11 +131,13 @@ describe("parseClipboard()", () => {
it("should parse text content alongside <image> `src` urls out of text/html", async () => { it("should parse text content alongside <image> `src` urls out of text/html", async () => {
const clipboardData = await parseClipboard( const clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ createPasteEvent({
types: { types: {
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`, "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([ expect(clipboardData.mixedContent).toEqual([
{ {
@ -141,6 +160,7 @@ describe("parseClipboard()", () => {
let clipboardData; let clipboardData;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ createPasteEvent({
types: { types: {
"text/plain": `a b "text/plain": `a b
@ -149,6 +169,7 @@ describe("parseClipboard()", () => {
7 10`, 7 10`,
}, },
}), }),
),
); );
expect(clipboardData.spreadsheet).toEqual({ expect(clipboardData.spreadsheet).toEqual({
title: "b", title: "b",
@ -157,6 +178,7 @@ describe("parseClipboard()", () => {
}); });
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ createPasteEvent({
types: { types: {
"text/html": `a b "text/html": `a b
@ -165,6 +187,7 @@ describe("parseClipboard()", () => {
7 10`, 7 10`,
}, },
}), }),
),
); );
expect(clipboardData.spreadsheet).toEqual({ expect(clipboardData.spreadsheet).toEqual({
title: "b", title: "b",
@ -173,6 +196,7 @@ describe("parseClipboard()", () => {
}); });
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ createPasteEvent({
types: { types: {
"text/html": `<html> "text/html": `<html>
@ -186,6 +210,7 @@ describe("parseClipboard()", () => {
7 10`, 7 10`,
}, },
}), }),
),
); );
expect(clipboardData.spreadsheet).toEqual({ expect(clipboardData.spreadsheet).toEqual({
title: "b", title: "b",

View File

@ -17,15 +17,25 @@ import {
import { getContainingFrame } from "@excalidraw/element"; 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 { import type {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { ExcalidrawError } from "./errors"; import { ExcalidrawError } from "./errors";
import { createFile, isSupportedImageFileType } from "./data/blob"; import {
createFile,
getFileHandle,
isSupportedImageFileType,
} from "./data/blob";
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
import type { FileSystemHandle } from "./data/filesystem";
import type { Spreadsheet } from "./charts"; import type { Spreadsheet } from "./charts";
import type { BinaryFiles } from "./types"; import type { BinaryFiles } from "./types";
@ -102,10 +112,11 @@ export const createPasteEvent = ({
if (typeof value !== "string") { if (typeof value !== "string") {
files = files || []; files = files || [];
files.push(value); files.push(value);
event.clipboardData?.items.add(value);
continue; continue;
} }
try { try {
event.clipboardData?.setData(type, value); event.clipboardData?.items.add(value, type);
if (event.clipboardData?.getData(type) !== value) { if (event.clipboardData?.getData(type) !== value) {
throw new Error(`Failed to set "${type}" as clipboardData item`); throw new Error(`Failed to set "${type}" as clipboardData item`);
} }
@ -230,14 +241,10 @@ function parseHTMLTree(el: ChildNode) {
return result; return result;
} }
const maybeParseHTMLPaste = ( const maybeParseHTMLDataItem = (
event: ClipboardEvent, dataItem: ParsedDataTransferItemType<typeof MIME_TYPES["html"]>,
): { type: "mixedContent"; value: PastedMixedContent } | null => { ): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = event.clipboardData?.getData(MIME_TYPES.html); const html = dataItem.value;
if (!html) {
return null;
}
try { try {
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html); const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
@ -333,18 +340,21 @@ export const readSystemClipboard = async () => {
* Parses "paste" ClipboardEvent. * Parses "paste" ClipboardEvent.
*/ */
const parseClipboardEventTextData = async ( const parseClipboardEventTextData = async (
event: ClipboardEvent, dataList: ParsedDataTranferList,
isPlainPaste = false, isPlainPaste = false,
): Promise<ParsedClipboardEventTextData> => { ): Promise<ParsedClipboardEventTextData> => {
try { try {
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event); const htmlItem = dataList.findByType(MIME_TYPES.html);
const mixedContent =
!isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem);
if (mixedContent) { if (mixedContent) {
if (mixedContent.value.every((item) => item.type === "text")) { if (mixedContent.value.every((item) => item.type === "text")) {
return { return {
type: "text", type: "text",
value: value:
event.clipboardData?.getData(MIME_TYPES.text) || dataList.getData(MIME_TYPES.text) ??
mixedContent.value mixedContent.value
.map((item) => item.value) .map((item) => item.value)
.join("\n") .join("\n")
@ -355,23 +365,150 @@ const parseClipboardEventTextData = async (
return mixedContent; return mixedContent;
} }
const text = event.clipboardData?.getData(MIME_TYPES.text); return {
type: "text",
return { type: "text", value: (text || "").trim() }; value: (dataList.getData(MIME_TYPES.text) || "").trim(),
};
} catch { } catch {
return { type: "text", value: "" }; 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. * Attempts to parse clipboard event.
*/ */
export const parseClipboard = async ( export const parseClipboard = async (
event: ClipboardEvent, dataList: ParsedDataTranferList,
isPlainPaste = false, isPlainPaste = false,
): Promise<ClipboardData> => { ): Promise<ClipboardData> => {
const parsedEventData = await parseClipboardEventTextData( const parsedEventData = await parseClipboardEventTextData(
event, dataList,
isPlainPaste, isPlainPaste,
); );

View File

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

View File

@ -18,8 +18,6 @@ import { CanvasError, ImageSceneDataError } from "../errors";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { decodeSvgBase64Payload } from "../scene/export"; import { decodeSvgBase64Payload } from "../scene/export";
import { isClipboardEvent } from "../clipboard";
import { base64ToString, stringToBase64, toByteString } from "./encode"; import { base64ToString, stringToBase64, toByteString } from "./encode";
import { nativeFileSystemSupported } from "./filesystem"; import { nativeFileSystemSupported } from "./filesystem";
import { isValidExcalidrawData, isValidLibrary } from "./json"; import { isValidExcalidrawData, isValidLibrary } from "./json";
@ -98,6 +96,8 @@ export const getMimeType = (blob: Blob | string): string => {
return MIME_TYPES.jpg; return MIME_TYPES.jpg;
} else if (/\.svg$/.test(name)) { } else if (/\.svg$/.test(name)) {
return MIME_TYPES.svg; return MIME_TYPES.svg;
} else if (/\.excalidrawlib$/.test(name)) {
return MIME_TYPES.excalidrawlib;
} }
return ""; return "";
}; };
@ -391,42 +391,6 @@ export const ImageURLToFile = async (
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" }); 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 ( export const getFileHandle = async (
event: DragEvent | React.DragEvent | DataTransferItem, event: DragEvent | React.DragEvent | DataTransferItem,
): Promise<FileSystemHandle | null> => { ): Promise<FileSystemHandle | null> => {

View File

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

View File

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

View File

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

View File

@ -478,43 +478,43 @@ export class API {
}); });
}; };
static drop = async (_blobs: Blob[] | Blob) => { static drop = async (items: ({kind: "string", value: string, type: string} | {kind: "file", file: File | Blob, type?: string })[]) => {
const blobs = Array.isArray(_blobs) ? _blobs : [_blobs];
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 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]; files.item = (index: number) => files[index];
Object.defineProperty(fileDropEvent, "dataTransfer", { Object.defineProperty(fileDropEvent, "dataTransfer", {
value: { value: {
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/files
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) => { getData: (type: string) => {
const idx = blobs.findIndex((b) => b.type === type); return items.find((item) => item.type === "string" && item.type === type) || "";
if (idx >= 0) {
return texts[idx];
}
if (type === "text") {
return texts.join("\n");
}
return "";
}, },
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", { Object.defineProperty(fileDropEvent, "clientX", {

View File

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

View File

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

View File

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

View File

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

View File

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