How to handle undo/redo event in javascript?
Asked Answered
D

6

10

I'm trying to detect whenever a form input's value changes using Javascript & JQuery. Unfortunately, I find JQuery's $(elem).change() insufficient because it only fires the change event when elem loses focus. I have to immediately know when there are changes in the form input's value. To this end, I've narrowed down events associated with a possible change in an input's value to keyup, paste, cut, undo, and redo. However, neither javascript nor JQuery seem to have a way of dealing with undo or redo.

var onChange = function ()
{
    alert('Checking for changes...');
};

$(this).off('keyup').on('keyup', onChange);
$(this).off('paste').on('paste', onChange);
$(this).off('cut').on('cut', onChange);

$(this).off('undo').on('undo', onChange);  // undo ?
$(this).off('redo').on('redo', onChange);  // redo ?

I've googled for undo/redo event in Javascript/JQuery but didn't find anything helpful. Can someone help on how to deal with undo/redo events?

Drawn answered 20/3, 2013 at 5:45 Comment(7)
if nothing else, you can check for ctrl-z keystrokes, which'd be the undo sequence on most systems. no idea if that'd trigger if the user did edit -> undo in the browser's menus, though.Billen
Why you cant make use of toggle function here ......!!Transoceanic
@MarcB Good point, catching the keystroke would definitely work. But there's no way to detect edit -> undoWindowsill
@undefined But there's no true event to catch for the browser/OS action of edit -> undo/redo (or by keyboard) so that's not really applicableWindowsill
Yes, and I didn't say return false listens to undo/redo pseudo-events. Those events do not exist at all.Excel
@undefined Right, but I don't think they care about that - they're just trying to detect the specific undo/redo (and several other) events...they don't seem worried about preventing the behavior of specific events.Windowsill
Oops. There is a typo in my first comment. There is no undo/redo event...Excel
K
8

There is no undo or redo event in javascript. If you wanted such functionality, you'd either have to write it yourself in javascript or find a library that offered such functionality.

If you're trying to trap all possible ways that an input control can be changed so you can see such a change immediately, then take a look at this sample code: http://jsfiddle.net/jfriend00/6qyS6/ which implemented a change callback for an input control. This code wasn't designed directly for a drop-down, but since it's a form of an input control, you can probably adapt this code to create your own change event for a drop-down.

Well, StackOverflow in their infinite wisdom is prohibiting me from posting just a reference to a jsFiddle so I have to paste all the code in here (for some reason, jsFiddles are singled out as opposed to other web references). I'm not representing this as an exact solution, but as a template you could use for how to detect user changes to an input control:

(function($) {

    var isIE = false;
    // conditional compilation which tells us if this is IE
    /*@cc_on
    isIE = true;
    @*/

    // Events to monitor if 'input' event is not supported
    // The boolean value is whether we have to 
    // re-check after the event with a setTimeout()
    var events = [
        "keyup", false,
        "blur", false,
        "focus", false,
        "drop", true,
        "change", false,
        "input", false,
        "textInput", false,
        "paste", true,
        "cut", true,
        "copy", true,
        "contextmenu", true
    ];
    // Test if the input event is supported
    // It's too buggy in IE so we never rely on it in IE
    if (!isIE) {
        var el = document.createElement("input");
        var gotInput = ("oninput" in el);
        if  (!gotInput) {
            el.setAttribute("oninput", 'return;');
            gotInput = typeof el["oninput"] == 'function';
        }
        el = null;
        // if 'input' event is supported, then use a smaller
        // set of events
        if (gotInput) {
            events = [
                "input", false,
                "textInput", false
            ];
        }
    }

    $.fn.userChange = function(fn, data) {
        function checkNotify(e, delay) {
            // debugging code
            if ($("#logAll").prop("checked")) {
                log('checkNotify - ' + e.type);
            }

            var self = this;
            var this$ = $(this);

            if (this.value !== this$.data("priorValue")) {
                this$.data("priorValue", this.value);
                fn.call(this, e, data);
            } else if (delay) {
                // The actual data change happens after some events
                // so we queue a check for after.
                // We need a copy of e for setTimeout() because the real e
                // may be overwritten before the setTimeout() fires
                var eCopy = $.extend({}, e);
                setTimeout(function() {checkNotify.call(self, eCopy, false)}, 1);
            }
        }

        // hook up event handlers for each item in this jQuery object
        // and remember initial value
        this.each(function() {
            var this$ = $(this).data("priorValue", this.value);
            for (var i = 0; i < events.length; i+=2) {
                (function(i) {
                    this$.on(events[i], function(e) {
                        checkNotify.call(this, e, events[i+1]);
                    });
                })(i);
            }
        });
    }
})(jQuery);    

function log(x) {
    jQuery("#log").append("<div>" + x + "</div>");
}

