Separate mutation of elbow arrows from mutateElement

This commit is contained in:
Marcel Mraz 2025-04-08 23:31:44 +00:00
parent 6fc85022ae
commit 703a8f0e78
11 changed files with 293 additions and 126 deletions

View file

@ -66,7 +66,7 @@ import {
} from "./typeChecks";
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
import { updateElbowArrowPoints } from "./elbowArrow";
import { mutateElbowArrow, updateElbowArrowPoints } from "./elbowArrow";
import type { Bounds } from "./bounds";
import type { ElementUpdate } from "./mutateElement";
@ -796,7 +796,20 @@ export const updateBoundElements = (
// `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(element.id)) {
mutateElement(element, bindings, true);
if (isElbowArrow(element)) {
mutateElbowArrow(
element,
bindings as {
startBinding: FixedPointBinding;
endBinding: FixedPointBinding;
},
true,
elementsMap,
);
} else {
mutateElement(element, bindings, true);
}
return;
}

View file

@ -22,6 +22,8 @@ import {
isDevEnv,
} from "@excalidraw/common";
import type { Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import {
@ -45,8 +47,8 @@ import {
vectorToHeading,
headingForPoint,
} from "./heading";
import { type ElementUpdate } from "./mutateElement";
import { isBindableElement } from "./typeChecks";
import { mutateElement, type ElementUpdate } from "./mutateElement";
import { isBindableElement, isElbowArrow } from "./typeChecks";
import {
type ExcalidrawElbowArrowElement,
type NonDeletedSceneElementsMap,
@ -61,6 +63,7 @@ import type {
Arrowhead,
ElementsMap,
ExcalidrawBindableElement,
ExcalidrawElement,
FixedPointBinding,
FixedSegment,
NonDeletedExcalidrawElement,
@ -879,6 +882,64 @@ const handleEndpointDrag = (
const MAX_POS = 1e6;
export const elbowArrowNeedsToGetNormalized = (
element: Readonly<ExcalidrawElement>,
updates: {
points?: readonly LocalPoint[];
fixedSegments?: readonly FixedSegment[] | null;
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
},
) => {
const { points, fixedSegments, startBinding, endBinding } = updates;
return (
isElbowArrow(element) &&
(Object.keys(updates).length === 0 || // normalization case
typeof points !== "undefined" || // repositioning
typeof fixedSegments !== "undefined" || // segment fixing
typeof startBinding !== "undefined" ||
typeof endBinding !== "undefined") // manual binding to element
);
};
/**
* Mutates an elbow arrow element and renormalizes it's properties if necessary.
*/
export const mutateElbowArrow = (
element: Readonly<ExcalidrawElbowArrowElement>,
updates: ElementUpdate<ExcalidrawElbowArrowElement>,
informMutation: boolean = true,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap | ElementsMap,
options?: {
isDragging?: boolean;
},
): ElementUpdate<ExcalidrawElbowArrowElement> => {
invariant(
!isElbowArrow(element),
`Element "${element.type}" is not an elbow arrow! Use \`mutateElement\` instead`,
);
if (!elbowArrowNeedsToGetNormalized(element, updates)) {
return mutateElement(element, updates, informMutation);
}
return mutateElement(
element,
{
...updates,
angle: 0 as Radians,
...updateElbowArrowPoints(
element,
elementsMap as NonDeletedSceneElementsMap,
updates,
options,
),
},
informMutation,
);
};
/**
*
*/
@ -887,7 +948,7 @@ export const updateElbowArrowPoints = (
elementsMap: NonDeletedSceneElementsMap,
updates: {
points?: readonly LocalPoint[];
fixedSegments?: FixedSegment[] | null;
fixedSegments?: readonly FixedSegment[] | null;
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
},

View file

@ -22,7 +22,7 @@ import {
// TODO: remove direct dependency on the scene, should be passed in or injected instead
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import Scene from "@excalidraw/excalidraw/scene/Scene";
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import type { Store } from "@excalidraw/excalidraw/store";
@ -50,7 +50,7 @@ import {
getMinMaxXYFromCurvePathOps,
} from "./bounds";
import { updateElbowArrowPoints } from "./elbowArrow";
import { mutateElbowArrow, updateElbowArrowPoints } from "./elbowArrow";
import { headingIsHorizontal, vectorToHeading } from "./heading";
import { bumpVersion, mutateElement } from "./mutateElement";
@ -794,7 +794,10 @@ export class LinearElementEditor {
elementsMap,
);
} else if (event.altKey && appState.editingLinearElement) {
if (linearElementEditor.lastUncommittedPoint == null) {
if (
linearElementEditor.lastUncommittedPoint == null &&
!isElbowArrow(element)
) {
mutateElement(element, {
points: [
...element.points,
@ -1219,7 +1222,12 @@ export class LinearElementEditor {
return acc;
}, []);
mutateElement(element, { points: nextPoints });
const updates = { points: nextPoints };
if (isElbowArrow(element)) {
mutateElbowArrow(element, updates, true, elementsMap);
} else {
mutateElement(element, updates);
}
// temp hack to ensure the line doesn't move when adding point to the end,
// potentially expanding the bounding box
@ -1425,9 +1433,12 @@ export class LinearElementEditor {
...element.points.slice(segmentMidpoint.index!),
];
mutateElement(element, {
points,
});
const updates = { points };
if (isElbowArrow(element)) {
mutateElbowArrow(element, updates, true, elementsMap);
} else {
mutateElement(element, updates);
}
ret.pointerDownState = {
...linearElementEditor.pointerDownState,
@ -1479,8 +1490,8 @@ export class LinearElementEditor {
updates.points = Array.from(nextPoints);
if (!options?.sceneElementsMap || Scene.getScene(element)) {
mutateElement(element, updates, true, {
if (!options?.sceneElementsMap) {
mutateElbowArrow(element, updates, true, options?.sceneElementsMap!, {
isDragging: options?.isDragging,
});
} else {
@ -1825,9 +1836,14 @@ export class LinearElementEditor {
.map((segment) => segment.index)
.reduce((count, idx) => (idx < index ? count + 1 : count), 0);
mutateElement(element, {
fixedSegments: nextFixedSegments,
});
mutateElbowArrow(
element,
{
fixedSegments: nextFixedSegments,
},
true,
elementsMap,
);
const point = pointFrom<GlobalPoint>(
element.x +
@ -1859,14 +1875,19 @@ export class LinearElementEditor {
static deleteFixedSegment(
element: ExcalidrawElbowArrowElement,
elementsMap: NonDeletedSceneElementsMap,
index: number,
): void {
mutateElement(element, {
fixedSegments: element.fixedSegments?.filter(
(segment) => segment.index !== index,
),
});
mutateElement(element, {}, true);
mutateElbowArrow(
element,
{
fixedSegments: element.fixedSegments?.filter(
(segment) => segment.index !== index,
),
},
true,
elementsMap,
);
}
}

View file

@ -2,23 +2,20 @@ import {
getSizeFromPoints,
randomInteger,
getUpdatedTimestamp,
toBrandedType,
invariant,
} from "@excalidraw/common";
// TODO: remove direct dependency on the scene, should be passed in or injected instead
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import Scene from "@excalidraw/excalidraw/scene/Scene";
import type { Radians } from "@excalidraw/math";
import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./ShapeCache";
import { updateElbowArrowPoints } from "./elbowArrow";
import { isElbowArrow } from "./typeChecks";
import { elbowArrowNeedsToGetNormalized } from "./elbowArrow";
import type { ExcalidrawElement, NonDeletedSceneElementsMap } from "./types";
import type { ExcalidrawElement } from "./types";
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>,
@ -33,54 +30,25 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
informMutation = true,
options?: {
// Currently only for elbow arrows.
// If true, the elbow arrow tries to bind to the nearest element. If false
// it tries to keep the same bound element, if any.
isDragging?: boolean;
},
): TElement => {
let didChange = false;
// casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732)
const { points, fixedSegments, fileId, startBinding, endBinding } =
const { points, fileId, fixedSegments, startBinding, endBinding } =
updates as any;
if (
isElbowArrow(element) &&
(Object.keys(updates).length === 0 || // normalization case
typeof points !== "undefined" || // repositioning
typeof fixedSegments !== "undefined" || // segment fixing
typeof startBinding !== "undefined" ||
typeof endBinding !== "undefined") // manual binding to element
) {
const elementsMap = toBrandedType<NonDeletedSceneElementsMap>(
Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(),
);
invariant(
elbowArrowNeedsToGetNormalized(element, {
points,
fixedSegments,
startBinding,
endBinding,
}),
"Elbow arrow should get normalized! Use `mutateElbowArrow` instead.",
);
updates = {
...updates,
angle: 0 as Radians,
...updateElbowArrowPoints(
{
...element,
x: updates.x || element.x,
y: updates.y || element.y,
},
elementsMap,
{
fixedSegments,
points,
startBinding,
endBinding,
},
{
isDragging: options?.isDragging,
},
),
};
} else if (typeof points !== "undefined") {
if (typeof points !== "undefined") {
updates = { ...getSizeFromPoints(points), ...updates };
}

View file

@ -60,6 +60,8 @@ import {
import { isInGroup } from "./groups";
import { mutateElbowArrow } from "./elbowArrow";
import type { BoundingBox } from "./bounds";
import type {
MaybeTransformHandleType,
@ -545,9 +547,14 @@ const rotateMultipleElements = (
if (isElbowArrow(element)) {
// Needed to re-route the arrow
mutateElement(element, {
points: getArrowLocalFixedPoints(element, elementsMap),
});
mutateElbowArrow(
element,
{
points: getArrowLocalFixedPoints(element, elementsMap),
},
false,
elementsMap,
);
} else {
mutateElement(
element,
@ -1527,10 +1534,14 @@ export const resizeMultipleElements = (
} of elementsAndUpdates) {
const { width, height, angle } = update;
mutateElement(element, update, false, {
// needed for the fixed binding point udpate to take effect
isDragging: true,
});
if (isElbowArrow(element)) {
mutateElbowArrow(element, update, false, elementsMap, {
// needed for the fixed binding point udpate to take effect
isDragging: true,
});
} else {
mutateElement(element, update, false);
}
updateBoundElements(element, elementsMap as SceneElementsMap, {
simultaneouslyUpdated: elementsToUpdate,

View file

@ -21,6 +21,7 @@ import {
import {
hasBoundTextElement,
isElbowArrow,
isTextBindableContainer,
isTextElement,
isUsingAdaptiveRadius,
@ -33,11 +34,14 @@ import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { newElement } from "@excalidraw/element/newElement";
import { mutateElbowArrow } from "@excalidraw/element/elbowArrow";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextContainer,
ExcalidrawTextElement,
FixedPointBinding,
} from "@excalidraw/element/types";
import type { Mutable } from "@excalidraw/common/utility-types";
@ -297,7 +301,21 @@ export const actionWrapTextInContainer = register({
}
if (startBinding || endBinding) {
mutateElement(ele, { startBinding, endBinding }, false);
const updates = { startBinding, endBinding };
if (isElbowArrow(ele)) {
mutateElbowArrow(
ele,
updates as {
startBinding: FixedPointBinding;
endBinding: FixedPointBinding;
},
false,
app.scene.getNonDeletedElementsMap(),
);
} else {
mutateElement(ele, updates, false);
}
}
});
}

View file

@ -3,10 +3,7 @@ import { KEYS, updateActiveTool } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
import { fixBindingsAfterDeletion } from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import {
mutateElement,
newElementWith,
} from "@excalidraw/element/mutateElement";
import { newElementWith } from "@excalidraw/element/mutateElement";
import { getContainerElement } from "@excalidraw/element/textElement";
import {
isBoundToContainer,
@ -20,6 +17,8 @@ import {
selectGroupsForSelectedElements,
} from "@excalidraw/element/groups";
import { mutateElbowArrow } from "@excalidraw/element/elbowArrow";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { t } from "../i18n";
@ -94,15 +93,21 @@ const deleteSelectedElements = (
el.boundElements.forEach((candidate) => {
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
if (bound && isElbowArrow(bound)) {
mutateElement(bound, {
startBinding:
el.id === bound.startBinding?.elementId
? null
: bound.startBinding,
endBinding:
el.id === bound.endBinding?.elementId ? null : bound.endBinding,
});
mutateElement(bound, { points: bound.points });
mutateElbowArrow(
bound,
{
startBinding:
el.id === bound.startBinding?.elementId
? null
: bound.startBinding,
endBinding:
el.id === bound.endBinding?.elementId
? null
: bound.endBinding,
},
true,
app.scene.getNonDeletedElementsMap(),
);
}
});
}

View file

@ -5,9 +5,12 @@ import {
bindOrUnbindLinearElement,
} from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { mutateElbowArrow } from "@excalidraw/element/elbowArrow";
import {
isBindingElement,
isElbowArrow,
isLinearElement,
} from "@excalidraw/element/typeChecks";
@ -16,6 +19,13 @@ import { isPathALoop } from "@excalidraw/element/shapes";
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
import type {
ExcalidrawElbowArrowElement,
ExcalidrawElement,
} from "@excalidraw/element/types";
import type { ElementUpdate } from "@excalidraw/element/mutateElement";
import { t } from "../i18n";
import { resetCursor } from "../cursor";
import { done } from "../components/icons";
@ -85,6 +95,16 @@ export const actionFinalize = register({
? appState.newElement
: null;
const mutate = (updates: ElementUpdate<ExcalidrawElbowArrowElement>) =>
isElbowArrow(multiPointElement as ExcalidrawElbowArrowElement)
? mutateElbowArrow(
multiPointElement as ExcalidrawElbowArrowElement,
updates,
true,
elementsMap,
)
: mutateElement(multiPointElement as ExcalidrawElement, updates);
if (multiPointElement) {
// pen and mouse have hover
if (
@ -96,7 +116,7 @@ export const actionFinalize = register({
!lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint
) {
mutateElement(multiPointElement, {
mutate({
points: multiPointElement.points.slice(0, -1),
});
}
@ -120,7 +140,7 @@ export const actionFinalize = register({
if (isLoop) {
const linePoints = multiPointElement.points;
const firstPoint = linePoints[0];
mutateElement(multiPointElement, {
mutate({
points: linePoints.map((p, index) =>
index === linePoints.length - 1
? pointFrom(firstPoint[0], firstPoint[1])

View file

@ -302,6 +302,10 @@ import {
import { isNonDeletedElement } from "@excalidraw/element";
import { mutateElbowArrow } from "@excalidraw/element/elbowArrow";
import type { ElementUpdate } from "@excalidraw/element/mutateElement";
import type { LocalPoint, Radians } from "@excalidraw/math";
import type {
@ -327,6 +331,7 @@ import type {
MagicGenerationData,
ExcalidrawNonSelectionElement,
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
} from "@excalidraw/element/types";
import type { ValueOf } from "@excalidraw/common/utility-types";
@ -5485,7 +5490,11 @@ class App extends React.Component<AppProps, AppState> {
if (midPoint && midPoint > -1) {
this.store.shouldCaptureIncrement();
LinearElementEditor.deleteFixedSegment(selectedElements[0], midPoint);
LinearElementEditor.deleteFixedSegment(
selectedElements[0],
this.scene.getNonDeletedElementsMap(),
midPoint,
);
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
{
@ -5991,23 +6000,30 @@ class App extends React.Component<AppProps, AppState> {
if (isPathALoop(points, this.state.zoom.value)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
}
// update last uncommitted point
mutateElement(
multiElement,
{
points: [
...points.slice(0, -1),
pointFrom<LocalPoint>(
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
),
],
},
false,
{
isDragging: true,
},
);
const updates = {
points: [
...points.slice(0, -1),
pointFrom<LocalPoint>(
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
),
],
};
if (isElbowArrow(multiElement)) {
mutateElbowArrow(
multiElement,
updates,
false,
this.scene.getNonDeletedElementsMap(),
{
isDragging: true,
},
);
} else {
// update last uncommitted point
mutateElement(multiElement, updates, false);
}
// in this path, we're mutating multiElement to reflect
// how it will be after adding pointer position as the next point
@ -8672,25 +8688,33 @@ class App extends React.Component<AppProps, AppState> {
));
}
const mutate = (
updates: ElementUpdate<ExcalidrawLinearElement>,
options: { isDragging?: boolean } = {},
) =>
isElbowArrow(newElement)
? mutateElbowArrow(
newElement,
updates as ElementUpdate<ExcalidrawElbowArrowElement>,
false,
this.scene.getNonDeletedElementsMap(),
options,
)
: mutateElement(newElement, updates, false);
if (points.length === 1) {
mutateElement(
newElement,
{
points: [...points, pointFrom<LocalPoint>(dx, dy)],
},
false,
);
mutate({
points: [...points, pointFrom<LocalPoint>(dx, dy)],
});
} else if (
points.length === 2 ||
(points.length > 1 && isElbowArrow(newElement))
) {
mutateElement(
newElement,
mutate(
{
points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
},
false,
{ isDragging: true },
{ isDragging: true }
);
}
@ -8937,7 +8961,12 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(),
);
if (element) {
mutateElement(element, {}, true);
mutateElbowArrow(
element as ExcalidrawElbowArrowElement,
{},
true,
this.scene.getNonDeletedElementsMap(),
);
}
}
@ -9080,7 +9109,7 @@ class App extends React.Component<AppProps, AppState> {
);
if (!pointerDownState.drag.hasOccurred && newElement && !multiElement) {
mutateElement(newElement, {
const updates = {
points: [
...newElement.points,
pointFrom<LocalPoint>(
@ -9088,7 +9117,19 @@ class App extends React.Component<AppProps, AppState> {
pointerCoords.y - newElement.y,
),
],
});
};
if (isElbowArrow(newElement)) {
mutateElbowArrow(
newElement,
updates,
false,
this.scene.getNonDeletedElementsMap(),
);
} else {
mutateElement(newElement, updates, false);
}
this.setState({
multiElement: newElement,
newElement,

View file

@ -13,10 +13,12 @@ import {
handleBindTextResize,
} from "@excalidraw/element/textElement";
import { isTextElement } from "@excalidraw/element/typeChecks";
import { isElbowArrow, isTextElement } from "@excalidraw/element/typeChecks";
import { getCommonBounds } from "@excalidraw/utils";
import { mutateElbowArrow } from "@excalidraw/element/elbowArrow";
import type {
ElementsMap,
ExcalidrawElement,
@ -80,7 +82,12 @@ const resizeElementInGroup = (
) => {
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
mutateElement(latestElement, updates, false);
if (isElbowArrow(latestElement)) {
mutateElbowArrow(latestElement, updates, false, elementsMap);
} else {
mutateElement(latestElement, updates, false);
}
const boundTextElement = getBoundTextElement(
origElement,
originalElementsMap,

View file

@ -264,6 +264,8 @@ export {
bumpVersion,
} from "@excalidraw/element/mutateElement";
export { mutateElbowArrow } from "@excalidraw/element/elbowArrow";
export { CaptureUpdateAction } from "./store";
export { parseLibraryTokensFromUrl, useHandleLibrary } from "./data/library";