cropElement.test.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. import React from "react";
  2. import { vi } from "vitest";
  3. import { KEYS, cloneJSON } from "@excalidraw/common";
  4. import {
  5. Excalidraw,
  6. exportToCanvas,
  7. exportToSvg,
  8. } from "@excalidraw/excalidraw";
  9. import {
  10. actionFlipHorizontal,
  11. actionFlipVertical,
  12. } from "@excalidraw/excalidraw/actions";
  13. import { API } from "@excalidraw/excalidraw/tests/helpers/api";
  14. import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
  15. import {
  16. act,
  17. GlobalTestState,
  18. render,
  19. unmountComponent,
  20. } from "@excalidraw/excalidraw/tests/test-utils";
  21. import type { NormalizedZoomValue } from "@excalidraw/excalidraw/types";
  22. import { duplicateElement } from "../src/duplicate";
  23. import type { ExcalidrawImageElement, ImageCrop } from "../src/types";
  24. const { h } = window;
  25. const mouse = new Pointer("mouse");
  26. beforeEach(async () => {
  27. unmountComponent();
  28. mouse.reset();
  29. localStorage.clear();
  30. sessionStorage.clear();
  31. vi.clearAllMocks();
  32. Object.assign(document, {
  33. elementFromPoint: () => GlobalTestState.canvas,
  34. });
  35. await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
  36. API.setAppState({
  37. zoom: {
  38. value: 1 as NormalizedZoomValue,
  39. },
  40. });
  41. const image = API.createElement({ type: "image", width: 200, height: 100 });
  42. API.setElements([image]);
  43. API.setAppState({
  44. selectedElementIds: {
  45. [image.id]: true,
  46. },
  47. });
  48. });
  49. const generateRandomNaturalWidthAndHeight = (image: ExcalidrawImageElement) => {
  50. const initialWidth = image.width;
  51. const initialHeight = image.height;
  52. const scale = 1 + Math.random() * 5;
  53. return {
  54. naturalWidth: initialWidth * scale,
  55. naturalHeight: initialHeight * scale,
  56. };
  57. };
  58. const compareCrops = (cropA: ImageCrop, cropB: ImageCrop) => {
  59. (Object.keys(cropA) as [keyof ImageCrop]).forEach((key) => {
  60. const propA = cropA[key];
  61. const propB = cropB[key];
  62. expect(propA as number).toBeCloseTo(propB as number);
  63. });
  64. };
  65. describe("Enter and leave the crop editor", () => {
  66. it("enter the editor by double clicking", () => {
  67. const image = h.elements[0];
  68. expect(h.state.croppingElementId).toBe(null);
  69. mouse.doubleClickOn(image);
  70. expect(h.state.croppingElementId).not.toBe(null);
  71. expect(h.state.croppingElementId).toBe(image.id);
  72. });
  73. it("enter the editor by pressing enter", () => {
  74. const image = h.elements[0];
  75. expect(h.state.croppingElementId).toBe(null);
  76. Keyboard.keyDown(KEYS.ENTER);
  77. expect(h.state.croppingElementId).not.toBe(null);
  78. expect(h.state.croppingElementId).toBe(image.id);
  79. });
  80. it("leave the editor by clicking outside", () => {
  81. const image = h.elements[0];
  82. Keyboard.keyDown(KEYS.ENTER);
  83. expect(h.state.croppingElementId).not.toBe(null);
  84. mouse.click(image.x - 20, image.y - 20);
  85. expect(h.state.croppingElementId).toBe(null);
  86. });
  87. it("leave the editor by pressing escape", () => {
  88. const image = h.elements[0];
  89. mouse.doubleClickOn(image);
  90. expect(h.state.croppingElementId).not.toBe(null);
  91. Keyboard.keyDown(KEYS.ESCAPE);
  92. expect(h.state.croppingElementId).toBe(null);
  93. });
  94. });
  95. describe("Crop an image", () => {
  96. it("Cropping changes the dimension", async () => {
  97. const image = h.elements[0] as ExcalidrawImageElement;
  98. const initialWidth = image.width;
  99. const initialHeight = image.height;
  100. const { naturalWidth, naturalHeight } =
  101. generateRandomNaturalWidthAndHeight(image);
  102. UI.crop(image, "w", naturalWidth, naturalHeight, [initialWidth / 2, 0]);
  103. expect(image.width).toBeLessThan(initialWidth);
  104. UI.crop(image, "n", naturalWidth, naturalHeight, [0, initialHeight / 2]);
  105. expect(image.height).toBeLessThan(initialHeight);
  106. });
  107. it("Cropping has minimal sizes", async () => {
  108. const image = h.elements[0] as ExcalidrawImageElement;
  109. const initialWidth = image.width;
  110. const initialHeight = image.height;
  111. const { naturalWidth, naturalHeight } =
  112. generateRandomNaturalWidthAndHeight(image);
  113. UI.crop(image, "w", naturalWidth, naturalHeight, [initialWidth, 0]);
  114. expect(image.width).toBeLessThan(initialWidth);
  115. expect(image.width).toBeGreaterThan(0);
  116. UI.crop(image, "w", naturalWidth, naturalHeight, [-initialWidth, 0]);
  117. UI.crop(image, "n", naturalWidth, naturalHeight, [0, initialHeight]);
  118. expect(image.height).toBeLessThan(initialHeight);
  119. expect(image.height).toBeGreaterThan(0);
  120. });
  121. it("Preserve aspect ratio", async () => {
  122. let image = h.elements[0] as ExcalidrawImageElement;
  123. const initialWidth = image.width;
  124. const initialHeight = image.height;
  125. const { naturalWidth, naturalHeight } =
  126. generateRandomNaturalWidthAndHeight(image);
  127. UI.crop(image, "w", naturalWidth, naturalHeight, [initialWidth / 3, 0]);
  128. let resizedWidth = image.width;
  129. let resizedHeight = image.height;
  130. // max height, cropping should not change anything
  131. UI.crop(
  132. image,
  133. "w",
  134. naturalWidth,
  135. naturalHeight,
  136. [-initialWidth / 3, 0],
  137. true,
  138. );
  139. expect(image.width).toBeCloseTo(resizedWidth, 10);
  140. expect(image.height).toBeCloseTo(resizedHeight, 10);
  141. // re-crop to initial state
  142. UI.crop(image, "w", naturalWidth, naturalHeight, [-initialWidth / 3, 0]);
  143. // change crop height and width
  144. UI.crop(image, "s", naturalWidth, naturalHeight, [0, -initialHeight / 2]);
  145. UI.crop(image, "e", naturalWidth, naturalHeight, [-initialWidth / 3, 0]);
  146. resizedWidth = image.width;
  147. resizedHeight = image.height;
  148. // test corner handle aspect ratio preserving
  149. UI.crop(image, "se", naturalWidth, naturalHeight, [initialWidth, 0], true);
  150. expect(image.width / image.height).toBe(resizedWidth / resizedHeight);
  151. expect(image.width).toBeLessThanOrEqual(initialWidth + 0.0001);
  152. expect(image.height).toBeLessThanOrEqual(initialHeight + 0.0001);
  153. // reset
  154. image = API.createElement({ type: "image", width: 200, height: 100 });
  155. API.setElements([image]);
  156. API.setAppState({
  157. selectedElementIds: {
  158. [image.id]: true,
  159. },
  160. });
  161. // 50 x 50 square
  162. UI.crop(image, "nw", naturalWidth, naturalHeight, [150, 50]);
  163. UI.crop(image, "n", naturalWidth, naturalHeight, [0, -100], true);
  164. expect(image.width).toBeCloseTo(image.height);
  165. // image is at the corner, not space to its right to expand, should not be able to resize
  166. expect(image.height).toBeCloseTo(50);
  167. UI.crop(image, "nw", naturalWidth, naturalHeight, [-150, -100], true);
  168. expect(image.width).toBeCloseTo(image.height);
  169. // max height should be reached
  170. expect(image.height).toBeCloseTo(initialHeight);
  171. expect(image.width).toBeCloseTo(initialHeight);
  172. });
  173. });
  174. describe("Cropping and other features", async () => {
  175. it("Cropping works independently of duplication", async () => {
  176. const image = h.elements[0] as ExcalidrawImageElement;
  177. const initialWidth = image.width;
  178. const initialHeight = image.height;
  179. const { naturalWidth, naturalHeight } =
  180. generateRandomNaturalWidthAndHeight(image);
  181. UI.crop(image, "nw", naturalWidth, naturalHeight, [
  182. initialWidth / 2,
  183. initialHeight / 2,
  184. ]);
  185. Keyboard.keyDown(KEYS.ESCAPE);
  186. const duplicatedImage = duplicateElement(null, new Map(), image);
  187. act(() => {
  188. h.app.scene.insertElement(duplicatedImage);
  189. });
  190. expect(duplicatedImage.width).toBe(image.width);
  191. expect(duplicatedImage.height).toBe(image.height);
  192. UI.crop(duplicatedImage, "nw", naturalWidth, naturalHeight, [
  193. -initialWidth / 2,
  194. -initialHeight / 2,
  195. ]);
  196. expect(duplicatedImage.width).toBe(initialWidth);
  197. expect(duplicatedImage.height).toBe(initialHeight);
  198. const resizedWidth = image.width;
  199. const resizedHeight = image.height;
  200. expect(image.width).not.toBe(duplicatedImage.width);
  201. expect(image.height).not.toBe(duplicatedImage.height);
  202. UI.crop(duplicatedImage, "se", naturalWidth, naturalHeight, [
  203. -initialWidth / 1.5,
  204. -initialHeight / 1.5,
  205. ]);
  206. expect(duplicatedImage.width).not.toBe(initialWidth);
  207. expect(image.width).toBe(resizedWidth);
  208. expect(duplicatedImage.height).not.toBe(initialHeight);
  209. expect(image.height).toBe(resizedHeight);
  210. });
  211. it("Resizing should not affect crop", async () => {
  212. const image = h.elements[0] as ExcalidrawImageElement;
  213. const initialWidth = image.width;
  214. const initialHeight = image.height;
  215. const { naturalWidth, naturalHeight } =
  216. generateRandomNaturalWidthAndHeight(image);
  217. UI.crop(image, "nw", naturalWidth, naturalHeight, [
  218. initialWidth / 2,
  219. initialHeight / 2,
  220. ]);
  221. const cropBeforeResizing = image.crop;
  222. const cropBeforeResizingCloned = cloneJSON(image.crop) as ImageCrop;
  223. expect(cropBeforeResizing).not.toBe(null);
  224. UI.crop(image, "e", naturalWidth, naturalHeight, [200, 0]);
  225. expect(cropBeforeResizing).toBe(image.crop);
  226. compareCrops(cropBeforeResizingCloned, image.crop!);
  227. UI.resize(image, "s", [0, -100]);
  228. expect(cropBeforeResizing).toBe(image.crop);
  229. compareCrops(cropBeforeResizingCloned, image.crop!);
  230. UI.resize(image, "ne", [-50, -50]);
  231. expect(cropBeforeResizing).toBe(image.crop);
  232. compareCrops(cropBeforeResizingCloned, image.crop!);
  233. });
  234. it("Flipping does not change crop", async () => {
  235. const image = h.elements[0] as ExcalidrawImageElement;
  236. const initialWidth = image.width;
  237. const initialHeight = image.height;
  238. const { naturalWidth, naturalHeight } =
  239. generateRandomNaturalWidthAndHeight(image);
  240. mouse.doubleClickOn(image);
  241. expect(h.state.croppingElementId).not.toBe(null);
  242. UI.crop(image, "nw", naturalWidth, naturalHeight, [
  243. initialWidth / 2,
  244. initialHeight / 2,
  245. ]);
  246. Keyboard.keyDown(KEYS.ESCAPE);
  247. const cropBeforeResizing = image.crop;
  248. const cropBeforeResizingCloned = cloneJSON(image.crop) as ImageCrop;
  249. API.executeAction(actionFlipHorizontal);
  250. expect(image.crop).toBe(cropBeforeResizing);
  251. compareCrops(cropBeforeResizingCloned, image.crop!);
  252. API.executeAction(actionFlipVertical);
  253. expect(image.crop).toBe(cropBeforeResizing);
  254. compareCrops(cropBeforeResizingCloned, image.crop!);
  255. });
  256. it("Exports should preserve crops", async () => {
  257. const image = h.elements[0] as ExcalidrawImageElement;
  258. const initialWidth = image.width;
  259. const initialHeight = image.height;
  260. const { naturalWidth, naturalHeight } =
  261. generateRandomNaturalWidthAndHeight(image);
  262. mouse.doubleClickOn(image);
  263. expect(h.state.croppingElementId).not.toBe(null);
  264. UI.crop(image, "nw", naturalWidth, naturalHeight, [
  265. initialWidth / 2,
  266. initialHeight / 4,
  267. ]);
  268. Keyboard.keyDown(KEYS.ESCAPE);
  269. const widthToHeightRatio = image.width / image.height;
  270. const canvas = await exportToCanvas({
  271. elements: [image],
  272. // @ts-ignore
  273. appState: h.state,
  274. files: h.app.files,
  275. exportPadding: 0,
  276. });
  277. const exportedCanvasRatio = canvas.width / canvas.height;
  278. expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio);
  279. const svg = await exportToSvg({
  280. elements: [image],
  281. // @ts-ignore
  282. appState: h.state,
  283. files: h.app.files,
  284. exportPadding: 0,
  285. });
  286. const svgWidth = svg.getAttribute("width");
  287. const svgHeight = svg.getAttribute("height");
  288. expect(svgWidth).toBeDefined();
  289. expect(svgHeight).toBeDefined();
  290. const exportedSvgRatio = Number(svgWidth) / Number(svgHeight);
  291. expect(widthToHeightRatio).toBeCloseTo(exportedSvgRatio);
  292. });
  293. });