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/api.ts | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/tests/helpers/api.ts')
| -rw-r--r-- | packages/excalidraw/tests/helpers/api.ts | 511 |
1 files changed, 511 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); + }); + }; +} |
