diff options
Diffstat (limited to 'packages/excalidraw/tests/binding.test.tsx')
| -rw-r--r-- | packages/excalidraw/tests/binding.test.tsx | 481 |
1 files changed, 481 insertions, 0 deletions
diff --git a/packages/excalidraw/tests/binding.test.tsx b/packages/excalidraw/tests/binding.test.tsx new file mode 100644 index 0000000..905a44e --- /dev/null +++ b/packages/excalidraw/tests/binding.test.tsx @@ -0,0 +1,481 @@ +import React from "react"; +import { fireEvent, render } from "./test-utils"; +import { Excalidraw, isLinearElement } from "../index"; +import { UI, Pointer, Keyboard } from "./helpers/ui"; +import { getTransformHandles } from "../element/transformHandles"; +import { API } from "./helpers/api"; +import { KEYS } from "../keys"; +import { actionWrapTextInContainer } from "../actions/actionBoundText"; +import { arrayToMap } from "../utils"; +import { pointFrom } from "@excalidraw/math"; + +const { h } = window; + +const mouse = new Pointer("mouse"); + +describe("element binding", () => { + beforeEach(async () => { + await render(<Excalidraw handleKeyboardGlobally={true} />); + }); + + it("should create valid binding if duplicate start/end points", async () => { + const rect = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 50, + height: 50, + }); + const arrow = API.createElement({ + type: "arrow", + x: 100, + y: 0, + width: 100, + height: 1, + points: [ + pointFrom(0, 0), + pointFrom(0, 0), + pointFrom(100, 0), + pointFrom(100, 0), + ], + }); + API.setElements([rect, arrow]); + expect(arrow.startBinding).toBe(null); + + // select arrow + mouse.clickAt(150, 0); + + // move arrow start to potential binding position + mouse.downAt(100, 0); + mouse.moveTo(55, 0); + mouse.up(0, 0); + + // Point selection is evaluated like the points are rendered, + // from right to left. So clicking on the first point should move the joint, + // not the start point. + expect(arrow.startBinding).toBe(null); + + // Now that the start point is free, move it into overlapping position + mouse.downAt(100, 0); + mouse.moveTo(55, 0); + mouse.up(0, 0); + + expect(API.getSelectedElements()).toEqual([arrow]); + + expect(arrow.startBinding).toEqual({ + elementId: rect.id, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }); + + // Move the end point to the overlapping binding position + mouse.downAt(200, 0); + mouse.moveTo(55, 0); + mouse.up(0, 0); + + // Both the start and the end points should be bound + expect(arrow.startBinding).toEqual({ + elementId: rect.id, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }); + expect(arrow.endBinding).toEqual({ + elementId: rect.id, + focus: expect.toBeNonNaNNumber(), + gap: expect.toBeNonNaNNumber(), + }); + }); + + //@TODO fix the test with rotation + it.skip("rotation of arrow should rebind both ends", () => { + const rectLeft = UI.createElement("rectangle", { + x: 0, + width: 200, + height: 500, + }); + const rectRight = UI.createElement("rectangle", { + x: 400, + width: 200, + height: 500, + }); + const arrow = UI.createElement("arrow", { + x: 210, + y: 250, + width: 180, + height: 1, + }); + expect(arrow.startBinding?.elementId).toBe(rectLeft.id); + expect(arrow.endBinding?.elementId).toBe(rectRight.id); + + const rotation = getTransformHandles( + arrow, + h.state.zoom, + arrayToMap(h.elements), + "mouse", + ).rotation!; + const rotationHandleX = rotation[0] + rotation[2] / 2; + const rotationHandleY = rotation[1] + rotation[3] / 2; + mouse.down(rotationHandleX, rotationHandleY); + mouse.move(300, 400); + mouse.up(); + expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI); + expect(arrow.angle).toBeLessThan(1.3 * Math.PI); + expect(arrow.startBinding?.elementId).toBe(rectRight.id); + expect(arrow.endBinding?.elementId).toBe(rectLeft.id); + }); + + // TODO fix & reenable once we rewrite tests to work with concurrency + it.skip( + "editing arrow and moving its head to bind it to element A, finalizing the" + + "editing by clicking on element A should end up selecting A", + async () => { + UI.createElement("rectangle", { + y: 0, + size: 100, + }); + // Create arrow bound to rectangle + UI.clickTool("arrow"); + mouse.down(50, -100); + mouse.up(0, 80); + + // Edit arrow with multi-point + mouse.doubleClick(); + // move arrow head + mouse.down(); + mouse.up(0, 10); + expect(API.getSelectedElement().type).toBe("arrow"); + + // NOTE this mouse down/up + await needs to be done in order to repro + // the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740 + mouse.reset(); + expect(h.state.editingLinearElement).not.toBe(null); + mouse.down(0, 0); + await new Promise((r) => setTimeout(r, 100)); + expect(h.state.editingLinearElement).toBe(null); + expect(API.getSelectedElement().type).toBe("rectangle"); + mouse.up(); + expect(API.getSelectedElement().type).toBe("rectangle"); + }, + ); + + it("should unbind arrow when moving it with keyboard", () => { + const rectangle = UI.createElement("rectangle", { + x: 75, + y: 0, + size: 100, + }); + + // Creates arrow 1px away from bidding with rectangle + const arrow = UI.createElement("arrow", { + x: 0, + y: 0, + size: 50, + }); + + expect(arrow.endBinding).toBe(null); + + mouse.downAt(50, 50); + mouse.moveTo(51, 0); + mouse.up(0, 0); + + // Test sticky connection + expect(API.getSelectedElement().type).toBe("arrow"); + Keyboard.keyPress(KEYS.ARROW_RIGHT); + expect(arrow.endBinding?.elementId).toBe(rectangle.id); + Keyboard.keyPress(KEYS.ARROW_LEFT); + expect(arrow.endBinding?.elementId).toBe(rectangle.id); + + // Sever connection + expect(API.getSelectedElement().type).toBe("arrow"); + Keyboard.keyPress(KEYS.ARROW_LEFT); + expect(arrow.endBinding).toBe(null); + Keyboard.keyPress(KEYS.ARROW_RIGHT); + expect(arrow.endBinding).toBe(null); + }); + + it("should unbind on bound element deletion", () => { + const rectangle = UI.createElement("rectangle", { + x: 60, + y: 0, + size: 100, + }); + + const arrow = UI.createElement("arrow", { + x: 0, + y: 0, + size: 50, + }); + + expect(arrow.endBinding?.elementId).toBe(rectangle.id); + + mouse.select(rectangle); + expect(API.getSelectedElement().type).toBe("rectangle"); + Keyboard.keyDown(KEYS.DELETE); + expect(arrow.endBinding).toBe(null); + }); + + it("should unbind on text element deletion by submitting empty text", async () => { + const text = API.createElement({ + type: "text", + text: "ola", + x: 60, + y: 0, + width: 100, + height: 100, + }); + + API.setElements([text]); + + const arrow = UI.createElement("arrow", { + x: 0, + y: 0, + size: 50, + }); + + expect(arrow.endBinding?.elementId).toBe(text.id); + + // edit text element and submit + // ------------------------------------------------------------------------- + + UI.clickTool("text"); + + mouse.clickAt(text.x + 50, text.y + 50); + + const editor = document.querySelector( + ".excalidraw-textEditorContainer > textarea", + ) as HTMLTextAreaElement; + + expect(editor).not.toBe(null); + + fireEvent.change(editor, { target: { value: "" } }); + fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); + + expect( + document.querySelector(".excalidraw-textEditorContainer > textarea"), + ).toBe(null); + expect(arrow.endBinding).toBe(null); + }); + + it("should keep binding on text update", async () => { + const text = API.createElement({ + type: "text", + text: "ola", + x: 60, + y: 0, + width: 100, + height: 100, + }); + + API.setElements([text]); + + const arrow = UI.createElement("arrow", { + x: 0, + y: 0, + size: 50, + }); + + expect(arrow.endBinding?.elementId).toBe(text.id); + + // delete text element by submitting empty text + // ------------------------------------------------------------------------- + + UI.clickTool("text"); + + mouse.clickAt(text.x + 50, text.y + 50); + const editor = document.querySelector( + ".excalidraw-textEditorContainer > textarea", + ) as HTMLTextAreaElement; + + expect(editor).not.toBe(null); + + fireEvent.change(editor, { target: { value: "asdasdasdasdas" } }); + fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); + + expect( + document.querySelector(".excalidraw-textEditorContainer > textarea"), + ).toBe(null); + expect(arrow.endBinding?.elementId).toBe(text.id); + }); + + it("should update binding when text containerized", async () => { + const rectangle1 = API.createElement({ + type: "rectangle", + id: "rectangle1", + width: 100, + height: 100, + boundElements: [ + { id: "arrow1", type: "arrow" }, + { id: "arrow2", type: "arrow" }, + ], + }); + + const arrow1 = API.createElement({ + type: "arrow", + id: "arrow1", + points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)], + startBinding: { + elementId: "rectangle1", + focus: 0.2, + gap: 7, + fixedPoint: [0.5, 1], + }, + endBinding: { + elementId: "text1", + focus: 0.2, + gap: 7, + fixedPoint: [1, 0.5], + }, + }); + + const arrow2 = API.createElement({ + type: "arrow", + id: "arrow2", + points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)], + startBinding: { + elementId: "text1", + focus: 0.2, + gap: 7, + fixedPoint: [0.5, 1], + }, + endBinding: { + elementId: "rectangle1", + focus: 0.2, + gap: 7, + fixedPoint: [1, 0.5], + }, + }); + + const text1 = API.createElement({ + type: "text", + id: "text1", + text: "ola", + boundElements: [ + { id: "arrow1", type: "arrow" }, + { id: "arrow2", type: "arrow" }, + ], + }); + + API.setElements([rectangle1, arrow1, arrow2, text1]); + + API.setSelectedElements([text1]); + + expect(h.state.selectedElementIds[text1.id]).toBe(true); + + API.executeAction(actionWrapTextInContainer); + + // new text container will be placed before the text element + const container = h.elements.at(-2)!; + + expect(container.type).toBe("rectangle"); + expect(container.id).not.toBe(rectangle1.id); + + expect(container).toEqual( + expect.objectContaining({ + boundElements: expect.arrayContaining([ + { + type: "text", + id: text1.id, + }, + { + type: "arrow", + id: arrow1.id, + }, + { + type: "arrow", + id: arrow2.id, + }, + ]), + }), + ); + + expect(arrow1.startBinding?.elementId).toBe(rectangle1.id); + expect(arrow1.endBinding?.elementId).toBe(container.id); + expect(arrow2.startBinding?.elementId).toBe(container.id); + expect(arrow2.endBinding?.elementId).toBe(rectangle1.id); + }); + + // #6459 + it("should unbind arrow only from the latest element", () => { + const rectLeft = UI.createElement("rectangle", { + x: 0, + width: 200, + height: 500, + }); + const rectRight = UI.createElement("rectangle", { + x: 400, + width: 200, + height: 500, + }); + const arrow = UI.createElement("arrow", { + x: 210, + y: 250, + width: 180, + height: 1, + }); + expect(arrow.startBinding?.elementId).toBe(rectLeft.id); + expect(arrow.endBinding?.elementId).toBe(rectRight.id); + + // Drag arrow off of bound rectangle range + const handles = getTransformHandles( + arrow, + h.state.zoom, + arrayToMap(h.elements), + "mouse", + ).se!; + + Keyboard.keyDown(KEYS.CTRL_OR_CMD); + const elX = handles[0] + handles[2] / 2; + const elY = handles[1] + handles[3] / 2; + mouse.downAt(elX, elY); + mouse.moveTo(300, 400); + mouse.up(); + + expect(arrow.startBinding).not.toBe(null); + expect(arrow.endBinding).toBe(null); + }); + + it("should not unbind when duplicating via selection group", () => { + const rectLeft = UI.createElement("rectangle", { + x: 0, + width: 200, + height: 500, + }); + const rectRight = UI.createElement("rectangle", { + x: 400, + y: 200, + width: 200, + height: 500, + }); + const arrow = UI.createElement("arrow", { + x: 210, + y: 250, + width: 177, + height: 1, + }); + expect(arrow.startBinding?.elementId).toBe(rectLeft.id); + expect(arrow.endBinding?.elementId).toBe(rectRight.id); + + mouse.downAt(-100, -100); + mouse.moveTo(650, 750); + mouse.up(0, 0); + + expect(API.getSelectedElements().length).toBe(3); + + mouse.moveTo(5, 5); + Keyboard.withModifierKeys({ alt: true }, () => { + mouse.downAt(5, 5); + mouse.moveTo(1000, 1000); + mouse.up(0, 0); + + expect(window.h.elements.length).toBe(6); + window.h.elements.forEach((element) => { + if (isLinearElement(element)) { + expect(element.startBinding).not.toBe(null); + expect(element.endBinding).not.toBe(null); + } else { + expect(element.boundElements).not.toBe(null); + } + }); + }); + }); +}); |