// hook up our test engine    
$("#clear").click(function() {
    $("#log").html("");
});


$("#container input").userChange(function(e) {
    log("change - " + e.type + " (" + this.value + ")");
});
Knecht answered 20/3, 2013 at 5:51 Comment(3)
Thanks for the excellent response! I'll be taking my time reading the code but I'd like to ask upfront whether this can or not deal with changes caused by an undo or redo?Drawn
@czarpino - I haven't tested how it with a browser's undo function so I don't know - you will need to try it. If the focus is in the field when the undo key is pressed, this should handle that. But, if the focus is elsewhere and the user uses undo from a menu, I don't know. It seems to work for me in Chrome in the jsFiddle, but I don't know the specific test cases you're looking to cover.Knecht
Thanks @jfriend00! The input event seems to be the "trick" as it handles undo & redo events.Drawn
M
5

You can monitor all the changes using MutationObserver. This won't give you event for every keydown and keyup, but it kind of consolidate multiple changes and give it out to you as single event.

  var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;
  var observer = new MutationObserver(function(mutations) {  
    mutations.forEach(function(mutation) {
        // mutation.target will give you element which has been modified.
        // mutation.addedNodes and mutation.removedNodes will give you operations that were performed on the node
        // happy coding :)
      });
  });
  observer.observe(elementsToMonitor, {
    attributes: true, 
    childList: true, 
    characterData: true 
   });

More info about MutationObserver https://developer.mozilla.org/en/docs/Web/API/MutationObserver

Methionine answered 27/3, 2014 at 7:45 Comment(0)
I
2

Hot Keys by John Resig (Creator of JQuery) may help

https://github.com/jeresig/jquery.hotkeys

From the readme file

If you want to use more than one modifiers (e.g. alt+ctrl+z) you should define them by an alphabetical order e.g. alt+ctrl+shift

