Mutate element in the scene

This commit is contained in:
Marcel Mraz 2025-04-09 00:15:00 +00:00
parent 703a8f0e78
commit a9c3b2a4d4
6 changed files with 162 additions and 225 deletions

View file

@ -909,7 +909,6 @@ export const elbowArrowNeedsToGetNormalized = (
export const mutateElbowArrow = ( export const mutateElbowArrow = (
element: Readonly<ExcalidrawElbowArrowElement>, element: Readonly<ExcalidrawElbowArrowElement>,
updates: ElementUpdate<ExcalidrawElbowArrowElement>, updates: ElementUpdate<ExcalidrawElbowArrowElement>,
informMutation: boolean = true,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap | ElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap | ElementsMap,
options?: { options?: {
isDragging?: boolean; isDragging?: boolean;
@ -921,23 +920,19 @@ export const mutateElbowArrow = (
); );
if (!elbowArrowNeedsToGetNormalized(element, updates)) { if (!elbowArrowNeedsToGetNormalized(element, updates)) {
return mutateElement(element, updates, informMutation); return mutateElement(element, updates);
} }
return mutateElement( return mutateElement(element, {
element, ...updates,
{ angle: 0 as Radians,
...updates, ...updateElbowArrowPoints(
angle: 0 as Radians, element,
...updateElbowArrowPoints( elementsMap as NonDeletedSceneElementsMap,
element, updates,
elementsMap as NonDeletedSceneElementsMap, options,
updates, ),
options, });
),
},
informMutation,
);
}; };
/** /**

View file

@ -20,8 +20,6 @@ import {
tupleToCoors, tupleToCoors,
} from "@excalidraw/common"; } 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 type Scene from "@excalidraw/excalidraw/scene/Scene"; import type Scene from "@excalidraw/excalidraw/scene/Scene";
import type { Store } from "@excalidraw/excalidraw/store"; import type { Store } from "@excalidraw/excalidraw/store";

View file

@ -5,10 +5,6 @@ import {
invariant, invariant,
} from "@excalidraw/common"; } 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 { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./ShapeCache"; import { ShapeCache } from "./ShapeCache";
@ -29,7 +25,6 @@ export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>( export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement, element: TElement,
updates: ElementUpdate<TElement>, updates: ElementUpdate<TElement>,
informMutation = true,
): TElement => { ): TElement => {
let didChange = false; let didChange = false;
@ -118,10 +113,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element.versionNonce = randomInteger(); element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp(); element.updated = getUpdatedTimestamp();
if (informMutation) {
Scene.getScene(element)?.triggerUpdate();
}
return element; return element;
}; };

View file

@ -1534,14 +1534,10 @@ export const resizeMultipleElements = (
} of elementsAndUpdates) { } of elementsAndUpdates) {
const { width, height, angle } = update; const { width, height, angle } = update;
if (isElbowArrow(element)) { scene.mutateElement(element, update, false, {
mutateElbowArrow(element, update, false, elementsMap, { // needed for the fixed binding point udpate to take effect
// needed for the fixed binding point udpate to take effect isDragging: true,
isDragging: true, });
});
} else {
mutateElement(element, update, false);
}
updateBoundElements(element, elementsMap as SceneElementsMap, { updateBoundElements(element, elementsMap as SceneElementsMap, {
simultaneouslyUpdated: elementsToUpdate, simultaneouslyUpdated: elementsToUpdate,
@ -1550,7 +1546,7 @@ export const resizeMultipleElements = (
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && boundTextFontSize) { if (boundTextElement && boundTextFontSize) {
mutateElement( scene.mutateElement(
boundTextElement, boundTextElement,
{ {
fontSize: boundTextFontSize, fontSize: boundTextFontSize,

View file

@ -122,10 +122,7 @@ import {
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { import { newElementWith } from "@excalidraw/element/mutateElement";
mutateElement,
newElementWith,
} from "@excalidraw/element/mutateElement";
import { import {
newFrameElement, newFrameElement,
@ -302,10 +299,6 @@ import {
import { isNonDeletedElement } from "@excalidraw/element"; 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 { LocalPoint, Radians } from "@excalidraw/math";
import type { import type {
@ -1409,7 +1402,7 @@ class App extends React.Component<AppProps, AppState> {
private resetEditingFrame = (frame: ExcalidrawFrameLikeElement | null) => { private resetEditingFrame = (frame: ExcalidrawFrameLikeElement | null) => {
if (frame) { if (frame) {
mutateElement(frame, { name: frame.name?.trim() || null }); this.scene.mutateElement(frame, { name: frame.name?.trim() || null });
} }
this.setState({ editingFrame: null }); this.setState({ editingFrame: null });
}; };
@ -1466,7 +1459,7 @@ class App extends React.Component<AppProps, AppState> {
autoFocus autoFocus
value={frameNameInEdit} value={frameNameInEdit}
onChange={(e) => { onChange={(e) => {
mutateElement(f, { this.scene.mutateElement(f, {
name: e.target.value, name: e.target.value,
}); });
}} }}
@ -1957,17 +1950,13 @@ class App extends React.Component<AppProps, AppState> {
// state only. // state only.
// Thus reset so that we prefer local cache (if there was some // Thus reset so that we prefer local cache (if there was some
// generationData set previously) // generationData set previously)
mutateElement( this.scene.mutateElement(frameElement, {
frameElement, customData: { generationData: undefined },
{ customData: { generationData: undefined } }, }, { informMutation: false });
false,
);
} else { } else {
mutateElement( this.scene.mutateElement(frameElement, {
frameElement, customData: { generationData: data },
{ customData: { generationData: data } }, }, { informMutation: false });
false,
);
} }
this.magicGenerations.set(frameElement.id, data); this.magicGenerations.set(frameElement.id, data);
this.triggerRender(); this.triggerRender();
@ -2138,7 +2127,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.insertElement(frame); this.scene.insertElement(frame);
for (const child of selectedElements) { for (const child of selectedElements) {
mutateElement(child, { frameId: frame.id }); this.scene.mutateElement(child, { frameId: frame.id });
} }
this.setState({ this.setState({
@ -3463,7 +3452,7 @@ class App extends React.Component<AppProps, AppState> {
} }
// hack to reset the `y` coord because we vertically center during // hack to reset the `y` coord because we vertically center during
// insertImageElement // insertImageElement
mutateElement(initializedImageElement, { y }, false); this.scene.mutateElement(initializedImageElement, { y }, { informMutation: false });
y = imageElement.y + imageElement.height + 25; y = imageElement.y + imageElement.height + 25;
@ -4429,14 +4418,10 @@ class App extends React.Component<AppProps, AppState> {
} }
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
mutateElement( this.scene.mutateElement(element, {
element, x: element.x + offsetX,
{ y: element.y + offsetY,
x: element.x + offsetX, }, { informMutation: false });
y: element.y + offsetY,
},
false,
);
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), { updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
simultaneouslyUpdated: selectedElements, simultaneouslyUpdated: selectedElements,
@ -5339,7 +5324,10 @@ class App extends React.Component<AppProps, AppState> {
const minHeight = getApproxMinLineHeight(fontSize, lineHeight); const minHeight = getApproxMinLineHeight(fontSize, lineHeight);
const newHeight = Math.max(container.height, minHeight); const newHeight = Math.max(container.height, minHeight);
const newWidth = Math.max(container.width, minWidth); const newWidth = Math.max(container.width, minWidth);
mutateElement(container, { height: newHeight, width: newWidth }); this.scene.mutateElement(container, {
height: newHeight,
width: newWidth,
});
sceneX = container.x + newWidth / 2; sceneX = container.x + newWidth / 2;
sceneY = container.y + newHeight / 2; sceneY = container.y + newHeight / 2;
if (parentCenterPosition) { if (parentCenterPosition) {
@ -5390,7 +5378,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
if (!existingTextElement && shouldBindToContainer && container) { if (!existingTextElement && shouldBindToContainer && container) {
mutateElement(container, { this.scene.mutateElement(container, {
boundElements: (container.boundElements || []).concat({ boundElements: (container.boundElements || []).concat({
type: "text", type: "text",
id: element.id, id: element.id,
@ -5939,7 +5927,7 @@ class App extends React.Component<AppProps, AppState> {
lastPoint, lastPoint,
) >= LINE_CONFIRM_THRESHOLD ) >= LINE_CONFIRM_THRESHOLD
) { ) {
mutateElement( this.scene.mutateElement(
multiElement, multiElement,
{ {
points: [ points: [
@ -5947,7 +5935,7 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<LocalPoint>(scenePointerX - rx, scenePointerY - ry), pointFrom<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
], ],
}, },
false, { informMutation: false },
); );
} else { } else {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
@ -5963,12 +5951,12 @@ class App extends React.Component<AppProps, AppState> {
) < LINE_CONFIRM_THRESHOLD ) < LINE_CONFIRM_THRESHOLD
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
mutateElement( this.scene.mutateElement(
multiElement, multiElement,
{ {
points: points.slice(0, -1), points: points.slice(0, -1),
}, },
false, { informMutation: false },
); );
} else { } else {
const [gridX, gridY] = getGridPoint( const [gridX, gridY] = getGridPoint(
@ -6000,30 +5988,24 @@ class App extends React.Component<AppProps, AppState> {
if (isPathALoop(points, this.state.zoom.value)) { if (isPathALoop(points, this.state.zoom.value)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
} }
const updates = {
points: [
...points.slice(0, -1),
pointFrom<LocalPoint>(
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
),
],
};
if (isElbowArrow(multiElement)) { // update last uncommitted point
mutateElbowArrow( this.scene.mutateElement(
multiElement, multiElement,
updates, {
false, points: [
this.scene.getNonDeletedElementsMap(), ...points.slice(0, -1),
{ pointFrom<LocalPoint>(
isDragging: true, lastCommittedX + dxFromLastCommitted,
}, lastCommittedY + dyFromLastCommitted,
); ),
} else { ],
// update last uncommitted point },
mutateElement(multiElement, updates, false); {
} isDragging: true,
informMutation: false,
},
);
// in this path, we're mutating multiElement to reflect // in this path, we're mutating multiElement to reflect
// how it will be after adding pointer position as the next point // how it will be after adding pointer position as the next point
@ -6693,7 +6675,7 @@ class App extends React.Component<AppProps, AppState> {
const frame = this.getTopLayerFrameAtSceneCoords({ x, y }); const frame = this.getTopLayerFrameAtSceneCoords({ x, y });
mutateElement(pendingImageElement, { this.scene.mutateElement(pendingImageElement, {
x, x,
y, y,
frameId: frame ? frame.id : null, frameId: frame ? frame.id : null,
@ -7747,7 +7729,7 @@ class App extends React.Component<AppProps, AppState> {
multiElement.type === "line" && multiElement.type === "line" &&
isPathALoop(multiElement.points, this.state.zoom.value) isPathALoop(multiElement.points, this.state.zoom.value)
) { ) {
mutateElement(multiElement, { this.scene.mutateElement(multiElement, {
lastCommittedPoint: lastCommittedPoint:
multiElement.points[multiElement.points.length - 1], multiElement.points[multiElement.points.length - 1],
}); });
@ -7758,7 +7740,7 @@ class App extends React.Component<AppProps, AppState> {
// Elbow arrows cannot be created by putting down points // Elbow arrows cannot be created by putting down points
// only the start and end points can be defined // only the start and end points can be defined
if (isElbowArrow(multiElement) && multiElement.points.length > 1) { if (isElbowArrow(multiElement) && multiElement.points.length > 1) {
mutateElement(multiElement, { this.scene.mutateElement(multiElement, {
lastCommittedPoint: lastCommittedPoint:
multiElement.points[multiElement.points.length - 1], multiElement.points[multiElement.points.length - 1],
}); });
@ -7795,7 +7777,7 @@ class App extends React.Component<AppProps, AppState> {
})); }));
// clicking outside commit zone → update reference for last committed // clicking outside commit zone → update reference for last committed
// point // point
mutateElement(multiElement, { this.scene.mutateElement(multiElement, {
lastCommittedPoint: multiElement.points[multiElement.points.length - 1], lastCommittedPoint: multiElement.points[multiElement.points.length - 1],
}); });
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
@ -7881,7 +7863,7 @@ class App extends React.Component<AppProps, AppState> {
), ),
}; };
}); });
mutateElement(element, { this.scene.mutateElement(element, {
points: [...element.points, pointFrom<LocalPoint>(0, 0)], points: [...element.points, pointFrom<LocalPoint>(0, 0)],
}); });
const boundElement = getHoveredElementForBinding( const boundElement = getHoveredElementForBinding(
@ -8463,7 +8445,7 @@ class App extends React.Component<AppProps, AppState> {
), ),
}; };
mutateElement(croppingElement, { this.scene.mutateElement(croppingElement, {
crop: nextCrop, crop: nextCrop,
}); });
@ -8660,13 +8642,15 @@ class App extends React.Component<AppProps, AppState> {
? newElement.pressures ? newElement.pressures
: [...newElement.pressures, event.pressure]; : [...newElement.pressures, event.pressure];
mutateElement( this.scene.mutateElement(
newElement, newElement,
{ {
points: [...points, pointFrom<LocalPoint>(dx, dy)], points: [...points, pointFrom<LocalPoint>(dx, dy)],
pressures, pressures,
}, },
false, {
informMutation: false,
},
); );
this.setState({ this.setState({
@ -8688,33 +8672,24 @@ 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) { if (points.length === 1) {
mutate({ this.scene.mutateElement(
points: [...points, pointFrom<LocalPoint>(dx, dy)], newElement,
}); {
points: [...points, pointFrom<LocalPoint>(dx, dy)],
},
{ informMutation: false },
);
} else if ( } else if (
points.length === 2 || points.length === 2 ||
(points.length > 1 && isElbowArrow(newElement)) (points.length > 1 && isElbowArrow(newElement))
) { ) {
mutate( this.scene.mutateElement(
newElement,
{ {
points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)], points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
}, },
{ isDragging: true } { isDragging: true },
); );
} }
@ -8925,7 +8900,7 @@ class App extends React.Component<AppProps, AppState> {
.map((e) => elementsMap.get(e.id)) .map((e) => elementsMap.get(e.id))
.filter((e) => isElbowArrow(e)) .filter((e) => isElbowArrow(e))
.forEach((e) => { .forEach((e) => {
!!e && mutateElement(e, {}, true); !!e && this.scene.mutateElement(e, {});
}); });
} }
} }
@ -8961,11 +8936,9 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
); );
if (element) { if (element) {
mutateElbowArrow( this.scene.mutateElement(
element as ExcalidrawElbowArrowElement, element as ExcalidrawElbowArrowElement,
{}, {},
true,
this.scene.getNonDeletedElementsMap(),
); );
} }
} }
@ -9062,7 +9035,7 @@ class App extends React.Component<AppProps, AppState> {
? [] ? []
: [...newElement.pressures, childEvent.pressure]; : [...newElement.pressures, childEvent.pressure];
mutateElement(newElement, { this.scene.mutateElement(newElement, {
points: [...points, pointFrom<LocalPoint>(dx, dy)], points: [...points, pointFrom<LocalPoint>(dx, dy)],
pressures, pressures,
lastCommittedPoint: pointFrom<LocalPoint>(dx, dy), lastCommittedPoint: pointFrom<LocalPoint>(dx, dy),
@ -9109,26 +9082,19 @@ class App extends React.Component<AppProps, AppState> {
); );
if (!pointerDownState.drag.hasOccurred && newElement && !multiElement) { if (!pointerDownState.drag.hasOccurred && newElement && !multiElement) {
const updates = { this.scene.mutateElement(
points: [ newElement,
...newElement.points, {
pointFrom<LocalPoint>( points: [
pointerCoords.x - newElement.x, ...newElement.points,
pointerCoords.y - newElement.y, pointFrom<LocalPoint>(
), pointerCoords.x - newElement.x,
], pointerCoords.y - newElement.y,
}; ),
],
if (isElbowArrow(newElement)) { },
mutateElbowArrow( { informMutation: false },
newElement, );
updates,
false,
this.scene.getNonDeletedElementsMap(),
);
} else {
mutateElement(newElement, updates, false);
}
this.setState({ this.setState({
multiElement: newElement, multiElement: newElement,
@ -9185,7 +9151,7 @@ class App extends React.Component<AppProps, AppState> {
); );
if (newElement.width < minWidth) { if (newElement.width < minWidth) {
mutateElement(newElement, { this.scene.mutateElement(newElement, {
autoResize: true, autoResize: true,
}); });
} }
@ -9235,7 +9201,13 @@ class App extends React.Component<AppProps, AppState> {
} }
if (newElement) { if (newElement) {
mutateElement(newElement, getNormalizedDimensions(newElement)); this.scene.mutateElement(
newElement,
getNormalizedDimensions(newElement),
{
informMutation: false,
},
);
// the above does not guarantee the scene to be rendered again, hence the trigger below // the above does not guarantee the scene to be rendered again, hence the trigger below
this.scene.triggerUpdate(); this.scene.triggerUpdate();
} }
@ -9267,7 +9239,7 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
// remove the linear element from all groups // remove the linear element from all groups
// before removing it from the frame as well // before removing it from the frame as well
mutateElement(linearElement, { this.scene.mutateElement(linearElement, {
groupIds: [], groupIds: [],
}); });
@ -9296,13 +9268,9 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingGroupId!, this.state.editingGroupId!,
); );
mutateElement( this.scene.mutateElement(element, {
element, groupIds: element.groupIds.slice(0, index),
{ }, { informMutation: false });
groupIds: element.groupIds.slice(0, index),
},
false,
);
} }
nextElements.forEach((element) => { nextElements.forEach((element) => {
@ -9313,13 +9281,9 @@ class App extends React.Component<AppProps, AppState> {
element.groupIds[element.groupIds.length - 1], element.groupIds[element.groupIds.length - 1],
).length < 2 ).length < 2
) { ) {
mutateElement( this.scene.mutateElement(element, {
element, groupIds: [],
{ }, { informMutation: false });
groupIds: [],
},
false,
);
} }
}); });
@ -9881,13 +9845,9 @@ class App extends React.Component<AppProps, AppState> {
const dataURL = const dataURL =
this.files[fileId]?.dataURL || (await getDataURL(imageFile)); this.files[fileId]?.dataURL || (await getDataURL(imageFile));
const imageElement = mutateElement( const imageElement = this.scene.mutateElement(_imageElement, {
_imageElement, fileId,
{ }, { informMutation: false }) as NonDeleted<InitializedExcalidrawImageElement>;
fileId,
},
false,
) as NonDeleted<InitializedExcalidrawImageElement>;
return new Promise<NonDeleted<InitializedExcalidrawImageElement>>( return new Promise<NonDeleted<InitializedExcalidrawImageElement>>(
async (resolve, reject) => { async (resolve, reject) => {
@ -9952,7 +9912,7 @@ class App extends React.Component<AppProps, AppState> {
showCursorImagePreview, showCursorImagePreview,
}); });
} catch (error: any) { } catch (error: any) {
mutateElement(imageElement, { this.scene.mutateElement(imageElement, {
isDeleted: true, isDeleted: true,
}); });
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
@ -10098,7 +10058,7 @@ class App extends React.Component<AppProps, AppState> {
imageElement.height < DRAGGING_THRESHOLD / this.state.zoom.value imageElement.height < DRAGGING_THRESHOLD / this.state.zoom.value
) { ) {
const placeholderSize = 100 / this.state.zoom.value; const placeholderSize = 100 / this.state.zoom.value;
mutateElement(imageElement, { this.scene.mutateElement(imageElement, {
x: imageElement.x - placeholderSize / 2, x: imageElement.x - placeholderSize / 2,
y: imageElement.y - placeholderSize / 2, y: imageElement.y - placeholderSize / 2,
width: placeholderSize, width: placeholderSize,
@ -10132,7 +10092,7 @@ class App extends React.Component<AppProps, AppState> {
const x = imageElement.x + imageElement.width / 2 - width / 2; const x = imageElement.x + imageElement.width / 2 - width / 2;
const y = imageElement.y + imageElement.height / 2 - height / 2; const y = imageElement.y + imageElement.height / 2 - height / 2;
mutateElement(imageElement, { this.scene.mutateElement(imageElement, {
x, x,
y, y,
width, width,
@ -10748,7 +10708,7 @@ class App extends React.Component<AppProps, AppState> {
transformHandleType, transformHandleType,
); );
mutateElement( this.scene.mutateElement(
croppingElement, croppingElement,
cropElement( cropElement(
croppingElement, croppingElement,

View file

@ -8,7 +8,10 @@ import {
isTestEnv, isTestEnv,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { isNonDeletedElement } from "@excalidraw/element"; import { isNonDeletedElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks"; import {
isElbowArrow,
isFrameLikeElement,
} from "@excalidraw/element/typeChecks";
import { getElementsInGroup } from "@excalidraw/element/groups"; import { getElementsInGroup } from "@excalidraw/element/groups";
import { import {
@ -19,7 +22,13 @@ import {
import { getSelectedElements } from "@excalidraw/element/selection"; import { getSelectedElements } from "@excalidraw/element/selection";
import type { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import {
mutateElement,
type ElementUpdate,
} from "@excalidraw/element/mutateElement";
import { mutateElbowArrow } from "@excalidraw/element/elbowArrow";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
@ -30,15 +39,17 @@ import type {
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
OrderedExcalidrawElement, OrderedExcalidrawElement,
Ordered, Ordered,
ExcalidrawElbowArrowElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { Assert, SameType } from "@excalidraw/common/utility-types"; import type {
Assert,
Mutable,
SameType,
} from "@excalidraw/common/utility-types";
import type { AppState } from "../types"; import type { AppState } from "../types";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
type ElementKey = ExcalidrawElement | ElementIdKey;
type SceneStateCallback = () => void; type SceneStateCallback = () => void;
type SceneStateCallbackRemover = () => void; type SceneStateCallbackRemover = () => void;
@ -102,44 +113,7 @@ const hashSelectionOpts = (
// in our codebase // in our codebase
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[]; export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => {
if (typeof elementKey === "string") {
return true;
}
return false;
};
class Scene { class Scene {
// ---------------------------------------------------------------------------
// static methods/props
// ---------------------------------------------------------------------------
private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>();
private static sceneMapById = new Map<string, Scene>();
static mapElementToScene(elementKey: ElementKey, scene: Scene) {
if (isIdKey(elementKey)) {
// for cases where we don't have access to the element object
// (e.g. restore serialized appState with id references)
this.sceneMapById.set(elementKey, scene);
} else {
this.sceneMapByElement.set(elementKey, scene);
// if mapping element objects, also cache the id string when later
// looking up by id alone
this.sceneMapById.set(elementKey.id, scene);
}
}
/**
* @deprecated pass down `app.scene` and use it directly
*/
static getScene(elementKey: ElementKey): Scene | null {
if (isIdKey(elementKey)) {
return this.sceneMapById.get(elementKey) || null;
}
return this.sceneMapByElement.get(elementKey) || null;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// instance methods/props // instance methods/props
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -308,7 +282,6 @@ class Scene {
nextFrameLikes.push(element); nextFrameLikes.push(element);
} }
this.elementsMap.set(element.id, element); this.elementsMap.set(element.id, element);
Scene.mapElementToScene(element, this);
}); });
const nonDeletedElements = getNonDeletedElements(this.elements); const nonDeletedElements = getNonDeletedElements(this.elements);
this.nonDeletedElements = nonDeletedElements.elements; this.nonDeletedElements = nonDeletedElements.elements;
@ -353,12 +326,6 @@ class Scene {
this.selectedElementsCache.elements = null; this.selectedElementsCache.elements = null;
this.selectedElementsCache.cache.clear(); this.selectedElementsCache.cache.clear();
Scene.sceneMapById.forEach((scene, elementKey) => {
if (scene === this) {
Scene.sceneMapById.delete(elementKey);
}
});
// done not for memory leaks, but to guard against possible late fires // done not for memory leaks, but to guard against possible late fires
// (I guess?) // (I guess?)
this.callbacks.clear(); this.callbacks.clear();
@ -455,6 +422,36 @@ class Scene {
// then, check if the id is a group // then, check if the id is a group
return getElementsInGroup(elementsMap, id); 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
mutateElement<TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
options: {
informMutation?: boolean;
isDragging?: boolean;
} = {
informMutation: true,
},
) {
if (isElbowArrow(element)) {
mutateElbowArrow(
element,
updates as ElementUpdate<ExcalidrawElbowArrowElement>,
this.getNonDeletedElementsMap(),
options,
);
} else {
mutateElement(element, updates);
}
if (options.informMutation) {
this.triggerUpdate();
}
return element;
}
} }
export default Scene; export default Scene;