Undo

4 important aspects that are discussed:

  1. Undoable Actions: what actions should (or can) be undone?
  2. State restoration: what part of UI is restored after undo?
  3. Granularity: how much should be undone at a time?
  4. Scope: is undo global, local, or someplace in between?

Recommendations for Undo Granularity

  • Ignore direct manipulation intermediate states
    • e.g. ignore mousemove during object resize or move states
  • Delimit chunks on discrete “input breaks“
    • e.g. words in text
  • Chunk all changes resulting from a single interface event
    • e.g. find and replace multiple words

Undo Benefits

  1. Undo lets you recover from errors
  2. Undo enables exploratory learning
  3. Undo lets you evaluate modifications
    • fast do-undo-redo cycle to evaluate last change to document

Undoable Actions

  • Some actions may be omitted from undo
    • Change to selection? Window resizing? Scrollbar positioning?
  • Some actions are destructive and not easily undone
    • e.g. quitting program with unsaved data, emptying trash
  • Some actions can’t be undone - e.g. printing

Forward Undo

  • Start from base document, then maintain of list of changes to compute current document
  • Undo by removing last change from list when computing current document

Reverse Undo

  • Apply change to update document, but also save “reverse” change
  • Undo by applying reverse change to document

How to implement reverse change? 2 options:

Option 1: Command pattern

  • save command and “reverse command” to change state

Option 2: Memento pattern

  • save snapshots of each document state
  • could be complete state or difference from “last” state

The stacks of Forward and Reverse Undo

Undo Manager

export interface Command {
  do(): void;
  undo(): void;
}
 
export class UndoManager {
  private undoStack: Command[] = [];
  private redoStack: Command[] = [];
 
  constructor() {}
 
  execute(command: Command) {
    this.undoStack.push(command);
    this.redoStack = [];
    console.log(this.toString());
  }
 
  undo() {
    const command = this.undoStack.pop();
   
    if (command) {
      this.redoStack.push(command);
      command.undo();
    }
    console.log(this.toString());
  }
 
  redo() {
    const command = this.redoStack.pop();
    if (command) {
      this.undoStack.push(command);
      command.do();
    }
    console.log(this.toString());
  }
 
  get canUndo() {
    return this.undoStack.length > 0;
  }
 
  get canRedo() {
    return this.redoStack.length > 0;
  }
 
  toString() {
    return `undoStack: ${this.undoStack.length}, redoStack: ${this.redoStack.length}`;
  }
}