Last active
October 14, 2024 01:43
-
-
Save 1j01/bd2329547904b97abc52fd5e76b008d8 to your computer and use it in GitHub Desktop.
Undo/redo history pattern example in JavaScript
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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