You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
success/packages/excalidraw/element/resizeElements.ts

1499 lines
41 KiB
TypeScript

import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
import { rescalePoints } from "../points";
import type {
ExcalidrawLinearElement,
ExcalidrawTextElement,
NonDeletedExcalidrawElement,
NonDeleted,
ExcalidrawElement,
ExcalidrawTextElementWithContainer,
ExcalidrawImageElement,
ElementsMap,
SceneElementsMap,
} from "./types";
import type { Mutable } from "../utility-types";
import {
getElementAbsoluteCoords,
getCommonBounds,
getResizedElementAbsoluteCoords,
getCommonBoundingBox,
getElementBounds,
} from "./bounds";
import type { BoundingBox } from "./bounds";
import {
isArrowElement,
isBoundToContainer,
isElbowArrow,
isFrameLikeElement,
isFreeDrawElement,
isImageElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import { mutateElement } from "./mutateElement";
import { getFontString } from "../utils";
import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
import type {
MaybeTransformHandleType,
TransformHandleDirection,
} from "./transformHandles";
import type { PointerDownState } from "../types";
import type Scene from "../scene/Scene";
import {
getApproxMinLineWidth,
getBoundTextElement,
getBoundTextElementId,
getContainerElement,
handleBindTextResize,
getBoundTextMaxWidth,
getApproxMinLineHeight,
measureText,
getMinTextElementWidth,
} from "./textElement";
import { wrapText } from "./textWrapping";
import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups";
import { mutateElbowArrow } from "./routing";
import type { GlobalPoint } from "../../math";
import {
pointCenter,
normalizeRadians,
pointFrom,
pointFromPair,
pointRotateRads,
type Radians,
type LocalPoint,
} from "../../math";
// Returns true when transform (resizing/rotation) happened
export const transformElements = (
originalElements: PointerDownState["originalElements"],
transformHandleType: MaybeTransformHandleType,
selectedElements: readonly NonDeletedExcalidrawElement[],
elementsMap: SceneElementsMap,
scene: Scene,
shouldRotateWithDiscreteAngle: boolean,
shouldResizeFromCenter: boolean,
shouldMaintainAspectRatio: boolean,
pointerX: number,
pointerY: number,
centerX: number,
centerY: number,
): boolean => {
if (selectedElements.length === 1) {
const [element] = selectedElements;
if (transformHandleType === "rotation") {
if (!isElbowArrow(element)) {
rotateSingleElement(
element,
elementsMap,
scene,
pointerX,
pointerY,
shouldRotateWithDiscreteAngle,
);
updateBoundElements(element, elementsMap);
}
} else if (isTextElement(element) && transformHandleType) {
resizeSingleTextElement(
originalElements,
element,
elementsMap,
transformHandleType,
shouldResizeFromCenter,
pointerX,
pointerY,
);
updateBoundElements(element, elementsMap);
return true;
} else if (transformHandleType) {
const elementId = selectedElements[0].id;
const latestElement = elementsMap.get(elementId);
const origElement = originalElements.get(elementId);
if (latestElement && origElement) {
const { nextWidth, nextHeight } =
getNextSingleWidthAndHeightFromPointer(
latestElement,
origElement,
elementsMap,
originalElements,
transformHandleType,
pointerX,
pointerY,
{
shouldMaintainAspectRatio,
shouldResizeFromCenter,
},
);
resizeSingleElement(
nextWidth,
nextHeight,
latestElement,
origElement,
elementsMap,
originalElements,
transformHandleType,
{
shouldMaintainAspectRatio,
shouldResizeFromCenter,
},
);
}
}
return true;
} else if (selectedElements.length > 1) {
if (transformHandleType === "rotation") {
rotateMultipleElements(
originalElements,
selectedElements,
elementsMap,
scene,
pointerX,
pointerY,
shouldRotateWithDiscreteAngle,
centerX,
centerY,
);
return true;
} else if (transformHandleType) {
const { nextWidth, nextHeight, flipByX, flipByY, originalBoundingBox } =
getNextMultipleWidthAndHeightFromPointer(
selectedElements,
originalElements,
elementsMap,
transformHandleType,
pointerX,
pointerY,
{
shouldMaintainAspectRatio,
shouldResizeFromCenter,
},
);
resizeMultipleElements(
selectedElements,
elementsMap,
transformHandleType,
scene,
{
shouldResizeFromCenter,
shouldMaintainAspectRatio,
originalElementsMap: originalElements,
flipByX,
flipByY,
nextWidth,
nextHeight,
originalBoundingBox,
},
);
return true;
}
}
return false;
};
const rotateSingleElement = (
element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
scene: Scene,
pointerX: number,
pointerY: number,
shouldRotateWithDiscreteAngle: boolean,
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
let angle: Radians;
if (isFrameLikeElement(element)) {
angle = 0 as Radians;
} else {
angle = ((5 * Math.PI) / 2 +
Math.atan2(pointerY - cy, pointerX - cx)) as Radians;
if (shouldRotateWithDiscreteAngle) {
angle = (angle + SHIFT_LOCKING_ANGLE / 2) as Radians;
angle = (angle - (angle % SHIFT_LOCKING_ANGLE)) as Radians;
}
angle = normalizeRadians(angle as Radians);
}
const boundTextElementId = getBoundTextElementId(element);
mutateElement(element, { angle });
if (boundTextElementId) {
const textElement =
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
if (textElement && !isArrowElement(element)) {
mutateElement(textElement, { angle });
}
}
};
export const rescalePointsInElement = (
element: NonDeletedExcalidrawElement,
width: number,
height: number,
normalizePoints: boolean,
) =>
isLinearElement(element) || isFreeDrawElement(element)
? {
points: rescalePoints(
0,
width,
rescalePoints(1, height, element.points, normalizePoints),
normalizePoints,
),
}
: {};
export const measureFontSizeFromWidth = (
element: NonDeleted<ExcalidrawTextElement>,
elementsMap: ElementsMap,
nextWidth: number,
): { size: number } | null => {
// We only use width to scale font on resize
let width = element.width;
const hasContainer = isBoundToContainer(element);
if (hasContainer) {
const container = getContainerElement(element, elementsMap);
if (container) {
width = getBoundTextMaxWidth(container, element);
}
}
const nextFontSize = element.fontSize * (nextWidth / width);
if (nextFontSize < MIN_FONT_SIZE) {
return null;
}
return {
size: nextFontSize,
};
};
const resizeSingleTextElement = (
originalElements: PointerDownState["originalElements"],
element: NonDeleted<ExcalidrawTextElement>,
elementsMap: ElementsMap,
transformHandleType: TransformHandleDirection,
shouldResizeFromCenter: boolean,
pointerX: number,
pointerY: number,
) => {
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
// rotation pointer with reverse angle
const [rotatedX, rotatedY] = pointRotateRads(
pointFrom(pointerX, pointerY),
pointFrom(cx, cy),
-element.angle as Radians,
);
let scaleX = 0;
let scaleY = 0;
if (transformHandleType !== "e" && transformHandleType !== "w") {
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;
const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth);
if (metrics === null) {
return;
}
const startTopLeft = [x1, y1];
const startBottomRight = [x2, y2];
const startCenter = [cx, cy];
let newTopLeft = pointFrom<GlobalPoint>(x1, y1);
if (["n", "w", "nw"].includes(transformHandleType)) {
newTopLeft = pointFrom<GlobalPoint>(
startBottomRight[0] - Math.abs(nextWidth),
startBottomRight[1] - Math.abs(nextHeight),
);
}
if (transformHandleType === "ne") {
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
newTopLeft = pointFrom<GlobalPoint>(
bottomLeft[0],
bottomLeft[1] - Math.abs(nextHeight),
);
}
if (transformHandleType === "sw") {
const topRight = [startBottomRight[0], startTopLeft[1]];
newTopLeft = pointFrom<GlobalPoint>(
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 = pointRotateRads(
newTopLeft,
pointFrom(cx, cy),
angle,
);
const newCenter = pointFrom<GlobalPoint>(
newTopLeft[0] + Math.abs(nextWidth) / 2,
newTopLeft[1] + Math.abs(nextHeight) / 2,
);
const rotatedNewCenter = pointRotateRads(
newCenter,
pointFrom(cx, cy),
angle,
);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
const [nextX, nextY] = newTopLeft;
mutateElement(element, {
fontSize: metrics.size,
width: nextWidth,
height: nextHeight,
x: nextX,
y: nextY,
});
}
if (transformHandleType === "e" || transformHandleType === "w") {
const stateAtResizeStart = originalElements.get(element.id)!;
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
stateAtResizeStart,
stateAtResizeStart.width,
stateAtResizeStart.height,
true,
);
const startTopLeft = pointFrom<GlobalPoint>(x1, y1);
const startBottomRight = pointFrom<GlobalPoint>(x2, y2);
const startCenter = pointCenter(startTopLeft, startBottomRight);
const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY),
startCenter,
-stateAtResizeStart.angle as Radians,
);
const [esx1, , esx2] = getResizedElementAbsoluteCoords(
element,
element.width,
element.height,
true,
);
const boundsCurrentWidth = esx2 - esx1;
const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
const minWidth = getMinTextElementWidth(
getFontString({
fontSize: element.fontSize,
fontFamily: element.fontFamily,
}),
element.lineHeight,
);
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
if (transformHandleType.includes("e")) {
scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
}
if (transformHandleType.includes("w")) {
scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
}
const newWidth =
element.width * scaleX < minWidth ? minWidth : element.width * scaleX;
const text = wrapText(
element.originalText,
getFontString(element),
Math.abs(newWidth),
);
const metrics = measureText(
text,
getFontString(element),
element.lineHeight,
);
const eleNewHeight = metrics.height;
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
getResizedElementAbsoluteCoords(
stateAtResizeStart,
newWidth,
eleNewHeight,
true,
);
const newBoundsWidth = newBoundsX2 - newBoundsX1;
const newBoundsHeight = newBoundsY2 - newBoundsY1;
let newTopLeft = [...startTopLeft] as [number, number];
if (["n", "w", "nw"].includes(transformHandleType)) {
newTopLeft = [
startBottomRight[0] - Math.abs(newBoundsWidth),
startTopLeft[1],
];
}
// adjust topLeft to new rotation point
const angle = stateAtResizeStart.angle;
const rotatedTopLeft = pointRotateRads(
pointFromPair(newTopLeft),
startCenter,
angle,
);
const newCenter = pointFrom(
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
);
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
const resizedElement: Partial<ExcalidrawTextElement> = {
width: Math.abs(newWidth),
height: Math.abs(metrics.height),
x: newTopLeft[0],
y: newTopLeft[1],
text,
autoResize: false,
};
mutateElement(element, resizedElement);
}
};
const rotateMultipleElements = (
originalElements: PointerDownState["originalElements"],
elements: readonly NonDeletedExcalidrawElement[],
elementsMap: SceneElementsMap,
scene: Scene,
pointerX: number,
pointerY: number,
shouldRotateWithDiscreteAngle: boolean,
centerX: number,
centerY: number,
) => {
let centerAngle =
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
if (shouldRotateWithDiscreteAngle) {
centerAngle += SHIFT_LOCKING_ANGLE / 2;
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
}
for (const element of elements) {
if (!isFrameLikeElement(element)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const origAngle =
originalElements.get(element.id)?.angle ?? element.angle;
const [rotatedCX, rotatedCY] = pointRotateRads(
pointFrom(cx, cy),
pointFrom(centerX, centerY),
(centerAngle + origAngle - element.angle) as Radians,
);
if (isElbowArrow(element)) {
const points = getArrowLocalFixedPoints(element, elementsMap);
mutateElbowArrow(element, elementsMap, points);
} else {
mutateElement(
element,
{
x: element.x + (rotatedCX - cx),
y: element.y + (rotatedCY - cy),
angle: normalizeRadians((centerAngle + origAngle) as Radians),
},
false,
);
}
updateBoundElements(element, elementsMap, {
simultaneouslyUpdated: elements,
});
const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !isArrowElement(element)) {
mutateElement(
boundText,
{
x: boundText.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy),
angle: normalizeRadians((centerAngle + origAngle) as Radians),
},
false,
);
}
}
}
scene.triggerUpdate();
};
export const getResizeOffsetXY = (
transformHandleType: MaybeTransformHandleType,
selectedElements: NonDeletedExcalidrawElement[],
elementsMap: ElementsMap,
x: number,
y: number,
): [number, number] => {
const [x1, y1, x2, y2] =
selectedElements.length === 1
? getElementAbsoluteCoords(selectedElements[0], elementsMap)
: getCommonBounds(selectedElements);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const angle = (
selectedElements.length === 1 ? selectedElements[0].angle : 0
) as Radians;
[x, y] = pointRotateRads(
pointFrom(x, y),
pointFrom(cx, cy),
-angle as Radians,
);
switch (transformHandleType) {
case "n":
return pointRotateRads(
pointFrom(x - (x1 + x2) / 2, y - y1),
pointFrom(0, 0),
angle,
);
case "s":
return pointRotateRads(
pointFrom(x - (x1 + x2) / 2, y - y2),
pointFrom(0, 0),
angle,
);
case "w":
return pointRotateRads(
pointFrom(x - x1, y - (y1 + y2) / 2),
pointFrom(0, 0),
angle,
);
case "e":
return pointRotateRads(
pointFrom(x - x2, y - (y1 + y2) / 2),
pointFrom(0, 0),
angle,
);
case "nw":
return pointRotateRads(pointFrom(x - x1, y - y1), pointFrom(0, 0), angle);
case "ne":
return pointRotateRads(pointFrom(x - x2, y - y1), pointFrom(0, 0), angle);
case "sw":
return pointRotateRads(pointFrom(x - x1, y - y2), pointFrom(0, 0), angle);
case "se":
return pointRotateRads(pointFrom(x - x2, y - y2), pointFrom(0, 0), angle);
default:
return [0, 0];
}
};
export const getResizeArrowDirection = (
transformHandleType: MaybeTransformHandleType,
element: NonDeleted<ExcalidrawLinearElement>,
): "origin" | "end" => {
const [, [px, py]] = element.points;
const isResizeEnd =
(transformHandleType === "nw" && (px < 0 || py < 0)) ||
(transformHandleType === "ne" && px >= 0) ||
(transformHandleType === "sw" && px <= 0) ||
(transformHandleType === "se" && (px > 0 || py > 0));
return isResizeEnd ? "end" : "origin";
};
type ResizeAnchor =
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right"
| "west-side"
| "north-side"
| "east-side"
| "south-side"
| "center";
const getResizeAnchor = (
handleDirection: TransformHandleDirection,
shouldMaintainAspectRatio: boolean,
shouldResizeFromCenter: boolean,
): ResizeAnchor => {
if (shouldResizeFromCenter) {
return "center";
}
if (shouldMaintainAspectRatio) {
switch (handleDirection) {
case "n":
return "south-side";
case "e": {
return "west-side";
}
case "s":
return "north-side";
case "w":
return "east-side";
case "ne":
return "bottom-left";
case "nw":
return "bottom-right";
case "se":
return "top-left";
case "sw":
return "top-right";
}
}
if (["e", "se", "s"].includes(handleDirection)) {
return "top-left";
} else if (["n", "nw", "w"].includes(handleDirection)) {
return "bottom-right";
} else if (handleDirection === "ne") {
return "bottom-left";
}
return "top-right";
};
const getResizedOrigin = (
prevOrigin: GlobalPoint,
prevWidth: number,
prevHeight: number,
newWidth: number,
newHeight: number,
angle: number,
handleDirection: TransformHandleDirection,
shouldMaintainAspectRatio: boolean,
shouldResizeFromCenter: boolean,
): { x: number; y: number } => {
const anchor = getResizeAnchor(
handleDirection,
shouldMaintainAspectRatio,
shouldResizeFromCenter,
);
const [x, y] = prevOrigin;
switch (anchor) {
case "top-left":
return {
x:
x +
(prevWidth - newWidth) / 2 +
((newWidth - prevWidth) / 2) * Math.cos(angle) +
((prevHeight - newHeight) / 2) * Math.sin(angle),
y:
y +
(prevHeight - newHeight) / 2 +
((newWidth - prevWidth) / 2) * Math.sin(angle) +
((newHeight - prevHeight) / 2) * Math.cos(angle),
};
case "top-right":
return {
x:
x +
((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1) +
((prevHeight - newHeight) / 2) * Math.sin(angle),
y:
y +
(prevHeight - newHeight) / 2 +
((prevWidth - newWidth) / 2) * Math.sin(angle) +
((newHeight - prevHeight) / 2) * Math.cos(angle),
};
case "bottom-left":
return {
x:
x +
((prevWidth - newWidth) / 2) * (1 - Math.cos(angle)) +
((newHeight - prevHeight) / 2) * Math.sin(angle),
y:
y +
((prevHeight - newHeight) / 2) * (Math.cos(angle) + 1) +
((newWidth - prevWidth) / 2) * Math.sin(angle),
};
case "bottom-right":
return {
x:
x +
((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1) +
((newHeight - prevHeight) / 2) * Math.sin(angle),
y:
y +
((prevHeight - newHeight) / 2) * (Math.cos(angle) + 1) +
((prevWidth - newWidth) / 2) * Math.sin(angle),
};
case "center":
return {
x: x - (newWidth - prevWidth) / 2,
y: y - (newHeight - prevHeight) / 2,
};
case "east-side":
return {
x: x + ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1),
y:
y +
(newHeight - prevHeight) / 2 +
((prevWidth - newWidth) / 2) * Math.sin(angle),
};
case "west-side":
return {
x: x + ((prevWidth - newWidth) / 2) * (1 - Math.cos(angle)),
y:
y +
((newWidth - prevWidth) / 2) * Math.sin(angle) +
(prevHeight - newHeight) / 2,
};
case "north-side":
return {
x:
x +
(prevWidth - newWidth) / 2 +
((prevHeight - newHeight) / 2) * Math.sin(angle),
y: y + ((newHeight - prevHeight) / 2) * (Math.cos(angle) - 1),
};
case "south-side":
return {
x:
x +
(prevWidth - newWidth) / 2 +
((newHeight - prevHeight) / 2) * Math.sin(angle),
y: y + ((prevHeight - newHeight) / 2) * (Math.cos(angle) + 1),
};
}
};
export const resizeSingleElement = (
nextWidth: number,
nextHeight: number,
latestElement: ExcalidrawElement,
origElement: ExcalidrawElement,
elementsMap: ElementsMap,
originalElementsMap: ElementsMap,
handleDirection: TransformHandleDirection,
{
shouldInformMutation = true,
shouldMaintainAspectRatio = false,
shouldResizeFromCenter = false,
}: {
shouldMaintainAspectRatio?: boolean;
shouldResizeFromCenter?: boolean;
shouldInformMutation?: boolean;
} = {},
) => {
let boundTextFont: { fontSize?: number } = {};
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement) {
const stateOfBoundTextElementAtResize = originalElementsMap.get(
boundTextElement.id,
) as typeof boundTextElement | undefined;
if (stateOfBoundTextElementAtResize) {
boundTextFont = {
fontSize: stateOfBoundTextElementAtResize.fontSize,
};
}
if (shouldMaintainAspectRatio) {
const updatedElement = {
...latestElement,
width: nextWidth,
height: nextHeight,
};
const nextFont = measureFontSizeFromWidth(
boundTextElement,
elementsMap,
getBoundTextMaxWidth(updatedElement, boundTextElement),
);
if (nextFont === null) {
return;
}
boundTextFont = {
fontSize: nextFont.size,
};
} else {
const minWidth = getApproxMinLineWidth(
getFontString(boundTextElement),
boundTextElement.lineHeight,
);
const minHeight = getApproxMinLineHeight(
boundTextElement.fontSize,
boundTextElement.lineHeight,
);
nextWidth = Math.max(nextWidth, minWidth);
nextHeight = Math.max(nextHeight, minHeight);
}
}
const rescaledPoints = rescalePointsInElement(
origElement,
nextWidth,
nextHeight,
true,
);
let previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
if (isLinearElement(origElement)) {
const [x1, y1] = getElementBounds(origElement, originalElementsMap);
previousOrigin = pointFrom<GlobalPoint>(x1, y1);
}
const newOrigin: {
x: number;
y: number;
} = getResizedOrigin(
previousOrigin,
origElement.width,
origElement.height,
nextWidth,
nextHeight,
origElement.angle,
handleDirection,
shouldMaintainAspectRatio!!,
shouldResizeFromCenter!!,
);
if (isLinearElement(origElement) && rescaledPoints.points) {
const offsetX = origElement.x - previousOrigin[0];
const offsetY = origElement.y - previousOrigin[1];
newOrigin.x += offsetX;
newOrigin.y += offsetY;
const scaledX = rescaledPoints.points[0][0];
const scaledY = rescaledPoints.points[0][1];
newOrigin.x += scaledX;
newOrigin.y += scaledY;
rescaledPoints.points = rescaledPoints.points.map((p) =>
pointFrom<LocalPoint>(p[0] - scaledX, p[1] - scaledY),
);
}
// flipping
if (nextWidth < 0) {
newOrigin.x = newOrigin.x + nextWidth;
}
if (nextHeight < 0) {
newOrigin.y = newOrigin.y + nextHeight;
}
if ("scale" in latestElement && "scale" in origElement) {
mutateElement(latestElement, {
scale: [
// defaulting because scaleX/Y can be 0/-0
(Math.sign(nextWidth) || origElement.scale[0]) * origElement.scale[0],
(Math.sign(nextHeight) || origElement.scale[1]) * origElement.scale[1],
],
});
}
if (
isArrowElement(latestElement) &&
boundTextElement &&
shouldMaintainAspectRatio
) {
const fontSize =
(nextWidth / latestElement.width) * boundTextElement.fontSize;
if (fontSize < MIN_FONT_SIZE) {
return;
}
boundTextFont.fontSize = fontSize;
}
if (
nextWidth !== 0 &&
nextHeight !== 0 &&
Number.isFinite(newOrigin.x) &&
Number.isFinite(newOrigin.y)
) {
const updates = {
...newOrigin,
width: Math.abs(nextWidth),
height: Math.abs(nextHeight),
...rescaledPoints,
};
mutateElement(latestElement, updates, shouldInformMutation);
updateBoundElements(latestElement, elementsMap as SceneElementsMap, {
// TODO: confirm with MARK if this actually makes sense
newSize: { width: nextWidth, height: nextHeight },
});
if (boundTextElement && boundTextFont != null) {
mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
});
}
handleBindTextResize(
latestElement,
elementsMap,
handleDirection,
shouldMaintainAspectRatio,
);
}
};
const getNextSingleWidthAndHeightFromPointer = (
latestElement: ExcalidrawElement,
origElement: ExcalidrawElement,
elementsMap: ElementsMap,
originalElementsMap: ElementsMap,
handleDirection: TransformHandleDirection,
pointerX: number,
pointerY: number,
{
shouldMaintainAspectRatio = false,
shouldResizeFromCenter = false,
}: {
shouldMaintainAspectRatio?: boolean;
shouldResizeFromCenter?: boolean;
} = {},
) => {
// Gets bounds corners
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
origElement,
origElement.width,
origElement.height,
true,
);
const startTopLeft = pointFrom(x1, y1);
const startBottomRight = pointFrom(x2, y2);
const startCenter = pointCenter(startTopLeft, startBottomRight);
// Calculate new dimensions based on cursor position
const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY),
startCenter,
-origElement.angle as Radians,
);
// Get bounds corners rendered on screen
const [esx1, esy1, esx2, esy2] = getResizedElementAbsoluteCoords(
latestElement,
latestElement.width,
latestElement.height,
true,
);
const boundsCurrentWidth = esx2 - esx1;
const boundsCurrentHeight = esy2 - esy1;
// It's important we set the initial scale value based on the width and height at resize start,
// otherwise previous dimensions affected by modifiers will be taken into account.
const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
const atStartBoundsHeight = startBottomRight[1] - startTopLeft[1];
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
let scaleY = atStartBoundsHeight / boundsCurrentHeight;
if (handleDirection.includes("e")) {
scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
}
if (handleDirection.includes("s")) {
scaleY = (rotatedPointer[1] - startTopLeft[1]) / boundsCurrentHeight;
}
if (handleDirection.includes("w")) {
scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
}
if (handleDirection.includes("n")) {
scaleY = (startBottomRight[1] - rotatedPointer[1]) / boundsCurrentHeight;
}
// We have to use dimensions of element on screen, otherwise the scaling of the
// dimensions won't match the cursor for linear elements.
let nextWidth = latestElement.width * scaleX;
let nextHeight = latestElement.height * scaleY;
if (shouldResizeFromCenter) {
nextWidth = 2 * nextWidth - origElement.width;
nextHeight = 2 * nextHeight - origElement.height;
}
// adjust dimensions to keep sides ratio
if (shouldMaintainAspectRatio) {
const widthRatio = Math.abs(nextWidth) / origElement.width;
const heightRatio = Math.abs(nextHeight) / origElement.height;
if (handleDirection.length === 1) {
nextHeight *= widthRatio;
nextWidth *= heightRatio;
}
if (handleDirection.length === 2) {
const ratio = Math.max(widthRatio, heightRatio);
nextWidth = origElement.width * ratio * Math.sign(nextWidth);
nextHeight = origElement.height * ratio * Math.sign(nextHeight);
}
}
return {
nextWidth,
nextHeight,
};
};
const getNextMultipleWidthAndHeightFromPointer = (
selectedElements: readonly NonDeletedExcalidrawElement[],
originalElementsMap: ElementsMap,
elementsMap: ElementsMap,
handleDirection: TransformHandleDirection,
pointerX: number,
pointerY: number,
{
shouldMaintainAspectRatio = false,
shouldResizeFromCenter = false,
}: {
shouldResizeFromCenter?: boolean;
shouldMaintainAspectRatio?: boolean;
} = {},
) => {
const originalElementsArray = selectedElements.map(
(el) => originalElementsMap.get(el.id)!,
);
// 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 = originalElementsArray.reduce((acc, orig) => {
if (!isLinearElement(orig)) {
return acc;
}
const textId = getBoundTextElementId(orig);
if (!textId) {
return acc;
}
const text = originalElementsMap.get(textId) ?? null;
if (!isBoundToContainer(text)) {
return acc;
}
return [
...acc,
{
...text,
...LinearElementEditor.getBoundTextElementPosition(
orig,
text,
elementsMap,
),
},
];
}, [] as ExcalidrawTextElementWithContainer[]);
const originalBoundingBox = getCommonBoundingBox(
originalElementsArray.map((orig) => orig).concat(boundTextElements),
);
const { minX, minY, maxX, maxY, midX, midY } = originalBoundingBox;
const width = maxX - minX;
const height = maxY - minY;
const anchorsMap = {
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],
} as Record<TransformHandleDirection, GlobalPoint>;
// 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] = shouldResizeFromCenter
? [midX, midY]
: anchorsMap[handleDirection];
const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
const scale =
Math.max(
Math.abs(pointerX - anchorX) / width || 0,
Math.abs(pointerY - anchorY) / height || 0,
) * resizeFromCenterScale;
let nextWidth =
handleDirection.includes("e") || handleDirection.includes("w")
? Math.abs(pointerX - anchorX) * resizeFromCenterScale
: width;
let nextHeight =
handleDirection.includes("n") || handleDirection.includes("s")
? Math.abs(pointerY - anchorY) * resizeFromCenterScale
: height;
if (shouldMaintainAspectRatio) {
nextWidth = width * scale * Math.sign(pointerX - anchorX);
nextHeight = height * scale * Math.sign(pointerY - anchorY);
}
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],
// 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],
};
const [flipByX, flipByY] = flipConditionsMap[handleDirection].map(
(condition) => condition,
);
return {
originalBoundingBox,
nextWidth,
nextHeight,
flipByX,
flipByY,
};
};
export const resizeMultipleElements = (
selectedElements: readonly NonDeletedExcalidrawElement[],
elementsMap: ElementsMap,
handleDirection: TransformHandleDirection,
scene: Scene,
{
shouldMaintainAspectRatio = false,
shouldResizeFromCenter = false,
flipByX = false,
flipByY = false,
nextHeight,
nextWidth,
originalElementsMap,
originalBoundingBox,
}: {
nextWidth?: number;
nextHeight?: number;
shouldMaintainAspectRatio?: boolean;
shouldResizeFromCenter?: boolean;
flipByX?: boolean;
flipByY?: boolean;
originalElementsMap?: ElementsMap;
// added to improve performance
originalBoundingBox?: BoundingBox;
} = {},
) => {
// in the case of just flipping, there is no need to specify the next width and height
if (
nextWidth === undefined &&
nextHeight === undefined &&
flipByX === undefined &&
flipByY === undefined
) {
return;
}
// do not allow next width or height to be 0
if (nextHeight === 0 || nextWidth === 0) {
return;
}
if (!originalElementsMap) {
originalElementsMap = elementsMap;
}
const targetElements = selectedElements.reduce(
(
acc: {
/** element at resize start */
orig: NonDeletedExcalidrawElement;
/** latest element */
latest: NonDeletedExcalidrawElement;
}[],
element,
) => {
const origElement = originalElementsMap!.get(element.id);
if (origElement) {
acc.push({ orig: origElement, latest: element });
}
return acc;
},
[],
);
let boundingBox: BoundingBox;
if (originalBoundingBox) {
boundingBox = originalBoundingBox;
} else {
const boundTextElements = targetElements.reduce((acc, { orig }) => {
if (!isLinearElement(orig)) {
return acc;
}
const textId = getBoundTextElementId(orig);
if (!textId) {
return acc;
}
const text = originalElementsMap!.get(textId) ?? null;
if (!isBoundToContainer(text)) {
return acc;
}
return [
...acc,
{
...text,
...LinearElementEditor.getBoundTextElementPosition(
orig,
text,
elementsMap,
),
},
];
}, [] as ExcalidrawTextElementWithContainer[]);
boundingBox = getCommonBoundingBox(
targetElements.map(({ orig }) => orig).concat(boundTextElements),
);
}
const { minX, minY, maxX, maxY, midX, midY } = boundingBox;
const width = maxX - minX;
const height = maxY - minY;
if (nextWidth === undefined && nextHeight === undefined) {
nextWidth = width;
nextHeight = height;
}
if (shouldMaintainAspectRatio) {
if (nextWidth === undefined) {
nextWidth = nextHeight! * (width / height);
} else if (nextHeight === undefined) {
nextHeight = nextWidth! * (height / width);
} else if (Math.abs(nextWidth / nextHeight - width / height) > 0.001) {
nextWidth = nextHeight * (width / height);
}
}
if (nextWidth && nextHeight) {
let scaleX =
handleDirection.includes("e") || handleDirection.includes("w")
? Math.abs(nextWidth) / width
: 1;
let scaleY =
handleDirection.includes("n") || handleDirection.includes("s")
? Math.abs(nextHeight) / height
: 1;
let scale: number;
if (handleDirection.length === 1) {
scale =
handleDirection.includes("e") || handleDirection.includes("w")
? scaleX
: scaleY;
} else {
scale = Math.max(
Math.abs(nextWidth) / width || 0,
Math.abs(nextHeight) / height || 0,
);
}
const anchorsMap = {
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],
} as Record<TransformHandleDirection, GlobalPoint>;
// 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] = shouldResizeFromCenter
? [midX, midY]
: anchorsMap[handleDirection];
const keepAspectRatio =
shouldMaintainAspectRatio ||
targetElements.some(
(item) =>
item.latest.angle !== 0 ||
isTextElement(item.latest) ||
isInGroup(item.latest),
);
if (keepAspectRatio) {
scaleX = scale;
scaleY = scale;
}
/**
* 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] = [flipByX ? -1 : 1, flipByY ? -1 : 1];
const elementsAndUpdates: {
element: NonDeletedExcalidrawElement;
update: Mutable<
Pick<ExcalidrawElement, "x" | "y" | "width" | "height" | "angle">
> & {
points?: ExcalidrawLinearElement["points"];
fontSize?: ExcalidrawTextElement["fontSize"];
scale?: ExcalidrawImageElement["scale"];
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
};
}[] = [];
for (const { orig, latest } of targetElements) {
// bounded text elements are updated along with their container elements
if (isTextElement(orig) && isBoundToContainer(orig)) {
continue;
}
const width = orig.width * scaleX;
const height = orig.height * scaleY;
const angle = normalizeRadians(
(orig.angle * flipFactorX * flipFactorY) as Radians,
);
const isLinearOrFreeDraw =
isLinearElement(orig) || isFreeDrawElement(orig);
const offsetX = orig.x - anchorX;
const offsetY = orig.y - anchorY;
const shiftX = flipByX && !isLinearOrFreeDraw ? width : 0;
const shiftY = flipByY && !isLinearOrFreeDraw ? height : 0;
const x = anchorX + flipFactorX * (offsetX * scaleX + shiftX);
const y = anchorY + flipFactorY * (offsetY * scaleY + shiftY);
const rescaledPoints = rescalePointsInElement(
orig,
width * flipFactorX,
height * flipFactorY,
false,
);
const update: typeof elementsAndUpdates[0]["update"] = {
x,
y,
width,
height,
angle,
...rescaledPoints,
};
if (isImageElement(orig)) {
update.scale = [
orig.scale[0] * flipFactorX,
orig.scale[1] * flipFactorY,
];
}
if (isTextElement(orig)) {
const metrics = measureFontSizeFromWidth(orig, elementsMap, width);
if (!metrics) {
return;
}
update.fontSize = metrics.size;
}
const boundTextElement = originalElementsMap.get(
getBoundTextElementId(orig) ?? "",
) as ExcalidrawTextElementWithContainer | undefined;
if (boundTextElement) {
if (keepAspectRatio) {
const newFontSize = boundTextElement.fontSize * scale;
if (newFontSize < MIN_FONT_SIZE) {
return;
}
update.boundTextFontSize = newFontSize;
} else {
update.boundTextFontSize = boundTextElement.fontSize;
}
}
elementsAndUpdates.push({
element: latest,
update,
});
}
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
for (const {
element,
update: { boundTextFontSize, ...update },
} of elementsAndUpdates) {
const { width, height, angle } = update;
mutateElement(element, update, false);
updateBoundElements(element, elementsMap as SceneElementsMap, {
simultaneouslyUpdated: elementsToUpdate,
newSize: { width, height },
});
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && boundTextFontSize) {
mutateElement(
boundTextElement,
{
fontSize: boundTextFontSize,
angle: isLinearElement(element) ? undefined : angle,
},
false,
);
handleBindTextResize(element, elementsMap, handleDirection, true);
}
}
scene.triggerUpdate();
}
};