How to make a undo/redo function [closed]
Asked Answered
C

1

29

I want to add an undo/redo function in my script. I have looked around and see some suggestions, most of them recommended to use the command pattern.

The function must work over one page - after a reload of the page the function must able to redo/undo the last things.

I don't know how the command pattern works, I think about to create a object, to store the a name of the function, the old and the new value - but I'm not sure if this is a efficient way to do this or not.

Maybe somebody could give me a small example how the code for an undo/redo function should look.

Charged answered 29/1, 2019 at 8:1 Comment(0)
P
77

There's 2 common ways of implementing undo/redo, both of them very well established and defined behavioral Design Patterns.

The Memento Pattern and the Command Pattern.

In the context of undo, both of them are conceptual mechanisms to return back to a previous program state.

  • In the Memento pattern, you save a copy of the current state, called the memento, so you can return to it when you undo.
  • In the Command pattern, you save the commands that affected the state. When you want to undo you execute a command rollback of the last executed command.

Mementos are super easy to implement but memory inefficient.

Commands are a pain-in-the-ass to implement but very efficient memory-wise.

In both patterns you have an Undo Stack, in which you save either mementos or commands. When you want to undo you pop off the undo stack and proceed accordingly, depending on which pattern you chose for your undo system.

There are hybrid solutions; or optimisations of the patterns themselves, for example delta-encoding, but they tend to fall into either of these 2 concepts so I won't expand on those.

That's it.

I'll expand in painful detail below but in effect I'm just expanding on these 2 patterns.

What is state

Descriptions of program state are sometimes dizzying. It's simple:

Program state is just the current situation you're in. In an MS-Paint app it's your drawing with all it's blotches of red and black, in a quiz game it's the current question you're on and how many you got wrong/right, in a Word Document it's your document with all it's alignment settings and the 4 paragraphs of text you have written.

In essence, state is the current point-in-time.

When you undo, you essentially reinstate a previous state, simply put you're trying to time travel.

Which undo pattern should I use?

An undo system for a simple multiple-step form will probably use the Memento pattern since it's current state, the chosen answers for each step, is miniscule. You can get away with continuously saving nearly-identical copies of the state.

A vector-drawing application like Adobe Illustrator, will probably be using the Command Pattern since it's current state, the Scene Graph, is enormous.

The Memento pattern

In the Memento Pattern you simply capture the whole current state.

The user wants to edit something:

  • you serialise the current state of the program
  • you push that serialised state into an undo stack
  • you actually perform the edit

In it's extreme, you can even capture a JSON of the entirety of your application in that point-in-time. That whole state you just captured/snapshotted or turned into JSON is called the memento.

When you want to undo afterwards:

  • pop that previously saved memento off the undo stack
  • feed it to your program, asking it to use it to replace it's current state with the state captured in the memento.

The program is now back to how it was before the edit.

Pros:

  • Straightforward implementation
  • Flexible. Since you capture and recreate entire states, you can go ahead and make any change you want to your program state in the meantime. When you undo, you recreate the entire state anyway so it's no problem.

Cons:

  • In some cases, a prohibitively heavy memory hog.
  • Capturing a save point is explicit. In most implementations you'll need to explicitly do something like app.captureUndoSavepoint() before you do some change.

Each time you want to capture an undo save point, you save an entire copy of the current state. That's obviously very inefficient memory-wise.

In a lot of cases, it's also expensive to reinstate entire copies of state. When you open a file of a Word Document, the program needs a bit of time to initialise the document and this and that. Undo needs to be snappy.

The Command Pattern

In this pattern whatever action your application can take is coded as a Command.

A command is packaged as a unit-of-work with 2 specific methods:

  • execute which performs the action.
  • undo which performs the opposite of execute, thus negating or undoing the effects that execute created.

This is an example of a Command that turns on a light bulb:

class TurnOnLightbulbCommand {
  constructor (lightbulb) {
    this.lightbulb = lightbulb
  }

  execute() {
    this.lightbulb.turnOn()
  }

  undo() {
    this.lightbulb.turnOff()
  }
}

// Usage 

const command = new TurnOnLightbulbCommand(lightbulb)

command.execute() // lightbulb turned on 

// Undo 

