mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-09-08 10:07:04 +02:00
250 lines
6.7 KiB
TypeScript
250 lines
6.7 KiB
TypeScript
import { Emitter } from "@excalidraw/common";
|
|
|
|
import {
|
|
CaptureUpdateAction,
|
|
StoreChange,
|
|
StoreDelta,
|
|
} from "@excalidraw/element";
|
|
|
|
import type { StoreSnapshot, Store } from "@excalidraw/element";
|
|
|
|
import type { SceneElementsMap } from "@excalidraw/element/types";
|
|
|
|
import type { AppState } from "./types";
|
|
|
|
export class HistoryDelta extends StoreDelta {
|
|
/**
|
|
* Apply the delta to the passed elements and appState, does not modify the snapshot.
|
|
*/
|
|
public applyTo(
|
|
elements: SceneElementsMap,
|
|
appState: AppState,
|
|
snapshot: StoreSnapshot,
|
|
): [SceneElementsMap, AppState, boolean] {
|
|
const [nextElements, elementsContainVisibleChange] = this.elements.applyTo(
|
|
elements,
|
|
// used to fallback into local snapshot in case we couldn't apply the delta
|
|
// due to a missing (force deleted) elements in the scene
|
|
snapshot.elements,
|
|
// we don't want to apply the `version` and `versionNonce` properties for history
|
|
// as we always need to end up with a new version due to collaboration,
|
|
// approaching each undo / redo as a new user action
|
|
{
|
|
excludedProperties: new Set(["version", "versionNonce"]),
|
|
},
|
|
);
|
|
|
|
const [nextAppState, appStateContainsVisibleChange] = this.appState.applyTo(
|
|
appState,
|
|
nextElements,
|
|
);
|
|
|
|
const appliedVisibleChanges =
|
|
elementsContainVisibleChange || appStateContainsVisibleChange;
|
|
|
|
return [nextElements, nextAppState, appliedVisibleChanges];
|
|
}
|
|
|
|
/**
|
|
* Overriding once to avoid type casting everywhere.
|
|
*/
|
|
public static override calculate(
|
|
prevSnapshot: StoreSnapshot,
|
|
nextSnapshot: StoreSnapshot,
|
|
) {
|
|
return super.calculate(prevSnapshot, nextSnapshot) as HistoryDelta;
|
|
}
|
|
|
|
/**
|
|
* Overriding once to avoid type casting everywhere.
|
|
*/
|
|
public static override inverse(delta: StoreDelta): HistoryDelta {
|
|
return super.inverse(delta) as HistoryDelta;
|
|
}
|
|
|
|
/**
|
|
* Overriding once to avoid type casting everywhere.
|
|
*/
|
|
public static override applyLatestChanges(
|
|
delta: StoreDelta,
|
|
prevElements: SceneElementsMap,
|
|
nextElements: SceneElementsMap,
|
|
modifierOptions?: "deleted" | "inserted",
|
|
) {
|
|
return super.applyLatestChanges(
|
|
delta,
|
|
prevElements,
|
|
nextElements,
|
|
modifierOptions,
|
|
) as HistoryDelta;
|
|
}
|
|
}
|
|
|
|
export class HistoryChangedEvent {
|
|
constructor(
|
|
public readonly isUndoStackEmpty: boolean = true,
|
|
public readonly isRedoStackEmpty: boolean = true,
|
|
) {}
|
|
}
|
|
|
|
export class History {
|
|
public readonly onHistoryChangedEmitter = new Emitter<
|
|
[HistoryChangedEvent]
|
|
>();
|
|
|
|
public readonly undoStack: HistoryDelta[] = [];
|
|
public readonly redoStack: HistoryDelta[] = [];
|
|
|
|
public get isUndoStackEmpty() {
|
|
return this.undoStack.length === 0;
|
|
}
|
|
|
|
public get isRedoStackEmpty() {
|
|
return this.redoStack.length === 0;
|
|
}
|
|
|
|
constructor(private readonly store: Store) {}
|
|
|
|
public clear() {
|
|
this.undoStack.length = 0;
|
|
this.redoStack.length = 0;
|
|
}
|
|
|
|
/**
|
|
* 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(delta: StoreDelta) {
|
|
if (delta.isEmpty() || delta instanceof HistoryDelta) {
|
|
return;
|
|
}
|
|
|
|
// construct history entry, so once it's emitted, it's not recorded again
|
|
const historyDelta = HistoryDelta.inverse(delta);
|
|
|
|
this.undoStack.push(historyDelta);
|
|
|
|
if (!historyDelta.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) {
|
|
return this.perform(
|
|
elements,
|
|
appState,
|
|
() => History.pop(this.undoStack),
|
|
(entry: HistoryDelta) => History.push(this.redoStack, entry),
|
|
);
|
|
}
|
|
|
|
public redo(elements: SceneElementsMap, appState: AppState) {
|
|
return this.perform(
|
|
elements,
|
|
appState,
|
|
() => History.pop(this.redoStack),
|
|
(entry: HistoryDelta) => History.push(this.undoStack, entry),
|
|
);
|
|
}
|
|
|
|
private perform(
|
|
elements: SceneElementsMap,
|
|
appState: AppState,
|
|
pop: () => HistoryDelta | null,
|
|
push: (entry: HistoryDelta) => void,
|
|
): [SceneElementsMap, AppState] | void {
|
|
try {
|
|
let historyDelta = pop();
|
|
|
|
if (historyDelta === null) {
|
|
return;
|
|
}
|
|
|
|
const action = CaptureUpdateAction.IMMEDIATELY;
|
|
|
|
let prevSnapshot = this.store.snapshot;
|
|
|
|
let nextElements = elements;
|
|
let nextAppState = appState;
|
|
let containsVisibleChange = false;
|
|
|
|
// iterate through the history entries in case they result in no visible changes
|
|
while (historyDelta) {
|
|
try {
|
|
[nextElements, nextAppState, containsVisibleChange] =
|
|
historyDelta.applyTo(nextElements, nextAppState, prevSnapshot);
|
|
|
|
const prevElements = prevSnapshot.elements;
|
|
const nextSnapshot = prevSnapshot.maybeClone(
|
|
action,
|
|
nextElements,
|
|
nextAppState,
|
|
);
|
|
|
|
const change = StoreChange.create(prevSnapshot, nextSnapshot);
|
|
const delta = HistoryDelta.applyLatestChanges(
|
|
historyDelta,
|
|
prevElements,
|
|
nextElements,
|
|
);
|
|
|
|
if (!delta.isEmpty()) {
|
|
// schedule immediate capture, so that it's emitted for the sync purposes
|
|
this.store.scheduleMicroAction({
|
|
action,
|
|
change,
|
|
delta,
|
|
});
|
|
|
|
historyDelta = delta;
|
|
}
|
|
|
|
prevSnapshot = nextSnapshot;
|
|
} finally {
|
|
push(historyDelta);
|
|
}
|
|
|
|
if (containsVisibleChange) {
|
|
break;
|
|
}
|
|
|
|
historyDelta = pop();
|
|
}
|
|
|
|
return [nextElements, nextAppState];
|
|
} finally {
|
|
// trigger the history change event before returning completely
|
|
// also trigger it just once, no need doing so on each entry
|
|
this.onHistoryChangedEmitter.trigger(
|
|
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
|
|
);
|
|
}
|
|
}
|
|
|
|
private static pop(stack: HistoryDelta[]): HistoryDelta | null {
|
|
if (!stack.length) {
|
|
return null;
|
|
}
|
|
|
|
const entry = stack.pop();
|
|
|
|
if (entry !== undefined) {
|
|
return entry;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static push(stack: HistoryDelta[], entry: HistoryDelta) {
|
|
const inversedEntry = HistoryDelta.inverse(entry);
|
|
return stack.push(inversedEntry);
|
|
}
|
|
}
|