import React from "react";
import { vi } from "vitest";
import {
DEFAULT_STICKY_NOTE_SIZE,
KEYS,
STICKY_NOTE_BODY_INSET_Y,
STICKY_NOTE_MIN_SIZE,
reseed,
} from "@excalidraw/common";
import type {
ExcalidrawLinearElement,
ExcalidrawStickyNoteElement,
} from "@excalidraw/element/types";
import { Excalidraw } from "../index";
import * as InteractiveScene from "../renderer/interactiveScene";
import * as StaticScene from "../renderer/staticScene";
import { API } from "./helpers/api";
import {
render,
fireEvent,
waitFor,
mockBoundingClientRect,
restoreOriginalGetBoundingClientRect,
unmountComponent,
} from "./test-utils";
import {
getTextEditor,
TEXT_EDITOR_SELECTOR,
updateTextEditor,
} from "./queries/dom";
unmountComponent();
const renderInteractiveScene = vi.spyOn(
InteractiveScene,
"renderInteractiveScene",
);
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
beforeEach(() => {
localStorage.clear();
renderInteractiveScene.mockClear();
renderStaticScene.mockClear();
reseed(7);
});
const { h } = window;
describe("Test dragCreate", () => {
describe("add element to the scene when pointer dragging long enough", () => {
it("rectangle", async () => {
const { getByToolName, container } = await render();
// select tool
const tool = getByToolName("rectangle");
fireEvent.click(tool);
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
// move to (60,70)
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.elements[0].type).toEqual("rectangle");
expect(h.elements[0].x).toEqual(30);
expect(h.elements[0].y).toEqual(20);
expect(h.elements[0].width).toEqual(30); // 60 - 30
expect(h.elements[0].height).toEqual(50); // 70 - 20
expect(h.elements.length).toMatchSnapshot();
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});
it("ellipse", async () => {
const { getByToolName, container } = await render();
// select tool
const tool = getByToolName("ellipse");
fireEvent.click(tool);
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
// move to (60,70)
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.elements[0].type).toEqual("ellipse");
expect(h.elements[0].x).toEqual(30);
expect(h.elements[0].y).toEqual(20);
expect(h.elements[0].width).toEqual(30); // 60 - 30
expect(h.elements[0].height).toEqual(50); // 70 - 20
expect(h.elements.length).toMatchSnapshot();
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});
it("diamond", async () => {
const { getByToolName, container } = await render();
// select tool
const tool = getByToolName("diamond");
fireEvent.click(tool);
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
// move to (60,70)
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
expect(h.elements[0].type).toEqual("diamond");
expect(h.elements[0].x).toEqual(30);
expect(h.elements[0].y).toEqual(20);
expect(h.elements[0].width).toEqual(30); // 60 - 30
expect(h.elements[0].height).toEqual(50); // 70 - 20
expect(h.elements.length).toMatchSnapshot();
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});
it("arrow", async () => {
const { getByToolName, container } = await render();
// select tool
const tool = getByToolName("arrow");
fireEvent.click(tool);
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
// move to (60,70)
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`6`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement;
expect(element.type).toEqual("arrow");
expect(element.x).toEqual(30);
expect(element.y).toEqual(20);
expect(element.points.length).toEqual(2);
expect(element.points[0]).toEqual([0, 0]);
expect(element.points[1]).toEqual([30, 50]); // (60 - 30, 70 - 20)
expect(h.elements.length).toMatchSnapshot();
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});
it("line", async () => {
const { getByToolName, container } = await render();
// select tool
const tool = getByToolName("line");
fireEvent.click(tool);
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
// move to (60,70)
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`6`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement;
expect(element.type).toEqual("line");
expect(element.x).toEqual(30);
expect(element.y).toEqual(20);
expect(element.points.length).toEqual(2);
expect(element.points[0]).toEqual([0, 0]);
expect(element.points[1]).toEqual([30, 50]); // (60 - 30, 70 - 20)
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});
});
describe("sticky note creation", () => {
const createDefaultStickyNote = (canvas: Element) => {
fireEvent.pointerDown(canvas, { clientX: 300, clientY: 300 });
fireEvent.pointerUp(canvas, { clientX: 300, clientY: 300 });
};
it("places a clicked note on the grid", async () => {
const { getByToolName, container } = await render();
API.setAppState({ gridModeEnabled: true, gridSize: 20 });
fireEvent.click(getByToolName("stickynote"));
createDefaultStickyNote(container.querySelector("canvas.interactive")!);
await getTextEditor();
expect(
h.elements.find((element) => element.type === "stickynote"),
).toMatchObject({ x: 180, y: 180 });
});
it("switches back to selection and starts editing when tool is unlocked", async () => {
const { getByToolName, container } = await render();
fireEvent.click(getByToolName("stickynote"));
const canvas = container.querySelector("canvas.interactive")!;
createDefaultStickyNote(canvas);
await getTextEditor();
const stickyNote = h.elements.find(
(element) => element.type === "stickynote",
);
const boundText = h.elements.find((element) => element.type === "text");
expect(stickyNote).toBeDefined();
expect(boundText).toEqual(
expect.objectContaining({
containerId: stickyNote?.id,
}),
);
expect(h.state.editingTextElement?.id).toBe(boundText?.id);
expect(h.state.activeTool.type).toBe("selection");
expect(h.state.activeTool.locked).toBe(false);
});
it("keeps sticky note tool active without editing when tool is locked", async () => {
const { getByToolName, container } = await render();
fireEvent.click(getByToolName("stickynote"));
fireEvent.click(getByToolName("lock"));
const canvas = container.querySelector("canvas.interactive")!;
createDefaultStickyNote(canvas);
await waitFor(() => {
expect(
h.elements.filter((element) => element.type === "stickynote"),
).toHaveLength(1);
});
expect(h.elements.filter((element) => element.type === "text")).toEqual(
[],
);
expect(h.state.editingTextElement).toBeNull();
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBeNull();
expect(h.state.activeTool.type).toBe("stickynote");
expect(h.state.activeTool.locked).toBe(true);
expect(h.state.selectedElementIds).toEqual({});
});
it("treats a wobble under the drag threshold as a click", async () => {
const { getByToolName, container } = await render();
fireEvent.click(getByToolName("stickynote"));
const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 300, clientY: 300 });
fireEvent.pointerMove(canvas, { clientX: 306, clientY: 305 });
fireEvent.pointerUp(canvas, { clientX: 306, clientY: 305 });
await getTextEditor();
const stickyNote = h.elements.find(
(element) => element.type === "stickynote",
) as ExcalidrawStickyNoteElement;
expect(stickyNote.width).toBe(DEFAULT_STICKY_NOTE_SIZE);
expect(stickyNote.height).toBe(DEFAULT_STICKY_NOTE_SIZE);
expect(stickyNote.baseHeight).toBe(DEFAULT_STICKY_NOTE_SIZE);
});
it("previews a drag at its true size and snaps to the minimum on release", async () => {
const { getByToolName, container } = await render();
fireEvent.click(getByToolName("stickynote"));
const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 300, clientY: 300 });
fireEvent.pointerMove(canvas, { clientX: 320, clientY: 320 });
// mid-gesture: no clamp, the note is exactly what was dragged
const draft = h.elements.find(
(element) => element.type === "stickynote",
) as ExcalidrawStickyNoteElement;
expect(draft.width).toBe(20);
expect(draft.height).toBe(20);
fireEvent.pointerUp(canvas, { clientX: 320, clientY: 320 });
await getTextEditor();
// on release it grows to the minimum, away from the origin corner: one
// 25px line at the default 20px ceiling plus padding and the date footer
// high — and as wide, since a plain drag is proportional and the square
// survives the snap
const stickyNote = h.elements.find(
(element) => element.type === "stickynote",
) as ExcalidrawStickyNoteElement;
expect(stickyNote.x).toBe(300);
expect(stickyNote.y).toBe(300);
expect(stickyNote.width).toBe(25 + STICKY_NOTE_BODY_INSET_Y);
expect(stickyNote.height).toBe(25 + STICKY_NOTE_BODY_INSET_Y);
expect(stickyNote.baseHeight).toBe(25 + STICKY_NOTE_BODY_INSET_Y);
});
it("sizes the minimum note to fit one line at a large font ceiling", async () => {
const { getByToolName, container } = await render();
// a 60px line (48 × 1.25) plus padding and the date footer high — and as
// wide, since a plain drag is proportional
API.setAppState({ currentItemFontSize: 48 });
fireEvent.click(getByToolName("stickynote"));
const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 300, clientY: 300 });
fireEvent.pointerMove(canvas, { clientX: 320, clientY: 320 });
fireEvent.pointerUp(canvas, { clientX: 320, clientY: 320 });
const editor = await getTextEditor();
const stickyNote = h.elements.find(
(element) => element.type === "stickynote",
) as ExcalidrawStickyNoteElement;
expect(stickyNote.width).toBe(60 + STICKY_NOTE_BODY_INSET_Y);
expect(stickyNote.height).toBe(60 + STICKY_NOTE_BODY_INSET_Y);
// one line fits without growing the note
updateTextEditor(editor, "A");
expect(
(
h.elements.find(
(element) => element.type === "stickynote",
) as ExcalidrawStickyNoteElement
).height,
).toBe(60 + STICKY_NOTE_BODY_INSET_Y);
});
it("drags out a square note by default and keeps it square through the snap", async () => {
const { getByToolName, container } = await render();
fireEvent.click(getByToolName("stickynote"));
const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 300, clientY: 300 });
// an uneven drag: proportional by default, so the draft is square
fireEvent.pointerMove(canvas, { clientX: 340, clientY: 320 });
const draft = h.elements.find(
(element) => element.type === "stickynote",
) as ExcalidrawStickyNoteElement;
expect([draft.width, draft.height]).toEqual([40, 40]);
fireEvent.pointerUp(canvas, { clientX: 340, clientY: 320 });
await getTextEditor();
// the two minimums differ; the square survives the snap
const note = h.elements.find((element) => element.type === "stickynote")!;
expect(note.width).toBe(25 + STICKY_NOTE_BODY_INSET_Y);
expect(note.height).toBe(25 + STICKY_NOTE_BODY_INSET_Y);
});
it("drags out a free-form note with Shift", async () => {
const { getByToolName, container } = await render();
fireEvent.click(getByToolName("stickynote"));
const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, {
clientX: 300,
clientY: 300,
shiftKey: true,
});
fireEvent.pointerMove(canvas, {
clientX: 340,
clientY: 320,
shiftKey: true,
});
const draft = h.elements.find(
(element) => element.type === "stickynote",
) as ExcalidrawStickyNoteElement;
expect([draft.width, draft.height]).toEqual([40, 20]);
fireEvent.pointerUp(canvas, {
clientX: 340,
clientY: 320,
shiftKey: true,
});
await getTextEditor();
// each side snaps to its own minimum
const note = h.elements.find((element) => element.type === "stickynote")!;
expect(note.width).toBe(STICKY_NOTE_MIN_SIZE);
expect(note.height).toBe(25 + STICKY_NOTE_BODY_INSET_Y);
});
it("keeps the origin corner fixed when a drag toward the top-left snaps to the minimum", async () => {
const { getByToolName, container } = await render();
fireEvent.click(getByToolName("stickynote"));
const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 300, clientY: 300 });
fireEvent.pointerMove(canvas, { clientX: 280, clientY: 280 });
fireEvent.pointerUp(canvas, { clientX: 280, clientY: 280 });
await getTextEditor();
const stickyNote = h.elements.find(
(element) => element.type === "stickynote",
) as ExcalidrawStickyNoteElement;
// the bottom-right corner is where the pointer went down
expect(stickyNote.x + stickyNote.width).toBe(300);
expect(stickyNote.y + stickyNote.height).toBe(300);
expect(stickyNote.width).toBe(25 + STICKY_NOTE_BODY_INSET_Y);
});
});
describe("do not add element to the scene if size is too small", () => {
beforeAll(() => {
mockBoundingClientRect();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
it("rectangle", async () => {
const { getByToolName, container } = await render();
// select tool
const tool = getByToolName("rectangle");
fireEvent.click(tool);
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
it("ellipse", async () => {
const { getByToolName, container } = await render();
// select tool
const tool = getByToolName("ellipse");
fireEvent.click(tool);
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
it("diamond", async () => {
const { getByToolName, container } = await render();
// select tool
const tool = getByToolName("diamond");
fireEvent.click(tool);
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
// finish (position does not matter)
fireEvent.pointerUp(canvas);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`5`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
});
it("arrow", async () => {
const { getByToolName, container } = await render(
,
);
// select tool
const tool = getByToolName("arrow");
fireEvent.click(tool);
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
// finish (position does not matter)
fireEvent.pointerUp(canvas);
// we need to finalize it because arrows and lines enter multi-mode
fireEvent.keyDown(document, {
key: KEYS.ENTER,
});
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`7`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements).toEqual([
expect.objectContaining({
type: "arrow",
isDeleted: true,
}),
]);
});
it("line", async () => {
const { getByToolName, container } = await render(
,
);
// select tool
const tool = getByToolName("line");
fireEvent.click(tool);
const canvas = container.querySelector("canvas.interactive")!;
// start from (30, 20)
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
// finish (position does not matter)
fireEvent.pointerUp(canvas);
// we need to finalize it because arrows and lines enter multi-mode
fireEvent.keyDown(document, {
key: KEYS.ENTER,
});
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`7`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements).toEqual([
expect.objectContaining({
type: "line",
isDeleted: true,
}),
]);
});
});
});