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

View File

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

View File

@ -23,9 +23,9 @@ import {
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
bindingBorderTest,
deconstructLinearOrFreeDrawElement, deconstructLinearOrFreeDrawElement,
getHoveredElementForBinding, getHoveredElementForBinding,
hitElementItself,
isPathALoop, isPathALoop,
type Store, type Store,
} from "@excalidraw/element"; } from "@excalidraw/element";
@ -42,6 +42,7 @@ import type {
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import { import {
getGlobalFixedPointForBindableElement,
getOutlineAvoidingPoint, getOutlineAvoidingPoint,
isBindingEnabled, isBindingEnabled,
maybeSuggestBindingsForBindingElementAtCoords, maybeSuggestBindingsForBindingElementAtCoords,
@ -55,7 +56,12 @@ import {
import { headingIsHorizontal, vectorToHeading } from "./heading"; import { headingIsHorizontal, vectorToHeading } from "./heading";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { isArrowElement, isBindingElement, isElbowArrow } from "./typeChecks"; import {
isArrowElement,
isBindingElement,
isElbowArrow,
isSimpleArrow,
} from "./typeChecks";
import { ShapeCache, toggleLinePolygonState } from "./shape"; import { ShapeCache, toggleLinePolygonState } from "./shape";
@ -1936,12 +1942,13 @@ const pointDraggingUpdates = (
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
appState: AppState, appState: AppState,
): PointsPositionUpdates => { ): PointsPositionUpdates => {
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap, true);
const hasMidPoints = const hasMidPoints =
selectedPointsIndices.filter( selectedPointsIndices.filter(
(_, idx) => idx > 0 && idx < element.points.length - 1, (_, idx) => idx > 0 && idx < element.points.length - 1,
).length > 0; ).length > 0;
return new Map( const updates = new Map(
selectedPointsIndices.map((pointIndex) => { selectedPointsIndices.map((pointIndex) => {
let newPointPosition: LocalPoint = let newPointPosition: LocalPoint =
pointIndex === lastClickedPoint pointIndex === lastClickedPoint
@ -1958,14 +1965,10 @@ const pointDraggingUpdates = (
); );
if ( if (
isSimpleArrow(element) &&
!hasMidPoints && !hasMidPoints &&
(pointIndex === 0 || pointIndex === element.points.length - 1) (pointIndex === 0 || pointIndex === element.points.length - 1)
) { ) {
const [, , , , cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
true,
);
let newGlobalPointPosition = pointRotateRads( let newGlobalPointPosition = pointRotateRads(
pointFrom<GlobalPoint>( pointFrom<GlobalPoint>(
element.x + newPointPosition[0], element.x + newPointPosition[0],
@ -1988,12 +1991,12 @@ const pointDraggingUpdates = (
); );
const otherPointInsideElement = const otherPointInsideElement =
!!hoveredElement && !!hoveredElement &&
hitElementItself({ !!bindingBorderTest(
element: hoveredElement, hoveredElement,
point: otherGlobalPoint, otherGlobalPoint,
elementsMap, elementsMap,
threshold: 0, appState.zoom,
}); );
if ( if (
isBindingEnabled(appState) && 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, scene,
appState, appState,
{ newArrow: true },
); );
} }
} }

View File

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