Browse Source

feat: use stats panel to crop (#8848)

* feat: use stats panel to crop

* fix: test flake

---------

Co-authored-by: dwelle <[email protected]>
Ryan Di 8 months ago
parent
commit
d99e4a23ca

+ 129 - 3
packages/excalidraw/components/Stats/Dimension.tsx

@@ -5,6 +5,13 @@ import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
 import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
 import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
 import type Scene from "../../scene/Scene";
 import type Scene from "../../scene/Scene";
 import type { AppState } from "../../types";
 import type { AppState } from "../../types";
+import { isImageElement } from "../../element/typeChecks";
+import {
+  MINIMAL_CROP_SIZE,
+  getUncroppedWidthAndHeight,
+} from "../../element/cropElement";
+import { mutateElement } from "../../element/mutateElement";
+import { clamp, round } from "../../../math";
 
 
 interface DimensionDragInputProps {
 interface DimensionDragInputProps {
   property: "width" | "height";
   property: "width" | "height";
@@ -27,6 +34,8 @@ const handleDimensionChange: DragInputCallbackType<
   shouldChangeByStepSize,
   shouldChangeByStepSize,
   nextValue,
   nextValue,
   property,
   property,
+  originalAppState,
+  instantChange,
   scene,
   scene,
 }) => {
 }) => {
   const elementsMap = scene.getNonDeletedElementsMap();
   const elementsMap = scene.getNonDeletedElementsMap();
@@ -37,6 +46,107 @@ const handleDimensionChange: DragInputCallbackType<
       shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
       shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
     const aspectRatio = origElement.width / origElement.height;
     const aspectRatio = origElement.width / origElement.height;
 
 
+    if (originalAppState.croppingElementId === origElement.id) {
+      const element = elementsMap.get(origElement.id);
+
+      if (!element || !isImageElement(element) || !element.crop) {
+        return;
+      }
+
+      const crop = element.crop;
+      let nextCrop = { ...crop };
+
+      const isFlippedByX = element.scale[0] === -1;
+      const isFlippedByY = element.scale[1] === -1;
+
+      const { width: uncroppedWidth, height: uncroppedHeight } =
+        getUncroppedWidthAndHeight(element);
+
+      const naturalToUncroppedWidthRatio = crop.naturalWidth / uncroppedWidth;
+      const naturalToUncroppedHeightRatio =
+        crop.naturalHeight / uncroppedHeight;
+
+      const MAX_POSSIBLE_WIDTH = isFlippedByX
+        ? crop.width + crop.x
+        : crop.naturalWidth - crop.x;
+
+      const MAX_POSSIBLE_HEIGHT = isFlippedByY
+        ? crop.height + crop.y
+        : crop.naturalHeight - crop.y;
+
+      const MIN_WIDTH = MINIMAL_CROP_SIZE * naturalToUncroppedWidthRatio;
+      const MIN_HEIGHT = MINIMAL_CROP_SIZE * naturalToUncroppedHeightRatio;
+
+      if (nextValue !== undefined) {
+        if (property === "width") {
+          const nextValueInNatural = nextValue * naturalToUncroppedWidthRatio;
+
+          const nextCropWidth = clamp(
+            nextValueInNatural,
+            MIN_WIDTH,
+            MAX_POSSIBLE_WIDTH,
+          );
+
+          nextCrop = {
+            ...nextCrop,
+            width: nextCropWidth,
+            x: isFlippedByX ? crop.x + crop.width - nextCropWidth : crop.x,
+          };
+        } else if (property === "height") {
+          const nextValueInNatural = nextValue * naturalToUncroppedHeightRatio;
+          const nextCropHeight = clamp(
+            nextValueInNatural,
+            MIN_HEIGHT,
+            MAX_POSSIBLE_HEIGHT,
+          );
+
+          nextCrop = {
+            ...nextCrop,
+            height: nextCropHeight,
+            y: isFlippedByY ? crop.y + crop.height - nextCropHeight : crop.y,
+          };
+        }
+
+        mutateElement(element, {
+          crop: nextCrop,
+          width: nextCrop.width / (crop.naturalWidth / uncroppedWidth),
+          height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
+        });
+        return;
+      }
+
+      const changeInWidth = property === "width" ? instantChange : 0;
+      const changeInHeight = property === "height" ? instantChange : 0;
+
+      const nextCropWidth = clamp(
+        crop.width + changeInWidth,
+        MIN_WIDTH,
+        MAX_POSSIBLE_WIDTH,
+      );
+
+      const nextCropHeight = clamp(
+        crop.height + changeInHeight,
+        MIN_WIDTH,
+        MAX_POSSIBLE_HEIGHT,
+      );
+
+      nextCrop = {
+        ...crop,
+        x: isFlippedByX ? crop.x + crop.width - nextCropWidth : crop.x,
+        y: isFlippedByY ? crop.y + crop.height - nextCropHeight : crop.y,
+        width: nextCropWidth,
+        height: nextCropHeight,
+      };
+
+      mutateElement(element, {
+        crop: nextCrop,
+        width: nextCrop.width / (crop.naturalWidth / uncroppedWidth),
+        height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
+      });
+
+      return;
+    }
+
     if (nextValue !== undefined) {
     if (nextValue !== undefined) {
       const nextWidth = Math.max(
       const nextWidth = Math.max(
         property === "width"
         property === "width"
@@ -117,9 +227,25 @@ const DimensionDragInput = ({
   scene,
   scene,
   appState,
   appState,
 }: DimensionDragInputProps) => {
 }: DimensionDragInputProps) => {
-  const value =
-    Math.round((property === "width" ? element.width : element.height) * 100) /
-    100;
+  let value = round(property === "width" ? element.width : element.height, 2);
+
+  if (
+    appState.croppingElementId &&
+    appState.croppingElementId === element.id &&
+    isImageElement(element) &&
+    element.crop
+  ) {
+    const { width: uncroppedWidth, height: uncroppedHeight } =
+      getUncroppedWidthAndHeight(element);
+    if (property === "width") {
+      const ratio = uncroppedWidth / element.crop.naturalWidth;
+      value = round(element.crop.width * ratio, 2);
+    }
+    if (property === "height") {
+      const ratio = uncroppedHeight / element.crop.naturalHeight;
+      value = round(element.crop.height * ratio, 2);
+    }
+  }
 
 
   return (
   return (
     <DragInput
     <DragInput

+ 101 - 3
packages/excalidraw/components/Stats/Position.tsx

@@ -4,7 +4,13 @@ import type { DragInputCallbackType } from "./DragInput";
 import { getStepSizedValue, moveElement } from "./utils";
 import { getStepSizedValue, moveElement } from "./utils";
 import type Scene from "../../scene/Scene";
 import type Scene from "../../scene/Scene";
 import type { AppState } from "../../types";
 import type { AppState } from "../../types";
-import { pointFrom, pointRotateRads } from "../../../math";
+import { clamp, pointFrom, pointRotateRads, round } from "../../../math";
+import { isImageElement } from "../../element/typeChecks";
+import {
+  getFlipAdjustedCropPosition,
+  getUncroppedWidthAndHeight,
+} from "../../element/cropElement";
+import { mutateElement } from "../../element/mutateElement";
 
 
 interface PositionProps {
 interface PositionProps {
   property: "x" | "y";
   property: "x" | "y";
@@ -18,12 +24,14 @@ const STEP_SIZE = 10;
 
 
 const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
 const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
   accumulatedChange,
   accumulatedChange,
+  instantChange,
   originalElements,
   originalElements,
   originalElementsMap,
   originalElementsMap,
   shouldChangeByStepSize,
   shouldChangeByStepSize,
   nextValue,
   nextValue,
   property,
   property,
   scene,
   scene,
+  originalAppState,
 }) => {
 }) => {
   const elementsMap = scene.getNonDeletedElementsMap();
   const elementsMap = scene.getNonDeletedElementsMap();
   const elements = scene.getNonDeletedElements();
   const elements = scene.getNonDeletedElements();
@@ -38,6 +46,82 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
     origElement.angle,
     origElement.angle,
   );
   );
 
 
+  if (originalAppState.croppingElementId === origElement.id) {
+    const element = elementsMap.get(origElement.id);
+
+    if (!element || !isImageElement(element) || !element.crop) {
+      return;
+    }
+
+    const crop = element.crop;
+    let nextCrop = crop;
+    const isFlippedByX = element.scale[0] === -1;
+    const isFlippedByY = element.scale[1] === -1;
+    const { width: uncroppedWidth, height: uncroppedHeight } =
+      getUncroppedWidthAndHeight(element);
+
+    if (nextValue !== undefined) {
+      if (property === "x") {
+        const nextValueInNatural =
+          nextValue * (crop.naturalWidth / uncroppedWidth);
+
+        if (isFlippedByX) {
+          nextCrop = {
+            ...crop,
+            x: clamp(
+              crop.naturalWidth - nextValueInNatural - crop.width,
+              0,
+              crop.naturalWidth - crop.width,
+            ),
+          };
+        } else {
+          nextCrop = {
+            ...crop,
+            x: clamp(
+              nextValue * (crop.naturalWidth / uncroppedWidth),
+              0,
+              crop.naturalWidth - crop.width,
+            ),
+          };
+        }
+      }
+
+      if (property === "y") {
+        nextCrop = {
+          ...crop,
+          y: clamp(
+            nextValue * (crop.naturalHeight / uncroppedHeight),
+            0,
+            crop.naturalHeight - crop.height,
+          ),
+        };
+      }
+
+      mutateElement(element, {
+        crop: nextCrop,
+      });
+
+      return;
+    }
+
+    const changeInX =
+      (property === "x" ? instantChange : 0) * (isFlippedByX ? -1 : 1);
+    const changeInY =
+      (property === "y" ? instantChange : 0) * (isFlippedByY ? -1 : 1);
+
+    nextCrop = {
+      ...crop,
+      x: clamp(crop.x + changeInX, 0, crop.naturalWidth - crop.width),
+      y: clamp(crop.y + changeInY, 0, crop.naturalHeight - crop.height),
+    };
+
+    mutateElement(element, {
+      crop: nextCrop,
+    });
+
+    return;
+  }
+
   if (nextValue !== undefined) {
   if (nextValue !== undefined) {
     const newTopLeftX = property === "x" ? nextValue : topLeftX;
     const newTopLeftX = property === "x" ? nextValue : topLeftX;
     const newTopLeftY = property === "y" ? nextValue : topLeftY;
     const newTopLeftY = property === "y" ? nextValue : topLeftY;
@@ -97,8 +181,22 @@ const Position = ({
     pointFrom(element.x + element.width / 2, element.y + element.height / 2),
     pointFrom(element.x + element.width / 2, element.y + element.height / 2),
     element.angle,
     element.angle,
   );
   );
-  const value =
-    Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
+  let value = round(property === "x" ? topLeftX : topLeftY, 2);
+
+  if (
+    appState.croppingElementId === element.id &&
+    isImageElement(element) &&
+    element.crop
+  ) {
+    const flipAdjustedPosition = getFlipAdjustedCropPosition(element);
+
+    if (flipAdjustedPosition) {
+      value = round(
+        property === "x" ? flipAdjustedPosition.x : flipAdjustedPosition.y,
+        2,
+      );
+    }
+  }
 
 
   return (
   return (
     <StatsDragInput
     <StatsDragInput

+ 39 - 3
packages/excalidraw/components/Stats/index.tsx

@@ -23,12 +23,14 @@ import Collapsible from "./Collapsible";
 import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
 import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
 import { getAtomicUnits } from "./utils";
 import { getAtomicUnits } from "./utils";
 import { STATS_PANELS } from "../../constants";
 import { STATS_PANELS } from "../../constants";
-import { isElbowArrow } from "../../element/typeChecks";
+import { isElbowArrow, isImageElement } from "../../element/typeChecks";
 import CanvasGrid from "./CanvasGrid";
 import CanvasGrid from "./CanvasGrid";
 import clsx from "clsx";
 import clsx from "clsx";
 
 
 import "./Stats.scss";
 import "./Stats.scss";
 import { isGridModeEnabled } from "../../snapping";
 import { isGridModeEnabled } from "../../snapping";
+import { getUncroppedWidthAndHeight } from "../../element/cropElement";
+import { round } from "../../../math";
 
 
 interface StatsProps {
 interface StatsProps {
   app: AppClassProperties;
   app: AppClassProperties;
@@ -128,6 +130,13 @@ export const StatsInner = memo(
     const multipleElements =
     const multipleElements =
       selectedElements.length > 1 ? selectedElements : null;
       selectedElements.length > 1 ? selectedElements : null;
 
 
+    const cropMode =
+      appState.croppingElementId && isImageElement(singleElement);
+
+    const unCroppedDimension = cropMode
+      ? getUncroppedWidthAndHeight(singleElement)
+      : null;
+
     const [sceneDimension, setSceneDimension] = useState<{
     const [sceneDimension, setSceneDimension] = useState<{
       width: number;
       width: number;
       height: number;
       height: number;
@@ -244,8 +253,34 @@ export const StatsInner = memo(
                 <StatsRows>
                 <StatsRows>
                   {singleElement && (
                   {singleElement && (
                     <>
                     <>
+                      {cropMode && (
+                        <StatsRow heading>
+                          {t("labels.unCroppedDimension")}
+                        </StatsRow>
+                      )}
+
+                      {appState.croppingElementId &&
+                        isImageElement(singleElement) &&
+                        unCroppedDimension && (
+                          <StatsRow columns={2}>
+                            <div>{t("stats.width")}</div>
+                            <div>{round(unCroppedDimension.width, 2)}</div>
+                          </StatsRow>
+                        )}
+
+                      {appState.croppingElementId &&
+                        isImageElement(singleElement) &&
+                        unCroppedDimension && (
+                          <StatsRow columns={2}>
+                            <div>{t("stats.height")}</div>
+                            <div>{round(unCroppedDimension.height, 2)}</div>
+                          </StatsRow>
+                        )}
+
                       <StatsRow heading data-testid="stats-element-type">
                       <StatsRow heading data-testid="stats-element-type">
-                        {t(`element.${singleElement.type}`)}
+                        {appState.croppingElementId
+                          ? t("labels.imageCropping")
+                          : t(`element.${singleElement.type}`)}
                       </StatsRow>
                       </StatsRow>
 
 
                       <StatsRow>
                       <StatsRow>
@@ -387,7 +422,8 @@ export const StatsInner = memo(
       prev.selectedElements === next.selectedElements &&
       prev.selectedElements === next.selectedElements &&
       prev.appState.stats.panels === next.appState.stats.panels &&
       prev.appState.stats.panels === next.appState.stats.panels &&
       prev.gridModeEnabled === next.gridModeEnabled &&
       prev.gridModeEnabled === next.gridModeEnabled &&
-      prev.appState.gridStep === next.appState.gridStep
+      prev.appState.gridStep === next.appState.gridStep &&
+      prev.appState.croppingElementId === next.appState.croppingElementId
     );
     );
   },
   },
 );
 );

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

@@ -26,7 +26,7 @@ import {
   getResizedElementAbsoluteCoords,
   getResizedElementAbsoluteCoords,
 } from "./bounds";
 } from "./bounds";
 
 
-const MINIMAL_CROP_SIZE = 10;
+export const MINIMAL_CROP_SIZE = 10;
 
 
 export const cropElement = (
 export const cropElement = (
   element: ExcalidrawImageElement,
   element: ExcalidrawImageElement,
@@ -585,3 +585,41 @@ const adjustCropPosition = (
     cropY,
     cropY,
   };
   };
 };
 };
+
+export const getFlipAdjustedCropPosition = (
+  element: ExcalidrawImageElement,
+  natural = false,
+) => {
+  const crop = element.crop;
+  if (!crop) {
+    return null;
+  }
+
+  const isFlippedByX = element.scale[0] === -1;
+  const isFlippedByY = element.scale[1] === -1;
+
+  let cropX = crop.x;
+  let cropY = crop.y;
+
+  if (isFlippedByX) {
+    cropX = crop.naturalWidth - crop.width - crop.x;
+  }
+
+  if (isFlippedByY) {
+    cropY = crop.naturalHeight - crop.height - crop.y;
+  }
+
+  if (natural) {
+    return {
+      x: cropX,
+      y: cropY,
+    };
+  }
+
+  const { width, height } = getUncroppedWidthAndHeight(element);
+
+  return {
+    x: cropX / (crop.naturalWidth / width),
+    y: cropY / (crop.naturalHeight / height),
+  };
+};

+ 2 - 0
packages/excalidraw/locales/en.json

@@ -157,6 +157,8 @@
     "zoomToFit": "Zoom to fit all elements",
     "zoomToFit": "Zoom to fit all elements",
     "installPWA": "Install Excalidraw locally (PWA)",
     "installPWA": "Install Excalidraw locally (PWA)",
     "autoResize": "Enable text auto-resizing",
     "autoResize": "Enable text auto-resizing",
+    "imageCropping": "Image cropping",
+    "unCroppedDimension": "Uncropped dimension",
     "copyElementLink": "Copy link to object",
     "copyElementLink": "Copy link to object",
     "linkToElement": "Link to object"
     "linkToElement": "Link to object"
   },
   },

+ 3 - 3
packages/excalidraw/tests/cropElement.test.tsx

@@ -186,14 +186,14 @@ describe("Crop an image", () => {
     // 50 x 50 square
     // 50 x 50 square
     UI.crop(image, "nw", naturalWidth, naturalHeight, [150, 50]);
     UI.crop(image, "nw", naturalWidth, naturalHeight, [150, 50]);
     UI.crop(image, "n", naturalWidth, naturalHeight, [0, -100], true);
     UI.crop(image, "n", naturalWidth, naturalHeight, [0, -100], true);
-    expect(image.width).toEqual(image.height);
+    expect(image.width).toBeCloseTo(image.height);
     // image is at the corner, not space to its right to expand, should not be able to resize
     // image is at the corner, not space to its right to expand, should not be able to resize
     expect(image.height).toBeCloseTo(50);
     expect(image.height).toBeCloseTo(50);
 
 
     UI.crop(image, "nw", naturalWidth, naturalHeight, [-150, -100], true);
     UI.crop(image, "nw", naturalWidth, naturalHeight, [-150, -100], true);
-    expect(image.width).toEqual(image.height);
+    expect(image.width).toBeCloseTo(image.height);
     // max height should be reached
     // max height should be reached
-    expect(image.height).toEqual(initialHeight);
+    expect(image.height).toBeCloseTo(initialHeight);
     expect(image.width).toBe(initialHeight);
     expect(image.width).toBe(initialHeight);
   });
   });
 });
 });