import { arrayToMap, reseed } from "@excalidraw/common"; import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math"; import { Excalidraw } from "@excalidraw/excalidraw"; import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { UI } from "@excalidraw/excalidraw/tests/helpers/ui"; import "@excalidraw/utils/test-utils"; import { render } from "@excalidraw/excalidraw/tests/test-utils"; import * as distance from "../src/distance"; import { hitElementItself } from "../src/collision"; describe("check rotated elements can be hit:", () => { beforeEach(async () => { localStorage.clear(); reseed(7); await render(); }); it("arrow", () => { UI.createElement("arrow", { x: 0, y: 0, width: 124, height: 302, angle: 1.8700426423973724, points: [ [0, 0], [120, -198], [-4, -302], ] as LocalPoint[], }); const hit = hitElementItself({ point: pointFrom(88, -68), element: window.h.elements[0], threshold: 10, elementsMap: window.h.scene.getNonDeletedElementsMap(), }); expect(hit).toBe(true); }); }); describe("hitElementItself cache", () => { beforeEach(async () => { // reset cache hitElementItself({ point: pointFrom(50, 50), element: API.createElement({ type: "rectangle", x: 0, y: 0, width: 100, height: 100, backgroundColor: "#ffffff", }), threshold: Infinity, elementsMap: new Map([]), }); localStorage.clear(); reseed(7); await render(); }); it("reuses cached result when threshold increases", () => { const element = API.createElement({ type: "rectangle", x: 0, y: 0, width: 100, height: 100, backgroundColor: "#ffffff", }); const elementsMap = arrayToMap([element]); const point = pointFrom(100.5, 50); const distanceSpy = jest.spyOn(distance, "distanceToElement"); expect( hitElementItself({ point, element, threshold: 1, elementsMap, }), ).toBe(true); expect(distanceSpy).toHaveBeenCalledTimes(1); expect( hitElementItself({ point, element, threshold: 10, elementsMap, }), ).toBe(true); expect(distanceSpy).toHaveBeenCalledTimes(1); distanceSpy.mockRestore(); }); it("does not reuse cache when threshold decreases", () => { const element = API.createElement({ type: "rectangle", x: 0, y: 0, width: 100, height: 100, backgroundColor: "transparent", }); const elementsMap = arrayToMap([element]); const point = pointFrom(105, 50); const distanceSpy = jest.spyOn(distance, "distanceToElement"); expect( hitElementItself({ point, element, threshold: 10, elementsMap, }), ).toBe(true); expect(distanceSpy).toHaveBeenCalledTimes(1); expect( hitElementItself({ point, element, threshold: 6, elementsMap, }), ).toBe(true); expect(distanceSpy).toHaveBeenCalledTimes(2); distanceSpy.mockRestore(); }); it("invalidates cache when element version changes", () => { const element = API.createElement({ type: "rectangle", x: 0, y: 0, width: 100, height: 100, backgroundColor: "#ffffff", }); const elementsMap = arrayToMap([element]); const point = pointFrom(100.5, 50); const distanceSpy = jest.spyOn(distance, "distanceToElement"); expect( hitElementItself({ point, element, threshold: 1, elementsMap, }), ).toBe(true); expect(distanceSpy).toHaveBeenCalledTimes(1); const movedElement = { ...element, version: element.version + 1, versionNonce: element.versionNonce + 1, }; expect( hitElementItself({ point, element: movedElement, threshold: 1, elementsMap, }), ).toBe(true); expect(distanceSpy).toHaveBeenCalledTimes(2); distanceSpy.mockRestore(); }); it("override does not affect caching", () => { const element = API.createElement({ type: "rectangle", x: 0, y: 0, width: 100, height: 100, backgroundColor: "transparent", }); const elementsMap = arrayToMap([element]); const point = pointFrom(50, 50); const distanceSpy = jest.spyOn(distance, "distanceToElement"); expect( hitElementItself({ point, element, threshold: 10, elementsMap, }), ).toBe(false); expect(distanceSpy).toHaveBeenCalledTimes(1); expect( hitElementItself({ point, element, threshold: 10, elementsMap, overrideShouldTestInside: true, }), ).toBe(true); }); }); describe("freedraw collision matches the rendered stroke width", () => { // A straight, horizontal stroke centered on y === 0. const points = Array.from({ length: 21 }, (_, i) => pointFrom(i * 5, 0), ); const createFreeDraw = (variability: "variable" | "constant") => API.createElement({ type: "freedraw", x: 0, y: 0, strokeWidth: 10, points, strokeOptions: { variability, streamline: 0.5 }, }); const distanceAt = ( element: ReturnType, x: number, y: number, ) => distance.distanceToElement( element, arrayToMap([element]), pointFrom(x, y), ); it("treats a point on the centerline as a direct hit for both modes", () => { expect(distanceAt(createFreeDraw("variable"), 50, 0)).toBe(0); expect(distanceAt(createFreeDraw("constant"), 50, 0)).toBe(0); }); it("hits across the body of a thick stroke, not just near the centerline", () => { expect(distanceAt(createFreeDraw("variable"), 50, 20)).toBe(0); }); it("uses a wider hit area for variable-width than for constant-width strokes", () => { const offset = 20; const variableDistance = distanceAt(createFreeDraw("variable"), 50, offset); const constantDistance = distanceAt(createFreeDraw("constant"), 50, offset); expect(variableDistance).toBe(0); expect(constantDistance).toBeGreaterThan(0); expect(variableDistance).toBeLessThan(constantDistance); }); it("does not hit points clearly outside even the widest stroke", () => { expect(distanceAt(createFreeDraw("variable"), 50, 45)).toBeGreaterThan(0); }); });