diff --git a/packages/element/src/delta.ts b/packages/element/src/delta.ts index 45b1fc348..5df3b15cd 100644 --- a/packages/element/src/delta.ts +++ b/packages/element/src/delta.ts @@ -18,7 +18,12 @@ import type { SceneElementsMap, } from "@excalidraw/element/types"; -import type { DTO, SubtypeOf, ValueOf } from "@excalidraw/common/utility-types"; +import type { + DTO, + Mutable, + SubtypeOf, + ValueOf, +} from "@excalidraw/common/utility-types"; import type { AppState, @@ -51,6 +56,8 @@ import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; import { Scene } from "./Scene"; +import { StoreSnapshot } from "./store"; + import type { BindableProp, BindingProp } from "./binding"; import type { ElementUpdate } from "./mutateElement"; @@ -858,10 +865,17 @@ export class AppStateDelta implements DeltaContainer { } } -type ElementPartial = Omit< - ElementUpdate>, - "seed" ->; +type ElementPartial = + Omit>, "id" | "updated" | "seed">; + +export type ApplyToOptions = { + excludedProperties: Set; +}; + +type ApplyToFlags = { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; +}; /** * Elements change is a low level primitive to capture a change between two sets of elements. @@ -1101,8 +1115,11 @@ export class ElementsDelta implements DeltaContainer { for (const key of Object.keys(partial) as Array) { // do not update following props: + // - `version` and `versionNonce`, as they should keep it's original value // - `boundElements`, as it is a reference value which is postprocessed to contain only deleted/inserted keys switch (key) { + case "version": + case "versionNonce": case "boundElements": latestPartial[key] = partial[key]; break; @@ -1150,12 +1167,15 @@ export class ElementsDelta implements DeltaContainer { public applyTo( elements: SceneElementsMap, - elementsSnapshot: Map, + snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements, + options: ApplyToOptions = { + excludedProperties: new Set(), + }, ): [SceneElementsMap, boolean] { let nextElements = new Map(elements) as SceneElementsMap; let changedElements: Map; - const flags = { + const flags: ApplyToFlags = { containsVisibleDifference: false, containsZindexDifference: false, }; @@ -1164,13 +1184,14 @@ export class ElementsDelta implements DeltaContainer { try { const applyDeltas = ElementsDelta.createApplier( nextElements, - elementsSnapshot, + snapshot, + options, flags, ); - const addedElements = applyDeltas("added", this.added); - const removedElements = applyDeltas("removed", this.removed); - const updatedElements = applyDeltas("updated", this.updated); + const addedElements = applyDeltas(this.added); + const removedElements = applyDeltas(this.removed); + const updatedElements = applyDeltas(this.updated); const affectedElements = this.resolveConflicts(elements, nextElements); @@ -1229,18 +1250,12 @@ export class ElementsDelta implements DeltaContainer { private static createApplier = ( nextElements: SceneElementsMap, - snapshot: Map, - flags: { - containsVisibleDifference: boolean; - containsZindexDifference: boolean; - }, + snapshot: StoreSnapshot["elements"], + options: ApplyToOptions, + flags: ApplyToFlags, ) => - ( - type: "added" | "removed" | "updated", - deltas: Record>, - ) => { + (deltas: Record>) => { const getElement = ElementsDelta.createGetter( - type, nextElements, snapshot, flags, @@ -1250,7 +1265,13 @@ export class ElementsDelta implements DeltaContainer { const element = getElement(id, delta.inserted); if (element) { - const newElement = ElementsDelta.applyDelta(element, delta, flags); + const newElement = ElementsDelta.applyDelta( + element, + delta, + options, + flags, + ); + nextElements.set(newElement.id, newElement); acc.set(newElement.id, newElement); } @@ -1261,13 +1282,9 @@ export class ElementsDelta implements DeltaContainer { private static createGetter = ( - type: "added" | "removed" | "updated", elements: SceneElementsMap, - snapshot: Map, - flags: { - containsVisibleDifference: boolean; - containsZindexDifference: boolean; - }, + snapshot: StoreSnapshot["elements"], + flags: ApplyToFlags, ) => (id: string, partial: ElementPartial) => { let element = elements.get(id); @@ -1281,10 +1298,7 @@ export class ElementsDelta implements DeltaContainer { 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) - ) { + if (!partial.isDeleted || (partial.isDeleted && !element.isDeleted)) { flags.containsVisibleDifference = true; } } else { @@ -1304,16 +1318,25 @@ export class ElementsDelta implements DeltaContainer { private static applyDelta( element: OrderedExcalidrawElement, delta: Delta, - flags: { - containsVisibleDifference: boolean; - containsZindexDifference: boolean; - } = { - // by default we don't care about about the flags - containsVisibleDifference: true, - containsZindexDifference: true, - }, + options: ApplyToOptions, + flags: ApplyToFlags, ) { - const { boundElements, ...directlyApplicablePartial } = delta.inserted; + const directlyApplicablePartial: Mutable = {}; + + for (const key of Object.keys(delta.inserted) as Array< + keyof typeof delta.inserted + >) { + if (key === "boundElements") { + continue; + } + + if (options.excludedProperties.has(key)) { + continue; + } + + const value = delta.inserted[key]; + Reflect.set(directlyApplicablePartial, key, value); + } if ( delta.deleted.boundElements?.length || @@ -1665,7 +1688,7 @@ export class ElementsDelta implements DeltaContainer { private static stripIrrelevantProps( partial: Partial, ): ElementPartial { - const { id, updated, version, versionNonce, ...strippedPartial } = partial; + const { id, updated, ...strippedPartial } = partial; return strippedPartial; } diff --git a/packages/element/src/store.ts b/packages/element/src/store.ts index fb8926d88..0026cd1c0 100644 --- a/packages/element/src/store.ts +++ b/packages/element/src/store.ts @@ -534,7 +534,7 @@ export class StoreDelta { /** * Inverse store delta, creates new instance of `StoreDelta`. */ - public static inverse(delta: StoreDelta): StoreDelta { + public static inverse(delta: StoreDelta) { return this.create(delta.elements.inverse(), delta.appState.inverse()); } @@ -545,7 +545,7 @@ export class StoreDelta { delta: StoreDelta, elements: SceneElementsMap, modifierOptions: "deleted" | "inserted", - ): StoreDelta { + ) { return this.create( delta.elements.applyLatestChanges(elements, modifierOptions), delta.appState, @@ -562,12 +562,9 @@ export class StoreDelta { delta: StoreDelta, elements: SceneElementsMap, appState: AppState, - prevSnapshot: StoreSnapshot = StoreSnapshot.empty(), ): [SceneElementsMap, AppState, boolean] { - const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo( - elements, - prevSnapshot.elements, - ); + const [nextElements, elementsContainVisibleChange] = + delta.elements.applyTo(elements); const [nextAppState, appStateContainsVisibleChange] = delta.appState.applyTo(appState, nextElements); diff --git a/packages/excalidraw/history.ts b/packages/excalidraw/history.ts index cd9dcffe2..a275700b8 100644 --- a/packages/excalidraw/history.ts +++ b/packages/excalidraw/history.ts @@ -7,11 +7,65 @@ import { type Store, } from "@excalidraw/element"; +import type { StoreSnapshot } from "@excalidraw/element"; + import type { SceneElementsMap } from "@excalidraw/element/types"; import type { AppState } from "./types"; -class HistoryEntry extends StoreDelta {} +class HistoryEntry extends StoreDelta { + /** + * Apply the delta to the passed elements and appState, does not modify the snapshot. + */ + public applyTo( + elements: SceneElementsMap, + appState: AppState, + snapshot: StoreSnapshot, + ): [SceneElementsMap, AppState, boolean] { + const [nextElements, elementsContainVisibleChange] = this.elements.applyTo( + elements, + // used to fallback into local snapshot in case we couldn't apply the delta + // due to a missing elements in the scene (force deleted) + snapshot.elements, + // we don't want to apply the version and versionNonce properties for history + { + excludedProperties: new Set(["version", "versionNonce"]), + }, + ); + + const [nextAppState, appStateContainsVisibleChange] = this.appState.applyTo( + appState, + nextElements, + ); + + const appliedVisibleChanges = + elementsContainVisibleChange || appStateContainsVisibleChange; + + return [nextElements, nextAppState, appliedVisibleChanges]; + } + + /** + * Overriding once to avoid type casting everywhere. + */ + public static override inverse(delta: StoreDelta): HistoryEntry { + return super.inverse(delta) as HistoryEntry; + } + + /** + * Overriding once to avoid type casting everywhere. + */ + public static override applyLatestChanges( + delta: StoreDelta, + elements: SceneElementsMap, + modifierOptions: "deleted" | "inserted", + ): HistoryEntry { + return super.applyLatestChanges( + delta, + elements, + modifierOptions, + ) as HistoryEntry; + } +} export class HistoryChangedEvent { constructor( @@ -112,12 +166,7 @@ export class History { while (historyEntry) { try { [nextElements, nextAppState, containsVisibleChange] = - StoreDelta.applyTo( - historyEntry, - nextElements, - nextAppState, - prevSnapshot, - ); + historyEntry.applyTo(nextElements, nextAppState, prevSnapshot); const nextSnapshot = prevSnapshot.maybeClone( action,