snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s

This commit is contained in:
upstream-mirror
2026-09-16 05:31:04 +05:00
commit 1a8d07ca6c
1286 changed files with 360144 additions and 0 deletions

View File

@@ -0,0 +1,579 @@
import fs from "fs";
import path from "path";
import util from "util";
import { pointFrom, type LocalPoint, type Radians } from "@excalidraw/math";
import {
DEFAULT_VERTICAL_ALIGN,
ROUNDNESS,
assertNever,
getStrokeWidthByKey,
getUpdatedTimestamp,
} from "@excalidraw/common";
import {
newArrowElement,
newElement,
newEmbeddableElement,
newFrameElement,
newFreeDrawElement,
newIframeElement,
newImageElement,
newLinearElement,
newMagicFrameElement,
newStickyNoteElement,
newTextElement,
} from "@excalidraw/element";
import { isUsingAdaptiveRadius, getSelectedElements } from "@excalidraw/element";
import { selectGroupsForSelectedElements } from "@excalidraw/element";
import { FONT_SIZES } from "@excalidraw/common";
import type {
ExcalidrawElement,
ExcalidrawGenericElement,
ExcalidrawTextElement,
ExcalidrawLinearElement,
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
FileId,
ExcalidrawFrameElement,
ExcalidrawElementType,
ExcalidrawMagicFrameElement,
ExcalidrawElbowArrowElement,
ExcalidrawArrowElement,
ExcalidrawStickyNoteElement,
FixedSegment,
NonDeleted,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import { getMimeType } from "../../data/blob";
import { createTestHook } from "../../components/App";
import { getDefaultAppState } from "../../appState";
import { GlobalTestState, createEvent, fireEvent, act } from "../test-utils";
import type { Action } from "../../actions/types";
import type App from "../../components/App";
import type { AppState } from "../../types";
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: NonDeletedExcalidrawElement[], 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 h.app.scene.mutateElement<T>>
) => {
act(() => {
h.app.scene.mutateElement(...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"];
created?: ExcalidrawElement["created"];
// 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"];
baseHeight?: T extends "stickynote"
? ExcalidrawStickyNoteElement["baseHeight"]
: never;
containerId?: T extends "text"
? ExcalidrawTextElement["containerId"]
: never;
points?: T extends "arrow" | "line" | "freedraw" ? readonly LocalPoint[] : never;
polygon?: T extends "line" ? boolean : never;
strokeOptions?: T extends "freedraw"
? ExcalidrawFreeDrawElement["strokeOptions"]
: 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;
}): NonDeleted<
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
: T extends "stickynote"
? ExcalidrawStickyNoteElement
: 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 ??
(type === "stickynote"
? appState.currentItemStickynoteStrokeColor
: appState.currentItemStrokeColor),
backgroundColor:
rest.backgroundColor ??
(type === "stickynote"
? appState.currentItemStickynoteBackgroundColor
: appState.currentItemBackgroundColor),
fillStyle: rest.fillStyle ?? appState.currentItemFillStyle,
strokeWidth:
rest.strokeWidth ??
getStrokeWidthByKey(type, appState.currentItemStrokeWidthKey),
strokeStyle: rest.strokeStyle ?? appState.currentItemStrokeStyle,
roundness: (
rest.roundness === undefined
? appState.currentItemRoundness === "round"
: rest.roundness
)
? {
type: isUsingAdaptiveRadius(type)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
roughness: rest.roughness ?? appState.currentItemRoughness,
opacity: rest.opacity ?? appState.currentItemOpacity,
boundElements: rest.boundElements ?? null,
locked: rest.locked ?? false,
created: rest.created === undefined ? getUpdatedTimestamp() : rest.created,
};
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 "stickynote":
element = newStickyNoteElement({
...base,
width,
height,
type,
baseHeight: rest.baseHeight ?? height,
});
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,
strokeOptions: rest.strokeOptions,
...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),
],
polygon: rest.polygon,
});
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: FONT_SIZES.sm,
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 ,
});
h.app.scene.mutateElement(
rectangle,
{
boundElements: [{ type: "text", id: text.id }],
},
);
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",
width: 50,
height: 20,
containerId: arrow.id,
frameId:
opts?.label?.frameId === undefined
? opts?.frameId ?? null
: opts?.label?.frameId ?? null,
});
h.app.scene.mutateElement(
arrow,
{
boundElements: [{ type: "text", id: text.id }],
},
);
return [arrow, text];
};
static readFile = async <T extends "utf8" | null>(
filepath: string,
encoding?: T,
): Promise<T extends "utf8" ? string : ArrayBuffer> => {
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 (items: ({kind: "string", value: string, type: string} | {kind: "file", file: File | Blob, type?: string })[]) => {
const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
const dataTransferFileItems = items.filter(i => i.kind === "file") as {kind: "file", file: File | Blob, type: string }[];
const files = dataTransferFileItems.map(item => item.file) as File[] & { item: (index: number) => File };
// https://developer.mozilla.org/en-US/docs/Web/API/FileList/item
files.item = (index: number) => files[index];
Object.defineProperty(fileDropEvent, "dataTransfer", {
value: {
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/files
files,
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/items
items: items.map((item, idx) => {
if (item.kind === "string") {
return {
kind: "string",
type: item.type,
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsString
getAsString: (cb: (text: string) => any) => cb(item.value),
};
}
return {
kind: "file",
type: item.type || item.file.type,
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsFile
getAsFile: () => item.file,
};
}),
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/getData
getData: (type: string) => {
return items.find((item) => item.type === "string" && item.type === type) || "";
},
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types
types: Array.from(new Set(items.map((item) => item.kind === "file" ? "Files" : item.type))),
},
});
Object.defineProperty(fileDropEvent, "clientX", {
value: 0,
});
Object.defineProperty(fileDropEvent, "clientY", {
value: 0,
});
await fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent);
};
static executeAction = (action: Action) => {
act(() => {
h.app.actionManager.executeAction(action);
});
};
}

