aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/tests/helpers
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/tests/helpers
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/tests/helpers')
-rw-r--r--packages/excalidraw/tests/helpers/api.ts511
-rw-r--r--packages/excalidraw/tests/helpers/colorize.ts2
-rw-r--r--packages/excalidraw/tests/helpers/mocks.ts32
-rw-r--r--packages/excalidraw/tests/helpers/polyfills.ts95
-rw-r--r--packages/excalidraw/tests/helpers/ui.ts647
5 files changed, 1287 insertions, 0 deletions
diff --git a/packages/excalidraw/tests/helpers/api.ts b/packages/excalidraw/tests/helpers/api.ts
new file mode 100644
index 0000000..218f371
--- /dev/null
+++ b/packages/excalidraw/tests/helpers/api.ts
@@ -0,0 +1,511 @@
+import type {
+ ExcalidrawElement,
+ ExcalidrawGenericElement,
+ ExcalidrawTextElement,
+ ExcalidrawLinearElement,
+ ExcalidrawFreeDrawElement,
+ ExcalidrawImageElement,
+ FileId,
+ ExcalidrawFrameElement,
+ ExcalidrawElementType,
+ ExcalidrawMagicFrameElement,
+ ExcalidrawElbowArrowElement,
+ ExcalidrawArrowElement,
+ FixedSegment,
+} from "../../element/types";
+import { newElement, newTextElement, newLinearElement } from "../../element";
+import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
+import { getDefaultAppState } from "../../appState";
+import { GlobalTestState, createEvent, fireEvent, act } from "../test-utils";
+import fs from "fs";
+import util from "util";
+import path from "path";
+import { getMimeType } from "../../data/blob";
+import {
+ newArrowElement,
+ newEmbeddableElement,
+ newFrameElement,
+ newFreeDrawElement,
+ newIframeElement,
+ newImageElement,
+ newMagicFrameElement,
+} from "../../element/newElement";
+import type { AppState } from "../../types";
+import { getSelectedElements } from "../../scene/selection";
+import { isLinearElementType } from "../../element/typeChecks";
+import type { Mutable } from "../../utility-types";
+import { assertNever } from "../../utils";
+import type App from "../../components/App";
+import { createTestHook } from "../../components/App";
+import type { Action } from "../../actions/types";
+import { mutateElement } from "../../element/mutateElement";
+import { pointFrom, type LocalPoint, type Radians } from "@excalidraw/math";
+import { selectGroupsForSelectedElements } from "../../groups";
+
+const readFile = util.promisify(fs.readFile);
+// so that window.h is available when App.tsx is not imported as well.
+createTestHook();
+
+const { h } = window;
+
+export class API {
+ static updateScene: InstanceType<typeof App>["updateScene"] = (...args) => {
+ act(() => {
+ h.app.updateScene(...args);
+ });
+ };
+ static setAppState: React.Component<any, AppState>["setState"] = (
+ state,
+ cb,
+ ) => {
+ act(() => {
+ h.setState(state, cb);
+ });
+ };
+
+ static setElements = (elements: readonly ExcalidrawElement[]) => {
+ act(() => {
+ h.elements = elements;
+ });
+ };
+
+ static setSelectedElements = (elements: ExcalidrawElement[], editingGroupId?: string | null) => {
+ act(() => {
+ h.setState({
+ ...selectGroupsForSelectedElements(
+ {
+ editingGroupId: editingGroupId ?? null,
+ selectedElementIds: elements.reduce((acc, element) => {
+ acc[element.id] = true;
+ return acc;
+ }, {} as Record<ExcalidrawElement["id"], true>),
+ },
+ elements,
+ h.state,
+ h.app,
+ )
+ });
+ });
+ };
+
+ // eslint-disable-next-line prettier/prettier
+ static updateElement = <T extends ExcalidrawElement>(
+ ...args: Parameters<typeof mutateElement<T>>
+ ) => {
+ act(() => {
+ mutateElement<T>(...args);
+ });
+ };
+
+ static getSelectedElements = (
+ includeBoundTextElement: boolean = false,
+ includeElementsInFrames: boolean = false,
+ ): ExcalidrawElement[] => {
+ return getSelectedElements(h.elements, h.state, {
+ includeBoundTextElement,
+ includeElementsInFrames,
+ });
+ };
+
+ static getSelectedElement = (): ExcalidrawElement => {
+ const selectedElements = API.getSelectedElements();
+ if (selectedElements.length !== 1) {
+ throw new Error(
+ `expected 1 selected element; got ${selectedElements.length}`,
+ );
+ }
+ return selectedElements[0];
+ };
+
+ static getUndoStack = () => {
+ // @ts-ignore
+ return h.history.undoStack;
+ };
+
+ static getRedoStack = () => {
+ // @ts-ignore
+ return h.history.redoStack;
+ };
+
+ static getSnapshot = () => {
+ return Array.from(h.store.snapshot.elements.values());
+ };
+
+ static clearSelection = () => {
+ act(() => {
+ // @ts-ignore
+ h.app.clearSelection(null);
+ });
+ expect(API.getSelectedElements().length).toBe(0);
+ };
+
+ static getElement = <T extends ExcalidrawElement>(element: T): T => {
+ return h.app.scene.getElementsMapIncludingDeleted().get(element.id) as T || element;
+ }
+
+ static createElement = <
+ T extends Exclude<ExcalidrawElementType, "selection"> = "rectangle",
+ >({
+ // @ts-ignore
+ type = "rectangle",
+ id,
+ x = 0,
+ y = x,
+ width = 100,
+ height = width,
+ isDeleted = false,
+ groupIds = [],
+ ...rest
+ }: {
+ type?: T;
+ x?: number;
+ y?: number;
+ height?: number;
+ width?: number;
+ angle?: number;
+ id?: string;
+ isDeleted?: boolean;
+ frameId?: ExcalidrawElement["id"] | null;
+ index?: ExcalidrawElement["index"];
+ groupIds?: ExcalidrawElement["groupIds"];
+ // generic element props
+ strokeColor?: ExcalidrawGenericElement["strokeColor"];
+ backgroundColor?: ExcalidrawGenericElement["backgroundColor"];
+ fillStyle?: ExcalidrawGenericElement["fillStyle"];
+ strokeWidth?: ExcalidrawGenericElement["strokeWidth"];
+ strokeStyle?: ExcalidrawGenericElement["strokeStyle"];
+ roundness?: ExcalidrawGenericElement["roundness"];
+ roughness?: ExcalidrawGenericElement["roughness"];
+ opacity?: ExcalidrawGenericElement["opacity"];
+ // text props
+ text?: T extends "text" ? ExcalidrawTextElement["text"] : never;
+ fontSize?: T extends "text" ? ExcalidrawTextElement["fontSize"] : never;
+ fontFamily?: T extends "text" ? ExcalidrawTextElement["fontFamily"] : never;
+ textAlign?: T extends "text" ? ExcalidrawTextElement["textAlign"] : never;
+ verticalAlign?: T extends "text"
+ ? ExcalidrawTextElement["verticalAlign"]
+ : never;
+ boundElements?: ExcalidrawGenericElement["boundElements"];
+ containerId?: T extends "text"
+ ? ExcalidrawTextElement["containerId"]
+ : never;
+ points?: T extends "arrow" | "line" | "freedraw" ? readonly LocalPoint[] : never;
+ locked?: boolean;
+ fileId?: T extends "image" ? string : never;
+ scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
+ status?: T extends "image" ? ExcalidrawImageElement["status"] : never;
+ startBinding?: T extends "arrow"
+ ? ExcalidrawArrowElement["startBinding"] | ExcalidrawElbowArrowElement["startBinding"]
+ : never;
+ endBinding?: T extends "arrow"
+ ? ExcalidrawArrowElement["endBinding"] | ExcalidrawElbowArrowElement["endBinding"]
+ : never;
+ startArrowhead?: T extends "arrow"
+ ? ExcalidrawArrowElement["startArrowhead"] | ExcalidrawElbowArrowElement["startArrowhead"]
+ : never;
+ endArrowhead?: T extends "arrow"
+ ? ExcalidrawArrowElement["endArrowhead"] | ExcalidrawElbowArrowElement["endArrowhead"]
+ : never;
+ elbowed?: boolean;
+ fixedSegments?: FixedSegment[] | null;
+ }): T extends "arrow" | "line"
+ ? ExcalidrawLinearElement
+ : T extends "freedraw"
+ ? ExcalidrawFreeDrawElement
+ : T extends "text"
+ ? ExcalidrawTextElement
+ : T extends "image"
+ ? ExcalidrawImageElement
+ : T extends "frame"
+ ? ExcalidrawFrameElement
+ : T extends "magicframe"
+ ? ExcalidrawMagicFrameElement
+ : ExcalidrawGenericElement => {
+ let element: Mutable<ExcalidrawElement> = null!;
+
+ const appState = h?.state || getDefaultAppState();
+
+ const base: Omit<
+ ExcalidrawGenericElement,
+ | "id"
+ | "type"
+ | "version"
+ | "versionNonce"
+ | "isDeleted"
+ | "groupIds"
+ | "link"
+ | "updated"
+ > = {
+ seed: 1,
+ x,
+ y,
+ width,
+ height,
+ frameId: rest.frameId ?? null,
+ index: rest.index ?? null,
+ angle: (rest.angle ?? 0) as Radians,
+ strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor,
+ backgroundColor:
+ rest.backgroundColor ?? appState.currentItemBackgroundColor,
+ fillStyle: rest.fillStyle ?? appState.currentItemFillStyle,
+ strokeWidth: rest.strokeWidth ?? appState.currentItemStrokeWidth,
+ strokeStyle: rest.strokeStyle ?? appState.currentItemStrokeStyle,
+ roundness: (
+ rest.roundness === undefined
+ ? appState.currentItemRoundness === "round"
+ : rest.roundness
+ )
+ ? {
+ type: isLinearElementType(type)
+ ? ROUNDNESS.PROPORTIONAL_RADIUS
+ : ROUNDNESS.ADAPTIVE_RADIUS,
+ }
+ : null,
+ roughness: rest.roughness ?? appState.currentItemRoughness,
+ opacity: rest.opacity ?? appState.currentItemOpacity,
+ boundElements: rest.boundElements ?? null,
+ locked: rest.locked ?? false,
+ };
+ switch (type) {
+ case "rectangle":
+ case "diamond":
+ case "ellipse":
+ element = newElement({
+ type: type as "rectangle" | "diamond" | "ellipse",
+ ...base,
+ });
+ break;
+ case "embeddable":
+ element = newEmbeddableElement({
+ type: "embeddable",
+ ...base,
+ });
+ break;
+ case "iframe":
+ element = newIframeElement({
+ type: "iframe",
+ ...base,
+ });
+ break;
+ case "text":
+ const fontSize = rest.fontSize ?? appState.currentItemFontSize;
+ const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily;
+ element = newTextElement({
+ ...base,
+ text: rest.text || "test",
+ fontSize,
+ fontFamily,
+ textAlign: rest.textAlign ?? appState.currentItemTextAlign,
+ verticalAlign: rest.verticalAlign ?? DEFAULT_VERTICAL_ALIGN,
+ containerId: rest.containerId ?? undefined,
+ });
+ element.width = width;
+ element.height = height;
+ break;
+ case "freedraw":
+ element = newFreeDrawElement({
+ type: type as "freedraw",
+ simulatePressure: true,
+ points: rest.points,
+ ...base,
+ });
+ break;
+ case "arrow":
+ element = newArrowElement({
+ ...base,
+ width,
+ height,
+ type,
+ points: rest.points ?? [
+ pointFrom<LocalPoint>(0, 0),
+ pointFrom<LocalPoint>(100, 100),
+ ],
+ elbowed: rest.elbowed ?? false,
+ });
+ break;
+ case "line":
+ element = newLinearElement({
+ ...base,
+ width,
+ height,
+ type,
+ points: rest.points ?? [
+ pointFrom<LocalPoint>(0, 0),
+ pointFrom<LocalPoint>(100, 100),
+ ],
+ });
+ break;
+ case "image":
+ element = newImageElement({
+ ...base,
+ width,
+ height,
+ type,
+ fileId: (rest.fileId as string as FileId) ?? null,
+ status: rest.status || "saved",
+ scale: rest.scale || [1, 1],
+ });
+ break;
+ case "frame":
+ element = newFrameElement({ ...base, width, height });
+ break;
+ case "magicframe":
+ element = newMagicFrameElement({ ...base, width, height });
+ break;
+ default:
+ assertNever(
+ type,
+ `API.createElement: unimplemented element type ${type}}`,
+ );
+ break;
+ }
+ if (element.type === "arrow") {
+ element.startBinding = rest.startBinding ?? null;
+ element.endBinding = rest.endBinding ?? null;
+ element.startArrowhead = rest.startArrowhead ?? null;
+ element.endArrowhead = rest.endArrowhead ?? null;
+ }
+ if (id) {
+ element.id = id;
+ }
+ if (isDeleted) {
+ element.isDeleted = isDeleted;
+ }
+ if (groupIds) {
+ element.groupIds = groupIds;
+ }
+ return element as any;
+ };
+
+ static createTextContainer = (opts?: {
+ frameId?: ExcalidrawElement["id"];
+ groupIds?: ExcalidrawElement["groupIds"];
+ label?: {
+ text?: string;
+ frameId?: ExcalidrawElement["id"] | null;
+ groupIds?: ExcalidrawElement["groupIds"];
+ };
+ }) => {
+ const rectangle = API.createElement({
+ type: "rectangle",
+ frameId: opts?.frameId || null,
+ groupIds: opts?.groupIds,
+ });
+
+ const text = API.createElement({
+ type: "text",
+ text: opts?.label?.text || "sample-text",
+ width: 50,
+ height: 20,
+ fontSize: 16,
+ containerId: rectangle.id,
+ frameId:
+ opts?.label?.frameId === undefined
+ ? opts?.frameId ?? null
+ : opts?.label?.frameId ?? null,
+ groupIds: opts?.label?.groupIds === undefined
+ ? opts?.groupIds
+ : opts?.label?.groupIds ,
+
+ });
+
+ mutateElement(
+ rectangle,
+ {
+ boundElements: [{ type: "text", id: text.id }],
+ },
+ false,
+ );
+
+ return [rectangle, text];
+ };
+
+ static createLabeledArrow = (opts?: {
+ frameId?: ExcalidrawElement["id"];
+ label?: {
+ text?: string;
+ frameId?: ExcalidrawElement["id"] | null;
+ };
+ }) => {
+ const arrow = API.createElement({
+ type: "arrow",
+ frameId: opts?.frameId || null,
+ });
+
+ const text = API.createElement({
+ type: "text",
+ id: "text2",
+ width: 50,
+ height: 20,
+ containerId: arrow.id,
+ frameId:
+ opts?.label?.frameId === undefined
+ ? opts?.frameId ?? null
+ : opts?.label?.frameId ?? null,
+ });
+
+ mutateElement(
+ arrow,
+ {
+ boundElements: [{ type: "text", id: text.id }],
+ },
+ false,
+ );
+
+ return [arrow, text];
+ };
+
+ static readFile = async <T extends "utf8" | null>(
+ filepath: string,
+ encoding?: T,
+ ): Promise<T extends "utf8" ? string : Buffer> => {
+ filepath = path.isAbsolute(filepath)
+ ? filepath
+ : path.resolve(path.join(__dirname, "../", filepath));
+ return readFile(filepath, { encoding }) as any;
+ };
+
+ static loadFile = async (filepath: string) => {
+ const { base, ext } = path.parse(filepath);
+ return new File([await API.readFile(filepath, null)], base, {
+ type: getMimeType(ext),
+ });
+ };
+
+ static drop = async (blob: Blob) => {
+ const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
+ const text = await new Promise<string>((resolve, reject) => {
+ try {
+ const reader = new FileReader();
+ reader.onload = () => {
+ resolve(reader.result as string);
+ };
+ reader.readAsText(blob);
+ } catch (error: any) {
+ reject(error);
+ }
+ });
+
+ const files = [blob] as File[] & { item: (index: number) => File };
+ files.item = (index: number) => files[index];
+
+ Object.defineProperty(fileDropEvent, "dataTransfer", {
+ value: {
+ files,
+ getData: (type: string) => {
+ if (type === blob.type) {
+ return text;
+ }
+ return "";
+ },
+ },
+ });
+ await fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent);
+ };
+
+ static executeAction = (action: Action) => {
+ act(() => {
+ h.app.actionManager.executeAction(action);
+ });
+ };
+}
diff --git a/packages/excalidraw/tests/helpers/colorize.ts b/packages/excalidraw/tests/helpers/colorize.ts
new file mode 100644
index 0000000..ca9c39d
--- /dev/null
+++ b/packages/excalidraw/tests/helpers/colorize.ts
@@ -0,0 +1,2 @@
+export const yellow = (str: string) => `\u001b[33m${str}\u001b[0m`;
+export const red = (str: string) => `\u001b[31m${str}\u001b[0m`;
diff --git a/packages/excalidraw/tests/helpers/mocks.ts b/packages/excalidraw/tests/helpers/mocks.ts
new file mode 100644
index 0000000..a87523e
--- /dev/null
+++ b/packages/excalidraw/tests/helpers/mocks.ts
@@ -0,0 +1,32 @@
+import { vi } from "vitest";
+import * as MermaidToExcalidraw from "@excalidraw/mermaid-to-excalidraw";
+import type { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
+import React from "react";
+
+export const mockMermaidToExcalidraw = (opts: {
+ parseMermaidToExcalidraw: typeof parseMermaidToExcalidraw;
+ mockRef?: boolean;
+}) => {
+ vi.mock("@excalidraw/mermaid-to-excalidraw", async (importActual) => {
+ const module = (await importActual()) as any;
+
+ return {
+ __esModule: true,
+ ...module,
+ };
+ });
+ const parseMermaidToExcalidrawSpy = vi.spyOn(
+ MermaidToExcalidraw,
+ "parseMermaidToExcalidraw",
+ );
+
+ parseMermaidToExcalidrawSpy.mockImplementation(opts.parseMermaidToExcalidraw);
+
+ if (opts.mockRef) {
+ vi.spyOn(React, "useRef").mockReturnValue({
+ current: {
+ parseMermaidToExcalidraw: parseMermaidToExcalidrawSpy,
+ },
+ });
+ }
+};
diff --git a/packages/excalidraw/tests/helpers/polyfills.ts b/packages/excalidraw/tests/helpers/polyfills.ts
new file mode 100644
index 0000000..967e8d4
--- /dev/null
+++ b/packages/excalidraw/tests/helpers/polyfills.ts
@@ -0,0 +1,95 @@
+import { URL } from "node:url";
+
+class ClipboardEvent {
+ constructor(
+ type: "paste" | "copy",
+ eventInitDict: {
+ clipboardData: DataTransfer;
+ },
+ ) {
+ return Object.assign(
+ new Event("paste", {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ }),
+ {
+ clipboardData: eventInitDict.clipboardData,
+ },
+ ) as any as ClipboardEvent;
+ }
+}
+
+type DataKind = "string" | "file";
+
+class DataTransferItem {
+ kind: DataKind;
+ type: string;
+ data: string | Blob;
+
+ constructor(kind: DataKind, type: string, data: string | Blob) {
+ this.kind = kind;
+ this.type = type;
+ this.data = data;
+ }
+
+ getAsString(callback: (data: string) => void): void {
+ if (this.kind === "string") {
+ callback(this.data as string);
+ }
+ }
+
+ getAsFile(): File | null {
+ if (this.kind === "file" && this.data instanceof File) {
+ return this.data;
+ }
+ return null;
+ }
+}
+
+class DataTransferList {
+ items: DataTransferItem[] = [];
+
+ add(data: string | File, type: string = ""): void {
+ if (typeof data === "string") {
+ this.items.push(new DataTransferItem("string", type, data));
+ } else if (data instanceof File) {
+ this.items.push(new DataTransferItem("file", type, data));
+ }
+ }
+
+ clear(): void {
+ this.items = [];
+ }
+}
+
+class DataTransfer {
+ public items: DataTransferList = new DataTransferList();
+ private _types: Record<string, string> = {};
+
+ get files() {
+ return this.items.items
+ .filter((item) => item.kind === "file")
+ .map((item) => item.getAsFile()!);
+ }
+
+ add(data: string | File, type: string = ""): void {
+ this.items.add(data, type);
+ }
+
+ setData(type: string, value: string) {
+ this._types[type] = value;
+ }
+
+ getData(type: string) {
+ return this._types[type] || "";
+ }
+}
+
+export const testPolyfills = {
+ ClipboardEvent,
+ DataTransfer,
+ DataTransferItem,
+ // https://github.com/vitest-dev/vitest/pull/4164#issuecomment-2172729965
+ URL,
+};
diff --git a/packages/excalidraw/tests/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts
new file mode 100644
index 0000000..b9b7023
--- /dev/null
+++ b/packages/excalidraw/tests/helpers/ui.ts
@@ -0,0 +1,647 @@
+import type {
+ ExcalidrawElement,
+ ExcalidrawLinearElement,
+ ExcalidrawTextElement,
+ ExcalidrawArrowElement,
+ ExcalidrawRectangleElement,
+ ExcalidrawEllipseElement,
+ ExcalidrawDiamondElement,
+ ExcalidrawTextContainer,
+ ExcalidrawTextElementWithContainer,
+ ExcalidrawImageElement,
+} from "../../element/types";
+import type { TransformHandleType } from "../../element/transformHandles";
+import {
+ getTransformHandles,
+ getTransformHandlesFromCoords,
+ OMIT_SIDES_FOR_FRAME,
+ OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
+ type TransformHandle,
+ type TransformHandleDirection,
+} from "../../element/transformHandles";
+import { KEYS } from "../../keys";
+import { act, fireEvent, GlobalTestState, screen } from "../test-utils";
+import { mutateElement } from "../../element/mutateElement";
+import { API } from "./api";
+import {
+ isLinearElement,
+ isFreeDrawElement,
+ isTextElement,
+ isFrameLikeElement,
+} from "../../element/typeChecks";
+import { getCommonBounds, getElementPointsCoords } from "../../element/bounds";
+import { getTextEditor } from "../queries/dom";
+import { arrayToMap } from "../../utils";
+import { createTestHook } from "../../components/App";
+import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
+import { pointFrom, pointRotateRads } from "@excalidraw/math";
+import { cropElement } from "../../element/cropElement";
+import type { ToolType } from "../../types";
+
+// so that window.h is available when App.tsx is not imported as well.
+createTestHook();
+
+const { h } = window;
+
+let altKey = false;
+let shiftKey = false;
+let ctrlKey = false;
+
+export type KeyboardModifiers = {
+ alt?: boolean;
+ shift?: boolean;
+ ctrl?: boolean;
+};
+export class Keyboard {
+ static withModifierKeys = (modifiers: KeyboardModifiers, cb: () => void) => {
+ const prevAltKey = altKey;
+ const prevShiftKey = shiftKey;
+ const prevCtrlKey = ctrlKey;
+
+ altKey = !!modifiers.alt;
+ shiftKey = !!modifiers.shift;
+ ctrlKey = !!modifiers.ctrl;
+
+ try {
+ cb();
+ } finally {
+ altKey = prevAltKey;
+ shiftKey = prevShiftKey;
+ ctrlKey = prevCtrlKey;
+ }
+ };
+
+ static keyDown = (
+ key: string,
+ target: HTMLElement | Document | Window = document,
+ ) => {
+ fireEvent.keyDown(target, {
+ key,
+ ctrlKey,
+ shiftKey,
+ altKey,
+ });
+ };
+
+ static keyUp = (
+ key: string,
+ target: HTMLElement | Document | Window = document,
+ ) => {
+ fireEvent.keyUp(target, {
+ key,
+ ctrlKey,
+ shiftKey,
+ altKey,
+ });
+ };
+
+ static keyPress = (key: string, target?: HTMLElement | Document | Window) => {
+ Keyboard.keyDown(key, target);
+ Keyboard.keyUp(key, target);
+ };
+
+ static codeDown = (code: string) => {
+ fireEvent.keyDown(document, {
+ code,
+ ctrlKey,
+ shiftKey,
+ altKey,
+ });
+ };
+
+ static codeUp = (code: string) => {
+ fireEvent.keyUp(document, {
+ code,
+ ctrlKey,
+ shiftKey,
+ altKey,
+ });
+ };
+
+ static codePress = (code: string) => {
+ Keyboard.codeDown(code);
+ Keyboard.codeUp(code);
+ };
+
+ static undo = () => {
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress("z");
+ });
+ };
+
+ static redo = () => {
+ Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
+ Keyboard.keyPress("z");
+ });
+ };
+
+ static exitTextEditor = (textarea: HTMLTextAreaElement) => {
+ fireEvent.keyDown(textarea, { key: KEYS.ESCAPE });
+ };
+}
+
+const getElementPointForSelection = (
+ element: ExcalidrawElement,
+): GlobalPoint => {
+ const { x, y, width, height, angle } = element;
+ const target = pointFrom<GlobalPoint>(
+ x +
+ (isLinearElement(element) || isFreeDrawElement(element) ? 0 : width / 2),
+ y,
+ );
+ let center: GlobalPoint;
+
+ if (isLinearElement(element)) {
+ const bounds = getElementPointsCoords(element, element.points);
+ center = pointFrom(
+ (bounds[0] + bounds[2]) / 2,
+ (bounds[1] + bounds[3]) / 2,
+ );
+ } else {
+ center = pointFrom(x + width / 2, y + height / 2);
+ }
+
+ if (isTextElement(element)) {
+ return center;
+ }
+
+ return pointRotateRads(target, center, angle);
+};
+
+export class Pointer {
+ public clientX = 0;
+ public clientY = 0;
+
+ constructor(
+ private readonly pointerType: "mouse" | "touch" | "pen",
+ private readonly pointerId = 1,
+ ) {}
+
+ reset() {
+ this.clientX = 0;
+ this.clientY = 0;
+ }
+
+ getPosition() {
+ return [this.clientX, this.clientY];
+ }
+
+ restorePosition(x = 0, y = 0) {
+ this.clientX = x;
+ this.clientY = y;
+ fireEvent.pointerMove(GlobalTestState.interactiveCanvas, this.getEvent());
+ }
+
+ private getEvent() {
+ return {
+ clientX: this.clientX,
+ clientY: this.clientY,
+ pointerType: this.pointerType,
+ pointerId: this.pointerId,
+ altKey,
+ shiftKey,
+ ctrlKey,
+ };
+ }
+
+ // incremental (moving by deltas)
+ // ---------------------------------------------------------------------------
+
+ move(dx: number, dy: number) {
+ if (dx !== 0 || dy !== 0) {
+ this.clientX += dx;
+ this.clientY += dy;
+ fireEvent.pointerMove(GlobalTestState.interactiveCanvas, this.getEvent());
+ }
+ }
+
+ down(dx = 0, dy = 0) {
+ this.move(dx, dy);
+ fireEvent.pointerDown(GlobalTestState.interactiveCanvas, this.getEvent());
+ }
+
+ up(dx = 0, dy = 0) {
+ this.move(dx, dy);
+ fireEvent.pointerUp(GlobalTestState.interactiveCanvas, this.getEvent());
+ }
+
+ click(dx = 0, dy = 0) {
+ this.down(dx, dy);
+ this.up();
+ }
+
+ doubleClick(dx = 0, dy = 0) {
+ this.move(dx, dy);
+ fireEvent.doubleClick(GlobalTestState.interactiveCanvas, this.getEvent());
+ }
+
+ // absolute coords
+ // ---------------------------------------------------------------------------
+
+ moveTo(x: number = this.clientX, y: number = this.clientY) {
+ this.clientX = x;
+ this.clientY = y;
+ // fire "mousemove" to update editor cursor position
+ fireEvent.mouseMove(document, this.getEvent());
+ fireEvent.pointerMove(GlobalTestState.interactiveCanvas, this.getEvent());
+ }
+
+ downAt(x = this.clientX, y = this.clientY) {
+ this.clientX = x;
+ this.clientY = y;
+ fireEvent.pointerDown(GlobalTestState.interactiveCanvas, this.getEvent());
+ }
+
+ upAt(x = this.clientX, y = this.clientY) {
+ this.clientX = x;
+ this.clientY = y;
+ fireEvent.pointerUp(GlobalTestState.interactiveCanvas, this.getEvent());
+ }
+
+ clickAt(x: number, y: number) {
+ this.downAt(x, y);
+ this.upAt();
+ }
+
+ rightClickAt(x: number, y: number) {
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: x,
+ clientY: y,
+ });
+ }
+
+ doubleClickAt(x: number, y: number) {
+ this.moveTo(x, y);
+ fireEvent.doubleClick(GlobalTestState.interactiveCanvas, this.getEvent());
+ }
+
+ // ---------------------------------------------------------------------------
+
+ select(
+ /** if multiple elements supplied, they're shift-selected */
+ elements: ExcalidrawElement | ExcalidrawElement[],
+ ) {
+ API.clearSelection();
+
+ Keyboard.withModifierKeys({ shift: true }, () => {
+ elements = Array.isArray(elements) ? elements : [elements];
+ elements.forEach((element) => {
+ this.reset();
+ this.click(...getElementPointForSelection(element));
+ });
+ });
+
+ this.reset();
+ }
+
+ clickOn(element: ExcalidrawElement) {
+ this.reset();
+ this.click(...getElementPointForSelection(element));
+ this.reset();
+ }
+
+ doubleClickOn(element: ExcalidrawElement) {
+ this.reset();
+ this.doubleClick(...getElementPointForSelection(element));
+ this.reset();
+ }
+}
+
+const mouse = new Pointer("mouse");
+
+const transform = (
+ element: ExcalidrawElement | ExcalidrawElement[],
+ handle: TransformHandleType,
+ mouseMove: [deltaX: number, deltaY: number],
+ keyboardModifiers: KeyboardModifiers = {},
+) => {
+ const elements = Array.isArray(element) ? element : [element];
+ act(() => {
+ h.setState({
+ selectedElementIds: elements.reduce(
+ (acc, e) => ({
+ ...acc,
+ [e.id]: true,
+ }),
+ {},
+ ),
+ });
+ });
+ let handleCoords: TransformHandle | undefined;
+ if (elements.length === 1) {
+ handleCoords = getTransformHandles(
+ elements[0],
+ h.state.zoom,
+ arrayToMap(h.elements),
+ "mouse",
+ {},
+ )[handle];
+ } else {
+ const [x1, y1, x2, y2] = getCommonBounds(elements);
+ const isFrameSelected = elements.some(isFrameLikeElement);
+ const transformHandles = getTransformHandlesFromCoords(
+ [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
+ 0 as Radians,
+ h.state.zoom,
+ "mouse",
+ isFrameSelected ? OMIT_SIDES_FOR_FRAME : OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
+ );
+ handleCoords = transformHandles[handle];
+ }
+
+ if (!handleCoords) {
+ throw new Error(`There is no "${handle}" handle for this selection`);
+ }
+
+ const clientX = handleCoords[0] + handleCoords[2] / 2;
+ const clientY = handleCoords[1] + handleCoords[3] / 2;
+
+ Keyboard.withModifierKeys(keyboardModifiers, () => {
+ mouse.reset();
+ mouse.down(clientX, clientY);
+ mouse.move(mouseMove[0], mouseMove[1]);
+ mouse.up();
+ });
+};
+
+const proxy = <T extends ExcalidrawElement>(
+ element: T,
+): typeof element & {
+ /** Returns the actual, current element from the elements array, instead of
+ the proxy */
+ get(): typeof element;
+} => {
+ return new Proxy(
+ {},
+ {
+ get(target, prop) {
+ const currentElement = h.elements.find(
+ ({ id }) => id === element.id,
+ ) as any;
+ if (prop === "get") {
+ if (currentElement.hasOwnProperty("get")) {
+ throw new Error(
+ "trying to get `get` test property, but ExcalidrawElement seems to define its own",
+ );
+ }
+ return () => currentElement;
+ }
+ return currentElement[prop];
+ },
+ },
+ ) as any;
+};
+
+/** Tools that can be used to draw shapes */
+type DrawingToolName = Exclude<ToolType, "lock" | "selection" | "eraser">;
+
+type Element<T extends DrawingToolName> = T extends "line" | "freedraw"
+ ? ExcalidrawLinearElement
+ : T extends "arrow"
+ ? ExcalidrawArrowElement
+ : T extends "text"
+ ? ExcalidrawTextElement
+ : T extends "rectangle"
+ ? ExcalidrawRectangleElement
+ : T extends "ellipse"
+ ? ExcalidrawEllipseElement
+ : T extends "diamond"
+ ? ExcalidrawDiamondElement
+ : ExcalidrawElement;
+
+export class UI {
+ static clickTool = (toolName: ToolType | "lock") => {
+ fireEvent.click(GlobalTestState.renderResult.getByToolName(toolName));
+ };
+
+ static clickLabeledElement = (label: string) => {
+ const element = document.querySelector(`[aria-label='${label}']`);
+ if (!element) {
+ throw new Error(`No labeled element found: ${label}`);
+ }
+ fireEvent.click(element);
+ };
+
+ static clickOnTestId = (testId: string) => {
+ const element = document.querySelector(`[data-testid='${testId}']`);
+ // const element = GlobalTestState.renderResult.queryByTestId(testId);
+ if (!element) {
+ throw new Error(`No element with testid "${testId}" found`);
+ }
+ fireEvent.click(element);
+ };
+
+ static clickByTitle = (title: string) => {
+ fireEvent.click(screen.getByTitle(title));
+ };
+
+ /**
+ * Creates an Excalidraw element, and returns a proxy that wraps it so that
+ * accessing props will return the latest ones from the object existing in
+ * the app's elements array. This is because across the app lifecycle we tend
+ * to recreate element objects and the returned reference will become stale.
+ *
+ * If you need to get the actual element, not the proxy, call `get()` method
+ * on the proxy object.
+ */
+ static createElement<T extends DrawingToolName>(
+ type: T,
+ {
+ position = 0,
+ x = position,
+ y = position,
+ size = 10,
+ width: initialWidth = size,
+ height: initialHeight = initialWidth,
+ angle = 0,
+ points: initialPoints,
+ }: {
+ position?: number;
+ x?: number;
+ y?: number;
+ size?: number;
+ width?: number;
+ height?: number;
+ angle?: number;
+ points?: T extends "line" | "arrow" | "freedraw" ? LocalPoint[] : never;
+ } = {},
+ ): Element<T> & {
+ /** Returns the actual, current element from the elements array, instead
+ of the proxy */
+ get(): Element<T>;
+ } {
+ const width = initialWidth ?? initialHeight ?? size;
+ const height = initialHeight ?? size;
+ const points: LocalPoint[] = initialPoints ?? [
+ pointFrom(0, 0),
+ pointFrom(width, height),
+ ];
+
+ UI.clickTool(type);
+
+ if (type === "text") {
+ mouse.reset();
+ mouse.click(x, y);
+ } else if ((type === "line" || type === "arrow") && points.length > 2) {
+ points.forEach((point) => {
+ mouse.reset();
+ mouse.click(x + point[0], y + point[1]);
+ });
+ Keyboard.keyPress(KEYS.ESCAPE);
+ } else if (type === "freedraw" && points.length > 2) {
+ const firstPoint = points[0];
+ mouse.reset();
+ mouse.down(x + firstPoint[0], y + firstPoint[1]);
+ points
+ .slice(1)
+ .forEach((point) => mouse.moveTo(x + point[0], y + point[1]));
+ mouse.upAt();
+ Keyboard.keyPress(KEYS.ESCAPE);
+ } else {
+ mouse.reset();
+ mouse.down(x, y);
+ mouse.reset();
+ mouse.up(x + width, y + height);
+ }
+ const origElement = h.elements[h.elements.length - 1] as any;
+
+ if (angle !== 0) {
+ act(() => {
+ mutateElement(origElement, { angle });
+ });
+ }
+
+ return proxy(origElement);
+ }
+
+ static async editText<
+ T extends ExcalidrawTextElement | ExcalidrawTextContainer,
+ >(element: T, text: string) {
+ const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
+ const openedEditor =
+ document.querySelector<HTMLTextAreaElement>(textEditorSelector);
+
+ if (!openedEditor) {
+ mouse.select(element);
+ Keyboard.keyPress(KEYS.ENTER);
+ }
+
+ const editor = await getTextEditor(textEditorSelector);
+ if (!editor) {
+ throw new Error("Can't find wysiwyg text editor in the dom");
+ }
+
+ fireEvent.input(editor, { target: { value: text } });
+ act(() => {
+ editor.blur();
+ });
+
+ return isTextElement(element)
+ ? element
+ : proxy(
+ h.elements[
+ h.elements.length - 1
+ ] as ExcalidrawTextElementWithContainer,
+ );
+ }
+
+ static updateInput = (input: HTMLInputElement, value: string | number) => {
+ act(() => {
+ input.focus();
+ fireEvent.change(input, { target: { value: String(value) } });
+ input.blur();
+ });
+ };
+
+ static resize(
+ element: ExcalidrawElement | ExcalidrawElement[],
+ handle: TransformHandleDirection,
+ mouseMove: [deltaX: number, deltaY: number],
+ keyboardModifiers: KeyboardModifiers = {},
+ ) {
+ return transform(element, handle, mouseMove, keyboardModifiers);
+ }
+
+ static crop(
+ element: ExcalidrawImageElement,
+ handle: TransformHandleDirection,
+ naturalWidth: number,
+ naturalHeight: number,
+ mouseMove: [deltaX: number, deltaY: number],
+ keepAspectRatio = false,
+ ) {
+ const handleCoords = getTransformHandles(
+ element,
+ h.state.zoom,
+ arrayToMap(h.elements),
+ "mouse",
+ {},
+ )[handle]!;
+
+ const clientX = handleCoords[0] + handleCoords[2] / 2;
+ const clientY = handleCoords[1] + handleCoords[3] / 2;
+
+ const mutations = cropElement(
+ element,
+ handle,
+ naturalWidth,
+ naturalHeight,
+ clientX + mouseMove[0],
+ clientY + mouseMove[1],
+ keepAspectRatio ? element.width / element.height : undefined,
+ );
+
+ API.updateElement(element, mutations);
+ }
+
+ static rotate(
+ element: ExcalidrawElement | ExcalidrawElement[],
+ mouseMove: [deltaX: number, deltaY: number],
+ keyboardModifiers: KeyboardModifiers = {},
+ ) {
+ return transform(element, "rotation", mouseMove, keyboardModifiers);
+ }
+
+ static group(elements: ExcalidrawElement[]) {
+ mouse.select(elements);
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.G);
+ });
+ }
+
+ static ungroup(elements: ExcalidrawElement[]) {
+ mouse.select(elements);
+ Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
+ Keyboard.keyPress(KEYS.G);
+ });
+ }
+
+ static queryContextMenu = () => {
+ return GlobalTestState.renderResult.container.querySelector(
+ ".context-menu",
+ ) as HTMLElement | null;
+ };
+
+ static queryStats = () => {
+ return GlobalTestState.renderResult.container.querySelector(
+ ".exc-stats",
+ ) as HTMLElement | null;
+ };
+
+ static queryStatsProperty = (label: string) => {
+ const elementStats = UI.queryStats()?.querySelector("#elementStats");
+
+ expect(elementStats).not.toBeNull();
+
+ if (elementStats) {
+ return (
+ elementStats?.querySelector(
+ `.exc-stats__row .drag-input-container[data-testid="${label}"]`,
+ ) || null
+ );
+ }
+
+ return null;
+ };
+}