contextmenu.test.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
  1. import ReactDOM from "react-dom";
  2. import {
  3. render,
  4. fireEvent,
  5. mockBoundingClientRect,
  6. restoreOriginalGetBoundingClientRect,
  7. GlobalTestState,
  8. screen,
  9. queryByText,
  10. queryAllByText,
  11. waitFor,
  12. } from "./test-utils";
  13. import ExcalidrawApp from "../excalidraw-app";
  14. import * as Renderer from "../renderer/renderScene";
  15. import { reseed } from "../random";
  16. import { UI, Pointer, Keyboard } from "./helpers/ui";
  17. import { KEYS } from "../keys";
  18. import { ShortcutName } from "../actions/shortcuts";
  19. import { copiedStyles } from "../actions/actionStyles";
  20. import { API } from "./helpers/api";
  21. import { setDateTimeForTests } from "../utils";
  22. import { t } from "../i18n";
  23. import { LibraryItem } from "../types";
  24. const checkpoint = (name: string) => {
  25. expect(renderScene.mock.calls.length).toMatchSnapshot(
  26. `[${name}] number of renders`,
  27. );
  28. expect(h.state).toMatchSnapshot(`[${name}] appState`);
  29. expect(h.history.getSnapshotForTest()).toMatchSnapshot(`[${name}] history`);
  30. expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
  31. h.elements.forEach((element, i) =>
  32. expect(element).toMatchSnapshot(`[${name}] element ${i}`),
  33. );
  34. };
  35. const mouse = new Pointer("mouse");
  36. // Unmount ReactDOM from root
  37. ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
  38. const renderScene = jest.spyOn(Renderer, "renderScene");
  39. beforeEach(() => {
  40. localStorage.clear();
  41. renderScene.mockClear();
  42. reseed(7);
  43. });
  44. const { h } = window;
  45. describe("contextMenu element", () => {
  46. beforeEach(async () => {
  47. localStorage.clear();
  48. renderScene.mockClear();
  49. reseed(7);
  50. setDateTimeForTests("201933152653");
  51. await render(<ExcalidrawApp />);
  52. });
  53. beforeAll(() => {
  54. mockBoundingClientRect();
  55. });
  56. afterAll(() => {
  57. restoreOriginalGetBoundingClientRect();
  58. });
  59. afterEach(() => {
  60. checkpoint("end of test");
  61. mouse.reset();
  62. mouse.down(0, 0);
  63. });
  64. it("shows context menu for canvas", () => {
  65. fireEvent.contextMenu(GlobalTestState.canvas, {
  66. button: 2,
  67. clientX: 1,
  68. clientY: 1,
  69. });
  70. const contextMenu = UI.queryContextMenu();
  71. const contextMenuOptions =
  72. contextMenu?.querySelectorAll(".context-menu li");
  73. const expectedShortcutNames: ShortcutName[] = [
  74. "selectAll",
  75. "gridMode",
  76. "zenMode",
  77. "viewMode",
  78. "stats",
  79. ];
  80. expect(contextMenu).not.toBeNull();
  81. expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
  82. expectedShortcutNames.forEach((shortcutName) => {
  83. expect(
  84. contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
  85. ).not.toBeNull();
  86. });
  87. });
  88. it("shows context menu for element", () => {
  89. UI.clickTool("rectangle");
  90. mouse.down(10, 10);
  91. mouse.up(20, 20);
  92. fireEvent.contextMenu(GlobalTestState.canvas, {
  93. button: 2,
  94. clientX: 1,
  95. clientY: 1,
  96. });
  97. const contextMenu = UI.queryContextMenu();
  98. const contextMenuOptions =
  99. contextMenu?.querySelectorAll(".context-menu li");
  100. const expectedShortcutNames: ShortcutName[] = [
  101. "copyStyles",
  102. "pasteStyles",
  103. "deleteSelectedElements",
  104. "addToLibrary",
  105. "flipHorizontal",
  106. "flipVertical",
  107. "sendBackward",
  108. "bringForward",
  109. "sendToBack",
  110. "bringToFront",
  111. "duplicateSelection",
  112. "hyperlink",
  113. "toggleLock",
  114. ];
  115. expect(contextMenu).not.toBeNull();
  116. expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
  117. expectedShortcutNames.forEach((shortcutName) => {
  118. expect(
  119. contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
  120. ).not.toBeNull();
  121. });
  122. });
  123. it("shows context menu for element", () => {
  124. const rect1 = API.createElement({
  125. type: "rectangle",
  126. x: 0,
  127. y: 0,
  128. height: 200,
  129. width: 200,
  130. backgroundColor: "red",
  131. });
  132. const rect2 = API.createElement({
  133. type: "rectangle",
  134. x: 0,
  135. y: 0,
  136. height: 200,
  137. width: 200,
  138. backgroundColor: "red",
  139. });
  140. h.elements = [rect1, rect2];
  141. API.setSelectedElements([rect1]);
  142. // lower z-index
  143. fireEvent.contextMenu(GlobalTestState.canvas, {
  144. button: 2,
  145. clientX: 100,
  146. clientY: 100,
  147. });
  148. expect(UI.queryContextMenu()).not.toBeNull();
  149. expect(API.getSelectedElement().id).toBe(rect1.id);
  150. // higher z-index
  151. API.setSelectedElements([rect2]);
  152. fireEvent.contextMenu(GlobalTestState.canvas, {
  153. button: 2,
  154. clientX: 100,
  155. clientY: 100,
  156. });
  157. expect(UI.queryContextMenu()).not.toBeNull();
  158. expect(API.getSelectedElement().id).toBe(rect2.id);
  159. });
  160. it("shows 'Group selection' in context menu for multiple selected elements", () => {
  161. UI.clickTool("rectangle");
  162. mouse.down(10, 10);
  163. mouse.up(10, 10);
  164. UI.clickTool("rectangle");
  165. mouse.down(10, -10);
  166. mouse.up(10, 10);
  167. mouse.reset();
  168. mouse.click(10, 10);
  169. Keyboard.withModifierKeys({ shift: true }, () => {
  170. mouse.click(20, 0);
  171. });
  172. fireEvent.contextMenu(GlobalTestState.canvas, {
  173. button: 2,
  174. clientX: 1,
  175. clientY: 1,
  176. });
  177. const contextMenu = UI.queryContextMenu();
  178. const contextMenuOptions =
  179. contextMenu?.querySelectorAll(".context-menu li");
  180. const expectedShortcutNames: ShortcutName[] = [
  181. "copyStyles",
  182. "pasteStyles",
  183. "deleteSelectedElements",
  184. "group",
  185. "addToLibrary",
  186. "sendBackward",
  187. "bringForward",
  188. "sendToBack",
  189. "bringToFront",
  190. "duplicateSelection",
  191. "toggleLock",
  192. ];
  193. expect(contextMenu).not.toBeNull();
  194. expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
  195. expectedShortcutNames.forEach((shortcutName) => {
  196. expect(
  197. contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
  198. ).not.toBeNull();
  199. });
  200. });
  201. it("shows 'Ungroup selection' in context menu for group inside selected elements", () => {
  202. UI.clickTool("rectangle");
  203. mouse.down(10, 10);
  204. mouse.up(10, 10);
  205. UI.clickTool("rectangle");
  206. mouse.down(10, -10);
  207. mouse.up(10, 10);
  208. mouse.reset();
  209. mouse.click(10, 10);
  210. Keyboard.withModifierKeys({ shift: true }, () => {
  211. mouse.click(20, 0);
  212. });
  213. Keyboard.withModifierKeys({ ctrl: true }, () => {
  214. Keyboard.keyPress(KEYS.G);
  215. });
  216. fireEvent.contextMenu(GlobalTestState.canvas, {
  217. button: 2,
  218. clientX: 1,
  219. clientY: 1,
  220. });
  221. const contextMenu = UI.queryContextMenu();
  222. const contextMenuOptions =
  223. contextMenu?.querySelectorAll(".context-menu li");
  224. const expectedShortcutNames: ShortcutName[] = [
  225. "copyStyles",
  226. "pasteStyles",
  227. "deleteSelectedElements",
  228. "ungroup",
  229. "addToLibrary",
  230. "sendBackward",
  231. "bringForward",
  232. "sendToBack",
  233. "bringToFront",
  234. "duplicateSelection",
  235. "toggleLock",
  236. ];
  237. expect(contextMenu).not.toBeNull();
  238. expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
  239. expectedShortcutNames.forEach((shortcutName) => {
  240. expect(
  241. contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
  242. ).not.toBeNull();
  243. });
  244. });
  245. it("selecting 'Copy styles' in context menu copies styles", () => {
  246. UI.clickTool("rectangle");
  247. mouse.down(10, 10);
  248. mouse.up(20, 20);
  249. fireEvent.contextMenu(GlobalTestState.canvas, {
  250. button: 2,
  251. clientX: 1,
  252. clientY: 1,
  253. });
  254. const contextMenu = UI.queryContextMenu();
  255. expect(copiedStyles).toBe("{}");
  256. fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
  257. expect(copiedStyles).not.toBe("{}");
  258. const element = JSON.parse(copiedStyles)[0];
  259. expect(element).toEqual(API.getSelectedElement());
  260. });
  261. it("selecting 'Paste styles' in context menu pastes styles", () => {
  262. UI.clickTool("rectangle");
  263. mouse.down(10, 10);
  264. mouse.up(20, 20);
  265. UI.clickTool("rectangle");
  266. mouse.down(10, 10);
  267. mouse.up(20, 20);
  268. // Change some styles of second rectangle
  269. UI.clickLabeledElement("Stroke");
  270. UI.clickLabeledElement(t("colors.c92a2a"));
  271. UI.clickLabeledElement("Background");
  272. UI.clickLabeledElement(t("colors.e64980"));
  273. // Fill style
  274. fireEvent.click(screen.getByTitle("Cross-hatch"));
  275. // Stroke width
  276. fireEvent.click(screen.getByTitle("Bold"));
  277. // Stroke style
  278. fireEvent.click(screen.getByTitle("Dotted"));
  279. // Roughness
  280. fireEvent.click(screen.getByTitle("Cartoonist"));
  281. // Opacity
  282. fireEvent.change(screen.getByLabelText("Opacity"), {
  283. target: { value: "60" },
  284. });
  285. mouse.reset();
  286. // Copy styles of second rectangle
  287. fireEvent.contextMenu(GlobalTestState.canvas, {
  288. button: 2,
  289. clientX: 40,
  290. clientY: 40,
  291. });
  292. let contextMenu = UI.queryContextMenu();
  293. fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
  294. const secondRect = JSON.parse(copiedStyles)[0];
  295. expect(secondRect.id).toBe(h.elements[1].id);
  296. mouse.reset();
  297. // Paste styles to first rectangle
  298. fireEvent.contextMenu(GlobalTestState.canvas, {
  299. button: 2,
  300. clientX: 10,
  301. clientY: 10,
  302. });
  303. contextMenu = UI.queryContextMenu();
  304. fireEvent.click(queryByText(contextMenu as HTMLElement, "Paste styles")!);
  305. const firstRect = API.getSelectedElement();
  306. expect(firstRect.id).toBe(h.elements[0].id);
  307. expect(firstRect.strokeColor).toBe("#c92a2a");
  308. expect(firstRect.backgroundColor).toBe("#e64980");
  309. expect(firstRect.fillStyle).toBe("cross-hatch");
  310. expect(firstRect.strokeWidth).toBe(2); // Bold: 2
  311. expect(firstRect.strokeStyle).toBe("dotted");
  312. expect(firstRect.roughness).toBe(2); // Cartoonist: 2
  313. expect(firstRect.opacity).toBe(60);
  314. });
  315. it("selecting 'Delete' in context menu deletes element", () => {
  316. UI.clickTool("rectangle");
  317. mouse.down(10, 10);
  318. mouse.up(20, 20);
  319. fireEvent.contextMenu(GlobalTestState.canvas, {
  320. button: 2,
  321. clientX: 1,
  322. clientY: 1,
  323. });
  324. const contextMenu = UI.queryContextMenu();
  325. fireEvent.click(queryAllByText(contextMenu as HTMLElement, "Delete")[0]);
  326. expect(API.getSelectedElements()).toHaveLength(0);
  327. expect(h.elements[0].isDeleted).toBe(true);
  328. });
  329. it("selecting 'Add to library' in context menu adds element to library", async () => {
  330. UI.clickTool("rectangle");
  331. mouse.down(10, 10);
  332. mouse.up(20, 20);
  333. fireEvent.contextMenu(GlobalTestState.canvas, {
  334. button: 2,
  335. clientX: 1,
  336. clientY: 1,
  337. });
  338. const contextMenu = UI.queryContextMenu();
  339. fireEvent.click(queryByText(contextMenu as HTMLElement, "Add to library")!);
  340. await waitFor(() => {
  341. const library = localStorage.getItem("excalidraw-library");
  342. expect(library).not.toBeNull();
  343. const addedElement = JSON.parse(library!)[0] as LibraryItem;
  344. expect(addedElement.elements[0]).toEqual(h.elements[0]);
  345. });
  346. });
  347. it("selecting 'Duplicate' in context menu duplicates element", () => {
  348. UI.clickTool("rectangle");
  349. mouse.down(10, 10);
  350. mouse.up(20, 20);
  351. fireEvent.contextMenu(GlobalTestState.canvas, {
  352. button: 2,
  353. clientX: 1,
  354. clientY: 1,
  355. });
  356. const contextMenu = UI.queryContextMenu();
  357. fireEvent.click(queryByText(contextMenu as HTMLElement, "Duplicate")!);
  358. expect(h.elements).toHaveLength(2);
  359. const { id: _id0, seed: _seed0, x: _x0, y: _y0, ...rect1 } = h.elements[0];
  360. const { id: _id1, seed: _seed1, x: _x1, y: _y1, ...rect2 } = h.elements[1];
  361. expect(rect1).toEqual(rect2);
  362. });
  363. it("selecting 'Send backward' in context menu sends element backward", () => {
  364. UI.clickTool("rectangle");
  365. mouse.down(10, 10);
  366. mouse.up(20, 20);
  367. UI.clickTool("rectangle");
  368. mouse.down(10, 10);
  369. mouse.up(20, 20);
  370. mouse.reset();
  371. fireEvent.contextMenu(GlobalTestState.canvas, {
  372. button: 2,
  373. clientX: 40,
  374. clientY: 40,
  375. });
  376. const contextMenu = UI.queryContextMenu();
  377. const elementsBefore = h.elements;
  378. fireEvent.click(queryByText(contextMenu as HTMLElement, "Send backward")!);
  379. expect(elementsBefore[0].id).toEqual(h.elements[1].id);
  380. expect(elementsBefore[1].id).toEqual(h.elements[0].id);
  381. });
  382. it("selecting 'Bring forward' in context menu brings element forward", () => {
  383. UI.clickTool("rectangle");
  384. mouse.down(10, 10);
  385. mouse.up(20, 20);
  386. UI.clickTool("rectangle");
  387. mouse.down(10, 10);
  388. mouse.up(20, 20);
  389. mouse.reset();
  390. fireEvent.contextMenu(GlobalTestState.canvas, {
  391. button: 2,
  392. clientX: 10,
  393. clientY: 10,
  394. });
  395. const contextMenu = UI.queryContextMenu();
  396. const elementsBefore = h.elements;
  397. fireEvent.click(queryByText(contextMenu as HTMLElement, "Bring forward")!);
  398. expect(elementsBefore[0].id).toEqual(h.elements[1].id);
  399. expect(elementsBefore[1].id).toEqual(h.elements[0].id);
  400. });
  401. it("selecting 'Send to back' in context menu sends element to back", () => {
  402. UI.clickTool("rectangle");
  403. mouse.down(10, 10);
  404. mouse.up(20, 20);
  405. UI.clickTool("rectangle");
  406. mouse.down(10, 10);
  407. mouse.up(20, 20);
  408. mouse.reset();
  409. fireEvent.contextMenu(GlobalTestState.canvas, {
  410. button: 2,
  411. clientX: 40,
  412. clientY: 40,
  413. });
  414. const contextMenu = UI.queryContextMenu();
  415. const elementsBefore = h.elements;
  416. fireEvent.click(queryByText(contextMenu as HTMLElement, "Send to back")!);
  417. expect(elementsBefore[1].id).toEqual(h.elements[0].id);
  418. });
  419. it("selecting 'Bring to front' in context menu brings element to front", () => {
  420. UI.clickTool("rectangle");
  421. mouse.down(10, 10);
  422. mouse.up(20, 20);
  423. UI.clickTool("rectangle");
  424. mouse.down(10, 10);
  425. mouse.up(20, 20);
  426. mouse.reset();
  427. fireEvent.contextMenu(GlobalTestState.canvas, {
  428. button: 2,
  429. clientX: 10,
  430. clientY: 10,
  431. });
  432. const contextMenu = UI.queryContextMenu();
  433. const elementsBefore = h.elements;
  434. fireEvent.click(queryByText(contextMenu as HTMLElement, "Bring to front")!);
  435. expect(elementsBefore[0].id).toEqual(h.elements[1].id);
  436. });
  437. it("selecting 'Group selection' in context menu groups selected elements", () => {
  438. UI.clickTool("rectangle");
  439. mouse.down(10, 10);
  440. mouse.up(20, 20);
  441. UI.clickTool("rectangle");
  442. mouse.down(10, 10);
  443. mouse.up(20, 20);
  444. mouse.reset();
  445. Keyboard.withModifierKeys({ shift: true }, () => {
  446. mouse.click(10, 10);
  447. });
  448. fireEvent.contextMenu(GlobalTestState.canvas, {
  449. button: 2,
  450. clientX: 1,
  451. clientY: 1,
  452. });
  453. const contextMenu = UI.queryContextMenu();
  454. fireEvent.click(
  455. queryByText(contextMenu as HTMLElement, "Group selection")!,
  456. );
  457. const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
  458. expect(h.elements[0].groupIds).toEqual(selectedGroupIds);
  459. expect(h.elements[1].groupIds).toEqual(selectedGroupIds);
  460. });
  461. it("selecting 'Ungroup selection' in context menu ungroups selected group", () => {
  462. UI.clickTool("rectangle");
  463. mouse.down(10, 10);
  464. mouse.up(20, 20);
  465. UI.clickTool("rectangle");
  466. mouse.down(10, 10);
  467. mouse.up(20, 20);
  468. mouse.reset();
  469. Keyboard.withModifierKeys({ shift: true }, () => {
  470. mouse.click(10, 10);
  471. });
  472. Keyboard.withModifierKeys({ ctrl: true }, () => {
  473. Keyboard.keyPress(KEYS.G);
  474. });
  475. fireEvent.contextMenu(GlobalTestState.canvas, {
  476. button: 2,
  477. clientX: 1,
  478. clientY: 1,
  479. });
  480. const contextMenu = UI.queryContextMenu();
  481. expect(contextMenu).not.toBeNull();
  482. fireEvent.click(
  483. queryByText(contextMenu as HTMLElement, "Ungroup selection")!,
  484. );
  485. const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
  486. expect(selectedGroupIds).toHaveLength(0);
  487. expect(h.elements[0].groupIds).toHaveLength(0);
  488. expect(h.elements[1].groupIds).toHaveLength(0);
  489. });
  490. it("right-clicking on a group should select whole group", () => {
  491. const rectangle1 = API.createElement({
  492. type: "rectangle",
  493. width: 100,
  494. backgroundColor: "red",
  495. fillStyle: "solid",
  496. groupIds: ["g1"],
  497. });
  498. const rectangle2 = API.createElement({
  499. type: "rectangle",
  500. width: 100,
  501. backgroundColor: "red",
  502. fillStyle: "solid",
  503. groupIds: ["g1"],
  504. });
  505. h.elements = [rectangle1, rectangle2];
  506. mouse.rightClickAt(50, 50);
  507. expect(API.getSelectedElements().length).toBe(2);
  508. expect(API.getSelectedElements()).toEqual([
  509. expect.objectContaining({ id: rectangle1.id }),
  510. expect.objectContaining({ id: rectangle2.id }),
  511. ]);
  512. });
  513. });