Browse Source

feat: Add option to flip single element on the context menu (#2520)

Co-authored-by: dwelle <luzar.david@gmail.com>
Riley Schnee 4 years ago
parent
commit
b0d7ff290f

+ 207 - 0
src/actions/actionFlip.ts

@@ -0,0 +1,207 @@
+import { register } from "./register";
+import { getSelectedElements } from "../scene";
+import { getElementMap, 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 { isLinearElement } from "../element/typeChecks";
+import { updateBoundElements } from "../element/binding";
+import { LinearElementEditor } from "../element/linearElementEditor";
+
+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;
+};
+
+export const actionFlipHorizontal = register({
+  name: "flipHorizontal",
+  perform: (elements, appState) => {
+    return {
+      elements: flipSelectedElements(elements, appState, "horizontal"),
+      appState,
+      commitToHistory: true,
+    };
+  },
+  keyTest: (event) => event.shiftKey && event.code === "KeyH",
+  contextItemLabel: "labels.flipHorizontal",
+  contextItemPredicate: (elements, appState) =>
+    enableActionFlipHorizontal(elements, appState),
+});
+
+export const actionFlipVertical = register({
+  name: "flipVertical",
+  perform: (elements, appState) => {
+    return {
+      elements: flipSelectedElements(elements, appState, "vertical"),
+      appState,
+      commitToHistory: true,
+    };
+  },
+  keyTest: (event) => event.shiftKey && event.code === "KeyV",
+  contextItemLabel: "labels.flipVertical",
+  contextItemPredicate: (elements, appState) =>
+    enableActionFlipVertical(elements, appState),
+});
+
+const flipSelectedElements = (
+  elements: readonly ExcalidrawElement[],
+  appState: Readonly<AppState>,
+  flipDirection: "horizontal" | "vertical",
+) => {
+  const selectedElements = getSelectedElements(
+    getNonDeletedElements(elements),
+    appState,
+  );
+
+  // remove once we allow for groups of elements to be flipped
+  if (selectedElements.length > 1) {
+    return elements;
+  }
+
+  const updatedElements = flipElements(
+    selectedElements,
+    appState,
+    flipDirection,
+  );
+
+  const updatedElementsMap = getElementMap(updatedElements);
+
+  return elements.map((element) => updatedElementsMap[element.id] || element);
+};
+
+const flipElements = (
+  elements: NonDeleted<ExcalidrawElement>[],
+  appState: AppState,
+  flipDirection: "horizontal" | "vertical",
+): ExcalidrawElement[] => {
+  for (let i = 0; i < elements.length; i++) {
+    flipElement(elements[i], appState);
+    // If vertical flip, rotate an extra 180
+    if (flipDirection === "vertical") {
+      rotateElement(elements[i], 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);
+
+  let finalOffsetX = 0;
+  if (isLinearElement(element)) {
+    finalOffsetX =
+      element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
+      element.width;
+  }
+
+  // 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 newNCoordsX = 0;
+  let nHandle = transformHandles.nw;
+  if (!nHandle) {
+    // Use ne handle instead
+    usingNWHandle = false;
+    nHandle = transformHandles.ne;
+    if (!nHandle) {
+      mutateElement(element, {
+        angle: originalAngle,
+      });
+      return;
+    }
+  }
+
+  if (isLinearElement(element)) {
+    for (let i = 1; i < element.points.length; i++) {
+      LinearElementEditor.movePoint(element, i, [
+        -element.points[i][0],
+        element.points[i][1],
+      ]);
+    }
+    LinearElementEditor.normalizePoints(element);
+  } else {
+    // calculate new x-coord for transformation
+    newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
+    resizeSingleElement(
+      element,
+      true,
+      element,
+      usingNWHandle ? "nw" : "ne",
+      false,
+      newNCoordsX,
+      nHandle[1],
+    );
+    // fix the size to account for handle sizes
+    mutateElement(element, {
+      width,
+      height,
+    });
+  }
+
+  // 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,
+  });
+
+  updateBoundElements(element);
+};
+
+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,
+  });
+
+  // Move back to original spot
+  mutateElement(element, {
+    x: originalX,
+    y: originalY,
+  });
+};

