Files
excalidraw/packages/element/src/binding.ts
Mark Tolmacs a7679363b1 Tests added
Fix binding

Remove unneeded params

Unfinished simple arrow avoidance

Fix newly created jumping arrow when gets outside

Do not apply the jumping logic to elbow arrows for new elements

Existing arrows now jump out

Type updates to support fixed binding for simple arrows

Fix crash for elbow arrws in mutateElement()

Refactored simple arrow creation

Updating tests

No confirm threshold when inside biding range

Fix multi-point arrow grid off

Make elbow arrows respect grids

Unbind arrow if bound and moved at shaft of arrow key

Fix binding test

Fix drag unbind when the bound element is in the selection

Do not move mid point for simple arrows bound on both ends

Add test for mobing mid points for simple arrows when bound on the same element on both ends

Fix linear editor bug when both midpoint and endpoint is moved

Fix all point multipoint arrow highlight and binding

Arrow dragging gets a little drag to avoid accidental unbinding

Fixed point binding for simple arrows when the arrow doesn't point to the element

Fix binding disabled use-case triggering arrow editor

Timed binding mode change for simple arrows

Apply fixes

Remove code to unbind on drag

Update simple arrow fixed point when arrow is dragged or moved by arrow keys

Binding highlight fixes

Change bind mode timeout logic

Fix tests

Add Alt bindMode switch

 No dragging of arrows when bound, similar to elbow

Fix timeout not taking effect immediately

Bumop z-index for arrows when dragged

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Only transparent bindables allow binding fallthrough

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix point click array creation interaction with fixed point binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Restrict new behavior to arrows only

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Allow binding inside images

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix already existing fixed binding retention

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Refactor and implement fixed point binding for unfilled elements

Restore drag

Removed point binding

Binding code refactor

Added centered focus point

Binding & focus point debug

Add invariants to check binding integrity in elements

Binding fixes

Small refactors

Completely rewritten binding

Include point updates after binding update

Fix point updates when endpoint dragged and opposite endpoint orbits

centered focus point only for new arrows

Make z-index arrow reorder on bind

Turn off inside binding mode after leaving a shape

Remove invariants from debug

feat: expose `applyTo` options, don't commit empty text element (#9744)

* Expose applyTo options, skip re-draw for empty text

* Don't commit empty text elements

test: added test file for distribute (#9754)

z-index update

Bind mode on precise binding

Fix binding to inside element

Fix initial arrow not following cursor (white dot)

Fix elbow arrow

Fix z-index so it works on hover

Fix fixed angle orbiting
2025-07-25 13:48:49 +02:00

1801 lines
51 KiB
TypeScript

import { KEYS, arrayToMap, invariant, tupleToCoors } from "@excalidraw/common";
import {
lineSegment,
pointFrom,
pointRotateRads,
type GlobalPoint,
vectorFromPoint,
pointDistanceSq,
clamp,
pointDistance,
pointFromVector,
vectorScale,
vectorNormalize,
PRECISION,
} from "@excalidraw/math";
import type { LineSegment, LocalPoint, Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
import {
doBoundsIntersect,
getCenterForBounds,
getElementBounds,
} from "./bounds";
import {
bindingBorderTest,
getHoveredElementForBinding,
getHoveredElementForBindingAndIfItsPrecise,
hitElementItself,
intersectElementWithLineSegment,
maxBindingDistanceFromOutline,
} from "./collision";
import { distanceToElement } from "./distance";
import {
headingForPointFromElement,
headingIsHorizontal,
vectorToHeading,
type Heading,
} from "./heading";
import { LinearElementEditor } from "./linearElementEditor";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import {
isArrowElement,
isBindableElement,
isBoundToContainer,
isElbowArrow,
isRectanguloidElement,
isTextElement,
} from "./typeChecks";
import { aabbForElement, elementCenterPoint } from "./bounds";
import { updateElbowArrowPoints } from "./elbowArrow";
import type { Scene } from "./Scene";
import type { Bounds } from "./bounds";
import type { ElementUpdate } from "./mutateElement";
import type {
ExcalidrawBindableElement,
ExcalidrawElement,
NonDeleted,
NonDeletedExcalidrawElement,
ElementsMap,
NonDeletedSceneElementsMap,
ExcalidrawTextElement,
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
FixedPoint,
FixedPointBinding,
PointsPositionUpdates,
Ordered,
BindMode,
} from "./types";
export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement>
| SuggestedPointBinding;
export type SuggestedPointBinding = [
NonDeleted<ExcalidrawArrowElement>,
"start" | "end" | "both",
NonDeleted<ExcalidrawBindableElement>,
];
export type BindingStrategy =
// Create a new binding with this mode
| {
mode: BindMode;
element: NonDeleted<ExcalidrawBindableElement>;
focusPoint?: GlobalPoint;
}
// Break the binding
| {
mode: null;
element?: undefined;
focusPoint?: undefined;
}
// Keep the existing binding
| {
mode: undefined;
element?: undefined;
focusPoint?: undefined;
};
export const FIXED_BINDING_DISTANCE = 5;
export const BINDING_HIGHLIGHT_THICKNESS = 10;
export const shouldEnableBindingForPointerEvent = (
event: React.PointerEvent<HTMLElement>,
) => {
return !event[KEYS.CTRL_OR_CMD];
};
export const isBindingEnabled = (appState: AppState): boolean => {
return appState.isBindingEnabled;
};
export const bindOrUnbindBindingElement = (
arrow: NonDeleted<ExcalidrawArrowElement>,
draggingPoints: PointsPositionUpdates,
scene: Scene,
appState: AppState,
opts?: {
newArrow: boolean;
},
) => {
const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints(
arrow,
draggingPoints,
scene.getNonDeletedElementsMap(),
scene.getNonDeletedElements(),
appState,
opts,
);
bindOrUnbindBindingElementEdge(arrow, start, "start", scene);
bindOrUnbindBindingElementEdge(arrow, end, "end", scene);
if (!isElbowArrow(arrow) && (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);
}
return { start, end };
};
const bindOrUnbindBindingElementEdge = (
arrow: NonDeleted<ExcalidrawArrowElement>,
{ mode, element, focusPoint }: BindingStrategy,
startOrEnd: "start" | "end",
scene: Scene,
): void => {
if (mode === null) {
// null means break the binding
unbindBindingElement(arrow, startOrEnd, scene);
} else if (mode !== undefined) {
bindBindingElement(arrow, element, mode, startOrEnd, scene, focusPoint);
}
};
const getOriginalBindingsIfStillCloseToBindingEnds = (
linearElement: NonDeleted<ExcalidrawArrowElement>,
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawElement> | null)[] =>
(["start", "end"] as const).map((edge) => {
const coors = tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
edge === "start" ? 0 : -1,
elementsMap,
),
);
const elementId =
edge === "start"
? linearElement.startBinding?.elementId
: linearElement.endBinding?.elementId;
if (elementId) {
const element = elementsMap.get(elementId);
if (
isBindableElement(element) &&
bindingBorderTest(
element,
pointFrom<GlobalPoint>(coors.x, coors.y),
elementsMap,
zoom,
)
) {
return element;
}
}
return null;
});
const bindingStrategyForEndpointDragging = (
point: GlobalPoint,
oppositeBinding: FixedPointBinding | null,
elementsMap: NonDeletedSceneElementsMap,
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 };
const { hovered, hit } = getHoveredElementForBindingAndIfItsPrecise(
point,
elements,
elementsMap,
zoom,
);
// If the global bind mode is in free binding mode, just bind
// where the pointer is and keep the other end intact
if (globalBindMode === "inside") {
current = hovered
? { element: hovered, mode: hit ? "inside" : "outside" }
: { mode: undefined };
return { current, other };
}
// Dragged start point is outside of any bindable element
// so we break any existing binding
if (!hovered) {
return { current: { mode: null }, other };
}
// Dragged point is on the binding gap of a bindable element
if (!hit) {
// If the opposite binding (if exists) is on the same element
if (oppositeBinding) {
if (oppositeBinding.elementId === hovered.id) {
return { current: { mode: null }, other };
}
// The opposite binding is on a different element
// eslint-disable-next-line no-else-return
else {
current = {
element: hovered,
mode: "orbit",
focusPoint: opts?.newArrow
? pointFrom<GlobalPoint>(
hovered.x + hovered.width / 2,
hovered.y + hovered.height / 2,
)
: undefined,
};
return { current, other };
}
}
// No opposite binding or the opposite binding is on a different element
current = { element: hovered, mode: "orbit" };
}
// The dragged point is inside the hovered bindable element
else {
// The opposite binding is on the same element
// eslint-disable-next-line no-lonely-if
if (oppositeBinding) {
if (oppositeBinding.elementId === hovered.id) {
// The opposite binding is on the binding gap of the same element
if (oppositeBinding.mode !== "inside") {
current = { element: hovered, mode: "orbit" };
other = { mode: null };
return { current, other };
}
// The opposite binding is inside the same element
// eslint-disable-next-line no-else-return
else {
current = { element: hovered, mode: "inside" };
return { current, other };
}
}
// The opposite binding is on a different element
// eslint-disable-next-line no-else-return
else {
current = {
element: hovered,
mode: "orbit",
focusPoint: opts?.newArrow
? pointFrom<GlobalPoint>(
hovered.x + hovered.width / 2,
hovered.y + hovered.height / 2,
)
: undefined,
};
return { current, other };
}
}
// The opposite binding is on a different element or no binding
else {
current = {
element: hovered,
mode: "orbit",
};
}
}
// Must return as only one endpoint is dragged, therefore
// the end binding strategy might accidentally gets overriden
return { current, other };
};
const getBindingStrategyForDraggingBindingElementEndpoints = (
arrow: NonDeleted<ExcalidrawArrowElement>,
draggingPoints: PointsPositionUpdates,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
appState: AppState,
opts?: {
newArrow: boolean;
},
): { start: BindingStrategy; end: BindingStrategy } => {
const globalBindMode = appState.bindMode || "focus";
const startIdx = 0;
const endIdx = arrow.points.length - 1;
const startDragged = draggingPoints.has(startIdx);
const endDragged = draggingPoints.has(endIdx);
let start: BindingStrategy = { mode: undefined };
let end: BindingStrategy = { mode: undefined };
// Special case for single point new arrows
if (arrow.points.length === 1) {
invariant(startDragged, "Single point arrow must have start dragged");
const localPoint = draggingPoints.get(0)?.point as LocalPoint;
const globalPoint = LinearElementEditor.getPointGlobalCoordinates(
arrow,
localPoint,
elementsMap,
);
const { hovered, hit } = getHoveredElementForBindingAndIfItsPrecise(
globalPoint,
elements,
elementsMap,
appState.zoom,
);
return {
start: hovered
? 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 },
};
}
// If none of the ends are dragged, we don't change anything
if (!startDragged && !endDragged) {
return { start, end };
}
// If both ends are dragged, we don't bind to anything
// and break existing bindings
if (startDragged && endDragged) {
return { start: { mode: null }, end: { mode: null } };
}
// If binding is disabled and an endpoint is dragged,
// we actively break the end binding
if (!isBindingEnabled(appState)) {
start = startDragged ? { mode: null } : start;
end = endDragged ? { mode: null } : end;
return { start, end };
}
// Only the start point is dragged
if (startDragged) {
const localPoint = draggingPoints.get(startIdx)?.point;
invariant(localPoint, "Local point must be defined for start dragging");
const globalPoint = LinearElementEditor.getPointGlobalCoordinates(
arrow,
localPoint,
elementsMap,
);
const { current, other } = bindingStrategyForEndpointDragging(
globalPoint,
arrow.endBinding,
elementsMap,
elements,
appState.zoom,
globalBindMode,
opts,
);
return { start: current, end: other };
}
// Only the end point is dragged
if (endDragged) {
const localPoint = draggingPoints.get(endIdx)?.point;
invariant(localPoint, "Local point must be defined for end dragging");
const globalPoint = LinearElementEditor.getPointGlobalCoordinates(
arrow,
localPoint,
elementsMap,
);
const { current, other } = bindingStrategyForEndpointDragging(
globalPoint,
arrow.startBinding,
elementsMap,
elements,
appState.zoom,
globalBindMode,
opts,
);
return { start: other, end: current };
}
return { start, end };
};
export const bindOrUnbindBindingElements = (
selectedArrows: NonDeleted<ExcalidrawArrowElement>[],
scene: Scene,
appState: AppState,
): void => {
selectedArrows.forEach((arrow) => {
bindOrUnbindBindingElement(
arrow,
new Map(), // No dragging points in this case
scene,
appState,
);
});
};
export const getSuggestedBindingsForBindingElements = (
selectedElements: NonDeleted<ExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
zoom: AppState["zoom"],
): SuggestedBinding[] => {
// HOT PATH: Bail out if selected elements list is too large
if (selectedElements.length > 50) {
return [];
}
return (
selectedElements
.filter(isArrowElement)
.flatMap((element) =>
getOriginalBindingsIfStillCloseToBindingEnds(
element,
elementsMap,
zoom,
),
)
.filter(
(element): element is NonDeleted<ExcalidrawBindableElement> =>
element !== null,
)
// Filter out bind candidates which are in the
// same selection / group with the arrow
//
// TODO: Is it worth turning the list into a set to avoid dupes?
.filter(
(element) =>
selectedElements.filter((selected) => selected.id === element?.id)
.length === 0,
)
);
};
export const maybeSuggestBindingsForBindingElementAtCoords = (
linearElement: NonDeleted<ExcalidrawArrowElement>,
startOrEndOrBoth: "start" | "end" | "both",
scene: Scene,
zoom: AppState["zoom"],
): ExcalidrawBindableElement[] => {
const startCoords = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
0,
scene.getNonDeletedElementsMap(),
);
const endCoords = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
-1,
scene.getNonDeletedElementsMap(),
);
const startHovered = getHoveredElementForBinding(
startCoords,
scene.getNonDeletedElements(),
scene.getNonDeletedElementsMap(),
zoom,
);
const endHovered = getHoveredElementForBinding(
endCoords,
scene.getNonDeletedElements(),
scene.getNonDeletedElementsMap(),
zoom,
);
const suggestedBindings = [];
if (startHovered != null && startHovered.id === endHovered?.id) {
const hitStart = hitElementItself({
element: startHovered,
elementsMap: scene.getNonDeletedElementsMap(),
point: pointFrom<GlobalPoint>(startCoords[0], startCoords[1]),
threshold: 0,
});
const hitEnd = hitElementItself({
element: endHovered,
elementsMap: scene.getNonDeletedElementsMap(),
point: pointFrom<GlobalPoint>(endCoords[0], endCoords[1]),
threshold: 0,
});
if (hitStart && hitEnd) {
suggestedBindings.push(startHovered);
}
} else if (startOrEndOrBoth === "start" && startHovered != null) {
suggestedBindings.push(startHovered);
} else if (startOrEndOrBoth === "end" && endHovered != null) {
suggestedBindings.push(endHovered);
}
return suggestedBindings;
};
export const bindBindingElement = (
arrow: NonDeleted<ExcalidrawArrowElement>,
hoveredElement: ExcalidrawBindableElement,
mode: BindMode,
startOrEnd: "start" | "end",
scene: Scene,
focusPoint?: GlobalPoint,
): void => {
const elementsMap = scene.getNonDeletedElementsMap();
let binding: FixedPointBinding;
if (isElbowArrow(arrow)) {
binding = {
elementId: hoveredElement.id,
mode: "orbit",
...calculateFixedPointForElbowArrowBinding(
arrow,
hoveredElement,
startOrEnd,
elementsMap,
),
};
} else {
binding = {
elementId: hoveredElement.id,
mode,
...calculateFixedPointForNonElbowArrowBinding(
arrow,
hoveredElement,
startOrEnd,
elementsMap,
focusPoint,
),
};
}
scene.mutateElement(arrow, {
[startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
});
const boundElementsMap = arrayToMap(hoveredElement.boundElements || []);
if (!boundElementsMap.has(arrow.id)) {
scene.mutateElement(hoveredElement, {
boundElements: (hoveredElement.boundElements || []).concat({
id: arrow.id,
type: "arrow",
}),
});
}
};
export const unbindBindingElement = (
arrow: NonDeleted<ExcalidrawArrowElement>,
startOrEnd: "start" | "end",
scene: Scene,
): ExcalidrawBindableElement["id"] | null => {
const field = startOrEnd === "start" ? "startBinding" : "endBinding";
const binding = arrow[field];
if (binding == null) {
return null;
}
const oppositeBinding =
arrow[startOrEnd === "start" ? "endBinding" : "startBinding"];
if (oppositeBinding?.elementId !== binding.elementId) {
// Only remove the record on the bound element if the other
// end is not bound to the same element
const boundElement = scene
.getNonDeletedElementsMap()
.get(binding.elementId) as ExcalidrawBindableElement;
scene.mutateElement(boundElement, {
boundElements: boundElement.boundElements?.filter(
(element) => element.id !== arrow.id,
),
});
}
scene.mutateElement(arrow, { [field]: null });
return binding.elementId;
};
// Supports translating, rotating and scaling `changedElement` with bound
// linear elements.
export const updateBoundElements = (
changedElement: NonDeletedExcalidrawElement,
scene: Scene,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
changedElements?: Map<string, ExcalidrawElement>;
},
) => {
if (!isBindableElement(changedElement)) {
return;
}
const { simultaneouslyUpdated } = options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated,
);
let elementsMap: ElementsMap = scene.getNonDeletedElementsMap();
if (options?.changedElements) {
elementsMap = new Map(elementsMap) as typeof elementsMap;
options.changedElements.forEach((element) => {
elementsMap.set(element.id, element);
});
}
boundElementsVisitor(elementsMap, changedElement, (element) => {
if (!isArrowElement(element) || element.isDeleted) {
return;
}
// In case the boundElements are stale
if (!doesNeedUpdate(element, changedElement)) {
return;
}
// Check for intersections before updating bound elements incase connected elements overlap
const startBindingElement = element.startBinding
? elementsMap.get(element.startBinding.elementId)
: null;
const endBindingElement = element.endBinding
? // PERF: If the arrow is bound to the same element on both ends.
startBindingElement?.id === element.endBinding.elementId
? startBindingElement
: elementsMap.get(element.endBinding.elementId)
: null;
let startBounds: Bounds | null = null;
let endBounds: Bounds | null = null;
if (startBindingElement && endBindingElement) {
startBounds = getElementBounds(startBindingElement, elementsMap);
endBounds = getElementBounds(endBindingElement, elementsMap);
}
// `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(element.id)) {
return;
}
const updates = bindableElementsVisitor(
elementsMap,
element,
(bindableElement, bindingProp) => {
if (
bindableElement &&
isBindableElement(bindableElement) &&
(bindingProp === "startBinding" || bindingProp === "endBinding") &&
(changedElement.id === element[bindingProp]?.elementId ||
(changedElement.id ===
element[
bindingProp === "startBinding" ? "endBinding" : "startBinding"
]?.elementId &&
!doBoundsIntersect(startBounds, endBounds)))
) {
const point = updateBoundPoint(
element,
bindingProp,
element[bindingProp],
bindableElement,
elementsMap,
);
if (point) {
return [
bindingProp === "startBinding" ? 0 : element.points.length - 1,
{ point },
] as MapEntry<PointsPositionUpdates>;
}
}
return null;
},
).filter(
(update): update is MapEntry<PointsPositionUpdates> => update !== null,
);
LinearElementEditor.movePoints(element, scene, new Map(updates), {
moveMidPointsWithElement:
!!startBindingElement &&
startBindingElement?.id === endBindingElement?.id,
});
const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !boundText.isDeleted) {
handleBindTextResize(element, scene, false);
}
});
};
export const updateBindings = (
latestElement: ExcalidrawElement,
scene: Scene,
appState: AppState,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
},
) => {
if (isArrowElement(latestElement)) {
bindOrUnbindBindingElement(latestElement, new Map(), scene, appState);
} else {
updateBoundElements(latestElement, scene, {
...options,
changedElements: new Map([[latestElement.id, latestElement]]),
});
}
};
const doesNeedUpdate = (
boundElement: NonDeleted<ExcalidrawArrowElement>,
changedElement: ExcalidrawBindableElement,
) => {
return (
boundElement.startBinding?.elementId === changedElement.id ||
boundElement.endBinding?.elementId === changedElement.id
);
};
const getSimultaneouslyUpdatedElementIds = (
simultaneouslyUpdated: readonly ExcalidrawElement[] | undefined,
): Set<ExcalidrawElement["id"]> => {
return new Set((simultaneouslyUpdated || []).map((element) => element.id));
};
export const getHeadingForElbowArrowSnap = (
p: Readonly<GlobalPoint>,
otherPoint: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement | undefined | null,
aabb: Bounds | undefined | null,
origPoint: GlobalPoint,
elementsMap: ElementsMap,
zoom?: AppState["zoom"],
): Heading => {
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
if (!bindableElement || !aabb) {
return otherPointHeading;
}
const d = distanceToElement(bindableElement, elementsMap, origPoint);
const bindDistance = maxBindingDistanceFromOutline(
bindableElement,
bindableElement.width,
bindableElement.height,
zoom,
);
const distance = d > bindDistance ? null : d;
if (!distance) {
return vectorToHeading(
vectorFromPoint(p, elementCenterPoint(bindableElement, elementsMap)),
);
}
return headingForPointFromElement(bindableElement, aabb, p);
};
export const bindPointToSnapToElementOutline = (
linearElement: ExcalidrawArrowElement,
bindableElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
customIntersector?: LineSegment<GlobalPoint>,
): GlobalPoint => {
const aabb = aabbForElement(bindableElement, elementsMap);
const localP =
linearElement.points[
startOrEnd === "start" ? 0 : linearElement.points.length - 1
];
const globalP = pointFrom<GlobalPoint>(
linearElement.x + localP[0],
linearElement.y + localP[1],
);
if (linearElement.points.length < 2) {
// New arrow creation, so no snapping
return globalP;
}
const edgePoint = isRectanguloidElement(bindableElement)
? avoidRectangularCorner(bindableElement, elementsMap, globalP)
: globalP;
const elbowed = isElbowArrow(linearElement);
const center = getCenterForBounds(aabb);
const adjacentPointIdx =
startOrEnd === "start" ? 1 : linearElement.points.length - 2;
const adjacentPoint = pointRotateRads(
pointFrom<GlobalPoint>(
linearElement.x + linearElement.points[adjacentPointIdx][0],
linearElement.y + linearElement.points[adjacentPointIdx][1],
),
center,
linearElement.angle ?? 0,
);
let intersection: GlobalPoint | null = null;
if (elbowed) {
const isHorizontal = headingIsHorizontal(
headingForPointFromElement(bindableElement, aabb, globalP),
);
const snapPoint = snapToMid(bindableElement, elementsMap, edgePoint);
const otherPoint = pointFrom<GlobalPoint>(
isHorizontal ? center[0] : snapPoint[0],
!isHorizontal ? center[1] : snapPoint[1],
);
const intersector =
customIntersector ??
lineSegment(
otherPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(snapPoint, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
otherPoint,
),
);
intersection = intersectElementWithLineSegment(
bindableElement,
elementsMap,
intersector,
FIXED_BINDING_DISTANCE,
).sort(pointDistanceSq)[0];
} else {
const intersector =
customIntersector ??
lineSegment(
adjacentPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)),
pointDistance(edgePoint, adjacentPoint) +
Math.max(bindableElement.width, bindableElement.height) * 2,
),
adjacentPoint,
),
);
intersection = intersectElementWithLineSegment(
bindableElement,
elementsMap,
intersector,
FIXED_BINDING_DISTANCE,
).sort(
(g, h) =>
pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint),
)[0];
}
if (
!intersection ||
// Too close to determine vector from intersection to edgePoint
pointDistanceSq(edgePoint, intersection) < PRECISION
) {
return edgePoint;
}
return intersection;
};
export const getOutlineAvoidingPoint = (
element: NonDeleted<ExcalidrawArrowElement>,
hoveredElement: ExcalidrawBindableElement | null,
coords: GlobalPoint,
pointIndex: number,
elementsMap: ElementsMap,
customIntersector?: LineSegment<GlobalPoint>,
): GlobalPoint => {
if (hoveredElement) {
const newPoints = Array.from(element.points);
newPoints[pointIndex] = pointFrom<LocalPoint>(
coords[0] - element.x,
coords[1] - element.y,
);
return bindPointToSnapToElementOutline(
{
...element,
points: newPoints,
},
hoveredElement,
pointIndex === 0 ? "start" : "end",
elementsMap,
customIntersector,
);
}
return coords;
};
export const avoidRectangularCorner = (
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
p: GlobalPoint,
): GlobalPoint => {
const center = elementCenterPoint(element, elementsMap);
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
// Top left
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
return pointRotateRads<GlobalPoint>(
pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y),
center,
element.angle,
);
}
return pointRotateRads(
pointFrom(element.x, element.y - FIXED_BINDING_DISTANCE),
center,
element.angle,
);
} else if (
nonRotatedPoint[0] < element.x &&
nonRotatedPoint[1] > element.y + element.height
) {
// Bottom left
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
return pointRotateRads(
pointFrom(
element.x,
element.y + element.height + FIXED_BINDING_DISTANCE,
),
center,
element.angle,
);
}
return pointRotateRads(
pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
center,
element.angle,
);
} else if (
nonRotatedPoint[0] > element.x + element.width &&
nonRotatedPoint[1] > element.y + element.height
) {
// Bottom right
if (
nonRotatedPoint[0] - element.x <
element.width + FIXED_BINDING_DISTANCE
) {
return pointRotateRads(
pointFrom(
element.x + element.width,
element.y + element.height + FIXED_BINDING_DISTANCE,
),
center,
element.angle,
);
}
return pointRotateRads(
pointFrom(
element.x + element.width + FIXED_BINDING_DISTANCE,
element.y + element.height,
),
center,
element.angle,
);
} else if (
nonRotatedPoint[0] > element.x + element.width &&
nonRotatedPoint[1] < element.y
) {
// Top right
if (
nonRotatedPoint[0] - element.x <
element.width + FIXED_BINDING_DISTANCE
) {
return pointRotateRads(
pointFrom(
element.x + element.width,
element.y - FIXED_BINDING_DISTANCE,
),
center,
element.angle,
);
}
return pointRotateRads(
pointFrom(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
center,
element.angle,
);
}
return p;
};
export const snapToMid = (
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
p: GlobalPoint,
tolerance: number = 0.05,
): GlobalPoint => {
const { x, y, width, height, angle } = element;
const center = elementCenterPoint(element, elementsMap, -0.1, -0.1);
const nonRotated = pointRotateRads(p, center, -angle as Radians);
// snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance
const verticalThreshold = clamp(tolerance * height, 5, 80);
const horizontalThreshold = clamp(tolerance * width, 5, 80);
if (
nonRotated[0] <= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold
) {
// LEFT
return pointRotateRads<GlobalPoint>(
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
center,
angle,
);
} else if (
nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold
) {
// TOP
return pointRotateRads(
pointFrom(center[0], y - FIXED_BINDING_DISTANCE),
center,
angle,
);
} else if (
nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThreshold
) {
// RIGHT
return pointRotateRads(
pointFrom(x + width + FIXED_BINDING_DISTANCE, center[1]),
center,
angle,
);
} else if (
nonRotated[1] >= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThreshold
) {
// DOWN
return pointRotateRads(
pointFrom(center[0], y + height + FIXED_BINDING_DISTANCE),
center,
angle,
);
} else if (element.type === "diamond") {
const distance = FIXED_BINDING_DISTANCE;
const topLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + height / 4 - distance,
);
const topRight = pointFrom<GlobalPoint>(
x + (3 * width) / 4 + distance,
y + height / 4 - distance,
);
const bottomLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance,
y + (3 * height) / 4 + distance,
);
const bottomRight = pointFrom<GlobalPoint>(
x + (3 * width) / 4 + distance,
y + (3 * height) / 4 + distance,
);
if (
pointDistance(topLeft, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(topLeft, center, angle);
}
if (
pointDistance(topRight, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(topRight, center, angle);
}
if (
pointDistance(bottomLeft, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(bottomLeft, center, angle);
}
if (
pointDistance(bottomRight, nonRotated) <
Math.max(horizontalThreshold, verticalThreshold)
) {
return pointRotateRads(bottomRight, center, angle);
}
}
return p;
};
export const updateBoundPoint = (
arrow: NonDeleted<ExcalidrawArrowElement>,
startOrEnd: "startBinding" | "endBinding",
binding: FixedPointBinding | null | undefined,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): LocalPoint | null => {
if (
binding == null ||
// We only need to update the other end if this is a 2 point line element
(binding.elementId !== bindableElement.id && arrow.points.length > 2)
) {
return null;
}
const fixedPoint = normalizeFixedPoint(binding.fixedPoint);
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(
element,
bindableElement,
global,
startOrEnd === "startBinding" ? 0 : arrow.points.length - 1,
elementsMap,
)
: global;
return LinearElementEditor.pointFromAbsoluteCoords(
arrow,
maybeOutlineGlobal,
elementsMap,
);
};
export const calculateFixedPointForElbowArrowBinding = (
linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): { fixedPoint: FixedPoint } => {
const bounds = [
hoveredElement.x,
hoveredElement.y,
hoveredElement.x + hoveredElement.width,
hoveredElement.y + hoveredElement.height,
] as Bounds;
const snappedPoint = bindPointToSnapToElementOutline(
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
);
const globalMidPoint = pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
);
const nonRotatedSnappedGlobalPoint = pointRotateRads(
snappedPoint,
globalMidPoint,
-hoveredElement.angle as Radians,
);
return {
fixedPoint: normalizeFixedPoint([
(nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
hoveredElement.width,
(nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
hoveredElement.height,
]),
};
};
export const calculateFixedPointForNonElbowArrowBinding = (
linearElement: NonDeleted<ExcalidrawArrowElement>,
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
focusPoint?: GlobalPoint,
): { fixedPoint: FixedPoint } => {
const edgePoint = focusPoint
? focusPoint
: 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]),
};
};
export const fixDuplicatedBindingsAfterDuplication = (
duplicatedElements: ExcalidrawElement[],
origIdToDuplicateId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
duplicateElementsMap: NonDeletedSceneElementsMap,
) => {
for (const duplicateElement of duplicatedElements) {
if ("boundElements" in duplicateElement && duplicateElement.boundElements) {
Object.assign(duplicateElement, {
boundElements: duplicateElement.boundElements.reduce(
(
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
binding,
) => {
const newBindingId = origIdToDuplicateId.get(binding.id);
if (newBindingId) {
acc.push({ ...binding, id: newBindingId });
}
return acc;
},
[],
),
});
}
if ("containerId" in duplicateElement && duplicateElement.containerId) {
Object.assign(duplicateElement, {
containerId:
origIdToDuplicateId.get(duplicateElement.containerId) ?? null,
});
}
if ("endBinding" in duplicateElement && duplicateElement.endBinding) {
const newEndBindingId = origIdToDuplicateId.get(
duplicateElement.endBinding.elementId,
);
Object.assign(duplicateElement, {
endBinding: newEndBindingId
? {
...duplicateElement.endBinding,
elementId: newEndBindingId,
}
: null,
});
}
if ("startBinding" in duplicateElement && duplicateElement.startBinding) {
const newEndBindingId = origIdToDuplicateId.get(
duplicateElement.startBinding.elementId,
);
Object.assign(duplicateElement, {
startBinding: newEndBindingId
? {
...duplicateElement.startBinding,
elementId: newEndBindingId,
}
: null,
});
}
if (isElbowArrow(duplicateElement)) {
Object.assign(
duplicateElement,
updateElbowArrowPoints(duplicateElement, duplicateElementsMap, {
points: [
duplicateElement.points[0],
duplicateElement.points[duplicateElement.points.length - 1],
],
}),
);
}
}
};
export const fixBindingsAfterDeletion = (
sceneElements: readonly ExcalidrawElement[],
deletedElements: readonly ExcalidrawElement[],
): void => {
const elements = arrayToMap(sceneElements);
for (const element of deletedElements) {
BoundElement.unbindAffected(elements, element, (element, updates) =>
mutateElement(element, elements, updates),
);
BindableElement.unbindAffected(elements, element, (element, updates) =>
mutateElement(element, elements, updates),
);
}
};
const newBoundElements = (
boundElements: ExcalidrawElement["boundElements"],
idsToRemove: Set<ExcalidrawElement["id"]>,
elementsToAdd: Array<ExcalidrawElement> = [],
) => {
if (!boundElements) {
return null;
}
const nextBoundElements = boundElements.filter(
(boundElement) => !idsToRemove.has(boundElement.id),
);
nextBoundElements.push(
...elementsToAdd.map(
(x) =>
({ id: x.id, type: x.type } as
| ExcalidrawArrowElement
| ExcalidrawTextElement),
),
);
return nextBoundElements;
};
export const bindingProperties: Set<BindableProp | BindingProp> = new Set([
"boundElements",
"frameId",
"containerId",
"startBinding",
"endBinding",
]);
export type BindableProp = "boundElements";
export type BindingProp =
| "frameId"
| "containerId"
| "startBinding"
| "endBinding";
type BoundElementsVisitingFunc = (
boundElement: ExcalidrawElement | undefined,
bindingProp: BindableProp,
bindingId: string,
) => void;
type BindableElementVisitingFunc<T> = (
bindableElement: ExcalidrawElement | undefined,
bindingProp: BindingProp,
bindingId: string,
) => T;
/**
* Tries to visit each bound element (does not have to be found).
*/
const boundElementsVisitor = (
elements: ElementsMap,
element: ExcalidrawElement,
visit: BoundElementsVisitingFunc,
) => {
if (isBindableElement(element)) {
// create new instance so that possible mutations won't play a role in visiting order
const boundElements = element.boundElements?.slice() ?? [];
// last added text should be the one we keep (~previous are duplicates)
boundElements.forEach(({ id }) => {
visit(elements.get(id), "boundElements", id);
});
}
};
/**
* Tries to visit each bindable element (does not have to be found).
*/
const bindableElementsVisitor = <T>(
elements: ElementsMap,
element: ExcalidrawElement,
visit: BindableElementVisitingFunc<T>,
): T[] => {
const result: T[] = [];
if (element.frameId) {
const id = element.frameId;
result.push(visit(elements.get(id), "frameId", id));
}
if (isBoundToContainer(element)) {
const id = element.containerId;
result.push(visit(elements.get(id), "containerId", id));
}
if (isArrowElement(element)) {
if (element.startBinding) {
const id = element.startBinding.elementId;
result.push(visit(elements.get(id), "startBinding", id));
}
if (element.endBinding) {
const id = element.endBinding.elementId;
result.push(visit(elements.get(id), "endBinding", id));
}
}
return result;
};
/**
* Bound element containing bindings to `frameId`, `containerId`, `startBinding` or `endBinding`.
*/
export class BoundElement {
/**
* Unbind the affected non deleted bindable elements (removing element from `boundElements`).
* - iterates non deleted bindable elements (`containerId` | `startBinding.elementId` | `endBinding.elementId`) of the current element
* - prepares updates to unbind each bindable element's `boundElements` from the current element
*/
public static unbindAffected(
elements: ElementsMap,
boundElement: ExcalidrawElement | undefined,
updateElementWith: (
affected: ExcalidrawElement,
updates: ElementUpdate<ExcalidrawElement>,
) => void,
) {
if (!boundElement) {
return;
}
bindableElementsVisitor(elements, boundElement, (bindableElement) => {
// bindable element is deleted, this is fine
if (!bindableElement || bindableElement.isDeleted) {
return;
}
boundElementsVisitor(
elements,
bindableElement,
(_, __, boundElementId) => {
if (boundElementId === boundElement.id) {
updateElementWith(bindableElement, {
boundElements: newBoundElements(
bindableElement.boundElements,
new Set([boundElementId]),
),
});
}
},
);
});
}
/**
* Rebind the next affected non deleted bindable elements (adding element to `boundElements`).
* - iterates non deleted bindable elements (`containerId` | `startBinding.elementId` | `endBinding.elementId`) of the current element
* - prepares updates to rebind each bindable element's `boundElements` to the current element
*
* NOTE: rebind expects that affected elements were previously unbound with `BoundElement.unbindAffected`
*/
public static rebindAffected = (
elements: ElementsMap,
boundElement: ExcalidrawElement | undefined,
updateElementWith: (
affected: ExcalidrawElement,
updates: ElementUpdate<ExcalidrawElement>,
) => void,
) => {
// don't try to rebind element that is deleted
if (!boundElement || boundElement.isDeleted) {
return;
}
bindableElementsVisitor(
elements,
boundElement,
(bindableElement, bindingProp) => {
// unbind from bindable elements, as bindings from non deleted elements into deleted elements are incorrect
if (!bindableElement || bindableElement.isDeleted) {
updateElementWith(boundElement, { [bindingProp]: null });
return;
}
// frame bindings are unidirectional, there is nothing to rebind
if (bindingProp === "frameId") {
return;
}
if (
bindableElement.boundElements?.find((x) => x.id === boundElement.id)
) {
return;
}
if (isArrowElement(boundElement)) {
// rebind if not found!
updateElementWith(bindableElement, {
boundElements: newBoundElements(
bindableElement.boundElements,
new Set(),
new Array(boundElement),
),
});
}
if (isTextElement(boundElement)) {
if (!bindableElement.boundElements?.find((x) => x.type === "text")) {
// rebind only if there is no other text bound already
updateElementWith(bindableElement, {
boundElements: newBoundElements(
bindableElement.boundElements,
new Set(),
new Array(boundElement),
),
});
} else {
// unbind otherwise
updateElementWith(boundElement, { [bindingProp]: null });
}
}
},
);
};
}
/**
* Bindable element containing bindings to `boundElements`.
*/
export class BindableElement {
/**
* Unbind the affected non deleted bound elements (resetting `containerId`, `startBinding`, `endBinding` to `null`).
* - iterates through non deleted `boundElements` of the current element
* - prepares updates to unbind each bound element from the current element
*/
public static unbindAffected(
elements: ElementsMap,
bindableElement: ExcalidrawElement | undefined,
updateElementWith: (
affected: ExcalidrawElement,
updates: ElementUpdate<ExcalidrawElement>,
) => void,
) {
if (!bindableElement) {
return;
}
boundElementsVisitor(elements, bindableElement, (boundElement) => {
// bound element is deleted, this is fine
if (!boundElement || boundElement.isDeleted) {
return;
}
bindableElementsVisitor(
elements,
boundElement,
(_, bindingProp, bindableElementId) => {
// making sure there is an element to be unbound
if (bindableElementId === bindableElement.id) {
updateElementWith(boundElement, { [bindingProp]: null });
}
},
);
});
}
/**
* Rebind the affected non deleted bound elements (for now setting only `containerId`, as we cannot rebind arrows atm).
* - iterates through non deleted `boundElements` of the current element
* - prepares updates to rebind each bound element to the current element or unbind it from `boundElements` in case of conflicts
*
* NOTE: rebind expects that affected elements were previously unbound with `BindaleElement.unbindAffected`
*/
public static rebindAffected = (
elements: ElementsMap,
bindableElement: ExcalidrawElement | undefined,
updateElementWith: (
affected: ExcalidrawElement,
updates: ElementUpdate<ExcalidrawElement>,
) => void,
) => {
// don't try to rebind element that is deleted (i.e. updated as deleted)
if (!bindableElement || bindableElement.isDeleted) {
return;
}
boundElementsVisitor(
elements,
bindableElement,
(boundElement, _, boundElementId) => {
// unbind from bindable elements, as bindings from non deleted elements into deleted elements are incorrect
if (!boundElement || boundElement.isDeleted) {
updateElementWith(bindableElement, {
boundElements: newBoundElements(
bindableElement.boundElements,
new Set([boundElementId]),
),
});
return;
}
if (isTextElement(boundElement)) {
const boundElements = bindableElement.boundElements?.slice() ?? [];
// check if this is the last element in the array, if not, there is an previously bound text which should be unbound
if (
boundElements.reverse().find((x) => x.type === "text")?.id ===
boundElement.id
) {
if (boundElement.containerId !== bindableElement.id) {
// rebind if not bound already!
updateElementWith(boundElement, {
containerId: bindableElement.id,
} as ElementUpdate<ExcalidrawTextElement>);
}
} else {
if (boundElement.containerId !== null) {
// unbind if not unbound already
updateElementWith(boundElement, {
containerId: null,
} as ElementUpdate<ExcalidrawTextElement>);
}
// unbind from boundElements as the element got bound to some other element in the meantime
updateElementWith(bindableElement, {
boundElements: newBoundElements(
bindableElement.boundElements,
new Set([boundElement.id]),
),
});
}
}
},
);
};
}
export const getGlobalFixedPointForBindableElement = (
fixedPointRatio: [number, number],
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): GlobalPoint => {
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
return pointRotateRads(
pointFrom(
element.x + element.width * fixedX,
element.y + element.height * fixedY,
),
elementCenterPoint(element, elementsMap),
element.angle,
);
};
export const getGlobalFixedPoints = (
arrow: ExcalidrawArrowElement,
elementsMap: ElementsMap,
): [GlobalPoint, GlobalPoint] => {
const startElement =
arrow.startBinding &&
(elementsMap.get(arrow.startBinding.elementId) as
| ExcalidrawBindableElement
| undefined);
const endElement =
arrow.endBinding &&
(elementsMap.get(arrow.endBinding.elementId) as
| ExcalidrawBindableElement
| undefined);
const startPoint =
startElement && arrow.startBinding
? getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
startElement as ExcalidrawBindableElement,
elementsMap,
)
: pointFrom<GlobalPoint>(
arrow.x + arrow.points[0][0],
arrow.y + arrow.points[0][1],
);
const endPoint =
endElement && arrow.endBinding
? getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
endElement as ExcalidrawBindableElement,
elementsMap,
)
: pointFrom<GlobalPoint>(
arrow.x + arrow.points[arrow.points.length - 1][0],
arrow.y + arrow.points[arrow.points.length - 1][1],
);
return [startPoint, endPoint];
};
export const getArrowLocalFixedPoints = (
arrow: ExcalidrawElbowArrowElement,
elementsMap: ElementsMap,
) => {
const [startPoint, endPoint] = getGlobalFixedPoints(arrow, elementsMap);
return [
LinearElementEditor.pointFromAbsoluteCoords(arrow, startPoint, elementsMap),
LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap),
];
};
export const normalizeFixedPoint = <T extends FixedPoint | null>(
fixedPoint: T,
): T extends null ? null : FixedPoint => {
// Do not allow a precise 0.5 for fixed point ratio
// to avoid jumping arrow heading due to floating point imprecision
if (
fixedPoint &&
(Math.abs(fixedPoint[0] - 0.5) < 0.0001 ||
Math.abs(fixedPoint[1] - 0.5) < 0.0001)
) {
return fixedPoint.map((ratio) =>
Math.abs(ratio - 0.5) < 0.0001 ? 0.5001 : ratio,
) as T extends null ? null : FixedPoint;
}
return fixedPoint as any as T extends null ? null : FixedPoint;
};