contextmenu.test.tsx 16 KB

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