Remove mutators, pass scene everywhere, make temp scenes for special cases

This commit is contained in:
Marcel Mraz 2025-04-15 23:24:59 +02:00
parent 567c9f51e4
commit acfa33650e
No known key found for this signature in database
GPG key ID: 4EBD6E62DC830CD2
33 changed files with 177 additions and 266 deletions

View file

@ -0,0 +1,453 @@
import throttle from "lodash.throttle";
import {
randomInteger,
arrayToMap,
toBrandedType,
isDevEnv,
isTestEnv,
} from "@excalidraw/common";
import { isNonDeletedElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { getElementsInGroup } from "@excalidraw/element/groups";
import {
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
} from "@excalidraw/element/fractionalIndex";
import { getSelectedElements } from "@excalidraw/element/selection";
import {
mutateElementWith,
type ElementUpdate,
} from "@excalidraw/element/mutateElement";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeleted,
ExcalidrawFrameLikeElement,
ElementsMapOrArray,
SceneElementsMap,
NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
Ordered,
ElementsMap,
} from "@excalidraw/element/types";
import type {
Assert,
Mutable,
SameType,
} from "@excalidraw/common/utility-types";
import type { AppState } from "../../excalidraw/types";
type SceneStateCallback = () => void;
type SceneStateCallbackRemover = () => void;
type SelectionHash = string & { __brand: "selectionHash" };
const getNonDeletedElements = <T extends ExcalidrawElement>(
allElements: readonly T[],
) => {
const elementsMap = new Map() as NonDeletedSceneElementsMap;
const elements: T[] = [];
for (const element of allElements) {
if (!element.isDeleted) {
elements.push(element as NonDeleted<T>);
elementsMap.set(
element.id,
element as Ordered<NonDeletedExcalidrawElement>,
);
}
}
return { elementsMap, elements };
};
const validateIndicesThrottled = throttle(
(elements: readonly ExcalidrawElement[]) => {
if (isDevEnv() || isTestEnv() || window?.DEBUG_FRACTIONAL_INDICES) {
validateFractionalIndices(elements, {
// throw only in dev & test, to remain functional on `DEBUG_FRACTIONAL_INDICES`
shouldThrow: isDevEnv() || isTestEnv(),
includeBoundTextValidation: true,
});
}
},
1000 * 60,
{ leading: true, trailing: false },
);
const hashSelectionOpts = (
opts: Parameters<InstanceType<typeof Scene>["getSelectedElements"]>[0],
) => {
const keys = ["includeBoundTextElement", "includeElementsInFrames"] as const;
type HashableKeys = Omit<typeof opts, "selectedElementIds" | "elements">;
// just to ensure we're hashing all expected keys
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type _ = Assert<
SameType<
Required<HashableKeys>,
Pick<Required<HashableKeys>, typeof keys[number]>
>
>;
let hash = "";
for (const key of keys) {
hash += `${key}:${opts[key] ? "1" : "0"}`;
}
return hash as SelectionHash;
};
// ideally this would be a branded type but it'd be insanely hard to work with
// in our codebase
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
class Scene {
// ---------------------------------------------------------------------------
// instance methods/props
// ---------------------------------------------------------------------------
private callbacks: Set<SceneStateCallback> = new Set();
private nonDeletedElements: readonly Ordered<NonDeletedExcalidrawElement>[] =
[];
private nonDeletedElementsMap = toBrandedType<NonDeletedSceneElementsMap>(
new Map(),
);
// ideally all elements within the scene should be wrapped around with `Ordered` type, but right now there is no real benefit doing so
private elements: readonly OrderedExcalidrawElement[] = [];
private nonDeletedFramesLikes: readonly NonDeleted<ExcalidrawFrameLikeElement>[] =
[];
private frames: readonly ExcalidrawFrameLikeElement[] = [];
private elementsMap = toBrandedType<SceneElementsMap>(new Map());
private selectedElementsCache: {
selectedElementIds: AppState["selectedElementIds"] | null;
elements: readonly NonDeletedExcalidrawElement[] | null;
cache: Map<SelectionHash, NonDeletedExcalidrawElement[]>;
} = {
selectedElementIds: null,
elements: null,
cache: new Map(),
};
/**
* Random integer regenerated each scene update.
*
* Does not relate to elements versions, it's only a renderer
* cache-invalidation nonce at the moment.
*/
private sceneNonce: number | undefined;
getSceneNonce() {
return this.sceneNonce;
}
getNonDeletedElementsMap() {
return this.nonDeletedElementsMap;
}
getElementsIncludingDeleted() {
return this.elements;
}
getElementsMapIncludingDeleted() {
return this.elementsMap;
}
getNonDeletedElements() {
return this.nonDeletedElements;
}
getFramesIncludingDeleted() {
return this.frames;
}
constructor(elementsMap: ElementsMap | null = null) {
if (elementsMap) {
this.replaceAllElements(elementsMap);
}
}
getSelectedElements(opts: {
// NOTE can be ommitted by making Scene constructor require App instance
selectedElementIds: AppState["selectedElementIds"];
/**
* for specific cases where you need to use elements not from current
* scene state. This in effect will likely result in cache-miss, and
* the cache won't be updated in this case.
*/
elements?: ElementsMapOrArray;
// selection-related options
includeBoundTextElement?: boolean;
includeElementsInFrames?: boolean;
}): NonDeleted<ExcalidrawElement>[] {
const hash = hashSelectionOpts(opts);
const elements = opts?.elements || this.nonDeletedElements;
if (
this.selectedElementsCache.elements === elements &&
this.selectedElementsCache.selectedElementIds === opts.selectedElementIds
) {
const cached = this.selectedElementsCache.cache.get(hash);
if (cached) {
return cached;
}
} else if (opts?.elements == null) {
// if we're operating on latest scene elements and the cache is not
// storing the latest elements, clear the cache
this.selectedElementsCache.cache.clear();
}
const selectedElements = getSelectedElements(
elements,
{ selectedElementIds: opts.selectedElementIds },
opts,
);
// cache only if we're not using custom elements
if (opts?.elements == null) {
this.selectedElementsCache.selectedElementIds = opts.selectedElementIds;
this.selectedElementsCache.elements = this.nonDeletedElements;
this.selectedElementsCache.cache.set(hash, selectedElements);
}
return selectedElements;
}
getNonDeletedFramesLikes(): readonly NonDeleted<ExcalidrawFrameLikeElement>[] {
return this.nonDeletedFramesLikes;
}
getElement<T extends ExcalidrawElement>(id: T["id"]): T | null {
return (this.elementsMap.get(id) as T | undefined) || null;
}
getNonDeletedElement(
id: ExcalidrawElement["id"],
): NonDeleted<ExcalidrawElement> | null {
const element = this.getElement(id);
if (element && isNonDeletedElement(element)) {
return element;
}
return null;
}
/**
* A utility method to help with updating all scene elements, with the added
* performance optimization of not renewing the array if no change is made.
*
* Maps all current excalidraw elements, invoking the callback for each
* element. The callback should either return a new mapped element, or the
* original element if no changes are made. If no changes are made to any
* element, this results in a no-op. Otherwise, the newly mapped elements
* are set as the next scene's elements.
*
* @returns whether a change was made
*/
mapElements(
iteratee: (element: ExcalidrawElement) => ExcalidrawElement,
): boolean {
let didChange = false;
const newElements = this.elements.map((element) => {
const nextElement = iteratee(element);
if (nextElement !== element) {
didChange = true;
}
return nextElement;
});
if (didChange) {
this.replaceAllElements(newElements);
}
return didChange;
}
replaceAllElements(nextElements: ElementsMapOrArray) {
const _nextElements =
// ts doesn't like `Array.isArray` of `instanceof Map`
nextElements instanceof Array
? nextElements
: Array.from(nextElements.values());
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
validateIndicesThrottled(_nextElements);
this.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear();
this.elements.forEach((element) => {
if (isFrameLikeElement(element)) {
nextFrameLikes.push(element);
}
this.elementsMap.set(element.id, element);
});
const nonDeletedElements = getNonDeletedElements(this.elements);
this.nonDeletedElements = nonDeletedElements.elements;
this.nonDeletedElementsMap = nonDeletedElements.elementsMap;
this.frames = nextFrameLikes;
this.nonDeletedFramesLikes = getNonDeletedElements(this.frames).elements;
this.triggerUpdate();
}
triggerUpdate() {
this.sceneNonce = randomInteger();
for (const callback of Array.from(this.callbacks)) {
callback();
}
}
onUpdate(cb: SceneStateCallback): SceneStateCallbackRemover {
if (this.callbacks.has(cb)) {
throw new Error();
}
this.callbacks.add(cb);
return () => {
if (!this.callbacks.has(cb)) {
throw new Error();
}
this.callbacks.delete(cb);
};
}
destroy() {
this.elements = [];
this.nonDeletedElements = [];
this.nonDeletedFramesLikes = [];
this.frames = [];
this.elementsMap.clear();
this.selectedElementsCache.selectedElementIds = null;
this.selectedElementsCache.elements = null;
this.selectedElementsCache.cache.clear();
// done not for memory leaks, but to guard against possible late fires
// (I guess?)
this.callbacks.clear();
}
insertElementAtIndex(element: ExcalidrawElement, index: number) {
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
);
}
const nextElements = [
...this.elements.slice(0, index),
element,
...this.elements.slice(index),
];
syncMovedIndices(nextElements, arrayToMap([element]));
this.replaceAllElements(nextElements);
}
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
if (!elements.length) {
return;
}
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
);
}
const nextElements = [
...this.elements.slice(0, index),
...elements,
...this.elements.slice(index),
];
syncMovedIndices(nextElements, arrayToMap(elements));
this.replaceAllElements(nextElements);
}
insertElement = (element: ExcalidrawElement) => {
const index = element.frameId
? this.getElementIndex(element.frameId)
: this.elements.length;
this.insertElementAtIndex(element, index);
};
insertElements = (elements: ExcalidrawElement[]) => {
if (!elements.length) {
return;
}
const index = elements[0]?.frameId
? this.getElementIndex(elements[0].frameId)
: this.elements.length;
this.insertElementsAtIndex(elements, index);
};
getElementIndex(elementId: string) {
return this.elements.findIndex((element) => element.id === elementId);
}
getContainerElement = (
element:
| (ExcalidrawElement & {
containerId: ExcalidrawElement["id"] | null;
})
| null,
) => {
if (!element) {
return null;
}
if (element.containerId) {
return this.getElement(element.containerId) || null;
}
return null;
};
getElementsFromId = (id: string): ExcalidrawElement[] => {
const elementsMap = this.getNonDeletedElementsMap();
// first check if the id is an element
const el = elementsMap.get(id);
if (el) {
return [el];
}
// then, check if the id is a group
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>>(
element: TElement,
updates: ElementUpdate<TElement>,
options: {
informMutation?: boolean;
isDragging?: boolean;
} = {
informMutation: true,
},
) {
const elementsMap = this.getNonDeletedElementsMap();
mutateElementWith(element, elementsMap, updates, options);
if (options.informMutation) {
this.triggerUpdate();
}
return element;
}
}
export default Scene;

