浏览代码

Resize multiple elements (rectangles, diamonds and ellipses onl… (#1193)

* experiment resizing multiple elements

* hack common component

* calculate scale properly (still se only)fg

* prioritize multi selection

* take handle offset into calculation

* fix master merge

* refactor resizeElements out from App

* wip: handlerRectanglesFromCoords

* fix test with type assertion

* properly show handles wip

* revert previous one and do a tweak

* remove unnecessary assignments

* replace hack code with good one

* refactor coords in arg

* resize NW

* resize from sw,ne

* fix with setResizeHandle

* do not show hint while resizing multiple elements

* empty commit

* fix format
Daishi Kato 5 年之前
父节点
当前提交
2cc1105ff5

+ 94 - 393
src/components/App.tsx

@@ -4,14 +4,12 @@ import socketIOClient from "socket.io-client";
 import rough from "roughjs/bin/rough";
 import { RoughCanvas } from "roughjs/bin/canvas";
 import { FlooredNumber } from "../types";
-import { getElementAbsoluteCoords } from "../element/bounds";
 
 import {
   newElement,
   newTextElement,
   duplicateElement,
   resizeTest,
-  normalizeResizeHandle,
   isInvisiblySmallElement,
   isTextElement,
   textWysiwyg,
@@ -24,6 +22,11 @@ import {
   getSyncableElements,
   hasNonDeletedElements,
   newLinearElement,
+  ResizeArrowFnType,
+  resizeElements,
+  getElementWithResizeHandler,
+  canResizeMutlipleElements,
+  getResizeHandlerFromCoords,
 } from "../element";
 import {
   deleteSelectedElements,
@@ -50,12 +53,7 @@ import {
 
 import { renderScene } from "../renderer";
 import { AppState, GestureEvent, Gesture } from "../types";
-import {
-  ExcalidrawElement,
-  ExcalidrawLinearElement,
-  ExcalidrawTextElement,
-} from "../element/types";
-import { rotate, adjustXYWithRotation } from "../math";
+import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
 
 import {
   isWritableElement,
@@ -76,7 +74,6 @@ import { createHistory, SceneHistory } from "../history";
 
 import ContextMenu from "./ContextMenu";
 
-import { getElementWithResizeHandler } from "../element/resizeTest";
 import { ActionManager } from "../actions/manager";
 import "../actions";
 import { actions } from "../actions/register";
@@ -102,7 +99,6 @@ import {
   DRAGGING_THRESHOLD,
   TEXT_TO_CENTER_SNAP_THRESHOLD,
   ARROW_CONFIRM_THRESHOLD,
-  SHIFT_LOCKING_ANGLE,
 } from "../constants";
 import { LayerUI } from "./LayerUI";
 import { ScrollBars, SceneState } from "../scene/types";
@@ -112,7 +108,6 @@ import { invalidateShapeForElement } from "../renderer/renderElement";
 import { unstable_batchedUpdates } from "react-dom";
 import { SceneStateCallbackRemover } from "../scene/globalScene";
 import { isLinearElement } from "../element/typeChecks";
-import { rescalePoints } from "../points";
 import { actionFinalize } from "../actions";
 
 /**
@@ -929,7 +924,7 @@ export class App extends React.Component<any, AppState> {
           };
         });
       });
-      this.socket.on("new-user", async (socketID: string) => {
+      this.socket.on("new-user", async (_socketID: string) => {
         this.broadcastScene("SCENE_INIT");
       });
 
@@ -1485,19 +1480,34 @@ export class App extends React.Component<any, AppState> {
       this.state,
     );
     if (selectedElements.length === 1 && !isOverScrollBar) {
-      const resizeElement = getElementWithResizeHandler(
+      const elementWithResizeHandler = getElementWithResizeHandler(
         globalSceneState.getAllElements(),
         this.state,
         { x, y },
         this.state.zoom,
         event.pointerType,
       );
-      if (resizeElement && resizeElement.resizeHandle) {
+      if (elementWithResizeHandler && elementWithResizeHandler.resizeHandle) {
         document.documentElement.style.cursor = getCursorForResizingElement(
-          resizeElement,
+          elementWithResizeHandler,
         );
         return;
       }
+    } else if (selectedElements.length > 1 && !isOverScrollBar) {
+      if (canResizeMutlipleElements(selectedElements)) {
+        const resizeHandle = getResizeHandlerFromCoords(
+          getCommonBounds(selectedElements),
+          { x, y },
+          this.state.zoom,
+          event.pointerType,
+        );
+        if (resizeHandle) {
+          document.documentElement.style.cursor = getCursorForResizingElement({
+            resizeHandle,
+          });
+          return;
+        }
+      }
     }
     const hitElement = getElementAtPosition(
       globalSceneState.getAllElements(),
@@ -1691,34 +1701,57 @@ export class App extends React.Component<any, AppState> {
 
     type ResizeTestType = ReturnType<typeof resizeTest>;
     let resizeHandle: ResizeTestType = false;
+    const setResizeHandle = (nextResizeHandle: ResizeTestType) => {
+      resizeHandle = nextResizeHandle;
+    };
     let isResizingElements = false;
     let draggingOccurred = false;
     let hitElement: ExcalidrawElement | null = null;
     let hitElementWasAddedToSelection = false;
     if (this.state.elementType === "selection") {
-      const resizeElement = getElementWithResizeHandler(
-        globalSceneState.getAllElements(),
-        this.state,
-        { x, y },
-        this.state.zoom,
-        event.pointerType,
-      );
-
       const selectedElements = getSelectedElements(
         globalSceneState.getAllElements(),
         this.state,
       );
-      if (selectedElements.length === 1 && resizeElement) {
-        this.setState({
-          resizingElement: resizeElement ? resizeElement.element : null,
-        });
-
-        resizeHandle = resizeElement.resizeHandle;
-        document.documentElement.style.cursor = getCursorForResizingElement(
-          resizeElement,
+      if (selectedElements.length === 1) {
+        const elementWithResizeHandler = getElementWithResizeHandler(
+          globalSceneState.getAllElements(),
+          this.state,
+          { x, y },
+          this.state.zoom,
+          event.pointerType,
         );
-        isResizingElements = true;
-      } else {
+        if (elementWithResizeHandler) {
+          this.setState({
+            resizingElement: elementWithResizeHandler
+              ? elementWithResizeHandler.element
+              : null,
+          });
+          resizeHandle = elementWithResizeHandler.resizeHandle;
+          document.documentElement.style.cursor = getCursorForResizingElement(
+            elementWithResizeHandler,
+          );
+          isResizingElements = true;
+        }
+      } else if (selectedElements.length > 1) {
+        if (canResizeMutlipleElements(selectedElements)) {
+          resizeHandle = getResizeHandlerFromCoords(
+            getCommonBounds(selectedElements),
+            { x, y },
+            this.state.zoom,
+            event.pointerType,
+          );
+          if (resizeHandle) {
+            document.documentElement.style.cursor = getCursorForResizingElement(
+              {
+                resizeHandle,
+              },
+            );
+            isResizingElements = true;
+          }
+        }
+      }
+      if (!isResizingElements) {
         hitElement = getElementAtPosition(
           globalSceneState.getAllElements(),
           this.state,
@@ -1908,82 +1941,9 @@ export class App extends React.Component<any, AppState> {
       }
     }
 
-    let resizeArrowFn:
-      | ((
-          element: ExcalidrawLinearElement,
-          pointIndex: number,
-          deltaX: number,
-          deltaY: number,
-          pointerX: number,
-          pointerY: number,
-          perfect: boolean,
-        ) => void)
-      | null = null;
-
-    const arrowResizeOrigin = (
-      element: ExcalidrawLinearElement,
-      pointIndex: number,
-      deltaX: number,
-      deltaY: number,
-      pointerX: number,
-      pointerY: number,
-      perfect: boolean,
-    ) => {
-      const [px, py] = element.points[pointIndex];
-      let x = element.x + deltaX;
-      let y = element.y + deltaY;
-      let pointX = px - deltaX;
-      let pointY = py - deltaY;
-
-      if (perfect) {
-        const { width, height } = getPerfectElementSize(
-          element.type,
-          px + element.x - pointerX,
-          py + element.y - pointerY,
-        );
-        x = px + element.x - width;
-        y = py + element.y - height;
-        pointX = width;
-        pointY = height;
-      }
-
-      mutateElement(element, {
-        x,
-        y,
-        points: element.points.map((point, i) =>
-          i === pointIndex ? ([pointX, pointY] as const) : point,
-        ),
-      });
-    };
-
-    const arrowResizeEnd = (
-      element: ExcalidrawLinearElement,
-      pointIndex: number,
-      deltaX: number,
-      deltaY: number,
-      pointerX: number,
-      pointerY: number,
-      perfect: boolean,
-    ) => {
-      const [px, py] = element.points[pointIndex];
-      if (perfect) {
-        const { width, height } = getPerfectElementSize(
-          element.type,
-          pointerX - element.x,
-          pointerY - element.y,
-        );
-        mutateElement(element, {
-          points: element.points.map((point, i) =>
-            i === pointIndex ? ([width, height] as const) : point,
-          ),
-        });
-      } else {
-        mutateElement(element, {
-          points: element.points.map((point, i) =>
-            i === pointIndex ? ([px + deltaX, py + deltaY] as const) : point,
-          ),
-        });
-      }
+    let resizeArrowFn: ResizeArrowFnType | null = null;
+    const setResizeArrrowFn = (fn: ResizeArrowFnType) => {
+      resizeArrowFn = fn;
     };
 
     const onPointerMove = withBatchedUpdates((event: PointerEvent) => {
@@ -2012,6 +1972,13 @@ export class App extends React.Component<any, AppState> {
         return;
       }
 
+      const { x, y } = viewportCoordsToSceneCoords(
+        event,
+        this.state,
+        this.canvas,
+        window.devicePixelRatio,
+      );
+
       // for arrows, don't start dragging until a given threshold
       //  to ensure we don't create a 2-point arrow by mistake when
       //  user clicks mouse in a way that it moves a tiny bit (thus
@@ -2021,289 +1988,30 @@ export class App extends React.Component<any, AppState> {
         (this.state.elementType === "arrow" ||
           this.state.elementType === "line")
       ) {
-        const { x, y } = viewportCoordsToSceneCoords(
-          event,
-          this.state,
-          this.canvas,
-          window.devicePixelRatio,
-        );
         if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD) {
           return;
         }
       }
 
-      if (isResizingElements && this.state.resizingElement) {
-        this.setState({
-          isResizing: resizeHandle !== "rotation",
-          isRotating: resizeHandle === "rotation",
-        });
-        const el = this.state.resizingElement;
-        const selectedElements = getSelectedElements(
-          globalSceneState.getAllElements(),
+      const resized =
+        isResizingElements &&
+        resizeElements(
+          resizeHandle,
+          setResizeHandle,
           this.state,
+          this.setAppState,
+          resizeArrowFn,
+          setResizeArrrowFn,
+          event,
+          x,
+          y,
+          lastX,
+          lastY,
         );
-        if (selectedElements.length === 1) {
-          const { x, y } = viewportCoordsToSceneCoords(
-            event,
-            this.state,
-            this.canvas,
-            window.devicePixelRatio,
-          );
-          const element = selectedElements[0];
-          const angle = element.angle;
-          // reverse rotate delta
-          const [deltaX, deltaY] = rotate(x - lastX, y - lastY, 0, 0, -angle);
-          switch (resizeHandle) {
-            case "nw":
-              if (isLinearElement(element) && element.points.length === 2) {
-                const [, p1] = element.points;
-
-                if (!resizeArrowFn) {
-                  if (p1[0] < 0 || p1[1] < 0) {
-                    resizeArrowFn = arrowResizeEnd;
-                  } else {
-                    resizeArrowFn = arrowResizeOrigin;
-                  }
-                }
-                resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey);
-              } else {
-                const width = element.width - deltaX;
-                const height = event.shiftKey ? width : element.height - deltaY;
-                const dY = element.height - height;
-                mutateElement(element, {
-                  width,
-                  height,
-                  ...adjustXYWithRotation("nw", element, deltaX, dY, angle),
-                  ...(isLinearElement(element) && width >= 0 && height >= 0
-                    ? {
-                        points: rescalePoints(
-                          0,
-                          width,
-                          rescalePoints(1, height, element.points),
-                        ),
-                      }
-                    : {}),
-                });
-              }
-              break;
-            case "ne":
-              if (isLinearElement(element) && element.points.length === 2) {
-                const [, p1] = element.points;
-                if (!resizeArrowFn) {
-                  if (p1[0] >= 0) {
-                    resizeArrowFn = arrowResizeEnd;
-                  } else {
-                    resizeArrowFn = arrowResizeOrigin;
-                  }
-                }
-                resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey);
-              } else {
-                const width = element.width + deltaX;
-                const height = event.shiftKey ? width : element.height - deltaY;
-                const dY = element.height - height;
-                mutateElement(element, {
-                  width,
-                  height,
-                  ...adjustXYWithRotation("ne", element, deltaX, dY, angle),
-                  ...(isLinearElement(element) && width >= 0 && height >= 0
-                    ? {
-                        points: rescalePoints(
-                          0,
-                          width,
-                          rescalePoints(1, height, element.points),
-                        ),
-                      }
-                    : {}),
-                });
-              }
-              break;
-            case "sw":
-              if (isLinearElement(element) && element.points.length === 2) {
-                const [, p1] = element.points;
-                if (!resizeArrowFn) {
-                  if (p1[0] <= 0) {
-                    resizeArrowFn = arrowResizeEnd;
-                  } else {
-                    resizeArrowFn = arrowResizeOrigin;
-                  }
-                }
-                resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey);
-              } else {
-                const width = element.width - deltaX;
-                const height = event.shiftKey ? width : element.height + deltaY;
-                const dY = height - element.height;
-                mutateElement(element, {
-                  width,
-                  height,
-                  ...adjustXYWithRotation("sw", element, deltaX, dY, angle),
-                  ...(isLinearElement(element) && width >= 0 && height >= 0
-                    ? {
-                        points: rescalePoints(
-                          0,
-                          width,
-                          rescalePoints(1, height, element.points),
-                        ),
-                      }
-                    : {}),
-                });
-              }
-              break;
-            case "se":
-              if (isLinearElement(element) && element.points.length === 2) {
-                const [, p1] = element.points;
-                if (!resizeArrowFn) {
-                  if (p1[0] > 0 || p1[1] > 0) {
-                    resizeArrowFn = arrowResizeEnd;
-                  } else {
-                    resizeArrowFn = arrowResizeOrigin;
-                  }
-                }
-                resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey);
-              } else {
-                const width = element.width + deltaX;
-                const height = event.shiftKey ? width : element.height + deltaY;
-                const dY = height - element.height;
-                mutateElement(element, {
-                  width,
-                  height,
-                  ...adjustXYWithRotation("se", element, deltaX, dY, angle),
-                  ...(isLinearElement(element) && width >= 0 && height >= 0
-                    ? {
-                        points: rescalePoints(
-                          0,
-                          width,
-                          rescalePoints(1, height, element.points),
-                        ),
-                      }
-                    : {}),
-                });
-              }
-              break;
-            case "n": {
-              const height = element.height - deltaY;
-
-              if (isLinearElement(element)) {
-                if (element.points.length > 2 && height <= 0) {
-                  // Someday we should implement logic to flip the shape.
-                  // But for now, just stop.
-                  break;
-                }
-                mutateElement(element, {
-                  height,
-                  ...adjustXYWithRotation("n", element, 0, deltaY, angle),
-                  points: rescalePoints(1, height, element.points),
-                });
-              } else {
-                mutateElement(element, {
-                  height,
-                  ...adjustXYWithRotation("n", element, 0, deltaY, angle),
-                });
-              }
-
-              break;
-            }
-            case "w": {
-              const width = element.width - deltaX;
-
-              if (isLinearElement(element)) {
-                if (element.points.length > 2 && width <= 0) {
-                  // Someday we should implement logic to flip the shape.
-                  // But for now, just stop.
-                  break;
-                }
-
-                mutateElement(element, {
-                  width,
-                  ...adjustXYWithRotation("w", element, deltaX, 0, angle),
-                  points: rescalePoints(0, width, element.points),
-                });
-              } else {
-                mutateElement(element, {
-                  width,
-                  ...adjustXYWithRotation("w", element, deltaX, 0, angle),
-                });
-              }
-              break;
-            }
-            case "s": {
-              const height = element.height + deltaY;
-
-              if (isLinearElement(element)) {
-                if (element.points.length > 2 && height <= 0) {
-                  // Someday we should implement logic to flip the shape.
-                  // But for now, just stop.
-                  break;
-                }
-                mutateElement(element, {
-                  height,
-                  ...adjustXYWithRotation("s", element, 0, deltaY, angle),
-                  points: rescalePoints(1, height, element.points),
-                });
-              } else {
-                mutateElement(element, {
-                  height,
-                  ...adjustXYWithRotation("s", element, 0, deltaY, angle),
-                });
-              }
-              break;
-            }
-            case "e": {
-              const width = element.width + deltaX;
-
-              if (isLinearElement(element)) {
-                if (element.points.length > 2 && width <= 0) {
-                  // Someday we should implement logic to flip the shape.
-                  // But for now, just stop.
-                  break;
-                }
-                mutateElement(element, {
-                  width,
-                  ...adjustXYWithRotation("e", element, deltaX, 0, angle),
-                  points: rescalePoints(0, width, element.points),
-                });
-              } else {
-                mutateElement(element, {
-                  width,
-                  ...adjustXYWithRotation("e", element, deltaX, 0, angle),
-                });
-              }
-              break;
-            }
-            case "rotation": {
-              const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-              const cx = (x1 + x2) / 2;
-              const cy = (y1 + y2) / 2;
-              let angle = (5 * Math.PI) / 2 + Math.atan2(y - cy, x - cx);
-              if (event.shiftKey) {
-                angle += SHIFT_LOCKING_ANGLE / 2;
-                angle -= angle % SHIFT_LOCKING_ANGLE;
-              }
-              if (angle >= 2 * Math.PI) {
-                angle -= 2 * Math.PI;
-              }
-              mutateElement(element, { angle });
-              break;
-            }
-          }
-
-          if (resizeHandle) {
-            resizeHandle = normalizeResizeHandle(element, resizeHandle);
-          }
-          normalizeDimensions(element);
-
-          document.documentElement.style.cursor = getCursorForResizingElement({
-            element,
-            resizeHandle,
-          });
-          mutateElement(el, {
-            x: element.x,
-            y: element.y,
-          });
-
-          lastX = x;
-          lastY = y;
-          return;
-        }
+      if (resized) {
+        lastX = x;
+        lastY = y;
+        return;
       }
 
       if (hitElement && this.state.selectedElementIds[hitElement.id]) {
@@ -2341,13 +2049,6 @@ export class App extends React.Component<any, AppState> {
         return;
       }
 
-      const { x, y } = viewportCoordsToSceneCoords(
-        event,
-        this.state,
-        this.canvas,
-        window.devicePixelRatio,
-      );
-
       let width = distance(originX, x);
       let height = distance(originY, y);
 
@@ -2533,7 +2234,7 @@ export class App extends React.Component<any, AppState> {
             },
           }));
         } else {
-          this.setState((prevState) => ({
+          this.setState((_prevState) => ({
             selectedElementIds: { [hitElement!.id]: true },
           }));
         }

+ 6 - 2
src/components/HintViewer.tsx

@@ -22,8 +22,12 @@ const getHints = ({ appState, elements }: Hint) => {
     return t("hints.linearElementMulti");
   }
 
-  if (isResizing && lastPointerDownWith === "mouse") {
-    const selectedElements = getSelectedElements(elements, appState);
+  const selectedElements = getSelectedElements(elements, appState);
+  if (
+    isResizing &&
+    lastPointerDownWith === "mouse" &&
+    selectedElements.length === 1
+  ) {
     const targetElement = selectedElements[0];
     if (isLinearElement(targetElement) && targetElement.points.length > 2) {
       return null;

+ 7 - 3
src/element/bounds.ts

@@ -7,7 +7,9 @@ import { isLinearElement } from "./typeChecks";
 
 // If the element is created from right to left, the width is going to be negative
 // This set of functions retrieves the absolute position of the 4 points.
-export function getElementAbsoluteCoords(element: ExcalidrawElement) {
+export function getElementAbsoluteCoords(
+  element: ExcalidrawElement,
+): [number, number, number, number] {
   if (isLinearElement(element)) {
     return getLinearElementAbsoluteBounds(element);
   }
@@ -36,7 +38,7 @@ export function getDiamondPoints(element: ExcalidrawElement) {
 
 export function getLinearElementAbsoluteBounds(
   element: ExcalidrawLinearElement,
-) {
+): [number, number, number, number] {
   if (element.points.length < 2 || !getShapeForElement(element)) {
     const { minX, minY, maxX, maxY } = element.points.reduce(
       (limits, [x, y]) => {
@@ -186,7 +188,9 @@ export function getArrowPoints(
   return [x2, y2, x3, y3, x4, y4];
 }
 
-export function getCommonBounds(elements: readonly ExcalidrawElement[]) {
+export function getCommonBounds(
+  elements: readonly ExcalidrawElement[],
+): [number, number, number, number] {
   if (!elements.length) {
     return [0, 0, 0, 0];
   }

+ 125 - 86
src/element/handlerRectangles.ts

@@ -13,6 +13,14 @@ const handleSizes: { [k in PointerType]: number } = {
 
 const ROTATION_HANDLER_GAP = 16;
 
+export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
+  e: true,
+  s: true,
+  n: true,
+  w: true,
+  rotation: true,
+};
+
 function generateHandler(
   x: number,
   y: number,
@@ -26,11 +34,13 @@ function generateHandler(
   return [xx - width / 2, yy - height / 2, width, height];
 }
 
-export function handlerRectangles(
-  element: ExcalidrawElement,
+export function handlerRectanglesFromCoords(
+  [x1, y1, x2, y2]: [number, number, number, number],
+  angle: number,
   zoom: number,
   pointerType: PointerType = "mouse",
-) {
+  omitSides: { [T in Sides]?: boolean } = {},
+): Partial<{ [T in Sides]: [number, number, number, number] }> {
   const size = handleSizes[pointerType];
   const handlerWidth = size / zoom;
   const handlerHeight = size / zoom;
@@ -38,116 +48,145 @@ export function handlerRectangles(
   const handlerMarginX = size / zoom;
   const handlerMarginY = size / zoom;
 
-  const [elementX1, elementY1, elementX2, elementY2] = getElementAbsoluteCoords(
-    element,
-  );
-
-  const elementWidth = elementX2 - elementX1;
-  const elementHeight = elementY2 - elementY1;
-  const cx = (elementX1 + elementX2) / 2;
-  const cy = (elementY1 + elementY2) / 2;
-  const angle = element.angle;
+  const width = x2 - x1;
+  const height = y2 - y1;
+  const cx = (x1 + x2) / 2;
+  const cy = (y1 + y2) / 2;
 
   const dashedLineMargin = 4 / zoom;
 
   const centeringOffset = (size - 8) / (2 * zoom);
 
-  const handlers =
-    {
-      nw: generateHandler(
-        elementX1 - dashedLineMargin - handlerMarginX + centeringOffset,
-        elementY1 - dashedLineMargin - handlerMarginY + centeringOffset,
-        handlerWidth,
-        handlerHeight,
-        cx,
-        cy,
-        angle,
-      ),
-      ne: generateHandler(
-        elementX2 + dashedLineMargin - centeringOffset,
-        elementY1 - dashedLineMargin - handlerMarginY + centeringOffset,
+  const handlers: Partial<
+    { [T in Sides]: [number, number, number, number] }
+  > = {
+    nw: omitSides["nw"]
+      ? undefined
+      : generateHandler(
+          x1 - dashedLineMargin - handlerMarginX + centeringOffset,
+          y1 - dashedLineMargin - handlerMarginY + centeringOffset,
+          handlerWidth,
+          handlerHeight,
+          cx,
+          cy,
+          angle,
+        ),
+    ne: omitSides["ne"]
+      ? undefined
+      : generateHandler(
+          x2 + dashedLineMargin - centeringOffset,
+          y1 - dashedLineMargin - handlerMarginY + centeringOffset,
+          handlerWidth,
+          handlerHeight,
+          cx,
+          cy,
+          angle,
+        ),
+    sw: omitSides["sw"]
+      ? undefined
+      : generateHandler(
+          x1 - dashedLineMargin - handlerMarginX + centeringOffset,
+          y2 + dashedLineMargin - centeringOffset,
+          handlerWidth,
+          handlerHeight,
+          cx,
+          cy,
+          angle,
+        ),
+    se: omitSides["se"]
+      ? undefined
+      : generateHandler(
+          x2 + dashedLineMargin - centeringOffset,
+          y2 + dashedLineMargin - centeringOffset,
+          handlerWidth,
+          handlerHeight,
+          cx,
+          cy,
+          angle,
+        ),
+    rotation: omitSides["rotation"]
+      ? undefined
+      : generateHandler(
+          x1 + width / 2 - handlerWidth / 2,
+          y1 -
+            dashedLineMargin -
+            handlerMarginY +
+            centeringOffset -
+            ROTATION_HANDLER_GAP / zoom,
+          handlerWidth,
+          handlerHeight,
+          cx,
+          cy,
+          angle,
+        ),
+  };
+
+  // We only want to show height handlers (all cardinal directions)  above a certain size
+  const minimumSizeForEightHandlers = (5 * size) / zoom;
+  if (Math.abs(width) > minimumSizeForEightHandlers) {
+    if (!omitSides["n"]) {
+      handlers["n"] = generateHandler(
+        x1 + width / 2 - handlerWidth / 2,
+        y1 - dashedLineMargin - handlerMarginY + centeringOffset,
         handlerWidth,
         handlerHeight,
         cx,
         cy,
         angle,
-      ),
-      sw: generateHandler(
-        elementX1 - dashedLineMargin - handlerMarginX + centeringOffset,
-        elementY2 + dashedLineMargin - centeringOffset,
+      );
+    }
+    if (!omitSides["s"]) {
+      handlers["s"] = generateHandler(
+        x1 + width / 2 - handlerWidth / 2,
+        y2 + dashedLineMargin - centeringOffset,
         handlerWidth,
         handlerHeight,
         cx,
         cy,
         angle,
-      ),
-      se: generateHandler(
-        elementX2 + dashedLineMargin - centeringOffset,
-        elementY2 + dashedLineMargin - centeringOffset,
+      );
+    }
+  }
+  if (Math.abs(height) > minimumSizeForEightHandlers) {
+    if (!omitSides["w"]) {
+      handlers["w"] = generateHandler(
+        x1 - dashedLineMargin - handlerMarginX + centeringOffset,
+        y1 + height / 2 - handlerHeight / 2,
         handlerWidth,
         handlerHeight,
         cx,
         cy,
         angle,
-      ),
-      rotation: generateHandler(
-        elementX1 + elementWidth / 2 - handlerWidth / 2,
-        elementY1 -
-          dashedLineMargin -
-          handlerMarginY +
-          centeringOffset -
-          ROTATION_HANDLER_GAP / zoom,
+      );
+    }
+    if (!omitSides["e"]) {
+      handlers["e"] = generateHandler(
+        x2 + dashedLineMargin - centeringOffset,
+        y1 + height / 2 - handlerHeight / 2,
         handlerWidth,
         handlerHeight,
         cx,
         cy,
         angle,
-      ),
-    } as { [T in Sides]: [number, number, number, number] };
-
-  // We only want to show height handlers (all cardinal directions)  above a certain size
-  const minimumSizeForEightHandlers = (5 * size) / zoom;
-  if (Math.abs(elementWidth) > minimumSizeForEightHandlers) {
-    handlers["n"] = generateHandler(
-      elementX1 + elementWidth / 2 - handlerWidth / 2,
-      elementY1 - dashedLineMargin - handlerMarginY + centeringOffset,
-      handlerWidth,
-      handlerHeight,
-      cx,
-      cy,
-      angle,
-    );
-    handlers["s"] = generateHandler(
-      elementX1 + elementWidth / 2 - handlerWidth / 2,
-      elementY2 + dashedLineMargin - centeringOffset,
-      handlerWidth,
-      handlerHeight,
-      cx,
-      cy,
-      angle,
-    );
-  }
-  if (Math.abs(elementHeight) > minimumSizeForEightHandlers) {
-    handlers["w"] = generateHandler(
-      elementX1 - dashedLineMargin - handlerMarginX + centeringOffset,
-      elementY1 + elementHeight / 2 - handlerHeight / 2,
-      handlerWidth,
-      handlerHeight,
-      cx,
-      cy,
-      angle,
-    );
-    handlers["e"] = generateHandler(
-      elementX2 + dashedLineMargin - centeringOffset,
-      elementY1 + elementHeight / 2 - handlerHeight / 2,
-      handlerWidth,
-      handlerHeight,
-      cx,
-      cy,
-      angle,
-    );
+      );
+    }
   }
 
+  return handlers;
+}
+
+export function handlerRectangles(
+  element: ExcalidrawElement,
+  zoom: number,
+  pointerType: PointerType = "mouse",
+) {
+  const handlers = handlerRectanglesFromCoords(
+    getElementAbsoluteCoords(element),
+    element.angle,
+    zoom,
+    pointerType,
+  );
+
   if (element.type === "arrow" || element.type === "line") {
     if (element.points.length === 2) {
       // only check the last point because starting point is always (0,0)

+ 9 - 1
src/element/index.ts

@@ -15,13 +15,21 @@ export {
   getLinearElementAbsoluteBounds,
 } from "./bounds";
 
-export { handlerRectangles } from "./handlerRectangles";
+export {
+  OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
+  handlerRectanglesFromCoords,
+  handlerRectangles,
+} from "./handlerRectangles";
 export { hitTest } from "./collision";
 export {
   resizeTest,
   getCursorForResizingElement,
   normalizeResizeHandle,
+  getElementWithResizeHandler,
+  getResizeHandlerFromCoords,
 } from "./resizeTest";
+export type { ResizeArrowFnType } from "./resizeElements";
+export { resizeElements, canResizeMutlipleElements } from "./resizeElements";
 export { isTextElement, isExcalidrawElement } from "./typeChecks";
 export { textWysiwyg } from "./textWysiwyg";
 export { redrawTextBoundingBox } from "./textElement";

+ 459 - 0
src/element/resizeElements.ts

@@ -0,0 +1,459 @@
+import { AppState } from "../types";
+import { SHIFT_LOCKING_ANGLE } from "../constants";
+import { getSelectedElements, globalSceneState } from "../scene";
+import { rescalePoints } from "../points";
+import { rotate, adjustXYWithRotation } from "../math";
+import { ExcalidrawElement, ExcalidrawLinearElement } from "./types";
+import { getElementAbsoluteCoords, getCommonBounds } from "./bounds";
+import { isLinearElement } from "./typeChecks";
+import { mutateElement } from "./mutateElement";
+import { getPerfectElementSize, normalizeDimensions } from "./sizeHelpers";
+import {
+  resizeTest,
+  getCursorForResizingElement,
+  normalizeResizeHandle,
+} from "./resizeTest";
+
+type ResizeTestType = ReturnType<typeof resizeTest>;
+
+export type ResizeArrowFnType = (
+  element: ExcalidrawLinearElement,
+  pointIndex: number,
+  deltaX: number,
+  deltaY: number,
+  pointerX: number,
+  pointerY: number,
+  perfect: boolean,
+) => void;
+
+const arrowResizeOrigin: ResizeArrowFnType = (
+  element: ExcalidrawLinearElement,
+  pointIndex: number,
+  deltaX: number,
+  deltaY: number,
+  pointerX: number,
+  pointerY: number,
+  perfect: boolean,
+) => {
+  const [px, py] = element.points[pointIndex];
+  let x = element.x + deltaX;
+  let y = element.y + deltaY;
+  let pointX = px - deltaX;
+  let pointY = py - deltaY;
+
+  if (perfect) {
+    const { width, height } = getPerfectElementSize(
+      element.type,
+      px + element.x - pointerX,
+      py + element.y - pointerY,
+    );
+    x = px + element.x - width;
+    y = py + element.y - height;
+    pointX = width;
+    pointY = height;
+  }
+
+  mutateElement(element, {
+    x,
+    y,
+    points: element.points.map((point, i) =>
+      i === pointIndex ? ([pointX, pointY] as const) : point,
+    ),
+  });
+};
+
+const arrowResizeEnd: ResizeArrowFnType = (
+  element: ExcalidrawLinearElement,
+  pointIndex: number,
+  deltaX: number,
+  deltaY: number,
+  pointerX: number,
+  pointerY: number,
+  perfect: boolean,
+) => {
+  const [px, py] = element.points[pointIndex];
+  if (perfect) {
+    const { width, height } = getPerfectElementSize(
+      element.type,
+      pointerX - element.x,
+      pointerY - element.y,
+    );
+    mutateElement(element, {
+      points: element.points.map((point, i) =>
+        i === pointIndex ? ([width, height] as const) : point,
+      ),
+    });
+  } else {
+    mutateElement(element, {
+      points: element.points.map((point, i) =>
+        i === pointIndex ? ([px + deltaX, py + deltaY] as const) : point,
+      ),
+    });
+  }
+};
+
+export function resizeElements(
+  resizeHandle: ResizeTestType,
+  setResizeHandle: (nextResizeHandle: ResizeTestType) => void,
+  appState: AppState,
+  setAppState: (obj: any) => void,
+  resizeArrowFn: ResizeArrowFnType | null,
+  setResizeArrowFn: (fn: ResizeArrowFnType) => void,
+  event: PointerEvent,
+  x: number,
+  y: number,
+  lastX: number,
+  lastY: number,
+) {
+  setAppState({
+    isResizing: resizeHandle !== "rotation",
+    isRotating: resizeHandle === "rotation",
+  });
+  const selectedElements = getSelectedElements(
+    globalSceneState.getAllElements(),
+    appState,
+  );
+  if (selectedElements.length === 1) {
+    const element = selectedElements[0];
+    const angle = element.angle;
+    // reverse rotate delta
+    const [deltaX, deltaY] = rotate(x - lastX, y - lastY, 0, 0, -angle);
+    switch (resizeHandle) {
+      case "nw":
+        if (isLinearElement(element) && element.points.length === 2) {
+          const [, p1] = element.points;
+
+          if (!resizeArrowFn) {
+            if (p1[0] < 0 || p1[1] < 0) {
+              resizeArrowFn = arrowResizeEnd;
+            } else {
+              resizeArrowFn = arrowResizeOrigin;
+            }
+          }
+          resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey);
+          setResizeArrowFn(resizeArrowFn);
+        } else {
+          const width = element.width - deltaX;
+          const height = event.shiftKey ? width : element.height - deltaY;
+          const dY = element.height - height;
+          mutateElement(element, {
+            width,
+            height,
+            ...adjustXYWithRotation("nw", element, deltaX, dY, angle),
+            ...(isLinearElement(element) && width >= 0 && height >= 0
+              ? {
+                  points: rescalePoints(
+                    0,
+                    width,
+                    rescalePoints(1, height, element.points),
+                  ),
+                }
+              : {}),
+          });
+        }
+        break;
+      case "ne":
+        if (isLinearElement(element) && element.points.length === 2) {
+          const [, p1] = element.points;
+          if (!resizeArrowFn) {
+            if (p1[0] >= 0) {
+              resizeArrowFn = arrowResizeEnd;
+            } else {
+              resizeArrowFn = arrowResizeOrigin;
+            }
+          }
+          resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey);
+          setResizeArrowFn(resizeArrowFn);
+        } else {
+          const width = element.width + deltaX;
+          const height = event.shiftKey ? width : element.height - deltaY;
+          const dY = element.height - height;
+          mutateElement(element, {
+            width,
+            height,
+            ...adjustXYWithRotation("ne", element, deltaX, dY, angle),
+            ...(isLinearElement(element) && width >= 0 && height >= 0
+              ? {
+                  points: rescalePoints(
+                    0,
+                    width,
+                    rescalePoints(1, height, element.points),
+                  ),
+                }
+              : {}),
+          });
+        }
+        break;
+      case "sw":
+        if (isLinearElement(element) && element.points.length === 2) {
+          const [, p1] = element.points;
+          if (!resizeArrowFn) {
+            if (p1[0] <= 0) {
+              resizeArrowFn = arrowResizeEnd;
+            } else {
+              resizeArrowFn = arrowResizeOrigin;
+            }
+          }
+          resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey);
+          setResizeArrowFn(resizeArrowFn);
+        } else {
+          const width = element.width - deltaX;
+          const height = event.shiftKey ? width : element.height + deltaY;
+          const dY = height - element.height;
+          mutateElement(element, {
+            width,
+            height,
+            ...adjustXYWithRotation("sw", element, deltaX, dY, angle),
+            ...(isLinearElement(element) && width >= 0 && height >= 0
+              ? {
+                  points: rescalePoints(
+                    0,
+                    width,
+                    rescalePoints(1, height, element.points),
+                  ),
+                }
+              : {}),
+          });
+        }
+        break;
+      case "se":
+        if (isLinearElement(element) && element.points.length === 2) {
+          const [, p1] = element.points;
+          if (!resizeArrowFn) {
+            if (p1[0] > 0 || p1[1] > 0) {
+              resizeArrowFn = arrowResizeEnd;
+            } else {
+              resizeArrowFn = arrowResizeOrigin;
+            }
+          }
+          resizeArrowFn(element, 1, deltaX, deltaY, x, y, event.shiftKey);
+          setResizeArrowFn(resizeArrowFn);
+        } else {
+          const width = element.width + deltaX;
+          const height = event.shiftKey ? width : element.height + deltaY;
+          const dY = height - element.height;
+          mutateElement(element, {
+            width,
+            height,
+            ...adjustXYWithRotation("se", element, deltaX, dY, angle),
+            ...(isLinearElement(element) && width >= 0 && height >= 0
+              ? {
+                  points: rescalePoints(
+                    0,
+                    width,
+                    rescalePoints(1, height, element.points),
+                  ),
+                }
+              : {}),
+          });
+        }
+        break;
+      case "n": {
+        const height = element.height - deltaY;
+
+        if (isLinearElement(element)) {
+          if (element.points.length > 2 && height <= 0) {
+            // Someday we should implement logic to flip the shape.
+            // But for now, just stop.
+            break;
+          }
+          mutateElement(element, {
+            height,
+            ...adjustXYWithRotation("n", element, 0, deltaY, angle),
+            points: rescalePoints(1, height, element.points),
+          });
+        } else {
+          mutateElement(element, {
+            height,
+            ...adjustXYWithRotation("n", element, 0, deltaY, angle),
+          });
+        }
+
+        break;
+      }
+      case "w": {
+        const width = element.width - deltaX;
+
+        if (isLinearElement(element)) {
+          if (element.points.length > 2 && width <= 0) {
+            // Someday we should implement logic to flip the shape.
+            // But for now, just stop.
+            break;
+          }
+
+          mutateElement(element, {
+            width,
+            ...adjustXYWithRotation("w", element, deltaX, 0, angle),
+            points: rescalePoints(0, width, element.points),
+          });
+        } else {
+          mutateElement(element, {
+            width,
+            ...adjustXYWithRotation("w", element, deltaX, 0, angle),
+          });
+        }
+        break;
+      }
+      case "s": {
+        const height = element.height + deltaY;
+
+        if (isLinearElement(element)) {
+          if (element.points.length > 2 && height <= 0) {
+            // Someday we should implement logic to flip the shape.
+            // But for now, just stop.
+            break;
+          }
+          mutateElement(element, {
+            height,
+            ...adjustXYWithRotation("s", element, 0, deltaY, angle),
+            points: rescalePoints(1, height, element.points),
+          });
+        } else {
+          mutateElement(element, {
+            height,
+            ...adjustXYWithRotation("s", element, 0, deltaY, angle),
+          });
+        }
+        break;
+      }
+      case "e": {
+        const width = element.width + deltaX;
+
+        if (isLinearElement(element)) {
+          if (element.points.length > 2 && width <= 0) {
+            // Someday we should implement logic to flip the shape.
+            // But for now, just stop.
+            break;
+          }
+          mutateElement(element, {
+            width,
+            ...adjustXYWithRotation("e", element, deltaX, 0, angle),
+            points: rescalePoints(0, width, element.points),
+          });
+        } else {
+          mutateElement(element, {
+            width,
+            ...adjustXYWithRotation("e", element, deltaX, 0, angle),
+          });
+        }
+        break;
+      }
+      case "rotation": {
+        const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+        const cx = (x1 + x2) / 2;
+        const cy = (y1 + y2) / 2;
+        let angle = (5 * Math.PI) / 2 + Math.atan2(y - cy, x - cx);
+        if (event.shiftKey) {
+          angle += SHIFT_LOCKING_ANGLE / 2;
+          angle -= angle % SHIFT_LOCKING_ANGLE;
+        }
+        if (angle >= 2 * Math.PI) {
+          angle -= 2 * Math.PI;
+        }
+        mutateElement(element, { angle });
+        break;
+      }
+    }
+
+    if (resizeHandle) {
+      setResizeHandle(normalizeResizeHandle(element, resizeHandle));
+    }
+    normalizeDimensions(element);
+
+    // do we need this?
+    document.documentElement.style.cursor = getCursorForResizingElement({
+      element,
+      resizeHandle,
+    });
+    // why do we need this?
+    if (appState.resizingElement) {
+      mutateElement(appState.resizingElement, {
+        x: element.x,
+        y: element.y,
+      });
+    }
+
+    return true;
+  } else if (selectedElements.length > 1) {
+    const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
+    const handleOffset = 4 / appState.zoom; // XXX import constant
+    const dashedLinePadding = 4 / appState.zoom; // XXX import constant
+    const minSize = handleOffset * 4;
+    const minScale = Math.max(minSize / (x2 - x1), minSize / (y2 - y1));
+    switch (resizeHandle) {
+      case "se": {
+        const scale = Math.max(
+          (x - handleOffset - dashedLinePadding - x1) / (x2 - x1),
+          (y - handleOffset - dashedLinePadding - y1) / (y2 - y1),
+        );
+        if (scale > minScale) {
+          selectedElements.forEach((element) => {
+            const width = element.width * scale;
+            const height = element.height * scale;
+            const x = element.x + (element.x - x1) * (scale - 1);
+            const y = element.y + (element.y - y1) * (scale - 1);
+            mutateElement(element, { width, height, x, y });
+          });
+        }
+        return true;
+      }
+      case "nw": {
+        const scale = Math.max(
+          (x2 - handleOffset - dashedLinePadding - x) / (x2 - x1),
+          (y2 - handleOffset - dashedLinePadding - y) / (y2 - y1),
+        );
+        if (scale > minScale) {
+          selectedElements.forEach((element) => {
+            const width = element.width * scale;
+            const height = element.height * scale;
+            const x = element.x - (x2 - element.x) * (scale - 1);
+            const y = element.y - (y2 - element.y) * (scale - 1);
+            mutateElement(element, { width, height, x, y });
+          });
+        }
+        return true;
+      }
+      case "ne": {
+        const scale = Math.max(
+          (x - handleOffset - dashedLinePadding - x1) / (x2 - x1),
+          (y2 - handleOffset - dashedLinePadding - y) / (y2 - y1),
+        );
+        if (scale > minScale) {
+          selectedElements.forEach((element) => {
+            const width = element.width * scale;
+            const height = element.height * scale;
+            const x = element.x + (element.x - x1) * (scale - 1);
+            const y = element.y - (y2 - element.y) * (scale - 1);
+            mutateElement(element, { width, height, x, y });
+          });
+        }
+        return true;
+      }
+      case "sw": {
+        const scale = Math.max(
+          (x2 - handleOffset - dashedLinePadding - x) / (x2 - x1),
+          (y - handleOffset - dashedLinePadding - y1) / (y2 - y1),
+        );
+        if (scale > minScale) {
+          selectedElements.forEach((element) => {
+            const width = element.width * scale;
+            const height = element.height * scale;
+            const x = element.x - (x2 - element.x) * (scale - 1);
+            const y = element.y + (element.y - y1) * (scale - 1);
+            mutateElement(element, { width, height, x, y });
+          });
+        }
+        return true;
+      }
+    }
+  }
+  return false;
+}
+
+export function canResizeMutlipleElements(
+  elements: readonly ExcalidrawElement[],
+) {
+  return elements.every((element) =>
+    ["rectangle", "diamond", "ellipse"].includes(element.type),
+  );
+}

+ 28 - 3
src/element/resizeTest.ts

@@ -1,6 +1,10 @@
 import { ExcalidrawElement, PointerType } from "./types";
 
-import { handlerRectangles } from "./handlerRectangles";
+import {
+  OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
+  handlerRectanglesFromCoords,
+  handlerRectangles,
+} from "./handlerRectangles";
 import { AppState } from "../types";
 import { isLinearElement } from "./typeChecks";
 
@@ -77,16 +81,37 @@ export function getElementWithResizeHandler(
   }, null as { element: ExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null);
 }
 
+export function getResizeHandlerFromCoords(
+  [x1, y1, x2, y2]: readonly [number, number, number, number],
+  { x, y }: { x: number; y: number },
+  zoom: number,
+  pointerType: PointerType,
+) {
+  const handlers = handlerRectanglesFromCoords(
+    [x1, y1, x2, y2],
+    0,
+    zoom,
+    pointerType,
+    OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
+  );
+
+  const found = Object.keys(handlers).find((key) => {
+    const handler = handlers[key as Exclude<HandlerRectanglesRet, "rotation">]!;
+    return handler && isInHandlerRect(handler, x, y);
+  });
+  return (found || false) as HandlerRectanglesRet;
+}
+
 /*
  * Returns bi-directional cursor for the element being resized
  */
 export function getCursorForResizingElement(resizingElement: {
-  element: ExcalidrawElement;
+  element?: ExcalidrawElement;
   resizeHandle: ReturnType<typeof resizeTest>;
 }): string {
   const { element, resizeHandle } = resizingElement;
   const shouldSwapCursors =
-    Math.sign(element.height) * Math.sign(element.width) === -1;
+    element && Math.sign(element.height) * Math.sign(element.width) === -1;
   let cursor = null;
 
   switch (resizeHandle) {

+ 58 - 1
src/renderer/renderScene.ts

@@ -3,7 +3,14 @@ import { RoughSVG } from "roughjs/bin/svg";
 
 import { FlooredNumber, AppState } from "../types";
 import { ExcalidrawElement } from "../element/types";
-import { getElementAbsoluteCoords, handlerRectangles } from "../element";
+import {
+  getElementAbsoluteCoords,
+  OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
+  handlerRectanglesFromCoords,
+  handlerRectangles,
+  getCommonBounds,
+  canResizeMutlipleElements,
+} from "../element";
 
 import { roundRect } from "./roundRect";
 import { SceneState } from "../scene/types";
@@ -263,6 +270,56 @@ export function renderScene(
         }
       });
       context.translate(-sceneState.scrollX, -sceneState.scrollY);
+    } else if (locallySelectedElements.length > 1) {
+      if (canResizeMutlipleElements(locallySelectedElements)) {
+        const dashedLinePadding = 4 / sceneState.zoom;
+        context.translate(sceneState.scrollX, sceneState.scrollY);
+        context.fillStyle = "#fff";
+        const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements);
+        const initialLineDash = context.getLineDash();
+        context.setLineDash([2 / sceneState.zoom]);
+        const lineWidth = context.lineWidth;
+        context.lineWidth = 1 / sceneState.zoom;
+        strokeRectWithRotation(
+          context,
+          x1 - dashedLinePadding,
+          y1 - dashedLinePadding,
+          x2 - x1 + dashedLinePadding * 2,
+          y2 - y1 + dashedLinePadding * 2,
+          (x1 + x2) / 2,
+          (y1 + y2) / 2,
+          0,
+        );
+        context.lineWidth = lineWidth;
+        context.setLineDash(initialLineDash);
+        const handlers = handlerRectanglesFromCoords(
+          [x1, y1, x2, y2],
+          0,
+          sceneState.zoom,
+          undefined,
+          OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
+        );
+        Object.keys(handlers).forEach((key) => {
+          const handler = handlers[key as HandlerRectanglesRet];
+          if (handler !== undefined) {
+            const lineWidth = context.lineWidth;
+            context.lineWidth = 1 / sceneState.zoom;
+            strokeRectWithRotation(
+              context,
+              handler[0],
+              handler[1],
+              handler[2],
+              handler[3],
+              handler[0] + handler[2] / 2,
+              handler[1] + handler[3] / 2,
+              0,
+              true, // fill before stroke
+            );
+            context.lineWidth = lineWidth;
+          }
+        });
+        context.translate(-sceneState.scrollX, -sceneState.scrollY);
+      }
     }
   }
 

+ 6 - 5
src/tests/regressionTests.test.tsx

@@ -162,12 +162,13 @@ function getSelectedElement(): ExcalidrawElement {
   return selectedElements[0];
 }
 
+type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
 function getResizeHandles() {
-  const rects = handlerRectangles(
-    getSelectedElement(),
-    h.state.zoom,
-    pointerType,
-  );
+  const rects =
+    handlerRectangles(getSelectedElement(), h.state.zoom, pointerType) as
+    {
+      [T in HandlerRectanglesRet]: [number, number, number, number];
+    };
 
   const rv: { [K in keyof typeof rects]: [number, number] } = {} as any;