linearElementEditor.test.tsx 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180
  1. import ReactDOM from "react-dom";
  2. import {
  3. ExcalidrawElement,
  4. ExcalidrawLinearElement,
  5. ExcalidrawTextElementWithContainer,
  6. FontString,
  7. } from "../element/types";
  8. import ExcalidrawApp from "../excalidraw-app";
  9. import { centerPoint } from "../math";
  10. import { reseed } from "../random";
  11. import * as Renderer from "../renderer/renderScene";
  12. import { Keyboard, Pointer, UI } from "./helpers/ui";
  13. import { screen, render, fireEvent, GlobalTestState } from "./test-utils";
  14. import { API } from "../tests/helpers/api";
  15. import { Point } from "../types";
  16. import { KEYS } from "../keys";
  17. import { LinearElementEditor } from "../element/linearElementEditor";
  18. import { queryByTestId, queryByText } from "@testing-library/react";
  19. import { resize, rotate } from "./utils";
  20. import { getBoundTextElementPosition, wrapText } from "../element/textElement";
  21. import { getMaxContainerWidth } from "../element/newElement";
  22. import * as textElementUtils from "../element/textElement";
  23. import { ROUNDNESS } from "../constants";
  24. const renderScene = jest.spyOn(Renderer, "renderScene");
  25. const { h } = window;
  26. const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
  27. describe("Test Linear Elements", () => {
  28. let container: HTMLElement;
  29. let canvas: HTMLCanvasElement;
  30. beforeEach(async () => {
  31. // Unmount ReactDOM from root
  32. ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
  33. localStorage.clear();
  34. renderScene.mockClear();
  35. reseed(7);
  36. const comp = await render(<ExcalidrawApp />);
  37. container = comp.container;
  38. canvas = container.querySelector("canvas")!;
  39. canvas.width = 1000;
  40. canvas.height = 1000;
  41. });
  42. const p1: Point = [20, 20];
  43. const p2: Point = [60, 20];
  44. const midpoint = centerPoint(p1, p2);
  45. const delta = 50;
  46. const mouse = new Pointer("mouse");
  47. const createTwoPointerLinearElement = (
  48. type: ExcalidrawLinearElement["type"],
  49. roundness: ExcalidrawElement["roundness"] = null,
  50. roughness: ExcalidrawLinearElement["roughness"] = 0,
  51. ) => {
  52. const line = API.createElement({
  53. x: p1[0],
  54. y: p1[1],
  55. width: p2[0] - p1[0],
  56. height: 0,
  57. type,
  58. roughness,
  59. points: [
  60. [0, 0],
  61. [p2[0] - p1[0], p2[1] - p1[1]],
  62. ],
  63. roundness,
  64. });
  65. h.elements = [line];
  66. mouse.clickAt(p1[0], p1[1]);
  67. return line;
  68. };
  69. const createThreePointerLinearElement = (
  70. type: ExcalidrawLinearElement["type"],
  71. roundness: ExcalidrawElement["roundness"] = null,
  72. roughness: ExcalidrawLinearElement["roughness"] = 0,
  73. ) => {
  74. //dragging line from midpoint
  75. const p3 = [midpoint[0] + delta - p1[0], midpoint[1] + delta - p1[1]];
  76. const line = API.createElement({
  77. x: p1[0],
  78. y: p1[1],
  79. width: p3[0] - p1[0],
  80. height: 0,
  81. type,
  82. roughness,
  83. points: [
  84. [0, 0],
  85. [p3[0], p3[1]],
  86. [p2[0] - p1[0], p2[1] - p1[1]],
  87. ],
  88. roundness,
  89. });
  90. h.elements = [line];
  91. mouse.clickAt(p1[0], p1[1]);
  92. return line;
  93. };
  94. const enterLineEditingMode = (
  95. line: ExcalidrawLinearElement,
  96. selectProgrammatically = false,
  97. ) => {
  98. if (selectProgrammatically) {
  99. API.setSelectedElements([line]);
  100. } else {
  101. mouse.clickAt(p1[0], p1[1]);
  102. }
  103. Keyboard.withModifierKeys({ ctrl: true }, () => {
  104. Keyboard.keyPress(KEYS.ENTER);
  105. });
  106. expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
  107. };
  108. const drag = (startPoint: Point, endPoint: Point) => {
  109. fireEvent.pointerDown(canvas, {
  110. clientX: startPoint[0],
  111. clientY: startPoint[1],
  112. });
  113. fireEvent.pointerMove(canvas, {
  114. clientX: endPoint[0],
  115. clientY: endPoint[1],
  116. });
  117. fireEvent.pointerUp(canvas, {
  118. clientX: endPoint[0],
  119. clientY: endPoint[1],
  120. });
  121. };
  122. const deletePoint = (point: Point) => {
  123. fireEvent.pointerDown(canvas, {
  124. clientX: point[0],
  125. clientY: point[1],
  126. });
  127. fireEvent.pointerUp(canvas, {
  128. clientX: point[0],
  129. clientY: point[1],
  130. });
  131. Keyboard.keyPress(KEYS.DELETE);
  132. };
  133. it("should not drag line and add midpoint until dragged beyond a threshold", () => {
  134. createTwoPointerLinearElement("line");
  135. const line = h.elements[0] as ExcalidrawLinearElement;
  136. const originalX = line.x;
  137. const originalY = line.y;
  138. expect(line.points.length).toEqual(2);
  139. mouse.clickAt(midpoint[0], midpoint[1]);
  140. drag(midpoint, [midpoint[0] + 1, midpoint[1] + 1]);
  141. expect(line.points.length).toEqual(2);
  142. expect(line.x).toBe(originalX);
  143. expect(line.y).toBe(originalY);
  144. expect(line.points.length).toEqual(2);
  145. drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
  146. expect(line.x).toBe(originalX);
  147. expect(line.y).toBe(originalY);
  148. expect(line.points.length).toEqual(3);
  149. });
  150. it("should allow dragging line from midpoint in 2 pointer lines outside editor", async () => {
  151. createTwoPointerLinearElement("line");
  152. const line = h.elements[0] as ExcalidrawLinearElement;
  153. expect(renderScene).toHaveBeenCalledTimes(7);
  154. expect((h.elements[0] as ExcalidrawLinearElement).points.length).toEqual(2);
  155. // drag line from midpoint
  156. drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
  157. expect(renderScene).toHaveBeenCalledTimes(11);
  158. expect(line.points.length).toEqual(3);
  159. expect(line.points).toMatchInlineSnapshot(`
  160. Array [
  161. Array [
  162. 0,
  163. 0,
  164. ],
  165. Array [
  166. 70,
  167. 50,
  168. ],
  169. Array [
  170. 40,
  171. 0,
  172. ],
  173. ]
  174. `);
  175. });
  176. it("should allow entering and exiting line editor via context menu", () => {
  177. createTwoPointerLinearElement("line");
  178. fireEvent.contextMenu(GlobalTestState.canvas, {
  179. button: 2,
  180. clientX: midpoint[0],
  181. clientY: midpoint[1],
  182. });
  183. // Enter line editor
  184. let contextMenu = document.querySelector(".context-menu");
  185. fireEvent.contextMenu(GlobalTestState.canvas, {
  186. button: 2,
  187. clientX: midpoint[0],
  188. clientY: midpoint[1],
  189. });
  190. fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!);
  191. expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
  192. // Exiting line editor
  193. fireEvent.contextMenu(GlobalTestState.canvas, {
  194. button: 2,
  195. clientX: midpoint[0],
  196. clientY: midpoint[1],
  197. });
  198. contextMenu = document.querySelector(".context-menu");
  199. fireEvent.contextMenu(GlobalTestState.canvas, {
  200. button: 2,
  201. clientX: midpoint[0],
  202. clientY: midpoint[1],
  203. });
  204. fireEvent.click(
  205. queryByText(contextMenu as HTMLElement, "Exit line editor")!,
  206. );
  207. expect(h.state.editingLinearElement?.elementId).toBeUndefined();
  208. });
  209. it("should enter line editor when using double clicked with ctrl key", () => {
  210. createTwoPointerLinearElement("line");
  211. expect(h.state.editingLinearElement?.elementId).toBeUndefined();
  212. Keyboard.withModifierKeys({ ctrl: true }, () => {
  213. mouse.doubleClick();
  214. });
  215. expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
  216. });
  217. describe("Inside editor", () => {
  218. it("should not drag line and add midpoint when dragged irrespective of threshold", () => {
  219. createTwoPointerLinearElement("line");
  220. const line = h.elements[0] as ExcalidrawLinearElement;
  221. const originalX = line.x;
  222. const originalY = line.y;
  223. enterLineEditingMode(line);
  224. expect(line.points.length).toEqual(2);
  225. mouse.clickAt(midpoint[0], midpoint[1]);
  226. expect(line.points.length).toEqual(2);
  227. drag(midpoint, [midpoint[0] + 1, midpoint[1] + 1]);
  228. expect(line.x).toBe(originalX);
  229. expect(line.y).toBe(originalY);
  230. expect(line.points.length).toEqual(3);
  231. });
  232. it("should allow dragging line from midpoint in 2 pointer lines", async () => {
  233. createTwoPointerLinearElement("line");
  234. const line = h.elements[0] as ExcalidrawLinearElement;
  235. enterLineEditingMode(line);
  236. // drag line from midpoint
  237. drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
  238. expect(renderScene).toHaveBeenCalledTimes(15);
  239. expect(line.points.length).toEqual(3);
  240. expect(line.points).toMatchInlineSnapshot(`
  241. Array [
  242. Array [
  243. 0,
  244. 0,
  245. ],
  246. Array [
  247. 70,
  248. 50,
  249. ],
  250. Array [
  251. 40,
  252. 0,
  253. ],
  254. ]
  255. `);
  256. });
  257. it("should update the midpoints when element roundness changed", async () => {
  258. createThreePointerLinearElement("line");
  259. const line = h.elements[0] as ExcalidrawLinearElement;
  260. expect(line.points.length).toEqual(3);
  261. enterLineEditingMode(line);
  262. const midPointsWithSharpEdge = LinearElementEditor.getEditorMidPoints(
  263. line,
  264. h.state,
  265. );
  266. // update roundness
  267. fireEvent.click(screen.getByTitle("Round"));
  268. expect(renderScene).toHaveBeenCalledTimes(12);
  269. const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints(
  270. h.elements[0] as ExcalidrawLinearElement,
  271. h.state,
  272. );
  273. expect(midPointsWithRoundEdge[0]).not.toEqual(midPointsWithSharpEdge[0]);
  274. expect(midPointsWithRoundEdge[1]).not.toEqual(midPointsWithSharpEdge[1]);
  275. expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
  276. Array [
  277. Array [
  278. 55.9697848965255,
  279. 47.442326230998205,
  280. ],
  281. Array [
  282. 76.08587175006699,
  283. 43.294165939653226,
  284. ],
  285. ]
  286. `);
  287. });
  288. it("should update all the midpoints when element position changed", async () => {
  289. createThreePointerLinearElement("line", {
  290. type: ROUNDNESS.PROPORTIONAL_RADIUS,
  291. });
  292. const line = h.elements[0] as ExcalidrawLinearElement;
  293. expect(line.points.length).toEqual(3);
  294. enterLineEditingMode(line);
  295. const points = LinearElementEditor.getPointsGlobalCoordinates(line);
  296. expect([line.x, line.y]).toEqual(points[0]);
  297. const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state);
  298. const startPoint = centerPoint(points[0], midPoints[0] as Point);
  299. const deltaX = 50;
  300. const deltaY = 20;
  301. const endPoint: Point = [startPoint[0] + deltaX, startPoint[1] + deltaY];
  302. // Move the element
  303. drag(startPoint, endPoint);
  304. expect(renderScene).toHaveBeenCalledTimes(16);
  305. expect([line.x, line.y]).toEqual([
  306. points[0][0] + deltaX,
  307. points[0][1] + deltaY,
  308. ]);
  309. const newMidPoints = LinearElementEditor.getEditorMidPoints(
  310. line,
  311. h.state,
  312. );
  313. expect(midPoints[0]).not.toEqual(newMidPoints[0]);
  314. expect(midPoints[1]).not.toEqual(newMidPoints[1]);
  315. expect(newMidPoints).toMatchInlineSnapshot(`
  316. Array [
  317. Array [
  318. 105.96978489652551,
  319. 67.4423262309982,
  320. ],
  321. Array [
  322. 126.08587175006699,
  323. 63.294165939653226,
  324. ],
  325. ]
  326. `);
  327. });
  328. describe("When edges are round", () => {
  329. // This is the expected midpoint for line with round edge
  330. // hence hardcoding it so if later some bug is introduced
  331. // this will fail and we can fix it
  332. const firstSegmentMidpoint: Point = [55, 45];
  333. const lastSegmentMidpoint: Point = [75, 40];
  334. let line: ExcalidrawLinearElement;
  335. beforeEach(() => {
  336. line = createThreePointerLinearElement("line");
  337. expect(line.points.length).toEqual(3);
  338. enterLineEditingMode(line);
  339. });
  340. it("should allow dragging lines from midpoints in between segments", async () => {
  341. // drag line via first segment midpoint
  342. drag(firstSegmentMidpoint, [
  343. firstSegmentMidpoint[0] + delta,
  344. firstSegmentMidpoint[1] + delta,
  345. ]);
  346. expect(line.points.length).toEqual(4);
  347. // drag line from last segment midpoint
  348. drag(lastSegmentMidpoint, [
  349. lastSegmentMidpoint[0] + delta,
  350. lastSegmentMidpoint[1] + delta,
  351. ]);
  352. expect(renderScene).toHaveBeenCalledTimes(21);
  353. expect(line.points.length).toEqual(5);
  354. expect((h.elements[0] as ExcalidrawLinearElement).points)
  355. .toMatchInlineSnapshot(`
  356. Array [
  357. Array [
  358. 0,
  359. 0,
  360. ],
  361. Array [
  362. 85,
  363. 75,
  364. ],
  365. Array [
  366. 70,
  367. 50,
  368. ],
  369. Array [
  370. 105,
  371. 70,
  372. ],
  373. Array [
  374. 40,
  375. 0,
  376. ],
  377. ]
  378. `);
  379. });
  380. it("should update only the first segment midpoint when its point is dragged", async () => {
  381. const points = LinearElementEditor.getPointsGlobalCoordinates(line);
  382. const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state);
  383. const hitCoords: Point = [points[0][0], points[0][1]];
  384. // Drag from first point
  385. drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]);
  386. expect(renderScene).toHaveBeenCalledTimes(16);
  387. const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line);
  388. expect([newPoints[0][0], newPoints[0][1]]).toEqual([
  389. points[0][0] - delta,
  390. points[0][1] - delta,
  391. ]);
  392. const newMidPoints = LinearElementEditor.getEditorMidPoints(
  393. line,
  394. h.state,
  395. );
  396. expect(midPoints[0]).not.toEqual(newMidPoints[0]);
  397. expect(midPoints[1]).toEqual(newMidPoints[1]);
  398. });
  399. it("should hide midpoints in the segment when points moved close", async () => {
  400. const points = LinearElementEditor.getPointsGlobalCoordinates(line);
  401. const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state);
  402. const hitCoords: Point = [points[0][0], points[0][1]];
  403. // Drag from first point
  404. drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
  405. expect(renderScene).toHaveBeenCalledTimes(16);
  406. const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line);
  407. expect([newPoints[0][0], newPoints[0][1]]).toEqual([
  408. points[0][0] + delta,
  409. points[0][1] + delta,
  410. ]);
  411. const newMidPoints = LinearElementEditor.getEditorMidPoints(
  412. line,
  413. h.state,
  414. );
  415. // This midpoint is hidden since the points are too close
  416. expect(newMidPoints[0]).toBeNull();
  417. expect(midPoints[1]).toEqual(newMidPoints[1]);
  418. });
  419. it("should remove the midpoint when one of the points in the segment is deleted", async () => {
  420. const line = h.elements[0] as ExcalidrawLinearElement;
  421. enterLineEditingMode(line);
  422. const points = LinearElementEditor.getPointsGlobalCoordinates(line);
  423. // dragging line from last segment midpoint
  424. drag(lastSegmentMidpoint, [
  425. lastSegmentMidpoint[0] + 50,
  426. lastSegmentMidpoint[1] + 50,
  427. ]);
  428. expect(line.points.length).toEqual(4);
  429. const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state);
  430. // delete 3rd point
  431. deletePoint(points[2]);
  432. expect(line.points.length).toEqual(3);
  433. expect(renderScene).toHaveBeenCalledTimes(22);
  434. const newMidPoints = LinearElementEditor.getEditorMidPoints(
  435. line,
  436. h.state,
  437. );
  438. expect(newMidPoints.length).toEqual(2);
  439. expect(midPoints[0]).toEqual(newMidPoints[0]);
  440. expect(midPoints[1]).toEqual(newMidPoints[1]);
  441. });
  442. });
  443. describe("When edges are round", () => {
  444. // This is the expected midpoint for line with round edge
  445. // hence hardcoding it so if later some bug is introduced
  446. // this will fail and we can fix it
  447. const firstSegmentMidpoint: Point = [
  448. 55.9697848965255, 47.442326230998205,
  449. ];
  450. const lastSegmentMidpoint: Point = [
  451. 76.08587175006699, 43.294165939653226,
  452. ];
  453. let line: ExcalidrawLinearElement;
  454. beforeEach(() => {
  455. line = createThreePointerLinearElement("line", {
  456. type: ROUNDNESS.PROPORTIONAL_RADIUS,
  457. });
  458. expect(line.points.length).toEqual(3);
  459. enterLineEditingMode(line);
  460. });
  461. it("should allow dragging lines from midpoints in between segments", async () => {
  462. // drag line from first segment midpoint
  463. drag(firstSegmentMidpoint, [
  464. firstSegmentMidpoint[0] + delta,
  465. firstSegmentMidpoint[1] + delta,
  466. ]);
  467. expect(line.points.length).toEqual(4);
  468. // drag line from last segment midpoint
  469. drag(lastSegmentMidpoint, [
  470. lastSegmentMidpoint[0] + delta,
  471. lastSegmentMidpoint[1] + delta,
  472. ]);
  473. expect(renderScene).toHaveBeenCalledTimes(21);
  474. expect(line.points.length).toEqual(5);
  475. expect((h.elements[0] as ExcalidrawLinearElement).points)
  476. .toMatchInlineSnapshot(`
  477. Array [
  478. Array [
  479. 0,
  480. 0,
  481. ],
  482. Array [
  483. 85.96978489652551,
  484. 77.4423262309982,
  485. ],
  486. Array [
  487. 70,
  488. 50,
  489. ],
  490. Array [
  491. 106.08587175006699,
  492. 73.29416593965323,
  493. ],
  494. Array [
  495. 40,
  496. 0,
  497. ],
  498. ]
  499. `);
  500. });
  501. it("should update all the midpoints when its point is dragged", async () => {
  502. const points = LinearElementEditor.getPointsGlobalCoordinates(line);
  503. const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state);
  504. const hitCoords: Point = [points[0][0], points[0][1]];
  505. // Drag from first point
  506. drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]);
  507. const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line);
  508. expect([newPoints[0][0], newPoints[0][1]]).toEqual([
  509. points[0][0] - delta,
  510. points[0][1] - delta,
  511. ]);
  512. const newMidPoints = LinearElementEditor.getEditorMidPoints(
  513. line,
  514. h.state,
  515. );
  516. expect(midPoints[0]).not.toEqual(newMidPoints[0]);
  517. expect(midPoints[1]).not.toEqual(newMidPoints[1]);
  518. expect(newMidPoints).toMatchInlineSnapshot(`
  519. Array [
  520. Array [
  521. 31.884084517616053,
  522. 23.13275505472383,
  523. ],
  524. Array [
  525. 77.74792546875662,
  526. 44.57840982272327,
  527. ],
  528. ]
  529. `);
  530. });
  531. it("should hide midpoints in the segment when points moved close", async () => {
  532. const points = LinearElementEditor.getPointsGlobalCoordinates(line);
  533. const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state);
  534. const hitCoords: Point = [points[0][0], points[0][1]];
  535. // Drag from first point
  536. drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
  537. expect(renderScene).toHaveBeenCalledTimes(16);
  538. const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line);
  539. expect([newPoints[0][0], newPoints[0][1]]).toEqual([
  540. points[0][0] + delta,
  541. points[0][1] + delta,
  542. ]);
  543. const newMidPoints = LinearElementEditor.getEditorMidPoints(
  544. line,
  545. h.state,
  546. );
  547. // This mid point is hidden due to point being too close
  548. expect(newMidPoints[0]).toBeNull();
  549. expect(newMidPoints[1]).not.toEqual(midPoints[1]);
  550. });
  551. it("should update all the midpoints when a point is deleted", async () => {
  552. drag(lastSegmentMidpoint, [
  553. lastSegmentMidpoint[0] + delta,
  554. lastSegmentMidpoint[1] + delta,
  555. ]);
  556. expect(line.points.length).toEqual(4);
  557. const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state);
  558. const points = LinearElementEditor.getPointsGlobalCoordinates(line);
  559. // delete 3rd point
  560. deletePoint(points[2]);
  561. expect(line.points.length).toEqual(3);
  562. const newMidPoints = LinearElementEditor.getEditorMidPoints(
  563. line,
  564. h.state,
  565. );
  566. expect(newMidPoints.length).toEqual(2);
  567. expect(midPoints[0]).not.toEqual(newMidPoints[0]);
  568. expect(midPoints[1]).not.toEqual(newMidPoints[1]);
  569. expect(newMidPoints).toMatchInlineSnapshot(`
  570. Array [
  571. Array [
  572. 55.9697848965255,
  573. 47.442326230998205,
  574. ],
  575. Array [
  576. 76.08587175006699,
  577. 43.294165939653226,
  578. ],
  579. ]
  580. `);
  581. });
  582. });
  583. it("in-editor dragging a line point covered by another element", () => {
  584. createTwoPointerLinearElement("line");
  585. const line = h.elements[0] as ExcalidrawLinearElement;
  586. h.elements = [
  587. line,
  588. API.createElement({
  589. type: "rectangle",
  590. x: line.x - 50,
  591. y: line.y - 50,
  592. width: 100,
  593. height: 100,
  594. backgroundColor: "red",
  595. fillStyle: "solid",
  596. }),
  597. ];
  598. const dragEndPositionOffset = [100, 100] as const;
  599. API.setSelectedElements([line]);
  600. enterLineEditingMode(line, true);
  601. drag(
  602. [line.points[0][0] + line.x, line.points[0][1] + line.y],
  603. [dragEndPositionOffset[0] + line.x, dragEndPositionOffset[1] + line.y],
  604. );
  605. expect(line.points).toMatchInlineSnapshot(`
  606. Array [
  607. Array [
  608. 0,
  609. 0,
  610. ],
  611. Array [
  612. -60,
  613. -100,
  614. ],
  615. ]
  616. `);
  617. });
  618. });
  619. describe("Test bound text element", () => {
  620. const DEFAULT_TEXT = "Online whiteboard collaboration made easy";
  621. const createBoundTextElement = (
  622. text: string,
  623. container: ExcalidrawLinearElement,
  624. ) => {
  625. const textElement = API.createElement({
  626. type: "text",
  627. x: 0,
  628. y: 0,
  629. text: wrapText(text, font, getMaxContainerWidth(container)),
  630. containerId: container.id,
  631. width: 30,
  632. height: 20,
  633. }) as ExcalidrawTextElementWithContainer;
  634. container = {
  635. ...container,
  636. boundElements: (container.boundElements || []).concat({
  637. type: "text",
  638. id: textElement.id,
  639. }),
  640. };
  641. const elements: ExcalidrawElement[] = [];
  642. h.elements.forEach((element) => {
  643. if (element.id === container.id) {
  644. elements.push(container);
  645. } else {
  646. elements.push(element);
  647. }
  648. });
  649. const updatedTextElement = { ...textElement, originalText: text };
  650. h.elements = [...elements, updatedTextElement];
  651. return { textElement: updatedTextElement, container };
  652. };
  653. describe("Test getBoundTextElementPosition", () => {
  654. it("should return correct position for 2 pointer arrow", () => {
  655. createTwoPointerLinearElement("arrow");
  656. const arrow = h.elements[0] as ExcalidrawLinearElement;
  657. const { textElement, container } = createBoundTextElement(
  658. DEFAULT_TEXT,
  659. arrow,
  660. );
  661. const position = LinearElementEditor.getBoundTextElementPosition(
  662. container,
  663. textElement,
  664. );
  665. expect(position).toMatchInlineSnapshot(`
  666. Object {
  667. "x": 25,
  668. "y": 10,
  669. }
  670. `);
  671. });
  672. it("should return correct position for arrow with odd points", () => {
  673. createThreePointerLinearElement("arrow", {
  674. type: ROUNDNESS.PROPORTIONAL_RADIUS,
  675. });
  676. const arrow = h.elements[0] as ExcalidrawLinearElement;
  677. const { textElement, container } = createBoundTextElement(
  678. DEFAULT_TEXT,
  679. arrow,
  680. );
  681. const position = LinearElementEditor.getBoundTextElementPosition(
  682. container,
  683. textElement,
  684. );
  685. expect(position).toMatchInlineSnapshot(`
  686. Object {
  687. "x": 75,
  688. "y": 60,
  689. }
  690. `);
  691. });
  692. it("should return correct position for arrow with even points", () => {
  693. createThreePointerLinearElement("arrow", {
  694. type: ROUNDNESS.PROPORTIONAL_RADIUS,
  695. });
  696. const arrow = h.elements[0] as ExcalidrawLinearElement;
  697. const { textElement, container } = createBoundTextElement(
  698. DEFAULT_TEXT,
  699. arrow,
  700. );
  701. enterLineEditingMode(container);
  702. // This is the expected midpoint for line with round edge
  703. // hence hardcoding it so if later some bug is introduced
  704. // this will fail and we can fix it
  705. const firstSegmentMidpoint: Point = [
  706. 55.9697848965255, 47.442326230998205,
  707. ];
  708. // drag line from first segment midpoint
  709. drag(firstSegmentMidpoint, [
  710. firstSegmentMidpoint[0] + delta,
  711. firstSegmentMidpoint[1] + delta,
  712. ]);
  713. const position = LinearElementEditor.getBoundTextElementPosition(
  714. container,
  715. textElement,
  716. );
  717. expect(position).toMatchInlineSnapshot(`
  718. Object {
  719. "x": 85.82201843191861,
  720. "y": 75.63461309860818,
  721. }
  722. `);
  723. });
  724. });
  725. it("should match styles for text editor", () => {
  726. createTwoPointerLinearElement("arrow");
  727. Keyboard.keyPress(KEYS.ENTER);
  728. const editor = document.querySelector(
  729. ".excalidraw-textEditorContainer > textarea",
  730. ) as HTMLTextAreaElement;
  731. expect(editor).toMatchSnapshot();
  732. });
  733. it("should bind text to arrow when double clicked", async () => {
  734. createTwoPointerLinearElement("arrow");
  735. const arrow = h.elements[0] as ExcalidrawLinearElement;
  736. expect(h.elements.length).toBe(1);
  737. expect(h.elements[0].id).toBe(arrow.id);
  738. mouse.doubleClickAt(arrow.x, arrow.y);
  739. expect(h.elements.length).toBe(2);
  740. const text = h.elements[1] as ExcalidrawTextElementWithContainer;
  741. expect(text.type).toBe("text");
  742. expect(text.containerId).toBe(arrow.id);
  743. mouse.down();
  744. const editor = document.querySelector(
  745. ".excalidraw-textEditorContainer > textarea",
  746. ) as HTMLTextAreaElement;
  747. fireEvent.change(editor, {
  748. target: { value: DEFAULT_TEXT },
  749. });
  750. await new Promise((r) => setTimeout(r, 0));
  751. editor.blur();
  752. expect(arrow.boundElements).toStrictEqual([
  753. { id: text.id, type: "text" },
  754. ]);
  755. expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
  756. .toMatchInlineSnapshot(`
  757. "Online whiteboard
  758. collaboration made
  759. easy"
  760. `);
  761. });
  762. it("should bind text to arrow when clicked on arrow and enter pressed", async () => {
  763. const arrow = createTwoPointerLinearElement("arrow");
  764. expect(h.elements.length).toBe(1);
  765. expect(h.elements[0].id).toBe(arrow.id);
  766. Keyboard.keyPress(KEYS.ENTER);
  767. expect(h.elements.length).toBe(2);
  768. const textElement = h.elements[1] as ExcalidrawTextElementWithContainer;
  769. expect(textElement.type).toBe("text");
  770. expect(textElement.containerId).toBe(arrow.id);
  771. const editor = document.querySelector(
  772. ".excalidraw-textEditorContainer > textarea",
  773. ) as HTMLTextAreaElement;
  774. await new Promise((r) => setTimeout(r, 0));
  775. fireEvent.change(editor, {
  776. target: { value: DEFAULT_TEXT },
  777. });
  778. editor.blur();
  779. expect(arrow.boundElements).toStrictEqual([
  780. { id: textElement.id, type: "text" },
  781. ]);
  782. expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
  783. .toMatchInlineSnapshot(`
  784. "Online whiteboard
  785. collaboration made
  786. easy"
  787. `);
  788. });
  789. it("should not bind text to line when double clicked", async () => {
  790. const line = createTwoPointerLinearElement("line");
  791. expect(h.elements.length).toBe(1);
  792. mouse.doubleClickAt(line.x, line.y);
  793. expect(h.elements.length).toBe(2);
  794. const text = h.elements[1] as ExcalidrawTextElementWithContainer;
  795. expect(text.type).toBe("text");
  796. expect(text.containerId).toBeNull();
  797. expect(line.boundElements).toBeNull();
  798. });
  799. it("should not rotate the bound text and update position of bound text and bounding box correctly when arrow rotated", () => {
  800. createThreePointerLinearElement("arrow", {
  801. type: ROUNDNESS.PROPORTIONAL_RADIUS,
  802. });
  803. const arrow = h.elements[0] as ExcalidrawLinearElement;
  804. const { textElement, container } = createBoundTextElement(
  805. DEFAULT_TEXT,
  806. arrow,
  807. );
  808. expect(container.angle).toBe(0);
  809. expect(textElement.angle).toBe(0);
  810. expect(getBoundTextElementPosition(arrow, textElement))
  811. .toMatchInlineSnapshot(`
  812. Object {
  813. "x": 75,
  814. "y": 60,
  815. }
  816. `);
  817. expect(textElement.text).toMatchInlineSnapshot(`
  818. "Online whiteboard
  819. collaboration made
  820. easy"
  821. `);
  822. expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
  823. .toMatchInlineSnapshot(`
  824. Array [
  825. 20,
  826. 20,
  827. 105,
  828. 80,
  829. 55.45893770831013,
  830. 45,
  831. ]
  832. `);
  833. rotate(container, -35, 55);
  834. expect(container.angle).toMatchInlineSnapshot(`1.3988061968364685`);
  835. expect(textElement.angle).toBe(0);
  836. expect(getBoundTextElementPosition(container, textElement))
  837. .toMatchInlineSnapshot(`
  838. Object {
  839. "x": 21.73926141863671,
  840. "y": 73.31003398390868,
  841. }
  842. `);
  843. expect(textElement.text).toMatchInlineSnapshot(`
  844. "Online whiteboard
  845. collaboration made
  846. easy"
  847. `);
  848. expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
  849. .toMatchInlineSnapshot(`
  850. Array [
  851. 20,
  852. 20,
  853. 102.41961302274555,
  854. 86.49012635273976,
  855. 55.45893770831013,
  856. 45,
  857. ]
  858. `);
  859. });
  860. it("should resize and position the bound text and bounding box correctly when 3 pointer arrow element resized", () => {
  861. createThreePointerLinearElement("arrow", {
  862. type: ROUNDNESS.PROPORTIONAL_RADIUS,
  863. });
  864. const arrow = h.elements[0] as ExcalidrawLinearElement;
  865. const { textElement, container } = createBoundTextElement(
  866. DEFAULT_TEXT,
  867. arrow,
  868. );
  869. expect(container.width).toBe(70);
  870. expect(container.height).toBe(50);
  871. expect(getBoundTextElementPosition(container, textElement))
  872. .toMatchInlineSnapshot(`
  873. Object {
  874. "x": 75,
  875. "y": 60,
  876. }
  877. `);
  878. expect(textElement.text).toMatchInlineSnapshot(`
  879. "Online whiteboard
  880. collaboration made
  881. easy"
  882. `);
  883. expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
  884. .toMatchInlineSnapshot(`
  885. Array [
  886. 20,
  887. 20,
  888. 105,
  889. 80,
  890. 55.45893770831013,
  891. 45,
  892. ]
  893. `);
  894. resize(container, "ne", [300, 200]);
  895. expect({ width: container.width, height: container.height })
  896. .toMatchInlineSnapshot(`
  897. Object {
  898. "height": 10,
  899. "width": 367,
  900. }
  901. `);
  902. expect(getBoundTextElementPosition(container, textElement))
  903. .toMatchInlineSnapshot(`
  904. Object {
  905. "x": 386.5,
  906. "y": 70,
  907. }
  908. `);
  909. expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
  910. .toMatchInlineSnapshot(`
  911. "Online whiteboard
  912. collaboration made easy"
  913. `);
  914. expect(LinearElementEditor.getElementAbsoluteCoords(container, true))
  915. .toMatchInlineSnapshot(`
  916. Array [
  917. 20,
  918. 60,
  919. 391.8122896842806,
  920. 70,
  921. 205.9061448421403,
  922. 65,
  923. ]
  924. `);
  925. });
  926. it("should resize and position the bound text correctly when 2 pointer linear element resized", () => {
  927. createTwoPointerLinearElement("arrow");
  928. const arrow = h.elements[0] as ExcalidrawLinearElement;
  929. const { textElement, container } = createBoundTextElement(
  930. DEFAULT_TEXT,
  931. arrow,
  932. );
  933. expect(container.width).toBe(40);
  934. expect(getBoundTextElementPosition(container, textElement))
  935. .toMatchInlineSnapshot(`
  936. Object {
  937. "x": 25,
  938. "y": 10,
  939. }
  940. `);
  941. expect(textElement.text).toMatchInlineSnapshot(`
  942. "Online whiteboard
  943. collaboration made
  944. easy"
  945. `);
  946. const points = LinearElementEditor.getPointsGlobalCoordinates(container);
  947. // Drag from last point
  948. drag(points[1], [points[1][0] + 300, points[1][1]]);
  949. expect({ width: container.width, height: container.height })
  950. .toMatchInlineSnapshot(`
  951. Object {
  952. "height": 0,
  953. "width": 340,
  954. }
  955. `);
  956. expect(getBoundTextElementPosition(container, textElement))
  957. .toMatchInlineSnapshot(`
  958. Object {
  959. "x": 189.5,
  960. "y": 20,
  961. }
  962. `);
  963. expect(textElement.text).toMatchInlineSnapshot(`
  964. "Online whiteboard
  965. collaboration made easy"
  966. `);
  967. });
  968. it("should not render vertical align tool when element selected", () => {
  969. createTwoPointerLinearElement("arrow");
  970. const arrow = h.elements[0] as ExcalidrawLinearElement;
  971. createBoundTextElement(DEFAULT_TEXT, arrow);
  972. API.setSelectedElements([arrow]);
  973. expect(queryByTestId(container, "align-top")).toBeNull();
  974. expect(queryByTestId(container, "align-middle")).toBeNull();
  975. expect(queryByTestId(container, "align-bottom")).toBeNull();
  976. });
  977. it("should wrap the bound text when arrow bound container moves", async () => {
  978. const rect = UI.createElement("rectangle", {
  979. x: 400,
  980. width: 200,
  981. height: 500,
  982. });
  983. const arrow = UI.createElement("arrow", {
  984. x: 210,
  985. y: 250,
  986. width: 400,
  987. height: 1,
  988. });
  989. mouse.select(arrow);
  990. Keyboard.keyPress(KEYS.ENTER);
  991. const editor = document.querySelector(
  992. ".excalidraw-textEditorContainer > textarea",
  993. ) as HTMLTextAreaElement;
  994. await new Promise((r) => setTimeout(r, 0));
  995. fireEvent.change(editor, { target: { value: DEFAULT_TEXT } });
  996. editor.blur();
  997. const textElement = h.elements[2] as ExcalidrawTextElementWithContainer;
  998. expect(arrow.endBinding?.elementId).toBe(rect.id);
  999. expect(arrow.width).toBe(400);
  1000. expect(rect.x).toBe(400);
  1001. expect(rect.y).toBe(0);
  1002. expect(
  1003. wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)),
  1004. ).toMatchInlineSnapshot(`
  1005. "Online whiteboard collaboration
  1006. made easy"
  1007. `);
  1008. const handleBindTextResizeSpy = jest.spyOn(
  1009. textElementUtils,
  1010. "handleBindTextResize",
  1011. );
  1012. mouse.select(rect);
  1013. mouse.downAt(rect.x, rect.y);
  1014. mouse.moveTo(200, 0);
  1015. mouse.upAt(200, 0);
  1016. expect(arrow.width).toBe(170);
  1017. expect(rect.x).toBe(200);
  1018. expect(rect.y).toBe(0);
  1019. expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
  1020. h.elements[1],
  1021. false,
  1022. );
  1023. expect(
  1024. wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)),
  1025. ).toMatchInlineSnapshot(`
  1026. "Online whiteboard
  1027. collaboration made
  1028. easy"
  1029. `);
  1030. });
  1031. });
  1032. });