You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
211 lines
5.5 KiB
TypeScript
211 lines
5.5 KiB
TypeScript
import type { AppStateChange, ElementsChange } from "./change";
|
|
import type { SceneElementsMap } from "./element/types";
|
|
import { Emitter } from "./emitter";
|
|
import type { Snapshot } from "./store";
|
|
import type { AppState } from "./types";
|
|
|
|
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]
|
|
>();
|
|
|
|
private readonly undoStack: HistoryStack = [];
|
|
private readonly redoStack: HistoryStack = [];
|
|
|
|
public get isUndoStackEmpty() {
|
|
return this.undoStack.length === 0;
|
|
}
|
|
|
|
public get isRedoStackEmpty() {
|
|
return this.redoStack.length === 0;
|
|
}
|
|
|
|
public clear() {
|
|
this.undoStack.length = 0;
|
|
this.redoStack.length = 0;
|
|
}
|
|
|
|
/**
|
|
* Record a local change which will go into the history
|
|
*/
|
|
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 undo(
|
|
elements: SceneElementsMap,
|
|
appState: AppState,
|
|
snapshot: Readonly<Snapshot>,
|
|
) {
|
|
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<Snapshot>,
|
|
) {
|
|
return this.perform(
|
|
elements,
|
|
appState,
|
|
snapshot,
|
|
() => History.pop(this.redoStack),
|
|
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
|
|
);
|
|
}
|
|
|
|
private perform(
|
|
elements: SceneElementsMap,
|
|
appState: AppState,
|
|
snapshot: Readonly<Snapshot>,
|
|
pop: () => HistoryEntry | null,
|
|
push: (entry: HistoryEntry) => void,
|
|
): [SceneElementsMap, AppState] | void {
|
|
try {
|
|
let historyEntry = pop();
|
|
|
|
if (historyEntry === null) {
|
|
return;
|
|
}
|
|
|
|
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] =
|
|
historyEntry.applyTo(nextElements, nextAppState, snapshot);
|
|
} finally {
|
|
// make sure to always push / pop, even if the increment is corrupted
|
|
push(historyEntry);
|
|
}
|
|
|
|
if (containsVisibleChange) {
|
|
break;
|
|
}
|
|
|
|
historyEntry = 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: 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 = entry.inverse().applyLatestChanges(prevElements);
|
|
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<Snapshot>,
|
|
): [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();
|
|
}
|
|
}
|