From 0c21f1ae071166d1413050f3abc02c21cb8b4aeb Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Fri, 25 Apr 2025 22:14:39 +0200 Subject: [PATCH] First steps towards onIncrement API --- excalidraw-app/App.tsx | 3 + excalidraw-app/tests/collab.test.tsx | 4 +- .../{excalidraw => common/src}/emitter.ts | 2 +- packages/common/src/index.ts | 1 + packages/common/src/utility-types.ts | 5 + .../change.ts => element/src/delta.ts} | 382 ++++---- packages/element/src/linearElementEditor.ts | 4 +- packages/element/src/store.ts | 867 ++++++++++++++++++ .../excalidraw/actions/actionAddToLibrary.ts | 3 +- packages/excalidraw/actions/actionAlign.tsx | 3 +- .../excalidraw/actions/actionBoundText.tsx | 4 +- packages/excalidraw/actions/actionCanvas.tsx | 3 +- .../excalidraw/actions/actionClipboard.tsx | 4 +- .../excalidraw/actions/actionCropEditor.tsx | 3 +- .../actions/actionDeleteSelected.tsx | 3 +- .../excalidraw/actions/actionDistribute.tsx | 3 +- .../actions/actionDuplicateSelection.tsx | 3 +- .../excalidraw/actions/actionElementLink.ts | 3 +- .../excalidraw/actions/actionElementLock.ts | 3 +- .../excalidraw/actions/actionEmbeddable.ts | 3 +- packages/excalidraw/actions/actionExport.tsx | 3 +- .../excalidraw/actions/actionFinalize.tsx | 3 +- packages/excalidraw/actions/actionFlip.ts | 3 +- packages/excalidraw/actions/actionFrame.ts | 3 +- packages/excalidraw/actions/actionGroup.tsx | 3 +- packages/excalidraw/actions/actionHistory.tsx | 24 +- .../excalidraw/actions/actionLinearEditor.tsx | 3 +- packages/excalidraw/actions/actionLink.tsx | 3 +- packages/excalidraw/actions/actionMenu.tsx | 4 +- .../excalidraw/actions/actionNavigate.tsx | 3 +- .../excalidraw/actions/actionProperties.tsx | 6 +- .../excalidraw/actions/actionSelectAll.ts | 4 +- packages/excalidraw/actions/actionStyles.ts | 3 +- .../actions/actionTextAutoResize.ts | 3 +- .../actions/actionToggleGridMode.tsx | 3 +- .../actions/actionToggleObjectsSnapMode.tsx | 3 +- .../actions/actionToggleSearchMenu.ts | 3 +- .../excalidraw/actions/actionToggleStats.tsx | 3 +- .../actions/actionToggleViewMode.tsx | 3 +- .../actions/actionToggleZenMode.tsx | 3 +- packages/excalidraw/actions/actionZindex.tsx | 3 +- packages/excalidraw/actions/types.ts | 3 +- packages/excalidraw/components/App.tsx | 131 ++- .../excalidraw/components/Stats/DragInput.tsx | 3 +- packages/excalidraw/data/library.ts | 2 +- packages/excalidraw/history.ts | 155 ++-- packages/excalidraw/hooks/useEmitter.ts | 2 +- packages/excalidraw/index.tsx | 4 +- packages/excalidraw/store.ts | 449 --------- packages/excalidraw/tests/history.test.tsx | 72 +- packages/excalidraw/types.ts | 12 +- 51 files changed, 1358 insertions(+), 870 deletions(-) rename packages/{excalidraw => common/src}/emitter.ts (94%) rename packages/{excalidraw/change.ts => element/src/delta.ts} (82%) create mode 100644 packages/element/src/store.ts delete mode 100644 packages/excalidraw/store.ts diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index bb62a0e96c..1f3d9954d2 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -807,6 +807,9 @@ const ExcalidrawWrapper = () => { { + console.log(increment); + }} initialData={initialStatePromiseRef.current.promise} isCollaborating={isCollaborating} onPointerUpdate={collabAPI?.onPointerUpdate} diff --git a/excalidraw-app/tests/collab.test.tsx b/excalidraw-app/tests/collab.test.tsx index 3572303f43..b19c8cbdb1 100644 --- a/excalidraw-app/tests/collab.test.tsx +++ b/excalidraw-app/tests/collab.test.tsx @@ -122,7 +122,7 @@ describe("collaboration", () => { expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); }); - const undoAction = createUndoAction(h.history, h.store); + const undoAction = createUndoAction(h.history); act(() => h.app.actionManager.executeAction(undoAction)); // with explicit undo (as addition) we expect our item to be restored from the snapshot! @@ -154,7 +154,7 @@ describe("collaboration", () => { expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); }); - const redoAction = createRedoAction(h.history, h.store); + const redoAction = createRedoAction(h.history); act(() => h.app.actionManager.executeAction(redoAction)); // with explicit redo (as removal) we again restore the element from the snapshot! diff --git a/packages/excalidraw/emitter.ts b/packages/common/src/emitter.ts similarity index 94% rename from packages/excalidraw/emitter.ts rename to packages/common/src/emitter.ts index 938269728e..7c069a5a05 100644 --- a/packages/excalidraw/emitter.ts +++ b/packages/common/src/emitter.ts @@ -1,4 +1,4 @@ -import type { UnsubscribeCallback } from "./types"; +import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types"; type Subscriber = (...payload: T) => void; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index d896ba98e5..79f243f4f0 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -9,3 +9,4 @@ export * from "./promise-pool"; export * from "./random"; export * from "./url"; export * from "./utils"; +export * from "./emitter"; diff --git a/packages/common/src/utility-types.ts b/packages/common/src/utility-types.ts index d4804d1957..dd26fc3973 100644 --- a/packages/common/src/utility-types.ts +++ b/packages/common/src/utility-types.ts @@ -68,3 +68,8 @@ export type MaybePromise = T | Promise; // get union of all keys from the union of types export type AllPossibleKeys = T extends any ? keyof T : never; + +/** Strip all the methods or functions from a type */ +export type DTO = { + [K in keyof T as T[K] extends Function ? never : K]: T[K]; +}; diff --git a/packages/excalidraw/change.ts b/packages/element/src/delta.ts similarity index 82% rename from packages/excalidraw/change.ts rename to packages/element/src/delta.ts index e7ba76f603..a58a75e6c5 100644 --- a/packages/excalidraw/change.ts +++ b/packages/element/src/delta.ts @@ -5,43 +5,7 @@ import { isDevEnv, isShallowEqual, isTestEnv, - toBrandedType, } from "@excalidraw/common"; -import { - BoundElement, - BindableElement, - bindingProperties, - updateBoundElements, -} from "@excalidraw/element/binding"; -import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; -import { - mutateElement, - newElementWith, -} from "@excalidraw/element/mutateElement"; -import { - getBoundTextElementId, - redrawTextBoundingBox, -} from "@excalidraw/element/textElement"; -import { - hasBoundTextElement, - isBindableElement, - isBoundToContainer, - isImageElement, - isTextElement, -} from "@excalidraw/element/typeChecks"; - -import { getNonDeletedGroupIds } from "@excalidraw/element/groups"; - -import { - orderByFractionalIndex, - syncMovedIndices, -} from "@excalidraw/element/fractionalIndex"; - -import Scene from "@excalidraw/element/Scene"; - -import type { BindableProp, BindingProp } from "@excalidraw/element/binding"; - -import type { ElementUpdate } from "@excalidraw/element/mutateElement"; import type { ExcalidrawElement, @@ -54,16 +18,42 @@ import type { SceneElementsMap, } from "@excalidraw/element/types"; -import type { SubtypeOf, ValueOf } from "@excalidraw/common/utility-types"; - -import { getObservedAppState } from "./store"; +import type { DTO, SubtypeOf, ValueOf } from "@excalidraw/common/utility-types"; import type { AppState, ObservedAppState, ObservedElementsAppState, ObservedStandaloneAppState, -} from "./types"; +} from "@excalidraw/excalidraw/types"; + +import { getObservedAppState } from "./store"; + +import { + BoundElement, + BindableElement, + bindingProperties, + updateBoundElements, +} from "./binding"; +import { LinearElementEditor } from "./linearElementEditor"; +import { mutateElement, newElementWith } from "./mutateElement"; +import { getBoundTextElementId, redrawTextBoundingBox } from "./textElement"; +import { + hasBoundTextElement, + isBindableElement, + isBoundToContainer, + isTextElement, +} from "./typeChecks"; + +import { getNonDeletedGroupIds } from "./groups"; + +import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; + +import Scene from "./Scene"; + +import type { BindableProp, BindingProp } from "./binding"; + +import type { ElementUpdate } from "./mutateElement"; /** * Represents the difference between two objects of the same type. @@ -74,7 +64,7 @@ import type { * * Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load. */ -class Delta { +export class Delta { private constructor( public readonly deleted: Partial, public readonly inserted: Partial, @@ -409,51 +399,56 @@ class Delta { } /** - * Encapsulates the modifications captured as `Delta`/s. + * Encapsulates a set of application-level `Delta`s. */ -interface Change { +export interface DeltaContainer { /** - * Inverses the `Delta`s inside while creating a new `Change`. + * Inverses the `Delta`s while creating a new `DeltaContainer` instance. */ - inverse(): Change; + inverse(): DeltaContainer; /** - * Applies the `Change` to the previous object. + * Applies the `Delta`s 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. + * @returns a tuple of the next object `T` with applied `Delta`s, and `boolean`, indicating whether the applied deltas resulted in a visible change. */ applyTo(previous: T, ...options: unknown[]): [T, boolean]; /** - * Checks whether there are actually `Delta`s. + * Checks whether all `Delta`s are empty. */ isEmpty(): boolean; } -export class AppStateChange implements Change { - private constructor(private readonly delta: Delta) {} +export class AppStateDelta implements DeltaContainer { + private constructor(public readonly delta: Delta) {} public static calculate( prevAppState: T, nextAppState: T, - ): AppStateChange { + ): AppStateDelta { const delta = Delta.calculate( prevAppState, nextAppState, undefined, - AppStateChange.postProcess, + AppStateDelta.postProcess, ); - return new AppStateChange(delta); + return new AppStateDelta(delta); + } + + public static restore(appStateDeltaDTO: DTO): AppStateDelta { + const { delta } = appStateDeltaDTO; + return new AppStateDelta(delta); } public static empty() { - return new AppStateChange(Delta.create({}, {})); + return new AppStateDelta(Delta.create({}, {})); } - public inverse(): AppStateChange { + public inverse(): AppStateDelta { const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted); - return new AppStateChange(inversedDelta); + return new AppStateDelta(inversedDelta); } public applyTo( @@ -594,13 +589,13 @@ export class AppStateChange implements Change { const nextObservedAppState = getObservedAppState(nextAppState); const containsStandaloneDifference = Delta.isRightDifferent( - AppStateChange.stripElementsProps(prevObservedAppState), - AppStateChange.stripElementsProps(nextObservedAppState), + AppStateDelta.stripElementsProps(prevObservedAppState), + AppStateDelta.stripElementsProps(nextObservedAppState), ); const containsElementsDifference = Delta.isRightDifferent( - AppStateChange.stripStandaloneProps(prevObservedAppState), - AppStateChange.stripStandaloneProps(nextObservedAppState), + AppStateDelta.stripStandaloneProps(prevObservedAppState), + AppStateDelta.stripStandaloneProps(nextObservedAppState), ); if (!containsStandaloneDifference && !containsElementsDifference) { @@ -615,8 +610,8 @@ export class AppStateChange implements Change { if (containsElementsDifference) { // filter invisible changes on each iteration const changedElementsProps = Delta.getRightDifferences( - AppStateChange.stripStandaloneProps(prevObservedAppState), - AppStateChange.stripStandaloneProps(nextObservedAppState), + AppStateDelta.stripStandaloneProps(prevObservedAppState), + AppStateDelta.stripStandaloneProps(nextObservedAppState), ) as Array; let nonDeletedGroupIds = new Set(); @@ -633,7 +628,7 @@ export class AppStateChange implements Change { for (const key of changedElementsProps) { switch (key) { case "selectedElementIds": - nextAppState[key] = AppStateChange.filterSelectedElements( + nextAppState[key] = AppStateDelta.filterSelectedElements( nextAppState[key], nextElements, visibleDifferenceFlag, @@ -641,7 +636,7 @@ export class AppStateChange implements Change { break; case "selectedGroupIds": - nextAppState[key] = AppStateChange.filterSelectedGroups( + nextAppState[key] = AppStateDelta.filterSelectedGroups( nextAppState[key], nonDeletedGroupIds, visibleDifferenceFlag, @@ -677,7 +672,7 @@ export class AppStateChange implements Change { break; case "selectedLinearElementId": case "editingLinearElementId": - const appStateKey = AppStateChange.convertToAppStateKey(key); + const appStateKey = AppStateDelta.convertToAppStateKey(key); const linearElement = nextAppState[appStateKey]; if (!linearElement) { @@ -823,50 +818,63 @@ type ElementPartial = Omit< * 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 { +export class ElementsDelta implements DeltaContainer { private constructor( - private readonly added: Map>, - private readonly removed: Map>, - private readonly updated: Map>, + public readonly added: Record>, + public readonly removed: Record>, + public readonly updated: Record>, ) {} public static create( - added: Map>, - removed: Map>, - updated: Map>, - options = { shouldRedistribute: false }, + added: Record>, + removed: Record>, + updated: Record>, + options: { + shouldRedistribute: boolean; + } = { + shouldRedistribute: false, + }, ) { - let change: ElementsChange; + let delta: ElementsDelta; if (options.shouldRedistribute) { - const nextAdded = new Map>(); - const nextRemoved = new Map>(); - const nextUpdated = new Map>(); + const nextAdded: Record> = {}; + const nextRemoved: Record> = {}; + const nextUpdated: Record> = {}; - const deltas = [...added, ...removed, ...updated]; + const deltas = [ + ...Object.entries(added), + ...Object.entries(removed), + ...Object.entries(updated), + ]; for (const [id, delta] of deltas) { if (this.satisfiesAddition(delta)) { - nextAdded.set(id, delta); + nextAdded[id] = delta; } else if (this.satisfiesRemoval(delta)) { - nextRemoved.set(id, delta); + nextRemoved[id] = delta; } else { - nextUpdated.set(id, delta); + nextUpdated[id] = delta; } } - change = new ElementsChange(nextAdded, nextRemoved, nextUpdated); + delta = new ElementsDelta(nextAdded, nextRemoved, nextUpdated); } else { - change = new ElementsChange(added, removed, updated); + delta = new ElementsDelta(added, removed, updated); } if (isTestEnv() || isDevEnv()) { - ElementsChange.validate(change, "added", this.satisfiesAddition); - ElementsChange.validate(change, "removed", this.satisfiesRemoval); - ElementsChange.validate(change, "updated", this.satisfiesUpdate); + ElementsDelta.validate(delta, "added", this.satisfiesAddition); + ElementsDelta.validate(delta, "removed", this.satisfiesRemoval); + ElementsDelta.validate(delta, "updated", this.satisfiesUpdate); } - return change; + return delta; + } + + public static restore(elementsDeltaDTO: DTO): ElementsDelta { + const { added, removed, updated } = elementsDeltaDTO; + return ElementsDelta.create(added, removed, updated); } private static satisfiesAddition = ({ @@ -888,17 +896,17 @@ export class ElementsChange implements Change { }: Delta) => !!deleted.isDeleted === !!inserted.isDeleted; private static validate( - change: ElementsChange, + elementsDelta: ElementsDelta, type: "added" | "removed" | "updated", satifies: (delta: Delta) => boolean, ) { - for (const [id, delta] of change[type].entries()) { + for (const [id, delta] of Object.entries(elementsDelta[type])) { if (!satifies(delta)) { console.error( `Broken invariant for "${type}" delta, element "${id}", delta:`, delta, ); - throw new Error(`ElementsChange invariant broken for element "${id}".`); + throw new Error(`ElementsDelta invariant broken for element "${id}".`); } } } @@ -909,19 +917,19 @@ export class ElementsChange implements Change { * @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. + * @returns `ElementsDelta` instance representing the `Delta` changes between the two sets of elements. */ public static calculate( prevElements: Map, nextElements: Map, - ): ElementsChange { + ): ElementsDelta { if (prevElements === nextElements) { - return ElementsChange.empty(); + return ElementsDelta.empty(); } - const added = new Map>(); - const removed = new Map>(); - const updated = new Map>(); + const added: Record> = {}; + const removed: Record> = {}; + const updated: Record> = {}; // 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()) { @@ -934,10 +942,10 @@ export class ElementsChange implements Change { const delta = Delta.create( deleted, inserted, - ElementsChange.stripIrrelevantProps, + ElementsDelta.stripIrrelevantProps, ); - removed.set(prevElement.id, delta); + removed[prevElement.id] = delta; } } @@ -954,10 +962,10 @@ export class ElementsChange implements Change { const delta = Delta.create( deleted, inserted, - ElementsChange.stripIrrelevantProps, + ElementsDelta.stripIrrelevantProps, ); - added.set(nextElement.id, delta); + added[nextElement.id] = delta; continue; } @@ -966,8 +974,8 @@ export class ElementsChange implements Change { const delta = Delta.calculate( prevElement, nextElement, - ElementsChange.stripIrrelevantProps, - ElementsChange.postProcess, + ElementsDelta.stripIrrelevantProps, + ElementsDelta.postProcess, ); if ( @@ -978,9 +986,9 @@ export class ElementsChange implements Change { ) { // notice that other props could have been updated as well if (prevElement.isDeleted && !nextElement.isDeleted) { - added.set(nextElement.id, delta); + added[nextElement.id] = delta; } else { - removed.set(nextElement.id, delta); + removed[nextElement.id] = delta; } continue; @@ -988,24 +996,24 @@ export class ElementsChange implements Change { // making sure there are at least some changes if (!Delta.isEmpty(delta)) { - updated.set(nextElement.id, delta); + updated[nextElement.id] = delta; } } } - return ElementsChange.create(added, removed, updated); + return ElementsDelta.create(added, removed, updated); } public static empty() { - return ElementsChange.create(new Map(), new Map(), new Map()); + return ElementsDelta.create({}, {}, {}); } - public inverse(): ElementsChange { - const inverseInternal = (deltas: Map>) => { - const inversedDeltas = new Map>(); + public inverse(): ElementsDelta { + const inverseInternal = (deltas: Record>) => { + const inversedDeltas: Record> = {}; - for (const [id, delta] of deltas.entries()) { - inversedDeltas.set(id, Delta.create(delta.inserted, delta.deleted)); + for (const [id, delta] of Object.entries(deltas)) { + inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted); } return inversedDeltas; @@ -1016,14 +1024,14 @@ export class ElementsChange implements Change { const updated = inverseInternal(this.updated); // notice we inverse removed with added not to break the invariants - return ElementsChange.create(removed, added, updated); + return ElementsDelta.create(removed, added, updated); } public isEmpty(): boolean { return ( - this.added.size === 0 && - this.removed.size === 0 && - this.updated.size === 0 + Object.keys(this.added).length === 0 && + Object.keys(this.removed).length === 0 && + Object.keys(this.updated).length === 0 ); } @@ -1034,7 +1042,10 @@ export class ElementsChange implements Change { * @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 { + public applyLatestChanges( + elements: SceneElementsMap, + modifierOptions: "deleted" | "inserted", + ): ElementsDelta { const modifier = (element: OrderedExcalidrawElement) => (partial: ElementPartial) => { const latestPartial: { [key: string]: unknown } = {}; @@ -1055,11 +1066,11 @@ export class ElementsChange implements Change { }; const applyLatestChangesInternal = ( - deltas: Map>, + deltas: Record>, ) => { - const modifiedDeltas = new Map>(); + const modifiedDeltas: Record> = {}; - for (const [id, delta] of deltas.entries()) { + for (const [id, delta] of Object.entries(deltas)) { const existingElement = elements.get(id); if (existingElement) { @@ -1067,12 +1078,12 @@ export class ElementsChange implements Change { delta.deleted, delta.inserted, modifier(existingElement), - "inserted", + modifierOptions, ); - modifiedDeltas.set(id, modifiedDelta); + modifiedDeltas[id] = modifiedDelta; } else { - modifiedDeltas.set(id, delta); + modifiedDeltas[id] = delta; } } @@ -1083,16 +1094,16 @@ export class ElementsChange implements Change { const removed = applyLatestChangesInternal(this.removed); const updated = applyLatestChangesInternal(this.updated); - return ElementsChange.create(added, removed, updated, { + return ElementsDelta.create(added, removed, updated, { shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated }); } public applyTo( elements: SceneElementsMap, - snapshot: Map, + elementsSnapshot: Map, ): [SceneElementsMap, boolean] { - let nextElements = toBrandedType(new Map(elements)); + let nextElements = new Map(elements) as SceneElementsMap; let changedElements: Map; const flags = { @@ -1102,15 +1113,15 @@ export class ElementsChange implements Change { // mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation) try { - const applyDeltas = ElementsChange.createApplier( + const applyDeltas = ElementsDelta.createApplier( nextElements, - snapshot, + elementsSnapshot, flags, ); - const addedElements = applyDeltas(this.added); - const removedElements = applyDeltas(this.removed); - const updatedElements = applyDeltas(this.updated); + const addedElements = applyDeltas("added", this.added); + const removedElements = applyDeltas("removed", this.removed); + const updatedElements = applyDeltas("updated", this.updated); const affectedElements = this.resolveConflicts(elements, nextElements); @@ -1122,7 +1133,7 @@ export class ElementsChange implements Change { ...affectedElements, ]); } catch (e) { - console.error(`Couldn't apply elements change`, e); + console.error(`Couldn't apply elements delta`, e); if (isTestEnv() || isDevEnv()) { throw e; @@ -1138,7 +1149,7 @@ export class ElementsChange implements Change { try { // 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 = ElementsDelta.reorderElements( nextElements, changedElements, flags, @@ -1149,9 +1160,9 @@ export class ElementsChange implements Change { // so we are creating a temp scene just to query and mutate elements const tempScene = new Scene(nextElements); - ElementsChange.redrawTextBoundingBoxes(tempScene, changedElements); + ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements); // Need ordered nextElements to avoid z-index binding issues - ElementsChange.redrawBoundArrows(tempScene, changedElements); + ElementsDelta.redrawBoundArrows(tempScene, changedElements); } catch (e) { console.error( `Couldn't mutate elements after applying elements change`, @@ -1166,36 +1177,42 @@ export class ElementsChange implements Change { } } - private static createApplier = ( - nextElements: SceneElementsMap, - snapshot: Map, - flags: { - containsVisibleDifference: boolean; - containsZindexDifference: boolean; - }, - ) => { - const getElement = ElementsChange.createGetter( - nextElements, - snapshot, - flags, - ); + private static createApplier = + ( + nextElements: SceneElementsMap, + snapshot: Map, + flags: { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; + }, + ) => + ( + type: "added" | "removed" | "updated", + deltas: Record>, + ) => { + const getElement = ElementsDelta.createGetter( + type, + nextElements, + snapshot, + flags, + ); - return (deltas: Map>) => - Array.from(deltas.entries()).reduce((acc, [id, delta]) => { + return Object.entries(deltas).reduce((acc, [id, delta]) => { const element = getElement(id, delta.inserted); if (element) { - const newElement = ElementsChange.applyDelta(element, delta, flags); + const newElement = ElementsDelta.applyDelta(element, delta, flags); nextElements.set(newElement.id, newElement); acc.set(newElement.id, newElement); } return acc; }, new Map()); - }; + }; private static createGetter = ( + type: "added" | "removed" | "updated", elements: SceneElementsMap, snapshot: Map, flags: { @@ -1221,6 +1238,14 @@ export class ElementsChange implements Change { ) { flags.containsVisibleDifference = true; } + } else { + // not in elements, not in snapshot? element might have been added remotely! + element = newElementWith( + { id, version: 1 } as OrderedExcalidrawElement, + { + ...partial, + }, + ); } } @@ -1257,7 +1282,8 @@ export class ElementsChange implements Change { }); } - if (isImageElement(element)) { + // TODO: this looks wrong, shouldn't be here + if (element.type === "image") { const _delta = delta as Delta>; // we want to override `crop` only if modified so that we don't reset // when undoing/redoing unrelated change @@ -1270,10 +1296,12 @@ export class ElementsChange implements Change { } if (!flags.containsVisibleDifference) { - // strip away fractional as even if it would be different, it doesn't have to result in visible change + // strip away fractional index, 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); + const containsVisibleDifference = ElementsDelta.checkForVisibleDifference( + element, + rest, + ); flags.containsVisibleDifference = containsVisibleDifference; } @@ -1316,6 +1344,8 @@ export class ElementsChange implements Change { * Resolves conflicts for all previously added, removed and updated elements. * Updates the previous deltas with all the changes after conflict resolution. * + * // TODO: revisit since some bound arrows seem to be often redrawn incorrectly + * * @returns all elements affected by the conflict resolution */ private resolveConflicts( @@ -1346,7 +1376,7 @@ export class ElementsChange implements Change { nextElement, nextElements, updates as ElementUpdate, - ) as OrderedExcalidrawElement; + ); } nextAffectedElements.set(affectedElement.id, affectedElement); @@ -1354,20 +1384,21 @@ export class ElementsChange implements Change { }; // 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); + for (const id of Object.keys(this.removed)) { + ElementsDelta.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); + for (const id of Object.keys(this.added)) { + ElementsDelta.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), - ), + for (const [id] of Array.from(Object.entries(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) { @@ -1375,7 +1406,7 @@ export class ElementsChange implements Change { continue; } - ElementsChange.rebindAffected(prevElements, nextElements, id, updater); + ElementsDelta.rebindAffected(prevElements, nextElements, id, updater); } // filter only previous elements, which were now affected @@ -1385,21 +1416,21 @@ export class ElementsChange implements Change { // 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( + const { added, removed, updated } = ElementsDelta.calculate( prevAffectedElements, nextAffectedElements, ); - for (const [id, delta] of added) { - this.added.set(id, delta); + for (const [id, delta] of Object.entries(added)) { + this.added[id] = delta; } - for (const [id, delta] of removed) { - this.removed.set(id, delta); + for (const [id, delta] of Object.entries(removed)) { + this.removed[id] = delta; } - for (const [id, delta] of updated) { - this.updated.set(id, delta); + for (const [id, delta] of Object.entries(updated)) { + this.updated[id] = delta; } return nextAffectedElements; @@ -1572,7 +1603,7 @@ export class ElementsChange implements Change { 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.`); + console.error(`Couldn't postprocess elements delta.`); if (isTestEnv() || isDevEnv()) { throw e; @@ -1585,8 +1616,7 @@ export class ElementsChange implements Change { private static stripIrrelevantProps( partial: Partial, ): ElementPartial { - const { id, updated, version, versionNonce, seed, ...strippedPartial } = - partial; + const { id, updated, version, versionNonce, ...strippedPartial } = partial; return strippedPartial; } diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 55e3f5c4f7..a6e4a1af7b 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -20,7 +20,7 @@ import { tupleToCoors, } from "@excalidraw/common"; -import type { Store } from "@excalidraw/excalidraw/store"; +import type { Store } from "@excalidraw/element/store"; import type { Radians } from "@excalidraw/math"; @@ -807,7 +807,7 @@ export class LinearElementEditor { }); ret.didAddPoint = true; } - store.shouldCaptureIncrement(); + store.scheduleCapture(); ret.linearElementEditor = { ...linearElementEditor, pointerDownState: { diff --git a/packages/element/src/store.ts b/packages/element/src/store.ts new file mode 100644 index 0000000000..80a5bbea7e --- /dev/null +++ b/packages/element/src/store.ts @@ -0,0 +1,867 @@ +import { + arrayToMap, + assertNever, + COLOR_PALETTE, + isDevEnv, + isTestEnv, + randomId, + Emitter, +} from "@excalidraw/common"; + +import type { DTO, ValueOf } from "@excalidraw/common/utility-types"; + +import type { AppState, ObservedAppState } from "@excalidraw/excalidraw/types"; + +import { deepCopyElement } from "./duplicate"; +import { newElementWith } from "./mutateElement"; +import { syncMovedIndices } from "./fractionalIndex"; + +import { ElementsDelta, AppStateDelta, Delta } from "./delta"; + +import { hashElementsVersion, hashString } from "./index"; + +import type { + ExcalidrawElement, + OrderedExcalidrawElement, + SceneElementsMap, +} from "./types"; + +export const CaptureUpdateAction = { + /** + * Immediately undoable. + * + * Use for updates which should be captured. + * Should be used for most of the local updates, except ephemerals such as dragging or resizing. + * + * These updates will _immediately_ make it to the local undo / redo stacks. + */ + IMMEDIATELY: "IMMEDIATELY", + /** + * Never undoable. + * + * Use for updates which should never be recorded, such as remote updates + * or scene initialization. + * + * These updates will _never_ make it to the local undo / redo stacks. + */ + NEVER: "NEVER", + /** + * Eventually undoable. + * + * Use for updates which should not be captured immediately - likely + * exceptions which are part of some async multi-step process. Otherwise, all + * such updates would end up being captured with the next + * `CaptureUpdateAction.IMMEDIATELY` - triggered either by the next `updateScene` + * or internally by the editor. + * + * These updates will _eventually_ make it to the local undo / redo stacks. + */ + EVENTUALLY: "EVENTUALLY", +} as const; + +export type CaptureUpdateActionType = ValueOf; + +type MicroActionsQueue = (() => void)[]; + +/** + * Store which captures the observed changes and emits them as `StoreIncrement` events. + */ +export class Store { + public readonly onStoreIncrementEmitter = new Emitter< + [DurableIncrement | EphemeralIncrement] + >(); + + private scheduledMacroActions: Set = new Set(); + private scheduledMicroActions: MicroActionsQueue = []; + + private _snapshot = StoreSnapshot.empty(); + + public get snapshot() { + return this._snapshot; + } + + public set snapshot(snapshot: StoreSnapshot) { + this._snapshot = snapshot; + } + + public scheduleAction(action: CaptureUpdateActionType) { + this.scheduledMacroActions.add(action); + this.satisfiesScheduledActionsInvariant(); + } + + /** + * Use to schedule a delta calculation, which will consquentially be emitted as `DurableStoreIncrement` and pushed in the undo stack. + */ + // TODO: Suspicious that this is called so many places. Seems error-prone. + public scheduleCapture() { + this.scheduleAction(CaptureUpdateAction.IMMEDIATELY); + } + + /** + * Schedule special "micro" actions, to-be executed before the next commit, before it executes a scheduled "macro" action. + */ + public scheduleMicroAction( + action: CaptureUpdateActionType, + elements: Map | undefined, + appState: AppState | ObservedAppState | undefined = undefined, + /** delta is only relevant for `CaptureUpdateAction.IMMEDIATELY`, as it's the only action producing `DurableStoreIncrement` containing a delta */ + delta: StoreDelta | undefined = undefined, + ) { + // create a snapshot first, so that it couldn't mutate in the meantime + const snapshot = this.maybeCloneSnapshot(action, elements, appState); + + if (!snapshot) { + return; + } + + this.scheduledMicroActions.push(() => + this.executeAction(action, snapshot, delta), + ); + } + + /** + * Performs the incoming `CaptureUpdateAction` and emits the corresponding `StoreIncrement`. + * Emits `DurableStoreIncrement` when action is "capture", emits `EphemeralStoreIncrement` otherwise. + * + * @emits StoreIncrement + */ + public commit( + elements: Map | undefined, + appState: AppState | ObservedAppState | undefined, + ): void { + // execute all scheduled micro actions first + // similar to microTasks, there can be many + this.flushMicroActions(); + + try { + const macroAction = this.getScheduledMacroAction(); + const nextSnapshot = this.maybeCloneSnapshot( + macroAction, + elements, + appState, + ); + + if (!nextSnapshot) { + // don't continue if there is not change detected + return; + } + + // execute a single scheduled "macro" function + // similar to macro tasks, there can be only one within a single commit + this.executeAction(macroAction, nextSnapshot); + } finally { + this.satisfiesScheduledActionsInvariant(); + // defensively reset all scheduled "macro" actions, possibly cleans up other runtime garbage + this.scheduledMacroActions = new Set(); + } + } + + /** + * Apply the increment to the passed elements and appState, does not modify the snapshot. + */ + public applyDeltaTo( + delta: StoreDelta, + elements: SceneElementsMap, + appState: AppState, + ): [SceneElementsMap, AppState, boolean] { + const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo( + elements, + this.snapshot.elements, + ); + + const [nextAppState, appStateContainsVisibleChange] = + delta.appState.applyTo(appState, nextElements); + + const appliedVisibleChanges = + elementsContainVisibleChange || appStateContainsVisibleChange; + + return [nextElements, nextAppState, appliedVisibleChanges]; + } + + /** + * Filters out yet uncomitted elements from `nextElements`, which are part of in-progress local async actions (ephemerals) and thus were not yet commited to the snapshot. + * + * This is necessary in updates in which we receive reconciled elements, already containing elements which were not yet captured by the local store (i.e. collab). + */ + public filterUncomittedElements( + prevElements: Map, + nextElements: Map, + ): Map { + const movedElements = new Map(); + + for (const [id, prevElement] of prevElements.entries()) { + const nextElement = nextElements.get(id); + + if (!nextElement) { + // Nothing to care about here, element was forcefully deleted + continue; + } + + const elementSnapshot = this.snapshot.elements.get(id); + + // Checks for in progress async user action + if (!elementSnapshot) { + // Detected yet uncomitted local element + nextElements.delete(id); + } else if (elementSnapshot.version < prevElement.version) { + // Element was already commited, but the snapshot version is lower than current local version + nextElements.set(id, elementSnapshot); + // Mark the element as potentially moved, as it could have + movedElements.set(id, elementSnapshot); + } + } + + // Make sure to sync only potentially invalid indices for all elements restored from the snapshot + const syncedElements = syncMovedIndices( + Array.from(nextElements.values()), + movedElements, + ); + + return arrayToMap(syncedElements); + } + /** + * Clears the store instance. + */ + public clear(): void { + this.snapshot = StoreSnapshot.empty(); + this.scheduledMacroActions = new Set(); + } + + /** + * Executes the incoming `CaptureUpdateAction`, emits the corresponding `StoreIncrement` and maybe updates the snapshot. + * + * @emits StoreIncrement + */ + private executeAction( + action: CaptureUpdateActionType, + snapshot: StoreSnapshot, + delta: StoreDelta | undefined = undefined, + ) { + try { + switch (action) { + // only immediately emits a durable increment + case CaptureUpdateAction.IMMEDIATELY: + this.emitDurableIncrement(snapshot, delta); + break; + // both never and eventually emit an ephemeral increment + case CaptureUpdateAction.NEVER: + case CaptureUpdateAction.EVENTUALLY: + this.emitEphemeralIncrement(snapshot); + break; + default: + assertNever(action, `Unknown store action`); + } + } finally { + // update the snpashot no-matter what, as it would mess up with the next action + switch (action) { + // both immediately and never update the snapshot, unlike eventually + case CaptureUpdateAction.IMMEDIATELY: + case CaptureUpdateAction.NEVER: + this.snapshot = snapshot; + break; + } + } + } + + /** + * Performs delta & change calculation and emits a durable increment. + * + * @emits StoreIncrement. + */ + private emitDurableIncrement( + snapshot: StoreSnapshot, + delta: StoreDelta | undefined = undefined, + ) { + const prevSnapshot = this.snapshot; + + // Calculate the deltas based on the previous and next snapshot + // We might have the delta already (i.e. when applying history entry), thus we don't need to calculate it again + const elementsDelta = delta + ? delta.elements + : snapshot.metadata.didElementsChange + ? ElementsDelta.calculate(prevSnapshot.elements, snapshot.elements) + : ElementsDelta.empty(); + + const appStateDelta = delta + ? delta.appState + : snapshot.metadata.didAppStateChange + ? AppStateDelta.calculate(prevSnapshot.appState, snapshot.appState) + : AppStateDelta.empty(); + + if (!elementsDelta.isEmpty() || !appStateDelta.isEmpty()) { + const delta = StoreDelta.create(elementsDelta, appStateDelta); + const change = StoreChange.create(prevSnapshot, snapshot); + const increment = new DurableIncrement(change, delta); + + // Notify listeners with the increment + this.onStoreIncrementEmitter.trigger(increment); + } + } + + /** + * Performs change calculation and emits an ephemeral increment. + * + * @emits EphemeralStoreIncrement + */ + private emitEphemeralIncrement(snapshot: StoreSnapshot) { + const prevSnapshot = this.snapshot; + const change = StoreChange.create(prevSnapshot, snapshot); + const increment = new EphemeralIncrement(change); + + // Notify listeners with the increment + this.onStoreIncrementEmitter.trigger(increment); + } + + /** + * Clones the snapshot if there are changes detected. + */ + private maybeCloneSnapshot( + action: CaptureUpdateActionType, + elements: Map | undefined, + appState: AppState | ObservedAppState | undefined, + ) { + if (!elements && !appState) { + return null; + } + + const prevSnapshot = this.snapshot; + const nextSnapshot = this.snapshot.maybeClone(action, elements, appState); + + if (prevSnapshot === nextSnapshot) { + return null; + } + + return nextSnapshot; + } + + private flushMicroActions() { + const microActions = [...this.scheduledMicroActions]; + + // clear the queue first, in case it mutates in the meantime + this.scheduledMicroActions = []; + + for (const microAction of microActions) { + try { + microAction(); + } catch (error) { + console.error(`Failed to execute scheduled micro action`, error); + } + } + } + + /** + * Returns the scheduled macro action. + */ + private getScheduledMacroAction() { + let scheduledAction: CaptureUpdateActionType; + + if (this.scheduledMacroActions.has(CaptureUpdateAction.IMMEDIATELY)) { + // Capture has a precedence over update, since it also performs snapshot update + scheduledAction = CaptureUpdateAction.IMMEDIATELY; + } else if (this.scheduledMacroActions.has(CaptureUpdateAction.NEVER)) { + // Update has a precedence over none, since it also emits an (ephemeral) increment + scheduledAction = CaptureUpdateAction.NEVER; + } else { + // Default is to emit ephemeral increment and don't update the snapshot + scheduledAction = CaptureUpdateAction.EVENTUALLY; + } + + return scheduledAction; + } + + /** + * Ensures that the scheduled actions invariant is satisfied. + */ + private satisfiesScheduledActionsInvariant() { + if ( + !( + this.scheduledMacroActions.size >= 0 && + this.scheduledMacroActions.size <= + Object.keys(CaptureUpdateAction).length + ) + ) { + const message = `There can be at most three store actions scheduled at the same time, but there are "${this.scheduledMacroActions.size}".`; + console.error(message, this.scheduledMacroActions.values()); + + if (isTestEnv() || isDevEnv()) { + throw new Error(message); + } + } + } +} + +/** + * Repsents a change to the store containing changed elements and appState. + */ +export class StoreChange { + // so figuring out what has changed should ideally be just quick reference checks + private constructor( + public readonly elements: Record, + public readonly appState: Partial, + ) {} + + public static create( + prevSnapshot: StoreSnapshot, + nextSnapshot: StoreSnapshot, + ) { + const changedElements = nextSnapshot.getChangedElements(prevSnapshot); + const changedAppState = nextSnapshot.getChangedAppState(prevSnapshot); + + return new StoreChange(changedElements, changedAppState); + } +} + +/** + * Encpasulates any change to the store (durable or ephemeral). + */ +export abstract class StoreIncrement { + protected constructor( + public readonly type: "durable" | "ephemeral", + public readonly change: StoreChange, + ) {} + + public static isDurable( + increment: StoreIncrement, + ): increment is DurableIncrement { + return increment.type === "durable"; + } + + public static isEphemeral( + increment: StoreIncrement, + ): increment is EphemeralIncrement { + return increment.type === "ephemeral"; + } +} + +/** + * Represents a durable change to the store. + */ +export class DurableIncrement extends StoreIncrement { + constructor( + public readonly change: StoreChange, + public readonly delta: StoreDelta, + ) { + super("durable", change); + } +} + +/** + * Represents an ephemeral change to the store. + */ +export class EphemeralIncrement extends StoreIncrement { + constructor(public readonly change: StoreChange) { + super("ephemeral", change); + } +} + +/** + * Represents a captured delta by the Store. + */ +export class StoreDelta { + protected constructor( + public readonly id: string, + public readonly elements: ElementsDelta, + public readonly appState: AppStateDelta, + ) {} + + /** + * Create a new instance of `StoreDelta`. + */ + public static create( + elements: ElementsDelta, + appState: AppStateDelta, + opts: { + id: string; + } = { + id: randomId(), + }, + ) { + return new this(opts.id, elements, appState); + } + + /** + * Restore a store delta instance from a DTO. + */ + public static restore(storeDeltaDTO: DTO) { + const { id, elements, appState } = storeDeltaDTO; + return new this( + id, + ElementsDelta.restore(elements), + AppStateDelta.restore(appState), + ); + } + + /** + * Parse and load the delta from the remote payload. + */ + public static load({ + id, + elements: { added, removed, updated }, + }: DTO) { + const elements = ElementsDelta.create(added, removed, updated, { + shouldRedistribute: false, + }); + + return new this(id, elements, AppStateDelta.empty()); + } + + /** + * Inverse store delta, creates new instance of `StoreDelta`. + */ + public static inverse(delta: StoreDelta): StoreDelta { + return this.create(delta.elements.inverse(), delta.appState.inverse()); + } + + /** + * Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`. + */ + public static applyLatestChanges( + delta: StoreDelta, + elements: SceneElementsMap, + modifierOptions: "deleted" | "inserted", + ): StoreDelta { + return this.create( + delta.elements.applyLatestChanges(elements, modifierOptions), + delta.appState, + { + id: delta.id, + }, + ); + } + + public isEmpty() { + return this.elements.isEmpty() && this.appState.isEmpty(); + } +} + +/** + * Represents a snapshot of the captured or updated changes in the store, + * used for producing deltas and emitting `DurableStoreIncrement`s. + */ +export class StoreSnapshot { + private _lastChangedElementsHash: number = 0; + private _lastChangedAppStateHash: number = 0; + + private constructor( + public readonly elements: Map, + public readonly appState: ObservedAppState, + public readonly metadata: { + didElementsChange: boolean; + didAppStateChange: boolean; + isEmpty?: boolean; + } = { + didElementsChange: false, + didAppStateChange: false, + isEmpty: false, + }, + ) {} + + public static empty() { + return new StoreSnapshot(new Map(), getDefaultObservedAppState(), { + didElementsChange: false, + didAppStateChange: false, + isEmpty: true, + }); + } + + public getChangedElements(prevSnapshot: StoreSnapshot) { + const changedElements: Record = {}; + + for (const [id, nextElement] of this.elements.entries()) { + // Due to the structural clone inside `maybeClone`, we can perform just these reference checks + if (prevSnapshot.elements.get(id) !== nextElement) { + changedElements[id] = nextElement; + } + } + + return changedElements; + } + + public getChangedAppState( + prevSnapshot: StoreSnapshot, + ): Partial { + return Delta.getRightDifferences( + prevSnapshot.appState, + this.appState, + ).reduce( + (acc, key) => + Object.assign(acc, { + [key]: this.appState[key as keyof ObservedAppState], + }), + {} as Partial, + ); + } + + public isEmpty() { + return this.metadata.isEmpty; + } + + /** + * Efficiently clone the existing snapshot, only if we detected changes. + * + * @returns same instance if there are no changes detected, new instance otherwise. + */ + public maybeClone( + action: CaptureUpdateActionType, + elements: Map | undefined, + appState: AppState | ObservedAppState | undefined, + ) { + const options = { + shouldCompareHashes: false, + }; + + if (action === CaptureUpdateAction.EVENTUALLY) { + // actions that do not update the snapshot immediately, must be additionally checked for changes against the latest hash + // as we are always comparing against the latest snapshot, so they would emit elements or appState as changed on every component update + // instead of just the first time the elements or appState actually changed + options.shouldCompareHashes = true; + } + + const nextElementsSnapshot = this.maybeCreateElementsSnapshot( + elements, + options, + ); + const nextAppStateSnapshot = this.maybeCreateAppStateSnapshot( + appState, + options, + ); + + let didElementsChange = false; + let didAppStateChange = false; + + if (this.elements !== nextElementsSnapshot) { + didElementsChange = true; + } + + if (this.appState !== nextAppStateSnapshot) { + didAppStateChange = true; + } + + if (!didElementsChange && !didAppStateChange) { + return this; + } + + const snapshot = new StoreSnapshot( + nextElementsSnapshot, + nextAppStateSnapshot, + { + didElementsChange, + didAppStateChange, + }, + ); + + return snapshot; + } + + private maybeCreateAppStateSnapshot( + appState: AppState | ObservedAppState | undefined, + options: { + shouldCompareHashes: boolean; + } = { + shouldCompareHashes: false, + }, + ) { + if (!appState) { + return this.appState; + } + + // Not watching over everything from the app state, just the relevant props + const nextAppStateSnapshot = !isObservedAppState(appState) + ? getObservedAppState(appState) + : appState; + + const changedAppState = this.detectChangedAppState( + nextAppStateSnapshot, + options, + ); + + if (!changedAppState) { + return this.appState; + } + + return nextAppStateSnapshot; + } + + private maybeCreateElementsSnapshot( + elements: Map | undefined, + options: { + shouldCompareHashes: boolean; + } = { + shouldCompareHashes: false, + }, + ) { + if (!elements) { + return this.elements; + } + + const changedElements = this.detectChangedElements(elements, options); + + if (!changedElements?.size) { + return this.elements; + } + + const elementsSnapshot = this.createElementsSnapshot(changedElements); + return elementsSnapshot; + } + + private detectChangedAppState( + nextObservedAppState: ObservedAppState, + options: { + shouldCompareHashes: boolean; + } = { + shouldCompareHashes: false, + }, + ) { + if (this.appState === nextObservedAppState) { + return; + } + + const changedAppState = Delta.getRightDifferences( + this.appState, + nextObservedAppState, + ); + + if (!changedAppState.length) { + return; + } + + const changedAppStateHash = hashString( + JSON.stringify(nextObservedAppState), + ); + + if ( + options.shouldCompareHashes && + this._lastChangedAppStateHash === changedAppStateHash + ) { + return; + } + + this._lastChangedElementsHash = changedAppStateHash; + + return changedAppState; + } + + /** + * Detect if there any changed elements. + * + * NOTE: we shouldn't just use `sceneVersionNonce` instead, as we need to call this before the scene updates. + */ + private detectChangedElements( + nextElements: Map, + options: { + shouldCompareHashes: boolean; + } = { + shouldCompareHashes: false, + }, + ) { + if (this.elements === nextElements) { + return; + } + + const changedElements: Map = new Map(); + + for (const [id, prevElement] of this.elements) { + const nextElement = nextElements.get(id); + + if (!nextElement) { + // element was deleted + changedElements.set( + id, + newElementWith(prevElement, { isDeleted: true }), + ); + } + } + + for (const [id, nextElement] of nextElements) { + const prevElement = this.elements.get(id); + + if ( + !prevElement || // element was added + prevElement.version < nextElement.version // element was updated + ) { + changedElements.set(id, nextElement); + } + } + + if (!changedElements.size) { + return; + } + + const changedElementsHash = hashElementsVersion( + Array.from(changedElements.values()), + ); + + if ( + options.shouldCompareHashes && + this._lastChangedElementsHash === changedElementsHash + ) { + return; + } + + this._lastChangedElementsHash = changedElementsHash; + + return changedElements; + } + + /** + * Perform structural clone, deep cloning only elements that changed. + */ + private createElementsSnapshot( + changedElements: Map, + ) { + const clonedElements = new Map(); + + for (const [id, prevElement] of this.elements) { + // Clone previous elements, never delete, in case nextElements would be just a subset of previous elements + // i.e. during collab, persist or whenenever isDeleted elements get cleared + clonedElements.set(id, prevElement); + } + + for (const [id, changedElement] of changedElements) { + clonedElements.set(id, deepCopyElement(changedElement)); + } + + return clonedElements; + } +} + +// hidden non-enumerable property for runtime checks +const hiddenObservedAppStateProp = "__observedAppState"; + +const getDefaultObservedAppState = (): ObservedAppState => { + return { + name: null, + editingGroupId: null, + viewBackgroundColor: COLOR_PALETTE.white, + selectedElementIds: {}, + selectedGroupIds: {}, + editingLinearElementId: null, + selectedLinearElementId: null, + croppingElementId: null, + }; +}; + +export const getObservedAppState = (appState: AppState): ObservedAppState => { + const observedAppState = { + name: appState.name, + editingGroupId: appState.editingGroupId, + viewBackgroundColor: appState.viewBackgroundColor, + selectedElementIds: appState.selectedElementIds, + selectedGroupIds: appState.selectedGroupIds, + editingLinearElementId: appState.editingLinearElement?.elementId || null, + selectedLinearElementId: appState.selectedLinearElement?.elementId || null, + croppingElementId: appState.croppingElementId, + }; + + Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, { + value: true, + enumerable: false, + }); + + return observedAppState; +}; + +const isObservedAppState = ( + appState: AppState | ObservedAppState, +): appState is ObservedAppState => + !!Reflect.get(appState, hiddenObservedAppStateProp); diff --git a/packages/excalidraw/actions/actionAddToLibrary.ts b/packages/excalidraw/actions/actionAddToLibrary.ts index 9216e52c23..cb45f64d68 100644 --- a/packages/excalidraw/actions/actionAddToLibrary.ts +++ b/packages/excalidraw/actions/actionAddToLibrary.ts @@ -1,8 +1,9 @@ import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common"; import { deepCopyElement } from "@excalidraw/element/duplicate"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { t } from "../i18n"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionAlign.tsx b/packages/excalidraw/actions/actionAlign.tsx index 0ef938c67f..918bdd8f48 100644 --- a/packages/excalidraw/actions/actionAlign.tsx +++ b/packages/excalidraw/actions/actionAlign.tsx @@ -8,6 +8,8 @@ import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common"; import { alignElements } from "@excalidraw/element/align"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { Alignment } from "@excalidraw/element/align"; @@ -25,7 +27,6 @@ import { import { t } from "../i18n"; import { isSomeElementSelected } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index c7843656cf..c740d6e906 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -33,6 +33,8 @@ import { syncMovedIndices } from "@excalidraw/element/fractionalIndex"; import { newElement } from "@excalidraw/element/newElement"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawElement, ExcalidrawLinearElement, @@ -44,8 +46,6 @@ import type { Mutable } from "@excalidraw/common/utility-types"; import type { Radians } from "@excalidraw/math"; -import { CaptureUpdateAction } from "../store"; - import { register } from "./register"; import type { AppState } from "../types"; diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index a8bd56e820..7d6bb6aad8 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -17,6 +17,8 @@ import { getNonDeletedElements } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element/mutateElement"; import { getCommonBounds, type SceneBounds } from "@excalidraw/element/bounds"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawElement } from "@excalidraw/element/types"; import { @@ -44,7 +46,6 @@ import { t } from "../i18n"; import { getNormalizedZoom } from "../scene"; import { centerScrollOn } from "../scene/scroll"; import { getStateForZoom } from "../scene/zoom"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index 9de6d70f47..2494595a86 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -3,6 +3,8 @@ import { getTextFromElements } from "@excalidraw/element/textElement"; import { CODES, KEYS, isFirefox } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { copyTextToSystemClipboard, copyToClipboard, @@ -15,8 +17,6 @@ import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons"; import { exportCanvas, prepareElementsForExport } from "../data/index"; import { t } from "../i18n"; -import { CaptureUpdateAction } from "../store"; - import { actionDeleteSelected } from "./actionDeleteSelected"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionCropEditor.tsx b/packages/excalidraw/actions/actionCropEditor.tsx index 1a7b6da696..b6e801785d 100644 --- a/packages/excalidraw/actions/actionCropEditor.tsx +++ b/packages/excalidraw/actions/actionCropEditor.tsx @@ -1,11 +1,12 @@ import { isImageElement } from "@excalidraw/element/typeChecks"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawImageElement } from "@excalidraw/element/types"; import { ToolButton } from "../components/ToolButton"; import { cropIcon } from "../components/icons"; import { t } from "../i18n"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index e183d05f49..696563ad75 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -17,11 +17,12 @@ import { selectGroupsForSelectedElements, } from "@excalidraw/element/groups"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawElement } from "@excalidraw/element/types"; import { t } from "../i18n"; import { getSelectedElements, isSomeElementSelected } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { TrashIcon } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; diff --git a/packages/excalidraw/actions/actionDistribute.tsx b/packages/excalidraw/actions/actionDistribute.tsx index 9f05ab6bfa..ab964d3b3c 100644 --- a/packages/excalidraw/actions/actionDistribute.tsx +++ b/packages/excalidraw/actions/actionDistribute.tsx @@ -8,6 +8,8 @@ import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/fra import { distributeElements } from "@excalidraw/element/distribute"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { Distribution } from "@excalidraw/element/distribute"; @@ -21,7 +23,6 @@ import { import { t } from "../i18n"; import { isSomeElementSelected } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index 034edf5438..882f8716a5 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -18,12 +18,13 @@ import { syncMovedIndices } from "@excalidraw/element/fractionalIndex"; import { duplicateElements } from "@excalidraw/element/duplicate"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { ToolButton } from "../components/ToolButton"; import { DuplicateIcon } from "../components/icons"; import { t } from "../i18n"; import { isSomeElementSelected } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionElementLink.ts b/packages/excalidraw/actions/actionElementLink.ts index 24ea8bbd65..ad8d01687f 100644 --- a/packages/excalidraw/actions/actionElementLink.ts +++ b/packages/excalidraw/actions/actionElementLink.ts @@ -4,11 +4,12 @@ import { getLinkIdAndTypeFromSelection, } from "@excalidraw/element/elementLink"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { copyTextToSystemClipboard } from "../clipboard"; import { copyIcon, elementLinkIcon } from "../components/icons"; import { t } from "../i18n"; import { getSelectedElements } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionElementLock.ts b/packages/excalidraw/actions/actionElementLock.ts index 6bc238a59b..0e97f19551 100644 --- a/packages/excalidraw/actions/actionElementLock.ts +++ b/packages/excalidraw/actions/actionElementLock.ts @@ -4,12 +4,13 @@ import { newElementWith } from "@excalidraw/element/mutateElement"; import { isFrameLikeElement } from "@excalidraw/element/typeChecks"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawElement } from "@excalidraw/element/types"; import { LockedIcon, UnlockedIcon } from "../components/icons"; import { getSelectedElements } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionEmbeddable.ts b/packages/excalidraw/actions/actionEmbeddable.ts index 5566522407..987b2b45ad 100644 --- a/packages/excalidraw/actions/actionEmbeddable.ts +++ b/packages/excalidraw/actions/actionEmbeddable.ts @@ -1,7 +1,8 @@ import { updateActiveTool } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { setCursorForShape } from "../cursor"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx index 8fcaea21bb..f8a9dca82a 100644 --- a/packages/excalidraw/actions/actionExport.tsx +++ b/packages/excalidraw/actions/actionExport.tsx @@ -7,6 +7,8 @@ import { import { getNonDeletedElements } from "@excalidraw/element"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { Theme } from "@excalidraw/element/types"; import { useDevice } from "../components/App"; @@ -24,7 +26,6 @@ import { resaveAsImageWithScene } from "../data/resave"; import { t } from "../i18n"; import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getExportSize } from "../scene/export"; -import { CaptureUpdateAction } from "../store"; import "../components/ToolIcon.scss"; diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 22638ee917..6ddb8ab52f 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -16,11 +16,12 @@ import { isPathALoop } from "@excalidraw/element/shapes"; import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { t } from "../i18n"; import { resetCursor } from "../cursor"; import { done } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index becc8a976d..f6c4f0c712 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -15,6 +15,8 @@ import { import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame"; import { CODES, KEYS, arrayToMap } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawArrowElement, ExcalidrawElbowArrowElement, @@ -24,7 +26,6 @@ import type { } from "@excalidraw/element/types"; import { getSelectedElements } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { flipHorizontal, flipVertical } from "../components/icons"; diff --git a/packages/excalidraw/actions/actionFrame.ts b/packages/excalidraw/actions/actionFrame.ts index 7882d26f6f..f5e91fd938 100644 --- a/packages/excalidraw/actions/actionFrame.ts +++ b/packages/excalidraw/actions/actionFrame.ts @@ -14,12 +14,13 @@ import { getElementsInGroup } from "@excalidraw/element/groups"; import { getCommonBounds } from "@excalidraw/element/bounds"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawElement } from "@excalidraw/element/types"; import { setCursorForShape } from "../cursor"; import { frameToolIcon } from "../components/icons"; import { getSelectedElements } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionGroup.tsx b/packages/excalidraw/actions/actionGroup.tsx index 6b47ef969d..de3f6b266c 100644 --- a/packages/excalidraw/actions/actionGroup.tsx +++ b/packages/excalidraw/actions/actionGroup.tsx @@ -28,6 +28,8 @@ import { import { syncMovedIndices } from "@excalidraw/element/fractionalIndex"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawElement, ExcalidrawTextElement, @@ -40,7 +42,6 @@ import { UngroupIcon, GroupIcon } from "../components/icons"; import { t } from "../i18n"; import { isSomeElementSelected } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx index a0dfb85df8..796601433e 100644 --- a/packages/excalidraw/actions/actionHistory.tsx +++ b/packages/excalidraw/actions/actionHistory.tsx @@ -1,5 +1,7 @@ import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { SceneElementsMap } from "@excalidraw/element/types"; import { ToolButton } from "../components/ToolButton"; @@ -7,10 +9,8 @@ import { UndoIcon, RedoIcon } from "../components/icons"; import { HistoryChangedEvent } from "../history"; import { useEmitter } from "../hooks/useEmitter"; import { t } from "../i18n"; -import { CaptureUpdateAction } from "../store"; import type { History } from "../history"; -import type { Store } from "../store"; import type { AppClassProperties, AppState } from "../types"; import type { Action, ActionResult } from "./types"; @@ -47,9 +47,9 @@ const executeHistoryAction = ( return { captureUpdate: CaptureUpdateAction.EVENTUALLY }; }; -type ActionCreator = (history: History, store: Store) => Action; +type ActionCreator = (history: History) => Action; -export const createUndoAction: ActionCreator = (history, store) => ({ +export const createUndoAction: ActionCreator = (history) => ({ name: "undo", label: "buttons.undo", icon: UndoIcon, @@ -57,11 +57,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({ viewMode: false, perform: (elements, appState, value, app) => executeHistoryAction(app, appState, () => - history.undo( - arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` - appState, - store.snapshot, - ), + history.undo(arrayToMap(elements) as SceneElementsMap, appState), ), keyTest: (event) => event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey, @@ -88,19 +84,15 @@ export const createUndoAction: ActionCreator = (history, store) => ({ }, }); -export const createRedoAction: ActionCreator = (history, store) => ({ +export const createRedoAction: ActionCreator = (history) => ({ name: "redo", label: "buttons.redo", icon: RedoIcon, trackEvent: { category: "history" }, viewMode: false, - perform: (elements, appState, _, app) => + perform: (elements, appState, __, app) => executeHistoryAction(app, appState, () => - history.redo( - arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` - appState, - store.snapshot, - ), + history.redo(arrayToMap(elements) as SceneElementsMap, appState), ), keyTest: (event) => (event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) || diff --git a/packages/excalidraw/actions/actionLinearEditor.tsx b/packages/excalidraw/actions/actionLinearEditor.tsx index 1645554bf7..1b122187f9 100644 --- a/packages/excalidraw/actions/actionLinearEditor.tsx +++ b/packages/excalidraw/actions/actionLinearEditor.tsx @@ -4,6 +4,8 @@ import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks"; import { arrayToMap } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawLinearElement } from "@excalidraw/element/types"; import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"; @@ -11,7 +13,6 @@ import { ToolButton } from "../components/ToolButton"; import { lineEditorIcon } from "../components/icons"; import { t } from "../i18n"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionLink.tsx b/packages/excalidraw/actions/actionLink.tsx index 71426267d1..d7a5ca7d24 100644 --- a/packages/excalidraw/actions/actionLink.tsx +++ b/packages/excalidraw/actions/actionLink.tsx @@ -2,13 +2,14 @@ import { isEmbeddableElement } from "@excalidraw/element/typeChecks"; import { KEYS, getShortcutKey } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { ToolButton } from "../components/ToolButton"; import { getContextMenuLabel } from "../components/hyperlink/Hyperlink"; import { LinkIcon } from "../components/icons"; import { t } from "../i18n"; import { getSelectedElements } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionMenu.tsx b/packages/excalidraw/actions/actionMenu.tsx index 67863e0208..8cdc489b44 100644 --- a/packages/excalidraw/actions/actionMenu.tsx +++ b/packages/excalidraw/actions/actionMenu.tsx @@ -4,12 +4,12 @@ import { getNonDeletedElements } from "@excalidraw/element"; import { showSelectedShapeActions } from "@excalidraw/element/showSelectedShapeActions"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { ToolButton } from "../components/ToolButton"; import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons"; import { t } from "../i18n"; -import { CaptureUpdateAction } from "../store"; - import { register } from "./register"; export const actionToggleCanvasMenu = register({ diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx index 7383868391..637df0450c 100644 --- a/packages/excalidraw/actions/actionNavigate.tsx +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -1,5 +1,7 @@ import clsx from "clsx"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { getClientColor } from "../clients"; import { Avatar } from "../components/Avatar"; import { @@ -8,7 +10,6 @@ import { microphoneMutedIcon, } from "../components/icons"; import { t } from "../i18n"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index df07960aff..8f1bfead75 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -54,6 +54,8 @@ import { hasStrokeColor } from "@excalidraw/element/comparisons"; import { updateElbowArrowPoints } from "@excalidraw/element/elbowArrow"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { LocalPoint } from "@excalidraw/math"; import type { @@ -70,6 +72,8 @@ import type { import type Scene from "@excalidraw/element/Scene"; +import type { CaptureUpdateActionType } from "@excalidraw/element/store"; + import { trackEvent } from "../analytics"; import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ColorPicker } from "../components/ColorPicker/ColorPicker"; @@ -131,11 +135,9 @@ import { getTargetElements, isSomeElementSelected, } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; -import type { CaptureUpdateActionType } from "../store"; import type { AppClassProperties, AppState, Primitive } from "../types"; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; diff --git a/packages/excalidraw/actions/actionSelectAll.ts b/packages/excalidraw/actions/actionSelectAll.ts index ea13636b74..9386bc9a15 100644 --- a/packages/excalidraw/actions/actionSelectAll.ts +++ b/packages/excalidraw/actions/actionSelectAll.ts @@ -6,9 +6,9 @@ import { arrayToMap, KEYS } from "@excalidraw/common"; import { selectGroupsForSelectedElements } from "@excalidraw/element/groups"; -import type { ExcalidrawElement } from "@excalidraw/element/types"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; -import { CaptureUpdateAction } from "../store"; +import type { ExcalidrawElement } from "@excalidraw/element/types"; import { selectAllIcon } from "../components/icons"; diff --git a/packages/excalidraw/actions/actionStyles.ts b/packages/excalidraw/actions/actionStyles.ts index 08b32e227f..f80a56990a 100644 --- a/packages/excalidraw/actions/actionStyles.ts +++ b/packages/excalidraw/actions/actionStyles.ts @@ -24,13 +24,14 @@ import { redrawTextBoundingBox, } from "@excalidraw/element/textElement"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ExcalidrawTextElement } from "@excalidraw/element/types"; import { paintIcon } from "../components/icons"; import { t } from "../i18n"; import { getSelectedElements } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionTextAutoResize.ts b/packages/excalidraw/actions/actionTextAutoResize.ts index 4a36cab408..15af366581 100644 --- a/packages/excalidraw/actions/actionTextAutoResize.ts +++ b/packages/excalidraw/actions/actionTextAutoResize.ts @@ -5,8 +5,9 @@ import { measureText } from "@excalidraw/element/textMeasurements"; import { isTextElement } from "@excalidraw/element/typeChecks"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { getSelectedElements } from "../scene"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionToggleGridMode.tsx b/packages/excalidraw/actions/actionToggleGridMode.tsx index 9415051f38..543485d8ad 100644 --- a/packages/excalidraw/actions/actionToggleGridMode.tsx +++ b/packages/excalidraw/actions/actionToggleGridMode.tsx @@ -1,7 +1,8 @@ import { CODES, KEYS } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { gridIcon } from "../components/icons"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx b/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx index ba092bff8b..1eef483aa1 100644 --- a/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx +++ b/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx @@ -1,7 +1,8 @@ import { CODES, KEYS } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { magnetIcon } from "../components/icons"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionToggleSearchMenu.ts b/packages/excalidraw/actions/actionToggleSearchMenu.ts index ce384fc665..b7821bce48 100644 --- a/packages/excalidraw/actions/actionToggleSearchMenu.ts +++ b/packages/excalidraw/actions/actionToggleSearchMenu.ts @@ -5,8 +5,9 @@ import { DEFAULT_SIDEBAR, } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { searchIcon } from "../components/icons"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionToggleStats.tsx b/packages/excalidraw/actions/actionToggleStats.tsx index ffa812e960..d044c01fb2 100644 --- a/packages/excalidraw/actions/actionToggleStats.tsx +++ b/packages/excalidraw/actions/actionToggleStats.tsx @@ -1,7 +1,8 @@ import { CODES, KEYS } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { abacusIcon } from "../components/icons"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionToggleViewMode.tsx b/packages/excalidraw/actions/actionToggleViewMode.tsx index e42a7a102d..f511ec6199 100644 --- a/packages/excalidraw/actions/actionToggleViewMode.tsx +++ b/packages/excalidraw/actions/actionToggleViewMode.tsx @@ -1,7 +1,8 @@ import { CODES, KEYS } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { eyeIcon } from "../components/icons"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionToggleZenMode.tsx b/packages/excalidraw/actions/actionToggleZenMode.tsx index e56e02ca76..a9dc8dd1ff 100644 --- a/packages/excalidraw/actions/actionToggleZenMode.tsx +++ b/packages/excalidraw/actions/actionToggleZenMode.tsx @@ -1,7 +1,8 @@ import { CODES, KEYS } from "@excalidraw/common"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { coffeeIcon } from "../components/icons"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/actionZindex.tsx b/packages/excalidraw/actions/actionZindex.tsx index 8eb5a50f27..753e423213 100644 --- a/packages/excalidraw/actions/actionZindex.tsx +++ b/packages/excalidraw/actions/actionZindex.tsx @@ -7,6 +7,8 @@ import { moveAllRight, } from "@excalidraw/element/zindex"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import { BringForwardIcon, BringToFrontIcon, @@ -14,7 +16,6 @@ import { SendToBackIcon, } from "../components/icons"; import { t } from "../i18n"; -import { CaptureUpdateAction } from "../store"; import { register } from "./register"; diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index c4a4d2cce5..a857be2f18 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -3,7 +3,8 @@ import type { OrderedExcalidrawElement, } from "@excalidraw/element/types"; -import type { CaptureUpdateActionType } from "../store"; +import type { CaptureUpdateActionType } from "@excalidraw/element/store"; + import type { AppClassProperties, AppState, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index ddb071981f..fd28fb951c 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -101,6 +101,7 @@ import { type EXPORT_IMAGE_TYPES, randomInteger, CLASSES, + Emitter, } from "@excalidraw/common"; import { @@ -303,6 +304,12 @@ import { isNonDeletedElement } from "@excalidraw/element"; import Scene from "@excalidraw/element/Scene"; +import { + Store, + CaptureUpdateAction, + StoreIncrement, +} from "@excalidraw/element/store"; + import type { ElementUpdate } from "@excalidraw/element/mutateElement"; import type { LocalPoint, Radians } from "@excalidraw/math"; @@ -454,9 +461,7 @@ import { resetCursor, setCursorForShape, } from "../cursor"; -import { Emitter } from "../emitter"; import { ElementCanvasButtons } from "../components/ElementCanvasButtons"; -import { Store, CaptureUpdateAction } from "../store"; import { LaserTrails } from "../laser-trails"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { textWysiwyg } from "../wysiwyg/textWysiwyg"; @@ -762,7 +767,7 @@ class App extends React.Component { this.visibleElements = []; this.store = new Store(); - this.history = new History(); + this.history = new History(this.store); if (excalidrawAPI) { const api: ExcalidrawImperativeAPI = { @@ -772,6 +777,7 @@ class App extends React.Component { addFiles: this.addFiles, resetScene: this.resetScene, getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted, + store: this.store, history: { clear: this.resetHistory, }, @@ -792,6 +798,7 @@ class App extends React.Component { updateFrameRendering: this.updateFrameRendering, toggleSidebar: this.toggleSidebar, onChange: (cb) => this.onChangeEmitter.on(cb), + onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb), onPointerDown: (cb) => this.onPointerDownEmitter.on(cb), onPointerUp: (cb) => this.onPointerUpEmitter.on(cb), onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb), @@ -810,15 +817,11 @@ class App extends React.Component { }; this.fonts = new Fonts(this.scene); - this.history = new History(); + this.history = new History(this.store); this.actionManager.registerAll(actions); - this.actionManager.registerAction( - createUndoAction(this.history, this.store), - ); - this.actionManager.registerAction( - createRedoAction(this.history, this.store), - ); + this.actionManager.registerAction(createUndoAction(this.history)); + this.actionManager.registerAction(createRedoAction(this.history)); } updateEditorAtom = ( @@ -1899,6 +1902,10 @@ class App extends React.Component { return this.scene.getElementsIncludingDeleted(); }; + public getSceneElementsMapIncludingDeleted = () => { + return this.scene.getElementsMapIncludingDeleted(); + }; + public getSceneElements = () => { return this.scene.getNonDeletedElements(); }; @@ -2215,11 +2222,7 @@ class App extends React.Component { return; } - if (actionResult.captureUpdate === CaptureUpdateAction.NEVER) { - this.store.shouldUpdateSnapshot(); - } else if (actionResult.captureUpdate === CaptureUpdateAction.IMMEDIATELY) { - this.store.shouldCaptureIncrement(); - } + this.store.scheduleAction(actionResult.captureUpdate); let didUpdate = false; @@ -2292,10 +2295,7 @@ class App extends React.Component { didUpdate = true; } - if ( - !didUpdate && - actionResult.captureUpdate !== CaptureUpdateAction.EVENTUALLY - ) { + if (!didUpdate) { this.scene.triggerUpdate(); } }); @@ -2548,7 +2548,11 @@ class App extends React.Component { } this.store.onStoreIncrementEmitter.on((increment) => { - this.history.record(increment.elementsChange, increment.appStateChange); + if (StoreIncrement.isDurable(increment)) { + this.history.record(increment.delta); + } + + this.props.onIncrement?.(increment); }); this.scene.onUpdate(this.triggerRender); @@ -2903,7 +2907,7 @@ class App extends React.Component { this.state.editingLinearElement && !this.state.selectedElementIds[this.state.editingLinearElement.elementId] ) { - // defer so that the shouldCaptureIncrement flag isn't reset via current update + // defer so that the scheduleCapture flag isn't reset via current update setTimeout(() => { // execute only if the condition still holds when the deferred callback // executes (it can be scheduled multiple times depending on how @@ -3358,7 +3362,7 @@ class App extends React.Component { this.addMissingFiles(opts.files); } - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); const nextElementsToSelect = excludeElementsInFramesFromSelection(duplicatedElements); @@ -3619,7 +3623,7 @@ class App extends React.Component { PLAIN_PASTE_TOAST_SHOWN = true; } - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); } setAppState: React.Component["setState"] = ( @@ -3975,51 +3979,42 @@ class App extends React.Component { */ captureUpdate?: SceneData["captureUpdate"]; }) => { - const nextElements = syncInvalidIndices(sceneData.elements ?? []); + const { elements, appState, collaborators, captureUpdate } = sceneData; - if ( - sceneData.captureUpdate && - sceneData.captureUpdate !== CaptureUpdateAction.EVENTUALLY - ) { + const nextElements = elements ? syncInvalidIndices(elements) : undefined; + + if (captureUpdate) { const prevCommittedAppState = this.store.snapshot.appState; const prevCommittedElements = this.store.snapshot.elements; - const nextCommittedAppState = sceneData.appState - ? Object.assign({}, prevCommittedAppState, sceneData.appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState` + const nextCommittedAppState = appState + ? Object.assign({}, prevCommittedAppState, appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState` : prevCommittedAppState; - const nextCommittedElements = sceneData.elements + const nextCommittedElements = elements ? this.store.filterUncomittedElements( - this.scene.getElementsMapIncludingDeleted(), // Only used to detect uncomitted local elements - arrayToMap(nextElements), // We expect all (already reconciled) elements + this.scene.getElementsMapIncludingDeleted(), // only used to detect uncomitted local elements + arrayToMap(nextElements ?? []), // we expect all (already reconciled) elements ) : prevCommittedElements; - // WARN: store action always performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter - // do NOT schedule store actions (execute after re-render), as it might cause unexpected concurrency issues if not handled well - if (sceneData.captureUpdate === CaptureUpdateAction.IMMEDIATELY) { - this.store.captureIncrement( - nextCommittedElements, - nextCommittedAppState, - ); - } else if (sceneData.captureUpdate === CaptureUpdateAction.NEVER) { - this.store.updateSnapshot( - nextCommittedElements, - nextCommittedAppState, - ); - } + this.store.scheduleMicroAction( + captureUpdate, + nextCommittedElements, + nextCommittedAppState, + ); } - if (sceneData.appState) { - this.setState(sceneData.appState); + if (appState) { + this.setState(appState); } - if (sceneData.elements) { + if (nextElements) { this.scene.replaceAllElements(nextElements); } - if (sceneData.collaborators) { - this.setState({ collaborators: sceneData.collaborators }); + if (collaborators) { + this.setState({ collaborators }); } }, ); @@ -4202,7 +4197,7 @@ class App extends React.Component { direction: event.shiftKey ? "left" : "right", }) ) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); } } if (conversionType) { @@ -4519,7 +4514,7 @@ class App extends React.Component { this.state.editingLinearElement.elementId !== selectedElements[0].id ) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); if (!isElbowArrow(selectedElement)) { this.setState({ editingLinearElement: new LinearElementEditor( @@ -4845,7 +4840,7 @@ class App extends React.Component { } as const; if (nextActiveTool.type === "freedraw") { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); } if (nextActiveTool.type === "lasso") { @@ -5062,7 +5057,7 @@ class App extends React.Component { ]); } if (!isDeleted || isExistingElement) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); } flushSync(() => { @@ -5475,7 +5470,7 @@ class App extends React.Component { }; private startImageCropping = (image: ExcalidrawImageElement) => { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); this.setState({ croppingElementId: image.id, }); @@ -5483,7 +5478,7 @@ class App extends React.Component { private finishImageCropping = () => { if (this.state.croppingElementId) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); this.setState({ croppingElementId: null, }); @@ -5518,7 +5513,7 @@ class App extends React.Component { selectedElements[0].id) && !isElbowArrow(selectedElements[0]) ) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); this.setState({ editingLinearElement: new LinearElementEditor( selectedElements[0], @@ -5546,7 +5541,7 @@ class App extends React.Component { : -1; if (midPoint && midPoint > -1) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); LinearElementEditor.deleteFixedSegment( selectedElements[0], this.scene, @@ -5608,7 +5603,7 @@ class App extends React.Component { getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds); if (selectedGroupId) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); this.setState((prevState) => ({ ...prevState, ...selectGroupsForSelectedElements( @@ -9131,7 +9126,7 @@ class App extends React.Component { if (isLinearElement(newElement)) { if (newElement!.points.length > 1) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); } const pointerCoords = viewportCoordsToSceneCoords( childEvent, @@ -9404,7 +9399,7 @@ class App extends React.Component { } if (resizingElement) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); } if (resizingElement && isInvisiblySmallElement(resizingElement)) { @@ -9744,7 +9739,7 @@ class App extends React.Component { this.state.selectedElementIds, ) ) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); } if ( @@ -9837,7 +9832,7 @@ class App extends React.Component { this.elementsPendingErasure = new Set(); if (didChange) { - this.store.shouldCaptureIncrement(); + this.store.scheduleCapture(); this.scene.replaceAllElements(elements); } }; @@ -10517,8 +10512,12 @@ class App extends React.Component { // restore the fractional indices by mutating elements syncInvalidIndices(elements.concat(ret.data.elements)); - // update the store snapshot for old elements, otherwise we would end up with duplicated fractional indices on undo - this.store.updateSnapshot(arrayToMap(elements), this.state); + // don't capture and only update the store snapshot for old elements, + // otherwise we would end up with duplicated fractional indices on undo + this.store.scheduleMicroAction( + CaptureUpdateAction.NEVER, + arrayToMap(elements), + ); this.setState({ isLoading: true }); this.syncActionResult({ diff --git a/packages/excalidraw/components/Stats/DragInput.tsx b/packages/excalidraw/components/Stats/DragInput.tsx index 6fdf909b24..208b48f6c2 100644 --- a/packages/excalidraw/components/Stats/DragInput.tsx +++ b/packages/excalidraw/components/Stats/DragInput.tsx @@ -5,11 +5,12 @@ import { EVENT, KEYS, cloneJSON } from "@excalidraw/common"; import { deepCopyElement } from "@excalidraw/element/duplicate"; +import { CaptureUpdateAction } from "@excalidraw/element/store"; + import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type Scene from "@excalidraw/element/Scene"; -import { CaptureUpdateAction } from "../../store"; import { useApp } from "../App"; import { InlineIcon } from "../InlineIcon"; diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts index 74252657e7..5a9b7fc150 100644 --- a/packages/excalidraw/data/library.ts +++ b/packages/excalidraw/data/library.ts @@ -14,6 +14,7 @@ import { resolvablePromise, toValidURL, Queue, + Emitter, } from "@excalidraw/common"; import { hashElementsVersion, hashString } from "@excalidraw/element"; @@ -26,7 +27,6 @@ import type { MaybePromise } from "@excalidraw/common/utility-types"; import { atom, editorJotaiStore } from "../editor-jotai"; -import { Emitter } from "../emitter"; import { AbortError } from "../errors"; import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg"; import { t } from "../i18n"; diff --git a/packages/excalidraw/history.ts b/packages/excalidraw/history.ts index 0481c84113..797ffc7bb4 100644 --- a/packages/excalidraw/history.ts +++ b/packages/excalidraw/history.ts @@ -1,12 +1,16 @@ +import { Emitter } from "@excalidraw/common"; + +import { + CaptureUpdateAction, + type Store, + StoreDelta, +} from "@excalidraw/element/store"; + import type { SceneElementsMap } from "@excalidraw/element/types"; -import { Emitter } from "./emitter"; - -import type { AppStateChange, ElementsChange } from "./change"; -import type { Snapshot } from "./store"; import type { AppState } from "./types"; -type HistoryStack = HistoryEntry[]; +class HistoryEntry extends StoreDelta {} export class HistoryChangedEvent { constructor( @@ -20,8 +24,8 @@ export class History { [HistoryChangedEvent] >(); - private readonly undoStack: HistoryStack = []; - private readonly redoStack: HistoryStack = []; + public readonly undoStack: HistoryEntry[] = []; + public readonly redoStack: HistoryEntry[] = []; public get isUndoStackEmpty() { return this.undoStack.length === 0; @@ -31,60 +35,52 @@ export class History { return this.redoStack.length === 0; } + constructor(private readonly store: Store) {} + public clear() { this.undoStack.length = 0; this.redoStack.length = 0; } /** - * Record a local change which will go into the history + * Record a non-empty local durable increment, which will go into the undo stack.. + * Do not re-record history entries, which were already pushed to undo / redo stack, as part of history action. */ - public record( - elementsChange: ElementsChange, - appStateChange: AppStateChange, - ) { - const entry = HistoryEntry.create(appStateChange, elementsChange); - - if (!entry.isEmpty()) { - // we have the latest changes, no need to `applyLatest`, which is done within `History.push` - this.undoStack.push(entry.inverse()); - - if (!entry.elementsChange.isEmpty()) { - // don't reset redo stack on local appState changes, - // as a simple click (unselect) could lead to losing all the redo entries - // only reset on non empty elements changes! - this.redoStack.length = 0; - } - - this.onHistoryChangedEmitter.trigger( - new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty), - ); + public record(delta: StoreDelta) { + if (delta.isEmpty() || delta instanceof HistoryEntry) { + return; } + + // construct history entry, so once it's emitted, it's not recorded again + const entry = HistoryEntry.inverse(delta); + + this.undoStack.push(entry); + + if (!entry.elements.isEmpty()) { + // don't reset redo stack on local appState changes, + // as a simple click (unselect) could lead to losing all the redo entries + // only reset on non empty elements changes! + this.redoStack.length = 0; + } + + this.onHistoryChangedEmitter.trigger( + new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty), + ); } - public undo( - elements: SceneElementsMap, - appState: AppState, - snapshot: Readonly, - ) { + public undo(elements: SceneElementsMap, appState: AppState) { return this.perform( elements, appState, - snapshot, () => History.pop(this.undoStack), (entry: HistoryEntry) => History.push(this.redoStack, entry, elements), ); } - public redo( - elements: SceneElementsMap, - appState: AppState, - snapshot: Readonly, - ) { + public redo(elements: SceneElementsMap, appState: AppState) { return this.perform( elements, appState, - snapshot, () => History.pop(this.redoStack), (entry: HistoryEntry) => History.push(this.undoStack, entry, elements), ); @@ -93,7 +89,6 @@ export class History { private perform( elements: SceneElementsMap, appState: AppState, - snapshot: Readonly, pop: () => HistoryEntry | null, push: (entry: HistoryEntry) => void, ): [SceneElementsMap, AppState] | void { @@ -111,10 +106,20 @@ export class History { // iterate through the history entries in case they result in no visible changes while (historyEntry) { try { + // creating iteration-scoped variables, so that we can use them in the unstable_scheduleCallback [nextElements, nextAppState, containsVisibleChange] = - historyEntry.applyTo(nextElements, nextAppState, snapshot); + this.store.applyDeltaTo(historyEntry, nextElements, nextAppState); + + // schedule immediate capture, so that it's emitted for the sync purposes + this.store.scheduleMicroAction( + CaptureUpdateAction.IMMEDIATELY, + nextElements, + nextAppState, + // create a new instance of the history entry, so that it's not mutated in the meantime + HistoryEntry.restore(historyEntry), + ); } finally { - // make sure to always push / pop, even if the increment is corrupted + // make sure to always push, even if the delta is corrupted push(historyEntry); } @@ -135,7 +140,7 @@ export class History { } } - private static pop(stack: HistoryStack): HistoryEntry | null { + private static pop(stack: HistoryEntry[]): HistoryEntry | null { if (!stack.length) { return null; } @@ -150,63 +155,17 @@ export class History { } private static push( - stack: HistoryStack, + stack: HistoryEntry[], entry: HistoryEntry, prevElements: SceneElementsMap, ) { - const updatedEntry = entry.inverse().applyLatestChanges(prevElements); + const inversedEntry = HistoryEntry.inverse(entry); + const updatedEntry = HistoryEntry.applyLatestChanges( + inversedEntry, + prevElements, + "inserted", + ); + return stack.push(updatedEntry); } } - -export class HistoryEntry { - private constructor( - public readonly appStateChange: AppStateChange, - public readonly elementsChange: ElementsChange, - ) {} - - public static create( - appStateChange: AppStateChange, - elementsChange: ElementsChange, - ) { - return new HistoryEntry(appStateChange, elementsChange); - } - - public inverse(): HistoryEntry { - return new HistoryEntry( - this.appStateChange.inverse(), - this.elementsChange.inverse(), - ); - } - - public applyTo( - elements: SceneElementsMap, - appState: AppState, - snapshot: Readonly, - ): [SceneElementsMap, AppState, boolean] { - const [nextElements, elementsContainVisibleChange] = - this.elementsChange.applyTo(elements, snapshot.elements); - - const [nextAppState, appStateContainsVisibleChange] = - this.appStateChange.applyTo(appState, nextElements); - - const appliedVisibleChanges = - elementsContainVisibleChange || appStateContainsVisibleChange; - - return [nextElements, nextAppState, appliedVisibleChanges]; - } - - /** - * Apply latest (remote) changes to the history entry, creates new instance of `HistoryEntry`. - */ - public applyLatestChanges(elements: SceneElementsMap): HistoryEntry { - const updatedElementsChange = - this.elementsChange.applyLatestChanges(elements); - - return HistoryEntry.create(this.appStateChange, updatedElementsChange); - } - - public isEmpty(): boolean { - return this.appStateChange.isEmpty() && this.elementsChange.isEmpty(); - } -} diff --git a/packages/excalidraw/hooks/useEmitter.ts b/packages/excalidraw/hooks/useEmitter.ts index eebbaaf306..3ecb24796c 100644 --- a/packages/excalidraw/hooks/useEmitter.ts +++ b/packages/excalidraw/hooks/useEmitter.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import type { Emitter } from "../emitter"; +import type { Emitter } from "@excalidraw/common"; export const useEmitter = ( emitter: Emitter<[TEvent]>, diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 17c59a1b50..07fbdfdbb7 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -23,6 +23,7 @@ polyfill(); const ExcalidrawBase = (props: ExcalidrawProps) => { const { onChange, + onIncrement, initialData, excalidrawAPI, isCollaborating = false, @@ -114,6 +115,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {