From ae89608985dfe5975b5fa0103d5aec64cb4fb83f Mon Sep 17 00:00:00 2001 From: Christopher Tangonan <161169629+cTangonan123@users.noreply.github.com> Date: Fri, 29 Aug 2025 03:31:23 -0700 Subject: [PATCH] fix: bound text rotation across alignments (#9914) Co-authored-by: A-Mundanilkunathil --- packages/element/src/resizeElements.ts | 22 ++- packages/element/src/textElement.ts | 24 ++- packages/element/tests/textElement.test.ts | 172 +++++++++++++++++- .../tests/__snapshots__/history.test.tsx.snap | 4 +- packages/excalidraw/tests/history.test.tsx | 8 +- packages/excalidraw/wysiwyg/textWysiwyg.tsx | 3 +- 6 files changed, 220 insertions(+), 13 deletions(-) diff --git a/packages/element/src/resizeElements.ts b/packages/element/src/resizeElements.ts index acb72b299b..8cfd807855 100644 --- a/packages/element/src/resizeElements.ts +++ b/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(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), }); } diff --git a/packages/element/src/textElement.ts b/packages/element/src/textElement.ts index 31347db240..523a8b8804 100644 --- a/packages/element/src/textElement.ts +++ b/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 }; }; diff --git a/packages/element/tests/textElement.test.ts b/packages/element/tests/textElement.test.ts index 5c10681a70..986854b985 100644 --- a/packages/element/tests/textElement.test.ts +++ b/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); + }); + }); +}); diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 2f9e04d562..c31b9ea7ce 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/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", } `; diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index 9ef8198569..47d87ce6d0 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/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, diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.tsx index 2255d8a5a2..73f3d7171b 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.tsx +++ b/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; } }