mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
1794 lines
51 KiB
TypeScript
1794 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 { 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 { moveArrowAboveBindable } from "./zindex";
|
|
|
|
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 (start.focusPoint || end.focusPoint) {
|
|
// If the strategy dictates a focus point override, then
|
|
// update the arrow points to point to the focus point.
|
|
const updates: PointsPositionUpdates = new Map();
|
|
|
|
if (start.focusPoint) {
|
|
updates.set(0, {
|
|
point:
|
|
updateBoundPoint(
|
|
arrow,
|
|
"startBinding",
|
|
arrow.startBinding,
|
|
start.element,
|
|
scene.getNonDeletedElementsMap(),
|
|
) || arrow.points[0],
|
|
});
|
|
}
|
|
|
|
if (end.focusPoint) {
|
|
updates.set(arrow.points.length - 1, {
|
|
point:
|
|
updateBoundPoint(
|
|
arrow,
|
|
"endBinding",
|
|
arrow.endBinding,
|
|
end.element,
|
|
scene.getNonDeletedElementsMap(),
|
|
) || arrow.points[arrow.points.length - 1],
|
|
});
|
|
}
|
|
|
|
LinearElementEditor.movePoints(arrow, scene, updates);
|
|
}
|
|
};
|
|
|
|
const bindOrUnbindBindingElementEdge = (
|
|
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,
|
|
),
|
|
};
|
|
}
|
|
|
|
moveArrowAboveBindable(arrow, hoveredElement, scene);
|
|
|
|
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,
|
|
): 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 = 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 {
|
|
intersection = intersectElementWithLineSegment(
|
|
bindableElement,
|
|
elementsMap,
|
|
lineSegment(
|
|
adjacentPoint,
|
|
pointFromVector(
|
|
vectorScale(
|
|
vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)),
|
|
pointDistance(edgePoint, adjacentPoint) +
|
|
Math.max(bindableElement.width, bindableElement.height) * 2,
|
|
),
|
|
adjacentPoint,
|
|
),
|
|
),
|
|
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,
|
|
): 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,
|
|
);
|
|
}
|
|
|
|
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;
|
|
};
|