Fix fixed angle orbiting

This commit is contained in:
Mark Tolmacs
2025-07-24 17:38:57 +02:00
parent 57e8734b3f
commit 83004e2c01
3 changed files with 122 additions and 60 deletions

View File

@ -15,7 +15,7 @@ import {
PRECISION, PRECISION,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LineSegment, LocalPoint, Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
@ -839,6 +839,7 @@ export const bindPointToSnapToElementOutline = (
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap, elementsMap: ElementsMap,
customIntersector?: LineSegment<GlobalPoint>,
): GlobalPoint => { ): GlobalPoint => {
const aabb = aabbForElement(bindableElement, elementsMap); const aabb = aabbForElement(bindableElement, elementsMap);
const localP = const localP =
@ -881,16 +882,18 @@ export const bindPointToSnapToElementOutline = (
isHorizontal ? center[0] : snapPoint[0], isHorizontal ? center[0] : snapPoint[0],
!isHorizontal ? center[1] : snapPoint[1], !isHorizontal ? center[1] : snapPoint[1],
); );
const intersector = lineSegment( const intersector =
otherPoint, customIntersector ??
pointFromVector( lineSegment(
vectorScale(
vectorNormalize(vectorFromPoint(snapPoint, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
otherPoint, otherPoint,
), pointFromVector(
); vectorScale(
vectorNormalize(vectorFromPoint(snapPoint, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
otherPoint,
),
);
intersection = intersectElementWithLineSegment( intersection = intersectElementWithLineSegment(
bindableElement, bindableElement,
elementsMap, elementsMap,
@ -898,9 +901,8 @@ export const bindPointToSnapToElementOutline = (
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
).sort(pointDistanceSq)[0]; ).sort(pointDistanceSq)[0];
} else { } else {
intersection = intersectElementWithLineSegment( const intersector =
bindableElement, customIntersector ??
elementsMap,
lineSegment( lineSegment(
adjacentPoint, adjacentPoint,
pointFromVector( pointFromVector(
@ -911,7 +913,11 @@ export const bindPointToSnapToElementOutline = (
), ),
adjacentPoint, adjacentPoint,
), ),
), );
intersection = intersectElementWithLineSegment(
bindableElement,
elementsMap,
intersector,
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
).sort( ).sort(
(g, h) => (g, h) =>
@ -936,6 +942,7 @@ export const getOutlineAvoidingPoint = (
coords: GlobalPoint, coords: GlobalPoint,
pointIndex: number, pointIndex: number,
elementsMap: ElementsMap, elementsMap: ElementsMap,
customIntersector?: LineSegment<GlobalPoint>,
): GlobalPoint => { ): GlobalPoint => {
if (hoveredElement) { if (hoveredElement) {
const newPoints = Array.from(element.points); const newPoints = Array.from(element.points);
@ -952,6 +959,7 @@ export const getOutlineAvoidingPoint = (
hoveredElement, hoveredElement,
pointIndex === 0 ? "start" : "end", pointIndex === 0 ? "start" : "end",
elementsMap, elementsMap,
customIntersector,
); );
} }

View File

@ -9,6 +9,7 @@ import {
vectorFromPoint, vectorFromPoint,
curveLength, curveLength,
curvePointAtLength, curvePointAtLength,
lineSegment,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape"; import { getCurvePathOps } from "@excalidraw/utils/shape";
@ -322,6 +323,8 @@ export class LinearElementEditor {
const draggingPoint = element.points[lastClickedPoint]; const draggingPoint = element.points[lastClickedPoint];
if (selectedPointsIndices && draggingPoint) { if (selectedPointsIndices && draggingPoint) {
const elements = app.scene.getNonDeletedElements();
if ( if (
shouldRotateWithDiscreteAngle(event) && shouldRotateWithDiscreteAngle(event) &&
selectedPointsIndices.length === 1 && selectedPointsIndices.length === 1 &&
@ -336,7 +339,6 @@ export class LinearElementEditor {
element.points[selectedIndex][1] - referencePoint[1], element.points[selectedIndex][1] - referencePoint[1],
element.points[selectedIndex][0] - referencePoint[0], element.points[selectedIndex][0] - referencePoint[0],
); );
const [width, height] = LinearElementEditor._getShiftLockedDelta( const [width, height] = LinearElementEditor._getShiftLockedDelta(
element, element,
elementsMap, elementsMap,
@ -345,22 +347,32 @@ export class LinearElementEditor {
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
customLineAngle, customLineAngle,
); );
const [x, y] = LinearElementEditor.getPointGlobalCoordinates(
element,
pointFrom<LocalPoint>(
width + referencePoint[0],
height + referencePoint[1],
),
elementsMap,
);
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
app.scene, app.scene,
new Map([ pointDraggingUpdates(
[ selectedPointsIndices,
selectedIndex, 0,
{ 0,
point: pointFrom( elementsMap,
width + referencePoint[0], lastClickedPoint,
height + referencePoint[1], element,
), x,
isDragging: selectedIndex === lastClickedPoint, y,
}, linearElementEditor,
], event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
]), elements,
app,
true,
),
); );
} else { } else {
const newDraggingPointPosition = LinearElementEditor.createPointAt( const newDraggingPointPosition = LinearElementEditor.createPointAt(
@ -372,7 +384,6 @@ export class LinearElementEditor {
); );
const deltaX = newDraggingPointPosition[0] - draggingPoint[0]; const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
const deltaY = newDraggingPointPosition[1] - draggingPoint[1]; const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
const elements = app.scene.getNonDeletedElements();
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
@ -995,7 +1006,6 @@ export class LinearElementEditor {
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) { if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
const lastCommittedPoint = points[points.length - 2]; const lastCommittedPoint = points[points.length - 2];
const [width, height] = LinearElementEditor._getShiftLockedDelta( const [width, height] = LinearElementEditor._getShiftLockedDelta(
element, element,
elementsMap, elementsMap,
@ -1937,6 +1947,7 @@ const pointDraggingUpdates = (
gridSize: NullableGridSize, gridSize: NullableGridSize,
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
app: AppClassProperties, app: AppClassProperties,
angleLocked?: boolean,
): PointsPositionUpdates => { ): PointsPositionUpdates => {
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap, true); const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap, true);
const hasMidPoints = const hasMidPoints =
@ -2001,12 +2012,29 @@ const pointDraggingUpdates = (
app.state.bindMode === "orbit" && app.state.bindMode === "orbit" &&
!otherPointInsideElement !otherPointInsideElement
) { ) {
let customIntersector;
if (angleLocked) {
const adjacentPointIndex =
pointIndex === 0 ? 1 : element.points.length - 2;
const globalAdjacentPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
adjacentPointIndex,
elementsMap,
);
customIntersector = lineSegment<GlobalPoint>(
globalAdjacentPoint,
newGlobalPointPosition,
);
}
newGlobalPointPosition = getOutlineAvoidingPoint( newGlobalPointPosition = getOutlineAvoidingPoint(
element, element,
hoveredElement, hoveredElement,
newGlobalPointPosition, newGlobalPointPosition,
pointIndex, pointIndex,
elementsMap, elementsMap,
customIntersector,
); );
} }

View File

@ -17,6 +17,7 @@ import {
vectorDot, vectorDot,
vectorNormalize, vectorNormalize,
pointsEqual, pointsEqual,
lineSegment,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { import {
@ -6132,11 +6133,29 @@ class App extends React.Component<AppProps, AppState> {
{ informMutation: false, isDragging: false }, { informMutation: false, isDragging: false },
); );
} else { } else {
let [gridX, gridY] = getGridPoint( const [lastCommittedX, lastCommittedY] =
multiElement?.lastCommittedPoint ?? [0, 0];
// Handle grid snapping
const [gridX, gridY] = getGridPoint(
scenePointerX, scenePointerX,
scenePointerY, scenePointerY,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
); );
let dxFromLastCommitted = gridX - rx - lastCommittedX;
let dyFromLastCommitted = gridY - ry - lastCommittedY;
if (shouldRotateWithDiscreteAngle(event)) {
({ width: dxFromLastCommitted, height: dyFromLastCommitted } =
getLockedLinearCursorAlignSize(
// actual coordinate of the last committed point
lastCommittedX + rx,
lastCommittedY + ry,
// cursor-grid coordinate
gridX,
gridY,
));
}
if ( if (
isArrowElement(multiElement) && isArrowElement(multiElement) &&
@ -6172,38 +6191,31 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<GlobalPoint>(scenePointerX, scenePointerY), pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
multiElement.points.length - 1, multiElement.points.length - 1,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
shouldRotateWithDiscreteAngle(event)
? lineSegment<GlobalPoint>(
otherPoint,
pointFrom<GlobalPoint>(
multiElement.x + lastCommittedX + dxFromLastCommitted,
multiElement.y + lastCommittedY + dyFromLastCommitted,
),
)
: undefined,
); );
gridX = avoidancePoint const x = avoidancePoint
? avoidancePoint[0] ? avoidancePoint[0]
: hoveredElement : hoveredElement
? scenePointerX ? scenePointerX
: gridX; : gridX;
gridY = avoidancePoint const y = avoidancePoint
? avoidancePoint[1] ? avoidancePoint[1]
: hoveredElement : hoveredElement
? scenePointerY ? scenePointerY
: gridY; : gridY;
dxFromLastCommitted = x - rx - lastCommittedX;
dyFromLastCommitted = y - ry - lastCommittedY;
} }
} }
const [lastCommittedX, lastCommittedY] =
multiElement?.lastCommittedPoint ?? [0, 0];
let dxFromLastCommitted = gridX - rx - lastCommittedX;
let dyFromLastCommitted = gridY - ry - lastCommittedY;
if (shouldRotateWithDiscreteAngle(event)) {
({ width: dxFromLastCommitted, height: dyFromLastCommitted } =
getLockedLinearCursorAlignSize(
// actual coordinate of the last committed point
lastCommittedX + rx,
lastCommittedY + ry,
// cursor-grid coordinate
gridX,
gridY,
));
}
if (isPathALoop(points, this.state.zoom.value)) { if (isPathALoop(points, this.state.zoom.value)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
} }
@ -9089,6 +9101,15 @@ class App extends React.Component<AppProps, AppState> {
let dx = gridX - newElement.x; let dx = gridX - newElement.x;
let dy = gridY - newElement.y; let dy = gridY - newElement.y;
if (shouldRotateWithDiscreteAngle(event) && points.length === 2) {
({ width: dx, height: dy } = getLockedLinearCursorAlignSize(
newElement.x,
newElement.y,
pointerCoords.x,
pointerCoords.y,
));
}
if ( if (
!isElbowArrow(newElement) && !isElbowArrow(newElement) &&
this.state.editingLinearElement && this.state.editingLinearElement &&
@ -9119,6 +9140,20 @@ class App extends React.Component<AppProps, AppState> {
: pointFrom(gridX, gridY), : pointFrom(gridX, gridY),
newElement.points.length - 1, newElement.points.length - 1,
elementsMap, elementsMap,
shouldRotateWithDiscreteAngle(event) &&
points.length === 2
? lineSegment(
LinearElementEditor.getPointGlobalCoordinates(
newElement,
points[0],
elementsMap,
),
pointFrom<GlobalPoint>(
newElement.x + dx,
newElement.y + dy,
),
)
: undefined,
) )
: pointFrom(gridX, gridY); : pointFrom(gridX, gridY);
@ -9176,15 +9211,6 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
if (shouldRotateWithDiscreteAngle(event) && points.length === 2) {
({ width: dx, height: dy } = getLockedLinearCursorAlignSize(
newElement.x,
newElement.y,
pointerCoords.x,
pointerCoords.y,
));
}
if (points.length === 1) { if (points.length === 1) {
this.scene.mutateElement( this.scene.mutateElement(
newElement, newElement,