feat: make clipboard more robust and reintroduce contextmenu actions (#7198)

This commit is contained in:
David Luzar
2023-10-28 21:29:28 +02:00
committed by GitHub
parent ec2de7205f
commit ea677d4581
17 changed files with 611 additions and 193 deletions

View File

@ -17,7 +17,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"keyTest": [Function],
"name": "cut",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
@ -27,7 +26,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"keyTest": undefined,
"name": "copy",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
@ -37,7 +35,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"keyTest": undefined,
"name": "paste",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
@ -4604,7 +4601,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"keyTest": [Function],
"name": "cut",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
@ -4614,7 +4610,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"keyTest": undefined,
"name": "copy",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
@ -4624,7 +4619,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"keyTest": undefined,
"name": "paste",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
@ -5187,7 +5181,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"keyTest": [Function],
"name": "cut",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
@ -5197,7 +5190,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"keyTest": undefined,
"name": "copy",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
@ -5207,7 +5199,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"keyTest": undefined,
"name": "paste",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
@ -5855,7 +5846,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"keyTest": undefined,
"name": "paste",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
@ -6109,7 +6099,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"keyTest": [Function],
"name": "cut",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
@ -6119,7 +6108,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"keyTest": undefined,
"name": "copy",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
@ -6129,7 +6117,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"keyTest": undefined,
"name": "paste",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
@ -6486,7 +6473,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"keyTest": [Function],
"name": "cut",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
@ -6496,7 +6482,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"keyTest": undefined,
"name": "copy",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
@ -6506,7 +6491,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"keyTest": undefined,
"name": "paste",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},

View File

@ -1,11 +1,6 @@
import { vi } from "vitest";
import ReactDOM from "react-dom";
import {
render,
waitFor,
GlobalTestState,
createPasteEvent,
} from "./test-utils";
import { render, waitFor, GlobalTestState } from "./test-utils";
import { Pointer, Keyboard } from "./helpers/ui";
import { Excalidraw } from "../packages/excalidraw/index";
import { KEYS } from "../keys";
@ -16,7 +11,7 @@ import {
import { getElementBounds } from "../element";
import { NormalizedZoomValue } from "../types";
import { API } from "./helpers/api";
import { copyToClipboard } from "../clipboard";
import { createPasteEvent, serializeAsClipboardJSON } from "../clipboard";
const { h } = window;
@ -37,7 +32,9 @@ vi.mock("../keys.ts", async (importOriginal) => {
const sendPasteEvent = (text: string) => {
const clipboardEvent = createPasteEvent({
"text/plain": text,
types: {
"text/plain": text,
},
});
document.dispatchEvent(clipboardEvent);
};
@ -86,7 +83,10 @@ beforeEach(async () => {
describe("general paste behavior", () => {
it("should randomize seed on paste", async () => {
const rectangle = API.createElement({ type: "rectangle" });
const clipboardJSON = (await copyToClipboard([rectangle], null))!;
const clipboardJSON = await serializeAsClipboardJSON({
elements: [rectangle],
files: null,
});
pasteWithCtrlCmdV(clipboardJSON);
await waitFor(() => {
@ -97,7 +97,10 @@ describe("general paste behavior", () => {
it("should retain seed on shift-paste", async () => {
const rectangle = API.createElement({ type: "rectangle" });
const clipboardJSON = (await copyToClipboard([rectangle], null))!;
const clipboardJSON = await serializeAsClipboardJSON({
elements: [rectangle],
files: null,
});
// assert we don't randomize seed on shift-paste
pasteWithCtrlCmdShiftV(clipboardJSON);

View File

@ -83,6 +83,7 @@ describe("contextMenu element", () => {
const contextMenuOptions =
contextMenu?.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [
"paste",
"selectAll",
"gridMode",
"zenMode",
@ -114,6 +115,9 @@ describe("contextMenu element", () => {
const contextMenuOptions =
contextMenu?.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [
"cut",
"copy",
"paste",
"copyStyles",
"pasteStyles",
"deleteSelectedElements",
@ -203,6 +207,9 @@ describe("contextMenu element", () => {
const contextMenuOptions =
contextMenu?.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [
"cut",
"copy",
"paste",
"copyStyles",
"pasteStyles",
"deleteSelectedElements",
@ -256,6 +263,9 @@ describe("contextMenu element", () => {
const contextMenuOptions =
contextMenu?.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [
"cut",
"copy",
"paste",
"copyStyles",
"pasteStyles",
"deleteSelectedElements",

View File

@ -1,6 +1,5 @@
import ReactDOM from "react-dom";
import {
createPasteEvent,
fireEvent,
GlobalTestState,
render,
@ -27,6 +26,7 @@ import { vi } from "vitest";
import * as blob from "../data/blob";
import { KEYS } from "../keys";
import { getBoundTextElementPosition } from "../element/textElement";
import { createPasteEvent } from "../clipboard";
const { h } = window;
const mouse = new Pointer("mouse");
@ -727,7 +727,7 @@ describe("freedraw", () => {
describe("image", () => {
const createImage = async () => {
const sendPasteEvent = (file?: File) => {
const clipboardEvent = createPasteEvent({}, file ? [file] : []);
const clipboardEvent = createPasteEvent({ files: file ? [file] : [] });
document.dispatchEvent(clipboardEvent);
};

View File

@ -0,0 +1,91 @@
class ClipboardEvent {
constructor(
type: "paste" | "copy",
eventInitDict: {
clipboardData: DataTransfer;
},
) {
return Object.assign(
new Event("paste", {
bubbles: true,
cancelable: true,
composed: true,
}),
{
clipboardData: eventInitDict.clipboardData,
},
) as any as ClipboardEvent;
}
}
type DataKind = "string" | "file";
class DataTransferItem {
kind: DataKind;
type: string;
data: string | Blob;
constructor(kind: DataKind, type: string, data: string | Blob) {
this.kind = kind;
this.type = type;
this.data = data;
}
getAsString(callback: (data: string) => void): void {
if (this.kind === "string") {
callback(this.data as string);
}
}
getAsFile(): File | null {
if (this.kind === "file" && this.data instanceof File) {
return this.data;
}
return null;
}
}
class DataTransferList {
items: DataTransferItem[] = [];
add(data: string | File, type: string = ""): void {
if (typeof data === "string") {
this.items.push(new DataTransferItem("string", type, data));
} else if (data instanceof File) {
this.items.push(new DataTransferItem("file", type, data));
}
}
clear(): void {
this.items = [];
}
}
class DataTransfer {
public items: DataTransferList = new DataTransferList();
private _types: Record<string, string> = {};
get files() {
return this.items.items
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile()!);
}
add(data: string | File, type: string = ""): void {
this.items.add(data, type);
}
setData(type: string, value: string) {
this._types[type] = value;
}
getData(type: string) {
return this._types[type] || "";
}
}
export const testPolyfills = {
ClipboardEvent,
DataTransfer,
DataTransferItem,
};

View File

@ -208,26 +208,6 @@ export const assertSelectedElements = (
expect(selectedElementIds).toEqual(expect.arrayContaining(ids));
};
export const createPasteEvent = <T extends "text/plain" | "text/html">(
items: Record<T, string>,
files?: File[],
) => {
return Object.assign(
new Event("paste", {
bubbles: true,
cancelable: true,
composed: true,
}),
{
clipboardData: {
getData: (type: string) =>
(items as Record<string, string>)[type] || "",
files: files || [],
},
},
) as any as ClipboardEvent;
};
export const toggleMenu = (container: HTMLElement) => {
// open menu
fireEvent.click(container.querySelector(".dropdown-menu-button")!);