mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: resize elements from the sides (#7855)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
parent
6e5aeb112d
commit
88812e0386
19 changed files with 3913 additions and 3741 deletions
|
@ -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({
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue