Files
excalidraw/packages/excalidraw/history.ts
T

165 lines
4.4 KiB
TypeScript

import { Emitter } from "./emitter";
import { type Store, StoreDelta, StoreIncrement } from "./store";
import type { SceneElementsMap } from "./element/types";
import type { AppState } from "./types";
export class HistoryEntry extends StoreDelta {}
type HistoryStack = HistoryEntry[];
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: HistoryStack = [];
public readonly redoStack: HistoryStack = [];
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 local change which will go into the history
*/
public record(increment: StoreIncrement) {
if (
StoreIncrement.isDurable(increment) &&
!increment.delta.isEmpty() &&
!(increment.delta instanceof HistoryEntry)
) {
// construct history entry, so once it's emitted, it's not recorded again
const entry = HistoryEntry.inverse(increment.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) {
return this.perform(
elements,
appState,
() => History.pop(this.undoStack),
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
);
}
public redo(elements: SceneElementsMap, appState: AppState) {
return this.perform(
elements,
appState,
() => History.pop(this.redoStack),
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
);
}
private perform(
elements: SceneElementsMap,
appState: AppState,
pop: () => HistoryEntry | null,
push: (entry: HistoryEntry) => void,
): [SceneElementsMap, AppState] | void {
try {
let historyEntry = pop();
if (historyEntry === null) {
return;
}
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 (historyEntry) {
try {
[nextElements, nextAppState, containsVisibleChange] =
this.store.applyDeltaTo(historyEntry, nextElements, nextAppState, {
triggerIncrement: true,
});
prevSnapshot = this.store.snapshot;
} catch (e) {
console.error("Failed to apply history entry:", e);
// rollback to the previous snapshot, so that we don't end up in an incosistent state
this.store.snapshot = prevSnapshot;
} finally {
// make sure to always push, even if the delta is corrupted
push(historyEntry);
}
if (containsVisibleChange) {
break;
}
historyEntry = pop();
}
if (nextElements === null || nextAppState === null) {
return;
}
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: HistoryStack): HistoryEntry | null {
if (!stack.length) {
return null;
}
const entry = stack.pop();
if (entry !== undefined) {
return entry;
}
return null;
}
private static push(
stack: HistoryStack,
entry: HistoryEntry,
prevElements: SceneElementsMap,
) {
const updatedEntry = HistoryEntry.applyLatestChanges(entry, prevElements);
return stack.push(updatedEntry);
}
}