command.undo() // lightbulb turned off

If you're writing a text editor for example, you would need to write a CharacterAddedCommand, CharacterRemovedCommand, CharacterPastedCommand and so on.

The user wants to add a character to the document:

  • Construct the appropriate command for the action, i.e CharacterAddedCommand
  • Invoke the commands execute method
  • Push the executed command into your undo stack.

To undo:

  • pop the last executed Command from the undo stack.
  • invoke it's command.undo() method to undo the effects of that same commands execute method - which was executed earlier.

Real-life examples:

Most sophisticated editing program nowadays will mostly be using this pattern.

The "simple" database transaction. In a database transaction you explicitly code what the transaction should do but also how to rollback those actions in case s hits the fan.

If something goes south, you "rollback" or "undo" the transaction. Command Pattern Undo is almost identical, except we save each executed transaction so we can call it's rollback/undo when we want to undo.

Pros:

  • Memory efficient.
  • Capturing a save point is automatic/implicit. No need to explicitly state it before doing some change.

Cons:

  • Clunky to implement. There's a fundamental change into how you interact with the program to execute actions.
  • Inflexible. Every change must now happen via prescribed commands. If you go ahead and mess with the state without going through this command mechanism, there's a chance the commands undos won't work, i.e: removing app.house.tv then trying to undo will throw a ReferenceError: this.house.tv is not defined.

Each action in your software must code inverse actions. Every undoable action in your application must be executed via Commands. For each command you must reason and explicitly code it's executeand undo methods.

Running Examples

The Mechanics of each pattern

Task: Our application, App, manages a House. We make changes on the house. We now want to implement undo functionality so we can revert any changes we make.

The app without undo functionality:

class App {
  constructor() {
    this.house = new House({ tvIsOn: true, wallColor: 'beige' })
  }
}

class House {
  constructor({ tvIsOn, wallColor }) {
    this.tv = new Television({ isOn: tvIsOn })
    this.wallColor = wallColor
  }
}

class Television {
  constructor({ isOn }) {
    this.isOn = isOn
  }
}

const app = new App()

console.log('initial state:', app.house.wallColor, app.house.tv.isOn)
// 'beige', true

/* Mess up the house */

app.house.wallColor = 'red'
app.house.tv.isOn = false
console.log('new state:', app.house.wallColor, app.house.tv.isOn)
// 'red', false

...now it sucks we messed up the house and wish there was a way to undo back to when the walls were beige and the TV was on.

Memento pattern

Implementing undo using the Memento Pattern.

That's easy:

  • Add an App.undoStack, the undo stack to save our mementos.
  • Add a App.captureUndoPoint method that saves the state of the house, as the memento
  • Add an App.undo method that reconstructs a House from the saved mementos.

class App {
  constructor() {
    this.undoStack = []
    this.house = new House({ tvIsOn: true, wallColor: 'beige' })
  }

  captureUndoPoint() {
    // serialize the whole house
    const memento = JSON.stringify(this.house)
    this.undoStack.push(memento)
  }

  undo() {
    const lastMemento = this.undoStack.pop()

    if (lastMemento) {
      const lastState = JSON.parse(lastMemento)
      // reconstruct the whole house back
      this.house = new House({
        tvIsOn: lastState.tv.isOn,
        wallColor: lastState.wallColor
      })
    }
  }
}

class House {
  constructor({ tvIsOn, wallColor }) {
    this.tv = new Television({
      isOn: tvIsOn
    })
    this.wallColor = wallColor
  }
}

class Television {
  constructor({ isOn }) {
    this.isOn = isOn // `true`/`false`, if 'on' or 'off'
  }
}

/* *** Initial State *** */

const app = new App()
app.captureUndoPoint()
console.log('initial state:', app.house.wallColor, app.house.tv.isOn)
// initial state: `beige`, `true`


/* *** Mess up the house *** */

// Mess up the wall color...
app.house.wallColor = 'red'
// and turn off the TV
app.house.tv.isOn = false

console.log('new state:', app.house.wallColor, app.house.tv.isOn)
// new state: `red`, `false`


/* *** Undo back to previous state *** */

app.undo()
console.log('undone state:', app.house.wallColor, app.house.tv.isOn)
// undone state: `beige`, `true`

