linearElementEditor.test.tsx 46 KB

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