浏览代码

fix: bound text rotation across alignments (#9914)

Co-authored-by: A-Mundanilkunathil <[email protected]>
Christopher Tangonan 1 周之前
父节点
当前提交
ae89608985

+ 19 - 3
packages/element/src/resizeElements.ts

@@ -35,6 +35,7 @@ import {
   getContainerElement,
   handleBindTextResize,
   getBoundTextMaxWidth,
+  computeBoundTextPosition,
 } from "./textElement";
 import {
   getMinTextElementWidth,
@@ -225,7 +226,16 @@ const rotateSingleElement = (
       scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
 
     if (textElement && !isArrowElement(element)) {
-      scene.mutateElement(textElement, { angle });
+      const { x, y } = computeBoundTextPosition(
+        element,
+        textElement,
+        scene.getNonDeletedElementsMap(),
+      );
+      scene.mutateElement(textElement, {
+        angle,
+        x,
+        y,
+      });
     }
   }
 };
@@ -416,9 +426,15 @@ const rotateMultipleElements = (
 
       const boundText = getBoundTextElement(element, elementsMap);
       if (boundText && !isArrowElement(element)) {
+        const { x, y } = computeBoundTextPosition(
+          element,
+          boundText,
+          elementsMap,
+        );
+
         scene.mutateElement(boundText, {
-          x: boundText.x + (rotatedCX - cx),
-          y: boundText.y + (rotatedCY - cy),
+          x,
+          y,
           angle: normalizeRadians((centerAngle + origAngle) as Radians),
         });
       }

+ 22 - 2
packages/element/src/textElement.ts

@@ -10,12 +10,12 @@ import {
   invariant,
 } from "@excalidraw/common";
 
+import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
+
 import type { AppState } from "@excalidraw/excalidraw/types";
 
 import type { ExtractSetType } from "@excalidraw/common/utility-types";
 
-import type { Radians } from "@excalidraw/math";
-
 import {
   resetOriginalContainerCache,
   updateOriginalContainerCache,
@@ -254,6 +254,26 @@ export const computeBoundTextPosition = (
     x =
       containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
   }
+  const angle = (container.angle ?? 0) as Radians;
+
+  if (angle !== 0) {
+    const contentCenter = pointFrom(
+      containerCoords.x + maxContainerWidth / 2,
+      containerCoords.y + maxContainerHeight / 2,
+    );
+    const textCenter = pointFrom(
+      x + boundTextElement.width / 2,
+      y + boundTextElement.height / 2,
+    );
+
+    const [rx, ry] = pointRotateRads(textCenter, contentCenter, angle);
+
+    return {
+      x: rx - boundTextElement.width / 2,
+      y: ry - boundTextElement.height / 2,
+    };
+  }
+
   return { x, y };
 };
 

+ 171 - 1
packages/element/tests/textElement.test.ts

@@ -1,13 +1,14 @@
 import { getLineHeight } from "@excalidraw/common";
 import { API } from "@excalidraw/excalidraw/tests/helpers/api";
 
-import { FONT_FAMILY } from "@excalidraw/common";
+import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
 
 import {
   computeContainerDimensionForBoundText,
   getContainerCoords,
   getBoundTextMaxWidth,
   getBoundTextMaxHeight,
+  computeBoundTextPosition,
 } from "../src/textElement";
 import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
 
@@ -207,3 +208,172 @@ describe("Test getDefaultLineHeight", () => {
     expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
   });
 });
