add history.shouldCreateEntry resolver (#1622)

This commit is contained in:
David Luzar 2020-05-23 07:26:59 +02:00 committed by GitHub
parent 22f7945c70
commit d2ae18995c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 1349 additions and 3449 deletions

View file

@ -1,18 +1,28 @@
import { AppState } from "./types";
import { ExcalidrawElement } from "./element/types";
import { clearAppStatePropertiesForHistory } from "./appState";
import { newElementWith } from "./element/mutateElement";
import { isLinearElement } from "./element/typeChecks";
type Result = {
appState: AppState;
export type HistoryEntry = {
appState: ReturnType<typeof clearAppStatePropertiesForHistory>;
elements: ExcalidrawElement[];
};
type HistoryEntrySerialized = string;
const clearAppStatePropertiesForHistory = (appState: AppState) => {
return {
selectedElementIds: appState.selectedElementIds,
viewBackgroundColor: appState.viewBackgroundColor,
name: appState.name,
};
};
export class SceneHistory {
private recording: boolean = true;
private stateHistory: string[] = [];
private redoStack: string[] = [];
private stateHistory: HistoryEntrySerialized[] = [];
private redoStack: HistoryEntrySerialized[] = [];
private lastEntry: HistoryEntry | null = null;
getSnapshotForTest() {
return {
@ -25,6 +35,20 @@ export class SceneHistory {
clear() {
this.stateHistory.length = 0;
this.redoStack.length = 0;
this.lastEntry = null;
}
private parseEntry(
entrySerialized: HistoryEntrySerialized | undefined,
): HistoryEntry | null {
if (entrySerialized === undefined) {
return null;
}
try {
return JSON.parse(entrySerialized);
} catch {
return null;
}
}
private generateEntry = (
@ -48,57 +72,96 @@ export class SceneHistory {
return elements;
}
elements.push(
newElementWith(element, {
// don't store last point if not committed
points:
element.lastCommittedPoint !==
element.points[element.points.length - 1]
? element.points.slice(0, -1)
: element.points,
// don't regenerate versionNonce else this will short-circuit our
// bail-on-no-change logic in pushEntry()
versionNonce: element.versionNonce,
}),
);
elements.push({
...element,
// don't store last point if not committed
points:
element.lastCommittedPoint !==
element.points[element.points.length - 1]
? element.points.slice(0, -1)
: element.points,
});
} else {
elements.push(
newElementWith(element, { versionNonce: element.versionNonce }),
);
elements.push(element);
}
return elements;
}, [] as Mutable<typeof elements>),
});
pushEntry(appState: AppState, elements: readonly ExcalidrawElement[]) {
const newEntry = this.generateEntry(appState, elements);
if (
this.stateHistory.length > 0 &&
this.stateHistory[this.stateHistory.length - 1] === newEntry
) {
// If the last entry is the same as this one, ignore it
return;
shouldCreateEntry(nextEntry: HistoryEntry): boolean {
const { lastEntry } = this;
if (!lastEntry) {
return true;
}
this.stateHistory.push(newEntry);
if (nextEntry.elements.length !== lastEntry.elements.length) {
return true;
}
// As a new entry was pushed, we invalidate the redo stack
this.clearRedoStack();
// loop from right to left as changes are likelier to happen on new elements
for (let i = nextEntry.elements.length - 1; i > -1; i--) {
const prev = nextEntry.elements[i];
const next = lastEntry.elements[i];
if (
!prev ||
!next ||
prev.id !== next.id ||
prev.version !== next.version ||
prev.versionNonce !== next.versionNonce
) {
return true;
}
}
// note: this is safe because entry's appState is guaranteed no excess props
let key: keyof typeof nextEntry.appState;
for (key in nextEntry.appState) {
if (key === "selectedElementIds") {
continue;
}
if (nextEntry.appState[key] !== lastEntry.appState[key]) {
return true;
}
}
return false;
}
restoreEntry(entry: string) {
try {
return JSON.parse(entry);
} catch {
return null;
pushEntry(appState: AppState, elements: readonly ExcalidrawElement[]) {
const newEntrySerialized = this.generateEntry(appState, elements);
const newEntry: HistoryEntry | null = this.parseEntry(newEntrySerialized);
if (newEntry) {
if (!this.shouldCreateEntry(newEntry)) {
return;
}
this.stateHistory.push(newEntrySerialized);
this.lastEntry = newEntry;
// As a new entry was pushed, we invalidate the redo stack
this.clearRedoStack();
}
}
private restoreEntry(
entrySerialized: HistoryEntrySerialized,
): HistoryEntry | null {
const entry = this.parseEntry(entrySerialized);
if (entry) {
entry.elements = entry.elements.map((element) => {
// renew versions
return newElementWith(element, {});
});
}
return entry;
}
clearRedoStack() {
this.redoStack.splice(0, this.redoStack.length);
}
redoOnce(): Result | null {
redoOnce(): HistoryEntry | null {
if (this.redoStack.length === 0) {
return null;
}
@ -113,7 +176,7 @@ export class SceneHistory {
return null;
}
undoOnce(): Result | null {
undoOnce(): HistoryEntry | null {
if (this.stateHistory.length === 1) {
return null;
}
@ -130,6 +193,19 @@ export class SceneHistory {
return null;
}
/**
* Updates history's `lastEntry` to latest app state. This is necessary
* when doing undo/redo which itself doesn't commit to history, but updates
* app state in a way that would break `shouldCreateEntry` which relies on
* `lastEntry` to reflect last comittable history state.
* We can't update `lastEntry` from within history when calling undo/redo
* because the action potentially mutates appState/elements before storing
* it.
*/
setCurrentState(appState: AppState, elements: readonly ExcalidrawElement[]) {
this.lastEntry = this.parseEntry(this.generateEntry(appState, elements));
}
// Suspicious that this is called so many places. Seems error-prone.
resumeRecording() {
this.recording = true;