+ 2 - 0
src/actions/index.ts

@@ -66,6 +66,8 @@ export {
   distributeVertically,
 } from "./actionDistribute";
 
+export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
+
 export {
   actionCopy,
   actionCut,

+ 5 - 1
src/actions/shortcuts.ts

@@ -23,7 +23,9 @@ export type ShortcutName =
   | "zenMode"
   | "stats"
   | "addToLibrary"
-  | "viewMode";
+  | "viewMode"
+  | "flipHorizontal"
+  | "flipVertical";
 
 const shortcutMap: Record<ShortcutName, string[]> = {
   cut: [getShortcutKey("CtrlOrCmd+X")],
@@ -57,6 +59,8 @@ const shortcutMap: Record<ShortcutName, string[]> = {
   zenMode: [getShortcutKey("Alt+Z")],
   stats: [],
   addToLibrary: [],
+  flipHorizontal: [getShortcutKey("Shift+H")],
+  flipVertical: [getShortcutKey("Shift+V")],
   viewMode: [getShortcutKey("Alt+R")],
 };
 

+ 2 - 0
src/actions/types.ts

@@ -85,6 +85,8 @@ export type ActionName =
   | "alignHorizontallyCentered"
   | "distributeHorizontally"
   | "distributeVertically"
+  | "flipHorizontal"
+  | "flipVertical"
   | "viewMode"
   | "exportWithDarkMode";
 

+ 15 - 0
src/components/App.tsx

@@ -17,6 +17,8 @@ import {
   actionDeleteSelected,
   actionDuplicateSelection,
   actionFinalize,
+  actionFlipHorizontal,
+  actionFlipVertical,
   actionGroup,
   actionPasteStyles,
   actionSelectAll,
@@ -3780,6 +3782,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       this.actionManager.getAppState(),
     );
 
+    const maybeFlipHorizontal = actionFlipHorizontal.contextItemPredicate!(
+      this.actionManager.getElementsIncludingDeleted(),
+      this.actionManager.getAppState(),
+    );
+
+    const maybeFlipVertical = actionFlipVertical.contextItemPredicate!(
+      this.actionManager.getElementsIncludingDeleted(),
+      this.actionManager.getAppState(),
+    );
+
     const separator = "separator";
 
     const _isMobile = isMobile();
@@ -3900,6 +3912,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         actionSendToBack,
         actionBringToFront,
         separator,
+        maybeFlipHorizontal && actionFlipHorizontal,
+        maybeFlipVertical && actionFlipVertical,
+        (maybeFlipHorizontal || maybeFlipVertical) && separator,
         actionDuplicateSelection,
         actionDeleteSelected,
       ],

+ 8 - 0
src/components/HelpDialog.tsx

@@ -349,6 +349,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
                   label={t("labels.ungroup")}
                   shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
                 />
+                <Shortcut
+                  label={t("labels.flipHorizontal")}
+                  shortcuts={[getShortcutKey("Shift+H")]}
+                />
+                <Shortcut
+                  label={t("labels.flipVertical")}
+                  shortcuts={[getShortcutKey("Shift+V")]}
+                />
               </ShortcutIsland>
             </Column>
           </Columns>

+ 3 - 3
src/element/resizeElements.ts

@@ -31,7 +31,7 @@ import {
 import { PointerDownState } from "../components/App";
 import { Point } from "../types";
 
-const normalizeAngle = (angle: number): number => {
+export const normalizeAngle = (angle: number): number => {
   if (angle >= 2 * Math.PI) {
     return angle - 2 * Math.PI;
   }
@@ -181,7 +181,7 @@ const getPerfectElementSizeWithRotation = (
   return rotate(size.width, size.height, 0, 0, -angle);
 };
 
-const reshapeSingleTwoPointElement = (
+export const reshapeSingleTwoPointElement = (
   element: NonDeleted<ExcalidrawLinearElement>,
   resizeArrowDirection: "origin" | "end",
   isRotateWithDiscreteAngle: boolean,
@@ -378,7 +378,7 @@ const resizeSingleTextElement = (
   }
 };
 
-const resizeSingleElement = (
+export const resizeSingleElement = (
   stateAtResizeStart: NonDeletedExcalidrawElement,
   shouldKeepSidesRatio: boolean,
   element: NonDeletedExcalidrawElement,

+ 2 - 0
src/locales/en.json

@@ -92,6 +92,8 @@
     "centerHorizontally": "Center horizontally",
     "distributeHorizontally": "Distribute horizontally",
     "distributeVertically": "Distribute vertically",
+    "flipHorizontal": "Flip horizontal",
+    "flipVertical": "Flip vertical",
     "viewMode": "View mode",
     "toggleExportColorScheme": "Toggle export color scheme",
     "share": "Share"

+ 4 - 0
src/packages/utils/CHANGELOG.md

@@ -5,3 +5,7 @@
 First release of `@excalidraw/utils` to provide utilities functions.
 
 - Added `exportToBlob` and `exportToSvg` to export an Excalidraw diagram definition, respectively, to a [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) and to a [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) ([#2246](https://github.com/excalidraw/excalidraw/pull/2246))
+
+### Features
+
+- Flip single elements horizontally or vertically [#2520](https://github.com/excalidraw/excalidraw/pull/2520)

+ 615 - 0
src/tests/flip.test.tsx

@@ -0,0 +1,615 @@
+import React from "react";
+import ReactDOM from "react-dom";
+import { render } from "./test-utils";
+import App from "../components/App";
+import { defaultLang, setLanguage } from "../i18n";
+import { UI, Pointer } from "./helpers/ui";
+import { API } from "./helpers/api";
+import { actionFlipHorizontal, actionFlipVertical } from "../actions";
+
+const { h } = window;
+
+const mouse = new Pointer("mouse");
+
+beforeEach(async () => {
+  // Unmount ReactDOM from root
+  ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
+  mouse.reset();
+
+  await setLanguage(defaultLang);
+  render(<App />);
+});
+
+const createAndSelectOneRectangle = (angle: number = 0) => {
+  UI.createElement("rectangle", {
+    x: 0,
+    y: 0,
+    width: 100,
+    height: 50,
+    angle,
+  });
+};
+
+const createAndSelectOneDiamond = (angle: number = 0) => {
+  UI.createElement("diamond", {
+    x: 0,
+    y: 0,
+    width: 100,
+    height: 50,
+    angle,
+  });
+};
+
+const createAndSelectOneEllipse = (angle: number = 0) => {
+  UI.createElement("ellipse", {
+    x: 0,
+    y: 0,
+    width: 100,
+    height: 50,
+    angle,
+  });
+};
+
+const createAndSelectOneArrow = (angle: number = 0) => {
+  UI.createElement("arrow", {
+    x: 0,
+    y: 0,
+    width: 100,
+    height: 50,
+    angle,
+  });
+};
+
+const createAndSelectOneLine = (angle: number = 0) => {
+  UI.createElement("line", {
+    x: 0,
+    y: 0,
+    width: 100,
+    height: 50,
+    angle,
+  });
+};
+
+const createAndReturnOneDraw = (angle: number = 0) => {
+  return UI.createElement("draw", {
+    x: 0,
+    y: 0,
+    width: 50,
+    height: 100,
+    angle,
+  });
+};
+
+// Rectangle element
+
+it("flips an unrotated rectangle horizontally correctly", () => {
+  createAndSelectOneRectangle();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipHorizontal);
+
+  // Check if x position did not change
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+});
+
+it("flips an unrotated rectangle vertically correctly", () => {
+  createAndSelectOneRectangle();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipVertical);
+
+  // Check if x position did not change
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+});
+
+it("flips a rotated rectangle horizontally correctly", () => {
+  const originalAngle = (3 * Math.PI) / 4;
+  const expectedAngle = (5 * Math.PI) / 4;
+
+  createAndSelectOneRectangle(originalAngle);
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipHorizontal);
+
+  // Check if x position did not change
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+
+  // Check angle
+  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAngle);
+});
+
+it("flips a rotated rectangle vertically correctly", () => {
+  const originalAngle = (3 * Math.PI) / 4;
+  const expectedAgnle = Math.PI / 4;
+
+  createAndSelectOneRectangle(originalAngle);
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipVertical);
+
+  // Check if x position did not change
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+
+  // Check angle
+  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAgnle);
+});
+
+// Diamond element
+
+it("flips an unrotated diamond horizontally correctly", () => {
+  createAndSelectOneDiamond();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipHorizontal);
+
+  // Check if x position did not change
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+});
+
+it("flips an unrotated diamond vertically correctly", () => {
+  createAndSelectOneDiamond();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipVertical);
+
+  // Check if x position did not change
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+});
+
+it("flips a rotated diamond horizontally correctly", () => {
+  const originalAngle = (5 * Math.PI) / 4;
+  const expectedAngle = (3 * Math.PI) / 4;
+
+  createAndSelectOneDiamond(originalAngle);
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipHorizontal);
+
+  // Check if x position did not change
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+
+  // Check angle
+  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAngle);
+});
+
+it("flips a rotated diamond vertically correctly", () => {
+  const originalAngle = (5 * Math.PI) / 4;
+  const expectedAngle = (7 * Math.PI) / 4;
+
+  createAndSelectOneDiamond(originalAngle);
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipVertical);
+
+  // Check if x position did not change
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+
+  // Check angle
+  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAngle);
+});
+
+// Ellipse element
+
+it("flips an unrotated ellipse horizontally correctly", () => {
+  createAndSelectOneEllipse();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipHorizontal);
+
+  // Check if x position did not change
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+});
+
+it("flips an unrotated ellipse vertically correctly", () => {
+  createAndSelectOneEllipse();
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipVertical);
+
+  // Check if x position did not change
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+});
+
+it("flips a rotated ellipse horizontally correctly", () => {
+  const originalAngle = (7 * Math.PI) / 4;
+  const expectedAngle = Math.PI / 4;
+
+  createAndSelectOneEllipse(originalAngle);
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipHorizontal);
+
+  // Check if x position did not change
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+
+  // Check angle
+  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAngle);
+});
+
+it("flips a rotated ellipse vertically correctly", () => {
+  const originalAngle = (7 * Math.PI) / 4;
+  const expectedAngle = (5 * Math.PI) / 4;
+
+  createAndSelectOneEllipse(originalAngle);
+
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipVertical);
+
+  // Check if x position did not change
+  expect(API.getSelectedElements()[0].x).toEqual(0);
+
+  expect(API.getSelectedElements()[0].y).toEqual(0);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+
+  // Check angle
+  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAngle);
+});
+
+// Arrow element
+
+it("flips an unrotated arrow horizontally correctly", () => {
+  createAndSelectOneArrow();
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipHorizontal);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+});
+
+it("flips an unrotated arrow vertically correctly", () => {
+  createAndSelectOneArrow();
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipVertical);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+});
+
+it("flips a rotated arrow horizontally correctly", () => {
+  const originalAngle = Math.PI / 4;
+  const expectedAngle = (7 * Math.PI) / 4;
+  createAndSelectOneArrow(originalAngle);
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipHorizontal);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+
+  // Check angle
+  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAngle);
+});
+
+it("flips a rotated arrow vertically correctly", () => {
+  const originalAngle = Math.PI / 4;
+  const expectedAngle = (3 * Math.PI) / 4;
+  createAndSelectOneArrow(originalAngle);
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipVertical);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+
+  // Check angle
+  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAngle);
+});
+
+// Line element
+
+it("flips an unrotated line horizontally correctly", () => {
+  createAndSelectOneLine();
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipHorizontal);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+});
+
+it("flips an unrotated line vertically correctly", () => {
+  createAndSelectOneLine();
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipVertical);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+});
+
+it("flips a rotated line horizontally correctly", () => {
+  const originalAngle = Math.PI / 4;
+  const expectedAngle = (7 * Math.PI) / 4;
+
+  createAndSelectOneLine(originalAngle);
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipHorizontal);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+
+  // Check angle
+  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAngle);
+});
+
+it("flips a rotated line vertically correctly", () => {
+  const originalAngle = Math.PI / 4;
+  const expectedAngle = (3 * Math.PI) / 4;
+
+  createAndSelectOneLine(originalAngle);
+
+  const originalWidth = API.getSelectedElements()[0].width;
+  const originalHeight = API.getSelectedElements()[0].height;
+
+  h.app.actionManager.executeAction(actionFlipVertical);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+
+  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+
+  // Check angle
+  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAngle);
+});
+
+// Draw element
+
+it("flips an unrotated drawing horizontally correctly", () => {
+  const draw = createAndReturnOneDraw();
+  // select draw, since not done automatically
+  h.state.selectedElementIds[draw.id] = true;
+
+  const originalWidth = draw.width;
+  const originalHeight = draw.height;
+
+  h.app.actionManager.executeAction(actionFlipHorizontal);
+
+  // Check if width and height did not change
+  expect(draw.width).toEqual(originalWidth);
+
+  expect(draw.height).toEqual(originalHeight);
+});
+
+it("flips an unrotated drawing vertically correctly", () => {
+  const draw = createAndReturnOneDraw();
+  // select draw, since not done automatically
+  h.state.selectedElementIds[draw.id] = true;
+
+  const originalWidth = draw.width;
+  const originalHeight = draw.height;
+
+  h.app.actionManager.executeAction(actionFlipVertical);
+
+  // Check if width and height did not change
+  expect(draw.width).toEqual(originalWidth);
+
+  expect(draw.height).toEqual(originalHeight);
+});
+
+it("flips a rotated drawing horizontally correctly", () => {
+  const originalAngle = Math.PI / 4;
+  const expectedAngle = (7 * Math.PI) / 4;
+
+  const draw = createAndReturnOneDraw(originalAngle);
+  // select draw, since not done automatically
+  h.state.selectedElementIds[draw.id] = true;
+
+  const originalWidth = draw.width;
+  const originalHeight = draw.height;
+
+  h.app.actionManager.executeAction(actionFlipHorizontal);
+
+  // Check if width and height did not change
+  expect(draw.width).toEqual(originalWidth);
+
+  expect(draw.height).toEqual(originalHeight);
+
+  // Check angle
+  expect(draw.angle).toBeCloseTo(expectedAngle);
+});
+
+it("flips a rotated drawing vertically correctly", () => {
+  const originalAngle = Math.PI / 4;
+  const expectedAngle = (3 * Math.PI) / 4;
+
+  const draw = createAndReturnOneDraw(originalAngle);
+  // select draw, since not done automatically
+  h.state.selectedElementIds[draw.id] = true;
+
+  const originalWidth = draw.width;
+  const originalHeight = draw.height;
+
+  h.app.actionManager.executeAction(actionFlipVertical);
+
+  // Check if width and height did not change
+  expect(API.getSelectedElement().width).toEqual(originalWidth);
+
+  expect(API.getSelectedElement().height).toEqual(originalHeight);
+
+  // Check angle
+  expect(API.getSelectedElement().angle).toBeCloseTo(expectedAngle);
+});

