|
@@ -14,17 +14,21 @@ import {
|
|
|
NonDeleted,
|
|
|
ExcalidrawElement,
|
|
|
ExcalidrawTextElementWithContainer,
|
|
|
+ ExcalidrawImageElement,
|
|
|
} from "./types";
|
|
|
+import type { Mutable } from "../utility-types";
|
|
|
import {
|
|
|
getElementAbsoluteCoords,
|
|
|
getCommonBounds,
|
|
|
getResizedElementAbsoluteCoords,
|
|
|
getCommonBoundingBox,
|
|
|
+ getElementPointsCoords,
|
|
|
} from "./bounds";
|
|
|
import {
|
|
|
isArrowElement,
|
|
|
isBoundToContainer,
|
|
|
isFreeDrawElement,
|
|
|
+ isImageElement,
|
|
|
isLinearElement,
|
|
|
isTextElement,
|
|
|
} from "./typeChecks";
|
|
@@ -49,8 +53,12 @@ import {
|
|
|
measureText,
|
|
|
getBoundTextMaxHeight,
|
|
|
} from "./textElement";
|
|
|
+import { LinearElementEditor } from "./linearElementEditor";
|
|
|
|
|
|
export const normalizeAngle = (angle: number): number => {
|
|
|
+ if (angle < 0) {
|
|
|
+ return angle + 2 * Math.PI;
|
|
|
+ }
|
|
|
if (angle >= 2 * Math.PI) {
|
|
|
return angle - 2 * Math.PI;
|
|
|
}
|
|
@@ -596,7 +604,7 @@ export const resizeSingleElement = (
|
|
|
}
|
|
|
};
|
|
|
|
|
|
-const resizeMultipleElements = (
|
|
|
+export const resizeMultipleElements = (
|
|
|
pointerDownState: PointerDownState,
|
|
|
selectedElements: readonly NonDeletedExcalidrawElement[],
|
|
|
transformHandleType: "nw" | "ne" | "sw" | "se",
|
|
@@ -627,8 +635,28 @@ const resizeMultipleElements = (
|
|
|
[],
|
|
|
);
|
|
|
|
|
|
+ // getCommonBoundingBox() uses getBoundTextElement() which returns null for
|
|
|
+ // original elements from pointerDownState, so we have to find and add these
|
|
|
+ // bound text elements manually. Additionally, the coordinates of bound text
|
|
|
+ // elements aren't always up to date.
|
|
|
+ const boundTextElements = targetElements.reduce((acc, { orig }) => {
|
|
|
+ if (!isLinearElement(orig)) {
|
|
|
+ return acc;
|
|
|
+ }
|
|
|
+ const textId = getBoundTextElementId(orig);
|
|
|
+ if (!textId) {
|
|
|
+ return acc;
|
|
|
+ }
|
|
|
+ const text = pointerDownState.originalElements.get(textId) ?? null;
|
|
|
+ if (!isBoundToContainer(text)) {
|
|
|
+ return acc;
|
|
|
+ }
|
|
|
+ const xy = LinearElementEditor.getBoundTextElementPosition(orig, text);
|
|
|
+ return [...acc, { ...text, ...xy }];
|
|
|
+ }, [] as ExcalidrawTextElementWithContainer[]);
|
|
|
+
|
|
|
const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
|
|
|
- targetElements.map(({ orig }) => orig),
|
|
|
+ targetElements.map(({ orig }) => orig).concat(boundTextElements),
|
|
|
);
|
|
|
const direction = transformHandleType;
|
|
|
|
|
@@ -640,12 +668,22 @@ const resizeMultipleElements = (
|
|
|
};
|
|
|
|
|
|
// anchor point must be on the opposite side of the dragged selection handle
|
|
|
- // or be the center of the selection if alt is pressed
|
|
|
+ // or be the center of the selection if shouldResizeFromCenter
|
|
|
const [anchorX, anchorY]: Point = shouldResizeFromCenter
|
|
|
? [midX, midY]
|
|
|
: mapDirectionsToAnchors[direction];
|
|
|
|
|
|
- const mapDirectionsToPointerSides: Record<
|
|
|
+ const scale =
|
|
|
+ Math.max(
|
|
|
+ Math.abs(pointerX - anchorX) / (maxX - minX) || 0,
|
|
|
+ Math.abs(pointerY - anchorY) / (maxY - minY) || 0,
|
|
|
+ ) * (shouldResizeFromCenter ? 2 : 1);
|
|
|
+
|
|
|
+ if (scale === 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const mapDirectionsToPointerPositions: Record<
|
|
|
typeof direction,
|
|
|
[x: boolean, y: boolean]
|
|
|
> = {
|
|
@@ -655,68 +693,117 @@ const resizeMultipleElements = (
|
|
|
nw: [pointerX <= anchorX, pointerY <= anchorY],
|
|
|
};
|
|
|
|
|
|
- // pointer side relative to anchor
|
|
|
- const [pointerSideX, pointerSideY] = mapDirectionsToPointerSides[
|
|
|
+ /**
|
|
|
+ * to flip an element:
|
|
|
+ * 1. determine over which axis is the element being flipped
|
|
|
+ * (could be x, y, or both) indicated by `flipFactorX` & `flipFactorY`
|
|
|
+ * 2. shift element's position by the amount of width or height (or both) or
|
|
|
+ * mirror points in the case of linear & freedraw elemenets
|
|
|
+ * 3. adjust element angle
|
|
|
+ */
|
|
|
+ const [flipFactorX, flipFactorY] = mapDirectionsToPointerPositions[
|
|
|
direction
|
|
|
].map((condition) => (condition ? 1 : -1));
|
|
|
+ const isFlippedByX = flipFactorX < 0;
|
|
|
+ const isFlippedByY = flipFactorY < 0;
|
|
|
+
|
|
|
+ const elementsAndUpdates: {
|
|
|
+ element: NonDeletedExcalidrawElement;
|
|
|
+ update: Mutable<
|
|
|
+ Pick<ExcalidrawElement, "x" | "y" | "width" | "height" | "angle">
|
|
|
+ > & {
|
|
|
+ points?: ExcalidrawLinearElement["points"];
|
|
|
+ fontSize?: ExcalidrawTextElement["fontSize"];
|
|
|
+ baseline?: ExcalidrawTextElement["baseline"];
|
|
|
+ scale?: ExcalidrawImageElement["scale"];
|
|
|
+ };
|
|
|
+ boundText: {
|
|
|
+ element: ExcalidrawTextElementWithContainer;
|
|
|
+ fontSize: ExcalidrawTextElement["fontSize"];
|
|
|
+ baseline: ExcalidrawTextElement["baseline"];
|
|
|
+ } | null;
|
|
|
+ }[] = [];
|
|
|
+
|
|
|
+ for (const { orig, latest } of targetElements) {
|
|
|
+ // bounded text elements are updated along with their container elements
|
|
|
+ if (isTextElement(orig) && isBoundToContainer(orig)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
|
|
|
- // stop resizing if a pointer is on the other side of selection
|
|
|
- if (pointerSideX < 0 && pointerSideY < 0) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- const scale =
|
|
|
- Math.max(
|
|
|
- (pointerSideX * Math.abs(pointerX - anchorX)) / (maxX - minX),
|
|
|
- (pointerSideY * Math.abs(pointerY - anchorY)) / (maxY - minY),
|
|
|
- ) * (shouldResizeFromCenter ? 2 : 1);
|
|
|
-
|
|
|
- if (scale === 0) {
|
|
|
- return;
|
|
|
- }
|
|
|
+ const width = orig.width * scale;
|
|
|
+ const height = orig.height * scale;
|
|
|
+ const angle = normalizeAngle(orig.angle * flipFactorX * flipFactorY);
|
|
|
|
|
|
- targetElements.forEach((element) => {
|
|
|
- const width = element.orig.width * scale;
|
|
|
- const height = element.orig.height * scale;
|
|
|
- const x = anchorX + (element.orig.x - anchorX) * scale;
|
|
|
- const y = anchorY + (element.orig.y - anchorY) * scale;
|
|
|
+ const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig);
|
|
|
+ const offsetX = orig.x - anchorX;
|
|
|
+ const offsetY = orig.y - anchorY;
|
|
|
+ const shiftX = isFlippedByX && !isLinearOrFreeDraw ? width : 0;
|
|
|
+ const shiftY = isFlippedByY && !isLinearOrFreeDraw ? height : 0;
|
|
|
+ const x = anchorX + flipFactorX * (offsetX * scale + shiftX);
|
|
|
+ const y = anchorY + flipFactorY * (offsetY * scale + shiftY);
|
|
|
|
|
|
- // readjust points for linear & free draw elements
|
|
|
const rescaledPoints = rescalePointsInElement(
|
|
|
- element.orig,
|
|
|
- width,
|
|
|
- height,
|
|
|
+ orig,
|
|
|
+ width * flipFactorX,
|
|
|
+ height * flipFactorY,
|
|
|
false,
|
|
|
);
|
|
|
|
|
|
- const update: {
|
|
|
- width: number;
|
|
|
- height: number;
|
|
|
- x: number;
|
|
|
- y: number;
|
|
|
- points?: Point[];
|
|
|
- fontSize?: number;
|
|
|
- baseline?: number;
|
|
|
- } = {
|
|
|
- width,
|
|
|
- height,
|
|
|
+ const update: typeof elementsAndUpdates[0]["update"] = {
|
|
|
x,
|
|
|
y,
|
|
|
+ width,
|
|
|
+ height,
|
|
|
+ angle,
|
|
|
...rescaledPoints,
|
|
|
};
|
|
|
|
|
|
- let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
|
|
|
+ if (isImageElement(orig) && targetElements.length === 1) {
|
|
|
+ update.scale = [orig.scale[0] * flipFactorX, orig.scale[1] * flipFactorY];
|
|
|
+ }
|
|
|
+
|
|
|
+ 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
|
|
|
+ );
|
|
|
+ };
|
|
|
|
|
|
- const boundTextElement = getBoundTextElement(element.latest);
|
|
|
+ if (isFlippedByX) {
|
|
|
+ update.x -= linearShift("x");
|
|
|
+ }
|
|
|
|
|
|
- if (boundTextElement || isTextElement(element.orig)) {
|
|
|
+ if (isFlippedByY) {
|
|
|
+ update.y -= linearShift("y");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ let boundText: typeof elementsAndUpdates[0]["boundText"] = null;
|
|
|
+
|
|
|
+ const boundTextElement = getBoundTextElement(latest);
|
|
|
+
|
|
|
+ if (boundTextElement || isTextElement(orig)) {
|
|
|
const updatedElement = {
|
|
|
- ...element.latest,
|
|
|
+ ...latest,
|
|
|
width,
|
|
|
height,
|
|
|
};
|
|
|
const metrics = measureFontSizeFromWidth(
|
|
|
- boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
|
|
+ boundTextElement ?? (orig as ExcalidrawTextElement),
|
|
|
boundTextElement
|
|
|
? getBoundTextMaxWidth(updatedElement)
|
|
|
: updatedElement.width,
|
|
@@ -729,29 +816,50 @@ const resizeMultipleElements = (
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- if (isTextElement(element.orig)) {
|
|
|
+ if (isTextElement(orig)) {
|
|
|
update.fontSize = metrics.size;
|
|
|
update.baseline = metrics.baseline;
|
|
|
}
|
|
|
|
|
|
if (boundTextElement) {
|
|
|
- boundTextUpdates = {
|
|
|
+ boundText = {
|
|
|
+ element: boundTextElement,
|
|
|
fontSize: metrics.size,
|
|
|
baseline: metrics.baseline,
|
|
|
};
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- updateBoundElements(element.latest, { newSize: { width, height } });
|
|
|
+ elementsAndUpdates.push({ element: latest, update, boundText });
|
|
|
+ }
|
|
|
|
|
|
- mutateElement(element.latest, update);
|
|
|
+ const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
|
|
|
|
|
|
- if (boundTextElement && boundTextUpdates) {
|
|
|
- mutateElement(boundTextElement, boundTextUpdates);
|
|
|
+ for (const { element, update, boundText } of elementsAndUpdates) {
|
|
|
+ const { width, height, angle } = update;
|
|
|
|
|
|
- handleBindTextResize(element.latest, transformHandleType);
|
|
|
+ mutateElement(element, update, false);
|
|
|
+
|
|
|
+ updateBoundElements(element, {
|
|
|
+ simultaneouslyUpdated: elementsToUpdate,
|
|
|
+ newSize: { width, height },
|
|
|
+ });
|
|
|
+
|
|
|
+ if (boundText) {
|
|
|
+ const { element: boundTextElement, ...boundTextUpdates } = boundText;
|
|
|
+ mutateElement(
|
|
|
+ boundTextElement,
|
|
|
+ {
|
|
|
+ ...boundTextUpdates,
|
|
|
+ angle: isLinearElement(element) ? undefined : angle,
|
|
|
+ },
|
|
|
+ false,
|
|
|
+ );
|
|
|
+ handleBindTextResize(element, transformHandleType);
|
|
|
}
|
|
|
- });
|
|
|
+ }
|
|
|
+
|
|
|
+ Scene.getScene(elementsAndUpdates[0].element)?.informMutation();
|
|
|
};
|
|
|
|
|
|
const rotateMultipleElements = (
|