api.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. import type {
  2. ExcalidrawElement,
  3. ExcalidrawGenericElement,
  4. ExcalidrawTextElement,
  5. ExcalidrawLinearElement,
  6. ExcalidrawFreeDrawElement,
  7. ExcalidrawImageElement,
  8. FileId,
  9. ExcalidrawFrameElement,
  10. ExcalidrawElementType,
  11. ExcalidrawMagicFrameElement,
  12. } from "../../element/types";
  13. import { newElement, newTextElement, newLinearElement } from "../../element";
  14. import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
  15. import { getDefaultAppState } from "../../appState";
  16. import { GlobalTestState, createEvent, fireEvent, act } from "../test-utils";
  17. import fs from "fs";
  18. import util from "util";
  19. import path from "path";
  20. import { getMimeType } from "../../data/blob";
  21. import {
  22. newArrowElement,
  23. newEmbeddableElement,
  24. newFrameElement,
  25. newFreeDrawElement,
  26. newIframeElement,
  27. newImageElement,
  28. newMagicFrameElement,
  29. } from "../../element/newElement";
  30. import type { AppState, Point } from "../../types";
  31. import { getSelectedElements } from "../../scene/selection";
  32. import { isLinearElementType } from "../../element/typeChecks";
  33. import type { Mutable } from "../../utility-types";
  34. import { assertNever } from "../../utils";
  35. import type App from "../../components/App";
  36. import { createTestHook } from "../../components/App";
  37. import type { Action } from "../../actions/types";
  38. import { mutateElement } from "../../element/mutateElement";
  39. const readFile = util.promisify(fs.readFile);
  40. // so that window.h is available when App.tsx is not imported as well.
  41. createTestHook();
  42. const { h } = window;
  43. export class API {
  44. static updateScene: InstanceType<typeof App>["updateScene"] = (...args) => {
  45. act(() => {
  46. h.app.updateScene(...args);
  47. });
  48. };
  49. static setAppState: React.Component<any, AppState>["setState"] = (
  50. state,
  51. cb,
  52. ) => {
  53. act(() => {
  54. h.setState(state, cb);
  55. });
  56. };
  57. static setElements = (elements: readonly ExcalidrawElement[]) => {
  58. act(() => {
  59. h.elements = elements;
  60. });
  61. };
  62. static setSelectedElements = (elements: ExcalidrawElement[]) => {
  63. act(() => {
  64. h.setState({
  65. selectedElementIds: elements.reduce((acc, element) => {
  66. acc[element.id] = true;
  67. return acc;
  68. }, {} as Record<ExcalidrawElement["id"], true>),
  69. });
  70. });
  71. };
  72. static updateElement = (
  73. ...[element, updates]: Parameters<typeof mutateElement>
  74. ) => {
  75. act(() => {
  76. mutateElement(element, updates);
  77. });
  78. };
  79. static getSelectedElements = (
  80. includeBoundTextElement: boolean = false,
  81. includeElementsInFrames: boolean = false,
  82. ): ExcalidrawElement[] => {
  83. return getSelectedElements(h.elements, h.state, {
  84. includeBoundTextElement,
  85. includeElementsInFrames,
  86. });
  87. };
  88. static getSelectedElement = (): ExcalidrawElement => {
  89. const selectedElements = API.getSelectedElements();
  90. if (selectedElements.length !== 1) {
  91. throw new Error(
  92. `expected 1 selected element; got ${selectedElements.length}`,
  93. );
  94. }
  95. return selectedElements[0];
  96. };
  97. static getUndoStack = () => {
  98. // @ts-ignore
  99. return h.history.undoStack;
  100. };
  101. static getRedoStack = () => {
  102. // @ts-ignore
  103. return h.history.redoStack;
  104. };
  105. static getSnapshot = () => {
  106. return Array.from(h.store.snapshot.elements.values());
  107. };
  108. static clearSelection = () => {
  109. act(() => {
  110. // @ts-ignore
  111. h.app.clearSelection(null);
  112. });
  113. expect(API.getSelectedElements().length).toBe(0);
  114. };
  115. static createElement = <
  116. T extends Exclude<ExcalidrawElementType, "selection"> = "rectangle",
  117. >({
  118. // @ts-ignore
  119. type = "rectangle",
  120. id,
  121. x = 0,
  122. y = x,
  123. width = 100,
  124. height = width,
  125. isDeleted = false,
  126. groupIds = [],
  127. ...rest
  128. }: {
  129. type?: T;
  130. x?: number;
  131. y?: number;
  132. height?: number;
  133. width?: number;
  134. angle?: number;
  135. id?: string;
  136. isDeleted?: boolean;
  137. frameId?: ExcalidrawElement["id"] | null;
  138. index?: ExcalidrawElement["index"];
  139. groupIds?: string[];
  140. // generic element props
  141. strokeColor?: ExcalidrawGenericElement["strokeColor"];
  142. backgroundColor?: ExcalidrawGenericElement["backgroundColor"];
  143. fillStyle?: ExcalidrawGenericElement["fillStyle"];
  144. strokeWidth?: ExcalidrawGenericElement["strokeWidth"];
  145. strokeStyle?: ExcalidrawGenericElement["strokeStyle"];
  146. roundness?: ExcalidrawGenericElement["roundness"];
  147. roughness?: ExcalidrawGenericElement["roughness"];
  148. opacity?: ExcalidrawGenericElement["opacity"];
  149. // text props
  150. text?: T extends "text" ? ExcalidrawTextElement["text"] : never;
  151. fontSize?: T extends "text" ? ExcalidrawTextElement["fontSize"] : never;
  152. fontFamily?: T extends "text" ? ExcalidrawTextElement["fontFamily"] : never;
  153. textAlign?: T extends "text" ? ExcalidrawTextElement["textAlign"] : never;
  154. verticalAlign?: T extends "text"
  155. ? ExcalidrawTextElement["verticalAlign"]
  156. : never;
  157. boundElements?: ExcalidrawGenericElement["boundElements"];
  158. containerId?: T extends "text"
  159. ? ExcalidrawTextElement["containerId"]
  160. : never;
  161. points?: T extends "arrow" | "line" ? readonly Point[] : never;
  162. locked?: boolean;
  163. fileId?: T extends "image" ? string : never;
  164. scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
  165. status?: T extends "image" ? ExcalidrawImageElement["status"] : never;
  166. startBinding?: T extends "arrow"
  167. ? ExcalidrawLinearElement["startBinding"]
  168. : never;
  169. endBinding?: T extends "arrow"
  170. ? ExcalidrawLinearElement["endBinding"]
  171. : never;
  172. elbowed?: boolean;
  173. }): T extends "arrow" | "line"
  174. ? ExcalidrawLinearElement
  175. : T extends "freedraw"
  176. ? ExcalidrawFreeDrawElement
  177. : T extends "text"
  178. ? ExcalidrawTextElement
  179. : T extends "image"
  180. ? ExcalidrawImageElement
  181. : T extends "frame"
  182. ? ExcalidrawFrameElement
  183. : T extends "magicframe"
  184. ? ExcalidrawMagicFrameElement
  185. : ExcalidrawGenericElement => {
  186. let element: Mutable<ExcalidrawElement> = null!;
  187. const appState = h?.state || getDefaultAppState();
  188. const base: Omit<
  189. ExcalidrawGenericElement,
  190. | "id"
  191. | "width"
  192. | "height"
  193. | "type"
  194. | "seed"
  195. | "version"
  196. | "versionNonce"
  197. | "isDeleted"
  198. | "groupIds"
  199. | "link"
  200. | "updated"
  201. > = {
  202. x,
  203. y,
  204. frameId: rest.frameId ?? null,
  205. index: rest.index ?? null,
  206. angle: rest.angle ?? 0,
  207. strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor,
  208. backgroundColor:
  209. rest.backgroundColor ?? appState.currentItemBackgroundColor,
  210. fillStyle: rest.fillStyle ?? appState.currentItemFillStyle,
  211. strokeWidth: rest.strokeWidth ?? appState.currentItemStrokeWidth,
  212. strokeStyle: rest.strokeStyle ?? appState.currentItemStrokeStyle,
  213. roundness: (
  214. rest.roundness === undefined
  215. ? appState.currentItemRoundness === "round"
  216. : rest.roundness
  217. )
  218. ? {
  219. type: isLinearElementType(type)
  220. ? ROUNDNESS.PROPORTIONAL_RADIUS
  221. : ROUNDNESS.ADAPTIVE_RADIUS,
  222. }
  223. : null,
  224. roughness: rest.roughness ?? appState.currentItemRoughness,
  225. opacity: rest.opacity ?? appState.currentItemOpacity,
  226. boundElements: rest.boundElements ?? null,
  227. locked: rest.locked ?? false,
  228. };
  229. switch (type) {
  230. case "rectangle":
  231. case "diamond":
  232. case "ellipse":
  233. element = newElement({
  234. type: type as "rectangle" | "diamond" | "ellipse",
  235. width,
  236. height,
  237. ...base,
  238. });
  239. break;
  240. case "embeddable":
  241. element = newEmbeddableElement({
  242. type: "embeddable",
  243. ...base,
  244. });
  245. break;
  246. case "iframe":
  247. element = newIframeElement({
  248. type: "iframe",
  249. ...base,
  250. });
  251. break;
  252. case "text":
  253. const fontSize = rest.fontSize ?? appState.currentItemFontSize;
  254. const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily;
  255. element = newTextElement({
  256. ...base,
  257. text: rest.text || "test",
  258. fontSize,
  259. fontFamily,
  260. textAlign: rest.textAlign ?? appState.currentItemTextAlign,
  261. verticalAlign: rest.verticalAlign ?? DEFAULT_VERTICAL_ALIGN,
  262. containerId: rest.containerId ?? undefined,
  263. });
  264. element.width = width;
  265. element.height = height;
  266. break;
  267. case "freedraw":
  268. element = newFreeDrawElement({
  269. type: type as "freedraw",
  270. simulatePressure: true,
  271. ...base,
  272. });
  273. break;
  274. case "arrow":
  275. element = newArrowElement({
  276. ...base,
  277. width,
  278. height,
  279. type,
  280. points: rest.points ?? [
  281. [0, 0],
  282. [100, 100],
  283. ],
  284. elbowed: rest.elbowed ?? false,
  285. });
  286. break;
  287. case "line":
  288. element = newLinearElement({
  289. ...base,
  290. width,
  291. height,
  292. type,
  293. points: rest.points ?? [
  294. [0, 0],
  295. [100, 100],
  296. ],
  297. });
  298. break;
  299. case "image":
  300. element = newImageElement({
  301. ...base,
  302. width,
  303. height,
  304. type,
  305. fileId: (rest.fileId as string as FileId) ?? null,
  306. status: rest.status || "saved",
  307. scale: rest.scale || [1, 1],
  308. });
  309. break;
  310. case "frame":
  311. element = newFrameElement({ ...base, width, height });
  312. break;
  313. case "magicframe":
  314. element = newMagicFrameElement({ ...base, width, height });
  315. break;
  316. default:
  317. assertNever(
  318. type,
  319. `API.createElement: unimplemented element type ${type}}`,
  320. );
  321. break;
  322. }
  323. if (element.type === "arrow") {
  324. element.startBinding = rest.startBinding ?? null;
  325. element.endBinding = rest.endBinding ?? null;
  326. }
  327. if (id) {
  328. element.id = id;
  329. }
  330. if (isDeleted) {
  331. element.isDeleted = isDeleted;
  332. }
  333. if (groupIds) {
  334. element.groupIds = groupIds;
  335. }
  336. return element as any;
  337. };
  338. static readFile = async <T extends "utf8" | null>(
  339. filepath: string,
  340. encoding?: T,
  341. ): Promise<T extends "utf8" ? string : Buffer> => {
  342. filepath = path.isAbsolute(filepath)
  343. ? filepath
  344. : path.resolve(path.join(__dirname, "../", filepath));
  345. return readFile(filepath, { encoding }) as any;
  346. };
  347. static loadFile = async (filepath: string) => {
  348. const { base, ext } = path.parse(filepath);
  349. return new File([await API.readFile(filepath, null)], base, {
  350. type: getMimeType(ext),
  351. });
  352. };
  353. static drop = async (blob: Blob) => {
  354. const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
  355. const text = await new Promise<string>((resolve, reject) => {
  356. try {
  357. const reader = new FileReader();
  358. reader.onload = () => {
  359. resolve(reader.result as string);
  360. };
  361. reader.readAsText(blob);
  362. } catch (error: any) {
  363. reject(error);
  364. }
  365. });
  366. const files = [blob] as File[] & { item: (index: number) => File };
  367. files.item = (index: number) => files[index];
  368. Object.defineProperty(fileDropEvent, "dataTransfer", {
  369. value: {
  370. files,
  371. getData: (type: string) => {
  372. if (type === blob.type) {
  373. return text;
  374. }
  375. return "";
  376. },
  377. },
  378. });
  379. await fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent);
  380. };
  381. static executeAction = (action: Action) => {
  382. act(() => {
  383. h.app.actionManager.executeAction(action);
  384. });
  385. };
  386. }