View File

@@ -0,0 +1,2 @@
export const yellow = (str: string) => `\u001b[33m${str}\u001b[0m`;
export const red = (str: string) => `\u001b[31m${str}\u001b[0m`;

View File

@@ -0,0 +1,6 @@
export const INITIALIZED_IMAGE_PROPS = {
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
};

View File

@@ -0,0 +1,111 @@
import * as MermaidToExcalidraw from "@excalidraw/mermaid-to-excalidraw";
import React from "react";
import { vi } from "vitest";
import type { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
import type { throttleRAF as throttleRAFType } from "@excalidraw/common";
type ThrottledFn<T extends unknown[]> = ((...args: T) => void) & {
flush: () => void;
cancel: () => void;
};
export const mockThrottleRAF: typeof throttleRAFType = <T extends unknown[]>(
fn: (...args: T) => void,
) => {
const ret = ((...args: T) => {
fn(...args);
}) as ThrottledFn<T>;
ret.flush = () => {};
ret.cancel = () => {};
return ret;
};
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,
},
});
}
};
// Mock for HTMLImageElement (use with `vi.unstubAllGlobals()`)
// as jsdom.resources: "usable" throws an error on image load
export const mockHTMLImageElement = (
naturalWidth: number,
naturalHeight: number,
) => {
vi.stubGlobal(
"Image",
class extends Image {
constructor() {
super();
Object.defineProperty(this, "naturalWidth", {
value: naturalWidth,
});
Object.defineProperty(this, "naturalHeight", {
value: naturalHeight,
});
queueMicrotask(() => {
this.onload?.({} as Event);
});
}
},
);
};
// Mocks for multiple HTMLImageElements (dimensions are assigned in the order of image initialization)
export const mockMultipleHTMLImageElements = (
sizes: (readonly [number, number])[],
) => {
const _sizes = [...sizes];
vi.stubGlobal(
"Image",
class extends Image {
constructor() {
super();
const size = _sizes.shift();
if (!size) {
throw new Error("Insufficient sizes");
}
Object.defineProperty(this, "naturalWidth", {
value: size[0],
});
Object.defineProperty(this, "naturalHeight", {
value: size[1],
});
queueMicrotask(() => {
this.onload?.({} as Event);
});
}
},
);
};

View File

