Ver código fonte

feat: add flipping for multiple elements (#5578)

* feat: add flipping when resizing multiple elements

* fix: image elements not flipping its content

* test: fix accidental resizing in grouping test

* fix: angles not flipping vertically when resizing

* feat: add flipping multiple elements with a command

* revert: image elements not flipping its content

This reverts commit cb989a6c66e62a02a8c04ce41f12507806c8d0a0.

* fix: add special cases for flipping text & images

* fix: a few corner cases for flipping

* fix: remove angle flip

* fix: bound text scaling when resizing

* fix: linear elements drifting away after multiple flips

* revert: fix linear elements drifting away after multiple flips

This reverts commit bffc33dd3ffe56c72029eee6aca843d992bac7ab.

* fix: linear elements unstable bounds

* revert: linear elements unstable bounds

This reverts commit 22ae9b02c4a49f0ed6448c27abe1969cf6abb1e3.

* fix: hand-drawn lines shift after flipping

* test: fix flipping tests

* test: fix the number of context menu items

* fix: incorrect scaling due to ignoring bound text when finding selection bounds

* fix: bound text coordinates not being updated

* fix: lines bound text rotation

* fix: incorrect placement of bound lines on flip

* remove redundant predicates in actionFlip

* update test

* refactor resizeElement with some renaming and comments

* fix grouped bounded text elements not being flipped correctly

* combine mutation for bounded text element

* remove incorrect return

* fix: linear elements bindings after flipping

* revert: remove incorrect return

This reverts commit e6b205ca900b504fe982e4ac1b3b19dcfca246b8.

* fix: minimum size for all elements in selection

---------

Co-authored-by: Ryan Di <[email protected]>
Alex Kim 2 anos atrás
pai
commit
6459ccda6a

+ 22 - 180
src/actions/actionFlip.ts

@@ -1,42 +1,17 @@
 import { register } from "./register";
 import { getSelectedElements } from "../scene";
 import { getNonDeletedElements } from "../element";
-import { mutateElement } from "../element/mutateElement";
 import { ExcalidrawElement, NonDeleted } from "../element/types";
-import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
-import { AppState } from "../types";
-import { getTransformHandles } from "../element/transformHandles";
-import { updateBoundElements } from "../element/binding";
+import { resizeMultipleElements } from "../element/resizeElements";
+import { AppState, PointerDownState } from "../types";
 import { arrayToMap } from "../utils";
-import {
-  getElementAbsoluteCoords,
-  getElementPointsCoords,
-} from "../element/bounds";
-import { isLinearElement } from "../element/typeChecks";
-import { LinearElementEditor } from "../element/linearElementEditor";
 import { CODES, KEYS } from "../keys";
-
-const enableActionFlipHorizontal = (
-  elements: readonly ExcalidrawElement[],
-  appState: AppState,
-) => {
-  const eligibleElements = getSelectedElements(
-    getNonDeletedElements(elements),
-    appState,
-  );
-  return eligibleElements.length === 1 && eligibleElements[0].type !== "text";
-};
-
-const enableActionFlipVertical = (
-  elements: readonly ExcalidrawElement[],
-  appState: AppState,
-) => {
-  const eligibleElements = getSelectedElements(
-    getNonDeletedElements(elements),
-    appState,
-  );
-  return eligibleElements.length === 1;
-};
+import { getCommonBoundingBox } from "../element/bounds";
+import {
+  bindOrUnbindSelectedElements,
+  isBindingEnabled,
+  unbindLinearElements,
+} from "../element/binding";
 
 export const actionFlipHorizontal = register({
   name: "flipHorizontal",
@@ -50,8 +25,6 @@ export const actionFlipHorizontal = register({
   },
   keyTest: (event) => event.shiftKey && event.code === CODES.H,
   contextItemLabel: "labels.flipHorizontal",
-  predicate: (elements, appState) =>
-    enableActionFlipHorizontal(elements, appState),
 });
 
 export const actionFlipVertical = register({
@@ -67,8 +40,6 @@ export const actionFlipVertical = register({
   keyTest: (event) =>
     event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
   contextItemLabel: "labels.flipVertical",
-  predicate: (elements, appState) =>
-    enableActionFlipVertical(elements, appState),
 });
 
 const flipSelectedElements = (
@@ -81,11 +52,6 @@ const flipSelectedElements = (
     appState,
   );
 
-  // remove once we allow for groups of elements to be flipped
-  if (selectedElements.length > 1) {
-    return elements;
-  }
-
   const updatedElements = flipElements(
     selectedElements,
     appState,
@@ -104,144 +70,20 @@ const flipElements = (
   appState: AppState,
   flipDirection: "horizontal" | "vertical",
 ): ExcalidrawElement[] => {
-  elements.forEach((element) => {
-    flipElement(element, appState);
-    // If vertical flip, rotate an extra 180
-    if (flipDirection === "vertical") {
-      rotateElement(element, Math.PI);
-    }
-  });
-  return elements;
-};
-
-const flipElement = (
-  element: NonDeleted<ExcalidrawElement>,
-  appState: AppState,
-) => {
-  const originalX = element.x;
-  const originalY = element.y;
-  const width = element.width;
-  const height = element.height;
-  const originalAngle = normalizeAngle(element.angle);
-
-  // Rotate back to zero, if necessary
-  mutateElement(element, {
-    angle: normalizeAngle(0),
-  });
-  // Flip unrotated by pulling TransformHandle to opposite side
-  const transformHandles = getTransformHandles(element, appState.zoom);
-  let usingNWHandle = true;
-  let nHandle = transformHandles.nw;
-  if (!nHandle) {
-    // Use ne handle instead
-    usingNWHandle = false;
-    nHandle = transformHandles.ne;
-    if (!nHandle) {
-      mutateElement(element, {
-        angle: originalAngle,
-      });
-      return;
-    }
-  }
-
-  let finalOffsetX = 0;
-  if (isLinearElement(element) && element.points.length < 3) {
-    finalOffsetX =
-      element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
-      element.width;
-  }
-
-  let initialPointsCoords;
-  if (isLinearElement(element)) {
-    initialPointsCoords = getElementPointsCoords(element, element.points);
-  }
-  const initialElementAbsoluteCoords = getElementAbsoluteCoords(element);
-
-  if (isLinearElement(element) && element.points.length < 3) {
-    for (let index = 1; index < element.points.length; index++) {
-      LinearElementEditor.movePoints(element, [
-        {
-          index,
-          point: [-element.points[index][0], element.points[index][1]],
-        },
-      ]);
-    }
-    LinearElementEditor.normalizePoints(element);
-  } else {
-    const elWidth = initialPointsCoords
-      ? initialPointsCoords[2] - initialPointsCoords[0]
-      : initialElementAbsoluteCoords[2] - initialElementAbsoluteCoords[0];
-
-    const startPoint = initialPointsCoords
-      ? [initialPointsCoords[0], initialPointsCoords[1]]
-      : [initialElementAbsoluteCoords[0], initialElementAbsoluteCoords[1]];
-
-    resizeSingleElement(
-      new Map().set(element.id, element),
-      false,
-      element,
-      usingNWHandle ? "nw" : "ne",
-      true,
-      usingNWHandle ? startPoint[0] + elWidth : startPoint[0] - elWidth,
-      startPoint[1],
-    );
-  }
-
-  // Rotate by (360 degrees - original angle)
-  let angle = normalizeAngle(2 * Math.PI - originalAngle);
-  if (angle < 0) {
-    // check, probably unnecessary
-    angle = normalizeAngle(angle + 2 * Math.PI);
-  }
-  mutateElement(element, {
-    angle,
-  });
-
-  // Move back to original spot to appear "flipped in place"
-  mutateElement(element, {
-    x: originalX + finalOffsetX,
-    y: originalY,
-    width,
-    height,
-  });
-
-  updateBoundElements(element);
-
-  if (initialPointsCoords && isLinearElement(element)) {
-    // Adjusting origin because when a beizer curve path exceeds min/max points it offsets the origin.
-    // There's still room for improvement since when the line roughness is > 1
-    // we still have a small offset of the origin when fliipping the element.
-    const finalPointsCoords = getElementPointsCoords(element, element.points);
-
-    const topLeftCoordsDiff = initialPointsCoords[0] - finalPointsCoords[0];
-    const topRightCoordDiff = initialPointsCoords[2] - finalPointsCoords[2];
-
-    const coordsDiff = topLeftCoordsDiff + topRightCoordDiff;
-
-    mutateElement(element, {
-      x: element.x + coordsDiff * 0.5,
-      y: element.y,
-      width,
-      height,
-    });
-  }
-};
+  const { minX, minY, maxX, maxY } = getCommonBoundingBox(elements);
+
+  resizeMultipleElements(
+    { originalElements: arrayToMap(elements) } as PointerDownState,
+    elements,
+    "nw",
+    true,
+    flipDirection === "horizontal" ? maxX : minX,
+    flipDirection === "horizontal" ? minY : maxY,
+  );
 
-const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => {
-  const originalX = element.x;
-  const originalY = element.y;
-  let angle = normalizeAngle(element.angle + rotationAngle);
-  if (angle < 0) {
-    // check, probably unnecessary
-    angle = normalizeAngle(2 * Math.PI + angle);
-  }
-  mutateElement(element, {
-    angle,
-  });
+  (isBindingEnabled(appState)
+    ? bindOrUnbindSelectedElements
+    : unbindLinearElements)(elements);
 
-  // Move back to original spot
-  mutateElement(element, {
-    x: originalX,
-    y: originalY,
-  });
+  return elements;
 };

+ 161 - 53
src/element/resizeElements.ts

@@ -14,17 +14,21 @@ import {
   NonDeleted,
   ExcalidrawElement,
   ExcalidrawTextElementWithContainer,
+  ExcalidrawImageElement,
 } from "./types";
+import type { Mutable } from "../utility-types";
 import {
   getElementAbsoluteCoords,
   getCommonBounds,
   getResizedElementAbsoluteCoords,
   getCommonBoundingBox,
+  getElementPointsCoords,
 } from "./bounds";
 import {
   isArrowElement,
   isBoundToContainer,
   isFreeDrawElement,
+  isImageElement,
   isLinearElement,
   isTextElement,
 } from "./typeChecks";
@@ -49,8 +53,12 @@ import {
   measureText,
   getBoundTextMaxHeight,
 } from "./textElement";
+import { LinearElementEditor } from "./linearElementEditor";
 
 export const normalizeAngle = (angle: number): number => {
+  if (angle < 0) {
+    return angle + 2 * Math.PI;
+  }
   if (angle >= 2 * Math.PI) {
     return angle - 2 * Math.PI;
   }
@@ -596,7 +604,7 @@ export const resizeSingleElement = (
   }
 };
 
-const resizeMultipleElements = (
+export const resizeMultipleElements = (
   pointerDownState: PointerDownState,
   selectedElements: readonly NonDeletedExcalidrawElement[],
   transformHandleType: "nw" | "ne" | "sw" | "se",
@@ -627,8 +635,28 @@ const resizeMultipleElements = (
     [],
   );
 
+  // getCommonBoundingBox() uses getBoundTextElement() which returns null for
+  // original elements from pointerDownState, so we have to find and add these
+  // bound text elements manually. Additionally, the coordinates of bound text
+  // elements aren't always up to date.
+  const boundTextElements = targetElements.reduce((acc, { orig }) => {
+    if (!isLinearElement(orig)) {
+      return acc;
+    }
+    const textId = getBoundTextElementId(orig);
+    if (!textId) {
+      return acc;
+    }
+    const text = pointerDownState.originalElements.get(textId) ?? null;
+    if (!isBoundToContainer(text)) {
+      return acc;
+    }
+    const xy = LinearElementEditor.getBoundTextElementPosition(orig, text);
+    return [...acc, { ...text, ...xy }];
+  }, [] as ExcalidrawTextElementWithContainer[]);
+
   const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
-    targetElements.map(({ orig }) => orig),
+    targetElements.map(({ orig }) => orig).concat(boundTextElements),
   );
   const direction = transformHandleType;
 
@@ -640,12 +668,22 @@ const resizeMultipleElements = (
   };
 
   // anchor point must be on the opposite side of the dragged selection handle
-  // or be the center of the selection if alt is pressed
+  // or be the center of the selection if shouldResizeFromCenter
   const [anchorX, anchorY]: Point = shouldResizeFromCenter
     ? [midX, midY]
     : mapDirectionsToAnchors[direction];
 
-  const mapDirectionsToPointerSides: Record<
+  const scale =
+    Math.max(
+      Math.abs(pointerX - anchorX) / (maxX - minX) || 0,
+      Math.abs(pointerY - anchorY) / (maxY - minY) || 0,
+    ) * (shouldResizeFromCenter ? 2 : 1);
+
+  if (scale === 0) {
+    return;
+  }
+
+  const mapDirectionsToPointerPositions: Record<
     typeof direction,
     [x: boolean, y: boolean]
   > = {
@@ -655,68 +693,117 @@ const resizeMultipleElements = (
     nw: [pointerX <= anchorX, pointerY <= anchorY],
   };
 
-  // pointer side relative to anchor
-  const [pointerSideX, pointerSideY] = mapDirectionsToPointerSides[
+  /**
+   * to flip an element:
+   * 1. determine over which axis is the element being flipped
+   *    (could be x, y, or both) indicated by `flipFactorX` & `flipFactorY`
+   * 2. shift element's position by the amount of width or height (or both) or
+   *    mirror points in the case of linear & freedraw elemenets
+   * 3. adjust element angle
+   */
+  const [flipFactorX, flipFactorY] = mapDirectionsToPointerPositions[
     direction
   ].map((condition) => (condition ? 1 : -1));
+  const isFlippedByX = flipFactorX < 0;
+  const isFlippedByY = flipFactorY < 0;
+
+  const elementsAndUpdates: {
+    element: NonDeletedExcalidrawElement;
+    update: Mutable<
+      Pick<ExcalidrawElement, "x" | "y" | "width" | "height" | "angle">
+    > & {
+      points?: ExcalidrawLinearElement["points"];
+      fontSize?: ExcalidrawTextElement["fontSize"];
+      baseline?: ExcalidrawTextElement["baseline"];
+      scale?: ExcalidrawImageElement["scale"];
+    };
+    boundText: {
+      element: ExcalidrawTextElementWithContainer;
+      fontSize: ExcalidrawTextElement["fontSize"];
+      baseline: ExcalidrawTextElement["baseline"];
+    } | null;
+  }[] = [];
+
+  for (const { orig, latest } of targetElements) {
+    // bounded text elements are updated along with their container elements
+    if (isTextElement(orig) && isBoundToContainer(orig)) {
+      continue;
+    }
 
-  // stop resizing if a pointer is on the other side of selection
-  if (pointerSideX < 0 && pointerSideY < 0) {
-    return;
-  }
-
-  const scale =
-    Math.max(
-      (pointerSideX * Math.abs(pointerX - anchorX)) / (maxX - minX),
-      (pointerSideY * Math.abs(pointerY - anchorY)) / (maxY - minY),
-    ) * (shouldResizeFromCenter ? 2 : 1);
-
-  if (scale === 0) {
-    return;
-  }
+    const width = orig.width * scale;
+    const height = orig.height * scale;
+    const angle = normalizeAngle(orig.angle * flipFactorX * flipFactorY);
 
-  targetElements.forEach((element) => {
-    const width = element.orig.width * scale;
-    const height = element.orig.height * scale;
-    const x = anchorX + (element.orig.x - anchorX) * scale;
-    const y = anchorY + (element.orig.y - anchorY) * scale;
+    const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig);
+    const offsetX = orig.x - anchorX;
+    const offsetY = orig.y - anchorY;
+    const shiftX = isFlippedByX && !isLinearOrFreeDraw ? width : 0;
+    const shiftY = isFlippedByY && !isLinearOrFreeDraw ? height : 0;
+    const x = anchorX + flipFactorX * (offsetX * scale + shiftX);
+    const y = anchorY + flipFactorY * (offsetY * scale + shiftY);
 
-    // readjust points for linear & free draw elements
     const rescaledPoints = rescalePointsInElement(
-      element.orig,
-      width,
-      height,
+      orig,
+      width * flipFactorX,
+      height * flipFactorY,
       false,
     );
 
-    const update: {
-      width: number;
-      height: number;
-      x: number;
-      y: number;
-      points?: Point[];
-      fontSize?: number;
-      baseline?: number;
-    } = {
-      width,
-      height,
+    const update: typeof elementsAndUpdates[0]["update"] = {
       x,
       y,
+      width,
+      height,
+      angle,
       ...rescaledPoints,
     };
 
-    let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
+    if (isImageElement(orig) && targetElements.length === 1) {
+      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
+        );
+      };
 
-    const boundTextElement = getBoundTextElement(element.latest);
+      if (isFlippedByX) {
+        update.x -= linearShift("x");
+      }
 
-    if (boundTextElement || isTextElement(element.orig)) {
+      if (isFlippedByY) {
+        update.y -= linearShift("y");
+      }
+    }
+
+    let boundText: typeof elementsAndUpdates[0]["boundText"] = null;
+
+    const boundTextElement = getBoundTextElement(latest);
+
+    if (boundTextElement || isTextElement(orig)) {
       const updatedElement = {
-        ...element.latest,
+        ...latest,
         width,
         height,
       };
       const metrics = measureFontSizeFromWidth(
-        boundTextElement ?? (element.orig as ExcalidrawTextElement),
+        boundTextElement ?? (orig as ExcalidrawTextElement),
         boundTextElement
           ? getBoundTextMaxWidth(updatedElement)
           : updatedElement.width,
@@ -729,29 +816,50 @@ const resizeMultipleElements = (
         return;
       }
 
-      if (isTextElement(element.orig)) {
+      if (isTextElement(orig)) {
         update.fontSize = metrics.size;
         update.baseline = metrics.baseline;
       }
 
       if (boundTextElement) {
-        boundTextUpdates = {
+        boundText = {
+          element: boundTextElement,
           fontSize: metrics.size,
           baseline: metrics.baseline,
         };
       }
     }
 
-    updateBoundElements(element.latest, { newSize: { width, height } });
+    elementsAndUpdates.push({ element: latest, update, boundText });
+  }
 
-    mutateElement(element.latest, update);
+  const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
 
-    if (boundTextElement && boundTextUpdates) {
-      mutateElement(boundTextElement, boundTextUpdates);
+  for (const { element, update, boundText } of elementsAndUpdates) {
+    const { width, height, angle } = update;
 
-      handleBindTextResize(element.latest, transformHandleType);
+    mutateElement(element, update, false);
+
+    updateBoundElements(element, {
+      simultaneouslyUpdated: elementsToUpdate,
+      newSize: { width, height },
+    });
+
+    if (boundText) {
+      const { element: boundTextElement, ...boundTextUpdates } = boundText;
+      mutateElement(
+        boundTextElement,
+        {
+          ...boundTextUpdates,
+          angle: isLinearElement(element) ? undefined : angle,
+        },
+        false,
+      );
+      handleBindTextResize(element, transformHandleType);
     }
-  });
+  }
+
+  Scene.getScene(elementsAndUpdates[0].element)?.informMutation();
 };
 
 const rotateMultipleElements = (

+ 0 - 10
src/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -197,7 +197,6 @@ Object {
         "keyTest": [Function],
         "name": "flipHorizontal",
         "perform": [Function],
-        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -207,7 +206,6 @@ Object {
         "keyTest": [Function],
         "name": "flipVertical",
         "perform": [Function],
-        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -4594,7 +4592,6 @@ Object {
         "keyTest": [Function],
         "name": "flipHorizontal",
         "perform": [Function],
-        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -4604,7 +4601,6 @@ Object {
         "keyTest": [Function],
         "name": "flipVertical",
         "perform": [Function],
-        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -5144,7 +5140,6 @@ Object {
         "keyTest": [Function],
         "name": "flipHorizontal",
         "perform": [Function],
-        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -5154,7 +5149,6 @@ Object {
         "keyTest": [Function],
         "name": "flipVertical",
         "perform": [Function],
-        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -6003,7 +5997,6 @@ Object {
         "keyTest": [Function],
         "name": "flipHorizontal",
         "perform": [Function],
-        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -6013,7 +6006,6 @@ Object {
         "keyTest": [Function],
         "name": "flipVertical",
         "perform": [Function],
-        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -6349,7 +6341,6 @@ Object {
         "keyTest": [Function],
         "name": "flipHorizontal",
         "perform": [Function],
-        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },
@@ -6359,7 +6350,6 @@ Object {
         "keyTest": [Function],
         "name": "flipVertical",
         "perform": [Function],
-        "predicate": [Function],
         "trackEvent": Object {
           "category": "element",
         },

+ 9 - 8
src/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -15332,7 +15332,10 @@ Object {
   "penMode": false,
   "pendingImageElementId": null,
   "previousSelectedElementIds": Object {
+    "id0": true,
+    "id1": true,
     "id2": true,
+    "id3": true,
   },
   "resizingElement": null,
   "scrollX": 0,
@@ -15342,7 +15345,6 @@ Object {
     "id0": true,
     "id1": true,
     "id2": true,
-    "id3": true,
     "id5": true,
   },
   "selectedGroupIds": Object {},
@@ -15390,7 +15392,7 @@ Object {
   "type": "rectangle",
   "updated": 1,
   "version": 4,
-  "versionNonce": 1505387817,
+  "versionNonce": 23633383,
   "width": 10,
   "x": 10,
   "y": 10,
@@ -15421,7 +15423,7 @@ Object {
   "type": "rectangle",
   "updated": 1,
   "version": 4,
-  "versionNonce": 23633383,
+  "versionNonce": 493213705,
   "width": 10,
   "x": 30,
   "y": 10,
@@ -15452,7 +15454,7 @@ Object {
   "type": "rectangle",
   "updated": 1,
   "version": 4,
-  "versionNonce": 493213705,
+  "versionNonce": 915032327,
   "width": 10,
   "x": 50,
   "y": 10,
@@ -15803,7 +15805,6 @@ Object {
           "id0": true,
           "id1": true,
           "id2": true,
-          "id3": true,
           "id5": true,
         },
         "selectedGroupIds": Object {},
@@ -15833,7 +15834,7 @@ Object {
           "type": "rectangle",
           "updated": 1,
           "version": 4,
-          "versionNonce": 1505387817,
+          "versionNonce": 23633383,
           "width": 10,
           "x": 10,
           "y": 10,
@@ -15861,7 +15862,7 @@ Object {
           "type": "rectangle",
           "updated": 1,
           "version": 4,
-          "versionNonce": 23633383,
+          "versionNonce": 493213705,
           "width": 10,
           "x": 30,
           "y": 10,
@@ -15889,7 +15890,7 @@ Object {
           "type": "rectangle",
           "updated": 1,
           "version": 4,
-          "versionNonce": 493213705,
+          "versionNonce": 915032327,
           "width": 10,
           "x": 50,
           "y": 10,

+ 4 - 0
src/tests/contextmenu.test.tsx

@@ -207,6 +207,8 @@ describe("contextMenu element", () => {
       "deleteSelectedElements",
       "group",
       "addToLibrary",
+      "flipHorizontal",
+      "flipVertical",
       "sendBackward",
       "bringForward",
       "sendToBack",
@@ -258,6 +260,8 @@ describe("contextMenu element", () => {
       "deleteSelectedElements",
       "ungroup",
       "addToLibrary",
+      "flipHorizontal",
+      "flipVertical",
       "sendBackward",
       "bringForward",
       "sendToBack",

+ 44 - 31
src/tests/flip.test.tsx

@@ -195,10 +195,8 @@ const checkElementsBoundingBox = async (
   debugger;
   await waitFor(() => {
     // Check if width and height did not change
-    expect(x1 - toleranceInPx <= x12 && x12 <= x1 + toleranceInPx).toBeTruthy();
-    expect(y1 - toleranceInPx <= y12 && y12 <= y1 + toleranceInPx).toBeTruthy();
-    expect(x2 - toleranceInPx <= x22 && x22 <= x2 + toleranceInPx).toBeTruthy();
-    expect(y2 - toleranceInPx <= y22 && y22 <= y2 + toleranceInPx).toBeTruthy();
+    expect(x2 - x1).toBeCloseTo(x22 - x12, -1);
+    expect(y2 - y1).toBeCloseTo(y22 - y12, -1);
   });
 };
 
@@ -216,14 +214,22 @@ const checkTwoPointsLineHorizontalFlip = async () => {
   h.app.actionManager.executeAction(actionFlipHorizontal);
   const newElement = h.elements[0] as ExcalidrawLinearElement;
   await waitFor(() => {
-    expect(originalElement.points[0][0]).toEqual(
-      newElement.points[0][0] !== 0 ? -newElement.points[0][0] : 0,
+    expect(originalElement.points[0][0]).toBeCloseTo(
+      -newElement.points[0][0],
+      5,
     );
-    expect(originalElement.points[0][1]).toEqual(newElement.points[0][1]);
-    expect(originalElement.points[1][0]).toEqual(
-      newElement.points[1][0] !== 0 ? -newElement.points[1][0] : 0,
+    expect(originalElement.points[0][1]).toBeCloseTo(
+      newElement.points[0][1],
+      5,
+    );
+    expect(originalElement.points[1][0]).toBeCloseTo(
+      -newElement.points[1][0],
+      5,
+    );
+    expect(originalElement.points[1][1]).toBeCloseTo(
+      newElement.points[1][1],
+      5,
     );
-    expect(originalElement.points[1][1]).toEqual(newElement.points[1][1]);
   });
 };
 
@@ -234,14 +240,22 @@ const checkTwoPointsLineVerticalFlip = async () => {
   h.app.actionManager.executeAction(actionFlipVertical);
   const newElement = h.elements[0] as ExcalidrawLinearElement;
   await waitFor(() => {
-    expect(originalElement.points[0][0]).toEqual(
-      newElement.points[0][0] !== 0 ? -newElement.points[0][0] : 0,
+    expect(originalElement.points[0][0]).toBeCloseTo(
+      newElement.points[0][0],
+      5,
+    );
+    expect(originalElement.points[0][1]).toBeCloseTo(
+      -newElement.points[0][1],
+      5,
+    );
+    expect(originalElement.points[1][0]).toBeCloseTo(
+      newElement.points[1][0],
+      5,
     );
-    expect(originalElement.points[0][1]).toEqual(newElement.points[0][1]);
-    expect(originalElement.points[1][0]).toEqual(
-      newElement.points[1][0] !== 0 ? -newElement.points[1][0] : 0,
+    expect(originalElement.points[1][1]).toBeCloseTo(
+      -newElement.points[1][1],
+      5,
     );
-    expect(originalElement.points[1][1]).toEqual(newElement.points[1][1]);
   });
 };
 
@@ -318,7 +332,7 @@ describe("rectangle", () => {
 
   it("flips a rotated rectangle vertically correctly", async () => {
     const originalAngle = (3 * Math.PI) / 4;
-    const expectedAgnle = Math.PI / 4;
+    const expectedAgnle = (5 * Math.PI) / 4;
 
     createAndSelectOneRectangle(originalAngle);
 
@@ -351,7 +365,7 @@ describe("diamond", () => {
 
   it("flips a rotated diamond vertically correctly", async () => {
     const originalAngle = (5 * Math.PI) / 4;
-    const expectedAngle = (7 * Math.PI) / 4;
+    const expectedAngle = (3 * Math.PI) / 4;
 
     createAndSelectOneDiamond(originalAngle);
 
@@ -384,7 +398,7 @@ describe("ellipse", () => {
 
   it("flips a rotated ellipse vertically correctly", async () => {
     const originalAngle = (7 * Math.PI) / 4;
-    const expectedAngle = (5 * Math.PI) / 4;
+    const expectedAngle = Math.PI / 4;
 
     createAndSelectOneEllipse(originalAngle);
 
@@ -429,7 +443,7 @@ describe("arrow", () => {
 
   it("flips a rotated arrow vertically with line inside min/max points bounds", async () => {
     const originalAngle = Math.PI / 4;
-    const expectedAngle = (3 * Math.PI) / 4;
+    const expectedAngle = (7 * Math.PI) / 4;
     const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
     h.app.scene.replaceAllElements([line]);
     h.app.state.selectedElementIds[line.id] = true;
@@ -481,7 +495,7 @@ describe("arrow", () => {
   //TODO: elements with curve outside minMax points have a wrong bounding box!!!
   it.skip("flips a rotated arrow vertically with line outside min/max points bounds", async () => {
     const originalAngle = Math.PI / 4;
-    const expectedAngle = (3 * Math.PI) / 4;
+    const expectedAngle = (7 * Math.PI) / 4;
     const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
     mutateElement(line, { angle: originalAngle });
     h.app.scene.replaceAllElements([line]);
@@ -512,7 +526,6 @@ describe("arrow", () => {
 
   it("flips a two points arrow vertically correctly", async () => {
     createAndSelectOneArrow();
-
     await checkTwoPointsLineVerticalFlip();
   });
 });
@@ -581,7 +594,7 @@ describe("line", () => {
   //TODO: elements with curve outside minMax points have a wrong bounding box
   it.skip("flips a rotated line vertically with line outside min/max points bounds", async () => {
     const originalAngle = Math.PI / 4;
-    const expectedAngle = (3 * Math.PI) / 4;
+    const expectedAngle = (7 * Math.PI) / 4;
     const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
     mutateElement(line, { angle: originalAngle });
     h.app.scene.replaceAllElements([line]);
@@ -616,7 +629,7 @@ describe("line", () => {
 
   it("flips a rotated line vertically with line inside min/max points bounds", async () => {
     const originalAngle = Math.PI / 4;
-    const expectedAngle = (3 * Math.PI) / 4;
+    const expectedAngle = (7 * Math.PI) / 4;
     const line = createLinearElementWithCurveInsideMinMaxPoints("line");
     h.app.scene.replaceAllElements([line]);
     h.app.state.selectedElementIds[line.id] = true;
@@ -670,7 +683,7 @@ describe("freedraw", () => {
 
   it("flips a rotated drawing vertically correctly", async () => {
     const originalAngle = Math.PI / 4;
-    const expectedAngle = (3 * Math.PI) / 4;
+    const expectedAngle = (7 * Math.PI) / 4;
 
     const draw = createAndReturnOneDraw(originalAngle);
     // select draw, since not done automatically
@@ -718,8 +731,8 @@ describe("image", () => {
     });
 
     await checkVerticalFlip();
-    expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([-1, 1]);
-    expect(h.elements[0].angle).toBeCloseTo(Math.PI);
+    expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([1, -1]);
+    expect(h.elements[0].angle).toBeCloseTo(0);
   });
 
   it("flips an rotated image horizontally correctly", async () => {
@@ -742,7 +755,7 @@ describe("image", () => {
 
   it("flips an rotated image vertically correctly", async () => {
     const originalAngle = Math.PI / 4;
-    const expectedAngle = (3 * Math.PI) / 4;
+    const expectedAngle = (7 * Math.PI) / 4;
     //paste image
     await createImage();
     await waitFor(() => {
@@ -757,7 +770,7 @@ describe("image", () => {
     });
 
     await checkRotatedVerticalFlip(expectedAngle);
-    expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([-1, 1]);
+    expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([1, -1]);
     expect(h.elements[0].angle).toBeCloseTo(expectedAngle);
   });
 
@@ -772,7 +785,7 @@ describe("image", () => {
     });
 
     await checkVerticalHorizontalFlip();
-    expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([1, 1]);
-    expect(h.elements[0].angle).toBeCloseTo(Math.PI);
+    expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([-1, -1]);
+    expect(h.elements[0].angle).toBeCloseTo(0);
   });
 });

+ 1 - 1
src/tests/regressionTests.test.tsx

@@ -542,7 +542,7 @@ describe("regression tests", () => {
       expect(element.groupIds.length).toBe(1);
     }
 
-    mouse.reset();
+    mouse.moveTo(-10, -10); // the NW resizing handle is at [0, 0], so moving further
     mouse.down();
     mouse.restorePosition(...end);
     mouse.up();