That was easy, we just JSON.stringify the house, saved it as the memento and used it to reconstruct a new house on undo.

In fact you could go ahead and crash the whole house:

app.house = null

and it wouldn't be a problem since when we undo we recreate it.

Command Pattern

Implementing undo using the Command Pattern

Here it gets tricky. To effect changes on the house, we have to explicitly code the Commands. Each command must code an execute method to perform the action and an undo method to reverse the action.

So we need:

  • The Commands themselves.
  • an undoStack again
  • an App.executeCommand method that executes our commands and saves them in the undoStack
  • an App.undo method that pulls previously executed commands out of the undoStack and calls command.undo.

Here are the 2 new commands:

class ChangeWallColorCommand {
  constructor({ house, currentColor, newColor }) {
    this.house = house
    this.currentColor = currentColor
    this.newColor = newColor
  }

  execute() {
    this.house.wallColor = this.newColor
  }

  undo() {
    this.house.wallColor = this.currentColor
  }
}

class switchTelevisionCommand {
  constructor({ tv, isOn }) {
    this.tv = tv
    this.isOn = isOn
  }

  execute() {
    this.tv.isOn = this.isOn
  }

  undo() {
    this.tv.isOn = !this.isOn
  }
}

Note that Commands must always implement two methods; undo and execute.

These 2 methods must never take arguments. They must use data saved in the instance to perform their work.

...now the complete example:

class App {
  constructor() {
    this.undoStack = []
    this.house = new House({ tvIsOn: true, wallColor: 'beige' })
  }

  executeCommand(command) {
    command.execute()
    this.undoStack.push(command)
  }

  undo() {
    const lastCommand = this.undoStack.pop()

    if (lastCommand)
      lastCommand.undo()
  }
}

class House {
  constructor({ tvIsOn, wallColor }) {
    this.tv = new Television({ isOn: tvIsOn })
    this.wallColor = wallColor
  }
}

class Television {
  constructor({ isOn }) {
    this.isOn = isOn // `true`/`false`, if 'on' or 'off'
  }
}

// Commands

class ChangeWallColorCommand {
  constructor({ house, currentColor, newColor }) {
    this.house = house
    this.currentColor = currentColor
    this.newColor = newColor
  }

  execute() {
    this.house.wallColor = this.newColor
  }

  undo() {
    this.house.wallColor = this.currentColor
  }
}

class switchTelevisionCommand {
  constructor({ tv, isOn }) {
    this.tv = tv
    this.isOn = isOn
  }

  execute() {
    this.tv.isOn = this.isOn
  }

  undo() {
    this.tv.isOn = !this.isOn
  }
}

/* *** Initial State *** */

const app = new App()
console.log('initial state:', app.house.wallColor, app.house.tv.isOn)
// initial state: `beige`, `true`


/* *** Mess up the house *** */

// Mess up the wall color...
const command1 = new ChangeWallColorCommand({
  house: app.house,
  currentColor: app.house.wallColor,
  newColor: 'red'
})

// and turn off the TV
const command2 = new switchTelevisionCommand({
  tv: app.house.tv,
  isOn: false
})

app.executeCommand(command1)
app.executeCommand(command2)
console.log('new state:', app.house.wallColor, app.house.tv.isOn)
// new state: `red`, `false`


/* *** Undo back to previous state *** */

app.undo() // undo command 1
app.undo() // undo command 2
console.log('undone state:', app.house.wallColor, app.house.tv.isOn)
// undone state: `beige`, `true`

At first glance, this looks terrible and convoluted. Why go into the hassle of coding those commands? The Memento pattern looks far more simple.

Well, memory issues. The Memento Pattern takes up a lot of space and doesn't scale well.

Let's look at another example...

Running Examples (memory profile comparison)

Task: We are now implementing a different program, a text editor.

We want to code the feature that allows adding characters in > text. That action must be undoable.

Memento pattern

Here we capture the state as the memento. The current state here is the value of the textarea.

You can see that with 5 lines of codes I've implemented undo that covers all the cases of manipulating the textarea. Adding text, removing text, pasting text etc. It just works.

...but the memory consumption of the Undo Stack grows very large, very quickly.

// The important bits:

