regressionTests.test.tsx 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556
  1. import { queryAllByText, queryByText } from "@testing-library/react";
  2. import React from "react";
  3. import ReactDOM from "react-dom";
  4. import { copiedStyles } from "../actions/actionStyles";
  5. import { ShortcutName } from "../actions/shortcuts";
  6. import { ExcalidrawElement } from "../element/types";
  7. import { CODES, KEYS } from "../keys";
  8. import ExcalidrawApp from "../excalidraw-app";
  9. import { reseed } from "../random";
  10. import * as Renderer from "../renderer/renderScene";
  11. import { setDateTimeForTests } from "../utils";
  12. import { API } from "./helpers/api";
  13. import { Keyboard, Pointer, UI } from "./helpers/ui";
  14. import {
  15. fireEvent,
  16. GlobalTestState,
  17. render,
  18. screen,
  19. waitFor,
  20. } from "./test-utils";
  21. import { defaultLang } from "../i18n";
  22. const { h } = window;
  23. const renderScene = jest.spyOn(Renderer, "renderScene");
  24. const assertSelectedElements = (...elements: ExcalidrawElement[]) => {
  25. expect(
  26. API.getSelectedElements().map((element) => {
  27. return element.id;
  28. }),
  29. ).toEqual(expect.arrayContaining(elements.map((element) => element.id)));
  30. };
  31. const mouse = new Pointer("mouse");
  32. const finger1 = new Pointer("touch", 1);
  33. const finger2 = new Pointer("touch", 2);
  34. const clickLabeledElement = (label: string) => {
  35. const element = document.querySelector(`[aria-label='${label}']`);
  36. if (!element) {
  37. throw new Error(`No labeled element found: ${label}`);
  38. }
  39. fireEvent.click(element);
  40. };
  41. /**
  42. * This is always called at the end of your test, so usually you don't need to call it.
  43. * However, if you have a long test, you might want to call it during the test so it's easier
  44. * to debug where a test failure came from.
  45. */
  46. const checkpoint = (name: string) => {
  47. expect(renderScene.mock.calls.length).toMatchSnapshot(
  48. `[${name}] number of renders`,
  49. );
  50. expect(h.state).toMatchSnapshot(`[${name}] appState`);
  51. expect(h.history.getSnapshotForTest()).toMatchSnapshot(`[${name}] history`);
  52. expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
  53. h.elements.forEach((element, i) =>
  54. expect(element).toMatchSnapshot(`[${name}] element ${i}`),
  55. );
  56. };
  57. beforeEach(async () => {
  58. // Unmount ReactDOM from root
  59. ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
  60. localStorage.clear();
  61. renderScene.mockClear();
  62. h.history.clear();
  63. reseed(7);
  64. setDateTimeForTests("201933152653");
  65. mouse.reset();
  66. finger1.reset();
  67. finger2.reset();
  68. await render(<ExcalidrawApp />);
  69. });
  70. afterEach(() => {
  71. checkpoint("end of test");
  72. });
  73. describe("regression tests", () => {
  74. it("draw every type of shape", () => {
  75. UI.clickTool("rectangle");
  76. mouse.down(10, -10);
  77. mouse.up(20, 10);
  78. UI.clickTool("diamond");
  79. mouse.down(10, -10);
  80. mouse.up(20, 10);
  81. UI.clickTool("ellipse");
  82. mouse.down(10, -10);
  83. mouse.up(20, 10);
  84. UI.clickTool("arrow");
  85. mouse.down(40, -10);
  86. mouse.up(50, 10);
  87. UI.clickTool("line");
  88. mouse.down(40, -10);
  89. mouse.up(50, 10);
  90. UI.clickTool("arrow");
  91. mouse.click(40, -10);
  92. mouse.click(50, 10);
  93. mouse.click(30, 10);
  94. Keyboard.keyPress(KEYS.ENTER);
  95. UI.clickTool("line");
  96. mouse.click(40, -20);
  97. mouse.click(50, 10);
  98. mouse.click(30, 10);
  99. Keyboard.keyPress(KEYS.ENTER);
  100. UI.clickTool("draw");
  101. mouse.down(40, -20);
  102. mouse.up(50, 10);
  103. expect(h.elements.map((element) => element.type)).toEqual([
  104. "rectangle",
  105. "diamond",
  106. "ellipse",
  107. "arrow",
  108. "line",
  109. "arrow",
  110. "line",
  111. "draw",
  112. ]);
  113. });
  114. it("click to select a shape", () => {
  115. UI.clickTool("rectangle");
  116. mouse.down(10, 10);
  117. mouse.up(10, 10);
  118. const firstRectPos = mouse.getPosition();
  119. UI.clickTool("rectangle");
  120. mouse.down(10, -10);
  121. mouse.up(10, 10);
  122. const prevSelectedId = API.getSelectedElement().id;
  123. mouse.restorePosition(...firstRectPos);
  124. mouse.click();
  125. expect(API.getSelectedElement().id).not.toEqual(prevSelectedId);
  126. });
  127. for (const [keys, shape] of [
  128. [`2${KEYS.R}`, "rectangle"],
  129. [`3${KEYS.D}`, "diamond"],
  130. [`4${KEYS.E}`, "ellipse"],
  131. [`5${KEYS.A}`, "arrow"],
  132. [`6${KEYS.L}`, "line"],
  133. [`7${KEYS.X}`, "draw"],
  134. ] as [string, ExcalidrawElement["type"]][]) {
  135. for (const key of keys) {
  136. it(`key ${key} selects ${shape} tool`, () => {
  137. Keyboard.keyPress(key);
  138. mouse.down(10, 10);
  139. mouse.up(10, 10);
  140. expect(API.getSelectedElement().type).toBe(shape);
  141. });
  142. }
  143. }
  144. it("change the properties of a shape", () => {
  145. UI.clickTool("rectangle");
  146. mouse.down(10, 10);
  147. mouse.up(10, 10);
  148. clickLabeledElement("Background");
  149. clickLabeledElement("#fa5252");
  150. clickLabeledElement("Stroke");
  151. clickLabeledElement("#5f3dc4");
  152. expect(API.getSelectedElement().backgroundColor).toBe("#fa5252");
  153. expect(API.getSelectedElement().strokeColor).toBe("#5f3dc4");
  154. });
  155. it("click on an element and drag it", () => {
  156. UI.clickTool("rectangle");
  157. mouse.down(10, 10);
  158. mouse.up(10, 10);
  159. const { x: prevX, y: prevY } = API.getSelectedElement();
  160. mouse.down(-10, -10);
  161. mouse.up(10, 10);
  162. const { x: nextX, y: nextY } = API.getSelectedElement();
  163. expect(nextX).toBeGreaterThan(prevX);
  164. expect(nextY).toBeGreaterThan(prevY);
  165. checkpoint("dragged");
  166. mouse.down();
  167. mouse.up(-10, -10);
  168. const { x, y } = API.getSelectedElement();
  169. expect(x).toBe(prevX);
  170. expect(y).toBe(prevY);
  171. });
  172. it("alt-drag duplicates an element", () => {
  173. UI.clickTool("rectangle");
  174. mouse.down(10, 10);
  175. mouse.up(10, 10);
  176. expect(
  177. h.elements.filter((element) => element.type === "rectangle").length,
  178. ).toBe(1);
  179. Keyboard.withModifierKeys({ alt: true }, () => {
  180. mouse.down(-10, -10);
  181. mouse.up(10, 10);
  182. });
  183. expect(
  184. h.elements.filter((element) => element.type === "rectangle").length,
  185. ).toBe(2);
  186. });
  187. it("click-drag to select a group", () => {
  188. UI.clickTool("rectangle");
  189. mouse.down(10, 10);
  190. mouse.up(10, 10);
  191. UI.clickTool("rectangle");
  192. mouse.down(10, -10);
  193. mouse.up(10, 10);
  194. const finalPosition = mouse.getPosition();
  195. UI.clickTool("rectangle");
  196. mouse.down(10, -10);
  197. mouse.up(10, 10);
  198. mouse.restorePosition(0, 0);
  199. mouse.down();
  200. mouse.restorePosition(...finalPosition);
  201. mouse.up(5, 5);
  202. expect(
  203. h.elements.filter((element) => h.state.selectedElementIds[element.id])
  204. .length,
  205. ).toBe(2);
  206. });
  207. it("shift-click to multiselect, then drag", () => {
  208. UI.clickTool("rectangle");
  209. mouse.down(10, 10);
  210. mouse.up(10, 10);
  211. UI.clickTool("rectangle");
  212. mouse.down(10, -10);
  213. mouse.up(10, 10);
  214. const prevRectsXY = h.elements
  215. .filter((element) => element.type === "rectangle")
  216. .map((element) => ({ x: element.x, y: element.y }));
  217. mouse.reset();
  218. mouse.click(10, 10);
  219. Keyboard.withModifierKeys({ shift: true }, () => {
  220. mouse.click(20, 0);
  221. });
  222. mouse.down();
  223. mouse.up(10, 10);
  224. h.elements
  225. .filter((element) => element.type === "rectangle")
  226. .forEach((element, i) => {
  227. expect(element.x).toBeGreaterThan(prevRectsXY[i].x);
  228. expect(element.y).toBeGreaterThan(prevRectsXY[i].y);
  229. });
  230. });
  231. it("pinch-to-zoom works", () => {
  232. expect(h.state.zoom.value).toBe(1);
  233. finger1.down(50, 50);
  234. finger2.down(60, 50);
  235. finger1.move(-10, 0);
  236. expect(h.state.zoom.value).toBeGreaterThan(1);
  237. const zoomed = h.state.zoom.value;
  238. finger1.move(5, 0);
  239. finger2.move(-5, 0);
  240. expect(h.state.zoom.value).toBeLessThan(zoomed);
  241. });
  242. it("two-finger scroll works", () => {
  243. const startScrollY = h.state.scrollY;
  244. finger1.down(50, 50);
  245. finger2.down(60, 50);
  246. finger1.up(0, -10);
  247. finger2.up(0, -10);
  248. expect(h.state.scrollY).toBeLessThan(startScrollY);
  249. const startScrollX = h.state.scrollX;
  250. finger1.restorePosition(50, 50);
  251. finger2.restorePosition(50, 60);
  252. finger1.down();
  253. finger2.down();
  254. finger1.up(10, 0);
  255. finger2.up(10, 0);
  256. expect(h.state.scrollX).toBeGreaterThan(startScrollX);
  257. });
  258. it("spacebar + drag scrolls the canvas", () => {
  259. const { scrollX: startScrollX, scrollY: startScrollY } = h.state;
  260. Keyboard.keyDown(KEYS.SPACE);
  261. mouse.down(50, 50);
  262. mouse.up(60, 60);
  263. Keyboard.keyUp(KEYS.SPACE);
  264. const { scrollX, scrollY } = h.state;
  265. expect(scrollX).not.toEqual(startScrollX);
  266. expect(scrollY).not.toEqual(startScrollY);
  267. });
  268. it("arrow keys", () => {
  269. UI.clickTool("rectangle");
  270. mouse.down(10, 10);
  271. mouse.up(10, 10);
  272. Keyboard.keyPress(KEYS.ARROW_LEFT);
  273. Keyboard.keyPress(KEYS.ARROW_LEFT);
  274. Keyboard.keyPress(KEYS.ARROW_RIGHT);
  275. Keyboard.keyPress(KEYS.ARROW_UP);
  276. Keyboard.keyPress(KEYS.ARROW_UP);
  277. Keyboard.keyPress(KEYS.ARROW_DOWN);
  278. expect(h.elements[0].x).toBe(9);
  279. expect(h.elements[0].y).toBe(9);
  280. });
  281. it("undo/redo drawing an element", () => {
  282. UI.clickTool("rectangle");
  283. mouse.down(10, -10);
  284. mouse.up(20, 10);
  285. UI.clickTool("rectangle");
  286. mouse.down(10, 0);
  287. mouse.up(30, 20);
  288. UI.clickTool("arrow");
  289. mouse.click(60, -10);
  290. mouse.click(60, 10);
  291. mouse.click(40, 10);
  292. Keyboard.keyPress(KEYS.ENTER);
  293. expect(h.elements.filter((element) => !element.isDeleted).length).toBe(3);
  294. Keyboard.withModifierKeys({ ctrl: true }, () => {
  295. Keyboard.keyPress(KEYS.Z);
  296. Keyboard.keyPress(KEYS.Z);
  297. });
  298. expect(h.elements.filter((element) => !element.isDeleted).length).toBe(2);
  299. Keyboard.withModifierKeys({ ctrl: true }, () => {
  300. Keyboard.keyPress(KEYS.Z);
  301. });
  302. expect(h.elements.filter((element) => !element.isDeleted).length).toBe(1);
  303. Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
  304. Keyboard.keyPress(KEYS.Z);
  305. });
  306. expect(h.elements.filter((element) => !element.isDeleted).length).toBe(2);
  307. });
  308. it("noop interaction after undo shouldn't create history entry", () => {
  309. expect(API.getStateHistory().length).toBe(1);
  310. UI.clickTool("rectangle");
  311. mouse.down(10, 10);
  312. mouse.up(10, 10);
  313. const firstElementEndPoint = mouse.getPosition();
  314. UI.clickTool("rectangle");
  315. mouse.down(10, -10);
  316. mouse.up(10, 10);
  317. const secondElementEndPoint = mouse.getPosition();
  318. expect(API.getStateHistory().length).toBe(3);
  319. Keyboard.withModifierKeys({ ctrl: true }, () => {
  320. Keyboard.keyPress(KEYS.Z);
  321. });
  322. expect(API.getStateHistory().length).toBe(2);
  323. // clicking an element shouldn't add to history
  324. mouse.restorePosition(...firstElementEndPoint);
  325. mouse.click();
  326. expect(API.getStateHistory().length).toBe(2);
  327. Keyboard.withModifierKeys({ shift: true, ctrl: true }, () => {
  328. Keyboard.keyPress(KEYS.Z);
  329. });
  330. expect(API.getStateHistory().length).toBe(3);
  331. // clicking an element shouldn't add to history
  332. mouse.click();
  333. expect(API.getStateHistory().length).toBe(3);
  334. const firstSelectedElementId = API.getSelectedElement().id;
  335. // same for clicking the element just redo-ed
  336. mouse.restorePosition(...secondElementEndPoint);
  337. mouse.click();
  338. expect(API.getStateHistory().length).toBe(3);
  339. expect(API.getSelectedElement().id).not.toEqual(firstSelectedElementId);
  340. });
  341. it("zoom hotkeys", () => {
  342. expect(h.state.zoom.value).toBe(1);
  343. fireEvent.keyDown(document, { code: CODES.EQUAL, ctrlKey: true });
  344. fireEvent.keyUp(document, { code: CODES.EQUAL, ctrlKey: true });
  345. expect(h.state.zoom.value).toBeGreaterThan(1);
  346. fireEvent.keyDown(document, { code: CODES.MINUS, ctrlKey: true });
  347. fireEvent.keyUp(document, { code: CODES.MINUS, ctrlKey: true });
  348. expect(h.state.zoom.value).toBe(1);
  349. });
  350. it("rerenders UI on language change", async () => {
  351. // select rectangle tool to show properties menu
  352. UI.clickTool("rectangle");
  353. // english lang should display `thin` label
  354. expect(screen.queryByTitle(/thin/i)).not.toBeNull();
  355. fireEvent.change(document.querySelector(".dropdown-select__language")!, {
  356. target: { value: "de-DE" },
  357. });
  358. // switching to german, `thin` label should no longer exist
  359. await waitFor(() => expect(screen.queryByTitle(/thin/i)).toBeNull());
  360. // reset language
  361. fireEvent.change(document.querySelector(".dropdown-select__language")!, {
  362. target: { value: defaultLang.code },
  363. });
  364. // switching back to English
  365. await waitFor(() => expect(screen.queryByTitle(/thin/i)).not.toBeNull());
  366. });
  367. it("make a group and duplicate it", () => {
  368. UI.clickTool("rectangle");
  369. mouse.down(10, 10);
  370. mouse.up(10, 10);
  371. UI.clickTool("rectangle");
  372. mouse.down(10, -10);
  373. mouse.up(10, 10);
  374. UI.clickTool("rectangle");
  375. mouse.down(10, -10);
  376. mouse.up(10, 10);
  377. const end = mouse.getPosition();
  378. mouse.reset();
  379. mouse.down();
  380. mouse.restorePosition(...end);
  381. mouse.up();
  382. expect(h.elements.length).toBe(3);
  383. for (const element of h.elements) {
  384. expect(element.groupIds.length).toBe(0);
  385. expect(h.state.selectedElementIds[element.id]).toBe(true);
  386. }
  387. Keyboard.withModifierKeys({ ctrl: true }, () => {
  388. Keyboard.codePress(CODES.G);
  389. });
  390. for (const element of h.elements) {
  391. expect(element.groupIds.length).toBe(1);
  392. }
  393. Keyboard.withModifierKeys({ alt: true }, () => {
  394. mouse.restorePosition(...end);
  395. mouse.down();
  396. mouse.up(10, 10);
  397. });
  398. expect(h.elements.length).toBe(6);
  399. const groups = new Set();
  400. for (const element of h.elements) {
  401. for (const groupId of element.groupIds) {
  402. groups.add(groupId);
  403. }
  404. }
  405. expect(groups.size).toBe(2);
  406. });
  407. it("double click to edit a group", () => {
  408. UI.clickTool("rectangle");
  409. mouse.down(10, 10);
  410. mouse.up(10, 10);
  411. UI.clickTool("rectangle");
  412. mouse.down(10, -10);
  413. mouse.up(10, 10);
  414. UI.clickTool("rectangle");
  415. mouse.down(10, -10);
  416. mouse.up(10, 10);
  417. Keyboard.withModifierKeys({ ctrl: true }, () => {
  418. Keyboard.keyPress(KEYS.A);
  419. Keyboard.codePress(CODES.G);
  420. });
  421. expect(API.getSelectedElements().length).toBe(3);
  422. expect(h.state.editingGroupId).toBe(null);
  423. mouse.doubleClick();
  424. expect(API.getSelectedElements().length).toBe(1);
  425. expect(h.state.editingGroupId).not.toBe(null);
  426. });
  427. it("adjusts z order when grouping", () => {
  428. const positions = [];
  429. UI.clickTool("rectangle");
  430. mouse.down(10, 10);
  431. mouse.up(10, 10);
  432. positions.push(mouse.getPosition());
  433. UI.clickTool("rectangle");
  434. mouse.down(10, -10);
  435. mouse.up(10, 10);
  436. positions.push(mouse.getPosition());
  437. UI.clickTool("rectangle");
  438. mouse.down(10, -10);
  439. mouse.up(10, 10);
  440. positions.push(mouse.getPosition());
  441. const ids = h.elements.map((element) => element.id);
  442. mouse.restorePosition(...positions[0]);
  443. mouse.click();
  444. mouse.restorePosition(...positions[2]);
  445. Keyboard.withModifierKeys({ shift: true }, () => {
  446. mouse.click();
  447. });
  448. Keyboard.withModifierKeys({ ctrl: true }, () => {
  449. Keyboard.codePress(CODES.G);
  450. });
  451. expect(h.elements.map((element) => element.id)).toEqual([
  452. ids[1],
  453. ids[0],
  454. ids[2],
  455. ]);
  456. });
  457. it("supports nested groups", () => {
  458. const rectA = UI.createElement("rectangle", { position: 0, size: 50 });
  459. const rectB = UI.createElement("rectangle", { position: 100, size: 50 });
  460. const rectC = UI.createElement("rectangle", { position: 200, size: 50 });
  461. Keyboard.withModifierKeys({ ctrl: true }, () => {
  462. Keyboard.keyPress(KEYS.A);
  463. Keyboard.codePress(CODES.G);
  464. });
  465. mouse.doubleClickOn(rectC);
  466. Keyboard.withModifierKeys({ shift: true }, () => {
  467. mouse.clickOn(rectA);
  468. });
  469. Keyboard.withModifierKeys({ ctrl: true }, () => {
  470. Keyboard.codePress(CODES.G);
  471. });
  472. expect(rectC.groupIds.length).toBe(2);
  473. expect(rectA.groupIds).toEqual(rectC.groupIds);
  474. expect(rectB.groupIds).toEqual(rectA.groupIds.slice(1));
  475. mouse.click(0, 100);
  476. expect(API.getSelectedElements().length).toBe(0);
  477. mouse.clickOn(rectA);
  478. expect(API.getSelectedElements().length).toBe(3);
  479. expect(h.state.editingGroupId).toBe(null);
  480. mouse.doubleClickOn(rectA);
  481. expect(API.getSelectedElements().length).toBe(2);
  482. expect(h.state.editingGroupId).toBe(rectA.groupIds[1]);
  483. mouse.doubleClickOn(rectA);
  484. expect(API.getSelectedElements().length).toBe(1);
  485. expect(h.state.editingGroupId).toBe(rectA.groupIds[0]);
  486. // click outside current (sub)group
  487. mouse.clickOn(rectB);
  488. expect(API.getSelectedElements().length).toBe(3);
  489. mouse.doubleClickOn(rectB);
  490. expect(API.getSelectedElements().length).toBe(1);
  491. });
  492. it("updates fontSize & fontFamily appState", () => {
  493. UI.clickTool("text");
  494. expect(h.state.currentItemFontFamily).toEqual(1); // Virgil
  495. fireEvent.click(screen.getByText(/code/i));
  496. expect(h.state.currentItemFontFamily).toEqual(3); // Cascadia
  497. });
  498. it("shows context menu for canvas", () => {
  499. fireEvent.contextMenu(GlobalTestState.canvas, {
  500. button: 2,
  501. clientX: 1,
  502. clientY: 1,
  503. });
  504. const contextMenu = document.querySelector(".context-menu");
  505. const expectedShortcutNames: ShortcutName[] = [
  506. "selectAll",
  507. "gridMode",
  508. "stats",
  509. ];
  510. expect(contextMenu).not.toBeNull();
  511. expect(contextMenu?.children.length).toBe(expectedShortcutNames.length);
  512. expectedShortcutNames.forEach((shortcutName) => {
  513. expect(
  514. contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
  515. ).not.toBeNull();
  516. });
  517. });
  518. it("shows context menu for element", () => {
  519. UI.clickTool("rectangle");
  520. mouse.down(10, 10);
  521. mouse.up(20, 20);
  522. fireEvent.contextMenu(GlobalTestState.canvas, {
  523. button: 2,
  524. clientX: 1,
  525. clientY: 1,
  526. });
  527. const contextMenu = document.querySelector(".context-menu");
  528. const expectedShortcutNames: ShortcutName[] = [
  529. "cut",
  530. "copyStyles",
  531. "pasteStyles",
  532. "delete",
  533. "addToLibrary",
  534. "sendBackward",
  535. "bringForward",
  536. "sendToBack",
  537. "bringToFront",
  538. "duplicateSelection",
  539. ];
  540. expect(contextMenu).not.toBeNull();
  541. expect(contextMenu?.children.length).toBe(expectedShortcutNames.length);
  542. expectedShortcutNames.forEach((shortcutName) => {
  543. expect(
  544. contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
  545. ).not.toBeNull();
  546. });
  547. });
  548. it("shows 'Group selection' in context menu for multiple selected elements", () => {
  549. UI.clickTool("rectangle");
  550. mouse.down(10, 10);
  551. mouse.up(10, 10);
  552. UI.clickTool("rectangle");
  553. mouse.down(10, -10);
  554. mouse.up(10, 10);
  555. mouse.reset();
  556. mouse.click(10, 10);
  557. Keyboard.withModifierKeys({ shift: true }, () => {
  558. mouse.click(20, 0);
  559. });
  560. fireEvent.contextMenu(GlobalTestState.canvas, {
  561. button: 2,
  562. clientX: 1,
  563. clientY: 1,
  564. });
  565. const contextMenu = document.querySelector(".context-menu");
  566. const expectedShortcutNames: ShortcutName[] = [
  567. "cut",
  568. "copyStyles",
  569. "pasteStyles",
  570. "delete",
  571. "group",
  572. "addToLibrary",
  573. "sendBackward",
  574. "bringForward",
  575. "sendToBack",
  576. "bringToFront",
  577. "duplicateSelection",
  578. ];
  579. expect(contextMenu).not.toBeNull();
  580. expect(contextMenu?.children.length).toBe(expectedShortcutNames.length);
  581. expectedShortcutNames.forEach((shortcutName) => {
  582. expect(
  583. contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
  584. ).not.toBeNull();
  585. });
  586. });
  587. it("shows 'Ungroup selection' in context menu for group inside selected elements", () => {
  588. UI.clickTool("rectangle");
  589. mouse.down(10, 10);
  590. mouse.up(10, 10);
  591. UI.clickTool("rectangle");
  592. mouse.down(10, -10);
  593. mouse.up(10, 10);
  594. mouse.reset();
  595. mouse.click(10, 10);
  596. Keyboard.withModifierKeys({ shift: true }, () => {
  597. mouse.click(20, 0);
  598. });
  599. Keyboard.withModifierKeys({ ctrl: true }, () => {
  600. Keyboard.codePress(CODES.G);
  601. });
  602. fireEvent.contextMenu(GlobalTestState.canvas, {
  603. button: 2,
  604. clientX: 1,
  605. clientY: 1,
  606. });
  607. const contextMenu = document.querySelector(".context-menu");
  608. const expectedShortcutNames: ShortcutName[] = [
  609. "cut",
  610. "copyStyles",
  611. "pasteStyles",
  612. "delete",
  613. "ungroup",
  614. "addToLibrary",
  615. "sendBackward",
  616. "bringForward",
  617. "sendToBack",
  618. "bringToFront",
  619. "duplicateSelection",
  620. ];
  621. expect(contextMenu).not.toBeNull();
  622. expect(contextMenu?.children.length).toBe(expectedShortcutNames.length);
  623. expectedShortcutNames.forEach((shortcutName) => {
  624. expect(
  625. contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
  626. ).not.toBeNull();
  627. });
  628. });
  629. it("selecting 'Copy styles' in context menu copies styles", () => {
  630. UI.clickTool("rectangle");
  631. mouse.down(10, 10);
  632. mouse.up(20, 20);
  633. fireEvent.contextMenu(GlobalTestState.canvas, {
  634. button: 2,
  635. clientX: 1,
  636. clientY: 1,
  637. });
  638. const contextMenu = document.querySelector(".context-menu");
  639. expect(copiedStyles).toBe("{}");
  640. fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
  641. expect(copiedStyles).not.toBe("{}");
  642. const element = JSON.parse(copiedStyles);
  643. expect(element).toEqual(API.getSelectedElement());
  644. });
  645. it("selecting 'Paste styles' in context menu pastes styles", () => {
  646. UI.clickTool("rectangle");
  647. mouse.down(10, 10);
  648. mouse.up(20, 20);
  649. UI.clickTool("rectangle");
  650. mouse.down(10, 10);
  651. mouse.up(20, 20);
  652. // Change some styles of second rectangle
  653. clickLabeledElement("Stroke");
  654. clickLabeledElement("#c92a2a");
  655. clickLabeledElement("Background");
  656. clickLabeledElement("#e64980");
  657. // Fill style
  658. fireEvent.click(screen.getByTitle("Cross-hatch"));
  659. // Stroke width
  660. fireEvent.click(screen.getByTitle("Bold"));
  661. // Stroke style
  662. fireEvent.click(screen.getByTitle("Dotted"));
  663. // Roughness
  664. fireEvent.click(screen.getByTitle("Cartoonist"));
  665. // Opacity
  666. fireEvent.change(screen.getByLabelText("Opacity"), {
  667. target: { value: "60" },
  668. });
  669. mouse.reset();
  670. // Copy styles of second rectangle
  671. fireEvent.contextMenu(GlobalTestState.canvas, {
  672. button: 2,
  673. clientX: 40,
  674. clientY: 40,
  675. });
  676. let contextMenu = document.querySelector(".context-menu");
  677. fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
  678. const secondRect = JSON.parse(copiedStyles);
  679. expect(secondRect.id).toBe(h.elements[1].id);
  680. mouse.reset();
  681. // Paste styles to first rectangle
  682. fireEvent.contextMenu(GlobalTestState.canvas, {
  683. button: 2,
  684. clientX: 10,
  685. clientY: 10,
  686. });
  687. contextMenu = document.querySelector(".context-menu");
  688. fireEvent.click(queryByText(contextMenu as HTMLElement, "Paste styles")!);
  689. const firstRect = API.getSelectedElement();
  690. expect(firstRect.id).toBe(h.elements[0].id);
  691. expect(firstRect.strokeColor).toBe("#c92a2a");
  692. expect(firstRect.backgroundColor).toBe("#e64980");
  693. expect(firstRect.fillStyle).toBe("cross-hatch");
  694. expect(firstRect.strokeWidth).toBe(2); // Bold: 2
  695. expect(firstRect.strokeStyle).toBe("dotted");
  696. expect(firstRect.roughness).toBe(2); // Cartoonist: 2
  697. expect(firstRect.opacity).toBe(60);
  698. });
  699. it("selecting 'Delete' in context menu deletes element", () => {
  700. UI.clickTool("rectangle");
  701. mouse.down(10, 10);
  702. mouse.up(20, 20);
  703. fireEvent.contextMenu(GlobalTestState.canvas, {
  704. button: 2,
  705. clientX: 1,
  706. clientY: 1,
  707. });
  708. const contextMenu = document.querySelector(".context-menu");
  709. fireEvent.click(queryAllByText(contextMenu as HTMLElement, "Delete")[0]);
  710. expect(API.getSelectedElements()).toHaveLength(0);
  711. expect(h.elements[0].isDeleted).toBe(true);
  712. });
  713. it("selecting 'Add to library' in context menu adds element to library", async () => {
  714. UI.clickTool("rectangle");
  715. mouse.down(10, 10);
  716. mouse.up(20, 20);
  717. fireEvent.contextMenu(GlobalTestState.canvas, {
  718. button: 2,
  719. clientX: 1,
  720. clientY: 1,
  721. });
  722. const contextMenu = document.querySelector(".context-menu");
  723. fireEvent.click(queryByText(contextMenu as HTMLElement, "Add to library")!);
  724. await waitFor(() => {
  725. const library = localStorage.getItem("excalidraw-library");
  726. expect(library).not.toBeNull();
  727. const addedElement = JSON.parse(library!)[0][0];
  728. expect(addedElement).toEqual(h.elements[0]);
  729. });
  730. });
  731. it("selecting 'Duplicate' in context menu duplicates element", () => {
  732. UI.clickTool("rectangle");
  733. mouse.down(10, 10);
  734. mouse.up(20, 20);
  735. fireEvent.contextMenu(GlobalTestState.canvas, {
  736. button: 2,
  737. clientX: 1,
  738. clientY: 1,
  739. });
  740. const contextMenu = document.querySelector(".context-menu");
  741. fireEvent.click(queryByText(contextMenu as HTMLElement, "Duplicate")!);
  742. expect(h.elements).toHaveLength(2);
  743. const { id: _id0, seed: _seed0, x: _x0, y: _y0, ...rect1 } = h.elements[0];
  744. const { id: _id1, seed: _seed1, x: _x1, y: _y1, ...rect2 } = h.elements[1];
  745. expect(rect1).toEqual(rect2);
  746. });
  747. it("selecting 'Send backward' in context menu sends element backward", () => {
  748. UI.clickTool("rectangle");
  749. mouse.down(10, 10);
  750. mouse.up(20, 20);
  751. UI.clickTool("rectangle");
  752. mouse.down(10, 10);
  753. mouse.up(20, 20);
  754. mouse.reset();
  755. fireEvent.contextMenu(GlobalTestState.canvas, {
  756. button: 2,
  757. clientX: 40,
  758. clientY: 40,
  759. });
  760. const contextMenu = document.querySelector(".context-menu");
  761. const elementsBefore = h.elements;
  762. fireEvent.click(queryByText(contextMenu as HTMLElement, "Send backward")!);
  763. expect(elementsBefore[0].id).toEqual(h.elements[1].id);
  764. expect(elementsBefore[1].id).toEqual(h.elements[0].id);
  765. });
  766. it("selecting 'Bring forward' in context menu brings element forward", () => {
  767. UI.clickTool("rectangle");
  768. mouse.down(10, 10);
  769. mouse.up(20, 20);
  770. UI.clickTool("rectangle");
  771. mouse.down(10, 10);
  772. mouse.up(20, 20);
  773. mouse.reset();
  774. fireEvent.contextMenu(GlobalTestState.canvas, {
  775. button: 2,
  776. clientX: 10,
  777. clientY: 10,
  778. });
  779. const contextMenu = document.querySelector(".context-menu");
  780. const elementsBefore = h.elements;
  781. fireEvent.click(queryByText(contextMenu as HTMLElement, "Bring forward")!);
  782. expect(elementsBefore[0].id).toEqual(h.elements[1].id);
  783. expect(elementsBefore[1].id).toEqual(h.elements[0].id);
  784. });
  785. it("selecting 'Send to back' in context menu sends element to back", () => {
  786. UI.clickTool("rectangle");
  787. mouse.down(10, 10);
  788. mouse.up(20, 20);
  789. UI.clickTool("rectangle");
  790. mouse.down(10, 10);
  791. mouse.up(20, 20);
  792. mouse.reset();
  793. fireEvent.contextMenu(GlobalTestState.canvas, {
  794. button: 2,
  795. clientX: 40,
  796. clientY: 40,
  797. });
  798. const contextMenu = document.querySelector(".context-menu");
  799. const elementsBefore = h.elements;
  800. fireEvent.click(queryByText(contextMenu as HTMLElement, "Send to back")!);
  801. expect(elementsBefore[1].id).toEqual(h.elements[0].id);
  802. });
  803. it("selecting 'Bring to front' in context menu brings element to front", () => {
  804. UI.clickTool("rectangle");
  805. mouse.down(10, 10);
  806. mouse.up(20, 20);
  807. UI.clickTool("rectangle");
  808. mouse.down(10, 10);
  809. mouse.up(20, 20);
  810. mouse.reset();
  811. fireEvent.contextMenu(GlobalTestState.canvas, {
  812. button: 2,
  813. clientX: 10,
  814. clientY: 10,
  815. });
  816. const contextMenu = document.querySelector(".context-menu");
  817. const elementsBefore = h.elements;
  818. fireEvent.click(queryByText(contextMenu as HTMLElement, "Bring to front")!);
  819. expect(elementsBefore[0].id).toEqual(h.elements[1].id);
  820. });
  821. it("selecting 'Group selection' in context menu groups selected elements", () => {
  822. UI.clickTool("rectangle");
  823. mouse.down(10, 10);
  824. mouse.up(20, 20);
  825. UI.clickTool("rectangle");
  826. mouse.down(10, 10);
  827. mouse.up(20, 20);
  828. mouse.reset();
  829. Keyboard.withModifierKeys({ shift: true }, () => {
  830. mouse.click(10, 10);
  831. });
  832. fireEvent.contextMenu(GlobalTestState.canvas, {
  833. button: 2,
  834. clientX: 1,
  835. clientY: 1,
  836. });
  837. const contextMenu = document.querySelector(".context-menu");
  838. fireEvent.click(
  839. queryByText(contextMenu as HTMLElement, "Group selection")!,
  840. );
  841. const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
  842. expect(h.elements[0].groupIds).toEqual(selectedGroupIds);
  843. expect(h.elements[1].groupIds).toEqual(selectedGroupIds);
  844. });
  845. it("selecting 'Ungroup selection' in context menu ungroups selected group", () => {
  846. UI.clickTool("rectangle");
  847. mouse.down(10, 10);
  848. mouse.up(20, 20);
  849. UI.clickTool("rectangle");
  850. mouse.down(10, 10);
  851. mouse.up(20, 20);
  852. mouse.reset();
  853. Keyboard.withModifierKeys({ shift: true }, () => {
  854. mouse.click(10, 10);
  855. });
  856. Keyboard.withModifierKeys({ ctrl: true }, () => {
  857. Keyboard.codePress(CODES.G);
  858. });
  859. fireEvent.contextMenu(GlobalTestState.canvas, {
  860. button: 2,
  861. clientX: 1,
  862. clientY: 1,
  863. });
  864. const contextMenu = document.querySelector(".context-menu");
  865. fireEvent.click(
  866. queryByText(contextMenu as HTMLElement, "Ungroup selection")!,
  867. );
  868. const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
  869. expect(selectedGroupIds).toHaveLength(0);
  870. expect(h.elements[0].groupIds).toHaveLength(0);
  871. expect(h.elements[1].groupIds).toHaveLength(0);
  872. });
  873. it("deselects selected element, on pointer up, when click hits element bounding box but doesn't hit the element", () => {
  874. UI.clickTool("ellipse");
  875. mouse.down();
  876. mouse.up(100, 100);
  877. // hits bounding box without hitting element
  878. mouse.down();
  879. expect(API.getSelectedElements().length).toBe(1);
  880. mouse.up();
  881. expect(API.getSelectedElements().length).toBe(0);
  882. });
  883. it("switches selected element on pointer down", () => {
  884. UI.clickTool("rectangle");
  885. mouse.down();
  886. mouse.up(10, 10);
  887. UI.clickTool("ellipse");
  888. mouse.down(10, 10);
  889. mouse.up(10, 10);
  890. expect(API.getSelectedElement().type).toBe("ellipse");
  891. // pointer down on rectangle
  892. mouse.reset();
  893. mouse.down();
  894. expect(API.getSelectedElement().type).toBe("rectangle");
  895. });
  896. it("can drag element that covers another element, while another elem is selected", () => {
  897. UI.clickTool("rectangle");
  898. mouse.down(100, 100);
  899. mouse.up(200, 200);
  900. UI.clickTool("rectangle");
  901. mouse.reset();
  902. mouse.down(100, 100);
  903. mouse.up(200, 200);
  904. UI.clickTool("ellipse");
  905. mouse.reset();
  906. mouse.down(300, 300);
  907. mouse.up(350, 350);
  908. expect(API.getSelectedElement().type).toBe("ellipse");
  909. // pointer down on rectangle
  910. mouse.reset();
  911. mouse.down(100, 100);
  912. mouse.up(200, 200);
  913. expect(API.getSelectedElement().type).toBe("rectangle");
  914. });
  915. it("deselects selected element on pointer down when pointer doesn't hit any element", () => {
  916. UI.clickTool("rectangle");
  917. mouse.down();
  918. mouse.up(10, 10);
  919. expect(API.getSelectedElements().length).toBe(1);
  920. // pointer down on space without elements
  921. mouse.down(100, 100);
  922. expect(API.getSelectedElements().length).toBe(0);
  923. });
  924. it("Drags selected element when hitting only bounding box and keeps element selected", () => {
  925. UI.clickTool("ellipse");
  926. mouse.down();
  927. mouse.up(10, 10);
  928. const { x: prevX, y: prevY } = API.getSelectedElement();
  929. // drag element from point on bounding box that doesn't hit element
  930. mouse.reset();
  931. mouse.down();
  932. mouse.up(25, 25);
  933. expect(API.getSelectedElement().x).toEqual(prevX + 25);
  934. expect(API.getSelectedElement().y).toEqual(prevY + 25);
  935. });
  936. it(
  937. "given selected element A with lower z-index than unselected element B and given B is partially over A " +
  938. "when clicking intersection between A and B " +
  939. "B should be selected on pointer up",
  940. () => {
  941. UI.clickTool("rectangle");
  942. // change background color since default is transparent
  943. // and transparent elements can't be selected by clicking inside of them
  944. clickLabeledElement("Background");
  945. clickLabeledElement("#fa5252");
  946. mouse.down();
  947. mouse.up(1000, 1000);
  948. // draw ellipse partially over rectangle.
  949. // since ellipse was created after rectangle it has an higher z-index.
  950. // we don't need to change background color again since change above
  951. // affects next drawn elements.
  952. UI.clickTool("ellipse");
  953. mouse.reset();
  954. mouse.down(500, 500);
  955. mouse.up(1000, 1000);
  956. // select rectangle
  957. mouse.reset();
  958. mouse.click();
  959. // pointer down on intersection between ellipse and rectangle
  960. mouse.down(900, 900);
  961. expect(API.getSelectedElement().type).toBe("rectangle");
  962. mouse.up();
  963. expect(API.getSelectedElement().type).toBe("ellipse");
  964. },
  965. );
  966. it(
  967. "given selected element A with lower z-index than unselected element B and given B is partially over A " +
  968. "when dragging on intersection between A and B " +
  969. "A should be dragged and keep being selected",
  970. () => {
  971. UI.clickTool("rectangle");
  972. // change background color since default is transparent
  973. // and transparent elements can't be selected by clicking inside of them
  974. clickLabeledElement("Background");
  975. clickLabeledElement("#fa5252");
  976. mouse.down();
  977. mouse.up(1000, 1000);
  978. // draw ellipse partially over rectangle.
  979. // since ellipse was created after rectangle it has an higher z-index.
  980. // we don't need to change background color again since change above
  981. // affects next drawn elements.
  982. UI.clickTool("ellipse");
  983. mouse.reset();
  984. mouse.down(500, 500);
  985. mouse.up(1000, 1000);
  986. // select rectangle
  987. mouse.reset();
  988. mouse.click();
  989. const { x: prevX, y: prevY } = API.getSelectedElement();
  990. // pointer down on intersection between ellipse and rectangle
  991. mouse.down(900, 900);
  992. mouse.up(100, 100);
  993. expect(API.getSelectedElement().type).toBe("rectangle");
  994. expect(API.getSelectedElement().x).toEqual(prevX + 100);
  995. expect(API.getSelectedElement().y).toEqual(prevY + 100);
  996. },
  997. );
  998. it("deselects group of selected elements on pointer down when pointer doesn't hit any element", () => {
  999. UI.clickTool("rectangle");
  1000. mouse.down();
  1001. mouse.up(10, 10);
  1002. UI.clickTool("ellipse");
  1003. mouse.down(100, 100);
  1004. mouse.up(10, 10);
  1005. // Selects first element without deselecting the second element
  1006. // Second element is already selected because creating it was our last action
  1007. mouse.reset();
  1008. Keyboard.withModifierKeys({ shift: true }, () => {
  1009. mouse.click(5, 5);
  1010. });
  1011. expect(API.getSelectedElements().length).toBe(2);
  1012. // pointer down on space without elements
  1013. mouse.reset();
  1014. mouse.down(500, 500);
  1015. expect(API.getSelectedElements().length).toBe(0);
  1016. });
  1017. it("switches from group of selected elements to another element on pointer down", () => {
  1018. UI.clickTool("rectangle");
  1019. mouse.down();
  1020. mouse.up(10, 10);
  1021. UI.clickTool("ellipse");
  1022. mouse.down(100, 100);
  1023. mouse.up(100, 100);
  1024. UI.clickTool("diamond");
  1025. mouse.down(100, 100);
  1026. mouse.up(100, 100);
  1027. // Selects ellipse without deselecting the diamond
  1028. // Diamond is already selected because creating it was our last action
  1029. mouse.reset();
  1030. Keyboard.withModifierKeys({ shift: true }, () => {
  1031. mouse.click(110, 160);
  1032. });
  1033. expect(API.getSelectedElements().length).toBe(2);
  1034. // select rectangle
  1035. mouse.reset();
  1036. mouse.down();
  1037. expect(API.getSelectedElement().type).toBe("rectangle");
  1038. });
  1039. it("deselects group of selected elements on pointer up when pointer hits common bounding box without hitting any element", () => {
  1040. UI.clickTool("rectangle");
  1041. mouse.down();
  1042. mouse.up(10, 10);
  1043. UI.clickTool("ellipse");
  1044. mouse.down(100, 100);
  1045. mouse.up(10, 10);
  1046. // Selects first element without deselecting the second element
  1047. // Second element is already selected because creating it was our last action
  1048. mouse.reset();
  1049. Keyboard.withModifierKeys({ shift: true }, () => {
  1050. mouse.click(5, 5);
  1051. });
  1052. // pointer down on common bounding box without hitting any of the elements
  1053. mouse.reset();
  1054. mouse.down(50, 50);
  1055. expect(API.getSelectedElements().length).toBe(2);
  1056. mouse.up();
  1057. expect(API.getSelectedElements().length).toBe(0);
  1058. });
  1059. it(
  1060. "drags selected elements from point inside common bounding box that doesn't hit any element " +
  1061. "and keeps elements selected after dragging",
  1062. () => {
  1063. UI.clickTool("rectangle");
  1064. mouse.down();
  1065. mouse.up(10, 10);
  1066. UI.clickTool("ellipse");
  1067. mouse.down(100, 100);
  1068. mouse.up(10, 10);
  1069. // Selects first element without deselecting the second element
  1070. // Second element is already selected because creating it was our last action
  1071. mouse.reset();
  1072. Keyboard.withModifierKeys({ shift: true }, () => {
  1073. mouse.click(5, 5);
  1074. });
  1075. expect(API.getSelectedElements().length).toBe(2);
  1076. const {
  1077. x: firstElementPrevX,
  1078. y: firstElementPrevY,
  1079. } = API.getSelectedElements()[0];
  1080. const {
  1081. x: secondElementPrevX,
  1082. y: secondElementPrevY,
  1083. } = API.getSelectedElements()[1];
  1084. // drag elements from point on common bounding box that doesn't hit any of the elements
  1085. mouse.reset();
  1086. mouse.down(50, 50);
  1087. mouse.up(25, 25);
  1088. expect(API.getSelectedElements()[0].x).toEqual(firstElementPrevX + 25);
  1089. expect(API.getSelectedElements()[0].y).toEqual(firstElementPrevY + 25);
  1090. expect(API.getSelectedElements()[1].x).toEqual(secondElementPrevX + 25);
  1091. expect(API.getSelectedElements()[1].y).toEqual(secondElementPrevY + 25);
  1092. expect(API.getSelectedElements().length).toBe(2);
  1093. },
  1094. );
  1095. it(
  1096. "given a group of selected elements with an element that is not selected inside the group common bounding box " +
  1097. "when element that is not selected is clicked " +
  1098. "should switch selection to not selected element on pointer up",
  1099. () => {
  1100. UI.clickTool("rectangle");
  1101. mouse.down();
  1102. mouse.up(10, 10);
  1103. UI.clickTool("ellipse");
  1104. mouse.down(100, 100);
  1105. mouse.up(100, 100);
  1106. UI.clickTool("diamond");
  1107. mouse.down(100, 100);
  1108. mouse.up(100, 100);
  1109. // Selects rectangle without deselecting the diamond
  1110. // Diamond is already selected because creating it was our last action
  1111. mouse.reset();
  1112. Keyboard.withModifierKeys({ shift: true }, () => {
  1113. mouse.click();
  1114. });
  1115. // pointer down on ellipse
  1116. mouse.down(110, 160);
  1117. expect(API.getSelectedElements().length).toBe(2);
  1118. mouse.up();
  1119. expect(API.getSelectedElement().type).toBe("ellipse");
  1120. },
  1121. );
  1122. it(
  1123. "given a selected element A and a not selected element B with higher z-index than A " +
  1124. "and given B partialy overlaps A " +
  1125. "when there's a shift-click on the overlapped section B is added to the selection",
  1126. () => {
  1127. UI.clickTool("rectangle");
  1128. // change background color since default is transparent
  1129. // and transparent elements can't be selected by clicking inside of them
  1130. clickLabeledElement("Background");
  1131. clickLabeledElement("#fa5252");
  1132. mouse.down();
  1133. mouse.up(1000, 1000);
  1134. // draw ellipse partially over rectangle.
  1135. // since ellipse was created after rectangle it has an higher z-index.
  1136. // we don't need to change background color again since change above
  1137. // affects next drawn elements.
  1138. UI.clickTool("ellipse");
  1139. mouse.reset();
  1140. mouse.down(500, 500);
  1141. mouse.up(1000, 1000);
  1142. // select rectangle
  1143. mouse.reset();
  1144. mouse.click();
  1145. // click on intersection between ellipse and rectangle
  1146. Keyboard.withModifierKeys({ shift: true }, () => {
  1147. mouse.click(900, 900);
  1148. });
  1149. expect(API.getSelectedElements().length).toBe(2);
  1150. },
  1151. );
  1152. it("shift click on selected element should deselect it on pointer up", () => {
  1153. UI.clickTool("rectangle");
  1154. mouse.down();
  1155. mouse.up(10, 10);
  1156. // Rectangle is already selected since creating
  1157. // it was our last action
  1158. Keyboard.withModifierKeys({ shift: true }, () => {
  1159. mouse.down();
  1160. });
  1161. expect(API.getSelectedElements().length).toBe(1);
  1162. Keyboard.withModifierKeys({ shift: true }, () => {
  1163. mouse.up();
  1164. });
  1165. expect(API.getSelectedElements().length).toBe(0);
  1166. });
  1167. it("single-clicking on a subgroup of a selected group should not alter selection", () => {
  1168. const rect1 = UI.createElement("rectangle", { x: 10 });
  1169. const rect2 = UI.createElement("rectangle", { x: 50 });
  1170. UI.group([rect1, rect2]);
  1171. const rect3 = UI.createElement("rectangle", { x: 10, y: 50 });
  1172. const rect4 = UI.createElement("rectangle", { x: 50, y: 50 });
  1173. UI.group([rect3, rect4]);
  1174. Keyboard.withModifierKeys({ ctrl: true }, () => {
  1175. Keyboard.keyPress(KEYS.A);
  1176. Keyboard.codePress(CODES.G);
  1177. });
  1178. const selectedGroupIds_prev = h.state.selectedGroupIds;
  1179. const selectedElements_prev = API.getSelectedElements();
  1180. mouse.clickOn(rect3);
  1181. expect(h.state.selectedGroupIds).toEqual(selectedGroupIds_prev);
  1182. expect(API.getSelectedElements()).toEqual(selectedElements_prev);
  1183. });
  1184. it("Cmd/Ctrl-click exclusively select element under pointer", () => {
  1185. const rect1 = UI.createElement("rectangle", { x: 0 });
  1186. const rect2 = UI.createElement("rectangle", { x: 30 });
  1187. UI.group([rect1, rect2]);
  1188. assertSelectedElements(rect1, rect2);
  1189. Keyboard.withModifierKeys({ ctrl: true }, () => {
  1190. mouse.clickOn(rect1);
  1191. });
  1192. assertSelectedElements(rect1);
  1193. API.clearSelection();
  1194. Keyboard.withModifierKeys({ ctrl: true }, () => {
  1195. mouse.clickOn(rect1);
  1196. });
  1197. assertSelectedElements(rect1);
  1198. const rect3 = UI.createElement("rectangle", { x: 60 });
  1199. UI.group([rect1, rect3]);
  1200. assertSelectedElements(rect1, rect2, rect3);
  1201. Keyboard.withModifierKeys({ ctrl: true }, () => {
  1202. mouse.clickOn(rect1);
  1203. });
  1204. assertSelectedElements(rect1);
  1205. API.clearSelection();
  1206. Keyboard.withModifierKeys({ ctrl: true }, () => {
  1207. mouse.clickOn(rect3);
  1208. });
  1209. assertSelectedElements(rect3);
  1210. });
  1211. it("should show fill icons when element has non transparent background", () => {
  1212. UI.clickTool("rectangle");
  1213. expect(screen.queryByText(/fill/i)).not.toBeNull();
  1214. mouse.down();
  1215. mouse.up(10, 10);
  1216. expect(screen.queryByText(/fill/i)).toBeNull();
  1217. clickLabeledElement("Background");
  1218. clickLabeledElement("#fa5252");
  1219. // select rectangle
  1220. mouse.reset();
  1221. mouse.click();
  1222. expect(screen.queryByText(/fill/i)).not.toBeNull();
  1223. });
  1224. });
  1225. it(
  1226. "given element A and group of elements B and given both are selected " +
  1227. "when user clicks on B, on pointer up " +
  1228. "only elements from B should be selected",
  1229. () => {
  1230. const rect1 = UI.createElement("rectangle", { y: 0 });
  1231. const rect2 = UI.createElement("rectangle", { y: 30 });
  1232. const rect3 = UI.createElement("rectangle", { y: 60 });
  1233. UI.group([rect1, rect3]);
  1234. expect(API.getSelectedElements().length).toBe(2);
  1235. expect(Object.keys(h.state.selectedGroupIds).length).toBe(1);
  1236. // Select second rectangle without deselecting group
  1237. Keyboard.withModifierKeys({ shift: true }, () => {
  1238. mouse.clickOn(rect2);
  1239. });
  1240. expect(API.getSelectedElements().length).toBe(3);
  1241. // clicking on first rectangle that is part of the group should select
  1242. // that group (exclusively)
  1243. mouse.clickOn(rect1);
  1244. expect(API.getSelectedElements().length).toBe(2);
  1245. expect(Object.keys(h.state.selectedGroupIds).length).toBe(1);
  1246. },
  1247. );
  1248. it(
  1249. "given element A and group of elements B and given both are selected " +
  1250. "when user shift-clicks on B, on pointer up " +
  1251. "only element A should be selected",
  1252. () => {
  1253. UI.clickTool("rectangle");
  1254. mouse.down();
  1255. mouse.up(100, 100);
  1256. UI.clickTool("rectangle");
  1257. mouse.down(10, 10);
  1258. mouse.up(100, 100);
  1259. UI.clickTool("rectangle");
  1260. mouse.down(10, 10);
  1261. mouse.up(100, 100);
  1262. // Select first rectangle while keeping third one selected.
  1263. // Third rectangle is selected because it was the last element to be created.
  1264. mouse.reset();
  1265. Keyboard.withModifierKeys({ shift: true }, () => {
  1266. mouse.click();
  1267. });
  1268. // Create group with first and third rectangle
  1269. Keyboard.withModifierKeys({ ctrl: true }, () => {
  1270. Keyboard.codePress(CODES.G);
  1271. });
  1272. expect(API.getSelectedElements().length).toBe(2);
  1273. const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
  1274. expect(selectedGroupIds.length).toBe(1);
  1275. // Select second rectangle without deselecting group
  1276. Keyboard.withModifierKeys({ shift: true }, () => {
  1277. mouse.click(110, 110);
  1278. });
  1279. expect(API.getSelectedElements().length).toBe(3);
  1280. // Pointer down o first rectangle that is part of the group
  1281. mouse.reset();
  1282. Keyboard.withModifierKeys({ shift: true }, () => {
  1283. mouse.down();
  1284. });
  1285. expect(API.getSelectedElements().length).toBe(3);
  1286. Keyboard.withModifierKeys({ shift: true }, () => {
  1287. mouse.up();
  1288. });
  1289. expect(API.getSelectedElements().length).toBe(1);
  1290. },
  1291. );