Coding a project that needs an undo-redo feature? Me too – here’s my thinking from the end of the development on the subject based on my learnings coding a rich text editor.
Let’s take your code editor as an example. Whether it’s VS Code or NotePad++, if you you type a letter then delete it again with the backspace key, you can undo both of those actions, in sequence. From this we can conclude that the editor ‘remembers’ what you did. It has more than a simple array or string of what you see in its model – it has some kind of list of changes to the model that can be applied and unapplied, then applied again. Huh?
Think of your bank account. It has a balance – the amount in the account at any time. We are all familiar with the idea of a ‘transaction’ against the account. If I take out £10 then that is a debit transaction, and if I add £30 then that is a credit. I can see all of the individual transactions over a period of time listed in my bank statement. I can look at the transactions and calculate what the balance would be if I were to ‘undo’ a transaction.
So these transactions are individual little messages that are applied to the bank account. This is how to think about your project. Whatever the ‘target’ of the changes in your project, any change to the target should be applied via a simple message. These can be collected into a list in the sequence of their creation. To apply the change encoded in a message to your target you ‘do’ the message. To process an ‘undo’ you only have to reverse the impact of the last message. This gives you a do-undo-redo stack.
In the case of the code editor, you have the internal list of the characters on display – lets call it the char list. When you press a key – lets say the letter x – to add a letter, this is wrapped in a simple message that says ‘At position 0 add letter x’. This message is applied to the char list which will now contain the new letter. This message is added to the undo stack. Next you press the backspace key – this time the message is ‘At position 1 removed one letter to the left’. This message is applied, your letter x is removed from the char list, and it is added to the undo stack.
If we call for an undo we just go to the top of the undo stack and read the message, then apply the reverse of the instructions in the message, and importantly we move the ‘last message’ pointer down the undo stack so that it points to the previous message. In this way, we always know the next message to be undone when an undo request is made. Redo is the same in reverse – advance the last message pointer one position up the stack from the current position, and carry out the change.
The actual contents of your message will vary to suit the project requirements. In my case of a rich text editor, I am storing two states in each message, one for the ‘before’ picture and one for the ‘after’ picture.
These pictures hold only
- the caret position (the text insert curor),
- a list of selected characters that the change will affect, if there is a selection,
- a codified action description. Example: ‘document-key-click’
If the message is being applied for the first time – a ‘do’ – or re-applied as a ‘redo’ then I use the ‘after’ picture to change the view as it would look after the change is applied. If the message is an ‘undo’ then I apply the ‘before’ picture of the change.
Just to be clear – in this rich text editor app I am developing I am not storing the entire list of chars in these messages – only the chars that are being changed. Otherwise if a user were editing War & Peace the undo stack would consume a lot of memory! But in some use cases it might be sensible to do just that – store a whole before and after image of whatever your project presents. I’ll leave that to you to decide for yourself.
Summary
The conclusion of this thinking is that you should start out thinking about transactional messages and an internal API as the way to approach your project. You don’t have to go all full-blown Enterprise Service Bus and learn all the jargon. All you need to do is think about packaging up any change your user can make into a little message, and having a way to ‘apply’ that message to the target of your project. This thinking will help you construct a good framework of functionality in your code that will be robust, extensible and maintainable. There are a couple of other benefits, in that logging the changes is easy since they all pass through common functions as the messages are received into your target, and writing complex tests can be vastly simplified from the nightmare that it would otherwise be.
Thanks for reading.
Photo by Suzy Hazelwood.
VW Oct 2022