├── .npmignore ├── package.json ├── demo.html ├── element.js ├── README.md ├── undoer.js └── LICENSE /.npmignore: -------------------------------------------------------------------------------- 1 | *.html -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "undoer", 3 | "version": "0.1.0", 4 | "description": "Native undo/redo behavior for web", 5 | "homepage": "https://github.com/samthor/undoer", 6 | "main": "undoer.js", 7 | "repository": "git@github.com:samthor/undoer.git", 8 | "author": "Sam Thorogood ", 9 | "license": "Apache-2.0" 10 | } 11 | -------------------------------------------------------------------------------- /demo.html: -------------------------------------------------------------------------------- 1 | 17 | 18 |

Undoer test page

19 | 20 | 25 | 26 |
27 | 28 |
29 | 30 | 31 | 32 |
33 | -------------------------------------------------------------------------------- /element.js: -------------------------------------------------------------------------------- 1 | 2 | import {Undoer} from './undoer.js'; 3 | 4 | 5 | const useShadowDOM = false && Element.prototype.attachShadow; 6 | 7 | 8 | export default class UndoerElement extends HTMLElement { 9 | 10 | static get observedAttributes() { 11 | return ['state']; 12 | } 13 | 14 | constructor() { 15 | super(); 16 | this._root = useShadowDOM ? this.attachShadow({mode: 'open'}) : this; 17 | 18 | // hide from the first attributeChangedCallback call 19 | this._selfAttributeChange = true; 20 | window.setTimeout(() => { 21 | this._selfAttributeChange = false; 22 | }); 23 | 24 | const callback = (data) => { 25 | const {value, attr} = data; 26 | this._updateAttribute(attr ? value : null); 27 | 28 | // hooray! tell the client 29 | this.dispatchEvent(new CustomEvent('state', {detail: value})); 30 | }; 31 | 32 | // set up initial zero undo state from attr 33 | const zero = this.getAttribute('state'); 34 | const attr = this.hasAttribute('state'); 35 | this._undoer = new Undoer(callback, {value: zero, attr}); 36 | } 37 | 38 | attributeChangedCallback(name, oldValue, newValue) { 39 | if (name === 'state' && !this._selfAttributeChange) { 40 | this._internalSet(newValue, true); 41 | } 42 | } 43 | 44 | set state(value) { 45 | if (!this.isConnected) { 46 | throw new Error('can\'t push state while disconnected'); 47 | } 48 | 49 | // render if simple "attribute safe" state 50 | const attr = (typeof value === 'string' || typeof value === 'number'); 51 | this._internalSet(value, attr); 52 | } 53 | 54 | get state() { 55 | const {value} = this._undoer.data; 56 | return value; 57 | } 58 | 59 | _updateAttribute(value) { 60 | this._selfAttributeChange = true; 61 | try { 62 | if (value) { 63 | this.setAttribute('state', value); 64 | } else { 65 | this.removeAttribute('state'); 66 | } 67 | } finally { 68 | this._selfAttributeChange = false; 69 | } 70 | } 71 | 72 | _internalSet(value, attr) { 73 | this._updateAttribute(attr ? value : null); 74 | this._undoer.push({value, attr}, this._root); 75 | } 76 | 77 | }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Native undo/redo behavior for web. 2 | This lets you push native undo stack events onto your pages, so that users can use Ctrl/Cmd-Z—or even use some other gesture (e.g., on iOS devices, you can shake your phone to Undo). 3 | 4 | See a [writeup on how this works](https://dev.to/chromiumdev/-native-undo--redo-for-the-web-3fl3) or an [awesome maze-based demo](https://codepen.io/samthor/pen/WJvLxd) for more. 5 | 6 | ## Usage 7 | 8 | Install on NPM/Yarn via `undoer`. 9 | You can use this element as a Web Component or as pure, imperative JavaScript. 10 | 11 | ### Web Component 12 | 13 | Add the dependency to your JS and register it as a CE: 14 | 15 | ```js 16 | import UndoerElement from './node_modules/undoer/element.js'; 17 | customElements.define('undoer-element', UndoerElement); 18 | ``` 19 | 20 | Then add the element to your page, optionally adding `state` attribute to set its zero initial state (otherwise it will be `null`): 21 | 22 | ```html 23 | 24 | ``` 25 | 26 | Finally, use the element's JavaScript API: 27 | 28 | ```js 29 | const undoerEl = document.querySelector('undoer-element'); 30 | 31 | undoerEl.addEventListener('state', (ev) => { 32 | console.info('user undo or redid', ev.detail); 33 | }); 34 | 35 | // set new state with 36 | undoerEl.state = 'new state'; 37 | undoerEl.state = /* any object */ ; 38 | 39 | // or via attribute for string state 40 | undoerEl.setAttribute('state', 'new state'); 41 | 42 | ``` 43 | 44 | ### Imperative JavaScript 45 | 46 | You can also use the raw `Undoer` class without CEs: 47 | 48 | ```js 49 | import {Undoer} from './node_modules/undoer/undoer.js'; 50 | // or 51 | import {Undoer} from 'undoer'; // your build system might allow this 52 | 53 | // construct with callback and push state 54 | const initialState = null; // default is null 55 | const undoer = new Undoer((data) => { 56 | console.info('user undo or redid', data); 57 | }, initialState); 58 | undoer.push('new state'); 59 | ``` 60 | 61 | ## Notes 62 | 63 | This makes sense as a Web Component as the undo behavior works by adding a hidden `
` to your page. 64 | In the WC case, this is as a child of the element: in the imperative case, it's added (by default) to `document.body`. 65 | -------------------------------------------------------------------------------- /undoer.js: -------------------------------------------------------------------------------- 1 | 2 | export class Undoer { 3 | 4 | /** 5 | * @template T 6 | * @param {function(T)} callback to call when undo/redo occurs 7 | * @param {T=} zero the zero state for undoing everything 8 | */ 9 | constructor(callback, zero=null) { 10 | this._duringUpdate = false; 11 | this._stack = [zero]; 12 | 13 | // nb. Previous versions of this used `input` for browsers other than Firefox (as Firefox 14 | // _only_ supports execCommand on contentEditable) 15 | this._ctrl = document.createElement('div'); 16 | this._ctrl.setAttribute('aria-hidden', 'true'); 17 | this._ctrl.style.opacity = 0; 18 | this._ctrl.style.position = 'fixed'; 19 | this._ctrl.style.top = '-1000px'; 20 | this._ctrl.style.pointerEvents = 'none'; 21 | this._ctrl.tabIndex = -1; 22 | 23 | this._ctrl.contentEditable = true; 24 | this._ctrl.textContent = '0'; 25 | this._ctrl.style.visibility = 'hidden'; // hide element while not used 26 | 27 | this._ctrl.addEventListener('focus', (ev) => { 28 | // Safari needs us to wait, can't blur immediately. 29 | window.setTimeout(() => void this._ctrl.blur(), 0); 30 | }); 31 | this._ctrl.addEventListener('input', (ev) => { 32 | if (!this._duringUpdate) { 33 | callback(this.data); 34 | } 35 | 36 | // clear selection, otherwise user copy gesture will copy value 37 | // nb. this _probably_ won't work inside Shadow DOM 38 | // nb. this is mitigated by the fact that we set visibility: 'hidden' 39 | const s = window.getSelection(); 40 | if (s.containsNode(this._ctrl, true)) { 41 | s.removeAllRanges(); 42 | } 43 | }); 44 | } 45 | 46 | /** 47 | * @return {number} the current stack value 48 | */ 49 | get _depth() { 50 | return +(this._ctrl.textContent) || 0; 51 | } 52 | 53 | /** 54 | * @return {T} the current data 55 | * @export 56 | */ 57 | get data() { 58 | return this._stack[this._depth]; 59 | } 60 | 61 | /** 62 | * Pushes a new undoable event. Adds to the browser's native undo/redo stack. 63 | * 64 | * @param {T} data the data for this undo event 65 | * @param {!Node=} parent to add to, uses document.body by default 66 | * @export 67 | */ 68 | push(data, parent) { 69 | // nb. We can't remove this later: the only case we could is if the user undoes everything 70 | // and then does some _other_ action (which we can't detect). 71 | if (!this._ctrl.parentNode) { 72 | // nb. we check parentNode as this would remove contentEditable's history 73 | (parent || document.body).appendChild(this._ctrl); 74 | } 75 | 76 | const nextID = this._depth + 1; 77 | this._stack.splice(nextID, this._stack.length - nextID, data); 78 | 79 | const previousFocus = document.activeElement; 80 | try { 81 | this._duringUpdate = true; 82 | this._ctrl.style.visibility = null; 83 | this._ctrl.focus(); 84 | document.execCommand('selectAll'); 85 | document.execCommand('insertText', false, nextID); 86 | } finally { 87 | this._duringUpdate = false; 88 | this._ctrl.style.visibility = 'hidden'; 89 | } 90 | 91 | previousFocus && previousFocus.focus(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------