From 107eae3916d293e9431d3fda61fe3a7c1398a90d Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Mon, 23 Dec 2024 18:10:35 +0800 Subject: [PATCH] refactor: separate resizing logic from pointer (#8155) * separate resizing logic for a single element * replace resize logic in stats * do not recompute width and height from points when they're already given * correctly update linear elements' position when resized * update snapshots * lint * simplify linear resizing logic * fix initial scale for aspect ratio * update tests for linear elements * test typo * separate pointer from resizing for multiple elements * lint and simplify * fix tests * lint * provide scene in param instead * type * refactor code * fix floating in tests * remove restrictions/checks on width & height * update pointer to dimension to prevent regression --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/excalidraw/actions/actionFlip.ts | 21 +- packages/excalidraw/components/App.tsx | 1 + .../excalidraw/components/Stats/Dimension.tsx | 30 +- .../components/Stats/MultiDimension.tsx | 31 +- packages/excalidraw/components/Stats/utils.ts | 102 +- packages/excalidraw/element/resizeElements.ts | 1405 ++++++++++------- packages/excalidraw/tests/resize.test.tsx | 151 +- 7 files changed, 1064 insertions(+), 677 deletions(-) diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index a02d6bcf8..933960090 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -12,7 +12,6 @@ import { resizeMultipleElements } from "../element/resizeElements"; import type { AppClassProperties, AppState } from "../types"; import { arrayToMap } from "../utils"; import { CODES, KEYS } from "../keys"; -import { getCommonBoundingBox } from "../element/bounds"; import { bindOrUnbindLinearElements, isBindingEnabled, @@ -27,6 +26,7 @@ import { } from "../element/typeChecks"; import { mutateElbowArrow } from "../element/routing"; import { mutateElement, newElementWith } from "../element/mutateElement"; +import { getCommonBoundingBox } from "../element/bounds"; export const actionFlipHorizontal = register({ name: "flipHorizontal", @@ -132,19 +132,14 @@ const flipElements = ( }); } - const { minX, minY, maxX, maxY, midX, midY } = - getCommonBoundingBox(selectedElements); + const { midX, midY } = getCommonBoundingBox(selectedElements); - resizeMultipleElements( - elementsMap, - selectedElements, - elementsMap, - "nw", - true, - true, - flipDirection === "horizontal" ? maxX : minX, - flipDirection === "horizontal" ? minY : maxY, - ); + resizeMultipleElements(selectedElements, elementsMap, "nw", app.scene, { + flipByX: flipDirection === "horizontal", + flipByY: flipDirection === "vertical", + shouldResizeFromCenter: true, + shouldMaintainAspectRatio: true, + }); bindOrUnbindLinearElements( selectedElements.filter(isLinearElement), diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 1e02f512f..d2069a17f 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -10570,6 +10570,7 @@ class App extends React.Component { transformHandleType, selectedElements, this.scene.getElementsMapIncludingDeleted(), + this.scene, shouldRotateWithDiscreteAngle(event), shouldResizeFromCenter(event), selectedElements.some((element) => isImageElement(element)) diff --git a/packages/excalidraw/components/Stats/Dimension.tsx b/packages/excalidraw/components/Stats/Dimension.tsx index 9816e726d..4e5a3825d 100644 --- a/packages/excalidraw/components/Stats/Dimension.tsx +++ b/packages/excalidraw/components/Stats/Dimension.tsx @@ -1,8 +1,9 @@ import type { ExcalidrawElement } from "../../element/types"; import DragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; -import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils"; +import { getStepSizedValue, isPropertyEditable } from "./utils"; import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; +import { resizeSingleElement } from "../../element/resizeElements"; import type Scene from "../../scene/Scene"; import type { AppState } from "../../types"; import { isImageElement } from "../../element/typeChecks"; @@ -30,6 +31,7 @@ const handleDimensionChange: DragInputCallbackType< > = ({ accumulatedChange, originalElements, + originalElementsMap, shouldKeepAspectRatio, shouldChangeByStepSize, nextValue, @@ -39,9 +41,9 @@ const handleDimensionChange: DragInputCallbackType< scene, }) => { const elementsMap = scene.getNonDeletedElementsMap(); - const elements = scene.getNonDeletedElements(); const origElement = originalElements[0]; - if (origElement) { + const latestElement = elementsMap.get(origElement.id); + if (origElement && latestElement) { const keepAspectRatio = shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement); const aspectRatio = origElement.width / origElement.height; @@ -165,14 +167,17 @@ const handleDimensionChange: DragInputCallbackType< MIN_WIDTH_OR_HEIGHT, ); - resizeElement( + resizeSingleElement( nextWidth, nextHeight, - keepAspectRatio, + latestElement, origElement, elementsMap, - elements, - scene, + originalElementsMap, + property === "width" ? "e" : "s", + { + shouldMaintainAspectRatio: keepAspectRatio, + }, ); return; @@ -209,14 +214,17 @@ const handleDimensionChange: DragInputCallbackType< nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); - resizeElement( + resizeSingleElement( nextWidth, nextHeight, - keepAspectRatio, + latestElement, origElement, elementsMap, - elements, - scene, + originalElementsMap, + property === "width" ? "e" : "s", + { + shouldMaintainAspectRatio: keepAspectRatio, + }, ); } }; diff --git a/packages/excalidraw/components/Stats/MultiDimension.tsx b/packages/excalidraw/components/Stats/MultiDimension.tsx index 257642e98..c02e87089 100644 --- a/packages/excalidraw/components/Stats/MultiDimension.tsx +++ b/packages/excalidraw/components/Stats/MultiDimension.tsx @@ -2,7 +2,10 @@ import { useMemo } from "react"; import { getCommonBounds, isTextElement } from "../../element"; import { updateBoundElements } from "../../element/binding"; import { mutateElement } from "../../element/mutateElement"; -import { rescalePointsInElement } from "../../element/resizeElements"; +import { + rescalePointsInElement, + resizeSingleElement, +} from "../../element/resizeElements"; import { getBoundTextElement, handleBindTextResize, @@ -17,7 +20,7 @@ import type { AppState } from "../../types"; import DragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; -import { getElementsInAtomicUnit, resizeElement } from "./utils"; +import { getElementsInAtomicUnit } from "./utils"; import type { AtomicUnit } from "./utils"; import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; import { pointFrom, type GlobalPoint } from "../../../math"; @@ -150,7 +153,6 @@ const handleDimensionChange: DragInputCallbackType< property, }) => { const elementsMap = scene.getNonDeletedElementsMap(); - const elements = scene.getNonDeletedElements(); const atomicUnits = getAtomicUnits(originalElements, originalAppState); if (nextValue !== undefined) { for (const atomicUnit of atomicUnits) { @@ -223,15 +225,17 @@ const handleDimensionChange: DragInputCallbackType< nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); - resizeElement( + resizeSingleElement( nextWidth, nextHeight, - false, + latestElement, origElement, elementsMap, - elements, - scene, - false, + originalElementsMap, + property === "width" ? "e" : "s", + { + shouldInformMutation: false, + }, ); } } @@ -324,14 +328,17 @@ const handleDimensionChange: DragInputCallbackType< nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); - resizeElement( + resizeSingleElement( nextWidth, nextHeight, - false, + latestElement, origElement, elementsMap, - elements, - scene, + originalElementsMap, + property === "width" ? "e" : "s", + { + shouldInformMutation: false, + }, ); } } diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts index d38158d0a..46c8cb982 100644 --- a/packages/excalidraw/components/Stats/utils.ts +++ b/packages/excalidraw/components/Stats/utils.ts @@ -5,17 +5,7 @@ import { updateBoundElements, } from "../../element/binding"; import { mutateElement } from "../../element/mutateElement"; -import { - measureFontSizeFromWidth, - rescalePointsInElement, -} from "../../element/resizeElements"; -import { - getApproxMinLineHeight, - getApproxMinLineWidth, - getBoundTextElement, - getBoundTextMaxWidth, - handleBindTextResize, -} from "../../element/textElement"; +import { getBoundTextElement } from "../../element/textElement"; import { isFrameLikeElement, isLinearElement, @@ -34,7 +24,6 @@ import { } from "../../groups"; import type Scene from "../../scene/Scene"; import type { AppState } from "../../types"; -import { getFontString } from "../../utils"; export type StatsInputProperty = | "x" @@ -121,95 +110,6 @@ export const newOrigin = ( }; }; -export const resizeElement = ( - nextWidth: number, - nextHeight: number, - keepAspectRatio: boolean, - origElement: ExcalidrawElement, - elementsMap: NonDeletedSceneElementsMap, - elements: readonly NonDeletedExcalidrawElement[], - scene: Scene, - shouldInformMutation = true, -) => { - const latestElement = elementsMap.get(origElement.id); - if (!latestElement) { - return; - } - let boundTextFont: { fontSize?: number } = {}; - const boundTextElement = getBoundTextElement(latestElement, elementsMap); - - if (boundTextElement) { - const minWidth = getApproxMinLineWidth( - getFontString(boundTextElement), - boundTextElement.lineHeight, - ); - const minHeight = getApproxMinLineHeight( - boundTextElement.fontSize, - boundTextElement.lineHeight, - ); - nextWidth = Math.max(nextWidth, minWidth); - nextHeight = Math.max(nextHeight, minHeight); - } - - mutateElement( - latestElement, - { - ...newOrigin( - latestElement.x, - latestElement.y, - latestElement.width, - latestElement.height, - nextWidth, - nextHeight, - latestElement.angle, - ), - width: nextWidth, - height: nextHeight, - ...rescalePointsInElement(origElement, nextWidth, nextHeight, true), - }, - shouldInformMutation, - ); - updateBindings(latestElement, elementsMap, elements, scene, { - newSize: { - width: nextWidth, - height: nextHeight, - }, - }); - - if (boundTextElement) { - boundTextFont = { - fontSize: boundTextElement.fontSize, - }; - if (keepAspectRatio) { - const updatedElement = { - ...latestElement, - width: nextWidth, - height: nextHeight, - }; - - const nextFont = measureFontSizeFromWidth( - boundTextElement, - elementsMap, - getBoundTextMaxWidth(updatedElement, boundTextElement), - ); - boundTextFont = { - fontSize: nextFont?.size ?? boundTextElement.fontSize, - }; - } - } - - updateBoundElements(latestElement, elementsMap, { - newSize: { width: nextWidth, height: nextHeight }, - }); - - if (boundTextElement && boundTextFont) { - mutateElement(boundTextElement, { - fontSize: boundTextFont.fontSize, - }); - } - handleBindTextResize(latestElement, elementsMap, "e", keepAspectRatio); -}; - export const moveElement = ( newTopLeftX: number, newTopLeftY: number, diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index dab5ffa1c..568cb1e8f 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -9,8 +9,6 @@ import type { ExcalidrawTextElementWithContainer, ExcalidrawImageElement, ElementsMap, - ExcalidrawArrowElement, - NonDeletedSceneElementsMap, SceneElementsMap, } from "./types"; import type { Mutable } from "../utility-types"; @@ -19,7 +17,9 @@ import { getCommonBounds, getResizedElementAbsoluteCoords, getCommonBoundingBox, + getElementBounds, } from "./bounds"; +import type { BoundingBox } from "./bounds"; import { isArrowElement, isBoundToContainer, @@ -38,7 +38,7 @@ import type { TransformHandleDirection, } from "./transformHandles"; import type { PointerDownState } from "../types"; -import Scene from "../scene/Scene"; +import type Scene from "../scene/Scene"; import { getApproxMinLineWidth, getBoundTextElement, @@ -62,6 +62,7 @@ import { pointFromPair, pointRotateRads, type Radians, + type LocalPoint, } from "../../math"; // Returns true when transform (resizing/rotation) happened @@ -70,6 +71,7 @@ export const transformElements = ( transformHandleType: MaybeTransformHandleType, selectedElements: readonly NonDeletedExcalidrawElement[], elementsMap: SceneElementsMap, + scene: Scene, shouldRotateWithDiscreteAngle: boolean, shouldResizeFromCenter: boolean, shouldMaintainAspectRatio: boolean, @@ -77,7 +79,7 @@ export const transformElements = ( pointerY: number, centerX: number, centerY: number, -) => { +): boolean => { if (selectedElements.length === 1) { const [element] = selectedElements; if (transformHandleType === "rotation") { @@ -85,6 +87,7 @@ export const transformElements = ( rotateSingleElement( element, elementsMap, + scene, pointerX, pointerY, shouldRotateWithDiscreteAngle, @@ -102,19 +105,43 @@ export const transformElements = ( pointerY, ); updateBoundElements(element, elementsMap); + return true; } else if (transformHandleType) { - resizeSingleElement( - originalElements, - shouldMaintainAspectRatio, - element, - elementsMap, - transformHandleType, - shouldResizeFromCenter, - pointerX, - pointerY, - ); + 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") { @@ -122,6 +149,7 @@ export const transformElements = ( originalElements, selectedElements, elementsMap, + scene, pointerX, pointerY, shouldRotateWithDiscreteAngle, @@ -130,16 +158,37 @@ export const transformElements = ( ); return true; } else if (transformHandleType) { + const { nextWidth, nextHeight, flipByX, flipByY, originalBoundingBox } = + getNextMultipleWidthAndHeightFromPointer( + selectedElements, + originalElements, + elementsMap, + transformHandleType, + pointerX, + pointerY, + { + shouldMaintainAspectRatio, + shouldResizeFromCenter, + }, + ); + resizeMultipleElements( - originalElements, selectedElements, elementsMap, transformHandleType, - shouldResizeFromCenter, - shouldMaintainAspectRatio, - pointerX, - pointerY, + scene, + { + shouldResizeFromCenter, + shouldMaintainAspectRatio, + originalElementsMap: originalElements, + flipByX, + flipByY, + nextWidth, + nextHeight, + originalBoundingBox, + }, ); + return true; } } @@ -149,6 +198,7 @@ export const transformElements = ( const rotateSingleElement = ( element: NonDeletedExcalidrawElement, elementsMap: ElementsMap, + scene: Scene, pointerX: number, pointerY: number, shouldRotateWithDiscreteAngle: boolean, @@ -173,9 +223,7 @@ const rotateSingleElement = ( mutateElement(element, { angle }); if (boundTextElementId) { const textElement = - Scene.getScene(element)?.getElement( - boundTextElementId, - ); + scene.getElement(boundTextElementId); if (textElement && !isArrowElement(element)) { mutateElement(textElement, { angle }); @@ -451,100 +499,329 @@ const resizeSingleTextElement = ( } }; -export const resizeSingleElement = ( +const rotateMultipleElements = ( originalElements: PointerDownState["originalElements"], - shouldMaintainAspectRatio: boolean, - element: NonDeletedExcalidrawElement, + elements: readonly NonDeletedExcalidrawElement[], elementsMap: SceneElementsMap, - transformHandleDirection: TransformHandleDirection, - shouldResizeFromCenter: boolean, + scene: Scene, pointerX: number, pointerY: number, + shouldRotateWithDiscreteAngle: boolean, + centerX: number, + centerY: number, ) => { - const stateAtResizeStart = originalElements.get(element.id)!; - // Gets bounds corners - const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords( - stateAtResizeStart, - stateAtResizeStart.width, - stateAtResizeStart.height, - true, - ); - const startTopLeft = pointFrom(x1, y1); - const startBottomRight = pointFrom(x2, y2); - const startCenter = pointCenter(startTopLeft, startBottomRight); + let centerAngle = + (5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX); + if (shouldRotateWithDiscreteAngle) { + centerAngle += SHIFT_LOCKING_ANGLE / 2; + centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE; + } - // Calculate new dimensions based on cursor position - const rotatedPointer = pointRotateRads( - pointFrom(pointerX, pointerY), - startCenter, - -stateAtResizeStart.angle as Radians, - ); + 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, + ); - // Get bounds corners rendered on screen - const [esx1, esy1, esx2, esy2] = getResizedElementAbsoluteCoords( - element, - element.width, - element.height, - true, - ); + 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, + ); + } - const boundsCurrentWidth = esx2 - esx1; - const boundsCurrentHeight = esy2 - esy1; + updateBoundElements(element, elementsMap, { + simultaneouslyUpdated: elements, + }); - // 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; + 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, + ); + } + } + } - let boundTextFont: { fontSize?: number } = {}; - const boundTextElement = getBoundTextElement(element, elementsMap); + scene.triggerUpdate(); +}; - if (transformHandleDirection.includes("e")) { - scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth; - } - if (transformHandleDirection.includes("s")) { - scaleY = (rotatedPointer[1] - startTopLeft[1]) / boundsCurrentHeight; - } - if (transformHandleDirection.includes("w")) { - scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth; - } - if (transformHandleDirection.includes("n")) { - scaleY = (startBottomRight[1] - rotatedPointer[1]) / boundsCurrentHeight; +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]; } +}; - // Linear elements dimensions differ from bounds dimensions - const eleInitialWidth = stateAtResizeStart.width; - const eleInitialHeight = stateAtResizeStart.height; - // We have to use dimensions of element on screen, otherwise the scaling of the - // dimensions won't match the cursor for linear elements. - let eleNewWidth = element.width * scaleX; - let eleNewHeight = element.height * scaleY; +export const getResizeArrowDirection = ( + transformHandleType: MaybeTransformHandleType, + element: NonDeleted, +): "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"; +}; - // adjust dimensions for resizing from center +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) { - eleNewWidth = 2 * eleNewWidth - eleInitialWidth; - eleNewHeight = 2 * eleNewHeight - eleInitialHeight; + return "center"; } - // adjust dimensions to keep sides ratio if (shouldMaintainAspectRatio) { - const widthRatio = Math.abs(eleNewWidth) / eleInitialWidth; - const heightRatio = Math.abs(eleNewHeight) / eleInitialHeight; - if (transformHandleDirection.length === 1) { - eleNewHeight *= widthRatio; - eleNewWidth *= heightRatio; - } - if (transformHandleDirection.length === 2) { - const ratio = Math.max(widthRatio, heightRatio); - eleNewWidth = eleInitialWidth * ratio * Math.sign(eleNewWidth); - eleNewHeight = eleInitialHeight * ratio * Math.sign(eleNewHeight); + 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 = originalElements.get( + const stateOfBoundTextElementAtResize = originalElementsMap.get( boundTextElement.id, ) as typeof boundTextElement | undefined; if (stateOfBoundTextElementAtResize) { @@ -554,9 +831,9 @@ export const resizeSingleElement = ( } if (shouldMaintainAspectRatio) { const updatedElement = { - ...element, - width: eleNewWidth, - height: eleNewHeight, + ...latestElement, + width: nextWidth, + height: nextHeight, }; const nextFont = measureFontSizeFromWidth( @@ -579,151 +856,83 @@ export const resizeSingleElement = ( boundTextElement.fontSize, boundTextElement.lineHeight, ); - eleNewWidth = Math.max(eleNewWidth, minWidth); - eleNewHeight = Math.max(eleNewHeight, minHeight); + nextWidth = Math.max(nextWidth, minWidth); + nextHeight = Math.max(nextHeight, minHeight); } } - const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] = - getResizedElementAbsoluteCoords( - stateAtResizeStart, - eleNewWidth, - eleNewHeight, - true, - ); - const newBoundsWidth = newBoundsX2 - newBoundsX1; - const newBoundsHeight = newBoundsY2 - newBoundsY1; - - // Calculate new topLeft based on fixed corner during resize - let newTopLeft = [...startTopLeft] as [number, number]; - if (["n", "w", "nw"].includes(transformHandleDirection)) { - newTopLeft = [ - startBottomRight[0] - Math.abs(newBoundsWidth), - startBottomRight[1] - Math.abs(newBoundsHeight), - ]; - } - if (transformHandleDirection === "ne") { - const bottomLeft = [startTopLeft[0], startBottomRight[1]]; - newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)]; - } - if (transformHandleDirection === "sw") { - const topRight = [startBottomRight[0], startTopLeft[1]]; - newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]]; - } + const rescaledPoints = rescalePointsInElement( + origElement, + nextWidth, + nextHeight, + true, + ); - // Keeps opposite handle fixed during resize - if (shouldMaintainAspectRatio) { - if (["s", "n"].includes(transformHandleDirection)) { - newTopLeft[0] = startCenter[0] - newBoundsWidth / 2; - } - if (["e", "w"].includes(transformHandleDirection)) { - newTopLeft[1] = startCenter[1] - newBoundsHeight / 2; - } + let previousOrigin = pointFrom(origElement.x, origElement.y); + + if (isLinearElement(origElement)) { + const [x1, y1] = getElementBounds(origElement, originalElementsMap); + previousOrigin = pointFrom(x1, y1); } - const flipX = eleNewWidth < 0; - const flipY = eleNewHeight < 0; + const newOrigin: { + x: number; + y: number; + } = getResizedOrigin( + previousOrigin, + origElement.width, + origElement.height, + nextWidth, + nextHeight, + origElement.angle, + handleDirection, + shouldMaintainAspectRatio!!, + shouldResizeFromCenter!!, + ); - // Flip horizontally - if (flipX) { - if (transformHandleDirection.includes("e")) { - newTopLeft[0] -= Math.abs(newBoundsWidth); - } - if (transformHandleDirection.includes("w")) { - newTopLeft[0] += Math.abs(newBoundsWidth); - } - } + if (isLinearElement(origElement) && rescaledPoints.points) { + const offsetX = origElement.x - previousOrigin[0]; + const offsetY = origElement.y - previousOrigin[1]; - // Flip vertically - if (flipY) { - if (transformHandleDirection.includes("s")) { - newTopLeft[1] -= Math.abs(newBoundsHeight); - } - if (transformHandleDirection.includes("n")) { - newTopLeft[1] += Math.abs(newBoundsHeight); - } - } + newOrigin.x += offsetX; + newOrigin.y += offsetY; - if (shouldResizeFromCenter) { - newTopLeft[0] = startCenter[0] - Math.abs(newBoundsWidth) / 2; - newTopLeft[1] = startCenter[1] - Math.abs(newBoundsHeight) / 2; - } + const scaledX = rescaledPoints.points[0][0]; + const scaledY = rescaledPoints.points[0][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, - ); + newOrigin.x += scaledX; + newOrigin.y += scaledY; - // For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner - // So we need to readjust (x,y) to be where the first point should be - const newOrigin = [...newTopLeft]; - const linearElementXOffset = stateAtResizeStart.x - newBoundsX1; - const linearElementYOffset = stateAtResizeStart.y - newBoundsY1; - newOrigin[0] += linearElementXOffset; - newOrigin[1] += linearElementYOffset; - - const nextX = newOrigin[0]; - const nextY = newOrigin[1]; - - // Readjust points for linear elements - let rescaledElementPointsY; - let rescaledPoints; - if (isLinearElement(element) || isFreeDrawElement(element)) { - rescaledElementPointsY = rescalePoints( - 1, - eleNewHeight, - (stateAtResizeStart as ExcalidrawLinearElement).points, - true, + rescaledPoints.points = rescaledPoints.points.map((p) => + pointFrom(p[0] - scaledX, p[1] - scaledY), ); + } - rescaledPoints = rescalePoints( - 0, - eleNewWidth, - rescaledElementPointsY, - true, - ); + // flipping + if (nextWidth < 0) { + newOrigin.x = newOrigin.x + nextWidth; + } + if (nextHeight < 0) { + newOrigin.y = newOrigin.y + nextHeight; } - const resizedElement = { - width: Math.abs(eleNewWidth), - height: Math.abs(eleNewHeight), - x: nextX, - y: nextY, - points: rescaledPoints, - }; - - if ("scale" in element && "scale" in stateAtResizeStart) { - mutateElement(element, { + if ("scale" in latestElement && "scale" in origElement) { + mutateElement(latestElement, { scale: [ // defaulting because scaleX/Y can be 0/-0 - (Math.sign(newBoundsX2 - stateAtResizeStart.x) || - stateAtResizeStart.scale[0]) * stateAtResizeStart.scale[0], - (Math.sign(newBoundsY2 - stateAtResizeStart.y) || - stateAtResizeStart.scale[1]) * stateAtResizeStart.scale[1], + (Math.sign(nextWidth) || origElement.scale[0]) * origElement.scale[0], + (Math.sign(nextHeight) || origElement.scale[1]) * origElement.scale[1], ], }); } if ( - isArrowElement(element) && + isArrowElement(latestElement) && boundTextElement && shouldMaintainAspectRatio ) { const fontSize = - (resizedElement.width / element.width) * boundTextElement.fontSize; + (nextWidth / latestElement.width) * boundTextElement.fontSize; if (fontSize < MIN_FONT_SIZE) { return; } @@ -731,18 +940,23 @@ export const resizeSingleElement = ( } if ( - resizedElement.width !== 0 && - resizedElement.height !== 0 && - Number.isFinite(resizedElement.x) && - Number.isFinite(resizedElement.y) + nextWidth !== 0 && + nextHeight !== 0 && + Number.isFinite(newOrigin.x) && + Number.isFinite(newOrigin.y) ) { - mutateElement(element, resizedElement); + const updates = { + ...newOrigin, + width: Math.abs(nextWidth), + height: Math.abs(nextHeight), + ...rescaledPoints, + }; - updateBoundElements(element, elementsMap, { - newSize: { - width: resizedElement.width, - height: resizedElement.height, - }, + 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) { @@ -751,52 +965,134 @@ export const resizeSingleElement = ( }); } handleBindTextResize( - element, + latestElement, elementsMap, - transformHandleDirection, + handleDirection, shouldMaintainAspectRatio, ); } }; -export const resizeMultipleElements = ( - originalElements: PointerDownState["originalElements"], +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[], - elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, - transformHandleType: TransformHandleDirection, - shouldResizeFromCenter: boolean, - shouldMaintainAspectRatio: boolean, + originalElementsMap: ElementsMap, + elementsMap: ElementsMap, + handleDirection: TransformHandleDirection, pointerX: number, pointerY: number, + { + shouldMaintainAspectRatio = false, + shouldResizeFromCenter = false, + }: { + shouldResizeFromCenter?: boolean; + shouldMaintainAspectRatio?: boolean; + } = {}, ) => { - // map selected elements to the original elements. While it never should - // happen that pointerDownState.originalElements won't contain the selected - // elements during resize, this coupling isn't guaranteed, so to ensure - // type safety we need to transform only those elements we filter. - const targetElements = selectedElements.reduce( - ( - acc: { - /** element at resize start */ - orig: NonDeletedExcalidrawElement; - /** latest element */ - latest: NonDeletedExcalidrawElement; - }[], - element, - ) => { - const origElement = originalElements.get(element.id); - if (origElement) { - acc.push({ orig: origElement, latest: element }); - } - return acc; - }, - [], + 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 = targetElements.reduce((acc, { orig }) => { + const boundTextElements = originalElementsArray.reduce((acc, orig) => { if (!isLinearElement(orig)) { return acc; } @@ -804,42 +1100,47 @@ export const resizeMultipleElements = ( if (!textId) { return acc; } - const text = originalElements.get(textId) ?? null; + const text = originalElementsMap.get(textId) ?? null; if (!isBoundToContainer(text)) { return acc; } - const xy = LinearElementEditor.getBoundTextElementPosition( - orig, - text, - elementsMap, - ); - return [...acc, { ...text, ...xy }]; + return [ + ...acc, + { + ...text, + ...LinearElementEditor.getBoundTextElementPosition( + orig, + text, + elementsMap, + ), + }, + ]; }, [] as ExcalidrawTextElementWithContainer[]); - const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox( - targetElements.map(({ orig }) => orig).concat(boundTextElements), + 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 direction = transformHandleType; - - const anchorsMap: Record = { - ne: pointFrom(minX, maxY), - se: pointFrom(minX, minY), - sw: pointFrom(maxX, minY), - nw: pointFrom(maxX, maxY), - e: pointFrom(minX, minY + height / 2), - w: pointFrom(maxX, minY + height / 2), - n: pointFrom(minX + width / 2, maxY), - s: pointFrom(minX + width / 2, 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; // 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 - ? pointFrom(midX, midY) - : anchorsMap[direction]; + ? [midX, midY] + : anchorsMap[handleDirection]; const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1; @@ -849,31 +1150,18 @@ export const resizeMultipleElements = ( Math.abs(pointerY - anchorY) / height || 0, ) * resizeFromCenterScale; - if (scale === 0) { - return; - } - - 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), - ); + 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 (keepAspectRatio) { - scaleX = scale; - scaleY = scale; + if (shouldMaintainAspectRatio) { + nextWidth = width * scale * Math.sign(pointerX - anchorX); + nextHeight = height * scale * Math.sign(pointerY - anchorY); } const flipConditionsMap: Record< @@ -895,271 +1183,316 @@ export const resizeMultipleElements = ( s: [false, pointerY < anchorY], }; - /** - * 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] = flipConditionsMap[direction].map( - (condition) => (condition ? -1 : 1), + const [flipByX, flipByY] = flipConditionsMap[handleDirection].map( + (condition) => condition, ); - const isFlippedByX = flipFactorX < 0; - const isFlippedByY = flipFactorY < 0; - - const elementsAndUpdates: { - element: NonDeletedExcalidrawElement; - update: Mutable< - Pick - > & { - points?: ExcalidrawLinearElement["points"]; - fontSize?: ExcalidrawTextElement["fontSize"]; - scale?: ExcalidrawImageElement["scale"]; - boundTextFontSize?: ExcalidrawTextElement["fontSize"]; - startBinding?: ExcalidrawArrowElement["startBinding"]; - endBinding?: ExcalidrawArrowElement["endBinding"]; - }; - }[] = []; - 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, - ); + return { + originalBoundingBox, + nextWidth, + nextHeight, + flipByX, + flipByY, + }; +}; - 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 * scaleX + shiftX); - const y = anchorY + flipFactorY * (offsetY * scaleY + shiftY); - - const rescaledPoints = rescalePointsInElement( - orig, - width * flipFactorX, - height * flipFactorY, - false, - ); +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; + } - const update: typeof elementsAndUpdates[0]["update"] = { - x, - y, - width, - height, - angle, - ...rescaledPoints, - }; + // do not allow next width or height to be 0 + if (nextHeight === 0 || nextWidth === 0) { + return; + } - if (isImageElement(orig)) { - update.scale = [orig.scale[0] * flipFactorX, orig.scale[1] * flipFactorY]; - } + if (!originalElementsMap) { + originalElementsMap = elementsMap; + } - if (isTextElement(orig)) { - const metrics = measureFontSizeFromWidth(orig, elementsMap, width); - if (!metrics) { - return; + 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 }); } - update.fontSize = metrics.size; - } + return acc; + }, + [], + ); - const boundTextElement = originalElements.get( - getBoundTextElementId(orig) ?? "", - ) as ExcalidrawTextElementWithContainer | undefined; + let boundingBox: BoundingBox; - if (boundTextElement) { - if (keepAspectRatio) { - const newFontSize = boundTextElement.fontSize * scale; - if (newFontSize < MIN_FONT_SIZE) { - return; - } - update.boundTextFontSize = newFontSize; - } else { - update.boundTextFontSize = boundTextElement.fontSize; + 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[]); - elementsAndUpdates.push({ - element: latest, - update, - }); + boundingBox = getCommonBoundingBox( + targetElements.map(({ orig }) => orig).concat(boundTextElements), + ); } + const { minX, minY, maxX, maxY, midX, midY } = boundingBox; + const width = maxX - minX; + const height = maxY - minY; - const elementsToUpdate = elementsAndUpdates.map(({ element }) => element); - - for (const { - element, - update: { boundTextFontSize, ...update }, - } of elementsAndUpdates) { - const { angle, width: newWidth, height: newHeight } = update; + if (nextWidth === undefined && nextHeight === undefined) { + nextWidth = width; + nextHeight = height; + } - mutateElement(element, update, false); + 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); + } + } - updateBoundElements(element, elementsMap, { - simultaneouslyUpdated: elementsToUpdate, - newSize: { width: newWidth, height: newHeight }, - }); + 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 boundTextElement = getBoundTextElement(element, elementsMap); - if (boundTextElement && boundTextFontSize) { - mutateElement( - boundTextElement, - { - fontSize: boundTextFontSize, - angle: isLinearElement(element) ? undefined : angle, - }, - false, + 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; + + // 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), ); - handleBindTextResize(element, elementsMap, transformHandleType, true); + + if (keepAspectRatio) { + scaleX = scale; + scaleY = scale; } - } - Scene.getScene(elementsAndUpdates[0].element)?.triggerUpdate(); -}; + /** + * 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 + > & { + points?: ExcalidrawLinearElement["points"]; + fontSize?: ExcalidrawTextElement["fontSize"]; + scale?: ExcalidrawImageElement["scale"]; + boundTextFontSize?: ExcalidrawTextElement["fontSize"]; + }; + }[] = []; -const rotateMultipleElements = ( - originalElements: PointerDownState["originalElements"], - elements: readonly NonDeletedExcalidrawElement[], - elementsMap: SceneElementsMap, - 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 { orig, latest } of targetElements) { + // bounded text elements are updated along with their container elements + if (isTextElement(orig) && isBoundToContainer(orig)) { + continue; + } - elements - .filter((element) => !isFrameLikeElement(element)) - .forEach((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, + const width = orig.width * scaleX; + const height = orig.height * scaleY; + const angle = normalizeRadians( + (orig.angle * flipFactorX * flipFactorY) 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, - ); + 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, + ]; } - updateBoundElements(element, elementsMap, { - simultaneouslyUpdated: elements, + 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 boundText = getBoundTextElement(element, elementsMap); - if (boundText && !isArrowElement(element)) { + 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( - boundText, + boundTextElement, { - x: boundText.x + (rotatedCX - cx), - y: boundText.y + (rotatedCY - cy), - angle: normalizeRadians((centerAngle + origAngle) as Radians), + fontSize: boundTextFontSize, + angle: isLinearElement(element) ? undefined : angle, }, false, ); + handleBindTextResize(element, elementsMap, handleDirection, true); } - }); - - Scene.getScene(elements[0])?.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]; + scene.triggerUpdate(); } }; - -export const getResizeArrowDirection = ( - transformHandleType: MaybeTransformHandleType, - element: NonDeleted, -): "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"; -}; diff --git a/packages/excalidraw/tests/resize.test.tsx b/packages/excalidraw/tests/resize.test.tsx index abf99bcf6..381629c8f 100644 --- a/packages/excalidraw/tests/resize.test.tsx +++ b/packages/excalidraw/tests/resize.test.tsx @@ -18,6 +18,8 @@ import { LinearElementEditor } from "../element/linearElementEditor"; import { arrayToMap } from "../utils"; import type { LocalPoint } from "../../math"; import { pointFrom } from "../../math"; +import { resizeSingleElement } from "../element/resizeElements"; +import { getSizeFromPoints } from "../points"; ReactDOM.unmountComponentAtNode(document.getElementById("root")!); @@ -235,7 +237,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => { }; it("resizes", async () => { - const element = UI.createElement(type, { points: points[type] }); + const element = UI.createElement("freedraw", { points: points.freedraw }); const bounds = getBoundsFromPoints(element); UI.resize(element, "ne", [30, -60]); @@ -249,7 +251,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => { }); it("flips while resizing", async () => { - const element = UI.createElement(type, { points: points[type] }); + const element = UI.createElement("freedraw", { points: points.freedraw }); const bounds = getBoundsFromPoints(element); UI.resize(element, "sw", [140, -80]); @@ -263,7 +265,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => { }); it("resizes with locked aspect ratio", async () => { - const element = UI.createElement(type, { points: points[type] }); + const element = UI.createElement("freedraw", { points: points.freedraw }); const bounds = getBoundsFromPoints(element); UI.resize(element, "ne", [30, -60], { shift: true }); @@ -280,7 +282,7 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => { }); it("resizes from center", async () => { - const element = UI.createElement(type, { points: points[type] }); + const element = UI.createElement("freedraw", { points: points.freedraw }); const bounds = getBoundsFromPoints(element); UI.resize(element, "nw", [-20, -30], { alt: true }); @@ -294,6 +296,147 @@ describe.each(["line", "freedraw"] as const)("%s element", (type) => { }); }); +describe("line element", () => { + const points: LocalPoint[] = [ + pointFrom(0, 0), + pointFrom(60, -20), + pointFrom(20, 40), + pointFrom(-40, 0), + ]; + + it("resizes", async () => { + UI.createElement("line", { points }); + + const element = h.elements[0] as ExcalidrawLinearElement; + + const { + x: prevX, + y: prevY, + width: prevWidth, + height: prevHeight, + } = element; + + const nextWidth = prevWidth + 30; + const nextHeight = prevHeight + 30; + + resizeSingleElement( + nextWidth, + nextHeight, + element, + element, + h.app.scene.getNonDeletedElementsMap(), + h.app.scene.getNonDeletedElementsMap(), + "ne", + ); + + expect(element.x).not.toBe(prevX); + expect(element.y).not.toBe(prevY); + + expect(element.width).toBe(nextWidth); + expect(element.height).toBe(nextHeight); + + expect(element.points[0]).toEqual([0, 0]); + + const { width, height } = getSizeFromPoints(element.points); + expect(width).toBe(element.width); + expect(height).toBe(element.height); + }); + + it("flips while resizing", async () => { + UI.createElement("line", { points }); + const element = h.elements[0] as ExcalidrawLinearElement; + + const { + width: prevWidth, + height: prevHeight, + points: prevPoints, + } = element; + + const nextWidth = prevWidth * -1; + const nextHeight = prevHeight * -1; + + resizeSingleElement( + nextWidth, + nextHeight, + element, + element, + h.app.scene.getNonDeletedElementsMap(), + h.app.scene.getNonDeletedElementsMap(), + "se", + ); + + expect(element.width).toBe(prevWidth); + expect(element.height).toBe(prevHeight); + + element.points.forEach((point, idx) => { + expect(point[0]).toBeCloseTo(prevPoints[idx][0] * -1); + expect(point[1]).toBeCloseTo(prevPoints[idx][1] * -1); + }); + }); + + it("resizes with locked aspect ratio", async () => { + UI.createElement("line", { points }); + const element = h.elements[0] as ExcalidrawLinearElement; + + const { width: prevWidth, height: prevHeight } = element; + + UI.resize(element, "ne", [30, -60], { shift: true }); + + const scaleHeight = element.width / prevWidth; + const scaleWidth = element.height / prevHeight; + + expect(scaleHeight).toBeCloseTo(scaleWidth); + }); + + it("resizes from center", async () => { + UI.createElement("line", { + points: [ + pointFrom(0, 0), + pointFrom(338.05644048727373, -180.4761618151104), + pointFrom(338.05644048727373, 180.4761618151104), + pointFrom(-338.05644048727373, 180.4761618151104), + pointFrom(-338.05644048727373, -180.4761618151104), + ], + }); + const element = h.elements[0] as ExcalidrawLinearElement; + + const { + x: prevX, + y: prevY, + width: prevWidth, + height: prevHeight, + } = element; + + const prevSmallestX = Math.min(...element.points.map((p) => p[0])); + const prevBiggestX = Math.max(...element.points.map((p) => p[0])); + + resizeSingleElement( + prevWidth + 20, + prevHeight, + element, + element, + h.app.scene.getNonDeletedElementsMap(), + h.app.scene.getNonDeletedElementsMap(), + "e", + { + shouldResizeFromCenter: true, + }, + ); + + expect(element.width).toBeCloseTo(prevWidth + 20); + expect(element.height).toBeCloseTo(prevHeight); + + expect(element.x).toBeCloseTo(prevX); + expect(element.y).toBeCloseTo(prevY); + + const smallestX = Math.min(...element.points.map((p) => p[0])); + const biggestX = Math.max(...element.points.map((p) => p[0])); + + expect(prevSmallestX - smallestX).toBeCloseTo(10); + expect(biggestX - prevBiggestX).toBeCloseTo(10); + }); +}); + describe("arrow element", () => { it("resizes with a label", async () => { const arrow = UI.createElement("arrow", {