Explorar o código

feat: image cropping (#8613)

Co-authored-by: dwelle <[email protected]>
Ryan Di hai 9 meses
pai
achega
e957c8e9ee
Modificáronse 36 ficheiros con 2198 adicións e 91 borrados
  1. 55 0
      packages/excalidraw/actions/actionCropEditor.tsx
  2. 2 0
      packages/excalidraw/actions/index.ts
  3. 2 1
      packages/excalidraw/actions/types.ts
  4. 4 0
      packages/excalidraw/appState.ts
  5. 32 1
      packages/excalidraw/change.ts
  6. 7 0
      packages/excalidraw/components/Actions.tsx
  7. 299 14
      packages/excalidraw/components/App.tsx
  8. 1 0
      packages/excalidraw/components/CommandPalette/CommandPalette.tsx
  9. 10 0
      packages/excalidraw/components/HelpDialog.tsx
  10. 8 0
      packages/excalidraw/components/HintViewer.tsx
  11. 2 0
      packages/excalidraw/components/canvases/InteractiveCanvas.tsx
  12. 1 0
      packages/excalidraw/components/canvases/StaticCanvas.tsx
  13. 9 0
      packages/excalidraw/components/icons.tsx
  14. 1 0
      packages/excalidraw/data/restore.ts
  15. 587 0
      packages/excalidraw/element/cropElement.ts
  16. 10 0
      packages/excalidraw/element/dragElements.ts
  17. 2 0
      packages/excalidraw/element/newElement.ts
  18. 7 3
      packages/excalidraw/element/resizeTest.ts
  19. 8 4
      packages/excalidraw/element/transformHandles.ts
  20. 11 0
      packages/excalidraw/element/types.ts
  21. 6 2
      packages/excalidraw/locales/en.json
  22. 179 57
      packages/excalidraw/renderer/interactiveScene.ts
  23. 59 4
      packages/excalidraw/renderer/renderElement.ts
  24. 20 3
      packages/excalidraw/renderer/staticSvgScene.ts
  25. 1 0
      packages/excalidraw/store.ts
  26. 254 0
      packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
  27. 0 0
      packages/excalidraw/tests/__snapshots__/export.test.tsx.snap
  28. 116 0
      packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
  29. 104 0
      packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
  30. 342 0
      packages/excalidraw/tests/cropElement.test.tsx
  31. 35 1
      packages/excalidraw/tests/helpers/ui.ts
  32. 11 0
      packages/excalidraw/types.ts
  33. 3 0
      packages/math/utils.ts
  34. 7 0
      packages/math/vector.ts
  35. 2 0
      packages/utils/__snapshots__/export.test.ts.snap
  36. 1 1
      setupTests.ts

+ 55 - 0
packages/excalidraw/actions/actionCropEditor.tsx

@@ -0,0 +1,55 @@
+import { register } from "./register";
+import { cropIcon } from "../components/icons";
+import { StoreAction } from "../store";
+import { ToolButton } from "../components/ToolButton";
+import { t } from "../i18n";
+import { isImageElement } from "../element/typeChecks";
+import type { ExcalidrawImageElement } from "../element/types";
+
+export const actionToggleCropEditor = register({
+  name: "cropEditor",
+  label: "helpDialog.cropStart",
+  icon: cropIcon,
+  viewMode: true,
+  trackEvent: { category: "menu" },
+  keywords: ["image", "crop"],
+  perform(elements, appState, _, app) {
+    const selectedElement = app.scene.getSelectedElements({
+      selectedElementIds: appState.selectedElementIds,
+      includeBoundTextElement: true,
+    })[0] as ExcalidrawImageElement;
+
+    return {
+      appState: {
+        ...appState,
+        isCropping: false,
+        croppingElementId: selectedElement.id,
+      },
+      storeAction: StoreAction.CAPTURE,
+    };
+  },
+  predicate: (elements, appState, _, app) => {
+    const selectedElements = app.scene.getSelectedElements(appState);
+    if (
+      !appState.croppingElementId &&
+      selectedElements.length === 1 &&
+      isImageElement(selectedElements[0])
+    ) {
+      return true;
+    }
+    return false;
+  },
+  PanelComponent: ({ appState, updateData, app }) => {
+    const label = t("helpDialog.cropStart");
+
+    return (
+      <ToolButton
+        type="button"
+        icon={cropIcon}
+        title={label}
+        aria-label={label}
+        onClick={() => updateData(null)}
+      />
+    );
+  },
+});

+ 2 - 0
packages/excalidraw/actions/index.ts

@@ -88,3 +88,5 @@ export { actionToggleElementLock } from "./actionElementLock";
 export { actionToggleLinearEditor } from "./actionLinearEditor";
 
 export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
+
+export { actionToggleCropEditor } from "./actionCropEditor";

+ 2 - 1
packages/excalidraw/actions/types.ts

@@ -134,7 +134,8 @@ export type ActionName =
   | "commandPalette"
   | "autoResize"
   | "elementStats"
-  | "searchMenu";
+  | "searchMenu"
+  | "cropEditor";
 
 export type PanelComponentProps = {
   elements: readonly ExcalidrawElement[];

+ 4 - 0
packages/excalidraw/appState.ts

@@ -116,6 +116,8 @@ export const getDefaultAppState = (): Omit<
     objectsSnapModeEnabled: false,
     userToFollow: null,
     followedBy: new Set(),
+    isCropping: false,
+    croppingElementId: null,
     searchMatches: [],
   };
 };
@@ -237,6 +239,8 @@ const APP_STATE_STORAGE_CONF = (<
   objectsSnapModeEnabled: { browser: true, export: false, server: false },
   userToFollow: { browser: false, export: false, server: false },
   followedBy: { browser: false, export: false, server: false },
+  isCropping: { browser: false, export: false, server: false },
+  croppingElementId: { browser: false, export: false, server: false },
   searchMatches: { browser: false, export: false, server: false },
 });
 

+ 32 - 1
packages/excalidraw/change.ts

@@ -17,13 +17,16 @@ import {
   hasBoundTextElement,
   isBindableElement,
   isBoundToContainer,
+  isImageElement,
   isTextElement,
 } from "./element/typeChecks";
 import type {
   ExcalidrawElement,
+  ExcalidrawImageElement,
   ExcalidrawLinearElement,
   ExcalidrawTextElement,
   NonDeleted,
+  Ordered,
   OrderedExcalidrawElement,
   SceneElementsMap,
 } from "./element/types";
@@ -626,6 +629,18 @@ export class AppStateChange implements Change<AppState> {
             );
 
             break;
+          case "croppingElementId": {
+            const croppingElementId = nextAppState[key];
+            const element =
+              croppingElementId && nextElements.get(croppingElementId);
+
+            if (element && !element.isDeleted) {
+              visibleDifferenceFlag.value = true;
+            } else {
+              nextAppState[key] = null;
+            }
+            break;
+          }
           case "editingGroupId":
             const editingGroupId = nextAppState[key];
 
@@ -756,6 +771,7 @@ export class AppStateChange implements Change<AppState> {
       selectedElementIds,
       editingLinearElementId,
       selectedLinearElementId,
+      croppingElementId,
       ...standaloneProps
     } = delta as ObservedAppState;
 
@@ -779,7 +795,10 @@ export class AppStateChange implements Change<AppState> {
   }
 }
 
