fix: popover positioning (#3399)

This commit is contained in:
David Luzar 2021-04-05 17:26:37 +02:00 committed by GitHub
parent 189b721eed
commit 9733ecb3df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 5199 additions and 4791 deletions

View File

@ -474,6 +474,7 @@ class App extends React.Component<AppProps, AppState> {
UIOptions={this.props.UIOptions}
/>
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
{this.state.showStats && (
<Stats
appState={this.state}
@ -3675,12 +3676,20 @@ class App extends React.Component<AppProps, AppState> {
const type = element ? "element" : "canvas";
const container = this.excalidrawContainerRef.current!;
const {
top: offsetTop,
left: offsetLeft,
} = container.getBoundingClientRect();
const left = event.clientX - offsetLeft;
const top = event.clientY - offsetTop;
if (element && !this.state.selectedElementIds[element.id]) {
this.setState({ selectedElementIds: { [element.id]: true } }, () => {
this._openContextMenu(event, type);
this._openContextMenu({ top, left }, type);
});
} else {
this._openContextMenu(event, type);
this._openContextMenu({ top, left }, type);
}
};
@ -3774,11 +3783,11 @@ class App extends React.Component<AppProps, AppState> {
/** @private use this.handleCanvasContextMenu */
private _openContextMenu = (
{
clientX,
clientY,
left,
top,
}: {
clientX: number;
clientY: number;
left: number;
top: number;
},
type: "canvas" | "element",
) => {
@ -3829,10 +3838,11 @@ class App extends React.Component<AppProps, AppState> {
ContextMenu.push({
options: viewModeOptions,
top: clientY,
left: clientX,
top,
left,
actionManager: this.actionManager,
appState: this.state,
container: this.excalidrawContainerRef.current!,
});
if (this.state.viewModeEnabled) {
@ -3872,10 +3882,11 @@ class App extends React.Component<AppProps, AppState> {
actionToggleViewMode,
actionToggleStats,
],
top: clientY,
left: clientX,
top,
left,
actionManager: this.actionManager,
appState: this.state,
container: this.excalidrawContainerRef.current!,
});
return;
}
@ -3883,10 +3894,11 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.viewModeEnabled) {
ContextMenu.push({
options: [navigator.clipboard && actionCopy, ...options],
top: clientY,
left: clientX,
top,
left,
actionManager: this.actionManager,
appState: this.state,
container: this.excalidrawContainerRef.current!,
});
return;
}
@ -3928,10 +3940,11 @@ class App extends React.Component<AppProps, AppState> {
actionDuplicateSelection,
actionDeleteSelected,
],
top: clientY,
left: clientX,
top,
left,
actionManager: this.actionManager,
appState: this.state,
container: this.excalidrawContainerRef.current!,
});
};

View File

@ -32,67 +32,63 @@ const ContextMenu = ({
actionManager,
appState,
}: ContextMenuProps) => {
const isDarkTheme = !!document
.querySelector(".excalidraw")
?.classList.contains("theme--dark");
return (
<div
className={clsx("excalidraw", {
"theme--dark theme--dark-background-none": isDarkTheme,
})}
<Popover
onCloseRequest={onCloseRequest}
top={top}
left={left}
fitInViewport={true}
>
<Popover
onCloseRequest={onCloseRequest}
top={top}
left={left}
fitInViewport={true}
<ul
className="context-menu"
onContextMenu={(event) => event.preventDefault()}
>
<ul
className="context-menu"
onContextMenu={(event) => event.preventDefault()}
>
{options.map((option, idx) => {
if (option === "separator") {
return <hr key={idx} className="context-menu-option-separator" />;
}
{options.map((option, idx) => {
if (option === "separator") {
return <hr key={idx} className="context-menu-option-separator" />;
}
const actionName = option.name;
const label = option.contextItemLabel
? t(option.contextItemLabel)
: "";
return (
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
<button
className={clsx("context-menu-option", {
dangerous: actionName === "deleteSelectedElements",
checkmark: option.checked?.(appState),
})}
onClick={() => actionManager.executeAction(option)}
>
<div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut">
{actionName
? getShortcutFromShortcutName(actionName as ShortcutName)
: ""}
</kbd>
</button>
</li>
);
})}
</ul>
</Popover>
</div>
const actionName = option.name;
const label = option.contextItemLabel
? t(option.contextItemLabel)
: "";
return (
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
<button
className={clsx("context-menu-option", {
dangerous: actionName === "deleteSelectedElements",
checkmark: option.checked?.(appState),
})}
onClick={() => actionManager.executeAction(option)}
>
<div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut">
{actionName
? getShortcutFromShortcutName(actionName as ShortcutName)
: ""}
</kbd>
</button>
</li>
);
})}
</ul>
</Popover>
);
};
let contextMenuNode: HTMLDivElement;
const getContextMenuNode = (): HTMLDivElement => {
const contextMenuNodeByContainer = new WeakMap<HTMLElement, HTMLDivElement>();
const getContextMenuNode = (container: HTMLElement): HTMLDivElement => {
let contextMenuNode = contextMenuNodeByContainer.get(container);
if (contextMenuNode) {
return contextMenuNode;
}
const div = document.createElement("div");
document.body.appendChild(div);
return (contextMenuNode = div);
contextMenuNode = document.createElement("div");
container
.querySelector(".excalidraw-contextMenuContainer")!
.appendChild(contextMenuNode);
contextMenuNodeByContainer.set(container, contextMenuNode);
return contextMenuNode;
};
type ContextMenuParams = {
@ -101,10 +97,16 @@ type ContextMenuParams = {
left: ContextMenuProps["left"];
actionManager: ContextMenuProps["actionManager"];
appState: Readonly<AppState>;
container: HTMLElement;
};
const handleClose = () => {
unmountComponentAtNode(getContextMenuNode());
const handleClose = (container: HTMLElement) => {
const contextMenuNode = contextMenuNodeByContainer.get(container);
if (contextMenuNode) {
unmountComponentAtNode(contextMenuNode);
contextMenuNode.remove();
contextMenuNodeByContainer.delete(container);
}
};
export default {
@ -121,11 +123,11 @@ export default {
top={params.top}
left={params.left}
options={options}
onCloseRequest={handleClose}
onCloseRequest={() => handleClose(params.container)}
actionManager={params.actionManager}
appState={params.appState}
/>,
getContextMenuNode(),
getContextMenuNode(params.container),
);
}
},

View File

@ -1,6 +1,6 @@
.excalidraw {
.popover {
position: fixed;
position: absolute;
z-index: 10;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,531 @@
import ReactDOM from "react-dom";
import {
render,
fireEvent,
mockBoundingClientRect,
restoreOriginalGetBoundingClientRect,
GlobalTestState,
screen,
queryByText,
queryAllByText,
waitFor,
} from "./test-utils";
import ExcalidrawApp from "../excalidraw-app";
import * as Renderer from "../renderer/renderScene";
import { reseed } from "../random";
import { UI, Pointer, Keyboard } from "./helpers/ui";
import { CODES } from "../keys";
import { ShortcutName } from "../actions/shortcuts";
import { copiedStyles } from "../actions/actionStyles";
import { API } from "./helpers/api";
import { setDateTimeForTests } from "../utils";
const checkpoint = (name: string) => {
expect(renderScene.mock.calls.length).toMatchSnapshot(
`[${name}] number of renders`,
);
expect(h.state).toMatchSnapshot(`[${name}] appState`);
expect(h.history.getSnapshotForTest()).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");
const queryContextMenu = () => {
return GlobalTestState.renderResult.container.querySelector(".context-menu");
};
const clickLabeledElement = (label: string) => {
const element = document.querySelector(`[aria-label='${label}']`);
if (!element) {
throw new Error(`No labeled element found: ${label}`);
}
fireEvent.click(element);
};
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = jest.spyOn(Renderer, "renderScene");
beforeEach(() => {
localStorage.clear();
renderScene.mockClear();
reseed(7);
});
const { h } = window;
describe("contextMenu element", () => {
beforeEach(async () => {
localStorage.clear();
renderScene.mockClear();
h.history.clear();
reseed(7);
setDateTimeForTests("201933152653");
await render(<ExcalidrawApp />);
});
beforeAll(() => {
mockBoundingClientRect();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
afterEach(() => {
checkpoint("end of test");
mouse.reset();
mouse.down(0, 0);
});
it("shows context menu for canvas", () => {
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
const contextMenuOptions = contextMenu?.querySelectorAll(
".context-menu li",
);
const expectedShortcutNames: ShortcutName[] = [
"selectAll",
"gridMode",
"zenMode",
"viewMode",
"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.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
const contextMenuOptions = contextMenu?.querySelectorAll(
".context-menu li",
);
const expectedShortcutNames: ShortcutName[] = [
"copyStyles",
"pasteStyles",
"deleteSelectedElements",
"addToLibrary",
"flipHorizontal",
"flipVertical",
"sendBackward",
"bringForward",
"sendToBack",
"bringToFront",
"duplicateSelection",
];
expect(contextMenu).not.toBeNull();
expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
expectedShortcutNames.forEach((shortcutName) => {
expect(
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
).not.toBeNull();
});
});
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.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
const contextMenuOptions = contextMenu?.querySelectorAll(
".context-menu li",
);
const expectedShortcutNames: ShortcutName[] = [
"copyStyles",
"pasteStyles",
"deleteSelectedElements",
"group",
"addToLibrary",
"sendBackward",
"bringForward",
"sendToBack",
"bringToFront",
"duplicateSelection",
];
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.codePress(CODES.G);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
const contextMenuOptions = contextMenu?.querySelectorAll(
".context-menu li",
);
const expectedShortcutNames: ShortcutName[] = [
"copyStyles",
"pasteStyles",
"deleteSelectedElements",
"ungroup",
"addToLibrary",
"sendBackward",
"bringForward",
"sendToBack",
"bringToFront",
"duplicateSelection",
];
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.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
expect(copiedStyles).toBe("{}");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
expect(copiedStyles).not.toBe("{}");
const element = JSON.parse(copiedStyles);
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
clickLabeledElement("Stroke");
clickLabeledElement("#c92a2a");
clickLabeledElement("Background");
clickLabeledElement("#e64980");
// 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" },
});
mouse.reset();
// Copy styles of second rectangle
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 40,
clientY: 40,
});
let contextMenu = queryContextMenu();
fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
const secondRect = JSON.parse(copiedStyles);
expect(secondRect.id).toBe(h.elements[1].id);
mouse.reset();
// Paste styles to first rectangle
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 10,
clientY: 10,
});
contextMenu = queryContextMenu();
fireEvent.click(queryByText(contextMenu as HTMLElement, "Paste styles")!);
const firstRect = API.getSelectedElement();
expect(firstRect.id).toBe(h.elements[0].id);
expect(firstRect.strokeColor).toBe("#c92a2a");
expect(firstRect.backgroundColor).toBe("#e64980");
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.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
fireEvent.click(queryAllByText(contextMenu as HTMLElement, "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.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
fireEvent.click(queryByText(contextMenu as HTMLElement, "Add to library")!);
await waitFor(() => {
const library = localStorage.getItem("excalidraw-library");
expect(library).not.toBeNull();
const addedElement = JSON.parse(library!)[0][0];
expect(addedElement).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.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
fireEvent.click(queryByText(contextMenu as HTMLElement, "Duplicate")!);
expect(h.elements).toHaveLength(2);
const { id: _id0, seed: _seed0, x: _x0, y: _y0, ...rect1 } = h.elements[0];
const { id: _id1, seed: _seed1, x: _x1, y: _y1, ...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.canvas, {
button: 2,
clientX: 40,
clientY: 40,
});
const contextMenu = queryContextMenu();
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "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.canvas, {
button: 2,
clientX: 10,
clientY: 10,
});
const contextMenu = queryContextMenu();
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "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.canvas, {
button: 2,
clientX: 40,
clientY: 40,
});
const contextMenu = queryContextMenu();
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "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.canvas, {
button: 2,
clientX: 10,
clientY: 10,
});
const contextMenu = queryContextMenu();
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "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.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
fireEvent.click(
queryByText(contextMenu as HTMLElement, "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.codePress(CODES.G);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = queryContextMenu();
expect(contextMenu).not.toBeNull();
fireEvent.click(
queryByText(contextMenu as HTMLElement, "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);
});
});

View File

@ -1,8 +1,4 @@
import { queryAllByText, queryByText } from "@testing-library/react";
import React from "react";
import ReactDOM from "react-dom";
import { copiedStyles } from "../actions/actionStyles";
import { ShortcutName } from "../actions/shortcuts";
import { ExcalidrawElement } from "../element/types";
import { CODES, KEYS } from "../keys";
import ExcalidrawApp from "../excalidraw-app";
@ -11,13 +7,7 @@ import * as Renderer from "../renderer/renderScene";
import { setDateTimeForTests } from "../utils";
import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import {
fireEvent,
GlobalTestState,
render,
screen,
waitFor,
} from "./test-utils";
import { fireEvent, render, screen, waitFor } from "./test-utils";
import { defaultLang } from "../i18n";
const { h } = window;
@ -612,441 +602,6 @@ describe("regression tests", () => {
expect(h.state.currentItemFontFamily).toEqual(3); // Cascadia
});
it("shows context menu for canvas", () => {
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
const contextMenuOptions = document.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [
"selectAll",
"gridMode",
"zenMode",
"viewMode",
"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.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
const contextMenuOptions = document.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [
"copyStyles",
"pasteStyles",
"deleteSelectedElements",
"addToLibrary",
"flipHorizontal",
"flipVertical",
"sendBackward",
"bringForward",
"sendToBack",
"bringToFront",
"duplicateSelection",
];
expect(contextMenu).not.toBeNull();
expect(contextMenuOptions.length).toBe(expectedShortcutNames.length);
expectedShortcutNames.forEach((shortcutName) => {
expect(
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
).not.toBeNull();
});
});
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.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
const contextMenuOptions = document.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [
"copyStyles",
"pasteStyles",
"deleteSelectedElements",
"group",
"addToLibrary",
"sendBackward",
"bringForward",
"sendToBack",
"bringToFront",
"duplicateSelection",
];
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.codePress(CODES.G);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
const contextMenuOptions = document.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [
"copyStyles",
"pasteStyles",
"deleteSelectedElements",
"ungroup",
"addToLibrary",
"sendBackward",
"bringForward",
"sendToBack",
"bringToFront",
"duplicateSelection",
];
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.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
expect(copiedStyles).toBe("{}");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
expect(copiedStyles).not.toBe("{}");
const element = JSON.parse(copiedStyles);
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
clickLabeledElement("Stroke");
clickLabeledElement("#c92a2a");
clickLabeledElement("Background");
clickLabeledElement("#e64980");
// 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" },
});
mouse.reset();
// Copy styles of second rectangle
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 40,
clientY: 40,
});
let contextMenu = document.querySelector(".context-menu");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
const secondRect = JSON.parse(copiedStyles);
expect(secondRect.id).toBe(h.elements[1].id);
mouse.reset();
// Paste styles to first rectangle
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 10,
clientY: 10,
});
contextMenu = document.querySelector(".context-menu");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Paste styles")!);
const firstRect = API.getSelectedElement();
expect(firstRect.id).toBe(h.elements[0].id);
expect(firstRect.strokeColor).toBe("#c92a2a");
expect(firstRect.backgroundColor).toBe("#e64980");
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.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
fireEvent.click(queryAllByText(contextMenu as HTMLElement, "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.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Add to library")!);
await waitFor(() => {
const library = localStorage.getItem("excalidraw-library");
expect(library).not.toBeNull();
const addedElement = JSON.parse(library!)[0][0];
expect(addedElement).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.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Duplicate")!);
expect(h.elements).toHaveLength(2);
const { id: _id0, seed: _seed0, x: _x0, y: _y0, ...rect1 } = h.elements[0];
const { id: _id1, seed: _seed1, x: _x1, y: _y1, ...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.canvas, {
button: 2,
clientX: 40,
clientY: 40,
});
const contextMenu = document.querySelector(".context-menu");
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "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.canvas, {
button: 2,
clientX: 10,
clientY: 10,
});
const contextMenu = document.querySelector(".context-menu");
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "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.canvas, {
button: 2,
clientX: 40,
clientY: 40,
});
const contextMenu = document.querySelector(".context-menu");
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "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.canvas, {
button: 2,
clientX: 10,
clientY: 10,
});
const contextMenu = document.querySelector(".context-menu");
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "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.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
fireEvent.click(
queryByText(contextMenu as HTMLElement, "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.codePress(CODES.G);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = document.querySelector(".context-menu");
fireEvent.click(
queryByText(contextMenu as HTMLElement, "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("deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element", () => {
UI.clickTool("ellipse");
mouse.down();