import { reseed } from "@excalidraw/common"; import { isElbowArrow, projectFixedPointOntoDiagonal, } from "@excalidraw/element"; import { pointFrom } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint } from "@excalidraw/math"; import type { ExcalidrawArrowElement, ExcalidrawBindableElement, ExcalidrawElement, } from "@excalidraw/element/types"; import { actionToggleArrowBinding } from "../actions/actionToggleArrowBinding"; import { Excalidraw, sceneCoordsToViewportCoords } from "../index"; import { API } from "./helpers/api"; import { Keyboard, Pointer, UI } from "./helpers/ui"; import { render, fireEvent, mockBoundingClientRect, restoreOriginalGetBoundingClientRect, waitFor, unmountComponent, } from "./test-utils"; unmountComponent(); const { h } = window; const mouse = new Pointer("mouse"); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Fire Ctrl (or Meta on Mac) keydown on document. */ const ctrlKeyDown = (extra: Partial = {}) => fireEvent.keyDown(document, { key: "Control", code: "ControlLeft", ctrlKey: true, repeat: false, ...extra, }); /** Fire Ctrl (or Meta on Mac) keyup on document (ctrlKey is false on keyup). */ const ctrlKeyUp = () => fireEvent.keyUp(document, { key: "Control", code: "ControlLeft", ctrlKey: false, }); // --------------------------------------------------------------------------- describe("Arrow binding – non-default case (bindingPreference: disabled)", () => { beforeAll(() => { mockBoundingClientRect(); }); afterAll(() => { restoreOriginalGetBoundingClientRect(); }); beforeEach(async () => { localStorage.clear(); reseed(7); await render(); h.state.width = 1920; h.state.height = 1080; }); afterEach(() => { mouse.reset(); }); // ------------------------------------------------------------------------- // actionToggleArrowBinding // ------------------------------------------------------------------------- describe("actionToggleArrowBinding", () => { it("isBindingEnabled defaults to true", () => { expect(h.state.isBindingEnabled).toBe(true); }); it("checked() reflects the current bindingPreference value", () => { expect(actionToggleArrowBinding.checked!(h.state)).toBe(true); API.setAppState({ bindingPreference: "disabled", isBindingEnabled: false, }); expect(actionToggleArrowBinding.checked!(h.state)).toBe(false); }); it("executing the action toggles binding from enabled → disabled", () => { expect(h.state.isBindingEnabled).toBe(true); API.executeAction(actionToggleArrowBinding); expect(h.state.isBindingEnabled).toBe(false); expect(h.state.bindingPreference).toBe("disabled"); }); it("executing the action toggles binding from disabled → enabled", () => { API.setAppState({ bindingPreference: "disabled", isBindingEnabled: false, }); API.executeAction(actionToggleArrowBinding); expect(h.state.isBindingEnabled).toBe(true); expect(h.state.bindingPreference).toBe("enabled"); }); it("checked() returns false after action disables binding", () => { API.executeAction(actionToggleArrowBinding); // true → false expect(actionToggleArrowBinding.checked!(h.state)).toBe(false); }); }); // ------------------------------------------------------------------------- // Arrow does not bind when isBindingEnabled is false // ------------------------------------------------------------------------- describe("Arrow drawing with binding disabled", () => { /** * Baseline: verify binding IS created with binding enabled so we know the * spatial setup is correct. */ it("arrow startBinding is set when binding is enabled", async () => { API.setElements([ API.createElement({ type: "rectangle", id: "baselineRect", x: 100, y: 100, width: 400, height: 200, }), ]); expect(h.state.isBindingEnabled).toBe(true); UI.clickTool("arrow"); // Start inside the rectangle so startBinding can be created mouse.down(200, 200); mouse.up(700, 200); await waitFor(() => { const arrow = h.elements.find( (el): el is ExcalidrawArrowElement => el.type === "arrow", ); expect(arrow).toBeDefined(); expect(arrow!.startBinding).not.toBeNull(); expect(arrow!.startBinding!.elementId).toBe("baselineRect"); }); }); it("arrow has no startBinding when binding is disabled", async () => { API.setElements([ API.createElement({ type: "rectangle", id: "rect1", x: 100, y: 100, width: 400, height: 200, }), ]); API.setAppState({ bindingPreference: "disabled", isBindingEnabled: false, }); UI.clickTool("arrow"); // Start inside the rectangle – binding is off, so no startBinding mouse.down(200, 200); mouse.up(700, 200); await waitFor(() => { const arrow = h.elements.find( (el): el is ExcalidrawArrowElement => el.type === "arrow", ); expect(arrow).toBeDefined(); expect(arrow!.startBinding).toBeNull(); }); }); it("arrow has no endBinding when binding is disabled", async () => { API.setElements([ API.createElement({ type: "rectangle", id: "rect2", x: 500, y: 100, width: 300, height: 200, }), ]); API.setAppState({ bindingPreference: "disabled", isBindingEnabled: false, }); UI.clickTool("arrow"); // End inside the target rectangle – binding off -> no endBinding mouse.down(100, 200); mouse.up(600, 200); await waitFor(() => { const arrow = h.elements.find( (el): el is ExcalidrawArrowElement => el.type === "arrow", ); expect(arrow).toBeDefined(); expect(arrow!.endBinding).toBeNull(); }); }); it("re-enabling binding via action causes new arrows to bind again", async () => { API.setElements([ API.createElement({ type: "rectangle", id: "rect3", x: 100, y: 100, width: 400, height: 200, }), ]); // Disable then re-enable binding API.executeAction(actionToggleArrowBinding); // true -> false API.executeAction(actionToggleArrowBinding); // false -> true expect(h.state.isBindingEnabled).toBe(true); UI.clickTool("arrow"); mouse.down(200, 200); mouse.up(700, 200); await waitFor(() => { const arrow = h.elements.find( (el): el is ExcalidrawArrowElement => el.type === "arrow", ); expect(arrow).toBeDefined(); expect(arrow!.startBinding).not.toBeNull(); }); }); it("elbow arrow does not snap to rectangle when binding is disabled", async () => { const rect = API.createElement({ type: "rectangle", id: "rect-elbow-no-snap", x: 900, y: 500, width: 200, height: 120, }) as ExcalidrawBindableElement; API.setElements([rect]); // Turn off arrow binding API.setAppState({ bindingPreference: "disabled", isBindingEnabled: false, isMidpointSnappingEnabled: false, }); expect(h.state.isBindingEnabled).toBe(false); // Create the elbow arrow UI.clickTool("arrow"); API.setAppState({ currentItemArrowType: "elbow" }); mouse.downAt(700, 400); mouse.upAt(760, 460); let arrow: ExcalidrawArrowElement; await waitFor(() => { const maybeArrow = h.elements.find(isElbowArrow); expect(maybeArrow).toBeDefined(); arrow = maybeArrow!; }); // Move the elbow arrow UI.clickTool("selection"); const insideRectanglePoint = pointFrom(1010, 570); const originalArrowEndGlobal = pointFrom( arrow!.x + arrow!.points[arrow!.points.length - 1][0], arrow!.y + arrow!.points[arrow!.points.length - 1][1], ); const originalArrowEndViewport = sceneCoordsToViewportCoords( { sceneX: originalArrowEndGlobal[0], sceneY: originalArrowEndGlobal[1], }, h.state, ); const insideRectangleViewport = sceneCoordsToViewportCoords( { sceneX: insideRectanglePoint[0], sceneY: insideRectanglePoint[1], }, h.state, ); // End point dragged inside the rectangle; with snapping enabled this // would snap to the rectangle outline. mouse.moveTo(originalArrowEndViewport.x, originalArrowEndViewport.y); mouse.down(); mouse.moveTo(insideRectangleViewport.x, insideRectangleViewport.y); mouse.up(); const updatedEnd = arrow!.points[arrow!.points.length - 1]; const updatedEndGlobal = pointFrom( arrow!.x + updatedEnd[0], arrow!.y + updatedEnd[1], ); expect(arrow!.startBinding).toBeNull(); expect(arrow!.endBinding).toBeNull(); expect(updatedEndGlobal).not.toEqual(originalArrowEndGlobal); expect(updatedEndGlobal).toEqual(insideRectanglePoint); }); }); // ------------------------------------------------------------------------- // Arrow does snap to midpoint when isMidpointSnappingEnabled is true // ------------------------------------------------------------------------- describe("Midpoint snapping", () => { it("does not snap to midpoint when midpoint snapping is turned off", () => { const rect = API.createElement({ type: "rectangle", id: "rectNoMidSnap", x: 100, y: 100, width: 400, height: 200, }) as ExcalidrawBindableElement; const arrow = API.createElement({ type: "arrow", x: 0, y: 250, width: 502, height: -48, points: [pointFrom(0, 0), pointFrom(502, -48)], }) as ExcalidrawArrowElement; const elementsMap = new Map([ [rect.id, rect], [arrow.id, arrow], ]); const point = pointFrom(502, 202); const snappedWithMidpoint = projectFixedPointOntoDiagonal( arrow, point, rect, "end", elementsMap, h.state.zoom, true, ); const snappedWithoutMidpoint = projectFixedPointOntoDiagonal( arrow, point, rect, "end", elementsMap, h.state.zoom, false, ); expect(snappedWithMidpoint).toEqual([500, 200]); expect(snappedWithoutMidpoint).not.toEqual([500, 200]); }); it("does not snap an angle-locked elbow binding to midpoint on finalize", async () => { const rect = API.createElement({ type: "rectangle", id: "rect-elbow-angle-lock", x: 600, y: 300, width: 200, height: 200, }) as ExcalidrawBindableElement; API.setElements([rect]); API.setAppState({ currentItemArrowType: "elbow", gridModeEnabled: false, isMidpointSnappingEnabled: true, }); const start = sceneCoordsToViewportCoords( { sceneX: 480, sceneY: 392 }, h.state, ); const target = sceneCoordsToViewportCoords( { sceneX: 596, sceneY: 397 }, h.state, ); UI.clickTool("arrow"); mouse.downAt(start.x, start.y); Keyboard.withModifierKeys({ shift: true }, () => { mouse.moveTo(target.x, target.y); mouse.upAt(); }); await waitFor(() => { const arrow = h.elements.find(isElbowArrow); expect(arrow).toBeDefined(); expect(arrow!.endBinding?.elementId).toBe(rect.id); const endY = arrow!.y + arrow!.points.at(-1)![1]; expect(endY).toBeCloseTo(397, 1); }); }); }); describe("Grid binding interactions", () => { it("does not snap angle-locked binding to grid when grid mode is disabled", async () => { const rect = API.createElement({ type: "rectangle", id: "rect-angle-lock-no-grid", x: 600, y: 300, width: 200, height: 200, }) as ExcalidrawBindableElement; API.setElements([rect]); API.setAppState({ gridModeEnabled: false, gridSize: 20 }); const start = sceneCoordsToViewportCoords( { sceneX: 300, sceneY: 333 }, h.state, ); const target = sceneCoordsToViewportCoords( { sceneX: 596, sceneY: 347 }, h.state, ); UI.clickTool("arrow"); mouse.downAt(start.x, start.y); Keyboard.withModifierKeys({ shift: true }, () => { mouse.moveTo(target.x, target.y); mouse.upAt(); }); await waitFor(() => { const arrow = h.elements.find( (element): element is ExcalidrawArrowElement => element.type === "arrow", ); expect(arrow).toBeDefined(); expect(arrow!.endBinding?.elementId).toBe(rect.id); const startY = arrow!.y + arrow!.points[0][1]; const endY = arrow!.y + arrow!.points.at(-1)![1]; expect(endY).toBeCloseTo(startY); }); }); it("uses the incoming direction to choose the grid axis for rotated bindables", async () => { const ellipse = API.createElement({ type: "ellipse", id: "ellipse-rotated-grid-binding", x: 610, y: 300, width: 240, height: 120, angle: Math.PI / 3, }) as ExcalidrawBindableElement; API.setElements([ellipse]); API.setAppState({ gridModeEnabled: true, gridSize: 20 }); const start = sceneCoordsToViewportCoords( { sceneX: 400, sceneY: 360 }, h.state, ); const target = sceneCoordsToViewportCoords( { sceneX: 658, sceneY: 360 }, h.state, ); UI.clickTool("arrow"); mouse.downAt(start.x, start.y); mouse.moveTo(target.x, target.y); mouse.upAt(); await waitFor(() => { const arrow = h.elements.find( (element): element is ExcalidrawArrowElement => element.type === "arrow", ); expect(arrow).toBeDefined(); expect(arrow!.endBinding?.elementId).toBe(ellipse.id); const startY = arrow!.y + arrow!.points[0][1]; const endY = arrow!.y + arrow!.points.at(-1)![1]; expect(endY).toBeCloseTo(startY, 1); }); }); }); // ------------------------------------------------------------------------- // Ctrl / Cmd key toggle // ------------------------------------------------------------------------- describe("Ctrl key toggle when binding preference is disabled", () => { it("Ctrl keydown temporarily enables binding when preference is disabled", () => { API.setAppState({ bindingPreference: "disabled", isBindingEnabled: false, }); ctrlKeyDown(); expect(h.state.isBindingEnabled).toBe(true); }); it("Ctrl keyup restores isBindingEnabled to false after the temporary toggle", () => { API.setAppState({ bindingPreference: "disabled", isBindingEnabled: false, }); ctrlKeyDown(); expect(h.state.isBindingEnabled).toBe(true); ctrlKeyUp(); expect(h.state.isBindingEnabled).toBe(false); }); it("full round-trip: off → Ctrl down → on → Ctrl up → off", () => { API.setAppState({ bindingPreference: "disabled", isBindingEnabled: false, }); ctrlKeyDown(); expect(h.state.isBindingEnabled).toBe(true); ctrlKeyUp(); expect(h.state.isBindingEnabled).toBe(false); }); it("full round-trip can be repeated after Ctrl release resets state", () => { API.setAppState({ bindingPreference: "disabled", isBindingEnabled: false, }); // First cycle ctrlKeyDown(); expect(h.state.isBindingEnabled).toBe(true); ctrlKeyUp(); expect(h.state.isBindingEnabled).toBe(false); // Second cycle ctrlKeyDown(); expect(h.state.isBindingEnabled).toBe(true); ctrlKeyUp(); expect(h.state.isBindingEnabled).toBe(false); }); }); describe("Ctrl key toggle when binding starts ON", () => { it("Ctrl keydown temporarily disables binding when it was on", () => { expect(h.state.isBindingEnabled).toBe(true); ctrlKeyDown(); expect(h.state.isBindingEnabled).toBe(false); }); it("Ctrl keyup restores isBindingEnabled to true after the temporary toggle", () => { expect(h.state.isBindingEnabled).toBe(true); ctrlKeyDown(); expect(h.state.isBindingEnabled).toBe(false); ctrlKeyUp(); expect(h.state.isBindingEnabled).toBe(true); }); }); // ------------------------------------------------------------------------- // event.repeat guard // ------------------------------------------------------------------------- describe("event.repeat guard", () => { it("Ctrl keydown with repeat=true does not toggle binding (preference: disabled)", () => { API.setAppState({ bindingPreference: "disabled", isBindingEnabled: false, }); ctrlKeyDown({ repeat: true }); // Must remain off – repeat events are ignored expect(h.state.isBindingEnabled).toBe(false); }); it("Ctrl keydown with repeat=true does not toggle binding (default: on)", () => { expect(h.state.isBindingEnabled).toBe(true); ctrlKeyDown({ repeat: true }); expect(h.state.isBindingEnabled).toBe(true); }); it("pressing another key while Ctrl held does not change binding state", () => { // Start ON, first Ctrl keydown flips to false expect(h.state.isBindingEnabled).toBe(true); ctrlKeyDown(); expect(h.state.isBindingEnabled).toBe(false); // A second keydown with Ctrl (e.g. pressing another key while Ctrl held) // should leave state unchanged fireEvent.keyDown(document, { key: "z", ctrlKey: true, repeat: false, }); expect(h.state.isBindingEnabled).toBe(false); // Ctrl keyup restores to preference value ctrlKeyUp(); expect(h.state.isBindingEnabled).toBe(true); }); }); // ------------------------------------------------------------------------- // View-mode guard // ------------------------------------------------------------------------- describe("View-mode guard", () => { it("Ctrl keydown in viewMode does not toggle isBindingEnabled", () => { API.setAppState({ bindingPreference: "disabled", isBindingEnabled: false, viewModeEnabled: true, }); ctrlKeyDown(); // Handler returns early in viewMode — state must stay false expect(h.state.isBindingEnabled).toBe(false); }); }); });