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

@ -3,7 +3,7 @@ import React from "react";
import { undo, redo } from "../components/icons"; import { undo, redo } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { SceneHistory } from "../history"; import { SceneHistory, HistoryEntry } from "../history";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
@ -13,10 +13,7 @@ import { newElementWith } from "../element/mutateElement";
const writeData = ( const writeData = (
prevElements: readonly ExcalidrawElement[], prevElements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
updater: () => { updater: () => HistoryEntry | null,
elements: ExcalidrawElement[];
appState: AppState;
} | null,
): ActionResult => { ): ActionResult => {
const commitToHistory = false; const commitToHistory = false;
if ( if (
@ -52,6 +49,7 @@ const writeData = (
), ),
appState: { ...appState, ...data.appState }, appState: { ...appState, ...data.appState },
commitToHistory, commitToHistory,
syncHistory: true,
}; };
} }
return { commitToHistory }; return { commitToHistory };

View File

@ -6,6 +6,7 @@ export type ActionResult = {
elements?: readonly ExcalidrawElement[] | null; elements?: readonly ExcalidrawElement[] | null;
appState?: AppState | null; appState?: AppState | null;
commitToHistory: boolean; commitToHistory: boolean;
syncHistory?: boolean;
}; };
type ActionFn = ( type ActionFn = (

View File

@ -70,26 +70,6 @@ export const clearAppStateForLocalStorage = (appState: AppState) => {
return exportedState; return exportedState;
}; };
export const clearAppStatePropertiesForHistory = (
appState: AppState,
): Partial<AppState> => {
return {
selectedElementIds: appState.selectedElementIds,
exportBackground: appState.exportBackground,
shouldAddWatermark: appState.shouldAddWatermark,
currentItemStrokeColor: appState.currentItemStrokeColor,
currentItemBackgroundColor: appState.currentItemBackgroundColor,
currentItemFillStyle: appState.currentItemFillStyle,
currentItemStrokeWidth: appState.currentItemStrokeWidth,
currentItemRoughness: appState.currentItemRoughness,
currentItemOpacity: appState.currentItemOpacity,
currentItemFont: appState.currentItemFont,
currentItemTextAlign: appState.currentItemTextAlign,
viewBackgroundColor: appState.viewBackgroundColor,
name: appState.name,
};
};
export const cleanAppStateForExport = (appState: AppState) => { export const cleanAppStateForExport = (appState: AppState) => {
return { return {
viewBackgroundColor: appState.viewBackgroundColor, viewBackgroundColor: appState.viewBackgroundColor,

View File

@ -276,12 +276,23 @@ class App extends React.Component<any, AppState> {
if (res.commitToHistory) { if (res.commitToHistory) {
history.resumeRecording(); history.resumeRecording();
} }
this.setState((state) => ({ this.setState(
...res.appState, (state) => ({
editingElement: editingElement || res.appState?.editingElement || null, ...res.appState,
isCollaborating: state.isCollaborating, editingElement:
collaborators: state.collaborators, editingElement || res.appState?.editingElement || null,
})); isCollaborating: state.isCollaborating,
collaborators: state.collaborators,
}),
() => {
if (res.syncHistory) {
history.setCurrentState(
this.state,
globalSceneState.getElementsIncludingDeleted(),
);
}
},
);
} }
}); });

View File

