aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/tests/contextmenu.test.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/tests/contextmenu.test.tsx')
-rw-r--r--packages/excalidraw/tests/contextmenu.test.tsx625
1 files changed, 625 insertions, 0 deletions
diff --git a/packages/excalidraw/tests/contextmenu.test.tsx b/packages/excalidraw/tests/contextmenu.test.tsx
new file mode 100644
index 0000000..bb3f415
--- /dev/null
+++ b/packages/excalidraw/tests/contextmenu.test.tsx
@@ -0,0 +1,625 @@
+import React from "react";
+import {
+ render,
+ fireEvent,
+ mockBoundingClientRect,
+ restoreOriginalGetBoundingClientRect,
+ GlobalTestState,
+ screen,
+ queryByText,
+ queryAllByText,
+ waitFor,
+ togglePopover,
+ unmountComponent,
+} from "./test-utils";
+import { Excalidraw } from "../index";
+import * as StaticScene from "../renderer/staticScene";
+import { reseed } from "../random";
+import { UI, Pointer, Keyboard } from "./helpers/ui";
+import { KEYS } from "../keys";
+import type { ShortcutName } from "../actions/shortcuts";
+import { copiedStyles } from "../actions/actionStyles";
+import { API } from "./helpers/api";
+import { setDateTimeForTests } from "../utils";
+import { vi } from "vitest";
+import type { ActionName } from "../actions/types";
+
+const checkpoint = (name: string) => {
+ expect(renderStaticScene.mock.calls.length).toMatchSnapshot(
+ `[${name}] number of renders`,
+ );
+ expect(h.state).toMatchSnapshot(`[${name}] appState`);
+ expect(h.history).toMatchSnapshot(`[${name}] history`);
+ expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
+ h.elements.forEach((element, i) =>
+ expect(element).toMatchSnapshot(`[${name}] element ${i}`),
+ );
+};
+
+const mouse = new Pointer("mouse");
+
+unmountComponent();
+
+const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
+beforeEach(() => {
+ localStorage.clear();
+ renderStaticScene.mockClear();
+ reseed(7);
+});
+
+const { h } = window;
+
+describe("contextMenu element", () => {
+ beforeEach(async () => {
+ localStorage.clear();
+ renderStaticScene.mockClear();
+ reseed(7);
+ setDateTimeForTests("201933152653");
+
+ await render(<Excalidraw handleKeyboardGlobally={true} />);
+ });
+
+ beforeAll(() => {
+ mockBoundingClientRect();
+ });
+
+ afterAll(() => {
+ restoreOriginalGetBoundingClientRect();
+ });
+
+ afterEach(() => {
+ checkpoint("end of test");
+
+ mouse.reset();
+ mouse.down(0, 0);
+ });
+
+ it("shows context menu for canvas", () => {
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 1,
+ clientY: 1,
+ });
+ const contextMenu = UI.queryContextMenu();
+ const contextMenuOptions =
+ contextMenu?.querySelectorAll(".context-menu li");
+ const expectedShortcutNames: ShortcutName[] = [
+ "paste",
+ "selectAll",
+ "gridMode",
+ "zenMode",
+ "viewMode",
+ "objectsSnapMode",
+ "stats",
+ ];
+
+ expect(contextMenu).not.toBeNull();
+ expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
+ expectedShortcutNames.forEach((shortcutName) => {
+ expect(
+ contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
+ ).not.toBeNull();
+ });
+ });
+
+ it("shows context menu for element", () => {
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(20, 20);
+
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 3,
+ clientY: 3,
+ });
+ const contextMenu = UI.queryContextMenu();
+ const contextMenuOptions =
+ contextMenu?.querySelectorAll(".context-menu li");
+ const expectedContextMenuItems: ActionName[] = [
+ "cut",
+ "copy",
+ "paste",
+ "wrapSelectionInFrame",
+ "copyStyles",
+ "pasteStyles",
+ "deleteSelectedElements",
+ "addToLibrary",
+ "flipHorizontal",
+ "flipVertical",
+ "sendBackward",
+ "bringForward",
+ "sendToBack",
+ "bringToFront",
+ "duplicateSelection",
+ "hyperlink",
+ "copyElementLink",
+ "toggleElementLock",
+ ];
+
+ expect(contextMenu).not.toBeNull();
+ expect(contextMenuOptions?.length).toBe(expectedContextMenuItems.length);
+ expectedContextMenuItems.forEach((item) => {
+ expect(
+ contextMenu?.querySelector(`li[data-testid="${item}"]`),
+ ).not.toBeNull();
+ });
+ });
+
+ it("shows context menu for element", () => {
+ const rect1 = API.createElement({
+ type: "rectangle",
+ x: 0,
+ y: 0,
+ height: 200,
+ width: 200,
+ backgroundColor: "red",
+ });
+ const rect2 = API.createElement({
+ type: "rectangle",
+ x: 0,
+ y: 0,
+ height: 200,
+ width: 200,
+ backgroundColor: "red",
+ });
+ API.setElements([rect1, rect2]);
+ API.setSelectedElements([rect1]);
+
+ // lower z-index
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 100,
+ clientY: 100,
+ });
+ expect(UI.queryContextMenu()).not.toBeNull();
+ expect(API.getSelectedElement().id).toBe(rect1.id);
+
+ // higher z-index
+ API.setSelectedElements([rect2]);
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 100,
+ clientY: 100,
+ });
+ expect(UI.queryContextMenu()).not.toBeNull();
+ expect(API.getSelectedElement().id).toBe(rect2.id);
+ });
+
+ it("shows 'Group selection' in context menu for multiple selected elements", () => {
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(10, 10);
+
+ UI.clickTool("rectangle");
+ mouse.down(12, -10);
+ mouse.up(10, 10);
+
+ mouse.reset();
+ mouse.click(10, 10);
+ Keyboard.withModifierKeys({ shift: true }, () => {
+ mouse.click(22, 0);
+ });
+
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 3,
+ clientY: 3,
+ });
+
+ const contextMenu = UI.queryContextMenu();
+ const contextMenuOptions =
+ contextMenu?.querySelectorAll(".context-menu li");
+ const expectedShortcutNames: ShortcutName[] = [
+ "cut",
+ "copy",
+ "paste",
+ "wrapSelectionInFrame",
+ "copyStyles",
+ "pasteStyles",
+ "deleteSelectedElements",
+ "group",
+ "addToLibrary",
+ "flipHorizontal",
+ "flipVertical",
+ "sendBackward",
+ "bringForward",
+ "sendToBack",
+ "bringToFront",
+ "duplicateSelection",
+ "toggleElementLock",
+ ];
+
+ expect(contextMenu).not.toBeNull();
+ expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
+ expectedShortcutNames.forEach((shortcutName) => {
+ expect(
+ contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
+ ).not.toBeNull();
+ });
+ });
+
+ it("shows 'Ungroup selection' in context menu for group inside selected elements", () => {
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(10, 10);
+
+ UI.clickTool("rectangle");
+ mouse.down(12, -10);
+ mouse.up(10, 10);
+
+ mouse.reset();
+ mouse.click(10, 10);
+ Keyboard.withModifierKeys({ shift: true }, () => {
+ mouse.click(22, 0);
+ });
+
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.G);
+ });
+
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 3,
+ clientY: 3,
+ });
+
+ const contextMenu = UI.queryContextMenu();
+ const contextMenuOptions =
+ contextMenu?.querySelectorAll(".context-menu li");
+ const expectedContextMenuItems: ActionName[] = [
+ "cut",
+ "copy",
+ "paste",
+ "wrapSelectionInFrame",
+ "copyStyles",
+ "pasteStyles",
+ "deleteSelectedElements",
+ "copyElementLink",
+ "ungroup",
+ "addToLibrary",
+ "flipHorizontal",
+ "flipVertical",
+ "sendBackward",
+ "bringForward",
+ "sendToBack",
+ "bringToFront",
+ "duplicateSelection",
+ "toggleElementLock",
+ ];
+
+ expect(contextMenu).not.toBeNull();
+ expect(contextMenuOptions?.length).toBe(expectedContextMenuItems.length);
+ expectedContextMenuItems.forEach((item) => {
+ expect(
+ contextMenu?.querySelector(`li[data-testid="${item}"]`),
+ ).not.toBeNull();
+ });
+ });
+
+ it("selecting 'Copy styles' in context menu copies styles", () => {
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(20, 20);
+
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 3,
+ clientY: 3,
+ });
+ const contextMenu = UI.queryContextMenu();
+ expect(copiedStyles).toBe("{}");
+ fireEvent.click(queryByText(contextMenu!, "Copy styles")!);
+ expect(copiedStyles).not.toBe("{}");
+ const element = JSON.parse(copiedStyles)[0];
+ expect(element).toEqual(API.getSelectedElement());
+ });
+
+ it("selecting 'Paste styles' in context menu pastes styles", () => {
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(20, 20);
+
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(20, 20);
+
+ // Change some styles of second rectangle
+ togglePopover("Stroke");
+ UI.clickOnTestId("color-red");
+ togglePopover("Background");
+ UI.clickOnTestId("color-blue");
+ // Fill style
+ fireEvent.click(screen.getByTitle("Cross-hatch"));
+ // Stroke width
+ fireEvent.click(screen.getByTitle("Bold"));
+ // Stroke style
+ fireEvent.click(screen.getByTitle("Dotted"));
+ // Roughness
+ fireEvent.click(screen.getByTitle("Cartoonist"));
+ // Opacity
+ fireEvent.change(screen.getByTestId("opacity"), {
+ target: { value: "60" },
+ });
+
+ // closing the background popover as this blocks
+ // context menu from rendering after we started focussing
+ // the popover once rendered :/
+ togglePopover("Background");
+
+ mouse.reset();
+
+ // Copy styles of second rectangle
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 40,
+ clientY: 40,
+ });
+
+ let contextMenu = UI.queryContextMenu();
+ fireEvent.click(queryByText(contextMenu!, "Copy styles")!);
+ const secondRect = JSON.parse(copiedStyles)[0];
+ expect(secondRect.id).toBe(h.elements[1].id);
+
+ mouse.reset();
+ // Paste styles to first rectangle
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 10,
+ clientY: 10,
+ });
+ contextMenu = UI.queryContextMenu();
+ fireEvent.click(queryByText(contextMenu!, "Paste styles")!);
+
+ const firstRect = API.getSelectedElement();
+ expect(firstRect.id).toBe(h.elements[0].id);
+ expect(firstRect.strokeColor).toBe("#e03131");
+ expect(firstRect.backgroundColor).toBe("#a5d8ff");
+ expect(firstRect.fillStyle).toBe("cross-hatch");
+ expect(firstRect.strokeWidth).toBe(2); // Bold: 2
+ expect(firstRect.strokeStyle).toBe("dotted");
+ expect(firstRect.roughness).toBe(2); // Cartoonist: 2
+ expect(firstRect.opacity).toBe(60);
+ });
+
+ it("selecting 'Delete' in context menu deletes element", () => {
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(20, 20);
+
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 3,
+ clientY: 3,
+ });
+ const contextMenu = UI.queryContextMenu();
+ fireEvent.click(queryAllByText(contextMenu!, "Delete")[0]);
+ expect(API.getSelectedElements()).toHaveLength(0);
+ expect(h.elements[0].isDeleted).toBe(true);
+ });
+
+ it("selecting 'Add to library' in context menu adds element to library", async () => {
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(20, 20);
+
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 3,
+ clientY: 3,
+ });
+ const contextMenu = UI.queryContextMenu();
+ fireEvent.click(queryByText(contextMenu!, "Add to library")!);
+
+ await waitFor(async () => {
+ const libraryItems = await h.app.library.getLatestLibrary();
+ expect(libraryItems[0].elements[0]).toEqual(h.elements[0]);
+ });
+ });
+
+ it("selecting 'Duplicate' in context menu duplicates element", () => {
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(20, 20);
+
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 3,
+ clientY: 3,
+ });
+ const contextMenu = UI.queryContextMenu();
+ fireEvent.click(queryByText(contextMenu!, "Duplicate")!);
+ expect(h.elements).toHaveLength(2);
+ const {
+ id: _id0,
+ seed: _seed0,
+ x: _x0,
+ y: _y0,
+ index: _fractionalIndex0,
+ version: _version0,
+ versionNonce: _versionNonce0,
+ ...rect1
+ } = h.elements[0];
+ const {
+ id: _id1,
+ seed: _seed1,
+ x: _x1,
+ y: _y1,
+ index: _fractionalIndex1,
+ version: _version1,
+ versionNonce: _versionNonce1,
+ ...rect2
+ } = h.elements[1];
+ expect(rect1).toEqual(rect2);
+ });
+
+ it("selecting 'Send backward' in context menu sends element backward", () => {
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(20, 20);
+
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(20, 20);
+
+ mouse.reset();
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 40,
+ clientY: 40,
+ });
+ const contextMenu = UI.queryContextMenu();
+ const elementsBefore = h.elements;
+ fireEvent.click(queryByText(contextMenu!, "Send backward")!);
+ expect(elementsBefore[0].id).toEqual(h.elements[1].id);
+ expect(elementsBefore[1].id).toEqual(h.elements[0].id);
+ });
+
+ it("selecting 'Bring forward' in context menu brings element forward", () => {
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(20, 20);
+
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(20, 20);
+
+ mouse.reset();
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 10,
+ clientY: 10,
+ });
+ const contextMenu = UI.queryContextMenu();
+ const elementsBefore = h.elements;
+ fireEvent.click(queryByText(contextMenu!, "Bring forward")!);
+ expect(elementsBefore[0].id).toEqual(h.elements[1].id);
+ expect(elementsBefore[1].id).toEqual(h.elements[0].id);
+ });
+
+ it("selecting 'Send to back' in context menu sends element to back", () => {
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(20, 20);
+
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(20, 20);
+
+ mouse.reset();
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 40,
+ clientY: 40,
+ });
+ const contextMenu = UI.queryContextMenu();
+ const elementsBefore = h.elements;
+ fireEvent.click(queryByText(contextMenu!, "Send to back")!);
+ expect(elementsBefore[1].id).toEqual(h.elements[0].id);
+ });
+
+ it("selecting 'Bring to front' in context menu brings element to front", () => {
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(20, 20);
+
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(20, 20);
+
+ mouse.reset();
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 10,
+ clientY: 10,
+ });
+ const contextMenu = UI.queryContextMenu();
+ const elementsBefore = h.elements;
+ fireEvent.click(queryByText(contextMenu!, "Bring to front")!);
+ expect(elementsBefore[0].id).toEqual(h.elements[1].id);
+ });
+
+ it("selecting 'Group selection' in context menu groups selected elements", () => {
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(20, 20);
+
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(20, 20);
+
+ mouse.reset();
+ Keyboard.withModifierKeys({ shift: true }, () => {
+ mouse.click(10, 10);
+ });
+
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 3,
+ clientY: 3,
+ });
+ const contextMenu = UI.queryContextMenu();
+ fireEvent.click(queryByText(contextMenu!, "Group selection")!);
+ const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
+ expect(h.elements[0].groupIds).toEqual(selectedGroupIds);
+ expect(h.elements[1].groupIds).toEqual(selectedGroupIds);
+ });
+
+ it("selecting 'Ungroup selection' in context menu ungroups selected group", () => {
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(20, 20);
+
+ UI.clickTool("rectangle");
+ mouse.down(10, 10);
+ mouse.up(20, 20);
+
+ mouse.reset();
+ Keyboard.withModifierKeys({ shift: true }, () => {
+ mouse.click(10, 10);
+ });
+
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.G);
+ });
+
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 3,
+ clientY: 3,
+ });
+
+ const contextMenu = UI.queryContextMenu();
+ expect(contextMenu).not.toBeNull();
+ fireEvent.click(queryByText(contextMenu!, "Ungroup selection")!);
+
+ const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
+ expect(selectedGroupIds).toHaveLength(0);
+ expect(h.elements[0].groupIds).toHaveLength(0);
+ expect(h.elements[1].groupIds).toHaveLength(0);
+ });
+
+ it("right-clicking on a group should select whole group", () => {
+ const rectangle1 = API.createElement({
+ type: "rectangle",
+ width: 100,
+ backgroundColor: "red",
+ fillStyle: "solid",
+ groupIds: ["g1"],
+ });
+ const rectangle2 = API.createElement({
+ type: "rectangle",
+ width: 100,
+ backgroundColor: "red",
+ fillStyle: "solid",
+ groupIds: ["g1"],
+ });
+ API.setElements([rectangle1, rectangle2]);
+
+ mouse.rightClickAt(50, 50);
+ expect(API.getSelectedElements().length).toBe(2);
+ expect(API.getSelectedElements()).toEqual([
+ expect.objectContaining({ id: rectangle1.id }),
+ expect.objectContaining({ id: rectangle2.id }),
+ ]);
+ });
+});