|
@@ -0,0 +1,1525 @@
|
|
|
+import { ENV } from "./constants";
|
|
|
+import type { BindableProp, BindingProp } from "./element/binding";
|
|
|
+import {
|
|
|
+ BoundElement,
|
|
|
+ BindableElement,
|
|
|
+ bindingProperties,
|
|
|
+ updateBoundElements,
|
|
|
+} from "./element/binding";
|
|
|
+import { LinearElementEditor } from "./element/linearElementEditor";
|
|
|
+import type { ElementUpdate } from "./element/mutateElement";
|
|
|
+import { mutateElement, newElementWith } from "./element/mutateElement";
|
|
|
+import {
|
|
|
+ getBoundTextElementId,
|
|
|
+ redrawTextBoundingBox,
|
|
|
+} from "./element/textElement";
|
|
|
+import {
|
|
|
+ hasBoundTextElement,
|
|
|
+ isBindableElement,
|
|
|
+ isBoundToContainer,
|
|
|
+ isTextElement,
|
|
|
+} from "./element/typeChecks";
|
|
|
+import type {
|
|
|
+ ExcalidrawElement,
|
|
|
+ ExcalidrawLinearElement,
|
|
|
+ ExcalidrawTextElement,
|
|
|
+ NonDeleted,
|
|
|
+ OrderedExcalidrawElement,
|
|
|
+ SceneElementsMap,
|
|
|
+} from "./element/types";
|
|
|
+import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
|
|
+import { getNonDeletedGroupIds } from "./groups";
|
|
|
+import { getObservedAppState } from "./store";
|
|
|
+import type {
|
|
|
+ AppState,
|
|
|
+ ObservedAppState,
|
|
|
+ ObservedElementsAppState,
|
|
|
+ ObservedStandaloneAppState,
|
|
|
+} from "./types";
|
|
|
+import type { SubtypeOf, ValueOf } from "./utility-types";
|
|
|
+import {
|
|
|
+ arrayToMap,
|
|
|
+ arrayToObject,
|
|
|
+ assertNever,
|
|
|
+ isShallowEqual,
|
|
|
+ toBrandedType,
|
|
|
+} from "./utils";
|
|
|
+
|
|
|
+/**
|
|
|
+ * Represents the difference between two objects of the same type.
|
|
|
+ *
|
|
|
+ * Both `deleted` and `inserted` partials represent the same set of added, removed or updated properties, where:
|
|
|
+ * - `deleted` is a set of all the deleted values
|
|
|
+ * - `inserted` is a set of all the inserted (added, updated) values
|
|
|
+ *
|
|
|
+ * Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load.
|
|
|
+ */
|
|
|
+class Delta<T> {
|
|
|
+ private constructor(
|
|
|
+ public readonly deleted: Partial<T>,
|
|
|
+ public readonly inserted: Partial<T>,
|
|
|
+ ) {}
|
|
|
+
|
|
|
+ public static create<T>(
|
|
|
+ deleted: Partial<T>,
|
|
|
+ inserted: Partial<T>,
|
|
|
+ modifier?: (delta: Partial<T>) => Partial<T>,
|
|
|
+ modifierOptions?: "deleted" | "inserted",
|
|
|
+ ) {
|
|
|
+ const modifiedDeleted =
|
|
|
+ modifier && modifierOptions !== "inserted" ? modifier(deleted) : deleted;
|
|
|
+ const modifiedInserted =
|
|
|
+ modifier && modifierOptions !== "deleted" ? modifier(inserted) : inserted;
|
|
|
+
|
|
|
+ return new Delta(modifiedDeleted, modifiedInserted);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Calculates the delta between two objects.
|
|
|
+ *
|
|
|
+ * @param prevObject - The previous state of the object.
|
|
|
+ * @param nextObject - The next state of the object.
|
|
|
+ *
|
|
|
+ * @returns new delta instance.
|
|
|
+ */
|
|
|
+ public static calculate<T extends { [key: string]: any }>(
|
|
|
+ prevObject: T,
|
|
|
+ nextObject: T,
|
|
|
+ modifier?: (partial: Partial<T>) => Partial<T>,
|
|
|
+ postProcess?: (
|
|
|
+ deleted: Partial<T>,
|
|
|
+ inserted: Partial<T>,
|
|
|
+ ) => [Partial<T>, Partial<T>],
|
|
|
+ ): Delta<T> {
|
|
|
+ if (prevObject === nextObject) {
|
|
|
+ return Delta.empty();
|
|
|
+ }
|
|
|
+
|
|
|
+ const deleted = {} as Partial<T>;
|
|
|
+ const inserted = {} as Partial<T>;
|
|
|
+
|
|
|
+ // O(n^3) here for elements, but it's not as bad as it looks:
|
|
|
+ // - we do this only on store recordings, not on every frame (not for ephemerals)
|
|
|
+ // - we do this only on previously detected changed elements
|
|
|
+ // - we do shallow compare only on the first level of properties (not going any deeper)
|
|
|
+ // - # of properties is reasonably small
|
|
|
+ for (const key of this.distinctKeysIterator(
|
|
|
+ "full",
|
|
|
+ prevObject,
|
|
|
+ nextObject,
|
|
|
+ )) {
|
|
|
+ deleted[key as keyof T] = prevObject[key];
|
|
|
+ inserted[key as keyof T] = nextObject[key];
|
|
|
+ }
|
|
|
+
|
|
|
+ const [processedDeleted, processedInserted] = postProcess
|
|
|
+ ? postProcess(deleted, inserted)
|
|
|
+ : [deleted, inserted];
|
|
|
+
|
|
|
+ return Delta.create(processedDeleted, processedInserted, modifier);
|
|
|
+ }
|
|
|
+
|
|
|
+ public static empty() {
|
|
|
+ return new Delta({}, {});
|
|
|
+ }
|
|
|
+
|
|
|
+ public static isEmpty<T>(delta: Delta<T>): boolean {
|
|
|
+ return (
|
|
|
+ !Object.keys(delta.deleted).length && !Object.keys(delta.inserted).length
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Merges deleted and inserted object partials.
|
|
|
+ */
|
|
|
+ public static mergeObjects<T extends { [key: string]: unknown }>(
|
|
|
+ prev: T,
|
|
|
+ added: T,
|
|
|
+ removed: T,
|
|
|
+ ) {
|
|
|
+ const cloned = { ...prev };
|
|
|
+
|
|
|
+ for (const key of Object.keys(removed)) {
|
|
|
+ delete cloned[key];
|
|
|
+ }
|
|
|
+
|
|
|
+ return { ...cloned, ...added };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Merges deleted and inserted array partials.
|
|
|
+ */
|
|
|
+ public static mergeArrays<T>(
|
|
|
+ prev: readonly T[] | null,
|
|
|
+ added: readonly T[] | null | undefined,
|
|
|
+ removed: readonly T[] | null | undefined,
|
|
|
+ predicate?: (value: T) => string,
|
|
|
+ ) {
|
|
|
+ return Object.values(
|
|
|
+ Delta.mergeObjects(
|
|
|
+ arrayToObject(prev ?? [], predicate),
|
|
|
+ arrayToObject(added ?? [], predicate),
|
|
|
+ arrayToObject(removed ?? [], predicate),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Diff object partials as part of the `postProcess`.
|
|
|
+ */
|
|
|
+ public static diffObjects<T, K extends keyof T, V extends ValueOf<T[K]>>(
|
|
|
+ deleted: Partial<T>,
|
|
|
+ inserted: Partial<T>,
|
|
|
+ property: K,
|
|
|
+ setValue: (prevValue: V | undefined) => V,
|
|
|
+ ) {
|
|
|
+ if (!deleted[property] && !inserted[property]) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (
|
|
|
+ typeof deleted[property] === "object" ||
|
|
|
+ typeof inserted[property] === "object"
|
|
|
+ ) {
|
|
|
+ type RecordLike = Record<string, V | undefined>;
|
|
|
+
|
|
|
+ const deletedObject: RecordLike = deleted[property] ?? {};
|
|
|
+ const insertedObject: RecordLike = inserted[property] ?? {};
|
|
|
+
|
|
|
+ const deletedDifferences = Delta.getLeftDifferences(
|
|
|
+ deletedObject,
|
|
|
+ insertedObject,
|
|
|
+ ).reduce((acc, curr) => {
|
|
|
+ acc[curr] = setValue(deletedObject[curr]);
|
|
|
+ return acc;
|
|
|
+ }, {} as RecordLike);
|
|
|
+
|
|
|
+ const insertedDifferences = Delta.getRightDifferences(
|
|
|
+ deletedObject,
|
|
|
+ insertedObject,
|
|
|
+ ).reduce((acc, curr) => {
|
|
|
+ acc[curr] = setValue(insertedObject[curr]);
|
|
|
+ return acc;
|
|
|
+ }, {} as RecordLike);
|
|
|
+
|
|
|
+ if (
|
|
|
+ Object.keys(deletedDifferences).length ||
|
|
|
+ Object.keys(insertedDifferences).length
|
|
|
+ ) {
|
|
|
+ Reflect.set(deleted, property, deletedDifferences);
|
|
|
+ Reflect.set(inserted, property, insertedDifferences);
|
|
|
+ } else {
|
|
|
+ Reflect.deleteProperty(deleted, property);
|
|
|
+ Reflect.deleteProperty(inserted, property);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Diff array partials as part of the `postProcess`.
|
|
|
+ */
|
|
|
+ public static diffArrays<T, K extends keyof T, V extends T[K]>(
|
|
|
+ deleted: Partial<T>,
|
|
|
+ inserted: Partial<T>,
|
|
|
+ property: K,
|
|
|
+ groupBy: (value: V extends ArrayLike<infer T> ? T : never) => string,
|
|
|
+ ) {
|
|
|
+ if (!deleted[property] && !inserted[property]) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (Array.isArray(deleted[property]) || Array.isArray(inserted[property])) {
|
|
|
+ const deletedArray = (
|
|
|
+ Array.isArray(deleted[property]) ? deleted[property] : []
|
|
|
+ ) as [];
|
|
|
+ const insertedArray = (
|
|
|
+ Array.isArray(inserted[property]) ? inserted[property] : []
|
|
|
+ ) as [];
|
|
|
+
|
|
|
+ const deletedDifferences = arrayToObject(
|
|
|
+ Delta.getLeftDifferences(
|
|
|
+ arrayToObject(deletedArray, groupBy),
|
|
|
+ arrayToObject(insertedArray, groupBy),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ const insertedDifferences = arrayToObject(
|
|
|
+ Delta.getRightDifferences(
|
|
|
+ arrayToObject(deletedArray, groupBy),
|
|
|
+ arrayToObject(insertedArray, groupBy),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+
|
|
|
+ if (
|
|
|
+ Object.keys(deletedDifferences).length ||
|
|
|
+ Object.keys(insertedDifferences).length
|
|
|
+ ) {
|
|
|
+ const deletedValue = deletedArray.filter(
|
|
|
+ (x) => deletedDifferences[groupBy ? groupBy(x) : String(x)],
|
|
|
+ );
|
|
|
+ const insertedValue = insertedArray.filter(
|
|
|
+ (x) => insertedDifferences[groupBy ? groupBy(x) : String(x)],
|
|
|
+ );
|
|
|
+
|
|
|
+ Reflect.set(deleted, property, deletedValue);
|
|
|
+ Reflect.set(inserted, property, insertedValue);
|
|
|
+ } else {
|
|
|
+ Reflect.deleteProperty(deleted, property);
|
|
|
+ Reflect.deleteProperty(inserted, property);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Compares if object1 contains any different value compared to the object2.
|
|
|
+ */
|
|
|
+ public static isLeftDifferent<T extends {}>(
|
|
|
+ object1: T,
|
|
|
+ object2: T,
|
|
|
+ skipShallowCompare = false,
|
|
|
+ ): boolean {
|
|
|
+ const anyDistinctKey = this.distinctKeysIterator(
|
|
|
+ "left",
|
|
|
+ object1,
|
|
|
+ object2,
|
|
|
+ skipShallowCompare,
|
|
|
+ ).next().value;
|
|
|
+
|
|
|
+ return !!anyDistinctKey;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Compares if object2 contains any different value compared to the object1.
|
|
|
+ */
|
|
|
+ public static isRightDifferent<T extends {}>(
|
|
|
+ object1: T,
|
|
|
+ object2: T,
|
|
|
+ skipShallowCompare = false,
|
|
|
+ ): boolean {
|
|
|
+ const anyDistinctKey = this.distinctKeysIterator(
|
|
|
+ "right",
|
|
|
+ object1,
|
|
|
+ object2,
|
|
|
+ skipShallowCompare,
|
|
|
+ ).next().value;
|
|
|
+
|
|
|
+ return !!anyDistinctKey;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Returns all the object1 keys that have distinct values.
|
|
|
+ */
|
|
|
+ public static getLeftDifferences<T extends {}>(
|
|
|
+ object1: T,
|
|
|
+ object2: T,
|
|
|
+ skipShallowCompare = false,
|
|
|
+ ) {
|
|
|
+ return Array.from(
|
|
|
+ this.distinctKeysIterator("left", object1, object2, skipShallowCompare),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Returns all the object2 keys that have distinct values.
|
|
|
+ */
|
|
|
+ public static getRightDifferences<T extends {}>(
|
|
|
+ object1: T,
|
|
|
+ object2: T,
|
|
|
+ skipShallowCompare = false,
|
|
|
+ ) {
|
|
|
+ return Array.from(
|
|
|
+ this.distinctKeysIterator("right", object1, object2, skipShallowCompare),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Iterator comparing values of object properties based on the passed joining strategy.
|
|
|
+ *
|
|
|
+ * @yields keys of properties with different values
|
|
|
+ *
|
|
|
+ * WARN: it's based on shallow compare performed only on the first level and doesn't go deeper than that.
|
|
|
+ */
|
|
|
+ private static *distinctKeysIterator<T extends {}>(
|
|
|
+ join: "left" | "right" | "full",
|
|
|
+ object1: T,
|
|
|
+ object2: T,
|
|
|
+ skipShallowCompare = false,
|
|
|
+ ) {
|
|
|
+ if (object1 === object2) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ let keys: string[] = [];
|
|
|
+
|
|
|
+ if (join === "left") {
|
|
|
+ keys = Object.keys(object1);
|
|
|
+ } else if (join === "right") {
|
|
|
+ keys = Object.keys(object2);
|
|
|
+ } else if (join === "full") {
|
|
|
+ keys = Array.from(
|
|
|
+ new Set([...Object.keys(object1), ...Object.keys(object2)]),
|
|
|
+ );
|
|
|
+ } else {
|
|
|
+ assertNever(
|
|
|
+ join,
|
|
|
+ `Unknown distinctKeysIterator's join param "${join}"`,
|
|
|
+ true,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const key of keys) {
|
|
|
+ const object1Value = object1[key as keyof T];
|
|
|
+ const object2Value = object2[key as keyof T];
|
|
|
+
|
|
|
+ if (object1Value !== object2Value) {
|
|
|
+ if (
|
|
|
+ !skipShallowCompare &&
|
|
|
+ typeof object1Value === "object" &&
|
|
|
+ typeof object2Value === "object" &&
|
|
|
+ object1Value !== null &&
|
|
|
+ object2Value !== null &&
|
|
|
+ isShallowEqual(object1Value, object2Value)
|
|
|
+ ) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ yield key;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Encapsulates the modifications captured as `Delta`/s.
|
|
|
+ */
|
|
|
+interface Change<T> {
|
|
|
+ /**
|
|
|
+ * Inverses the `Delta`s inside while creating a new `Change`.
|
|
|
+ */
|
|
|
+ inverse(): Change<T>;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Applies the `Change` to the previous object.
|
|
|
+ *
|
|
|
+ * @returns a tuple of the next object `T` with applied change, and `boolean`, indicating whether the applied change resulted in a visible change.
|
|
|
+ */
|
|
|
+ applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Checks whether there are actually `Delta`s.
|
|
|
+ */
|
|
|
+ isEmpty(): boolean;
|
|
|
+}
|
|
|
+
|
|
|
+export class AppStateChange implements Change<AppState> {
|
|
|
+ private constructor(private readonly delta: Delta<ObservedAppState>) {}
|
|
|
+
|
|
|
+ public static calculate<T extends ObservedAppState>(
|
|
|
+ prevAppState: T,
|
|
|
+ nextAppState: T,
|
|
|
+ ): AppStateChange {
|
|
|
+ const delta = Delta.calculate(
|
|
|
+ prevAppState,
|
|
|
+ nextAppState,
|
|
|
+ undefined,
|
|
|
+ AppStateChange.postProcess,
|
|
|
+ );
|
|
|
+
|
|
|
+ return new AppStateChange(delta);
|
|
|
+ }
|
|
|
+
|
|
|
+ public static empty() {
|
|
|
+ return new AppStateChange(Delta.create({}, {}));
|
|
|
+ }
|
|
|
+
|
|
|
+ public inverse(): AppStateChange {
|
|
|
+ const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted);
|
|
|
+ return new AppStateChange(inversedDelta);
|
|
|
+ }
|
|
|
+
|
|
|
+ public applyTo(
|
|
|
+ appState: AppState,
|
|
|
+ nextElements: SceneElementsMap,
|
|
|
+ ): [AppState, boolean] {
|
|
|
+ try {
|
|
|
+ const {
|
|
|
+ selectedElementIds: removedSelectedElementIds = {},
|
|
|
+ selectedGroupIds: removedSelectedGroupIds = {},
|
|
|
+ } = this.delta.deleted;
|
|
|
+
|
|
|
+ const {
|
|
|
+ selectedElementIds: addedSelectedElementIds = {},
|
|
|
+ selectedGroupIds: addedSelectedGroupIds = {},
|
|
|
+ selectedLinearElementId,
|
|
|
+ editingLinearElementId,
|
|
|
+ ...directlyApplicablePartial
|
|
|
+ } = this.delta.inserted;
|
|
|
+
|
|
|
+ const mergedSelectedElementIds = Delta.mergeObjects(
|
|
|
+ appState.selectedElementIds,
|
|
|
+ addedSelectedElementIds,
|
|
|
+ removedSelectedElementIds,
|
|
|
+ );
|
|
|
+
|
|
|
+ const mergedSelectedGroupIds = Delta.mergeObjects(
|
|
|
+ appState.selectedGroupIds,
|
|
|
+ addedSelectedGroupIds,
|
|
|
+ removedSelectedGroupIds,
|
|
|
+ );
|
|
|
+
|
|
|
+ const selectedLinearElement =
|
|
|
+ selectedLinearElementId && nextElements.has(selectedLinearElementId)
|
|
|
+ ? new LinearElementEditor(
|
|
|
+ nextElements.get(
|
|
|
+ selectedLinearElementId,
|
|
|
+ ) as NonDeleted<ExcalidrawLinearElement>,
|
|
|
+ )
|
|
|
+ : null;
|
|
|
+
|
|
|
+ const editingLinearElement =
|
|
|
+ editingLinearElementId && nextElements.has(editingLinearElementId)
|
|
|
+ ? new LinearElementEditor(
|
|
|
+ nextElements.get(
|
|
|
+ editingLinearElementId,
|
|
|
+ ) as NonDeleted<ExcalidrawLinearElement>,
|
|
|
+ )
|
|
|
+ : null;
|
|
|
+
|
|
|
+ const nextAppState = {
|
|
|
+ ...appState,
|
|
|
+ ...directlyApplicablePartial,
|
|
|
+ selectedElementIds: mergedSelectedElementIds,
|
|
|
+ selectedGroupIds: mergedSelectedGroupIds,
|
|
|
+ selectedLinearElement:
|
|
|
+ typeof selectedLinearElementId !== "undefined"
|
|
|
+ ? selectedLinearElement // element was either inserted or deleted
|
|
|
+ : appState.selectedLinearElement, // otherwise assign what we had before
|
|
|
+ editingLinearElement:
|
|
|
+ typeof editingLinearElementId !== "undefined"
|
|
|
+ ? editingLinearElement // element was either inserted or deleted
|
|
|
+ : appState.editingLinearElement, // otherwise assign what we had before
|
|
|
+ };
|
|
|
+
|
|
|
+ const constainsVisibleChanges = this.filterInvisibleChanges(
|
|
|
+ appState,
|
|
|
+ nextAppState,
|
|
|
+ nextElements,
|
|
|
+ );
|
|
|
+
|
|
|
+ return [nextAppState, constainsVisibleChanges];
|
|
|
+ } catch (e) {
|
|
|
+ // shouldn't really happen, but just in case
|
|
|
+ console.error(`Couldn't apply appstate change`, e);
|
|
|
+
|
|
|
+ if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+
|
|
|
+ return [appState, false];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ public isEmpty(): boolean {
|
|
|
+ return Delta.isEmpty(this.delta);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * It is necessary to post process the partials in case of reference values,
|
|
|
+ * for which we need to calculate the real diff between `deleted` and `inserted`.
|
|
|
+ */
|
|
|
+ private static postProcess<T extends ObservedAppState>(
|
|
|
+ deleted: Partial<T>,
|
|
|
+ inserted: Partial<T>,
|
|
|
+ ): [Partial<T>, Partial<T>] {
|
|
|
+ try {
|
|
|
+ Delta.diffObjects(
|
|
|
+ deleted,
|
|
|
+ inserted,
|
|
|
+ "selectedElementIds",
|
|
|
+ // ts language server has a bit trouble resolving this, so we are giving it a little push
|
|
|
+ (_) => true as ValueOf<T["selectedElementIds"]>,
|
|
|
+ );
|
|
|
+ Delta.diffObjects(
|
|
|
+ deleted,
|
|
|
+ inserted,
|
|
|
+ "selectedGroupIds",
|
|
|
+ (prevValue) => (prevValue ?? false) as ValueOf<T["selectedGroupIds"]>,
|
|
|
+ );
|
|
|
+ } catch (e) {
|
|
|
+ // if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
|
|
|
+ console.error(`Couldn't postprocess appstate change deltas.`);
|
|
|
+
|
|
|
+ if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ return [deleted, inserted];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Mutates `nextAppState` be filtering out state related to deleted elements.
|
|
|
+ *
|
|
|
+ * @returns `true` if a visible change is found, `false` otherwise.
|
|
|
+ */
|
|
|
+ private filterInvisibleChanges(
|
|
|
+ prevAppState: AppState,
|
|
|
+ nextAppState: AppState,
|
|
|
+ nextElements: SceneElementsMap,
|
|
|
+ ): boolean {
|
|
|
+ // TODO: #7348 we could still get an empty undo/redo, as we assume that previous appstate does not contain references to deleted elements
|
|
|
+ // which is not always true - i.e. now we do cleanup appstate during history, but we do not do it during remote updates
|
|
|
+ const prevObservedAppState = getObservedAppState(prevAppState);
|
|
|
+ const nextObservedAppState = getObservedAppState(nextAppState);
|
|
|
+
|
|
|
+ const containsStandaloneDifference = Delta.isRightDifferent(
|
|
|
+ AppStateChange.stripElementsProps(prevObservedAppState),
|
|
|
+ AppStateChange.stripElementsProps(nextObservedAppState),
|
|
|
+ );
|
|
|
+
|
|
|
+ const containsElementsDifference = Delta.isRightDifferent(
|
|
|
+ AppStateChange.stripStandaloneProps(prevObservedAppState),
|
|
|
+ AppStateChange.stripStandaloneProps(nextObservedAppState),
|
|
|
+ );
|
|
|
+
|
|
|
+ if (!containsStandaloneDifference && !containsElementsDifference) {
|
|
|
+ // no change in appstate was detected
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ const visibleDifferenceFlag = {
|
|
|
+ value: containsStandaloneDifference,
|
|
|
+ };
|
|
|
+
|
|
|
+ if (containsElementsDifference) {
|
|
|
+ // filter invisible changes on each iteration
|
|
|
+ const changedElementsProps = Delta.getRightDifferences(
|
|
|
+ AppStateChange.stripStandaloneProps(prevObservedAppState),
|
|
|
+ AppStateChange.stripStandaloneProps(nextObservedAppState),
|
|
|
+ ) as Array<keyof ObservedElementsAppState>;
|
|
|
+
|
|
|
+ let nonDeletedGroupIds = new Set<string>();
|
|
|
+
|
|
|
+ if (
|
|
|
+ changedElementsProps.includes("editingGroupId") ||
|
|
|
+ changedElementsProps.includes("selectedGroupIds")
|
|
|
+ ) {
|
|
|
+ // this one iterates through all the non deleted elements, so make sure it's not done twice
|
|
|
+ nonDeletedGroupIds = getNonDeletedGroupIds(nextElements);
|
|
|
+ }
|
|
|
+
|
|
|
+ // check whether delta properties are related to the existing non-deleted elements
|
|
|
+ for (const key of changedElementsProps) {
|
|
|
+ switch (key) {
|
|
|
+ case "selectedElementIds":
|
|
|
+ nextAppState[key] = AppStateChange.filterSelectedElements(
|
|
|
+ nextAppState[key],
|
|
|
+ nextElements,
|
|
|
+ visibleDifferenceFlag,
|
|
|
+ );
|
|
|
+
|
|
|
+ break;
|
|
|
+ case "selectedGroupIds":
|
|
|
+ nextAppState[key] = AppStateChange.filterSelectedGroups(
|
|
|
+ nextAppState[key],
|
|
|
+ nonDeletedGroupIds,
|
|
|
+ visibleDifferenceFlag,
|
|
|
+ );
|
|
|
+
|
|
|
+ break;
|
|
|
+ case "editingGroupId":
|
|
|
+ const editingGroupId = nextAppState[key];
|
|
|
+
|
|
|
+ if (!editingGroupId) {
|
|
|
+ // previously there was an editingGroup (assuming visible), now there is none
|
|
|
+ visibleDifferenceFlag.value = true;
|
|
|
+ } else if (nonDeletedGroupIds.has(editingGroupId)) {
|
|
|
+ // previously there wasn't an editingGroup, now there is one which is visible
|
|
|
+ visibleDifferenceFlag.value = true;
|
|
|
+ } else {
|
|
|
+ // there was assigned an editingGroup now, but it's related to deleted element
|
|
|
+ nextAppState[key] = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ break;
|
|
|
+ case "selectedLinearElementId":
|
|
|
+ case "editingLinearElementId":
|
|
|
+ const appStateKey = AppStateChange.convertToAppStateKey(key);
|
|
|
+ const linearElement = nextAppState[appStateKey];
|
|
|
+
|
|
|
+ if (!linearElement) {
|
|
|
+ // previously there was a linear element (assuming visible), now there is none
|
|
|
+ visibleDifferenceFlag.value = true;
|
|
|
+ } else {
|
|
|
+ const element = nextElements.get(linearElement.elementId);
|
|
|
+
|
|
|
+ if (element && !element.isDeleted) {
|
|
|
+ // previously there wasn't a linear element, now there is one which is visible
|
|
|
+ visibleDifferenceFlag.value = true;
|
|
|
+ } else {
|
|
|
+ // there was assigned a linear element now, but it's deleted
|
|
|
+ nextAppState[appStateKey] = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ break;
|
|
|
+ default: {
|
|
|
+ assertNever(
|
|
|
+ key,
|
|
|
+ `Unknown ObservedElementsAppState's key "${key}"`,
|
|
|
+ true,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return visibleDifferenceFlag.value;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static convertToAppStateKey(
|
|
|
+ key: keyof Pick<
|
|
|
+ ObservedElementsAppState,
|
|
|
+ "selectedLinearElementId" | "editingLinearElementId"
|
|
|
+ >,
|
|
|
+ ): keyof Pick<AppState, "selectedLinearElement" | "editingLinearElement"> {
|
|
|
+ switch (key) {
|
|
|
+ case "selectedLinearElementId":
|
|
|
+ return "selectedLinearElement";
|
|
|
+ case "editingLinearElementId":
|
|
|
+ return "editingLinearElement";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static filterSelectedElements(
|
|
|
+ selectedElementIds: AppState["selectedElementIds"],
|
|
|
+ elements: SceneElementsMap,
|
|
|
+ visibleDifferenceFlag: { value: boolean },
|
|
|
+ ) {
|
|
|
+ const ids = Object.keys(selectedElementIds);
|
|
|
+
|
|
|
+ if (!ids.length) {
|
|
|
+ // previously there were ids (assuming related to visible elements), now there are none
|
|
|
+ visibleDifferenceFlag.value = true;
|
|
|
+ return selectedElementIds;
|
|
|
+ }
|
|
|
+
|
|
|
+ const nextSelectedElementIds = { ...selectedElementIds };
|
|
|
+
|
|
|
+ for (const id of ids) {
|
|
|
+ const element = elements.get(id);
|
|
|
+
|
|
|
+ if (element && !element.isDeleted) {
|
|
|
+ // there is a selected element id related to a visible element
|
|
|
+ visibleDifferenceFlag.value = true;
|
|
|
+ } else {
|
|
|
+ delete nextSelectedElementIds[id];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return nextSelectedElementIds;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static filterSelectedGroups(
|
|
|
+ selectedGroupIds: AppState["selectedGroupIds"],
|
|
|
+ nonDeletedGroupIds: Set<string>,
|
|
|
+ visibleDifferenceFlag: { value: boolean },
|
|
|
+ ) {
|
|
|
+ const ids = Object.keys(selectedGroupIds);
|
|
|
+
|
|
|
+ if (!ids.length) {
|
|
|
+ // previously there were ids (assuming related to visible groups), now there are none
|
|
|
+ visibleDifferenceFlag.value = true;
|
|
|
+ return selectedGroupIds;
|
|
|
+ }
|
|
|
+
|
|
|
+ const nextSelectedGroupIds = { ...selectedGroupIds };
|
|
|
+
|
|
|
+ for (const id of Object.keys(nextSelectedGroupIds)) {
|
|
|
+ if (nonDeletedGroupIds.has(id)) {
|
|
|
+ // there is a selected group id related to a visible group
|
|
|
+ visibleDifferenceFlag.value = true;
|
|
|
+ } else {
|
|
|
+ delete nextSelectedGroupIds[id];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return nextSelectedGroupIds;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static stripElementsProps(
|
|
|
+ delta: Partial<ObservedAppState>,
|
|
|
+ ): Partial<ObservedStandaloneAppState> {
|
|
|
+ // WARN: Do not remove the type-casts as they here to ensure proper type checks
|
|
|
+ const {
|
|
|
+ editingGroupId,
|
|
|
+ selectedGroupIds,
|
|
|
+ selectedElementIds,
|
|
|
+ editingLinearElementId,
|
|
|
+ selectedLinearElementId,
|
|
|
+ ...standaloneProps
|
|
|
+ } = delta as ObservedAppState;
|
|
|
+
|
|
|
+ return standaloneProps as SubtypeOf<
|
|
|
+ typeof standaloneProps,
|
|
|
+ ObservedStandaloneAppState
|
|
|
+ >;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static stripStandaloneProps(
|
|
|
+ delta: Partial<ObservedAppState>,
|
|
|
+ ): Partial<ObservedElementsAppState> {
|
|
|
+ // WARN: Do not remove the type-casts as they here to ensure proper type checks
|
|
|
+ const { name, viewBackgroundColor, ...elementsProps } =
|
|
|
+ delta as ObservedAppState;
|
|
|
+
|
|
|
+ return elementsProps as SubtypeOf<
|
|
|
+ typeof elementsProps,
|
|
|
+ ObservedElementsAppState
|
|
|
+ >;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+type ElementPartial = Omit<ElementUpdate<OrderedExcalidrawElement>, "seed">;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Elements change is a low level primitive to capture a change between two sets of elements.
|
|
|
+ * It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions.
|
|
|
+ */
|
|
|
+export class ElementsChange implements Change<SceneElementsMap> {
|
|
|
+ private constructor(
|
|
|
+ private readonly added: Map<string, Delta<ElementPartial>>,
|
|
|
+ private readonly removed: Map<string, Delta<ElementPartial>>,
|
|
|
+ private readonly updated: Map<string, Delta<ElementPartial>>,
|
|
|
+ ) {}
|
|
|
+
|
|
|
+ public static create(
|
|
|
+ added: Map<string, Delta<ElementPartial>>,
|
|
|
+ removed: Map<string, Delta<ElementPartial>>,
|
|
|
+ updated: Map<string, Delta<ElementPartial>>,
|
|
|
+ options = { shouldRedistribute: false },
|
|
|
+ ) {
|
|
|
+ let change: ElementsChange;
|
|
|
+
|
|
|
+ if (options.shouldRedistribute) {
|
|
|
+ const nextAdded = new Map<string, Delta<ElementPartial>>();
|
|
|
+ const nextRemoved = new Map<string, Delta<ElementPartial>>();
|
|
|
+ const nextUpdated = new Map<string, Delta<ElementPartial>>();
|
|
|
+
|
|
|
+ const deltas = [...added, ...removed, ...updated];
|
|
|
+
|
|
|
+ for (const [id, delta] of deltas) {
|
|
|
+ if (this.satisfiesAddition(delta)) {
|
|
|
+ nextAdded.set(id, delta);
|
|
|
+ } else if (this.satisfiesRemoval(delta)) {
|
|
|
+ nextRemoved.set(id, delta);
|
|
|
+ } else {
|
|
|
+ nextUpdated.set(id, delta);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ change = new ElementsChange(nextAdded, nextRemoved, nextUpdated);
|
|
|
+ } else {
|
|
|
+ change = new ElementsChange(added, removed, updated);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
|
|
+ ElementsChange.validate(change, "added", this.satisfiesAddition);
|
|
|
+ ElementsChange.validate(change, "removed", this.satisfiesRemoval);
|
|
|
+ ElementsChange.validate(change, "updated", this.satisfiesUpdate);
|
|
|
+ }
|
|
|
+
|
|
|
+ return change;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static satisfiesAddition = ({
|
|
|
+ deleted,
|
|
|
+ inserted,
|
|
|
+ }: Delta<ElementPartial>) =>
|
|
|
+ // dissallowing added as "deleted", which could cause issues when resolving conflicts
|
|
|
+ deleted.isDeleted === true && !inserted.isDeleted;
|
|
|
+
|
|
|
+ private static satisfiesRemoval = ({
|
|
|
+ deleted,
|
|
|
+ inserted,
|
|
|
+ }: Delta<ElementPartial>) =>
|
|
|
+ !deleted.isDeleted && inserted.isDeleted === true;
|
|
|
+
|
|
|
+ private static satisfiesUpdate = ({
|
|
|
+ deleted,
|
|
|
+ inserted,
|
|
|
+ }: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
|
|
|
+
|
|
|
+ private static validate(
|
|
|
+ change: ElementsChange,
|
|
|
+ type: "added" | "removed" | "updated",
|
|
|
+ satifies: (delta: Delta<ElementPartial>) => boolean,
|
|
|
+ ) {
|
|
|
+ for (const [id, delta] of change[type].entries()) {
|
|
|
+ if (!satifies(delta)) {
|
|
|
+ console.error(
|
|
|
+ `Broken invariant for "${type}" delta, element "${id}", delta:`,
|
|
|
+ delta,
|
|
|
+ );
|
|
|
+ throw new Error(`ElementsChange invariant broken for element "${id}".`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Calculates the `Delta`s between the previous and next set of elements.
|
|
|
+ *
|
|
|
+ * @param prevElements - Map representing the previous state of elements.
|
|
|
+ * @param nextElements - Map representing the next state of elements.
|
|
|
+ *
|
|
|
+ * @returns `ElementsChange` instance representing the `Delta` changes between the two sets of elements.
|
|
|
+ */
|
|
|
+ public static calculate<T extends OrderedExcalidrawElement>(
|
|
|
+ prevElements: Map<string, T>,
|
|
|
+ nextElements: Map<string, T>,
|
|
|
+ ): ElementsChange {
|
|
|
+ if (prevElements === nextElements) {
|
|
|
+ return ElementsChange.empty();
|
|
|
+ }
|
|
|
+
|
|
|
+ const added = new Map<string, Delta<ElementPartial>>();
|
|
|
+ const removed = new Map<string, Delta<ElementPartial>>();
|
|
|
+ const updated = new Map<string, Delta<ElementPartial>>();
|
|
|
+
|
|
|
+ // this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements
|
|
|
+ for (const prevElement of prevElements.values()) {
|
|
|
+ const nextElement = nextElements.get(prevElement.id);
|
|
|
+
|
|
|
+ if (!nextElement) {
|
|
|
+ const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
|
|
|
+ const inserted = { isDeleted: true } as ElementPartial;
|
|
|
+
|
|
|
+ const delta = Delta.create(
|
|
|
+ deleted,
|
|
|
+ inserted,
|
|
|
+ ElementsChange.stripIrrelevantProps,
|
|
|
+ );
|
|
|
+
|
|
|
+ removed.set(prevElement.id, delta);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const nextElement of nextElements.values()) {
|
|
|
+ const prevElement = prevElements.get(nextElement.id);
|
|
|
+
|
|
|
+ if (!prevElement) {
|
|
|
+ const deleted = { isDeleted: true } as ElementPartial;
|
|
|
+ const inserted = {
|
|
|
+ ...nextElement,
|
|
|
+ isDeleted: false,
|
|
|
+ } as ElementPartial;
|
|
|
+
|
|
|
+ const delta = Delta.create(
|
|
|
+ deleted,
|
|
|
+ inserted,
|
|
|
+ ElementsChange.stripIrrelevantProps,
|
|
|
+ );
|
|
|
+
|
|
|
+ added.set(nextElement.id, delta);
|
|
|
+
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (prevElement.versionNonce !== nextElement.versionNonce) {
|
|
|
+ const delta = Delta.calculate<ElementPartial>(
|
|
|
+ prevElement,
|
|
|
+ nextElement,
|
|
|
+ ElementsChange.stripIrrelevantProps,
|
|
|
+ ElementsChange.postProcess,
|
|
|
+ );
|
|
|
+
|
|
|
+ if (
|
|
|
+ // making sure we don't get here some non-boolean values (i.e. undefined, null, etc.)
|
|
|
+ typeof prevElement.isDeleted === "boolean" &&
|
|
|
+ typeof nextElement.isDeleted === "boolean" &&
|
|
|
+ prevElement.isDeleted !== nextElement.isDeleted
|
|
|
+ ) {
|
|
|
+ // notice that other props could have been updated as well
|
|
|
+ if (prevElement.isDeleted && !nextElement.isDeleted) {
|
|
|
+ added.set(nextElement.id, delta);
|
|
|
+ } else {
|
|
|
+ removed.set(nextElement.id, delta);
|
|
|
+ }
|
|
|
+
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // making sure there are at least some changes
|
|
|
+ if (!Delta.isEmpty(delta)) {
|
|
|
+ updated.set(nextElement.id, delta);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return ElementsChange.create(added, removed, updated);
|
|
|
+ }
|
|
|
+
|
|
|
+ public static empty() {
|
|
|
+ return ElementsChange.create(new Map(), new Map(), new Map());
|
|
|
+ }
|
|
|
+
|
|
|
+ public inverse(): ElementsChange {
|
|
|
+ const inverseInternal = (deltas: Map<string, Delta<ElementPartial>>) => {
|
|
|
+ const inversedDeltas = new Map<string, Delta<ElementPartial>>();
|
|
|
+
|
|
|
+ for (const [id, delta] of deltas.entries()) {
|
|
|
+ inversedDeltas.set(id, Delta.create(delta.inserted, delta.deleted));
|
|
|
+ }
|
|
|
+
|
|
|
+ return inversedDeltas;
|
|
|
+ };
|
|
|
+
|
|
|
+ const added = inverseInternal(this.added);
|
|
|
+ const removed = inverseInternal(this.removed);
|
|
|
+ const updated = inverseInternal(this.updated);
|
|
|
+
|
|
|
+ // notice we inverse removed with added not to break the invariants
|
|
|
+ return ElementsChange.create(removed, added, updated);
|
|
|
+ }
|
|
|
+
|
|
|
+ public isEmpty(): boolean {
|
|
|
+ return (
|
|
|
+ this.added.size === 0 &&
|
|
|
+ this.removed.size === 0 &&
|
|
|
+ this.updated.size === 0
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Update delta/s based on the existing elements.
|
|
|
+ *
|
|
|
+ * @param elements current elements
|
|
|
+ * @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
|
|
|
+ * @returns new instance with modified delta/s
|
|
|
+ */
|
|
|
+ public applyLatestChanges(elements: SceneElementsMap): ElementsChange {
|
|
|
+ const modifier =
|
|
|
+ (element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
|
|
|
+ const latestPartial: { [key: string]: unknown } = {};
|
|
|
+
|
|
|
+ for (const key of Object.keys(partial) as Array<keyof typeof partial>) {
|
|
|
+ // do not update following props:
|
|
|
+ // - `boundElements`, as it is a reference value which is postprocessed to contain only deleted/inserted keys
|
|
|
+ switch (key) {
|
|
|
+ case "boundElements":
|
|
|
+ latestPartial[key] = partial[key];
|
|
|
+ break;
|
|
|
+ default:
|
|
|
+ latestPartial[key] = element[key];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return latestPartial;
|
|
|
+ };
|
|
|
+
|
|
|
+ const applyLatestChangesInternal = (
|
|
|
+ deltas: Map<string, Delta<ElementPartial>>,
|
|
|
+ ) => {
|
|
|
+ const modifiedDeltas = new Map<string, Delta<ElementPartial>>();
|
|
|
+
|
|
|
+ for (const [id, delta] of deltas.entries()) {
|
|
|
+ const existingElement = elements.get(id);
|
|
|
+
|
|
|
+ if (existingElement) {
|
|
|
+ const modifiedDelta = Delta.create(
|
|
|
+ delta.deleted,
|
|
|
+ delta.inserted,
|
|
|
+ modifier(existingElement),
|
|
|
+ "inserted",
|
|
|
+ );
|
|
|
+
|
|
|
+ modifiedDeltas.set(id, modifiedDelta);
|
|
|
+ } else {
|
|
|
+ modifiedDeltas.set(id, delta);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return modifiedDeltas;
|
|
|
+ };
|
|
|
+
|
|
|
+ const added = applyLatestChangesInternal(this.added);
|
|
|
+ const removed = applyLatestChangesInternal(this.removed);
|
|
|
+ const updated = applyLatestChangesInternal(this.updated);
|
|
|
+
|
|
|
+ return ElementsChange.create(added, removed, updated, {
|
|
|
+ shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ public applyTo(
|
|
|
+ elements: SceneElementsMap,
|
|
|
+ snapshot: Map<string, OrderedExcalidrawElement>,
|
|
|
+ ): [SceneElementsMap, boolean] {
|
|
|
+ let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
|
|
|
+ let changedElements: Map<string, OrderedExcalidrawElement>;
|
|
|
+
|
|
|
+ const flags = {
|
|
|
+ containsVisibleDifference: false,
|
|
|
+ containsZindexDifference: false,
|
|
|
+ };
|
|
|
+
|
|
|
+ // mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
|
|
+ try {
|
|
|
+ const applyDeltas = ElementsChange.createApplier(
|
|
|
+ nextElements,
|
|
|
+ snapshot,
|
|
|
+ flags,
|
|
|
+ );
|
|
|
+
|
|
|
+ const addedElements = applyDeltas(this.added);
|
|
|
+ const removedElements = applyDeltas(this.removed);
|
|
|
+ const updatedElements = applyDeltas(this.updated);
|
|
|
+
|
|
|
+ const affectedElements = this.resolveConflicts(elements, nextElements);
|
|
|
+
|
|
|
+ // TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
|
|
|
+ changedElements = new Map([
|
|
|
+ ...addedElements,
|
|
|
+ ...removedElements,
|
|
|
+ ...updatedElements,
|
|
|
+ ...affectedElements,
|
|
|
+ ]);
|
|
|
+ } catch (e) {
|
|
|
+ console.error(`Couldn't apply elements change`, e);
|
|
|
+
|
|
|
+ if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+
|
|
|
+ // should not really happen, but just in case we cannot apply deltas, let's return the previous elements with visible change set to `true`
|
|
|
+ // even though there is obviously no visible change, returning `false` could be dangerous, as i.e.:
|
|
|
+ // in the worst case, it could lead into iterating through the whole stack with no possibility to redo
|
|
|
+ // instead, the worst case when returning `true` is an empty undo / redo
|
|
|
+ return [elements, true];
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
|
|
|
+ ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
|
|
|
+ ElementsChange.redrawBoundArrows(nextElements, changedElements);
|
|
|
+
|
|
|
+ // the following reorder performs also mutations, but only on new instances of changed elements
|
|
|
+ // (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
|
|
+ nextElements = ElementsChange.reorderElements(
|
|
|
+ nextElements,
|
|
|
+ changedElements,
|
|
|
+ flags,
|
|
|
+ );
|
|
|
+ } catch (e) {
|
|
|
+ console.error(
|
|
|
+ `Couldn't mutate elements after applying elements change`,
|
|
|
+ e,
|
|
|
+ );
|
|
|
+
|
|
|
+ if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ return [nextElements, flags.containsVisibleDifference];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static createApplier = (
|
|
|
+ nextElements: SceneElementsMap,
|
|
|
+ snapshot: Map<string, OrderedExcalidrawElement>,
|
|
|
+ flags: {
|
|
|
+ containsVisibleDifference: boolean;
|
|
|
+ containsZindexDifference: boolean;
|
|
|
+ },
|
|
|
+ ) => {
|
|
|
+ const getElement = ElementsChange.createGetter(
|
|
|
+ nextElements,
|
|
|
+ snapshot,
|
|
|
+ flags,
|
|
|
+ );
|
|
|
+
|
|
|
+ return (deltas: Map<string, Delta<ElementPartial>>) =>
|
|
|
+ Array.from(deltas.entries()).reduce((acc, [id, delta]) => {
|
|
|
+ const element = getElement(id, delta.inserted);
|
|
|
+
|
|
|
+ if (element) {
|
|
|
+ const newElement = ElementsChange.applyDelta(element, delta, flags);
|
|
|
+ nextElements.set(newElement.id, newElement);
|
|
|
+ acc.set(newElement.id, newElement);
|
|
|
+ }
|
|
|
+
|
|
|
+ return acc;
|
|
|
+ }, new Map<string, OrderedExcalidrawElement>());
|
|
|
+ };
|
|
|
+
|
|
|
+ private static createGetter =
|
|
|
+ (
|
|
|
+ elements: SceneElementsMap,
|
|
|
+ snapshot: Map<string, OrderedExcalidrawElement>,
|
|
|
+ flags: {
|
|
|
+ containsVisibleDifference: boolean;
|
|
|
+ containsZindexDifference: boolean;
|
|
|
+ },
|
|
|
+ ) =>
|
|
|
+ (id: string, partial: ElementPartial) => {
|
|
|
+ let element = elements.get(id);
|
|
|
+
|
|
|
+ if (!element) {
|
|
|
+ // always fallback to the local snapshot, in cases when we cannot find the element in the elements array
|
|
|
+ element = snapshot.get(id);
|
|
|
+
|
|
|
+ if (element) {
|
|
|
+ // as the element was brought from the snapshot, it automatically results in a possible zindex difference
|
|
|
+ flags.containsZindexDifference = true;
|
|
|
+
|
|
|
+ // as the element was force deleted, we need to check if adding it back results in a visible change
|
|
|
+ if (
|
|
|
+ partial.isDeleted === false ||
|
|
|
+ (partial.isDeleted !== true && element.isDeleted === false)
|
|
|
+ ) {
|
|
|
+ flags.containsVisibleDifference = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return element;
|
|
|
+ };
|
|
|
+
|
|
|
+ private static applyDelta(
|
|
|
+ element: OrderedExcalidrawElement,
|
|
|
+ delta: Delta<ElementPartial>,
|
|
|
+ flags: {
|
|
|
+ containsVisibleDifference: boolean;
|
|
|
+ containsZindexDifference: boolean;
|
|
|
+ } = {
|
|
|
+ // by default we don't care about about the flags
|
|
|
+ containsVisibleDifference: true,
|
|
|
+ containsZindexDifference: true,
|
|
|
+ },
|
|
|
+ ) {
|
|
|
+ const { boundElements, ...directlyApplicablePartial } = delta.inserted;
|
|
|
+
|
|
|
+ if (
|
|
|
+ delta.deleted.boundElements?.length ||
|
|
|
+ delta.inserted.boundElements?.length
|
|
|
+ ) {
|
|
|
+ const mergedBoundElements = Delta.mergeArrays(
|
|
|
+ element.boundElements,
|
|
|
+ delta.inserted.boundElements,
|
|
|
+ delta.deleted.boundElements,
|
|
|
+ (x) => x.id,
|
|
|
+ );
|
|
|
+
|
|
|
+ Object.assign(directlyApplicablePartial, {
|
|
|
+ boundElements: mergedBoundElements,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!flags.containsVisibleDifference) {
|
|
|
+ // strip away fractional as even if it would be different, it doesn't have to result in visible change
|
|
|
+ const { index, ...rest } = directlyApplicablePartial;
|
|
|
+ const containsVisibleDifference =
|
|
|
+ ElementsChange.checkForVisibleDifference(element, rest);
|
|
|
+
|
|
|
+ flags.containsVisibleDifference = containsVisibleDifference;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!flags.containsZindexDifference) {
|
|
|
+ flags.containsZindexDifference =
|
|
|
+ delta.deleted.index !== delta.inserted.index;
|
|
|
+ }
|
|
|
+
|
|
|
+ return newElementWith(element, directlyApplicablePartial);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Check for visible changes regardless of whether they were removed, added or updated.
|
|
|
+ */
|
|
|
+ private static checkForVisibleDifference(
|
|
|
+ element: OrderedExcalidrawElement,
|
|
|
+ partial: ElementPartial,
|
|
|
+ ) {
|
|
|
+ if (element.isDeleted && partial.isDeleted !== false) {
|
|
|
+ // when it's deleted and partial is not false, it cannot end up with a visible change
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (element.isDeleted && partial.isDeleted === false) {
|
|
|
+ // when we add an element, it results in a visible change
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (element.isDeleted === false && partial.isDeleted) {
|
|
|
+ // when we remove an element, it results in a visible change
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ // check for any difference on a visible element
|
|
|
+ return Delta.isRightDifferent(element, partial);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Resolves conflicts for all previously added, removed and updated elements.
|
|
|
+ * Updates the previous deltas with all the changes after conflict resolution.
|
|
|
+ *
|
|
|
+ * @returns all elements affected by the conflict resolution
|
|
|
+ */
|
|
|
+ private resolveConflicts(
|
|
|
+ prevElements: SceneElementsMap,
|
|
|
+ nextElements: SceneElementsMap,
|
|
|
+ ) {
|
|
|
+ const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
|
|
|
+ const updater = (
|
|
|
+ element: ExcalidrawElement,
|
|
|
+ updates: ElementUpdate<ExcalidrawElement>,
|
|
|
+ ) => {
|
|
|
+ const nextElement = nextElements.get(element.id); // only ever modify next element!
|
|
|
+ if (!nextElement) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ let affectedElement: OrderedExcalidrawElement;
|
|
|
+
|
|
|
+ if (prevElements.get(element.id) === nextElement) {
|
|
|
+ // create the new element instance in case we didn't modify the element yet
|
|
|
+ // so that we won't end up in an incosistent state in case we would fail in the middle of mutations
|
|
|
+ affectedElement = newElementWith(
|
|
|
+ nextElement,
|
|
|
+ updates as ElementUpdate<OrderedExcalidrawElement>,
|
|
|
+ );
|
|
|
+ } else {
|
|
|
+ affectedElement = mutateElement(
|
|
|
+ nextElement,
|
|
|
+ updates as ElementUpdate<OrderedExcalidrawElement>,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ nextAffectedElements.set(affectedElement.id, affectedElement);
|
|
|
+ nextElements.set(affectedElement.id, affectedElement);
|
|
|
+ };
|
|
|
+
|
|
|
+ // removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound
|
|
|
+ for (const [id] of this.removed) {
|
|
|
+ ElementsChange.unbindAffected(prevElements, nextElements, id, updater);
|
|
|
+ }
|
|
|
+
|
|
|
+ // added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound
|
|
|
+ for (const [id] of this.added) {
|
|
|
+ ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
|
|
|
+ }
|
|
|
+
|
|
|
+ // updated delta is affecting the binding only in case it contains changed binding or bindable property
|
|
|
+ for (const [id] of Array.from(this.updated).filter(([_, delta]) =>
|
|
|
+ Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
|
|
|
+ bindingProperties.has(prop as BindingProp | BindableProp),
|
|
|
+ ),
|
|
|
+ )) {
|
|
|
+ const updatedElement = nextElements.get(id);
|
|
|
+ if (!updatedElement || updatedElement.isDeleted) {
|
|
|
+ // skip fixing bindings for updates on deleted elements
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
|
|
|
+ }
|
|
|
+
|
|
|
+ // filter only previous elements, which were now affected
|
|
|
+ const prevAffectedElements = new Map(
|
|
|
+ Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
|
|
|
+ );
|
|
|
+
|
|
|
+ // calculate complete deltas for affected elements, and assign them back to all the deltas
|
|
|
+ // technically we could do better here if perf. would become an issue
|
|
|
+ const { added, removed, updated } = ElementsChange.calculate(
|
|
|
+ prevAffectedElements,
|
|
|
+ nextAffectedElements,
|
|
|
+ );
|
|
|
+
|
|
|
+ for (const [id, delta] of added) {
|
|
|
+ this.added.set(id, delta);
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const [id, delta] of removed) {
|
|
|
+ this.removed.set(id, delta);
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const [id, delta] of updated) {
|
|
|
+ this.updated.set(id, delta);
|
|
|
+ }
|
|
|
+
|
|
|
+ return nextAffectedElements;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Non deleted affected elements of removed elements (before and after applying delta),
|
|
|
+ * should be unbound ~ bindings should not point from non deleted into the deleted element/s.
|
|
|
+ */
|
|
|
+ private static unbindAffected(
|
|
|
+ prevElements: SceneElementsMap,
|
|
|
+ nextElements: SceneElementsMap,
|
|
|
+ id: string,
|
|
|
+ updater: (
|
|
|
+ element: ExcalidrawElement,
|
|
|
+ updates: ElementUpdate<ExcalidrawElement>,
|
|
|
+ ) => void,
|
|
|
+ ) {
|
|
|
+ // the instance could have been updated, so make sure we are passing the latest element to each function below
|
|
|
+ const prevElement = () => prevElements.get(id); // element before removal
|
|
|
+ const nextElement = () => nextElements.get(id); // element after removal
|
|
|
+
|
|
|
+ BoundElement.unbindAffected(nextElements, prevElement(), updater);
|
|
|
+ BoundElement.unbindAffected(nextElements, nextElement(), updater);
|
|
|
+
|
|
|
+ BindableElement.unbindAffected(nextElements, prevElement(), updater);
|
|
|
+ BindableElement.unbindAffected(nextElements, nextElement(), updater);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Non deleted affected elements of added or updated element/s (before and after applying delta),
|
|
|
+ * should be rebound (if possible) with the current element ~ bindings should be bidirectional.
|
|
|
+ */
|
|
|
+ private static rebindAffected(
|
|
|
+ prevElements: SceneElementsMap,
|
|
|
+ nextElements: SceneElementsMap,
|
|
|
+ id: string,
|
|
|
+ updater: (
|
|
|
+ element: ExcalidrawElement,
|
|
|
+ updates: ElementUpdate<ExcalidrawElement>,
|
|
|
+ ) => void,
|
|
|
+ ) {
|
|
|
+ // the instance could have been updated, so make sure we are passing the latest element to each function below
|
|
|
+ const prevElement = () => prevElements.get(id); // element before addition / update
|
|
|
+ const nextElement = () => nextElements.get(id); // element after addition / update
|
|
|
+
|
|
|
+ BoundElement.unbindAffected(nextElements, prevElement(), updater);
|
|
|
+ BoundElement.rebindAffected(nextElements, nextElement(), updater);
|
|
|
+
|
|
|
+ BindableElement.unbindAffected(
|
|
|
+ nextElements,
|
|
|
+ prevElement(),
|
|
|
+ (element, updates) => {
|
|
|
+ // we cannot rebind arrows with bindable element so we don't unbind them at all during rebind (we still need to unbind them on removal)
|
|
|
+ // TODO: #7348 add startBinding / endBinding to the `BoundElement` context so that we could rebind arrows and remove this condition
|
|
|
+ if (isTextElement(element)) {
|
|
|
+ updater(element, updates);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ );
|
|
|
+ BindableElement.rebindAffected(nextElements, nextElement(), updater);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static redrawTextBoundingBoxes(
|
|
|
+ elements: SceneElementsMap,
|
|
|
+ changed: Map<string, OrderedExcalidrawElement>,
|
|
|
+ ) {
|
|
|
+ const boxesToRedraw = new Map<
|
|
|
+ string,
|
|
|
+ { container: OrderedExcalidrawElement; boundText: ExcalidrawTextElement }
|
|
|
+ >();
|
|
|
+
|
|
|
+ for (const element of changed.values()) {
|
|
|
+ if (isBoundToContainer(element)) {
|
|
|
+ const { containerId } = element as ExcalidrawTextElement;
|
|
|
+ const container = containerId ? elements.get(containerId) : undefined;
|
|
|
+
|
|
|
+ if (container) {
|
|
|
+ boxesToRedraw.set(container.id, {
|
|
|
+ container,
|
|
|
+ boundText: element as ExcalidrawTextElement,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (hasBoundTextElement(element)) {
|
|
|
+ const boundTextElementId = getBoundTextElementId(element);
|
|
|
+ const boundText = boundTextElementId
|
|
|
+ ? elements.get(boundTextElementId)
|
|
|
+ : undefined;
|
|
|
+
|
|
|
+ if (boundText) {
|
|
|
+ boxesToRedraw.set(element.id, {
|
|
|
+ container: element,
|
|
|
+ boundText: boundText as ExcalidrawTextElement,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const { container, boundText } of boxesToRedraw.values()) {
|
|
|
+ if (container.isDeleted || boundText.isDeleted) {
|
|
|
+ // skip redraw if one of them is deleted, as it would not result in a meaningful redraw
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ redrawTextBoundingBox(boundText, container, elements, false);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static redrawBoundArrows(
|
|
|
+ elements: SceneElementsMap,
|
|
|
+ changed: Map<string, OrderedExcalidrawElement>,
|
|
|
+ ) {
|
|
|
+ for (const element of changed.values()) {
|
|
|
+ if (!element.isDeleted && isBindableElement(element)) {
|
|
|
+ updateBoundElements(element, elements);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static reorderElements(
|
|
|
+ elements: SceneElementsMap,
|
|
|
+ changed: Map<string, OrderedExcalidrawElement>,
|
|
|
+ flags: {
|
|
|
+ containsVisibleDifference: boolean;
|
|
|
+ containsZindexDifference: boolean;
|
|
|
+ },
|
|
|
+ ) {
|
|
|
+ if (!flags.containsZindexDifference) {
|
|
|
+ return elements;
|
|
|
+ }
|
|
|
+
|
|
|
+ const previous = Array.from(elements.values());
|
|
|
+ const reordered = orderByFractionalIndex([...previous]);
|
|
|
+
|
|
|
+ if (
|
|
|
+ !flags.containsVisibleDifference &&
|
|
|
+ Delta.isRightDifferent(previous, reordered, true)
|
|
|
+ ) {
|
|
|
+ // we found a difference in order!
|
|
|
+ flags.containsVisibleDifference = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ // let's synchronize all invalid indices of moved elements
|
|
|
+ return arrayToMap(syncMovedIndices(reordered, changed)) as typeof elements;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * It is necessary to post process the partials in case of reference values,
|
|
|
+ * for which we need to calculate the real diff between `deleted` and `inserted`.
|
|
|
+ */
|
|
|
+ private static postProcess(
|
|
|
+ deleted: ElementPartial,
|
|
|
+ inserted: ElementPartial,
|
|
|
+ ): [ElementPartial, ElementPartial] {
|
|
|
+ try {
|
|
|
+ Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
|
|
|
+ } catch (e) {
|
|
|
+ // if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
|
|
|
+ console.error(`Couldn't postprocess elements change deltas.`);
|
|
|
+
|
|
|
+ if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ return [deleted, inserted];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static stripIrrelevantProps(
|
|
|
+ partial: Partial<OrderedExcalidrawElement>,
|
|
|
+ ): ElementPartial {
|
|
|
+ const { id, updated, version, versionNonce, seed, ...strippedPartial } =
|
|
|
+ partial;
|
|
|
+
|
|
|
+ return strippedPartial;
|
|
|
+ }
|
|
|
+}
|