Files
upstream-excalidraw/packages/excalidraw/tests/dragCreate.test.tsx
2026-09-16 05:31:04 +05:00

601 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(<Excalidraw />);
// 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(<Excalidraw />);
// 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(<Excalidraw />);
// 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(<Excalidraw />);
// 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(<Excalidraw />);
// 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(<Excalidraw />);
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(<Excalidraw />);
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(<Excalidraw />);
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(<Excalidraw />);
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(<Excalidraw />);
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(<Excalidraw />);
// 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(<Excalidraw />);
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(<Excalidraw />);
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(<Excalidraw />);
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(<Excalidraw />);
// 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(<Excalidraw />);
// 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(<Excalidraw />);
// 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(
<Excalidraw handleKeyboardGlobally={true} />,
);
// 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(
<Excalidraw handleKeyboardGlobally={true} />,
);
// 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,
}),
]);
});
});
});