@@ -0,0 +1,135 @@
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 DataTransferItemList extends Array<DataTransferItem> {
add(data: string | File, type: string = ""): void {
if (typeof data === "string") {
this.push(new DataTransferItem("string", type, data));
} else if (data instanceof File) {
this.push(new DataTransferItem("file", type, data));
}
}
clear(): void {
this.clear();
}
}
class DataTransfer {
public items: DataTransferItemList = new DataTransferItemList();
get files() {
return this.items
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile()!);
}
add(data: string | File, type: string = ""): void {
if (typeof data === "string") {
this.items.add(data, type);
} else {
this.items.add(data);
}
}
setData(type: string, value: string) {
this.items.add(value, type);
}
getData(type: string) {
return this.items.find((item) => item.type === type)?.data || "";
}
}
export const testPolyfills = {
ClipboardEvent,
DataTransfer,
DataTransferItem,
// https://github.com/vitest-dev/vitest/pull/4164#issuecomment-2172729965
URL,
};
export const PolyfillLocalStorage = () => {
// Node.js 25+ provides a native localStorage global that shadows jsdom's,
// and jsdom's own localStorage also uses the native one -- both are broken
// (empty objects without Storage methods). On older Node versions, jsdom
// provides a working localStorage. This polyfill replaces localStorage on
// all supported versions with a standard Storage implementation backed by
// a Map, ensuring consistent behavior regardless of the Node.js version.
const storage = new Map<string, string>();
const storagePolyfill: Storage = {
get length() {
return storage.size;
},
clear() {
storage.clear();
},
key(index) {
return Array.from(storage.keys())[index] ?? null;
},
getItem(key) {
return storage.get(key) ?? null;
},
setItem(key, value) {
storage.set(key, value);
},
removeItem(key) {
storage.delete(key);
},
*[Symbol.iterator]() {
yield* storage.entries();
},
};
Object.defineProperty(window, "localStorage", {
value: storagePolyfill,
writable: true,
configurable: true,
});
};

View File

@@ -0,0 +1,700 @@
import { pointFrom, pointRotateRads } from "@excalidraw/math";
import {
elementCenterPoint,
getCommonBounds,
getElementPointsCoords,
getLineHeightInPx,
} from "@excalidraw/element";
import { cropElement } from "@excalidraw/element";
import {
getTransformHandles,
getTransformHandlesFromCoords,
OMIT_SIDES_FOR_FRAME,
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
type TransformHandle,
type TransformHandleDirection,
} from "@excalidraw/element";
import {
isLinearElement,
isFreeDrawElement,
isTextElement,
isFrameLikeElement,
} from "@excalidraw/element";
import { KEYS, arrayToMap, getLineHeight } from "@excalidraw/common";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
import type { TransformHandleType } from "@excalidraw/element";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
ExcalidrawArrowElement,
ExcalidrawRectangleElement,
ExcalidrawEllipseElement,
ExcalidrawDiamondElement,
ExcalidrawTextContainer,
ExcalidrawTextElementWithContainer,
ExcalidrawImageElement,
ElementsMap,
} from "@excalidraw/element/types";
import { createTestHook } from "../../components/App";
import { getTextEditor, TEXT_EDITOR_SELECTOR } from "../queries/dom";
import { act, fireEvent, GlobalTestState, screen } from "../test-utils";
import { API } from "./api";
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,
elementsMap: ElementsMap,
): GlobalPoint => {
const { x, y, width, 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 = elementCenterPoint(element, elementsMap);
}
if (isTextElement(element)) {
return center;
}
return pointRotateRads(target, center, angle);
};
export class Pointer {
public clientX = 0;
public clientY = 0;
static activePointers: Pointer[] = [];
static resetAll() {
Pointer.activePointers.forEach((pointer) => pointer.reset());
}
constructor(
private readonly pointerType: "mouse" | "touch" | "pen",
private readonly pointerId = 1,
) {
Pointer.activePointers.push(this);
}
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,
h.app.scene.getElementsMapIncludingDeleted(),
),
);
});
});
this.reset();
}
clickOn(element: ExcalidrawElement) {
this.reset();
this.click(
...getElementPointForSelection(
element,
h.app.scene.getElementsMapIncludingDeleted(),
),
);
this.reset();
}
doubleClickOn(element: ExcalidrawElement) {
this.reset();
this.doubleClick(
...getElementPointForSelection(
element,
h.app.scene.getElementsMapIncludingDeleted(),
),
);
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" | "lasso"
>;
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));
};
/** clicks a tool that lives in the extra-tools dropdown, opening it first */
static clickExtraTool = (toolName: ToolType) => {
fireEvent.click(
document.querySelector(".App-toolbar__extra-tools-trigger")!,
);
UI.clickTool(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") {
const clickY = h.state.gridModeEnabled
? y
: y +
getLineHeightInPx(
h.state.currentItemFontSize,
getLineHeight(h.state.currentItemFontFamily),
) /
2;
mouse.reset();
mouse.click(x, clickY);
} 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(() => {
h.app.scene.mutateElement(origElement, { angle });
});
}
return proxy(origElement);
}
static async editText<
T extends ExcalidrawTextElement | ExcalidrawTextContainer,
>(element: T, text: string) {
const openedEditor =
document.querySelector<HTMLTextAreaElement>(TEXT_EDITOR_SELECTOR);
if (!openedEditor) {
mouse.select(element);
Keyboard.keyPress(KEYS.ENTER);
}
const editor = await getTextEditor();
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,
h.scene.getNonDeletedElementsMap(),
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;
};
}