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.
success/packages/excalidraw/history.ts

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);
}
Fix issues related to history (#701) * Separate UI from Canvas * Explicitly define history recording * ActionManager: Set syncActionState during construction instead of in every call * Add commit to history flag to necessary actions * Disable undoing during multiElement * Write custom equality function for UI component to render it only when specific props and elements change * Remove stale comments about history skipping * Stop undo/redoing when in resizing element mode * wip * correctly reset resizingElement & add undo check * Separate selection element from the rest of the array and stop redrawing the UI when dragging the selection * Remove selectionElement from local storage * Remove unnecessary readonly type casting in actionFinalize * Fix undo / redo for multi points * Fix an issue that did not update history when elements were locked * Disable committing to history for noops - deleteSelected without deleting anything - Basic selection * Use generateEntry only inside history and pass elements and appstate to history * Update component after every history resume * Remove last item from the history only if in multi mode * Resume recording when element type is not selection * ensure we prevent hotkeys only on writable elements * Remove selection clearing from history * Remove one point arrows as they are invisibly small * Remove shape of elements from local storage * Fix removing invisible element from the array * add missing history resuming cases & simplify slice * fix lint * don't regenerate elements if no elements deselected * regenerate elements array on selection * reset state.selectionElement unconditionally * Use getter instead of passing appState and scene data through functions to actions * fix import Co-authored-by: David Luzar <luzar.david@gmail.com>
5 years ago
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();
}
}