-type ElementPartial = Omit<ElementUpdate<OrderedExcalidrawElement>, "seed">;
+type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
+  ElementUpdate<Ordered<T>>,
+  "seed"
+>;
 
 /**
  * Elements change is a low level primitive to capture a change between two sets of elements.
@@ -1216,6 +1235,18 @@ export class ElementsChange implements Change<SceneElementsMap> {
       });
     }
 
+    if (isImageElement(element)) {
+      const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
+      // we want to override `crop` only if modified so that we don't reset
+      // when undoing/redoing unrelated change
+      if (_delta.deleted.crop || _delta.inserted.crop) {
+        Object.assign(directlyApplicablePartial, {
+          // apply change verbatim
+          crop: _delta.inserted.crop ?? null,
+        });
+      }
+    }
+
     if (!flags.containsVisibleDifference) {
       // strip away fractional as even if it would be different, it doesn't have to result in visible change
       const { index, ...rest } = directlyApplicablePartial;

+ 7 - 0
packages/excalidraw/components/Actions.tsx

@@ -26,6 +26,7 @@ import { trackEvent } from "../analytics";
 import {
   hasBoundTextElement,
   isElbowArrow,
+  isImageElement,
   isLinearElement,
   isTextElement,
 } from "../element/typeChecks";
@@ -127,6 +128,11 @@ export const SelectedShapeActions = ({
     isLinearElement(targetElements[0]) &&
     !isElbowArrow(targetElements[0]);
 
+  const showCropEditorAction =
+    !appState.croppingElementId &&
+    targetElements.length === 1 &&
+    isImageElement(targetElements[0]);
+
   return (
     <div className="panelColumn">
       <div>
@@ -245,6 +251,7 @@ export const SelectedShapeActions = ({
             {renderAction("group")}
             {renderAction("ungroup")}
             {showLinkIcon && renderAction("hyperlink")}
+            {showCropEditorAction && renderAction("cropEditor")}
             {showLineEditorAction && renderAction("toggleLinearEditor")}
           </div>
         </fieldset>

+ 299 - 14
packages/excalidraw/components/App.tsx

@@ -35,6 +35,7 @@ import {
   actionToggleElementLock,
   actionToggleLinearEditor,
   actionToggleObjectsSnapMode,
+  actionToggleCropEditor,
 } from "../actions";
 import { createRedoAction, createUndoAction } from "../actions/actionHistory";
 import { ActionManager } from "../actions/manager";
@@ -445,7 +446,19 @@ import {
 } from "../element/flowchart";
 import { searchItemInFocusAtom } from "./SearchMenu";
 import type { LocalPoint, Radians } from "../../math";
-import { pointFrom, pointDistance, vector } from "../../math";
+import {
+  clamp,
+  pointFrom,
+  pointDistance,
+  vector,
+  pointRotateRads,
+  vectorScale,
+  vectorFromPoint,
+  vectorSubtract,
+  vectorDot,
+  vectorNormalize,
+} from "../../math";
+import { cropElement } from "../element/cropElement";
 
 const AppContext = React.createContext<AppClassProperties>(null!);
 const AppPropsContext = React.createContext<AppProps>(null!);
@@ -589,6 +602,7 @@ class App extends React.Component<AppProps, AppState> {
   lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
     null;
   lastPointerMoveEvent: PointerEvent | null = null;
+  lastPointerMoveCoords: { x: number; y: number } | null = null;
   lastViewportPosition = { x: 0, y: 0 };
 
   animationFrameHandler = new AnimationFrameHandler();
@@ -3924,6 +3938,28 @@ class App extends React.Component<AppProps, AppState> {
       }
 
       if (!isInputLike(event.target)) {
+        if (
+          (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
+          this.state.croppingElementId
+        ) {
+          this.finishImageCropping();
+          return;
+        }
+
+        const selectedElements = getSelectedElements(
+          this.scene.getNonDeletedElementsMap(),
+          this.state,
+        );
+
+        if (
+          selectedElements.length === 1 &&
+          isImageElement(selectedElements[0]) &&
+          event.key === KEYS.ENTER
+        ) {
+          this.startImageCropping(selectedElements[0]);
+          return;
+        }
+
         if (
           event.key === KEYS.ESCAPE &&
           this.flowChartCreator.isCreatingChart
@@ -4911,7 +4947,7 @@ class App extends React.Component<AppProps, AppState> {
       const selectionShape = getSelectionBoxShape(
         element,
         this.scene.getNonDeletedElementsMap(),
-        this.getElementHitThreshold(),
+        isImageElement(element) ? 0 : this.getElementHitThreshold(),
       );
 
       return isPointInShape(pointFrom(x, y), selectionShape);
@@ -5140,6 +5176,22 @@ class App extends React.Component<AppProps, AppState> {
     }
   };
 
+  private startImageCropping = (image: ExcalidrawImageElement) => {
+    this.store.shouldCaptureIncrement();
+    this.setState({
+      croppingElementId: image.id,
+    });
+  };
+
+  private finishImageCropping = () => {
+    if (this.state.croppingElementId) {
+      this.store.shouldCaptureIncrement();
+      this.setState({
+        croppingElementId: null,
+      });
+    }
+  };
+
   private handleCanvasDoubleClick = (
     event: React.MouseEvent<HTMLCanvasElement>,
   ) => {
@@ -5171,6 +5223,11 @@ class App extends React.Component<AppProps, AppState> {
       }
     }
 
+    if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
+      this.startImageCropping(selectedElements[0]);
+      return;
+    }
+
     resetCursor(this.interactiveCanvas);
 
     let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
@@ -6740,11 +6797,24 @@ class App extends React.Component<AppProps, AppState> {
             this.device,
           );
         if (elementWithTransformHandleType != null) {
-          this.setState({
-            resizingElement: elementWithTransformHandleType.element,
-          });
-          pointerDownState.resize.handleType =
-            elementWithTransformHandleType.transformHandleType;
+          if (
+            elementWithTransformHandleType.transformHandleType === "rotation"
+          ) {
+            this.setState({
+              resizingElement: elementWithTransformHandleType.element,
+            });
+            pointerDownState.resize.handleType =
+              elementWithTransformHandleType.transformHandleType;
+          } else if (this.state.croppingElementId) {
+            pointerDownState.resize.handleType =
+              elementWithTransformHandleType.transformHandleType;
+          } else {
+            this.setState({
+              resizingElement: elementWithTransformHandleType.element,
+            });
+            pointerDownState.resize.handleType =
+              elementWithTransformHandleType.transformHandleType;
+          }
         }
       } else if (selectedElements.length > 1) {
         pointerDownState.resize.handleType = getTransformHandleTypeFromCoords(
@@ -6811,6 +6881,13 @@ class App extends React.Component<AppProps, AppState> {
             pointerDownState.origin.y,
           );
 
+        if (
+          this.state.croppingElementId &&
+          pointerDownState.hit.element?.id !== this.state.croppingElementId
+        ) {
+          this.finishImageCropping();
+        }
+
         if (pointerDownState.hit.element) {
           // Early return if pointer is hitting link icon
           const hitLinkElement = this.getElementLinkAtPosition(
@@ -7612,6 +7689,11 @@ class App extends React.Component<AppProps, AppState> {
     pointerDownState: PointerDownState,
   ) {
     return withBatchedUpdatesThrottled((event: PointerEvent) => {
+      const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
+      const lastPointerCoords =
+        this.lastPointerMoveCoords ?? pointerDownState.origin;
+      this.lastPointerMoveCoords = pointerCoords;
+
       // We need to initialize dragOffsetXY only after we've updated
       // `state.selectedElementIds` on pointerDown. Doing it here in pointerMove
       // event handler should hopefully ensure we're already working with
@@ -7634,8 +7716,6 @@ class App extends React.Component<AppProps, AppState> {
         return;
       }
 
-      const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
-
       if (isEraserActive(this.state)) {
         this.handleEraser(event, pointerDownState, pointerCoords);
         return;
@@ -7672,6 +7752,9 @@ class App extends React.Component<AppProps, AppState> {
       if (pointerDownState.resize.isResizing) {
         pointerDownState.lastCoords.x = pointerCoords.x;
         pointerDownState.lastCoords.y = pointerCoords.y;
+        if (this.maybeHandleCrop(pointerDownState, event)) {
+          return true;
+        }
         if (this.maybeHandleResize(pointerDownState, event)) {
           return true;
         }
@@ -7845,6 +7928,96 @@ class App extends React.Component<AppProps, AppState> {
             }
           }
 
+          // #region move crop region
+          if (this.state.croppingElementId) {
+            const croppingElement = this.scene
+              .getNonDeletedElementsMap()
+              .get(this.state.croppingElementId);
+
+            if (
+              croppingElement &&
+              isImageElement(croppingElement) &&
+              croppingElement.crop !== null &&
+              pointerDownState.hit.element === croppingElement
+            ) {
+              const crop = croppingElement.crop;
+              const image =
+                isInitializedImageElement(croppingElement) &&
+                this.imageCache.get(croppingElement.fileId)?.image;
+
+              if (image && !(image instanceof Promise)) {
+                const instantDragOffset = vectorScale(
+                  vector(
+                    pointerCoords.x - lastPointerCoords.x,
+                    pointerCoords.y - lastPointerCoords.y,
+                  ),
+                  Math.max(this.state.zoom.value, 2),
+                );
+
+                const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
+                  croppingElement,
+                  elementsMap,
+                );
+
+                const topLeft = vectorFromPoint(
+                  pointRotateRads(
+                    pointFrom(x1, y1),
+                    pointFrom(cx, cy),
+                    croppingElement.angle,
+                  ),
+                );
+                const topRight = vectorFromPoint(
+                  pointRotateRads(
+                    pointFrom(x2, y1),
+                    pointFrom(cx, cy),
+                    croppingElement.angle,
+                  ),
+                );
+                const bottomLeft = vectorFromPoint(
+                  pointRotateRads(
+                    pointFrom(x1, y2),
+                    pointFrom(cx, cy),
+                    croppingElement.angle,
+                  ),
+                );
+                const topEdge = vectorNormalize(
+                  vectorSubtract(topRight, topLeft),
+                );
+                const leftEdge = vectorNormalize(
+                  vectorSubtract(bottomLeft, topLeft),
+                );
+
+                // project instantDrafOffset onto leftEdge and topEdge to decompose
+                const offsetVector = vector(
+                  vectorDot(instantDragOffset, topEdge),
+                  vectorDot(instantDragOffset, leftEdge),
+                );
+
+                const nextCrop = {
+                  ...crop,
+                  x: clamp(
+                    crop.x -
+                      offsetVector[0] * Math.sign(croppingElement.scale[0]),
+                    0,
+                    image.naturalWidth - crop.width,
+                  ),
+                  y: clamp(
+                    crop.y -
+                      offsetVector[1] * Math.sign(croppingElement.scale[1]),
+                    0,
+                    image.naturalHeight - crop.height,
+                  ),
+                };
+
+                mutateElement(croppingElement, {
+                  crop: nextCrop,
+                });
+
+                return;
+              }
+            }
+          }
+
           // Snap cache *must* be synchronously popuplated before initial drag,
           // otherwise the first drag even will not snap, causing a jump before
           // it snaps to its position if previously snapped already.
@@ -7978,6 +8151,7 @@ class App extends React.Component<AppProps, AppState> {
             this.maybeCacheVisibleGaps(event, selectedElements, true);
             this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
           }
+
           return;
         }
       }
@@ -8226,15 +8400,18 @@ class App extends React.Component<AppProps, AppState> {
       const {
         newElement,
         resizingElement,
+        croppingElementId,
         multiElement,
         activeTool,
         isResizing,
         isRotating,
+        isCropping,
       } = this.state;
 
       this.setState((prevState) => ({
         isResizing: false,
         isRotating: false,
+        isCropping: false,
         resizingElement: null,
         selectionElement: null,
         frameToHighlight: null,
@@ -8244,6 +8421,8 @@ class App extends React.Component<AppProps, AppState> {
         originSnapOffset: null,
       }));
 
+      this.lastPointerMoveCoords = null;
+
       SnapCache.setReferenceSnapPoints(null);
       SnapCache.setVisibleGaps(null);
 
@@ -8726,6 +8905,20 @@ class App extends React.Component<AppProps, AppState> {
         }
       }
 
+      // click outside the cropping region to exit
+      if (
+        // not in the cropping mode at all
+        !croppingElementId ||
+        // in the cropping mode
+        (croppingElementId &&
+          // not cropping and no hit element
+          ((!hitElement && !isCropping) ||
+            // hitting something else
+            (hitElement && hitElement.id !== croppingElementId)))
+      ) {
+        this.finishImageCropping();
+      }
+
       const pointerStart = this.lastPointerDownEvent;
       const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent;
 
@@ -8981,7 +9174,12 @@ class App extends React.Component<AppProps, AppState> {
         this.store.shouldCaptureIncrement();
       }
 
-      if (pointerDownState.drag.hasOccurred || isResizing || isRotating) {
+      if (
+        pointerDownState.drag.hasOccurred ||
+        isResizing ||
+        isRotating ||
+        isCropping
+      ) {
         // We only allow binding via linear elements, specifically via dragging
         // the endpoints ("start" or "end").
         const linearElements = this.scene
@@ -9195,7 +9393,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,
@@ -9348,7 +9546,7 @@ class App extends React.Component<AppProps, AppState> {
     }
   };
 
-  private initializeImageDimensions = (
+  initializeImageDimensions = (
     imageElement: ExcalidrawImageElement,
     forceNaturalSize = false,
   ) => {
@@ -9396,7 +9594,13 @@ class App extends React.Component<AppProps, AppState> {
       const x = imageElement.x + imageElement.width / 2 - width / 2;
       const y = imageElement.y + imageElement.height / 2 - height / 2;
 
-      mutateElement(imageElement, { x, y, width, height });
+      mutateElement(imageElement, {
+        x,
+        y,
+        width,
+        height,
+        crop: null,
+      });
     }
   };
 
@@ -9935,6 +10139,83 @@ class App extends React.Component<AppProps, AppState> {
     }
   };
 
+  private maybeHandleCrop = (
+    pointerDownState: PointerDownState,
+    event: MouseEvent | KeyboardEvent,
+  ): boolean => {
+    // to crop, we must already be in the cropping mode, where croppingElement has been set
+    if (!this.state.croppingElementId) {
+      return false;
+    }
+
+    const transformHandleType = pointerDownState.resize.handleType;
+    const pointerCoords = pointerDownState.lastCoords;
+    const [x, y] = getGridPoint(
+      pointerCoords.x - pointerDownState.resize.offset.x,
+      pointerCoords.y - pointerDownState.resize.offset.y,
+      this.getEffectiveGridSize(),
+    );
+
+    const croppingElement = this.scene
+      .getNonDeletedElementsMap()
+      .get(this.state.croppingElementId);
+
+    if (
+      transformHandleType &&
+      croppingElement &&
+      isImageElement(croppingElement)
+    ) {
+      const croppingAtStateStart = pointerDownState.originalElements.get(
+        croppingElement.id,
+      );
+
+      const image =
+        isInitializedImageElement(croppingElement) &&
+        this.imageCache.get(croppingElement.fileId)?.image;
+
+      if (
+        croppingAtStateStart &&
+        isImageElement(croppingAtStateStart) &&
+        image &&
+        !(image instanceof Promise)
+      ) {
+        mutateElement(
+          croppingElement,
+          cropElement(
+            croppingElement,
+            transformHandleType,
+            image.naturalWidth,
+            image.naturalHeight,
+            x,
+            y,
+            event.shiftKey
+              ? croppingAtStateStart.width / croppingAtStateStart.height
+              : undefined,
+          ),
+        );
+
+        updateBoundElements(
+          croppingElement,
+          this.scene.getNonDeletedElementsMap(),
+          {
+            oldSize: {
+              width: croppingElement.width,
+              height: croppingElement.height,
+            },
+          },
+        );
+
+        this.setState({
+          isCropping: transformHandleType && transformHandleType !== "rotation",
+        });
+      }
+
+      return true;
+    }
+
+    return false;
+  };
+
   private maybeHandleResize = (
     pointerDownState: PointerDownState,
     event: MouseEvent | KeyboardEvent,
@@ -9951,7 +10232,9 @@ class App extends React.Component<AppProps, AppState> {
       // Frames cannot be rotated.
       (selectedFrames.length > 0 && transformHandleType === "rotation") ||
       // Elbow arrows cannot be transformed (resized or rotated).
-      (selectedElements.length === 1 && isElbowArrow(selectedElements[0]))
+      (selectedElements.length === 1 && isElbowArrow(selectedElements[0])) ||
+      // Do not resize when in crop mode
+      this.state.croppingElementId
     ) {
       return false;
     }
@@ -10126,6 +10409,8 @@ class App extends React.Component<AppProps, AppState> {
       actionSelectAllElementsInFrame,
       actionRemoveAllElementsFromFrame,
       CONTEXT_MENU_SEPARATOR,
+      actionToggleCropEditor,
+      CONTEXT_MENU_SEPARATOR,
       ...options,
       CONTEXT_MENU_SEPARATOR,
       actionCopyStyles,

+ 1 - 0
packages/excalidraw/components/CommandPalette/CommandPalette.tsx

@@ -279,6 +279,7 @@ function CommandPaletteInner({
         actionManager.actions.increaseFontSize,
         actionManager.actions.decreaseFontSize,
         actionManager.actions.toggleLinearEditor,
+        actionManager.actions.cropEditor,
         actionLink,
       ].map((action: Action) =>
         actionToCommand(

+ 10 - 0
packages/excalidraw/components/HelpDialog.tsx

@@ -222,6 +222,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
               ]}
               isOr={false}
             />
+            <Shortcut
+              label={t("helpDialog.cropStart")}
+              shortcuts={[t("helpDialog.doubleClick"), getShortcutKey("Enter")]}
+              isOr={true}
+            />
+            <Shortcut
+              label={t("helpDialog.cropFinish")}
+              shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
+              isOr={true}
+            />
             <Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
             <Shortcut
               label={t("helpDialog.preventBinding")}

+ 8 - 0
packages/excalidraw/components/HintViewer.tsx

@@ -100,6 +100,14 @@ const getHints = ({
     return t("hints.text_editing");
   }
 
+  if (appState.croppingElementId) {
+    return t("hints.leaveCropEditor");
+  }
+
+  if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
+    return t("hints.enterCropEditor");
+  }
+
   if (activeTool.type === "selection") {
     if (
       appState.selectionElement &&

+ 2 - 0
packages/excalidraw/components/canvases/InteractiveCanvas.tsx

@@ -203,6 +203,8 @@ const getRelevantAppStateProps = (
   snapLines: appState.snapLines,
   zenModeEnabled: appState.zenModeEnabled,
   editingTextElement: appState.editingTextElement,
+  isCropping: appState.isCropping,
+  croppingElementId: appState.croppingElementId,
   searchMatches: appState.searchMatches,
 });
 

+ 1 - 0
packages/excalidraw/components/canvases/StaticCanvas.tsx

@@ -107,6 +107,7 @@ const getRelevantAppStateProps = (
   frameToHighlight: appState.frameToHighlight,
   editingGroupId: appState.editingGroupId,
   currentHoveredFontFamily: appState.currentHoveredFontFamily,
+  croppingElementId: appState.croppingElementId,
 });
 
 const areEqual = (

+ 9 - 0
packages/excalidraw/components/icons.tsx

@@ -2147,3 +2147,12 @@ export const upIcon = createIcon(
   </g>,
   tablerIconProps,
 );
+
+export const cropIcon = createIcon(
+  <g strokeWidth="1.25">
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M8 5v10a1 1 0 0 0 1 1h10" />
+    <path d="M5 8h10a1 1 0 0 1 1 1v10" />
+  </g>,
+  tablerIconProps,
+);

+ 1 - 0
packages/excalidraw/data/restore.ts

@@ -258,6 +258,7 @@ const restoreElement = (
         status: element.status || "pending",
         fileId: element.fileId,
         scale: element.scale || [1, 1],
+        crop: element.crop ?? null,
       });
     case "line":
     // @ts-ignore LEGACY type

+ 587 - 0
packages/excalidraw/element/cropElement.ts

@@ -0,0 +1,587 @@
+import { type Point } from "points-on-curve";
+import {
+  type Radians,
+  pointFrom,
+  pointCenter,
+  pointRotateRads,
+  vectorFromPoint,
+  vectorNormalize,
+  vectorSubtract,
+  vectorAdd,
+  vectorScale,
+  pointFromVector,
+  clamp,
+  isCloseTo,
+} from "../../math";
+import type { TransformHandleType } from "./transformHandles";
+import type {
+  ElementsMap,
+  ExcalidrawElement,
+  ExcalidrawImageElement,
+  ImageCrop,
+  NonDeleted,
+} from "./types";
+import {
+  getElementAbsoluteCoords,
+  getResizedElementAbsoluteCoords,
+} from "./bounds";
+
+const MINIMAL_CROP_SIZE = 10;
+
+export const cropElement = (
+  element: ExcalidrawImageElement,
+  transformHandle: TransformHandleType,
+  naturalWidth: number,
+  naturalHeight: number,
+  pointerX: number,
+  pointerY: number,
+  widthAspectRatio?: number,
+) => {
+  const { width: uncroppedWidth, height: uncroppedHeight } =
+    getUncroppedWidthAndHeight(element);
+
+  const naturalWidthToUncropped = naturalWidth / uncroppedWidth;
+  const naturalHeightToUncropped = naturalHeight / uncroppedHeight;
+
+  const croppedLeft = (element.crop?.x ?? 0) / naturalWidthToUncropped;
+  const croppedTop = (element.crop?.y ?? 0) / naturalHeightToUncropped;
+
+  /**
+   *      uncropped width
+   * *––––––––––––––––––––––––*
+   * |     (x,y) (natural)    |
+   * |       *–––––––*        |
+   * |       |///////| height | uncropped height
+   * |       *–––––––*        |
+   * |    width (natural)     |
+   * *––––––––––––––––––––––––*
+   */
+
+  const rotatedPointer = pointRotateRads(
+    pointFrom(pointerX, pointerY),
+    pointFrom(element.x + element.width / 2, element.y + element.height / 2),
+    -element.angle as Radians,
+  );
+
+  pointerX = rotatedPointer[0];
+  pointerY = rotatedPointer[1];
+
+  let nextWidth = element.width;
+  let nextHeight = element.height;
+
+  let crop: ImageCrop | null = element.crop ?? {
+    x: 0,
+    y: 0,
+    width: naturalWidth,
+    height: naturalHeight,
+    naturalWidth,
+    naturalHeight,
+  };
+
+  const previousCropHeight = crop.height;
+  const previousCropWidth = crop.width;
+
+  const isFlippedByX = element.scale[0] === -1;
+  const isFlippedByY = element.scale[1] === -1;
+
+  let changeInHeight = pointerY - element.y;
+  let changeInWidth = pointerX - element.x;
+
+  if (transformHandle.includes("n")) {
+    nextHeight = clamp(
+      element.height - changeInHeight,
+      MINIMAL_CROP_SIZE,
+      isFlippedByY ? uncroppedHeight - croppedTop : element.height + croppedTop,
+    );
+  }
+
+  if (transformHandle.includes("s")) {
+    changeInHeight = pointerY - element.y - element.height;
+    nextHeight = clamp(
+      element.height + changeInHeight,
+      MINIMAL_CROP_SIZE,
+      isFlippedByY ? element.height + croppedTop : uncroppedHeight - croppedTop,
+    );
+  }
+
+  if (transformHandle.includes("e")) {
+    changeInWidth = pointerX - element.x - element.width;
+
+    nextWidth = clamp(
+      element.width + changeInWidth,
+      MINIMAL_CROP_SIZE,
+      isFlippedByX ? element.width + croppedLeft : uncroppedWidth - croppedLeft,
+    );
+  }
+
+  if (transformHandle.includes("w")) {
+    nextWidth = clamp(
+      element.width - changeInWidth,
+      MINIMAL_CROP_SIZE,
+      isFlippedByX ? uncroppedWidth - croppedLeft : element.width + croppedLeft,
+    );
+  }
+
+  const updateCropWidthAndHeight = (crop: ImageCrop) => {
+    crop.height = nextHeight * naturalHeightToUncropped;
+    crop.width = nextWidth * naturalWidthToUncropped;
+  };
+
+  updateCropWidthAndHeight(crop);
+
+  const adjustFlipForHandle = (
+    handle: TransformHandleType,
+    crop: ImageCrop,
+  ) => {
+    updateCropWidthAndHeight(crop);
+    if (handle.includes("n")) {
+      if (!isFlippedByY) {
+        crop.y += previousCropHeight - crop.height;
+      }
+    }
+    if (handle.includes("s")) {
+      if (isFlippedByY) {
+        crop.y += previousCropHeight - crop.height;
+      }
+    }
+    if (handle.includes("e")) {
+      if (isFlippedByX) {
+        crop.x += previousCropWidth - crop.width;
+      }
+    }
+    if (handle.includes("w")) {
+      if (!isFlippedByX) {
+        crop.x += previousCropWidth - crop.width;
+      }
+    }
+  };
+
+  switch (transformHandle) {
+    case "n": {
+      if (widthAspectRatio) {
+        const distanceToLeft = croppedLeft + element.width / 2;
+        const distanceToRight =
+          uncroppedWidth - croppedLeft - element.width / 2;
+
+        const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
+
+        nextWidth = clamp(
+          nextHeight * widthAspectRatio,
+          MINIMAL_CROP_SIZE,
+          MAX_WIDTH,
+        );
+        nextHeight = nextWidth / widthAspectRatio;
+      }
+
+      adjustFlipForHandle(transformHandle, crop);
+
+      if (widthAspectRatio) {
+        crop.x += (previousCropWidth - crop.width) / 2;
+      }
+
+      break;
+    }
+    case "s": {
+      if (widthAspectRatio) {
+        const distanceToLeft = croppedLeft + element.width / 2;
+        const distanceToRight =
+          uncroppedWidth - croppedLeft - element.width / 2;
+
+        const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
+
+        nextWidth = clamp(
+          nextHeight * widthAspectRatio,
+          MINIMAL_CROP_SIZE,
+          MAX_WIDTH,
+        );
+        nextHeight = nextWidth / widthAspectRatio;
+      }
+
+      adjustFlipForHandle(transformHandle, crop);
+
+      if (widthAspectRatio) {
+        crop.x += (previousCropWidth - crop.width) / 2;
+      }
+
+      break;
+    }
+    case "w": {
+      if (widthAspectRatio) {
+        const distanceToTop = croppedTop + element.height / 2;
+        const distanceToBottom =
+          uncroppedHeight - croppedTop - element.height / 2;
+
+        const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
+
+        nextHeight = clamp(
+          nextWidth / widthAspectRatio,
+          MINIMAL_CROP_SIZE,
+          MAX_HEIGHT,
+        );
+        nextWidth = nextHeight * widthAspectRatio;
+      }
+
+      adjustFlipForHandle(transformHandle, crop);
+
+      if (widthAspectRatio) {
+        crop.y += (previousCropHeight - crop.height) / 2;
+      }
+
+      break;
+    }
+    case "e": {
+      if (widthAspectRatio) {
+        const distanceToTop = croppedTop + element.height / 2;
+        const distanceToBottom =
+          uncroppedHeight - croppedTop - element.height / 2;
+
+        const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
+
+        nextHeight = clamp(
+          nextWidth / widthAspectRatio,
+          MINIMAL_CROP_SIZE,
+          MAX_HEIGHT,
+        );
+        nextWidth = nextHeight * widthAspectRatio;
+      }
+
+      adjustFlipForHandle(transformHandle, crop);
+
+      if (widthAspectRatio) {
+        crop.y += (previousCropHeight - crop.height) / 2;
+      }
+
+      break;
+    }
+    case "ne": {
+      if (widthAspectRatio) {
+        if (changeInWidth > -changeInHeight) {
+          const MAX_HEIGHT = isFlippedByY
+            ? uncroppedHeight - croppedTop
+            : croppedTop + element.height;
+
+          nextHeight = clamp(
+            nextWidth / widthAspectRatio,
+            MINIMAL_CROP_SIZE,
+            MAX_HEIGHT,
+          );
+          nextWidth = nextHeight * widthAspectRatio;
+        } else {
+          const MAX_WIDTH = isFlippedByX
+            ? croppedLeft + element.width
+            : uncroppedWidth - croppedLeft;
+
+          nextWidth = clamp(
+            nextHeight * widthAspectRatio,
+            MINIMAL_CROP_SIZE,
+            MAX_WIDTH,
+          );
+          nextHeight = nextWidth / widthAspectRatio;
+        }
+      }
+
+      adjustFlipForHandle(transformHandle, crop);
+      break;
+    }
+    case "nw": {
+      if (widthAspectRatio) {
+        if (changeInWidth < changeInHeight) {
+          const MAX_HEIGHT = isFlippedByY
+            ? uncroppedHeight - croppedTop
+            : croppedTop + element.height;
+          nextHeight = clamp(
+            nextWidth / widthAspectRatio,
+            MINIMAL_CROP_SIZE,
+            MAX_HEIGHT,
+          );
+          nextWidth = nextHeight * widthAspectRatio;
+        } else {
+          const MAX_WIDTH = isFlippedByX
+            ? uncroppedWidth - croppedLeft
+            : croppedLeft + element.width;
+
+          nextWidth = clamp(
+            nextHeight * widthAspectRatio,
+            MINIMAL_CROP_SIZE,
+            MAX_WIDTH,
+          );
+          nextHeight = nextWidth / widthAspectRatio;
+        }
+      }
+
+      adjustFlipForHandle(transformHandle, crop);
+      break;
+    }
+    case "se": {
+      if (widthAspectRatio) {
+        if (changeInWidth > changeInHeight) {
+          const MAX_HEIGHT = isFlippedByY
+            ? croppedTop + element.height
+            : uncroppedHeight - croppedTop;
+
+          nextHeight = clamp(
+            nextWidth / widthAspectRatio,
+            MINIMAL_CROP_SIZE,
+            MAX_HEIGHT,
+          );
+          nextWidth = nextHeight * widthAspectRatio;
+        } else {
+          const MAX_WIDTH = isFlippedByX
+            ? croppedLeft + element.width
+            : uncroppedWidth - croppedLeft;
+
+          nextWidth = clamp(
+            nextHeight * widthAspectRatio,
+            MINIMAL_CROP_SIZE,
+            MAX_WIDTH,
+          );
+          nextHeight = nextWidth / widthAspectRatio;
+        }
+      }
+
+      adjustFlipForHandle(transformHandle, crop);
+      break;
+    }
+    case "sw": {
+      if (widthAspectRatio) {
+        if (-changeInWidth > changeInHeight) {
+          const MAX_HEIGHT = isFlippedByY
+            ? croppedTop + element.height
+            : uncroppedHeight - croppedTop;
+
+          nextHeight = clamp(
+            nextWidth / widthAspectRatio,
+            MINIMAL_CROP_SIZE,
+            MAX_HEIGHT,
+          );
+          nextWidth = nextHeight * widthAspectRatio;
+        } else {
+          const MAX_WIDTH = isFlippedByX
+            ? uncroppedWidth - croppedLeft
+            : croppedLeft + element.width;
+
+          nextWidth = clamp(
+            nextHeight * widthAspectRatio,
+            MINIMAL_CROP_SIZE,
+            MAX_WIDTH,
+          );
+          nextHeight = nextWidth / widthAspectRatio;
+        }
+      }
+
+      adjustFlipForHandle(transformHandle, crop);
+      break;
+    }
+    default:
+      break;
+  }
+
+  const newOrigin = recomputeOrigin(
+    element,
+    transformHandle,
+    nextWidth,
+    nextHeight,
+    !!widthAspectRatio,
+  );
+
+  // reset crop to null if we're back to orig size
+  if (
+    isCloseTo(crop.width, crop.naturalWidth) &&
+    isCloseTo(crop.height, crop.naturalHeight)
+  ) {
+    crop = null;
+  }
+
+  return {
+    x: newOrigin[0],
+    y: newOrigin[1],
+    width: nextWidth,
+    height: nextHeight,
+    crop,
+  };
+};
+
+const recomputeOrigin = (
+  stateAtCropStart: NonDeleted<ExcalidrawElement>,
+  transformHandle: TransformHandleType,
+  width: number,
+  height: number,
+  shouldMaintainAspectRatio?: boolean,
+) => {
+  const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
+    stateAtCropStart,
+    stateAtCropStart.width,
+    stateAtCropStart.height,
+    true,
+  );
+  const startTopLeft = pointFrom(x1, y1);
+  const startBottomRight = pointFrom(x2, y2);
+  const startCenter: any = pointCenter(startTopLeft, startBottomRight);
+
+  const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
+    getResizedElementAbsoluteCoords(stateAtCropStart, width, height, true);
+  const newBoundsWidth = newBoundsX2 - newBoundsX1;
+  const newBoundsHeight = newBoundsY2 - newBoundsY1;
+
+  // Calculate new topLeft based on fixed corner during resize
+  let newTopLeft = [...startTopLeft] as [number, number];
+
+  if (["n", "w", "nw"].includes(transformHandle)) {
+    newTopLeft = [
+      startBottomRight[0] - Math.abs(newBoundsWidth),
+      startBottomRight[1] - Math.abs(newBoundsHeight),
+    ];
+  }
+  if (transformHandle === "ne") {
+    const bottomLeft = [startTopLeft[0], startBottomRight[1]];
+    newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)];
+  }
+  if (transformHandle === "sw") {
+    const topRight = [startBottomRight[0], startTopLeft[1]];
+    newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]];
+  }
+
+  if (shouldMaintainAspectRatio) {
+    if (["s", "n"].includes(transformHandle)) {
+      newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
+    }
+    if (["e", "w"].includes(transformHandle)) {
+      newTopLeft[1] = startCenter[1] - newBoundsHeight / 2;
+    }
+  }
+
+  // adjust topLeft to new rotation point
+  const angle = stateAtCropStart.angle;
+  const rotatedTopLeft = pointRotateRads(newTopLeft, startCenter, angle);
+  const newCenter: Point = [
+    newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
+    newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
+  ];
+  const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
+  newTopLeft = pointRotateRads(
+    rotatedTopLeft,
+    rotatedNewCenter,
+    -angle as Radians,
+  );
+
+  const newOrigin = [...newTopLeft];
+  newOrigin[0] += stateAtCropStart.x - newBoundsX1;
+  newOrigin[1] += stateAtCropStart.y - newBoundsY1;
+
+  return newOrigin;
+};
+
+// refer to https://link.excalidraw.com/l/6rfy1007QOo/6stx5PmRn0k
+export const getUncroppedImageElement = (
+  element: ExcalidrawImageElement,
+  elementsMap: ElementsMap,
+) => {
+  if (element.crop) {
+    const { width, height } = getUncroppedWidthAndHeight(element);
+
+    const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
+      element,
+      elementsMap,
+    );
+
+    const topLeftVector = vectorFromPoint(
+      pointRotateRads(pointFrom(x1, y1), pointFrom(cx, cy), element.angle),
+    );
+    const topRightVector = vectorFromPoint(
+      pointRotateRads(pointFrom(x2, y1), pointFrom(cx, cy), element.angle),
+    );
+    const topEdgeNormalized = vectorNormalize(
+      vectorSubtract(topRightVector, topLeftVector),
+    );
+    const bottomLeftVector = vectorFromPoint(
+      pointRotateRads(pointFrom(x1, y2), pointFrom(cx, cy), element.angle),
+    );
+    const leftEdgeVector = vectorSubtract(bottomLeftVector, topLeftVector);
+    const leftEdgeNormalized = vectorNormalize(leftEdgeVector);
+
+    const { cropX, cropY } = adjustCropPosition(element.crop, element.scale);
+
+    const rotatedTopLeft = vectorAdd(
+      vectorAdd(
+        topLeftVector,
+        vectorScale(
+          topEdgeNormalized,
+          (-cropX * width) / element.crop.naturalWidth,
+        ),
+      ),
+      vectorScale(
+        leftEdgeNormalized,
+        (-cropY * height) / element.crop.naturalHeight,
+      ),
+    );
+
+    const center = pointFromVector(
+      vectorAdd(
+        vectorAdd(rotatedTopLeft, vectorScale(topEdgeNormalized, width / 2)),
+        vectorScale(leftEdgeNormalized, height / 2),
+      ),
+    );
+
+    const unrotatedTopLeft = pointRotateRads(
+      pointFromVector(rotatedTopLeft),
+      center,
+      -element.angle as Radians,
+    );
+
+    const uncroppedElement: ExcalidrawImageElement = {
+      ...element,
+      x: unrotatedTopLeft[0],
+      y: unrotatedTopLeft[1],
+      width,
+      height,
+      crop: null,
+    };
+
+    return uncroppedElement;
+  }
+
+  return element;
+};
+
+export const getUncroppedWidthAndHeight = (element: ExcalidrawImageElement) => {
+  if (element.crop) {
+    const width =
+      element.width / (element.crop.width / element.crop.naturalWidth);
+    const height =
+      element.height / (element.crop.height / element.crop.naturalHeight);
+
+    return {
+      width,
+      height,
+    };
+  }
+
+  return {
+    width: element.width,
+    height: element.height,
+  };
+};
+
+const adjustCropPosition = (
+  crop: ImageCrop,
+  scale: ExcalidrawImageElement["scale"],
+) => {
+  let cropX = crop.x;
+  let cropY = crop.y;
+
+  const flipX = scale[0] === -1;
+  const flipY = scale[1] === -1;
+
+  if (flipX) {
+    cropX = crop.naturalWidth - Math.abs(cropX) - crop.width;
+  }
+
+  if (flipY) {
+    cropY = crop.naturalHeight - Math.abs(cropY) - crop.height;
+  }
+
+  return {
+    cropX,
+    cropY,
+  };
+};

