Expose scene.mutateElement and use original mutateElement

This commit is contained in:
Marcel Mraz 2025-04-16 13:10:14 +02:00
parent 11600ee6a6
commit 2e4ca2d11a
No known key found for this signature in database
GPG key ID: 4EBD6E62DC830CD2
44 changed files with 249 additions and 260 deletions

View file

@ -20,7 +20,7 @@ import {
import { getSelectedElements } from "@excalidraw/element/selection";
import {
mutateElementWith,
mutateElement,
type ElementUpdate,
} from "@excalidraw/element/mutateElement";
@ -424,23 +424,22 @@ class Scene {
return getElementsInGroup(elementsMap, id);
};
// TODO_SCENE: should be accessed as app.scene through the API
// TODO_SCENE: inform mutation false is the new default, meaning all mutateElement with nothing should likely use scene instead
// Mutate an element with passed updates and trigger the component to update. Make sure you
// are calling it either from a React event handler or within unstable_batchedUpdates().
mutate<TElement extends Mutable<ExcalidrawElement>>(
mutateElement<TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
options: {
informMutation?: boolean;
isDragging?: boolean;
informMutation: boolean;
isDragging: boolean;
} = {
informMutation: true,
isDragging: false,
},
) {
const elementsMap = this.getNonDeletedElementsMap();
mutateElementWith(element, elementsMap, updates, options);
mutateElement(element, elementsMap, updates, options);
if (options.informMutation) {
this.triggerUpdate();

View file

@ -32,7 +32,7 @@ export const alignElements = (
);
return group.map((element) => {
// update element
const updatedEle = scene.mutate(element, {
const updatedEle = scene.mutateElement(element, {
x: element.x + translation.x,
y: element.y + translation.y,
});

View file

@ -48,7 +48,7 @@ import {
type Heading,
} from "./heading";
import { LinearElementEditor } from "./linearElementEditor";
import { mutateElementWith } from "./mutateElement";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import {
isArrowElement,
@ -157,7 +157,7 @@ export const bindOrUnbindLinearElement = (
);
getNonDeletedElements(scene, onlyUnbound).forEach((element) => {
scene.mutate(element, {
scene.mutateElement(element, {
boundElements: element.boundElements?.filter(
(element) =>
element.type !== "arrow" || element.id !== linearElement.id,
@ -509,13 +509,13 @@ export const bindLinearElement = (
};
}
scene.mutate(linearElement, {
scene.mutateElement(linearElement, {
[startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
});
const boundElementsMap = arrayToMap(hoveredElement.boundElements || []);
if (!boundElementsMap.has(linearElement.id)) {
scene.mutate(hoveredElement, {
scene.mutateElement(hoveredElement, {
boundElements: (hoveredElement.boundElements || []).concat({
id: linearElement.id,
type: "arrow",
@ -564,7 +564,7 @@ const unbindLinearElement = (
if (binding == null) {
return null;
}
scene.mutate(linearElement, { [field]: null });
scene.mutateElement(linearElement, { [field]: null });
return binding.elementId;
};
@ -790,7 +790,7 @@ export const updateBoundElements = (
// `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(element.id)) {
mutateElementWith(element, elementsMap, bindings);
scene.mutateElement(element, bindings);
return;
}
@ -1499,7 +1499,7 @@ const fixReversedBindingsForBindables = (
(el) => el.id === newArrowId,
)! as ExcalidrawArrowElement;
mutateElementWith(newArrow, originalElements, {
mutateElement(newArrow, originalElements, {
startBinding:
oldArrow.startBinding?.elementId === binding.id
? {
@ -1515,7 +1515,7 @@ const fixReversedBindingsForBindables = (
}
: newArrow.endBinding,
});
mutateElementWith(duplicate, originalElements, {
mutateElement(duplicate, originalElements, {
boundElements: [
...(duplicate.boundElements ?? []).filter(
(el) => el.id !== binding.id && el.id !== newArrowId,
@ -1529,7 +1529,7 @@ const fixReversedBindingsForBindables = (
} else {
// Linked arrow is outside the selection,
// so we move the binding to the duplicate
mutateElementWith(oldArrow, originalElements, {
mutateElement(oldArrow, originalElements, {
startBinding:
oldArrow.startBinding?.elementId === original.id
? {
@ -1545,7 +1545,7 @@ const fixReversedBindingsForBindables = (
}
: oldArrow.endBinding,
});
mutateElementWith(duplicate, originalElements, {
mutateElement(duplicate, originalElements, {
boundElements: [
...(duplicate.boundElements ?? []),
{
@ -1554,7 +1554,7 @@ const fixReversedBindingsForBindables = (
},
],
});
mutateElementWith(original, originalElements, {
mutateElement(original, originalElements, {
boundElements:
original.boundElements?.filter((_, i) => i !== idx) ?? null,
});
@ -1580,13 +1580,13 @@ const fixReversedBindingsForArrows = (
const newBindable = elementsWithClones.find(
(el) => el.id === newBindableId,
) as ExcalidrawBindableElement;
mutateElementWith(duplicate, originalElements, {
mutateElement(duplicate, originalElements, {
[bindingProp]: {
...original[bindingProp],
elementId: newBindableId,
},
});
mutateElementWith(newBindable, originalElements, {
mutateElement(newBindable, originalElements, {
boundElements: [
...(newBindable.boundElements ?? []).filter(
(el) => el.id !== original.id && el.id !== duplicate.id,
@ -1603,13 +1603,13 @@ const fixReversedBindingsForArrows = (
(el) => el.id === oldBindableId,
);
if (originalBindable) {
mutateElementWith(duplicate, originalElements, {
mutateElement(duplicate, originalElements, {
[bindingProp]: original[bindingProp],
});
mutateElementWith(original, originalElements, {
mutateElement(original, originalElements, {
[bindingProp]: null,
});
mutateElementWith(originalBindable, originalElements, {
mutateElement(originalBindable, originalElements, {
boundElements: [
...(originalBindable.boundElements?.filter(
(el) => el.id !== original.id,
@ -1672,10 +1672,10 @@ export const fixBindingsAfterDeletion = (
for (const element of deletedElements) {
BoundElement.unbindAffected(elements, element, (element, updates) =>
mutateElementWith(element, elements, updates),
mutateElement(element, elements, updates),
);
BindableElement.unbindAffected(elements, element, (element, updates) =>
mutateElementWith(element, elements, updates),
mutateElement(element, elements, updates),
);
}
};

View file

@ -168,7 +168,7 @@ const updateElementCoords = (
const nextX = originalElement.x + dragOffset.x;
const nextY = originalElement.y + dragOffset.y;
scene.mutate(element, {
scene.mutateElement(element, {
x: nextX,
y: nextY,
});
@ -292,7 +292,7 @@ export const dragNewElement = ({
};
}
scene.mutate(
scene.mutateElement(
newElement,
{
x: newX + (originOffset?.x ?? 0),
@ -302,7 +302,7 @@ export const dragNewElement = ({
...textAutoResize,
...imageInitialDimension,
},
{ informMutation },
{ informMutation, isDragging: false },
);
}
};

View file

@ -46,7 +46,7 @@ import {
headingForPoint,
} from "./heading";
import { type ElementUpdate } from "./mutateElement";
import { isBindableElement, isElbowArrow } from "./typeChecks";
import { isBindableElement } from "./typeChecks";
import {
type ExcalidrawElbowArrowElement,
type NonDeletedSceneElementsMap,
@ -60,7 +60,6 @@ import type {
Arrowhead,
ElementsMap,
ExcalidrawBindableElement,
ExcalidrawElement,
FixedPointBinding,
FixedSegment,
NonDeletedExcalidrawElement,
@ -879,27 +878,6 @@ 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
);
};
/**
*
*/

View file

@ -19,7 +19,7 @@ import {
type Heading,
} from "./heading";
import { LinearElementEditor } from "./linearElementEditor";
import { mutateElementWith } from "./mutateElement";
import { mutateElement } from "./mutateElement";
import { newArrowElement, newElement } from "./newElement";
import { aabbForElement } from "./shapes";
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
@ -678,7 +678,7 @@ export class FlowChartCreator {
)
) {
this.pendingNodes = this.pendingNodes.map((node) =>
mutateElementWith(node, elementsMap, {
mutateElement(node, elementsMap, {
frameId: startNode.frameId,
}),
);

View file

@ -2,7 +2,7 @@ import { generateNKeysBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common";
import { mutateElementWith } from "./mutateElement";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement } from "./textElement";
import { hasBoundTextElement } from "./typeChecks";
@ -177,7 +177,7 @@ export const syncMovedIndices = (
// split mutation so we don't end up in an incosistent state
for (const [element, update] of elementsUpdates) {
mutateElementWith(element, arrayToMap(elements), update);
mutateElement(element, arrayToMap(elements), update);
}
} catch (e) {
// fallback to default sync
@ -198,7 +198,7 @@ export const syncInvalidIndices = (
const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, update] of elementsUpdates) {
mutateElementWith(element, arrayToMap(elements), update);
mutateElement(element, arrayToMap(elements), update);
}
return elements as OrderedExcalidrawElement[];

View file

@ -19,7 +19,7 @@ import {
getCommonBounds,
getElementAbsoluteCoords,
} from "./bounds";
import { mutateElementWith } from "./mutateElement";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, getContainerElement } from "./textElement";
import {
isFrameElement,
@ -57,7 +57,7 @@ export const bindElementsToFramesAfterDuplication = (
if (nextElementId) {
const nextElement = nextElementMap.get(nextElementId);
if (nextElement) {
mutateElementWith(nextElement, nextElementMap, {
mutateElement(nextElement, nextElementMap, {
frameId: nextFrameId ?? element.frameId,
});
}
@ -563,7 +563,7 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
}
for (const element of finalElementsToAdd) {
mutateElementWith(element, elementsMap, {
mutateElement(element, elementsMap, {
frameId: frame.id,
});
}
@ -603,7 +603,7 @@ export const removeElementsFromFrame = (
}
for (const [, element] of _elementsToRemove) {
mutateElementWith(element, elementsMap, {
mutateElement(element, elementsMap, {
frameId: null,
});
}

View file

@ -47,7 +47,7 @@ import {
} from "./bounds";
import { headingIsHorizontal, vectorToHeading } from "./heading";
import { mutateElementWith } from "./mutateElement";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import {
isBindingElement,
@ -793,7 +793,7 @@ export class LinearElementEditor {
);
} else if (event.altKey && appState.editingLinearElement) {
if (linearElementEditor.lastUncommittedPoint == null) {
scene.mutate(element, {
scene.mutateElement(element, {
points: [
...element.points,
LinearElementEditor.createPointAt(
@ -1165,7 +1165,7 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
) {
mutateElementWith(
mutateElement(
element,
elementsMap,
LinearElementEditor.getNormalizedPoints(element),
@ -1221,7 +1221,7 @@ export class LinearElementEditor {
return acc;
}, []);
scene.mutate(element, { points: nextPoints });
scene.mutateElement(element, { points: nextPoints });
// temp hack to ensure the line doesn't move when adding point to the end,
// potentially expanding the bounding box
@ -1442,7 +1442,7 @@ export class LinearElementEditor {
...element.points.slice(segmentMidpoint.index!),
];
scene.mutate(element, { points });
scene.mutateElement(element, { points });
ret.pointerDownState = {
...linearElementEditor.pointerDownState,
@ -1495,8 +1495,9 @@ export class LinearElementEditor {
updates.points = Array.from(nextPoints);
scene.mutate(element, updates, {
isDragging: options?.isDragging,
scene.mutateElement(element, updates, {
informMutation: true,
isDragging: options?.isDragging ?? false,
});
} else {
const nextCoords = getElementPointsCoords(element, nextPoints);
@ -1512,7 +1513,7 @@ export class LinearElementEditor {
pointFrom(dX, dY),
element.angle,
);
scene.mutate(element, {
scene.mutateElement(element, {
...otherUpdates,
points: nextPoints,
x: element.x + rotated[0],
@ -1571,7 +1572,7 @@ export class LinearElementEditor {
elementsMap,
);
if (points.length < 2) {
mutateElementWith(boundTextElement, elementsMap, { isDeleted: true });
mutateElement(boundTextElement, elementsMap, { isDeleted: true });
}
let x = 0;
let y = 0;
@ -1823,7 +1824,7 @@ export class LinearElementEditor {
.map((segment) => segment.index)
.reduce((count, idx) => (idx < index ? count + 1 : count), 0);
scene.mutate(element, {
scene.mutateElement(element, {
fixedSegments: nextFixedSegments,
});
@ -1860,7 +1861,7 @@ export class LinearElementEditor {
scene: Scene,
index: number,
): void {
scene.mutate(element, {
scene.mutateElement(element, {
fixedSegments: element.fixedSegments?.filter(
(segment) => segment.index !== index,
),

View file

@ -10,12 +10,12 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./ShapeCache";
import {
elbowArrowNeedsToGetNormalized,
updateElbowArrowPoints,
} from "./elbowArrow";
import { updateElbowArrowPoints } from "./elbowArrow";
import { isElbowArrow } from "./typeChecks";
import type {
ElementsMap,
ExcalidrawElbowArrowElement,
ExcalidrawElement,
NonDeletedSceneElementsMap,
@ -26,24 +26,38 @@ export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
"id" | "version" | "versionNonce" | "updated"
>;
// This function tracks updates of text elements for the purposes for collaboration.
// The version is used to compare updates when more than one user is working in
// the same drawing. Note: this won't trigger the component to update, unlike `scene.mutate`.
export const mutateElementWith = <TElement extends Mutable<ExcalidrawElement>>(
/**
* This function tracks updates of text elements for the purposes for collaboration.
* The version is used to compare updates when more than one user is working in
* the same drawing.
*
* WARNING: this won't trigger the component to update, so if you need to trigger component update,
* use `scene.mutateElement` or `ExcalidrawImperativeAPI.mutateElement` instead.
*/
export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
elementsMap: Map<string, ExcalidrawElement>,
elementsMap: ElementsMap,
updates: ElementUpdate<TElement>,
options?: {
isDragging?: boolean;
},
) => {
let didChange = false;
// casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732)
const { points, fixedSegments, startBinding, endBinding, fileId } =
updates as any;
if (
elbowArrowNeedsToGetNormalized(
element,
updates as ElementUpdate<ExcalidrawElbowArrowElement>,
)
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 normalizedUpdates = {
updates = {
...updates,
angle: 0 as Radians,
...updateElbowArrowPoints(
@ -52,35 +66,8 @@ export const mutateElementWith = <TElement extends Mutable<ExcalidrawElement>>(
updates as ElementUpdate<ExcalidrawElbowArrowElement>,
options,
),
} as ElementUpdate<ExcalidrawElbowArrowElement>;
return mutateElement(
element as ExcalidrawElbowArrowElement,
normalizedUpdates,
);
}
return mutateElement(element, updates);
};
/**
* This function tracks updates of text elements for the purposes for collaboration.
* The version is used to compare updates when more than one user is working in
* the same drawing.
*
* @deprecated Use `scene.mutate` as direct equivalent, or `mutateElementWith` in case you don't need to trigger component update.
*/
export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
): TElement => {
let didChange = false;
// casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732)
const { points, fileId } = updates as any;
if (typeof points !== "undefined") {
};
} else if (typeof points !== "undefined") {
updates = { ...getSizeFromPoints(points), ...updates };
}

View file

@ -30,7 +30,6 @@ import {
getElementBounds,
} from "./bounds";
import { LinearElementEditor } from "./linearElementEditor";
import { mutateElementWith } from "./mutateElement";
import {
getBoundTextElement,
getBoundTextElementId,
@ -230,13 +229,13 @@ const rotateSingleElement = (
}
const boundTextElementId = getBoundTextElementId(element);
scene.mutate(element, { angle });
scene.mutateElement(element, { angle });
if (boundTextElementId) {
const textElement =
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
if (textElement && !isArrowElement(element)) {
scene.mutate(textElement, { angle });
scene.mutateElement(textElement, { angle });
}
}
};
@ -391,7 +390,7 @@ const resizeSingleTextElement = (
);
const [nextX, nextY] = newTopLeft;
scene.mutate(element, {
scene.mutateElement(element, {
fontSize: metrics.size,
width: nextWidth,
height: nextHeight,
@ -506,7 +505,7 @@ const resizeSingleTextElement = (
autoResize: false,
};
scene.mutate(element, resizedElement);
scene.mutateElement(element, resizedElement);
}
};
@ -552,7 +551,7 @@ const rotateMultipleElements = (
angle: normalizeRadians((centerAngle + origAngle) as Radians),
};
scene.mutate(element, updates);
scene.mutateElement(element, updates);
updateBoundElements(element, scene, {
simultaneouslyUpdated: elements,
@ -560,7 +559,7 @@ const rotateMultipleElements = (
const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !isArrowElement(element)) {
mutateElementWith(boundText, elementsMap, {
scene.mutateElement(boundText, {
x: boundText.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy),
angle: normalizeRadians((centerAngle + origAngle) as Radians),
@ -923,7 +922,7 @@ export const resizeSingleElement = (
}
if ("scale" in latestElement && "scale" in origElement) {
scene.mutate(latestElement, {
scene.mutateElement(latestElement, {
scale: [
// defaulting because scaleX/Y can be 0/-0
(Math.sign(nextWidth) || origElement.scale[0]) * origElement.scale[0],
@ -958,8 +957,9 @@ export const resizeSingleElement = (
...rescaledPoints,
};
scene.mutate(latestElement, updates, {
scene.mutateElement(latestElement, updates, {
informMutation: shouldInformMutation,
isDragging: false,
});
updateBoundElements(latestElement, scene, {
@ -968,7 +968,7 @@ export const resizeSingleElement = (
});
if (boundTextElement && boundTextFont != null) {
scene.mutate(boundTextElement, {
scene.mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
});
}
@ -1518,7 +1518,8 @@ export const resizeMultipleElements = (
} of elementsAndUpdates) {
const { width, height, angle } = update;
scene.mutate(element, update, {
scene.mutateElement(element, update, {
informMutation: true,
// needed for the fixed binding point udpate to take effect
isDragging: true,
});
@ -1530,7 +1531,7 @@ export const resizeMultipleElements = (
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && boundTextFontSize) {
scene.mutate(boundTextElement, {
scene.mutateElement(boundTextElement, {
fontSize: boundTextFontSize,
angle: isLinearElement(element) ? undefined : angle,
});

View file

@ -93,7 +93,7 @@ export const redrawTextBoundingBox = (
metrics.height,
container.type,
);
scene.mutate(container, { height: nextHeight });
scene.mutateElement(container, { height: nextHeight });
updateOriginalContainerCache(container.id, nextHeight);
}
@ -102,7 +102,7 @@ export const redrawTextBoundingBox = (
metrics.width,
container.type,
);
scene.mutate(container, { width: nextWidth });
scene.mutateElement(container, { width: nextWidth });
}
const updatedTextElement = {
@ -120,7 +120,7 @@ export const redrawTextBoundingBox = (
boundTextUpdates.y = y;
}
scene.mutate(textElement, boundTextUpdates);
scene.mutateElement(textElement, boundTextUpdates);
};
export const handleBindTextResize = (
@ -182,20 +182,20 @@ export const handleBindTextResize = (
transformHandleType === "n")
? container.y - diff
: container.y;
scene.mutate(container, {
scene.mutateElement(container, {
height: containerHeight,
y: updatedY,
});
}
scene.mutate(textElement, {
scene.mutateElement(textElement, {
text,
width: nextWidth,
height: nextHeight,
});
if (!isArrowElement(container)) {
scene.mutate(
scene.mutateElement(
textElement,
computeBoundTextPosition(container, textElement, elementsMap),
);

View file

@ -62,7 +62,7 @@ describe("duplicating single elements", () => {
// @ts-ignore
element.__proto__ = { hello: "world" };
h.app.scene.mutate(element, {
h.app.scene.mutateElement(element, {
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
});

View file

@ -143,7 +143,7 @@ describe("elbow arrow routing", () => {
elbowed: true,
}) as ExcalidrawElbowArrowElement;
scene.insertElement(arrow);
h.app.scene.mutate(arrow, {
h.app.scene.mutateElement(arrow, {
points: [
pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
@ -195,7 +195,7 @@ describe("elbow arrow routing", () => {
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
h.app.scene.mutate(arrow, {
h.app.scene.mutateElement(arrow, {
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
});

View file

@ -35,7 +35,7 @@ describe("normalizeElementsOrder", () => {
boundElements: [],
});
h.app.scene.mutate(container, {
h.app.scene.mutateElement(container, {
boundElements: [{ type: "text", id: boundText.id }],
});
@ -352,7 +352,7 @@ describe("normalizeElementsOrder", () => {
containerId: container.id,
});
h.app.scene.mutate(container, {
h.app.scene.mutateElement(container, {
boundElements: [
{ type: "text", id: boundText.id },
{ type: "text", id: "xxx" },
@ -387,7 +387,7 @@ describe("normalizeElementsOrder", () => {
boundElements: [],
groupIds: ["C", "A"],
});
h.app.scene.mutate(container, {
h.app.scene.mutateElement(container, {
boundElements: [{ type: "text", id: boundText.id }],
});