Idden answered 20/3, 2013 at 5:50 Comment(1)
This is the closest/only possibility. Still doesn't capture edit -> undoWindowsill
H
0
<input type="text"/>
<script>
var newInput = "";
var oldInput = [$('input').val()];
$('input').on('input',function(){
    newInput = $(this).val();
    redo = false;
    $(oldInput).each(function(i){if(newInput==oldInput[i]){redo = true; return false});
    if(redo){
        console.log('do code for an undo or redo');
    }
oldInput.push(newInput);
console.log([oldInput,newInput]);
});
</script>

The basic concept is to store previous input values and check if the new input value equals one of those previous ones. It's not perfect (e.g. a backspace triggers it) and a little inefficient (see next paragraph), but you should be able to get your desired results.

Instead of keeping all previous inputs, you could look at the code for undo to see what it actually keeps (I think it just keeps most inputs as lost as they are within a timeframe of each other).

Handful answered 16/5, 2015 at 22:29 Comment(0)
G
0

There was a time when I needed something like this in a project I was working on. The marked solution didn't seem that elegant to me to get the result. I used a combination of a couple of things answered here to do it.

function UndoListener(options){
    if(!options.el instanceof HTMLElement) return;
    this.el = options.el;
    this.callback = options.callback || function(){};
    this.expectedChange = false;
    this.init();
}

UndoListener.prototype = {
    constructor: UndoListener,
    addListeners: function(){
        this.el.addEventListener('keydown', (e) => this.expectedChange = this.eventChecker(e));
        this.el.addEventListener('cut', (e) => this.expectedChange = true);
        this.el.addEventListener('paste', (e) => this.expectedChange = true);
    },
    addObserver: function(){
        this.observer = new MutationObserver((mt) => {
            if(!this.expectedChange){
                this.expectedChange = true;
                this.observer.disconnect();
                this.callback.call(this.el, {
                    original: [...mt].shift().oldValue,
                    current: this.el.innerText
                });
                this.addObserver();
            }
            this.expectedChange = false;
        });

        this.observer.observe(this.el, {
            characterData: true,
            subtree: true,
            characterDataOldValue: true
        });
    },
    eventChecker: function(event) {
        return !(~['z','y'].indexOf(event.key) && (event.ctrlKey || event.metaKey));
    },
    init: function(){
        this.addListeners();
        this.addObserver();
    }
}

This uses MutationObserver to "catch" undo events. It does this because MutationObserver fires after events fire. We check if the event was an expected event, like keydown or cut and allow the change to occur without the callback firing. If the event was unexpected, we assume an undo has occurred. This cannot differentiate between undo and redo; the callback will fire on either. Usage:

var catcher = new UndoListener({
    el: document.querySelector('.container'),
    callback: function(val){
        console.log('callback fired', val);
    }
});

I have this working in action on codepen.

Gipon answered 8/1, 2018 at 16:12 Comment(1)
Unfortunately MutationObserver does not work for form elements, such as textarea.Negrillo
I
0

I created a state undo/redo snapshot manager class, which would be great for tracking the change history on an entire HTML element with child inputs. If you want to take snapshots on a more granular level, you can bind on keypress events instead of the change events I used in my example.

  <div id="buttons">
     <button type="button" id="undo_btn">Undo</button>
     <button type="button" id="redo_btn">Redo</button>
  </div>
  <br/><br/>
  <div id="content">
     <label>
        Input1:
        <input type="text" value="" />
     </label>
     <br/><br/>
     <label>
        Input2:
        <input type="text" value="" />
     </label>
     <br/><br/>
     <label>
        Input3:
        <input type="text" value="" />
     </label>
     <br/><br/>
     <label>
        Input4:
        <input type="text" value="" />
     </label>
     <br/><br/>
  </div>

  <script type="text/javascript">
  var StateUndoRedo = function() {
     var init = function(opts) {
        var self = this;
        self.opts = opts;
        if(typeof(self.opts['undo_disabled']) == 'undefined') {
           self.opts['undo_disabled'] = function() {};
        }
        if(typeof(self.opts['undo_enabled']) == 'undefined') {
           self.opts['undo_enabled'] = function() {};
        }
        if(typeof(self.opts['redo_disabled']) == 'undefined') {
           self.opts['redo_disabled'] = function() {};
        }
        if(typeof(self.opts['redo_enabled']) == 'undefined') {
           self.opts['redo_enabled'] = function() {};
        }
        if(typeof(self.opts['restore']) == 'undefined') {
           self.opts['restore'] = function() {};
        }
        self.opts['undo_disabled']();
        self.opts['redo_disabled']();
     }

     var add = function(state) {
        var self = this;
        if(typeof(self.states) == 'undefined') {
           self.states = [];
        }
        if(typeof(self.state_index) == 'undefined') {
           self.state_index = -1;
        }
        self.state_index++;
        self.states[self.state_index] = state;
        self.states.length = self.state_index + 1;
        if(self.state_index > 0) {
           self.opts['undo_enabled']();
        }
        self.opts['redo_disabled']();
     }

     var undo = function() {
        var self = this;
        if(self.state_index > 0) {
           self.state_index--;
           if(self.state_index == 0) {
              self.opts['undo_disabled']();
           } else {
              self.opts['undo_enabled']();
           }
           self.opts['redo_enabled']();

           self.opts['restore'](self.states[self.state_index]);
       }
     }

     var redo = function() {
        var self = this;
        if(self.state_index < self.states.length) {
           self.state_index++;
           if(self.state_index == self.states.length - 1) {
              self.opts['redo_disabled']();
           } else {
              self.opts['redo_enabled']();
           }
           self.opts['undo_enabled']();

           self.opts['restore'](self.states[self.state_index]);
       }
     }

     var restore = function() {
        var self = this;
        self.opts['restore'](self.states[self.state_index]);
     }

     var clear = function() {
        var self = this;
        self.state_index = 0;
        //self.states = [];
     }

     return {
        init: init,
        add: add,
        undo: undo,
        redo: redo,
        restore: restore,
        clear: clear
     };
  };

  //initialize object
  var o = new StateUndoRedo();
  o.init({
     'undo_disabled': function() {
        //make the undo button hidden
        document.getElementById("undo_btn").disabled = true;
     },
     'undo_enabled': function() {
        //make the undo button visible
        document.getElementById("undo_btn").disabled = false;
     },
     'redo_disabled': function() {
        //make the redo button hidden
        document.getElementById("redo_btn").disabled = true;
     },
     'redo_enabled': function() {
        //make the redo button visible
        document.getElementById("redo_btn").disabled = false;
     },
     'restore': function(state) {
        //replace the current content with the restored state content
        document.getElementById("content").innerHTML = state;
     }
  });

  //initialize first state
  o.add(document.getElementById("content").innerHTML);
  o.restore();
  o.clear();

  //bind click events for undo/redo buttons
  document.getElementById("undo_btn").addEventListener("click", function() {
     o.undo();
  });
  document.getElementById("redo_btn").addEventListener("click", function() {
     o.redo();
  });

  //bind change events for content element
  document.getElementById('content').addEventListener("change", function(event) {
     // the following is required since vanilla JS innerHTML 
     // does not capture user-changed values of inputs
     // so we set the attributes explicitly (use jQuery to avoid this)
     var elems = document.querySelectorAll("#content input");
     for(var i = 0; i < elems.length; i++) {
        elems[i].setAttribute("value", elems[i].value);
     }

     //take a snapshot of the current state of the content element
     o.add(document.getElementById("content").innerHTML);
  });
  </script>

See this JSFiddle: https://jsfiddle.net/up73q4t0/56/

Interstitial answered 25/3, 2018 at 20:19 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.