gristlabs_grist-core/app/client/components/UndoStack.ts
Paul Fitzpatrick 876a0298a2 (core) do not look at content of recent actions when loading documents
Summary:
This removes the need for any information drawn from the content of recent actions when loading a document.

The undo/redo system does need some facts about recent actions up front. But that system has an important restriction: only actions a particular client is known to have generated can be undone by that client.

So in this diff, as we store which client has performed an action, we also store the few pieces of metadata about that action that the undo/redo system needs: `linkId`, `otherId`, `rowIdHint`, `isUndo` fields. These are all small integers (or in one case a boolean).

An existing limitation is that information about which client has performed which action is stored in memory in the worker, and not persisted anywhere. This diff does not change that limitation, meaning that undos continue to not survive a worker transition. A reasonable way to deal with that would be to back the store with redis.

Test Plan: existing tests pass

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D3044
2021-09-29 11:27:02 -04:00

149 lines
5.7 KiB
TypeScript

import {CursorPos} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc';
import * as dispose from 'app/client/lib/dispose';
import {MinimalActionGroup} from 'app/common/ActionGroup';
import {PromiseChain} from 'app/common/gutil';
import {fromKo, Observable} from 'grainjs';
import * as ko from 'knockout';
export interface ActionGroupWithCursorPos extends MinimalActionGroup {
cursorPos?: CursorPos;
}
// Provides observables indicating disabled state for undo/redo.
export interface IUndoState {
isUndoDisabled: Observable<boolean>;
isRedoDisabled: Observable<boolean>;
}
/**
* Maintains the stack of actions which can be undone and redone, and maintains the
* position in this stack. Undo and redo actions are generated and sent to the server here.
*/
export class UndoStack extends dispose.Disposable {
public undoDisabledObs: ko.Observable<boolean>;
public redoDisabledObs: ko.Observable<boolean>;
private _gristDoc: GristDoc;
private _stack: ActionGroupWithCursorPos[];
private _pointer: number;
private _linkMap: {[actionNum: number]: MinimalActionGroup};
// Chain of promises which send undo actions to the server. This delays the execution of the
// next action until the current one has been received and moved the pointer index.
private _undoChain = new PromiseChain<void>();
public create(log: MinimalActionGroup[], options: {gristDoc: GristDoc}) {
this._gristDoc = options.gristDoc;
// TODO: _stack and _linkMap grow without bound within a single session.
// The top of the stack is stack.length - 1. The pointer points above the most
// recently applied (not undone) action.
this._stack = [];
this._pointer = 0;
// Map leading from actionNums to the action groups which link to them.
this._linkMap = {};
// Observables for when there is nothing to undo/redo.
this.undoDisabledObs = ko.observable(true);
this.redoDisabledObs = ko.observable(true);
// Set the history nav interface in the DocPageModel to properly enable/disabled undo/redo.
if (this._gristDoc.docPageModel) {
this._gristDoc.docPageModel.undoState.set({
isUndoDisabled: fromKo(this.undoDisabledObs),
isRedoDisabled: fromKo(this.redoDisabledObs)
});
}
// Initialize the stack from the log of recent actions from the server.
log.forEach(ag => { this.pushAction(ag); });
}
/**
* Should only be given own actions. Pays attention to actionNum, otherId, linkId, and
* uses those to adjust undo index.
*/
public pushAction(ag: MinimalActionGroup): void {
if (!ag.fromSelf) {
return;
}
const otherIndex = ag.otherId ?
this._stack.findIndex(a => a.actionNum === ag.otherId) : -1;
if (ag.linkId) {
// Link action. Add the action to the linkMap, but not to any stacks.
this._linkMap[ag.linkId] = ag;
} else if (otherIndex > -1) {
// Undo/redo action from the current session.
this._pointer = ag.isUndo ? otherIndex : otherIndex + 1;
} else {
// Either a normal action from the current session, or an undo/redo which
// applies to a non-recent action. Bury all undone actions.
if (!this.redoDisabledObs()) {
this._stack.splice(this._pointer);
}
// Reset pointer and add to the stack (if not an undo action).
if (!ag.otherId) {
this._stack.push(ag);
}
this._pointer = this._stack.length;
}
this.undoDisabledObs(this._pointer <= 0);
this.redoDisabledObs(this._pointer >= this._stack.length);
}
// Send an undo action. This should be called when the user presses 'undo'.
public sendUndoAction(): Promise<void> {
return this._undoChain.add(() => this._sendAction(true));
}
// Send a redo action. This should be called when the user presses 'redo'.
public sendRedoAction(): Promise<void> {
return this._undoChain.add(() => this._sendAction(false));
}
private async _sendAction(isUndo: boolean): Promise<void> {
// Pick the action group to undo or redo.
const ag = this._stack[isUndo ? this._pointer - 1 : this._pointer];
if (!ag) { return; }
try {
// Get all actions in the bundle that starts at the current index. Typically, an array with a
// single action group is returned.
const actionGroups = this._findActionBundle(ag);
// When we undo/redo, jump to the place where this action occurred, to bring the user to the
// context where the change was originally made. We jump first immediately to feel more
// responsive, then again when the action is done. The second jump matters more for most
// changes, but the first is the important one when Undoing an AddRecord.
this._gristDoc.moveToCursorPos(ag.cursorPos, ag).catch(() => { /* do nothing */ });
await this._gristDoc.docComm.applyUserActionsById(
actionGroups.map(a => a.actionNum),
actionGroups.map(a => a.actionHash),
isUndo,
{ otherId: ag.actionNum });
this._gristDoc.moveToCursorPos(ag.cursorPos, ag).catch(() => { /* do nothing */ });
} catch (err) {
err.message = `Failed to apply ${isUndo ? 'undo' : 'redo'} action: ${err.message}`;
throw err;
}
}
/**
* Find all actionGroups in the bundle that starts with the given action group.
*/
private _findActionBundle(ag: MinimalActionGroup) {
const prevNums = new Set();
const actionGroups = [];
// Follow references through the linkMap adding items to the array bundle.
while (ag && !prevNums.has(ag.actionNum)) {
// Checking that actions are only accessed once prevents an infinite circular loop.
actionGroups.push(ag);
prevNums.add(ag.actionNum);
ag = this._linkMap[ag.actionNum];
}
return actionGroups;
}
}