1
0
mirror of https://github.com/excalidraw/excalidraw.git synced 2024-11-10 11:35:52 +01:00

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 <ryan.weihao.di@gmail.com>
This commit is contained in:
Alex Kim 2023-05-25 19:27:41 +05:00 committed by GitHub
parent 75bea48b54
commit 6459ccda6a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 240 additions and 282 deletions

@ -1,42 +1,17 @@
import { register } from "./register"; import { register } from "./register";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { mutateElement } from "../element/mutateElement";
import { ExcalidrawElement, NonDeleted } from "../element/types"; import { ExcalidrawElement, NonDeleted } from "../element/types";
import { normalizeAngle, resizeSingleElement } from "../element/resizeElements"; import { resizeMultipleElements } from "../element/resizeElements";
import { AppState } from "../types"; import { AppState, PointerDownState } from "../types";
import { getTransformHandles } from "../element/transformHandles";
import { updateBoundElements } from "../element/binding";
import { arrayToMap } from "../utils"; 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"; import { CODES, KEYS } from "../keys";
import { getCommonBoundingBox } from "../element/bounds";
const enableActionFlipHorizontal = ( import {
elements: readonly ExcalidrawElement[], bindOrUnbindSelectedElements,
appState: AppState, isBindingEnabled,
) => { unbindLinearElements,
const eligibleElements = getSelectedElements( } from "../element/binding";
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({ export const actionFlipHorizontal = register({
name: "flipHorizontal", name: "flipHorizontal",
@ -50,8 +25,6 @@ export const actionFlipHorizontal = register({
}, },
keyTest: (event) => event.shiftKey && event.code === CODES.H, keyTest: (event) => event.shiftKey && event.code === CODES.H,
contextItemLabel: "labels.flipHorizontal", contextItemLabel: "labels.flipHorizontal",
predicate: (elements, appState) =>
enableActionFlipHorizontal(elements, appState),
}); });
export const actionFlipVertical = register({ export const actionFlipVertical = register({
@ -67,8 +40,6 @@ export const actionFlipVertical = register({
keyTest: (event) => keyTest: (event) =>
event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD], event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
contextItemLabel: "labels.flipVertical", contextItemLabel: "labels.flipVertical",
predicate: (elements, appState) =>
enableActionFlipVertical(elements, appState),
}); });
const flipSelectedElements = ( const flipSelectedElements = (
@ -81,11 +52,6 @@ const flipSelectedElements = (
appState, appState,
); );
// remove once we allow for groups of elements to be flipped
if (selectedElements.length > 1) {
return elements;
}
const updatedElements = flipElements( const updatedElements = flipElements(
selectedElements, selectedElements,
appState, appState,
@ -104,144 +70,20 @@ const flipElements = (
appState: AppState, appState: AppState,
flipDirection: "horizontal" | "vertical", flipDirection: "horizontal" | "vertical",
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
elements.forEach((element) => { const { minX, minY, maxX, maxY } = getCommonBoundingBox(elements);
flipElement(element, appState);
// If vertical flip, rotate an extra 180 resizeMultipleElements(
if (flipDirection === "vertical") { { originalElements: arrayToMap(elements) } as PointerDownState,
rotateElement(element, Math.PI); elements,
} "nw",
}); true,
flipDirection === "horizontal" ? maxX : minX,
flipDirection === "horizontal" ? minY : maxY,
);
(isBindingEnabled(appState)
? bindOrUnbindSelectedElements
: unbindLinearElements)(elements);
return elements; 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 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,
});
};

@ -14,17 +14,21 @@ import {
NonDeleted, NonDeleted,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ExcalidrawImageElement,
} from "./types"; } from "./types";
import type { Mutable } from "../utility-types";
import { import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
getCommonBounds, getCommonBounds,
getResizedElementAbsoluteCoords, getResizedElementAbsoluteCoords,
getCommonBoundingBox, getCommonBoundingBox,
getElementPointsCoords,
} from "./bounds"; } from "./bounds";
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
isFreeDrawElement, isFreeDrawElement,
isImageElement,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
@ -49,8 +53,12 @@ import {
measureText, measureText,
getBoundTextMaxHeight, getBoundTextMaxHeight,
} from "./textElement"; } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
export const normalizeAngle = (angle: number): number => { export const normalizeAngle = (angle: number): number => {
if (angle < 0) {
return angle + 2 * Math.PI;
}
if (angle >= 2 * Math.PI) { if (angle >= 2 * Math.PI) {
return angle - 2 * Math.PI; return angle - 2 * Math.PI;
} }
@ -596,7 +604,7 @@ export const resizeSingleElement = (
} }
}; };
const resizeMultipleElements = ( export const resizeMultipleElements = (
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
selectedElements: readonly NonDeletedExcalidrawElement[], selectedElements: readonly NonDeletedExcalidrawElement[],
transformHandleType: "nw" | "ne" | "sw" | "se", 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( const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
targetElements.map(({ orig }) => orig), targetElements.map(({ orig }) => orig).concat(boundTextElements),
); );
const direction = transformHandleType; const direction = transformHandleType;
@ -640,12 +668,22 @@ const resizeMultipleElements = (
}; };
// anchor point must be on the opposite side of the dragged selection handle // anchor point must be on the opposite side of the dragged selection handle
// or be the center of the selection if alt is pressed // or be the center of the selection if shouldResizeFromCenter
const [anchorX, anchorY]: Point = shouldResizeFromCenter const [anchorX, anchorY]: Point = shouldResizeFromCenter
? [midX, midY] ? [midX, midY]
: mapDirectionsToAnchors[direction]; : 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, typeof direction,
[x: boolean, y: boolean] [x: boolean, y: boolean]
> = { > = {
@ -655,68 +693,117 @@ const resizeMultipleElements = (
nw: [pointerX <= anchorX, pointerY <= anchorY], 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 direction
].map((condition) => (condition ? 1 : -1)); ].map((condition) => (condition ? 1 : -1));
const isFlippedByX = flipFactorX < 0;
const isFlippedByY = flipFactorY < 0;
// stop resizing if a pointer is on the other side of selection const elementsAndUpdates: {
if (pointerSideX < 0 && pointerSideY < 0) { element: NonDeletedExcalidrawElement;
return; 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;
} }
const scale = const width = orig.width * scale;
Math.max( const height = orig.height * scale;
(pointerSideX * Math.abs(pointerX - anchorX)) / (maxX - minX), const angle = normalizeAngle(orig.angle * flipFactorX * flipFactorY);
(pointerSideY * Math.abs(pointerY - anchorY)) / (maxY - minY),
) * (shouldResizeFromCenter ? 2 : 1);
if (scale === 0) { const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig);
return; 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);
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;
// readjust points for linear & free draw elements
const rescaledPoints = rescalePointsInElement( const rescaledPoints = rescalePointsInElement(
element.orig, orig,
width, width * flipFactorX,
height, height * flipFactorY,
false, false,
); );
const update: { const update: typeof elementsAndUpdates[0]["update"] = {
width: number;
height: number;
x: number;
y: number;
points?: Point[];
fontSize?: number;
baseline?: number;
} = {
width,
height,
x, x,
y, y,
width,
height,
angle,
...rescaledPoints, ...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];
}
const boundTextElement = getBoundTextElement(element.latest); 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];
if (boundTextElement || isTextElement(element.orig)) { const linearShift = (axis: "x" | "y") => {
const i = axis === "x" ? 0 : 1;
return (
(newBounds[i + 2] -
newXY[i] -
(origXY[i] - origBounds[i]) * scale +
(origBounds[i + 2] - origXY[i]) * scale -
(newXY[i] - newBounds[i])) /
2
);
};
if (isFlippedByX) {
update.x -= linearShift("x");
}
if (isFlippedByY) {
update.y -= linearShift("y");
}
}
let boundText: typeof elementsAndUpdates[0]["boundText"] = null;
const boundTextElement = getBoundTextElement(latest);
if (boundTextElement || isTextElement(orig)) {
const updatedElement = { const updatedElement = {
...element.latest, ...latest,
width, width,
height, height,
}; };
const metrics = measureFontSizeFromWidth( const metrics = measureFontSizeFromWidth(
boundTextElement ?? (element.orig as ExcalidrawTextElement), boundTextElement ?? (orig as ExcalidrawTextElement),
boundTextElement boundTextElement
? getBoundTextMaxWidth(updatedElement) ? getBoundTextMaxWidth(updatedElement)
: updatedElement.width, : updatedElement.width,
@ -729,29 +816,50 @@ const resizeMultipleElements = (
return; return;
} }
if (isTextElement(element.orig)) { if (isTextElement(orig)) {
update.fontSize = metrics.size; update.fontSize = metrics.size;
update.baseline = metrics.baseline; update.baseline = metrics.baseline;
} }
if (boundTextElement) { if (boundTextElement) {
boundTextUpdates = { boundText = {
element: boundTextElement,
fontSize: metrics.size, fontSize: metrics.size,
baseline: metrics.baseline, baseline: metrics.baseline,
}; };
} }
} }
updateBoundElements(element.latest, { newSize: { width, height } }); elementsAndUpdates.push({ element: latest, update, boundText });
mutateElement(element.latest, update);
if (boundTextElement && boundTextUpdates) {
mutateElement(boundTextElement, boundTextUpdates);
handleBindTextResize(element.latest, transformHandleType);
} }
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
for (const { element, update, boundText } of elementsAndUpdates) {
const { width, height, angle } = update;
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 = ( const rotateMultipleElements = (

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

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

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

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

@ -542,7 +542,7 @@ describe("regression tests", () => {
expect(element.groupIds.length).toBe(1); 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.down();
mouse.restorePosition(...end); mouse.restorePosition(...end);
mouse.up(); mouse.up();