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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: "😀" }),

View File

@ -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<string>((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", {

View File

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

View File

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

View File

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

View File

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

View File

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