Browse Source

feat: resize elements from the sides (#7855)

Co-authored-by: dwelle <[email protected]>
Ryan Di 1 year ago
parent
commit
88812e0386

+ 2 - 2
package.json

@@ -26,8 +26,8 @@
     "@types/chai": "4.3.0",
     "@types/chai": "4.3.0",
     "@types/jest": "27.4.0",
     "@types/jest": "27.4.0",
     "@types/lodash.throttle": "4.1.7",
     "@types/lodash.throttle": "4.1.7",
-    "@types/react": "18.0.15",
-    "@types/react-dom": "18.0.6",
+    "@types/react": "18.2.0",
+    "@types/react-dom": "18.2.0",
     "@types/socket.io-client": "3.0.0",
     "@types/socket.io-client": "3.0.0",
     "@vitejs/plugin-react": "3.1.0",
     "@vitejs/plugin-react": "3.1.0",
     "@vitest/coverage-v8": "0.33.0",
     "@vitest/coverage-v8": "0.33.0",

+ 1 - 0
packages/excalidraw/actions/actionFlip.ts

@@ -119,6 +119,7 @@ const flipElements = (
     elementsMap,
     elementsMap,
     "nw",
     "nw",
     true,
     true,
+    true,
     flipDirection === "horizontal" ? maxX : minX,
     flipDirection === "horizontal" ? maxX : minX,
     flipDirection === "horizontal" ? minY : maxY,
     flipDirection === "horizontal" ? minY : maxY,
   );
   );

+ 58 - 29
packages/excalidraw/components/App.tsx

@@ -90,6 +90,7 @@ import {
   EDITOR_LS_KEYS,
   EDITOR_LS_KEYS,
   isIOS,
   isIOS,
   supportsResizeObserver,
   supportsResizeObserver,
+  DEFAULT_COLLISION_THRESHOLD,
 } from "../constants";
 } from "../constants";
 import { ExportedElements, exportCanvas, loadFromBlob } from "../data";
 import { ExportedElements, exportCanvas, loadFromBlob } from "../data";
 import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
 import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
@@ -1703,6 +1704,7 @@ class App extends React.Component<AppProps, AppState> {
                           }
                           }
                           scale={window.devicePixelRatio}
                           scale={window.devicePixelRatio}
                           appState={this.state}
                           appState={this.state}
+                          device={this.device}
                           renderInteractiveSceneCallback={
                           renderInteractiveSceneCallback={
                             this.renderInteractiveSceneCallback
                             this.renderInteractiveSceneCallback
                           }
                           }
@@ -4528,7 +4530,7 @@ class App extends React.Component<AppProps, AppState> {
         shape: this.getElementShape(elementWithHighestZIndex),
         shape: this.getElementShape(elementWithHighestZIndex),
         // when overlapping, we would like to be more precise
         // when overlapping, we would like to be more precise
         // this also avoids the need to update past tests
         // this also avoids the need to update past tests
-        threshold: this.getHitThreshold() / 2,
+        threshold: this.getElementHitThreshold() / 2,
         frameNameBound: isFrameLikeElement(elementWithHighestZIndex)
         frameNameBound: isFrameLikeElement(elementWithHighestZIndex)
           ? this.frameNameBoundsCache.get(elementWithHighestZIndex)
           ? this.frameNameBoundsCache.get(elementWithHighestZIndex)
           : null,
           : null,
@@ -4591,8 +4593,8 @@ class App extends React.Component<AppProps, AppState> {
     return elements;
     return elements;
   }
   }
 
 
