Quellcode durchsuchen

refactor to simplify tests

Ryan Di vor 10 Monaten
Ursprung
Commit
b0375fe5db

+ 28 - 13
packages/excalidraw/components/App.tsx

@@ -9398,7 +9398,7 @@ class App extends React.Component<AppProps, AppState> {
   /**
    * inserts image into elements array and rerenders
    */
-  private insertImageElement = async (
+  insertImageElement = async (
     imageElement: ExcalidrawImageElement,
     imageFile: File,
     showCursorImagePreview?: boolean,
@@ -9551,7 +9551,7 @@ class App extends React.Component<AppProps, AppState> {
     }
   };
 
-  private initializeImageDimensions = (
+  initializeImageDimensions = (
     imageElement: ExcalidrawImageElement,
     forceNaturalSize = false,
   ) => {
@@ -10161,19 +10161,34 @@ class App extends React.Component<AppProps, AppState> {
       this.getEffectiveGridSize(),
     );
 
+    const element = this.state.croppingElement;
+
     if (transformHandleType) {
-      cropElement(
-        this.state.croppingElement,
-        this.scene.getNonDeletedElementsMap(),
-        this.imageCache,
-        transformHandleType,
-        x,
-        y,
-      );
+      const image =
+        isInitializedImageElement(element) &&
+        this.imageCache.get(element.fileId)?.image;
 
-      this.setState({
-        isCropping: transformHandleType && transformHandleType !== "rotation",
-      });
+      if (image && !(image instanceof Promise)) {
+        mutateElement(
+          element,
+          cropElement(
+            element,
+            transformHandleType,
+            image.naturalWidth,
+            image.naturalHeight,
+            x,
+            y,
+          ),
+        );
+
+        updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
+          oldSize: { width: element.width, height: element.height },
+        });
+
+        this.setState({
+          isCropping: transformHandleType && transformHandleType !== "rotation",
+        });
+      }
 
       return true;
     }

+ 1 - 36
packages/excalidraw/element/cropElement.ts

@@ -12,8 +12,6 @@ import {
   pointFromVector,
   clamp,
 } from "../../math";
-import { updateBoundElements } from "./binding";
-import { mutateElement } from "./mutateElement";
 import type { TransformHandleType } from "./transformHandles";
 import type {
   ElementsMap,
@@ -21,16 +19,13 @@ import type {
   ExcalidrawImageElement,
   ImageCrop,
   NonDeleted,
-  NonDeletedSceneElementsMap,
 } from "./types";
 import {
   getElementAbsoluteCoords,
   getResizedElementAbsoluteCoords,
 } from "./bounds";
-import type { AppClassProperties } from "../types";
-import { isInitializedImageElement } from "./typeChecks";
 
-const _cropElement = (
+export const cropElement = (
   element: ExcalidrawImageElement,
   transformHandle: TransformHandleType,
   naturalWidth: number,
@@ -152,36 +147,6 @@ const _cropElement = (
   };
 };
 
-export const cropElement = (
-  element: ExcalidrawImageElement,
-  elementsMap: NonDeletedSceneElementsMap,
-  imageCache: AppClassProperties["imageCache"],
-  transformHandle: TransformHandleType,
-  pointerX: number,
-  pointerY: number,
-) => {
-  const image =
-    isInitializedImageElement(element) && imageCache.get(element.fileId)?.image;
-
-  if (image && !(image instanceof Promise)) {
-    mutateElement(
-      element,
-      _cropElement(
-        element,
-        transformHandle,
-        image.naturalWidth,
-        image.naturalHeight,
-        pointerX,
-        pointerY,
-      ),
-    );
-
-    updateBoundElements(element, elementsMap, {
-      oldSize: { width: element.width, height: element.height },
-    });
-  }
-};
-
 const recomputeOrigin = (
   stateAtCropStart: NonDeleted<ExcalidrawElement>,
   transformHandle: TransformHandleType,

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
packages/excalidraw/tests/__snapshots__/export.test.tsx.snap


+ 285 - 0
packages/excalidraw/tests/cropElement.test.tsx

@@ -0,0 +1,285 @@
+import React from "react";
+import ReactDOM from "react-dom";
+import { vi } from "vitest";
+import { Keyboard, Pointer, UI } from "./helpers/ui";
+import type { ExcalidrawImageElement, ImageCrop } from "../element/types";
+import { GlobalTestState, render } from "./test-utils";
+import { Excalidraw, exportToCanvas, exportToSvg } from "..";
+import { API } from "./helpers/api";
+import type { NormalizedZoomValue } from "../types";
+import { KEYS } from "../keys";
+import { duplicateElement } from "../element";
+import { cloneJSON } from "../utils";
+import { actionFlipHorizontal, actionFlipVertical } from "../actions";
+
+const { h } = window;
+const mouse = new Pointer("mouse");
+
+beforeEach(async () => {
+  // Unmount ReactDOM from root
+  ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
+
+  mouse.reset();
+  localStorage.clear();
+  sessionStorage.clear();
+  vi.clearAllMocks();
+
+  Object.assign(document, {
+    elementFromPoint: () => GlobalTestState.canvas,
+  });
+  await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
+  API.setAppState({
+    zoom: {
+      value: 1 as NormalizedZoomValue,
+    },
+  });
+
+  const image = API.createElement({ type: "image", width: 200, height: 100 });
+  API.setElements([image]);
+  API.setAppState({
+    selectedElementIds: {
+      [image.id]: true,
+    },
+  });
+});
+
+const generateRandomNaturalWidthAndHeight = (image: ExcalidrawImageElement) => {
+  const initialWidth = image.width;
+  const initialHeight = image.height;
+
+  const scale = 1 + Math.random() * 5;
+
+  return {
+    naturalWidth: initialWidth * scale,
+    naturalHeight: initialHeight * scale,
+  };
+};
+
+const compareCrops = (cropA: ImageCrop, cropB: ImageCrop) => {
+  (Object.keys(cropA) as [keyof ImageCrop]).forEach((key) => {
+    const propA = cropA[key];
+    const propB = cropB[key];
+
+    if (key === "naturalDimension") {
+      const [naturalWidthA, naturalHeightA] = propA as [number, number];
+      const [naturalWidthB, naturalHeightB] = propB as [number, number];
+
+      expect(naturalWidthA).toBeCloseTo(naturalWidthB);
+      expect(naturalHeightA).toBeCloseTo(naturalHeightB);
+    } else {
+      expect(propA as number).toBeCloseTo(propB as number);
+    }
+  });
+};
+
+describe("Enter and leave the crop editor", () => {
+  it("enter the editor by double clicking", () => {
+    const image = h.elements[0];
+    expect(h.state.croppingElement).toBe(null);
+    mouse.doubleClickOn(image);
+    expect(h.state.croppingElement).not.toBe(null);
+    expect(h.state.croppingElement?.id).toBe(image.id);
+  });
+
+  it("enter the editor by pressing enter", () => {
+    const image = h.elements[0];
+    expect(h.state.croppingElement).toBe(null);
+    Keyboard.keyDown(KEYS.ENTER);
+    expect(h.state.croppingElement).not.toBe(null);
+    expect(h.state.croppingElement?.id).toBe(image.id);
+  });
+
+  it("leave the editor by clicking outside", () => {
+    const image = h.elements[0];
+    Keyboard.keyDown(KEYS.ENTER);
+    expect(h.state.croppingElement).not.toBe(null);
+
+    mouse.click(image.x - 20, image.y - 20);
+    expect(h.state.croppingElement).toBe(null);
+  });
+
+  it("leave the editor by pressing escape", () => {
+    const image = h.elements[0];
+    mouse.doubleClickOn(image);
+    expect(h.state.croppingElement).not.toBe(null);
+
+    Keyboard.keyDown(KEYS.ESCAPE);
+    expect(h.state.croppingElement).toBe(null);
+  });
+});
+
+describe("Crop an image", () => {
+  it("Cropping changes the dimension", async () => {
+    const image = h.elements[0] as ExcalidrawImageElement;
+
+    const initialWidth = image.width;
+    const initialHeight = image.height;
+
+    const { naturalWidth, naturalHeight } =
+      generateRandomNaturalWidthAndHeight(image);
+
+    UI.crop(image, "w", naturalWidth, naturalHeight, [initialWidth / 2, 0]);
+
+    expect(image.width).toBeLessThan(initialWidth);
+    UI.crop(image, "n", naturalWidth, naturalHeight, [0, initialHeight / 2]);
+    expect(image.height).toBeLessThan(initialHeight);
+  });
+
+  it("Cropping has minimal sizes", async () => {
+    const image = h.elements[0] as ExcalidrawImageElement;
+    const initialWidth = image.width;
+    const initialHeight = image.height;
+
+    const { naturalWidth, naturalHeight } =
+      generateRandomNaturalWidthAndHeight(image);
+
+    UI.crop(image, "w", naturalWidth, naturalHeight, [initialWidth, 0]);
+    expect(image.width).toBeLessThan(initialWidth);
+    expect(image.width).toBeGreaterThan(0);
+    UI.crop(image, "w", naturalWidth, naturalHeight, [-initialWidth, 0]);
+    UI.crop(image, "n", naturalWidth, naturalHeight, [0, initialHeight]);
+    expect(image.height).toBeLessThan(initialHeight);
+    expect(image.height).toBeGreaterThan(0);
+  });
+});
+
+describe("Cropping and other features", async () => {
+  it("Cropping works independently of duplication", async () => {
+    const image = h.elements[0] as ExcalidrawImageElement;
+    const initialWidth = image.width;
+    const initialHeight = image.height;
+
+    const { naturalWidth, naturalHeight } =
+      generateRandomNaturalWidthAndHeight(image);
+
+    UI.crop(image, "nw", naturalWidth, naturalHeight, [
+      initialWidth / 2,
+      initialHeight / 2,
+    ]);
+    Keyboard.keyDown(KEYS.ESCAPE);
+    const duplicatedImage = duplicateElement(null, new Map(), image, {});
+    h.app.scene.insertElement(duplicatedImage);
+
+    expect(duplicatedImage.width).toBe(image.width);
+    expect(duplicatedImage.height).toBe(image.height);
+
+    UI.crop(duplicatedImage, "nw", naturalWidth, naturalHeight, [
+      -initialWidth / 2,
+      -initialHeight / 2,
+    ]);
+    expect(duplicatedImage.width).toBe(initialWidth);
+    expect(duplicatedImage.height).toBe(initialHeight);
+    const resizedWidth = image.width;
+    const resizedHeight = image.height;
+
+    expect(image.width).not.toBe(duplicatedImage.width);
+    expect(image.height).not.toBe(duplicatedImage.height);
+    UI.crop(duplicatedImage, "se", naturalWidth, naturalHeight, [
+      -initialWidth / 1.5,
+      -initialHeight / 1.5,
+    ]);
+    expect(duplicatedImage.width).not.toBe(initialWidth);
+    expect(image.width).toBe(resizedWidth);
+    expect(duplicatedImage.height).not.toBe(initialHeight);
+    expect(image.height).toBe(resizedHeight);
+  });
+
+  it("Resizing should not affect crop", async () => {
+    const image = h.elements[0] as ExcalidrawImageElement;
+    const initialWidth = image.width;
+    const initialHeight = image.height;
+
+    const { naturalWidth, naturalHeight } =
+      generateRandomNaturalWidthAndHeight(image);
+
+    UI.crop(image, "nw", naturalWidth, naturalHeight, [
+      initialWidth / 2,
+      initialHeight / 2,
+    ]);
+    const cropBeforeResizing = image.crop;
+    const cropBeforeResizingCloned = cloneJSON(image.crop) as ImageCrop;
+    expect(cropBeforeResizing).not.toBe(null);
+
+    UI.crop(image, "e", naturalWidth, naturalHeight, [200, 0]);
+    expect(cropBeforeResizing).toBe(image.crop);
+    compareCrops(cropBeforeResizingCloned, image.crop!);
+
+    UI.resize(image, "s", [0, -100]);
+    expect(cropBeforeResizing).toBe(image.crop);
+    compareCrops(cropBeforeResizingCloned, image.crop!);
+
+    UI.resize(image, "ne", [-50, -50]);
+    expect(cropBeforeResizing).toBe(image.crop);
+    compareCrops(cropBeforeResizingCloned, image.crop!);
+  });
+
+  it("Flipping does not change crop", async () => {
+    const image = h.elements[0] as ExcalidrawImageElement;
+    const initialWidth = image.width;
+    const initialHeight = image.height;
+
+    const { naturalWidth, naturalHeight } =
+      generateRandomNaturalWidthAndHeight(image);
+
+    mouse.doubleClickOn(image);
+    expect(h.state.croppingElement).not.toBe(null);
+    UI.crop(image, "nw", naturalWidth, naturalHeight, [
+      initialWidth / 2,
+      initialHeight / 2,
+    ]);
+    Keyboard.keyDown(KEYS.ESCAPE);
+    const cropBeforeResizing = image.crop;
+    const cropBeforeResizingCloned = cloneJSON(image.crop) as ImageCrop;
+
+    API.executeAction(actionFlipHorizontal);
+    expect(image.crop).toBe(cropBeforeResizing);
+    compareCrops(cropBeforeResizingCloned, image.crop!);
+
+    API.executeAction(actionFlipVertical);
+    expect(image.crop).toBe(cropBeforeResizing);
+    compareCrops(cropBeforeResizingCloned, image.crop!);
+  });
+
+  it("Exports should preserve crops", async () => {
+    const image = h.elements[0] as ExcalidrawImageElement;
+    const initialWidth = image.width;
+    const initialHeight = image.height;
+
+    const { naturalWidth, naturalHeight } =
+      generateRandomNaturalWidthAndHeight(image);
+
+    mouse.doubleClickOn(image);
+    expect(h.state.croppingElement).not.toBe(null);
+    UI.crop(image, "nw", naturalWidth, naturalHeight, [
+      initialWidth / 2,
+      initialHeight / 4,
+    ]);
+    Keyboard.keyDown(KEYS.ESCAPE);
+    const widthToHeightRatio = image.width / image.height;
+
+    const canvas = await exportToCanvas({
+      elements: [image],
+      appState: h.state,
+      files: h.app.files,
+      exportPadding: 0,
+    });
+    const exportedCanvasRatio = canvas.width / canvas.height;
+
+    expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio);
+
+    const svg = await exportToSvg({
+      elements: [image],
+      appState: h.state,
+      files: h.app.files,
+      exportPadding: 0,
+    });
+    const svgWidth = svg.getAttribute("width");
+    const svgHeight = svg.getAttribute("height");
+
+    expect(svgWidth).toBeDefined();
+    expect(svgHeight).toBeDefined();
+
+    const exportedSvgRatio = Number(svgWidth) / Number(svgHeight);
+    expect(widthToHeightRatio).toBeCloseTo(exportedSvgRatio);
+  });
+});

+ 32 - 1
packages/excalidraw/tests/helpers/ui.ts

@@ -1,4 +1,3 @@
-import type { ToolType } from "../../types";
 import type {
   ExcalidrawElement,
   ExcalidrawLinearElement,
@@ -9,6 +8,7 @@ import type {
   ExcalidrawDiamondElement,
   ExcalidrawTextContainer,
   ExcalidrawTextElementWithContainer,
+  ExcalidrawImageElement,
 } from "../../element/types";
 import type { TransformHandleType } from "../../element/transformHandles";
 import {
@@ -35,6 +35,7 @@ import { arrayToMap } from "../../utils";
 import { createTestHook } from "../../components/App";
 import type { GlobalPoint, LocalPoint, Radians } from "../../../math";
 import { pointFrom, pointRotateRads } from "../../../math";
+import { cropElement } from "../../element/cropElement";
 
 // so that window.h is available when App.tsx is not imported as well.
 createTestHook();
@@ -561,6 +562,36 @@ export class UI {
     return transform(element, handle, mouseMove, keyboardModifiers);
   }
 
+  static crop(
+    element: ExcalidrawImageElement,
+    handle: TransformHandleDirection,
+    naturalWidth: number,
+    naturalHeight: number,
+    mouseMove: [deltaX: number, deltaY: number],
+  ) {
+    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,
+      handle,
+      naturalWidth,
+      naturalHeight,
+      clientX + mouseMove[0],
+      clientY + mouseMove[1],
+    );
+
+    mutateElement(element, mutations);
+  }
+
   static rotate(
     element: ExcalidrawElement | ExcalidrawElement[],
     mouseMove: [deltaX: number, deltaY: number],

+ 1 - 1
setupTests.ts

@@ -104,7 +104,7 @@ console.error = (...args) => {
   // the react's act() warning usually doesn't contain any useful stack trace
   // so we're catching the log and re-logging the message with the test name,
   // also stripping the actual component stack trace as it's not useful
-  if (args[0]?.includes("act(")) {
+  if (args[0]?.includes?.("act(")) {
     _consoleError(
       yellow(
         `<<< WARNING: test "${

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.