snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
388
packages/excalidraw/tests/tool.test.tsx
Normal file
388
packages/excalidraw/tests/tool.test.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user