import React from "react"; import { vi } from "vitest"; import { KEYS, ROUNDNESS, arrayToMap, reseed } from "@excalidraw/common"; import { getElementBounds, getElementLineSegments, getElementsWithinSelection, } from "@excalidraw/element"; import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math"; import { TOOLS } from "../components/Tools"; import { Excalidraw } from "../index"; import * as InteractiveCanvas from "../renderer/interactiveScene"; import * as StaticScene from "../renderer/staticScene"; import { API } from "./helpers/api"; import { Keyboard, Pointer, UI } from "./helpers/ui"; import { act, render, fireEvent, mockBoundingClientRect, restoreOriginalGetBoundingClientRect, assertSelectedElements, unmountComponent, } from "./test-utils"; unmountComponent(); const renderInteractiveScene = vi.spyOn( InteractiveCanvas, "renderInteractiveScene", ); const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); beforeEach(() => { localStorage.clear(); renderInteractiveScene.mockClear(); renderStaticScene.mockClear(); reseed(7); }); const { h } = window; const mouse = new Pointer("mouse"); const getOutlineBounds = (element: ReturnType) => { const sceneElement = API.getElement(element); const elementsMap = h.scene.getNonDeletedElementsMap(); const points = getElementLineSegments(sceneElement, elementsMap).flat(); return [ Math.min(...points.map((point) => point[0])), Math.min(...points.map((point) => point[1])), Math.max(...points.map((point) => point[0])), Math.max(...points.map((point) => point[1])), ] as const; }; describe("box-selection", () => { beforeEach(async () => { await render(); }); it("should allow adding to selection via box-select when holding shift", async () => { const rect1 = API.createElement({ type: "rectangle", x: 0, y: 0, width: 50, height: 50, backgroundColor: "red", fillStyle: "solid", }); const rect2 = API.createElement({ type: "rectangle", x: 100, y: 0, width: 50, height: 50, }); API.setElements([rect1, rect2]); mouse.downAt(175, -20); mouse.move(-1000, -1000); mouse.moveTo(85, 70); mouse.up(); assertSelectedElements([rect2.id]); Keyboard.withModifierKeys({ shift: true }, () => { mouse.downAt(75, -20); mouse.move(-1000, -1000); mouse.moveTo(-15, 70); mouse.up(); }); assertSelectedElements([rect2.id, rect1.id]); }); it("should (de)select element when box-selecting over and out while not holding shift", async () => { const rect1 = API.createElement({ type: "rectangle", x: 0, y: 0, width: 50, height: 50, backgroundColor: "red", fillStyle: "solid", }); API.setElements([rect1]); mouse.downAt(75, -20); mouse.move(-1000, -1000); mouse.moveTo(-15, 70); assertSelectedElements([rect1.id]); mouse.moveTo(100, -100); assertSelectedElements([]); mouse.up(); assertSelectedElements([]); }); it("should not select an element when the selection box only partially overlaps it", () => { const rect1 = API.createElement({ type: "rectangle", x: 0, y: 0, width: 50, height: 50, backgroundColor: "red", fillStyle: "solid", }); API.setElements([rect1]); mouse.downAt(25, -20); mouse.move(-1000, -1000); mouse.moveTo(75, 70); mouse.up(); assertSelectedElements([]); }); }); describe("lasso reselection", () => { beforeEach(async () => { await render(); }); it("should allow ctrl+alt lasso reselection when starting inside the active common bounds", () => { const rectA = API.createElement({ type: "rectangle", x: 0, y: 0, width: 100, height: 100, backgroundColor: "red", fillStyle: "solid", }); const rectB = API.createElement({ type: "rectangle", x: 220, y: 0, width: 100, height: 100, backgroundColor: "blue", fillStyle: "solid", }); API.setElements([rectA, rectB]); mouse.select([rectA, rectB]); act(() => { h.app.setActiveTool({ type: "lasso" }); }); // NOTE: the lasso starts inside the common bounds of the selection, and // encloses rectA only (the default box selection mode being "contain") Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => { mouse.downAt(110, 50); mouse.moveTo(110, -50); expect(h.app.lassoTrail.hasCurrentTrail).toBe(true); mouse.moveTo(-50, -50); mouse.moveTo(-50, 150); mouse.moveTo(110, 150); mouse.moveTo(110, 50); mouse.up(); }); assertSelectedElements([rectA.id]); }); }); describe("box-selection overlap mode", () => { const boxSelect = ( startX: number, startY: number, endX: number, endY: number, ) => { mouse.downAt(startX, startY); mouse.move(-1000, -1000); mouse.moveTo(endX, endY); mouse.up(); }; const boxSelectTopLeftAabbCorner = ( element: ReturnType, ) => { const sceneElement = API.getElement(element); const elementsMap = h.scene.getNonDeletedElementsMap(); const [x1, y1] = getElementBounds(sceneElement, elementsMap); boxSelect(x1 + 2, y1 + 2, x1 + 12, y1 + 12); }; const boxSelectTopRightAabbCorner = ( element: ReturnType, ) => { const sceneElement = API.getElement(element); const elementsMap = h.scene.getNonDeletedElementsMap(); const [, y1, x2] = getElementBounds(sceneElement, elementsMap); boxSelect(x2 - 12, y1 + 2, x2 - 2, y1 + 12); }; const boxSelectTopLeftRotatedLocalBoundsCorner = ( element: ReturnType, ) => { const sceneElement = API.getElement(element); const elementsMap = h.scene.getNonDeletedElementsMap(); const [x1, y1, x2, y2] = getElementBounds(sceneElement, elementsMap, true); const center = pointFrom((x1 + x2) / 2, (y1 + y2) / 2); const [cornerX, cornerY] = pointRotateRads( pointFrom(x1, y1), center, sceneElement.angle, ); boxSelect(cornerX - 4, cornerY - 4, cornerX + 4, cornerY + 4); }; beforeEach(async () => { await render( , ); }); it("should select an element when the selection box partially overlaps it", () => { const rect1 = API.createElement({ type: "rectangle", x: 0, y: 0, width: 50, height: 50, backgroundColor: "red", fillStyle: "solid", }); API.setElements([rect1]); boxSelect(25, -20, 75, 70); assertSelectedElements([rect1.id]); }); it("should select the whole group when overlapping one group member", () => { const rect1 = API.createElement({ type: "rectangle", x: 0, y: 0, width: 50, height: 50, groupIds: ["A"], }); const rect2 = API.createElement({ type: "rectangle", x: 100, y: 0, width: 50, height: 50, groupIds: ["A"], }); API.setElements([rect1, rect2]); boxSelect(25, -20, 75, 70); assertSelectedElements([rect1.id, rect2.id]); expect(h.state.selectedGroupIds).toEqual({ A: true }); }); it("should return all group elements when overlapping one group member", () => { const rect1 = API.createElement({ type: "rectangle", id: "rect1", x: 0, y: 0, width: 50, height: 50, groupIds: ["A"], }); const rect2 = API.createElement({ type: "rectangle", id: "rect2", x: 100, y: 0, width: 50, height: 50, groupIds: ["A"], }); const rect3 = API.createElement({ type: "rectangle", id: "rect3", x: 200, y: 0, width: 50, height: 50, }); const selection = API.createElement({ type: "rectangle", x: 125, y: -10, width: 10, height: 70, }); const elements = [rect1, rect2, rect3]; expect( getElementsWithinSelection( elements, selection, arrayToMap([...elements, selection]), false, "overlap", ).map((element) => element.id), ).toEqual([rect1.id, rect2.id]); }); it("should retain nested and interleaved group element order", () => { const outerNested1 = API.createElement({ type: "rectangle", id: "outerNested1", x: 0, y: 0, width: 50, height: 50, groupIds: ["inner", "outer"], }); const other1 = API.createElement({ type: "rectangle", id: "other1", x: 70, y: 0, width: 50, height: 50, groupIds: ["other"], }); const outerOnly = API.createElement({ type: "rectangle", id: "outerOnly", x: 140, y: 0, width: 50, height: 50, groupIds: ["outer"], }); const other2 = API.createElement({ type: "rectangle", id: "other2", x: 210, y: 0, width: 50, height: 50, groupIds: ["other"], }); const outerNested2 = API.createElement({ type: "rectangle", id: "outerNested2", x: 280, y: 0, width: 50, height: 50, groupIds: ["inner", "outer"], }); const selection = API.createElement({ type: "rectangle", x: 295, y: -10, width: 10, height: 70, }); const elements = [outerNested1, other1, outerOnly, other2, outerNested2]; expect( getElementsWithinSelection( elements, selection, arrayToMap([...elements, selection]), false, "overlap", ).map((element) => element.id), ).toEqual([outerNested1.id, outerOnly.id, outerNested2.id]); }); it("should not select a transparent rectangle when the selection box stays inside it", () => { const rect1 = API.createElement({ type: "rectangle", x: 0, y: 0, width: 100, height: 100, backgroundColor: "transparent", fillStyle: "solid", }); API.setElements([rect1]); boxSelect(25, 25, 75, 75); assertSelectedElements([]); }); it("should select a transparent rectangle when the selection box crosses its outline", () => { const rect1 = API.createElement({ type: "rectangle", x: 0, y: 0, width: 100, height: 100, backgroundColor: "transparent", fillStyle: "solid", }); API.setElements([rect1]); boxSelect(25, 25, 125, 75); assertSelectedElements([rect1.id]); }); it("should not select a rotated transparent rectangle when the selection box stays inside it", () => { const rect1 = API.createElement({ type: "rectangle", x: 0, y: 0, width: 100, height: 100, angle: Math.PI / 4, backgroundColor: "transparent", fillStyle: "solid", }); API.setElements([rect1]); boxSelect(40, 40, 60, 60); assertSelectedElements([]); }); it("should select a rotated rounded rectangle when the selection box contains its outline but not its bounds", () => { const rect = API.createElement({ type: "rectangle", x: 0, y: 0, width: 100, height: 180, angle: Math.PI / 6, backgroundColor: "transparent", fillStyle: "solid", roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS }, roughness: 0, }); API.setElements([rect]); const sceneRect = API.getElement(rect); const elementsMap = h.scene.getNonDeletedElementsMap(); const [boundsX1, boundsY1, boundsX2, boundsY2] = getElementBounds( sceneRect, elementsMap, ); const [outlineX1, outlineY1, outlineX2, outlineY2] = getOutlineBounds(rect); expect(outlineX1).toBeGreaterThan(boundsX1 - 1); expect(outlineY1).toBeGreaterThan(boundsY1 - 1); expect(outlineX2).toBeLessThan(boundsX2 + 1); expect(outlineY2).toBeLessThan(boundsY2 + 1); boxSelect( outlineX1 - (outlineX1 - boundsX1) / 2, outlineY1 - (outlineY1 - boundsY1) / 2, outlineX2 + (boundsX2 - outlineX2) / 2, outlineY2 + (boundsY2 - outlineY2) / 2, ); assertSelectedElements([rect.id]); }); it("should not select a filled rotated rectangle when the selection box only overlaps its axis-aligned bounds", () => { const rect = API.createElement({ type: "rectangle", x: 0, y: 0, width: 100, height: 100, angle: Math.PI / 4, backgroundColor: "red", fillStyle: "solid", }); API.setElements([rect]); boxSelectTopLeftAabbCorner(rect); assertSelectedElements([]); }); it("should not select a filled ellipse when the selection box only overlaps its bounds corner", () => { const ellipse = API.createElement({ type: "ellipse", x: 0, y: 0, width: 100, height: 100, backgroundColor: "red", fillStyle: "solid", }); API.setElements([ellipse]); boxSelectTopRightAabbCorner(ellipse); assertSelectedElements([]); }); it("should not select a filled diamond when the selection box only overlaps its bounds corner", () => { const diamond = API.createElement({ type: "diamond", x: 0, y: 0, width: 100, height: 100, backgroundColor: "red", fillStyle: "solid", }); API.setElements([diamond]); boxSelectTopRightAabbCorner(diamond); assertSelectedElements([]); }); it("should not select a filled rotated ellipse when the selection box only overlaps its axis-aligned bounds", () => { const ellipse = API.createElement({ type: "ellipse", x: 0, y: 0, width: 100, height: 100, angle: Math.PI / 4, backgroundColor: "red", fillStyle: "solid", }); API.setElements([ellipse]); boxSelectTopLeftRotatedLocalBoundsCorner(ellipse); assertSelectedElements([]); }); it("should not select a filled rotated diamond when the selection box only overlaps its rotated local bounds", () => { const diamond = API.createElement({ type: "diamond", x: 0, y: 0, width: 100, height: 100, angle: Math.PI / 4, backgroundColor: "red", fillStyle: "solid", }); API.setElements([diamond]); boxSelectTopLeftRotatedLocalBoundsCorner(diamond); assertSelectedElements([]); }); it("should not select rotated text when the selection box only overlaps its axis-aligned bounds", () => { const text = API.createElement({ type: "text", x: 0, y: 0, width: 100, height: 100, angle: Math.PI / 4, text: "test", }); API.setElements([text]); boxSelect(-18, -18, -8, -8); assertSelectedElements([]); }); it("should not select rotated image when the selection box only overlaps its axis-aligned bounds", () => { const image = API.createElement({ type: "image", x: 0, y: 0, width: 100, height: 100, angle: Math.PI / 4, fileId: "file_A", status: "saved", }); API.setElements([image]); boxSelect(-18, -18, -8, -8); assertSelectedElements([]); }); it("should deselect a selected rotated rectangle when clicking in the empty corner of its axis-aligned bounds", () => { const rect = API.createElement({ type: "rectangle", x: 0, y: 0, width: 100, height: 100, angle: Math.PI / 4, backgroundColor: "red", fillStyle: "solid", }); API.setElements([rect]); mouse.clickAt(50, 50); assertSelectedElements([rect.id]); const sceneRect = API.getElement(rect); const elementsMap = h.scene.getNonDeletedElementsMap(); const [x1, y1] = getElementBounds(sceneRect, elementsMap); mouse.clickAt(x1 + 2, y1 + 2); assertSelectedElements([]); }); it("should not select a line when the selection box only overlaps its bounds", () => { const line = API.createElement({ type: "line", x: 0, y: 0, width: 100, height: 100, backgroundColor: "transparent", points: [pointFrom(0, 0), pointFrom(100, 100)], }); API.setElements([line]); boxSelect(20, 50, 30, 60); assertSelectedElements([]); }); it("should not click-select rotated freedraw in the corner of its axis-aligned bounds", () => { const freedraw = API.createElement({ type: "freedraw", x: 0, y: 0, width: 100, height: 100, angle: Math.PI / 4, backgroundColor: "transparent", points: [ pointFrom(0, 0), pointFrom(100, 0), pointFrom(100, 100), pointFrom(0, 100), pointFrom(0, 0), ], }); API.setElements([freedraw]); const sceneFreedraw = API.getElement(freedraw); const elementsMap = h.scene.getNonDeletedElementsMap(); const [x1, y1] = getElementBounds(sceneFreedraw, elementsMap); mouse.clickAt(x1 + 2, y1 + 2); assertSelectedElements([]); }); it("should not select a freedraw when the selection box only overlaps its bounds", () => { const freedraw = API.createElement({ type: "freedraw", x: 0, y: 0, width: 100, height: 100, backgroundColor: "transparent", points: [ pointFrom(0, 0), pointFrom(50, 50), pointFrom(100, 100), ], }); API.setElements([freedraw]); boxSelect(20, 50, 30, 60); assertSelectedElements([]); }); it("should not select a transparent framed element when the selection box stays inside its clipped bounds", () => { const frame = API.createElement({ type: "frame", x: 0, y: 0, width: 100, height: 100, backgroundColor: "transparent", fillStyle: "solid", }); const rect1 = API.createElement({ type: "rectangle", x: 50, y: 10, width: 100, height: 80, frameId: frame.id, backgroundColor: "transparent", fillStyle: "solid", }); API.setElements([frame, rect1]); boxSelect(60, 20, 90, 60); assertSelectedElements([]); }); it("should not select a framed element when selection only overlaps its clipped-out outline", () => { const frame = API.createElement({ type: "frame", x: 100, y: 100, width: 100, height: 100, }); const rect1 = API.createElement({ type: "rectangle", x: 50, y: 50, width: 200, height: 200, frameId: frame.id, backgroundColor: "red", fillStyle: "solid", }); API.setElements([frame, rect1]); boxSelect(40, 170, 70, 220); assertSelectedElements([]); }); }); describe("inner box-selection", () => { beforeEach(async () => { await render(); }); it("selecting elements visually nested inside another", async () => { const rect1 = API.createElement({ type: "rectangle", x: 0, y: 0, width: 300, height: 300, backgroundColor: "red", fillStyle: "solid", }); const rect2 = API.createElement({ type: "rectangle", x: 50, y: 50, width: 50, height: 50, }); const rect3 = API.createElement({ type: "rectangle", x: 150, y: 150, width: 50, height: 50, }); API.setElements([rect1, rect2, rect3]); Keyboard.withModifierKeys({ ctrl: true }, () => { mouse.downAt(40, 40); mouse.move(-1000, -1000); mouse.moveTo(290, 290); mouse.up(); assertSelectedElements([rect2.id, rect3.id]); }); }); it("selecting grouped elements visually nested inside another", async () => { const rect1 = API.createElement({ type: "rectangle", x: 0, y: 0, width: 300, height: 300, backgroundColor: "red", fillStyle: "solid", }); const rect2 = API.createElement({ type: "rectangle", x: 50, y: 50, width: 50, height: 50, groupIds: ["A"], }); const rect3 = API.createElement({ type: "rectangle", x: 150, y: 150, width: 50, height: 50, groupIds: ["A"], }); API.setElements([rect1, rect2, rect3]); Keyboard.withModifierKeys({ ctrl: true }, () => { mouse.downAt(40, 40); mouse.move(-1000, -1000); mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10); mouse.up(); assertSelectedElements([rect1.id]); expect(h.state.selectedGroupIds).toEqual({}); }); Keyboard.withModifierKeys({ ctrl: true }, () => { mouse.downAt(40, 40); mouse.move(-1000, -1000); mouse.moveTo(rect3.x + rect3.width + 10, rect3.y + rect3.height + 10); mouse.up(); assertSelectedElements([rect2.id, rect3.id]); expect(h.state.selectedGroupIds).toEqual({ A: true }); }); }); it("does not select a nested outer group until all members are contained", async () => { const innerRect1 = API.createElement({ type: "rectangle", x: 50, y: 50, width: 50, height: 50, groupIds: ["inner", "outer"], }); const innerRect2 = API.createElement({ type: "rectangle", x: 120, y: 50, width: 50, height: 50, groupIds: ["inner", "outer"], }); const outerRect = API.createElement({ type: "rectangle", x: 190, y: 50, width: 50, height: 50, groupIds: ["outer"], }); API.setElements([innerRect1, innerRect2, outerRect]); Keyboard.withModifierKeys({ ctrl: true }, () => { mouse.downAt(0, 0); mouse.move(-1000, -1000); mouse.moveTo( innerRect2.x + innerRect2.width + 10, innerRect2.y + innerRect2.height + 10, ); mouse.up(); assertSelectedElements([]); expect(h.state.selectedGroupIds).toEqual({}); }); Keyboard.withModifierKeys({ ctrl: true }, () => { mouse.downAt(0, 0); mouse.move(-1000, -1000); mouse.moveTo( outerRect.x + outerRect.width + 10, outerRect.y + outerRect.height + 10, ); mouse.up(); assertSelectedElements([innerRect1.id, innerRect2.id, outerRect.id]); expect(h.state.selectedGroupIds).toEqual({ outer: true }); }); }); it.skip("checks nested containment against the current editing depth", async () => { const innerRect1 = API.createElement({ type: "rectangle", x: 50, y: 50, width: 50, height: 50, groupIds: ["inner", "outer"], }); const innerRect2 = API.createElement({ type: "rectangle", x: 120, y: 50, width: 50, height: 50, groupIds: ["inner", "outer"], }); const outerRect = API.createElement({ type: "rectangle", x: 190, y: 50, width: 50, height: 50, groupIds: ["outer"], }); const selection = API.createElement({ type: "rectangle", x: 40, y: 40, width: 140, height: 70, }); const elements = [innerRect1, innerRect2, outerRect]; const elementsMap = arrayToMap([...elements, selection]); expect( getElementsWithinSelection( elements, selection, elementsMap, false, "contain", ).map((element) => element.id), ).toEqual([]); expect( getElementsWithinSelection( elements, selection, elementsMap, false, "contain", // "outer", /* editingGroupId - add as param once we implement nested group handling */ ).map((element) => element.id), ).toEqual([innerRect1.id, innerRect2.id]); }); it("ignores grouped bound text when checking box-selection containment", async () => { const container = API.createElement({ type: "rectangle", id: "container", x: 50, y: 50, width: 50, height: 50, groupIds: ["A"], boundElements: [{ type: "text", id: "bound-text" }], }); const boundText = API.createElement({ type: "text", id: "bound-text", x: 50, y: 50, width: 50, height: 20, containerId: container.id, groupIds: ["A"], }); const rect = API.createElement({ type: "rectangle", x: 150, y: 150, width: 50, height: 50, groupIds: ["A"], }); API.setElements([container, boundText, rect]); Keyboard.withModifierKeys({ ctrl: true }, () => { mouse.downAt(40, 40); mouse.move(-1000, -1000); mouse.moveTo(rect.x + rect.width + 10, rect.y + rect.height + 10); mouse.up(); expect(h.state.selectedElementIds[container.id]).toBe(true); expect(h.state.selectedElementIds[rect.id]).toBe(true); expect(h.state.selectedGroupIds).toEqual({ A: true }); }); }); it("selecting & deselecting grouped elements visually nested inside another", async () => { const rect1 = API.createElement({ type: "rectangle", x: 0, y: 0, width: 300, height: 300, backgroundColor: "red", fillStyle: "solid", }); const rect2 = API.createElement({ type: "rectangle", x: 50, y: 50, width: 50, height: 50, groupIds: ["A"], }); const rect3 = API.createElement({ type: "rectangle", x: 150, y: 150, width: 50, height: 50, groupIds: ["A"], }); API.setElements([rect1, rect2, rect3]); Keyboard.withModifierKeys({ ctrl: true }, () => { mouse.downAt(rect2.x - 20, rect2.y - 20); mouse.move(-1000, -1000); mouse.moveTo(rect3.x + rect3.width + 10, rect3.y + rect3.height + 10); assertSelectedElements([rect2.id, rect3.id]); expect(h.state.selectedGroupIds).toEqual({ A: true }); mouse.moveTo(rect2.x - 10, rect2.y - 10); assertSelectedElements([rect1.id]); expect(h.state.selectedGroupIds).toEqual({}); mouse.up(); }); }); }); describe("selection element", () => { it("create selection element on pointer down", async () => { const { getByToolName, container } = await render(); // select tool const tool = getByToolName("selection"); fireEvent.click(tool); const canvas = container.querySelector("canvas.interactive")!; fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 }); expect(renderInteractiveScene).toHaveBeenCalledTimes(3); expect(renderStaticScene).toHaveBeenCalledTimes(3); const selectionElement = h.state.selectionElement!; expect(selectionElement).not.toBeNull(); expect(selectionElement.type).toEqual("selection"); expect([selectionElement.x, selectionElement.y]).toEqual([60, 100]); expect([selectionElement.width, selectionElement.height]).toEqual([0, 0]); // TODO: There is a memory leak if pointer up is not triggered fireEvent.pointerUp(canvas); }); it("resize selection element on pointer move", async () => { const { getByToolName, container } = await render(); // select tool const tool = getByToolName("selection"); fireEvent.click(tool); const canvas = container.querySelector("canvas.interactive")!; fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 }); fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 }); fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 }); expect(renderInteractiveScene).toHaveBeenCalledTimes(5); expect(renderStaticScene).toHaveBeenCalledTimes(3); const selectionElement = h.state.selectionElement!; expect(selectionElement).not.toBeNull(); expect(selectionElement.type).toEqual("selection"); expect([selectionElement.x, selectionElement.y]).toEqual([60, 30]); expect([selectionElement.width, selectionElement.height]).toEqual([90, 70]); // TODO: There is a memory leak if pointer up is not triggered fireEvent.pointerUp(canvas); }); it("remove selection element on pointer up", async () => { const { getByToolName, container } = await render(); // select tool const tool = getByToolName("selection"); fireEvent.click(tool); const canvas = container.querySelector("canvas.interactive")!; fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 }); fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 }); fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 }); fireEvent.pointerUp(canvas); expect(renderInteractiveScene).toHaveBeenCalledTimes(6); expect(renderStaticScene).toHaveBeenCalledTimes(3); expect(h.state.selectionElement).toBeNull(); }); }); describe("select single element on the scene", () => { beforeAll(() => { mockBoundingClientRect(); }); afterAll(() => { restoreOriginalGetBoundingClientRect(); }); it("rectangle", async () => { const { getByToolName, container } = await render( , ); const canvas = container.querySelector("canvas.interactive")!; { // create element const tool = getByToolName("rectangle"); fireEvent.click(tool); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerUp(canvas); fireEvent.keyDown(document, { key: KEYS.ESCAPE, }); } const tool = getByToolName("selection"); fireEvent.click(tool); // click on a line on the rectangle fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 }); fireEvent.pointerUp(canvas); expect(renderInteractiveScene).toHaveBeenCalledTimes(8); expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); h.elements.forEach((element) => expect(element).toMatchSnapshot()); }); it("diamond", async () => { const { getByToolName, container } = await render( , ); const canvas = container.querySelector("canvas.interactive")!; { // create element const tool = getByToolName("diamond"); fireEvent.click(tool); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerUp(canvas); fireEvent.keyDown(document, { key: KEYS.ESCAPE, }); } const tool = getByToolName("selection"); fireEvent.click(tool); // click on a line on the rectangle fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 }); fireEvent.pointerUp(canvas); expect(renderInteractiveScene).toHaveBeenCalledTimes(8); expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); h.elements.forEach((element) => expect(element).toMatchSnapshot()); }); it("ellipse", async () => { const { getByToolName, container } = await render( , ); const canvas = container.querySelector("canvas.interactive")!; { // create element const tool = getByToolName("ellipse"); fireEvent.click(tool); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerUp(canvas); fireEvent.keyDown(document, { key: KEYS.ESCAPE, }); } const tool = getByToolName("selection"); fireEvent.click(tool); // click on a line on the rectangle fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 }); fireEvent.pointerUp(canvas); expect(renderInteractiveScene).toHaveBeenCalledTimes(8); expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); h.elements.forEach((element) => expect(element).toMatchSnapshot()); }); it("arrow", async () => { const { getByToolName, container } = await render( , ); const canvas = container.querySelector("canvas.interactive")!; { // create element const tool = getByToolName("arrow"); fireEvent.click(tool); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerUp(canvas); fireEvent.keyDown(document, { key: KEYS.ESCAPE, }); } /* 1 2 3 4 5 6 7 8 9 1 2 x 3 4 . 5 6 7 x 8 9 */ const tool = getByToolName("selection"); fireEvent.click(tool); // click on a line on the arrow fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 }); fireEvent.pointerUp(canvas); expect(renderInteractiveScene).toHaveBeenCalledTimes(11); expect(renderStaticScene).toHaveBeenCalledTimes(9); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); h.elements.forEach((element) => expect(element).toMatchSnapshot()); }); it("arrow escape", async () => { const { getByToolName, container } = await render( , ); const canvas = container.querySelector("canvas.interactive")!; { // create element const tool = getByToolName("line"); fireEvent.click(tool); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerUp(canvas); fireEvent.keyDown(document, { key: KEYS.ESCAPE, }); } /* 1 2 3 4 5 6 7 8 9 1 2 x 3 4 . 5 6 7 x 8 9 */ const tool = getByToolName("selection"); fireEvent.click(tool); // click on a line on the arrow fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 }); fireEvent.pointerUp(canvas); expect(renderInteractiveScene).toHaveBeenCalledTimes(11); expect(renderStaticScene).toHaveBeenCalledTimes(9); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); h.elements.forEach((element) => expect(element).toMatchSnapshot()); }); }); describe("tool locking & selection", () => { it("should not select newly created element while tool is locked", async () => { await render(); UI.clickTool("lock"); expect(h.state.activeTool.locked).toBe(true); for (const value of Object.keys(TOOLS) as (keyof typeof TOOLS)[]) { if ( value !== "image" && value !== "selection" && value !== "lasso" && value !== "eraser" && value !== "arrow" && value !== "hand" && value !== "laser" && // no top-level toolbar button (rendered in the extra-tools dropdown) value !== "frame" && value !== "embeddable" && value !== "autoshape" && value !== "bucketfill" ) { const element = UI.createElement(value); expect(h.state.selectedElementIds[element.id]).not.toBe(true); } } }); }); describe("selectedElementIds stability", () => { beforeEach(async () => { await render(); }); it("box-selection should be stable when not changing selection", () => { const rectangle = API.createElement({ type: "rectangle", x: 0, y: 0, width: 10, height: 10, }); API.setElements([rectangle]); const selectedElementIds_1 = h.state.selectedElementIds; mouse.downAt(-100, -100); mouse.moveTo(-50, -50); mouse.up(); expect(h.state.selectedElementIds).toBe(selectedElementIds_1); mouse.downAt(-50, -50); mouse.move(-1000, -1000); mouse.moveTo(50, 50); const selectedElementIds_2 = h.state.selectedElementIds; expect(selectedElementIds_2).toEqual({ [rectangle.id]: true }); mouse.moveTo(60, 60); // box-selecting further without changing selection should keep // selectedElementIds stable (the same object) expect(h.state.selectedElementIds).toBe(selectedElementIds_2); mouse.up(); expect(h.state.selectedElementIds).toBe(selectedElementIds_2); }); }); describe("deselecting", () => { beforeEach(async () => { await render(); }); it("esc unwinds nested group editing before deselecting", () => { const rectA = API.createElement({ type: "rectangle", x: 0, y: 0, groupIds: ["inner", "outer"], }); const rectB = API.createElement({ type: "rectangle", x: 100, y: 0, groupIds: ["outer"], }); const rectC = API.createElement({ type: "rectangle", x: 200, y: 0, groupIds: ["inner", "outer"], }); API.setElements([rectA, rectB, rectC]); mouse.select(rectA); assertSelectedElements(rectA, rectB, rectC); expect(h.state.editingGroupId).toBeNull(); mouse.doubleClickOn(rectA); assertSelectedElements(rectA, rectC); expect(h.state.editingGroupId).toBe("outer"); mouse.doubleClickOn(rectA); assertSelectedElements(rectA); expect(h.state.editingGroupId).toBe("inner"); Keyboard.keyPress(KEYS.ESCAPE); assertSelectedElements(rectA, rectC); expect(h.state.editingGroupId).toBe("outer"); Keyboard.keyPress(KEYS.ESCAPE); assertSelectedElements(rectA, rectB, rectC); expect(h.state.editingGroupId).toBeNull(); expect(h.state.selectedGroupIds).toEqual({ outer: true }); Keyboard.keyPress(KEYS.ESCAPE); expect(API.getSelectedElements()).toEqual([]); expect(h.state.editingGroupId).toBeNull(); expect(h.state.selectedGroupIds).toEqual({}); }); });