linearElementEditor.test.tsx 45 KB

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