const undoStack = []

const captureMemento = () => {
  // the memento is just the value of the textarea!
  const memento = document.querySelector('#textarea').value
  undoStack.push(memento)

  updateUI()
}

const undo = () => {
  const lastMemento = undoStack.pop()

  // reinstate the program state from memento
  if (lastMemento)
    document.querySelector('textarea').value = lastMemento


  updateUI()
}

// Just utilities, event binding etc...

const updateUI = () => {
  const bytes = new Blob([JSON.stringify(undoStack)]).size
  document.querySelector('#bytes').innerText = bytes

  if (undoStack.length)
    document.querySelector('#undo-btn').removeAttribute('disabled')
  else
    document.querySelector('#undo-btn').setAttribute('disabled', true)
}

document.querySelector('#undo-btn')
  .addEventListener('click', () => undo())

document.querySelector('#textarea')
  .addEventListener('keydown', () => captureMemento())
  
// Prevent undo by CTRL + Z 
document.addEventListener('keydown', e => {
  if ((e.metaKey || e.ctrlKe) && e.key === 'z') 
    e.preventDefault()
})

// Just filling with a lot of text ...
document.querySelector('textarea').value = `
Lorem Ipsum is simply dummy text of the printing and typesetting industry.
Lorem Ipsum has been the industry's standard dummy text ever since the
1500s, when an unknown printer took a galley of type and scrambled it to
make a type specimen book. It has survived not only five centuries,
but also the leap into electronic typesetting, remaining essentially
unchanged. It was popularised in the 1960s with the release of Letraset
sheets containing Lorem Ipsum passages, and more recently with desktop
publishing software like Aldus PageMaker including versions of Lorem
Ipsum.
`
textarea {
  width: 100%;
}
<h3> Memento Pattern </h3>
<h4> Click in text. Type characters. Click "Undo" to undo</h4>
<button id="undo-btn" disabled="true">Undo</button>
<label> Undo stack consumes: <strong id="bytes">0</strong>  bytes.</label>
<hr>
<textarea id="textarea" rows="8" cols="6"></textarea>

Since we save a memento on each character addition, our undo stack looks like this when we type "hello":

  • entire-initial-text + 'h'
  • entire-initial-text + 'h' + 'e'
  • entire-initial-text + 'h' + 'e' + 'l'
  • entire-initial-text + 'h' + 'e' + 'l' + 'l'
  • entire-initial-text + 'h' + 'e' + 'l' + 'l' + 'o'

Just typing "Hello" requires 4134 bytes of undo stack size. And the growth curve isn't linear so the more you type the worse it becomes.

On some programs you can get smart here, see comments below on diffing, others not so much; think about a Photoshop-like application where the state is entire high-res images.

Command Pattern

A far more tricky implementantion.

Here have only implemented 1 command, the KeyAddedCommand so the app only takes cares of the case where we want to add characters (and undo that addition).

However, this is much more memory efficient.

That's because we're only saving the commands we executed in the undo stack.

// The important bits:

const undoStack = []

const executeCommand = command => {
  command.execute()
  undoStack.push(command)

  updateUI()
}

const undo = () => {
  const lastCommand = undoStack.pop()
  
  if (lastCommand)
    lastCommand.undo()

  updateUI()
}

// The commands (super important)
// ...actually it's just one command for now

class KeyAddedCommand {
  constructor({
    id,
    key,
    position
  }) {
    this.id = id
    this.key = key
    this.position = position
  }

  execute() {
    const target = document.querySelector('#' + this.id)
    const split = target.value.split('')
    split.splice(this.position, 0, this.key)
    target.value = split.join('')

    target.setSelectionRange(this.position + 1, this.position + 1)
    target.focus()
  }

  undo() {
    const target = document.querySelector('#' + this.id)
    const split = target.value.split('')
    split.splice(this.position, 1)
    target.value = split.join('')

    target.setSelectionRange(this.position, this.position)
    target.focus()
  }
}

