import React from "react"; import { vi } from "vitest"; import { CURSOR_TYPE, resolvablePromise, updateActiveTool, } from "@excalidraw/common"; import { Excalidraw } from "../index"; import { findShapeByKey } from "../components/Tools"; import { API } from "./helpers/api"; import { Pointer } from "./helpers/ui"; import { act, fireEvent, GlobalTestState, render } from "./test-utils"; import type { AppClassProperties, ExcalidrawImperativeAPI } from "../types"; describe("setActiveTool()", () => { const h = window.h; let excalidrawAPI: ExcalidrawImperativeAPI; const mouse = new Pointer("mouse"); beforeEach(async () => { const excalidrawAPIPromise = resolvablePromise(); await render( excalidrawAPIPromise.resolve(api as any)} />, ); excalidrawAPI = await excalidrawAPIPromise; }); it("should expose setActiveTool on package API", () => { expect(excalidrawAPI.setActiveTool).toBeDefined(); expect(excalidrawAPI.setActiveTool).toBe(h.app.setActiveTool); }); it("should set the active tool type", async () => { expect(h.state.activeTool.type).toBe("selection"); act(() => { excalidrawAPI.setActiveTool({ type: "rectangle" }); }); expect(h.state.activeTool.type).toBe("rectangle"); mouse.down(10, 10); mouse.up(20, 20); expect(h.state.activeTool.type).toBe("selection"); }); it("should support tool locking", async () => { expect(h.state.activeTool.type).toBe("selection"); act(() => { excalidrawAPI.setActiveTool({ type: "rectangle", locked: true }); }); expect(h.state.activeTool.type).toBe("rectangle"); mouse.down(10, 10); mouse.up(20, 20); expect(h.state.activeTool.type).toBe("rectangle"); }); it("should set custom tool", async () => { expect(h.state.activeTool.type).toBe("selection"); act(() => { excalidrawAPI.setActiveTool({ type: "custom", customType: "comment" }); }); expect(h.state.activeTool.type).toBe("custom"); expect(h.state.activeTool.customType).toBe("comment"); }); }); describe("findShapeByKey()", () => { const appWithPreferredTool = ( preferredSelectionTool: "selection" | "lasso", ) => ({ state: { preferredSelectionTool: { type: preferredSelectionTool, }, }, } as AppClassProperties); it("selection shortcuts activate selection when it's preferred", () => { const app = appWithPreferredTool("selection"); expect(findShapeByKey("v", app)).toBe("selection"); expect(findShapeByKey("1", app)).toBe("selection"); }); it("selection shortcuts activate lasso when it's preferred", () => { const app = appWithPreferredTool("lasso"); expect(findShapeByKey("v", app)).toBe("lasso"); expect(findShapeByKey("1", app)).toBe("lasso"); }); it("letter shortcuts are CapsLock-insensitive", () => { const app = appWithPreferredTool("selection"); expect(findShapeByKey("V", app)).toBe("selection"); expect(findShapeByKey("R", app)).toBe("rectangle"); expect(findShapeByKey("X", app)).toBe("freedraw"); }); it("matches shift-bound tools only when shift is held", () => { const app = appWithPreferredTool("selection"); expect(findShapeByKey("X", app, true)).toBe("autoshape"); expect(findShapeByKey("x", app, true)).toBe("autoshape"); // Pressing "X" while CapsLock is active (no shift) stays freedraw expect(findShapeByKey("X", app, false)).toBe("freedraw"); }); it("does not match plain-bound tools when shift is held", () => { const app = appWithPreferredTool("selection"); expect(findShapeByKey("R", app, true)).toBeNull(); expect(findShapeByKey("V", app, true)).toBeNull(); }); }); describe("props.activeTool (forced tool)", () => { const h = window.h; const mouse = new Pointer("mouse"); const queryToolButton = (type: string) => GlobalTestState.renderResult.container.querySelector( `[data-testid="toolbar-${type}"]`, ); beforeEach(() => { mouse.reset(); }); afterEach(() => { vi.restoreAllMocks(); }); it("forces the tool from the first render, behaving as locked", async () => { await render(); expect(h.state.activeTool.type).toBe("rectangle"); // forcing behaves as locked without mutating the user's padlock state expect(h.state.activeTool.locked).toBe(false); // the cursor reflects the forced tool without any pointer interaction expect(GlobalTestState.interactiveCanvas.style.cursor).toBe( CURSOR_TYPE.CROSSHAIR, ); // drawing doesn't revert to selection, and the drawn element isn't // auto-selected (locked-tool behavior) mouse.down(10, 10); mouse.up(20, 20); expect(h.state.activeTool.type).toBe("rectangle"); expect(h.elements.length).toBe(1); expect(h.state.selectedElementIds).toEqual({}); }); it("does not clobber the user's padlock preference", async () => { await render(); // user locks the tool (Q) fireEvent.keyDown(document, { key: "q", code: "KeyQ" }); expect(h.state.activeTool.locked).toBe(true); // forcing & unforcing leaves the preference alone GlobalTestState.renderResult.rerender( , ); expect(h.state.activeTool.type).toBe("freedraw"); expect(h.state.activeTool.locked).toBe(true); GlobalTestState.renderResult.rerender( , ); expect(h.state.activeTool.locked).toBe(true); }); it("ignores user & API tool switching without side effects", async () => { await render( , ); const rect = API.createElement({ type: "rectangle", x: 10, y: 10, width: 50, height: 50, }); API.setElements([rect]); API.setAppState({ selectedElementIds: { [rect.id]: true } }); // the letter shortcut is refused before it can clear the selection fireEvent.keyDown(document, { key: "r", code: "KeyR" }); expect(h.state.activeTool.type).toBe("selection"); expect(h.state.selectedElementIds).toEqual({ [rect.id]: true }); // the imperative API is refused as well — the prop is the single owner act(() => { h.app.setActiveTool({ type: "freedraw" }); }); expect(h.state.activeTool.type).toBe("selection"); }); it("keeps the tool in sync with prop changes, until unset", async () => { await render(); expect(h.state.activeTool.type).toBe("rectangle"); GlobalTestState.renderResult.rerender( , ); expect(h.state.activeTool.type).toBe("custom"); expect(h.state.activeTool.customType).toBe("comment"); // customType-only changes are picked up GlobalTestState.renderResult.rerender( , ); expect(h.state.activeTool.customType).toBe("pin"); // unset → uncontrolled: the current tool stays, switching works again GlobalTestState.renderResult.rerender(); expect(h.state.activeTool.customType).toBe("pin"); act(() => { h.app.setActiveTool({ type: "freedraw" }); }); expect(h.state.activeTool.type).toBe("freedraw"); }); it("snaps back when internal flows reset the tool", async () => { await render(); expect(h.state.activeTool.type).toBe("laser"); // simulate a funnel-bypassing writer (restore on scene load, // actionFinalize, ...) act(() => { API.setAppState({ activeTool: updateActiveTool(h.state, { type: "selection" }), }); }); expect(h.state.activeTool.type).toBe("laser"); }); it("renders non-forced toolbar buttons disabled and gates the tool lock", async () => { await render( , ); expect(queryToolButton("rectangle")!.disabled).toBe(false); expect(queryToolButton("rectangle")!.getAttribute("aria-pressed")).toBe( "true", ); expect(queryToolButton("freedraw")!.disabled).toBe(true); expect(queryToolButton("eraser")!.disabled).toBe(true); expect(queryToolButton("lock")).toBe(null); // the extra-tools dropdown items are disabled as well fireEvent.click( GlobalTestState.renderResult.container.querySelector( ".App-toolbar__extra-tools-trigger", )!, ); const frameItem = document.querySelector( '[data-testid="toolbar-frame"]', ); expect(frameItem).not.toBe(null); expect(frameItem!.disabled).toBe(true); const laserItem = document.querySelector( '[data-testid="toolbar-laser"]', ); expect(laserItem!.disabled).toBe(true); // Q (toggle tool lock) is ignored — locking is implied while forced expect(h.state.activeTool.locked).toBe(false); fireEvent.keyDown(document, { key: "q", code: "KeyQ" }); expect(h.state.activeTool.locked).toBe(false); expect(h.state.activeTool.type).toBe("rectangle"); }); it("cannot force the image tool", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); await render(); expect(h.state.activeTool.type).toBe("selection"); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining(`"image" tool cannot be forced`), ); }); it("composes with interaction.enabled.tools (presenter → viewer)", async () => { await render( , ); // presenter: forced laser while otherwise non-interactive expect(h.state.activeTool.type).toBe("laser"); expect(h.app.isInteractionEnabled()).toBe(false); expect(h.app.isToolSupported("laser")).toBe(true); // the laser cursor applies without any pointer interaction expect(GlobalTestState.interactiveCanvas.style.cursor).toContain("url("); // viewer: fully inert, tool resolves to the default selection GlobalTestState.renderResult.rerender( , ); expect(h.state.activeTool.type).toBe("selection"); // back to presenter GlobalTestState.renderResult.rerender( , ); expect(h.state.activeTool.type).toBe("laser"); }); it("forcing a non-activatable tool resolves to selection until activatable", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); await render( , ); expect(h.state.activeTool.type).toBe("selection"); expect(warnSpy).toHaveBeenCalled(); // the tool becomes activatable → the forced tool applies GlobalTestState.renderResult.rerender( , ); expect(h.state.activeTool.type).toBe("laser"); }); }); describe("toolbar", () => { const queryTool = (type: string) => document.querySelector( `[data-testid="toolbar-${type}"]`, ); const openExtraTools = () => fireEvent.click( document.querySelector(".App-toolbar__extra-tools-trigger")!, ); it("keeps the image tool in the extra-tools menu", async () => { await render(); expect(queryTool("image")).toBe(null); openExtraTools(); expect(queryTool("image")).not.toBe(null); expect(queryTool("frame")).not.toBe(null); }); it("hides the image tool through UIOptions.tools.image", async () => { await render(); openExtraTools(); expect(queryTool("image")).toBe(null); expect(queryTool("frame")).not.toBe(null); }); it("badges tools with their letter shortcut", async () => { await render(); const badge = (type: string) => queryTool(type)?.querySelector(".ToolIcon__keybinding")?.textContent; expect(badge("selection")).toBe("V"); expect(badge("rectangle")).toBe("R"); expect(badge("text")).toBe("T"); expect(badge("stickynote")).toBe("N"); expect(badge("eraser")).toBe("E"); }); });