mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-08-06 05:46:26 +00:00
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
1801 lines
51 KiB
TypeScript
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;
|
|
};
|