// Must implement:
// - KeyRemoved command
// - TextPastedCommand
//
// ...and many others...
document.querySelector('#textarea').addEventListener('keydown', e => {
  e.preventDefault()

  if (e.key.length === 1) {

    const command = new KeyAddedCommand({
      id: e.target.getAttribute('id'),
      key: e.key,
      position: e.target.selectionEnd
    })

    executeCommand(command)

  } else if (e.key === 'Backspace') {

    // Not implemented yet!
    /*
    const command = new KeyRemovedCommand({
      id: e.target.getAttribute('id'),
      position: e.target.selectionEnd
    })

    executeCommand(command)
    */
  }
})

// Just utilities, event bindings etc...

document.querySelector('#undo-btn').addEventListener('click', () => undo())

// Prevent undo by CTRL + Z 
document.addEventListener('keydown', e => {
  if ((e.metaKey || e.ctrlKe) && e.key === 'z') 
    e.preventDefault()
})

const updateUI = () => {
  const bytes = new Blob([JSON.stringify(undoStack)]).size
  document.querySelector('#bytes').innerText = bytes

  if (undoStack.length)
    document.querySelector('#undo-btn').removeAttribute('disabled')
  else
    document.querySelector('#undo-btn').setAttribute('disabled', true)
}

document.querySelector('textarea').value = `
Lorem Ipsum is simply dummy text of the printing and typesetting industry.
Lorem Ipsum has been the industry's standard dummy text ever since the
1500s, when an unknown printer took a galley of type and scrambled it to
make a type specimen book. It has survived not only five centuries,
but also the leap into electronic typesetting, remaining essentially
unchanged. It was popularised in the 1960s with the release of Letraset
sheets containing Lorem Ipsum passages, and more recently with desktop
publishing software like Aldus PageMaker including versions of Lorem
Ipsum.
    `
textarea {
  width: 100%;
}
<h3> Command Pattern </h3>
<h4> Click in text. Type characters. Click "Undo" to undo</h4>
<button id="undo-btn" disabled>Undo</button>
<label> Undo stack consumes: <strong id="bytes">0</strong>  bytes.</label>
<hr>
<textarea id="textarea" rows="8" cols="6"></textarea>
</body>

Now the undo stack looks like this when we type "hello":

  • { command: 'KeyAdded', key: 'h', position: '10' }
  • { command: 'KeyAdded', key: 'e', position: '11' }
  • { command: 'KeyAdded', key: 'l', position: '12' }
  • { command: 'KeyAdded', key: 'l', position: '13' }
  • { command: 'KeyAdded', key: 'o', position: '14' }

Typing "hello" requires just 206 bytes (compared with 4134 bytes of the Memento pattern) and the growth factor here is constant.

So it's much more memory efficient.

But again, a PITA to implement; each action needs to be coded as a command.

Here's the command that adds characters (and undoes the same):

class KeyAddedCommand {
  constructor({ id, key, position }) {
    this.id = id
    this.key = key
    this.position = position
  }

  execute() {
    // code that adds the `this.key` at `this.position`
  }

  undo() {
    // code that remove item at `this.position`
    //
    // in this case, the `key` added in `execute` above.
    // thus, this method negates, reverses or undoes what
    // the `execute` method did.

  }
}

You have to also code the following commands (not an exhaustive list):

  • keyRemovedCommand
  • textPastedCommand

...etc

Gotchas of the Command Pattern

A Command must:

keyword must means it is an absolute requirement, otherwise the mechanism will not work

  • save all the data it needs to perform it's execute/undo as internal state at construction/instantiation.
  • provide an: execute.
  • provide an: undo method.

It's crucial that your execute or undo methods take no parameters. They must use the data they possess as part of their construction to perform their action and/or undo that action.

All the parameters to complete the action must be passed and saved in the instance, when you construct/initialise it.

The method names could instead be apply and rollback but whatever they are, all your commands must expose 2 methods and name them uniformly. You can't have one command with execute/undo methods and another with apply/rollback, obviously.

Good example of a Command:

  • Implements execute and undo methods
  • Data to perform either is saved in the instance, during construction
class switchTelevisionCommand {
  constructor({ tv, isOn }) {
    // GOOD:
    // All data is passed on construction time and saved
    // in the command
    this.tv = tv
    this.isOn = isOn
  }

  // GOOD. 
  // Takes no parameters, uses internal state to
  // execute
  execute() {
    tv.isOn = this.isOn
  }

