mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-09-10 13:26:11 -04:00
fix: pasting not working in firefox (#9947)
This commit is contained in:
parent
3bdaafe4b5
commit
b9d27d308e
@ -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",
|
||||||
|
@ -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(
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
await parseDataTransferEvent(
|
||||||
|
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(
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
await parseDataTransferEvent(
|
||||||
|
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(
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
await parseDataTransferEvent(
|
||||||
|
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(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/plain": json,
|
types: {
|
||||||
},
|
"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(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": json,
|
types: {
|
||||||
},
|
"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(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<div> ${json}</div>`,
|
types: {
|
||||||
},
|
"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(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<img src="https://example.com/image.png" />`,
|
types: {
|
||||||
},
|
"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(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
|
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([
|
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(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
|
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([
|
expect(clipboardData.mixedContent).toEqual([
|
||||||
{
|
{
|
||||||
@ -141,14 +160,16 @@ describe("parseClipboard()", () => {
|
|||||||
let clipboardData;
|
let clipboardData;
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/plain": `a b
|
types: {
|
||||||
1 2
|
"text/plain": `a b
|
||||||
4 5
|
1 2
|
||||||
7 10`,
|
4 5
|
||||||
},
|
7 10`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
@ -157,14 +178,16 @@ describe("parseClipboard()", () => {
|
|||||||
});
|
});
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `a b
|
types: {
|
||||||
1 2
|
"text/html": `a b
|
||||||
4 5
|
1 2
|
||||||
7 10`,
|
4 5
|
||||||
},
|
7 10`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
@ -173,19 +196,21 @@ describe("parseClipboard()", () => {
|
|||||||
});
|
});
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<html>
|
types: {
|
||||||
<body>
|
"text/html": `<html>
|
||||||
<!--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="{"1":2,"2":"a"}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"b"}">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="{"1":3,"3":1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":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="{"1":3,"3":4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":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="{"1":3,"3":7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":10}">10</td></tr></tbody></table><!--EndFragment-->
|
<body>
|
||||||
</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="{"1":2,"2":"a"}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"b"}">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="{"1":3,"3":1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":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="{"1":3,"3":4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":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="{"1":3,"3":7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":10}">10</td></tr></tbody></table><!--EndFragment-->
|
||||||
</html>`,
|
</body>
|
||||||
"text/plain": `a b
|
</html>`,
|
||||||
1 2
|
"text/plain": `a b
|
||||||
4 5
|
1 2
|
||||||
7 10`,
|
4 5
|
||||||
},
|
7 10`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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) &&
|
||||||
|
@ -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> => {
|
||||||
|
@ -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,
|
||||||
|
@ -35,20 +35,23 @@ describe("appState", () => {
|
|||||||
expect(h.state.viewBackgroundColor).toBe("#F00");
|
expect(h.state.viewBackgroundColor).toBe("#F00");
|
||||||
});
|
});
|
||||||
|
|
||||||
await API.drop(
|
await API.drop([
|
||||||
new Blob(
|
{
|
||||||
[
|
kind: "file",
|
||||||
JSON.stringify({
|
file: new Blob(
|
||||||
type: EXPORT_DATA_TYPES.excalidraw,
|
[
|
||||||
appState: {
|
JSON.stringify({
|
||||||
viewBackgroundColor: "#000",
|
type: EXPORT_DATA_TYPES.excalidraw,
|
||||||
},
|
appState: {
|
||||||
elements: [API.createElement({ type: "rectangle", id: "A" })],
|
viewBackgroundColor: "#000",
|
||||||
}),
|
},
|
||||||
],
|
elements: [API.createElement({ type: "rectangle", id: "A" })],
|
||||||
{ 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" })]);
|
||||||
|
@ -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: "😀" }),
|
||||||
|
@ -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", {
|
||||||
|
@ -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 {
|
||||||
this.items.add(data, type);
|
if (typeof data === "string") {
|
||||||
|
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 || "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -568,21 +568,24 @@ 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",
|
||||||
JSON.stringify({
|
file: new Blob(
|
||||||
type: EXPORT_DATA_TYPES.excalidraw,
|
[
|
||||||
appState: {
|
JSON.stringify({
|
||||||
...getDefaultAppState(),
|
type: EXPORT_DATA_TYPES.excalidraw,
|
||||||
viewBackgroundColor: "#000",
|
appState: {
|
||||||
},
|
...getDefaultAppState(),
|
||||||
elements: [API.createElement({ type: "rectangle", id: "B" })],
|
viewBackgroundColor: "#000",
|
||||||
}),
|
},
|
||||||
],
|
elements: [API.createElement({ type: "rectangle", id: "B" })],
|
||||||
{ 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([
|
(
|
||||||
API.loadFile("./fixtures/deer.png"),
|
await Promise.all([
|
||||||
API.loadFile("./fixtures/smiley.png"),
|
API.loadFile("./fixtures/deer.png"),
|
||||||
]),
|
API.loadFile("./fixtures/smiley.png"),
|
||||||
|
])
|
||||||
|
).map((file) => ({
|
||||||
|
kind: "file",
|
||||||
|
file,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
await assertImageTest();
|
await assertImageTest();
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
|
@ -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,23 +117,20 @@ describe("library", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await API.drop(
|
await API.drop([
|
||||||
new Blob(
|
{
|
||||||
[
|
kind: "string",
|
||||||
serializeLibraryAsJSON([
|
value: serializeLibraryAsJSON([
|
||||||
{
|
{
|
||||||
id: "item1",
|
id: "item1",
|
||||||
status: "published",
|
status: "published",
|
||||||
elements: [rectangle, text, arrow],
|
elements: [rectangle, text, arrow],
|
||||||
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" })]);
|
||||||
});
|
});
|
||||||
|
@ -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),
|
||||||
);
|
);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user