fix: Restore arrow start point when self binding

This commit is contained in:
Mark Tolmacs
2025-11-08 15:45:43 +01:00
parent d93a6f09fe
commit 3ac0a3c3ef
3 changed files with 134 additions and 114 deletions

View File

@@ -665,41 +665,46 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
otherBinding.elementId, otherBinding.elementId,
) as NonDeleted<ExcalidrawBindableElement>) ) as NonDeleted<ExcalidrawBindableElement>)
: undefined; : undefined;
const otherFocusPoint = // const otherFocusPoint =
otherBinding && // otherBinding &&
otherBindableElement && // otherBindableElement &&
getGlobalFixedPointForBindableElement( // getGlobalFixedPointForBindableElement(
otherBinding.fixedPoint, // otherBinding.fixedPoint,
otherBindableElement, // otherBindableElement,
elementsMap, // elementsMap,
); // );
const otherPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( // const otherPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow, // arrow,
startDragged ? -1 : 0, // startDragged ? -1 : 0,
elementsMap, // elementsMap,
); // );
const otherFocusPointIsInElement = // const otherFocusPointIsInElement =
otherBindableElement && // otherBindableElement &&
otherFocusPoint && // otherFocusPoint &&
isPointInElement(otherFocusPoint, otherBindableElement, elementsMap); // isPointInElement(otherFocusPoint, otherBindableElement, elementsMap);
// Handle outside-outside binding with the same element // Handle outside-outside binding to the same element
if ( if (otherBinding && otherBinding.elementId === hit?.id) {
otherBinding &&
otherBinding.elementId === hit?.id &&
!pointInElement &&
!otherFocusPointIsInElement
) {
const [startFixedPoint, endFixedPoint] = getGlobalFixedPoints( const [startFixedPoint, endFixedPoint] = getGlobalFixedPoints(
arrow, arrow,
elementsMap, elementsMap,
); );
invariant(
!opts?.newArrow || appState.selectedLinearElement?.initialState.origin,
"appState.selectedLinearElement.initialState.origin must be defined for new arrows",
);
return { return {
start: { start: {
mode: "inside", mode: "inside",
element: hit, element: hit,
focusPoint: startDragged ? globalPoint : startFixedPoint, focusPoint: startDragged
? globalPoint
: // NOTE: Can only affect the start point because new arrows always drag the end point
opts?.newArrow
? appState.selectedLinearElement!.initialState.origin!
: startFixedPoint,
}, },
end: { end: {
mode: "inside", mode: "inside",
@@ -709,8 +714,31 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
}; };
} }
// Handle special alt key case to inside bind no matter what
if (opts?.altKey) {
return {
start:
startDragged && hit
? {
mode: "inside",
element: hit,
focusPoint: globalPoint,
}
: start,
end:
endDragged && hit
? {
mode: "inside",
element: hit,
focusPoint: globalPoint,
}
: end,
};
}
// Handle normal cases
const current: BindingStrategy = hit const current: BindingStrategy = hit
? pointInElement || opts?.altKey ? pointInElement
? { ? {
mode: "inside", mode: "inside",
element: hit, element: hit,
@@ -731,27 +759,13 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
: { mode: null }; : { mode: null };
const other: BindingStrategy = const other: BindingStrategy =
!opts?.altKey && opts?.newArrow && otherBindableElement otherBindableElement &&
? otherBindableElement.id === hit?.id appState.selectedLinearElement?.initialState.altFocusPoint
? { ? {
mode: "inside", mode: "orbit",
element: otherBindableElement, element: otherBindableElement,
focusPoint: otherPoint, focusPoint: appState.selectedLinearElement.initialState.altFocusPoint,
} }
: {
mode: "orbit",
element: otherBindableElement,
focusPoint:
otherFocusPointIsInElement && !opts?.initialBinding
? otherFocusPoint
: projectFixedPointOntoDiagonal(
arrow,
otherPoint,
otherBindableElement,
startDragged ? "end" : "start",
elementsMap,
) || otherPoint,
}
: { mode: undefined }; : { mode: undefined };
return { return {
@@ -2128,7 +2142,7 @@ export class BindableElement {
} }
export const getGlobalFixedPointForBindableElement = ( export const getGlobalFixedPointForBindableElement = (
fixedPointRatio: [number, number], fixedPointRatio: FixedPoint,
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): GlobalPoint => { ): GlobalPoint => {

View File

@@ -29,6 +29,7 @@ import {
getHoveredElementForBinding, getHoveredElementForBinding,
isPathALoop, isPathALoop,
moveArrowAboveBindable, moveArrowAboveBindable,
projectFixedPointOntoDiagonal,
type Store, type Store,
} from "@excalidraw/element"; } from "@excalidraw/element";
@@ -139,6 +140,7 @@ export class LinearElementEditor {
added: boolean; added: boolean;
}; };
arrowStartIsInside: boolean; arrowStartIsInside: boolean;
altFocusPoint: Readonly<GlobalPoint> | null;
}>; }>;
/** whether you're dragging a point */ /** whether you're dragging a point */
@@ -189,6 +191,7 @@ export class LinearElementEditor {
added: false, added: false,
}, },
arrowStartIsInside: false, arrowStartIsInside: false,
altFocusPoint: null,
}; };
this.hoverPointIndex = -1; this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null; this.segmentMidPointHoveredCoords = null;
@@ -395,9 +398,28 @@ export class LinearElementEditor {
return null; return null;
} }
const startBindingElement =
isBindingElement(element) &&
element.startBinding &&
(elementsMap.get(
element.startBinding.elementId,
) as ExcalidrawBindableElement | null);
const newLinearElementEditor = { const newLinearElementEditor = {
...linearElementEditor, ...linearElementEditor,
customLineAngle, customLineAngle,
initialState: {
...linearElementEditor.initialState,
altFocusPoint:
!linearElementEditor.initialState.altFocusPoint && startBindingElement
? projectFixedPointOntoDiagonal(
element,
pointFrom<GlobalPoint>(element.x, element.y),
startBindingElement,
"start",
elementsMap,
)
: linearElementEditor.initialState.altFocusPoint,
},
}; };
return { return {
@@ -582,6 +604,12 @@ export class LinearElementEditor {
: null; : null;
const newHoverPointIndex = newLastClickedPoint; const newHoverPointIndex = newLastClickedPoint;
const startBindingElement =
isBindingElement(element) &&
element.startBinding &&
(elementsMap.get(
element.startBinding.elementId,
) as ExcalidrawBindableElement | null);
const newLinearElementEditor = { const newLinearElementEditor = {
...linearElementEditor, ...linearElementEditor,
@@ -589,6 +617,16 @@ export class LinearElementEditor {
initialState: { initialState: {
...linearElementEditor.initialState, ...linearElementEditor.initialState,
lastClickedPoint: newLastClickedPoint, lastClickedPoint: newLastClickedPoint,
altFocusPoint:
!linearElementEditor.initialState.altFocusPoint && startBindingElement
? projectFixedPointOntoDiagonal(
element,
pointFrom<GlobalPoint>(element.x, element.y),
startBindingElement,
"start",
elementsMap,
)
: linearElementEditor.initialState.altFocusPoint,
}, },
segmentMidPointHoveredCoords: newSelectedMidPointHoveredCoords, segmentMidPointHoveredCoords: newSelectedMidPointHoveredCoords,
hoverPointIndex: newHoverPointIndex, hoverPointIndex: newHoverPointIndex,
@@ -959,6 +997,7 @@ export class LinearElementEditor {
appState, appState,
elementsMap, elementsMap,
); );
const point = pointFrom<GlobalPoint>(scenePointer.x, scenePointer.y);
let segmentMidpointIndex = null; let segmentMidpointIndex = null;
if (segmentMidpoint) { if (segmentMidpoint) {
@@ -990,7 +1029,7 @@ export class LinearElementEditor {
initialState: { initialState: {
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices, prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
lastClickedPoint: -1, lastClickedPoint: -1,
origin: pointFrom<GlobalPoint>(scenePointer.x, scenePointer.y), origin: point,
segmentMidpoint: { segmentMidpoint: {
value: segmentMidpoint, value: segmentMidpoint,
index: segmentMidpointIndex, index: segmentMidpointIndex,
@@ -999,6 +1038,7 @@ export class LinearElementEditor {
arrowStartIsInside: arrowStartIsInside:
!!app.state.newElement && !!app.state.newElement &&
(app.state.bindMode === "inside" || app.state.bindMode === "skip"), (app.state.bindMode === "inside" || app.state.bindMode === "skip"),
altFocusPoint: null,
}, },
selectedPointsIndices: [element.points.length - 1], selectedPointsIndices: [element.points.length - 1],
lastUncommittedPoint: null, lastUncommittedPoint: null,
@@ -1051,7 +1091,7 @@ export class LinearElementEditor {
initialState: { initialState: {
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices, prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
lastClickedPoint: clickedPointIndex, lastClickedPoint: clickedPointIndex,
origin: pointFrom<GlobalPoint>(scenePointer.x, scenePointer.y), origin: point,
segmentMidpoint: { segmentMidpoint: {
value: segmentMidpoint, value: segmentMidpoint,
index: segmentMidpointIndex, index: segmentMidpointIndex,
@@ -1060,6 +1100,7 @@ export class LinearElementEditor {
arrowStartIsInside: arrowStartIsInside:
!!app.state.newElement && !!app.state.newElement &&
(app.state.bindMode === "inside" || app.state.bindMode === "skip"), (app.state.bindMode === "inside" || app.state.bindMode === "skip"),
altFocusPoint: null,
}, },
selectedPointsIndices: nextSelectedPointsIndices, selectedPointsIndices: nextSelectedPointsIndices,
pointerOffset: targetPoint pointerOffset: targetPoint
@@ -2280,22 +2321,23 @@ const pointDraggingUpdates = (
)! as ExcalidrawBindableElement) )! as ExcalidrawBindableElement)
: null; : null;
const startLocalPoint = endIsDraggingOverStartElement const startLocalPoint =
? nextArrow.points[0] endIsDraggingOverStartElement && getFeatureFlag("COMPLEX_BINDINGS")
: startIsDraggingOverEndElement && ? nextArrow.points[0]
app.state.bindMode !== "inside" && : startIsDraggingOverEndElement &&
getFeatureFlag("COMPLEX_BINDINGS") app.state.bindMode !== "inside" &&
? nextArrow.points[nextArrow.points.length - 1] getFeatureFlag("COMPLEX_BINDINGS")
: startBindable ? nextArrow.points[nextArrow.points.length - 1]
? updateBoundPoint( : startBindable
nextArrow, ? updateBoundPoint(
"startBinding", nextArrow,
nextArrow.startBinding, "startBinding",
startBindable, nextArrow.startBinding,
elementsMap, startBindable,
customIntersector, elementsMap,
) || nextArrow.points[0] customIntersector,
: nextArrow.points[0]; ) || nextArrow.points[0]
: nextArrow.points[0];
const endChanged = const endChanged =
pointDistance( pointDistance(
@@ -2337,49 +2379,6 @@ const pointDraggingUpdates = (
}; };
}; };
// const shouldAllowDraggingPoint = (
// element: ExcalidrawLinearElement,
// scenePointerX: number,
// scenePointerY: number,
// selectedPointsIndices: readonly number[],
// elementsMap: Readonly<NonDeletedSceneElementsMap>,
// app: AppClassProperties,
// ) => {
// if (!isSimpleArrow(element)) {
// return true;
// }
// const scenePointer = pointFrom<GlobalPoint>(scenePointerX, scenePointerY);
// // Do not allow dragging the bound arrow closer to the shape than
// // the dragging threshold
// let allowDrag = true;
// if (selectedPointsIndices.includes(0) && element.startBinding) {
// const boundElement = elementsMap.get(
// element.startBinding.elementId,
// )! as ExcalidrawBindableElement;
// const dist = distanceToElement(boundElement, elementsMap, scenePointer);
// const inside = isPointInElement(scenePointer, boundElement, elementsMap);
// allowDrag =
// allowDrag && (dist > getFixedBindingDistance(boundElement) || inside);
// }
// if (
// selectedPointsIndices.includes(element.points.length - 1) &&
// element.endBinding
// ) {
// const boundElement = elementsMap.get(
// element.endBinding.elementId,
// )! as ExcalidrawBindableElement;
// const dist = distanceToElement(boundElement, elementsMap, scenePointer);
// const inside = isPointInElement(scenePointer, boundElement, elementsMap);
// allowDrag =
// allowDrag && (dist > getFixedBindingDistance(boundElement) || inside);
// }
// return allowDrag;
// };
const determineCustomLinearAngle = ( const determineCustomLinearAngle = (
pivotPoint: LocalPoint, pivotPoint: LocalPoint,
draggedPoint: LocalPoint, draggedPoint: LocalPoint,

View File

@@ -31,6 +31,7 @@ import { elementCenterPoint, getDiamondPoints } from "./bounds";
import { generateLinearCollisionShape } from "./shape"; import { generateLinearCollisionShape } from "./shape";
import { isPointInElement } from "./collision";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { isRectangularElement } from "./typeChecks"; import { isRectangularElement } from "./typeChecks";
@@ -557,14 +558,17 @@ export const projectFixedPointOntoDiagonal = (
element: ExcalidrawElement, element: ExcalidrawElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ): GlobalPoint | null => {
invariant(arrow.points.length >= 2, "Arrow must have at least two points");
if (arrow.width < 1 && arrow.height < 1) {
return null;
}
const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement( const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement(
element, element,
elementsMap, elementsMap,
); );
invariant(arrow.points.length >= 2, "Arrow must have at least two points");
const a = LinearElementEditor.getPointAtIndexGlobalCoordinates( const a = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow, arrow,
startOrEnd === "start" ? 1 : arrow.points.length - 2, startOrEnd === "start" ? 1 : arrow.points.length - 2,
@@ -587,9 +591,12 @@ export const projectFixedPointOntoDiagonal = (
const d1 = p1 && pointDistance(a, p1); const d1 = p1 && pointDistance(a, p1);
const d2 = p2 && pointDistance(a, p2); const d2 = p2 && pointDistance(a, p2);
let p = null;
if (d1 != null && d2 != null) { if (d1 != null && d2 != null) {
return d1 < d2 ? p1 : p2; p = d1 < d2 ? p1 : p2;
} else {
p = p1 || p2 || null;
} }
return p1 || p2 || null; return p && isPointInElement(p, element, elementsMap) ? p : null;
}; };