├── .gitignore ├── .babelrc ├── webpack.config.js ├── LICENSE ├── package.json ├── src ├── __tests__ │ └── plugin.spec.js └── plugin.js ├── README.md └── dist └── vuex-undo-redo.min.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { "modules": false }] 4 | ], 5 | "env": { 6 | "test": { 7 | "presets": [ 8 | ["env", { "targets": { "node": "current" }}] 9 | ] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const merge = require('webpack-merge'); 3 | const path = require('path'); 4 | 5 | module.exports = { 6 | entry: path.resolve(__dirname + '/src/plugin.js'), 7 | output: { 8 | path: path.resolve(__dirname + '/dist/'), 9 | filename: 'vuex-undo-redo.min.js', 10 | libraryTarget: 'window', 11 | library: 'VuexUndoRedo', 12 | }, 13 | module: { 14 | loaders: [ 15 | { 16 | test: /\.js$/, 17 | loader: 'babel-loader', 18 | include: __dirname, 19 | exclude: /node_modules/, 20 | options: { 21 | presets: ["babel-preset-env"], 22 | plugins: ["transform-runtime"], 23 | } 24 | } 25 | ] 26 | }, 27 | plugins: [ 28 | new webpack.optimize.UglifyJsPlugin( { 29 | minimize : true, 30 | sourceMap : false, 31 | mangle: true, 32 | compress: { 33 | warnings: false 34 | } 35 | } ) 36 | ] 37 | }; 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Anthony Gore 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vuex-undo-redo", 3 | "version": "1.1.4", 4 | "description": "A Vue.js plugin to undo/redo mutations", 5 | "main": "src/plugin.js", 6 | "scripts": { 7 | "build": "rimraf ./dist && webpack --config ./webpack.config.js", 8 | "test": "jest" 9 | }, 10 | "author": "Anthony Gore", 11 | "license": "MIT", 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/anthonygore/vuex-undo-redo" 15 | }, 16 | "devDependencies": { 17 | "@vue/test-utils": "^1.0.0-beta.25", 18 | "babel-core": "^6.10.4", 19 | "babel-jest": "^23.6.0", 20 | "babel-loader": "^7.1.2", 21 | "babel-plugin-transform-runtime": "^6.9.0", 22 | "babel-preset-env": "^1.6.1", 23 | "babel-runtime": "^6.9.2", 24 | "jest": "^23.6.0", 25 | "rimraf": "^2.6.1", 26 | "vue": "^2.5.17", 27 | "vue-jest": "^3.0.0", 28 | "vue-template-compiler": "^2.5.17", 29 | "vuex": "^3.0.1", 30 | "webpack": "^3.8.1", 31 | "webpack-merge": "^4.1.0" 32 | }, 33 | "jest": { 34 | "moduleFileExtensions": [ 35 | "js", 36 | "json", 37 | "vue" 38 | ], 39 | "transform": { 40 | ".*\\.(vue)$": "vue-jest", 41 | "^.+\\.js$": "/node_modules/babel-jest" 42 | } 43 | }, 44 | "dependencies": {} 45 | } 46 | -------------------------------------------------------------------------------- /src/__tests__/plugin.spec.js: -------------------------------------------------------------------------------- 1 | import { createLocalVue, shallowMount } from '@vue/test-utils'; 2 | import Vuex from "vuex"; 3 | import plugin from '../plugin'; 4 | 5 | describe('plugin', () => { 6 | it('should throw error if installed before new Vuex.Store is called', () => { 7 | const localVue = createLocalVue(); 8 | expect(() => { 9 | localVue.use(plugin); 10 | }).toThrow(); 11 | }); 12 | it('should not throw error if installed after new Vuex.Store is called', () => { 13 | const localVue = createLocalVue(); 14 | expect(() => { 15 | localVue.use(Vuex); 16 | new Vuex.Store({}); 17 | localVue.use(plugin); 18 | }).not.toThrow(); 19 | }); 20 | it('should undo/redo data property', done => { 21 | const localVue = createLocalVue(); 22 | localVue.use(Vuex); 23 | const storeConfig = { 24 | state: { 25 | myVal: 0 26 | }, 27 | mutations: { 28 | inc(state) { 29 | state.myVal++; 30 | }, 31 | emptyState() { 32 | this.replaceState({ myVal: 0 }); 33 | } 34 | } 35 | }; 36 | let store = new Vuex.Store(storeConfig); 37 | localVue.use(plugin); 38 | let component = { 39 | template: "
", 40 | methods: { 41 | inc() { 42 | this.$store.commit("inc"); 43 | } 44 | }, 45 | created() { 46 | expect(this.$store.state.myVal).toBe(0); 47 | this.inc(); 48 | expect(this.$store.state.myVal).toBe(1); 49 | this.undo(); 50 | expect(this.$store.state.myVal).toBe(0); 51 | this.redo(); 52 | expect(this.$store.state.myVal).toBe(1); 53 | done(); 54 | } 55 | }; 56 | shallowMount(component, { 57 | localVue, 58 | store 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/plugin.js: -------------------------------------------------------------------------------- 1 | const EMPTY_STATE = 'emptyState'; 2 | 3 | module.exports = { 4 | install(Vue, options = {}) { 5 | if (!Vue._installedPlugins.find(plugin => plugin.Store)) { 6 | throw new Error("VuexUndoRedo plugin must be installed after the Vuex plugin.") 7 | } 8 | Vue.mixin({ 9 | data() { 10 | return { 11 | done: [], 12 | undone: [], 13 | newMutation: true, 14 | ignoreMutations: options.ignoreMutations|| [] 15 | }; 16 | }, 17 | created() { 18 | if (this.$store) { 19 | this.$store.subscribe(mutation => { 20 | if (mutation.type !== EMPTY_STATE && this.ignoreMutations.indexOf(mutation.type) === -1) { 21 | this.done.push(mutation); 22 | } 23 | if (this.newMutation) { 24 | this.undone = []; 25 | } 26 | }); 27 | } 28 | }, 29 | computed: { 30 | canRedo() { 31 | return this.undone.length; 32 | }, 33 | canUndo() { 34 | return this.done.length; 35 | } 36 | }, 37 | methods: { 38 | redo() { 39 | let commit = this.undone.pop(); 40 | this.newMutation = false; 41 | switch (typeof commit.payload) { 42 | case 'object': 43 | this.$store.commit(`${commit.type}`, Object.assign({}, commit.payload)); 44 | break; 45 | default: 46 | this.$store.commit(`${commit.type}`, commit.payload); 47 | } 48 | this.newMutation = true; 49 | }, 50 | undo() { 51 | this.undone.push(this.done.pop()); 52 | this.newMutation = false; 53 | this.$store.commit(EMPTY_STATE); 54 | this.done.forEach(mutation => { 55 | switch (typeof mutation.payload) { 56 | case 'object': 57 | this.$store.commit(`${mutation.type}`, Object.assign({}, mutation.payload)); 58 | break; 59 | default: 60 | this.$store.commit(`${mutation.type}`, mutation.payload); 61 | } 62 | this.done.pop(); 63 | }); 64 | this.newMutation = true; 65 | } 66 | } 67 | }); 68 | }, 69 | } 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vuex-undo-redo 2 | 3 | A Vue.js plugin that allows you to undo or redo a mutation. 4 | 5 | > The building of this plugin is documented in the article *[Create A Vuex Undo/Redo For VueJS](https://vuejsdevelopers.com/2017/11/13/vue-js-vuex-undo-redo/)* 6 | 7 | ## Live demos 8 | 9 | [![Edit Vuex Undo/Redo](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/vjo3xlpyny) 10 | 11 | There's also a demo in [this Codepen](https://codepen.io/anthonygore/pen/NwGmqJ). The source code for the demo is [here](https://github.com/anthonygore/vuex-undo-redo-example). 12 | 13 | ## Installation 14 | 15 | ```js 16 | npm i --save-dev vuex-undo-redo 17 | ``` 18 | 19 | ### Browser 20 | 21 | ```html 22 | 23 | ``` 24 | 25 | ### Module 26 | 27 | ```js 28 | import VuexUndoRedo from 'vuex-undo-redo'; 29 | ``` 30 | 31 | ## Usage 32 | 33 | Since it's a plugin, use it like: 34 | 35 | ```js 36 | Vue.use(VuexUndoRedo); 37 | ``` 38 | 39 | You must, of course, have the Vuex plugin installed as well, and it must be installed before this plugin. You must also create a Vuex store which must implement a mutation `emptyState` which should revert the store back to the initial state e.g.: 40 | 41 | ```js 42 | new Vuex.Store({ 43 | state: { 44 | myVal: null 45 | }, 46 | mutations: { 47 | emptyState() { 48 | this.replaceState({ myval: null }); 49 | } 50 | } 51 | }); 52 | ``` 53 | 54 | ### Ignoring mutations 55 | 56 | Occasionally, you may want to perform mutations without including them in the undo history (say you are working on an image editor and the user toggles grid visibility - you probably do not want this in undo history). The plugin has an `ignoredMutations` setting to leave these mutations out of the history: 57 | 58 | ```js 59 | Vue.use(VuexUndoRedo, { ignoreMutations: [ 'toggleGrid' ]}); 60 | ``` 61 | 62 | It's worth noting that this only means the mutations will not be recorded in the undo history. You must still manually manage your state object in the `emptyState` mutation: 63 | 64 | ```js 65 | emptyState(state) { 66 | this.replaceState({ myval: null, showGrid: state.showGrid }); 67 | } 68 | ``` 69 | 70 | ## API 71 | 72 | ### Options 73 | 74 | `ignoredMutations` an array of mutations that the plugin will ignore 75 | 76 | ### Computed properties 77 | 78 | `canUndo` a boolean which tells you if the state is undo-able 79 | 80 | `canRedo` a boolean which tells you if the state is redo-able 81 | 82 | ### Methods 83 | 84 | `undo` undoes the last mutation 85 | 86 | `redo` redoes the last mutation 87 | -------------------------------------------------------------------------------- /dist/vuex-undo-redo.min.js: -------------------------------------------------------------------------------- 1 | window.VuexUndoRedo=function(t){function n(r){if(e[r])return e[r].exports;var o=e[r]={i:r,l:!1,exports:{}};return t[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}var e={};return n.m=t,n.c=e,n.d=function(t,e,r){n.o(t,e)||Object.defineProperty(t,e,{configurable:!1,enumerable:!0,get:r})},n.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return n.d(e,"a",e),e},n.o=function(t,n){return Object.prototype.hasOwnProperty.call(t,n)},n.p="",n(n.s=38)}([function(t,n){var e=t.exports="undefined"!=typeof window&&window.Math==Math?window:"undefined"!=typeof self&&self.Math==Math?self:Function("return this")();"number"==typeof __g&&(__g=e)},function(t,n){var e={}.hasOwnProperty;t.exports=function(t,n){return e.call(t,n)}},function(t,n,e){var r=e(3),o=e(11);t.exports=e(4)?function(t,n,e){return r.f(t,n,o(1,e))}:function(t,n,e){return t[n]=e,t}},function(t,n,e){var r=e(9),o=e(28),i=e(16),u=Object.defineProperty;n.f=e(4)?Object.defineProperty:function(t,n,e){if(r(t),n=i(n,!0),r(e),o)try{return u(t,n,e)}catch(t){}if("get"in e||"set"in e)throw TypeError("Accessors not supported!");return"value"in e&&(t[n]=e.value),t}},function(t,n,e){t.exports=!e(7)(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})},function(t,n,e){var r=e(31),o=e(17);t.exports=function(t){return r(o(t))}},function(t,n,e){var r=e(20)("wks"),o=e(13),i=e(0).Symbol,u="function"==typeof i;(t.exports=function(t){return r[t]||(r[t]=u&&i[t]||(u?i:o)("Symbol."+t))}).store=r},function(t,n){t.exports=function(t){try{return!!t()}catch(t){return!0}}},function(t,n){var e=t.exports={version:"2.5.1"};"number"==typeof __e&&(__e=e)},function(t,n,e){var r=e(10);t.exports=function(t){if(!r(t))throw TypeError(t+" is not an object!");return t}},function(t,n){t.exports=function(t){return"object"==typeof t?null!==t:"function"==typeof t}},function(t,n){t.exports=function(t,n){return{enumerable:!(1&t),configurable:!(2&t),writable:!(4&t),value:n}}},function(t,n,e){var r=e(30),o=e(21);t.exports=Object.keys||function(t){return r(t,o)}},function(t,n){var e=0,r=Math.random();t.exports=function(t){return"Symbol(".concat(void 0===t?"":t,")_",(++e+r).toString(36))}},function(t,n){n.f={}.propertyIsEnumerable},function(t,n,e){var r=e(0),o=e(8),i=e(42),u=e(2),c=function(t,n,e){var f,s,a,p=t&c.F,l=t&c.G,y=t&c.S,h=t&c.P,d=t&c.B,v=t&c.W,b=l?o:o[n]||(o[n]={}),m=b.prototype,g=l?r:y?r[n]:(r[n]||{}).prototype;l&&(e=n);for(f in e)(s=!p&&g&&void 0!==g[f])&&f in b||(a=s?g[f]:e[f],b[f]=l&&"function"!=typeof g[f]?e[f]:d&&s?i(a,r):v&&g[f]==a?function(t){var n=function(n,e,r){if(this instanceof t){switch(arguments.length){case 0:return new t;case 1:return new t(n);case 2:return new t(n,e)}return new t(n,e,r)}return t.apply(this,arguments)};return n.prototype=t.prototype,n}(a):h&&"function"==typeof a?i(Function.call,a):a,h&&((b.virtual||(b.virtual={}))[f]=a,t&c.R&&m&&!m[f]&&u(m,f,a)))};c.F=1,c.G=2,c.S=4,c.P=8,c.B=16,c.W=32,c.U=64,c.R=128,t.exports=c},function(t,n,e){var r=e(10);t.exports=function(t,n){if(!r(t))return t;var e,o;if(n&&"function"==typeof(e=t.toString)&&!r(o=e.call(t)))return o;if("function"==typeof(e=t.valueOf)&&!r(o=e.call(t)))return o;if(!n&&"function"==typeof(e=t.toString)&&!r(o=e.call(t)))return o;throw TypeError("Can't convert object to primitive value")}},function(t,n){t.exports=function(t){if(void 0==t)throw TypeError("Can't call method on "+t);return t}},function(t,n){var e=Math.ceil,r=Math.floor;t.exports=function(t){return isNaN(t=+t)?0:(t>0?r:e)(t)}},function(t,n,e){var r=e(20)("keys"),o=e(13);t.exports=function(t){return r[t]||(r[t]=o(t))}},function(t,n,e){var r=e(0),o=r["__core-js_shared__"]||(r["__core-js_shared__"]={});t.exports=function(t){return o[t]||(o[t]={})}},function(t,n){t.exports="constructor,hasOwnProperty,isPrototypeOf,propertyIsEnumerable,toLocaleString,toString,valueOf".split(",")},function(t,n){n.f=Object.getOwnPropertySymbols},function(t,n){t.exports=!0},function(t,n){t.exports={}},function(t,n,e){var r=e(3).f,o=e(1),i=e(6)("toStringTag");t.exports=function(t,n,e){t&&!o(t=e?t:t.prototype,i)&&r(t,i,{configurable:!0,value:n})}},function(t,n,e){n.f=e(6)},function(t,n,e){var r=e(0),o=e(8),i=e(23),u=e(26),c=e(3).f;t.exports=function(t){var n=o.Symbol||(o.Symbol=i?{}:r.Symbol||{});"_"==t.charAt(0)||t in n||c(n,t,{value:u.f(t)})}},function(t,n,e){t.exports=!e(4)&&!e(7)(function(){return 7!=Object.defineProperty(e(29)("div"),"a",{get:function(){return 7}}).a})},function(t,n,e){var r=e(10),o=e(0).document,i=r(o)&&r(o.createElement);t.exports=function(t){return i?o.createElement(t):{}}},function(t,n,e){var r=e(1),o=e(5),i=e(45)(!1),u=e(19)("IE_PROTO");t.exports=function(t,n){var e,c=o(t),f=0,s=[];for(e in c)e!=u&&r(c,e)&&s.push(e);for(;n.length>f;)r(c,e=n[f++])&&(~i(s,e)||s.push(e));return s}},function(t,n,e){var r=e(32);t.exports=Object("z").propertyIsEnumerable(0)?Object:function(t){return"String"==r(t)?t.split(""):Object(t)}},function(t,n){var e={}.toString;t.exports=function(t){return e.call(t).slice(8,-1)}},function(t,n,e){var r=e(17);t.exports=function(t){return Object(r(t))}},function(t,n,e){"use strict";var r=e(23),o=e(15),i=e(35),u=e(2),c=e(1),f=e(24),s=e(53),a=e(25),p=e(56),l=e(6)("iterator"),y=!([].keys&&"next"in[].keys()),h=function(){return this};t.exports=function(t,n,e,d,v,b,m){s(e,n,d);var g,x,S,O=function(t){if(!y&&t in M)return M[t];switch(t){case"keys":case"values":return function(){return new e(this,t)}}return function(){return new e(this,t)}},w=n+" Iterator",_="values"==v,j=!1,M=t.prototype,P=M[l]||M["@@iterator"]||v&&M[v],E=P||O(v),L=v?_?O("entries"):E:void 0,T="Array"==n?M.entries||P:P;if(T&&(S=p(T.call(new t)))!==Object.prototype&&S.next&&(a(S,w,!0),r||c(S,l)||u(S,l,h)),_&&P&&"values"!==P.name&&(j=!0,E=function(){return P.call(this)}),r&&!m||!y&&!j&&M[l]||u(M,l,E),f[n]=E,f[w]=h,v)if(g={values:_?E:O("values"),keys:b?E:O("keys"),entries:L},m)for(x in g)x in M||i(M,x,g[x]);else o(o.P+o.F*(y||j),n,g);return g}},function(t,n,e){t.exports=e(2)},function(t,n,e){var r=e(9),o=e(54),i=e(21),u=e(19)("IE_PROTO"),c=function(){},f=function(){var t,n=e(29)("iframe"),r=i.length;for(n.style.display="none",e(55).appendChild(n),n.src="javascript:",t=n.contentWindow.document,t.open(),t.write("