From 7f0aa5854a97a968c481f775821dda782977cc6b Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Fri, 14 Nov 2025 10:27:56 +0100 Subject: [PATCH] fix: Arrow angle --- packages/element/src/linearElementEditor.ts | 74 ++++++++++--------- .../tests/linearElementEditor.test.tsx | 63 +++++++++++++++- packages/excalidraw/data/restore.ts | 26 +------ 3 files changed, 100 insertions(+), 63 deletions(-) diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 07bab96e97..a1e1e5bb9c 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -2243,44 +2243,36 @@ const pointDraggingUpdates = ( } // Simulate the updated arrow for the bind point calculation - const originalStartGlobalPoint = - LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - 0, - elementsMap, - ); - const offsetStartGlobalPoint = startIsDragged - ? pointFrom( - originalStartGlobalPoint[0] + deltaX, - originalStartGlobalPoint[1] + deltaY, - ) - : originalStartGlobalPoint; - const offsetStartLocalPoint = LinearElementEditor.pointFromAbsoluteCoords( - element, - offsetStartGlobalPoint, - elementsMap, + const updatedPoints = element.points.map((p, idx) => { + const update = naiveDraggingPoints.get(idx); + return update ? update.point : p; + }); + + const offsetX = updatedPoints[0][0]; + const offsetY = updatedPoints[0][1]; + const normalizedPoints = updatedPoints.map((p) => + pointFrom(p[0] - offsetX, p[1] - offsetY), + ); + + const nextCoords = getElementPointsCoords(element, normalizedPoints); + const prevCoords = getElementPointsCoords(element, element.points); + const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2; + const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2; + const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2; + const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2; + const dX = prevCenterX - nextCenterX; + const dY = prevCenterY - nextCenterY; + const rotatedOffset = pointRotateRads( + pointFrom(offsetX, offsetY), + pointFrom(dX, dY), + element.angle, ); - const offsetEndLocalPoint = endIsDragged - ? pointFrom( - element.points[element.points.length - 1][0] + deltaX, - element.points[element.points.length - 1][1] + deltaY, - ) - : element.points[element.points.length - 1]; const nextArrow = { ...element, - points: [ - offsetStartLocalPoint, - ...element.points - .slice(1, -1) - .map((p) => - pointFrom( - p[0] + element.x - offsetStartGlobalPoint[0], - p[1] + element.y - offsetStartGlobalPoint[1], - ), - ), - offsetEndLocalPoint, - ], + points: normalizedPoints, + x: element.x + rotatedOffset[0], + y: element.y + rotatedOffset[1], startBinding: updates.startBinding === undefined ? element.startBinding @@ -2373,6 +2365,16 @@ const pointDraggingUpdates = ( ) || nextArrow.points[0] : nextArrow.points[0]; + const startOffset = pointFrom(offsetX, offsetY); + const startLocalPointAbsolute = pointFrom( + startLocalPoint[0] + startOffset[0], + startLocalPoint[1] + startOffset[1], + ); + const endLocalPointAbsolute = pointFrom( + endLocalPoint[0] + startOffset[0], + endLocalPoint[1] + startOffset[1], + ); + const endChanged = pointDistance( endLocalPoint, @@ -2403,9 +2405,9 @@ const pointDraggingUpdates = ( return [ idx, idx === 0 - ? { point: startLocalPoint, isDragging: true } + ? { point: startLocalPointAbsolute, isDragging: true } : idx === element.points.length - 1 - ? { point: endLocalPoint, isDragging: true } + ? { point: endLocalPointAbsolute, isDragging: true } : naiveDraggingPoints.get(idx)!, ]; }), diff --git a/packages/element/tests/linearElementEditor.test.tsx b/packages/element/tests/linearElementEditor.test.tsx index 5759c591dd..d97d57b2f0 100644 --- a/packages/element/tests/linearElementEditor.test.tsx +++ b/packages/element/tests/linearElementEditor.test.tsx @@ -1,4 +1,4 @@ -import { pointCenter, pointFrom } from "@excalidraw/math"; +import { pointCenter, pointFrom, pointRotateRads } from "@excalidraw/math"; import { act, queryByTestId, queryByText } from "@testing-library/react"; import { vi } from "vitest"; @@ -24,12 +24,13 @@ import { unmountComponent, } from "@excalidraw/excalidraw/tests/test-utils"; -import type { GlobalPoint, LocalPoint } from "@excalidraw/math"; +import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import { wrapText } from "../src"; import * as textElementUtils from "../src/textElement"; import { getBoundTextElementPosition, getBoundTextMaxWidth } from "../src"; import { LinearElementEditor } from "../src"; +import { elementCenterPoint } from "../src/bounds"; import { newArrowElement } from "../src"; import { @@ -59,7 +60,7 @@ describe("Test Linear Elements", () => { beforeEach(async () => { unmountComponent(); - localStorage.clear(); + //localStorage.clear(); renderInteractiveScene.mockClear(); renderStaticScene.mockClear(); reseed(7); @@ -954,6 +955,62 @@ describe("Test Linear Elements", () => { ] `); }); + + it("keeps rotated arrow start point aligned with pointer while dragging", () => { + const arrow = createThreePointerLinearElement("arrow"); + const angle = 1.2; + h.app.scene.mutateElement(arrow, { angle: angle as Radians }); + + const elementsMap = h.app.scene.getNonDeletedElementsMap(); + const center = elementCenterPoint(arrow, elementsMap); + const expectedStart = pointRotateRads( + pointFrom( + arrow.x + arrow.points[0][0], + arrow.y + arrow.points[0][1], + ), + center, + angle as Radians, + ); + const actualStart = LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + 0, + elementsMap, + ); + + const initialOffset = { + x: expectedStart[0] - actualStart[0], + y: expectedStart[1] - actualStart[1], + }; + expect(Math.hypot(initialOffset.x, initialOffset.y)).toBeGreaterThan(0); + + API.setSelectedElements([arrow]); + enterLineEditingMode(arrow, true); + + const dragOffset = { x: 25, y: -15 }; + const dragTarget = pointFrom( + expectedStart[0] + dragOffset.x, + expectedStart[1] + dragOffset.y, + ); + + mouse.downAt(expectedStart[0], expectedStart[1]); + mouse.moveTo(dragTarget[0], dragTarget[1]); + mouse.upAt(dragTarget[0], dragTarget[1]); + + const updatedMap = h.app.scene.getNonDeletedElementsMap(); + const movedStart = LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + 0, + updatedMap, + ); + + const finalOffset = { + x: dragTarget[0] - movedStart[0], + y: dragTarget[1] - movedStart[1], + }; + + expect(finalOffset.x).toBeCloseTo(initialOffset.x, 6); + expect(finalOffset.y).toBeCloseTo(initialOffset.y, 6); + }); }); describe("Test bound text element", () => { diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index de6027c58e..9dd19f1dab 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -393,35 +393,14 @@ export const restoreElement = ( ...getSizeFromPoints(points), }); case "arrow": { - const { - startArrowhead = null, - endArrowhead = "arrow", - angle = 0, - } = element; + const { startArrowhead = null, endArrowhead = "arrow" } = element; const x: number | undefined = element.x; const y: number | undefined = element.y; - let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one + const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one !Array.isArray(element.points) || element.points.length < 2 ? [pointFrom(0, 0), pointFrom(element.width, element.height)] : element.points; - if (angle !== 0) { - points = LinearElementEditor.getPointsGlobalCoordinates( - element, - elementsMap, - ).map((point) => - LinearElementEditor.pointFromAbsoluteCoords( - element as ExcalidrawArrowElement, - pointRotateRads( - point, - elementCenterPoint(element, elementsMap), - angle, - ), - elementsMap, - ), - ); - } - const base = { type: element.type, startBinding: repairBinding( @@ -443,7 +422,6 @@ export const restoreElement = ( y, elbowed: (element as ExcalidrawArrowElement).elbowed, ...getSizeFromPoints(points), - angle: 0 as Radians, }; // TODO: Separate arrow from linear element