+ 7 - 0
src/tests/helpers/ui.ts

@@ -6,6 +6,7 @@ import {
 import { CODES } from "../../keys";
 import { ToolName } from "../queries/toolQueries";
 import { fireEvent, GlobalTestState } from "../test-utils";
+import { mutateElement } from "../../element/mutateElement";
 import { API } from "./api";
 
 const { h } = window;
@@ -202,6 +203,7 @@ export class UI {
       size = 10,
       width = size,
       height = width,
+      angle = 0,
     }: {
       position?: number;
       x?: number;
@@ -209,6 +211,7 @@ export class UI {
       size?: number;
       width?: number;
       height?: number;
+      angle?: number;
     } = {},
   ): (T extends "arrow" | "line" | "draw"
     ? ExcalidrawLinearElement
@@ -231,6 +234,10 @@ export class UI {
 
     const origElement = h.elements[h.elements.length - 1] as any;
 
+    if (angle !== 0) {
+      mutateElement(origElement, { angle });
+    }
+
     return new Proxy(
       {},
       {

+ 2 - 0
src/tests/regressionTests.test.tsx

@@ -653,6 +653,8 @@ describe("regression tests", () => {
       "pasteStyles",
       "deleteSelectedElements",
       "addToLibrary",
+      "flipHorizontal",
+      "flipVertical",
       "sendBackward",
       "bringForward",
       "sendToBack",