+ 10 - 0
packages/excalidraw/element/dragElements.ts

@@ -16,6 +16,7 @@ import {
   isArrowElement,
   isElbowArrow,
   isFrameLikeElement,
+  isImageElement,
   isTextElement,
 } from "./typeChecks";
 import { getFontString } from "../utils";
@@ -251,6 +252,14 @@ export const dragNewElement = ({
   }
 
   if (width !== 0 && height !== 0) {
+    let imageInitialDimension = null;
+    if (isImageElement(newElement)) {
+      imageInitialDimension = {
+        initialWidth: width,
+        initialHeight: height,
+      };
+    }
+
     mutateElement(
       newElement,
       {
@@ -259,6 +268,7 @@ export const dragNewElement = ({
         width,
         height,
         ...textAutoResize,
+        ...imageInitialDimension,
       },
       informMutation,
     );

+ 2 - 0
packages/excalidraw/element/newElement.ts

@@ -477,6 +477,7 @@ export const newImageElement = (
     status?: ExcalidrawImageElement["status"];
     fileId?: ExcalidrawImageElement["fileId"];
     scale?: ExcalidrawImageElement["scale"];
+    crop?: ExcalidrawImageElement["crop"];
   } & ElementConstructorOpts,
 ): NonDeleted<ExcalidrawImageElement> => {
   return {
@@ -487,6 +488,7 @@ export const newImageElement = (
     status: opts.status ?? "pending",
     fileId: opts.fileId ?? null,
     scale: opts.scale ?? [1, 1],
+    crop: opts.crop ?? null,
   };
 };
 

+ 7 - 3
packages/excalidraw/element/resizeTest.ts

@@ -20,7 +20,7 @@ import type { AppState, Device, Zoom } from "../types";
 import type { Bounds } from "./bounds";
 import { getElementAbsoluteCoords } from "./bounds";
 import { SIDE_RESIZING_THRESHOLD } from "../constants";
-import { isLinearElement } from "./typeChecks";
+import { isImageElement, isLinearElement } from "./typeChecks";
 import type { GlobalPoint, LineSegment, LocalPoint } from "../../math";
 import {
   pointFrom,
@@ -90,7 +90,11 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
 
     // do not resize from the sides for linear elements with only two points
     if (!(isLinearElement(element) && element.points.length <= 2)) {
-      const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
+      const SPACING = isImageElement(element)
+        ? 0
+        : SIDE_RESIZING_THRESHOLD / zoom.value;
+      const ZOOMED_SIDE_RESIZING_THRESHOLD =
+        SIDE_RESIZING_THRESHOLD / zoom.value;
       const sides = getSelectionBorders(
         pointFrom(x1 - SPACING, y1 - SPACING),
         pointFrom(x2 + SPACING, y2 + SPACING),
@@ -104,7 +108,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
           pointOnLineSegment(
             pointFrom(x, y),
             side as LineSegment<Point>,
-            SPACING,
+            ZOOMED_SIDE_RESIZING_THRESHOLD,
           )
         ) {
           return dir as TransformHandleType;

+ 8 - 4
packages/excalidraw/element/transformHandles.ts

@@ -11,6 +11,7 @@ import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
 import {
   isElbowArrow,
   isFrameLikeElement,
+  isImageElement,
   isLinearElement,
 } from "./typeChecks";
 import {
@@ -129,6 +130,7 @@ export const getTransformHandlesFromCoords = (
   pointerType: PointerType,
   omitSides: { [T in TransformHandleType]?: boolean } = {},
   margin = 4,
+  spacing = DEFAULT_TRANSFORM_HANDLE_SPACING,
 ): TransformHandles => {
   const size = transformHandleSizes[pointerType];
   const handleWidth = size / zoom.value;
@@ -140,8 +142,7 @@ export const getTransformHandlesFromCoords = (
   const width = x2 - x1;
   const height = y2 - y1;
   const dashedLineMargin = margin / zoom.value;
-  const centeringOffset =
-    (size - DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / (2 * zoom.value);
+  const centeringOffset = (size - spacing * 2) / (2 * zoom.value);
 
   const transformHandles: TransformHandles = {
     nw: omitSides.nw
@@ -301,8 +302,10 @@ export const getTransformHandles = (
       rotation: true,
     };
   }
-  const dashedLineMargin = isLinearElement(element)
+  const margin = isLinearElement(element)
     ? DEFAULT_TRANSFORM_HANDLE_SPACING + 8
+    : isImageElement(element)
+    ? 0
     : DEFAULT_TRANSFORM_HANDLE_SPACING;
   return getTransformHandlesFromCoords(
     getElementAbsoluteCoords(element, elementsMap, true),
@@ -310,7 +313,8 @@ export const getTransformHandles = (
     zoom,
     pointerType,
     omitSides,
-    dashedLineMargin,
+    margin,
+    isImageElement(element) ? 0 : undefined,
   );
 };
 

+ 11 - 0
packages/excalidraw/element/types.ts

@@ -132,6 +132,15 @@ export type IframeData =
       | { type: "document"; srcdoc: (theme: Theme) => string }
     );
 
+export type ImageCrop = {
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+  naturalWidth: number;
+  naturalHeight: number;
+};
+
 export type ExcalidrawImageElement = _ExcalidrawElementBase &
   Readonly<{
     type: "image";
@@ -140,6 +149,8 @@ export type ExcalidrawImageElement = _ExcalidrawElementBase &
     status: "pending" | "saved" | "error";
     /** X and Y scale factors <-1, 1>, used for image axis flipping */
     scale: [number, number];
+    /** whether an element is cropped */
+    crop: ImageCrop | null;
   }>;
 
 export type InitializedExcalidrawImageElement = MarkNonNullable<

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

@@ -328,7 +328,9 @@
     "deepBoxSelect": "Hold CtrlOrCmd to deep select, and to prevent dragging",
     "eraserRevert": "Hold Alt to revert the elements marked for deletion",
     "firefox_clipboard_write": "This feature can likely be enabled by setting the \"dom.events.asyncClipboard.clipboardItem\" flag to \"true\". To change the browser flags in Firefox, visit the \"about:config\" page.",
-    "disableSnapping": "Hold CtrlOrCmd to disable snapping"
+    "disableSnapping": "Hold CtrlOrCmd to disable snapping",
+    "enterCropEditor": "Double click the image or press ENTER to crop the image",
+    "leaveCropEditor": "Click outside the image or press ENTER or ESCAPE to finish cropping"
   },
   "canvasError": {
     "cannotShowPreview": "Cannot show preview",
@@ -399,7 +401,9 @@
     "zoomToSelection": "Zoom to selection",
     "toggleElementLock": "Lock/unlock selection",
     "movePageUpDown": "Move page up/down",
-    "movePageLeftRight": "Move page left/right"
+    "movePageLeftRight": "Move page left/right",
+    "cropStart": "Crop image",
+    "cropFinish": "Finish image cropping"
   },
   "clearCanvasDialog": {
     "title": "Clear canvas"

+ 179 - 57
packages/excalidraw/renderer/interactiveScene.ts

@@ -54,6 +54,7 @@ import oc from "open-color";
 import {
   isElbowArrow,
   isFrameLikeElement,
+  isImageElement,
   isLinearElement,
   isTextElement,
 } from "../element/typeChecks";
@@ -62,6 +63,7 @@ import type {
   ExcalidrawBindableElement,
   ExcalidrawElement,
   ExcalidrawFrameLikeElement,
+  ExcalidrawImageElement,
   ExcalidrawLinearElement,
   ExcalidrawTextElement,
   GroupId,
@@ -307,38 +309,42 @@ const renderBindingHighlightForSuggestedPointBinding = (
   });
 };
 
+type ElementSelectionBorder = {
+  angle: number;
+  x1: number;
+  y1: number;
+  x2: number;
+  y2: number;
+  selectionColors: string[];
+  dashed?: boolean;
+  cx: number;
+  cy: number;
+  activeEmbeddable: boolean;
+  padding?: number;
+};
+
 const renderSelectionBorder = (
   context: CanvasRenderingContext2D,
   appState: InteractiveCanvasAppState,
-  elementProperties: {
-    angle: number;
-    elementX1: number;
-    elementY1: number;
-    elementX2: number;
-    elementY2: number;
-    selectionColors: string[];
-    dashed?: boolean;
-    cx: number;
-    cy: number;
-    activeEmbeddable: boolean;
-  },
+  elementProperties: ElementSelectionBorder,
 ) => {
   const {
     angle,
-    elementX1,
-    elementY1,
-    elementX2,
-    elementY2,
+    x1,
+    y1,
+    x2,
+    y2,
     selectionColors,
     cx,
     cy,
     dashed,
     activeEmbeddable,
   } = elementProperties;
-  const elementWidth = elementX2 - elementX1;
-  const elementHeight = elementY2 - elementY1;
+  const elementWidth = x2 - x1;
+  const elementHeight = y2 - y1;
 
-  const padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2;
+  const padding =
+    elementProperties.padding ?? DEFAULT_TRANSFORM_HANDLE_SPACING * 2;
 
   const linePadding = padding / appState.zoom.value;
   const lineWidth = 8 / appState.zoom.value;
@@ -360,8 +366,8 @@ const renderSelectionBorder = (
     context.lineDashOffset = (lineWidth + spaceWidth) * index;
     strokeRectWithRotation(
       context,
-      elementX1 - linePadding,
-      elementY1 - linePadding,
+      x1 - linePadding,
+      y1 - linePadding,
       elementWidth + linePadding * 2,
       elementHeight + linePadding * 2,
       cx,
@@ -433,18 +439,17 @@ const renderElementsBoxHighlight = (
   );
 
   const getSelectionFromElements = (elements: ExcalidrawElement[]) => {
-    const [elementX1, elementY1, elementX2, elementY2] =
-      getCommonBounds(elements);
+    const [x1, y1, x2, y2] = getCommonBounds(elements);
     return {
       angle: 0,
-      elementX1,
-      elementX2,
-      elementY1,
-      elementY2,
+      x1,
+      x2,
+      y1,
+      y2,
       selectionColors: ["rgb(0,118,255)"],
       dashed: false,
-      cx: elementX1 + (elementX2 - elementX1) / 2,
-      cy: elementY1 + (elementY2 - elementY1) / 2,
+      cx: x1 + (x2 - x1) / 2,
+      cy: y1 + (y2 - y1) / 2,
       activeEmbeddable: false,
     };
   };
@@ -594,6 +599,111 @@ const renderTransformHandles = (
   });
 };
 
+const renderCropHandles = (
+  context: CanvasRenderingContext2D,
+  renderConfig: InteractiveCanvasRenderConfig,
+  appState: InteractiveCanvasAppState,
+  croppingElement: ExcalidrawImageElement,
+  elementsMap: ElementsMap,
+): void => {
+  const [x1, y1, , , cx, cy] = getElementAbsoluteCoords(
+    croppingElement,
+    elementsMap,
+  );
+
+  const LINE_WIDTH = 3;
+  const LINE_LENGTH = 20;
+
+  const ZOOMED_LINE_WIDTH = LINE_WIDTH / appState.zoom.value;
+  const ZOOMED_HALF_LINE_WIDTH = ZOOMED_LINE_WIDTH / 2;
+
+  const HALF_WIDTH = cx - x1 + ZOOMED_LINE_WIDTH;
+  const HALF_HEIGHT = cy - y1 + ZOOMED_LINE_WIDTH;
+
+  const HORIZONTAL_LINE_LENGTH = Math.min(
+    LINE_LENGTH / appState.zoom.value,
+    HALF_WIDTH,
+  );
+  const VERTICAL_LINE_LENGTH = Math.min(
+    LINE_LENGTH / appState.zoom.value,
+    HALF_HEIGHT,
+  );
+
+  context.save();
+  context.fillStyle = renderConfig.selectionColor;
+  context.strokeStyle = renderConfig.selectionColor;
+  context.lineWidth = ZOOMED_LINE_WIDTH;
+
+  const handles: Array<
+    [
+      [number, number],
+      [number, number],
+      [number, number],
+      [number, number],
+      [number, number],
+    ]
+  > = [
+    [
+      // x, y
+      [-HALF_WIDTH, -HALF_HEIGHT],
+      // horizontal line: first start and to
+      [0, ZOOMED_HALF_LINE_WIDTH],
+      [HORIZONTAL_LINE_LENGTH, ZOOMED_HALF_LINE_WIDTH],
+      // vertical line: second  start and to
+      [ZOOMED_HALF_LINE_WIDTH, 0],
+      [ZOOMED_HALF_LINE_WIDTH, VERTICAL_LINE_LENGTH],
+    ],
+    [
+      [HALF_WIDTH - ZOOMED_HALF_LINE_WIDTH, -HALF_HEIGHT],
+      [ZOOMED_HALF_LINE_WIDTH, ZOOMED_HALF_LINE_WIDTH],
+      [
+        -HORIZONTAL_LINE_LENGTH + ZOOMED_HALF_LINE_WIDTH,
+        ZOOMED_HALF_LINE_WIDTH,
+      ],
+      [0, 0],
+      [0, VERTICAL_LINE_LENGTH],
+    ],
+    [
+      [-HALF_WIDTH, HALF_HEIGHT],
+      [0, -ZOOMED_HALF_LINE_WIDTH],
+      [HORIZONTAL_LINE_LENGTH, -ZOOMED_HALF_LINE_WIDTH],
+      [ZOOMED_HALF_LINE_WIDTH, 0],
+      [ZOOMED_HALF_LINE_WIDTH, -VERTICAL_LINE_LENGTH],
+    ],
+    [
+      [HALF_WIDTH - ZOOMED_HALF_LINE_WIDTH, HALF_HEIGHT],
+      [ZOOMED_HALF_LINE_WIDTH, -ZOOMED_HALF_LINE_WIDTH],
+      [
+        -HORIZONTAL_LINE_LENGTH + ZOOMED_HALF_LINE_WIDTH,
+        -ZOOMED_HALF_LINE_WIDTH,
+      ],
+      [0, 0],
+      [0, -VERTICAL_LINE_LENGTH],
+    ],
+  ];
+
+  handles.forEach((handle) => {
+    const [[x, y], [x1s, y1s], [x1t, y1t], [x2s, y2s], [x2t, y2t]] = handle;
+
+    context.save();
+    context.translate(cx, cy);
+    context.rotate(croppingElement.angle);
+
+    context.beginPath();
+    context.moveTo(x + x1s, y + y1s);
+    context.lineTo(x + x1t, y + y1t);
+    context.stroke();
+
+    context.beginPath();
+    context.moveTo(x + x2s, y + y2s);
+    context.lineTo(x + x2t, y + y2t);
+    context.stroke();
+    context.restore();
+  });
+
+  context.restore();
+};
+
 const renderTextBox = (
   text: NonDeleted<ExcalidrawTextElement>,
   context: CanvasRenderingContext2D,
@@ -671,7 +781,7 @@ const _renderInteractiveScene = ({
   }
 
   // Paint selection element
-  if (appState.selectionElement) {
+  if (appState.selectionElement && !appState.isCropping) {
     try {
       renderSelectionElement(
         appState.selectionElement,
@@ -783,18 +893,7 @@ const _renderInteractiveScene = ({
       // Optimisation for finding quickly relevant element ids
       const locallySelectedIds = arrayToMap(selectedElements);
 
-      const selections: {
-        angle: number;
-        elementX1: number;
-        elementY1: number;
-        elementX2: number;
-        elementY2: number;
-        selectionColors: string[];
-        dashed?: boolean;
-        cx: number;
-        cy: number;
-        activeEmbeddable: boolean;
-      }[] = [];
+      const selections: ElementSelectionBorder[] = [];
 
       for (const element of elementsMap.values()) {
         const selectionColors = [];
@@ -833,14 +932,17 @@ const _renderInteractiveScene = ({
         }
 
         if (selectionColors.length) {
-          const [elementX1, elementY1, elementX2, elementY2, cx, cy] =
-            getElementAbsoluteCoords(element, elementsMap, true);
+          const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
+            element,
+            elementsMap,
+            true,
+          );
           selections.push({
             angle: element.angle,
-            elementX1,
-            elementY1,
-            elementX2,
-            elementY2,
+            x1,
+            y1,
+            x2,
+            y2,
             selectionColors,
             dashed: !!remoteClients,
             cx,
@@ -848,24 +950,28 @@ const _renderInteractiveScene = ({
             activeEmbeddable:
               appState.activeEmbeddable?.element === element &&
               appState.activeEmbeddable.state === "active",
+            padding:
+              element.id === appState.croppingElementId ||
+              isImageElement(element)
+                ? 0
+                : undefined,
           });
         }
       }
 
       const addSelectionForGroupId = (groupId: GroupId) => {
         const groupElements = getElementsInGroup(elementsMap, groupId);
-        const [elementX1, elementY1, elementX2, elementY2] =
-          getCommonBounds(groupElements);
+        const [x1, y1, x2, y2] = getCommonBounds(groupElements);
         selections.push({
           angle: 0,
-          elementX1,
-          elementX2,
-          elementY1,
-          elementY2,
+          x1,
+          x2,
+          y1,
+          y2,
           selectionColors: [oc.black],
           dashed: true,
-          cx: elementX1 + (elementX2 - elementX1) / 2,
-          cy: elementY1 + (elementY2 - elementY1) / 2,
+          cx: x1 + (x2 - x1) / 2,
+          cy: y1 + (y2 - y1) / 2,
           activeEmbeddable: false,
         });
       };
@@ -900,7 +1006,9 @@ const _renderInteractiveScene = ({
         !appState.viewModeEnabled &&
         showBoundingBox &&
         // do not show transform handles when text is being edited
-        !isTextElement(appState.editingTextElement)
+        !isTextElement(appState.editingTextElement) &&
+        // do not show transform handles when image is being cropped
+        !appState.croppingElementId
       ) {
         renderTransformHandles(
           context,
@@ -910,6 +1018,20 @@ const _renderInteractiveScene = ({
           selectedElements[0].angle,
         );
       }
+
+      if (appState.croppingElementId && !appState.isCropping) {
+        const croppingElement = elementsMap.get(appState.croppingElementId);
+
+        if (croppingElement && isImageElement(croppingElement)) {
+          renderCropHandles(
+            context,
+            renderConfig,
+            appState,
+            croppingElement,
+            elementsMap,
+          );
+        }
+      }
     } else if (selectedElements.length > 1 && !appState.isRotating) {
       const dashedLinePadding =
         (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value;

+ 59 - 4
packages/excalidraw/renderer/renderElement.ts

@@ -17,6 +17,7 @@ import {
   isArrowElement,
   hasBoundTextElement,
   isMagicFrameElement,
+  isImageElement,
 } from "../element/typeChecks";
 import { getElementAbsoluteCoords } from "../element/bounds";
 import type { RoughCanvas } from "roughjs/bin/canvas";
@@ -61,6 +62,7 @@ import { ShapeCache } from "../scene/ShapeCache";
 import { getVerticalOffset } from "../fonts";
 import { isRightAngleRads } from "../../math";
 import { getCornerRadius } from "../shapes";
+import { getUncroppedImageElement } from "../element/cropElement";
 
 // using a stronger invert (100% vs our regular 93%) and saturate
 // as a temp hack to make images in dark theme look closer to original
@@ -434,8 +436,22 @@ const drawElementOnCanvas = (
           );
           context.clip();
         }
+
+        const { x, y, width, height } = element.crop
+          ? element.crop
+          : {
+              x: 0,
+              y: 0,
+              width: img.naturalWidth,
+              height: img.naturalHeight,
+            };
+
         context.drawImage(
           img,
+          x,
+          y,
+          width,
+          height,
           0 /* hardcoded for the selection box*/,
           0,
           element.width,
@@ -921,14 +937,53 @@ export const renderElement = (
           context.imageSmoothingEnabled = false;
         }
 
-        drawElementFromCanvas(
-          elementWithCanvas,
-          context,
+        if (
+          element.id === appState.croppingElementId &&
+          isImageElement(elementWithCanvas.element) &&
+          elementWithCanvas.element.crop !== null
+        ) {
+          context.save();
+          context.globalAlpha = 0.1;
+
+          const uncroppedElementCanvas = generateElementCanvas(
+            getUncroppedImageElement(elementWithCanvas.element, elementsMap),
+            allElementsMap,
+            appState.zoom,
+            renderConfig,
+            appState,
+          );
+
+          if (uncroppedElementCanvas) {
+            drawElementFromCanvas(
+              uncroppedElementCanvas,
+              context,
+              renderConfig,
+              appState,
+              allElementsMap,
+            );
+          }
+
+          context.restore();
+        }
+
+        const _elementWithCanvas = generateElementCanvas(
+          elementWithCanvas.element,
+          allElementsMap,
+          appState.zoom,
           renderConfig,
           appState,
-          allElementsMap,
         );
 
+        if (_elementWithCanvas) {
+          drawElementFromCanvas(
+            _elementWithCanvas,
+            context,
+            renderConfig,
+            appState,
+            allElementsMap,
+          );
+        }
+
         // reset
         context.imageSmoothingEnabled = currentImageSmoothingStatus;
       }

+ 20 - 3
packages/excalidraw/renderer/staticSvgScene.ts

@@ -37,6 +37,7 @@ import { getFontFamilyString, isRTL, isTestEnv } from "../utils";
 import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
 import { getVerticalOffset } from "../fonts";
 import { getCornerRadius, isPathALoop } from "../shapes";
+import { getUncroppedWidthAndHeight } from "../element/cropElement";
 
 const roughSVGDrawWithPrecision = (
   rsvg: RoughSVG,
@@ -417,12 +418,28 @@ const renderElementToSvg = (
           symbol.id = symbolId;
 
           const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image");
-
-          image.setAttribute("width", "100%");
-          image.setAttribute("height", "100%");
           image.setAttribute("href", fileData.dataURL);
           image.setAttribute("preserveAspectRatio", "none");
 
+          if (element.crop) {
+            const { width: uncroppedWidth, height: uncroppedHeight } =
+              getUncroppedWidthAndHeight(element);
+
+            symbol.setAttribute(
+              "viewBox",
+              `${
+                element.crop.x / (element.crop.naturalWidth / uncroppedWidth)
+              } ${
+                element.crop.y / (element.crop.naturalHeight / uncroppedHeight)
+              } ${width} ${height}`,
+            );
+            image.setAttribute("width", `${uncroppedWidth}`);
+            image.setAttribute("height", `${uncroppedHeight}`);
+          } else {
+            image.setAttribute("width", "100%");
+            image.setAttribute("height", "100%");
+          }
+
           symbol.appendChild(image);
 
           root.prepend(symbol);

+ 1 - 0
packages/excalidraw/store.ts

@@ -21,6 +21,7 @@ export const getObservedAppState = (appState: AppState): ObservedAppState => {
     selectedGroupIds: appState.selectedGroupIds,
     editingLinearElementId: appState.editingLinearElement?.elementId || null,
     selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
+    croppingElementId: appState.croppingElementId,
   };
 
   Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {

+ 254 - 0
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -116,6 +116,50 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
         },
       },
       "separator",
+      {
+        "PanelComponent": [Function],
+        "icon": <svg
+          aria-hidden="true"
+          className=""
+          fill="none"
+          focusable="false"
+          role="img"
+          stroke="currentColor"
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          strokeWidth={2}
+          viewBox="0 0 24 24"
+        >
+          <g
+            strokeWidth="1.25"
+          >
+            <path
+              d="M0 0h24v24H0z"
+              fill="none"
+              stroke="none"
+            />
+            <path
+              d="M8 5v10a1 1 0 0 0 1 1h10"
+            />
+            <path
+              d="M5 8h10a1 1 0 0 1 1 1v10"
+            />
+          </g>
+        </svg>,
+        "keywords": [
+          "image",
+          "crop",
+        ],
+        "label": "helpDialog.cropStart",
+        "name": "cropEditor",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": {
+          "category": "menu",
+        },
+        "viewMode": true,
+      },
+      "separator",
       {
         "icon": <svg
           aria-hidden="true"
@@ -794,6 +838,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
     "left": 30,
     "top": 40,
   },
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -836,6 +881,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -1000,6 +1046,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -1042,6 +1089,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -1216,6 +1264,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -1258,6 +1307,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -1547,6 +1597,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -1589,6 +1640,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -1878,6 +1930,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -1920,6 +1973,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -2094,6 +2148,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -2136,6 +2191,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -2334,6 +2390,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -2376,6 +2433,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -2635,6 +2693,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -2677,6 +2736,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -3004,6 +3064,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -3046,6 +3107,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -3479,6 +3541,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -3521,6 +3584,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -3802,6 +3866,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -3844,6 +3909,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -4125,6 +4191,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -4167,6 +4234,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -4633,6 +4701,50 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
         },
       },
       "separator",
+      {
+        "PanelComponent": [Function],
+        "icon": <svg
+          aria-hidden="true"
+          className=""
+          fill="none"
+          focusable="false"
+          role="img"
+          stroke="currentColor"
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          strokeWidth={2}
+          viewBox="0 0 24 24"
+        >
+          <g
+            strokeWidth="1.25"
+          >
+            <path
+              d="M0 0h24v24H0z"
+              fill="none"
+              stroke="none"
+            />
+            <path
+              d="M8 5v10a1 1 0 0 0 1 1h10"
+            />
+            <path
+              d="M5 8h10a1 1 0 0 1 1 1v10"
+            />
+          </g>
+        </svg>,
+        "keywords": [
+          "image",
+          "crop",
+        ],
+        "label": "helpDialog.cropStart",
+        "name": "cropEditor",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": {
+          "category": "menu",
+        },
+        "viewMode": true,
+      },
+      "separator",
       {
         "icon": <svg
           aria-hidden="true"
@@ -5311,6 +5423,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
     "left": -17,
     "top": -7,
   },
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -5353,6 +5466,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -5760,6 +5874,50 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
         },
       },
       "separator",
+      {
+        "PanelComponent": [Function],
+        "icon": <svg
+          aria-hidden="true"
+          className=""
+          fill="none"
+          focusable="false"
+          role="img"
+          stroke="currentColor"
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          strokeWidth={2}
+          viewBox="0 0 24 24"
+        >
+          <g
+            strokeWidth="1.25"
+          >
+            <path
+              d="M0 0h24v24H0z"
+              fill="none"
+              stroke="none"
+            />
+            <path
+              d="M8 5v10a1 1 0 0 0 1 1h10"
+            />
+            <path
+              d="M5 8h10a1 1 0 0 1 1 1v10"
+            />
+          </g>
+        </svg>,
+        "keywords": [
+          "image",
+          "crop",
+        ],
+        "label": "helpDialog.cropStart",
+        "name": "cropEditor",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": {
+          "category": "menu",
+        },
+        "viewMode": true,
+      },
+      "separator",
       {
         "icon": <svg
           aria-hidden="true"
@@ -6438,6 +6596,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
     "left": -17,
     "top": -7,
   },
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -6480,6 +6639,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -7373,6 +7533,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
     "left": -19,
     "top": -9,
   },
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -7415,6 +7576,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -7607,6 +7769,50 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
         },
       },
       "separator",
+      {
+        "PanelComponent": [Function],
+        "icon": <svg
+          aria-hidden="true"
+          className=""
+          fill="none"
+          focusable="false"
+          role="img"
+          stroke="currentColor"
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          strokeWidth={2}
+          viewBox="0 0 24 24"
+        >
+          <g
+            strokeWidth="1.25"
+          >
+            <path
+              d="M0 0h24v24H0z"
+              fill="none"
+              stroke="none"
+            />
+            <path
+              d="M8 5v10a1 1 0 0 0 1 1h10"
+            />
+            <path
+              d="M5 8h10a1 1 0 0 1 1 1v10"
+            />
+          </g>
+        </svg>,
+        "keywords": [
+          "image",
+          "crop",
+        ],
+        "label": "helpDialog.cropStart",
+        "name": "cropEditor",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": {
+          "category": "menu",
+        },
+        "viewMode": true,
+      },
+      "separator",
       {
         "icon": <svg
           aria-hidden="true"
@@ -8285,6 +8491,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
     "left": -17,
     "top": -7,
   },
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -8327,6 +8534,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -8501,6 +8709,50 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
         },
       },
       "separator",
+      {
+        "PanelComponent": [Function],
+        "icon": <svg
+          aria-hidden="true"
+          className=""
+          fill="none"
+          focusable="false"
+          role="img"
+          stroke="currentColor"
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          strokeWidth={2}
+          viewBox="0 0 24 24"
+        >
+          <g
+            strokeWidth="1.25"
+          >
+            <path
+              d="M0 0h24v24H0z"
+              fill="none"
+              stroke="none"
+            />
+            <path
+              d="M8 5v10a1 1 0 0 0 1 1h10"
+            />
+            <path
+              d="M5 8h10a1 1 0 0 1 1 1v10"
+            />
+          </g>
+        </svg>,
+        "keywords": [
+          "image",
+          "crop",
+        ],
+        "label": "helpDialog.cropStart",
+        "name": "cropEditor",
+        "perform": [Function],
+        "predicate": [Function],
+        "trackEvent": {
+          "category": "menu",
+        },
+        "viewMode": true,
+      },
+      "separator",
       {
         "icon": <svg
           aria-hidden="true"
@@ -9179,6 +9431,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
     "left": 80,
     "top": 90,
   },
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -9221,6 +9474,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
packages/excalidraw/tests/__snapshots__/export.test.tsx.snap


+ 116 - 0
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap

@@ -11,6 +11,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -53,6 +54,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -613,6 +615,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -655,6 +658,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -1119,6 +1123,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -1161,6 +1166,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -1487,6 +1493,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -1529,6 +1536,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -1856,6 +1864,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -1898,6 +1907,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -2123,6 +2133,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -2165,6 +2176,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -2563,6 +2575,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -2605,6 +2618,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -2862,6 +2876,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -2904,6 +2919,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -3146,6 +3162,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -3188,6 +3205,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -3440,6 +3458,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -3482,6 +3501,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -3726,6 +3746,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -3768,6 +3789,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -3961,6 +3983,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -4003,6 +4026,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -4220,6 +4244,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -4262,6 +4287,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -4493,6 +4519,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -4535,6 +4562,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -4724,6 +4752,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -4766,6 +4795,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -4955,6 +4985,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -4997,6 +5028,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -5184,6 +5216,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -5226,6 +5259,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -5413,6 +5447,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -5455,6 +5490,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -5672,6 +5708,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -5714,6 +5751,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -6003,6 +6041,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -6045,6 +6084,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -6428,6 +6468,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -6470,6 +6511,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -6806,6 +6848,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -6848,6 +6891,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -7125,6 +7169,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -7167,6 +7212,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -7423,6 +7469,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -7465,6 +7512,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -7652,6 +7700,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -7694,6 +7743,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -8007,6 +8057,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -8049,6 +8100,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -8362,6 +8414,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -8404,6 +8457,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -8766,6 +8820,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -8808,6 +8863,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -9053,6 +9109,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -9095,6 +9152,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -9318,6 +9376,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -9360,6 +9419,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -9582,6 +9642,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -9624,6 +9685,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -9813,6 +9875,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -9855,6 +9918,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -10114,6 +10178,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -10156,6 +10221,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -10454,6 +10520,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -10496,6 +10563,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -10689,6 +10757,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -10731,6 +10800,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -11142,6 +11212,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -11184,6 +11255,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -11396,6 +11468,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -11438,6 +11511,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -11635,6 +11709,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -11677,6 +11752,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -11876,6 +11952,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -11918,6 +11995,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -12277,6 +12355,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -12319,6 +12398,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -12524,6 +12604,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -12566,6 +12647,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -12765,6 +12847,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -12807,6 +12890,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -13006,6 +13090,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -13048,6 +13133,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -13253,6 +13339,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -13295,6 +13382,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -13585,6 +13673,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -13627,6 +13716,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -13757,6 +13847,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -13799,6 +13890,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -14045,6 +14137,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -14087,6 +14180,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -14312,6 +14406,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -14354,6 +14449,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -14587,6 +14683,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -14629,6 +14726,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -14748,6 +14846,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -14790,6 +14889,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -15444,6 +15544,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -15486,6 +15587,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -16064,6 +16166,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -16106,6 +16209,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -16684,6 +16788,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -16726,6 +16831,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -17396,6 +17502,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -17438,6 +17545,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -18146,6 +18254,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -18188,6 +18297,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -18620,6 +18730,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -18662,6 +18773,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -19142,6 +19254,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -19184,6 +19297,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -19598,6 +19712,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -19640,6 +19755,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
   "gridStep": 5,
   "height": 0,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,

+ 104 - 0
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -11,6 +11,7 @@ exports[`given element A and group of elements B and given both are selected whe
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -53,6 +54,7 @@ exports[`given element A and group of elements B and given both are selected whe
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -423,6 +425,7 @@ exports[`given element A and group of elements B and given both are selected whe
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -465,6 +468,7 @@ exports[`given element A and group of elements B and given both are selected whe
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -826,6 +830,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -868,6 +873,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": false,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -1368,6 +1374,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -1410,6 +1417,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -1569,6 +1577,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -1611,6 +1620,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -1941,6 +1951,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -1983,6 +1994,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -2178,6 +2190,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -2220,6 +2233,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -2355,6 +2369,7 @@ exports[`regression tests > can drag element that covers another element, while
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -2397,6 +2412,7 @@ exports[`regression tests > can drag element that covers another element, while
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -2672,6 +2688,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -2714,6 +2731,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -2915,6 +2933,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -2957,6 +2976,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -3155,6 +3175,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -3197,6 +3218,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -3382,6 +3404,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -3424,6 +3447,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -3635,6 +3659,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -3677,6 +3702,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -3943,6 +3969,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -3985,6 +4012,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -4354,6 +4382,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -4396,6 +4425,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -4634,6 +4664,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -4676,6 +4707,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -4884,6 +4916,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -4926,6 +4959,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -5091,6 +5125,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -5133,6 +5168,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -5287,6 +5323,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -5329,6 +5366,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -5666,6 +5704,7 @@ exports[`regression tests > drags selected elements from point inside common bou
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -5708,6 +5747,7 @@ exports[`regression tests > drags selected elements from point inside common bou
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -5953,6 +5993,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -5995,6 +6036,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -6758,6 +6800,7 @@ exports[`regression tests > given a group of selected elements with an element t
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -6800,6 +6843,7 @@ exports[`regression tests > given a group of selected elements with an element t
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -7085,6 +7129,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -7127,6 +7172,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -7358,6 +7404,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -7400,6 +7447,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -7589,6 +7637,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -7631,6 +7680,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -7823,6 +7873,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -7865,6 +7916,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -8000,6 +8052,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -8042,6 +8095,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -8177,6 +8231,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -8219,6 +8274,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -8354,6 +8410,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -8396,6 +8453,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -8574,6 +8632,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -8616,6 +8675,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -8793,6 +8853,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -8835,6 +8896,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -8984,6 +9046,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -9026,6 +9089,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -9204,6 +9268,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -9246,6 +9311,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -9381,6 +9447,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -9423,6 +9490,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -9600,6 +9668,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -9642,6 +9711,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -9777,6 +9847,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -9819,6 +9890,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -9968,6 +10040,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -10010,6 +10083,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -10145,6 +10219,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -10187,6 +10262,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -10656,6 +10732,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -10698,6 +10775,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -10930,6 +11008,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -10972,6 +11051,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -11053,6 +11133,7 @@ exports[`regression tests > shift click on selected element should deselect it o
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -11095,6 +11176,7 @@ exports[`regression tests > shift click on selected element should deselect it o
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -11249,6 +11331,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -11291,6 +11374,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -11557,6 +11641,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -11599,6 +11684,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -11966,6 +12052,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -12008,6 +12095,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -12576,6 +12664,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -12618,6 +12707,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -12702,6 +12792,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -12744,6 +12835,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -13283,6 +13375,7 @@ exports[`regression tests > switches from group of selected elements to another
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -13325,6 +13418,7 @@ exports[`regression tests > switches from group of selected elements to another
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -13618,6 +13712,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -13660,6 +13755,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -13880,6 +13976,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -13922,6 +14019,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -14003,6 +14101,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -14045,6 +14144,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -14379,6 +14479,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -14421,6 +14522,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -14502,6 +14604,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -14544,6 +14647,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
   "gridStep": 5,
   "height": 768,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,

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

@@ -0,0 +1,342 @@
+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 { act, 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];
+
+    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.croppingElementId).toBe(null);
+    mouse.doubleClickOn(image);
+    expect(h.state.croppingElementId).not.toBe(null);
+    expect(h.state.croppingElementId).toBe(image.id);
+  });
+
+  it("enter the editor by pressing enter", () => {
+    const image = h.elements[0];
+    expect(h.state.croppingElementId).toBe(null);
+    Keyboard.keyDown(KEYS.ENTER);
+    expect(h.state.croppingElementId).not.toBe(null);
+    expect(h.state.croppingElementId).toBe(image.id);
+  });
+
+  it("leave the editor by clicking outside", () => {
+    const image = h.elements[0];
+    Keyboard.keyDown(KEYS.ENTER);
+    expect(h.state.croppingElementId).not.toBe(null);
+
+    mouse.click(image.x - 20, image.y - 20);
+    expect(h.state.croppingElementId).toBe(null);
+  });
+
+  it("leave the editor by pressing escape", () => {
+    const image = h.elements[0];
+    mouse.doubleClickOn(image);
+    expect(h.state.croppingElementId).not.toBe(null);
+
+    Keyboard.keyDown(KEYS.ESCAPE);
+    expect(h.state.croppingElementId).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);
+  });
+
+  it("Preserve aspect ratio", async () => {
+    let 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 / 3, 0]);
+
+    let resizedWidth = image.width;
+    let resizedHeight = image.height;
+
+    // max height, cropping should not change anything
+    UI.crop(
+      image,
+      "w",
+      naturalWidth,
+      naturalHeight,
+      [-initialWidth / 3, 0],
+      true,
+    );
+    expect(image.width).toBe(resizedWidth);
+    expect(image.height).toBe(resizedHeight);
+
+    // re-crop to initial state
+    UI.crop(image, "w", naturalWidth, naturalHeight, [-initialWidth / 3, 0]);
+    // change crop height and width
+    UI.crop(image, "s", naturalWidth, naturalHeight, [0, -initialHeight / 2]);
+    UI.crop(image, "e", naturalWidth, naturalHeight, [-initialWidth / 3, 0]);
+
+    resizedWidth = image.width;
+    resizedHeight = image.height;
+
+    // test corner handle aspect ratio preserving
+    UI.crop(image, "se", naturalWidth, naturalHeight, [initialWidth, 0], true);
+    expect(image.width / image.height).toBe(resizedWidth / resizedHeight);
+    expect(image.width).toBeLessThanOrEqual(initialWidth);
+    expect(image.height).toBeLessThanOrEqual(initialHeight);
+
+    // reset
+    image = API.createElement({ type: "image", width: 200, height: 100 });
+    API.setElements([image]);
+    API.setAppState({
+      selectedElementIds: {
+        [image.id]: true,
+      },
+    });
+
+    // 50 x 50 square
+    UI.crop(image, "nw", naturalWidth, naturalHeight, [150, 50]);
+    UI.crop(image, "n", naturalWidth, naturalHeight, [0, -100], true);
+    expect(image.width).toEqual(image.height);
+    // image is at the corner, not space to its right to expand, should not be able to resize
+    expect(image.height).toBeCloseTo(50);
+
+    UI.crop(image, "nw", naturalWidth, naturalHeight, [-150, -100], true);
+    expect(image.width).toEqual(image.height);
+    // max height should be reached
+    expect(image.height).toEqual(initialHeight);
+    expect(image.width).toBe(initialHeight);
+  });
+});
+
+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, {});
+    act(() => {
+      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.croppingElementId).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.croppingElementId).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);
+  });
+});

+ 35 - 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,8 @@ 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";
+import type { ToolType } from "../../types";
 
 // so that window.h is available when App.tsx is not imported as well.
 createTestHook();
@@ -561,6 +563,38 @@ export class UI {
     return transform(element, handle, mouseMove, keyboardModifiers);
   }
 
+  static crop(
+    element: ExcalidrawImageElement,
+    handle: TransformHandleDirection,
+    naturalWidth: number,
+    naturalHeight: number,
+    mouseMove: [deltaX: number, deltaY: number],
+    keepAspectRatio = false,
+  ) {
+    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],
+      keepAspectRatio ? element.width / element.height : undefined,
+    );
+
+    API.updateElement(element, mutations);
+  }
+
   static rotate(
     element: ExcalidrawElement | ExcalidrawElement[],
     mouseMove: [deltaX: number, deltaY: number],

+ 11 - 0
packages/excalidraw/types.ts

@@ -176,6 +176,8 @@ export type StaticCanvasAppState = Readonly<
     gridStep: AppState["gridStep"];
     frameRendering: AppState["frameRendering"];
     currentHoveredFontFamily: AppState["currentHoveredFontFamily"];
+    // Cropping
+    croppingElementId: AppState["croppingElementId"];
   }
 >;
 
@@ -198,6 +200,9 @@ export type InteractiveCanvasAppState = Readonly<
     snapLines: AppState["snapLines"];
     zenModeEnabled: AppState["zenModeEnabled"];
     editingTextElement: AppState["editingTextElement"];
+    // Cropping
+    isCropping: AppState["isCropping"];
+    croppingElementId: AppState["croppingElementId"];
     // Search matches
     searchMatches: AppState["searchMatches"];
   }
@@ -219,6 +224,7 @@ export type ObservedElementsAppState = {
   editingLinearElementId: LinearElementEditor["elementId"] | null;
   // Right now it's coupled to `editingLinearElement`, ideally it should not be really needed as we already have selectedElementIds & editingLinearElementId
   selectedLinearElementId: LinearElementEditor["elementId"] | null;
+  croppingElementId: AppState["croppingElementId"];
 };
 
 export interface AppState {
@@ -386,6 +392,11 @@ export interface AppState {
   userToFollow: UserToFollow | null;
   /** the socket ids of the users following the current user */
   followedBy: Set<SocketId>;
+
+  /** image cropping */
+  isCropping: boolean;
+  croppingElementId: ExcalidrawElement["id"] | null;
+
   searchMatches: readonly SearchMatch[];
 }
 

+ 3 - 0
packages/math/utils.ts

@@ -28,3 +28,6 @@ export const average = (a: number, b: number) => (a + b) / 2;
 export const isFiniteNumber = (value: any): value is number => {
   return typeof value === "number" && Number.isFinite(value);
 };
+
+export const isCloseTo = (a: number, b: number, precision = PRECISION) =>
+  Math.abs(a - b) < precision;

+ 7 - 0
packages/math/vector.ts

@@ -139,3 +139,10 @@ export const vectorNormalize = (v: Vector): Vector => {
 
   return vector(v[0] / m, v[1] / m);
 };
+
+/**
+ * Project the first vector onto the second vector
+ */
+export const vectorProjection = (a: Vector, b: Vector) => {
+  return vectorScale(b, vectorDot(a, b) / vectorDot(b, b));
+};

+ 2 - 0
packages/utils/__snapshots__/export.test.ts.snap

@@ -11,6 +11,7 @@ exports[`exportToSvg > with default arguments 1`] = `
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElementId": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -53,6 +54,7 @@ exports[`exportToSvg > with default arguments 1`] = `
   "gridSize": 20,
   "gridStep": 5,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,

+ 1 - 1
setupTests.ts

@@ -105,7 +105,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 "${

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio