regressionTests.test.tsx 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132
  1. import ReactDOM from "react-dom";
  2. import { ExcalidrawElement } from "../element/types";
  3. import { CODES, KEYS } from "../keys";
  4. import ExcalidrawApp from "../excalidraw-app";
  5. import { reseed } from "../random";
  6. import * as Renderer from "../renderer/renderScene";
  7. import { setDateTimeForTests } from "../utils";
  8. import { API } from "./helpers/api";
  9. import { Keyboard, Pointer, UI } from "./helpers/ui";
  10. import { fireEvent, render, screen, waitFor } from "./test-utils";
  11. import { defaultLang } from "../i18n";
  12. const { h } = window;
  13. const renderScene = jest.spyOn(Renderer, "renderScene");
  14. const assertSelectedElements = (...elements: ExcalidrawElement[]) => {
  15. expect(
  16. API.getSelectedElements().map((element) => {
  17. return element.id;
  18. }),
  19. ).toEqual(expect.arrayContaining(elements.map((element) => element.id)));
  20. };
  21. const mouse = new Pointer("mouse");
  22. const finger1 = new Pointer("touch", 1);
  23. const finger2 = new Pointer("touch", 2);
  24. const clickLabeledElement = (label: string) => {
  25. const element = document.querySelector(`[aria-label='${label}']`);
  26. if (!element) {
  27. throw new Error(`No labeled element found: ${label}`);
  28. }
  29. fireEvent.click(element);
  30. };
  31. /**
  32. * This is always called at the end of your test, so usually you don't need to call it.
  33. * However, if you have a long test, you might want to call it during the test so it's easier
  34. * to debug where a test failure came from.
  35. */
  36. const checkpoint = (name: string) => {
  37. expect(renderScene.mock.calls.length).toMatchSnapshot(
  38. `[${name}] number of renders`,
  39. );
  40. expect(h.state).toMatchSnapshot(`[${name}] appState`);
  41. expect(h.history.getSnapshotForTest()).toMatchSnapshot(`[${name}] history`);
  42. expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
  43. h.elements.forEach((element, i) =>
  44. expect(element).toMatchSnapshot(`[${name}] element ${i}`),
  45. );
  46. };
  47. beforeEach(async () => {
  48. // Unmount ReactDOM from root
  49. ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
  50. localStorage.clear();
  51. renderScene.mockClear();
  52. reseed(7);
  53. setDateTimeForTests("201933152653");
  54. mouse.reset();
  55. finger1.reset();
  56. finger2.reset();
  57. await render(<ExcalidrawApp />);
  58. h.setState({ height: 768, width: 1024 });
  59. });
  60. afterEach(() => {
  61. checkpoint("end of test");
  62. });
  63. describe("regression tests", () => {
  64. it("draw every type of shape", () => {
  65. UI.clickTool("rectangle");
  66. mouse.down(10, -10);
  67. mouse.up(20, 10);
  68. UI.clickTool("diamond");
  69. mouse.down(10, -10);
  70. mouse.up(20, 10);
  71. UI.clickTool("ellipse");
  72. mouse.down(10, -10);
  73. mouse.up(20, 10);
  74. UI.clickTool("arrow");
  75. mouse.down(40, -10);
  76. mouse.up(50, 10);
  77. UI.clickTool("line");
  78. mouse.down(40, -10);
  79. mouse.up(50, 10);
  80. UI.clickTool("arrow");
  81. mouse.click(40, -10);
  82. mouse.click(50, 10);
  83. mouse.click(30, 10);
  84. Keyboard.keyPress(KEYS.ENTER);
  85. UI.clickTool("line");
  86. mouse.click(40, -20);
  87. mouse.click(50, 10);
  88. mouse.click(30, 10);
  89. Keyboard.keyPress(KEYS.ENTER);
  90. UI.clickTool("freedraw");
  91. mouse.down(40, -20);
  92. mouse.up(50, 10);
  93. expect(h.elements.map((element) => element.type)).toEqual([
  94. "rectangle",
  95. "diamond",
  96. "ellipse",
  97. "arrow",
  98. "line",
  99. "arrow",
  100. "line",
  101. "freedraw",
  102. ]);
  103. });
  104. it("click to select a shape", () => {
  105. UI.clickTool("rectangle");
  106. mouse.down(10, 10);
  107. mouse.up(10, 10);
  108. const firstRectPos = mouse.getPosition();
  109. UI.clickTool("rectangle");
  110. mouse.down(10, -10);
  111. mouse.up(10, 10);
  112. const prevSelectedId = API.getSelectedElement().id;
  113. mouse.restorePosition(...firstRectPos);
  114. mouse.click();
  115. expect(API.getSelectedElement().id).not.toEqual(prevSelectedId);
  116. });
  117. for (const [keys, shape, shouldSelect] of [
  118. [`2${KEYS.R}`, "rectangle", true],
  119. [`3${KEYS.D}`, "diamond", true],
  120. [`4${KEYS.E}`, "ellipse", true],
  121. [`5${KEYS.A}`, "arrow", true],
  122. [`6${KEYS.L}`, "line", true],
  123. [`7${KEYS.X}`, "freedraw", false],
  124. ] as [string, ExcalidrawElement["type"], boolean][]) {
  125. for (const key of keys) {
  126. it(`key ${key} selects ${shape} tool`, () => {
  127. Keyboard.keyPress(key);
  128. expect(h.state.elementType).toBe(shape);
  129. mouse.down(10, 10);
  130. mouse.up(10, 10);
  131. if (shouldSelect) {
  132. expect(API.getSelectedElement().type).toBe(shape);
  133. }
  134. });
  135. }
  136. }
  137. it("change the properties of a shape", () => {
  138. UI.clickTool("rectangle");
  139. mouse.down(10, 10);
  140. mouse.up(10, 10);
  141. clickLabeledElement("Background");
  142. clickLabeledElement("#fa5252");
  143. clickLabeledElement("Stroke");
  144. clickLabeledElement("#5f3dc4");
  145. expect(API.getSelectedElement().backgroundColor).toBe("#fa5252");
  146. expect(API.getSelectedElement().strokeColor).toBe("#5f3dc4");
  147. });
  148. it("click on an element and drag it", () => {
  149. UI.clickTool("rectangle");
  150. mouse.down(10, 10);
  151. mouse.up(10, 10);
  152. const { x: prevX, y: prevY } = API.getSelectedElement();
  153. mouse.down(-10, -10);
  154. mouse.up(10, 10);
  155. const { x: nextX, y: nextY } = API.getSelectedElement();
  156. expect(nextX).toBeGreaterThan(prevX);
  157. expect(nextY).toBeGreaterThan(prevY);
  158. checkpoint("dragged");
  159. mouse.down();
  160. mouse.up(-10, -10);
  161. const { x, y } = API.getSelectedElement();
  162. expect(x).toBe(prevX);
  163. expect(y).toBe(prevY);
  164. });
  165. it("alt-drag duplicates an element", () => {
  166. UI.clickTool("rectangle");
  167. mouse.down(10, 10);
  168. mouse.up(10, 10);
  169. expect(
  170. h.elements.filter((element) => element.type === "rectangle").length,
  171. ).toBe(1);
  172. Keyboard.withModifierKeys({ alt: true }, () => {
  173. mouse.down(-10, -10);
  174. mouse.up(10, 10);
  175. });
  176. expect(
  177. h.elements.filter((element) => element.type === "rectangle").length,
  178. ).toBe(2);
  179. });
  180. it("click-drag to select a group", () => {
  181. UI.clickTool("rectangle");
  182. mouse.down(10, 10);
  183. mouse.up(10, 10);
  184. UI.clickTool("rectangle");
  185. mouse.down(10, -10);
  186. mouse.up(10, 10);
  187. const finalPosition = mouse.getPosition();
  188. UI.clickTool("rectangle");
  189. mouse.down(10, -10);
  190. mouse.up(10, 10);
  191. mouse.restorePosition(0, 0);
  192. mouse.down();
  193. mouse.restorePosition(...finalPosition);
  194. mouse.up(5, 5);
  195. expect(
  196. h.elements.filter((element) => h.state.selectedElementIds[element.id])
  197. .length,
  198. ).toBe(2);
  199. });
  200. it("shift-click to multiselect, then drag", () => {
  201. UI.clickTool("rectangle");
  202. mouse.down(10, 10);
  203. mouse.up(10, 10);
  204. UI.clickTool("rectangle");
  205. mouse.down(10, -10);
  206. mouse.up(10, 10);
  207. const prevRectsXY = h.elements
  208. .filter((element) => element.type === "rectangle")
  209. .map((element) => ({ x: element.x, y: element.y }));
  210. mouse.reset();
  211. mouse.click(10, 10);
  212. Keyboard.withModifierKeys({ shift: true }, () => {
  213. mouse.click(20, 0);
  214. });
  215. mouse.down();
  216. mouse.up(10, 10);
  217. h.elements
  218. .filter((element) => element.type === "rectangle")
  219. .forEach((element, i) => {
  220. expect(element.x).toBeGreaterThan(prevRectsXY[i].x);
  221. expect(element.y).toBeGreaterThan(prevRectsXY[i].y);
  222. });
  223. });
  224. it("pinch-to-zoom works", () => {
  225. expect(h.state.zoom.value).toBe(1);
  226. finger1.down(50, 50);
  227. finger2.down(60, 50);
  228. finger1.move(-10, 0);
  229. expect(h.state.zoom.value).toBeGreaterThan(1);
  230. const zoomed = h.state.zoom.value;
  231. finger1.move(5, 0);
  232. finger2.move(-5, 0);
  233. expect(h.state.zoom.value).toBeLessThan(zoomed);
  234. });
  235. it("two-finger scroll works", () => {
  236. const startScrollY = h.state.scrollY;
  237. finger1.down(50, 50);
  238. finger2.down(60, 50);
  239. finger1.up(0, -10);
  240. finger2.up(0, -10);
  241. expect(h.state.scrollY).toBeLessThan(startScrollY);
  242. const startScrollX = h.state.scrollX;
  243. finger1.restorePosition(50, 50);
  244. finger2.restorePosition(50, 60);
  245. finger1.down();
  246. finger2.down();
  247. finger1.up(10, 0);
  248. finger2.up(10, 0);
  249. expect(h.state.scrollX).toBeGreaterThan(startScrollX);
  250. });
  251. it("spacebar + drag scrolls the canvas", () => {
  252. const { scrollX: startScrollX, scrollY: startScrollY } = h.state;
  253. Keyboard.keyDown(KEYS.SPACE);
  254. mouse.down(50, 50);
  255. mouse.up(60, 60);
  256. Keyboard.keyUp(KEYS.SPACE);
  257. const { scrollX, scrollY } = h.state;
  258. expect(scrollX).not.toEqual(startScrollX);
  259. expect(scrollY).not.toEqual(startScrollY);
  260. });
  261. it("arrow keys", () => {
  262. UI.clickTool("rectangle");
  263. mouse.down(10, 10);
  264. mouse.up(10, 10);
  265. Keyboard.keyPress(KEYS.ARROW_LEFT);
  266. Keyboard.keyPress(KEYS.ARROW_LEFT);
  267. Keyboard.keyPress(KEYS.ARROW_RIGHT);
  268. Keyboard.keyPress(KEYS.ARROW_UP);
  269. Keyboard.keyPress(KEYS.ARROW_UP);
  270. Keyboard.keyPress(KEYS.ARROW_DOWN);
  271. expect(h.elements[0].x).toBe(9);
  272. expect(h.elements[0].y).toBe(9);
  273. });
  274. it("undo/redo drawing an element", () => {
  275. UI.clickTool("rectangle");
  276. mouse.down(10, -10);
  277. mouse.up(20, 10);
  278. UI.clickTool("rectangle");
  279. mouse.down(10, 0);
  280. mouse.up(30, 20);
  281. UI.clickTool("arrow");
  282. mouse.click(60, -10);
  283. mouse.click(60, 10);
  284. mouse.click(40, 10);
  285. Keyboard.keyPress(KEYS.ENTER);
  286. expect(h.elements.filter((element) => !element.isDeleted).length).toBe(3);
  287. Keyboard.withModifierKeys({ ctrl: true }, () => {
  288. Keyboard.keyPress(KEYS.Z);
  289. Keyboard.keyPress(KEYS.Z);
  290. });
  291. expect(h.elements.filter((element) => !element.isDeleted).length).toBe(2);
  292. Keyboard.withModifierKeys({ ctrl: true }, () => {
  293. Keyboard.keyPress(KEYS.Z);
  294. });
  295. expect(h.elements.filter((element) => !element.isDeleted).length).toBe(1);
  296. Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
  297. Keyboard.keyPress(KEYS.Z);
  298. });
  299. expect(h.elements.filter((element) => !element.isDeleted).length).toBe(2);
  300. });
  301. it("noop interaction after undo shouldn't create history entry", () => {
  302. expect(API.getStateHistory().length).toBe(1);
  303. UI.clickTool("rectangle");
  304. mouse.down(10, 10);
  305. mouse.up(10, 10);
  306. const firstElementEndPoint = mouse.getPosition();
  307. UI.clickTool("rectangle");
  308. mouse.down(10, -10);
  309. mouse.up(10, 10);
  310. const secondElementEndPoint = mouse.getPosition();
  311. expect(API.getStateHistory().length).toBe(3);
  312. Keyboard.withModifierKeys({ ctrl: true }, () => {
  313. Keyboard.keyPress(KEYS.Z);
  314. });
  315. expect(API.getStateHistory().length).toBe(2);
  316. // clicking an element shouldn't add to history
  317. mouse.restorePosition(...firstElementEndPoint);
  318. mouse.click();
  319. expect(API.getStateHistory().length).toBe(2);
  320. Keyboard.withModifierKeys({ shift: true, ctrl: true }, () => {
  321. Keyboard.keyPress(KEYS.Z);
  322. });
  323. expect(API.getStateHistory().length).toBe(3);
  324. // clicking an element shouldn't add to history
  325. mouse.click();
  326. expect(API.getStateHistory().length).toBe(3);
  327. const firstSelectedElementId = API.getSelectedElement().id;
  328. // same for clicking the element just redo-ed
  329. mouse.restorePosition(...secondElementEndPoint);
  330. mouse.click();
  331. expect(API.getStateHistory().length).toBe(3);
  332. expect(API.getSelectedElement().id).not.toEqual(firstSelectedElementId);
  333. });
  334. it("zoom hotkeys", () => {
  335. expect(h.state.zoom.value).toBe(1);
  336. fireEvent.keyDown(document, {
  337. code: CODES.EQUAL,
  338. ctrlKey: true,
  339. });
  340. fireEvent.keyUp(document, {
  341. code: CODES.EQUAL,
  342. ctrlKey: true,
  343. });
  344. expect(h.state.zoom.value).toBeGreaterThan(1);
  345. fireEvent.keyDown(document, {
  346. code: CODES.MINUS,
  347. ctrlKey: true,
  348. });
  349. fireEvent.keyUp(document, {
  350. code: CODES.MINUS,
  351. ctrlKey: true,
  352. });
  353. expect(h.state.zoom.value).toBe(1);
  354. });
  355. it("rerenders UI on language change", async () => {
  356. // select rectangle tool to show properties menu
  357. UI.clickTool("rectangle");
  358. // english lang should display `thin` label
  359. expect(screen.queryByTitle(/thin/i)).not.toBeNull();
  360. fireEvent.change(document.querySelector(".dropdown-select__language")!, {
  361. target: { value: "de-DE" },
  362. });
  363. // switching to german, `thin` label should no longer exist
  364. await waitFor(() => expect(screen.queryByTitle(/thin/i)).toBeNull());
  365. // reset language
  366. fireEvent.change(document.querySelector(".dropdown-select__language")!, {
  367. target: { value: defaultLang.code },
  368. });
  369. // switching back to English
  370. await waitFor(() => expect(screen.queryByTitle(/thin/i)).not.toBeNull());
  371. });
  372. it("make a group and duplicate it", () => {
  373. UI.clickTool("rectangle");
  374. mouse.down(10, 10);
  375. mouse.up(10, 10);
  376. UI.clickTool("rectangle");
  377. mouse.down(10, -10);
  378. mouse.up(10, 10);
  379. UI.clickTool("rectangle");
  380. mouse.down(10, -10);
  381. mouse.up(10, 10);
  382. const end = mouse.getPosition();
  383. mouse.reset();
  384. mouse.down();
  385. mouse.restorePosition(...end);
  386. mouse.up();
  387. expect(h.elements.length).toBe(3);
  388. for (const element of h.elements) {
  389. expect(element.groupIds.length).toBe(0);
  390. expect(h.state.selectedElementIds[element.id]).toBe(true);
  391. }
  392. Keyboard.withModifierKeys({ ctrl: true }, () => {
  393. Keyboard.codePress(CODES.G);
  394. });
  395. for (const element of h.elements) {
  396. expect(element.groupIds.length).toBe(1);
  397. }
  398. Keyboard.withModifierKeys({ alt: true }, () => {
  399. mouse.restorePosition(...end);
  400. mouse.down();
  401. mouse.up(10, 10);
  402. });
  403. expect(h.elements.length).toBe(6);
  404. const groups = new Set();
  405. for (const element of h.elements) {
  406. for (const groupId of element.groupIds) {
  407. groups.add(groupId);
  408. }
  409. }
  410. expect(groups.size).toBe(2);
  411. });
  412. it("double click to edit a group", () => {
  413. UI.clickTool("rectangle");
  414. mouse.down(10, 10);
  415. mouse.up(10, 10);
  416. UI.clickTool("rectangle");
  417. mouse.down(10, -10);
  418. mouse.up(10, 10);
  419. UI.clickTool("rectangle");
  420. mouse.down(10, -10);
  421. mouse.up(10, 10);
  422. Keyboard.withModifierKeys({ ctrl: true }, () => {
  423. Keyboard.keyPress(KEYS.A);
  424. Keyboard.codePress(CODES.G);
  425. });
  426. expect(API.getSelectedElements().length).toBe(3);
  427. expect(h.state.editingGroupId).toBe(null);
  428. mouse.doubleClick();
  429. expect(API.getSelectedElements().length).toBe(1);
  430. expect(h.state.editingGroupId).not.toBe(null);
  431. });
  432. it("adjusts z order when grouping", () => {
  433. const positions = [];
  434. UI.clickTool("rectangle");
  435. mouse.down(10, 10);
  436. mouse.up(10, 10);
  437. positions.push(mouse.getPosition());
  438. UI.clickTool("rectangle");
  439. mouse.down(10, -10);
  440. mouse.up(10, 10);
  441. positions.push(mouse.getPosition());
  442. UI.clickTool("rectangle");
  443. mouse.down(10, -10);
  444. mouse.up(10, 10);
  445. positions.push(mouse.getPosition());
  446. const ids = h.elements.map((element) => element.id);
  447. mouse.restorePosition(...positions[0]);
  448. mouse.click();
  449. mouse.restorePosition(...positions[2]);
  450. Keyboard.withModifierKeys({ shift: true }, () => {
  451. mouse.click();
  452. });
  453. Keyboard.withModifierKeys({ ctrl: true }, () => {
  454. Keyboard.codePress(CODES.G);
  455. });
  456. expect(h.elements.map((element) => element.id)).toEqual([
  457. ids[1],
  458. ids[0],
  459. ids[2],
  460. ]);
  461. });
  462. it("supports nested groups", () => {
  463. const rectA = UI.createElement("rectangle", { position: 0, size: 50 });
  464. const rectB = UI.createElement("rectangle", { position: 100, size: 50 });
  465. const rectC = UI.createElement("rectangle", { position: 200, size: 50 });
  466. Keyboard.withModifierKeys({ ctrl: true }, () => {
  467. Keyboard.keyPress(KEYS.A);
  468. Keyboard.codePress(CODES.G);
  469. });
  470. mouse.doubleClickOn(rectC);
  471. Keyboard.withModifierKeys({ shift: true }, () => {
  472. mouse.clickOn(rectA);
  473. });
  474. Keyboard.withModifierKeys({ ctrl: true }, () => {
  475. Keyboard.codePress(CODES.G);
  476. });
  477. expect(rectC.groupIds.length).toBe(2);
  478. expect(rectA.groupIds).toEqual(rectC.groupIds);
  479. expect(rectB.groupIds).toEqual(rectA.groupIds.slice(1));
  480. mouse.click(0, 100);
  481. expect(API.getSelectedElements().length).toBe(0);
  482. mouse.clickOn(rectA);
  483. expect(API.getSelectedElements().length).toBe(3);
  484. expect(h.state.editingGroupId).toBe(null);
  485. mouse.doubleClickOn(rectA);
  486. expect(API.getSelectedElements().length).toBe(2);
  487. expect(h.state.editingGroupId).toBe(rectA.groupIds[1]);
  488. mouse.doubleClickOn(rectA);
  489. expect(API.getSelectedElements().length).toBe(1);
  490. expect(h.state.editingGroupId).toBe(rectA.groupIds[0]);
  491. // click outside current (sub)group
  492. mouse.clickOn(rectB);
  493. expect(API.getSelectedElements().length).toBe(3);
  494. mouse.doubleClickOn(rectB);
  495. expect(API.getSelectedElements().length).toBe(1);
  496. });
  497. it("updates fontSize & fontFamily appState", () => {
  498. UI.clickTool("text");
  499. expect(h.state.currentItemFontFamily).toEqual(1); // Virgil
  500. fireEvent.click(screen.getByTitle(/code/i));
  501. expect(h.state.currentItemFontFamily).toEqual(3); // Cascadia
  502. });
  503. it("deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element", () => {
  504. UI.clickTool("ellipse");
  505. mouse.down();
  506. mouse.up(100, 100);
  507. // hits bounding box without hitting element
  508. mouse.down();
  509. expect(API.getSelectedElements().length).toBe(1);
  510. mouse.up();
  511. expect(API.getSelectedElements().length).toBe(0);
  512. });
  513. it("switches selected element on pointer down", () => {
  514. UI.clickTool("rectangle");
  515. mouse.down();
  516. mouse.up(10, 10);
  517. UI.clickTool("ellipse");
  518. mouse.down(10, 10);
  519. mouse.up(10, 10);
  520. expect(API.getSelectedElement().type).toBe("ellipse");
  521. // pointer down on rectangle
  522. mouse.reset();
  523. mouse.down();
  524. expect(API.getSelectedElement().type).toBe("rectangle");
  525. });
  526. it("can drag element that covers another element, while another elem is selected", () => {
  527. UI.clickTool("rectangle");
  528. mouse.down(100, 100);
  529. mouse.up(200, 200);
  530. UI.clickTool("rectangle");
  531. mouse.reset();
  532. mouse.down(100, 100);
  533. mouse.up(200, 200);
  534. UI.clickTool("ellipse");
  535. mouse.reset();
  536. mouse.down(300, 300);
  537. mouse.up(350, 350);
  538. expect(API.getSelectedElement().type).toBe("ellipse");
  539. // pointer down on rectangle
  540. mouse.reset();
  541. mouse.down(100, 100);
  542. mouse.up(200, 200);
  543. expect(API.getSelectedElement().type).toBe("rectangle");
  544. });
  545. it("deselects selected element on pointer down when pointer doesn't hit any element", () => {
  546. UI.clickTool("rectangle");
  547. mouse.down();
  548. mouse.up(10, 10);
  549. expect(API.getSelectedElements().length).toBe(1);
  550. // pointer down on space without elements
  551. mouse.down(100, 100);
  552. expect(API.getSelectedElements().length).toBe(0);
  553. });
  554. it("Drags selected element when hitting only bounding box and keeps element selected", () => {
  555. UI.clickTool("ellipse");
  556. mouse.down();
  557. mouse.up(10, 10);
  558. const { x: prevX, y: prevY } = API.getSelectedElement();
  559. // drag element from point on bounding box that doesn't hit element
  560. mouse.reset();
  561. mouse.down();
  562. mouse.up(25, 25);
  563. expect(API.getSelectedElement().x).toEqual(prevX + 25);
  564. expect(API.getSelectedElement().y).toEqual(prevY + 25);
  565. });
  566. it(
  567. "given selected element A with lower z-index than unselected element B and given B is partially over A " +
  568. "when clicking intersection between A and B " +
  569. "B should be selected on pointer up",
  570. () => {
  571. UI.clickTool("rectangle");
  572. // change background color since default is transparent
  573. // and transparent elements can't be selected by clicking inside of them
  574. clickLabeledElement("Background");
  575. clickLabeledElement("#fa5252");
  576. mouse.down();
  577. mouse.up(1000, 1000);
  578. // draw ellipse partially over rectangle.
  579. // since ellipse was created after rectangle it has an higher z-index.
  580. // we don't need to change background color again since change above
  581. // affects next drawn elements.
  582. UI.clickTool("ellipse");
  583. mouse.reset();
  584. mouse.down(500, 500);
  585. mouse.up(1000, 1000);
  586. // select rectangle
  587. mouse.reset();
  588. mouse.click();
  589. // pointer down on intersection between ellipse and rectangle
  590. mouse.down(900, 900);
  591. expect(API.getSelectedElement().type).toBe("rectangle");
  592. mouse.up();
  593. expect(API.getSelectedElement().type).toBe("ellipse");
  594. },
  595. );
  596. it(
  597. "given selected element A with lower z-index than unselected element B and given B is partially over A " +
  598. "when dragging on intersection between A and B " +
  599. "A should be dragged and keep being selected",
  600. () => {
  601. UI.clickTool("rectangle");
  602. // change background color since default is transparent
  603. // and transparent elements can't be selected by clicking inside of them
  604. clickLabeledElement("Background");
  605. clickLabeledElement("#fa5252");
  606. mouse.down();
  607. mouse.up(1000, 1000);
  608. // draw ellipse partially over rectangle.
  609. // since ellipse was created after rectangle it has an higher z-index.
  610. // we don't need to change background color again since change above
  611. // affects next drawn elements.
  612. UI.clickTool("ellipse");
  613. mouse.reset();
  614. mouse.down(500, 500);
  615. mouse.up(1000, 1000);
  616. // select rectangle
  617. mouse.reset();
  618. mouse.click();
  619. const { x: prevX, y: prevY } = API.getSelectedElement();
  620. // pointer down on intersection between ellipse and rectangle
  621. mouse.down(900, 900);
  622. mouse.up(100, 100);
  623. expect(API.getSelectedElement().type).toBe("rectangle");
  624. expect(API.getSelectedElement().x).toEqual(prevX + 100);
  625. expect(API.getSelectedElement().y).toEqual(prevY + 100);
  626. },
  627. );
  628. it("deselects group of selected elements on pointer down when pointer doesn't hit any element", () => {
  629. UI.clickTool("rectangle");
  630. mouse.down();
  631. mouse.up(10, 10);
  632. UI.clickTool("ellipse");
  633. mouse.down(100, 100);
  634. mouse.up(10, 10);
  635. // Selects first element without deselecting the second element
  636. // Second element is already selected because creating it was our last action
  637. mouse.reset();
  638. Keyboard.withModifierKeys({ shift: true }, () => {
  639. mouse.click(5, 5);
  640. });
  641. expect(API.getSelectedElements().length).toBe(2);
  642. // pointer down on space without elements
  643. mouse.reset();
  644. mouse.down(500, 500);
  645. expect(API.getSelectedElements().length).toBe(0);
  646. });
  647. it("switches from group of selected elements to another element on pointer down", () => {
  648. UI.clickTool("rectangle");
  649. mouse.down();
  650. mouse.up(10, 10);
  651. UI.clickTool("ellipse");
  652. mouse.down(100, 100);
  653. mouse.up(100, 100);
  654. UI.clickTool("diamond");
  655. mouse.down(100, 100);
  656. mouse.up(100, 100);
  657. // Selects ellipse without deselecting the diamond
  658. // Diamond is already selected because creating it was our last action
  659. mouse.reset();
  660. Keyboard.withModifierKeys({ shift: true }, () => {
  661. mouse.click(110, 160);
  662. });
  663. expect(API.getSelectedElements().length).toBe(2);
  664. // select rectangle
  665. mouse.reset();
  666. mouse.down();
  667. expect(API.getSelectedElement().type).toBe("rectangle");
  668. });
  669. it("deselects group of selected elements on pointer up when pointer hits common bounding box without hitting any element", () => {
  670. UI.clickTool("rectangle");
  671. mouse.down();
  672. mouse.up(10, 10);
  673. UI.clickTool("ellipse");
  674. mouse.down(100, 100);
  675. mouse.up(10, 10);
  676. // Selects first element without deselecting the second element
  677. // Second element is already selected because creating it was our last action
  678. mouse.reset();
  679. Keyboard.withModifierKeys({ shift: true }, () => {
  680. mouse.click(5, 5);
  681. });
  682. // pointer down on common bounding box without hitting any of the elements
  683. mouse.reset();
  684. mouse.down(50, 50);
  685. expect(API.getSelectedElements().length).toBe(2);
  686. mouse.up();
  687. expect(API.getSelectedElements().length).toBe(0);
  688. });
  689. it(
  690. "drags selected elements from point inside common bounding box that doesn't hit any element " +
  691. "and keeps elements selected after dragging",
  692. () => {
  693. UI.clickTool("rectangle");
  694. mouse.down();
  695. mouse.up(10, 10);
  696. UI.clickTool("ellipse");
  697. mouse.down(100, 100);
  698. mouse.up(10, 10);
  699. // Selects first element without deselecting the second element
  700. // Second element is already selected because creating it was our last action
  701. mouse.reset();
  702. Keyboard.withModifierKeys({ shift: true }, () => {
  703. mouse.click(5, 5);
  704. });
  705. expect(API.getSelectedElements().length).toBe(2);
  706. const {
  707. x: firstElementPrevX,
  708. y: firstElementPrevY,
  709. } = API.getSelectedElements()[0];
  710. const {
  711. x: secondElementPrevX,
  712. y: secondElementPrevY,
  713. } = API.getSelectedElements()[1];
  714. // drag elements from point on common bounding box that doesn't hit any of the elements
  715. mouse.reset();
  716. mouse.down(50, 50);
  717. mouse.up(25, 25);
  718. expect(API.getSelectedElements()[0].x).toEqual(firstElementPrevX + 25);
  719. expect(API.getSelectedElements()[0].y).toEqual(firstElementPrevY + 25);
  720. expect(API.getSelectedElements()[1].x).toEqual(secondElementPrevX + 25);
  721. expect(API.getSelectedElements()[1].y).toEqual(secondElementPrevY + 25);
  722. expect(API.getSelectedElements().length).toBe(2);
  723. },
  724. );
  725. it(
  726. "given a group of selected elements with an element that is not selected inside the group common bounding box " +
  727. "when element that is not selected is clicked " +
  728. "should switch selection to not selected element on pointer up",
  729. () => {
  730. UI.clickTool("rectangle");
  731. mouse.down();
  732. mouse.up(10, 10);
  733. UI.clickTool("ellipse");
  734. mouse.down(100, 100);
  735. mouse.up(100, 100);
  736. UI.clickTool("diamond");
  737. mouse.down(100, 100);
  738. mouse.up(100, 100);
  739. // Selects rectangle without deselecting the diamond
  740. // Diamond is already selected because creating it was our last action
  741. mouse.reset();
  742. Keyboard.withModifierKeys({ shift: true }, () => {
  743. mouse.click();
  744. });
  745. // pointer down on ellipse
  746. mouse.down(110, 160);
  747. expect(API.getSelectedElements().length).toBe(2);
  748. mouse.up();
  749. expect(API.getSelectedElement().type).toBe("ellipse");
  750. },
  751. );
  752. it(
  753. "given a selected element A and a not selected element B with higher z-index than A " +
  754. "and given B partialy overlaps A " +
  755. "when there's a shift-click on the overlapped section B is added to the selection",
  756. () => {
  757. UI.clickTool("rectangle");
  758. // change background color since default is transparent
  759. // and transparent elements can't be selected by clicking inside of them
  760. clickLabeledElement("Background");
  761. clickLabeledElement("#fa5252");
  762. mouse.down();
  763. mouse.up(1000, 1000);
  764. // draw ellipse partially over rectangle.
  765. // since ellipse was created after rectangle it has an higher z-index.
  766. // we don't need to change background color again since change above
  767. // affects next drawn elements.
  768. UI.clickTool("ellipse");
  769. mouse.reset();
  770. mouse.down(500, 500);
  771. mouse.up(1000, 1000);
  772. // select rectangle
  773. mouse.reset();
  774. mouse.click();
  775. // click on intersection between ellipse and rectangle
  776. Keyboard.withModifierKeys({ shift: true }, () => {
  777. mouse.click(900, 900);
  778. });
  779. expect(API.getSelectedElements().length).toBe(2);
  780. },
  781. );
  782. it("shift click on selected element should deselect it on pointer up", () => {
  783. UI.clickTool("rectangle");
  784. mouse.down();
  785. mouse.up(10, 10);
  786. // Rectangle is already selected since creating
  787. // it was our last action
  788. Keyboard.withModifierKeys({ shift: true }, () => {
  789. mouse.down();
  790. });
  791. expect(API.getSelectedElements().length).toBe(1);
  792. Keyboard.withModifierKeys({ shift: true }, () => {
  793. mouse.up();
  794. });
  795. expect(API.getSelectedElements().length).toBe(0);
  796. });
  797. it("single-clicking on a subgroup of a selected group should not alter selection", () => {
  798. const rect1 = UI.createElement("rectangle", { x: 10 });
  799. const rect2 = UI.createElement("rectangle", { x: 50 });
  800. UI.group([rect1, rect2]);
  801. const rect3 = UI.createElement("rectangle", { x: 10, y: 50 });
  802. const rect4 = UI.createElement("rectangle", { x: 50, y: 50 });
  803. UI.group([rect3, rect4]);
  804. Keyboard.withModifierKeys({ ctrl: true }, () => {
  805. Keyboard.keyPress(KEYS.A);
  806. Keyboard.codePress(CODES.G);
  807. });
  808. const selectedGroupIds_prev = h.state.selectedGroupIds;
  809. const selectedElements_prev = API.getSelectedElements();
  810. mouse.clickOn(rect3);
  811. expect(h.state.selectedGroupIds).toEqual(selectedGroupIds_prev);
  812. expect(API.getSelectedElements()).toEqual(selectedElements_prev);
  813. });
  814. it("Cmd/Ctrl-click exclusively select element under pointer", () => {
  815. const rect1 = UI.createElement("rectangle", { x: 0 });
  816. const rect2 = UI.createElement("rectangle", { x: 30 });
  817. UI.group([rect1, rect2]);
  818. assertSelectedElements(rect1, rect2);
  819. Keyboard.withModifierKeys({ ctrl: true }, () => {
  820. mouse.clickOn(rect1);
  821. });
  822. assertSelectedElements(rect1);
  823. API.clearSelection();
  824. Keyboard.withModifierKeys({ ctrl: true }, () => {
  825. mouse.clickOn(rect1);
  826. });
  827. assertSelectedElements(rect1);
  828. const rect3 = UI.createElement("rectangle", { x: 60 });
  829. UI.group([rect1, rect3]);
  830. assertSelectedElements(rect1, rect2, rect3);
  831. Keyboard.withModifierKeys({ ctrl: true }, () => {
  832. mouse.clickOn(rect1);
  833. });
  834. assertSelectedElements(rect1);
  835. API.clearSelection();
  836. Keyboard.withModifierKeys({ ctrl: true }, () => {
  837. mouse.clickOn(rect3);
  838. });
  839. assertSelectedElements(rect3);
  840. });
  841. it("should show fill icons when element has non transparent background", () => {
  842. UI.clickTool("rectangle");
  843. expect(screen.queryByText(/fill/i)).not.toBeNull();
  844. mouse.down();
  845. mouse.up(10, 10);
  846. expect(screen.queryByText(/fill/i)).toBeNull();
  847. clickLabeledElement("Background");
  848. clickLabeledElement("#fa5252");
  849. // select rectangle
  850. mouse.reset();
  851. mouse.click();
  852. expect(screen.queryByText(/fill/i)).not.toBeNull();
  853. });
  854. });
  855. it(
  856. "given element A and group of elements B and given both are selected " +
  857. "when user clicks on B, on pointer up " +
  858. "only elements from B should be selected",
  859. () => {
  860. const rect1 = UI.createElement("rectangle", { y: 0 });
  861. const rect2 = UI.createElement("rectangle", { y: 30 });
  862. const rect3 = UI.createElement("rectangle", { y: 60 });
  863. UI.group([rect1, rect3]);
  864. expect(API.getSelectedElements().length).toBe(2);
  865. expect(Object.keys(h.state.selectedGroupIds).length).toBe(1);
  866. // Select second rectangle without deselecting group
  867. Keyboard.withModifierKeys({ shift: true }, () => {
  868. mouse.clickOn(rect2);
  869. });
  870. expect(API.getSelectedElements().length).toBe(3);
  871. // clicking on first rectangle that is part of the group should select
  872. // that group (exclusively)
  873. mouse.clickOn(rect1);
  874. expect(API.getSelectedElements().length).toBe(2);
  875. expect(Object.keys(h.state.selectedGroupIds).length).toBe(1);
  876. },
  877. );
  878. it(
  879. "given element A and group of elements B and given both are selected " +
  880. "when user shift-clicks on B, on pointer up " +
  881. "only element A should be selected",
  882. () => {
  883. UI.clickTool("rectangle");
  884. mouse.down();
  885. mouse.up(100, 100);
  886. UI.clickTool("rectangle");
  887. mouse.down(10, 10);
  888. mouse.up(100, 100);
  889. UI.clickTool("rectangle");
  890. mouse.down(10, 10);
  891. mouse.up(100, 100);
  892. // Select first rectangle while keeping third one selected.
  893. // Third rectangle is selected because it was the last element to be created.
  894. mouse.reset();
  895. Keyboard.withModifierKeys({ shift: true }, () => {
  896. mouse.click();
  897. });
  898. // Create group with first and third rectangle
  899. Keyboard.withModifierKeys({ ctrl: true }, () => {
  900. Keyboard.codePress(CODES.G);
  901. });
  902. expect(API.getSelectedElements().length).toBe(2);
  903. const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
  904. expect(selectedGroupIds.length).toBe(1);
  905. // Select second rectangle without deselecting group
  906. Keyboard.withModifierKeys({ shift: true }, () => {
  907. mouse.click(110, 110);
  908. });
  909. expect(API.getSelectedElements().length).toBe(3);
  910. // Pointer down o first rectangle that is part of the group
  911. mouse.reset();
  912. Keyboard.withModifierKeys({ shift: true }, () => {
  913. mouse.down();
  914. });
  915. expect(API.getSelectedElements().length).toBe(3);
  916. Keyboard.withModifierKeys({ shift: true }, () => {
  917. mouse.up();
  918. });
  919. expect(API.getSelectedElements().length).toBe(1);
  920. },
  921. );