├── .nvmrc ├── bin ├── bld └── bld.config.ts ├── jestConsole.ts ├── .yarnrc.yml ├── wallaby.js ├── .gitignore ├── src ├── Api.ts ├── test │ ├── Utils.test.ts │ └── UndoRedo.test.ts ├── Hooks.ts ├── Middleware.ts ├── Utils.ts ├── Actions.ts └── HistoryStore.ts ├── jest.config.cjs ├── watch-tsconfig.json ├── tsconfig.json ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 14.9.0 2 | -------------------------------------------------------------------------------- /bin/bld: -------------------------------------------------------------------------------- 1 | ../node_modules/.bin/tys -------------------------------------------------------------------------------- /jestConsole.ts: -------------------------------------------------------------------------------- 1 | import { CustomConsole } from "@jest/console"; 2 | 3 | global.console = new CustomConsole(process.stdout, process.stderr); 4 | -------------------------------------------------------------------------------- /bin/bld.config.ts: -------------------------------------------------------------------------------- 1 | import { TysConfig } from "tys"; 2 | 3 | export default { 4 | tsFile: "build.ts", 5 | strict: false, 6 | } as TysConfig; 7 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 5 | spec: "@yarnpkg/plugin-interactive-tools" 6 | 7 | yarnPath: .yarn/releases/yarn-3.1.1.cjs 8 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = () => ({ 2 | autoDetect: true, 3 | trace: true, 4 | files: [ 5 | "src/**/*.ts", 6 | { pattern: "**/src/**/*.test.ts", ignore: true }, 7 | ], 8 | tests: ["src/**/*.test.ts"], 9 | trace: true, 10 | }); 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | .vscode/ 3 | .DS_Store 4 | *.log 5 | web_modules/ 6 | web/ 7 | build/ 8 | .yalc/ 9 | tmp/ 10 | ts-out/ 11 | 12 | # yarn stuff 13 | node_modules/ 14 | .pnp.* 15 | .yarn/* 16 | !.yarn/patches 17 | !.yarn/plugins 18 | !.yarn/releases 19 | !.yarn/sdks 20 | !.yarn/versions -------------------------------------------------------------------------------- /src/Api.ts: -------------------------------------------------------------------------------- 1 | import { undoRedo } from "./Middleware"; 2 | import { 3 | KeyPathFilter, 4 | undoable, 5 | WithUndo, 6 | ActionStateFilter, 7 | } from "./Actions"; 8 | import { useUndoGroup, useUndoIgnore } from "./Hooks"; 9 | 10 | export { undoRedo, undoable, useUndoGroup, useUndoIgnore }; 11 | 12 | export type { WithUndo, ActionStateFilter, KeyPathFilter }; 13 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | verbose: true, 5 | setupFilesAfterEnv: ["./jestConsole.ts"], 6 | testMatch: ["**/src/**/*.test.ts"], 7 | collectCoverageFrom: ["**/*.ts", "!**/node_modules/**", "!tmp/**"], 8 | testPathIgnorePatterns: ["/node_modules/", "/tmp/"], 9 | setupFiles: ["jest-localstorage-mock"], 10 | }; 11 | -------------------------------------------------------------------------------- /watch-tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "strict": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "module": "ES2020", 10 | "moduleResolution": "node", 11 | "downlevelIteration": true, 12 | "noEmit": true 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules"] 16 | } 17 | -------------------------------------------------------------------------------- /src/test/Utils.test.ts: -------------------------------------------------------------------------------- 1 | import { should } from "chai"; 2 | import _ from "lodash"; 3 | import { copyFiltered } from "../Utils"; 4 | 5 | should(); 6 | 7 | test("remove functions", () => { 8 | const src: any = { 9 | foo: "bar", 10 | deep: { fi: "fi" }, 11 | deepArray: [{ fo: "fo" }], 12 | }; 13 | const withFun = _.cloneDeep(src); 14 | withFun.fun = () => {}; 15 | withFun.deep.fun = () => {}; 16 | withFun.deepArray[0].fun = () => {}; 17 | withFun.should.deep.not.equal(src); 18 | 19 | const filtered = copyFiltered(withFun, _.isFunction); 20 | filtered.should.deep.equal(src); 21 | filtered.should.not.equal(src); 22 | }); 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "emitDeclarationOnly": true, 6 | "declaration": true, 7 | "allowJs": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "esModuleInterop": true, 12 | "module": "ES2020", 13 | "moduleResolution": "node", 14 | "downlevelIteration": true, 15 | "noEmit": false, 16 | "importsNotUsedAsValues": "remove", 17 | "sourceMap": true, 18 | "outDir": "dist" 19 | }, 20 | "files": ["src/Api.ts"], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /src/Hooks.ts: -------------------------------------------------------------------------------- 1 | import { createTypedHooks } from "easy-peasy"; 2 | import { WithUndo } from "./Actions"; 3 | 4 | const typedHooks = createTypedHooks(); 5 | 6 | const useStoreActions = typedHooks.useStoreActions; 7 | 8 | export function useUndoGroup(): (fn: () => T, msg?: string) => T { 9 | const undoGroupStart = useStoreActions((model) => model.undoGroupStart); 10 | const undoGroupComplete = useStoreActions((model) => model.undoGroupComplete); 11 | 12 | return function undoGroup(fn: () => T, msg?: string): T { 13 | let result: T; 14 | 15 | undoGroupStart(msg); 16 | try { 17 | result = fn(); 18 | } finally { 19 | undoGroupComplete(); 20 | } 21 | return result; 22 | }; 23 | } 24 | 25 | export function useUndoIgnore(): (fn: () => T, msg?: string) => T { 26 | const undoGroupStart = useStoreActions((model) => model.undoGroupStart); 27 | const undoGroupIgnore = useStoreActions((model) => model.undoGroupIgnore); 28 | 29 | return function undoGroup(fn: () => T, msg?: string): T { 30 | let result: T; 31 | 32 | undoGroupStart(msg); 33 | try { 34 | result = fn(); 35 | } finally { 36 | undoGroupIgnore(); 37 | } 38 | return result; 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/Middleware.ts: -------------------------------------------------------------------------------- 1 | import { AnyAction, Dispatch, Middleware, MiddlewareAPI } from "redux"; 2 | 3 | /** @returns redux middleware to support undo/redo actions. 4 | * 5 | * The middleware does two things: 6 | * 7 | * 1) for undo/redo actions, the middlware does nothing. The undo/redo reducer will handle those. 8 | * 9 | * 2) for normal actions, the middeware dispatches an additional undoSave action to 10 | * follow the original action. The reducer for the undoSave action will save the state 11 | * in undo history. 12 | */ 13 | export function undoRedo(): Middleware { 14 | const result = (api: MiddlewareAPI) => (next: Dispatch) => ( 15 | action: AnyAction 16 | ) => { 17 | if (alwaysSkipAction(action.type) || undoAction(action.type)) { 18 | return next(action); 19 | } else { 20 | const prevState = api.getState(); 21 | const result = next(action); 22 | api.dispatch({ 23 | type: "@action.undoSave", 24 | payload: { action, prevState }, 25 | }); 26 | return result; 27 | } 28 | }; 29 | 30 | return result; 31 | } 32 | 33 | function alwaysSkipAction(actionType: string): boolean { 34 | return actionType === "@action.ePRS" || actionType === "@@INIT"; 35 | } 36 | 37 | function undoAction(actionType: string): boolean { 38 | return ( 39 | actionType === "@action.undoSave" || 40 | actionType === "@action.undoReset" || 41 | actionType === "@action.undoUndo" || 42 | actionType === "@action.undoRedo" || 43 | actionType === "@action.undoGroupStart" || 44 | actionType === "@action.undoGroupIgnore" || 45 | actionType === "@action.undoGroupComplete" 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "undo-peasy", 3 | "version": "0.6.3", 4 | "main": "dist/Api.js", 5 | "module": "dist/Api.ts", 6 | "types": "dist/Api.d.ts", 7 | "exports": { 8 | "import": "./dist/Api.js" 9 | }, 10 | "files": [ 11 | "dist", 12 | "package.json" 13 | ], 14 | "dependencies": { 15 | "fast-json-patch": "^3.1.1", 16 | "lodash": "^4.17.21" 17 | }, 18 | "scripts": { 19 | "test": "jest", 20 | "format": "prettier \"src/**/*.{js,jsx,ts,tsx,css}\" --write", 21 | "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"", 22 | "build": "bin/bld prod", 23 | "dist": "bin/bld prod", 24 | "dev": "bin/bld" 25 | }, 26 | "devDependencies": { 27 | "@skypack/package-check": "^0.2.2", 28 | "@types/chai": "^4.3.3", 29 | "@types/fast-json-patch": "^1.1.5", 30 | "@types/jest": "^29.0.3", 31 | "@types/lodash": "^4.14.185", 32 | "@types/node-localstorage": "^1.3.0", 33 | "@typescript-eslint/eslint-plugin": "^5.38.0", 34 | "@typescript-eslint/parser": "^5.38.0", 35 | "chai": "^4.3.6", 36 | "chokidar": "^3.5.3", 37 | "easy-peasy": "^5.1.0", 38 | "esbuild": "^0.15.8", 39 | "esbuild-node-externals": "^1.5.0", 40 | "eslint": "^8.23.1", 41 | "eslint-config-prettier": "^8.5.0", 42 | "eslint-plugin-prettier": "^4.2.1", 43 | "fs-extra": "^10.1.0", 44 | "immer": "^9.0.15", 45 | "jest": "^29.0.3", 46 | "jest-localstorage-mock": "^2.4.22", 47 | "prettier": "^2.7.1", 48 | "react": "^18.2.0", 49 | "ts-jest": "^29.0.1", 50 | "typescript": "^4.8.3", 51 | "tys": "^0.2.5", 52 | "yalc": "^1.0.0-pre.53" 53 | }, 54 | "peerDependencies": { 55 | "easy-peasy": "^5.0.4" 56 | }, 57 | "description": "undo/redo for easy peasy", 58 | "repository": { 59 | "type": "git", 60 | "url": "https://github.com/mighdoll/undo-peasy.git" 61 | }, 62 | "author": "lee ", 63 | "homepage": "https://github.com/mighdoll/undo-peasy#readme", 64 | "license": "MIT", 65 | "keywords": [ 66 | "react", 67 | "redux", 68 | "state", 69 | "typescript", 70 | "easy-peasy" 71 | ], 72 | "packageManager": "yarn@3.1.1" 73 | } 74 | -------------------------------------------------------------------------------- /src/Utils.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | 3 | type ObjOrArray = object | Array; 4 | 5 | /** Return a copy of an object with some fields elided. 6 | * 7 | * @param fn skip properties for which this function returns true 8 | */ 9 | export function copyFiltered( 10 | src: ObjOrArray, 11 | fn: (value: any, key: string, path: string[]) => boolean 12 | ): ObjOrArray { 13 | return filterCopyRecurse(src, []); 14 | 15 | function filterCopyRecurse(obj: ObjOrArray, path: string[]): ObjOrArray { 16 | if (_.isArray(obj)) { 17 | return obj.map((elem) => filterCopyRecurse(elem, path)); 18 | } 19 | 20 | if (_.isObject(obj)) { 21 | const filtered = Object.entries(obj).filter( 22 | ([key, value]) => !fn(value, key, path) 23 | ); 24 | const copies = filtered.map(([key, value]) => [ 25 | key, 26 | filterCopyRecurse(value, path.concat([key])), 27 | ]); 28 | return Object.fromEntries(copies); 29 | } 30 | 31 | return obj; 32 | } 33 | } 34 | 35 | /** replace undefined fields with a default value */ 36 | export function replaceUndefined, U>( 37 | obj: T, 38 | defaults: U 39 | ): T & U { 40 | const result = { ...defaults, ...removeUndefined(obj) }; 41 | return result; 42 | } 43 | 44 | /** @return a copy, eliding fields with undefined values */ 45 | export function removeUndefined(obj: T): T { 46 | const result = { ...obj }; 47 | for (const key in result) { 48 | if (result[key] === undefined) { 49 | delete result[key]; 50 | } 51 | } 52 | return result; 53 | } 54 | 55 | export interface AnyObject { 56 | [key: string]: any; 57 | } 58 | 59 | /** @return the paths of all computed properties nested in an easy peasy model instance */ 60 | export function findModelComputeds( 61 | src: AnyObject, 62 | pathPrefix: string[] = [] 63 | ): string[][] { 64 | const result = Object.entries(src).flatMap(([key, value]) => { 65 | if (isComputedField(value)) { 66 | 67 | const getter = [pathPrefix.concat([key])]; 68 | return getter; 69 | } else if (_.isPlainObject(value)) { 70 | return findModelComputeds(value, pathPrefix.concat([key])); 71 | } else { 72 | return []; 73 | } 74 | }); 75 | return result; 76 | } 77 | 78 | export const computedSymbol = "$_c"; 79 | 80 | function isComputedField(value: unknown): boolean { 81 | if (_.isPlainObject(value)) { 82 | return (value as AnyObject)[computedSymbol] !== undefined; 83 | } else { 84 | return false; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Undo/Redo support for [easy peasy](https://easy-peasy.now.sh/). 2 | 3 | `undo-peasy` 4 | 5 | - automatically saves a history of state changes made in your application. 6 | - provides ready to use undo and redo actions. 7 | 8 | ## Usage 9 | 10 | 1. Attach `undoRedo` middlware in `createStore`. 11 | The middleware will automatically save every state change made to an `undoable` model. 12 | `const store = createStore(appModel, { middleware: [undoRedo()], });` 13 | 1. If using typescript, extend `WithUndo` in the root application model. 14 | `WithUndo` will add types for undo actions and metadata. 15 | `interface Model extends WithUndo { count: number; increment: Action; }` 16 | 1. Use `undoable` to wrap the root application model instance. 17 | `undoable()` will make undo/redo actions available and save state changes forwarded by the middleware. 18 | `const appModel: Model = undoable({ count: 0, increment: action((state) => { state.count++; }), });` 19 | 1. Profit 20 | ``` 21 | const undo = useStoreActions((actions) => actions.undoUndo); 22 | ``` 23 | 24 | ## Supported Actions 25 | 26 | - **`undoUndo`** - restore state to the most recently saved version. 27 | - **`undoRedo`** - restore state to the most recently undone version. 28 | - `undoGroupStart` - start a group, no states will be saved until group completes. 29 | - `undoGroupComplete` - complete a group of changes and save state. 30 | - `undoGroupIgnore` - complete a group of changes and don't save state. 31 | - `undoReset` - erases saved undo/redo history and saves the current state. 32 | - `undoSave` - save current application state to undo history. 33 | (undoSave is generated automatically by the middleware.) 34 | 35 | ## Configuration 36 | 37 | The `undoable()` function accepts an optional configuration object as its second parameter. 38 | 39 | - `maxHistory` - maximum number of history states to save. The oldest states are dropped to prevent the history from growing without bounds. 40 | - `noSaveKeys` - a function that tells undoRedo not to save certain keys inside the state model 41 | to undo/redo history. e.g. view state in the model. 42 | - `skipAction` - a function that tells undoRedo not to save state after user specified actions 43 | or state conditions. 44 | - `logDiffs` - set to true to see some debug logging about changes to undo state 45 | 46 | History is persisted in the browser's localStorage. 47 | 48 | ## Hooks 49 | 50 | `useUndoGroup()` - returns a function that can be used to group a related set of changes into 51 | one undo/redo state. 52 | 53 | ``` 54 | const undoGroup = useUndoGroup(); 55 | 56 | undoGroup(() => { 57 | /* state changes in here will be saved as a single undo/redo state */ 58 | }); 59 | ``` 60 | 61 | `useUndoIgnore()` - returns a function that can be used to defer a set of changes. 62 | 63 | ``` 64 | const undoIgnore = useUndoIgnore(); 65 | 66 | undoIgnore(() => { 67 | /* state changes in here will be deferred, and not stored as separate undo/redo entries */ 68 | }); 69 | 70 | /* The next recorded change will combine with the deferred state into a single undo/redo entry */ 71 | ``` 72 | -------------------------------------------------------------------------------- /src/Actions.ts: -------------------------------------------------------------------------------- 1 | import { Action, action, State } from "easy-peasy"; 2 | import _ from "lodash"; 3 | import { AnyAction } from "redux"; 4 | import { HistoryStore, historyStore } from "./HistoryStore"; 5 | import { AnyObject, copyFiltered, findModelComputeds } from "./Utils"; 6 | 7 | /** Implementation strategy overview for undo/redo 8 | * 9 | * Add easy-peasy actions for undo and redo. (Also add actions for reset and save, though these 10 | * aren't typically needed by users.) 11 | * 12 | * Store a stack of undo/current/redo states. These are stored as json 13 | * strings in the browser's localStorage key value store. 14 | * 15 | * Middleware to automatically trigger the save action after other 16 | * easy-peasy or redux actions. 17 | * 18 | * Note that computed properties and view properties specified by the programmer are not 19 | * saved. Computed and view properties are merged into the current app state 20 | * on undo/redo. 21 | */ 22 | 23 | /** 24 | * WithUndo specifies some actions and state to support Undo/Redo on your easy-peasy 25 | * model type. 26 | * 27 | * The root application model interface should extend WithUndo. 28 | */ 29 | export interface WithUndo { 30 | undoSave: Action; 31 | undoReset: Action; 32 | undoUndo: Action; 33 | undoRedo: Action; 34 | undoGroupStart: Action; 35 | undoGroupComplete: Action; 36 | undoGroupIgnore: Action; 37 | } 38 | 39 | interface ActionAndState { 40 | action: AnyAction; 41 | prevState?: AnyObject; 42 | } 43 | 44 | export type KeyPathFilter = (key: string, path: string[]) => boolean; 45 | 46 | export interface UndoOptions { 47 | /** save no more than this many undo states */ 48 | maxHistory?: number; 49 | 50 | /** don't save state keys matching this filter (e.g. transient view in the state) */ 51 | noSaveKeys?: KeyPathFilter; 52 | 53 | /** set to true to log each saved state */ 54 | logDiffs?: boolean; 55 | 56 | /** set to true to log grouping levels */ 57 | logGroups?: boolean; 58 | 59 | /** return true for actions that should not be saved into undo history */ 60 | skipAction?: ActionStateFilter; 61 | } 62 | 63 | export type ActionStateFilter = ( 64 | state: State, 65 | action: AnyAction 66 | ) => boolean; 67 | 68 | /** 69 | * Extend a model instance with undo actions and metadata 70 | * 71 | * The root application model should be wrapped in undoable(). 72 | * @param model application model 73 | */ 74 | export function undoable( 75 | model: M, 76 | historyOptions?: UndoOptions 77 | ): ModelWithUndo { 78 | const { model: modelWithUndo } = undoableModelAndHistory( 79 | model, 80 | historyOptions 81 | ); 82 | return modelWithUndo; 83 | } 84 | 85 | export interface EnrichedModel { 86 | model: ModelWithUndo; 87 | history: HistoryStore; 88 | } 89 | 90 | export interface ControlApi { 91 | undoGroup: (fn: () => T) => T; 92 | undoPause: () => void; 93 | undoContinue: () => void; 94 | } 95 | 96 | const skipNoKeys = (_str: string, _path: string[]) => false; 97 | 98 | /** (internal api for undoable(), exposes more for testing) 99 | * 100 | * extend a model instance with undo actions and metadata, and also return 101 | * the history store. 102 | */ 103 | export function undoableModelAndHistory( 104 | model: M, 105 | options?: UndoOptions 106 | ): EnrichedModel { 107 | const computeds = findModelComputeds(model); 108 | const history = historyStore(filterState, options); 109 | const noSaveKeys = options?.noSaveKeys || skipNoKeys; 110 | const skipAction = options?.skipAction || (() => false); 111 | let grouped = 0; 112 | 113 | const undoSave = action( 114 | (draftState, actionState) => { 115 | if (options?.logGroups) { 116 | console.log("undo save, group level:", grouped); 117 | } 118 | if (grouped === 0) { 119 | if (actionState) { 120 | const { action, prevState } = actionState; 121 | save(draftState, action, prevState); 122 | } else { 123 | save(draftState, { type: "@manualsave" }); 124 | } 125 | } 126 | } 127 | ); 128 | 129 | function save( 130 | draftState: AnyObject, 131 | action: AnyAction, 132 | prevState?: AnyObject 133 | ) { 134 | const state = filterState(draftState) as State; 135 | if (!skipAction(state, action)) { 136 | if (prevState && !history.initialized()) { 137 | const prevFiltered = filterState(prevState); 138 | history.save(state, prevFiltered); 139 | } else { 140 | history.save(state, prevState); 141 | } 142 | } 143 | } 144 | 145 | const undoReset = action((draftState) => { 146 | const state = filterState(draftState); 147 | history.reset(state); 148 | grouped = 0; 149 | if (options?.logGroups) { 150 | console.log("undo reset, group level:", grouped); 151 | } 152 | }); 153 | 154 | const undoUndo = action((draftState) => { 155 | const undoState = history.undo(draftState); 156 | if (undoState) { 157 | Object.assign(draftState, undoState); 158 | } 159 | }); 160 | 161 | const undoRedo = action((draftState) => { 162 | const redoState = history.redo(draftState); 163 | if (redoState) { 164 | Object.assign(draftState, redoState); 165 | } 166 | }); 167 | 168 | const undoGroupStart = action((_, msg) => { 169 | grouped++; 170 | if (options?.logGroups) { 171 | const definedMessage = msg !== undefined ? msg : ""; 172 | console.log("group start, group level:", grouped, definedMessage); 173 | } 174 | }); 175 | 176 | const undoGroupComplete = action((draftState) => { 177 | grouped--; 178 | if (grouped <= 0) { 179 | grouped = 0; 180 | save(draftState, { type: "@action.undoGroupComplete" }, draftState); 181 | } 182 | if (options?.logGroups) { 183 | console.log("group complete, group level:", grouped); 184 | } 185 | }); 186 | 187 | const undoGroupIgnore = action((draftState) => { 188 | grouped--; 189 | if (grouped <= 0) { 190 | grouped = 0; 191 | } 192 | if (options?.logGroups) { 193 | console.log("group ignore, group level:", grouped); 194 | } 195 | }); 196 | 197 | const modelWithUndo = { 198 | ...model, 199 | undoSave, 200 | undoUndo, 201 | undoRedo, 202 | undoReset, 203 | undoGroupStart, 204 | undoGroupComplete, 205 | undoGroupIgnore, 206 | }; 207 | 208 | return { model: modelWithUndo, history }; 209 | 210 | /** 211 | * Return a copy of state, removing properties that don't need to be persisted. 212 | * 213 | * In particular, remove computed properties and properties that match a user filter for e.g. interim view state. 214 | */ 215 | function filterState(draftState: AnyObject): AnyObject { 216 | // remove keys that shouldn't be saved in undo history (computeds, user filtered, and computeds metadata) 217 | const filteredState: AnyObject = copyFiltered( 218 | draftState, 219 | (_value, key, path) => { 220 | const fullPath = path.concat([key]); 221 | const isComputed = !!computeds.find((computedPath) => 222 | _.isEqual(fullPath, computedPath) 223 | ); 224 | return isComputed || noSaveKeys(key, path); 225 | } 226 | ); 227 | 228 | return filteredState; 229 | } 230 | } 231 | 232 | export type ModelWithUndo = { 233 | [P in keyof T]: T[P]; 234 | } & 235 | WithUndo; 236 | -------------------------------------------------------------------------------- /src/HistoryStore.ts: -------------------------------------------------------------------------------- 1 | import { compare } from "fast-json-patch"; 2 | import _ from "lodash"; 3 | import { UndoOptions } from "./Actions"; 4 | import { AnyObject } from "./Utils"; 5 | 6 | export const keyPrefix = "undo-redo-"; 7 | export const currentKey = keyPrefix + "state-current"; 8 | export const oldestKey = keyPrefix + "state-oldest"; 9 | 10 | /** Store a stack of undo/redo state objects in localStorage. 11 | * 12 | * Each undo redo state is stored in a separate key/value. 13 | * The oldest undo state is stored with the key: "undo-redo-0", 14 | * more recent states are "-1", "-2", etc. 15 | * 16 | * The current state is stored in the key "undo-redo-state-current" 17 | * The oldest state is stored in the key "undo-redo-state-oldest" 18 | * 19 | * If the number of saved states would exceed maxHistory, the oldest 20 | * state is dropped. (e.g. after dropping one state, the oldest state 21 | * becomes "undo-redo-1".) 22 | * 23 | * keys with indices smaller than the current state hold undo states, 24 | * keys with larger indices hold redo states. 25 | */ 26 | 27 | export interface HistoryStore { 28 | save(state: AnyObject, prevState: AnyObject | undefined): void; 29 | reset(state: AnyObject): void; 30 | undo(state: AnyObject): AnyObject | undefined; 31 | redo(state: AnyObject): AnyObject | undefined; 32 | initialized(): boolean; 33 | 34 | // functions with an _ prefix are exposed for testing, but not intended for public use 35 | _currentIndex(): number | undefined; 36 | _oldestIndex(): number | undefined; 37 | _allSaved(): AnyObject[]; 38 | _erase(): void; 39 | _getState(index: number): AnyObject | undefined; 40 | } 41 | 42 | const defaultMaxHistory = 250; 43 | 44 | /** return a persistent store that holds undo/redo history 45 | * @param toPlainState remove computed and view fields from a state object 46 | */ 47 | export function historyStore( 48 | toPlainState: (state: AnyObject) => AnyObject, 49 | options?: UndoOptions 50 | ): HistoryStore { 51 | const maxHistory = options?.maxHistory || defaultMaxHistory; 52 | const storage = getStorage(); 53 | const logDiffs = options?.logDiffs || false; 54 | let empty = currentIndex() !== undefined; 55 | 56 | return { 57 | save, 58 | reset, 59 | undo, 60 | redo, 61 | initialized, 62 | _currentIndex: currentIndex, 63 | _oldestIndex: oldestIndex, 64 | _allSaved, 65 | _erase, 66 | _getState, 67 | }; 68 | 69 | function initialized(): boolean { 70 | if (empty) { 71 | empty = currentIndex() !== undefined; 72 | } 73 | return empty; 74 | } 75 | 76 | function save(state: AnyObject, prevState: AnyObject | undefined): void { 77 | const currentDex = currentIndex(); 78 | const oldestDex = oldestIndex() || 0; 79 | if (currentDex === undefined) { 80 | if (prevState) { 81 | saveState(prevState, 0); 82 | saveStateIfNew(state, 0); 83 | } else { 84 | saveState(state, 0); 85 | } 86 | storage.setItem(oldestKey, "0"); 87 | if (logDiffs) { 88 | console.log("save state: 0\n", state); 89 | } 90 | } else { 91 | const newDex = saveStateIfNew(state, currentDex); 92 | 93 | if (newDex !== currentDex) { 94 | // delete now invalid redo states 95 | deleteNewest(newDex + 1); 96 | 97 | // limit growth of old states 98 | const size = newDex - oldestDex + 1; 99 | if (size > maxHistory) { 100 | deleteOldest(maxHistory); 101 | } 102 | } 103 | } 104 | } 105 | 106 | function reset(state: AnyObject): void { 107 | deleteNewest(0); 108 | saveState(state, 0); 109 | storage.setItem(oldestKey, "0"); 110 | if (logDiffs) { 111 | console.log("reset\n", state); 112 | } 113 | } 114 | 115 | function undo(state: AnyObject): AnyObject | undefined { 116 | const currentDex = currentIndex(); 117 | if (currentDex === undefined || currentDex === 0) { 118 | return undefined; 119 | } 120 | const undoDex = (currentDex - 1).toString(); 121 | const stateString = storage.getItem(keyPrefix + undoDex); 122 | if (stateString === null) { 123 | console.log("unexpected null entry at index:", undoDex); 124 | return undefined; 125 | } 126 | storage.setItem(currentKey, undoDex); 127 | const undoState = JSON.parse(stateString); 128 | if (logDiffs) { 129 | const rawState = toPlainState(state); 130 | const diff = compare(rawState, undoState); 131 | console.log("undo\n", ...diff); 132 | } 133 | return undoState; 134 | } 135 | 136 | function redo(state: AnyObject): AnyObject | undefined { 137 | const currentDex = currentIndex(); 138 | if (currentDex === undefined) { 139 | return undefined; 140 | } 141 | const redoDex = (currentDex + 1).toString(); 142 | const stateString = storage.getItem(keyPrefix + redoDex); 143 | if (stateString === null) { 144 | return undefined; 145 | } 146 | storage.setItem(currentKey, redoDex); 147 | const redoState = JSON.parse(stateString); 148 | if (logDiffs) { 149 | const rawState = toPlainState(state); 150 | const diff = compare(rawState, redoState); 151 | console.log("redo\n", ...diff); 152 | } 153 | return redoState; 154 | } 155 | 156 | function saveState(state: AnyObject, index: number): void { 157 | saveStateString(JSON.stringify(state), index); 158 | } 159 | 160 | function saveStateIfNew(state: AnyObject, currentDex: number): number { 161 | const currentStateString = storage.getItem(keyPrefix + currentDex); 162 | const stateString = JSON.stringify(state); 163 | if (currentStateString !== stateString) { 164 | logDiff(state, currentStateString); 165 | const newDex = currentDex + 1; 166 | saveStateString(stateString, newDex); 167 | 168 | return newDex; 169 | } else { 170 | if (options?.logDiffs) { 171 | console.log(`not saving unchanged state @${currentDex}`); 172 | } 173 | return currentDex; 174 | } 175 | } 176 | 177 | function logDiff(newState: AnyObject, oldStateString: string | null) { 178 | if (logDiffs) { 179 | if (oldStateString) { 180 | const oldState = JSON.parse(oldStateString); 181 | const diff = compare(oldState, newState); 182 | console.log("save:\n", ...diff); 183 | } else { 184 | console.log("save:\n", newState); 185 | } 186 | } 187 | } 188 | 189 | function saveStateString(stateString: string, index: number): void { 190 | const indexString = index.toString(); 191 | storage.setItem(keyPrefix + indexString, stateString); 192 | storage.setItem(currentKey, indexString); 193 | } 194 | 195 | function currentIndex(): number | undefined { 196 | const valueString = storage.getItem(currentKey); 197 | if (valueString) { 198 | return parseInt(valueString); 199 | } else { 200 | return undefined; 201 | } 202 | } 203 | 204 | function oldestIndex(): number | undefined { 205 | const valueString = storage.getItem(oldestKey); 206 | if (valueString) { 207 | return parseInt(valueString); 208 | } else { 209 | return undefined; 210 | } 211 | } 212 | 213 | /** delete all states newer than start */ 214 | function deleteNewest(start: number): void { 215 | const key = keyPrefix + start; 216 | const item = storage.getItem(key); 217 | if (item) { 218 | storage.removeItem(key); 219 | deleteNewest(start + 1); 220 | } 221 | } 222 | 223 | /** delete oldest states until we fit under maxSize */ 224 | function deleteOldest(maxHistory: number): void { 225 | const currentDex = currentIndex() || 0; 226 | const oldestDex = oldestIndex() || 0; 227 | const size = currentDex - oldestDex + 1; 228 | if (currentDex - maxHistory < 0 || size < maxHistory) { 229 | console.log("returning early..."); 230 | return; 231 | } 232 | const newOldest = Math.max(0, currentDex - maxHistory + 1); 233 | for (let i = oldestDex; i < newOldest; i++) { 234 | storage.removeItem(keyPrefix + i); 235 | } 236 | storage.setItem(oldestKey, newOldest.toString()); 237 | } 238 | 239 | /** for testing */ 240 | function _allSaved(): AnyObject[] { 241 | const results: AnyObject[] = []; 242 | _.times(10).forEach((i) => { 243 | const item = storage.getItem(keyPrefix + i); 244 | if (item) { 245 | results.push(JSON.parse(item)); 246 | } 247 | }); 248 | return results; 249 | } 250 | 251 | function _erase(): void { 252 | storage.clear(); 253 | } 254 | 255 | function _getState(index: number): AnyObject | undefined { 256 | const item = storage.getItem(keyPrefix + index); 257 | if (!item) { 258 | return undefined; 259 | } 260 | return JSON.parse(item); 261 | } 262 | } 263 | 264 | function getStorage(): Storage { 265 | return localStorage; // for now, just one store. (tests use one mocked store per test thread.) 266 | } 267 | -------------------------------------------------------------------------------- /src/test/UndoRedo.test.ts: -------------------------------------------------------------------------------- 1 | import { should, assert } from "chai"; 2 | import { 3 | action, 4 | Action, 5 | ActionMapper, 6 | computed, 7 | Computed, 8 | createStore, 9 | State, 10 | Store, 11 | ValidActionProperties, 12 | } from "easy-peasy"; 13 | import { enableES5 } from "immer"; 14 | import { HistoryStore } from "../HistoryStore"; 15 | import { undoRedo as undoRedoMiddleware } from "../Middleware"; 16 | import { 17 | UndoOptions, 18 | ModelWithUndo, 19 | undoableModelAndHistory, 20 | } from "../Actions"; 21 | import { AnyObject, findModelComputeds } from "../Utils"; 22 | import { AnyAction } from "redux"; 23 | 24 | should(); 25 | enableES5(); 26 | 27 | interface Model { 28 | count: number; 29 | increment: Action; 30 | } 31 | 32 | const simpleModel: Model = { 33 | count: 0, 34 | increment: action((state) => { 35 | state.count++; 36 | }), 37 | }; 38 | 39 | interface ViewModel { 40 | count: number; 41 | view: number; 42 | viewDeep: { a: number }; 43 | increment: Action; 44 | doubleView: Action; 45 | doubleDeepView: Action; 46 | countSquared: Computed; 47 | } 48 | 49 | const viewModel: ViewModel = { 50 | count: 0, 51 | view: 7, 52 | viewDeep: { a: 9 }, 53 | doubleView: action((state) => { 54 | state.view *= 2; 55 | }), 56 | doubleDeepView: action((state) => { 57 | state.viewDeep.a *= 2; 58 | }), 59 | increment: action((state) => { 60 | state.count++; 61 | }), 62 | countSquared: computed([(model) => model.view], (view) => view * view), 63 | }; 64 | 65 | interface StoreAndHistory { 66 | store: Store; 67 | actions: ActionMapper< 68 | ModelWithUndo, 69 | ValidActionProperties> 70 | >; 71 | history: HistoryStore; 72 | } 73 | 74 | function withStore( 75 | fn: (storeAndHistory: StoreAndHistory) => void, 76 | historyOptions?: UndoOptions 77 | ) { 78 | const { model, history } = undoableModelAndHistory( 79 | simpleModel, 80 | historyOptions 81 | ); 82 | history._erase(); 83 | const store = createStore(model, { 84 | middleware: [undoRedoMiddleware()], 85 | }); 86 | const actions = store.getActions(); 87 | try { 88 | fn({ store, actions, history }); 89 | } finally { 90 | history._erase(); 91 | } 92 | } 93 | 94 | function withViewStore( 95 | fn: (storeAndHistory: StoreAndHistory) => void 96 | ) { 97 | const { model, history } = undoableModelAndHistory(viewModel, { 98 | noSaveKeys: viewKeys, 99 | }); 100 | history._erase(); 101 | const store = createStore(model, { 102 | middleware: [undoRedoMiddleware()], 103 | }); 104 | const actions = store.getActions(); 105 | try { 106 | fn({ store, actions, history }); 107 | } finally { 108 | history._erase(); 109 | } 110 | } 111 | 112 | function viewKeys(key: string): boolean { 113 | return key === "view" || key === "viewDeep"; 114 | } 115 | 116 | function historyExpect( 117 | history: HistoryStore, 118 | e: { length: number; index: number | undefined } 119 | ): void { 120 | const index = history._currentIndex()!; 121 | const length = history._allSaved().length; 122 | length.should.equal(e.length, "history length"); 123 | expect(index).toEqual(e.index); 124 | } 125 | 126 | test("undo, no reset first", () => { 127 | withStore(({ store, actions, history }) => { 128 | actions.increment(); 129 | actions.undoUndo(); 130 | 131 | store.getState().count.should.equal(0); 132 | }); 133 | }); 134 | 135 | test("zero state filters views", () => { 136 | withViewStore(({ history, actions }) => { 137 | actions.increment(); 138 | history._currentIndex()!.should.equal(1); 139 | history._getState(0); 140 | expect(history._getState(0)?.view).toBeUndefined; 141 | }); 142 | }); 143 | 144 | test("save an action", () => { 145 | withStore(({ actions, history }) => { 146 | actions.increment(); 147 | 148 | historyExpect(history, { length: 2, index: 1 }); 149 | }); 150 | }); 151 | 152 | test("save two actions", () => { 153 | withStore(({ actions, history }) => { 154 | actions.increment(); 155 | actions.increment(); 156 | 157 | historyExpect(history, { length: 3, index: 2 }); 158 | }); 159 | }); 160 | 161 | test("undo an action", () => { 162 | withStore(({ store, history, actions }) => { 163 | actions.increment(); 164 | actions.undoUndo(); 165 | 166 | store.getState().count.should.equal(0); 167 | historyExpect(history, { length: 2, index: 0 }); 168 | }); 169 | }); 170 | 171 | test("manual save", () => { 172 | withStore(({ store, history, actions }) => { 173 | store.getState().count = 7; // cheat and manually modify state 174 | actions.undoSave(); // verify that it's ok to call w/o parameters 175 | 176 | store.getState().count.should.equal(7); 177 | historyExpect(history, { length: 1, index: 0 }); 178 | }); 179 | }); 180 | 181 | test("undo two actions", () => { 182 | withStore(({ store, history, actions }) => { 183 | actions.increment(); 184 | actions.increment(); 185 | actions.undoUndo(); 186 | actions.undoUndo(); 187 | 188 | store.getState().count.should.equal(0); 189 | historyExpect(history, { length: 3, index: 0 }); 190 | }); 191 | }); 192 | 193 | test("two actions, undo one", () => { 194 | withStore(({ store, history, actions }) => { 195 | actions.increment(); 196 | actions.increment(); 197 | actions.undoUndo(); 198 | 199 | store.getState().count.should.equal(1); 200 | historyExpect(history, { length: 3, index: 1 }); 201 | }); 202 | }); 203 | 204 | test("don't save duplicate state", () => { 205 | withStore(({ store, history, actions }) => { 206 | actions.increment(); 207 | store.getState().count.should.equal(1); 208 | actions.undoSave({ action: { type: "do_nada" }, prevState: {} }); 209 | 210 | historyExpect(history, { length: 2, index: 1 }); 211 | }); 212 | }); 213 | 214 | test("redo", () => { 215 | withStore(({ store, history, actions }) => { 216 | actions.increment(); 217 | actions.increment(); 218 | actions.increment(); 219 | store.getState().count.should.equal(3); 220 | actions.undoUndo(); 221 | actions.undoUndo(); 222 | store.getState().count.should.equal(1); 223 | actions.undoRedo(); 224 | store.getState().count.should.equal(2); 225 | 226 | historyExpect(history, { length: 4, index: 2 }); 227 | }); 228 | }); 229 | 230 | test("redo unavailable", () => { 231 | withStore(({ store, history, actions }) => { 232 | actions.increment(); 233 | store.getState().count.should.equal(1); 234 | historyExpect(history, { length: 2, index: 1 }); 235 | actions.undoRedo(); 236 | store.getState().count.should.equal(1); 237 | 238 | historyExpect(history, { length: 2, index: 1 }); 239 | }); 240 | }); 241 | 242 | test("undo empty doesn't crash", () => { 243 | withStore(({ actions }) => { 244 | actions.undoUndo(); 245 | }); 246 | }); 247 | 248 | test("redo empty doesn't crash", () => { 249 | withStore(({ actions }) => { 250 | actions.undoRedo(); 251 | }); 252 | }); 253 | 254 | test("reset clears history", () => { 255 | withStore(({ store, history, actions }) => { 256 | actions.increment(); 257 | actions.increment(); 258 | actions.undoReset(); 259 | store.getState().count.should.equal(2); 260 | 261 | historyExpect(history, { length: 1, index: 0 }); 262 | }); 263 | }); 264 | 265 | test("views are not saved", () => { 266 | withViewStore(({ history }) => { 267 | const savedView = history._getState(0)?.view; 268 | 269 | assert(savedView === undefined); 270 | }); 271 | }); 272 | 273 | test("views actions are not saved", () => { 274 | withViewStore(({ actions, history }) => { 275 | actions.doubleView(); 276 | 277 | historyExpect(history, { length: 1, index: 0 }); 278 | }); 279 | }); 280 | 281 | test("deep view actions are not saved", () => { 282 | withViewStore(({ actions, history }) => { 283 | actions.doubleDeepView(); 284 | 285 | historyExpect(history, { length: 1, index: 0 }); 286 | }); 287 | }); 288 | 289 | test("computed values are not saved", () => { 290 | withViewStore(({ store, actions, history }) => { 291 | store.getState().countSquared.should.equal(49); 292 | actions.increment(); 293 | const savedState = history._getState(1)!; 294 | Object.keys(savedState).includes("countSquared").should.equal(false); 295 | }); 296 | }); 297 | 298 | test("maxHistory can simply limit size", () => { 299 | withStore( 300 | ({ actions, history }) => { 301 | actions.increment(); 302 | history._currentIndex()!.should.equal(1); 303 | history._oldestIndex()!.should.equal(0); 304 | expect(history._getState(0)).toBeDefined(); 305 | actions.increment(); 306 | history._currentIndex()!.should.equal(2); 307 | expect(history._getState(0)).toBeUndefined(); 308 | history._oldestIndex()!.should.equal(1); 309 | }, 310 | { maxHistory: 2 } 311 | ); 312 | }); 313 | 314 | test("maxHistory works with redo too", () => { 315 | withStore( 316 | ({ actions, history }) => { 317 | actions.increment(); 318 | actions.increment(); 319 | actions.undoUndo(); 320 | actions.undoUndo(); 321 | historyExpect(history, { length: 3, index: 0 }); 322 | actions.increment(); 323 | actions.increment(); 324 | actions.increment(); 325 | historyExpect(history, { length: 3, index: 3 }); 326 | expect(history._getState(0)).toBeUndefined(); 327 | history._oldestIndex()!.should.equal(1); 328 | }, 329 | { maxHistory: 3 } 330 | ); 331 | }); 332 | 333 | test("findModelComputeds", () => { 334 | findModelComputeds(viewModel).should.deep.equal([["countSquared"]]); 335 | }); 336 | 337 | test("actionStateFilter", () => { 338 | withStore( 339 | ({ actions, history }) => { 340 | actions.increment(); 341 | historyExpect(history, { length: 2, index: 1 }); 342 | actions.increment(); 343 | historyExpect(history, { length: 3, index: 2 }); 344 | actions.increment(); 345 | historyExpect(history, { length: 3, index: 2 }); 346 | }, 347 | { skipAction } 348 | ); 349 | 350 | function skipAction(state: State, action: AnyAction): boolean { 351 | action.type.should.equal("@action.increment"); 352 | if (state.count > 2) { 353 | return true; 354 | } 355 | return false; 356 | } 357 | }); 358 | 359 | test("group Undo", () => { 360 | withStore(({ actions, history }) => { 361 | actions.undoGroupStart(); 362 | actions.increment(); 363 | actions.increment(); 364 | actions.undoGroupComplete(); 365 | historyExpect(history, { length: 1, index: 0 }); 366 | (history._getState(0) as Model).count.should.equal(2); 367 | }); 368 | }); 369 | 370 | test("actionStateFilter with group Undo", () => { 371 | withStore( 372 | ({ actions, history }) => { 373 | actions.undoGroupStart(); 374 | actions.increment(); 375 | actions.increment(); 376 | actions.undoGroupComplete(); 377 | historyExpect(history, { length: 0, index: undefined }); 378 | }, 379 | { skipAction } 380 | ); 381 | 382 | function skipAction(state: State, action: AnyAction): boolean { 383 | action.type.should.equal("@action.undoGroupComplete"); 384 | state.count.should.equal(2); 385 | return true; 386 | } 387 | }); 388 | 389 | test("group Ignore", () => { 390 | withStore(({ actions, history }) => { 391 | actions.undoGroupStart(); 392 | actions.increment(); 393 | actions.increment(); 394 | actions.undoGroupIgnore(); 395 | actions.increment(); 396 | history._currentIndex(); 397 | historyExpect(history, { length: 2, index: 1 }); 398 | (history._getState(1) as Model).count.should.equal(3); 399 | }); 400 | }); 401 | --------------------------------------------------------------------------------