snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
579
packages/excalidraw/tests/helpers/api.ts
Normal file
579
packages/excalidraw/tests/helpers/api.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
}
|
||||
2
packages/excalidraw/tests/helpers/colorize.ts
Normal file
2
packages/excalidraw/tests/helpers/colorize.ts
Normal 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`;
|
||||
6
packages/excalidraw/tests/helpers/constants.ts
Normal file
6
packages/excalidraw/tests/helpers/constants.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const INITIALIZED_IMAGE_PROPS = {
|
||||
type: "image",
|
||||
fileId: expect.any(String),
|
||||
x: expect.toBeNonNaNNumber(),
|
||||
y: expect.toBeNonNaNNumber(),
|
||||
};
|
||||
111
packages/excalidraw/tests/helpers/mocks.ts
Normal file
111
packages/excalidraw/tests/helpers/mocks.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
135
packages/excalidraw/tests/helpers/polyfills.ts
Normal file
135
packages/excalidraw/tests/helpers/polyfills.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
700
packages/excalidraw/tests/helpers/ui.ts
Normal file
700
packages/excalidraw/tests/helpers/ui.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user