+
+describe("Test computeBoundTextPosition", () => {
+  const createMockElementsMap = () => new Map();
+
+  // Helper function to create rectangle test case with 90-degree rotation
+  const createRotatedRectangleTestCase = (
+    textAlign: string,
+    verticalAlign: string,
+  ) => {
+    const container = API.createElement({
+      type: "rectangle",
+      x: 100,
+      y: 100,
+      width: 200,
+      height: 100,
+      angle: (Math.PI / 2) as any, // 90 degrees
+    });
+
+    const boundTextElement = API.createElement({
+      type: "text",
+      width: 80,
+      height: 40,
+      text: "hello darkness my old friend",
+      textAlign: textAlign as any,
+      verticalAlign: verticalAlign as any,
+      containerId: container.id,
+    }) as ExcalidrawTextElementWithContainer;
+
+    const elementsMap = createMockElementsMap();
+
+    return { container, boundTextElement, elementsMap };
+  };
+
+  describe("90-degree rotation with all alignment combinations", () => {
+    // Test all 9 combinations of horizontal (left, center, right) and vertical (top, middle, bottom) alignment
+
+    it("should position text with LEFT + TOP alignment at 90-degree rotation", () => {
+      const { container, boundTextElement, elementsMap } =
+        createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.TOP);
+
+      const result = computeBoundTextPosition(
+        container,
+        boundTextElement,
+        elementsMap,
+      );
+
+      expect(result.x).toBeCloseTo(185, 1);
+      expect(result.y).toBeCloseTo(75, 1);
+    });
+
+    it("should position text with LEFT + MIDDLE alignment at 90-degree rotation", () => {
+      const { container, boundTextElement, elementsMap } =
+        createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.MIDDLE);
+
+      const result = computeBoundTextPosition(
+        container,
+        boundTextElement,
+        elementsMap,
+      );
+
+      expect(result.x).toBeCloseTo(160, 1);
+      expect(result.y).toBeCloseTo(75, 1);
+    });
+
+    it("should position text with LEFT + BOTTOM alignment at 90-degree rotation", () => {
+      const { container, boundTextElement, elementsMap } =
+        createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.BOTTOM);
+
+      const result = computeBoundTextPosition(
+        container,
+        boundTextElement,
+        elementsMap,
+      );
+
+      expect(result.x).toBeCloseTo(135, 1);
+      expect(result.y).toBeCloseTo(75, 1);
+    });
+
+    it("should position text with CENTER + TOP alignment at 90-degree rotation", () => {
+      const { container, boundTextElement, elementsMap } =
+        createRotatedRectangleTestCase(TEXT_ALIGN.CENTER, VERTICAL_ALIGN.TOP);
+
+      const result = computeBoundTextPosition(
+        container,
+        boundTextElement,
+        elementsMap,
+      );
+
+      expect(result.x).toBeCloseTo(185, 1);
+      expect(result.y).toBeCloseTo(130, 1);
+    });
+
+    it("should position text with CENTER + MIDDLE alignment at 90-degree rotation", () => {
+      const { container, boundTextElement, elementsMap } =
+        createRotatedRectangleTestCase(
+          TEXT_ALIGN.CENTER,
+          VERTICAL_ALIGN.MIDDLE,
+        );
+
+      const result = computeBoundTextPosition(
+        container,
+        boundTextElement,
+        elementsMap,
+      );
+
+      expect(result.x).toBeCloseTo(160, 1);
+      expect(result.y).toBeCloseTo(130, 1);
+    });
+
+    it("should position text with CENTER + BOTTOM alignment at 90-degree rotation", () => {
+      const { container, boundTextElement, elementsMap } =
+        createRotatedRectangleTestCase(
+          TEXT_ALIGN.CENTER,
+          VERTICAL_ALIGN.BOTTOM,
+        );
+
+      const result = computeBoundTextPosition(
+        container,
+        boundTextElement,
+        elementsMap,
+      );
+
+      expect(result.x).toBeCloseTo(135, 1);
+      expect(result.y).toBeCloseTo(130, 1);
+    });
+
+    it("should position text with RIGHT + TOP alignment at 90-degree rotation", () => {
+      const { container, boundTextElement, elementsMap } =
+        createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.TOP);
+
+      const result = computeBoundTextPosition(
+        container,
+        boundTextElement,
+        elementsMap,
+      );
+
+      expect(result.x).toBeCloseTo(185, 1);
+      expect(result.y).toBeCloseTo(185, 1);
+    });
+
+    it("should position text with RIGHT + MIDDLE alignment at 90-degree rotation", () => {
+      const { container, boundTextElement, elementsMap } =
+        createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.MIDDLE);
+
+      const result = computeBoundTextPosition(
+        container,
+        boundTextElement,
+        elementsMap,
+      );
+
+      expect(result.x).toBeCloseTo(160, 1);
+      expect(result.y).toBeCloseTo(185, 1);
+    });
+
+    it("should position text with RIGHT + BOTTOM alignment at 90-degree rotation", () => {
+      const { container, boundTextElement, elementsMap } =
+        createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.BOTTOM);
+
+      const result = computeBoundTextPosition(
+        container,
+        boundTextElement,
+        elementsMap,
+      );
+
+      expect(result.x).toBeCloseTo(135, 1);
+      expect(result.y).toBeCloseTo(185, 1);
+    });
+  });
+});

+ 2 - 2
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap

@@ -4886,8 +4886,8 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "version": 6,
   "verticalAlign": "top",
   "width": 80,
-  "x": 205,
-  "y": 205,
+  "x": "241.29526",
+  "y": "247.59241",
 }
 `;
 

+ 4 - 4
packages/excalidraw/tests/history.test.tsx

@@ -4354,8 +4354,8 @@ describe("history", () => {
           expect.objectContaining({
             ...textProps,
             // text element got redrawn!
-            x: 205,
-            y: 205,
+            x: 241.295259647664,
+            y: 247.59240920619527,
             angle: 90,
             id: text.id,
             containerId: container.id,
@@ -4398,8 +4398,8 @@ describe("history", () => {
           }),
           expect.objectContaining({
             ...textProps,
-            x: 205,
-            y: 205,
+            x: 241.295259647664,
+            y: 247.59240920619527,
             angle: 90,
             id: text.id,
             containerId: container.id,

+ 2 - 1
packages/excalidraw/wysiwyg/textWysiwyg.tsx

@@ -215,11 +215,12 @@ export const textWysiwyg = ({
           );
           app.scene.mutateElement(container, { height: targetContainerHeight });
         } else {
-          const { y } = computeBoundTextPosition(
+          const { x, y } = computeBoundTextPosition(
             container,
             updatedTextElement as ExcalidrawTextElementWithContainer,
             elementsMap,
           );
+          coordX = x;
           coordY = y;
         }
       }