Fixed point binding for simple arrows

This commit is contained in:
Mark Tolmacs
2025-06-18 19:21:00 +02:00
parent 416da62138
commit 1a499cc2c6
4 changed files with 283 additions and 75 deletions

View File

@ -37,7 +37,7 @@ import {
getCenterForBounds, getCenterForBounds,
getElementBounds, getElementBounds,
} from "./bounds"; } from "./bounds";
import { intersectElementWithLineSegment } from "./collision"; import { intersectElementWithLineSegment, isPointInElement } from "./collision";
import { distanceToElement } from "./distance"; import { distanceToElement } from "./distance";
import { import {
headingForPointFromElement, headingForPointFromElement,
@ -127,6 +127,9 @@ export const bindOrUnbindLinearElement = (
endBindingElement: ExcalidrawBindableElement | null | "keep", endBindingElement: ExcalidrawBindableElement | null | "keep",
scene: Scene, scene: Scene,
): void => { ): void => {
const bothEndBoundToTheSameElement =
linearElement.startBinding?.elementId ===
linearElement.endBinding?.elementId && !!linearElement.startBinding;
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set(); const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set(); const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
@ -151,18 +154,20 @@ export const bindOrUnbindLinearElement = (
elementsMap, elementsMap,
); );
const onlyUnbound = Array.from(unboundFromElementIds).filter( if (!bothEndBoundToTheSameElement) {
(id) => !boundToElementIds.has(id), const onlyUnbound = Array.from(unboundFromElementIds).filter(
); (id) => !boundToElementIds.has(id),
);
getNonDeletedElements(scene, onlyUnbound).forEach((element) => { getNonDeletedElements(scene, onlyUnbound).forEach((element) => {
scene.mutateElement(element, { scene.mutateElement(element, {
boundElements: element.boundElements?.filter( boundElements: element.boundElements?.filter(
(element) => (element) =>
element.type !== "arrow" || element.id !== linearElement.id, element.type !== "arrow" || element.id !== linearElement.id,
), ),
});
}); });
}); }
}; };
const bindOrUnbindLinearElementEdge = ( const bindOrUnbindLinearElementEdge = (
@ -203,6 +208,7 @@ const bindOrUnbindLinearElementEdge = (
linearElement, linearElement,
bindableElement, bindableElement,
startOrEnd, startOrEnd,
elementsMap,
) )
: startOrEnd === "start" || : startOrEnd === "start" ||
otherEdgeBindableElement.id !== bindableElement.id) otherEdgeBindableElement.id !== bindableElement.id)
@ -459,6 +465,7 @@ export const maybeBindLinearElement = (
linearElement, linearElement,
hoveredElement, hoveredElement,
"end", "end",
elementsMap,
) )
) { ) {
bindLinearElement(linearElement, hoveredElement, "end", scene); bindLinearElement(linearElement, hoveredElement, "end", scene);
@ -487,29 +494,64 @@ export const bindLinearElement = (
return; return;
} }
let binding: PointBinding | FixedPointBinding = { const elementsMap = scene.getNonDeletedElementsMap();
elementId: hoveredElement.id, let binding: PointBinding | FixedPointBinding;
...normalizePointBinding(
calculateFocusAndGap(
linearElement,
hoveredElement,
startOrEnd,
scene.getNonDeletedElementsMap(),
),
hoveredElement,
),
};
if (isElbowArrow(linearElement)) { if (isElbowArrow(linearElement)) {
binding = { binding = {
...binding, elementId: hoveredElement.id,
...normalizePointBinding(
calculateFocusAndGap(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
hoveredElement,
),
...calculateFixedPointForElbowArrowBinding( ...calculateFixedPointForElbowArrowBinding(
linearElement, linearElement,
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
scene.getNonDeletedElementsMap(), elementsMap,
), ),
}; };
} else {
// For non-elbow arrows, check if the endpoint is inside the shape
const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
startOrEnd === "start" ? 0 : -1,
elementsMap,
);
if (isPointInElement(edgePoint, hoveredElement, elementsMap)) {
// Use FixedPoint binding when the arrow endpoint is inside the shape
binding = {
elementId: hoveredElement.id,
focus: 0,
gap: 0,
...calculateFixedPointForNonElbowArrowBinding(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
};
} else {
// Use traditional focus/gap binding when the endpoint is outside the shape
binding = {
elementId: hoveredElement.id,
...normalizePointBinding(
calculateFocusAndGap(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
hoveredElement,
),
};
}
} }
scene.mutateElement(linearElement, { scene.mutateElement(linearElement, {
@ -532,14 +574,36 @@ const isLinearElementSimpleAndAlreadyBoundOnOppositeEdge = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): boolean => { ): boolean => {
const otherBinding = const otherBinding =
linearElement[startOrEnd === "start" ? "endBinding" : "startBinding"]; linearElement[startOrEnd === "start" ? "endBinding" : "startBinding"];
return isLinearElementSimpleAndAlreadyBound(
linearElement, // Only prevent binding if opposite end is bound to the same element
otherBinding?.elementId, if (
bindableElement, otherBinding?.elementId !== bindableElement.id ||
); !isLinearElementSimple(linearElement)
) {
return false;
}
// For non-elbow arrows, allow FixedPoint binding even when both ends bind to the same element
if (!isElbowArrow(linearElement)) {
const currentEndPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
startOrEnd === "start" ? 0 : -1,
elementsMap,
);
// If current end would use FixedPoint binding, allow it
if (isPointInElement(currentEndPoint, bindableElement, elementsMap)) {
return false;
}
}
// Prevent traditional focus/gap binding when both ends would bind to the same element
return true;
}; };
export const isLinearElementSimpleAndAlreadyBound = ( export const isLinearElementSimpleAndAlreadyBound = (
@ -1254,15 +1318,22 @@ const updateBoundPoint = (
const direction = startOrEnd === "startBinding" ? -1 : 1; const direction = startOrEnd === "startBinding" ? -1 : 1;
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
if (isElbowArrow(linearElement) && isFixedPointBinding(binding)) { if (isFixedPointBinding(binding)) {
const fixedPoint = const fixedPoint =
normalizeFixedPoint(binding.fixedPoint) ?? normalizeFixedPoint(binding.fixedPoint) ??
calculateFixedPointForElbowArrowBinding( (isElbowArrow(linearElement)
linearElement, ? calculateFixedPointForElbowArrowBinding(
bindableElement, linearElement,
startOrEnd === "startBinding" ? "start" : "end", bindableElement,
elementsMap, startOrEnd === "startBinding" ? "start" : "end",
).fixedPoint; elementsMap,
).fixedPoint
: calculateFixedPointForNonElbowArrowBinding(
linearElement,
bindableElement,
startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
).fixedPoint);
const globalMidPoint = elementCenterPoint(bindableElement, elementsMap); const globalMidPoint = elementCenterPoint(bindableElement, elementsMap);
const global = pointFrom<GlobalPoint>( const global = pointFrom<GlobalPoint>(
bindableElement.x + fixedPoint[0] * bindableElement.width, bindableElement.x + fixedPoint[0] * bindableElement.width,
@ -1401,6 +1472,42 @@ export const calculateFixedPointForElbowArrowBinding = (
}; };
}; };
export const calculateFixedPointForNonElbowArrowBinding = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): { fixedPoint: FixedPoint } => {
const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
startOrEnd === "start" ? 0 : -1,
elementsMap,
);
// Convert the global point to element-local coordinates
const elementCenter = pointFrom(
hoveredElement.x + hoveredElement.width / 2,
hoveredElement.y + hoveredElement.height / 2,
);
// Rotate the point to account for element rotation
const nonRotatedPoint = pointRotateRads(
edgePoint,
elementCenter,
-hoveredElement.angle as Radians,
);
// Calculate the ratio relative to the element's bounds
const fixedPointX =
(nonRotatedPoint[0] - hoveredElement.x) / hoveredElement.width;
const fixedPointY =
(nonRotatedPoint[1] - hoveredElement.y) / hoveredElement.height;
return {
fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]),
};
};
const maybeCalculateNewGapWhenScaling = ( const maybeCalculateNewGapWhenScaling = (
changedElement: ExcalidrawBindableElement, changedElement: ExcalidrawBindableElement,
currentBinding: PointBinding | null | undefined, currentBinding: PointBinding | null | undefined,

View File

@ -88,8 +88,12 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "ellipse-1", "elementId": "ellipse-1",
"focus": -0.007519379844961235, "fixedPoint": [
"gap": 11.562288374879595, 0.04,
0.4633333333333333,
],
"focus": 0,
"gap": 0,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -174,8 +178,12 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "diamond-1", "elementId": "diamond-1",
"fixedPoint": [
0.9357142857142857,
0.5001,
],
"focus": 0, "focus": 0,
"gap": 4.535423522449215, "gap": 0,
}, },
"strokeColor": "#e67700", "strokeColor": "#e67700",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -1539,8 +1547,12 @@ exports[`Test Transform > should transform the elements correctly when linear el
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "B", "elementId": "B",
"fixedPoint": [
0.46387050630528887,
0.48466257668711654,
],
"focus": 0, "focus": 0,
"gap": 32, "gap": 0,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,

View File

@ -28,9 +28,14 @@ import { LinearElementEditor } from "@excalidraw/element";
import { bumpVersion } from "@excalidraw/element"; import { bumpVersion } from "@excalidraw/element";
import { getContainerElement } from "@excalidraw/element"; import { getContainerElement } from "@excalidraw/element";
import { detectLineHeight } from "@excalidraw/element"; import { detectLineHeight } from "@excalidraw/element";
import {
isPointInElement,
calculateFixedPointForNonElbowArrowBinding,
} from "@excalidraw/element";
import { import {
isArrowBoundToElement, isArrowBoundToElement,
isArrowElement, isArrowElement,
isBindableElement,
isElbowArrow, isElbowArrow,
isFixedPointBinding, isFixedPointBinding,
isLinearElement, isLinearElement,
@ -519,6 +524,87 @@ const repairFrameMembership = (
} }
}; };
/**
* Migrates old PointBinding to FixedPointBinding for non-elbow arrows
* when arrow endpoints are inside bindable shapes.
*
* NOTE mutates element.
*/
const migratePointBindingToFixedPoint = (
element: Mutable<ExcalidrawElement>,
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
) => {
if (!isArrowElement(element) || isElbowArrow(element)) {
return;
}
let shouldUpdateElement = false;
let newStartBinding: FixedPointBinding | PointBinding | null =
element.startBinding;
let newEndBinding: FixedPointBinding | PointBinding | null =
element.endBinding;
// Check start binding
if (element.startBinding && !isFixedPointBinding(element.startBinding)) {
const boundElement = elementsMap.get(element.startBinding.elementId);
if (boundElement && isBindableElement(boundElement)) {
const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
0,
elementsMap,
);
if (isPointInElement(edgePoint, boundElement, elementsMap)) {
const { fixedPoint } = calculateFixedPointForNonElbowArrowBinding(
element,
boundElement,
"start",
elementsMap,
);
newStartBinding = {
elementId: element.startBinding.elementId,
focus: 0,
gap: 0,
fixedPoint,
};
shouldUpdateElement = true;
}
}
}
// Check end binding
if (element.endBinding && !isFixedPointBinding(element.endBinding)) {
const boundElement = elementsMap.get(element.endBinding.elementId);
if (boundElement && isBindableElement(boundElement)) {
const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
-1,
elementsMap,
);
if (isPointInElement(edgePoint, boundElement, elementsMap)) {
const { fixedPoint } = calculateFixedPointForNonElbowArrowBinding(
element,
boundElement,
"end",
elementsMap,
);
newEndBinding = {
elementId: element.endBinding.elementId,
focus: 0,
gap: 0,
fixedPoint,
};
shouldUpdateElement = true;
}
}
}
if (shouldUpdateElement) {
(element as Mutable<ExcalidrawLinearElement>).startBinding =
newStartBinding;
(element as Mutable<ExcalidrawLinearElement>).endBinding = newEndBinding;
}
};
export const restoreElements = ( export const restoreElements = (
elements: ImportedDataState["elements"], elements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */ /** NOTE doesn't serve for reconciliation */
@ -598,6 +684,9 @@ export const restoreElements = (
(element as Mutable<ExcalidrawLinearElement>).endBinding = null; (element as Mutable<ExcalidrawLinearElement>).endBinding = null;
} }
} }
// Migrate old PointBinding to FixedPointBinding for non-elbow arrows
migratePointBindingToFixedPoint(element, restoredElementsMap);
} }
// NOTE (mtolmacs): Temporary fix for extremely large arrows // NOTE (mtolmacs): Temporary fix for extremely large arrows

View File

@ -195,7 +195,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "99.19972", "height": 150,
"id": "id4", "id": "id4",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@ -209,8 +209,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0, 0,
], ],
[ [
"98.40611", "124.00500",
"99.19972", 150,
], ],
], ],
"roughness": 1, "roughness": 1,
@ -225,7 +225,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 35, "version": 35,
"width": "98.40611", "width": "124.00500",
"x": 1, "x": 1,
"y": 0, "y": 0,
} }
@ -332,15 +332,15 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
"height": "68.58402", "height": "104.34908",
"points": [ "points": [
[ [
0, 0,
0, 0,
], ],
[ [
98, "124.00500",
"68.58402", "104.34908",
], ],
], ],
"startBinding": { "startBinding": {
@ -356,7 +356,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"focus": "-0.02000", "focus": "-0.02000",
"gap": 1, "gap": 1,
}, },
"height": "0.00656", "height": "0.00849",
"points": [ "points": [
[ [
0, 0,
@ -364,7 +364,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
[ [
"98.00000", "98.00000",
"-0.00656", "-0.00849",
], ],
], ],
"startBinding": { "startBinding": {
@ -415,15 +415,15 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
}, },
"id4": { "id4": {
"deleted": { "deleted": {
"height": "99.19972", "height": 150,
"points": [ "points": [
[ [
0, 0,
0, 0,
], ],
[ [
"98.40611", "124.00500",
"99.19972", 150,
], ],
], ],
"startBinding": null, "startBinding": null,
@ -431,15 +431,15 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
"height": "68.58402", "height": "104.34908",
"points": [ "points": [
[ [
0, 0,
0, 0,
], ],
[ [
98, "124.00500",
"68.58402", "104.34908",
], ],
], ],
"startBinding": { "startBinding": {
@ -448,7 +448,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"gap": 1, "gap": 1,
}, },
"version": 33, "version": 33,
"y": "35.82151", "y": "45.65092",
}, },
}, },
}, },
@ -1228,7 +1228,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "1.36342", "height": "49.99000",
"id": "id4", "id": "id4",
"index": "Zz", "index": "Zz",
"isDeleted": false, "isDeleted": false,
@ -1242,8 +1242,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0, 0,
], ],
[ [
98, "150.01000",
"1.36342", "49.99000",
], ],
], ],
"roughness": 1, "roughness": 1,
@ -1264,9 +1264,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 11, "version": 11,
"width": 98, "width": "150.01000",
"x": 1, "x": 0,
"y": 0, "y": "0.01000",
} }
`; `;
@ -1591,7 +1591,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "1.36342", "height": "49.99000",
"id": "id5", "id": "id5",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
@ -1605,8 +1605,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0, 0,
], ],
[ [
98, "249.99000",
"1.36342", "-49.99000",
], ],
], ],
"roughness": 1, "roughness": 1,
@ -1627,9 +1627,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 11, "version": 11,
"width": 98, "width": "249.99000",
"x": 1, "x": "-49.99000",
"y": 0, "y": 50,
} }
`; `;
@ -1741,7 +1741,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "1.36342", "height": "49.99000",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
@ -1754,8 +1754,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0, 0,
], ],
[ [
98, "249.99000",
"1.36342", "-49.99000",
], ],
], ],
"roughness": 1, "roughness": 1,
@ -1775,9 +1775,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 11, "version": 11,
"width": 98, "width": "249.99000",
"x": 1, "x": "-49.99000",
"y": 0, "y": 50,
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,