refactor: remove dependency on the (static) Scene (#9389)

This commit is contained in:
Marcel Mraz 2025-04-23 13:45:08 +02:00 committed by GitHub
parent debf2ad608
commit 1913599594
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 812 additions and 925 deletions

View file

@ -0,0 +1,468 @@
import throttle from "lodash.throttle";
import {
randomInteger,
arrayToMap,
toBrandedType,
isDevEnv,
isTestEnv,
isReadonlyArray,
} from "@excalidraw/common";
import { isNonDeletedElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { getElementsInGroup } from "@excalidraw/element/groups";
import {
orderByFractionalIndex,
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
} from "@excalidraw/element/fractionalIndex";
import { getSelectedElements } from "@excalidraw/element/selection";
import {
mutateElement,
type ElementUpdate,
} from "@excalidraw/element/mutateElement";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeleted,
ExcalidrawFrameLikeElement,
ElementsMapOrArray,
SceneElementsMap,
NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
Ordered,
} 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(elements: ElementsMapOrArray | null = null) {
if (elements) {
this.replaceAllElements(elements);
}
}
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) {
// ts doesn't like `Array.isArray` of `instanceof Map`
if (!isReadonlyArray(nextElements)) {
// need to order by fractional indices to get the correct order
nextElements = orderByFractionalIndex(
Array.from(nextElements.values()) as OrderedExcalidrawElement[],
);
}
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);
};
// 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().
mutateElement<TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
options: {
informMutation: boolean;
isDragging: boolean;
} = {
informMutation: true,
isDragging: false,
},
) {
const elementsMap = this.getNonDeletedElementsMap();
const { version: prevVersion } = element;
const { version: nextVersion } = mutateElement(
element,
elementsMap,
updates,
options,
);
if (
// skip if the element is not in the scene (i.e. selection)
this.elementsMap.has(element.id) &&
// skip if the element's version hasn't changed, as mutateElement returned the same element
prevVersion !== nextVersion &&
options.informMutation
) {
this.triggerUpdate();
}
return element;
}
}
export default Scene;

View file

