Optimize undo history (#1632)
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
51608c07b0
commit
6512ede9ca
@ -114,7 +114,7 @@ export const newLinearElement = (
|
|||||||
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
|
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
|
||||||
//
|
//
|
||||||
// Adapted from https://github.com/lukeed/klona
|
// Adapted from https://github.com/lukeed/klona
|
||||||
const _duplicateElement = (val: any, depth: number = 0) => {
|
export const deepCopyElement = (val: any, depth: number = 0) => {
|
||||||
if (val == null || typeof val !== "object") {
|
if (val == null || typeof val !== "object") {
|
||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
@ -130,7 +130,7 @@ const _duplicateElement = (val: any, depth: number = 0) => {
|
|||||||
if (depth === 0 && (key === "shape" || key === "canvas")) {
|
if (depth === 0 && (key === "shape" || key === "canvas")) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
tmp[key] = _duplicateElement(val[key], depth + 1);
|
tmp[key] = deepCopyElement(val[key], depth + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tmp;
|
return tmp;
|
||||||
@ -140,7 +140,7 @@ const _duplicateElement = (val: any, depth: number = 0) => {
|
|||||||
let k = val.length;
|
let k = val.length;
|
||||||
const arr = new Array(k);
|
const arr = new Array(k);
|
||||||
while (k--) {
|
while (k--) {
|
||||||
arr[k] = _duplicateElement(val[k], depth + 1);
|
arr[k] = deepCopyElement(val[k], depth + 1);
|
||||||
}
|
}
|
||||||
return arr;
|
return arr;
|
||||||
}
|
}
|
||||||
@ -152,7 +152,7 @@ export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
element: TElement,
|
element: TElement,
|
||||||
overrides?: Partial<TElement>,
|
overrides?: Partial<TElement>,
|
||||||
): TElement => {
|
): TElement => {
|
||||||
let copy: TElement = _duplicateElement(element);
|
let copy: TElement = deepCopyElement(element);
|
||||||
copy.id = randomId();
|
copy.id = randomId();
|
||||||
copy.seed = randomInteger();
|
copy.seed = randomInteger();
|
||||||
if (overrides) {
|
if (overrides) {
|
||||||
|
115
src/history.ts
115
src/history.ts
@ -2,13 +2,23 @@ import { AppState } from "./types";
|
|||||||
import { ExcalidrawElement } from "./element/types";
|
import { ExcalidrawElement } from "./element/types";
|
||||||
import { newElementWith } from "./element/mutateElement";
|
import { newElementWith } from "./element/mutateElement";
|
||||||
import { isLinearElement } from "./element/typeChecks";
|
import { isLinearElement } from "./element/typeChecks";
|
||||||
|
import { deepCopyElement } from "./element/newElement";
|
||||||
|
|
||||||
export type HistoryEntry = {
|
export interface HistoryEntry {
|
||||||
appState: ReturnType<typeof clearAppStatePropertiesForHistory>;
|
appState: ReturnType<typeof clearAppStatePropertiesForHistory>;
|
||||||
elements: ExcalidrawElement[];
|
elements: ExcalidrawElement[];
|
||||||
};
|
}
|
||||||
|
|
||||||
type HistoryEntrySerialized = string;
|
interface DehydratedExcalidrawElement {
|
||||||
|
id: string;
|
||||||
|
version: number;
|
||||||
|
versionNonce: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DehydratedHistoryEntry {
|
||||||
|
appState: ReturnType<typeof clearAppStatePropertiesForHistory>;
|
||||||
|
elements: DehydratedExcalidrawElement[];
|
||||||
|
}
|
||||||
|
|
||||||
const clearAppStatePropertiesForHistory = (appState: AppState) => {
|
const clearAppStatePropertiesForHistory = (appState: AppState) => {
|
||||||
return {
|
return {
|
||||||
@ -19,16 +29,73 @@ const clearAppStatePropertiesForHistory = (appState: AppState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class SceneHistory {
|
export class SceneHistory {
|
||||||
|
private elementCache = new Map<
|
||||||
|
string,
|
||||||
|
Map<number, Map<number, ExcalidrawElement>>
|
||||||
|
>();
|
||||||
private recording: boolean = true;
|
private recording: boolean = true;
|
||||||
private stateHistory: HistoryEntrySerialized[] = [];
|
private stateHistory: DehydratedHistoryEntry[] = [];
|
||||||
private redoStack: HistoryEntrySerialized[] = [];
|
private redoStack: DehydratedHistoryEntry[] = [];
|
||||||
private lastEntry: HistoryEntry | null = null;
|
private lastEntry: HistoryEntry | null = null;
|
||||||
|
|
||||||
|
private hydrateHistoryEntry({
|
||||||
|
appState,
|
||||||
|
elements,
|
||||||
|
}: DehydratedHistoryEntry): HistoryEntry {
|
||||||
|
return {
|
||||||
|
appState,
|
||||||
|
elements: elements.map((dehydratedExcalidrawElement) => {
|
||||||
|
const element = this.elementCache
|
||||||
|
.get(dehydratedExcalidrawElement.id)
|
||||||
|
?.get(dehydratedExcalidrawElement.version)
|
||||||
|
?.get(dehydratedExcalidrawElement.versionNonce);
|
||||||
|
if (!element) {
|
||||||
|
throw new Error(
|
||||||
|
`Element not found: ${dehydratedExcalidrawElement.id}:${dehydratedExcalidrawElement.version}:${dehydratedExcalidrawElement.versionNonce}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private dehydrateHistoryEntry({
|
||||||
|
appState,
|
||||||
|
elements,
|
||||||
|
}: HistoryEntry): DehydratedHistoryEntry {
|
||||||
|
return {
|
||||||
|
appState,
|
||||||
|
elements: elements.map((element) => {
|
||||||
|
if (!this.elementCache.has(element.id)) {
|
||||||
|
this.elementCache.set(element.id, new Map());
|
||||||
|
}
|
||||||
|
const versions = this.elementCache.get(element.id)!;
|
||||||
|
if (!versions.has(element.version)) {
|
||||||
|
versions.set(element.version, new Map());
|
||||||
|
}
|
||||||
|
const nonces = versions.get(element.version)!;
|
||||||
|
if (!nonces.has(element.versionNonce)) {
|
||||||
|
nonces.set(element.versionNonce, deepCopyElement(element));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: element.id,
|
||||||
|
version: element.version,
|
||||||
|
versionNonce: element.versionNonce,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
getSnapshotForTest() {
|
getSnapshotForTest() {
|
||||||
return {
|
return {
|
||||||
recording: this.recording,
|
recording: this.recording,
|
||||||
stateHistory: this.stateHistory.map((s) => JSON.parse(s)),
|
stateHistory: this.stateHistory.map((dehydratedHistoryEntry) =>
|
||||||
redoStack: this.redoStack.map((s) => JSON.parse(s)),
|
this.hydrateHistoryEntry(dehydratedHistoryEntry),
|
||||||
|
),
|
||||||
|
redoStack: this.redoStack.map((dehydratedHistoryEntry) =>
|
||||||
|
this.hydrateHistoryEntry(dehydratedHistoryEntry),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,26 +103,14 @@ export class SceneHistory {
|
|||||||
this.stateHistory.length = 0;
|
this.stateHistory.length = 0;
|
||||||
this.redoStack.length = 0;
|
this.redoStack.length = 0;
|
||||||
this.lastEntry = null;
|
this.lastEntry = null;
|
||||||
}
|
this.elementCache.clear();
|
||||||
|
|
||||||
private parseEntry(
|
|
||||||
entrySerialized: HistoryEntrySerialized | undefined,
|
|
||||||
): HistoryEntry | null {
|
|
||||||
if (entrySerialized === undefined) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.parse(entrySerialized);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateEntry = (
|
private generateEntry = (
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
) =>
|
): DehydratedHistoryEntry =>
|
||||||
JSON.stringify({
|
this.dehydrateHistoryEntry({
|
||||||
appState: clearAppStatePropertiesForHistory(appState),
|
appState: clearAppStatePropertiesForHistory(appState),
|
||||||
elements: elements.reduce((elements, element) => {
|
elements: elements.reduce((elements, element) => {
|
||||||
if (
|
if (
|
||||||
@ -129,25 +184,23 @@ export class SceneHistory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pushEntry(appState: AppState, elements: readonly ExcalidrawElement[]) {
|
pushEntry(appState: AppState, elements: readonly ExcalidrawElement[]) {
|
||||||
const newEntrySerialized = this.generateEntry(appState, elements);
|
const newEntryDehydrated = this.generateEntry(appState, elements);
|
||||||
const newEntry: HistoryEntry | null = this.parseEntry(newEntrySerialized);
|
const newEntry: HistoryEntry = this.hydrateHistoryEntry(newEntryDehydrated);
|
||||||
|
|
||||||
if (newEntry) {
|
if (newEntry) {
|
||||||
if (!this.shouldCreateEntry(newEntry)) {
|
if (!this.shouldCreateEntry(newEntry)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stateHistory.push(newEntrySerialized);
|
this.stateHistory.push(newEntryDehydrated);
|
||||||
this.lastEntry = newEntry;
|
this.lastEntry = newEntry;
|
||||||
// As a new entry was pushed, we invalidate the redo stack
|
// As a new entry was pushed, we invalidate the redo stack
|
||||||
this.clearRedoStack();
|
this.clearRedoStack();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private restoreEntry(
|
private restoreEntry(entrySerialized: DehydratedHistoryEntry): HistoryEntry {
|
||||||
entrySerialized: HistoryEntrySerialized,
|
const entry = this.hydrateHistoryEntry(entrySerialized);
|
||||||
): HistoryEntry | null {
|
|
||||||
const entry = this.parseEntry(entrySerialized);
|
|
||||||
if (entry) {
|
if (entry) {
|
||||||
entry.elements = entry.elements.map((element) => {
|
entry.elements = entry.elements.map((element) => {
|
||||||
// renew versions
|
// renew versions
|
||||||
@ -203,7 +256,9 @@ export class SceneHistory {
|
|||||||
* it.
|
* it.
|
||||||
*/
|
*/
|
||||||
setCurrentState(appState: AppState, elements: readonly ExcalidrawElement[]) {
|
setCurrentState(appState: AppState, elements: readonly ExcalidrawElement[]) {
|
||||||
this.lastEntry = this.parseEntry(this.generateEntry(appState, elements));
|
this.lastEntry = this.hydrateHistoryEntry(
|
||||||
|
this.generateEntry(appState, elements),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suspicious that this is called so many places. Seems error-prone.
|
// Suspicious that this is called so many places. Seems error-prone.
|
||||||
|
Loading…
x
Reference in New Issue
Block a user