* Refactor: simplify linear element type * Refactor: dedupe scrollbar handling * First step towards binding - establish relationship and basic test for dragged lines * Refactor: use zoom from appstate * Refactor: generalize getElementAtPosition * Only consider bindable elements in hit test * Refactor: pull out pieces of hit test for reuse later * Refactor: pull out diamond from hit test for reuse later * Refactor: pull out text from hit test for reuse later * Suggest binding when hovering * Give shapes in regression test real size * Give shapes in undo/redo test real size * Keep bound element highlighted * Show binding suggestion for multi-point elements * Move binding to its on module with functions so that I can use it from actions, add support for binding end of multi-point elements * Use Id instead of ID * Improve boundary offset for non-squarish elements * Fix localStorage for binding on linear elements * Simplify dragging code and fix elements bound twice to the same shape * Fix binding for rectangles * Bind both ends at the end of the linear element creation, needed for focus points * wip * Refactor: Renames and reshapes for next commit * Calculate and store focus points and gaps, but dont use them yet * Focus points for rectangles * Dont blow up when canceling linear element * Stop suggesting binding when a non-compatible tool is selected * Clean up collision code * Using Geometric Algebra for hit tests * Correct binding for all shapes * Constant gap around polygon corners * Fix rotation handling * Generalize update and fix hit test for rotated elements * Handle rotation realtime * Handle scaling * Remove vibration when moving bound and binding element together * Handle simultenous scaling * Allow binding and unbinding when editing linear elements * Dont delete binding when the end point wasnt touched * Bind on enter/escape when editing * Support multiple suggested bindable elements in preparation for supporting linear elements dragging * Update binding when moving linear elements * Update binding when resizing linear elements * Dont re-render UI on binding hints * Update both ends when one is moved * Use distance instead of focus point for binding * Complicated approach for posterity, ignore this commit * Revert the complicated approach * Better focus point strategy, working for all shapes * Update snapshots * Dont break binding gap when mirroring shape * Dont break binding gap when grid mode pushes it inside * Dont bind draw elements * Support alt duplication * Fix alt duplication to * Support cmd+D duplication * All copy mechanisms are supported * Allow binding shapes to arrows, having arrows created first * Prevent arrows from disappearing for ellipses * Better binding suggestion highlight for shapes * Dont suggest second binding for simple elements when editing or moving them * Dont steal already bound linear elements when moving shapes * Fix highlighting diamonds and more precisely highlight other shapes * Highlight linear element edges for binding * Highlight text binding too * Handle deletion * Dont suggest second binding for simple linear elements when creating them * Dont highlight bound element during creation * Fix binding for rotated linear elements * Fix collision check for ellipses * Dont show suggested bindings for selected pairs * Bind multi-point linear elements when the tool is switched - important for mobile * Handle unbinding one of two bound edges correctly * Rename boundElement in state to startBoundElement * Dont double account for zoom when rendering binding highlight * Fix rendering of edited linear element point handles * Suggest binding when adding new point to a linear element * Bind when adding a new point to a linear element and dont unbind when moving middle elements * Handle deleting points * Add cmd modifier key to disable binding * Use state for enabling binding, fix not binding for linear elements during creation * Drop support for binding lines, only arrows are bindable * Reset binding mode on blur * Fix not binding lines
675 lines
21 KiB
TypeScript
675 lines
21 KiB
TypeScript
import {
|
|
ExcalidrawLinearElement,
|
|
ExcalidrawBindableElement,
|
|
NonDeleted,
|
|
NonDeletedExcalidrawElement,
|
|
PointBinding,
|
|
ExcalidrawElement,
|
|
} from "./types";
|
|
import { getElementAtPosition } from "../scene";
|
|
import { AppState } from "../types";
|
|
import { isBindableElement, isBindingElement } from "./typeChecks";
|
|
import {
|
|
bindingBorderTest,
|
|
distanceToBindableElement,
|
|
maxBindingGap,
|
|
determineFocusDistance,
|
|
intersectElementWithLine,
|
|
determineFocusPoint,
|
|
} from "./collision";
|
|
import { mutateElement } from "./mutateElement";
|
|
import Scene from "../scene/Scene";
|
|
import { LinearElementEditor } from "./linearElementEditor";
|
|
import { tupleToCoors } from "../utils";
|
|
|
|
export type SuggestedBinding =
|
|
| NonDeleted<ExcalidrawBindableElement>
|
|
| SuggestedPointBinding;
|
|
|
|
export type SuggestedPointBinding = [
|
|
NonDeleted<ExcalidrawLinearElement>,
|
|
"start" | "end" | "both",
|
|
NonDeleted<ExcalidrawBindableElement>,
|
|
];
|
|
|
|
export const isBindingEnabled = (appState: AppState): boolean => {
|
|
return appState.isBindingEnabled;
|
|
};
|
|
|
|
export const bindOrUnbindLinearElement = (
|
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
|
startBindingElement: ExcalidrawBindableElement | null | "keep",
|
|
endBindingElement: ExcalidrawBindableElement | null | "keep",
|
|
): void => {
|
|
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
|
|
const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
|
|
bindOrUnbindLinearElementEdge(
|
|
linearElement,
|
|
startBindingElement,
|
|
"start",
|
|
boundToElementIds,
|
|
unboundFromElementIds,
|
|
);
|
|
bindOrUnbindLinearElementEdge(
|
|
linearElement,
|
|
endBindingElement,
|
|
"end",
|
|
boundToElementIds,
|
|
unboundFromElementIds,
|
|
);
|
|
|
|
const onlyUnbound = Array.from(unboundFromElementIds).filter(
|
|
(id) => !boundToElementIds.has(id),
|
|
);
|
|
Scene.getScene(linearElement)!
|
|
.getNonDeletedElements(onlyUnbound)
|
|
.forEach((element) => {
|
|
mutateElement(element, {
|
|
boundElementIds: element.boundElementIds?.filter(
|
|
(id) => id !== linearElement.id,
|
|
),
|
|
});
|
|
});
|
|
};
|
|
|
|
const bindOrUnbindLinearElementEdge = (
|
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
|
bindableElement: ExcalidrawBindableElement | null | "keep",
|
|
startOrEnd: "start" | "end",
|
|
// Is mutated
|
|
boundToElementIds: Set<ExcalidrawBindableElement["id"]>,
|
|
// Is mutated
|
|
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
|
|
): void => {
|
|
if (bindableElement !== "keep") {
|
|
if (bindableElement != null) {
|
|
bindLinearElement(linearElement, bindableElement, startOrEnd);
|
|
boundToElementIds.add(bindableElement.id);
|
|
} else {
|
|
const unbound = unbindLinearElement(linearElement, startOrEnd);
|
|
if (unbound != null) {
|
|
unboundFromElementIds.add(unbound);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
export const bindOrUnbindSelectedElements = (
|
|
elements: NonDeleted<ExcalidrawElement>[],
|
|
): void => {
|
|
elements.forEach((element) => {
|
|
if (isBindingElement(element)) {
|
|
bindOrUnbindLinearElement(
|
|
element,
|
|
getElligibleElementForBindingElement(element, "start"),
|
|
getElligibleElementForBindingElement(element, "end"),
|
|
);
|
|
} else if (isBindableElement(element)) {
|
|
maybeBindBindableElement(element);
|
|
}
|
|
});
|
|
};
|
|
|
|
export const maybeBindBindableElement = (
|
|
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
|
): void => {
|
|
getElligibleElementsForBindableElementAndWhere(
|
|
bindableElement,
|
|
).forEach(([linearElement, where]) =>
|
|
bindOrUnbindLinearElement(
|
|
linearElement,
|
|
where === "end" ? "keep" : bindableElement,
|
|
where === "start" ? "keep" : bindableElement,
|
|
),
|
|
);
|
|
};
|
|
|
|
export const maybeBindLinearElement = (
|
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
|
appState: AppState,
|
|
scene: Scene,
|
|
pointerCoords: { x: number; y: number },
|
|
): void => {
|
|
if (appState.startBoundElement != null) {
|
|
bindLinearElement(linearElement, appState.startBoundElement, "start");
|
|
}
|
|
const hoveredElement = getHoveredElementForBinding(pointerCoords, scene);
|
|
if (hoveredElement != null) {
|
|
bindLinearElement(linearElement, hoveredElement, "end");
|
|
}
|
|
};
|
|
|
|
const bindLinearElement = (
|
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
|
hoveredElement: ExcalidrawBindableElement,
|
|
startOrEnd: "start" | "end",
|
|
): void => {
|
|
if (
|
|
isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
|
|
linearElement,
|
|
hoveredElement,
|
|
startOrEnd,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
mutateElement(linearElement, {
|
|
[startOrEnd === "start" ? "startBinding" : "endBinding"]: {
|
|
elementId: hoveredElement.id,
|
|
...calculateFocusAndGap(linearElement, hoveredElement, startOrEnd),
|
|
} as PointBinding,
|
|
});
|
|
mutateElement(hoveredElement, {
|
|
boundElementIds: [
|
|
...new Set([...(hoveredElement.boundElementIds ?? []), linearElement.id]),
|
|
],
|
|
});
|
|
};
|
|
|
|
// Don't bind both ends of a simple segment
|
|
const isLinearElementSimpleAndAlreadyBoundOnOppositeEdge = (
|
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
|
bindableElement: ExcalidrawBindableElement,
|
|
startOrEnd: "start" | "end",
|
|
): boolean => {
|
|
const otherBinding =
|
|
linearElement[startOrEnd === "start" ? "endBinding" : "startBinding"];
|
|
return isLinearElementSimpleAndAlreadyBound(
|
|
linearElement,
|
|
otherBinding?.elementId,
|
|
bindableElement,
|
|
);
|
|
};
|
|
|
|
export const isLinearElementSimpleAndAlreadyBound = (
|
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
|
alreadyBoundToId: ExcalidrawBindableElement["id"] | undefined,
|
|
bindableElement: ExcalidrawBindableElement,
|
|
): boolean => {
|
|
return (
|
|
alreadyBoundToId === bindableElement.id && linearElement.points.length < 3
|
|
);
|
|
};
|
|
|
|
export const unbindLinearElements = (
|
|
elements: NonDeleted<ExcalidrawElement>[],
|
|
): void => {
|
|
elements.forEach((element) => {
|
|
if (isBindingElement(element)) {
|
|
bindOrUnbindLinearElement(element, null, null);
|
|
}
|
|
});
|
|
};
|
|
|
|
const unbindLinearElement = (
|
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
|
startOrEnd: "start" | "end",
|
|
): ExcalidrawBindableElement["id"] | null => {
|
|
const field = startOrEnd === "start" ? "startBinding" : "endBinding";
|
|
const binding = linearElement[field];
|
|
if (binding == null) {
|
|
return null;
|
|
}
|
|
mutateElement(linearElement, { [field]: null });
|
|
return binding.elementId;
|
|
};
|
|
|
|
export const getHoveredElementForBinding = (
|
|
pointerCoords: {
|
|
x: number;
|
|
y: number;
|
|
},
|
|
scene: Scene,
|
|
): NonDeleted<ExcalidrawBindableElement> | null => {
|
|
const hoveredElement = getElementAtPosition(
|
|
scene.getElements(),
|
|
(element) =>
|
|
isBindableElement(element) && bindingBorderTest(element, pointerCoords),
|
|
);
|
|
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
|
|
};
|
|
|
|
const calculateFocusAndGap = (
|
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
|
hoveredElement: ExcalidrawBindableElement,
|
|
startOrEnd: "start" | "end",
|
|
): { focus: number; gap: number } => {
|
|
const direction = startOrEnd === "start" ? -1 : 1;
|
|
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
|
|
const adjacentPointIndex = edgePointIndex - direction;
|
|
const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
|
linearElement,
|
|
edgePointIndex,
|
|
);
|
|
const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
|
linearElement,
|
|
adjacentPointIndex,
|
|
);
|
|
return {
|
|
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint),
|
|
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)),
|
|
};
|
|
};
|
|
|
|
// Supports translating, rotating and scaling `changedElement` with bound
|
|
// linear elements.
|
|
// Because scaling involves moving the focus points as well, it is
|
|
// done before the `changedElement` is updated, and the `newSize` is passed
|
|
// in explicitly.
|
|
export const updateBoundElements = (
|
|
changedElement: NonDeletedExcalidrawElement,
|
|
options?: {
|
|
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
|
newSize?: { width: number; height: number };
|
|
},
|
|
) => {
|
|
const boundElementIds = changedElement.boundElementIds ?? [];
|
|
if (boundElementIds.length === 0) {
|
|
return;
|
|
}
|
|
const { newSize, simultaneouslyUpdated } = options ?? {};
|
|
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
|
simultaneouslyUpdated,
|
|
);
|
|
(Scene.getScene(changedElement)!.getNonDeletedElements(
|
|
boundElementIds,
|
|
) as NonDeleted<ExcalidrawLinearElement>[]).forEach((linearElement) => {
|
|
const bindableElement = changedElement as ExcalidrawBindableElement;
|
|
// In case the boundElementIds are stale
|
|
if (!doesNeedUpdate(linearElement, bindableElement)) {
|
|
return;
|
|
}
|
|
const startBinding = maybeCalculateNewGapWhenScaling(
|
|
bindableElement,
|
|
linearElement.startBinding,
|
|
newSize,
|
|
);
|
|
const endBinding = maybeCalculateNewGapWhenScaling(
|
|
bindableElement,
|
|
linearElement.endBinding,
|
|
newSize,
|
|
);
|
|
// `linearElement` is being moved/scaled already, just update the binding
|
|
if (simultaneouslyUpdatedElementIds.has(linearElement.id)) {
|
|
mutateElement(linearElement, { startBinding, endBinding });
|
|
return;
|
|
}
|
|
updateBoundPoint(
|
|
linearElement,
|
|
"start",
|
|
startBinding,
|
|
changedElement as ExcalidrawBindableElement,
|
|
);
|
|
updateBoundPoint(
|
|
linearElement,
|
|
"end",
|
|
endBinding,
|
|
changedElement as ExcalidrawBindableElement,
|
|
);
|
|
});
|
|
};
|
|
|
|
const doesNeedUpdate = (
|
|
boundElement: NonDeleted<ExcalidrawLinearElement>,
|
|
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));
|
|
};
|
|
|
|
const updateBoundPoint = (
|
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
|
startOrEnd: "start" | "end",
|
|
binding: PointBinding | null | undefined,
|
|
changedElement: ExcalidrawBindableElement,
|
|
): void => {
|
|
if (
|
|
binding == null ||
|
|
// We only need to update the other end if this is a 2 point line element
|
|
(binding.elementId !== changedElement.id && linearElement.points.length > 2)
|
|
) {
|
|
return;
|
|
}
|
|
const bindingElement = Scene.getScene(linearElement)!.getElement(
|
|
binding.elementId,
|
|
) as ExcalidrawBindableElement | null;
|
|
if (bindingElement == null) {
|
|
// We're not cleaning up after deleted elements atm., so handle this case
|
|
return;
|
|
}
|
|
const direction = startOrEnd === "start" ? -1 : 1;
|
|
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
|
|
const adjacentPointIndex = edgePointIndex - direction;
|
|
const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
|
linearElement,
|
|
adjacentPointIndex,
|
|
);
|
|
const focusPointAbsolute = determineFocusPoint(
|
|
bindingElement,
|
|
binding.focus,
|
|
adjacentPoint,
|
|
);
|
|
let newEdgePoint;
|
|
// The linear element was not originally pointing inside the bound shape,
|
|
// we can point directly at the focus point
|
|
if (binding.gap === 0) {
|
|
newEdgePoint = focusPointAbsolute;
|
|
} else {
|
|
const intersections = intersectElementWithLine(
|
|
bindingElement,
|
|
adjacentPoint,
|
|
focusPointAbsolute,
|
|
binding.gap,
|
|
);
|
|
if (intersections.length === 0) {
|
|
// This should never happen, since focusPoint should always be
|
|
// inside the element, but just in case, bail out
|
|
newEdgePoint = focusPointAbsolute;
|
|
} else {
|
|
// Guaranteed to intersect because focusPoint is always inside the shape
|
|
newEdgePoint = intersections[0];
|
|
}
|
|
}
|
|
LinearElementEditor.movePoint(
|
|
linearElement,
|
|
edgePointIndex,
|
|
LinearElementEditor.pointFromAbsoluteCoords(linearElement, newEdgePoint),
|
|
{ [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding },
|
|
);
|
|
};
|
|
|
|
const maybeCalculateNewGapWhenScaling = (
|
|
changedElement: ExcalidrawBindableElement,
|
|
currentBinding: PointBinding | null | undefined,
|
|
newSize: { width: number; height: number } | undefined,
|
|
): PointBinding | null | undefined => {
|
|
if (currentBinding == null || newSize == null) {
|
|
return currentBinding;
|
|
}
|
|
const { gap, focus, elementId } = currentBinding;
|
|
const { width: newWidth, height: newHeight } = newSize;
|
|
const { width, height } = changedElement;
|
|
const newGap = Math.max(
|
|
1,
|
|
Math.min(
|
|
maxBindingGap(changedElement, newWidth, newHeight),
|
|
gap * (newWidth < newHeight ? newWidth / width : newHeight / height),
|
|
),
|
|
);
|
|
return { elementId, gap: newGap, focus };
|
|
};
|
|
|
|
export const getEligibleElementsForBinding = (
|
|
elements: NonDeleted<ExcalidrawElement>[],
|
|
): SuggestedBinding[] => {
|
|
const includedElementIds = new Set(elements.map(({ id }) => id));
|
|
return elements.flatMap((element) =>
|
|
isBindingElement(element)
|
|
? (getElligibleElementsForBindingElement(
|
|
element as NonDeleted<ExcalidrawLinearElement>,
|
|
).filter(
|
|
(element) => !includedElementIds.has(element.id),
|
|
) as SuggestedBinding[])
|
|
: isBindableElement(element)
|
|
? getElligibleElementsForBindableElementAndWhere(element).filter(
|
|
(binding) => !includedElementIds.has(binding[0].id),
|
|
)
|
|
: [],
|
|
);
|
|
};
|
|
|
|
const getElligibleElementsForBindingElement = (
|
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
|
): NonDeleted<ExcalidrawBindableElement>[] => {
|
|
return [
|
|
getElligibleElementForBindingElement(linearElement, "start"),
|
|
getElligibleElementForBindingElement(linearElement, "end"),
|
|
].filter(
|
|
(element): element is NonDeleted<ExcalidrawBindableElement> =>
|
|
element != null,
|
|
);
|
|
};
|
|
|
|
const getElligibleElementForBindingElement = (
|
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
|
startOrEnd: "start" | "end",
|
|
): NonDeleted<ExcalidrawBindableElement> | null => {
|
|
return getElligibleElementForBindingElementAtCoors(
|
|
linearElement,
|
|
startOrEnd,
|
|
getLinearElementEdgeCoors(linearElement, startOrEnd),
|
|
);
|
|
};
|
|
|
|
export const getElligibleElementForBindingElementAtCoors = (
|
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
|
startOrEnd: "start" | "end",
|
|
pointerCoords: {
|
|
x: number;
|
|
y: number;
|
|
},
|
|
): NonDeleted<ExcalidrawBindableElement> | null => {
|
|
const bindableElement = getHoveredElementForBinding(
|
|
pointerCoords,
|
|
Scene.getScene(linearElement)!,
|
|
);
|
|
if (bindableElement == null) {
|
|
return null;
|
|
}
|
|
// Note: We could push this check inside a version of
|
|
// `getHoveredElementForBinding`, but it's unlikely this is needed.
|
|
if (
|
|
isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
|
|
linearElement,
|
|
bindableElement,
|
|
startOrEnd,
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
return bindableElement;
|
|
};
|
|
|
|
const getLinearElementEdgeCoors = (
|
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
|
startOrEnd: "start" | "end",
|
|
): { x: number; y: number } => {
|
|
const index = startOrEnd === "start" ? 0 : -1;
|
|
return tupleToCoors(
|
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(linearElement, index),
|
|
);
|
|
};
|
|
|
|
const getElligibleElementsForBindableElementAndWhere = (
|
|
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
|
): SuggestedPointBinding[] => {
|
|
return Scene.getScene(bindableElement)!
|
|
.getElements()
|
|
.map((element) => {
|
|
if (!isBindingElement(element)) {
|
|
return null;
|
|
}
|
|
const canBindStart = isLinearElementEligibleForNewBindingByBindable(
|
|
element,
|
|
"start",
|
|
bindableElement,
|
|
);
|
|
const canBindEnd = isLinearElementEligibleForNewBindingByBindable(
|
|
element,
|
|
"end",
|
|
bindableElement,
|
|
);
|
|
if (!canBindStart && !canBindEnd) {
|
|
return null;
|
|
}
|
|
return [
|
|
element,
|
|
canBindStart && canBindEnd ? "both" : canBindStart ? "start" : "end",
|
|
bindableElement,
|
|
];
|
|
})
|
|
.filter((maybeElement) => maybeElement != null) as SuggestedPointBinding[];
|
|
};
|
|
|
|
const isLinearElementEligibleForNewBindingByBindable = (
|
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
|
startOrEnd: "start" | "end",
|
|
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
|
): boolean => {
|
|
const existingBinding =
|
|
linearElement[startOrEnd === "start" ? "startBinding" : "endBinding"];
|
|
return (
|
|
existingBinding == null &&
|
|
!isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
|
|
linearElement,
|
|
bindableElement,
|
|
startOrEnd,
|
|
) &&
|
|
bindingBorderTest(
|
|
bindableElement,
|
|
getLinearElementEdgeCoors(linearElement, startOrEnd),
|
|
)
|
|
);
|
|
};
|
|
|
|
// We need to:
|
|
// 1: Update elements not selected to point to duplicated elements
|
|
// 2: Update duplicated elements to point to other duplicated elements
|
|
export const fixBindingsAfterDuplication = (
|
|
sceneElements: readonly ExcalidrawElement[],
|
|
oldElements: readonly ExcalidrawElement[],
|
|
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
|
// There are three copying mechanisms: Copy-paste, duplication and alt-drag.
|
|
// Only when alt-dragging the new "duplicates" act as the "old", while
|
|
// the "old" elements act as the "new copy" - essentially working reverse
|
|
// to the other two.
|
|
duplicatesServeAsOld?: "duplicatesServeAsOld" | undefined,
|
|
): void => {
|
|
// First collect all the binding/bindable elements, so we only update
|
|
// each once, regardless of whether they were duplicated or not.
|
|
const allBoundElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
|
const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
|
const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
|
|
oldElements.forEach((oldElement) => {
|
|
const { boundElementIds } = oldElement;
|
|
if (boundElementIds != null && boundElementIds.length > 0) {
|
|
boundElementIds.forEach((boundElementId) => {
|
|
if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElementId)) {
|
|
allBoundElementIds.add(boundElementId);
|
|
}
|
|
});
|
|
allBindableElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
|
|
}
|
|
if (isBindingElement(oldElement)) {
|
|
if (oldElement.startBinding != null) {
|
|
const { elementId } = oldElement.startBinding;
|
|
if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) {
|
|
allBindableElementIds.add(elementId);
|
|
}
|
|
}
|
|
if (oldElement.endBinding != null) {
|
|
const { elementId } = oldElement.endBinding;
|
|
if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) {
|
|
allBindableElementIds.add(elementId);
|
|
}
|
|
}
|
|
if (oldElement.startBinding != null || oldElement.endBinding != null) {
|
|
allBoundElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Update the linear elements
|
|
(sceneElements.filter(({ id }) =>
|
|
allBoundElementIds.has(id),
|
|
) as ExcalidrawLinearElement[]).forEach((element) => {
|
|
const { startBinding, endBinding } = element;
|
|
mutateElement(element, {
|
|
startBinding: newBindingAfterDuplication(
|
|
startBinding,
|
|
oldIdToDuplicatedId,
|
|
),
|
|
endBinding: newBindingAfterDuplication(endBinding, oldIdToDuplicatedId),
|
|
});
|
|
});
|
|
|
|
// Update the bindable shapes
|
|
sceneElements
|
|
.filter(({ id }) => allBindableElementIds.has(id))
|
|
.forEach((bindableElement) => {
|
|
const { boundElementIds } = bindableElement;
|
|
if (boundElementIds != null && boundElementIds.length > 0) {
|
|
mutateElement(bindableElement, {
|
|
boundElementIds: boundElementIds.map(
|
|
(boundElementId) =>
|
|
oldIdToDuplicatedId.get(boundElementId) ?? boundElementId,
|
|
),
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
const newBindingAfterDuplication = (
|
|
binding: PointBinding | null,
|
|
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
|
): PointBinding | null => {
|
|
if (binding == null) {
|
|
return null;
|
|
}
|
|
const { elementId, focus, gap } = binding;
|
|
return {
|
|
focus,
|
|
gap,
|
|
elementId: oldIdToDuplicatedId.get(elementId) ?? elementId,
|
|
};
|
|
};
|
|
|
|
export const fixBindingsAfterDeletion = (
|
|
sceneElements: readonly ExcalidrawElement[],
|
|
deletedElements: readonly ExcalidrawElement[],
|
|
): void => {
|
|
const deletedElementIds = new Set(
|
|
deletedElements.map((element) => element.id),
|
|
);
|
|
// Non deleted and need an update
|
|
const boundElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
|
deletedElements.forEach((deletedElement) => {
|
|
if (isBindableElement(deletedElement)) {
|
|
deletedElement.boundElementIds?.forEach((id) => {
|
|
if (!deletedElementIds.has(id)) {
|
|
boundElementIds.add(id);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
(sceneElements.filter(({ id }) =>
|
|
boundElementIds.has(id),
|
|
) as ExcalidrawLinearElement[]).forEach(
|
|
(element: ExcalidrawLinearElement) => {
|
|
const { startBinding, endBinding } = element;
|
|
mutateElement(element, {
|
|
startBinding: newBindingAfterDeletion(startBinding, deletedElementIds),
|
|
endBinding: newBindingAfterDeletion(endBinding, deletedElementIds),
|
|
});
|
|
},
|
|
);
|
|
};
|
|
|
|
const newBindingAfterDeletion = (
|
|
binding: PointBinding | null,
|
|
deletedElementIds: Set<ExcalidrawElement["id"]>,
|
|
): PointBinding | null => {
|
|
if (binding == null || deletedElementIds.has(binding.elementId)) {
|
|
return null;
|
|
}
|
|
return binding;
|
|
};
|