contextmenu.test.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  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. // Unmount ReactDOM from root
  40. ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
  41. const renderScene = jest.spyOn(Renderer, "renderScene");
  42. beforeEach(() => {
  43. localStorage.clear();
  44. renderScene.mockClear();
  45. reseed(7);
  46. });
  47. const { h } = window;
  48. describe("contextMenu element", () => {
  49. beforeEach(async () => {
  50. localStorage.clear();
  51. renderScene.mockClear();
  52. reseed(7);
  53. setDateTimeForTests("201933152653");
  54. await render(<ExcalidrawApp />);
  55. });
  56. beforeAll(() => {
  57. mockBoundingClientRect();
  58. });
  59. afterAll(() => {
  60. restoreOriginalGetBoundingClientRect();
  61. });
  62. afterEach(() => {
  63. checkpoint("end of test");
  64. mouse.reset();
  65. mouse.down(0, 0);
  66. });
  67. it("shows context menu for canvas", () => {
  68. fireEvent.contextMenu(GlobalTestState.canvas, {
  69. button: 2,
  70. clientX: 1,
  71. clientY: 1,
  72. });
  73. const contextMenu = queryContextMenu();
  74. const contextMenuOptions =
  75. contextMenu?.querySelectorAll(".context-menu li");
  76. const expectedShortcutNames: ShortcutName[] = [
  77. "selectAll",
  78. "gridMode",
  79. "zenMode",
  80. "viewMode",
  81. "stats",
  82. ];
  83. expect(contextMenu).not.toBeNull();
  84. expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
  85. expectedShortcutNames.forEach((shortcutName) => {
  86. expect(
  87. contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
  88. ).not.toBeNull();
  89. });
  90. });
  91. it("shows context menu for element", () => {
  92. UI.clickTool("rectangle");
  93. mouse.down(10, 10);
  94. mouse.up(20, 20);
  95. fireEvent.contextMenu(GlobalTestState.canvas, {
  96. button: 2,
  97. clientX: 1,
  98. clientY: 1,
  99. });
  100. const contextMenu = queryContextMenu();
  101. const contextMenuOptions =
  102. contextMenu?.querySelectorAll(".context-menu li");
  103. const expectedShortcutNames: ShortcutName[] = [
  104. "copyStyles",
  105. "pasteStyles",
  106. "deleteSelectedElements",
  107. "addToLibrary",
  108. "flipHorizontal",
  109. "flipVertical",
  110. "sendBackward",
  111. "bringForward",
  112. "sendToBack",
  113. "bringToFront",
  114. "duplicateSelection",
  115. "hyperlink",
  116. ];
  117. expect(contextMenu).not.toBeNull();
  118. expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
  119. expectedShortcutNames.forEach((shortcutName) => {
  120. expect(
  121. contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
  122. ).not.toBeNull();
  123. });
  124. });
  125. it("shows context menu for element", () => {
  126. const rect1 = API.createElement({
  127. type: "rectangle",
  128. x: 0,
  129. y: 0,
  130. height: 200,
  131. width: 200,
  132. backgroundColor: "red",
  133. });
  134. const rect2 = API.createElement({
  135. type: "rectangle",
  136. x: 0,
  137. y: 0,
  138. height: 200,
  139. width: 200,
  140. backgroundColor: "red",
  141. });
  142. h.elements = [rect1, rect2];
  143. API.setSelectedElements([rect1]);
  144. // lower z-index
  145. fireEvent.contextMenu(GlobalTestState.canvas, {
  146. button: 2,
  147. clientX: 100,
  148. clientY: 100,
  149. });
  150. expect(queryContextMenu()).not.toBeNull();
  151. expect(API.getSelectedElement().id).toBe(rect1.id);
  152. // higher z-index
  153. API.setSelectedElements([rect2]);
  154. fireEvent.contextMenu(GlobalTestState.canvas, {
  155. button: 2,
  156. clientX: 100,
  157. clientY: 100,
  158. });
  159. expect(queryContextMenu()).not.toBeNull();
  160. expect(API.getSelectedElement().id).toBe(rect2.id);
  161. });
  162. it("shows 'Group selection' in context menu for multiple selected elements", () => {
  163. UI.clickTool("rectangle");
  164. mouse.down(10, 10);
  165. mouse.up(10, 10);
  166. UI.clickTool("rectangle");
  167. mouse.down(10, -10);
  168. mouse.up(10, 10);
  169. mouse.reset();
  170. mouse.click(10, 10);
  171. Keyboard.withModifierKeys({ shift: true }, () => {
  172. mouse.click(20, 0);
  173. });
  174. fireEvent.contextMenu(GlobalTestState.canvas, {
  175. button: 2,
  176. clientX: 1,
  177. clientY: 1,
  178. });
  179. const contextMenu = queryContextMenu();
  180. const contextMenuOptions =
  181. contextMenu?.querySelectorAll(".context-menu li");
  182. const expectedShortcutNames: ShortcutName[] = [
  183. "copyStyles",
  184. "pasteStyles",
  185. "deleteSelectedElements",
  186. "group",
  187. "addToLibrary",
  188. "sendBackward",
  189. "bringForward",
  190. "sendToBack",
  191. "bringToFront",
  192. "duplicateSelection",
  193. ];
  194. expect(contextMenu).not.toBeNull();
  195. expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
  196. expectedShortcutNames.forEach((shortcutName) => {
  197. expect(
  198. contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
  199. ).not.toBeNull();
  200. });
  201. });
  202. it("shows 'Ungroup selection' in context menu for group inside selected elements", () => {
  203. UI.clickTool("rectangle");
  204. mouse.down(10, 10);
  205. mouse.up(10, 10);
  206. UI.clickTool("rectangle");
  207. mouse.down(10, -10);
  208. mouse.up(10, 10);
  209. mouse.reset();
  210. mouse.click(10, 10);
  211. Keyboard.withModifierKeys({ shift: true }, () => {
  212. mouse.click(20, 0);
  213. });
  214. Keyboard.withModifierKeys({ ctrl: true }, () => {
  215. Keyboard.codePress(CODES.G);
  216. });
  217. fireEvent.contextMenu(GlobalTestState.canvas, {
  218. button: 2,
  219. clientX: 1,
  220. clientY: 1,
  221. });
  222. const contextMenu = queryContextMenu();
  223. const contextMenuOptions =
  224. contextMenu?.querySelectorAll(".context-menu li");
  225. const expectedShortcutNames: ShortcutName[] = [
  226. "copyStyles",
  227. "pasteStyles",
  228. "deleteSelectedElements",
  229. "ungroup",
  230. "addToLibrary",
  231. "sendBackward",
  232. "bringForward",
  233. "sendToBack",
  234. "bringToFront",
  235. "duplicateSelection",
  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 = 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);
  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 = queryContextMenu();
  293. fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
  294. const secondRect = JSON.parse(copiedStyles);
  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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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.codePress(CODES.G);
  474. });
  475. fireEvent.contextMenu(GlobalTestState.canvas, {
  476. button: 2,
  477. clientX: 1,
  478. clientY: 1,
  479. });
  480. const contextMenu = 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. });