fix: Bindings at partially overlapping binding areas (#9536)

This commit is contained in:
Márk Tolmács 2025-06-16 12:30:59 +02:00 committed by GitHub
parent 958597dfaa
commit 0a19c93509
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 174 additions and 138 deletions

View File

@ -384,6 +384,48 @@ export const getSuggestedBindingsForArrows = (
); );
}; };
export const maybeSuggestBindingsForLinearElementAtCoords = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
/** scene coords */
pointerCoords: {
x: number;
y: number;
}[],
scene: Scene,
zoom: AppState["zoom"],
// During line creation the start binding hasn't been written yet
// into `linearElement`
oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
): ExcalidrawBindableElement[] =>
Array.from(
pointerCoords.reduce(
(acc: Set<NonDeleted<ExcalidrawBindableElement>>, coords) => {
const hoveredBindableElement = getHoveredElementForBinding(
coords,
scene.getNonDeletedElements(),
scene.getNonDeletedElementsMap(),
zoom,
isElbowArrow(linearElement),
isElbowArrow(linearElement),
);
if (
hoveredBindableElement != null &&
!isLinearElementSimpleAndAlreadyBound(
linearElement,
oppositeBindingBoundElement?.id,
hoveredBindableElement,
)
) {
acc.add(hoveredBindableElement);
}
return acc;
},
new Set() as Set<NonDeleted<ExcalidrawBindableElement>>,
),
);
export const maybeBindLinearElement = ( export const maybeBindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
appState: AppState, appState: AppState,
@ -513,7 +555,7 @@ export const isLinearElementSimpleAndAlreadyBound = (
const isLinearElementSimple = ( const isLinearElementSimple = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
): boolean => linearElement.points.length < 3; ): boolean => linearElement.points.length < 3 && !isElbowArrow(linearElement);
const unbindLinearElement = ( const unbindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,

View File

@ -20,6 +20,7 @@ import {
getGridPoint, getGridPoint,
invariant, invariant,
tupleToCoors, tupleToCoors,
viewportCoordsToSceneCoords,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@ -45,6 +46,7 @@ import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
getHoveredElementForBinding, getHoveredElementForBinding,
isBindingEnabled, isBindingEnabled,
maybeSuggestBindingsForLinearElementAtCoords,
} from "./binding"; } from "./binding";
import { import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
@ -275,18 +277,13 @@ export class LinearElementEditor {
app: AppClassProperties, app: AppClassProperties,
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
maybeSuggestBinding: (
element: NonDeleted<ExcalidrawLinearElement>,
pointSceneCoords: { x: number; y: number }[],
) => void,
linearElementEditor: LinearElementEditor, linearElementEditor: LinearElementEditor,
scene: Scene, ): Pick<AppState, keyof AppState> | null {
): LinearElementEditor | null {
if (!linearElementEditor) { if (!linearElementEditor) {
return null; return null;
} }
const { elementId } = linearElementEditor; const { elementId } = linearElementEditor;
const elementsMap = 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;
if (!element) { if (!element) {
@ -347,7 +344,7 @@ export class LinearElementEditor {
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
scene, app.scene,
new Map([ new Map([
[ [
selectedIndex, selectedIndex,
@ -375,7 +372,7 @@ export class LinearElementEditor {
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
scene, app.scene,
new Map( new Map(
selectedPointsIndices.map((pointIndex) => { selectedPointsIndices.map((pointIndex) => {
const newPointPosition: LocalPoint = const newPointPosition: LocalPoint =
@ -407,46 +404,59 @@ export class LinearElementEditor {
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) { if (boundTextElement) {
handleBindTextResize(element, scene, false); handleBindTextResize(element, app.scene, false);
} }
// suggest bindings for first and last point if selected // suggest bindings for first and last point if selected
let suggestedBindings: ExcalidrawBindableElement[] = [];
if (isBindingElement(element, false)) { if (isBindingElement(element, false)) {
const firstSelectedIndex = selectedPointsIndices[0] === 0;
const lastSelectedIndex =
selectedPointsIndices[selectedPointsIndices.length - 1] ===
element.points.length - 1;
const coords: { x: number; y: number }[] = []; const coords: { x: number; y: number }[] = [];
const firstSelectedIndex = selectedPointsIndices[0]; if (!firstSelectedIndex !== !lastSelectedIndex) {
if (firstSelectedIndex === 0) { coords.push({ x: scenePointerX, y: scenePointerY });
coords.push( } else {
tupleToCoors( if (firstSelectedIndex) {
LinearElementEditor.getPointGlobalCoordinates( coords.push(
element, tupleToCoors(
element.points[0], LinearElementEditor.getPointGlobalCoordinates(
elementsMap, element,
element.points[0],
elementsMap,
),
), ),
), );
); }
}
const lastSelectedIndex = if (lastSelectedIndex) {
selectedPointsIndices[selectedPointsIndices.length - 1]; coords.push(
if (lastSelectedIndex === element.points.length - 1) { tupleToCoors(
coords.push( LinearElementEditor.getPointGlobalCoordinates(
tupleToCoors( element,
LinearElementEditor.getPointGlobalCoordinates( element.points[
element, selectedPointsIndices[selectedPointsIndices.length - 1]
element.points[lastSelectedIndex], ],
elementsMap, elementsMap,
),
), ),
), );
); }
} }
if (coords.length) { if (coords.length) {
maybeSuggestBinding(element, coords); suggestedBindings = maybeSuggestBindingsForLinearElementAtCoords(
element,
coords,
app.scene,
app.state.zoom,
);
} }
} }
return { const newLinearElementEditor = {
...linearElementEditor, ...linearElementEditor,
selectedPointsIndices, selectedPointsIndices,
segmentMidPointHoveredCoords: segmentMidPointHoveredCoords:
@ -466,6 +476,15 @@ export class LinearElementEditor {
isDragging: true, isDragging: true,
customLineAngle, customLineAngle,
}; };
return {
...app.state,
editingLinearElement: app.state.editingLinearElement
? newLinearElementEditor
: null,
selectedLinearElement: newLinearElementEditor,
suggestedBindings,
};
} }
return null; return null;
@ -479,6 +498,7 @@ export class LinearElementEditor {
): LinearElementEditor { ): LinearElementEditor {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements(); const elements = scene.getNonDeletedElements();
const pointerCoords = viewportCoordsToSceneCoords(event, appState);
const { elementId, selectedPointsIndices, isDragging, pointerDownState } = const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
editingLinearElement; editingLinearElement;
@ -534,13 +554,15 @@ export class LinearElementEditor {
const bindingElement = isBindingEnabled(appState) const bindingElement = isBindingEnabled(appState)
? getHoveredElementForBinding( ? getHoveredElementForBinding(
tupleToCoors( (selectedPointsIndices?.length ?? 0) > 1
LinearElementEditor.getPointAtIndexGlobalCoordinates( ? tupleToCoors(
element, LinearElementEditor.getPointAtIndexGlobalCoordinates(
selectedPoint!, element,
elementsMap, selectedPoint!,
), elementsMap,
), ),
)
: pointerCoords,
elements, elements,
elementsMap, elementsMap,
appState.zoom, appState.zoom,

View File

@ -14,7 +14,12 @@ import {
isLineElement, isLineElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common"; import {
KEYS,
arrayToMap,
tupleToCoors,
updateActiveTool,
} from "@excalidraw/common";
import { isPathALoop } from "@excalidraw/element"; import { isPathALoop } from "@excalidraw/element";
import { isInvisiblySmallElement } from "@excalidraw/element"; import { isInvisiblySmallElement } from "@excalidraw/element";
@ -43,12 +48,16 @@ export const actionFinalize = register({
trackEvent: false, trackEvent: false,
perform: (elements, appState, data, app) => { perform: (elements, appState, data, app) => {
const { interactiveCanvas, focusContainer, scene } = app; const { interactiveCanvas, focusContainer, scene } = app;
const { event, sceneCoords } =
(data as {
event?: PointerEvent;
sceneCoords?: { x: number; y: number };
}) ?? {};
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
if (data?.event && appState.selectedLinearElement) { if (event && appState.selectedLinearElement) {
const linearElementEditor = LinearElementEditor.handlePointerUp( const linearElementEditor = LinearElementEditor.handlePointerUp(
data.event, event,
appState.selectedLinearElement, appState.selectedLinearElement,
appState, appState,
app.scene, app.scene,
@ -204,12 +213,17 @@ export const actionFinalize = register({
element.points.length > 1 && element.points.length > 1 &&
isBindingEnabled(appState) isBindingEnabled(appState)
) { ) {
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( const coords =
element, sceneCoords ??
-1, tupleToCoors(
arrayToMap(elements), LinearElementEditor.getPointAtIndexGlobalCoordinates(
); element,
maybeBindLinearElement(element, appState, { x, y }, scene); -1,
arrayToMap(elements),
),
);
maybeBindLinearElement(element, appState, coords, scene);
} }
} }
} }

View File

@ -105,12 +105,12 @@ import {
import { import {
getObservedAppState, getObservedAppState,
getCommonBounds, getCommonBounds,
maybeSuggestBindingsForLinearElementAtCoords,
getElementAbsoluteCoords, getElementAbsoluteCoords,
bindOrUnbindLinearElements, bindOrUnbindLinearElements,
fixBindingsAfterDeletion, fixBindingsAfterDeletion,
getHoveredElementForBinding, getHoveredElementForBinding,
isBindingEnabled, isBindingEnabled,
isLinearElementSimpleAndAlreadyBound,
shouldEnableBindingForPointerEvent, shouldEnableBindingForPointerEvent,
updateBoundElements, updateBoundElements,
getSuggestedBindingsForArrows, getSuggestedBindingsForArrows,
@ -237,7 +237,6 @@ import {
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
import type { import type {
ExcalidrawBindableElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ExcalidrawGenericElement, ExcalidrawGenericElement,
@ -5883,11 +5882,15 @@ class App extends React.Component<AppProps, AppState> {
// and point // and point
const { newElement } = this.state; const { newElement } = this.state;
if (isBindingElement(newElement, false)) { if (isBindingElement(newElement, false)) {
this.maybeSuggestBindingsForLinearElementAtCoords( this.setState({
newElement, suggestedBindings: maybeSuggestBindingsForLinearElementAtCoords(
[scenePointer], newElement,
this.state.startBoundElement, [scenePointer],
); this.scene,
this.state.zoom,
this.state.startBoundElement,
),
});
} else { } else {
this.maybeSuggestBindingAtCursor(scenePointer, false); this.maybeSuggestBindingAtCursor(scenePointer, false);
} }
@ -8217,31 +8220,19 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
const newLinearElementEditor = LinearElementEditor.handlePointDragging( const newState = LinearElementEditor.handlePointDragging(
event, event,
this, this,
pointerCoords.x, pointerCoords.x,
pointerCoords.y, pointerCoords.y,
(element, pointsSceneCoords) => {
this.maybeSuggestBindingsForLinearElementAtCoords(
element,
pointsSceneCoords,
);
},
linearElementEditor, linearElementEditor,
this.scene,
); );
if (newLinearElementEditor) { if (newState) {
pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y; pointerDownState.lastCoords.y = pointerCoords.y;
pointerDownState.drag.hasOccurred = true; pointerDownState.drag.hasOccurred = true;
this.setState({ this.setState(newState);
editingLinearElement: this.state.editingLinearElement
? newLinearElementEditor
: null,
selectedLinearElement: newLinearElementEditor,
});
return; return;
} }
@ -8720,11 +8711,15 @@ class App extends React.Component<AppProps, AppState> {
if (isBindingElement(newElement, false)) { if (isBindingElement(newElement, false)) {
// When creating a linear element by dragging // When creating a linear element by dragging
this.maybeSuggestBindingsForLinearElementAtCoords( this.setState({
newElement, suggestedBindings: maybeSuggestBindingsForLinearElementAtCoords(
[pointerCoords], newElement,
this.state.startBoundElement, [pointerCoords],
); this.scene,
this.state.zoom,
this.state.startBoundElement,
),
});
} }
} else { } else {
pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.x = pointerCoords.x;
@ -8919,16 +8914,17 @@ class App extends React.Component<AppProps, AppState> {
const hitElements = pointerDownState.hit.allHitElements; const hitElements = pointerDownState.hit.allHitElements;
const sceneCoords = viewportCoordsToSceneCoords(
{ clientX: childEvent.clientX, clientY: childEvent.clientY },
this.state,
);
if ( if (
this.state.activeTool.type === "selection" && this.state.activeTool.type === "selection" &&
!pointerDownState.boxSelection.hasOccurred && !pointerDownState.boxSelection.hasOccurred &&
!pointerDownState.resize.isResizing && !pointerDownState.resize.isResizing &&
!hitElements.some((el) => this.state.selectedElementIds[el.id]) !hitElements.some((el) => this.state.selectedElementIds[el.id])
) { ) {
const sceneCoords = viewportCoordsToSceneCoords(
{ clientX: childEvent.clientX, clientY: childEvent.clientY },
this.state,
);
const hitLockedElement = this.getElementAtPosition( const hitLockedElement = this.getElementAtPosition(
sceneCoords.x, sceneCoords.x,
sceneCoords.y, sceneCoords.y,
@ -9029,6 +9025,7 @@ class App extends React.Component<AppProps, AppState> {
} else if (this.state.selectedLinearElement.isDragging) { } else if (this.state.selectedLinearElement.isDragging) {
this.actionManager.executeAction(actionFinalize, "ui", { this.actionManager.executeAction(actionFinalize, "ui", {
event: childEvent, event: childEvent,
sceneCoords,
}); });
} }
} }
@ -9123,7 +9120,10 @@ class App extends React.Component<AppProps, AppState> {
isBindingEnabled(this.state) && isBindingEnabled(this.state) &&
isBindingElement(newElement, false) isBindingElement(newElement, false)
) { ) {
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize, "ui", {
event: childEvent,
sceneCoords,
});
} }
this.setState({ suggestedBindings: [], startBoundElement: null }); this.setState({ suggestedBindings: [], startBoundElement: null });
if (!activeTool.locked) { if (!activeTool.locked) {
@ -9706,7 +9706,8 @@ class App extends React.Component<AppProps, AppState> {
} }
if ( if (
pointerDownState.drag.hasOccurred || (pointerDownState.drag.hasOccurred &&
!this.state.selectedLinearElement) ||
isResizing || isResizing ||
isRotating || isRotating ||
isCropping isCropping
@ -10172,49 +10173,6 @@ class App extends React.Component<AppProps, AppState> {
}); });
}; };
private maybeSuggestBindingsForLinearElementAtCoords = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
/** scene coords */
pointerCoords: {
x: number;
y: number;
}[],
// During line creation the start binding hasn't been written yet
// into `linearElement`
oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
): void => {
if (!pointerCoords.length) {
return;
}
const suggestedBindings = pointerCoords.reduce(
(acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
const hoveredBindableElement = getHoveredElementForBinding(
coords,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
isElbowArrow(linearElement),
isElbowArrow(linearElement),
);
if (
hoveredBindableElement != null &&
!isLinearElementSimpleAndAlreadyBound(
linearElement,
oppositeBindingBoundElement?.id,
hoveredBindableElement,
)
) {
acc.push(hoveredBindableElement);
}
return acc;
},
[],
);
this.setState({ suggestedBindings });
};
private clearSelection(hitElement: ExcalidrawElement | null): void { private clearSelection(hitElement: ExcalidrawElement | null): void {
this.setState((prevState) => ({ this.setState((prevState) => ({
selectedElementIds: makeNextSelectedElementIds({}, prevState), selectedElementIds: makeNextSelectedElementIds({}, prevState),

View File

@ -224,7 +224,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 37, "version": 35,
"width": "98.40611", "width": "98.40611",
"x": 1, "x": 1,
"y": 0, "y": 0,
@ -348,7 +348,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"focus": "0.02970", "focus": "0.02970",
"gap": 1, "gap": 1,
}, },
"version": 35, "version": 33,
}, },
"inserted": { "inserted": {
"endBinding": { "endBinding": {
@ -372,7 +372,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"focus": "0.02000", "focus": "0.02000",
"gap": 1, "gap": 1,
}, },
"version": 32, "version": 30,
}, },
}, },
}, },
@ -427,7 +427,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
], ],
"startBinding": null, "startBinding": null,
"version": 37, "version": 35,
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@ -447,7 +447,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"focus": "0.02970", "focus": "0.02970",
"gap": 1, "gap": 1,
}, },
"version": 35, "version": 33,
"y": "35.82151", "y": "35.82151",
}, },
}, },
@ -828,7 +828,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 33, "version": 31,
"width": 0, "width": 0,
"x": 149, "x": 149,
"y": 0, "y": 0,
@ -878,7 +878,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"id4": { "id4": {
"deleted": { "deleted": {
"endBinding": null, "endBinding": null,
"version": 32, "version": 30,
}, },
"inserted": { "inserted": {
"endBinding": { "endBinding": {
@ -886,7 +886,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"focus": -0, "focus": -0,
"gap": 1, "gap": 1,
}, },
"version": 30, "version": 28,
}, },
}, },
}, },
@ -922,7 +922,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"id4": { "id4": {
"deleted": { "deleted": {
"startBinding": null, "startBinding": null,
"version": 33, "version": 31,
}, },
"inserted": { "inserted": {
"startBinding": { "startBinding": {
@ -930,7 +930,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
"version": 32, "version": 30,
}, },
}, },
}, },