Skip to content

Instantly share code, notes, and snippets.

@1j01
Last active October 14, 2024 01:43
Show Gist options
  • Save 1j01/bd2329547904b97abc52fd5e76b008d8 to your computer and use it in GitHub Desktop.
Save 1j01/bd2329547904b97abc52fd5e76b008d8 to your computer and use it in GitHub Desktop.
Undo/redo history pattern example in JavaScript
// first setup our stacks of history states
// (const refers to the reference to the arrays, but they arrays themselves are mutable)
const undos = [];
const redos = [];
// undo and redo are symmetrical operations, so they *could* be factored to use an "undoOrRedo" / "stepHistory" function that takes two stacks as arguments, but it might be clearer as two functions
const undo = () => {
if (undos.length < 1) { return false; }
redos.push(getState());
setState(undos.pop());
return true;
};
const redo = () => {
if (redos.length < 1) { return false; }
undos.push(getState());
setState(redos.pop());
return true;
};
// usage example: undoable(()=> { state.width = 100; });
// (this also supports calling undoable with no action function, before
// continuously changing the state (e.g. adding points to a brush stroke),
// but this is not recommended; it would be better to, for instance,
// have a separate state for a brush stroke in progress,
// and add it to the main state as an undoable step when it's finished;
// this would also let you cancel the operation simply)
const undoable = (synchronousAction) => {
// you may also want to mark the document as unsaved here
// DESTROY any redos - this is the common practice, but let's examine this assumption later
redos.length = 0;
undos.push(getState());
if (synchronousAction) {
synchronousAction();
onStateChanged();
}
return true;
};
// --- now interface with the application ---
let state = {
// if you're going to save files based on the serialized state OR persist it in localStorage,
// you should include a version number, so old states can be upgraded,
// and the user can be told when old states aren't supported
// for an example of this pattern, see https://github.com/1j01/mopaint/blob/2db35857fbbb76980ce1229dadef531bf5147fc6/src/components/App.js#L60
// (that one TODO comment is accidentally left in there, it's done with `nounPhraseThingToLoad`)
// format: "my-app",
// formatVersion: 1,
width: 640,
height: 480,
strokes: []
};
const onStateChanged = () => {
// could update any derived state here to ensure consistency when saving
// or better yet, keep derived state out of `state`!
// (derived state would be, for instance, in a traffic simulator, if the user places roads,
// but trees are filled in automatically as decoration -
// if the trees are a function of the roads, they are derived state)
// you should probably save the user's data:
/*
// Note: this doesn't handle multiple tabs / instances of the app.
// Only the last updated one will "win", and data in any other instances will be lost.
try {
localStorage.appState = getState();
} catch (error) {
// show a warning here that data isn't saved
// but don't use alert() or anything that obtrusive
// (the user should still be able to use the tool with site data turned off in their browser,
// but just be made aware that their data isn't safe (and why))
}
*/
// alternatively you could render in an animation loop (i.e. constantly),
// or you could use a pattern like Observables to react to state changes
render();
};
// serialize the state
const getState = () => JSON.stringify(state);
// deserialize and apply a state
const setState = (stateJSON) => {
state = JSON.parse(stateJSON);
onStateChanged();
};
// (you could swap out JSON for a library like ARSON if you need cyclic references, etc.)
const render = () => {
// TODO (render based on state)
};
/*
// load from storage
try {
if (localStorage.appState) {
setState(localStorage.appState);
}
} catch(err) { }
*/
render();
// some example usage
console.log(state);
undoable(()=> { state.width = 100; });
console.log(state);
undo();
console.log(state);
redo();
console.log(state);
// TODO: hook up Ctrl+Z to undo(), and both Ctrl+Y and Ctrl+Shift+Z to redo()
// (Ctrl+Y is conventional in Windows, and Ctrl+Shift+Z is very common across platforms because it's related to Ctrl+Z and easy to press with one hand)
// (For macOS it would be Cmd+Z and Cmd+Shift+Z)
// Recommended: also create toolbar buttons with tooltips that show the keyboard shortcut
// TODO: don't destroy history as is the common practice, but instead provide nonlinear history -
// keep a tree of states and show this tree to the user when they attempt to redo after undoing and doing something other than undo/redo;
// that way they can at least get back to any state, if not merge states after diverging, which could be complicated;
// also provide a way to show the tree at any time, perhaps ctrl+shift+y
// TODO: include undo/redo history in localStorage, and give the user a (clear) way to clear it
// TODO: for autosave, to avoid conflicts with multiple instances of the app, have "sessions"
// and add UI for managing sessions (e.g. in jspaint, File > Manage Storage).
// (You could maybe, on app start, look for existing sessions,
// and try to communicate with other tabs (with a SharedWorker? BroadcastChannel API?)
// to see what sessions are open (with some timeout on messaging response time),
// and present UI to "recover" sessions that are not open.)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment