浏览代码

feat: rotating multiple elements (#1960)

Daishi Kato 4 年之前
父节点
当前提交
a2e7d8d560
共有 4 个文件被更改,包括 151 次插入24 次删除
  1. 14 0
      src/components/App.tsx
  2. 0 1
      src/element/handlerRectangles.ts
  3. 115 11
      src/element/resizeElements.ts
  4. 22 12
      src/renderer/renderScene.ts

+ 14 - 0
src/components/App.tsx

@@ -205,6 +205,10 @@ type PointerDownState = Readonly<{
     offset: { x: number; y: number };
     // This is determined on the initial pointer down event
     arrowDirection: "origin" | "end";
+    // This is a center point of selected elements determined on the initial pointer down event (for rotation only)
+    center: { x: number; y: number };
+    // This is a list of selected elements determined on the initial pointer down event (for rotation only)
+    originalElements: readonly NonDeleted<ExcalidrawElement>[];
   };
   hit: {
     // The element the pointer is "hitting", is determined on the initial
@@ -2213,6 +2217,11 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       this.canvas,
       window.devicePixelRatio,
     );
+    const selectedElements = getSelectedElements(
+      globalSceneState.getElements(),
+      this.state,
+    );
+    const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements);
 
     return {
       origin,
@@ -2231,6 +2240,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         isResizing: false,
         offset: { x: 0, y: 0 },
         arrowDirection: "origin",
+        center: { x: (maxX + minX) / 2, y: (maxY + minY) / 2 },
+        originalElements: selectedElements.map((element) => ({ ...element })),
       },
       hit: {
         element: null,
@@ -2709,6 +2720,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
             getResizeCenterPointKey(event),
             resizeX,
             resizeY,
+            pointerDownState.resize.center.x,
+            pointerDownState.resize.center.y,
+            pointerDownState.resize.originalElements,
           )
         ) {
           return;

+ 0 - 1
src/element/handlerRectangles.ts

@@ -18,7 +18,6 @@ export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
   s: true,
   n: true,
   w: true,
-  rotation: true,
 };
 
 const OMIT_SIDES_FOR_TEXT_ELEMENT = {

+ 115 - 11
src/element/resizeElements.ts

@@ -23,18 +23,28 @@ import {
 } from "./resizeTest";
 import { measureText, getFontString } from "../utils";
 
+const normalizeAngle = (angle: number): number => {
+  if (angle >= 2 * Math.PI) {
+    return angle - 2 * Math.PI;
+  }
+  return angle;
+};
+
 type ResizeTestType = ReturnType<typeof resizeTest>;
 
 export const resizeElements = (
   resizeHandle: ResizeTestType,
   setResizeHandle: (nextResizeHandle: ResizeTestType) => void,
-  selectedElements: NonDeletedExcalidrawElement[],
+  selectedElements: readonly NonDeletedExcalidrawElement[],
   resizeArrowDirection: "origin" | "end",
   isRotateWithDiscreteAngle: boolean,
   isResizeWithSidesSameLength: boolean,
   isResizeCenterPoint: boolean,
   pointerX: number,
   pointerY: number,
+  centerX: number,
+  centerY: number,
+  originalElements: readonly NonDeletedExcalidrawElement[],
 ) => {
   if (selectedElements.length === 1) {
     const [element] = selectedElements;
@@ -100,15 +110,32 @@ export const resizeElements = (
     });
 
     return true;
-  } else if (
-    selectedElements.length > 1 &&
-    (resizeHandle === "nw" ||
+  } else if (selectedElements.length > 1) {
+    if (resizeHandle === "rotation") {
+      rotateMultipleElements(
+        selectedElements,
+        pointerX,
+        pointerY,
+        isRotateWithDiscreteAngle,
+        centerX,
+        centerY,
+        originalElements,
+      );
+      return true;
+    } else if (
+      resizeHandle === "nw" ||
       resizeHandle === "ne" ||
       resizeHandle === "sw" ||
-      resizeHandle === "se")
-  ) {
-    resizeMultipleElements(selectedElements, resizeHandle, pointerX, pointerY);
-    return true;
+      resizeHandle === "se"
+    ) {
+      resizeMultipleElements(
+        selectedElements,
+        resizeHandle,
+        pointerX,
+        pointerY,
+      );
+      return true;
+    }
   }
   return false;
 };
@@ -127,9 +154,7 @@ const rotateSingleElement = (
     angle += SHIFT_LOCKING_ANGLE / 2;
     angle -= angle % SHIFT_LOCKING_ANGLE;
   }
-  if (angle >= 2 * Math.PI) {
-    angle -= 2 * Math.PI;
-  }
+  angle = normalizeAngle(angle);
   mutateElement(element, { angle });
 };
 
@@ -536,6 +561,85 @@ const resizeMultipleElements = (
   }
 };
 
+const rotateMultipleElements = (
+  elements: readonly NonDeletedExcalidrawElement[],
+  pointerX: number,
+  pointerY: number,
+  isRotateWithDiscreteAngle: boolean,
+  centerX: number,
+  centerY: number,
+  originalElements: readonly NonDeletedExcalidrawElement[],
+) => {
+  let centerAngle =
+    (5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
+  if (isRotateWithDiscreteAngle) {
+    centerAngle += SHIFT_LOCKING_ANGLE / 2;
+    centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
+  }
+  elements.forEach((element, index) => {
+    if (isLinearElement(element) && element.points.length === 2) {
+      // FIXME this is a bit tricky (how can we make this more readable?)
+      const originalElement = originalElements[index];
+      if (
+        !isLinearElement(originalElement) ||
+        originalElement.points.length !== 2
+      ) {
+        throw new Error("original element not compatible"); // should not happen
+      }
+      const [x1, y1, x2, y2] = getElementAbsoluteCoords(originalElement);
+      const cx = (x1 + x2) / 2;
+      const cy = (y1 + y2) / 2;
+      const [rotatedCX, rotatedCY] = rotate(
+        cx,
+        cy,
+        centerX,
+        centerY,
+        centerAngle,
+      );
+      const { points } = originalElement;
+      const [rotatedX, rotatedY] = rotate(
+        points[1][0],
+        points[1][1],
+        points[0][0],
+        points[0][1],
+        centerAngle,
+      );
+      mutateElement(element, {
+        x:
+          originalElement.x +
+          (rotatedCX - cx) +
+          ((originalElement.points[0][0] + originalElement.points[1][0]) / 2 -
+            (points[0][0] + rotatedX) / 2),
+        y:
+          originalElement.y +
+          (rotatedCY - cy) +
+          ((originalElement.points[0][1] + originalElement.points[1][1]) / 2 -
+            (points[0][1] + rotatedY) / 2),
+        points: [
+          [points[0][0], points[0][1]],
+          [rotatedX, rotatedY],
+        ],
+      });
+    } else {
+      const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+      const cx = (x1 + x2) / 2;
+      const cy = (y1 + y2) / 2;
+      const [rotatedCX, rotatedCY] = rotate(
+        cx,
+        cy,
+        centerX,
+        centerY,
+        centerAngle + originalElements[index].angle - element.angle,
+      );
+      mutateElement(element, {
+        x: element.x + (rotatedCX - cx),
+        y: element.y + (rotatedCY - cy),
+        angle: normalizeAngle(centerAngle + originalElements[index].angle),
+      });
+    }
+  });
+};
+
 export const getResizeOffsetXY = (
   resizeHandle: ResizeTestType,
   selectedElements: NonDeletedExcalidrawElement[],

+ 22 - 12
src/renderer/renderScene.ts

@@ -399,7 +399,7 @@ export const renderScene = (
         }
       });
       context.translate(-sceneState.scrollX, -sceneState.scrollY);
-    } else if (locallySelectedElements.length > 1) {
+    } else if (locallySelectedElements.length > 1 && !appState.isRotating) {
       const dashedLinePadding = 4 / sceneState.zoom;
       context.translate(sceneState.scrollX, sceneState.scrollY);
       context.fillStyle = oc.white;
@@ -432,17 +432,27 @@ export const renderScene = (
         if (handler !== undefined) {
           const lineWidth = context.lineWidth;
           context.lineWidth = 1 / sceneState.zoom;
-          strokeRectWithRotation(
-            context,
-            handler[0],
-            handler[1],
-            handler[2],
-            handler[3],
-            handler[0] + handler[2] / 2,
-            handler[1] + handler[3] / 2,
-            0,
-            true, // fill before stroke
-          );
+          if (key === "rotation") {
+            strokeCircle(
+              context,
+              handler[0],
+              handler[1],
+              handler[2],
+              handler[3],
+            );
+          } else {
+            strokeRectWithRotation(
+              context,
+              handler[0],
+              handler[1],
+              handler[2],
+              handler[3],
+              handler[0] + handler[2] / 2,
+              handler[1] + handler[3] / 2,
+              0,
+              true, // fill before stroke
+            );
+          }
           context.lineWidth = lineWidth;
         }
       });