linearElementEditor.test.tsx 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539
  1. import { pointCenter, pointFrom } from "@excalidraw/math";
  2. import { act, queryByTestId, queryByText } from "@testing-library/react";
  3. import { vi } from "vitest";
  4. import {
  5. ROUNDNESS,
  6. VERTICAL_ALIGN,
  7. KEYS,
  8. reseed,
  9. arrayToMap,
  10. } from "@excalidraw/common";
  11. import { Excalidraw } from "@excalidraw/excalidraw";
  12. import * as InteractiveCanvas from "@excalidraw/excalidraw/renderer/interactiveScene";
  13. import * as StaticScene from "@excalidraw/excalidraw/renderer/staticScene";
  14. import { API } from "@excalidraw/excalidraw/tests/helpers/api";
  15. import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
  16. import {
  17. screen,
  18. render,
  19. fireEvent,
  20. GlobalTestState,
  21. unmountComponent,
  22. } from "@excalidraw/excalidraw/tests/test-utils";
  23. import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
  24. import { wrapText } from "../src";
  25. import * as textElementUtils from "../src/textElement";
  26. import { getBoundTextElementPosition, getBoundTextMaxWidth } from "../src";
  27. import { LinearElementEditor } from "../src";
  28. import { newArrowElement } from "../src";
  29. import {
  30. getTextEditor,
  31. TEXT_EDITOR_SELECTOR,
  32. } from "../../excalidraw/tests/queries/dom";
  33. import type {
  34. ExcalidrawElement,
  35. ExcalidrawLinearElement,
  36. ExcalidrawTextElementWithContainer,
  37. FontString,
  38. } from "../src/types";
  39. const renderInteractiveScene = vi.spyOn(
  40. InteractiveCanvas,
  41. "renderInteractiveScene",
  42. );
  43. const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
  44. const { h } = window;
  45. const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
  46. describe("Test Linear Elements", () => {
  47. let container: HTMLElement;
  48. let interactiveCanvas: HTMLCanvasElement;
  49. beforeEach(async () => {
  50. unmountComponent();
  51. localStorage.clear();
  52. renderInteractiveScene.mockClear();
  53. renderStaticScene.mockClear();
  54. reseed(7);
  55. const comp = await render(<Excalidraw handleKeyboardGlobally={true} />);
  56. h.state.width = 1000;
  57. h.state.height = 1000;
  58. container = comp.container;
  59. interactiveCanvas = container.querySelector("canvas.interactive")!;
  60. });
  61. const p1 = pointFrom<GlobalPoint>(20, 20);
  62. const p2 = pointFrom<GlobalPoint>(60, 20);
  63. const midpoint = pointCenter<GlobalPoint>(p1, p2);
  64. const delta = 50;
  65. const mouse = new Pointer("mouse");
  66. const createTwoPointerLinearElement = (
  67. type: ExcalidrawLinearElement["type"],
  68. roundness: ExcalidrawElement["roundness"] = null,
  69. roughness: ExcalidrawLinearElement["roughness"] = 0,
  70. ) => {
  71. const line = API.createElement({
  72. x: p1[0],
  73. y: p1[1],
  74. width: p2[0] - p1[0],
  75. height: 0,
  76. type,
  77. roughness,
  78. points: [pointFrom(0, 0), pointFrom(p2[0] - p1[0], p2[1] - p1[1])],
  79. roundness,
  80. });
  81. API.setElements([line]);
  82. mouse.clickAt(p1[0], p1[1]);
  83. return line;
  84. };
  85. const createThreePointerLinearElement = (
  86. type: ExcalidrawLinearElement["type"],
  87. roundness: ExcalidrawElement["roundness"] = null,
  88. roughness: ExcalidrawLinearElement["roughness"] = 0,
  89. ) => {
  90. //dragging line from midpoint
  91. const p3 = [midpoint[0] + delta - p1[0], midpoint[1] + delta - p1[1]];
  92. const line = API.createElement({
  93. x: p1[0],
  94. y: p1[1],
  95. width: p3[0] - p1[0],
  96. height: 0,
  97. type,
  98. roughness,
  99. points: [
  100. pointFrom(0, 0),
  101. pointFrom(p3[0], p3[1]),
  102. pointFrom(p2[0] - p1[0], p2[1] - p1[1]),
  103. ],
  104. roundness,
  105. });
  106. h.app.scene.mutateElement(line, { points: line.points });
  107. API.setElements([line]);
  108. mouse.clickAt(p1[0], p1[1]);
  109. return line;
  110. };
  111. const enterLineEditingMode = (
  112. line: ExcalidrawLinearElement,
  113. selectProgrammatically = false,
  114. ) => {
  115. if (selectProgrammatically) {
  116. API.setSelectedElements([line]);
  117. } else {
  118. mouse.clickAt(p1[0], p1[1]);
  119. }
  120. Keyboard.withModifierKeys({ ctrl: true }, () => {
  121. Keyboard.keyPress(KEYS.ENTER);
  122. });
  123. expect(h.state.selectedLinearElement?.isEditing).toBe(true);
  124. expect(h.state.selectedLinearElement?.elementId).toEqual(line.id);
  125. };
  126. const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
  127. fireEvent.pointerDown(interactiveCanvas, {
  128. clientX: startPoint[0],
  129. clientY: startPoint[1],
  130. });
  131. fireEvent.pointerMove(interactiveCanvas, {
  132. clientX: endPoint[0],
  133. clientY: endPoint[1],
  134. });
  135. fireEvent.pointerUp(interactiveCanvas, {
  136. clientX: endPoint[0],
  137. clientY: endPoint[1],
  138. });
  139. };
  140. const deletePoint = (point: GlobalPoint) => {
  141. fireEvent.pointerDown(interactiveCanvas, {
  142. clientX: point[0],
  143. clientY: point[1],
  144. });
  145. fireEvent.pointerUp(interactiveCanvas, {
  146. clientX: point[0],
  147. clientY: point[1],
  148. });
  149. Keyboard.keyPress(KEYS.DELETE);
  150. };
  151. it("should normalize the element points at creation", () => {
  152. const element = newArrowElement({
  153. type: "arrow",
  154. points: [pointFrom<LocalPoint>(0.5, 0), pointFrom<LocalPoint>(100, 100)],
  155. x: 0,
  156. y: 0,
  157. });
  158. expect(element.points).toEqual([
  159. pointFrom<LocalPoint>(0.5, 0),
  160. pointFrom<LocalPoint>(100, 100),
  161. ]);
  162. new LinearElementEditor(element, arrayToMap(h.elements));
  163. expect(element.points).toEqual([
  164. pointFrom<LocalPoint>(0, 0),
  165. pointFrom<LocalPoint>(99.5, 100),
  166. ]);
  167. });
  168. it("should not drag line and add midpoint until dragged beyond a threshold", () => {
  169. createTwoPointerLinearElement("line");
  170. const line = h.elements[0] as ExcalidrawLinearElement;
  171. const originalX = line.x;
  172. const originalY = line.y;
  173. expect(line.points.length).toEqual(2);
  174. mouse.clickAt(midpoint[0], midpoint[1]);
  175. drag(midpoint, pointFrom(midpoint[0] + 1, midpoint[1] + 1));
  176. expect(line.points.length).toEqual(2);
  177. expect(line.x).toBe(originalX);
  178. expect(line.y).toBe(originalY);
  179. expect(line.points.length).toEqual(2);
  180. drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
  181. expect(line.x).toBe(originalX);
  182. expect(line.y).toBe(originalY);
  183. expect(line.points.length).toEqual(3);
  184. });
  185. it("should allow dragging line from midpoint in 2 pointer lines outside editor", async () => {
  186. createTwoPointerLinearElement("line");
  187. const line = h.elements[0] as ExcalidrawLinearElement;
  188. expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`5`);
  189. expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
  190. expect((h.elements[0] as ExcalidrawLinearElement).points.length).toEqual(2);
  191. // drag line from midpoint
  192. drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
  193. expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`);
  194. expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
  195. expect(line.points.length).toEqual(3);
  196. expect(line.points).toMatchInlineSnapshot(`
  197. [
  198. [
  199. 0,
  200. 0,
  201. ],
  202. [
  203. 70,
  204. 50,
  205. ],
  206. [
  207. 40,
  208. 0,
  209. ],
  210. ]
  211. `);
  212. });
  213. it("should allow entering and exiting line editor via context menu", () => {
  214. createTwoPointerLinearElement("line");
  215. fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
  216. button: 2,
  217. clientX: midpoint[0],
  218. clientY: midpoint[1],
  219. });
  220. // Enter line editor
  221. const contextMenu = document.querySelector(".context-menu");
  222. fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
  223. button: 2,
  224. clientX: midpoint[0],
  225. clientY: midpoint[1],
  226. });
  227. fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!);
  228. expect(h.state.selectedLinearElement?.isEditing).toBe(true);
  229. expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
  230. });
  231. it("should enter line editor via enter (line)", () => {
  232. createTwoPointerLinearElement("line");
  233. expect(h.state.selectedLinearElement?.isEditing).toBe(false);
  234. mouse.clickAt(midpoint[0], midpoint[1]);
  235. Keyboard.keyPress(KEYS.ENTER);
  236. expect(h.state.selectedLinearElement?.isEditing).toBe(true);
  237. expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
  238. });
  239. // ctrl+enter alias (to align with arrows)
  240. it("should enter line editor via ctrl+enter (line)", () => {
  241. createTwoPointerLinearElement("line");
  242. expect(h.state.selectedLinearElement?.isEditing).toBe(false);
  243. mouse.clickAt(midpoint[0], midpoint[1]);
  244. Keyboard.withModifierKeys({ ctrl: true }, () => {
  245. Keyboard.keyPress(KEYS.ENTER);
  246. });
  247. expect(h.state.selectedLinearElement?.isEditing).toBe(true);
  248. expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
  249. });
  250. it("should enter line editor via ctrl+enter (arrow)", () => {
  251. createTwoPointerLinearElement("arrow");
  252. expect(h.state.selectedLinearElement?.isEditing).toBe(false);
  253. mouse.clickAt(midpoint[0], midpoint[1]);
  254. Keyboard.withModifierKeys({ ctrl: true }, () => {
  255. Keyboard.keyPress(KEYS.ENTER);
  256. });
  257. expect(h.state.selectedLinearElement?.isEditing).toBe(true);
  258. expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
  259. });
  260. it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
  261. createTwoPointerLinearElement("arrow");
  262. expect(h.state.selectedLinearElement?.isEditing).toBe(false);
  263. Keyboard.withModifierKeys({ ctrl: true }, () => {
  264. mouse.doubleClick();
  265. });
  266. expect(h.state.selectedLinearElement?.isEditing).toBe(true);
  267. expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
  268. });
  269. it("should enter line editor on ctrl+dblclick (line)", () => {
  270. createTwoPointerLinearElement("line");
  271. expect(h.state.selectedLinearElement?.isEditing).toBe(false);
  272. Keyboard.withModifierKeys({ ctrl: true }, () => {
  273. mouse.doubleClick();
  274. });
  275. expect(h.state.selectedLinearElement?.isEditing).toBe(true);
  276. expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
  277. });
  278. it("should enter line editor on dblclick (line)", () => {
  279. createTwoPointerLinearElement("line");
  280. expect(h.state.selectedLinearElement?.isEditing).toBe(false);
  281. mouse.doubleClick();
  282. expect(h.state.selectedLinearElement?.isEditing).toBe(true);
  283. expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
  284. });
  285. it("should not enter line editor on dblclick (arrow)", async () => {
  286. createTwoPointerLinearElement("arrow");
  287. expect(h.state.selectedLinearElement?.isEditing).toBe(false);
  288. mouse.doubleClick();
  289. expect(h.state.selectedLinearElement?.isEditing).toBe(false);
  290. await getTextEditor();
  291. });
  292. it("shouldn't create text element on double click in line editor (arrow)", async () => {
  293. createTwoPointerLinearElement("arrow");
  294. const arrow = h.elements[0] as ExcalidrawLinearElement;
  295. enterLineEditingMode(arrow);
  296. expect(h.state.selectedLinearElement?.isEditing).toBe(true);
  297. expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
  298. mouse.doubleClick();
  299. expect(h.state.selectedLinearElement?.isEditing).toBe(true);
  300. expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
  301. expect(h.elements.length).toEqual(1);
  302. expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
  303. });
  304. describe("Inside editor", () => {
  305. it("should not drag line and add midpoint when dragged irrespective of threshold", () => {
  306. createTwoPointerLinearElement("line");
  307. const line = h.elements[0] as ExcalidrawLinearElement;
  308. const originalX = line.x;
  309. const originalY = line.y;
  310. enterLineEditingMode(line);
  311. expect(h.state.selectedLinearElement?.isEditing).toBe(true);
  312. expect(line.points.length).toEqual(2);
  313. mouse.clickAt(midpoint[0], midpoint[1]);
  314. expect(line.points.length).toEqual(2);
  315. drag(midpoint, pointFrom(midpoint[0] + 1, midpoint[1] + 1));
  316. expect(line.x).toBe(originalX);
  317. expect(line.y).toBe(originalY);
  318. expect(line.points.length).toEqual(3);
  319. });
  320. it("should allow dragging line from midpoint in 2 pointer lines", async () => {
  321. createTwoPointerLinearElement("line");
  322. const line = h.elements[0] as ExcalidrawLinearElement;
  323. enterLineEditingMode(line);
  324. // drag line from midpoint
  325. drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
  326. expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
  327. `11`,
  328. );
  329. expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
  330. expect(line.points.length).toEqual(3);
  331. expect(line.points).toMatchInlineSnapshot(`
  332. [
  333. [
  334. 0,
  335. 0,
  336. ],
  337. [
  338. 70,
  339. 50,
  340. ],
  341. [
  342. 40,
  343. 0,
  344. ],
  345. ]
  346. `);
  347. });
  348. it("should update the midpoints when element roundness changed", async () => {
  349. createThreePointerLinearElement("line");
  350. const line = h.elements[0] as ExcalidrawLinearElement;
  351. expect(line.points.length).toEqual(3);
  352. enterLineEditingMode(line);
  353. const midPointsWithSharpEdge = LinearElementEditor.getEditorMidPoints(
  354. line,
  355. h.app.scene.getNonDeletedElementsMap(),
  356. h.state,
  357. );
  358. // update roundness
  359. fireEvent.click(screen.getByTitle("Round"));
  360. expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
  361. `9`,
  362. );
  363. expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
  364. const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints(
  365. h.elements[0] as ExcalidrawLinearElement,
  366. h.app.scene.getNonDeletedElementsMap(),
  367. h.state,
  368. );
  369. expect(midPointsWithRoundEdge[0]).not.toEqual(midPointsWithSharpEdge[0]);
  370. expect(midPointsWithRoundEdge[1]).not.toEqual(midPointsWithSharpEdge[1]);
  371. expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
  372. [
  373. [
  374. "54.27552",
  375. "46.16120",
  376. ],
  377. [
  378. "76.95494",
  379. "44.56052",
  380. ],
  381. ]
  382. `);
  383. });
  384. it("should update all the midpoints when element position changed", async () => {
  385. const elementsMap = arrayToMap(h.elements);
  386. createThreePointerLinearElement("line", {
  387. type: ROUNDNESS.PROPORTIONAL_RADIUS,
  388. });
  389. const line = h.elements[0] as ExcalidrawLinearElement;
  390. expect(line.points.length).toEqual(3);
  391. enterLineEditingMode(line);
  392. const points = LinearElementEditor.getPointsGlobalCoordinates(
  393. line,
  394. elementsMap,
  395. );
  396. expect([line.x, line.y]).toEqual(points[0]);
  397. const midPoints = LinearElementEditor.getEditorMidPoints(
  398. line,
  399. h.app.scene.getNonDeletedElementsMap(),
  400. h.state,
  401. );
  402. const startPoint = pointCenter(points[0], midPoints[0]!);
  403. const deltaX = 50;
  404. const deltaY = 20;
  405. const endPoint = pointFrom<GlobalPoint>(
  406. startPoint[0] + deltaX,
  407. startPoint[1] + deltaY,
  408. );
  409. // Move the element
  410. drag(startPoint, endPoint);
  411. expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
  412. `11`,
  413. );
  414. expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
  415. expect([line.x, line.y]).toEqual([
  416. points[0][0] + deltaX,
  417. points[0][1] + deltaY,
  418. ]);
  419. const newMidPoints = LinearElementEditor.getEditorMidPoints(
  420. line,
  421. h.app.scene.getNonDeletedElementsMap(),
  422. h.state,
  423. );
  424. expect(midPoints[0]).not.toEqual(newMidPoints[0]);
  425. expect(midPoints[1]).not.toEqual(newMidPoints[1]);
  426. expect(newMidPoints).toMatchInlineSnapshot(`
  427. [
  428. [
  429. "104.27552",
  430. "66.16120",
  431. ],
  432. [
  433. "126.95494",
  434. "64.56052",
  435. ],
  436. ]
  437. `);
  438. });
  439. describe("When edges are round", () => {
  440. // This is the expected midpoint for line with round edge
  441. // hence hardcoding it so if later some bug is introduced
  442. // this will fail and we can fix it
  443. const firstSegmentMidpoint = pointFrom<GlobalPoint>(55, 45);
  444. const lastSegmentMidpoint = pointFrom<GlobalPoint>(75, 40);
  445. let line: ExcalidrawLinearElement;
  446. beforeEach(() => {
  447. line = createThreePointerLinearElement("line");
  448. expect(line.points.length).toEqual(3);
  449. enterLineEditingMode(line);
  450. });
  451. it("should allow dragging lines from midpoints in between segments", async () => {
  452. // drag line via first segment midpoint
  453. drag(
  454. firstSegmentMidpoint,
  455. pointFrom(
  456. firstSegmentMidpoint[0] + delta,
  457. firstSegmentMidpoint[1] + delta,
  458. ),
  459. );
  460. expect(line.points.length).toEqual(4);
  461. // drag line from last segment midpoint
  462. drag(
  463. lastSegmentMidpoint,
  464. pointFrom(
  465. lastSegmentMidpoint[0] + delta,
  466. lastSegmentMidpoint[1] + delta,
  467. ),
  468. );
  469. expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
  470. `14`,
  471. );
  472. expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
  473. expect(line.points.length).toEqual(5);
  474. expect((h.elements[0] as ExcalidrawLinearElement).points)
  475. .toMatchInlineSnapshot(`
  476. [
  477. [
  478. 0,
  479. 0,
  480. ],
  481. [
  482. 85,
  483. 75,
  484. ],
  485. [
  486. 70,
  487. 50,
  488. ],
  489. [
  490. 105,
  491. 70,
  492. ],
  493. [
  494. 40,
  495. 0,
  496. ],
  497. ]
  498. `);
  499. });
  500. it("should update only the first segment midpoint when its point is dragged", async () => {
  501. const elementsMap = arrayToMap(h.elements);
  502. const points = LinearElementEditor.getPointsGlobalCoordinates(
  503. line,
  504. elementsMap,
  505. );
  506. const midPoints = LinearElementEditor.getEditorMidPoints(
  507. line,
  508. h.app.scene.getNonDeletedElementsMap(),
  509. h.state,
  510. );
  511. const hitCoords = pointFrom<GlobalPoint>(points[0][0], points[0][1]);
  512. // Drag from first point
  513. drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
  514. expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
  515. `11`,
  516. );
  517. expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
  518. const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
  519. line,
  520. elementsMap,
  521. );
  522. expect([newPoints[0][0], newPoints[0][1]]).toEqual([
  523. points[0][0] - delta,
  524. points[0][1] - delta,
  525. ]);
  526. const newMidPoints = LinearElementEditor.getEditorMidPoints(
  527. line,
  528. h.app.scene.getNonDeletedElementsMap(),
  529. h.state,
  530. );
  531. expect(midPoints[0]).not.toEqual(newMidPoints[0]);
  532. expect(midPoints[1]).toEqual(newMidPoints[1]);
  533. });
  534. it("should hide midpoints in the segment when points moved close", async () => {
  535. const elementsMap = arrayToMap(h.elements);
  536. const points = LinearElementEditor.getPointsGlobalCoordinates(
  537. line,
  538. elementsMap,
  539. );
  540. const midPoints = LinearElementEditor.getEditorMidPoints(
  541. line,
  542. h.app.scene.getNonDeletedElementsMap(),
  543. h.state,
  544. );
  545. const hitCoords = pointFrom<GlobalPoint>(points[0][0], points[0][1]);
  546. // Drag from first point
  547. drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
  548. expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
  549. `11`,
  550. );
  551. expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
  552. const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
  553. line,
  554. elementsMap,
  555. );
  556. expect([newPoints[0][0], newPoints[0][1]]).toEqual([
  557. points[0][0] + delta,
  558. points[0][1] + delta,
  559. ]);
  560. const newMidPoints = LinearElementEditor.getEditorMidPoints(
  561. line,
  562. h.app.scene.getNonDeletedElementsMap(),
  563. h.state,
  564. );
  565. // This midpoint is hidden since the points are too close
  566. expect(newMidPoints[0]).toBeNull();
  567. expect(midPoints[1]).toEqual(newMidPoints[1]);
  568. });
  569. it("should remove the midpoint when one of the points in the segment is deleted", async () => {
  570. const line = h.elements[0] as ExcalidrawLinearElement;
  571. enterLineEditingMode(line);
  572. const points = LinearElementEditor.getPointsGlobalCoordinates(
  573. line,
  574. arrayToMap(h.elements),
  575. );
  576. // dragging line from last segment midpoint
  577. drag(
  578. lastSegmentMidpoint,
  579. pointFrom(lastSegmentMidpoint[0] + 50, lastSegmentMidpoint[1] + 50),
  580. );
  581. expect(line.points.length).toEqual(4);
  582. const midPoints = LinearElementEditor.getEditorMidPoints(
  583. line,
  584. h.app.scene.getNonDeletedElementsMap(),
  585. h.state,
  586. );
  587. // delete 3rd point
  588. deletePoint(points[2]);
  589. expect(line.points.length).toEqual(3);
  590. expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
  591. `17`,
  592. );
  593. expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`);
  594. const newMidPoints = LinearElementEditor.getEditorMidPoints(
  595. line,
  596. h.app.scene.getNonDeletedElementsMap(),
  597. h.state,
  598. );
  599. expect(newMidPoints.length).toEqual(2);
  600. expect(midPoints[0]).toEqual(newMidPoints[0]);
  601. expect(midPoints[1]).toEqual(newMidPoints[1]);
  602. });
  603. });
  604. describe("When edges are round", () => {
  605. // This is the expected midpoint for line with round edge
  606. // hence hardcoding it so if later some bug is introduced
  607. // this will fail and we can fix it
  608. const firstSegmentMidpoint = pointFrom<GlobalPoint>(
  609. 55.9697848965255,
  610. 47.442326230998205,
  611. );
  612. const lastSegmentMidpoint = pointFrom<GlobalPoint>(
  613. 76.08587175006699,
  614. 43.294165939653226,
  615. );
  616. let line: ExcalidrawLinearElement;
  617. beforeEach(() => {
  618. line = createThreePointerLinearElement("line", {
  619. type: ROUNDNESS.PROPORTIONAL_RADIUS,
  620. });
  621. expect(line.points.length).toEqual(3);
  622. enterLineEditingMode(line);
  623. });
  624. it("should allow dragging lines from midpoints in between segments", async () => {
  625. // drag line from first segment midpoint
  626. drag(
  627. firstSegmentMidpoint,
  628. pointFrom(
  629. firstSegmentMidpoint[0] + delta,
  630. firstSegmentMidpoint[1] + delta,
  631. ),
  632. );
  633. expect(line.points.length).toEqual(4);
  634. // drag line from last segment midpoint
  635. drag(
  636. lastSegmentMidpoint,
  637. pointFrom(
  638. lastSegmentMidpoint[0] + delta,
  639. lastSegmentMidpoint[1] + delta,
  640. ),
  641. );
  642. expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
  643. `14`,
  644. );
  645. expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
  646. expect(line.points.length).toEqual(5);
  647. expect((h.elements[0] as ExcalidrawLinearElement).points)
  648. .toMatchInlineSnapshot(`
  649. [
  650. [
  651. 0,
  652. 0,
  653. ],
  654. [
  655. "85.96978",
  656. "77.44233",
  657. ],
  658. [
  659. 70,
  660. 50,
  661. ],
  662. [
  663. "106.08587",
  664. "73.29417",
  665. ],
  666. [
  667. 40,
  668. 0,
  669. ],
  670. ]
  671. `);
  672. });
  673. it("should update all the midpoints when its point is dragged", async () => {
  674. const elementsMap = arrayToMap(h.elements);
  675. const points = LinearElementEditor.getPointsGlobalCoordinates(
  676. line,
  677. elementsMap,
  678. );
  679. const midPoints = LinearElementEditor.getEditorMidPoints(
  680. line,
  681. h.app.scene.getNonDeletedElementsMap(),
  682. h.state,
  683. );
  684. const hitCoords = pointFrom<GlobalPoint>(points[0][0], points[0][1]);
  685. // Drag from first point
  686. drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
  687. const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
  688. line,
  689. elementsMap,
  690. );
  691. expect([newPoints[0][0], newPoints[0][1]]).toEqual([
  692. points[0][0] - delta,
  693. points[0][1] - delta,
  694. ]);
  695. const newMidPoints = LinearElementEditor.getEditorMidPoints(
  696. line,
  697. h.app.scene.getNonDeletedElementsMap(),
  698. h.state,
  699. );
  700. expect(midPoints[0]).not.toEqual(newMidPoints[0]);
  701. expect(midPoints[1]).not.toEqual(newMidPoints[1]);
  702. expect(newMidPoints).toMatchInlineSnapshot(`
  703. [
  704. [
  705. "29.28349",
  706. "20.91105",
  707. ],
  708. [
  709. "78.86048",
  710. "46.12277",
  711. ],
  712. ]
  713. `);
  714. });
  715. it("should hide midpoints in the segment when points moved close", async () => {
  716. const elementsMap = arrayToMap(h.elements);
  717. const points = LinearElementEditor.getPointsGlobalCoordinates(
  718. line,
  719. elementsMap,
  720. );
  721. const midPoints = LinearElementEditor.getEditorMidPoints(
  722. line,
  723. h.app.scene.getNonDeletedElementsMap(),
  724. h.state,
  725. );
  726. const hitCoords = pointFrom<GlobalPoint>(points[0][0], points[0][1]);
  727. // Drag from first point
  728. drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
  729. expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
  730. `11`,
  731. );
  732. expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
  733. const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
  734. line,
  735. elementsMap,
  736. );
  737. expect([newPoints[0][0], newPoints[0][1]]).toEqual([
  738. points[0][0] + delta,
  739. points[0][1] + delta,
  740. ]);
  741. const newMidPoints = LinearElementEditor.getEditorMidPoints(
  742. line,
  743. h.app.scene.getNonDeletedElementsMap(),
  744. h.state,
  745. );
  746. // This mid point is hidden due to point being too close
  747. expect(newMidPoints[0]).toBeNull();
  748. expect(newMidPoints[1]).not.toEqual(midPoints[1]);
  749. });
  750. it("should update all the midpoints when a point is deleted", async () => {
  751. const elementsMap = arrayToMap(h.elements);
  752. drag(
  753. lastSegmentMidpoint,
  754. pointFrom(
  755. lastSegmentMidpoint[0] + delta,
  756. lastSegmentMidpoint[1] + delta,
  757. ),
  758. );
  759. expect(line.points.length).toEqual(4);
  760. const midPoints = LinearElementEditor.getEditorMidPoints(
  761. line,
  762. h.app.scene.getNonDeletedElementsMap(),
  763. h.state,
  764. );
  765. const points = LinearElementEditor.getPointsGlobalCoordinates(
  766. line,
  767. elementsMap,
  768. );
  769. // delete 3rd point
  770. deletePoint(points[2]);
  771. expect(line.points.length).toEqual(3);
  772. const newMidPoints = LinearElementEditor.getEditorMidPoints(
  773. line,
  774. h.app.scene.getNonDeletedElementsMap(),
  775. h.state,
  776. );
  777. expect(newMidPoints.length).toEqual(2);
  778. expect(midPoints[0]).not.toEqual(newMidPoints[0]);
  779. expect(midPoints[1]).not.toEqual(newMidPoints[1]);
  780. expect(newMidPoints).toMatchInlineSnapshot(`
  781. [
  782. [
  783. "54.27552",
  784. "46.16120",
  785. ],
  786. [
  787. "76.95494",
  788. "44.56052",
  789. ],
  790. ]
  791. `);
  792. });
  793. });
  794. it("in-editor dragging a line point covered by another element", () => {
  795. createTwoPointerLinearElement("line");
  796. const line = h.elements[0] as ExcalidrawLinearElement;
  797. API.setElements([
  798. line,
  799. API.createElement({
  800. type: "rectangle",
  801. x: line.x - 50,
  802. y: line.y - 50,
  803. width: 100,
  804. height: 100,
  805. backgroundColor: "red",
  806. fillStyle: "solid",
  807. }),
  808. ]);
  809. const dragEndPositionOffset = [100, 100] as const;
  810. API.setSelectedElements([line]);
  811. enterLineEditingMode(line, true);
  812. drag(
  813. pointFrom(line.points[0][0] + line.x, line.points[0][1] + line.y),
  814. pointFrom(
  815. dragEndPositionOffset[0] + line.x,
  816. dragEndPositionOffset[1] + line.y,
  817. ),
  818. );
  819. expect(line.points).toMatchInlineSnapshot(`
  820. [
  821. [
  822. 0,
  823. 0,
  824. ],
  825. [
  826. -60,
  827. -100,
  828. ],
  829. ]
  830. `);
  831. });
  832. });
  833. describe("Test bound text element", () => {
  834. const DEFAULT_TEXT = "Online whiteboard collaboration made easy";
  835. const createBoundTextElement = (
  836. text: string,
  837. container: ExcalidrawLinearElement,
  838. ) => {
  839. const textElement = API.createElement({
  840. type: "text",
  841. x: 0,
  842. y: 0,
  843. text: wrapText(text, font, getBoundTextMaxWidth(container, null)),
  844. containerId: container.id,
  845. width: 30,
  846. height: 20,
  847. }) as ExcalidrawTextElementWithContainer;
  848. container = {
  849. ...container,
  850. boundElements: (container.boundElements || []).concat({
  851. type: "text",
  852. id: textElement.id,
  853. }),
  854. };
  855. const elements: ExcalidrawElement[] = [];
  856. h.elements.forEach((element) => {
  857. if (element.id === container.id) {
  858. elements.push(container);
  859. } else {
  860. elements.push(element);
  861. }
  862. });
  863. const updatedTextElement = { ...textElement, originalText: text };
  864. API.setElements([...elements, updatedTextElement]);
  865. return { textElement: updatedTextElement, container };
  866. };
  867. describe("Test getBoundTextElementPosition", () => {
  868. it("should return correct position for 2 pointer arrow", () => {
  869. createTwoPointerLinearElement("arrow");
  870. const arrow = h.elements[0] as ExcalidrawLinearElement;
  871. const { textElement, container } = createBoundTextElement(
  872. DEFAULT_TEXT,
  873. arrow,
  874. );
  875. const position = LinearElementEditor.getBoundTextElementPosition(
  876. container,
  877. textElement,
  878. arrayToMap(h.elements),
  879. );
  880. expect(position).toMatchInlineSnapshot(`
  881. {
  882. "x": 25,
  883. "y": 10,
  884. }
  885. `);
  886. });
  887. it("should return correct position for arrow with odd points", () => {
  888. createThreePointerLinearElement("arrow", {
  889. type: ROUNDNESS.PROPORTIONAL_RADIUS,
  890. });
  891. const arrow = h.elements[0] as ExcalidrawLinearElement;
  892. const { textElement, container } = createBoundTextElement(
  893. DEFAULT_TEXT,
  894. arrow,
  895. );
  896. const position = LinearElementEditor.getBoundTextElementPosition(
  897. container,
  898. textElement,
  899. arrayToMap(h.elements),
  900. );
  901. expect(position).toMatchInlineSnapshot(`
  902. {
  903. "x": 75,
  904. "y": 60,
  905. }
  906. `);
  907. });
  908. it("should return correct position for arrow with even points", () => {
  909. createThreePointerLinearElement("arrow", {
  910. type: ROUNDNESS.PROPORTIONAL_RADIUS,
  911. });
  912. const arrow = h.elements[0] as ExcalidrawLinearElement;
  913. const { textElement, container } = createBoundTextElement(
  914. DEFAULT_TEXT,
  915. arrow,
  916. );
  917. enterLineEditingMode(container);
  918. // This is the expected midpoint for line with round edge
  919. // hence hardcoding it so if later some bug is introduced
  920. // this will fail and we can fix it
  921. const firstSegmentMidpoint = pointFrom<GlobalPoint>(
  922. 55.9697848965255,
  923. 47.442326230998205,
  924. );
  925. // drag line from first segment midpoint
  926. drag(
  927. firstSegmentMidpoint,
  928. pointFrom(
  929. firstSegmentMidpoint[0] + delta,
  930. firstSegmentMidpoint[1] + delta,
  931. ),
  932. );
  933. const position = LinearElementEditor.getBoundTextElementPosition(
  934. container,
  935. textElement,
  936. arrayToMap(h.elements),
  937. );
  938. expect(position).toMatchInlineSnapshot(`
  939. {
  940. "x": "86.17305",
  941. "y": "76.11251",
  942. }
  943. `);
  944. });
  945. });
  946. it("should match styles for text editor", async () => {
  947. createTwoPointerLinearElement("arrow");
  948. Keyboard.keyPress(KEYS.ENTER);
  949. const editor = await getTextEditor();
  950. expect(editor).toMatchSnapshot();
  951. });
  952. it("should bind text to arrow when double clicked", async () => {
  953. createTwoPointerLinearElement("arrow");
  954. const arrow = h.elements[0] as ExcalidrawLinearElement;
  955. expect(h.elements.length).toBe(1);
  956. expect(h.elements[0].id).toBe(arrow.id);
  957. mouse.doubleClickAt(arrow.x, arrow.y);
  958. expect(h.elements.length).toBe(2);
  959. const text = h.elements[1] as ExcalidrawTextElementWithContainer;
  960. expect(text.type).toBe("text");
  961. expect(text.containerId).toBe(arrow.id);
  962. mouse.down();
  963. const editor = await getTextEditor();
  964. fireEvent.change(editor, {
  965. target: { value: DEFAULT_TEXT },
  966. });
  967. Keyboard.exitTextEditor(editor);
  968. expect(arrow.boundElements).toStrictEqual([
  969. { id: text.id, type: "text" },
  970. ]);
  971. expect(
  972. (h.elements[1] as ExcalidrawTextElementWithContainer).text,
  973. ).toMatchSnapshot();
  974. });
  975. it("should bind text to arrow when clicked on arrow and enter pressed", async () => {
  976. const arrow = createTwoPointerLinearElement("arrow");
  977. expect(h.elements.length).toBe(1);
  978. expect(h.elements[0].id).toBe(arrow.id);
  979. Keyboard.keyPress(KEYS.ENTER);
  980. expect(h.elements.length).toBe(2);
  981. const textElement = h.elements[1] as ExcalidrawTextElementWithContainer;
  982. expect(textElement.type).toBe("text");
  983. expect(textElement.containerId).toBe(arrow.id);
  984. const editor = await getTextEditor();
  985. fireEvent.change(editor, {
  986. target: { value: DEFAULT_TEXT },
  987. });
  988. Keyboard.exitTextEditor(editor);
  989. expect(arrow.boundElements).toStrictEqual([
  990. { id: textElement.id, type: "text" },
  991. ]);
  992. expect(
  993. (h.elements[1] as ExcalidrawTextElementWithContainer).text,
  994. ).toMatchSnapshot();
  995. });
  996. it("should not bind text to line when double clicked", async () => {
  997. const line = createTwoPointerLinearElement("line");
  998. expect(h.elements.length).toBe(1);
  999. mouse.doubleClickAt(line.x, line.y);
  1000. expect(h.elements.length).toBe(1);
  1001. });
  1002. // TODO fix #7029 and rewrite this test
  1003. it.todo(
  1004. "should not rotate the bound text and update position of bound text and bounding box correctly when arrow rotated",
  1005. );
  1006. it("should resize and position the bound text and bounding box correctly when 3 pointer arrow element resized", () => {
  1007. createThreePointerLinearElement("arrow", {
  1008. type: ROUNDNESS.PROPORTIONAL_RADIUS,
  1009. });
  1010. const arrow = h.elements[0] as ExcalidrawLinearElement;
  1011. const { textElement, container } = createBoundTextElement(
  1012. DEFAULT_TEXT,
  1013. arrow,
  1014. );
  1015. expect(container.width).toBe(70);
  1016. expect(container.height).toBe(50);
  1017. expect(
  1018. getBoundTextElementPosition(
  1019. container,
  1020. textElement,
  1021. arrayToMap(h.elements),
  1022. ),
  1023. ).toMatchInlineSnapshot(`
  1024. {
  1025. "x": 75,
  1026. "y": 60,
  1027. }
  1028. `);
  1029. expect(textElement.text).toMatchSnapshot();
  1030. expect(
  1031. LinearElementEditor.getElementAbsoluteCoords(
  1032. container,
  1033. h.app.scene.getNonDeletedElementsMap(),
  1034. true,
  1035. ),
  1036. ).toMatchInlineSnapshot(`
  1037. [
  1038. 20,
  1039. 20,
  1040. 105,
  1041. 80,
  1042. "55.45894",
  1043. 45,
  1044. ]
  1045. `);
  1046. UI.resize(container, "ne", [300, 200]);
  1047. expect({ width: container.width, height: container.height })
  1048. .toMatchInlineSnapshot(`
  1049. {
  1050. "height": 130,
  1051. "width": "366.11716",
  1052. }
  1053. `);
  1054. expect(
  1055. getBoundTextElementPosition(
  1056. container,
  1057. textElement,
  1058. arrayToMap(h.elements),
  1059. ),
  1060. ).toMatchInlineSnapshot(`
  1061. {
  1062. "x": "271.11716",
  1063. "y": 45,
  1064. }
  1065. `);
  1066. expect(
  1067. (h.elements[1] as ExcalidrawTextElementWithContainer).text,
  1068. ).toMatchSnapshot();
  1069. expect(
  1070. LinearElementEditor.getElementAbsoluteCoords(
  1071. container,
  1072. h.app.scene.getNonDeletedElementsMap(),
  1073. true,
  1074. ),
  1075. ).toMatchInlineSnapshot(`
  1076. [
  1077. 20,
  1078. 35,
  1079. "501.11716",
  1080. 95,
  1081. "205.45894",
  1082. "52.50000",
  1083. ]
  1084. `);
  1085. });
  1086. it("should resize and position the bound text correctly when 2 pointer linear element resized", () => {
  1087. createTwoPointerLinearElement("arrow");
  1088. const arrow = h.elements[0] as ExcalidrawLinearElement;
  1089. const { textElement, container } = createBoundTextElement(
  1090. DEFAULT_TEXT,
  1091. arrow,
  1092. );
  1093. expect(container.width).toBe(40);
  1094. const elementsMap = arrayToMap(h.elements);
  1095. expect(getBoundTextElementPosition(container, textElement, elementsMap))
  1096. .toMatchInlineSnapshot(`
  1097. {
  1098. "x": 25,
  1099. "y": 10,
  1100. }
  1101. `);
  1102. expect(textElement.text).toMatchSnapshot();
  1103. const points = LinearElementEditor.getPointsGlobalCoordinates(
  1104. container,
  1105. elementsMap,
  1106. );
  1107. // Drag from last point
  1108. drag(points[1], pointFrom(points[1][0] + 300, points[1][1]));
  1109. expect({ width: container.width, height: container.height })
  1110. .toMatchInlineSnapshot(`
  1111. {
  1112. "height": 130,
  1113. "width": 340,
  1114. }
  1115. `);
  1116. expect(getBoundTextElementPosition(container, textElement, elementsMap))
  1117. .toMatchInlineSnapshot(`
  1118. {
  1119. "x": 75,
  1120. "y": -5,
  1121. }
  1122. `);
  1123. expect(textElement.text).toMatchSnapshot();
  1124. });
  1125. it("should not render vertical align tool when element selected", () => {
  1126. createTwoPointerLinearElement("arrow");
  1127. const arrow = h.elements[0] as ExcalidrawLinearElement;
  1128. createBoundTextElement(DEFAULT_TEXT, arrow);
  1129. API.setSelectedElements([arrow]);
  1130. expect(queryByTestId(container, "align-top")).toBeNull();
  1131. expect(queryByTestId(container, "align-middle")).toBeNull();
  1132. expect(queryByTestId(container, "align-bottom")).toBeNull();
  1133. });
  1134. it("should wrap the bound text when arrow bound container moves", async () => {
  1135. const rect = UI.createElement("rectangle", {
  1136. x: 400,
  1137. width: 200,
  1138. height: 500,
  1139. });
  1140. const arrow = UI.createElement("arrow", {
  1141. x: -10,
  1142. y: 250,
  1143. width: 410,
  1144. height: 1,
  1145. });
  1146. mouse.select(arrow);
  1147. Keyboard.keyPress(KEYS.ENTER);
  1148. const editor = await getTextEditor();
  1149. fireEvent.change(editor, { target: { value: DEFAULT_TEXT } });
  1150. Keyboard.exitTextEditor(editor);
  1151. const textElement = h.elements[2] as ExcalidrawTextElementWithContainer;
  1152. expect(arrow.endBinding?.elementId).toBe(rect.id);
  1153. expect(arrow.width).toBeCloseTo(399);
  1154. expect(rect.x).toBe(400);
  1155. expect(rect.y).toBe(0);
  1156. expect(
  1157. wrapText(
  1158. textElement.originalText,
  1159. font,
  1160. getBoundTextMaxWidth(arrow, null),
  1161. ),
  1162. ).toMatchSnapshot();
  1163. const handleBindTextResizeSpy = vi.spyOn(
  1164. textElementUtils,
  1165. "handleBindTextResize",
  1166. );
  1167. mouse.select(rect);
  1168. mouse.downAt(rect.x, rect.y);
  1169. mouse.moveTo(200, 0);
  1170. mouse.upAt(200, 0);
  1171. expect(arrow.width).toBeCloseTo(199);
  1172. expect(rect.x).toBe(200);
  1173. expect(rect.y).toBe(0);
  1174. expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
  1175. h.elements[0],
  1176. h.app.scene,
  1177. "nw",
  1178. false,
  1179. );
  1180. expect(
  1181. wrapText(
  1182. textElement.originalText,
  1183. font,
  1184. getBoundTextMaxWidth(arrow, null),
  1185. ),
  1186. ).toMatchSnapshot();
  1187. });
  1188. it("should not render horizontal align tool when element selected", () => {
  1189. createTwoPointerLinearElement("arrow");
  1190. const arrow = h.elements[0] as ExcalidrawLinearElement;
  1191. createBoundTextElement(DEFAULT_TEXT, arrow);
  1192. API.setSelectedElements([arrow]);
  1193. expect(queryByTestId(container, "align-left")).toBeNull();
  1194. expect(queryByTestId(container, "align-horizontal-center")).toBeNull();
  1195. expect(queryByTestId(container, "align-right")).toBeNull();
  1196. });
  1197. it("should update label coords when a label binded via context menu is unbinded", async () => {
  1198. createTwoPointerLinearElement("arrow");
  1199. const text = API.createElement({
  1200. type: "text",
  1201. text: "Hello Excalidraw",
  1202. });
  1203. expect(text.x).toBe(0);
  1204. expect(text.y).toBe(0);
  1205. API.setElements([h.elements[0], text]);
  1206. const container = h.elements[0];
  1207. API.setSelectedElements([container, text]);
  1208. fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
  1209. button: 2,
  1210. clientX: 20,
  1211. clientY: 30,
  1212. });
  1213. let contextMenu = document.querySelector(".context-menu");
  1214. fireEvent.click(
  1215. queryByText(contextMenu as HTMLElement, "Bind text to the container")!,
  1216. );
  1217. expect(container.boundElements).toStrictEqual([
  1218. { id: h.elements[1].id, type: "text" },
  1219. ]);
  1220. expect(text.containerId).toBe(container.id);
  1221. expect(text.verticalAlign).toBe(VERTICAL_ALIGN.MIDDLE);
  1222. mouse.reset();
  1223. mouse.clickAt(
  1224. container.x + container.width / 2,
  1225. container.y + container.height / 2,
  1226. );
  1227. mouse.down();
  1228. mouse.up();
  1229. API.setSelectedElements([h.elements[0], h.elements[1]]);
  1230. fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
  1231. button: 2,
  1232. clientX: 20,
  1233. clientY: 30,
  1234. });
  1235. contextMenu = document.querySelector(".context-menu");
  1236. fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!);
  1237. expect(container.boundElements).toEqual([]);
  1238. expect(text).toEqual(
  1239. expect.objectContaining({
  1240. containerId: null,
  1241. width: 160,
  1242. height: 25,
  1243. x: -40,
  1244. y: 7.5,
  1245. }),
  1246. );
  1247. });
  1248. it("should not update label position when arrow dragged", () => {
  1249. createTwoPointerLinearElement("arrow");
  1250. let arrow = h.elements[0] as ExcalidrawLinearElement;
  1251. createBoundTextElement(DEFAULT_TEXT, arrow);
  1252. let label = h.elements[1] as ExcalidrawTextElementWithContainer;
  1253. expect(arrow.x).toBe(20);
  1254. expect(arrow.y).toBe(20);
  1255. expect(label.x).toBe(0);
  1256. expect(label.y).toBe(0);
  1257. mouse.reset();
  1258. mouse.select(arrow);
  1259. mouse.select(label);
  1260. mouse.downAt(arrow.x, arrow.y);
  1261. mouse.moveTo(arrow.x + 20, arrow.y + 30);
  1262. mouse.up(arrow.x + 20, arrow.y + 30);
  1263. arrow = h.elements[0] as ExcalidrawLinearElement;
  1264. label = h.elements[1] as ExcalidrawTextElementWithContainer;
  1265. expect(arrow.x).toBe(80);
  1266. expect(arrow.y).toBe(100);
  1267. expect(label.x).toBe(0);
  1268. expect(label.y).toBe(0);
  1269. });
  1270. });
  1271. describe("Test moving linear element points", () => {
  1272. it("should move the endpoint in the negative direction correctly when the start point is also moved in the positive direction", async () => {
  1273. const line = createThreePointerLinearElement("arrow");
  1274. const [origStartX, origStartY] = [line.x, line.y];
  1275. act(() => {
  1276. LinearElementEditor.movePoints(
  1277. line,
  1278. h.app.scene,
  1279. new Map([
  1280. [
  1281. 0,
  1282. {
  1283. point: pointFrom(
  1284. line.points[0][0] + 10,
  1285. line.points[0][1] + 10,
  1286. ),
  1287. },
  1288. ],
  1289. [
  1290. line.points.length - 1,
  1291. {
  1292. point: pointFrom(
  1293. line.points[line.points.length - 1][0] - 10,
  1294. line.points[line.points.length - 1][1] - 10,
  1295. ),
  1296. },
  1297. ],
  1298. ]),
  1299. );
  1300. });
  1301. expect(line.x).toBe(origStartX + 10);
  1302. expect(line.y).toBe(origStartY + 10);
  1303. expect(line.points[line.points.length - 1][0]).toBe(20);
  1304. expect(line.points[line.points.length - 1][1]).toBe(-20);
  1305. });
  1306. it("should preserve original angle when dragging endpoint with SHIFT key", () => {
  1307. createTwoPointerLinearElement("line");
  1308. const line = h.elements[0] as ExcalidrawLinearElement;
  1309. enterLineEditingMode(line);
  1310. const elementsMap = arrayToMap(h.elements);
  1311. const points = LinearElementEditor.getPointsGlobalCoordinates(
  1312. line,
  1313. elementsMap,
  1314. );
  1315. // Calculate original angle between first and last point
  1316. const originalAngle = Math.atan2(
  1317. points[1][1] - points[0][1],
  1318. points[1][0] - points[0][0],
  1319. );
  1320. // Drag the second point (endpoint) with SHIFT key pressed
  1321. const startPoint = pointFrom<GlobalPoint>(points[1][0], points[1][1]);
  1322. const endPoint = pointFrom<GlobalPoint>(
  1323. startPoint[0] + 4,
  1324. startPoint[1] + 4,
  1325. );
  1326. // Perform drag with SHIFT key modifier
  1327. Keyboard.withModifierKeys({ shift: true }, () => {
  1328. mouse.downAt(startPoint[0], startPoint[1]);
  1329. mouse.moveTo(endPoint[0], endPoint[1]);
  1330. mouse.upAt(endPoint[0], endPoint[1]);
  1331. });
  1332. // Get updated points after drag
  1333. const updatedPoints = LinearElementEditor.getPointsGlobalCoordinates(
  1334. line,
  1335. elementsMap,
  1336. );
  1337. // Calculate new angle
  1338. const newAngle = Math.atan2(
  1339. updatedPoints[1][1] - updatedPoints[0][1],
  1340. updatedPoints[1][0] - updatedPoints[0][0],
  1341. );
  1342. // The angle should be preserved (within a small tolerance for floating point precision)
  1343. const angleDifference = Math.abs(newAngle - originalAngle);
  1344. const tolerance = 0.01; // Small tolerance for floating point precision
  1345. expect(angleDifference).toBeLessThan(tolerance);
  1346. });
  1347. });
  1348. });