regressionTests.test.tsx 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225
  1. import { reseed } from "../random";
  2. import React from "react";
  3. import ReactDOM from "react-dom";
  4. import * as Renderer from "../renderer/renderScene";
  5. import { waitFor, render, screen, fireEvent } from "./test-utils";
  6. import App from "../components/App";
  7. import { setLanguage } from "../i18n";
  8. import { ToolName } from "./queries/toolQueries";
  9. import { KEYS, Key } from "../keys";
  10. import { setDateTimeForTests } from "../utils";
  11. import { ExcalidrawElement } from "../element/types";
  12. import { getTransformHandles as _getTransformHandles } from "../element";
  13. import { queryByText } from "@testing-library/react";
  14. import { copiedStyles } from "../actions/actionStyles";
  15. const { h } = window;
  16. const renderScene = jest.spyOn(Renderer, "renderScene");
  17. let getByToolName: (name: string) => HTMLElement = null!;
  18. let canvas: HTMLCanvasElement = null!;
  19. const clickTool = (toolName: ToolName) => {
  20. fireEvent.click(getByToolName(toolName));
  21. };
  22. let altKey = false;
  23. let shiftKey = false;
  24. let ctrlKey = false;
  25. function withModifierKeys(
  26. modifiers: { alt?: boolean; shift?: boolean; ctrl?: boolean },
  27. cb: () => void,
  28. ) {
  29. const prevAltKey = altKey;
  30. const prevShiftKey = shiftKey;
  31. const prevCtrlKey = ctrlKey;
  32. altKey = !!modifiers.alt;
  33. shiftKey = !!modifiers.shift;
  34. ctrlKey = !!modifiers.ctrl;
  35. try {
  36. cb();
  37. } finally {
  38. altKey = prevAltKey;
  39. shiftKey = prevShiftKey;
  40. ctrlKey = prevCtrlKey;
  41. }
  42. }
  43. const hotkeyDown = (hotkey: Key) => {
  44. const key = KEYS[hotkey];
  45. if (typeof key !== "string") {
  46. throw new Error("must provide a hotkey, not a key code");
  47. }
  48. keyDown(key);
  49. };
  50. const hotkeyUp = (hotkey: Key) => {
  51. const key = KEYS[hotkey];
  52. if (typeof key !== "string") {
  53. throw new Error("must provide a hotkey, not a key code");
  54. }
  55. keyUp(key);
  56. };
  57. const keyDown = (key: string) => {
  58. fireEvent.keyDown(document, {
  59. key,
  60. ctrlKey,
  61. shiftKey,
  62. altKey,
  63. keyCode: key.toUpperCase().charCodeAt(0),
  64. which: key.toUpperCase().charCodeAt(0),
  65. });
  66. };
  67. const keyUp = (key: string) => {
  68. fireEvent.keyUp(document, {
  69. key,
  70. ctrlKey,
  71. shiftKey,
  72. altKey,
  73. keyCode: key.toUpperCase().charCodeAt(0),
  74. which: key.toUpperCase().charCodeAt(0),
  75. });
  76. };
  77. const hotkeyPress = (key: Key) => {
  78. hotkeyDown(key);
  79. hotkeyUp(key);
  80. };
  81. const keyPress = (key: string) => {
  82. keyDown(key);
  83. keyUp(key);
  84. };
  85. class Pointer {
  86. private clientX = 0;
  87. private clientY = 0;
  88. constructor(
  89. private readonly pointerType: "mouse" | "touch" | "pen",
  90. private readonly pointerId = 1,
  91. ) {}
  92. reset() {
  93. this.clientX = 0;
  94. this.clientY = 0;
  95. }
  96. getPosition() {
  97. return [this.clientX, this.clientY];
  98. }
  99. restorePosition(x = 0, y = 0) {
  100. this.clientX = x;
  101. this.clientY = y;
  102. fireEvent.pointerMove(canvas, this.getEvent());
  103. }
  104. private getEvent() {
  105. return {
  106. clientX: this.clientX,
  107. clientY: this.clientY,
  108. pointerType: this.pointerType,
  109. pointerId: this.pointerId,
  110. altKey,
  111. shiftKey,
  112. ctrlKey,
  113. };
  114. }
  115. move(dx: number, dy: number) {
  116. if (dx !== 0 || dy !== 0) {
  117. this.clientX += dx;
  118. this.clientY += dy;
  119. fireEvent.pointerMove(canvas, this.getEvent());
  120. }
  121. }
  122. down(dx = 0, dy = 0) {
  123. this.move(dx, dy);
  124. fireEvent.pointerDown(canvas, this.getEvent());
  125. }
  126. up(dx = 0, dy = 0) {
  127. this.move(dx, dy);
  128. fireEvent.pointerUp(canvas, this.getEvent());
  129. }
  130. click(dx = 0, dy = 0) {
  131. this.down(dx, dy);
  132. this.up();
  133. }
  134. doubleClick(dx = 0, dy = 0) {
  135. this.move(dx, dy);
  136. fireEvent.doubleClick(canvas, this.getEvent());
  137. }
  138. }
  139. const mouse = new Pointer("mouse");
  140. const finger1 = new Pointer("touch", 1);
  141. const finger2 = new Pointer("touch", 2);
  142. const clickLabeledElement = (label: string) => {
  143. const element = document.querySelector(`[aria-label='${label}']`);
  144. if (!element) {
  145. throw new Error(`No labeled element found: ${label}`);
  146. }
  147. fireEvent.click(element);
  148. };
  149. const getSelectedElements = (): ExcalidrawElement[] => {
  150. return h.elements.filter((element) => h.state.selectedElementIds[element.id]);
  151. };
  152. const getSelectedElement = (): ExcalidrawElement => {
  153. const selectedElements = getSelectedElements();
  154. if (selectedElements.length !== 1) {
  155. throw new Error(
  156. `expected 1 selected element; got ${selectedElements.length}`,
  157. );
  158. }
  159. return selectedElements[0];
  160. };
  161. function getStateHistory() {
  162. // @ts-ignore
  163. return h.history.stateHistory;
  164. }
  165. type HandlerRectanglesRet = keyof ReturnType<typeof _getTransformHandles>;
  166. const getTransformHandles = (pointerType: "mouse" | "touch" | "pen") => {
  167. const rects = _getTransformHandles(
  168. getSelectedElement(),
  169. h.state.zoom,
  170. pointerType,
  171. ) as {
  172. [T in HandlerRectanglesRet]: [number, number, number, number];
  173. };
  174. const rv: { [K in keyof typeof rects]: [number, number] } = {} as any;
  175. for (const handlePos in rects) {
  176. const [x, y, width, height] = rects[handlePos as keyof typeof rects];
  177. rv[handlePos as keyof typeof rects] = [x + width / 2, y + height / 2];
  178. }
  179. return rv;
  180. };
  181. /**
  182. * This is always called at the end of your test, so usually you don't need to call it.
  183. * However, if you have a long test, you might want to call it during the test so it's easier
  184. * to debug where a test failure came from.
  185. */
  186. const checkpoint = (name: string) => {
  187. expect(renderScene.mock.calls.length).toMatchSnapshot(
  188. `[${name}] number of renders`,
  189. );
  190. expect(h.state).toMatchSnapshot(`[${name}] appState`);
  191. expect(h.history.getSnapshotForTest()).toMatchSnapshot(`[${name}] history`);
  192. expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
  193. h.elements.forEach((element, i) =>
  194. expect(element).toMatchSnapshot(`[${name}] element ${i}`),
  195. );
  196. };
  197. beforeEach(async () => {
  198. // Unmount ReactDOM from root
  199. ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
  200. localStorage.clear();
  201. renderScene.mockClear();
  202. h.history.clear();
  203. reseed(7);
  204. setDateTimeForTests("201933152653");
  205. mouse.reset();
  206. finger1.reset();
  207. finger2.reset();
  208. altKey = ctrlKey = shiftKey = false;
  209. await setLanguage("en.json");
  210. const renderResult = render(<App />);
  211. getByToolName = renderResult.getByToolName;
  212. canvas = renderResult.container.querySelector("canvas")!;
  213. });
  214. afterEach(() => {
  215. checkpoint("end of test");
  216. });
  217. describe("regression tests", () => {
  218. it("draw every type of shape", () => {
  219. clickTool("rectangle");
  220. mouse.down(10, -10);
  221. mouse.up(20, 10);
  222. clickTool("diamond");
  223. mouse.down(10, -10);
  224. mouse.up(20, 10);
  225. clickTool("ellipse");
  226. mouse.down(10, -10);
  227. mouse.up(20, 10);
  228. clickTool("arrow");
  229. mouse.down(40, -10);
  230. mouse.up(50, 10);
  231. clickTool("line");
  232. mouse.down(40, -10);
  233. mouse.up(50, 10);
  234. clickTool("arrow");
  235. mouse.click(40, -10);
  236. mouse.click(50, 10);
  237. mouse.click(30, 10);
  238. hotkeyPress("ENTER");
  239. clickTool("line");
  240. mouse.click(40, -20);
  241. mouse.click(50, 10);
  242. mouse.click(30, 10);
  243. hotkeyPress("ENTER");
  244. clickTool("draw");
  245. mouse.down(40, -20);
  246. mouse.up(50, 10);
  247. expect(h.elements.map((element) => element.type)).toEqual([
  248. "rectangle",
  249. "diamond",
  250. "ellipse",
  251. "arrow",
  252. "line",
  253. "arrow",
  254. "line",
  255. "draw",
  256. ]);
  257. });
  258. it("click to select a shape", () => {
  259. clickTool("rectangle");
  260. mouse.down(10, 10);
  261. mouse.up(10, 10);
  262. const firstRectPos = mouse.getPosition();
  263. clickTool("rectangle");
  264. mouse.down(10, -10);
  265. mouse.up(10, 10);
  266. const prevSelectedId = getSelectedElement().id;
  267. mouse.restorePosition(...firstRectPos);
  268. mouse.click();
  269. expect(getSelectedElement().id).not.toEqual(prevSelectedId);
  270. });
  271. for (const [keys, shape] of [
  272. ["2r", "rectangle"],
  273. ["3d", "diamond"],
  274. ["4e", "ellipse"],
  275. ["5a", "arrow"],
  276. ["6l", "line"],
  277. ["7x", "draw"],
  278. ] as [string, ExcalidrawElement["type"]][]) {
  279. for (const key of keys) {
  280. it(`hotkey ${key} selects ${shape} tool`, () => {
  281. keyPress(key);
  282. mouse.down(10, 10);
  283. mouse.up(10, 10);
  284. expect(getSelectedElement().type).toBe(shape);
  285. });
  286. }
  287. }
  288. it("change the properties of a shape", () => {
  289. clickTool("rectangle");
  290. mouse.down(10, 10);
  291. mouse.up(10, 10);
  292. clickLabeledElement("Background");
  293. clickLabeledElement("#fa5252");
  294. clickLabeledElement("Stroke");
  295. clickLabeledElement("#5f3dc4");
  296. expect(getSelectedElement().backgroundColor).toBe("#fa5252");
  297. expect(getSelectedElement().strokeColor).toBe("#5f3dc4");
  298. });
  299. it("resize an element, trying every resize handle", () => {
  300. clickTool("rectangle");
  301. mouse.down(10, 10);
  302. mouse.up(10, 10);
  303. const transformHandles = getTransformHandles("mouse");
  304. delete transformHandles.rotation; // exclude rotation handle
  305. for (const handlePos in transformHandles) {
  306. const [x, y] = transformHandles[
  307. handlePos as keyof typeof transformHandles
  308. ];
  309. const { width: prevWidth, height: prevHeight } = getSelectedElement();
  310. mouse.restorePosition(x, y);
  311. mouse.down();
  312. mouse.up(-5, -5);
  313. const {
  314. width: nextWidthNegative,
  315. height: nextHeightNegative,
  316. } = getSelectedElement();
  317. expect(
  318. prevWidth !== nextWidthNegative || prevHeight !== nextHeightNegative,
  319. ).toBeTruthy();
  320. checkpoint(`resize handle ${handlePos} (-5, -5)`);
  321. mouse.down();
  322. mouse.up(5, 5);
  323. const { width, height } = getSelectedElement();
  324. expect(width).toBe(prevWidth);
  325. expect(height).toBe(prevHeight);
  326. checkpoint(`unresize handle ${handlePos} (-5, -5)`);
  327. mouse.restorePosition(x, y);
  328. mouse.down();
  329. mouse.up(5, 5);
  330. const {
  331. width: nextWidthPositive,
  332. height: nextHeightPositive,
  333. } = getSelectedElement();
  334. expect(
  335. prevWidth !== nextWidthPositive || prevHeight !== nextHeightPositive,
  336. ).toBeTruthy();
  337. checkpoint(`resize handle ${handlePos} (+5, +5)`);
  338. mouse.down();
  339. mouse.up(-5, -5);
  340. const { width: finalWidth, height: finalHeight } = getSelectedElement();
  341. expect(finalWidth).toBe(prevWidth);
  342. expect(finalHeight).toBe(prevHeight);
  343. checkpoint(`unresize handle ${handlePos} (+5, +5)`);
  344. }
  345. });
  346. it("click on an element and drag it", () => {
  347. clickTool("rectangle");
  348. mouse.down(10, 10);
  349. mouse.up(10, 10);
  350. const { x: prevX, y: prevY } = getSelectedElement();
  351. mouse.down(-10, -10);
  352. mouse.up(10, 10);
  353. const { x: nextX, y: nextY } = getSelectedElement();
  354. expect(nextX).toBeGreaterThan(prevX);
  355. expect(nextY).toBeGreaterThan(prevY);
  356. checkpoint("dragged");
  357. mouse.down();
  358. mouse.up(-10, -10);
  359. const { x, y } = getSelectedElement();
  360. expect(x).toBe(prevX);
  361. expect(y).toBe(prevY);
  362. });
  363. it("alt-drag duplicates an element", () => {
  364. clickTool("rectangle");
  365. mouse.down(10, 10);
  366. mouse.up(10, 10);
  367. expect(
  368. h.elements.filter((element) => element.type === "rectangle").length,
  369. ).toBe(1);
  370. withModifierKeys({ alt: true }, () => {
  371. mouse.down(-10, -10);
  372. mouse.up(10, 10);
  373. });
  374. expect(
  375. h.elements.filter((element) => element.type === "rectangle").length,
  376. ).toBe(2);
  377. });
  378. it("click-drag to select a group", () => {
  379. clickTool("rectangle");
  380. mouse.down(10, 10);
  381. mouse.up(10, 10);
  382. clickTool("rectangle");
  383. mouse.down(10, -10);
  384. mouse.up(10, 10);
  385. const finalPosition = mouse.getPosition();
  386. clickTool("rectangle");
  387. mouse.down(10, -10);
  388. mouse.up(10, 10);
  389. mouse.restorePosition(0, 0);
  390. mouse.down();
  391. mouse.restorePosition(...finalPosition);
  392. mouse.up(5, 5);
  393. expect(
  394. h.elements.filter((element) => h.state.selectedElementIds[element.id])
  395. .length,
  396. ).toBe(2);
  397. });
  398. it("shift-click to multiselect, then drag", () => {
  399. clickTool("rectangle");
  400. mouse.down(10, 10);
  401. mouse.up(10, 10);
  402. clickTool("rectangle");
  403. mouse.down(10, -10);
  404. mouse.up(10, 10);
  405. const prevRectsXY = h.elements
  406. .filter((element) => element.type === "rectangle")
  407. .map((element) => ({ x: element.x, y: element.y }));
  408. mouse.reset();
  409. mouse.click(10, 10);
  410. withModifierKeys({ shift: true }, () => {
  411. mouse.click(20, 0);
  412. });
  413. mouse.down();
  414. mouse.up(10, 10);
  415. h.elements
  416. .filter((element) => element.type === "rectangle")
  417. .forEach((element, i) => {
  418. expect(element.x).toBeGreaterThan(prevRectsXY[i].x);
  419. expect(element.y).toBeGreaterThan(prevRectsXY[i].y);
  420. });
  421. });
  422. it("pinch-to-zoom works", () => {
  423. expect(h.state.zoom).toBe(1);
  424. finger1.down(50, 50);
  425. finger2.down(60, 50);
  426. finger1.move(-10, 0);
  427. expect(h.state.zoom).toBeGreaterThan(1);
  428. const zoomed = h.state.zoom;
  429. finger1.move(5, 0);
  430. finger2.move(-5, 0);
  431. expect(h.state.zoom).toBeLessThan(zoomed);
  432. });
  433. it("two-finger scroll works", () => {
  434. const startScrollY = h.state.scrollY;
  435. finger1.down(50, 50);
  436. finger2.down(60, 50);
  437. finger1.up(0, -10);
  438. finger2.up(0, -10);
  439. expect(h.state.scrollY).toBeLessThan(startScrollY);
  440. const startScrollX = h.state.scrollX;
  441. finger1.restorePosition(50, 50);
  442. finger2.restorePosition(50, 60);
  443. finger1.down();
  444. finger2.down();
  445. finger1.up(10, 0);
  446. finger2.up(10, 0);
  447. expect(h.state.scrollX).toBeGreaterThan(startScrollX);
  448. });
  449. it("spacebar + drag scrolls the canvas", () => {
  450. const { scrollX: startScrollX, scrollY: startScrollY } = h.state;
  451. hotkeyDown("SPACE");
  452. mouse.down(50, 50);
  453. mouse.up(60, 60);
  454. hotkeyUp("SPACE");
  455. const { scrollX, scrollY } = h.state;
  456. expect(scrollX).not.toEqual(startScrollX);
  457. expect(scrollY).not.toEqual(startScrollY);
  458. });
  459. it("arrow keys", () => {
  460. clickTool("rectangle");
  461. mouse.down(10, 10);
  462. mouse.up(10, 10);
  463. hotkeyPress("ARROW_LEFT");
  464. hotkeyPress("ARROW_LEFT");
  465. hotkeyPress("ARROW_RIGHT");
  466. hotkeyPress("ARROW_UP");
  467. hotkeyPress("ARROW_UP");
  468. hotkeyPress("ARROW_DOWN");
  469. expect(h.elements[0].x).toBe(9);
  470. expect(h.elements[0].y).toBe(9);
  471. });
  472. it("undo/redo drawing an element", () => {
  473. clickTool("rectangle");
  474. mouse.down(10, -10);
  475. mouse.up(20, 10);
  476. clickTool("rectangle");
  477. mouse.down(10, 0);
  478. mouse.up(30, 20);
  479. clickTool("arrow");
  480. mouse.click(60, -10);
  481. mouse.click(60, 10);
  482. mouse.click(40, 10);
  483. hotkeyPress("ENTER");
  484. expect(h.elements.filter((element) => !element.isDeleted).length).toBe(3);
  485. withModifierKeys({ ctrl: true }, () => {
  486. keyPress("z");
  487. keyPress("z");
  488. });
  489. expect(h.elements.filter((element) => !element.isDeleted).length).toBe(2);
  490. withModifierKeys({ ctrl: true }, () => {
  491. keyPress("z");
  492. });
  493. expect(h.elements.filter((element) => !element.isDeleted).length).toBe(1);
  494. withModifierKeys({ ctrl: true, shift: true }, () => {
  495. keyPress("z");
  496. });
  497. expect(h.elements.filter((element) => !element.isDeleted).length).toBe(2);
  498. });
  499. it("noop interaction after undo shouldn't create history entry", () => {
  500. // NOTE: this will fail if this test case is run in isolation. There's
  501. // some leaking state or race conditions in initialization/teardown
  502. // (couldn't figure out)
  503. expect(getStateHistory().length).toBe(0);
  504. clickTool("rectangle");
  505. mouse.down(10, 10);
  506. mouse.up(10, 10);
  507. const firstElementEndPoint = mouse.getPosition();
  508. clickTool("rectangle");
  509. mouse.down(10, -10);
  510. mouse.up(10, 10);
  511. const secondElementEndPoint = mouse.getPosition();
  512. expect(getStateHistory().length).toBe(2);
  513. withModifierKeys({ ctrl: true }, () => {
  514. keyPress("z");
  515. });
  516. expect(getStateHistory().length).toBe(1);
  517. // clicking an element shouldn't add to history
  518. mouse.restorePosition(...firstElementEndPoint);
  519. mouse.click();
  520. expect(getStateHistory().length).toBe(1);
  521. withModifierKeys({ shift: true, ctrl: true }, () => {
  522. keyPress("z");
  523. });
  524. expect(getStateHistory().length).toBe(2);
  525. // clicking an element shouldn't add to history
  526. mouse.click();
  527. expect(getStateHistory().length).toBe(2);
  528. const firstSelectedElementId = getSelectedElement().id;
  529. // same for clicking the element just redo-ed
  530. mouse.restorePosition(...secondElementEndPoint);
  531. mouse.click();
  532. expect(getStateHistory().length).toBe(2);
  533. expect(getSelectedElement().id).not.toEqual(firstSelectedElementId);
  534. });
  535. it("zoom hotkeys", () => {
  536. expect(h.state.zoom).toBe(1);
  537. fireEvent.keyDown(document, { code: "Equal", ctrlKey: true });
  538. fireEvent.keyUp(document, { code: "Equal", ctrlKey: true });
  539. expect(h.state.zoom).toBeGreaterThan(1);
  540. fireEvent.keyDown(document, { code: "Minus", ctrlKey: true });
  541. fireEvent.keyUp(document, { code: "Minus", ctrlKey: true });
  542. expect(h.state.zoom).toBe(1);
  543. });
  544. it("rerenders UI on language change", async () => {
  545. // select rectangle tool to show properties menu
  546. clickTool("rectangle");
  547. // english lang should display `hachure` label
  548. expect(screen.queryByText(/hachure/i)).not.toBeNull();
  549. fireEvent.change(document.querySelector(".dropdown-select__language")!, {
  550. target: { value: "de-DE" },
  551. });
  552. // switching to german, `hachure` label should no longer exist
  553. await waitFor(() => expect(screen.queryByText(/hachure/i)).toBeNull());
  554. // reset language
  555. fireEvent.change(document.querySelector(".dropdown-select__language")!, {
  556. target: { value: "en" },
  557. });
  558. // switching back to English
  559. await waitFor(() => expect(screen.queryByText(/hachure/i)).not.toBeNull());
  560. });
  561. it("make a group and duplicate it", () => {
  562. clickTool("rectangle");
  563. mouse.down(10, 10);
  564. mouse.up(10, 10);
  565. clickTool("rectangle");
  566. mouse.down(10, -10);
  567. mouse.up(10, 10);
  568. clickTool("rectangle");
  569. mouse.down(10, -10);
  570. mouse.up(10, 10);
  571. const end = mouse.getPosition();
  572. mouse.reset();
  573. mouse.down();
  574. mouse.restorePosition(...end);
  575. mouse.up();
  576. expect(h.elements.length).toBe(3);
  577. for (const element of h.elements) {
  578. expect(element.groupIds.length).toBe(0);
  579. expect(h.state.selectedElementIds[element.id]).toBe(true);
  580. }
  581. withModifierKeys({ ctrl: true }, () => {
  582. keyPress("g");
  583. });
  584. for (const element of h.elements) {
  585. expect(element.groupIds.length).toBe(1);
  586. }
  587. withModifierKeys({ alt: true }, () => {
  588. mouse.restorePosition(...end);
  589. mouse.down();
  590. mouse.up(10, 10);
  591. });
  592. expect(h.elements.length).toBe(6);
  593. const groups = new Set();
  594. for (const element of h.elements) {
  595. for (const groupId of element.groupIds) {
  596. groups.add(groupId);
  597. }
  598. }
  599. expect(groups.size).toBe(2);
  600. });
  601. it("double click to edit a group", () => {
  602. clickTool("rectangle");
  603. mouse.down(10, 10);
  604. mouse.up(10, 10);
  605. clickTool("rectangle");
  606. mouse.down(10, -10);
  607. mouse.up(10, 10);
  608. clickTool("rectangle");
  609. mouse.down(10, -10);
  610. mouse.up(10, 10);
  611. withModifierKeys({ ctrl: true }, () => {
  612. keyPress("a");
  613. keyPress("g");
  614. });
  615. expect(getSelectedElements().length).toBe(3);
  616. expect(h.state.editingGroupId).toBe(null);
  617. mouse.doubleClick();
  618. expect(getSelectedElements().length).toBe(1);
  619. expect(h.state.editingGroupId).not.toBe(null);
  620. });
  621. it("adjusts z order when grouping", () => {
  622. const positions = [];
  623. clickTool("rectangle");
  624. mouse.down(10, 10);
  625. mouse.up(10, 10);
  626. positions.push(mouse.getPosition());
  627. clickTool("rectangle");
  628. mouse.down(10, -10);
  629. mouse.up(10, 10);
  630. positions.push(mouse.getPosition());
  631. clickTool("rectangle");
  632. mouse.down(10, -10);
  633. mouse.up(10, 10);
  634. positions.push(mouse.getPosition());
  635. const ids = h.elements.map((element) => element.id);
  636. mouse.restorePosition(...positions[0]);
  637. mouse.click();
  638. mouse.restorePosition(...positions[2]);
  639. withModifierKeys({ shift: true }, () => {
  640. mouse.click();
  641. });
  642. withModifierKeys({ ctrl: true }, () => {
  643. keyPress("g");
  644. });
  645. expect(h.elements.map((element) => element.id)).toEqual([
  646. ids[1],
  647. ids[0],
  648. ids[2],
  649. ]);
  650. });
  651. it("supports nested groups", () => {
  652. const positions: number[][] = [];
  653. clickTool("rectangle");
  654. mouse.down(10, 10);
  655. mouse.up(10, 10);
  656. positions.push(mouse.getPosition());
  657. clickTool("rectangle");
  658. mouse.down(10, -10);
  659. mouse.up(10, 10);
  660. positions.push(mouse.getPosition());
  661. clickTool("rectangle");
  662. mouse.down(10, -10);
  663. mouse.up(10, 10);
  664. positions.push(mouse.getPosition());
  665. withModifierKeys({ ctrl: true }, () => {
  666. keyPress("a");
  667. keyPress("g");
  668. });
  669. mouse.doubleClick();
  670. withModifierKeys({ shift: true }, () => {
  671. mouse.restorePosition(...positions[0]);
  672. mouse.click();
  673. });
  674. withModifierKeys({ ctrl: true }, () => {
  675. keyPress("g");
  676. });
  677. const groupIds = h.elements[2].groupIds;
  678. expect(groupIds.length).toBe(2);
  679. expect(h.elements[1].groupIds).toEqual(groupIds);
  680. expect(h.elements[0].groupIds).toEqual(groupIds.slice(1));
  681. mouse.click(50, 50);
  682. expect(getSelectedElements().length).toBe(0);
  683. mouse.restorePosition(...positions[0]);
  684. mouse.click();
  685. expect(getSelectedElements().length).toBe(3);
  686. expect(h.state.editingGroupId).toBe(null);
  687. mouse.doubleClick();
  688. expect(getSelectedElements().length).toBe(2);
  689. expect(h.state.editingGroupId).toBe(groupIds[1]);
  690. mouse.doubleClick();
  691. expect(getSelectedElements().length).toBe(1);
  692. expect(h.state.editingGroupId).toBe(groupIds[0]);
  693. // click out of the group
  694. mouse.restorePosition(...positions[1]);
  695. mouse.click();
  696. expect(getSelectedElements().length).toBe(0);
  697. mouse.click();
  698. expect(getSelectedElements().length).toBe(3);
  699. mouse.doubleClick();
  700. expect(getSelectedElements().length).toBe(1);
  701. });
  702. it("updates fontSize & fontFamily appState", () => {
  703. clickTool("text");
  704. expect(h.state.currentItemFontFamily).toEqual(1); // Virgil
  705. fireEvent.click(screen.getByText(/code/i));
  706. expect(h.state.currentItemFontFamily).toEqual(3); // Cascadia
  707. });
  708. it("shows context menu for canvas", () => {
  709. fireEvent.contextMenu(canvas, { button: 2, clientX: 1, clientY: 1 });
  710. const contextMenu = document.querySelector(".context-menu");
  711. const options = contextMenu?.querySelectorAll(".context-menu-option");
  712. const expectedOptions = ["Select all", "Toggle grid mode"];
  713. expect(contextMenu).not.toBeNull();
  714. expect(options?.length).toBe(2);
  715. expect(options?.item(0).textContent).toBe(expectedOptions[0]);
  716. });
  717. it("shows context menu for element", () => {
  718. clickTool("rectangle");
  719. mouse.down(10, 10);
  720. mouse.up(20, 20);
  721. fireEvent.contextMenu(canvas, { button: 2, clientX: 1, clientY: 1 });
  722. const contextMenu = document.querySelector(".context-menu");
  723. const options = contextMenu?.querySelectorAll(".context-menu-option");
  724. const expectedOptions = [
  725. "Copy styles",
  726. "Paste styles",
  727. "Delete",
  728. "Add to library",
  729. "Send backward",
  730. "Bring forward",
  731. "Send to back",
  732. "Bring to front",
  733. "Duplicate",
  734. ];
  735. expect(contextMenu).not.toBeNull();
  736. expect(contextMenu?.children.length).toBe(9);
  737. options?.forEach((opt, i) => {
  738. expect(opt.textContent).toBe(expectedOptions[i]);
  739. });
  740. });
  741. it("shows 'Group selection' in context menu for multiple selected elements", () => {
  742. clickTool("rectangle");
  743. mouse.down(10, 10);
  744. mouse.up(10, 10);
  745. clickTool("rectangle");
  746. mouse.down(10, -10);
  747. mouse.up(10, 10);
  748. mouse.reset();
  749. mouse.click(10, 10);
  750. withModifierKeys({ shift: true }, () => {
  751. mouse.click(20, 0);
  752. });
  753. fireEvent.contextMenu(canvas, { button: 2, clientX: 1, clientY: 1 });
  754. const contextMenu = document.querySelector(".context-menu");
  755. const options = contextMenu?.querySelectorAll(".context-menu-option");
  756. const expectedOptions = [
  757. "Copy styles",
  758. "Paste styles",
  759. "Delete",
  760. "Group selection",
  761. "Add to library",
  762. "Send backward",
  763. "Bring forward",
  764. "Send to back",
  765. "Bring to front",
  766. "Duplicate",
  767. ];
  768. expect(contextMenu).not.toBeNull();
  769. expect(contextMenu?.children.length).toBe(10);
  770. options?.forEach((opt, i) => {
  771. expect(opt.textContent).toBe(expectedOptions[i]);
  772. });
  773. });
  774. it("shows 'Ungroup selection' in context menu for group inside selected elements", () => {
  775. clickTool("rectangle");
  776. mouse.down(10, 10);
  777. mouse.up(10, 10);
  778. clickTool("rectangle");
  779. mouse.down(10, -10);
  780. mouse.up(10, 10);
  781. mouse.reset();
  782. mouse.click(10, 10);
  783. withModifierKeys({ shift: true }, () => {
  784. mouse.click(20, 0);
  785. });
  786. withModifierKeys({ ctrl: true }, () => {
  787. keyPress("g");
  788. });
  789. fireEvent.contextMenu(canvas, { button: 2, clientX: 1, clientY: 1 });
  790. const contextMenu = document.querySelector(".context-menu");
  791. const options = contextMenu?.querySelectorAll(".context-menu-option");
  792. const expectedOptions = [
  793. "Copy styles",
  794. "Paste styles",
  795. "Delete",
  796. "Ungroup selection",
  797. "Add to library",
  798. "Send backward",
  799. "Bring forward",
  800. "Send to back",
  801. "Bring to front",
  802. "Duplicate",
  803. ];
  804. expect(contextMenu).not.toBeNull();
  805. expect(contextMenu?.children.length).toBe(10);
  806. options?.forEach((opt, i) => {
  807. expect(opt.textContent).toBe(expectedOptions[i]);
  808. });
  809. });
  810. it("selecting 'Copy styles' in context menu copies styles", () => {
  811. clickTool("rectangle");
  812. mouse.down(10, 10);
  813. mouse.up(20, 20);
  814. fireEvent.contextMenu(canvas, { button: 2, clientX: 1, clientY: 1 });
  815. const contextMenu = document.querySelector(".context-menu");
  816. expect(copiedStyles).toBe("{}");
  817. fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
  818. expect(copiedStyles).not.toBe("{}");
  819. const element = JSON.parse(copiedStyles);
  820. expect(element).toEqual(getSelectedElement());
  821. });
  822. it("selecting 'Paste styles' in context menu pastes styles", () => {
  823. clickTool("rectangle");
  824. mouse.down(10, 10);
  825. mouse.up(20, 20);
  826. clickTool("rectangle");
  827. mouse.down(10, 10);
  828. mouse.up(20, 20);
  829. // Change some styles of second rectangle
  830. clickLabeledElement("Stroke");
  831. clickLabeledElement("#c92a2a");
  832. clickLabeledElement("Background");
  833. clickLabeledElement("#e64980");
  834. // Fill style
  835. fireEvent.click(screen.getByLabelText("Cross-hatch"));
  836. // Stroke width
  837. fireEvent.click(screen.getByLabelText("Bold"));
  838. // Stroke style
  839. fireEvent.click(screen.getByLabelText("Dotted"));
  840. // Roughness
  841. fireEvent.click(screen.getByLabelText("Cartoonist"));
  842. // Opacity
  843. fireEvent.change(screen.getByLabelText("Opacity"), {
  844. target: { value: "60" },
  845. });
  846. mouse.reset();
  847. // Copy styles of second rectangle
  848. fireEvent.contextMenu(canvas, { button: 2, clientX: 40, clientY: 40 });
  849. let contextMenu = document.querySelector(".context-menu");
  850. fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
  851. const secondRect = JSON.parse(copiedStyles);
  852. expect(secondRect.id).toBe(h.elements[1].id);
  853. mouse.reset();
  854. // Paste styles to first rectangle
  855. fireEvent.contextMenu(canvas, { button: 2, clientX: 10, clientY: 10 });
  856. contextMenu = document.querySelector(".context-menu");
  857. fireEvent.click(queryByText(contextMenu as HTMLElement, "Paste styles")!);
  858. const firstRect = getSelectedElement();
  859. expect(firstRect.id).toBe(h.elements[0].id);
  860. expect(firstRect.strokeColor).toBe("#c92a2a");
  861. expect(firstRect.backgroundColor).toBe("#e64980");
  862. expect(firstRect.fillStyle).toBe("cross-hatch");
  863. expect(firstRect.strokeWidth).toBe(2); // Bold: 2
  864. expect(firstRect.strokeStyle).toBe("dotted");
  865. expect(firstRect.roughness).toBe(2); // Cartoonist: 2
  866. expect(firstRect.opacity).toBe(60);
  867. });
  868. it("selecting 'Delete' in context menu deletes element", () => {
  869. clickTool("rectangle");
  870. mouse.down(10, 10);
  871. mouse.up(20, 20);
  872. fireEvent.contextMenu(canvas, { button: 2, clientX: 1, clientY: 1 });
  873. const contextMenu = document.querySelector(".context-menu");
  874. fireEvent.click(queryByText(contextMenu as HTMLElement, "Delete")!);
  875. expect(getSelectedElements()).toHaveLength(0);
  876. expect(h.elements[0].isDeleted).toBe(true);
  877. });
  878. it("selecting 'Add to library' in context menu adds element to library", async () => {
  879. clickTool("rectangle");
  880. mouse.down(10, 10);
  881. mouse.up(20, 20);
  882. fireEvent.contextMenu(canvas, { button: 2, clientX: 1, clientY: 1 });
  883. const contextMenu = document.querySelector(".context-menu");
  884. fireEvent.click(queryByText(contextMenu as HTMLElement, "Add to library")!);
  885. await waitFor(() => {
  886. const library = localStorage.getItem("excalidraw-library");
  887. expect(library).not.toBeNull();
  888. const addedElement = JSON.parse(library!)[0][0];
  889. expect(addedElement).toEqual(h.elements[0]);
  890. });
  891. });
  892. it("selecting 'Duplicate' in context menu duplicates element", () => {
  893. clickTool("rectangle");
  894. mouse.down(10, 10);
  895. mouse.up(20, 20);
  896. fireEvent.contextMenu(canvas, { button: 2, clientX: 1, clientY: 1 });
  897. const contextMenu = document.querySelector(".context-menu");
  898. fireEvent.click(queryByText(contextMenu as HTMLElement, "Duplicate")!);
  899. expect(h.elements).toHaveLength(2);
  900. const { id: _id0, seed: _seed0, x: _x0, y: _y0, ...rect1 } = h.elements[0];
  901. const { id: _id1, seed: _seed1, x: _x1, y: _y1, ...rect2 } = h.elements[1];
  902. expect(rect1).toEqual(rect2);
  903. });
  904. it("selecting 'Send backward' in context menu sends element backward", () => {
  905. clickTool("rectangle");
  906. mouse.down(10, 10);
  907. mouse.up(20, 20);
  908. clickTool("rectangle");
  909. mouse.down(10, 10);
  910. mouse.up(20, 20);
  911. mouse.reset();
  912. fireEvent.contextMenu(canvas, { button: 2, clientX: 40, clientY: 40 });
  913. const contextMenu = document.querySelector(".context-menu");
  914. const elementsBefore = h.elements;
  915. fireEvent.click(queryByText(contextMenu as HTMLElement, "Send backward")!);
  916. expect(elementsBefore[0].id).toEqual(h.elements[1].id);
  917. expect(elementsBefore[1].id).toEqual(h.elements[0].id);
  918. });
  919. it("selecting 'Bring forward' in context menu brings element forward", () => {
  920. clickTool("rectangle");
  921. mouse.down(10, 10);
  922. mouse.up(20, 20);
  923. clickTool("rectangle");
  924. mouse.down(10, 10);
  925. mouse.up(20, 20);
  926. mouse.reset();
  927. fireEvent.contextMenu(canvas, { button: 2, clientX: 10, clientY: 10 });
  928. const contextMenu = document.querySelector(".context-menu");
  929. const elementsBefore = h.elements;
  930. fireEvent.click(queryByText(contextMenu as HTMLElement, "Bring forward")!);
  931. expect(elementsBefore[0].id).toEqual(h.elements[1].id);
  932. expect(elementsBefore[1].id).toEqual(h.elements[0].id);
  933. });
  934. it("selecting 'Send to back' in context menu sends element to back", () => {
  935. clickTool("rectangle");
  936. mouse.down(10, 10);
  937. mouse.up(20, 20);
  938. clickTool("rectangle");
  939. mouse.down(10, 10);
  940. mouse.up(20, 20);
  941. mouse.reset();
  942. fireEvent.contextMenu(canvas, { button: 2, clientX: 40, clientY: 40 });
  943. const contextMenu = document.querySelector(".context-menu");
  944. const elementsBefore = h.elements;
  945. fireEvent.click(queryByText(contextMenu as HTMLElement, "Send to back")!);
  946. expect(elementsBefore[1].id).toEqual(h.elements[0].id);
  947. });
  948. it("selecting 'Bring to front' in context menu brings element to front", () => {
  949. clickTool("rectangle");
  950. mouse.down(10, 10);
  951. mouse.up(20, 20);
  952. clickTool("rectangle");
  953. mouse.down(10, 10);
  954. mouse.up(20, 20);
  955. mouse.reset();
  956. fireEvent.contextMenu(canvas, { button: 2, clientX: 10, clientY: 10 });
  957. const contextMenu = document.querySelector(".context-menu");
  958. const elementsBefore = h.elements;
  959. fireEvent.click(queryByText(contextMenu as HTMLElement, "Bring to front")!);
  960. expect(elementsBefore[0].id).toEqual(h.elements[1].id);
  961. });
  962. it("selecting 'Group selection' in context menu groups selected elements", () => {
  963. clickTool("rectangle");
  964. mouse.down(10, 10);
  965. mouse.up(20, 20);
  966. clickTool("rectangle");
  967. mouse.down(10, 10);
  968. mouse.up(20, 20);
  969. mouse.reset();
  970. withModifierKeys({ shift: true }, () => {
  971. mouse.click(10, 10);
  972. });
  973. fireEvent.contextMenu(canvas, { button: 2, clientX: 1, clientY: 1 });
  974. const contextMenu = document.querySelector(".context-menu");
  975. fireEvent.click(
  976. queryByText(contextMenu as HTMLElement, "Group selection")!,
  977. );
  978. const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
  979. expect(h.elements[0].groupIds).toEqual(selectedGroupIds);
  980. expect(h.elements[1].groupIds).toEqual(selectedGroupIds);
  981. });
  982. it("selecting 'Ungroup selection' in context menu ungroups selected group", () => {
  983. clickTool("rectangle");
  984. mouse.down(10, 10);
  985. mouse.up(20, 20);
  986. clickTool("rectangle");
  987. mouse.down(10, 10);
  988. mouse.up(20, 20);
  989. mouse.reset();
  990. withModifierKeys({ shift: true }, () => {
  991. mouse.click(10, 10);
  992. });
  993. withModifierKeys({ ctrl: true }, () => {
  994. keyPress("g");
  995. });
  996. fireEvent.contextMenu(canvas, { button: 2, clientX: 1, clientY: 1 });
  997. const contextMenu = document.querySelector(".context-menu");
  998. fireEvent.click(
  999. queryByText(contextMenu as HTMLElement, "Ungroup selection")!,
  1000. );
  1001. const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
  1002. expect(selectedGroupIds).toHaveLength(0);
  1003. expect(h.elements[0].groupIds).toHaveLength(0);
  1004. expect(h.elements[1].groupIds).toHaveLength(0);
  1005. });
  1006. it("keeps selected element selected when click hits element bounding box but doesn't hit the element", () => {
  1007. clickTool("ellipse");
  1008. mouse.down(0, 0);
  1009. mouse.up(100, 100);
  1010. // click on bounding box but not on element
  1011. mouse.click(0, 0);
  1012. expect(getSelectedElements().length).toBe(1);
  1013. });
  1014. });