mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Introducing independent change detection for appState and elements Generalizing object change, cleanup, refactoring, comments, solving typing issues Shaping increment, change, delta hierarchy Structural clone of elements Introducing store and incremental API Disabling buttons for canvas actions, smaller store and changes improvements Update history entry based on latest changes, iterate through the stack for visible changes to limit empty commands Solving concurrency issues, solving (partly) linear element issues, introducing commitToStore breaking change Fixing existing tests, updating snapshots Trying to be smarter on the appstate change detection Extending collab test, refactoring action / updateScene params, bugfixes Resetting snapshots Resetting snapshots UI / API tests for history - WIP Changing actions related to the observed appstate to at least update the store snapshot - WIP Adding skipping of snapshot update flag for most no-breaking changes compatible solution Ignoring uncomitted elements from local async actions, updating store directly in updateScene Bound element issues - WIP
619 lines
17 KiB
TypeScript
619 lines
17 KiB
TypeScript
import ReactDOM from "react-dom";
|
|
import {
|
|
render,
|
|
fireEvent,
|
|
mockBoundingClientRect,
|
|
restoreOriginalGetBoundingClientRect,
|
|
GlobalTestState,
|
|
screen,
|
|
queryByText,
|
|
queryAllByText,
|
|
waitFor,
|
|
togglePopover,
|
|
} from "./test-utils";
|
|
import { Excalidraw } from "../packages/excalidraw/index";
|
|
import * as Renderer from "../renderer/renderScene";
|
|
import { reseed } from "../random";
|
|
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
|
import { KEYS } from "../keys";
|
|
import { ShortcutName } from "../actions/shortcuts";
|
|
import { copiedStyles } from "../actions/actionStyles";
|
|
import { API } from "./helpers/api";
|
|
import { setDateTimeForTests } from "../utils";
|
|
import { vi } from "vitest";
|
|
|
|
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");
|
|
|
|
// Unmount ReactDOM from root
|
|
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
|
|
|
const renderStaticScene = vi.spyOn(Renderer, "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: 1,
|
|
clientY: 1,
|
|
});
|
|
const contextMenu = UI.queryContextMenu();
|
|
const contextMenuOptions =
|
|
contextMenu?.querySelectorAll(".context-menu li");
|
|
const expectedShortcutNames: ShortcutName[] = [
|
|
"cut",
|
|
"copy",
|
|
"paste",
|
|
"copyStyles",
|
|
"pasteStyles",
|
|
"deleteSelectedElements",
|
|
"addToLibrary",
|
|
"flipHorizontal",
|
|
"flipVertical",
|
|
"sendBackward",
|
|
"bringForward",
|
|
"sendToBack",
|
|
"bringToFront",
|
|
"duplicateSelection",
|
|
"hyperlink",
|
|
"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 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",
|
|
});
|
|
h.elements = [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(10, -10);
|
|
mouse.up(10, 10);
|
|
|
|
mouse.reset();
|
|
mouse.click(10, 10);
|
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
mouse.click(20, 0);
|
|
});
|
|
|
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
|
button: 2,
|
|
clientX: 1,
|
|
clientY: 1,
|
|
});
|
|
|
|
const contextMenu = UI.queryContextMenu();
|
|
const contextMenuOptions =
|
|
contextMenu?.querySelectorAll(".context-menu li");
|
|
const expectedShortcutNames: ShortcutName[] = [
|
|
"cut",
|
|
"copy",
|
|
"paste",
|
|
"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(10, -10);
|
|
mouse.up(10, 10);
|
|
|
|
mouse.reset();
|
|
mouse.click(10, 10);
|
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
mouse.click(20, 0);
|
|
});
|
|
|
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
Keyboard.keyPress(KEYS.G);
|
|
});
|
|
|
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
|
button: 2,
|
|
clientX: 1,
|
|
clientY: 1,
|
|
});
|
|
|
|
const contextMenu = UI.queryContextMenu();
|
|
const contextMenuOptions =
|
|
contextMenu?.querySelectorAll(".context-menu li");
|
|
const expectedShortcutNames: ShortcutName[] = [
|
|
"cut",
|
|
"copy",
|
|
"paste",
|
|
"copyStyles",
|
|
"pasteStyles",
|
|
"deleteSelectedElements",
|
|
"ungroup",
|
|
"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("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: 1,
|
|
clientY: 1,
|
|
});
|
|
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.getByLabelText("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: 1,
|
|
clientY: 1,
|
|
});
|
|
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: 1,
|
|
clientY: 1,
|
|
});
|
|
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: 1,
|
|
clientY: 1,
|
|
});
|
|
const contextMenu = UI.queryContextMenu();
|
|
fireEvent.click(queryByText(contextMenu!, "Duplicate")!);
|
|
expect(h.elements).toHaveLength(2);
|
|
const {
|
|
id: _id0,
|
|
seed: _seed0,
|
|
x: _x0,
|
|
y: _y0,
|
|
fractionalIndex: _fractionalIndex0,
|
|
version: _version0,
|
|
versionNonce: _versionNonce0,
|
|
...rect1
|
|
} = h.elements[0];
|
|
const {
|
|
id: _id1,
|
|
seed: _seed1,
|
|
x: _x1,
|
|
y: _y1,
|
|
fractionalIndex: _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: 1,
|
|
clientY: 1,
|
|
});
|
|
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: 1,
|
|
clientY: 1,
|
|
});
|
|
|
|
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"],
|
|
});
|
|
h.elements = [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 }),
|
|
]);
|
|
});
|
|
});
|