Files
2026-09-16 05:31:04 +05:00

389 lines
13 KiB
TypeScript

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<ExcalidrawImperativeAPI>();
await render(
<Excalidraw
onExcalidrawAPI={(api) => 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<HTMLButtonElement>(
`[data-testid="toolbar-${type}"]`,
);
beforeEach(() => {
mouse.reset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("forces the tool from the first render, behaving as locked", async () => {
await render(<Excalidraw activeTool={{ type: "rectangle" }} />);
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(<Excalidraw handleKeyboardGlobally={true} />);
// 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(
<Excalidraw
activeTool={{ type: "freedraw" }}
handleKeyboardGlobally={true}
/>,
);
expect(h.state.activeTool.type).toBe("freedraw");
expect(h.state.activeTool.locked).toBe(true);
GlobalTestState.renderResult.rerender(
<Excalidraw handleKeyboardGlobally={true} />,
);
expect(h.state.activeTool.locked).toBe(true);
});
it("ignores user & API tool switching without side effects", async () => {
await render(
<Excalidraw
activeTool={{ type: "selection" }}
handleKeyboardGlobally={true}
/>,
);
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(<Excalidraw activeTool={{ type: "rectangle" }} />);
expect(h.state.activeTool.type).toBe("rectangle");
GlobalTestState.renderResult.rerender(
<Excalidraw activeTool={{ type: "custom", customType: "comment" }} />,
);
expect(h.state.activeTool.type).toBe("custom");
expect(h.state.activeTool.customType).toBe("comment");
// customType-only changes are picked up
GlobalTestState.renderResult.rerender(
<Excalidraw activeTool={{ type: "custom", customType: "pin" }} />,
);
expect(h.state.activeTool.customType).toBe("pin");
// unset → uncontrolled: the current tool stays, switching works again
GlobalTestState.renderResult.rerender(<Excalidraw />);
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(<Excalidraw activeTool={{ type: "laser" }} />);
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(
<Excalidraw
activeTool={{ type: "rectangle" }}
handleKeyboardGlobally={true}
/>,
);
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<HTMLButtonElement>(
'[data-testid="toolbar-frame"]',
);
expect(frameItem).not.toBe(null);
expect(frameItem!.disabled).toBe(true);
const laserItem = document.querySelector<HTMLButtonElement>(
'[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(<Excalidraw activeTool={{ type: "image" } as any} />);
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(
<Excalidraw
activeTool={{ type: "laser" }}
interaction={{ enabled: { tools: { laser: true } } }}
/>,
);
// 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(
<Excalidraw activeTool={{ type: "selection" }} interaction={false} />,
);
expect(h.state.activeTool.type).toBe("selection");
// back to presenter
GlobalTestState.renderResult.rerender(
<Excalidraw
activeTool={{ type: "laser" }}
interaction={{ enabled: { tools: { laser: true } } }}
/>,
);
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(
<Excalidraw activeTool={{ type: "laser" }} interaction={false} />,
);
expect(h.state.activeTool.type).toBe("selection");
expect(warnSpy).toHaveBeenCalled();
// the tool becomes activatable → the forced tool applies
GlobalTestState.renderResult.rerender(
<Excalidraw
activeTool={{ type: "laser" }}
interaction={{ enabled: { tools: { laser: true } } }}
/>,
);
expect(h.state.activeTool.type).toBe("laser");
});
});
describe("toolbar", () => {
const queryTool = (type: string) =>
document.querySelector<HTMLButtonElement>(
`[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(<Excalidraw />);
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(<Excalidraw UIOptions={{ tools: { image: false } }} />);
openExtraTools();
expect(queryTool("image")).toBe(null);
expect(queryTool("frame")).not.toBe(null);
});
it("badges tools with their letter shortcut", async () => {
await render(<Excalidraw />);
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");
});
});