binding.test.tsx 13 KB

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