fix: Arrow angle

This commit is contained in:
Mark Tolmacs
2025-11-14 10:27:56 +01:00
parent f44198629c
commit 7f0aa5854a
3 changed files with 100 additions and 63 deletions

View File

@@ -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<GlobalPoint>(
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<LocalPoint>(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<LocalPoint>(
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<LocalPoint>(
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<LocalPoint>(offsetX, offsetY);
const startLocalPointAbsolute = pointFrom<LocalPoint>(
startLocalPoint[0] + startOffset[0],
startLocalPoint[1] + startOffset[1],
);
const endLocalPointAbsolute = pointFrom<LocalPoint>(
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)!,
];
}),

View File

@@ -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<GlobalPoint>(
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<GlobalPoint>(
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", () => {

View File

@@ -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