regressionTests.test.tsx 49 KB

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