From b0d7ff290fafc94bfcffd987df4af45664dd46ae Mon Sep 17 00:00:00 2001 From: Riley Schnee Date: Fri, 26 Mar 2021 11:45:08 -0400 Subject: [PATCH] feat: Add option to flip single element on the context menu (#2520) Co-authored-by: dwelle --- src/actions/actionFlip.ts | 207 ++++++++++ src/actions/index.ts | 2 + src/actions/shortcuts.ts | 6 +- src/actions/types.ts | 2 + src/components/App.tsx | 15 + src/components/HelpDialog.tsx | 8 + src/element/resizeElements.ts | 6 +- src/locales/en.json | 2 + src/packages/utils/CHANGELOG.md | 4 + src/tests/flip.test.tsx | 615 +++++++++++++++++++++++++++++ src/tests/helpers/ui.ts | 7 + src/tests/regressionTests.test.tsx | 2 + 12 files changed, 872 insertions(+), 4 deletions(-) create mode 100644 src/actions/actionFlip.ts create mode 100644 src/tests/flip.test.tsx diff --git a/src/actions/actionFlip.ts b/src/actions/actionFlip.ts new file mode 100644 index 00000000..7be77d5d --- /dev/null +++ b/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, + 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[], + 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, + 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, + }); +}; diff --git a/src/actions/index.ts b/src/actions/index.ts index b335e44e..fed4139e 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -66,6 +66,8 @@ export { distributeVertically, } from "./actionDistribute"; +export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip"; + export { actionCopy, actionCut, diff --git a/src/actions/shortcuts.ts b/src/actions/shortcuts.ts index 4c9bc60c..23df3791 100644 --- a/src/actions/shortcuts.ts +++ b/src/actions/shortcuts.ts @@ -23,7 +23,9 @@ export type ShortcutName = | "zenMode" | "stats" | "addToLibrary" - | "viewMode"; + | "viewMode" + | "flipHorizontal" + | "flipVertical"; const shortcutMap: Record = { cut: [getShortcutKey("CtrlOrCmd+X")], @@ -57,6 +59,8 @@ const shortcutMap: Record = { zenMode: [getShortcutKey("Alt+Z")], stats: [], addToLibrary: [], + flipHorizontal: [getShortcutKey("Shift+H")], + flipVertical: [getShortcutKey("Shift+V")], viewMode: [getShortcutKey("Alt+R")], }; diff --git a/src/actions/types.ts b/src/actions/types.ts index 26fa4244..0d8cbafa 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -85,6 +85,8 @@ export type ActionName = | "alignHorizontallyCentered" | "distributeHorizontally" | "distributeVertically" + | "flipHorizontal" + | "flipVertical" | "viewMode" | "exportWithDarkMode"; diff --git a/src/components/App.tsx b/src/components/App.tsx index 0f07de3f..822500d6 100644 --- a/src/components/App.tsx +++ b/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 { 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 { actionSendToBack, actionBringToFront, separator, + maybeFlipHorizontal && actionFlipHorizontal, + maybeFlipVertical && actionFlipVertical, + (maybeFlipHorizontal || maybeFlipVertical) && separator, actionDuplicateSelection, actionDeleteSelected, ], diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index 156e62ba..5fad45e2 100644 --- a/src/components/HelpDialog.tsx +++ b/src/components/HelpDialog.tsx @@ -349,6 +349,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { label={t("labels.ungroup")} shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]} /> + + diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index 1e66550c..b8a1a65f 100644 --- a/src/element/resizeElements.ts +++ b/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, resizeArrowDirection: "origin" | "end", isRotateWithDiscreteAngle: boolean, @@ -378,7 +378,7 @@ const resizeSingleTextElement = ( } }; -const resizeSingleElement = ( +export const resizeSingleElement = ( stateAtResizeStart: NonDeletedExcalidrawElement, shouldKeepSidesRatio: boolean, element: NonDeletedExcalidrawElement, diff --git a/src/locales/en.json b/src/locales/en.json index a82b3fb0..dfbbb8c7 100644 --- a/src/locales/en.json +++ b/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" diff --git a/src/packages/utils/CHANGELOG.md b/src/packages/utils/CHANGELOG.md index 395b77e7..875d8e82 100644 --- a/src/packages/utils/CHANGELOG.md +++ b/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) diff --git a/src/tests/flip.test.tsx b/src/tests/flip.test.tsx new file mode 100644 index 00000000..b2d0cad4 --- /dev/null +++ b/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(); +}); + +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); +}); diff --git a/src/tests/helpers/ui.ts b/src/tests/helpers/ui.ts index 5f8f23c7..5bcb3ddd 100644 --- a/src/tests/helpers/ui.ts +++ b/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( {}, { diff --git a/src/tests/regressionTests.test.tsx b/src/tests/regressionTests.test.tsx index b87f5fa8..c1c7ccce 100644 --- a/src/tests/regressionTests.test.tsx +++ b/src/tests/regressionTests.test.tsx @@ -653,6 +653,8 @@ describe("regression tests", () => { "pasteStyles", "deleteSelectedElements", "addToLibrary", + "flipHorizontal", + "flipVertical", "sendBackward", "bringForward", "sendToBack",