Existing arrows now jump out

This commit is contained in:
Mark Tolmacs 2025-06-20 22:22:32 +02:00
parent 0dd76db5f0
commit 331790e9af
No known key found for this signature in database
2 changed files with 263 additions and 87 deletions

View File

@ -25,6 +25,7 @@ import {
import { import {
deconstructLinearOrFreeDrawElement, deconstructLinearOrFreeDrawElement,
hitElementItself,
isPathALoop, isPathALoop,
type Store, type Store,
} from "@excalidraw/element"; } from "@excalidraw/element";
@ -43,6 +44,7 @@ import type {
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import { import {
bindLinearElement,
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
getHoveredElementForBinding, getHoveredElementForBinding,
getOutlineAvoidingPoint, getOutlineAvoidingPoint,
@ -60,6 +62,7 @@ import { mutateElement } from "./mutateElement";
import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { import {
isArrowElement, isArrowElement,
isBindableElement,
isBindingElement, isBindingElement,
isElbowArrow, isElbowArrow,
isFixedPointBinding, isFixedPointBinding,
@ -87,6 +90,8 @@ import type {
FixedSegment, FixedSegment,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
PointsPositionUpdates, PointsPositionUpdates,
NonDeletedExcalidrawElement,
Ordered,
} from "./types"; } from "./types";
/** /**
@ -136,6 +141,7 @@ export class LinearElementEditor {
index: number | null; index: number | null;
added: boolean; added: boolean;
}; };
arrowOtherPoint?: GlobalPoint;
}>; }>;
/** whether you're dragging a point */ /** whether you're dragging a point */
@ -280,6 +286,7 @@ export class LinearElementEditor {
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
linearElementEditor: LinearElementEditor, linearElementEditor: LinearElementEditor,
thresholdCallback: (element: ExcalidrawElement) => number,
): Pick<AppState, keyof AppState> | null { ): Pick<AppState, keyof AppState> | null {
if (!linearElementEditor) { if (!linearElementEditor) {
return null; return null;
@ -288,6 +295,8 @@ export class LinearElementEditor {
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
let customLineAngle = linearElementEditor.customLineAngle; let customLineAngle = linearElementEditor.customLineAngle;
let arrowOtherPoint: GlobalPoint | undefined =
linearElementEditor.pointerDownState.arrowOtherPoint;
if (!element) { if (!element) {
return null; return null;
} }
@ -370,99 +379,39 @@ export class LinearElementEditor {
scenePointerY - linearElementEditor.pointerOffset.y, scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
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();
arrowOtherPoint = pointDraggingOtherEndpoint(
element,
elementsMap,
selectedPointsIndices,
scenePointerX,
scenePointerY,
linearElementEditor,
app.scene,
elements,
app.state.zoom,
thresholdCallback,
);
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
app.scene, app.scene,
new Map( pointDraggingUpdates(
selectedPointsIndices.map((pointIndex) => { selectedPointsIndices,
let newPointPosition: LocalPoint = deltaX,
pointIndex === lastClickedPoint deltaY,
? LinearElementEditor.createPointAt( elementsMap,
element, lastClickedPoint,
elementsMap, element,
scenePointerX - linearElementEditor.pointerOffset.x, scenePointerX,
scenePointerY - linearElementEditor.pointerOffset.y, scenePointerY,
event[KEYS.CTRL_OR_CMD] linearElementEditor,
? null event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
: app.getEffectiveGridSize(), elements,
) app.state.zoom,
: pointFrom(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
);
if (
pointIndex === 0 ||
pointIndex === element.points.length - 1
) {
const [, , , , cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
true,
);
let newGlobalPointPosition = pointRotateRads(
pointFrom<GlobalPoint>(
element.x + newPointPosition[0],
element.y + newPointPosition[1],
),
pointFrom<GlobalPoint>(cx, cy),
element.angle,
);
const hoveredElement = getHoveredElementForBinding(
{
x: newGlobalPointPosition[0],
y: newGlobalPointPosition[1],
},
app.scene.getNonDeletedElements(),
elementsMap,
app.state.zoom,
true,
isElbowArrow(element),
);
const otherBinding =
element[pointIndex === 0 ? "endBinding" : "startBinding"];
// Allow binding inside the element if both ends are inside
if (
isArrowElement(element) &&
!(
hoveredElement?.id === otherBinding?.elementId &&
hoveredElement != null
)
) {
newGlobalPointPosition = getOutlineAvoidingPoint(
element,
hoveredElement,
newGlobalPointPosition,
pointIndex,
elementsMap,
);
}
newPointPosition = LinearElementEditor.createPointAt(
element,
elementsMap,
newGlobalPointPosition[0] -
linearElementEditor.pointerOffset.x,
newGlobalPointPosition[1] -
linearElementEditor.pointerOffset.y,
null,
);
}
return [
pointIndex,
{
point: newPointPosition,
isDragging: pointIndex === lastClickedPoint,
},
];
}),
), ),
); );
} }
@ -541,6 +490,10 @@ export class LinearElementEditor {
: -1, : -1,
isDragging: true, isDragging: true,
customLineAngle, customLineAngle,
pointerDownState: {
...linearElementEditor.pointerDownState,
arrowOtherPoint,
},
}; };
return { return {
@ -671,6 +624,10 @@ export class LinearElementEditor {
isDragging: false, isDragging: false,
pointerOffset: { x: 0, y: 0 }, pointerOffset: { x: 0, y: 0 },
customLineAngle: null, customLineAngle: null,
pointerDownState: {
...editingLinearElement.pointerDownState,
arrowOtherPoint: undefined,
},
}; };
} }
@ -2043,3 +2000,221 @@ const normalizeSelectedPoints = (
nextPoints = nextPoints.sort((a, b) => a - b); nextPoints = nextPoints.sort((a, b) => a - b);
return nextPoints.length ? nextPoints : null; return nextPoints.length ? nextPoints : null;
}; };
const pointDraggingUpdates = (
selectedPointsIndices: readonly number[],
deltaX: number,
deltaY: number,
elementsMap: NonDeletedSceneElementsMap,
lastClickedPoint: number,
element: NonDeleted<ExcalidrawLinearElement>,
scenePointerX: number,
scenePointerY: number,
linearElementEditor: LinearElementEditor,
gridSize: NullableGridSize,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
zoom: AppState["zoom"],
): PointsPositionUpdates => {
return new Map(
selectedPointsIndices.map((pointIndex) => {
let newPointPosition: LocalPoint =
pointIndex === lastClickedPoint
? LinearElementEditor.createPointAt(
element,
elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
gridSize,
)
: pointFrom(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
);
if (pointIndex === 0 || pointIndex === element.points.length - 1) {
const [, , , , cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
true,
);
let newGlobalPointPosition = pointRotateRads(
pointFrom<GlobalPoint>(
element.x + newPointPosition[0],
element.y + newPointPosition[1],
),
pointFrom<GlobalPoint>(cx, cy),
element.angle,
);
const hoveredElement = getHoveredElementForBinding(
{
x: newGlobalPointPosition[0],
y: newGlobalPointPosition[1],
},
elements,
elementsMap,
zoom,
true,
isElbowArrow(element),
);
const otherBinding =
element[pointIndex === 0 ? "endBinding" : "startBinding"];
// Allow binding inside the element if both ends are inside
if (
isArrowElement(element) &&
!(
hoveredElement?.id === otherBinding?.elementId &&
hoveredElement != null
)
) {
newGlobalPointPosition = getOutlineAvoidingPoint(
element,
hoveredElement,
newGlobalPointPosition,
pointIndex,
elementsMap,
);
}
newPointPosition = LinearElementEditor.createPointAt(
element,
elementsMap,
newGlobalPointPosition[0] - linearElementEditor.pointerOffset.x,
newGlobalPointPosition[1] - linearElementEditor.pointerOffset.y,
null,
);
}
return [
pointIndex,
{
point: newPointPosition,
isDragging: pointIndex === lastClickedPoint,
},
];
}),
);
};
const pointDraggingOtherEndpoint = (
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: NonDeletedSceneElementsMap,
selectedPointsIndices: readonly number[],
scenePointerX: number,
scenePointerY: number,
linearElementEditor: LinearElementEditor,
scene: Scene,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
zoom: AppState["zoom"],
thresholdCallback: (element: ExcalidrawElement) => number,
) => {
let arrowOtherPoint = linearElementEditor.pointerDownState.arrowOtherPoint;
if (isArrowElement(element) && !isElbowArrow(element)) {
const startPointIsIncluded = selectedPointsIndices.includes(0);
const endPointIsIncluded = selectedPointsIndices.includes(
element.points.length - 1,
);
if (
// Make sure that not both of the endpoints are selected
(startPointIsIncluded || endPointIsIncluded) &&
startPointIsIncluded !== endPointIsIncluded
) {
const otherBinding =
element[startPointIsIncluded ? "endBinding" : "startBinding"];
if (
// The other end is bound
otherBinding
) {
const otherElement = elementsMap.get(otherBinding.elementId);
invariant(
isBindableElement(otherElement),
"Other element should exist in elementsMap at all times and be a bindable element",
);
let newOtherPointPosition;
// Only avoid shape if the start and end point is not inside
// the same element
if (
!hitElementItself({
point: pointFrom(scenePointerX, scenePointerY),
element: otherElement,
elementsMap,
threshold: thresholdCallback(otherElement),
})
) {
// If we don't have a restore point, that means we need to jump out
// of the element but first, create the restore point
if (!arrowOtherPoint) {
arrowOtherPoint = LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[
startPointIsIncluded ? element.points.length - 1 : 0
],
elementsMap,
);
}
// Find a snap point outside the element
const newOtherGlobalPoint = getOutlineAvoidingPoint(
element,
otherElement,
arrowOtherPoint,
startPointIsIncluded ? element.points.length - 1 : 0,
elementsMap,
);
newOtherPointPosition = LinearElementEditor.createPointAt(
element,
elementsMap,
newOtherGlobalPoint[0] - linearElementEditor.pointerOffset.x,
newOtherGlobalPoint[1] - linearElementEditor.pointerOffset.y,
null,
);
}
// Restore the saved point if we are back inside the element
else if (arrowOtherPoint) {
console.warn("RESTORE");
newOtherPointPosition = LinearElementEditor.createPointAt(
element,
elementsMap,
arrowOtherPoint[0] - linearElementEditor.pointerOffset.x,
arrowOtherPoint[1] - linearElementEditor.pointerOffset.y,
null,
);
arrowOtherPoint = undefined;
}
// Finally, move the other endpoint if needed
if (newOtherPointPosition) {
LinearElementEditor.movePoints(
element,
scene,
new Map([
[
startPointIsIncluded ? element.points.length - 1 : 0,
{
point: newOtherPointPosition,
},
],
]),
);
bindLinearElement(
element,
otherElement,
startPointIsIncluded ? "end" : "start",
scene,
);
}
}
}
}
return arrowOtherPoint;
};

View File

@ -8249,6 +8249,7 @@ class App extends React.Component<AppProps, AppState> {
pointerCoords.x, pointerCoords.x,
pointerCoords.y, pointerCoords.y,
linearElementEditor, linearElementEditor,
(element) => this.getElementHitThreshold(element),
); );
if (newState) { if (newState) {
pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.x = pointerCoords.x;