-  private getHitThreshold() {
-    return 10 / this.state.zoom.value;
+  private getElementHitThreshold() {
+    return DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value;
   }
   }
 
 
   private hitElement(
   private hitElement(
@@ -4610,7 +4612,7 @@ class App extends React.Component<AppProps, AppState> {
       const selectionShape = getSelectionBoxShape(
       const selectionShape = getSelectionBoxShape(
         element,
         element,
         this.scene.getNonDeletedElementsMap(),
         this.scene.getNonDeletedElementsMap(),
-        this.getHitThreshold(),
+        this.getElementHitThreshold(),
       );
       );
 
 
       return isPointInShape([x, y], selectionShape);
       return isPointInShape([x, y], selectionShape);
@@ -4631,7 +4633,7 @@ class App extends React.Component<AppProps, AppState> {
       y,
       y,
       element,
       element,
       shape: this.getElementShape(element),
       shape: this.getElementShape(element),
-      threshold: this.getHitThreshold(),
+      threshold: this.getElementHitThreshold(),
       frameNameBound: isFrameLikeElement(element)
       frameNameBound: isFrameLikeElement(element)
         ? this.frameNameBoundsCache.get(element)
         ? this.frameNameBoundsCache.get(element)
         : null,
         : null,
@@ -4663,7 +4665,7 @@ class App extends React.Component<AppProps, AppState> {
           y,
           y,
           element: elements[index],
           element: elements[index],
           shape: this.getElementShape(elements[index]),
           shape: this.getElementShape(elements[index]),
-          threshold: this.getHitThreshold(),
+          threshold: this.getElementHitThreshold(),
         })
         })
       ) {
       ) {
         hitElement = elements[index];
         hitElement = elements[index];
@@ -4916,7 +4918,7 @@ class App extends React.Component<AppProps, AppState> {
             y: sceneY,
             y: sceneY,
             element: container,
             element: container,
             shape: this.getElementShape(container),
             shape: this.getElementShape(container),
-            threshold: this.getHitThreshold(),
+            threshold: this.getElementHitThreshold(),
           })
           })
         ) {
         ) {
           const midPoint = getContainerCenter(
           const midPoint = getContainerCenter(
@@ -5331,24 +5333,41 @@ class App extends React.Component<AppProps, AppState> {
       !isOverScrollBar &&
       !isOverScrollBar &&
       !this.state.editingLinearElement
       !this.state.editingLinearElement
     ) {
     ) {
-      const elementWithTransformHandleType = getElementWithTransformHandleType(
-        elements,
-        this.state,
-        scenePointerX,
-        scenePointerY,
-        this.state.zoom,
-        event.pointerType,
-        this.scene.getNonDeletedElementsMap(),
-      );
+      // for linear elements, we'd like to prioritize point dragging over edge resizing
+      // therefore, we update and check hovered point index first
+      if (this.state.selectedLinearElement) {
+        this.handleHoverSelectedLinearElement(
+          this.state.selectedLinearElement,
+          scenePointerX,
+          scenePointerY,
+        );
+      }
+
       if (
       if (
-        elementWithTransformHandleType &&
-        elementWithTransformHandleType.transformHandleType
+        !this.state.selectedLinearElement ||
+        this.state.selectedLinearElement.hoverPointIndex === -1
       ) {
       ) {
-        setCursor(
-          this.interactiveCanvas,
-          getCursorForResizingElement(elementWithTransformHandleType),
-        );
-        return;
+        const elementWithTransformHandleType =
+          getElementWithTransformHandleType(
+            elements,
+            this.state,
+            scenePointerX,
+            scenePointerY,
+            this.state.zoom,
+            event.pointerType,
+            this.scene.getNonDeletedElementsMap(),
+            this.device,
+          );
+        if (
+          elementWithTransformHandleType &&
+          elementWithTransformHandleType.transformHandleType
+        ) {
+          setCursor(
+            this.interactiveCanvas,
+            getCursorForResizingElement(elementWithTransformHandleType),
+          );
+          return;
+        }
       }
       }
     } else if (selectedElements.length > 1 && !isOverScrollBar) {
     } else if (selectedElements.length > 1 && !isOverScrollBar) {
       const transformHandleType = getTransformHandleTypeFromCoords(
       const transformHandleType = getTransformHandleTypeFromCoords(
@@ -5357,6 +5376,7 @@ class App extends React.Component<AppProps, AppState> {
         scenePointerY,
         scenePointerY,
         this.state.zoom,
         this.state.zoom,
         event.pointerType,
         event.pointerType,
+        this.device,
       );
       );
       if (transformHandleType) {
       if (transformHandleType) {
         setCursor(
         setCursor(
@@ -5509,7 +5529,7 @@ class App extends React.Component<AppProps, AppState> {
       scenePointer.x,
       scenePointer.x,
       scenePointer.y,
       scenePointer.y,
     );
     );
-    const threshold = this.getHitThreshold();
+    const threshold = this.getElementHitThreshold();
     const point = { ...pointerDownState.lastCoords };
     const point = { ...pointerDownState.lastCoords };
     let samplingInterval = 0;
     let samplingInterval = 0;
     while (samplingInterval <= distance) {
     while (samplingInterval <= distance) {
@@ -5606,7 +5626,7 @@ class App extends React.Component<AppProps, AppState> {
 
 
         if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) {
         if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) {
           setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
           setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
-        } else {
+        } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
           setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
           setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
         }
         }
       } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
       } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
@@ -6306,7 +6326,14 @@ class App extends React.Component<AppProps, AppState> {
       const elementsMap = this.scene.getNonDeletedElementsMap();
       const elementsMap = this.scene.getNonDeletedElementsMap();
       const selectedElements = this.scene.getSelectedElements(this.state);
       const selectedElements = this.scene.getSelectedElements(this.state);
 
 
-      if (selectedElements.length === 1 && !this.state.editingLinearElement) {
+      if (
+        selectedElements.length === 1 &&
+        !this.state.editingLinearElement &&
+        !(
+          this.state.selectedLinearElement &&
+          this.state.selectedLinearElement.hoverPointIndex !== -1
+        )
+      ) {
         const elementWithTransformHandleType =
         const elementWithTransformHandleType =
           getElementWithTransformHandleType(
           getElementWithTransformHandleType(
             elements,
             elements,
@@ -6316,6 +6343,7 @@ class App extends React.Component<AppProps, AppState> {
             this.state.zoom,
             this.state.zoom,
             event.pointerType,
             event.pointerType,
             this.scene.getNonDeletedElementsMap(),
             this.scene.getNonDeletedElementsMap(),
+            this.device,
           );
           );
         if (elementWithTransformHandleType != null) {
         if (elementWithTransformHandleType != null) {
           this.setState({
           this.setState({
@@ -6331,6 +6359,7 @@ class App extends React.Component<AppProps, AppState> {
           pointerDownState.origin.y,
           pointerDownState.origin.y,
           this.state.zoom,
           this.state.zoom,
           event.pointerType,
           event.pointerType,
+          this.device,
         );
         );
       }
       }
       if (pointerDownState.resize.handleType) {
       if (pointerDownState.resize.handleType) {
@@ -6587,7 +6616,7 @@ class App extends React.Component<AppProps, AppState> {
     }
     }
 
 
     // How many pixels off the shape boundary we still consider a hit
     // How many pixels off the shape boundary we still consider a hit
-    const threshold = this.getHitThreshold();
+    const threshold = this.getElementHitThreshold();
     const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
     const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
     return (
     return (
       point.x > x1 - threshold &&
       point.x > x1 - threshold &&
@@ -8412,7 +8441,7 @@ class App extends React.Component<AppProps, AppState> {
               y: pointerDownState.origin.y,
               y: pointerDownState.origin.y,
               element: hitElement,
               element: hitElement,
               shape: this.getElementShape(hitElement),
               shape: this.getElementShape(hitElement),
-              threshold: this.getHitThreshold(),
+              threshold: this.getElementHitThreshold(),
               frameNameBound: isFrameLikeElement(hitElement)
               frameNameBound: isFrameLikeElement(hitElement)
                 ? this.frameNameBoundsCache.get(hitElement)
                 ? this.frameNameBoundsCache.get(hitElement)
                 : null,
                 : null,
@@ -9525,7 +9554,7 @@ class App extends React.Component<AppProps, AppState> {
         this.scene.getElementsMapIncludingDeleted(),
         this.scene.getElementsMapIncludingDeleted(),
         shouldRotateWithDiscreteAngle(event),
         shouldRotateWithDiscreteAngle(event),
         shouldResizeFromCenter(event),
         shouldResizeFromCenter(event),
-        selectedElements.length === 1 && isImageElement(selectedElements[0])
+        selectedElements.some((element) => isImageElement(element))
           ? !shouldMaintainAspectRatio(event)
           ? !shouldMaintainAspectRatio(event)
           : shouldMaintainAspectRatio(event),
           : shouldMaintainAspectRatio(event),
         resizeX,
         resizeX,

+ 3 - 1
packages/excalidraw/components/canvases/InteractiveCanvas.tsx

@@ -3,7 +3,7 @@ import { isShallowEqual, sceneCoordsToViewportCoords } from "../../utils";
 import { CURSOR_TYPE } from "../../constants";
 import { CURSOR_TYPE } from "../../constants";
 import { t } from "../../i18n";
 import { t } from "../../i18n";
 import type { DOMAttributes } from "react";
 import type { DOMAttributes } from "react";
-import type { AppState, InteractiveCanvasAppState } from "../../types";
+import type { AppState, Device, InteractiveCanvasAppState } from "../../types";
 import type {
 import type {
   InteractiveCanvasRenderConfig,
   InteractiveCanvasRenderConfig,
   RenderableElementsMap,
   RenderableElementsMap,
@@ -23,6 +23,7 @@ type InteractiveCanvasProps = {
   selectionNonce: number | undefined;
   selectionNonce: number | undefined;
   scale: number;
   scale: number;
   appState: InteractiveCanvasAppState;
   appState: InteractiveCanvasAppState;
+  device: Device;
   renderInteractiveSceneCallback: (
   renderInteractiveSceneCallback: (
     data: RenderInteractiveSceneCallback,
     data: RenderInteractiveSceneCallback,
   ) => void;
   ) => void;
@@ -132,6 +133,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
           selectionColor,
           selectionColor,
           renderScrollbars: false,
           renderScrollbars: false,
         },
         },
+        device: props.device,
         callback: props.renderInteractiveSceneCallback,
         callback: props.renderInteractiveSceneCallback,
       },
       },
       isRenderThrottlingEnabled(),
       isRenderThrottlingEnabled(),

+ 7 - 0
packages/excalidraw/constants.ts

@@ -148,6 +148,13 @@ export const DEFAULT_VERTICAL_ALIGN = "top";
 export const DEFAULT_VERSION = "{version}";
 export const DEFAULT_VERSION = "{version}";
 export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
 export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
 
 
+export const SIDE_RESIZING_THRESHOLD = 2 * DEFAULT_TRANSFORM_HANDLE_SPACING;
+// a small epsilon to make side resizing always take precedence
+// (avoids an increase in renders and changes to tests)
+const EPSILON = 0.00001;
+export const DEFAULT_COLLISION_THRESHOLD =
+  2 * SIDE_RESIZING_THRESHOLD - EPSILON;
+
 export const COLOR_WHITE = "#ffffff";
 export const COLOR_WHITE = "#ffffff";
 export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
 export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
 // keep this in sync with CSS
 // keep this in sync with CSS

+ 139 - 142
packages/excalidraw/element/resizeElements.ts

@@ -1,12 +1,7 @@
 import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
 import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
 import { rescalePoints } from "../points";
 import { rescalePoints } from "../points";
 
 
-import {
-  rotate,
-  adjustXYWithRotation,
-  centerPoint,
-  rotatePoint,
-} from "../math";
+import { rotate, centerPoint, rotatePoint } from "../math";
 import {
 import {
   ExcalidrawLinearElement,
   ExcalidrawLinearElement,
   ExcalidrawTextElement,
   ExcalidrawTextElement,
@@ -23,7 +18,6 @@ import {
   getCommonBounds,
   getCommonBounds,
   getResizedElementAbsoluteCoords,
   getResizedElementAbsoluteCoords,
   getCommonBoundingBox,
   getCommonBoundingBox,
-  getElementPointsCoords,
 } from "./bounds";
 } from "./bounds";
 import {
 import {
   isArrowElement,
   isArrowElement,
@@ -38,7 +32,6 @@ import { mutateElement } from "./mutateElement";
 import { getFontString } from "../utils";
 import { getFontString } from "../utils";
 import { updateBoundElements } from "./binding";
 import { updateBoundElements } from "./binding";
 import {
 import {
-  TransformHandleType,
   MaybeTransformHandleType,
   MaybeTransformHandleType,
   TransformHandleDirection,
   TransformHandleDirection,
 } from "./transformHandles";
 } from "./transformHandles";
@@ -54,6 +47,7 @@ import {
   getApproxMinLineHeight,
   getApproxMinLineHeight,
 } from "./textElement";
 } from "./textElement";
 import { LinearElementEditor } from "./linearElementEditor";
 import { LinearElementEditor } from "./linearElementEditor";
+import { isInGroup } from "../groups";
 
 
 export const normalizeAngle = (angle: number): number => {
 export const normalizeAngle = (angle: number): number => {
   if (angle < 0) {
   if (angle < 0) {
@@ -133,18 +127,14 @@ export const transformElements = (
         centerY,
         centerY,
       );
       );
       return true;
       return true;
-    } else if (
-      transformHandleType === "nw" ||
-      transformHandleType === "ne" ||
-      transformHandleType === "sw" ||
-      transformHandleType === "se"
-    ) {
+    } else if (transformHandleType) {
       resizeMultipleElements(
       resizeMultipleElements(
         originalElements,
         originalElements,
         selectedElements,
         selectedElements,
         elementsMap,
         elementsMap,
         transformHandleType,
         transformHandleType,
         shouldResizeFromCenter,
         shouldResizeFromCenter,
+        shouldMaintainAspectRatio,
         pointerX,
         pointerX,
         pointerY,
         pointerY,
       );
       );
@@ -232,26 +222,6 @@ const measureFontSizeFromWidth = (
   };
   };
 };
 };
 
 
-const getSidesForTransformHandle = (
-  transformHandleType: TransformHandleType,
-  shouldResizeFromCenter: boolean,
-) => {
-  return {
-    n:
-      /^(n|ne|nw)$/.test(transformHandleType) ||
-      (shouldResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
-    s:
-      /^(s|se|sw)$/.test(transformHandleType) ||
-      (shouldResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
-    w:
-      /^(w|nw|sw)$/.test(transformHandleType) ||
-      (shouldResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
-    e:
-      /^(e|ne|se)$/.test(transformHandleType) ||
-      (shouldResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
-  };
-};
-
 const resizeSingleTextElement = (
 const resizeSingleTextElement = (
   element: NonDeleted<ExcalidrawTextElement>,
   element: NonDeleted<ExcalidrawTextElement>,
   elementsMap: ElementsMap,
   elementsMap: ElementsMap,
@@ -260,9 +230,10 @@ const resizeSingleTextElement = (
   pointerX: number,
   pointerX: number,
   pointerY: number,
   pointerY: number,
 ) => {
 ) => {
-  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
-  const cx = (x1 + x2) / 2;
-  const cy = (y1 + y2) / 2;
+  const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
+    element,
+    elementsMap,
+  );
   // rotation pointer with reverse angle
   // rotation pointer with reverse angle
   const [rotatedX, rotatedY] = rotate(
   const [rotatedX, rotatedY] = rotate(
     pointerX,
     pointerX,
@@ -271,33 +242,24 @@ const resizeSingleTextElement = (
     cy,
     cy,
     -element.angle,
     -element.angle,
   );
   );
-  let scale: number;
-  switch (transformHandleType) {
-    case "se":
-      scale = Math.max(
-        (rotatedX - x1) / (x2 - x1),
-        (rotatedY - y1) / (y2 - y1),
-      );
-      break;
-    case "nw":
-      scale = Math.max(
-        (x2 - rotatedX) / (x2 - x1),
-        (y2 - rotatedY) / (y2 - y1),
-      );
-      break;
-    case "ne":
-      scale = Math.max(
-        (rotatedX - x1) / (x2 - x1),
-        (y2 - rotatedY) / (y2 - y1),
-      );
-      break;
-    case "sw":
-      scale = Math.max(
-        (x2 - rotatedX) / (x2 - x1),
-        (rotatedY - y1) / (y2 - y1),
-      );
-      break;
+  let scaleX = 0;
+  let scaleY = 0;
+
+  if (transformHandleType.includes("e")) {
+    scaleX = (rotatedX - x1) / (x2 - x1);
   }
   }
+  if (transformHandleType.includes("w")) {
+    scaleX = (x2 - rotatedX) / (x2 - x1);
+  }
+  if (transformHandleType.includes("n")) {
+    scaleY = (y2 - rotatedY) / (y2 - y1);
+  }
+  if (transformHandleType.includes("s")) {
+    scaleY = (rotatedY - y1) / (y2 - y1);
+  }
+
+  const scale = Math.max(scaleX, scaleY);
+
   if (scale > 0) {
   if (scale > 0) {
     const nextWidth = element.width * scale;
     const nextWidth = element.width * scale;
     const nextHeight = element.height * scale;
     const nextHeight = element.height * scale;
@@ -305,32 +267,55 @@ const resizeSingleTextElement = (
     if (metrics === null) {
     if (metrics === null) {
       return;
       return;
     }
     }
-    const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
-      element,
-      nextWidth,
-      nextHeight,
-      false,
-    );
-    const deltaX1 = (x1 - nextX1) / 2;
-    const deltaY1 = (y1 - nextY1) / 2;
-    const deltaX2 = (x2 - nextX2) / 2;
-    const deltaY2 = (y2 - nextY2) / 2;
-    const [nextElementX, nextElementY] = adjustXYWithRotation(
-      getSidesForTransformHandle(transformHandleType, shouldResizeFromCenter),
-      element.x,
-      element.y,
-      element.angle,
-      deltaX1,
-      deltaY1,
-      deltaX2,
-      deltaY2,
-    );
+
+    const startTopLeft = [x1, y1];
+    const startBottomRight = [x2, y2];
+    const startCenter = [cx, cy];
+
+    let newTopLeft = [x1, y1] as [number, number];
+    if (["n", "w", "nw"].includes(transformHandleType)) {
+      newTopLeft = [
+        startBottomRight[0] - Math.abs(nextWidth),
+        startBottomRight[1] - Math.abs(nextHeight),
+      ];
+    }
+    if (transformHandleType === "ne") {
+      const bottomLeft = [startTopLeft[0], startBottomRight[1]];
+      newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(nextHeight)];
+    }
+    if (transformHandleType === "sw") {
+      const topRight = [startBottomRight[0], startTopLeft[1]];
+      newTopLeft = [topRight[0] - Math.abs(nextWidth), topRight[1]];
+    }
+
+    if (["s", "n"].includes(transformHandleType)) {
+      newTopLeft[0] = startCenter[0] - nextWidth / 2;
+    }
+    if (["e", "w"].includes(transformHandleType)) {
+      newTopLeft[1] = startCenter[1] - nextHeight / 2;
+    }
+
+    if (shouldResizeFromCenter) {
+      newTopLeft[0] = startCenter[0] - Math.abs(nextWidth) / 2;
+      newTopLeft[1] = startCenter[1] - Math.abs(nextHeight) / 2;
+    }
+
+    const angle = element.angle;
+    const rotatedTopLeft = rotatePoint(newTopLeft, [cx, cy], angle);
+    const newCenter: Point = [
+      newTopLeft[0] + Math.abs(nextWidth) / 2,
+      newTopLeft[1] + Math.abs(nextHeight) / 2,
+    ];
+    const rotatedNewCenter = rotatePoint(newCenter, [cx, cy], angle);
+    newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
+    const [nextX, nextY] = newTopLeft;
+
     mutateElement(element, {
     mutateElement(element, {
       fontSize: metrics.size,
       fontSize: metrics.size,
       width: nextWidth,
       width: nextWidth,
       height: nextHeight,
       height: nextHeight,
-      x: nextElementX,
-      y: nextElementY,
+      x: nextX,
+      y: nextY,
     });
     });
   }
   }
 };
 };
@@ -636,8 +621,9 @@ export const resizeMultipleElements = (
   originalElements: PointerDownState["originalElements"],
   originalElements: PointerDownState["originalElements"],
   selectedElements: readonly NonDeletedExcalidrawElement[],
   selectedElements: readonly NonDeletedExcalidrawElement[],
   elementsMap: ElementsMap,
   elementsMap: ElementsMap,
-  transformHandleType: "nw" | "ne" | "sw" | "se",
+  transformHandleType: TransformHandleDirection,
   shouldResizeFromCenter: boolean,
   shouldResizeFromCenter: boolean,
+  shouldMaintainAspectRatio: boolean,
   pointerX: number,
   pointerX: number,
   pointerY: number,
   pointerY: number,
 ) => {
 ) => {
@@ -691,43 +677,80 @@ export const resizeMultipleElements = (
   const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
   const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
     targetElements.map(({ orig }) => orig).concat(boundTextElements),
     targetElements.map(({ orig }) => orig).concat(boundTextElements),
   );
   );
-
-  // const originalHeight = maxY - minY;
-  // const originalWidth = maxX - minX;
+  const width = maxX - minX;
+  const height = maxY - minY;
 
 
   const direction = transformHandleType;
   const direction = transformHandleType;
 
 
-  const mapDirectionsToAnchors: Record<typeof direction, Point> = {
+  const anchorsMap: Record<TransformHandleDirection, Point> = {
     ne: [minX, maxY],
     ne: [minX, maxY],
     se: [minX, minY],
     se: [minX, minY],
     sw: [maxX, minY],
     sw: [maxX, minY],
     nw: [maxX, maxY],
     nw: [maxX, maxY],
+    e: [minX, minY + height / 2],
+    w: [maxX, minY + height / 2],
+    n: [minX + width / 2, maxY],
+    s: [minX + width / 2, minY],
   };
   };
 
 
   // anchor point must be on the opposite side of the dragged selection handle
   // anchor point must be on the opposite side of the dragged selection handle
   // or be the center of the selection if shouldResizeFromCenter
   // or be the center of the selection if shouldResizeFromCenter
   const [anchorX, anchorY]: Point = shouldResizeFromCenter
   const [anchorX, anchorY]: Point = shouldResizeFromCenter
     ? [midX, midY]
     ? [midX, midY]
-    : mapDirectionsToAnchors[direction];
+    : anchorsMap[direction];
+
+  const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
 
 
   const scale =
   const scale =
     Math.max(
     Math.max(
-      Math.abs(pointerX - anchorX) / (maxX - minX) || 0,
-      Math.abs(pointerY - anchorY) / (maxY - minY) || 0,
-    ) * (shouldResizeFromCenter ? 2 : 1);
+      Math.abs(pointerX - anchorX) / width || 0,
+      Math.abs(pointerY - anchorY) / height || 0,
+    ) * resizeFromCenterScale;
 
 
   if (scale === 0) {
   if (scale === 0) {
     return;
     return;
   }
   }
 
 
-  const mapDirectionsToPointerPositions: Record<
-    typeof direction,
+  let scaleX =
+    direction.includes("e") || direction.includes("w")
+      ? (Math.abs(pointerX - anchorX) / width) * resizeFromCenterScale
+      : 1;
+  let scaleY =
+    direction.includes("n") || direction.includes("s")
+      ? (Math.abs(pointerY - anchorY) / height) * resizeFromCenterScale
+      : 1;
+
+  const keepAspectRatio =
+    shouldMaintainAspectRatio ||
+    targetElements.some(
+      (item) =>
+        item.latest.angle !== 0 ||
+        isTextElement(item.latest) ||
+        isInGroup(item.latest),
+    );
+
+  if (keepAspectRatio) {
+    scaleX = scale;
+    scaleY = scale;
+  }
+
+  const flipConditionsMap: Record<
+    TransformHandleDirection,
+    // Condition for which we should flip or not flip the selected elements
+    // - when evaluated to `true`, we flip
+    // - therefore, setting it to always `false` means we do not flip (in that direction) at all
     [x: boolean, y: boolean]
     [x: boolean, y: boolean]
   > = {
   > = {
-    ne: [pointerX >= anchorX, pointerY <= anchorY],
-    se: [pointerX >= anchorX, pointerY >= anchorY],
-    sw: [pointerX <= anchorX, pointerY >= anchorY],
-    nw: [pointerX <= anchorX, pointerY <= anchorY],
+    ne: [pointerX < anchorX, pointerY > anchorY],
+    se: [pointerX < anchorX, pointerY < anchorY],
+    sw: [pointerX > anchorX, pointerY < anchorY],
+    nw: [pointerX > anchorX, pointerY > anchorY],
+    // e.g. when resizing from the "e" side, we do not need to consider changes in the `y` direction
+    //      and therefore, we do not need to flip in the `y` direction at all
+    e: [pointerX < anchorX, false],
+    w: [pointerX > anchorX, false],
+    n: [false, pointerY > anchorY],
+    s: [false, pointerY < anchorY],
   };
   };
 
 
   /**
   /**
@@ -738,9 +761,9 @@ export const resizeMultipleElements = (
    *    mirror points in the case of linear & freedraw elemenets
    *    mirror points in the case of linear & freedraw elemenets
    * 3. adjust element angle
    * 3. adjust element angle
    */
    */
-  const [flipFactorX, flipFactorY] = mapDirectionsToPointerPositions[
-    direction
-  ].map((condition) => (condition ? 1 : -1));
+  const [flipFactorX, flipFactorY] = flipConditionsMap[direction].map(
+    (condition) => (condition ? -1 : 1),
+  );
   const isFlippedByX = flipFactorX < 0;
   const isFlippedByX = flipFactorX < 0;
   const isFlippedByY = flipFactorY < 0;
   const isFlippedByY = flipFactorY < 0;
 
 
@@ -762,8 +785,8 @@ export const resizeMultipleElements = (
       continue;
       continue;
     }
     }
 
 
-    const width = orig.width * scale;
-    const height = orig.height * scale;
+    const width = orig.width * scaleX;
+    const height = orig.height * scaleY;
     const angle = normalizeAngle(orig.angle * flipFactorX * flipFactorY);
     const angle = normalizeAngle(orig.angle * flipFactorX * flipFactorY);
 
 
     const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig);
     const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig);
@@ -771,8 +794,8 @@ export const resizeMultipleElements = (
     const offsetY = orig.y - anchorY;
     const offsetY = orig.y - anchorY;
     const shiftX = isFlippedByX && !isLinearOrFreeDraw ? width : 0;
     const shiftX = isFlippedByX && !isLinearOrFreeDraw ? width : 0;
     const shiftY = isFlippedByY && !isLinearOrFreeDraw ? height : 0;
     const shiftY = isFlippedByY && !isLinearOrFreeDraw ? height : 0;
-    const x = anchorX + flipFactorX * (offsetX * scale + shiftX);
-    const y = anchorY + flipFactorY * (offsetY * scale + shiftY);
+    const x = anchorX + flipFactorX * (offsetX * scaleX + shiftX);
+    const y = anchorY + flipFactorY * (offsetY * scaleY + shiftY);
 
 
     const rescaledPoints = rescalePointsInElement(
     const rescaledPoints = rescalePointsInElement(
       orig,
       orig,
@@ -790,40 +813,10 @@ export const resizeMultipleElements = (
       ...rescaledPoints,
       ...rescaledPoints,
     };
     };
 
 
-    if (isImageElement(orig) && targetElements.length === 1) {
+    if (isImageElement(orig)) {
       update.scale = [orig.scale[0] * flipFactorX, orig.scale[1] * flipFactorY];
       update.scale = [orig.scale[0] * flipFactorX, orig.scale[1] * flipFactorY];
     }
     }
 
 
-    if (isLinearElement(orig) && (isFlippedByX || isFlippedByY)) {
-      const origBounds = getElementPointsCoords(orig, orig.points);
-      const newBounds = getElementPointsCoords(
-        { ...orig, x, y },
-        rescaledPoints.points!,
-      );
-      const origXY = [orig.x, orig.y];
-      const newXY = [x, y];
-
-      const linearShift = (axis: "x" | "y") => {
-        const i = axis === "x" ? 0 : 1;
-        return (
-          (newBounds[i + 2] -
-            newXY[i] -
-            (origXY[i] - origBounds[i]) * scale +
-            (origBounds[i + 2] - origXY[i]) * scale -
-            (newXY[i] - newBounds[i])) /
-          2
-        );
-      };
-
-      if (isFlippedByX) {
-        update.x -= linearShift("x");
-      }
-
-      if (isFlippedByY) {
-        update.y -= linearShift("y");
-      }
-    }
-
     if (isTextElement(orig)) {
     if (isTextElement(orig)) {
       const metrics = measureFontSizeFromWidth(orig, elementsMap, width);
       const metrics = measureFontSizeFromWidth(orig, elementsMap, width);
       if (!metrics) {
       if (!metrics) {
@@ -837,11 +830,15 @@ export const resizeMultipleElements = (
     ) as ExcalidrawTextElementWithContainer | undefined;
     ) as ExcalidrawTextElementWithContainer | undefined;
 
 
     if (boundTextElement) {
     if (boundTextElement) {
-      const newFontSize = boundTextElement.fontSize * scale;
-      if (newFontSize < MIN_FONT_SIZE) {
-        return;
+      if (keepAspectRatio) {
+        const newFontSize = boundTextElement.fontSize * scale;
+        if (newFontSize < MIN_FONT_SIZE) {
+          return;
+        }
+        update.boundTextFontSize = newFontSize;
+      } else {
+        update.boundTextFontSize = boundTextElement.fontSize;
       }
       }
-      update.boundTextFontSize = newFontSize;
     }
     }
 
 
     elementsAndUpdates.push({
     elementsAndUpdates.push({

+ 99 - 6
packages/excalidraw/element/resizeTest.ts

@@ -6,15 +6,24 @@ import {
 } from "./types";
 } from "./types";
 
 
 import {
 import {
-  OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
   getTransformHandlesFromCoords,
   getTransformHandlesFromCoords,
   getTransformHandles,
   getTransformHandles,
   TransformHandleType,
   TransformHandleType,
   TransformHandle,
   TransformHandle,
   MaybeTransformHandleType,
   MaybeTransformHandleType,
+  getOmitSidesForDevice,
+  canResizeFromSides,
 } from "./transformHandles";
 } from "./transformHandles";
-import { AppState, Zoom } from "../types";
-import { Bounds } from "./bounds";
+import { AppState, Device, Zoom } from "../types";
+import { Bounds, getElementAbsoluteCoords } from "./bounds";
+import { SIDE_RESIZING_THRESHOLD } from "../constants";
+import {
+  angleToDegrees,
+  pointOnLine,
+  pointRotate,
+} from "../../utils/geometry/geometry";
+import { Line, Point } from "../../utils/geometry/shape";
+import { isLinearElement } from "./typeChecks";
 
 
 const isInsideTransformHandle = (
 const isInsideTransformHandle = (
   transformHandle: TransformHandle,
   transformHandle: TransformHandle,
@@ -34,13 +43,20 @@ export const resizeTest = (
   y: number,
   y: number,
   zoom: Zoom,
   zoom: Zoom,
   pointerType: PointerType,
   pointerType: PointerType,
+  device: Device,
 ): MaybeTransformHandleType => {
 ): MaybeTransformHandleType => {
   if (!appState.selectedElementIds[element.id]) {
   if (!appState.selectedElementIds[element.id]) {
     return false;
     return false;
   }
   }
 
 
   const { rotation: rotationTransformHandle, ...transformHandles } =
   const { rotation: rotationTransformHandle, ...transformHandles } =
-    getTransformHandles(element, zoom, elementsMap, pointerType);
+    getTransformHandles(
+      element,
+      zoom,
+      elementsMap,
+      pointerType,
+      getOmitSidesForDevice(device),
+    );
 
 
   if (
   if (
     rotationTransformHandle &&
     rotationTransformHandle &&
@@ -62,6 +78,35 @@ export const resizeTest = (
     return filter[0] as TransformHandleType;
     return filter[0] as TransformHandleType;
   }
   }
 
 
+  if (canResizeFromSides(device)) {
+    const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
+      element,
+      elementsMap,
+    );
+
+    // Note that for a text element, when "resized" from the side
+    // we should make it wrap/unwrap
+    if (
+      element.type !== "text" &&
+      !(isLinearElement(element) && element.points.length <= 2)
+    ) {
+      const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
+      const sides = getSelectionBorders(
+        [x1 - SPACING, y1 - SPACING],
+        [x2 + SPACING, y2 + SPACING],
+        [cx, cy],
+        angleToDegrees(element.angle),
+      );
+
+      for (const [dir, side] of Object.entries(sides)) {
+        // test to see if x, y are on the line segment
+        if (pointOnLine([x, y], side as Line, SPACING)) {
+          return dir as TransformHandleType;
+        }
+      }
+    }
+  }
+
   return false;
   return false;
 };
 };
 
 
@@ -73,6 +118,7 @@ export const getElementWithTransformHandleType = (
   zoom: Zoom,
   zoom: Zoom,
   pointerType: PointerType,
   pointerType: PointerType,
   elementsMap: ElementsMap,
   elementsMap: ElementsMap,
+  device: Device,
 ) => {
 ) => {
   return elements.reduce((result, element) => {
   return elements.reduce((result, element) => {
     if (result) {
     if (result) {
@@ -86,6 +132,7 @@ export const getElementWithTransformHandleType = (
       scenePointerY,
       scenePointerY,
       zoom,
       zoom,
       pointerType,
       pointerType,
+      device,
     );
     );
     return transformHandleType ? { element, transformHandleType } : null;
     return transformHandleType ? { element, transformHandleType } : null;
   }, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
   }, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
@@ -97,13 +144,14 @@ export const getTransformHandleTypeFromCoords = (
   scenePointerY: number,
   scenePointerY: number,
   zoom: Zoom,
   zoom: Zoom,
   pointerType: PointerType,
   pointerType: PointerType,
+  device: Device,
 ): MaybeTransformHandleType => {
 ): MaybeTransformHandleType => {
   const transformHandles = getTransformHandlesFromCoords(
   const transformHandles = getTransformHandlesFromCoords(
     [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
     [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
     0,
     0,
     zoom,
     zoom,
     pointerType,
     pointerType,
-    OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
+    getOmitSidesForDevice(device),
   );
   );
 
 
   const found = Object.keys(transformHandles).find((key) => {
   const found = Object.keys(transformHandles).find((key) => {
@@ -114,7 +162,33 @@ export const getTransformHandleTypeFromCoords = (
       isInsideTransformHandle(transformHandle, scenePointerX, scenePointerY)
       isInsideTransformHandle(transformHandle, scenePointerX, scenePointerY)
     );
     );
   });
   });
-  return (found || false) as MaybeTransformHandleType;
+
+  if (found) {
+    return found as MaybeTransformHandleType;
+  }
+
+  if (canResizeFromSides(device)) {
+    const cx = (x1 + x2) / 2;
+    const cy = (y1 + y2) / 2;
+
+    const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
+
+    const sides = getSelectionBorders(
+      [x1 - SPACING, y1 - SPACING],
+      [x2 + SPACING, y2 + SPACING],
+      [cx, cy],
+      angleToDegrees(0),
+    );
+
+    for (const [dir, side] of Object.entries(sides)) {
+      // test to see if x, y are on the line segment
+      if (pointOnLine([scenePointerX, scenePointerY], side as Line, SPACING)) {
+        return dir as TransformHandleType;
+      }
+    }
+  }
+
+  return false;
 };
 };
 
 
 const RESIZE_CURSORS = ["ns", "nesw", "ew", "nwse"];
 const RESIZE_CURSORS = ["ns", "nesw", "ew", "nwse"];
@@ -174,3 +248,22 @@ export const getCursorForResizingElement = (resizingElement: {
 
 
   return cursor ? `${cursor}-resize` : "";
   return cursor ? `${cursor}-resize` : "";
 };
 };
+
+const getSelectionBorders = (
+  [x1, y1]: Point,
+  [x2, y2]: Point,
+  center: Point,
+  angleInDegrees: number,
+) => {
+  const topLeft = pointRotate([x1, y1], angleInDegrees, center);
+  const topRight = pointRotate([x2, y1], angleInDegrees, center);
+  const bottomLeft = pointRotate([x1, y2], angleInDegrees, center);
+  const bottomRight = pointRotate([x2, y2], angleInDegrees, center);
+
+  return {
+    n: [topLeft, topRight],
+    e: [topRight, bottomRight],
+    s: [bottomRight, bottomLeft],
+    w: [bottomLeft, topLeft],
+  };
+};

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

@@ -7,10 +7,14 @@ import {
 
 
 import { Bounds, getElementAbsoluteCoords } from "./bounds";
 import { Bounds, getElementAbsoluteCoords } from "./bounds";
 import { rotate } from "../math";
 import { rotate } from "../math";
-import { InteractiveCanvasAppState, Zoom } from "../types";
+import { Device, InteractiveCanvasAppState, Zoom } from "../types";
 import { isTextElement } from ".";
 import { isTextElement } from ".";
 import { isFrameLikeElement, isLinearElement } from "./typeChecks";
 import { isFrameLikeElement, isLinearElement } from "./typeChecks";
-import { DEFAULT_TRANSFORM_HANDLE_SPACING } from "../constants";
+import {
+  DEFAULT_TRANSFORM_HANDLE_SPACING,
+  isAndroid,
+  isIOS,
+} from "../constants";
 
 
 export type TransformHandleDirection =
 export type TransformHandleDirection =
   | "n"
   | "n"
@@ -38,6 +42,13 @@ const transformHandleSizes: { [k in PointerType]: number } = {
 
 
 const ROTATION_RESIZE_HANDLE_GAP = 16;
 const ROTATION_RESIZE_HANDLE_GAP = 16;
 
 
+export const DEFAULT_OMIT_SIDES = {
+  e: true,
+  s: true,
+  n: true,
+  w: true,
+};
+
 export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
 export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
   e: true,
   e: true,
   s: true,
   s: true,
@@ -89,6 +100,26 @@ const generateTransformHandle = (
   return [xx - width / 2, yy - height / 2, width, height];
   return [xx - width / 2, yy - height / 2, width, height];
 };
 };
 
 
+export const canResizeFromSides = (device: Device) => {
+  if (device.viewport.isMobile) {
+    return false;
+  }
+
+  if (device.isTouchScreen && (isAndroid || isIOS)) {
+    return false;
+  }
+
+  return true;
+};
+
+export const getOmitSidesForDevice = (device: Device) => {
+  if (canResizeFromSides(device)) {
+    return DEFAULT_OMIT_SIDES;
+  }
+
+  return {};
+};
+
 export const getTransformHandlesFromCoords = (
 export const getTransformHandlesFromCoords = (
   [x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
   [x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
   angle: number,
   angle: number,
@@ -232,8 +263,8 @@ export const getTransformHandles = (
   element: ExcalidrawElement,
   element: ExcalidrawElement,
   zoom: Zoom,
   zoom: Zoom,
   elementsMap: ElementsMap,
   elementsMap: ElementsMap,
-
   pointerType: PointerType = "mouse",
   pointerType: PointerType = "mouse",
+  omitSides: { [T in TransformHandleType]?: boolean } = DEFAULT_OMIT_SIDES,
 ): TransformHandles => {
 ): TransformHandles => {
   // so that when locked element is selected (especially when you toggle lock
   // so that when locked element is selected (especially when you toggle lock
   // via keyboard) the locked element is visually distinct, indicating
   // via keyboard) the locked element is visually distinct, indicating
@@ -242,7 +273,6 @@ export const getTransformHandles = (
     return {};
     return {};
   }
   }
 
 
-  let omitSides: { [T in TransformHandleType]?: boolean } = {};
   if (element.type === "freedraw" || isLinearElement(element)) {
   if (element.type === "freedraw" || isLinearElement(element)) {
     if (element.points.length === 2) {
     if (element.points.length === 2) {
       // only check the last point because starting point is always (0,0)
       // only check the last point because starting point is always (0,0)
@@ -263,6 +293,7 @@ export const getTransformHandles = (
     omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
     omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
   } else if (isFrameLikeElement(element)) {
   } else if (isFrameLikeElement(element)) {
     omitSides = {
     omitSides = {
+      ...omitSides,
       rotation: true,
       rotation: true,
     };
     };
   }
   }

+ 4 - 0
packages/excalidraw/groups.ts

@@ -387,3 +387,7 @@ export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => {
 
 
   return maxGroup === elements.length;
   return maxGroup === elements.length;
 };
 };
+
+export const isInGroup = (element: NonDeletedExcalidrawElement) => {
+  return element.groupIds.length > 0;
+};

+ 5 - 4
packages/excalidraw/renderer/interactiveScene.ts

@@ -1,6 +1,5 @@
 import {
 import {
   getElementAbsoluteCoords,
   getElementAbsoluteCoords,
-  OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
   getTransformHandlesFromCoords,
   getTransformHandlesFromCoords,
   getTransformHandles,
   getTransformHandles,
   getCommonBounds,
   getCommonBounds,
@@ -23,7 +22,7 @@ import {
   selectGroupsFromGivenElements,
   selectGroupsFromGivenElements,
 } from "../groups";
 } from "../groups";
 import {
 import {
-  OMIT_SIDES_FOR_FRAME,
+  getOmitSidesForDevice,
   shouldShowBoundingBox,
   shouldShowBoundingBox,
   TransformHandles,
   TransformHandles,
   TransformHandleType,
   TransformHandleType,
@@ -577,6 +576,7 @@ const _renderInteractiveScene = ({
   scale,
   scale,
   appState,
   appState,
   renderConfig,
   renderConfig,
+  device,
 }: InteractiveSceneRenderConfig) => {
 }: InteractiveSceneRenderConfig) => {
   if (canvas === null) {
   if (canvas === null) {
     return { atLeastOneVisibleElement: false, elementsMap };
     return { atLeastOneVisibleElement: false, elementsMap };
@@ -806,6 +806,7 @@ const _renderInteractiveScene = ({
         appState.zoom,
         appState.zoom,
         elementsMap,
         elementsMap,
         "mouse", // when we render we don't know which pointer type so use mouse,
         "mouse", // when we render we don't know which pointer type so use mouse,
+        getOmitSidesForDevice(device),
       );
       );
       if (!appState.viewModeEnabled && showBoundingBox) {
       if (!appState.viewModeEnabled && showBoundingBox) {
         renderTransformHandles(
         renderTransformHandles(
@@ -844,8 +845,8 @@ const _renderInteractiveScene = ({
         appState.zoom,
         appState.zoom,
         "mouse",
         "mouse",
         isFrameSelected
         isFrameSelected
-          ? OMIT_SIDES_FOR_FRAME
-          : OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
+          ? { ...getOmitSidesForDevice(device), rotation: true }
+          : getOmitSidesForDevice(device),
       );
       );
       if (selectedElements.some((element) => !element.locked)) {
       if (selectedElements.some((element) => !element.locked)) {
         renderTransformHandles(
         renderTransformHandles(

+ 2 - 0
packages/excalidraw/scene/types.ts

@@ -16,6 +16,7 @@ import {
   StaticCanvasAppState,
   StaticCanvasAppState,
   SocketId,
   SocketId,
   UserIdleState,
   UserIdleState,
+  Device,
 } from "../types";
 } from "../types";
 import { MakeBrand } from "../utility-types";
 import { MakeBrand } from "../utility-types";
 
 
@@ -85,6 +86,7 @@ export type InteractiveSceneRenderConfig = {
   scale: number;
   scale: number;
   appState: InteractiveCanvasAppState;
   appState: InteractiveCanvasAppState;
   renderConfig: InteractiveCanvasRenderConfig;
   renderConfig: InteractiveCanvasRenderConfig;
+  device: Device;
   callback: (data: RenderInteractiveSceneCallback) => void;
   callback: (data: RenderInteractiveSceneCallback) => void;
 };
 };
 
 

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

@@ -2170,14 +2170,14 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 1278240551,
+  "seed": 449462985,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 4,
   "version": 4,
-  "versionNonce": 1150084233,
+  "versionNonce": 1014066025,
   "width": 20,
   "width": 20,
   "x": -10,
   "x": -10,
   "y": 0,
   "y": 0,
@@ -2404,14 +2404,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 1278240551,
+  "seed": 449462985,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 3,
   "version": 3,
-  "versionNonce": 401146281,
+  "versionNonce": 1150084233,
   "width": 20,
   "width": 20,
   "x": -10,
   "x": -10,
   "y": 0,
   "y": 0,
@@ -2438,14 +2438,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 1150084233,
+  "seed": 1014066025,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 4,
   "version": 4,
-  "versionNonce": 1116226695,
+  "versionNonce": 238820263,
   "width": 20,
   "width": 20,
   "x": 0,
   "x": 0,
   "y": 10,
   "y": 10,
@@ -2704,14 +2704,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 1278240551,
+  "seed": 449462985,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 4,
   "version": 4,
-  "versionNonce": 1505387817,
+  "versionNonce": 493213705,
   "width": 20,
   "width": 20,
   "x": -10,
   "x": -10,
   "y": 0,
   "y": 0,
@@ -2740,14 +2740,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 1150084233,
+  "seed": 1014066025,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 4,
   "version": 4,
-  "versionNonce": 23633383,
+  "versionNonce": 915032327,
   "width": 20,
   "width": 20,
   "x": 20,
   "x": 20,
   "y": 30,
   "y": 30,
@@ -3060,14 +3060,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 1278240551,
+  "seed": 449462985,
   "strokeColor": "#e03131",
   "strokeColor": "#e03131",
   "strokeStyle": "dotted",
   "strokeStyle": "dotted",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 4,
   "version": 4,
-  "versionNonce": 640725609,
+  "versionNonce": 941653321,
   "width": 20,
   "width": 20,
   "x": -10,
   "x": -10,
   "y": 0,
   "y": 0,
@@ -3094,14 +3094,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 760410951,
+  "seed": 289600103,
   "strokeColor": "#e03131",
   "strokeColor": "#e03131",
   "strokeStyle": "dotted",
   "strokeStyle": "dotted",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 9,
   "version": 9,
-  "versionNonce": 1315507081,
+  "versionNonce": 640725609,
   "width": 20,
   "width": 20,
   "x": 20,
   "x": 20,
   "y": 30,
   "y": 30,
@@ -3840,14 +3840,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 1150084233,
+  "seed": 1014066025,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 4,
   "version": 4,
-  "versionNonce": 1604849351,
+  "versionNonce": 23633383,
   "width": 20,
   "width": 20,
   "x": 20,
   "x": 20,
   "y": 30,
   "y": 30,
@@ -3874,14 +3874,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 1278240551,
+  "seed": 449462985,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 3,
   "version": 3,
-  "versionNonce": 401146281,
+  "versionNonce": 1150084233,
   "width": 20,
   "width": 20,
   "x": -10,
   "x": -10,
   "y": 0,
   "y": 0,
@@ -5224,8 +5224,8 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
         },
         },
       },
       },
     ],
     ],
-    "left": -19,
-    "top": -9,
+    "left": -17,
+    "top": -7,
   },
   },
   "currentChartType": "bar",
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemBackgroundColor": "transparent",
@@ -5342,14 +5342,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 449462985,
+  "seed": 453191,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 3,
   "version": 3,
-  "versionNonce": 1150084233,
+  "versionNonce": 1014066025,
   "width": 10,
   "width": 10,
   "x": -10,
   "x": -10,
   "y": 0,
   "y": 0,
@@ -5376,16 +5376,16 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 1014066025,
+  "seed": 400692809,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 3,
   "version": 3,
-  "versionNonce": 1604849351,
+  "versionNonce": 23633383,
   "width": 10,
   "width": 10,
-  "x": 10,
+  "x": 12,
   "y": 0,
   "y": 0,
 }
 }
 `;
 `;
@@ -5493,7 +5493,7 @@ History {
               "strokeWidth": 2,
               "strokeWidth": 2,
               "type": "rectangle",
               "type": "rectangle",
               "width": 10,
               "width": 10,
-              "x": 10,
+              "x": 12,
               "y": 0,
               "y": 0,
             },
             },
             "inserted": {
             "inserted": {
@@ -6349,8 +6349,8 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
         },
         },
       },
       },
     ],
     ],
-    "left": -19,
-    "top": -9,
+    "left": -17,
+    "top": -7,
   },
   },
   "currentChartType": "bar",
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemBackgroundColor": "transparent",
@@ -6516,7 +6516,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
   "version": 4,
   "version": 4,
   "versionNonce": 747212839,
   "versionNonce": 747212839,
   "width": 10,
   "width": 10,
-  "x": 10,
+  "x": 12,
   "y": 0,
   "y": 0,
 }
 }
 `;
 `;
@@ -6624,7 +6624,7 @@ History {
               "strokeWidth": 2,
               "strokeWidth": 2,
               "type": "rectangle",
               "type": "rectangle",
               "width": 10,
               "width": 10,
-              "x": 10,
+              "x": 12,
               "y": 0,
               "y": 0,
             },
             },
             "inserted": {
             "inserted": {
@@ -8181,8 +8181,8 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
         },
         },
       },
       },
     ],
     ],
-    "left": -19,
-    "top": -9,
+    "left": -17,
+    "top": -7,
   },
   },
   "currentChartType": "bar",
   "currentChartType": "bar",
   "currentItemBackgroundColor": "transparent",
   "currentItemBackgroundColor": "transparent",

+ 2 - 4
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -1400,9 +1400,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
   "penDetected": false,
   "penDetected": false,
   "penMode": false,
   "penMode": false,
   "pendingImageElementId": null,
   "pendingImageElementId": null,
-  "previousSelectedElementIds": {
-    "id0": true,
-  },
+  "previousSelectedElementIds": {},
   "resizingElement": null,
   "resizingElement": null,
   "scrollX": 0,
   "scrollX": 0,
   "scrollY": 0,
   "scrollY": 0,
@@ -1522,7 +1520,7 @@ History {
 
 
 exports[`regression tests > Drags selected element when hitting only bounding box and keeps element selected > [end of test] number of elements 1`] = `0`;
 exports[`regression tests > Drags selected element when hitting only bounding box and keeps element selected > [end of test] number of elements 1`] = `0`;
 
 
-exports[`regression tests > Drags selected element when hitting only bounding box and keeps element selected > [end of test] number of renders 1`] = `9`;
+exports[`regression tests > Drags selected element when hitting only bounding box and keeps element selected > [end of test] number of renders 1`] = `11`;
 
 
 exports[`regression tests > adjusts z order when grouping > [end of test] appState 1`] = `
 exports[`regression tests > adjusts z order when grouping > [end of test] appState 1`] = `
 {
 {

+ 1 - 0
packages/excalidraw/tests/binding.test.tsx

@@ -45,6 +45,7 @@ describe("element binding", () => {
     mouse.downAt(100, 0);
     mouse.downAt(100, 0);
     mouse.moveTo(55, 0);
     mouse.moveTo(55, 0);
     mouse.up(0, 0);
     mouse.up(0, 0);
+    expect(API.getSelectedElements()).toEqual([arrow]);
     expect(arrow.startBinding).toEqual({
     expect(arrow.startBinding).toEqual({
       elementId: rect.id,
       elementId: rect.id,
       focus: expect.toBeNonNaNNumber(),
       focus: expect.toBeNonNaNNumber(),

+ 22 - 22
packages/excalidraw/tests/contextmenu.test.tsx

@@ -108,8 +108,8 @@ describe("contextMenu element", () => {
 
 
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       button: 2,
-      clientX: 1,
-      clientY: 1,
+      clientX: 3,
+      clientY: 3,
     });
     });
     const contextMenu = UI.queryContextMenu();
     const contextMenu = UI.queryContextMenu();
     const contextMenuOptions =
     const contextMenuOptions =
@@ -188,19 +188,19 @@ describe("contextMenu element", () => {
     mouse.up(10, 10);
     mouse.up(10, 10);
 
 
     UI.clickTool("rectangle");
     UI.clickTool("rectangle");
-    mouse.down(10, -10);
+    mouse.down(12, -10);
     mouse.up(10, 10);
     mouse.up(10, 10);
 
 
     mouse.reset();
     mouse.reset();
     mouse.click(10, 10);
     mouse.click(10, 10);
     Keyboard.withModifierKeys({ shift: true }, () => {
     Keyboard.withModifierKeys({ shift: true }, () => {
-      mouse.click(20, 0);
+      mouse.click(22, 0);
     });
     });
 
 
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       button: 2,
-      clientX: 1,
-      clientY: 1,
+      clientX: 3,
+      clientY: 3,
     });
     });
 
 
     const contextMenu = UI.queryContextMenu();
     const contextMenu = UI.queryContextMenu();
@@ -240,13 +240,13 @@ describe("contextMenu element", () => {
     mouse.up(10, 10);
     mouse.up(10, 10);
 
 
     UI.clickTool("rectangle");
     UI.clickTool("rectangle");
-    mouse.down(10, -10);
+    mouse.down(12, -10);
     mouse.up(10, 10);
     mouse.up(10, 10);
 
 
     mouse.reset();
     mouse.reset();
     mouse.click(10, 10);
     mouse.click(10, 10);
     Keyboard.withModifierKeys({ shift: true }, () => {
     Keyboard.withModifierKeys({ shift: true }, () => {
-      mouse.click(20, 0);
+      mouse.click(22, 0);
     });
     });
 
 
     Keyboard.withModifierKeys({ ctrl: true }, () => {
     Keyboard.withModifierKeys({ ctrl: true }, () => {
@@ -255,8 +255,8 @@ describe("contextMenu element", () => {
 
 
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       button: 2,
-      clientX: 1,
-      clientY: 1,
+      clientX: 3,
+      clientY: 3,
     });
     });
 
 
     const contextMenu = UI.queryContextMenu();
     const contextMenu = UI.queryContextMenu();
@@ -297,8 +297,8 @@ describe("contextMenu element", () => {
 
 
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       button: 2,
-      clientX: 1,
-      clientY: 1,
+      clientX: 3,
+      clientY: 3,
     });
     });
     const contextMenu = UI.queryContextMenu();
     const contextMenu = UI.queryContextMenu();
     expect(copiedStyles).toBe("{}");
     expect(copiedStyles).toBe("{}");
@@ -382,8 +382,8 @@ describe("contextMenu element", () => {
 
 
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       button: 2,
-      clientX: 1,
-      clientY: 1,
+      clientX: 3,
+      clientY: 3,
     });
     });
     const contextMenu = UI.queryContextMenu();
     const contextMenu = UI.queryContextMenu();
     fireEvent.click(queryAllByText(contextMenu!, "Delete")[0]);
     fireEvent.click(queryAllByText(contextMenu!, "Delete")[0]);
@@ -398,8 +398,8 @@ describe("contextMenu element", () => {
 
 
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       button: 2,
-      clientX: 1,
-      clientY: 1,
+      clientX: 3,
+      clientY: 3,
     });
     });
     const contextMenu = UI.queryContextMenu();
     const contextMenu = UI.queryContextMenu();
     fireEvent.click(queryByText(contextMenu!, "Add to library")!);
     fireEvent.click(queryByText(contextMenu!, "Add to library")!);
@@ -417,8 +417,8 @@ describe("contextMenu element", () => {
 
 
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       button: 2,
-      clientX: 1,
-      clientY: 1,
+      clientX: 3,
+      clientY: 3,
     });
     });
     const contextMenu = UI.queryContextMenu();
     const contextMenu = UI.queryContextMenu();
     fireEvent.click(queryByText(contextMenu!, "Duplicate")!);
     fireEvent.click(queryByText(contextMenu!, "Duplicate")!);
@@ -548,8 +548,8 @@ describe("contextMenu element", () => {
 
 
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       button: 2,
-      clientX: 1,
-      clientY: 1,
+      clientX: 3,
+      clientY: 3,
     });
     });
     const contextMenu = UI.queryContextMenu();
     const contextMenu = UI.queryContextMenu();
     fireEvent.click(queryByText(contextMenu!, "Group selection")!);
     fireEvent.click(queryByText(contextMenu!, "Group selection")!);
@@ -578,8 +578,8 @@ describe("contextMenu element", () => {
 
 
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       button: 2,
-      clientX: 1,
-      clientY: 1,
+      clientX: 3,
+      clientY: 3,
     });
     });
 
 
     const contextMenu = UI.queryContextMenu();
     const contextMenu = UI.queryContextMenu();

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

@@ -315,6 +315,7 @@ const transform = (
       h.state.zoom,
       h.state.zoom,
       arrayToMap(h.elements),
       arrayToMap(h.elements),
       "mouse",
       "mouse",
+      {},
     )[handle];
     )[handle];
   } else {
   } else {
     const [x1, y1, x2, y2] = getCommonBounds(elements);
     const [x1, y1, x2, y2] = getCommonBounds(elements);

+ 17 - 7
packages/excalidraw/tests/regressionTests.test.tsx

@@ -199,7 +199,6 @@ describe("regression tests", () => {
     expect(
     expect(
       h.elements.filter((element) => element.type === "rectangle").length,
       h.elements.filter((element) => element.type === "rectangle").length,
     ).toBe(1);
     ).toBe(1);
-
     Keyboard.withModifierKeys({ alt: true }, () => {
     Keyboard.withModifierKeys({ alt: true }, () => {
       mouse.down(-8, -8);
       mouse.down(-8, -8);
       mouse.up(10, 10);
       mouse.up(10, 10);
@@ -725,7 +724,7 @@ describe("regression tests", () => {
     mouse.up(10, 10);
     mouse.up(10, 10);
 
 
     const { x: prevX, y: prevY } = API.getSelectedElement();
     const { x: prevX, y: prevY } = API.getSelectedElement();
-
+    API.clearSelection();
     // drag element from point on bounding box that doesn't hit element
     // drag element from point on bounding box that doesn't hit element
     mouse.reset();
     mouse.reset();
     mouse.down(8, 8);
     mouse.down(8, 8);
@@ -1015,12 +1014,22 @@ describe("regression tests", () => {
   });
   });
 
 
   it("single-clicking on a subgroup of a selected group should not alter selection", () => {
   it("single-clicking on a subgroup of a selected group should not alter selection", () => {
-    const rect1 = UI.createElement("rectangle", { x: 10 });
-    const rect2 = UI.createElement("rectangle", { x: 50 });
+    const rect1 = UI.createElement("rectangle", {
+      x: 10,
+    });
+    const rect2 = UI.createElement("rectangle", {
+      x: 50,
+    });
     UI.group([rect1, rect2]);
     UI.group([rect1, rect2]);
 
 
-    const rect3 = UI.createElement("rectangle", { x: 10, y: 50 });
-    const rect4 = UI.createElement("rectangle", { x: 50, y: 50 });
+    const rect3 = UI.createElement("rectangle", {
+      x: 10,
+      y: 50,
+    });
+    const rect4 = UI.createElement("rectangle", {
+      x: 50,
+      y: 50,
+    });
     UI.group([rect3, rect4]);
     UI.group([rect3, rect4]);
 
 
     Keyboard.withModifierKeys({ ctrl: true }, () => {
     Keyboard.withModifierKeys({ ctrl: true }, () => {
@@ -1079,8 +1088,9 @@ describe("regression tests", () => {
     UI.group([rect1, rect3]);
     UI.group([rect1, rect3]);
     assertSelectedElements(rect1, rect2, rect3);
     assertSelectedElements(rect1, rect2, rect3);
 
 
+    mouse.reset();
     Keyboard.withModifierKeys({ ctrl: true }, () => {
     Keyboard.withModifierKeys({ ctrl: true }, () => {
-      mouse.clickOn(rect1);
+      mouse.click(10, 5);
     });
     });
     assertSelectedElements(rect1);
     assertSelectedElements(rect1);
 
 

+ 22 - 10
packages/excalidraw/tests/resize.test.tsx

@@ -544,7 +544,9 @@ describe("multiple selection", () => {
       1 + move[1] / selectionHeight,
       1 + move[1] / selectionHeight,
     );
     );
 
 
-    UI.resize([rectangle, diamond, ellipse], "se", move);
+    UI.resize([rectangle, diamond, ellipse], "se", move, {
+      shift: true,
+    });
 
 
     expect(rectangle.x).toBeCloseTo(0);
     expect(rectangle.x).toBeCloseTo(0);
     expect(rectangle.y).toBeCloseTo(0);
     expect(rectangle.y).toBeCloseTo(0);
@@ -613,7 +615,9 @@ describe("multiple selection", () => {
       1 + move[1] / selectionHeight,
       1 + move[1] / selectionHeight,
     );
     );
 
 
-    UI.resize([line, freedraw], "se", move);
+    UI.resize([line, freedraw], "se", move, {
+      shift: true,
+    });
 
 
     expect(line.x).toBeCloseTo(60 * scale);
     expect(line.x).toBeCloseTo(60 * scale);
     expect(line.y).toBeCloseTo(40 * scale);
     expect(line.y).toBeCloseTo(40 * scale);
@@ -653,7 +657,9 @@ describe("multiple selection", () => {
       1 - move[1] / selectionHeight,
       1 - move[1] / selectionHeight,
     );
     );
 
 
-    UI.resize([horizLine, vertLine, diagLine], "nw", move);
+    UI.resize([horizLine, vertLine, diagLine], "nw", move, {
+      shift: true,
+    });
 
 
     expect(horizLine.x).toBeCloseTo(selectionWidth * (1 - scale));
     expect(horizLine.x).toBeCloseTo(selectionWidth * (1 - scale));
     expect(horizLine.y).toBeCloseTo(selectionHeight * (1 - scale));
     expect(horizLine.y).toBeCloseTo(selectionHeight * (1 - scale));
@@ -703,7 +709,9 @@ describe("multiple selection", () => {
     const rightArrowBinding = { ...rightBoundArrow.endBinding };
     const rightArrowBinding = { ...rightBoundArrow.endBinding };
     delete rightArrowBinding.gap;
     delete rightArrowBinding.gap;
 
 
-    UI.resize([rectangle, rightBoundArrow], "nw", move);
+    UI.resize([rectangle, rightBoundArrow], "nw", move, {
+      shift: true,
+    });
 
 
     expect(leftBoundArrow.x).toBeCloseTo(-110);
     expect(leftBoundArrow.x).toBeCloseTo(-110);
     expect(leftBoundArrow.y).toBeCloseTo(50);
     expect(leftBoundArrow.y).toBeCloseTo(50);
@@ -751,7 +759,9 @@ describe("multiple selection", () => {
     const move = [80, 0] as [number, number];
     const move = [80, 0] as [number, number];
     const scale = move[0] / selectionWidth + 1;
     const scale = move[0] / selectionWidth + 1;
     const elementsMap = arrayToMap(h.elements);
     const elementsMap = arrayToMap(h.elements);
-    UI.resize([topArrow.get(), bottomArrow.get()], "se", move);
+    UI.resize([topArrow.get(), bottomArrow.get()], "se", move, {
+      shift: true,
+    });
     const topArrowLabelPos = LinearElementEditor.getBoundTextElementPosition(
     const topArrowLabelPos = LinearElementEditor.getBoundTextElementPosition(
       topArrow,
       topArrow,
       topArrowLabel,
       topArrowLabel,
@@ -815,7 +825,7 @@ describe("multiple selection", () => {
       1 - move[1] / selectionHeight,
       1 - move[1] / selectionHeight,
     );
     );
 
 
-    UI.resize([topText, bottomText], "ne", move);
+    UI.resize([topText, bottomText], "ne", move, { shift: true });
 
 
     expect(topText.x).toBeCloseTo(0);
     expect(topText.x).toBeCloseTo(0);
     expect(topText.y).toBeCloseTo(-selectionHeight * (scale - 1));
     expect(topText.y).toBeCloseTo(-selectionHeight * (scale - 1));
@@ -828,7 +838,7 @@ describe("multiple selection", () => {
     expect(bottomText.angle).toEqual(0);
     expect(bottomText.angle).toEqual(0);
   });
   });
 
 
-  it("resizes with images", () => {
+  it("resizes with images (proportional)", () => {
     const topImage = API.createElement({
     const topImage = API.createElement({
       type: "image",
       type: "image",
       x: 0,
       x: 0,
@@ -891,7 +901,7 @@ describe("multiple selection", () => {
       1 + (2 * move[1]) / selectionHeight,
       1 + (2 * move[1]) / selectionHeight,
     );
     );
 
 
-    UI.resize([rectangle, ellipse], "se", move, { alt: true });
+    UI.resize([rectangle, ellipse], "se", move, { shift: true, alt: true });
 
 
     expect(rectangle.x).toBeCloseTo(-200 * scale);
     expect(rectangle.x).toBeCloseTo(-200 * scale);
     expect(rectangle.y).toBeCloseTo(-140 * scale);
     expect(rectangle.y).toBeCloseTo(-140 * scale);
@@ -954,7 +964,9 @@ describe("multiple selection", () => {
     const scaleY = -scaleX;
     const scaleY = -scaleX;
     const lineOrigBounds = getBoundsFromPoints(line);
     const lineOrigBounds = getBoundsFromPoints(line);
     const elementsMap = arrayToMap(h.elements);
     const elementsMap = arrayToMap(h.elements);
-    UI.resize([line, image, rectangle, boundArrow], "se", move);
+    UI.resize([line, image, rectangle, boundArrow], "se", move, {
+      shift: true,
+    });
     const lineNewBounds = getBoundsFromPoints(line);
     const lineNewBounds = getBoundsFromPoints(line);
     const arrowLabelPos = LinearElementEditor.getBoundTextElementPosition(
     const arrowLabelPos = LinearElementEditor.getBoundTextElementPosition(
       boundArrow,
       boundArrow,
@@ -979,7 +991,7 @@ describe("multiple selection", () => {
     expect(image.width).toBeCloseTo(100 * -scaleX);
     expect(image.width).toBeCloseTo(100 * -scaleX);
     expect(image.height).toBeCloseTo(100 * scaleY);
     expect(image.height).toBeCloseTo(100 * scaleY);
     expect(image.angle).toBeCloseTo((Math.PI * 5) / 6);
     expect(image.angle).toBeCloseTo((Math.PI * 5) / 6);
-    expect(image.scale).toEqual([1, 1]);
+    expect(image.scale).toEqual([-1, 1]);
 
 
     expect(rectangle.x).toBeCloseTo((180 + 160) * scaleX);
     expect(rectangle.x).toBeCloseTo((180 + 160) * scaleX);
     expect(rectangle.y).toBeCloseTo(60 * scaleY);
     expect(rectangle.y).toBeCloseTo(60 * scaleY);

File diff suppressed because it is too large
+ 186 - 537
yarn.lock


Some files were not shown because too many files changed in this diff