From a0d413ab4eff961a5c42a37c2df67737b3ce60a1 Mon Sep 17 00:00:00 2001
From: Alex Kim <45559664+alex-kim-dev@users.noreply.github.com>
Date: Sat, 13 Aug 2022 22:53:10 +0500
Subject: [PATCH] fix: resize multiple elements from center (#5560)

Co-authored-by: Ryan Di <ryan.weihao.di@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
---
 src/element/bounds.ts         | 131 +++++++++++++----
 src/element/resizeElements.ts | 257 +++++++++++++++++-----------------
 src/points.ts                 |  52 ++-----
 3 files changed, 245 insertions(+), 195 deletions(-)

diff --git a/src/element/bounds.ts b/src/element/bounds.ts
index 8290b1ce..a1e4197c 100644
--- a/src/element/bounds.ts
+++ b/src/element/bounds.ts
@@ -18,6 +18,7 @@ import { rescalePoints } from "../points";
 
 // x and y position of top left corner, x and y position of bottom right corner
 export type Bounds = readonly [number, number, number, number];
+type MaybeQuadraticSolution = [number | null, number | null] | false;
 
 // If the element is created from right to left, the width is going to be negative
 // This set of functions retrieves the absolute position of the 4 points.
@@ -68,11 +69,95 @@ export const getCurvePathOps = (shape: Drawable): Op[] => {
   return shape.sets[0].ops;
 };
 
+// reference: https://eliot-jones.com/2019/12/cubic-bezier-curve-bounding-boxes
+const getBezierValueForT = (
+  t: number,
+  p0: number,
+  p1: number,
+  p2: number,
+  p3: number,
+) => {
+  const oneMinusT = 1 - t;
+  return (
+    Math.pow(oneMinusT, 3) * p0 +
+    3 * Math.pow(oneMinusT, 2) * t * p1 +
+    3 * oneMinusT * Math.pow(t, 2) * p2 +
+    Math.pow(t, 3) * p3
+  );
+};
+
+const solveQuadratic = (
+  p0: number,
+  p1: number,
+  p2: number,
+  p3: number,
+): MaybeQuadraticSolution => {
+  const i = p1 - p0;
+  const j = p2 - p1;
+  const k = p3 - p2;
+
+  const a = 3 * i - 6 * j + 3 * k;
+  const b = 6 * j - 6 * i;
+  const c = 3 * i;
+
+  const sqrtPart = b * b - 4 * a * c;
+  const hasSolution = sqrtPart >= 0;
+
+  if (!hasSolution) {
+    return false;
+  }
+
+  const t1 = (-b + Math.sqrt(sqrtPart)) / (2 * a);
+  const t2 = (-b - Math.sqrt(sqrtPart)) / (2 * a);
+
+  let s1 = null;
+  let s2 = null;
+
+  if (t1 >= 0 && t1 <= 1) {
+    s1 = getBezierValueForT(t1, p0, p1, p2, p3);
+  }
+
+  if (t2 >= 0 && t2 <= 1) {
+    s2 = getBezierValueForT(t2, p0, p1, p2, p3);
+  }
+
+  return [s1, s2];
+};
+
+const getCubicBezierCurveBound = (
+  p0: Point,
+  p1: Point,
+  p2: Point,
+  p3: Point,
+): Bounds => {
+  const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
+  const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
+
+  let minX = Math.min(p0[0], p3[0]);
+  let maxX = Math.max(p0[0], p3[0]);
+
+  if (solX) {
+    const xs = solX.filter((x) => x !== null) as number[];
+    minX = Math.min(minX, ...xs);
+    maxX = Math.max(maxX, ...xs);
+  }
+
+  let minY = Math.min(p0[1], p3[1]);
+  let maxY = Math.max(p0[1], p3[1]);
+  if (solY) {
+    const ys = solY.filter((y) => y !== null) as number[];
+    minY = Math.min(minY, ...ys);
+    maxY = Math.max(maxY, ...ys);
+  }
+  return [minX, minY, maxX, maxY];
+};
+
 const getMinMaxXYFromCurvePathOps = (
   ops: Op[],
   transformXY?: (x: number, y: number) => [number, number],
 ): [number, number, number, number] => {
   let currentP: Point = [0, 0];
+
   const { minX, minY, maxX, maxY } = ops.reduce(
     (limits, { op, data }) => {
       // There are only four operation types:
@@ -83,38 +168,29 @@ const getMinMaxXYFromCurvePathOps = (
         // move operation does not draw anything; so, it always
         // returns false
       } else if (op === "bcurveTo") {
-        // create points from bezier curve
-        // bezier curve stores data as a flattened array of three positions
-        // [x1, y1, x2, y2, x3, y3]
-        const p1 = [data[0], data[1]] as Point;
-        const p2 = [data[2], data[3]] as Point;
-        const p3 = [data[4], data[5]] as Point;
+        const _p1 = [data[0], data[1]] as Point;
+        const _p2 = [data[2], data[3]] as Point;
+        const _p3 = [data[4], data[5]] as Point;
 
-        const p0 = currentP;
-        currentP = p3;
+        const p1 = transformXY ? transformXY(..._p1) : _p1;
+        const p2 = transformXY ? transformXY(..._p2) : _p2;
+        const p3 = transformXY ? transformXY(..._p3) : _p3;
 
-        const equation = (t: number, idx: number) =>
-          Math.pow(1 - t, 3) * p3[idx] +
-          3 * t * Math.pow(1 - t, 2) * p2[idx] +
-          3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
-          p0[idx] * Math.pow(t, 3);
+        const p0 = transformXY ? transformXY(...currentP) : currentP;
+        currentP = _p3;
 
-        let t = 0;
-        while (t <= 1.0) {
-          let x = equation(t, 0);
-          let y = equation(t, 1);
-          if (transformXY) {
-            [x, y] = transformXY(x, y);
-          }
+        const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
+          p0,
+          p1,
+          p2,
+          p3,
+        );
 
-          limits.minY = Math.min(limits.minY, y);
-          limits.minX = Math.min(limits.minX, x);
+        limits.minX = Math.min(limits.minX, minX);
+        limits.minY = Math.min(limits.minY, minY);
 
-          limits.maxX = Math.max(limits.maxX, x);
-          limits.maxY = Math.max(limits.maxY, y);
-
-          t += 0.1;
-        }
+        limits.maxX = Math.max(limits.maxX, maxX);
+        limits.maxY = Math.max(limits.maxY, maxY);
       } else if (op === "lineTo") {
         // TODO: Implement this
       } else if (op === "qcurveTo") {
@@ -124,7 +200,6 @@ const getMinMaxXYFromCurvePathOps = (
     },
     { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
   );
-
   return [minX, minY, maxX, maxY];
 };
 
diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts
index ff492563..ac32a070 100644
--- a/src/element/resizeElements.ts
+++ b/src/element/resizeElements.ts
@@ -18,6 +18,7 @@ import {
   getElementAbsoluteCoords,
   getCommonBounds,
   getResizedElementAbsoluteCoords,
+  getCommonBoundingBox,
 } from "./bounds";
 import {
   isFreeDrawElement,
@@ -137,8 +138,10 @@ export const transformElements = (
       transformHandleType === "se"
     ) {
       resizeMultipleElements(
+        pointerDownState,
         selectedElements,
         transformHandleType,
+        shouldResizeFromCenter,
         pointerX,
         pointerY,
       );
@@ -637,146 +640,142 @@ export const resizeSingleElement = (
 };
 
 const resizeMultipleElements = (
-  elements: readonly NonDeletedExcalidrawElement[],
+  pointerDownState: PointerDownState,
+  selectedElements: readonly NonDeletedExcalidrawElement[],
   transformHandleType: "nw" | "ne" | "sw" | "se",
+  shouldResizeFromCenter: boolean,
   pointerX: number,
   pointerY: number,
 ) => {
-  const [x1, y1, x2, y2] = getCommonBounds(elements);
-  let scale: number;
-  let getNextXY: (
-    element: NonDeletedExcalidrawElement,
-    origCoords: readonly [number, number, number, number],
-    finalCoords: readonly [number, number, number, number],
-  ) => { x: number; y: number };
-  switch (transformHandleType) {
-    case "se":
-      scale = Math.max(
-        (pointerX - x1) / (x2 - x1),
-        (pointerY - y1) / (y2 - y1),
-      );
-      getNextXY = (element, [origX1, origY1], [finalX1, finalY1]) => {
-        const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1;
-        const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1;
-        return { x, y };
-      };
-      break;
-    case "nw":
-      scale = Math.max(
-        (x2 - pointerX) / (x2 - x1),
-        (y2 - pointerY) / (y2 - y1),
-      );
-      getNextXY = (element, [, , origX2, origY2], [, , finalX2, finalY2]) => {
-        const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2;
-        const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2;
-        return { x, y };
-      };
-      break;
-    case "ne":
-      scale = Math.max(
-        (pointerX - x1) / (x2 - x1),
-        (y2 - pointerY) / (y2 - y1),
-      );
-      getNextXY = (element, [origX1, , , origY2], [finalX1, , , finalY2]) => {
-        const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1;
-        const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2;
-        return { x, y };
-      };
-      break;
-    case "sw":
-      scale = Math.max(
-        (x2 - pointerX) / (x2 - x1),
-        (pointerY - y1) / (y2 - y1),
-      );
-      getNextXY = (element, [, origY1, origX2], [, finalY1, finalX2]) => {
-        const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2;
-        const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1;
-        return { x, y };
-      };
-      break;
+  // 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 = pointerDownState.originalElements.get(element.id);
+      if (origElement) {
+        acc.push({ orig: origElement, latest: element });
+      }
+      return acc;
+    },
+    [],
+  );
+
+  const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
+    targetElements.map(({ orig }) => orig),
+  );
+  const direction = transformHandleType;
+
+  const mapDirectionsToAnchors: Record<typeof direction, Point> = {
+    ne: [minX, maxY],
+    se: [minX, minY],
+    sw: [maxX, minY],
+    nw: [maxX, maxY],
+  };
+
+  // anchor point must be on the opposite side of the dragged selection handle
+  // or be the center of the selection if alt is pressed
+  const [anchorX, anchorY]: Point = shouldResizeFromCenter
+    ? [midX, midY]
+    : mapDirectionsToAnchors[direction];
+
+  const mapDirectionsToPointerSides: Record<
+    typeof direction,
+    [x: boolean, y: boolean]
+  > = {
+    ne: [pointerX >= anchorX, pointerY <= anchorY],
+    se: [pointerX >= anchorX, pointerY >= anchorY],
+    sw: [pointerX <= anchorX, pointerY >= anchorY],
+    nw: [pointerX <= anchorX, pointerY <= anchorY],
+  };
+
+  // pointer side relative to anchor
+  const [pointerSideX, pointerSideY] = mapDirectionsToPointerSides[
+    direction
+  ].map((condition) => (condition ? 1 : -1));
+
+  // stop resizing if a pointer is on the other side of selection
+  if (pointerSideX < 0 && pointerSideY < 0) {
+    return;
   }
-  if (scale > 0) {
-    const updates = elements.reduce(
-      (prev, element) => {
-        if (!prev) {
-          return prev;
+
+  const scale =
+    Math.max(
+      (pointerSideX * Math.abs(pointerX - anchorX)) / (maxX - minX),
+      (pointerSideY * Math.abs(pointerY - anchorY)) / (maxY - minY),
+    ) * (shouldResizeFromCenter ? 2 : 1);
+
+  if (scale === 1) {
+    return;
+  }
+
+  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;
+
+    // readjust points for linear & free draw elements
+    const rescaledPoints = rescalePointsInElement(element.orig, width, height);
+
+    const update: {
+      width: number;
+      height: number;
+      x: number;
+      y: number;
+      points?: Point[];
+      fontSize?: number;
+      baseline?: number;
+    } = {
+      width,
+      height,
+      x,
+      y,
+      ...rescaledPoints,
+    };
+
+    let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
+
+    const boundTextElement = getBoundTextElement(element.latest);
+
+    if (boundTextElement || isTextElement(element.orig)) {
+      const optionalPadding = boundTextElement ? BOUND_TEXT_PADDING * 2 : 0;
+      const textMeasurements = measureFontSizeFromWH(
+        boundTextElement ?? (element.orig as ExcalidrawTextElement),
+        width - optionalPadding,
+        height - optionalPadding,
+      );
+      if (textMeasurements) {
+        if (isTextElement(element.orig)) {
+          update.fontSize = textMeasurements.size;
+          update.baseline = textMeasurements.baseline;
         }
-        const width = element.width * scale;
-        const height = element.height * scale;
-        const boundTextElement = getBoundTextElement(element);
-        let font: { fontSize?: number; baseline?: number } = {};
 
         if (boundTextElement) {
-          const nextFont = measureFontSizeFromWH(
-            boundTextElement,
-            width - BOUND_TEXT_PADDING * 2,
-            height - BOUND_TEXT_PADDING * 2,
-          );
-
-          if (nextFont === null) {
-            return null;
-          }
-          font = {
-            fontSize: nextFont.size,
-            baseline: nextFont.baseline,
+          boundTextUpdates = {
+            fontSize: textMeasurements.size,
+            baseline: textMeasurements.baseline,
           };
         }
-
-        if (isTextElement(element)) {
-          const nextFont = measureFontSizeFromWH(element, width, height);
-          if (nextFont === null) {
-            return null;
-          }
-          font = { fontSize: nextFont.size, baseline: nextFont.baseline };
-        }
-        const origCoords = getElementAbsoluteCoords(element);
-
-        const rescaledPoints = rescalePointsInElement(element, width, height);
-
-        updateBoundElements(element, {
-          newSize: { width, height },
-          simultaneouslyUpdated: elements,
-        });
-
-        const finalCoords = getResizedElementAbsoluteCoords(
-          {
-            ...element,
-            ...rescaledPoints,
-          },
-          width,
-          height,
-        );
-
-        const { x, y } = getNextXY(element, origCoords, finalCoords);
-        return [...prev, { width, height, x, y, ...rescaledPoints, ...font }];
-      },
-      [] as
-        | {
-            width: number;
-            height: number;
-            x: number;
-            y: number;
-            points?: (readonly [number, number])[];
-            fontSize?: number;
-            baseline?: number;
-          }[]
-        | null,
-    );
-    if (updates) {
-      elements.forEach((element, index) => {
-        mutateElement(element, updates[index]);
-        const boundTextElement = getBoundTextElement(element);
-
-        if (boundTextElement) {
-          mutateElement(boundTextElement, {
-            fontSize: updates[index].fontSize,
-            baseline: updates[index].baseline,
-          });
-          handleBindTextResize(element, transformHandleType);
-        }
-      });
+      }
     }
-  }
+
+    mutateElement(element.latest, update);
+
+    if (boundTextElement && boundTextUpdates) {
+      mutateElement(boundTextElement, boundTextUpdates);
+      handleBindTextResize(element.latest, transformHandleType);
+    }
+  });
 };
 
 const rotateMultipleElements = (
diff --git a/src/points.ts b/src/points.ts
index c407ffbb..311b8bbc 100644
--- a/src/points.ts
+++ b/src/points.ts
@@ -9,46 +9,22 @@ export const getSizeFromPoints = (points: readonly Point[]) => {
   };
 };
 
+/** @arg dimension, 0 for rescaling only x, 1 for y */
 export const rescalePoints = (
   dimension: 0 | 1,
-  nextDimensionSize: number,
-  prevPoints: readonly Point[],
+  newSize: number,
+  points: readonly Point[],
 ): Point[] => {
-  const prevDimValues = prevPoints.map((point) => point[dimension]);
-  const prevMaxDimension = Math.max(...prevDimValues);
-  const prevMinDimension = Math.min(...prevDimValues);
-  const prevDimensionSize = prevMaxDimension - prevMinDimension;
+  const coordinates = points.map((point) => point[dimension]);
+  const maxCoordinate = Math.max(...coordinates);
+  const minCoordinate = Math.min(...coordinates);
+  const size = maxCoordinate - minCoordinate;
+  const scale = size === 0 ? 1 : newSize / size;
 
-  const dimensionScaleFactor =
-    prevDimensionSize === 0 ? 1 : nextDimensionSize / prevDimensionSize;
-
-  let nextMinDimension = Infinity;
-
-  const scaledPoints = prevPoints.map(
-    (prevPoint) =>
-      prevPoint.map((value, currentDimension) => {
-        if (currentDimension !== dimension) {
-          return value;
-        }
-        const scaledValue = value * dimensionScaleFactor;
-        nextMinDimension = Math.min(scaledValue, nextMinDimension);
-        return scaledValue;
-      }) as [number, number],
-  );
-
-  if (scaledPoints.length === 2) {
-    // we don't translate two-point lines
-    return scaledPoints;
-  }
-
-  const translation = prevMinDimension - nextMinDimension;
-
-  const nextPoints = scaledPoints.map(
-    (scaledPoint) =>
-      scaledPoint.map((value, currentDimension) => {
-        return currentDimension === dimension ? value + translation : value;
-      }) as [number, number],
-  );
-
-  return nextPoints;
+  return points.map((point): Point => {
+    const newCoordinate = point[dimension] * scale;
+    const newPoint = [...point];
+    newPoint[dimension] = newCoordinate;
+    return newPoint as unknown as Point;
+  });
 };