View file

@ -1,9 +1,9 @@
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds";
import { getMaximumGroups } from "./groups";
import type Scene from "./Scene";
import type { BoundingBox } from "./bounds";
import type { ExcalidrawElement } from "./types";
@ -38,7 +38,7 @@ export const alignElements = (
});
// update bound elements
updateBoundElements(element, elementsMap, {
updateBoundElements(element, scene, {
simultaneouslyUpdated: group,
});
return updatedEle;

View file

@ -30,8 +30,6 @@ import { isPointOnShape } from "@excalidraw/utils/collision";
import type { LocalPoint, Radians } from "@excalidraw/math";
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
@ -68,6 +66,8 @@ import {
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
import { updateElbowArrowPoints } from "./elbowArrow";
import type Scene from "./Scene";
import type { Bounds } from "./bounds";
import type { ElementUpdate } from "./mutateElement";
import type {
@ -177,8 +177,6 @@ const bindOrUnbindLinearElementEdge = (
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
scene: Scene,
): void => {
const elementsMap = scene.getNonDeletedElementsMap();
// "keep" is for method chaining convenience, a "no-op", so just bail out
if (bindableElement === "keep") {
return;
@ -209,23 +207,11 @@ const bindOrUnbindLinearElementEdge = (
: startOrEnd === "start" ||
otherEdgeBindableElement.id !== bindableElement.id)
) {
bindLinearElement(
linearElement,
bindableElement,
startOrEnd,
elementsMap,
(...args) => scene.mutate(...args),
);
bindLinearElement(linearElement, bindableElement, startOrEnd, scene);
boundToElementIds.add(bindableElement.id);
}
} else {
bindLinearElement(
linearElement,
bindableElement,
startOrEnd,
elementsMap,
(...args) => scene.mutate(...args),
);
bindLinearElement(linearElement, bindableElement, startOrEnd, scene);
boundToElementIds.add(bindableElement.id);
}
};
@ -443,8 +429,7 @@ export const maybeBindLinearElement = (
linearElement,
appState.startBoundElement,
"start",
elementsMap,
(...args) => scene.mutate(...args),
scene,
);
}
@ -465,13 +450,7 @@ export const maybeBindLinearElement = (
"end",
)
) {
bindLinearElement(
linearElement,
hoveredElement,
"end",
elementsMap,
(...args) => scene.mutate(...args),
);
bindLinearElement(linearElement, hoveredElement, "end", scene);
}
}
};
@ -500,11 +479,7 @@ export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
mutator: (
element: ExcalidrawElement,
updates: ElementUpdate<ExcalidrawElement>,
) => ExcalidrawElement,
scene: Scene,
): void => {
if (!isArrowElement(linearElement)) {
return;
@ -517,7 +492,7 @@ export const bindLinearElement = (
linearElement,
hoveredElement,
startOrEnd,
elementsMap as NonDeletedSceneElementsMap,
scene.getNonDeletedElementsMap(),
),
hoveredElement,
),
@ -534,13 +509,13 @@ export const bindLinearElement = (
};
}
mutator(linearElement, {
scene.mutate(linearElement, {
[startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
});
const boundElementsMap = arrayToMap(hoveredElement.boundElements || []);
if (!boundElementsMap.has(linearElement.id)) {
mutator(hoveredElement, {
scene.mutate(hoveredElement, {
boundElements: (hoveredElement.boundElements || []).concat({
id: linearElement.id,
type: "arrow",
@ -757,7 +732,7 @@ const calculateFocusAndGap = (
// in explicitly.
export const updateBoundElements = (
changedElement: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
scene: Scene,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
@ -773,6 +748,8 @@ export const updateBoundElements = (
return;
}
const elementsMap = scene.getNonDeletedElementsMap();
boundElementsVisitor(elementsMap, changedElement, (element) => {
if (!isLinearElement(element) || element.isDeleted) {
return;
@ -860,7 +837,7 @@ export const updateBoundElements = (
}> => update !== null,
);
LinearElementEditor.movePoints(element, elementsMap, updates, {
LinearElementEditor.movePoints(element, scene, updates, {
...(changedElement.id === element.startBinding?.elementId
? { startBinding: bindings.startBinding }
: {}),

View file

@ -11,8 +11,6 @@ import type {
PointerDownState,
} from "@excalidraw/excalidraw/types";
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { updateBoundElements } from "./binding";
@ -28,6 +26,8 @@ import {
isTextElement,
} from "./typeChecks";
import type Scene from "./Scene";
import type { Bounds } from "./bounds";
import type { ExcalidrawElement } from "./types";
@ -118,7 +118,7 @@ export const dragSelectedElements = (
adjustedOffset,
);
}
updateBoundElements(element, scene.getNonDeletedElementsMap(), {
updateBoundElements(element, scene, {
simultaneouslyUpdated: Array.from(elementsToUpdate),
});
}

View file

@ -7,8 +7,6 @@ import type {
PendingExcalidrawElements,
} from "@excalidraw/excalidraw/types";
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import { bindLinearElement } from "./binding";
import { updateElbowArrowPoints } from "./elbowArrow";
import {
@ -41,6 +39,8 @@ import {
type OrderedExcalidrawElement,
} from "./types";
import type Scene from "./Scene";
type LinkDirection = "up" | "right" | "down" | "left";
const VERTICAL_OFFSET = 100;
@ -445,20 +445,8 @@ const createBindingArrow = (
const elementsMap = scene.getNonDeletedElementsMap();
bindLinearElement(
bindingArrow,
startBindingElement,
"start",
elementsMap,
(...args) => scene.mutate(...args),
);
bindLinearElement(
bindingArrow,
endBindingElement,
"end",
elementsMap,
(...args) => scene.mutate(...args),
);
bindLinearElement(bindingArrow, startBindingElement, "start", scene);
bindLinearElement(bindingArrow, endBindingElement, "end", scene);
const changedElements = new Map<string, OrderedExcalidrawElement>();
changedElements.set(
@ -474,7 +462,7 @@ const createBindingArrow = (
bindingArrow as OrderedExcalidrawElement,
);
LinearElementEditor.movePoints(bindingArrow, elementsMap, [
LinearElementEditor.movePoints(bindingArrow, scene, [
{
index: 1,
point: bindingArrow.points[1],

View file

@ -3,8 +3,6 @@ import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds";
import type { ExcalidrawElementsIncludingDeleted } from "@excalidraw/excalidraw/scene/Scene";
import type {
AppClassProperties,
AppState,
@ -29,6 +27,8 @@ import {
isTextElement,
} from "./typeChecks";
import type { ExcalidrawElementsIncludingDeleted } from "./Scene";
import type {
ElementsMap,
ElementsMapOrArray,

View file

@ -20,8 +20,6 @@ import {
tupleToCoors,
} from "@excalidraw/common";
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import type { Store } from "@excalidraw/excalidraw/store";
import type { Radians } from "@excalidraw/math";
@ -69,6 +67,8 @@ import {
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
import type Scene from "./Scene";
import type { Bounds } from "./bounds";
import type {
NonDeleted,
@ -80,7 +80,6 @@ import type {
ElementsMap,
NonDeletedSceneElementsMap,
FixedPointBinding,
SceneElementsMap,
FixedSegment,
ExcalidrawElbowArrowElement,
} from "./types";
@ -307,7 +306,7 @@ export class LinearElementEditor {
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
LinearElementEditor.movePoints(element, elementsMap, [
LinearElementEditor.movePoints(element, scene, [
{
index: selectedIndex,
point: pointFrom(
@ -331,7 +330,7 @@ export class LinearElementEditor {
LinearElementEditor.movePoints(
element,
elementsMap,
scene,
selectedPointsIndices.map((pointIndex) => {
const newPointPosition: LocalPoint =
pointIndex === lastClickedPoint
@ -452,7 +451,7 @@ export class LinearElementEditor {
selectedPoint === element.points.length - 1
) {
if (isPathALoop(element.points, appState.zoom.value)) {
LinearElementEditor.movePoints(element, elementsMap, [
LinearElementEditor.movePoints(element, scene, [
{
index: selectedPoint,
point:
@ -932,13 +931,13 @@ export class LinearElementEditor {
scenePointerX: number,
scenePointerY: number,
app: AppClassProperties,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
): LinearElementEditor | null {
const appState = app.state;
if (!appState.editingLinearElement) {
return null;
}
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
return appState.editingLinearElement;
@ -949,7 +948,7 @@ export class LinearElementEditor {
if (!event.altKey) {
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, elementsMap, [
LinearElementEditor.deletePoints(element, app.scene, [
points.length - 1,
]);
}
@ -989,16 +988,14 @@ export class LinearElementEditor {
}
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints(element, elementsMap, [
LinearElementEditor.movePoints(element, app.scene, [
{
index: element.points.length - 1,
point: newPoint,
},
]);
} else {
LinearElementEditor.addPoints(element, elementsMap, [
{ point: newPoint },
]);
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]);
}
return {
...appState.editingLinearElement,
@ -1168,7 +1165,6 @@ export class LinearElementEditor {
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
) {
// TODO_SCENE: we don't need to inform mutation here?
mutateElementWith(
element,
elementsMap,
@ -1231,7 +1227,7 @@ export class LinearElementEditor {
// potentially expanding the bounding box
if (pointAddedToEnd) {
const lastPoint = element.points[element.points.length - 1];
LinearElementEditor.movePoints(element, elementsMap, [
LinearElementEditor.movePoints(element, scene, [
{
index: element.points.length - 1,
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
@ -1250,7 +1246,7 @@ export class LinearElementEditor {
static deletePoints(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
scene: Scene,
pointIndices: readonly number[],
) {
let offsetX = 0;
@ -1283,7 +1279,7 @@ export class LinearElementEditor {
LinearElementEditor._updatePoints(
element,
elementsMap,
scene,
nextPoints,
offsetX,
offsetY,
@ -1292,7 +1288,7 @@ export class LinearElementEditor {
static addPoints(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
scene: Scene,
targetPoints: { point: LocalPoint }[],
) {
const offsetX = 0;
@ -1301,7 +1297,7 @@ export class LinearElementEditor {
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
LinearElementEditor._updatePoints(
element,
elementsMap,
scene,
nextPoints,
offsetX,
offsetY,
@ -1310,7 +1306,7 @@ export class LinearElementEditor {
static movePoints(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
scene: Scene,
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
otherUpdates?: {
startBinding?: PointBinding | null;
@ -1349,7 +1345,7 @@ export class LinearElementEditor {
LinearElementEditor._updatePoints(
element,
elementsMap,
scene,
nextPoints,
offsetX,
offsetY,
@ -1462,7 +1458,7 @@ export class LinearElementEditor {
private static _updatePoints(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
scene: Scene,
nextPoints: readonly LocalPoint[],
offsetX: number,
offsetY: number,
@ -1499,7 +1495,7 @@ export class LinearElementEditor {
updates.points = Array.from(nextPoints);
mutateElementWith(element, elementsMap, updates, {
scene.mutate(element, updates, {
isDragging: options?.isDragging,
});
} else {
@ -1516,7 +1512,7 @@ export class LinearElementEditor {
pointFrom(dX, dY),
element.angle,
);
mutateElement(element, {
scene.mutate(element, {
...otherUpdates,
points: nextPoints,
x: element.x + rotated[0],

View file

@ -17,8 +17,6 @@ import {
import type { GlobalPoint } from "@excalidraw/math";
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import type { PointerDownState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
@ -60,6 +58,8 @@ import {
import { isInGroup } from "./groups";
import type Scene from "./Scene";
import type { BoundingBox } from "./bounds";
import type {
MaybeTransformHandleType,
@ -103,7 +103,7 @@ export const transformElements = (
pointerY,
shouldRotateWithDiscreteAngle,
);
updateBoundElements(element, elementsMap);
updateBoundElements(element, scene);
}
} else if (isTextElement(element) && transformHandleType) {
resizeSingleTextElement(
@ -115,7 +115,7 @@ export const transformElements = (
pointerX,
pointerY,
);
updateBoundElements(element, elementsMap);
updateBoundElements(element, scene);
return true;
} else if (transformHandleType) {
const elementId = selectedElements[0].id;
@ -554,7 +554,7 @@ const rotateMultipleElements = (
scene.mutate(element, updates);
updateBoundElements(element, elementsMap, {
updateBoundElements(element, scene, {
simultaneouslyUpdated: elements,
});
@ -964,7 +964,7 @@ export const resizeSingleElement = (
const elementsMap = scene.getNonDeletedElementsMap();
updateBoundElements(latestElement, elementsMap, {
updateBoundElements(latestElement, scene, {
// TODO: confirm with MARK if this actually makes sense
newSize: { width: nextWidth, height: nextHeight },
});
@ -1525,7 +1525,7 @@ export const resizeMultipleElements = (
isDragging: true,
});
updateBoundElements(element, scene.getNonDeletedElementsMap(), {
updateBoundElements(element, scene, {
simultaneouslyUpdated: elementsToUpdate,
newSize: { width, height },
});

View file

@ -27,7 +27,7 @@ import {
isTextElement,
} from "./typeChecks";
import type { ElementUpdate } from "./mutateElement";
import type Scene from "./Scene";
import type { MaybeTransformHandleType } from "./transformHandles";
import type {
@ -43,12 +43,10 @@ import type {
export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement,
container: ExcalidrawElement | null,
elementsMap: ElementsMap,
mutator: (
element: ExcalidrawElement,
updates: ElementUpdate<ExcalidrawElement>,
) => ExcalidrawElement,
scene: Scene,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
let maxWidth = undefined;
const boundTextUpdates = {
x: textElement.x,
@ -96,30 +94,34 @@ export const redrawTextBoundingBox = (
metrics.height,
container.type,
);
mutator(container, { height: nextHeight });
scene.mutate(container, { height: nextHeight });
updateOriginalContainerCache(container.id, nextHeight);
}
if (metrics.width > maxContainerWidth) {
const nextWidth = computeContainerDimensionForBoundText(
metrics.width,
container.type,
);
mutator(container, { width: nextWidth });
scene.mutate(container, { width: nextWidth });
}
const updatedTextElement = {
...textElement,
...boundTextUpdates,
} as ExcalidrawTextElementWithContainer;
const { x, y } = computeBoundTextPosition(
container,
updatedTextElement,
elementsMap,
);
boundTextUpdates.x = x;
boundTextUpdates.y = y;
}
mutator(textElement, boundTextUpdates);
scene.mutate(textElement, boundTextUpdates);
};
export const handleBindTextResize = (

View file

@ -2,8 +2,6 @@ import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types";
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import { isFrameLikeElement } from "./typeChecks";
import { getElementsInGroup } from "./groups";
@ -12,6 +10,8 @@ import { syncMovedIndices } from "./fractionalIndex";
import { getSelectedElements } from "./selection";
import type Scene from "./Scene";
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {

View file

@ -2,7 +2,6 @@ import { ARROW_TYPE } from "@excalidraw/common";
import { pointFrom } from "@excalidraw/math";
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
import Scene from "@excalidraw/excalidraw/scene/Scene";
import { actionSelectAll } from "@excalidraw/excalidraw/actions";
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection";
@ -23,6 +22,8 @@ import type { LocalPoint } from "@excalidraw/math";
import { bindLinearElement } from "../src/binding";
import Scene from "../src/Scene";
import type {
ExcalidrawArrowElement,
ExcalidrawBindableElement,
@ -187,13 +188,9 @@ describe("elbow arrow routing", () => {
scene.insertElement(rectangle1);
scene.insertElement(rectangle2);
scene.insertElement(arrow);
const elementsMap = scene.getNonDeletedElementsMap();
bindLinearElement(arrow, rectangle1, "start", elementsMap, (...args) =>
scene.mutate(...args),
);
bindLinearElement(arrow, rectangle2, "end", elementsMap, (...args) =>
scene.mutate(...args),
);
bindLinearElement(arrow, rectangle1, "start", scene);
bindLinearElement(arrow, rectangle2, "end", scene);
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);