@ -1,12 +1,11 @@
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds";
import { mutateElement } from "./mutateElement";
import { getMaximumGroups } from "./groups";
import type Scene from "./Scene";
import type { BoundingBox } from "./bounds";
import type { ElementsMap, ExcalidrawElement } from "./types";
import type { ExcalidrawElement } from "./types";
export interface Alignment {
position: "start" | "center" | "end";
@ -15,10 +14,10 @@ export interface Alignment {
export const alignElements = (
selectedElements: ExcalidrawElement[],
elementsMap: ElementsMap,
alignment: Alignment,
scene: Scene,
): ExcalidrawElement[] => {
const elementsMap = scene.getNonDeletedElementsMap();
const groups: ExcalidrawElement[][] = getMaximumGroups(
selectedElements,
elementsMap,
@ -33,12 +32,13 @@ export const alignElements = (
);
return group.map((element) => {
// update element
const updatedEle = mutateElement(element, {
const updatedEle = scene.mutateElement(element, {
x: element.x + translation.x,
y: element.y + translation.y,
});
// update bound elements
updateBoundElements(element, scene.getNonDeletedElementsMap(), {
updateBoundElements(element, scene, {
simultaneouslyUpdated: group,
});
return updatedEle;

View file

@ -31,8 +31,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 {
@ -84,7 +84,6 @@ import type {
OrderedExcalidrawElement,
ExcalidrawElbowArrowElement,
FixedPoint,
SceneElementsMap,
FixedPointBinding,
} from "./types";
@ -130,7 +129,6 @@ export const bindOrUnbindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startBindingElement: ExcalidrawBindableElement | null | "keep",
endBindingElement: ExcalidrawBindableElement | null | "keep",
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
): void => {
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
@ -142,7 +140,7 @@ export const bindOrUnbindLinearElement = (
"start",
boundToElementIds,
unboundFromElementIds,
elementsMap,
scene,
);
bindOrUnbindLinearElementEdge(
linearElement,
@ -151,7 +149,7 @@ export const bindOrUnbindLinearElement = (
"end",
boundToElementIds,
unboundFromElementIds,
elementsMap,
scene,
);
const onlyUnbound = Array.from(unboundFromElementIds).filter(
@ -159,7 +157,7 @@ export const bindOrUnbindLinearElement = (
);
getNonDeletedElements(scene, onlyUnbound).forEach((element) => {
mutateElement(element, {
scene.mutateElement(element, {
boundElements: element.boundElements?.filter(
(element) =>
element.type !== "arrow" || element.id !== linearElement.id,
@ -177,7 +175,7 @@ const bindOrUnbindLinearElementEdge = (
boundToElementIds: Set<ExcalidrawBindableElement["id"]>,
// Is mutated
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
): void => {
// "keep" is for method chaining convenience, a "no-op", so just bail out
if (bindableElement === "keep") {
@ -186,7 +184,7 @@ const bindOrUnbindLinearElementEdge = (
// null means break the bind, so nothing to consider here
if (bindableElement === null) {
const unbound = unbindLinearElement(linearElement, startOrEnd);
const unbound = unbindLinearElement(linearElement, startOrEnd, scene);
if (unbound != null) {
unboundFromElementIds.add(unbound);
}
@ -209,16 +207,11 @@ const bindOrUnbindLinearElementEdge = (
: startOrEnd === "start" ||
otherEdgeBindableElement.id !== bindableElement.id)
) {
bindLinearElement(
linearElement,
bindableElement,
startOrEnd,
elementsMap,
);
bindLinearElement(linearElement, bindableElement, startOrEnd, scene);
boundToElementIds.add(bindableElement.id);
}
} else {
bindLinearElement(linearElement, bindableElement, startOrEnd, elementsMap);
bindLinearElement(linearElement, bindableElement, startOrEnd, scene);
boundToElementIds.add(bindableElement.id);
}
};
@ -362,11 +355,9 @@ const getBindingStrategyForDraggingArrowOrJoints = (
export const bindOrUnbindLinearElements = (
selectedElements: NonDeleted<ExcalidrawLinearElement>[],
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
isBindingEnabled: boolean,
draggingPoints: readonly number[] | null,
scene: Scene,
zoom?: AppState["zoom"],
): void => {
selectedElements.forEach((selectedElement) => {
@ -376,20 +367,20 @@ export const bindOrUnbindLinearElements = (
selectedElement,
isBindingEnabled,
draggingPoints ?? [],
elementsMap,
elements,
scene.getNonDeletedElementsMap(),
scene.getNonDeletedElements(),
zoom,
)
: // The arrow itself (the shaft) or the inner joins are dragged
getBindingStrategyForDraggingArrowOrJoints(
selectedElement,
elementsMap,
elements,
scene.getNonDeletedElementsMap(),
scene.getNonDeletedElements(),
isBindingEnabled,
zoom,
);
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene);
bindOrUnbindLinearElement(selectedElement, start, end, scene);
});
};
@ -429,15 +420,17 @@ export const maybeBindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
pointerCoords: { x: number; y: number },
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
): void => {
const elements = scene.getNonDeletedElements();
const elementsMap = scene.getNonDeletedElementsMap();
if (appState.startBoundElement != null) {
bindLinearElement(
linearElement,
appState.startBoundElement,
"start",
elementsMap,
scene,
);
}
@ -458,7 +451,7 @@ export const maybeBindLinearElement = (
"end",
)
) {
bindLinearElement(linearElement, hoveredElement, "end", elementsMap);
bindLinearElement(linearElement, hoveredElement, "end", scene);
}
}
};
@ -487,7 +480,7 @@ export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
): void => {
if (!isArrowElement(linearElement)) {
return;
@ -500,7 +493,7 @@ export const bindLinearElement = (
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
scene.getNonDeletedElementsMap(),
),
hoveredElement,
),
@ -513,18 +506,17 @@ export const bindLinearElement = (
linearElement,
hoveredElement,
startOrEnd,
elementsMap,
),
};
}
mutateElement(linearElement, {
scene.mutateElement(linearElement, {
[startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
});
const boundElementsMap = arrayToMap(hoveredElement.boundElements || []);
if (!boundElementsMap.has(linearElement.id)) {
mutateElement(hoveredElement, {
scene.mutateElement(hoveredElement, {
boundElements: (hoveredElement.boundElements || []).concat({
id: linearElement.id,
type: "arrow",
@ -566,13 +558,14 @@ const isLinearElementSimple = (
const unbindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end",
scene: Scene,
): ExcalidrawBindableElement["id"] | null => {
const field = startOrEnd === "start" ? "startBinding" : "endBinding";
const binding = linearElement[field];
if (binding == null) {
return null;
}
mutateElement(linearElement, { [field]: null });
scene.mutateElement(linearElement, { [field]: null });
return binding.elementId;
};
@ -740,7 +733,7 @@ const calculateFocusAndGap = (
// in explicitly.
export const updateBoundElements = (
changedElement: NonDeletedExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
scene: Scene,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
@ -756,6 +749,8 @@ export const updateBoundElements = (
return;
}
const elementsMap = scene.getNonDeletedElementsMap();
boundElementsVisitor(elementsMap, changedElement, (element) => {
if (!isLinearElement(element) || element.isDeleted) {
return;
@ -796,7 +791,7 @@ export const updateBoundElements = (
// `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(element.id)) {
mutateElement(element, bindings, true);
scene.mutateElement(element, bindings);
return;
}
@ -843,23 +838,18 @@ export const updateBoundElements = (
}> => update !== null,
);
LinearElementEditor.movePoints(
element,
updates,
{
...(changedElement.id === element.startBinding?.elementId
? { startBinding: bindings.startBinding }
: {}),
...(changedElement.id === element.endBinding?.elementId
? { endBinding: bindings.endBinding }
: {}),
},
elementsMap as NonDeletedSceneElementsMap,
);
LinearElementEditor.movePoints(element, scene, updates, {
...(changedElement.id === element.startBinding?.elementId
? { startBinding: bindings.startBinding }
: {}),
...(changedElement.id === element.endBinding?.elementId
? { endBinding: bindings.endBinding }
: {}),
});
const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !boundText.isDeleted) {
handleBindTextResize(element, elementsMap, false);
handleBindTextResize(element, scene, false);
}
});
};
@ -885,7 +875,6 @@ export const getHeadingForElbowArrowSnap = (
otherPoint: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement | undefined | null,
aabb: Bounds | undefined | null,
elementsMap: ElementsMap,
origPoint: GlobalPoint,
zoom?: AppState["zoom"],
): Heading => {
@ -895,12 +884,7 @@ export const getHeadingForElbowArrowSnap = (
return otherPointHeading;
}
const distance = getDistanceForBinding(
origPoint,
bindableElement,
elementsMap,
zoom,
);
const distance = getDistanceForBinding(origPoint, bindableElement, zoom);
if (!distance) {
return vectorToHeading(
@ -914,7 +898,6 @@ export const getHeadingForElbowArrowSnap = (
const getDistanceForBinding = (
point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom?: AppState["zoom"],
) => {
const distance = distanceToBindableElement(bindableElement, point);
@ -1216,7 +1199,6 @@ const updateBoundPoint = (
linearElement,
bindableElement,
startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
).fixedPoint;
const globalMidPoint = elementCenterPoint(bindableElement);
const global = pointFrom<GlobalPoint>(
@ -1320,7 +1302,6 @@ export const calculateFixedPointForElbowArrowBinding = (
linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): { fixedPoint: FixedPoint } => {
const bounds = [
hoveredElement.x,
@ -1486,8 +1467,12 @@ export const fixBindingsAfterDeletion = (
const elements = arrayToMap(sceneElements);
for (const element of deletedElements) {
BoundElement.unbindAffected(elements, element, mutateElement);
BindableElement.unbindAffected(elements, element, mutateElement);
BoundElement.unbindAffected(elements, element, (element, updates) =>
mutateElement(element, elements, updates),
);
BindableElement.unbindAffected(elements, element, (element, updates) =>
mutateElement(element, elements, updates),
);
}
};

View file

@ -11,13 +11,10 @@ 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";
import { getCommonBounds } from "./bounds";
import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers";
import { getBoundTextElement } from "./textElement";
import { getMinTextElementWidth } from "./textMeasurements";
@ -29,6 +26,8 @@ import {
isTextElement,
} from "./typeChecks";
import type Scene from "./Scene";
import type { Bounds } from "./bounds";
import type { ExcalidrawElement } from "./types";
@ -104,7 +103,7 @@ export const dragSelectedElements = (
);
elementsToUpdate.forEach((element) => {
updateElementCoords(pointerDownState, element, adjustedOffset);
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
if (!isArrowElement(element)) {
// skip arrow labels since we calculate its position during render
const textElement = getBoundTextElement(
@ -112,9 +111,14 @@ export const dragSelectedElements = (
scene.getNonDeletedElementsMap(),
);
if (textElement) {
updateElementCoords(pointerDownState, textElement, adjustedOffset);
updateElementCoords(
pointerDownState,
textElement,
scene,
adjustedOffset,
);
}
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
updateBoundElements(element, scene, {
simultaneouslyUpdated: Array.from(elementsToUpdate),
});
}
@ -155,6 +159,7 @@ const calculateOffset = (
const updateElementCoords = (
pointerDownState: PointerDownState,
element: NonDeletedExcalidrawElement,
scene: Scene,
dragOffset: { x: number; y: number },
) => {
const originalElement =
@ -163,7 +168,7 @@ const updateElementCoords = (
const nextX = originalElement.x + dragOffset.x;
const nextY = originalElement.y + dragOffset.y;
mutateElement(element, {
scene.mutateElement(element, {
x: nextX,
y: nextY,
});
@ -190,6 +195,7 @@ export const dragNewElement = ({
shouldMaintainAspectRatio,
shouldResizeFromCenter,
zoom,
scene,
widthAspectRatio = null,
originOffset = null,
informMutation = true,
@ -205,6 +211,7 @@ export const dragNewElement = ({
shouldMaintainAspectRatio: boolean;
shouldResizeFromCenter: boolean;
zoom: NormalizedZoomValue;
scene: Scene;
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
true */
widthAspectRatio?: number | null;
@ -285,7 +292,7 @@ export const dragNewElement = ({
};
}
mutateElement(
scene.mutateElement(
newElement,
{
x: newX + (originOffset?.x ?? 0),
@ -295,7 +302,7 @@ export const dragNewElement = ({
...textAutoResize,
...imageInitialDimension,
},
informMutation,
{ informMutation, isDragging: false },
);
}
};

View file

@ -50,7 +50,6 @@ import { isBindableElement } from "./typeChecks";
import {
type ExcalidrawElbowArrowElement,
type NonDeletedSceneElementsMap,
type SceneElementsMap,
} from "./types";
import { aabbForElement, pointInsideBounds } from "./shapes";
@ -887,7 +886,7 @@ export const updateElbowArrowPoints = (
elementsMap: NonDeletedSceneElementsMap,
updates: {
points?: readonly LocalPoint[];
fixedSegments?: FixedSegment[] | null;
fixedSegments?: readonly FixedSegment[] | null;
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
},
@ -1273,14 +1272,12 @@ const getElbowArrowData = (
const startHeading = getBindPointHeading(
startGlobalPoint,
endGlobalPoint,
elementsMap,
hoveredStartElement,
origStartGlobalPoint,
);
const endHeading = getBindPointHeading(
endGlobalPoint,
startGlobalPoint,
elementsMap,
hoveredEndElement,
origEndGlobalPoint,
);
@ -2250,7 +2247,6 @@ const getGlobalPoint = (
const getBindPointHeading = (
p: GlobalPoint,
otherPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: GlobalPoint,
): Heading =>
@ -2268,7 +2264,6 @@ const getBindPointHeading = (
number,
],
),
elementsMap,
origPoint,
);

View file

@ -39,6 +39,8 @@ import {
type OrderedExcalidrawElement,
} from "./types";
import type Scene from "./Scene";
type LinkDirection = "up" | "right" | "down" | "left";
const VERTICAL_OFFSET = 100;
@ -236,10 +238,11 @@ const getOffsets = (
const addNewNode = (
element: ExcalidrawFlowchartNodeElement,
elementsMap: ElementsMap,
appState: AppState,
direction: LinkDirection,
scene: Scene,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
const successors = getSuccessors(element, elementsMap, direction);
const predeccessors = getPredecessors(element, elementsMap, direction);
@ -274,9 +277,9 @@ const addNewNode = (
const bindingArrow = createBindingArrow(
element,
nextNode,
elementsMap,
direction,
appState,
scene,
);
return {
@ -287,9 +290,9 @@ const addNewNode = (
export const addNewNodes = (
startNode: ExcalidrawFlowchartNodeElement,
elementsMap: ElementsMap,
appState: AppState,
direction: LinkDirection,
scene: Scene,
numberOfNodes: number,
) => {
// always start from 0 and distribute evenly
@ -352,9 +355,9 @@ export const addNewNodes = (
const bindingArrow = createBindingArrow(
startNode,
nextNode,
elementsMap,
direction,
appState,
scene,
);
newNodes.push(nextNode);
@ -367,9 +370,9 @@ export const addNewNodes = (
const createBindingArrow = (
startBindingElement: ExcalidrawFlowchartNodeElement,
endBindingElement: ExcalidrawFlowchartNodeElement,
elementsMap: ElementsMap,
direction: LinkDirection,
appState: AppState,
scene: Scene,
) => {
let startX: number;
let startY: number;
@ -440,18 +443,10 @@ const createBindingArrow = (
elbowed: true,
});
bindLinearElement(
bindingArrow,
startBindingElement,
"start",
elementsMap as NonDeletedSceneElementsMap,
);
bindLinearElement(
bindingArrow,
endBindingElement,
"end",
elementsMap as NonDeletedSceneElementsMap,
);
const elementsMap = scene.getNonDeletedElementsMap();
bindLinearElement(bindingArrow, startBindingElement, "start", scene);
bindLinearElement(bindingArrow, endBindingElement, "end", scene);
const changedElements = new Map<string, OrderedExcalidrawElement>();
changedElements.set(
@ -467,7 +462,7 @@ const createBindingArrow = (
bindingArrow as OrderedExcalidrawElement,
);
LinearElementEditor.movePoints(bindingArrow, [
LinearElementEditor.movePoints(bindingArrow, scene, [
{
index: 1,
point: bindingArrow.points[1],
@ -632,16 +627,17 @@ export class FlowChartCreator {
createNodes(
startNode: ExcalidrawFlowchartNodeElement,
elementsMap: ElementsMap,
appState: AppState,
direction: LinkDirection,
scene: Scene,
) {
const elementsMap = scene.getNonDeletedElementsMap();
if (direction !== this.direction) {
const { nextNode, bindingArrow } = addNewNode(
startNode,
elementsMap,
appState,
direction,
scene,
);
this.numberOfNodes = 1;
@ -652,9 +648,9 @@ export class FlowChartCreator {
this.numberOfNodes += 1;
const newNodes = addNewNodes(
startNode,
elementsMap,
appState,
direction,
scene,
this.numberOfNodes,
);
@ -682,13 +678,9 @@ export class FlowChartCreator {
)
) {
this.pendingNodes = this.pendingNodes.map((node) =>
mutateElement(
node,
{
frameId: startNode.frameId,
},
false,
),
mutateElement(node, elementsMap, {
frameId: startNode.frameId,
}),
);
}
}

View file

@ -7,6 +7,7 @@ import { getBoundTextElement } from "./textElement";
import { hasBoundTextElement } from "./typeChecks";
import type {
ElementsMap,
ExcalidrawElement,
FractionalIndex,
OrderedExcalidrawElement,
@ -152,9 +153,10 @@ export const orderByFractionalIndex = (
*/
export const syncMovedIndices = (
elements: readonly ExcalidrawElement[],
movedElements: Map<string, ExcalidrawElement>,
movedElements: ElementsMap,
): OrderedExcalidrawElement[] => {
try {
const elementsMap = arrayToMap(elements);
const indicesGroups = getMovedIndicesGroups(elements, movedElements);
// try generatating indices, throws on invalid movedElements
@ -176,7 +178,7 @@ export const syncMovedIndices = (
// split mutation so we don't end up in an incosistent state
for (const [element, update] of elementsUpdates) {
mutateElement(element, update, false);
mutateElement(element, elementsMap, update);
}
} catch (e) {
// fallback to default sync
@ -194,10 +196,12 @@ export const syncMovedIndices = (
export const syncInvalidIndices = (
elements: readonly ExcalidrawElement[],
): OrderedExcalidrawElement[] => {
const elementsMap = arrayToMap(elements);
const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, update] of elementsUpdates) {
mutateElement(element, update, false);
mutateElement(element, elementsMap, update);
}
return elements as OrderedExcalidrawElement[];
@ -210,7 +214,7 @@ export const syncInvalidIndices = (
*/
const getMovedIndicesGroups = (
elements: readonly ExcalidrawElement[],
movedElements: Map<string, ExcalidrawElement>,
movedElements: ElementsMap,
) => {
const indicesGroups: number[][] = [];

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,
@ -56,13 +56,9 @@ export const bindElementsToFramesAfterDuplication = (
const nextFrameId = origIdToDuplicateId.get(element.frameId);
const nextElement = nextElementId && nextElementMap.get(nextElementId);
if (nextElement) {
mutateElement(
nextElement,
{
frameId: nextFrameId ?? null,
},
false,
);
mutateElement(nextElement, nextElementMap, {
frameId: nextFrameId ?? null,
});
}
}
}
@ -565,13 +561,9 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
}
for (const element of finalElementsToAdd) {
mutateElement(
element,
{
frameId: frame.id,
},
false,
);
mutateElement(element, elementsMap, {
frameId: frame.id,
});
}
return allElements;
@ -609,13 +601,9 @@ export const removeElementsFromFrame = (
}
for (const [, element] of _elementsToRemove) {
mutateElement(
element,
{
frameId: null,
},
false,
);
mutateElement(element, elementsMap, {
frameId: null,
});
}
};

View file

@ -20,10 +20,6 @@ import {
tupleToCoors,
} 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 { Store } from "@excalidraw/excalidraw/store";
import type { Radians } from "@excalidraw/math";
@ -50,10 +46,8 @@ import {
getMinMaxXYFromCurvePathOps,
} from "./bounds";
import { updateElbowArrowPoints } from "./elbowArrow";
import { headingIsHorizontal, vectorToHeading } from "./heading";
import { bumpVersion, mutateElement } from "./mutateElement";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import {
isBindingElement,
@ -73,6 +67,8 @@ import {
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
import type Scene from "./Scene";
import type { Bounds } from "./bounds";
import type {
NonDeleted,
@ -84,7 +80,6 @@ import type {
ElementsMap,
NonDeletedSceneElementsMap,
FixedPointBinding,
SceneElementsMap,
FixedSegment,
ExcalidrawElbowArrowElement,
} from "./types";
@ -127,15 +122,17 @@ export class LinearElementEditor {
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly elbowed: boolean;
constructor(element: NonDeleted<ExcalidrawLinearElement>) {
constructor(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
) {
this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId";
};
if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
console.error("Linear element is not normalized", Error().stack);
LinearElementEditor.normalizePoints(element);
LinearElementEditor.normalizePoints(element, elementsMap);
}
this.selectedPointsIndices = null;
this.lastUncommittedPoint = null;
this.isDragging = false;
@ -309,7 +306,7 @@ export class LinearElementEditor {
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
LinearElementEditor.movePoints(element, [
LinearElementEditor.movePoints(element, scene, [
{
index: selectedIndex,
point: pointFrom(
@ -333,6 +330,7 @@ export class LinearElementEditor {
LinearElementEditor.movePoints(
element,
scene,
selectedPointsIndices.map((pointIndex) => {
const newPointPosition: LocalPoint =
pointIndex === lastClickedPoint
@ -358,7 +356,7 @@ export class LinearElementEditor {
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
handleBindTextResize(element, elementsMap, false);
handleBindTextResize(element, scene, false);
}
// suggest bindings for first and last point if selected
@ -453,7 +451,7 @@ export class LinearElementEditor {
selectedPoint === element.points.length - 1
) {
if (isPathALoop(element.points, appState.zoom.value)) {
LinearElementEditor.movePoints(element, [
LinearElementEditor.movePoints(element, scene, [
{
index: selectedPoint,
point:
@ -795,7 +793,7 @@ export class LinearElementEditor {
);
} else if (event.altKey && appState.editingLinearElement) {
if (linearElementEditor.lastUncommittedPoint == null) {
mutateElement(element, {
scene.mutateElement(element, {
points: [
...element.points,
LinearElementEditor.createPointAt(
@ -861,7 +859,6 @@ export class LinearElementEditor {
element,
startBindingElement,
endBindingElement,
elementsMap,
scene,
);
}
@ -934,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;
@ -951,7 +948,9 @@ export class LinearElementEditor {
if (!event.altKey) {
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, [points.length - 1]);
LinearElementEditor.deletePoints(element, app.scene, [
points.length - 1,
]);
}
return {
...appState.editingLinearElement,
@ -989,14 +988,14 @@ export class LinearElementEditor {
}
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints(element, [
LinearElementEditor.movePoints(element, app.scene, [
{
index: element.points.length - 1,
point: newPoint,
},
]);
} else {
LinearElementEditor.addPoints(element, [{ point: newPoint }]);
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]);
}
return {
...appState.editingLinearElement,
@ -1160,23 +1159,26 @@ export class LinearElementEditor {
y: element.y + offsetY,
};
}
// element-mutating methods
// ---------------------------------------------------------------------------
static normalizePoints(element: NonDeleted<ExcalidrawLinearElement>) {
mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
static normalizePoints(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
) {
mutateElement(
element,
elementsMap,
LinearElementEditor.getNormalizedPoints(element),
);
}
static duplicateSelectedPoints(
appState: AppState,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
): AppState {
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
invariant(
appState.editingLinearElement,
"Not currently editing a linear element",
);
const elementsMap = scene.getNonDeletedElementsMap();
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
@ -1219,13 +1221,13 @@ export class LinearElementEditor {
return acc;
}, []);
mutateElement(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
if (pointAddedToEnd) {
const lastPoint = element.points[element.points.length - 1];
LinearElementEditor.movePoints(element, [
LinearElementEditor.movePoints(element, scene, [
{
index: element.points.length - 1,
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
@ -1244,6 +1246,7 @@ export class LinearElementEditor {
static deletePoints(
element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene,
pointIndices: readonly number[],
) {
let offsetX = 0;
@ -1274,28 +1277,41 @@ export class LinearElementEditor {
return acc;
}, []);
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
LinearElementEditor._updatePoints(
element,
scene,
nextPoints,
offsetX,
offsetY,
);
}
static addPoints(
element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene,
targetPoints: { point: LocalPoint }[],
) {
const offsetX = 0;
const offsetY = 0;
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
LinearElementEditor._updatePoints(
element,
scene,
nextPoints,
offsetX,
offsetY,
);
}
static movePoints(
element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene,
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
otherUpdates?: {
startBinding?: PointBinding | null;
endBinding?: PointBinding | null;
},
sceneElementsMap?: NonDeletedSceneElementsMap,
) {
const { points } = element;
@ -1329,6 +1345,7 @@ export class LinearElementEditor {
LinearElementEditor._updatePoints(
element,
scene,
nextPoints,
offsetX,
offsetY,
@ -1339,7 +1356,6 @@ export class LinearElementEditor {
dragging || targetPoint.isDragging === true,
false,
),
sceneElementsMap,
},
);
}
@ -1394,8 +1410,9 @@ export class LinearElementEditor {
pointerCoords: PointerCoords,
app: AppClassProperties,
snapToGrid: boolean,
elementsMap: ElementsMap,
scene: Scene,
) {
const elementsMap = scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(
linearElementEditor.elementId,
elementsMap,
@ -1425,9 +1442,7 @@ export class LinearElementEditor {
...element.points.slice(segmentMidpoint.index!),
];
mutateElement(element, {
points,
});
scene.mutateElement(element, { points });
ret.pointerDownState = {
...linearElementEditor.pointerDownState,
@ -1443,6 +1458,7 @@ export class LinearElementEditor {
private static _updatePoints(
element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene,
nextPoints: readonly LocalPoint[],
offsetX: number,
offsetY: number,
@ -1479,28 +1495,10 @@ export class LinearElementEditor {
updates.points = Array.from(nextPoints);
if (!options?.sceneElementsMap || Scene.getScene(element)) {
mutateElement(element, updates, true, {
isDragging: options?.isDragging,
});
} else {
// The element is not in the scene, so we need to use the provided
// scene map.
Object.assign(element, {
...updates,
angle: 0 as Radians,
...updateElbowArrowPoints(
element,
options.sceneElementsMap,
updates,
{
isDragging: options?.isDragging,
},
),
});
}
bumpVersion(element);
scene.mutateElement(element, updates, {
informMutation: true,
isDragging: options?.isDragging ?? false,
});
} else {
const nextCoords = getElementPointsCoords(element, nextPoints);
const prevCoords = getElementPointsCoords(element, element.points);
@ -1515,7 +1513,7 @@ export class LinearElementEditor {
pointFrom(dX, dY),
element.angle,
);
mutateElement(element, {
scene.mutateElement(element, {
...otherUpdates,
points: nextPoints,
x: element.x + rotated[0],
@ -1574,7 +1572,7 @@ export class LinearElementEditor {
elementsMap,
);
if (points.length < 2) {
mutateElement(boundTextElement, { isDeleted: true });
mutateElement(boundTextElement, elementsMap, { isDeleted: true });
}
let x = 0;
let y = 0;
@ -1781,8 +1779,9 @@ export class LinearElementEditor {
index: number,
x: number,
y: number,
elementsMap: ElementsMap,
scene: Scene,
): LinearElementEditor {
const elementsMap = scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(
linearElement.elementId,
elementsMap,
@ -1825,7 +1824,7 @@ export class LinearElementEditor {
.map((segment) => segment.index)
.reduce((count, idx) => (idx < index ? count + 1 : count), 0);
mutateElement(element, {
scene.mutateElement(element, {
fixedSegments: nextFixedSegments,
});
@ -1859,14 +1858,14 @@ export class LinearElementEditor {
static deleteFixedSegment(
element: ExcalidrawElbowArrowElement,
scene: Scene,
index: number,
): void {
mutateElement(element, {
scene.mutateElement(element, {
fixedSegments: element.fixedSegments?.filter(
(segment) => segment.index !== index,
),
});
mutateElement(element, {}, true);
}
}

View file

@ -2,13 +2,8 @@ import {
getSizeFromPoints,
randomInteger,
getUpdatedTimestamp,
toBrandedType,
} 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";
@ -16,35 +11,42 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./ShapeCache";
import { updateElbowArrowPoints } from "./elbowArrow";
import { isElbowArrow } from "./typeChecks";
import type { ExcalidrawElement, NonDeletedSceneElementsMap } from "./types";
import type {
ElementsMap,
ExcalidrawElbowArrowElement,
ExcalidrawElement,
NonDeletedSceneElementsMap,
} from "./types";
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>,
"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 will trigger the component to update. Make sure you
// are calling it either from a React event handler or within unstable_batchedUpdates().
/**
* 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: ElementsMap,
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, fixedSegments, startBinding, endBinding, fileId } =
updates as any;
if (
@ -55,10 +57,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
typeof startBinding !== "undefined" ||
typeof endBinding !== "undefined") // manual binding to element
) {
const elementsMap = toBrandedType<NonDeletedSceneElementsMap>(
Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(),
);
updates = {
...updates,
angle: 0 as Radians,
@ -68,16 +66,9 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
x: updates.x || element.x,
y: updates.y || element.y,
},
elementsMap,
{
fixedSegments,
points,
startBinding,
endBinding,
},
{
isDragging: options?.isDragging,
},
elementsMap as NonDeletedSceneElementsMap,
updates as ElementUpdate<ExcalidrawElbowArrowElement>,
options,
),
};
} else if (typeof points !== "undefined") {
@ -150,10 +141,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp();
if (informMutation) {
Scene.getScene(element)?.triggerUpdate();
}
return element;
};

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";
@ -32,7 +30,6 @@ import {
getElementBounds,
} from "./bounds";
import { LinearElementEditor } from "./linearElementEditor";
import { mutateElement } from "./mutateElement";
import {
getBoundTextElement,
getBoundTextElementId,
@ -60,6 +57,8 @@ import {
import { isInGroup } from "./groups";
import type Scene from "./Scene";
import type { BoundingBox } from "./bounds";
import type {
MaybeTransformHandleType,
@ -74,7 +73,6 @@ import type {
ExcalidrawTextElementWithContainer,
ExcalidrawImageElement,
ElementsMap,
SceneElementsMap,
ExcalidrawElbowArrowElement,
} from "./types";
@ -83,7 +81,6 @@ export const transformElements = (
originalElements: PointerDownState["originalElements"],
transformHandleType: MaybeTransformHandleType,
selectedElements: readonly NonDeletedExcalidrawElement[],
elementsMap: SceneElementsMap,
scene: Scene,
shouldRotateWithDiscreteAngle: boolean,
shouldResizeFromCenter: boolean,
@ -93,31 +90,31 @@ export const transformElements = (
centerX: number,
centerY: number,
): boolean => {
const elementsMap = scene.getNonDeletedElementsMap();
if (selectedElements.length === 1) {
const [element] = selectedElements;
if (transformHandleType === "rotation") {
if (!isElbowArrow(element)) {
rotateSingleElement(
element,
elementsMap,
scene,
pointerX,
pointerY,
shouldRotateWithDiscreteAngle,
);
updateBoundElements(element, elementsMap);
updateBoundElements(element, scene);
}
} else if (isTextElement(element) && transformHandleType) {
resizeSingleTextElement(
originalElements,
element,
elementsMap,
scene,
transformHandleType,
shouldResizeFromCenter,
pointerX,
pointerY,
);
updateBoundElements(element, elementsMap);
updateBoundElements(element, scene);
return true;
} else if (transformHandleType) {
const elementId = selectedElements[0].id;
@ -129,8 +126,6 @@ export const transformElements = (
getNextSingleWidthAndHeightFromPointer(
latestElement,
origElement,
elementsMap,
originalElements,
transformHandleType,
pointerX,
pointerY,
@ -145,8 +140,8 @@ export const transformElements = (
nextHeight,
latestElement,
origElement,
elementsMap,
originalElements,
scene,
transformHandleType,
{
shouldMaintainAspectRatio,
@ -161,7 +156,6 @@ export const transformElements = (
rotateMultipleElements(
originalElements,
selectedElements,
elementsMap,
scene,
pointerX,
pointerY,
@ -210,13 +204,15 @@ export const transformElements = (
const rotateSingleElement = (
element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
scene: Scene,
pointerX: number,
pointerY: number,
shouldRotateWithDiscreteAngle: boolean,
) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(
element,
scene.getNonDeletedElementsMap(),
);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
let angle: Radians;
@ -233,13 +229,13 @@ const rotateSingleElement = (
}
const boundTextElementId = getBoundTextElementId(element);
mutateElement(element, { angle });
scene.mutateElement(element, { angle });
if (boundTextElementId) {
const textElement =
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
if (textElement && !isArrowElement(element)) {
mutateElement(textElement, { angle });
scene.mutateElement(textElement, { angle });
}
}
};
@ -289,12 +285,13 @@ export const measureFontSizeFromWidth = (
const resizeSingleTextElement = (
originalElements: PointerDownState["originalElements"],
element: NonDeleted<ExcalidrawTextElement>,
elementsMap: ElementsMap,
scene: Scene,
transformHandleType: TransformHandleDirection,
shouldResizeFromCenter: boolean,
pointerX: number,
pointerY: number,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
@ -393,7 +390,7 @@ const resizeSingleTextElement = (
);
const [nextX, nextY] = newTopLeft;
mutateElement(element, {
scene.mutateElement(element, {
fontSize: metrics.size,
width: nextWidth,
height: nextHeight,
@ -508,14 +505,13 @@ const resizeSingleTextElement = (
autoResize: false,
};
mutateElement(element, resizedElement);
scene.mutateElement(element, resizedElement);
}
};
const rotateMultipleElements = (
originalElements: PointerDownState["originalElements"],
elements: readonly NonDeletedExcalidrawElement[],
elementsMap: SceneElementsMap,
scene: Scene,
pointerX: number,
pointerY: number,
@ -523,6 +519,7 @@ const rotateMultipleElements = (
centerX: number,
centerY: number,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
let centerAngle =
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
if (shouldRotateWithDiscreteAngle) {
@ -543,38 +540,30 @@ const rotateMultipleElements = (
(centerAngle + origAngle - element.angle) as Radians,
);
if (isElbowArrow(element)) {
// Needed to re-route the arrow
mutateElement(element, {
points: getArrowLocalFixedPoints(element, elementsMap),
});
} else {
mutateElement(
element,
{
const updates = isElbowArrow(element)
? {
// Needed to re-route the arrow
points: getArrowLocalFixedPoints(element, elementsMap),
}
: {
x: element.x + (rotatedCX - cx),
y: element.y + (rotatedCY - cy),
angle: normalizeRadians((centerAngle + origAngle) as Radians),
},
false,
);
}
};
updateBoundElements(element, elementsMap, {
scene.mutateElement(element, updates);
updateBoundElements(element, scene, {
simultaneouslyUpdated: elements,
});
const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !isArrowElement(element)) {
mutateElement(
boundText,
{
x: boundText.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy),
angle: normalizeRadians((centerAngle + origAngle) as Radians),
},
false,
);
scene.mutateElement(boundText, {
x: boundText.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy),
angle: normalizeRadians((centerAngle + origAngle) as Radians),
});
}
}
}
@ -819,8 +808,8 @@ export const resizeSingleElement = (
nextHeight: number,
latestElement: ExcalidrawElement,
origElement: ExcalidrawElement,
elementsMap: ElementsMap,
originalElementsMap: ElementsMap,
scene: Scene,
handleDirection: TransformHandleDirection,
{
shouldInformMutation = true,
@ -833,6 +822,7 @@ export const resizeSingleElement = (
} = {},
) => {
let boundTextFont: { fontSize?: number } = {};
const elementsMap = scene.getNonDeletedElementsMap();
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement) {
@ -932,7 +922,7 @@ export const resizeSingleElement = (
}
if ("scale" in latestElement && "scale" in origElement) {
mutateElement(latestElement, {
scene.mutateElement(latestElement, {
scale: [
// defaulting because scaleX/Y can be 0/-0
(Math.sign(nextWidth) || origElement.scale[0]) * origElement.scale[0],
@ -967,21 +957,24 @@ export const resizeSingleElement = (
...rescaledPoints,
};
mutateElement(latestElement, updates, shouldInformMutation);
scene.mutateElement(latestElement, updates, {
informMutation: shouldInformMutation,
isDragging: false,
});
updateBoundElements(latestElement, elementsMap as SceneElementsMap, {
updateBoundElements(latestElement, scene, {
// TODO: confirm with MARK if this actually makes sense
newSize: { width: nextWidth, height: nextHeight },
});
if (boundTextElement && boundTextFont != null) {
mutateElement(boundTextElement, {
scene.mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
});
}
handleBindTextResize(
latestElement,
elementsMap,
scene,
handleDirection,
shouldMaintainAspectRatio,
);
@ -991,8 +984,6 @@ export const resizeSingleElement = (
const getNextSingleWidthAndHeightFromPointer = (
latestElement: ExcalidrawElement,
origElement: ExcalidrawElement,
elementsMap: ElementsMap,
originalElementsMap: ElementsMap,
handleDirection: TransformHandleDirection,
pointerX: number,
pointerY: number,
@ -1527,27 +1518,24 @@ export const resizeMultipleElements = (
} of elementsAndUpdates) {
const { width, height, angle } = update;
mutateElement(element, update, false, {
scene.mutateElement(element, update, {
informMutation: true,
// needed for the fixed binding point udpate to take effect
isDragging: true,
});
updateBoundElements(element, elementsMap as SceneElementsMap, {
updateBoundElements(element, scene, {
simultaneouslyUpdated: elementsToUpdate,
newSize: { width, height },
});
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && boundTextFontSize) {
mutateElement(
boundTextElement,
{
fontSize: boundTextFontSize,
angle: isLinearElement(element) ? undefined : angle,
},
false,
);
handleBindTextResize(element, elementsMap, handleDirection, true);
scene.mutateElement(boundTextElement, {
fontSize: boundTextFontSize,
angle: isLinearElement(element) ? undefined : angle,
});
handleBindTextResize(element, scene, handleDirection, true);
}
}

View file

@ -1,4 +1,4 @@
import { isShallowEqual } from "@excalidraw/common";
import { arrayToMap, isShallowEqual } from "@excalidraw/common";
import type {
AppState,
@ -264,6 +264,7 @@ export const makeNextSelectedElementIds = (
const _getLinearElementEditor = (
targetElements: readonly ExcalidrawElement[],
allElements: readonly NonDeletedExcalidrawElement[],
) => {
const linears = targetElements.filter(isLinearElement);
if (linears.length === 1) {
@ -274,7 +275,7 @@ const _getLinearElementEditor = (
);
if (onlySingleLinearSelected) {
return new LinearElementEditor(linear);
return new LinearElementEditor(linear, arrayToMap(allElements));
}
}
@ -287,7 +288,7 @@ export const getSelectionStateForElements = (
appState: AppState,
) => {
return {
selectedLinearElement: _getLinearElementEditor(targetElements),
selectedLinearElement: _getLinearElementEditor(targetElements, allElements),
...selectGroupsForSelectedElements(
{
editingGroupId: appState.editingGroupId,

View file

@ -6,7 +6,6 @@ import {
import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types";
import { getCommonBounds, getElementBounds } from "./bounds";
import { mutateElement } from "./mutateElement";
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import type { ElementsMap, ExcalidrawElement } from "./types";
@ -170,41 +169,6 @@ export const getLockedLinearCursorAlignSize = (
return { width, height };
};
export const resizePerfectLineForNWHandler = (
element: ExcalidrawElement,
x: number,
y: number,
) => {
const anchorX = element.x + element.width;
const anchorY = element.y + element.height;
const distanceToAnchorX = x - anchorX;
const distanceToAnchorY = y - anchorY;
if (Math.abs(distanceToAnchorX) < Math.abs(distanceToAnchorY) / 2) {
mutateElement(element, {
x: anchorX,
width: 0,
y,
height: -distanceToAnchorY,
});
} else if (Math.abs(distanceToAnchorY) < Math.abs(element.width) / 2) {
mutateElement(element, {
y: anchorY,
height: 0,
});
} else {
const nextHeight =
Math.sign(distanceToAnchorY) *
Math.sign(distanceToAnchorX) *
element.width;
mutateElement(element, {
x,
y: anchorY - nextHeight,
width: -distanceToAnchorX,
height: nextHeight,
});
}
};
export const getNormalizedDimensions = (
element: Pick<ExcalidrawElement, "width" | "height" | "x" | "y">,
): {

View file

@ -14,12 +14,14 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import type { ExtractSetType } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math";
import {
resetOriginalContainerCache,
updateOriginalContainerCache,
} from "./containerCache";
import { LinearElementEditor } from "./linearElementEditor";
import { mutateElement } from "./mutateElement";
import { measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping";
import {
@ -28,7 +30,7 @@ import {
isTextElement,
} from "./typeChecks";
import type { Radians } from "../../math/src";
import type Scene from "./Scene";
import type { MaybeTransformHandleType } from "./transformHandles";
import type {
@ -44,9 +46,10 @@ import type {
export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement,
container: ExcalidrawElement | null,
elementsMap: ElementsMap,
informMutation = true,
scene: Scene,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
let maxWidth = undefined;
if (!isProdEnv()) {
@ -106,38 +109,43 @@ export const redrawTextBoundingBox = (
metrics.height,
container.type,
);
mutateElement(container, { height: nextHeight }, informMutation);
scene.mutateElement(container, { height: nextHeight });
updateOriginalContainerCache(container.id, nextHeight);
}
if (metrics.width > maxContainerWidth) {
const nextWidth = computeContainerDimensionForBoundText(
metrics.width,
container.type,
);
mutateElement(container, { width: nextWidth }, informMutation);
scene.mutateElement(container, { width: nextWidth });
}
const updatedTextElement = {
...textElement,
...boundTextUpdates,
} as ExcalidrawTextElementWithContainer;
const { x, y } = computeBoundTextPosition(
container,
updatedTextElement,
elementsMap,
);
boundTextUpdates.x = x;
boundTextUpdates.y = y;
}
mutateElement(textElement, boundTextUpdates, informMutation);
scene.mutateElement(textElement, boundTextUpdates);
};
export const handleBindTextResize = (
container: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
scene: Scene,
transformHandleType: MaybeTransformHandleType,
shouldMaintainAspectRatio = false,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
const boundTextElementId = getBoundTextElementId(container);
if (!boundTextElementId) {
return;
@ -190,20 +198,20 @@ export const handleBindTextResize = (
transformHandleType === "n")
? container.y - diff
: container.y;
mutateElement(container, {
scene.mutateElement(container, {
height: containerHeight,
y: updatedY,
});
}
mutateElement(textElement, {
scene.mutateElement(textElement, {
text,
width: nextWidth,
height: nextHeight,
});
if (!isArrowElement(container)) {
mutateElement(
scene.mutateElement(
textElement,
computeBoundTextPosition(container, textElement, elementsMap),
);

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

@ -7,7 +7,7 @@ import {
isPrimitive,
} from "@excalidraw/common";
import { Excalidraw } from "@excalidraw/excalidraw";
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions";
@ -24,7 +24,6 @@ import {
import type { LocalPoint } from "@excalidraw/math";
import { mutateElement } from "../src/mutateElement";
import { duplicateElement, duplicateElements } from "../src/duplicate";
import type { ExcalidrawLinearElement } from "../src/types";
@ -62,7 +61,7 @@ describe("duplicating single elements", () => {
// @ts-ignore
element.__proto__ = { hello: "world" };
mutateElement(element, {
mutateElement(element, new Map(), {
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
});

View file

@ -1,8 +1,7 @@
import { ARROW_TYPE } from "@excalidraw/common";
import { pointFrom } from "@excalidraw/math";
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
import { Excalidraw } 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,
@ -142,7 +143,7 @@ describe("elbow arrow routing", () => {
elbowed: true,
}) as ExcalidrawElbowArrowElement;
scene.insertElement(arrow);
mutateElement(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),
@ -187,14 +188,14 @@ describe("elbow arrow routing", () => {
scene.insertElement(rectangle1);
scene.insertElement(rectangle2);
scene.insertElement(arrow);
const elementsMap = scene.getNonDeletedElementsMap();
bindLinearElement(arrow, rectangle1, "start", elementsMap);
bindLinearElement(arrow, rectangle2, "end", elementsMap);
bindLinearElement(arrow, rectangle1, "start", scene);
bindLinearElement(arrow, rectangle2, "end", scene);
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
mutateElement(arrow, {
h.app.scene.mutateElement(arrow, {
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
});

View file

@ -14,6 +14,7 @@ import { deepCopyElement } from "@excalidraw/element/duplicate";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import type {
ElementsMap,
ExcalidrawElement,
FractionalIndex,
} from "@excalidraw/element/types";
@ -749,7 +750,7 @@ function testInvalidIndicesSync(args: {
function prepareArguments(
elementsLike: { id: string; index?: string }[],
movedElementsIds?: string[],
): [ExcalidrawElement[], Map<string, ExcalidrawElement> | undefined] {
): [ExcalidrawElement[], ElementsMap | undefined] {
const elements = elementsLike.map((x) =>
API.createElement({ id: x.id, index: x.index as FractionalIndex }),
);
@ -764,7 +765,7 @@ function prepareArguments(
function test(
name: string,
elements: ExcalidrawElement[],
movedElements: Map<string, ExcalidrawElement> | undefined,
movedElements: ElementsMap | undefined,
expectUnchangedElements: Map<string, { id: string }>,
expectValidInput?: boolean,
) {

View file

@ -333,7 +333,7 @@ describe("line element", () => {
element,
element,
h.app.scene.getNonDeletedElementsMap(),
h.app.scene.getNonDeletedElementsMap(),
h.app.scene,
"ne",
);
@ -369,7 +369,7 @@ describe("line element", () => {
element,
element,
h.app.scene.getNonDeletedElementsMap(),
h.app.scene.getNonDeletedElementsMap(),
h.app.scene,
"se",
);
@ -424,7 +424,7 @@ describe("line element", () => {
element,
element,
h.app.scene.getNonDeletedElementsMap(),
h.app.scene.getNonDeletedElementsMap(),
h.app.scene,
"e",
{
shouldResizeFromCenter: true,

View file

@ -1,10 +1,12 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { mutateElement } from "../src/mutateElement";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { normalizeElementOrder } from "../src/sortElements";
import type { ExcalidrawElement } from "../src/types";
const { h } = window;
const assertOrder = (
elements: readonly ExcalidrawElement[],
expectedOrder: string[],
@ -35,7 +37,7 @@ describe("normalizeElementsOrder", () => {
boundElements: [],
});
mutateElement(container, {
mutateElement(container, new Map(), {
boundElements: [{ type: "text", id: boundText.id }],
});
@ -352,7 +354,7 @@ describe("normalizeElementsOrder", () => {
containerId: container.id,
});
mutateElement(container, {
h.app.scene.mutateElement(container, {
boundElements: [
{ type: "text", id: boundText.id },
{ type: "text", id: "xxx" },
@ -387,7 +389,7 @@ describe("normalizeElementsOrder", () => {
boundElements: [],
groupIds: ["C", "A"],
});
mutateElement(container, {
h.app.scene.mutateElement(container, {
boundElements: [{ type: "text", id: boundText.id }],
});