diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/tests/helpers | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/tests/helpers')
| -rw-r--r-- | packages/excalidraw/tests/helpers/api.ts | 511 | ||||
| -rw-r--r-- | packages/excalidraw/tests/helpers/colorize.ts | 2 | ||||
| -rw-r--r-- | packages/excalidraw/tests/helpers/mocks.ts | 32 | ||||
| -rw-r--r-- | packages/excalidraw/tests/helpers/polyfills.ts | 95 | ||||
| -rw-r--r-- | packages/excalidraw/tests/helpers/ui.ts | 647 |
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; + }; +} |