@ -74,6 +74,7 @@ export const restore = (
// all elements must have version > 0 so getDrawingVersion() will pick up newly added elements // all elements must have version > 0 so getDrawingVersion() will pick up newly added elements
version: element.version || 1, version: element.version || 1,
id: element.id || randomId(), id: element.id || randomId(),
isDeleted: false,
fillStyle: element.fillStyle || "hachure", fillStyle: element.fillStyle || "hachure",
strokeWidth: element.strokeWidth || 1, strokeWidth: element.strokeWidth || 1,
strokeStyle: element.strokeStyle ?? "solid", strokeStyle: element.strokeStyle ?? "solid",

View File

@ -1,18 +1,28 @@
import { AppState } from "./types"; import { AppState } from "./types";
import { ExcalidrawElement } from "./element/types"; import { ExcalidrawElement } from "./element/types";
import { clearAppStatePropertiesForHistory } from "./appState";
import { newElementWith } from "./element/mutateElement"; import { newElementWith } from "./element/mutateElement";
import { isLinearElement } from "./element/typeChecks"; import { isLinearElement } from "./element/typeChecks";
type Result = { export type HistoryEntry = {
appState: AppState; appState: ReturnType<typeof clearAppStatePropertiesForHistory>;
elements: ExcalidrawElement[]; elements: ExcalidrawElement[];
}; };
type HistoryEntrySerialized = string;
const clearAppStatePropertiesForHistory = (appState: AppState) => {
return {
selectedElementIds: appState.selectedElementIds,
viewBackgroundColor: appState.viewBackgroundColor,
name: appState.name,
};
};
export class SceneHistory { export class SceneHistory {
private recording: boolean = true; private recording: boolean = true;
private stateHistory: string[] = []; private stateHistory: HistoryEntrySerialized[] = [];
private redoStack: string[] = []; private redoStack: HistoryEntrySerialized[] = [];
private lastEntry: HistoryEntry | null = null;
getSnapshotForTest() { getSnapshotForTest() {
return { return {
@ -25,6 +35,20 @@ export class SceneHistory {
clear() { clear() {
this.stateHistory.length = 0; this.stateHistory.length = 0;
this.redoStack.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 = ( private generateEntry = (
@ -48,57 +72,96 @@ export class SceneHistory {
return elements; return elements;
} }
elements.push( elements.push({
newElementWith(element, { ...element,
// don't store last point if not committed // don't store last point if not committed
points: points:
element.lastCommittedPoint !== element.lastCommittedPoint !==
element.points[element.points.length - 1] element.points[element.points.length - 1]
? element.points.slice(0, -1) ? element.points.slice(0, -1)
: element.points, : element.points,
// don't regenerate versionNonce else this will short-circuit our });
// bail-on-no-change logic in pushEntry()
versionNonce: element.versionNonce,
}),
);
} else { } else {
elements.push( elements.push(element);
newElementWith(element, { versionNonce: element.versionNonce }),
);
} }
return elements; return elements;
}, [] as Mutable<typeof elements>), }, [] as Mutable<typeof elements>),
}); });
pushEntry(appState: AppState, elements: readonly ExcalidrawElement[]) { shouldCreateEntry(nextEntry: HistoryEntry): boolean {
const newEntry = this.generateEntry(appState, elements); const { lastEntry } = this;
if (
this.stateHistory.length > 0 && if (!lastEntry) {
this.stateHistory[this.stateHistory.length - 1] === newEntry return true;
) {
// If the last entry is the same as this one, ignore it
return;
} }
this.stateHistory.push(newEntry); if (nextEntry.elements.length !== lastEntry.elements.length) {
return true;
}
// As a new entry was pushed, we invalidate the redo stack // loop from right to left as changes are likelier to happen on new elements
this.clearRedoStack(); 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) { pushEntry(appState: AppState, elements: readonly ExcalidrawElement[]) {
try { const newEntrySerialized = this.generateEntry(appState, elements);
return JSON.parse(entry); const newEntry: HistoryEntry | null = this.parseEntry(newEntrySerialized);
} catch {
return null; 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() { clearRedoStack() {
this.redoStack.splice(0, this.redoStack.length); this.redoStack.splice(0, this.redoStack.length);
} }
redoOnce(): Result | null { redoOnce(): HistoryEntry | null {
if (this.redoStack.length === 0) { if (this.redoStack.length === 0) {
return null; return null;
} }
@ -113,7 +176,7 @@ export class SceneHistory {
return null; return null;
} }
undoOnce(): Result | null { undoOnce(): HistoryEntry | null {
if (this.stateHistory.length === 1) { if (this.stateHistory.length === 1) {
return null; return null;
} }
@ -130,6 +193,19 @@ export class SceneHistory {
return null; 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. // Suspicious that this is called so many places. Seems error-prone.
resumeRecording() { resumeRecording() {
this.recording = true; this.recording = true;

View File

@ -10,13 +10,13 @@ Object {
"isDeleted": false, "isDeleted": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 2019559783, "seed": 401146281,
"strokeColor": "#000000", "strokeColor": "#000000",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"version": 4, "version": 4,
"versionNonce": 1150084233, "versionNonce": 2019559783,
"width": 30, "width": 30,
"x": 30, "x": 30,
"y": 20, "y": 20,
@ -39,7 +39,7 @@ Object {
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"version": 5, "version": 5,
"versionNonce": 1014066025, "versionNonce": 1116226695,
"width": 30, "width": 30,
"x": -10, "x": -10,
"y": 60, "y": 60,
@ -62,7 +62,7 @@ Object {
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"versionNonce": 401146281, "versionNonce": 453191,
"width": 30, "width": 30,
"x": 0, "x": 0,
"y": 40, "y": 40,

View File

@ -34,7 +34,7 @@ Object {
"strokeWidth": 1, "strokeWidth": 1,
"type": "arrow", "type": "arrow",
"version": 7, "version": 7,
"versionNonce": 1116226695, "versionNonce": 1150084233,
"width": 70, "width": 70,
"x": 30, "x": 30,
"y": 30, "y": 30,
@ -75,7 +75,7 @@ Object {
"strokeWidth": 1, "strokeWidth": 1,
"type": "line", "type": "line",
"version": 7, "version": 7,
"versionNonce": 1116226695, "versionNonce": 1150084233,
"width": 70, "width": 70,
"x": 30, "x": 30,
"y": 30, "y": 30,

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@ Object {
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"versionNonce": 1150084233, "versionNonce": 401146281,
"width": 30, "width": 30,
"x": 29, "x": 29,
"y": 47, "y": 47,
@ -39,7 +39,7 @@ Object {
"strokeWidth": 1, "strokeWidth": 1,
"type": "rectangle", "type": "rectangle",
"version": 3, "version": 3,
"versionNonce": 1150084233, "versionNonce": 401146281,
"width": 30, "width": 30,
"x": 29, "x": 29,
"y": 47, "y": 47,

View File

@ -162,6 +162,11 @@ const getSelectedElement = (): ExcalidrawElement => {
return selectedElements[0]; return selectedElements[0];
}; };
function getStateHistory() {
// @ts-ignore
return h.history.stateHistory;
}
type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>; type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
const getResizeHandles = () => { const getResizeHandles = () => {
const rects = handlerRectangles( const rects = handlerRectangles(
@ -569,6 +574,46 @@ describe("regression tests", () => {
expect(h.elements.filter((element) => !element.isDeleted).length).toBe(2); expect(h.elements.filter((element) => !element.isDeleted).length).toBe(2);
}); });
it("noop interaction after undo shouldn't create history entry", () => {
// NOTE: this will fail if this test case is run in isolation. There's
// some leaking state or race conditions in initialization/teardown
// (couldn't figure out)
expect(getStateHistory().length).toBe(0);
clickTool("rectangle");
pointerDown(10, 10);
pointerMove(20, 20);
pointerUp();
clickTool("rectangle");
pointerDown(30, 10);
pointerMove(40, 20);
pointerUp();
expect(getStateHistory().length).toBe(2);
keyPress("z", true);
expect(getStateHistory().length).toBe(1);
// clicking an element shouldn't addu to history
pointerDown(10, 10);
pointerUp();
expect(getStateHistory().length).toBe(1);
keyPress("z", true, true);
expect(getStateHistory().length).toBe(2);
// clicking an element shouldn't addu to history
pointerDown(10, 10);
pointerUp();
expect(getStateHistory().length).toBe(2);
// same for clicking the element just redo-ed
pointerDown(30, 10);
pointerUp();
expect(getStateHistory().length).toBe(2);
});
it("zoom hotkeys", () => { it("zoom hotkeys", () => {
expect(h.state.zoom).toBe(1); expect(h.state.zoom).toBe(1);
fireEvent.keyDown(document, { code: "Equal", ctrlKey: true }); fireEvent.keyDown(document, { code: "Equal", ctrlKey: true });