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,