feat: multiplayer undo / redo (#7348)
parent
5211b003b8
commit
530617be90
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Emitter } from "../emitter";
|
||||
|
||||
export const useEmitter = <TEvent extends unknown>(
|
||||
emitter: Emitter<[TEvent]>,
|
||||
initialState: TEvent,
|
||||
) => {
|
||||
const [event, setEvent] = useState<TEvent>(initialState);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = emitter.on((event) => {
|
||||
setEvent(event);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [emitter]);
|
||||
|
||||
return event;
|
||||
};
|
@ -0,0 +1,332 @@
|
||||
import { getDefaultAppState } from "./appState";
|
||||
import { AppStateChange, ElementsChange } from "./change";
|
||||
import { newElementWith } from "./element/mutateElement";
|
||||
import { deepCopyElement } from "./element/newElement";
|
||||
import { OrderedExcalidrawElement } from "./element/types";
|
||||
import { Emitter } from "./emitter";
|
||||
import { AppState, ObservedAppState } from "./types";
|
||||
import { isShallowEqual } from "./utils";
|
||||
|
||||
export const getObservedAppState = (appState: AppState): ObservedAppState => {
|
||||
return {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
export const StoreAction = {
|
||||
NONE: "NONE",
|
||||
UPDATE: "UPDATE",
|
||||
CAPTURE: "CAPTURE",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Store which captures the observed changes and emits them as `StoreIncrementEvent` events.
|
||||
*
|
||||
* @experimental this interface is experimental and subject to change.
|
||||
*/
|
||||
export interface IStore {
|
||||
onStoreIncrementEmitter: Emitter<[StoreIncrementEvent]>;
|
||||
get snapshot(): Snapshot;
|
||||
set snapshot(snapshot: Snapshot);
|
||||
|
||||
/**
|
||||
* Use to schedule update of the snapshot, useful on updates for which we don't need to calculate increments (i.e. remote updates).
|
||||
*/
|
||||
shouldUpdateSnapshot(): void;
|
||||
|
||||
/**
|
||||
* Use to schedule calculation of a store increment on a next component update.
|
||||
*/
|
||||
shouldCaptureIncrement(): void;
|
||||
|
||||
/**
|
||||
* Capture changes to the `elements` and `appState` by calculating changes (based on a snapshot) and emitting resulting changes as a store increment.
|
||||
*
|
||||
* @emits StoreIncrementEvent
|
||||
*/
|
||||
capture(
|
||||
elements: Map<string, OrderedExcalidrawElement>,
|
||||
appState: AppState,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Clears the store instance.
|
||||
*/
|
||||
clear(): void;
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* Once we will be exchanging just store increments for all ephemerals, this could be deprecated.
|
||||
*/
|
||||
ignoreUncomittedElements(
|
||||
prevElements: Map<string, OrderedExcalidrawElement>,
|
||||
nextElements: Map<string, OrderedExcalidrawElement>,
|
||||
): Map<string, OrderedExcalidrawElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represent an increment to the Store.
|
||||
*/
|
||||
class StoreIncrementEvent {
|
||||
constructor(
|
||||
public readonly elementsChange: ElementsChange,
|
||||
public readonly appStateChange: AppStateChange,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class Store implements IStore {
|
||||
public readonly onStoreIncrementEmitter = new Emitter<
|
||||
[StoreIncrementEvent]
|
||||
>();
|
||||
|
||||
private calculatingIncrement: boolean = false;
|
||||
private updatingSnapshot: boolean = false;
|
||||
|
||||
private _snapshot = Snapshot.empty();
|
||||
|
||||
public get snapshot() {
|
||||
return this._snapshot;
|
||||
}
|
||||
|
||||
public set snapshot(snapshot: Snapshot) {
|
||||
this._snapshot = snapshot;
|
||||
}
|
||||
|
||||
public shouldUpdateSnapshot = () => {
|
||||
this.updatingSnapshot = true;
|
||||
};
|
||||
|
||||
// Suspicious that this is called so many places. Seems error-prone.
|
||||
public shouldCaptureIncrement = () => {
|
||||
this.calculatingIncrement = true;
|
||||
};
|
||||
|
||||
public capture = (
|
||||
elements: Map<string, OrderedExcalidrawElement>,
|
||||
appState: AppState,
|
||||
): void => {
|
||||
// Quick exit for irrelevant changes
|
||||
if (!this.calculatingIncrement && !this.updatingSnapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextSnapshot = this._snapshot.clone(elements, appState);
|
||||
|
||||
// Optimisation, don't continue if nothing has changed
|
||||
if (this._snapshot !== nextSnapshot) {
|
||||
// Calculate and record the changes based on the previous and next snapshot
|
||||
if (this.calculatingIncrement) {
|
||||
const elementsChange = nextSnapshot.meta.didElementsChange
|
||||
? ElementsChange.calculate(
|
||||
this._snapshot.elements,
|
||||
nextSnapshot.elements,
|
||||
)
|
||||
: ElementsChange.empty();
|
||||
|
||||
const appStateChange = nextSnapshot.meta.didAppStateChange
|
||||
? AppStateChange.calculate(
|
||||
this._snapshot.appState,
|
||||
nextSnapshot.appState,
|
||||
)
|
||||
: AppStateChange.empty();
|
||||
|
||||
if (!elementsChange.isEmpty() || !appStateChange.isEmpty()) {
|
||||
// Notify listeners with the increment
|
||||
this.onStoreIncrementEmitter.trigger(
|
||||
new StoreIncrementEvent(elementsChange, appStateChange),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the snapshot
|
||||
this._snapshot = nextSnapshot;
|
||||
}
|
||||
} finally {
|
||||
// Reset props
|
||||
this.updatingSnapshot = false;
|
||||
this.calculatingIncrement = false;
|
||||
}
|
||||
};
|
||||
|
||||
public ignoreUncomittedElements = (
|
||||
prevElements: Map<string, OrderedExcalidrawElement>,
|
||||
nextElements: Map<string, OrderedExcalidrawElement>,
|
||||
) => {
|
||||
for (const [id, prevElement] of prevElements.entries()) {
|
||||
const nextElement = nextElements.get(id);
|
||||
|
||||
if (!nextElement) {
|
||||
// Nothing to care about here, elements were 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 current local version
|
||||
nextElements.set(id, elementSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
return nextElements;
|
||||
};
|
||||
|
||||
public clear = (): void => {
|
||||
this._snapshot = Snapshot.empty();
|
||||
};
|
||||
}
|
||||
|
||||
export class Snapshot {
|
||||
private constructor(
|
||||
public readonly elements: Map<string, OrderedExcalidrawElement>,
|
||||
public readonly appState: ObservedAppState,
|
||||
public readonly meta: {
|
||||
didElementsChange: boolean;
|
||||
didAppStateChange: boolean;
|
||||
isEmpty?: boolean;
|
||||
} = {
|
||||
didElementsChange: false,
|
||||
didAppStateChange: false,
|
||||
isEmpty: false,
|
||||
},
|
||||
) {}
|
||||
|
||||
public static empty() {
|
||||
return new Snapshot(
|
||||
new Map(),
|
||||
getObservedAppState(getDefaultAppState() as AppState),
|
||||
{ didElementsChange: false, didAppStateChange: false, isEmpty: true },
|
||||
);
|
||||
}
|
||||
|
||||
public isEmpty() {
|
||||
return this.meta.isEmpty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Efficiently clone the existing snapshot.
|
||||
*
|
||||
* @returns same instance if there are no changes detected, new instance otherwise.
|
||||
*/
|
||||
public clone(
|
||||
elements: Map<string, OrderedExcalidrawElement>,
|
||||
appState: AppState,
|
||||
) {
|
||||
const didElementsChange = this.detectChangedElements(elements);
|
||||
|
||||
// Not watching over everything from app state, just the relevant props
|
||||
const nextAppStateSnapshot = getObservedAppState(appState);
|
||||
const didAppStateChange = this.detectChangedAppState(nextAppStateSnapshot);
|
||||
|
||||
// Nothing has changed, so there is no point of continuing further
|
||||
if (!didElementsChange && !didAppStateChange) {
|
||||
return this;
|
||||
}
|
||||
|
||||
// Clone only if there was really a change
|
||||
let nextElementsSnapshot = this.elements;
|
||||
if (didElementsChange) {
|
||||
nextElementsSnapshot = this.createElementsSnapshot(elements);
|
||||
}
|
||||
|
||||
const snapshot = new Snapshot(nextElementsSnapshot, nextAppStateSnapshot, {
|
||||
didElementsChange,
|
||||
didAppStateChange,
|
||||
});
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if there any changed elements.
|
||||
*
|
||||
* NOTE: we shouldn't use `sceneVersionNonce` instead, as we need to call this before the scene updates.
|
||||
*/
|
||||
private detectChangedElements(
|
||||
nextElements: Map<string, OrderedExcalidrawElement>,
|
||||
) {
|
||||
if (this.elements === nextElements) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.elements.size !== nextElements.size) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// loop from right to left as changes are likelier to happen on new elements
|
||||
const keys = Array.from(nextElements.keys());
|
||||
|
||||
for (let i = keys.length - 1; i >= 0; i--) {
|
||||
const prev = this.elements.get(keys[i]);
|
||||
const next = nextElements.get(keys[i]);
|
||||
if (
|
||||
!prev ||
|
||||
!next ||
|
||||
prev.id !== next.id ||
|
||||
prev.versionNonce !== next.versionNonce
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private detectChangedAppState(observedAppState: ObservedAppState) {
|
||||
return !isShallowEqual(this.appState, observedAppState, {
|
||||
selectedElementIds: isShallowEqual,
|
||||
selectedGroupIds: isShallowEqual,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform structural clone, cloning only elements that changed.
|
||||
*/
|
||||
private createElementsSnapshot(
|
||||
nextElements: Map<string, OrderedExcalidrawElement>,
|
||||
) {
|
||||
const clonedElements = new Map();
|
||||
|
||||
for (const [id, prevElement] of this.elements.entries()) {
|
||||
// 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
|
||||
if (!nextElements.get(id)) {
|
||||
// When we cannot find the prev element in the next elements, we mark it as deleted
|
||||
clonedElements.set(
|
||||
id,
|
||||
newElementWith(prevElement, { isDeleted: true }),
|
||||
);
|
||||
} else {
|
||||
clonedElements.set(id, prevElement);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, nextElement] of nextElements.entries()) {
|
||||
const prevElement = clonedElements.get(id);
|
||||
|
||||
// At this point our elements are reconcilled already, meaning the next element is always newer
|
||||
if (
|
||||
!prevElement || // element was added
|
||||
(prevElement && prevElement.versionNonce !== nextElement.versionNonce) // element was updated
|
||||
) {
|
||||
clonedElements.set(id, deepCopyElement(nextElement));
|
||||
}
|
||||
}
|
||||
|
||||
return clonedElements;
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue