binding.test.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. import { KEYS, arrayToMap } from "@excalidraw/common";
  2. import { pointFrom } from "@excalidraw/math";
  3. import { actionWrapTextInContainer } from "@excalidraw/excalidraw/actions/actionBoundText";
  4. import { Excalidraw, isLinearElement } from "@excalidraw/excalidraw";
  5. import { API } from "@excalidraw/excalidraw/tests/helpers/api";
  6. import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
  7. import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
  8. import { getTransformHandles } from "../src/transformHandles";
  9. import {
  10. getTextEditor,
  11. TEXT_EDITOR_SELECTOR,
  12. } from "../../excalidraw/tests/queries/dom";
  13. const { h } = window;
  14. const mouse = new Pointer("mouse");
  15. describe("element binding", () => {
  16. beforeEach(async () => {
  17. await render(<Excalidraw handleKeyboardGlobally={true} />);
  18. });
  19. it("should create valid binding if duplicate start/end points", async () => {
  20. const rect = API.createElement({
  21. type: "rectangle",
  22. x: 0,
  23. y: 0,
  24. width: 50,
  25. height: 50,
  26. });
  27. const arrow = API.createElement({
  28. type: "arrow",
  29. x: 100,
  30. y: 0,
  31. width: 100,
  32. height: 1,
  33. points: [
  34. pointFrom(0, 0),
  35. pointFrom(0, 0),
  36. pointFrom(100, 0),
  37. pointFrom(100, 0),
  38. ],
  39. });
  40. API.setElements([rect, arrow]);
  41. expect(arrow.startBinding).toBe(null);
  42. // select arrow
  43. mouse.clickAt(150, 0);
  44. // move arrow start to potential binding position
  45. mouse.downAt(100, 0);
  46. mouse.moveTo(55, 0);
  47. mouse.up(0, 0);
  48. // Point selection is evaluated like the points are rendered,
  49. // from right to left. So clicking on the first point should move the joint,
  50. // not the start point.
  51. expect(arrow.startBinding).toBe(null);
  52. // Now that the start point is free, move it into overlapping position
  53. mouse.downAt(100, 0);
  54. mouse.moveTo(55, 0);
  55. mouse.up(0, 0);
  56. expect(API.getSelectedElements()).toEqual([arrow]);
  57. expect(arrow.startBinding).toEqual({
  58. elementId: rect.id,
  59. focus: expect.toBeNonNaNNumber(),
  60. gap: expect.toBeNonNaNNumber(),
  61. });
  62. // Move the end point to the overlapping binding position
  63. mouse.downAt(200, 0);
  64. mouse.moveTo(55, 0);
  65. mouse.up(0, 0);
  66. // Both the start and the end points should be bound
  67. expect(arrow.startBinding).toEqual({
  68. elementId: rect.id,
  69. focus: expect.toBeNonNaNNumber(),
  70. gap: expect.toBeNonNaNNumber(),
  71. });
  72. expect(arrow.endBinding).toEqual({
  73. elementId: rect.id,
  74. focus: expect.toBeNonNaNNumber(),
  75. gap: expect.toBeNonNaNNumber(),
  76. });
  77. });
  78. //@TODO fix the test with rotation
  79. it.skip("rotation of arrow should rebind both ends", () => {
  80. const rectLeft = UI.createElement("rectangle", {
  81. x: 0,
  82. width: 200,
  83. height: 500,
  84. });
  85. const rectRight = UI.createElement("rectangle", {
  86. x: 400,
  87. width: 200,
  88. height: 500,
  89. });
  90. const arrow = UI.createElement("arrow", {
  91. x: 210,
  92. y: 250,
  93. width: 180,
  94. height: 1,
  95. });
  96. expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
  97. expect(arrow.endBinding?.elementId).toBe(rectRight.id);
  98. const rotation = getTransformHandles(
  99. arrow,
  100. h.state.zoom,
  101. arrayToMap(h.elements),
  102. "mouse",
  103. ).rotation!;
  104. const rotationHandleX = rotation[0] + rotation[2] / 2;
  105. const rotationHandleY = rotation[1] + rotation[3] / 2;
  106. mouse.down(rotationHandleX, rotationHandleY);
  107. mouse.move(300, 400);
  108. mouse.up();
  109. expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI);
  110. expect(arrow.angle).toBeLessThan(1.3 * Math.PI);
  111. expect(arrow.startBinding?.elementId).toBe(rectRight.id);
  112. expect(arrow.endBinding?.elementId).toBe(rectLeft.id);
  113. });
  114. // TODO fix & reenable once we rewrite tests to work with concurrency
  115. it.skip(
  116. "editing arrow and moving its head to bind it to element A, finalizing the" +
  117. "editing by clicking on element A should end up selecting A",
  118. async () => {
  119. UI.createElement("rectangle", {
  120. y: 0,
  121. size: 100,
  122. });
  123. // Create arrow bound to rectangle
  124. UI.clickTool("arrow");
  125. mouse.down(50, -100);
  126. mouse.up(0, 80);
  127. // Edit arrow with multi-point
  128. mouse.doubleClick();
  129. // move arrow head
  130. mouse.down();
  131. mouse.up(0, 10);
  132. expect(API.getSelectedElement().type).toBe("arrow");
  133. // NOTE this mouse down/up + await needs to be done in order to repro
  134. // the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
  135. mouse.reset();
  136. expect(h.state.selectedLinearElement?.isEditing).toBe(true);
  137. mouse.down(0, 0);
  138. await new Promise((r) => setTimeout(r, 100));
  139. expect(h.state.selectedLinearElement?.isEditing).toBe(false);
  140. expect(API.getSelectedElement().type).toBe("rectangle");
  141. mouse.up();
  142. expect(API.getSelectedElement().type).toBe("rectangle");
  143. },
  144. );
  145. it("should unbind arrow when moving it with keyboard", () => {
  146. const rectangle = UI.createElement("rectangle", {
  147. x: 75,
  148. y: 0,
  149. size: 100,
  150. });
  151. // Creates arrow 1px away from bidding with rectangle
  152. const arrow = UI.createElement("arrow", {
  153. x: 0,
  154. y: 0,
  155. size: 49,
  156. });
  157. expect(arrow.endBinding).toBe(null);
  158. mouse.downAt(49, 49);
  159. mouse.moveTo(51, 0);
  160. mouse.up(0, 0);
  161. // Test sticky connection
  162. expect(API.getSelectedElement().type).toBe("arrow");
  163. Keyboard.keyPress(KEYS.ARROW_RIGHT);
  164. expect(arrow.endBinding?.elementId).toBe(rectangle.id);
  165. Keyboard.keyPress(KEYS.ARROW_LEFT);
  166. expect(arrow.endBinding?.elementId).toBe(rectangle.id);
  167. // Sever connection
  168. expect(API.getSelectedElement().type).toBe("arrow");
  169. Keyboard.keyPress(KEYS.ARROW_LEFT);
  170. expect(arrow.endBinding).toBe(null);
  171. Keyboard.keyPress(KEYS.ARROW_RIGHT);
  172. expect(arrow.endBinding).toBe(null);
  173. });
  174. it("should unbind on bound element deletion", () => {
  175. const rectangle = UI.createElement("rectangle", {
  176. x: 60,
  177. y: 0,
  178. size: 100,
  179. });
  180. const arrow = UI.createElement("arrow", {
  181. x: 0,
  182. y: 0,
  183. size: 50,
  184. });
  185. expect(arrow.endBinding?.elementId).toBe(rectangle.id);
  186. mouse.select(rectangle);
  187. expect(API.getSelectedElement().type).toBe("rectangle");
  188. Keyboard.keyDown(KEYS.DELETE);
  189. expect(arrow.endBinding).toBe(null);
  190. });
  191. it("should unbind on text element deletion by submitting empty text", async () => {
  192. const text = API.createElement({
  193. type: "text",
  194. text: "ola",
  195. x: 60,
  196. y: 0,
  197. width: 100,
  198. height: 100,
  199. });
  200. API.setElements([text]);
  201. const arrow = UI.createElement("arrow", {
  202. x: 0,
  203. y: 0,
  204. size: 50,
  205. });
  206. expect(arrow.endBinding?.elementId).toBe(text.id);
  207. // edit text element and submit
  208. // -------------------------------------------------------------------------
  209. UI.clickTool("text");
  210. mouse.clickAt(text.x + 50, text.y + 50);
  211. const editor = await getTextEditor();
  212. fireEvent.change(editor, { target: { value: "" } });
  213. fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
  214. expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
  215. expect(arrow.endBinding).toBe(null);
  216. });
  217. it("should keep binding on text update", async () => {
  218. const text = API.createElement({
  219. type: "text",
  220. text: "ola",
  221. x: 60,
  222. y: 0,
  223. width: 100,
  224. height: 100,
  225. });
  226. API.setElements([text]);
  227. const arrow = UI.createElement("arrow", {
  228. x: 0,
  229. y: 0,
  230. size: 50,
  231. });
  232. expect(arrow.endBinding?.elementId).toBe(text.id);
  233. // delete text element by submitting empty text
  234. // -------------------------------------------------------------------------
  235. UI.clickTool("text");
  236. mouse.clickAt(text.x + 50, text.y + 50);
  237. const editor = await getTextEditor();
  238. expect(editor).not.toBe(null);
  239. fireEvent.change(editor, { target: { value: "asdasdasdasdas" } });
  240. fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
  241. expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
  242. expect(arrow.endBinding?.elementId).toBe(text.id);
  243. });
  244. it("should update binding when text containerized", async () => {
  245. const rectangle1 = API.createElement({
  246. type: "rectangle",
  247. id: "rectangle1",
  248. width: 100,
  249. height: 100,
  250. boundElements: [
  251. { id: "arrow1", type: "arrow" },
  252. { id: "arrow2", type: "arrow" },
  253. ],
  254. });
  255. const arrow1 = API.createElement({
  256. type: "arrow",
  257. id: "arrow1",
  258. points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)],
  259. startBinding: {
  260. elementId: "rectangle1",
  261. focus: 0.2,
  262. gap: 7,
  263. fixedPoint: [0.5, 1],
  264. },
  265. endBinding: {
  266. elementId: "text1",
  267. focus: 0.2,
  268. gap: 7,
  269. fixedPoint: [1, 0.5],
  270. },
  271. });
  272. const arrow2 = API.createElement({
  273. type: "arrow",
  274. id: "arrow2",
  275. points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)],
  276. startBinding: {
  277. elementId: "text1",
  278. focus: 0.2,
  279. gap: 7,
  280. fixedPoint: [0.5, 1],
  281. },
  282. endBinding: {
  283. elementId: "rectangle1",
  284. focus: 0.2,
  285. gap: 7,
  286. fixedPoint: [1, 0.5],
  287. },
  288. });
  289. const text1 = API.createElement({
  290. type: "text",
  291. id: "text1",
  292. text: "ola",
  293. boundElements: [
  294. { id: "arrow1", type: "arrow" },
  295. { id: "arrow2", type: "arrow" },
  296. ],
  297. });
  298. API.setElements([rectangle1, arrow1, arrow2, text1]);
  299. API.setSelectedElements([text1]);
  300. expect(h.state.selectedElementIds[text1.id]).toBe(true);
  301. API.executeAction(actionWrapTextInContainer);
  302. // new text container will be placed before the text element
  303. const container = h.elements.at(-2)!;
  304. expect(container.type).toBe("rectangle");
  305. expect(container.id).not.toBe(rectangle1.id);
  306. expect(container).toEqual(
  307. expect.objectContaining({
  308. boundElements: expect.arrayContaining([
  309. {
  310. type: "text",
  311. id: text1.id,
  312. },
  313. {
  314. type: "arrow",
  315. id: arrow1.id,
  316. },
  317. {
  318. type: "arrow",
  319. id: arrow2.id,
  320. },
  321. ]),
  322. }),
  323. );
  324. expect(arrow1.startBinding?.elementId).toBe(rectangle1.id);
  325. expect(arrow1.endBinding?.elementId).toBe(container.id);
  326. expect(arrow2.startBinding?.elementId).toBe(container.id);
  327. expect(arrow2.endBinding?.elementId).toBe(rectangle1.id);
  328. });
  329. // #6459
  330. it("should unbind arrow only from the latest element", () => {
  331. const rectLeft = UI.createElement("rectangle", {
  332. x: 0,
  333. width: 200,
  334. height: 500,
  335. });
  336. const rectRight = UI.createElement("rectangle", {
  337. x: 400,
  338. width: 200,
  339. height: 500,
  340. });
  341. const arrow = UI.createElement("arrow", {
  342. x: 210,
  343. y: 250,
  344. width: 180,
  345. height: 1,
  346. });
  347. expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
  348. expect(arrow.endBinding?.elementId).toBe(rectRight.id);
  349. // Drag arrow off of bound rectangle range
  350. const handles = getTransformHandles(
  351. arrow,
  352. h.state.zoom,
  353. arrayToMap(h.elements),
  354. "mouse",
  355. ).se!;
  356. Keyboard.keyDown(KEYS.CTRL_OR_CMD);
  357. const elX = handles[0] + handles[2] / 2;
  358. const elY = handles[1] + handles[3] / 2;
  359. mouse.downAt(elX, elY);
  360. mouse.moveTo(300, 400);
  361. mouse.up();
  362. expect(arrow.startBinding).not.toBe(null);
  363. expect(arrow.endBinding).toBe(null);
  364. });
  365. it("should not unbind when duplicating via selection group", () => {
  366. const rectLeft = UI.createElement("rectangle", {
  367. x: 0,
  368. width: 200,
  369. height: 500,
  370. });
  371. const rectRight = UI.createElement("rectangle", {
  372. x: 400,
  373. y: 200,
  374. width: 200,
  375. height: 500,
  376. });
  377. const arrow = UI.createElement("arrow", {
  378. x: 210,
  379. y: 250,
  380. width: 177,
  381. height: 1,
  382. });
  383. expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
  384. expect(arrow.endBinding?.elementId).toBe(rectRight.id);
  385. mouse.downAt(-100, -100);
  386. mouse.moveTo(650, 750);
  387. mouse.up(0, 0);
  388. expect(API.getSelectedElements().length).toBe(3);
  389. mouse.moveTo(5, 5);
  390. Keyboard.withModifierKeys({ alt: true }, () => {
  391. mouse.downAt(5, 5);
  392. mouse.moveTo(1000, 1000);
  393. mouse.up(0, 0);
  394. expect(window.h.elements.length).toBe(6);
  395. window.h.elements.forEach((element) => {
  396. if (isLinearElement(element)) {
  397. expect(element.startBinding).not.toBe(null);
  398. expect(element.endBinding).not.toBe(null);
  399. } else {
  400. expect(element.boundElements).not.toBe(null);
  401. }
  402. });
  403. });
  404. });
  405. });