Include point updates after binding update

Fix point updates when endpoint dragged and opposite endpoint orbits

centered focus point only for new arrows
This commit is contained in:
Mark Tolmacs
2025-07-14 16:47:42 +02:00
parent 64e3e8a044
commit 149bb3481a
5 changed files with 193 additions and 89 deletions

View File

@ -11,11 +11,14 @@ import { type AppState } from "@excalidraw/excalidraw/types";
import { arrayToMap, invariant, throttleRAF } from "@excalidraw/common";
import { useCallback, useImperativeHandle, useRef } from "react";
import { isArrowElement, isBindableElement } from "@excalidraw/element";
import {
getGlobalFixedPointForBindableElement,
isArrowElement,
isBindableElement,
} from "@excalidraw/element";
import {
isLineSegment,
pointFrom,
type GlobalPoint,
type LineSegment,
} from "@excalidraw/math";
@ -94,9 +97,10 @@ const _renderBinding = (
const bindable = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
const [x, y] = pointFrom<GlobalPoint>(
bindable.x + bindable.width * binding.fixedPoint[0],
bindable.y + bindable.height * binding.fixedPoint[1],
const [x, y] = getGlobalFixedPointForBindableElement(
binding.fixedPoint,
bindable,
elementsMap,
);
context.save();
@ -128,9 +132,10 @@ const _renderBindableBinding = (
const bindable = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
const [x, y] = pointFrom<GlobalPoint>(
bindable.x + bindable.width * binding.fixedPoint[0],
bindable.y + bindable.height * binding.fixedPoint[1],
const [x, y] = getGlobalFixedPointForBindableElement(
binding.fixedPoint,
bindable,
elementsMap,
);
context.save();

View File

@ -125,6 +125,9 @@ export const bindOrUnbindBindingElement = (
draggingPoints: PointsPositionUpdates,
scene: Scene,
appState: AppState,
opts?: {
newArrow: boolean;
},
) => {
const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints(
arrow,
@ -132,9 +135,43 @@ export const bindOrUnbindBindingElement = (
scene.getNonDeletedElementsMap(),
scene.getNonDeletedElements(),
appState,
opts,
);
bindOrUnbindBindingElementEdge(arrow, start, "start", scene);
bindOrUnbindBindingElementEdge(arrow, end, "end", scene);
if (start.focusPoint || end.focusPoint) {
// If the strategy dictates a focus point override, then
// update the arrow points to point to the focus point.
const updates: PointsPositionUpdates = new Map();
if (start.focusPoint) {
updates.set(0, {
point:
updateBoundPoint(
arrow,
"startBinding",
arrow.startBinding,
start.element,
scene.getNonDeletedElementsMap(),
) || arrow.points[0],
});
}
if (end.focusPoint) {
updates.set(arrow.points.length - 1, {
point:
updateBoundPoint(
arrow,
"endBinding",
arrow.endBinding,
end.element,
scene.getNonDeletedElementsMap(),
) || arrow.points[arrow.points.length - 1],
});
}
LinearElementEditor.movePoints(arrow, scene, updates);
}
};
const bindOrUnbindBindingElementEdge = (
@ -193,6 +230,9 @@ const bindingStrategyForEndpointDragging = (
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
zoom: AppState["zoom"],
globalBindMode?: AppState["bindMode"],
opts?: {
newArrow: boolean;
},
): { current: BindingStrategy; other: BindingStrategy } => {
let current: BindingStrategy = { mode: undefined };
let other: BindingStrategy = { mode: undefined };
@ -233,10 +273,12 @@ const bindingStrategyForEndpointDragging = (
current = {
element: hovered,
mode: "orbit",
focusPoint: pointFrom<GlobalPoint>(
focusPoint: opts?.newArrow
? pointFrom<GlobalPoint>(
hovered.x + hovered.width / 2,
hovered.y + hovered.height / 2,
),
)
: undefined,
};
return { current, other };
@ -273,10 +315,12 @@ const bindingStrategyForEndpointDragging = (
current = {
element: hovered,
mode: "orbit",
focusPoint: pointFrom<GlobalPoint>(
focusPoint: opts?.newArrow
? pointFrom<GlobalPoint>(
hovered.x + hovered.width / 2,
hovered.y + hovered.height / 2,
),
)
: undefined,
};
return { current, other };
@ -302,6 +346,9 @@ const getBindingStrategyForDraggingBindingElementEndpoints = (
elementsMap: NonDeletedSceneElementsMap,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
appState: AppState,
opts?: {
newArrow: boolean;
},
): { start: BindingStrategy; end: BindingStrategy } => {
const globalBindMode = appState.bindMode || "focus";
const startIdx = 0;
@ -330,7 +377,18 @@ const getBindingStrategyForDraggingBindingElementEndpoints = (
return {
start: hovered
? { element: hovered, mode: hit ? "inside" : "outside" }
? hit
? { element: hovered, mode: "inside" }
: opts?.newArrow
? {
element: hovered,
mode: "orbit",
focusPoint: pointFrom<GlobalPoint>(
hovered.x + hovered.width / 2,
hovered.y + hovered.height / 2,
),
}
: { element: hovered, mode: "inside" }
: { mode: undefined },
end: { mode: undefined },
};
@ -372,6 +430,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints = (
elements,
appState.zoom,
globalBindMode,
opts,
);
return { start: current, end: other };
@ -393,6 +452,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints = (
elements,
appState.zoom,
globalBindMode,
opts,
);
return { start: other, end: current };
@ -560,21 +620,6 @@ export const bindBindingElement = (
}
};
export const isBindingElementSimpleAndAlreadyBound = (
linearElement: NonDeleted<ExcalidrawArrowElement>,
alreadyBoundToId: ExcalidrawBindableElement["id"] | undefined,
bindableElement: ExcalidrawBindableElement,
): boolean => {
return (
alreadyBoundToId === bindableElement.id &&
isBindingElementSimple(linearElement)
);
};
const isBindingElementSimple = (
linearElement: NonDeleted<ExcalidrawArrowElement>,
): boolean => linearElement.points.length < 3 && !isElbowArrow(linearElement);
export const unbindBindingElement = (
arrow: NonDeleted<ExcalidrawArrowElement>,
startOrEnd: "start" | "end",
@ -1115,7 +1160,7 @@ export const snapToMid = (
};
export const updateBoundPoint = (
linearElement: NonDeleted<ExcalidrawArrowElement>,
arrow: NonDeleted<ExcalidrawArrowElement>,
startOrEnd: "startBinding" | "endBinding",
binding: FixedPointBinding | null | undefined,
bindableElement: ExcalidrawBindableElement,
@ -1124,36 +1169,37 @@ export const updateBoundPoint = (
if (
binding == null ||
// We only need to update the other end if this is a 2 point line element
(binding.elementId !== bindableElement.id &&
linearElement.points.length > 2)
(binding.elementId !== bindableElement.id && arrow.points.length > 2)
) {
return null;
}
const fixedPoint = normalizeFixedPoint(binding.fixedPoint);
const globalMidPoint = elementCenterPoint(bindableElement, elementsMap);
const global = pointFrom<GlobalPoint>(
bindableElement.x + fixedPoint[0] * bindableElement.width,
bindableElement.y + fixedPoint[1] * bindableElement.height,
);
const rotatedGlobal = pointRotateRads(
global,
globalMidPoint,
bindableElement.angle,
const global = getGlobalFixedPointForBindableElement(
fixedPoint,
bindableElement,
elementsMap,
);
const element =
arrow.points.length === 1
? {
...arrow,
points: [arrow.points[0], arrow.points[0]],
}
: arrow;
const maybeOutlineGlobal =
binding.mode === "orbit"
? getOutlineAvoidingPoint(
linearElement,
element,
bindableElement,
rotatedGlobal,
startOrEnd === "startBinding" ? 0 : linearElement.points.length - 1,
global,
startOrEnd === "startBinding" ? 0 : arrow.points.length - 1,
elementsMap,
)
: rotatedGlobal;
: global;
return LinearElementEditor.pointFromAbsoluteCoords(
linearElement,
arrow,
maybeOutlineGlobal,
elementsMap,
);

View File

@ -23,9 +23,9 @@ import {
} from "@excalidraw/common";
import {
bindingBorderTest,
deconstructLinearOrFreeDrawElement,
getHoveredElementForBinding,
hitElementItself,
isPathALoop,
type Store,
} from "@excalidraw/element";
@ -42,6 +42,7 @@ import type {
} from "@excalidraw/excalidraw/types";
import {
getGlobalFixedPointForBindableElement,
getOutlineAvoidingPoint,
isBindingEnabled,
maybeSuggestBindingsForBindingElementAtCoords,
@ -55,7 +56,12 @@ import {
import { headingIsHorizontal, vectorToHeading } from "./heading";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { isArrowElement, isBindingElement, isElbowArrow } from "./typeChecks";
import {
isArrowElement,
isBindingElement,
isElbowArrow,
isSimpleArrow,
} from "./typeChecks";
import { ShapeCache, toggleLinePolygonState } from "./shape";
@ -1936,12 +1942,13 @@ const pointDraggingUpdates = (
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
appState: AppState,
): PointsPositionUpdates => {
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap, true);
const hasMidPoints =
selectedPointsIndices.filter(
(_, idx) => idx > 0 && idx < element.points.length - 1,
).length > 0;
return new Map(
const updates = new Map(
selectedPointsIndices.map((pointIndex) => {
let newPointPosition: LocalPoint =
pointIndex === lastClickedPoint
@ -1958,14 +1965,10 @@ const pointDraggingUpdates = (
);
if (
isSimpleArrow(element) &&
!hasMidPoints &&
(pointIndex === 0 || pointIndex === element.points.length - 1)
) {
const [, , , , cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
true,
);
let newGlobalPointPosition = pointRotateRads(
pointFrom<GlobalPoint>(
element.x + newPointPosition[0],
@ -1988,12 +1991,12 @@ const pointDraggingUpdates = (
);
const otherPointInsideElement =
!!hoveredElement &&
hitElementItself({
element: hoveredElement,
point: otherGlobalPoint,
!!bindingBorderTest(
hoveredElement,
otherGlobalPoint,
elementsMap,
threshold: 0,
});
appState.zoom,
);
if (
isBindingEnabled(appState) &&
@ -2029,4 +2032,60 @@ const pointDraggingUpdates = (
];
}),
);
if (isSimpleArrow(element)) {
const adjacentPointIndices =
element.points.length === 2
? [0, 1]
: element.points.length === 3
? [1]
: [1, element.points.length - 2];
adjacentPointIndices
.filter((adjacentPointIndex) =>
selectedPointsIndices.includes(adjacentPointIndex),
)
.flatMap((adjacentPointIndex) =>
element.points.length === 3
? [0, 2]
: adjacentPointIndex === 1
? 0
: element.points.length - 1,
)
.forEach((pointIndex) => {
const binding =
element[pointIndex === 0 ? "startBinding" : "endBinding"];
const bindingIsOrbiting = binding?.mode === "orbit";
if (bindingIsOrbiting) {
const hoveredElement = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
const focusGlobalPoint = getGlobalFixedPointForBindableElement(
binding.fixedPoint,
hoveredElement,
elementsMap,
);
const newGlobalPointPosition = getOutlineAvoidingPoint(
element,
hoveredElement,
focusGlobalPoint,
pointIndex,
elementsMap,
);
const newPointPosition = LinearElementEditor.createPointAt(
element,
elementsMap,
newGlobalPointPosition[0] - linearElementEditor.pointerOffset.x,
newGlobalPointPosition[1] - linearElementEditor.pointerOffset.y,
null,
);
updates.set(pointIndex, {
point: newPointPosition,
isDragging: false,
});
}
});
}
return updates;
};

View File

@ -276,6 +276,7 @@ export const actionFinalize = register({
]),
scene,
appState,
{ newArrow: true },
);
}
}

View File

@ -239,6 +239,7 @@ import {
calculateFixedPointForNonElbowArrowBinding,
normalizeFixedPoint,
bindOrUnbindBindingElement,
updateBoundPoint,
} from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@ -6236,40 +6237,31 @@ class App extends React.Component<AppProps, AppState> {
const startElement = this.scene.getElement(
multiElement.startBinding.elementId,
) as ExcalidrawBindableElement;
const avoidancePoint = getOutlineAvoidingPoint(
const localPoint = updateBoundPoint(
multiElement,
"startBinding",
multiElement.startBinding,
startElement,
startPoint,
0,
elementsMap,
);
if (!pointsEqual(startPoint, avoidancePoint)) {
LinearElementEditor.movePoints(
const avoidancePoint = localPoint
? LinearElementEditor.getPointGlobalCoordinates(
multiElement,
this.scene,
new Map([
[
0,
{
point: LinearElementEditor.pointFromAbsoluteCoords(
localPoint,
elementsMap,
)
: null;
if (avoidancePoint && !pointsEqual(startPoint, avoidancePoint)) {
const point = LinearElementEditor.pointFromAbsoluteCoords(
multiElement,
avoidancePoint,
elementsMap,
),
},
],
]),
{
startBinding: {
...multiElement.startBinding,
...calculateFixedPointForNonElbowArrowBinding(
);
LinearElementEditor.movePoints(
multiElement,
startElement,
"start",
elementsMap,
),
},
},
this.scene,
new Map([[0, { point }]]),
);
}
}
@ -8168,6 +8160,7 @@ class App extends React.Component<AppProps, AppState> {
]),
this.scene,
this.state,
{ newArrow: true },
);
}
this.setState((prevState) => {