How can you support Undo and Redo natively for elements that don't support it by default? Think drag and drop reordering, games, or rich text editors where the built-in support just doesn't cut it. 🙅✂️📄
This is possible today using a bit of logic coupled with the execCommand
method. First, a demo! Click/focus the maze, and navigate with your arrow keys (or use the buttons). Most importantly, be sure to try Undo and Redo to go back and forward.
There are a few ways to Undo—you probably tried Ctrl/Cmd-Z
, but you can also use your browser's Undo menu item or right click on the page. On Mobile Safari 📱🤠, shake 👋 your phone to Undo ↩️.
(I'm not sure how to Undo on other mobile browsers.)
If you're just here for the code rather than the post, I've built an
Undoer
class, which provides helpers for native undo/redo. Check it out here ⤵️💻
Why
If you're building an interactive app-like experience for the web, you should aim to provide as many built-in UX flows as possible, so that your users can be productive. These might include supporting the ESC
key, the browser's inbuilt navigation buttons, drag and drop, or even native sharing via the Web Share API—all idioms that even less savvy technical users are used to.
How
Under the hood, the maze above is actually modifying a text field—as a user would—and watching to see if it's value changes back to a previously recorded state. What does that mean?
As we make changes (the blue boxes above), we secretly do update a 'hidden' text field with a new number, which is an index into an array recording the position of the player.
When the player performs an Undo (the red boxes), we pop the last event off the stack, and call the maze back with the previous state. In this case, state 1:
{x:2, y:1}
.The later states remain (in case the user calls Redo) until the user performs another action, when we erase them.
Build It
Let's plan out the flows. When the user uses their keyboard, we handle the event—both updating the maze and pushing a new state. And when the user performs undo or redo, we detect the change and restore the maze to whatever previous state we expect.
Ok! Let's build it.
1. The 'hidden' text field
We create a text field as normal, and append it to the page. We don't use display: none
or visibility: hidden
to hide the field—hiding the element "properly" makes it ineligible for a browser's normal undo/redo stack. So in order to make it feel unusable, we:
- set its
tabIndex
to -1, which removes it from normal tab order - add a
focus
handler which immediately blurs the element, so it never retains focus - can set its
opacity
/position
such that the element isn't visible—although while you're testing, it can be good to keep it around
Here's how we add the undoer
element:
const undoer = document.createElement('input');
undoer.value = '0';
undoer.tabIndex = -1; // don't allow automatic tab
undoer.addEventListener('focus', () => {
window.setTimeout(() => undoer.blur(), 0); // prevent focus, delay for Safari
});
// You could hide it (but not with `display: none`—it must be on the page):
//undoer.style.opacity = 0;
//undoer.style.position = 'absolute';
document.body.appendChild(undoer);
Q: This input might confuse accessible user-agents. Please leave me a comment if you have ideas on how to solve this for screen readers etc!
2. Pretending to enter input
Let's create a method pushNewUndoState
to push a new state onto the stack, and record these states. This is the key part of this post—we use execCommand
to fake a user input. The code below pushes a new state onto the stack, and "types" the next number:
let duringInput = false;
const undoStack = [{x: 1, y: 1}]; // we have to start with our initial state
function pushNewUndoState(data) {
// remove states past now, add our new state
const nextStateId = +undoer.value + 1;
undoStack.splice(nextStateId, undoStack.length - nextStateId, data);
duringInput = true;
// focus and "type" the next number
undoer.focus();
document.execCommand('selectAll');
document.execCommand('insertText', false, nextStateId);
duringInput = false;
}
The document.execCommand
is very powerful and allows us to emulate user input on the page. In this case, we just want to select the current number and type a new number. The browser is smart enough to coalesce these two unique actions into undo "undoable" event.
Note that:
We focus on the
undoer
, but this is prevented by the event handler we set up initiallyYou could add code to refocus on the previous
document.activeElement
, if you're using focus in your application: otherwise the focus will be lostThe
.splice
method removes any states past our current one. This happens if the user undoes things, then performs a new action—it removes the possible redo actions.
3. Listening to input events
Finally, let's listen to changes in the input so we can trigger a callback letting the maze know the user wants to revert their position:
undoer.addEventListener('input', (ev) => {
// nb. don't use 'change', it doesn't fire in all browsers
if (!duringInput) {
const data = undoStack[+undoer.value];
updateMazeWithData(data);
}
});
We don't want to trigger a callback duringInput
, aka, when the pushNewUndoState
method above is doing its work. We only want to know when the input has changed if the user performs an undo or redo.
4. Special-case for Firefox
Firefox has a long-standing issue that means that execCommand
is only supported in a contentEditable
. Supporting this is actually fairly easy—you can just swap out the 'hidden' input
for an editable div
, and use .textContent
instead of .value
—phew. That's explanation enough, but I've left the code changes out of this post.
The code I've included with this blog post just uses <div contentEditable>
, but you could choose either approach. 🤔
🎉 That's all there is to it, but read on for some additional thoughts ⬇️
Extras
Keen observers might note that this is a very similar model to the History API, which uses history.pushState
and window.onpopstate
.
The onpopstate
method is actually badly named—it is called whether the state is popped (the Back button) or pushed (the Forward action). The analogy in using native undo/redo is where we listen to the input
event of our 'hidden' text input.
Intercepting Undo & Redo for text fields
This approach to undo and redo can be extended to work on text fields or elements which generate their own undo events. Why would you want to do this? As I mentioned in the very first paragraph, maybe you're building a rich text editor, and undo and redo isn't doing exactly what you want.
You can do this by using execCommand
to immediately undo a user action, setting the updated value programmatically (which doesn't generate an undoable event) and then replace it with your own event, via our method pushNewUndoState
.
For instance, we can piggyback the input
event:
theInput.addEventListener('input', (ev) => {
const currentValue = theInput.value;
document.execCommand('undo'); // undo this change
theInput.value = currentValue; // ... but immediately reset the value
// TODO: You might want to persist the selection position.
pushNewUndoState(currentValue); // ... our special state to keep
});
⚠️ This works well, but be careful with this approach! Unless you're sure that the user just changed the element you care about, you might be undoing some other user action on your page.
The undo keyboard shortcuts
Many examples of custom undo & redo on the web use keydown
handlers to detect whether the user is entering Ctrl/Cmd-Z
, and performing a special action. This usually works but has two challenges:
It doesn't capture undo and redo through a context menu, or through the browser's menu bar
It's not accessible: if a user has rebound the keys to something else, or uses an alternative input device to undo or redo
These two reasons are fundamentally why it's worth making the browser work for us—through our 'hidden' input.
Thanks
Thanks for reading! As I mentioned above, I've turned this knowledge into a class you can use in your own apps, but hopefully the idioms above are simple enough to apply directly within your own code too. Check the 'undoer' package out here ⤵️💻.
Ask me questions here or find me on Twitter.
Acknowlegements
- Some icons from Icons8.com, used under CC BY-ND.
- Emoji by Emojityper.