feat: resize elements from the sides (#7855)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
pull/7968/head
Ryan Di 9 months ago committed by GitHub
parent 6e5aeb112d
commit 88812e0386
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -26,8 +26,8 @@
"@types/chai": "4.3.0",
"@types/jest": "27.4.0",
"@types/lodash.throttle": "4.1.7",
"@types/react": "18.0.15",
"@types/react-dom": "18.0.6",
"@types/react": "18.2.0",
"@types/react-dom": "18.2.0",
"@types/socket.io-client": "3.0.0",
"@vitejs/plugin-react": "3.1.0",
"@vitest/coverage-v8": "0.33.0",

@ -119,6 +119,7 @@ const flipElements = (
elementsMap,
"nw",
true,
true,
flipDirection === "horizontal" ? maxX : minX,
flipDirection === "horizontal" ? minY : maxY,
);

@ -90,6 +90,7 @@ import {
EDITOR_LS_KEYS,
isIOS,
supportsResizeObserver,
DEFAULT_COLLISION_THRESHOLD,
} from "../constants";
import { ExportedElements, exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
@ -1703,6 +1704,7 @@ class App extends React.Component<AppProps, AppState> {
}
scale={window.devicePixelRatio}
appState={this.state}
device={this.device}
renderInteractiveSceneCallback={
this.renderInteractiveSceneCallback
}
@ -4528,7 +4530,7 @@ class App extends React.Component<AppProps, AppState> {
shape: this.getElementShape(elementWithHighestZIndex),
// when overlapping, we would like to be more precise
// this also avoids the need to update past tests
threshold: this.getHitThreshold() / 2,
threshold: this.getElementHitThreshold() / 2,
frameNameBound: isFrameLikeElement(elementWithHighestZIndex)
? this.frameNameBoundsCache.get(elementWithHighestZIndex)
: null,
@ -4591,8 +4593,8 @@ class App extends React.Component<AppProps, AppState> {
return elements;
}
private getHitThreshold() {
return 10 / this.state.zoom.value;
private getElementHitThreshold() {
return DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value;
}
private hitElement(
@ -4610,7 +4612,7 @@ class App extends React.Component<AppProps, AppState> {
const selectionShape = getSelectionBoxShape(
element,
this.scene.getNonDeletedElementsMap(),
this.getHitThreshold(),
this.getElementHitThreshold(),
);
return isPointInShape([x, y], selectionShape);
@ -4631,7 +4633,7 @@ class App extends React.Component<AppProps, AppState> {
y,
element,
shape: this.getElementShape(element),
threshold: this.getHitThreshold(),
threshold: this.getElementHitThreshold(),
frameNameBound: isFrameLikeElement(element)
? this.frameNameBoundsCache.get(element)
: null,
@ -4663,7 +4665,7 @@ class App extends React.Component<AppProps, AppState> {
y,
element: elements[index],
shape: this.getElementShape(elements[index]),
threshold: this.getHitThreshold(),
threshold: this.getElementHitThreshold(),
})
) {
hitElement = elements[index];
@ -4916,7 +4918,7 @@ class App extends React.Component<AppProps, AppState> {
y: sceneY,
element: container,
shape: this.getElementShape(container),
threshold: this.getHitThreshold(),
threshold: this.getElementHitThreshold(),
})
) {
const midPoint = getContainerCenter(
@ -5331,24 +5333,41 @@ class App extends React.Component<AppProps, AppState> {
!isOverScrollBar &&
!this.state.editingLinearElement
) {
const elementWithTransformHandleType = getElementWithTransformHandleType(
elements,
this.state,
scenePointerX,
scenePointerY,
this.state.zoom,
event.pointerType,
this.scene.getNonDeletedElementsMap(),
);
// for linear elements, we'd like to prioritize point dragging over edge resizing
// therefore, we update and check hovered point index first
if (this.state.selectedLinearElement) {
this.handleHoverSelectedLinearElement(
this.state.selectedLinearElement,
scenePointerX,
scenePointerY,
);
}
if (
elementWithTransformHandleType &&
elementWithTransformHandleType.transformHandleType
!this.state.selectedLinearElement ||
this.state.selectedLinearElement.hoverPointIndex === -1
) {
setCursor(
this.interactiveCanvas,
getCursorForResizingElement(elementWithTransformHandleType),
);
return;
const elementWithTransformHandleType =
getElementWithTransformHandleType(
elements,
this.state,
scenePointerX,
scenePointerY,
this.state.zoom,
event.pointerType,
this.scene.getNonDeletedElementsMap(),
this.device,
);
if (
elementWithTransformHandleType &&
elementWithTransformHandleType.transformHandleType
) {
setCursor(
this.interactiveCanvas,
getCursorForResizingElement(elementWithTransformHandleType),
);
return;
}
}
} else if (selectedElements.length > 1 && !isOverScrollBar) {
const transformHandleType = getTransformHandleTypeFromCoords(
@ -5357,6 +5376,7 @@ class App extends React.Component<AppProps, AppState> {
scenePointerY,
this.state.zoom,
event.pointerType,
this.device,
);
if (transformHandleType) {
setCursor(
@ -5509,7 +5529,7 @@ class App extends React.Component<AppProps, AppState> {
scenePointer.x,
scenePointer.y,
);
const threshold = this.getHitThreshold();
const threshold = this.getElementHitThreshold();
const point = { ...pointerDownState.lastCoords };
let samplingInterval = 0;
while (samplingInterval <= distance) {
@ -5606,7 +5626,7 @@ class App extends React.Component<AppProps, AppState> {
if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
} else {
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
@ -6306,7 +6326,14 @@ class App extends React.Component<AppProps, AppState> {
const elementsMap = this.scene.getNonDeletedElementsMap();
const selectedElements = this.scene.getSelectedElements(this.state);
if (selectedElements.length === 1 && !this.state.editingLinearElement) {
if (
selectedElements.length === 1 &&
!this.state.editingLinearElement &&
!(
this.state.selectedLinearElement &&
this.state.selectedLinearElement.hoverPointIndex !== -1
)
) {
const elementWithTransformHandleType =
getElementWithTransformHandleType(
elements,
@ -6316,6 +6343,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.zoom,
event.pointerType,
this.scene.getNonDeletedElementsMap(),
this.device,
);
if (elementWithTransformHandleType != null) {
this.setState({
@ -6331,6 +6359,7 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.origin.y,
this.state.zoom,
event.pointerType,
this.device,
);
}
if (pointerDownState.resize.handleType) {
@ -6587,7 +6616,7 @@ class App extends React.Component<AppProps, AppState> {
}
// How many pixels off the shape boundary we still consider a hit
const threshold = this.getHitThreshold();
const threshold = this.getElementHitThreshold();
const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
return (
point.x > x1 - threshold &&
@ -8412,7 +8441,7 @@ class App extends React.Component<AppProps, AppState> {
y: pointerDownState.origin.y,
element: hitElement,
shape: this.getElementShape(hitElement),
threshold: this.getHitThreshold(),
threshold: this.getElementHitThreshold(),
frameNameBound: isFrameLikeElement(hitElement)
? this.frameNameBoundsCache.get(hitElement)
: null,
@ -9525,7 +9554,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getElementsMapIncludingDeleted(),
shouldRotateWithDiscreteAngle(event),
shouldResizeFromCenter(event),
selectedElements.length === 1 && isImageElement(selectedElements[0])
selectedElements.some((element) => isImageElement(element))
? !shouldMaintainAspectRatio(event)
: shouldMaintainAspectRatio(event),
resizeX,

@ -3,7 +3,7 @@ import { isShallowEqual, sceneCoordsToViewportCoords } from "../../utils";
import { CURSOR_TYPE } from "../../constants";
import { t } from "../../i18n";
import type { DOMAttributes } from "react";
import type { AppState, InteractiveCanvasAppState } from "../../types";
import type { AppState, Device, InteractiveCanvasAppState } from "../../types";
import type {
InteractiveCanvasRenderConfig,
RenderableElementsMap,
@ -23,6 +23,7 @@ type InteractiveCanvasProps = {
selectionNonce: number | undefined;
scale: number;
appState: InteractiveCanvasAppState;
device: Device;
renderInteractiveSceneCallback: (
data: RenderInteractiveSceneCallback,
) => void;
@ -132,6 +133,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
selectionColor,
renderScrollbars: false,
},
device: props.device,
callback: props.renderInteractiveSceneCallback,
},
isRenderThrottlingEnabled(),

@ -148,6 +148,13 @@ export const DEFAULT_VERTICAL_ALIGN = "top";
export const DEFAULT_VERSION = "{version}";
export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
export const SIDE_RESIZING_THRESHOLD = 2 * DEFAULT_TRANSFORM_HANDLE_SPACING;
// a small epsilon to make side resizing always take precedence
// (avoids an increase in renders and changes to tests)
const EPSILON = 0.00001;
export const DEFAULT_COLLISION_THRESHOLD =
2 * SIDE_RESIZING_THRESHOLD - EPSILON;
export const COLOR_WHITE = "#ffffff";
export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
// keep this in sync with CSS

@ -1,12 +1,7 @@
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
import { rescalePoints } from "../points";
import {
rotate,
adjustXYWithRotation,
centerPoint,
rotatePoint,
} from "../math";
import { rotate, centerPoint, rotatePoint } from "../math";
import {
ExcalidrawLinearElement,
ExcalidrawTextElement,
@ -23,7 +18,6 @@ import {
getCommonBounds,
getResizedElementAbsoluteCoords,
getCommonBoundingBox,
getElementPointsCoords,
} from "./bounds";
import {
isArrowElement,
@ -38,7 +32,6 @@ import { mutateElement } from "./mutateElement";
import { getFontString } from "../utils";
import { updateBoundElements } from "./binding";
import {
TransformHandleType,
MaybeTransformHandleType,
TransformHandleDirection,
} from "./transformHandles";
@ -54,6 +47,7 @@ import {
getApproxMinLineHeight,
} from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups";
export const normalizeAngle = (angle: number): number => {
if (angle < 0) {
@ -133,18 +127,14 @@ export const transformElements = (
centerY,
);
return true;
} else if (
transformHandleType === "nw" ||
transformHandleType === "ne" ||
transformHandleType === "sw" ||
transformHandleType === "se"
) {
} else if (transformHandleType) {
resizeMultipleElements(
originalElements,
selectedElements,
elementsMap,
transformHandleType,
shouldResizeFromCenter,
shouldMaintainAspectRatio,
pointerX,
pointerY,
);
@ -232,26 +222,6 @@ const measureFontSizeFromWidth = (
};
};
const getSidesForTransformHandle = (
transformHandleType: TransformHandleType,
shouldResizeFromCenter: boolean,
) => {
return {
n:
/^(n|ne|nw)$/.test(transformHandleType) ||
(shouldResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
s:
/^(s|se|sw)$/.test(transformHandleType) ||
(shouldResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
w:
/^(w|nw|sw)$/.test(transformHandleType) ||
(shouldResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
e:
/^(e|ne|se)$/.test(transformHandleType) ||
(shouldResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
};
};
const resizeSingleTextElement = (
element: NonDeleted<ExcalidrawTextElement>,
elementsMap: ElementsMap,
@ -260,9 +230,10 @@ const resizeSingleTextElement = (
pointerX: number,
pointerY: number,
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
// rotation pointer with reverse angle
const [rotatedX, rotatedY] = rotate(
pointerX,
@ -271,33 +242,24 @@ const resizeSingleTextElement = (
cy,
-element.angle,
);
let scale: number;
switch (transformHandleType) {
case "se":
scale = Math.max(
(rotatedX - x1) / (x2 - x1),
(rotatedY - y1) / (y2 - y1),
);
break;
case "nw":
scale = Math.max(
(x2 - rotatedX) / (x2 - x1),
(y2 - rotatedY) / (y2 - y1),
);
break;
case "ne":
scale = Math.max(
(rotatedX - x1) / (x2 - x1),
(y2 - rotatedY) / (y2 - y1),
);
break;
case "sw":
scale = Math.max(
(x2 - rotatedX) / (x2 - x1),
(rotatedY - y1) / (y2 - y1),
);
break;
let scaleX = 0;
let scaleY = 0;
if (transformHandleType.includes("e")) {
scaleX = (rotatedX - x1) / (x2 - x1);
}
if (transformHandleType.includes("w")) {
scaleX = (x2 - rotatedX) / (x2 - x1);
}
if (transformHandleType.includes("n")) {
scaleY = (y2 - rotatedY) / (y2 - y1);
}
if (transformHandleType.includes("s")) {
scaleY = (rotatedY - y1) / (y2 - y1);
}
const scale = Math.max(scaleX, scaleY);
if (scale > 0) {
const nextWidth = element.width * scale;
const nextHeight = element.height * scale;
@ -305,32 +267,55 @@ const resizeSingleTextElement = (
if (metrics === null) {
return;
}
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
element,
nextWidth,
nextHeight,
false,
);
const deltaX1 = (x1 - nextX1) / 2;
const deltaY1 = (y1 - nextY1) / 2;
const deltaX2 = (x2 - nextX2) / 2;
const deltaY2 = (y2 - nextY2) / 2;
const [nextElementX, nextElementY] = adjustXYWithRotation(
getSidesForTransformHandle(transformHandleType, shouldResizeFromCenter),
element.x,
element.y,
element.angle,
deltaX1,
deltaY1,
deltaX2,
deltaY2,
);
const startTopLeft = [x1, y1];
const startBottomRight = [x2, y2];
const startCenter = [cx, cy];
let newTopLeft = [x1, y1] as [number, number];
if (["n", "w", "nw"].includes(transformHandleType)) {
newTopLeft = [
startBottomRight[0] - Math.abs(nextWidth),
startBottomRight[1] - Math.abs(nextHeight),
];
}
if (transformHandleType === "ne") {
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(nextHeight)];
}
if (transformHandleType === "sw") {
const topRight = [startBottomRight[0], startTopLeft[1]];
newTopLeft = [topRight[0] - Math.abs(nextWidth), topRight[1]];
}
if (["s", "n"].includes(transformHandleType)) {
newTopLeft[0] = startCenter[0] - nextWidth / 2;
}
if (["e", "w"].includes(transformHandleType)) {
newTopLeft[1] = startCenter[1] - nextHeight / 2;
}
if (shouldResizeFromCenter) {
newTopLeft[0] = startCenter[0] - Math.abs(nextWidth) / 2;
newTopLeft[1] = startCenter[1] - Math.abs(nextHeight) / 2;
}
const angle = element.angle;
const rotatedTopLeft = rotatePoint(newTopLeft, [cx, cy], angle);
const newCenter: Point = [
newTopLeft[0] + Math.abs(nextWidth) / 2,
newTopLeft[1] + Math.abs(nextHeight) / 2,
];
const rotatedNewCenter = rotatePoint(newCenter, [cx, cy], angle);
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
const [nextX, nextY] = newTopLeft;
mutateElement(element, {
fontSize: metrics.size,
width: nextWidth,
height: nextHeight,
x: nextElementX,
y: nextElementY,
x: nextX,
y: nextY,
});
}
};
@ -636,8 +621,9 @@ export const resizeMultipleElements = (
originalElements: PointerDownState["originalElements"],
selectedElements: readonly NonDeletedExcalidrawElement[],
elementsMap: ElementsMap,
transformHandleType: "nw" | "ne" | "sw" | "se",
transformHandleType: TransformHandleDirection,
shouldResizeFromCenter: boolean,
shouldMaintainAspectRatio: boolean,
pointerX: number,
pointerY: number,
) => {
@ -691,43 +677,80 @@ export const resizeMultipleElements = (
const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
targetElements.map(({ orig }) => orig).concat(boundTextElements),
);
// const originalHeight = maxY - minY;
// const originalWidth = maxX - minX;
const width = maxX - minX;
const height = maxY - minY;
const direction = transformHandleType;
const mapDirectionsToAnchors: Record<typeof direction, Point> = {
const anchorsMap: Record<TransformHandleDirection, Point> = {
ne: [minX, maxY],
se: [minX, minY],
sw: [maxX, minY],
nw: [maxX, maxY],
e: [minX, minY + height / 2],
w: [maxX, minY + height / 2],
n: [minX + width / 2, maxY],
s: [minX + width / 2, minY],
};
// anchor point must be on the opposite side of the dragged selection handle
// or be the center of the selection if shouldResizeFromCenter
const [anchorX, anchorY]: Point = shouldResizeFromCenter
? [midX, midY]
: mapDirectionsToAnchors[direction];
: anchorsMap[direction];
const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
const scale =
Math.max(
Math.abs(pointerX - anchorX) / (maxX - minX) || 0,
Math.abs(pointerY - anchorY) / (maxY - minY) || 0,
) * (shouldResizeFromCenter ? 2 : 1);
Math.abs(pointerX - anchorX) / width || 0,
Math.abs(pointerY - anchorY) / height || 0,
) * resizeFromCenterScale;
if (scale === 0) {
return;
}
const mapDirectionsToPointerPositions: Record<
typeof direction,
let scaleX =
direction.includes("e") || direction.includes("w")
? (Math.abs(pointerX - anchorX) / width) * resizeFromCenterScale
: 1;
let scaleY =
direction.includes("n") || direction.includes("s")
? (Math.abs(pointerY - anchorY) / height) * resizeFromCenterScale
: 1;
const keepAspectRatio =
shouldMaintainAspectRatio ||
targetElements.some(
(item) =>
item.latest.angle !== 0 ||
isTextElement(item.latest) ||
isInGroup(item.latest),
);
if (keepAspectRatio) {
scaleX = scale;
scaleY = scale;
}
const flipConditionsMap: Record<
TransformHandleDirection,
// Condition for which we should flip or not flip the selected elements
// - when evaluated to `true`, we flip
// - therefore, setting it to always `false` means we do not flip (in that direction) at all
[x: boolean, y: boolean]
> = {
ne: [pointerX >= anchorX, pointerY <= anchorY],
se: [pointerX >= anchorX, pointerY >= anchorY],
sw: [pointerX <= anchorX, pointerY >= anchorY],
nw: [pointerX <= anchorX, pointerY <= anchorY],
ne: [pointerX < anchorX, pointerY > anchorY],
se: [pointerX < anchorX, pointerY < anchorY],
sw: [pointerX > anchorX, pointerY < anchorY],
nw: [pointerX > anchorX, pointerY > anchorY],
// e.g. when resizing from the "e" side, we do not need to consider changes in the `y` direction
// and therefore, we do not need to flip in the `y` direction at all
e: [pointerX < anchorX, false],
w: [pointerX > anchorX, false],
n: [false, pointerY > anchorY],
s: [false, pointerY < anchorY],
};
/**
@ -738,9 +761,9 @@ export const resizeMultipleElements = (
* mirror points in the case of linear & freedraw elemenets
* 3. adjust element angle
*/
const [flipFactorX, flipFactorY] = mapDirectionsToPointerPositions[
direction
].map((condition) => (condition ? 1 : -1));
const [flipFactorX, flipFactorY] = flipConditionsMap[direction].map(
(condition) => (condition ? -1 : 1),
);
const isFlippedByX = flipFactorX < 0;
const isFlippedByY = flipFactorY < 0;
@ -762,8 +785,8 @@ export const resizeMultipleElements = (
continue;
}
const width = orig.width * scale;
const height = orig.height * scale;
const width = orig.width * scaleX;
const height = orig.height * scaleY;
const angle = normalizeAngle(orig.angle * flipFactorX * flipFactorY);
const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig);
@ -771,8 +794,8 @@ export const resizeMultipleElements = (
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);
const x = anchorX + flipFactorX * (offsetX * scaleX + shiftX);
const y = anchorY + flipFactorY * (offsetY * scaleY + shiftY);
const rescaledPoints = rescalePointsInElement(
orig,
@ -790,40 +813,10 @@ export const resizeMultipleElements = (
...rescaledPoints,
};
if (isImageElement(orig) && targetElements.length === 1) {
if (isImageElement(orig)) {
update.scale = [orig.scale[0] * flipFactorX, orig.scale[1] * flipFactorY];
}
if (isLinearElement(orig) && (isFlippedByX || isFlippedByY)) {
const origBounds = getElementPointsCoords(orig, orig.points);
const newBounds = getElementPointsCoords(
{ ...orig, x, y },
rescaledPoints.points!,
);
const origXY = [orig.x, orig.y];
const newXY = [x, y];
const linearShift = (axis: "x" | "y") => {
const i = axis === "x" ? 0 : 1;
return (
(newBounds[i + 2] -
newXY[i] -
(origXY[i] - origBounds[i]) * scale +
(origBounds[i + 2] - origXY[i]) * scale -
(newXY[i] - newBounds[i])) /
2
);
};
if (isFlippedByX) {
update.x -= linearShift("x");
}
if (isFlippedByY) {
update.y -= linearShift("y");
}
}
if (isTextElement(orig)) {
const metrics = measureFontSizeFromWidth(orig, elementsMap, width);
if (!metrics) {
@ -837,11 +830,15 @@ export const resizeMultipleElements = (
) as ExcalidrawTextElementWithContainer | undefined;
if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale;
if (newFontSize < MIN_FONT_SIZE) {
return;
if (keepAspectRatio) {
const newFontSize = boundTextElement.fontSize * scale;
if (newFontSize < MIN_FONT_SIZE) {
return;
}
update.boundTextFontSize = newFontSize;
} else {
update.boundTextFontSize = boundTextElement.fontSize;
}
update.boundTextFontSize = newFontSize;
}
elementsAndUpdates.push({

@ -6,15 +6,24 @@ import {
} from "./types";
import {
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
getTransformHandlesFromCoords,
getTransformHandles,
TransformHandleType,
TransformHandle,
MaybeTransformHandleType,
getOmitSidesForDevice,
canResizeFromSides,
} from "./transformHandles";
import { AppState, Zoom } from "../types";
import { Bounds } from "./bounds";
import { AppState, Device, Zoom } from "../types";
import { Bounds, getElementAbsoluteCoords } from "./bounds";
import { SIDE_RESIZING_THRESHOLD } from "../constants";
import {
angleToDegrees,
pointOnLine,
pointRotate,
} from "../../utils/geometry/geometry";
import { Line, Point } from "../../utils/geometry/shape";
import { isLinearElement } from "./typeChecks";
const isInsideTransformHandle = (
transformHandle: TransformHandle,
@ -34,13 +43,20 @@ export const resizeTest = (
y: number,
zoom: Zoom,
pointerType: PointerType,
device: Device,
): MaybeTransformHandleType => {
if (!appState.selectedElementIds[element.id]) {
return false;
}
const { rotation: rotationTransformHandle, ...transformHandles } =
getTransformHandles(element, zoom, elementsMap, pointerType);
getTransformHandles(
element,
zoom,
elementsMap,
pointerType,
getOmitSidesForDevice(device),
);
if (
rotationTransformHandle &&
@ -62,6 +78,35 @@ export const resizeTest = (
return filter[0] as TransformHandleType;
}
if (canResizeFromSides(device)) {
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
// Note that for a text element, when "resized" from the side
// we should make it wrap/unwrap
if (
element.type !== "text" &&
!(isLinearElement(element) && element.points.length <= 2)
) {
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders(
[x1 - SPACING, y1 - SPACING],
[x2 + SPACING, y2 + SPACING],
[cx, cy],
angleToDegrees(element.angle),
);
for (const [dir, side] of Object.entries(sides)) {
// test to see if x, y are on the line segment
if (pointOnLine([x, y], side as Line, SPACING)) {
return dir as TransformHandleType;
}
}
}
}
return false;
};
@ -73,6 +118,7 @@ export const getElementWithTransformHandleType = (
zoom: Zoom,
pointerType: PointerType,
elementsMap: ElementsMap,
device: Device,
) => {
return elements.reduce((result, element) => {
if (result) {
@ -86,6 +132,7 @@ export const getElementWithTransformHandleType = (
scenePointerY,
zoom,
pointerType,
device,
);
return transformHandleType ? { element, transformHandleType } : null;
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
@ -97,13 +144,14 @@ export const getTransformHandleTypeFromCoords = (
scenePointerY: number,
zoom: Zoom,
pointerType: PointerType,
device: Device,
): MaybeTransformHandleType => {
const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
0,
zoom,
pointerType,
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
getOmitSidesForDevice(device),
);
const found = Object.keys(transformHandles).find((key) => {
@ -114,7 +162,33 @@ export const getTransformHandleTypeFromCoords = (
isInsideTransformHandle(transformHandle, scenePointerX, scenePointerY)
);
});
return (found || false) as MaybeTransformHandleType;
if (found) {
return found as MaybeTransformHandleType;
}
if (canResizeFromSides(device)) {
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders(
[x1 - SPACING, y1 - SPACING],
[x2 + SPACING, y2 + SPACING],
[cx, cy],
angleToDegrees(0),
);
for (const [dir, side] of Object.entries(sides)) {
// test to see if x, y are on the line segment
if (pointOnLine([scenePointerX, scenePointerY], side as Line, SPACING)) {
return dir as TransformHandleType;
}
}
}
return false;
};
const RESIZE_CURSORS = ["ns", "nesw", "ew", "nwse"];
@ -174,3 +248,22 @@ export const getCursorForResizingElement = (resizingElement: {
return cursor ? `${cursor}-resize` : "";
};
const getSelectionBorders = (
[x1, y1]: Point,
[x2, y2]: Point,
center: Point,
angleInDegrees: number,
) => {
const topLeft = pointRotate([x1, y1], angleInDegrees, center);
const topRight = pointRotate([x2, y1], angleInDegrees, center);
const bottomLeft = pointRotate([x1, y2], angleInDegrees, center);
const bottomRight = pointRotate([x2, y2], angleInDegrees, center);
return {
n: [topLeft, topRight],
e: [topRight, bottomRight],
s: [bottomRight, bottomLeft],
w: [bottomLeft, topLeft],
};
};

@ -7,10 +7,14 @@ import {
import { Bounds, getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math";
import { InteractiveCanvasAppState, Zoom } from "../types";
import { Device, InteractiveCanvasAppState, Zoom } from "../types";
import { isTextElement } from ".";
import { isFrameLikeElement, isLinearElement } from "./typeChecks";
import { DEFAULT_TRANSFORM_HANDLE_SPACING } from "../constants";
import {
DEFAULT_TRANSFORM_HANDLE_SPACING,
isAndroid,
isIOS,
} from "../constants";
export type TransformHandleDirection =
| "n"
@ -38,6 +42,13 @@ const transformHandleSizes: { [k in PointerType]: number } = {
const ROTATION_RESIZE_HANDLE_GAP = 16;
export const DEFAULT_OMIT_SIDES = {
e: true,
s: true,
n: true,
w: true,
};
export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
e: true,
s: true,
@ -89,6 +100,26 @@ const generateTransformHandle = (
return [xx - width / 2, yy - height / 2, width, height];
};
export const canResizeFromSides = (device: Device) => {
if (device.viewport.isMobile) {
return false;
}
if (device.isTouchScreen && (isAndroid || isIOS)) {
return false;
}
return true;
};
export const getOmitSidesForDevice = (device: Device) => {
if (canResizeFromSides(device)) {
return DEFAULT_OMIT_SIDES;
}
return {};
};
export const getTransformHandlesFromCoords = (
[x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
angle: number,
@ -232,8 +263,8 @@ export const getTransformHandles = (
element: ExcalidrawElement,
zoom: Zoom,
elementsMap: ElementsMap,
pointerType: PointerType = "mouse",
omitSides: { [T in TransformHandleType]?: boolean } = DEFAULT_OMIT_SIDES,
): TransformHandles => {
// so that when locked element is selected (especially when you toggle lock
// via keyboard) the locked element is visually distinct, indicating
@ -242,7 +273,6 @@ export const getTransformHandles = (
return {};
}
let omitSides: { [T in TransformHandleType]?: boolean } = {};
if (element.type === "freedraw" || isLinearElement(element)) {
if (element.points.length === 2) {
// only check the last point because starting point is always (0,0)
@ -263,6 +293,7 @@ export const getTransformHandles = (
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
} else if (isFrameLikeElement(element)) {
omitSides = {
...omitSides,
rotation: true,
};
}

@ -387,3 +387,7 @@ export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => {
return maxGroup === elements.length;
};
export const isInGroup = (element: NonDeletedExcalidrawElement) => {
return element.groupIds.length > 0;
};

@ -1,6 +1,5 @@
import {
getElementAbsoluteCoords,
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
getTransformHandlesFromCoords,
getTransformHandles,
getCommonBounds,
@ -23,7 +22,7 @@ import {
selectGroupsFromGivenElements,
} from "../groups";
import {
OMIT_SIDES_FOR_FRAME,
getOmitSidesForDevice,
shouldShowBoundingBox,
TransformHandles,
TransformHandleType,
@ -577,6 +576,7 @@ const _renderInteractiveScene = ({
scale,
appState,
renderConfig,
device,
}: InteractiveSceneRenderConfig) => {
if (canvas === null) {
return { atLeastOneVisibleElement: false, elementsMap };
@ -806,6 +806,7 @@ const _renderInteractiveScene = ({
appState.zoom,
elementsMap,
"mouse", // when we render we don't know which pointer type so use mouse,
getOmitSidesForDevice(device),
);
if (!appState.viewModeEnabled && showBoundingBox) {
renderTransformHandles(
@ -844,8 +845,8 @@ const _renderInteractiveScene = ({
appState.zoom,
"mouse",
isFrameSelected
? OMIT_SIDES_FOR_FRAME
: OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
? { ...getOmitSidesForDevice(device), rotation: true }
: getOmitSidesForDevice(device),
);
if (selectedElements.some((element) => !element.locked)) {
renderTransformHandles(

@ -16,6 +16,7 @@ import {
StaticCanvasAppState,
SocketId,
UserIdleState,
Device,
} from "../types";
import { MakeBrand } from "../utility-types";
@ -85,6 +86,7 @@ export type InteractiveSceneRenderConfig = {
scale: number;
appState: InteractiveCanvasAppState;
renderConfig: InteractiveCanvasRenderConfig;
device: Device;
callback: (data: RenderInteractiveSceneCallback) => void;
};

@ -2170,14 +2170,14 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"roundness": {
"type": 3,
},
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 1150084233,
"versionNonce": 1014066025,
"width": 20,
"x": -10,
"y": 0,
@ -2404,14 +2404,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"roundness": {
"type": 3,
},
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 401146281,
"versionNonce": 1150084233,
"width": 20,
"x": -10,
"y": 0,
@ -2438,14 +2438,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"roundness": {
"type": 3,
},
"seed": 1150084233,
"seed": 1014066025,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 1116226695,
"versionNonce": 238820263,
"width": 20,
"x": 0,
"y": 10,
@ -2704,14 +2704,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"roundness": {
"type": 3,
},
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 1505387817,
"versionNonce": 493213705,
"width": 20,
"x": -10,
"y": 0,
@ -2740,14 +2740,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"roundness": {
"type": 3,
},
"seed": 1150084233,
"seed": 1014066025,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 23633383,
"versionNonce": 915032327,
"width": 20,
"x": 20,
"y": 30,
@ -3060,14 +3060,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"roundness": {
"type": 3,
},
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#e03131",
"strokeStyle": "dotted",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 640725609,
"versionNonce": 941653321,
"width": 20,
"x": -10,
"y": 0,
@ -3094,14 +3094,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"roundness": {
"type": 3,
},
"seed": 760410951,
"seed": 289600103,
"strokeColor": "#e03131",
"strokeStyle": "dotted",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 9,
"versionNonce": 1315507081,
"versionNonce": 640725609,
"width": 20,
"x": 20,
"y": 30,
@ -3840,14 +3840,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"roundness": {
"type": 3,
},
"seed": 1150084233,
"seed": 1014066025,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 1604849351,
"versionNonce": 23633383,
"width": 20,
"x": 20,
"y": 30,
@ -3874,14 +3874,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"roundness": {
"type": 3,
},
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 401146281,
"versionNonce": 1150084233,
"width": 20,
"x": -10,
"y": 0,
@ -5224,8 +5224,8 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
},
},
],
"left": -19,
"top": -9,
"left": -17,
"top": -7,
},
"currentChartType": "bar",
"currentItemBackgroundColor": "transparent",
@ -5342,14 +5342,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"roundness": {
"type": 3,
},
"seed": 449462985,
"seed": 453191,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 1150084233,
"versionNonce": 1014066025,
"width": 10,
"x": -10,
"y": 0,
@ -5376,16 +5376,16 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"roundness": {
"type": 3,
},
"seed": 1014066025,
"seed": 400692809,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 1604849351,
"versionNonce": 23633383,
"width": 10,
"x": 10,
"x": 12,
"y": 0,
}
`;
@ -5493,7 +5493,7 @@ History {
"strokeWidth": 2,
"type": "rectangle",
"width": 10,
"x": 10,
"x": 12,
"y": 0,
},
"inserted": {
@ -6349,8 +6349,8 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
},
},
],
"left": -19,
"top": -9,
"left": -17,
"top": -7,
},
"currentChartType": "bar",
"currentItemBackgroundColor": "transparent",
@ -6516,7 +6516,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"version": 4,
"versionNonce": 747212839,
"width": 10,
"x": 10,
"x": 12,
"y": 0,
}
`;
@ -6624,7 +6624,7 @@ History {
"strokeWidth": 2,
"type": "rectangle",
"width": 10,
"x": 10,
"x": 12,
"y": 0,
},
"inserted": {
@ -8181,8 +8181,8 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
},
},
],
"left": -19,
"top": -9,
"left": -17,
"top": -7,
},
"currentChartType": "bar",
"currentItemBackgroundColor": "transparent",

@ -1400,9 +1400,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"penDetected": false,
"penMode": false,
"pendingImageElementId": null,
"previousSelectedElementIds": {
"id0": true,
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
@ -1522,7 +1520,7 @@ History {
exports[`regression tests > Drags selected element when hitting only bounding box and keeps element selected > [end of test] number of elements 1`] = `0`;
exports[`regression tests > Drags selected element when hitting only bounding box and keeps element selected > [end of test] number of renders 1`] = `9`;
exports[`regression tests > Drags selected element when hitting only bounding box and keeps element selected > [end of test] number of renders 1`] = `11`;
exports[`regression tests > adjusts z order when grouping > [end of test] appState 1`] = `
{

@ -45,6 +45,7 @@ describe("element binding", () => {
mouse.downAt(100, 0);
mouse.moveTo(55, 0);
mouse.up(0, 0);
expect(API.getSelectedElements()).toEqual([arrow]);
expect(arrow.startBinding).toEqual({
elementId: rect.id,
focus: expect.toBeNonNaNNumber(),

@ -108,8 +108,8 @@ describe("contextMenu element", () => {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
clientX: 3,
clientY: 3,
});
const contextMenu = UI.queryContextMenu();
const contextMenuOptions =
@ -188,19 +188,19 @@ describe("contextMenu element", () => {
mouse.up(10, 10);
UI.clickTool("rectangle");
mouse.down(10, -10);
mouse.down(12, -10);
mouse.up(10, 10);
mouse.reset();
mouse.click(10, 10);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(20, 0);
mouse.click(22, 0);
});
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
clientX: 3,
clientY: 3,
});
const contextMenu = UI.queryContextMenu();
@ -240,13 +240,13 @@ describe("contextMenu element", () => {
mouse.up(10, 10);
UI.clickTool("rectangle");
mouse.down(10, -10);
mouse.down(12, -10);
mouse.up(10, 10);
mouse.reset();
mouse.click(10, 10);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(20, 0);
mouse.click(22, 0);
});
Keyboard.withModifierKeys({ ctrl: true }, () => {
@ -255,8 +255,8 @@ describe("contextMenu element", () => {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
clientX: 3,
clientY: 3,
});
const contextMenu = UI.queryContextMenu();
@ -297,8 +297,8 @@ describe("contextMenu element", () => {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
clientX: 3,
clientY: 3,
});
const contextMenu = UI.queryContextMenu();
expect(copiedStyles).toBe("{}");
@ -382,8 +382,8 @@ describe("contextMenu element", () => {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
clientX: 3,
clientY: 3,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryAllByText(contextMenu!, "Delete")[0]);
@ -398,8 +398,8 @@ describe("contextMenu element", () => {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
clientX: 3,
clientY: 3,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByText(contextMenu!, "Add to library")!);
@ -417,8 +417,8 @@ describe("contextMenu element", () => {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
clientX: 3,
clientY: 3,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByText(contextMenu!, "Duplicate")!);
@ -548,8 +548,8 @@ describe("contextMenu element", () => {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
clientX: 3,
clientY: 3,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByText(contextMenu!, "Group selection")!);
@ -578,8 +578,8 @@ describe("contextMenu element", () => {
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
clientX: 3,
clientY: 3,
});
const contextMenu = UI.queryContextMenu();

@ -315,6 +315,7 @@ const transform = (
h.state.zoom,
arrayToMap(h.elements),
"mouse",
{},
)[handle];
} else {
const [x1, y1, x2, y2] = getCommonBounds(elements);

@ -199,7 +199,6 @@ describe("regression tests", () => {
expect(
h.elements.filter((element) => element.type === "rectangle").length,
).toBe(1);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(-8, -8);
mouse.up(10, 10);
@ -725,7 +724,7 @@ describe("regression tests", () => {
mouse.up(10, 10);
const { x: prevX, y: prevY } = API.getSelectedElement();
API.clearSelection();
// drag element from point on bounding box that doesn't hit element
mouse.reset();
mouse.down(8, 8);
@ -1015,12 +1014,22 @@ describe("regression tests", () => {
});
it("single-clicking on a subgroup of a selected group should not alter selection", () => {
const rect1 = UI.createElement("rectangle", { x: 10 });
const rect2 = UI.createElement("rectangle", { x: 50 });
const rect1 = UI.createElement("rectangle", {
x: 10,
});
const rect2 = UI.createElement("rectangle", {
x: 50,
});
UI.group([rect1, rect2]);
const rect3 = UI.createElement("rectangle", { x: 10, y: 50 });
const rect4 = UI.createElement("rectangle", { x: 50, y: 50 });
const rect3 = UI.createElement("rectangle", {
x: 10,
y: 50,
});
const rect4 = UI.createElement("rectangle", {
x: 50,
y: 50,
});
UI.group([rect3, rect4]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
@ -1079,8 +1088,9 @@ describe("regression tests", () => {
UI.group([rect1, rect3]);
assertSelectedElements(rect1, rect2, rect3);
mouse.reset();
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.clickOn(rect1);
mouse.click(10, 5);
});
assertSelectedElements(rect1);

@ -544,7 +544,9 @@ describe("multiple selection", () => {
1 + move[1] / selectionHeight,
);
UI.resize([rectangle, diamond, ellipse], "se", move);
UI.resize([rectangle, diamond, ellipse], "se", move, {
shift: true,
});
expect(rectangle.x).toBeCloseTo(0);
expect(rectangle.y).toBeCloseTo(0);
@ -613,7 +615,9 @@ describe("multiple selection", () => {
1 + move[1] / selectionHeight,
);
UI.resize([line, freedraw], "se", move);
UI.resize([line, freedraw], "se", move, {
shift: true,
});
expect(line.x).toBeCloseTo(60 * scale);
expect(line.y).toBeCloseTo(40 * scale);
@ -653,7 +657,9 @@ describe("multiple selection", () => {
1 - move[1] / selectionHeight,
);
UI.resize([horizLine, vertLine, diagLine], "nw", move);
UI.resize([horizLine, vertLine, diagLine], "nw", move, {
shift: true,
});
expect(horizLine.x).toBeCloseTo(selectionWidth * (1 - scale));
expect(horizLine.y).toBeCloseTo(selectionHeight * (1 - scale));
@ -703,7 +709,9 @@ describe("multiple selection", () => {
const rightArrowBinding = { ...rightBoundArrow.endBinding };
delete rightArrowBinding.gap;
UI.resize([rectangle, rightBoundArrow], "nw", move);
UI.resize([rectangle, rightBoundArrow], "nw", move, {
shift: true,
});
expect(leftBoundArrow.x).toBeCloseTo(-110);
expect(leftBoundArrow.y).toBeCloseTo(50);
@ -751,7 +759,9 @@ describe("multiple selection", () => {
const move = [80, 0] as [number, number];
const scale = move[0] / selectionWidth + 1;
const elementsMap = arrayToMap(h.elements);
UI.resize([topArrow.get(), bottomArrow.get()], "se", move);
UI.resize([topArrow.get(), bottomArrow.get()], "se", move, {
shift: true,
});
const topArrowLabelPos = LinearElementEditor.getBoundTextElementPosition(
topArrow,
topArrowLabel,
@ -815,7 +825,7 @@ describe("multiple selection", () => {
1 - move[1] / selectionHeight,
);
UI.resize([topText, bottomText], "ne", move);
UI.resize([topText, bottomText], "ne", move, { shift: true });
expect(topText.x).toBeCloseTo(0);
expect(topText.y).toBeCloseTo(-selectionHeight * (scale - 1));
@ -828,7 +838,7 @@ describe("multiple selection", () => {
expect(bottomText.angle).toEqual(0);
});
it("resizes with images", () => {
it("resizes with images (proportional)", () => {
const topImage = API.createElement({
type: "image",
x: 0,
@ -891,7 +901,7 @@ describe("multiple selection", () => {
1 + (2 * move[1]) / selectionHeight,
);
UI.resize([rectangle, ellipse], "se", move, { alt: true });
UI.resize([rectangle, ellipse], "se", move, { shift: true, alt: true });
expect(rectangle.x).toBeCloseTo(-200 * scale);
expect(rectangle.y).toBeCloseTo(-140 * scale);
@ -954,7 +964,9 @@ describe("multiple selection", () => {
const scaleY = -scaleX;
const lineOrigBounds = getBoundsFromPoints(line);
const elementsMap = arrayToMap(h.elements);
UI.resize([line, image, rectangle, boundArrow], "se", move);
UI.resize([line, image, rectangle, boundArrow], "se", move, {
shift: true,
});
const lineNewBounds = getBoundsFromPoints(line);
const arrowLabelPos = LinearElementEditor.getBoundTextElementPosition(
boundArrow,
@ -979,7 +991,7 @@ describe("multiple selection", () => {
expect(image.width).toBeCloseTo(100 * -scaleX);
expect(image.height).toBeCloseTo(100 * scaleY);
expect(image.angle).toBeCloseTo((Math.PI * 5) / 6);
expect(image.scale).toEqual([1, 1]);
expect(image.scale).toEqual([-1, 1]);
expect(rectangle.x).toBeCloseTo((180 + 160) * scaleX);
expect(rectangle.y).toBeCloseTo(60 * scaleY);

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save