  // GOOD. 
  // Takes no parameters, uses internal state to
  // execute
  undo() {
    this.tv.isOn = !this.isOn
  }
}

Bad example of a Command:

Data to perform either does not exist in instance and passed as parameter to execute or undo.

class switchTelevisionCommand {
  constructor({ tv }) {
    this.tv = tv
  }

  // BAD: Passing parameters to `execute` or `undo`
  execute(isOn) {
    this.tv.isOn = isOn
  }

  undo() {
    // won't be able to `undo()` later
    // `this.isOn === undefined ``
    this.tv.isOn = !this.isOn
  }
}

What about redo?

Redo is nothing more than saving an undone command into a redoStack. On redo, you pop the redoStack and call the execute method again.

Hope this helps.

Predicant answered 29/1, 2019 at 8:5 Comment(15)
like you wrote i think command pattern are the better choice. Is it possible to make me a little script to see, explain and understand how this should be looking for in js-code?Charged
I'm writing them now, but be advised that they are a gross oversimplification of the formal patterns.Predicant
ok - could you give me a example for the command pattern too? so i can start to learn, did this function works after a pagereload?Charged
Depending on the type of application, a "Memento of commands" might be better. For instance in graphic tools, you can't always have an inverse command, and saving each full state would drain the memory in no time. So storing all the commands and calling them all in sequence on undo is the way to go.Subjacent
@Subjacent Stellar, didn't know about that. Basically you're replaying all the commands until you reach the current state - 1, is that correct?Predicant
.Yes that's it.Subjacent
"Memento of commands" is a great idea! I would suggest a rename though to avoid confusion - I initially confused it to be a list of commands that would help you undo, step-by-step from present to the past. Instead, I'd just call it "command history", and explain: store your starting position (original image in photoshop), and save each command (edits). To Undo, start from the beginning and replay all commands except 1."Incestuous
@BenButterworth This is a neat idea, but may run into performance issues if some of the commands take a long time to complete (i.e. Photoshop filters being applied, etc). In such case, it may be wise to save snapshots of the entire project periodically, especially after long-running commands, thus giving you another starting point to avoid waiting on long commands to complete.Westward
git famously uses the momento pattern. But to avoid excessive use of memory (disk storage) each state is stored as a diff with the previous known state. So it's a memento pattern with delta compression.Essayist
@Essayist I would not equate diffing operations as anything like the memento pattern. A memento needs to capture an entire point in the past, in the memento. Git can smartly get to a point in the past because it stores it's difference but it doesn't store the past itself. That's my understanding of the word memento at least, something akin to the expression "Déjà vou"Predicant
Then again isn't the difference of the past, the past itself? Maybe you're right semantically speaking.Predicant
The more I think about it, the more I think you're right. The small issue in this is that when people talk about the Memento pattern, they usually mean a dumb snapshot, with no need to fuss about the internals. "Just grab it's memento/snapshot and get it over with" is an expression that means just serialise the object and store that text; you don't need to concern yourself about what it does, when and how. Then when you need to undo just tell the object to deserialize itself back to how it was and you're done. There's 0 involvement from the managing code other than that.Predicant
@Predicant The diffing is just an optimisation. The initial version of git stores the entire state of the directory per commit. Linus first got the history management right and only then implemented the diffing as a means to reduce disk space usage. So at its core git is basically a momento patternEssayist
@Predicant Here's a simple demo of undo/redo using momento pattern with diffing and deflate compression to reduce memory footprint of the snapshots: slebetman.net/undo-demo . Note that I'm displaying the history as base64 encoded string but I'm acrually storing it in binary in RAM as Uint8Array. The code for the snapshot+diff+compression can be found here: github.com/slebetman/yaml-compress-test/blob/master/lib/…Essayist
@Essayist That's real nice. That's the stuff I've been working on for the past 5-6 years; collaborative objects and how to efficiently sync/store them between client-server-clients, which crosses into CRDT territory some times. The problem with diffing is that it tends to be computationally expensive in some cases, so you'd rather just send the patches over the wire as they are issued by the user, i.e item-moved, x:30, y:30 rather than allowing a change on the state, then calculating a diff to send over the wire.Predicant

© 2022 - 2024 — McMap. All rights reserved.