From 864353be5ff8b15489a63628c2708e453c83c642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Tue, 27 May 2025 12:39:45 +0200 Subject: [PATCH] feat: Try to preserve line angle on SHIFT+drag (#9570) --- packages/element/src/linearElementEditor.ts | 14 ++++++ packages/element/src/sizeHelpers.ts | 41 +++++++++++++-- .../tests/linearElementEditor.test.tsx | 50 +++++++++++++++++++ .../regressionTests.test.tsx.snap | 4 ++ packages/math/src/angle.ts | 32 ++++++++++++ 5 files changed, 138 insertions(+), 3 deletions(-) diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index e282535c43..447d1e3682 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -149,6 +149,7 @@ export class LinearElementEditor { public readonly hoverPointIndex: number; public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly elbowed: boolean; + public readonly customLineAngle: number | null; constructor( element: NonDeleted, @@ -186,6 +187,7 @@ export class LinearElementEditor { this.hoverPointIndex = -1; this.segmentMidPointHoveredCoords = null; this.elbowed = isElbowArrow(element) && element.elbowed; + this.customLineAngle = null; } // --------------------------------------------------------------------------- @@ -289,6 +291,7 @@ export class LinearElementEditor { const { elementId } = linearElementEditor; const elementsMap = scene.getNonDeletedElementsMap(); const element = LinearElementEditor.getElement(elementId, elementsMap); + let customLineAngle = linearElementEditor.customLineAngle; if (!element) { return null; } @@ -329,6 +332,12 @@ export class LinearElementEditor { const selectedIndex = selectedPointsIndices[0]; const referencePoint = element.points[selectedIndex === 0 ? 1 : selectedIndex - 1]; + customLineAngle = + linearElementEditor.customLineAngle ?? + Math.atan2( + element.points[selectedIndex][1] - referencePoint[1], + element.points[selectedIndex][0] - referencePoint[0], + ); const [width, height] = LinearElementEditor._getShiftLockedDelta( element, @@ -336,6 +345,7 @@ export class LinearElementEditor { referencePoint, pointFrom(scenePointerX, scenePointerY), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + customLineAngle, ); LinearElementEditor.movePoints( @@ -457,6 +467,7 @@ export class LinearElementEditor { ? lastClickedPoint : -1, isDragging: true, + customLineAngle, }; } @@ -574,6 +585,7 @@ export class LinearElementEditor { : selectedPointsIndices, isDragging: false, pointerOffset: { x: 0, y: 0 }, + customLineAngle: null, }; } @@ -1595,6 +1607,7 @@ export class LinearElementEditor { referencePoint: LocalPoint, scenePointer: GlobalPoint, gridSize: NullableGridSize, + customLineAngle?: number, ) { const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates( element, @@ -1620,6 +1633,7 @@ export class LinearElementEditor { referencePointCoords[1], gridX, gridY, + customLineAngle, ); return pointRotateRads( diff --git a/packages/element/src/sizeHelpers.ts b/packages/element/src/sizeHelpers.ts index a8339b49ff..ae973d90e7 100644 --- a/packages/element/src/sizeHelpers.ts +++ b/packages/element/src/sizeHelpers.ts @@ -2,6 +2,12 @@ import { SHIFT_LOCKING_ANGLE, viewportCoordsToSceneCoords, } from "@excalidraw/common"; +import { + normalizeRadians, + radiansBetweenAngles, + radiansDifference, + type Radians, +} from "@excalidraw/math"; import { pointsEqual } from "@excalidraw/math"; @@ -152,13 +158,42 @@ export const getLockedLinearCursorAlignSize = ( originY: number, x: number, y: number, + customAngle?: number, ) => { let width = x - originX; let height = y - originY; - const lockedAngle = - Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) * - SHIFT_LOCKING_ANGLE; + const angle = Math.atan2(height, width) as Radians; + let lockedAngle = (Math.round(angle / SHIFT_LOCKING_ANGLE) * + SHIFT_LOCKING_ANGLE) as Radians; + + if (customAngle) { + // If custom angle is provided, we check if the angle is close to the + // custom angle, snap to that if close engough, otherwise snap to the + // higher or lower angle depending on the current angle vs custom angle. + const lower = (Math.floor(customAngle / SHIFT_LOCKING_ANGLE) * + SHIFT_LOCKING_ANGLE) as Radians; + if ( + radiansBetweenAngles( + angle, + lower, + (lower + SHIFT_LOCKING_ANGLE) as Radians, + ) + ) { + if ( + radiansDifference(angle, customAngle as Radians) < + SHIFT_LOCKING_ANGLE / 6 + ) { + lockedAngle = customAngle as Radians; + } else if ( + normalizeRadians(angle) > normalizeRadians(customAngle as Radians) + ) { + lockedAngle = (lower + SHIFT_LOCKING_ANGLE) as Radians; + } else { + lockedAngle = lower; + } + } + } if (lockedAngle === 0) { height = 0; diff --git a/packages/element/tests/linearElementEditor.test.tsx b/packages/element/tests/linearElementEditor.test.tsx index 85987428e7..1edbce25b1 100644 --- a/packages/element/tests/linearElementEditor.test.tsx +++ b/packages/element/tests/linearElementEditor.test.tsx @@ -1411,5 +1411,55 @@ describe("Test Linear Elements", () => { expect(line.points[line.points.length - 1][0]).toBe(20); expect(line.points[line.points.length - 1][1]).toBe(-20); }); + + it("should preserve original angle when dragging endpoint with SHIFT key", () => { + createTwoPointerLinearElement("line"); + const line = h.elements[0] as ExcalidrawLinearElement; + enterLineEditingMode(line); + + const elementsMap = arrayToMap(h.elements); + const points = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); + + // Calculate original angle between first and last point + const originalAngle = Math.atan2( + points[1][1] - points[0][1], + points[1][0] - points[0][0], + ); + + // Drag the second point (endpoint) with SHIFT key pressed + const startPoint = pointFrom(points[1][0], points[1][1]); + const endPoint = pointFrom( + startPoint[0] + 4, + startPoint[1] + 4, + ); + + // Perform drag with SHIFT key modifier + Keyboard.withModifierKeys({ shift: true }, () => { + mouse.downAt(startPoint[0], startPoint[1]); + mouse.moveTo(endPoint[0], endPoint[1]); + mouse.upAt(endPoint[0], endPoint[1]); + }); + + // Get updated points after drag + const updatedPoints = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); + + // Calculate new angle + const newAngle = Math.atan2( + updatedPoints[1][1] - updatedPoints[0][1], + updatedPoints[1][0] - updatedPoints[0][0], + ); + + // The angle should be preserved (within a small tolerance for floating point precision) + const angleDifference = Math.abs(newAngle - originalAngle); + const tolerance = 0.01; // Small tolerance for floating point precision + + expect(angleDifference).toBeLessThan(tolerance); + }); }); }); diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index c9013ddcc7..a81ed30352 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -8630,6 +8630,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, "selectedLinearElement": LinearElementEditor { + "customLineAngle": null, "elbowed": false, "elementId": "id0", "endBindingElement": "keep", @@ -8853,6 +8854,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, "selectedLinearElement": LinearElementEditor { + "customLineAngle": null, "elbowed": false, "elementId": "id0", "endBindingElement": "keep", @@ -9270,6 +9272,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, "selectedLinearElement": LinearElementEditor { + "customLineAngle": null, "elbowed": false, "elementId": "id0", "endBindingElement": "keep", @@ -9673,6 +9676,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, "selectedLinearElement": LinearElementEditor { + "customLineAngle": null, "elbowed": false, "elementId": "id0", "endBindingElement": "keep", diff --git a/packages/math/src/angle.ts b/packages/math/src/angle.ts index 353dc5dad6..e500b75152 100644 --- a/packages/math/src/angle.ts +++ b/packages/math/src/angle.ts @@ -49,3 +49,35 @@ export function radiansToDegrees(degrees: Radians): Degrees { export function isRightAngleRads(rads: Radians): boolean { return Math.abs(Math.sin(2 * rads)) < PRECISION; } + +export function radiansBetweenAngles( + a: Radians, + min: Radians, + max: Radians, +): boolean { + a = normalizeRadians(a); + min = normalizeRadians(min); + max = normalizeRadians(max); + + if (min < max) { + return a >= min && a <= max; + } + + // The range wraps around the 0 angle + return a >= min || a <= max; +} + +export function radiansDifference(a: Radians, b: Radians): Radians { + a = normalizeRadians(a); + b = normalizeRadians(b); + + let diff = a - b; + + if (diff < -Math.PI) { + diff = (diff + 2 * Math.PI) as Radians; + } else if (diff > Math.PI) { + diff = (diff - 2 * Math.PI) as Radians; + } + + return Math.abs(diff) as Radians; +}