diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 39f3948269..117fd2220c 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -408,19 +408,13 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = ( const otherIsInsideBinding = !!appState.selectedLinearElement?.pointerDownState.arrowStartIsInside; - // We need to "jump" the start point out with the detached - // focus point of the center of the bound element - invariant( - arrowOriginalStartPoint, - "Arrow original start point must be defined", - ); const other: BindingStrategy = { mode: otherIsInsideBinding ? "inside" : "orbit", element: otherElement, focusPoint: snapToCenter( otherElement, elementsMap, - arrowOriginalStartPoint, + arrowOriginalStartPoint ?? pointFrom(arrow.x, arrow.y), ), }; diff --git a/packages/element/src/resizeElements.ts b/packages/element/src/resizeElements.ts index b059e0dec4..feb52d177a 100644 --- a/packages/element/src/resizeElements.ts +++ b/packages/element/src/resizeElements.ts @@ -222,7 +222,19 @@ const rotateSingleElement = ( } const boundTextElementId = getBoundTextElementId(element); - scene.mutateElement(element, { angle }); + let update: ElementUpdate = { + angle, + }; + + if (isBindingElement(element)) { + update = { + ...update, + startBinding: null, + endBinding: null, + } as ElementUpdate; + } + + scene.mutateElement(element, update); if (boundTextElementId) { const textElement = scene.getElement(boundTextElementId); diff --git a/packages/element/tests/binding.test.tsx b/packages/element/tests/binding.test.tsx index c6fbf1a83a..35892d1c53 100644 --- a/packages/element/tests/binding.test.tsx +++ b/packages/element/tests/binding.test.tsx @@ -22,735 +22,708 @@ import { TEXT_EDITOR_SELECTOR, } from "../../excalidraw/tests/queries/dom"; -import type { ExcalidrawLinearElement, FixedPointBinding } from "../src/types"; +import type { + ExcalidrawArrowElement, + ExcalidrawLinearElement, + FixedPointBinding, +} from "../src/types"; const { h } = window; const mouse = new Pointer("mouse"); -describe("element binding", () => { - beforeEach(async () => { - await render(); +describe("binding for simple arrows", () => { + describe("when both endpoints are bound inside the same element", () => { + beforeEach(async () => { + mouse.reset(); + + await act(() => { + return setLanguage(defaultLang); + }); + await render(); + }); + + it("should create an `inside` binding", () => { + // Create a rectangle + UI.clickTool("rectangle"); + mouse.reset(); + mouse.downAt(100, 100); + mouse.moveTo(200, 200); + mouse.up(); + + const rect = API.getSelectedElement(); + + // Draw arrow with endpoint inside the filled rectangle + UI.clickTool("arrow"); + mouse.downAt(110, 110); + mouse.moveTo(160, 160); + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + expect(arrow.x).toBe(110); + expect(arrow.y).toBe(110); + + // Should bind to the rectangle since endpoint is inside + expect(arrow.startBinding?.elementId).toBe(rect.id); + expect(arrow.endBinding?.elementId).toBe(rect.id); + + const startBinding = arrow.startBinding as FixedPointBinding; + expect(startBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0); + expect(startBinding.fixedPoint[0]).toBeLessThanOrEqual(1); + expect(startBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0); + expect(startBinding.fixedPoint[1]).toBeLessThanOrEqual(1); + expect(startBinding.mode).toBe("inside"); + + const endBinding = arrow.endBinding as FixedPointBinding; + expect(endBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0); + expect(endBinding.fixedPoint[0]).toBeLessThanOrEqual(1); + expect(endBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0); + expect(endBinding.fixedPoint[1]).toBeLessThanOrEqual(1); + expect(endBinding.mode).toBe("inside"); + + // Move the bindable + mouse.downAt(100, 150); + mouse.moveTo(280, 110); + mouse.up(); + + // Check if the arrow moved + expect(arrow.x).toBe(290); + expect(arrow.y).toBe(70); + + // Restore bindable + mouse.reset(); + mouse.downAt(280, 110); + mouse.moveTo(130, 110); + mouse.up(); + + // Move the start point of the arrow to check if + // the behavior remains the same for old arrows + mouse.reset(); + mouse.downAt(110, 110); + mouse.moveTo(120, 120); + mouse.up(); + + // Move the bindable again + mouse.reset(); + mouse.downAt(130, 110); + mouse.moveTo(280, 110); + mouse.up(); + + // Check if the arrow moved + expect(arrow.x).toBe(290); + expect(arrow.y).toBe(70); + }); + + it("3+ point arrow should be dragged along with the bindable", () => { + // Create two rectangles as binding targets + const rectLeft = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + }); + + const rectRight = API.createElement({ + type: "rectangle", + x: 300, + y: 0, + width: 100, + height: 100, + }); + + // Create a non-elbowed arrow with inner points bound to different elements + const arrow = API.createElement({ + type: "arrow", + x: 100, + y: 50, + width: 200, + height: 0, + points: [ + pointFrom(0, 0), // start point + pointFrom(50, -20), // first inner point + pointFrom(150, 20), // second inner point + pointFrom(200, 0), // end point + ], + startBinding: { + elementId: rectLeft.id, + fixedPoint: [0.5, 0.5], + mode: "orbit", + }, + endBinding: { + elementId: rectRight.id, + fixedPoint: [0.5, 0.5], + mode: "orbit", + }, + }); + + API.setElements([rectLeft, rectRight, arrow]); + + // Store original inner point positions + const originalInnerPoint1 = [...arrow.points[1]]; + const originalInnerPoint2 = [...arrow.points[2]]; + + // Move the right rectangle down by 50 pixels + mouse.reset(); + mouse.downAt(350, 50); // Click on the right rectangle + mouse.moveTo(350, 100); // Move it down + mouse.up(); + + // Verify that inner points did NOT move when bound to different elements + // The arrow should NOT translate inner points proportionally when only one end moves + expect(arrow.points[1][0]).toBe(originalInnerPoint1[0]); + expect(arrow.points[1][1]).toBe(originalInnerPoint1[1]); + expect(arrow.points[2][0]).toBe(originalInnerPoint2[0]); + expect(arrow.points[2][1]).toBe(originalInnerPoint2[1]); + }); }); - //@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); + describe("when arrow is outside of shape", () => { + beforeEach(async () => { + mouse.reset(); - 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); + await act(() => { + return setLanguage(defaultLang); + }); + await render(); + }); + + it("should handle new arrow start point binding", () => { + // Create a rectangle + UI.clickTool("rectangle"); + mouse.downAt(100, 100); + mouse.moveTo(200, 200); + mouse.up(); + + const rectangle = API.getSelectedElement(); + + // Create arrow with arrow tool + UI.clickTool("arrow"); + mouse.downAt(150, 150); // Start inside rectangle + mouse.moveTo(250, 150); // End outside + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + + // Arrow should have start binding to rectangle + expect(arrow.startBinding?.elementId).toBe(rectangle.id); + expect(arrow.startBinding?.mode).toBe("orbit"); // Default is orbit, not inside + expect(arrow.endBinding).toBeNull(); + }); + + it("should handle new arrow end point binding", () => { + // Create a rectangle + UI.clickTool("rectangle"); + mouse.downAt(100, 100); + mouse.moveTo(200, 200); + mouse.up(); + + const rectangle = API.getSelectedElement(); + + // Create arrow with end point in binding zone + UI.clickTool("arrow"); + mouse.downAt(50, 150); // Start outside + mouse.moveTo(190, 190); // End near rectangle edge (should bind as orbit) + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + + // Arrow should have end binding to rectangle + expect(arrow.endBinding?.elementId).toBe(rectangle.id); + expect(arrow.endBinding?.mode).toBe("orbit"); + expect(arrow.startBinding).toBeNull(); + }); + + it("should create orbit binding when one of the cursor is inside rectangle", () => { + // Create a filled solid rectangle + UI.clickTool("rectangle"); + mouse.downAt(100, 100); + mouse.moveTo(200, 200); + mouse.up(); + + const rect = API.getSelectedElement(); + API.updateElement(rect, { + fillStyle: "solid", + backgroundColor: "#a5d8ff", + }); + + // Draw arrow with endpoint inside the filled rectangle, since only + // filled bindables bind inside the shape + UI.clickTool("arrow"); + mouse.downAt(10, 10); + mouse.moveTo(160, 160); + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + expect(arrow.x).toBe(10); + expect(arrow.y).toBe(10); + expect(arrow.width).toBeCloseTo(86.4669660940663); + expect(arrow.height).toBeCloseTo(86.46696609406821); + + // Should bind to the rectangle since endpoint is inside + expect(arrow.startBinding).toBe(null); + expect(arrow.endBinding?.elementId).toBe(rect.id); + + const endBinding = arrow.endBinding as FixedPointBinding; + expect(endBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0); + expect(endBinding.fixedPoint[0]).toBeLessThanOrEqual(1); + expect(endBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0); + expect(endBinding.fixedPoint[1]).toBeLessThanOrEqual(1); + + mouse.reset(); + + // Move the bindable + mouse.downAt(130, 110); + mouse.moveTo(280, 110); + mouse.up(); + + // Check if the arrow moved + expect(arrow.x).toBe(10); + expect(arrow.y).toBe(10); + expect(arrow.width).toBeCloseTo(235); + expect(arrow.height).toBeCloseTo(117.5); + + // Restore bindable + mouse.reset(); + mouse.downAt(280, 110); + mouse.moveTo(130, 110); + mouse.up(); + + // Move the arrow out + mouse.reset(); + mouse.click(10, 10); + mouse.downAt(96.466, 96.466); + mouse.moveTo(50, 50); + mouse.up(); + + expect(arrow.startBinding).toBe(null); + expect(arrow.endBinding).toBe(null); + + // Re-bind the arrow by moving the cursor inside the rectangle + mouse.reset(); + mouse.downAt(50, 50); + mouse.moveTo(150, 150); + mouse.up(); + + // Check if the arrow is still on the outside + expect(arrow.width).toBeCloseTo(86, 0); + expect(arrow.height).toBeCloseTo(86, 0); + }); + + it("should happen even if the arrow is not pointing at the element", () => { + // Create a rectangle positioned so the extended arrow segment will miss it + const rect = API.createElement({ + type: "rectangle", + x: 100, + y: 100, + width: 100, + height: 100, + }); + + API.setElements([rect]); + + // Draw an arrow that doesn't point at the rectangle (extended segment will miss) + UI.clickTool("arrow"); + mouse.reset(); + mouse.downAt(125, 93); // Start point + mouse.moveTo(175, 93); // End point - arrow direction is horizontal, misses rectangle + mouse.up(); + + const arrow = API.getSelectedElement() as ExcalidrawLinearElement; + + // Should create a fixed point binding since the extended line segment + // from the last arrow segment misses the rectangle + expect(arrow.startBinding?.elementId).toBe(rect.id); + expect(arrow.startBinding).toHaveProperty("fixedPoint"); + expect( + (arrow.startBinding as FixedPointBinding).fixedPoint[0], + ).toBeGreaterThanOrEqual(0); + expect( + (arrow.startBinding as FixedPointBinding).fixedPoint[0], + ).toBeLessThanOrEqual(1); + expect( + (arrow.startBinding as FixedPointBinding).fixedPoint[1], + ).toBeLessThanOrEqual(0.5); + expect( + (arrow.startBinding as FixedPointBinding).fixedPoint[1], + ).toBeLessThanOrEqual(1); + expect(arrow.endBinding).toBe(null); + }); }); - // 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", { + describe("", () => { + beforeEach(async () => { + mouse.reset(); + + await act(() => { + return setLanguage(defaultLang); + }); + await render(); + }); + + it( + "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 + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ENTER); + }); + + // move arrow head + mouse.down(); + mouse.up(0, 10); + expect(API.getSelectedElement().type).toBe("arrow"); + + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + mouse.reset(); + mouse.clickAt(-50, -50); + expect(h.state.selectedLinearElement?.isEditing).toBe(false); + expect(API.getSelectedElement().type).toBe("arrow"); + + // Edit arrow + Keyboard.withModifierKeys({ ctrl: true }, () => { + Keyboard.keyPress(KEYS.ENTER); + }); + expect(h.state.selectedLinearElement?.isEditing).toBe(true); + mouse.reset(); + mouse.clickAt(0, 0); + expect(h.state.selectedLinearElement).toBeNull(); + expect(API.getSelectedElement().type).toBe("rectangle"); + }, + ); + + it("should unbind on bound element deletion", () => { + const rectangle = UI.createElement("rectangle", { + x: 60, 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"); + const arrow = UI.createElement("arrow", { + x: 0, + y: 0, + size: 50, + }); - // 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.selectedLinearElement?.isEditing).toBe(true); - mouse.down(0, 0); - await new Promise((r) => setTimeout(r, 100)); - expect(h.state.selectedLinearElement?.isEditing).toBe(false); + 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 arrow when arrow is resized", () => { + 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!; + + 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(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, + expect(arrow.startBinding).toBe(null); + expect(arrow.endBinding).toBe(null); }); - // Creates arrow 1px away from bidding with rectangle - const arrow = UI.createElement("arrow", { - x: 0, - y: 0, - size: 49, + it("should unbind arrow when arrow is rotated", () => { + const rectLeft = UI.createElement("rectangle", { + x: 0, + width: 200, + height: 500, + }); + const rectRight = UI.createElement("rectangle", { + x: 400, + width: 200, + height: 500, + }); + + UI.clickTool("arrow"); + mouse.reset(); + mouse.clickAt(210, 250); + mouse.moveTo(300, 200); + mouse.clickAt(300, 200); + mouse.moveTo(390, 251); + mouse.clickAt(390, 251); + + const arrow = API.getSelectedElement() as ExcalidrawArrowElement; + + 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.reset(); + 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).toBeNull(); + expect(arrow.endBinding).toBeNull(); }); - 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(49, 49); - 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).not.toBe(null); - Keyboard.keyPress(KEYS.ARROW_RIGHT); - expect(arrow.endBinding).not.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 = await getTextEditor(); - - fireEvent.change(editor, { target: { value: "" } }); - fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); - - expect(document.querySelector(TEXT_EDITOR_SELECTOR)).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 = await getTextEditor(); - - expect(editor).not.toBe(null); - - fireEvent.change(editor, { target: { value: "asdasdasdasdas" } }); - fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); - - expect(document.querySelector(TEXT_EDITOR_SELECTOR)).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", - fixedPoint: [0.5, 1], - mode: "orbit", - }, - endBinding: { - elementId: "text1", - fixedPoint: [1, 0.5], - mode: "orbit", - }, - }); - - const arrow2 = API.createElement({ - type: "arrow", - id: "arrow2", - points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)], - startBinding: { - elementId: "text1", - fixedPoint: [0.5, 1], - mode: "orbit", - }, - endBinding: { - elementId: "rectangle1", - fixedPoint: [1, 0.5], - mode: "orbit", - }, - }); - - 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 when arrow is resized", () => { - 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!; - - 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).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.downAt(-100, -100); + mouse.moveTo(650, 750); 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); - } + 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); + } + }); }); }); }); -}); -describe("Fixed-point arrow binding", () => { - beforeEach(async () => { - await render(); - }); + describe("to text elements", () => { + beforeEach(async () => { + mouse.reset(); - it("should create fixed-point binding when both arrow endpoint is inside rectangle", () => { - // Create a filled solid rectangle - UI.clickTool("rectangle"); - mouse.downAt(100, 100); - mouse.moveTo(200, 200); - mouse.up(); - - const rect = API.getSelectedElement(); - API.updateElement(rect, { fillStyle: "solid", backgroundColor: "#a5d8ff" }); - - // Draw arrow with endpoint inside the filled rectangle, since only - // filled bindables bind inside the shape - UI.clickTool("arrow"); - mouse.downAt(110, 110); - mouse.moveTo(160, 160); - mouse.up(); - - const arrow = API.getSelectedElement() as ExcalidrawLinearElement; - expect(arrow.x).toBe(110); - expect(arrow.y).toBe(110); - - // Should bind to the rectangle since endpoint is inside - expect(arrow.startBinding?.elementId).toBe(rect.id); - expect(arrow.endBinding?.elementId).toBe(rect.id); - - const startBinding = arrow.startBinding as FixedPointBinding; - expect(startBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0); - expect(startBinding.fixedPoint[0]).toBeLessThanOrEqual(1); - expect(startBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0); - expect(startBinding.fixedPoint[1]).toBeLessThanOrEqual(1); - - const endBinding = arrow.endBinding as FixedPointBinding; - expect(endBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0); - expect(endBinding.fixedPoint[0]).toBeLessThanOrEqual(1); - expect(endBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0); - expect(endBinding.fixedPoint[1]).toBeLessThanOrEqual(1); - - mouse.reset(); - - // Move the bindable - mouse.downAt(130, 110); - mouse.moveTo(280, 110); - mouse.up(); - - // Check if the arrow moved - expect(arrow.x).toBe(260); - expect(arrow.y).toBe(110); - }); - - it("should create orbit binding when one of the arrow endpoint is inside rectangle", () => { - // Create a filled solid rectangle - UI.clickTool("rectangle"); - mouse.downAt(100, 100); - mouse.moveTo(200, 200); - mouse.up(); - - const rect = API.getSelectedElement(); - API.updateElement(rect, { fillStyle: "solid", backgroundColor: "#a5d8ff" }); - - // Draw arrow with endpoint inside the filled rectangle, since only - // filled bindables bind inside the shape - UI.clickTool("arrow"); - mouse.downAt(10, 10); - mouse.moveTo(160, 160); - mouse.up(); - - const arrow = API.getSelectedElement() as ExcalidrawLinearElement; - expect(arrow.x).toBe(10); - expect(arrow.y).toBe(10); - expect(arrow.width).toBeCloseTo(86.4669660940663); - expect(arrow.height).toBeCloseTo(86.46696609406821); - - // Should bind to the rectangle since endpoint is inside - expect(arrow.startBinding).toBe(null); - expect(arrow.endBinding?.elementId).toBe(rect.id); - - const endBinding = arrow.endBinding as FixedPointBinding; - expect(endBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0); - expect(endBinding.fixedPoint[0]).toBeLessThanOrEqual(1); - expect(endBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0); - expect(endBinding.fixedPoint[1]).toBeLessThanOrEqual(1); - - mouse.reset(); - - // Move the bindable - mouse.downAt(130, 110); - mouse.moveTo(280, 110); - mouse.up(); - - // Check if the arrow moved - expect(arrow.x).toBe(10); - expect(arrow.y).toBe(10); - expect(arrow.width).toBeCloseTo(235); - expect(arrow.height).toBeCloseTo(117.5); - }); - - it("should maintain relative position when arrow start point is dragged outside and rectangle is moved", () => { - // Create a filled solid rectangle - UI.clickTool("rectangle"); - mouse.downAt(100, 100); - mouse.moveTo(200, 200); - mouse.up(); - - const rect = API.getSelectedElement(); - API.updateElement(rect, { fillStyle: "solid", backgroundColor: "#a5d8ff" }); - - // Draw arrow with both endpoints inside the filled rectangle, creating same-element binding - UI.clickTool("arrow"); - mouse.downAt(120, 120); - mouse.moveTo(180, 180); - mouse.up(); - - const arrow = API.getSelectedElement() as ExcalidrawLinearElement; - - // Both ends should be bound to the same rectangle - expect(arrow.startBinding?.elementId).toBe(rect.id); - expect(arrow.endBinding?.elementId).toBe(rect.id); - - mouse.reset(); - - // Select the arrow and drag the start point outside the rectangle - mouse.downAt(120, 120); - mouse.moveTo(50, 50); // Move start point outside rectangle - mouse.up(); - - mouse.reset(); - - // Move the rectangle by dragging it - mouse.downAt(150, 110); - mouse.moveTo(300, 300); - mouse.up(); - - expect(arrow.x).toBe(50); - expect(arrow.y).toBe(50); - expect(arrow.width).toBeCloseTo(280, 0); - expect(arrow.height).toBeCloseTo(320, 0); - }); - - it("should move inner points when arrow is bound to same element on both ends", () => { - // Create one rectangle as binding target - const rect = API.createElement({ - type: "rectangle", - x: 50, - y: 50, - width: 200, - height: 100, - fillStyle: "solid", - backgroundColor: "#a5d8ff", + await act(() => { + return setLanguage(defaultLang); + }); + await render(); }); - // Create a non-elbowed arrow with inner points bound to the same element on both ends - const arrow = API.createElement({ - type: "arrow", - x: 100, - y: 75, - width: 100, - height: 50, - points: [ - pointFrom(0, 0), // start point - pointFrom(25, -25), // first inner point - pointFrom(75, 25), // second inner point - pointFrom(100, 0), // end point - ], - startBinding: { - elementId: rect.id, - fixedPoint: [0.25, 0.5], - mode: "orbit", - }, - endBinding: { - elementId: rect.id, - fixedPoint: [0.75, 0.5], - mode: "orbit", - }, + 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", + fixedPoint: [0.5, 1], + mode: "orbit", + }, + endBinding: { + elementId: "text1", + fixedPoint: [1, 0.5], + mode: "orbit", + }, + }); + + const arrow2 = API.createElement({ + type: "arrow", + id: "arrow2", + points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)], + startBinding: { + elementId: "text1", + fixedPoint: [0.5, 1], + mode: "orbit", + }, + endBinding: { + elementId: "rectangle1", + fixedPoint: [1, 0.5], + mode: "orbit", + }, + }); + + 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); }); - API.setElements([rect, arrow]); + it("should keep binding on text update", async () => { + const text = API.createElement({ + type: "text", + text: "ola", + x: 60, + y: 0, + width: 100, + height: 100, + }); - // Store original inner point positions (local coordinates) - const originalInnerPoint1 = [...arrow.points[1]]; - const originalInnerPoint2 = [...arrow.points[2]]; + API.setElements([text]); - // Move the rectangle - mouse.reset(); - mouse.downAt(150, 100); // Click on the rectangle - mouse.moveTo(300, 200); // Move it down and to the right - mouse.up(); + const arrow = UI.createElement("arrow", { + x: 0, + y: 0, + size: 50, + }); - // Verify that inner points moved with the arrow (same local coordinates) - // When both ends are bound to the same element, inner points should maintain - // their local coordinates relative to the arrow's origin - expect(arrow.points[1][0]).toBe(originalInnerPoint1[0]); - expect(arrow.points[1][1]).toBe(originalInnerPoint1[1]); - expect(arrow.points[2][0]).toBe(originalInnerPoint2[0]); - expect(arrow.points[2][1]).toBe(originalInnerPoint2[1]); - }); + expect(arrow.endBinding?.elementId).toBe(text.id); - it("should NOT move inner points when arrow is bound to different elements", () => { - // Create two rectangles as binding targets - const rectLeft = API.createElement({ - type: "rectangle", - x: 0, - y: 0, - width: 100, - height: 100, + // delete text element by submitting empty text + // ------------------------------------------------------------------------- + + UI.clickTool("text"); + + mouse.clickAt(text.x + 50, text.y + 50); + const editor = await getTextEditor(); + + expect(editor).not.toBe(null); + + fireEvent.change(editor, { target: { value: "asdasdasdasdas" } }); + fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); + + expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); + expect(arrow.endBinding?.elementId).toBe(text.id); }); - const rectRight = API.createElement({ - type: "rectangle", - x: 300, - y: 0, - width: 100, - height: 100, + 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 = await getTextEditor(); + + fireEvent.change(editor, { target: { value: "" } }); + fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); + + expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); + expect(arrow.endBinding).toBe(null); }); - - // Create a non-elbowed arrow with inner points bound to different elements - const arrow = API.createElement({ - type: "arrow", - x: 100, - y: 50, - width: 200, - height: 0, - points: [ - pointFrom(0, 0), // start point - pointFrom(50, -20), // first inner point - pointFrom(150, 20), // second inner point - pointFrom(200, 0), // end point - ], - startBinding: { - elementId: rectLeft.id, - fixedPoint: [0.5, 0.5], - mode: "orbit", - }, - endBinding: { - elementId: rectRight.id, - fixedPoint: [0.5, 0.5], - mode: "orbit", - }, - }); - - API.setElements([rectLeft, rectRight, arrow]); - - // Store original inner point positions - const originalInnerPoint1 = [...arrow.points[1]]; - const originalInnerPoint2 = [...arrow.points[2]]; - - // Move the right rectangle down by 50 pixels - mouse.reset(); - mouse.downAt(350, 50); // Click on the right rectangle - mouse.moveTo(350, 100); // Move it down - mouse.up(); - - // Verify that inner points did NOT move when bound to different elements - // The arrow should NOT translate inner points proportionally when only one end moves - expect(arrow.points[1][0]).toBe(originalInnerPoint1[0]); - expect(arrow.points[1][1]).toBe(originalInnerPoint1[1]); - expect(arrow.points[2][0]).toBe(originalInnerPoint2[0]); - expect(arrow.points[2][1]).toBe(originalInnerPoint2[1]); - }); -}); - -describe("line segment extension binding", () => { - beforeEach(async () => { - mouse.reset(); - - await act(() => { - return setLanguage(defaultLang); - }); - await render(); - }); - - it("should bind when extended segment intersects element", () => { - // Create a rectangle that will be intersected by the extended arrow segment - const rect = API.createElement({ - type: "rectangle", - x: 100, - y: 100, - width: 100, - height: 100, - }); - - API.setElements([rect]); - - // Draw an arrow that points at the rectangle (extended segment will intersect) - UI.clickTool("arrow"); - mouse.downAt(0, 0); // Start point - mouse.moveTo(120, 95); // End point - arrow direction points toward rectangle - mouse.up(); - - const arrow = API.getSelectedElement() as ExcalidrawLinearElement; - - // Should create a binding since the extended line segment - // from the last arrow segment intersects the rectangle - expect(arrow.endBinding?.elementId).toBe(rect.id); - expect(arrow.endBinding).toHaveProperty("mode"); - expect(arrow.endBinding).toHaveProperty("fixedPoint"); - }); - - it("should bind even if the arrow is not pointing at the element", () => { - // Create a rectangle positioned so the extended arrow segment will miss it - const rect = API.createElement({ - type: "rectangle", - x: 100, - y: 100, - width: 100, - height: 100, - }); - - API.setElements([rect]); - - // Draw an arrow that doesn't point at the rectangle (extended segment will miss) - UI.clickTool("arrow"); - mouse.reset(); - mouse.downAt(125, 93); // Start point - mouse.moveTo(175, 93); // End point - arrow direction is horizontal, misses rectangle - mouse.up(); - - const arrow = API.getSelectedElement() as ExcalidrawLinearElement; - - // Should create a fixed point binding since the extended line segment - // from the last arrow segment misses the rectangle - expect(arrow.startBinding?.elementId).toBe(rect.id); - expect(arrow.startBinding).toHaveProperty("fixedPoint"); - expect( - (arrow.startBinding as FixedPointBinding).fixedPoint[0], - ).toBeGreaterThanOrEqual(0); - expect( - (arrow.startBinding as FixedPointBinding).fixedPoint[0], - ).toBeLessThanOrEqual(1); - expect( - (arrow.startBinding as FixedPointBinding).fixedPoint[1], - ).toBeLessThanOrEqual(0.5); - expect( - (arrow.startBinding as FixedPointBinding).fixedPoint[1], - ).toBeLessThanOrEqual(1); - expect(arrow.endBinding).toBe(null); }); });