Announcement

Thursday, September 06, 2012

'shortcut' binding for KnockoutJS

[NOTE : This post was originally published on  BootStrapToday blog as "Shortcut binding for Knockoutjs"]

Now that we have BootStrapToday V3 based on KnockoutJS in production, we are working on various enhancements to improve user experience. One of the enhancement we have added in this release, is Keyboard shortcuts for various operations (Ctrl+Enter for form submit, Esc for cancel and various other shortcuts). However, implementing shortcuts is tricky. Basically we wanted to do the following
  • Simulate A 'click' on some attached html element when user presses the shortcut key.
  • The shortcuts can contain the modifier keys like 'ctrl or meta', 'alt' and special keys like 'enter' or 'escape'.
  • Since we dynamically 'load' various pages, different shortcuts will be available at different points, or same shortcut will behave differently on different pages. For example, when 'e' is pressed when Ticket view is visible, will open the ticket edit form, same key will open 'milestone' edit when milestone view is visible. Hence any event handler attached has to be removed and new handler need to be added when the context changes.
  • Different browsers behave slightly differently. For example, 'ctrl+enter'  will trigger 'keydown' event but not the 'keypress' event in some browsers. Chrome capture Ctrl+F and Alt+F, so we cannot use some combinations.
  • Consider a situation, where shortcut key is 'n'. I  added an 'event handler' to handle the keypress. Now if form is open and focus is on form input field, if user presses 'n', shortcut will be triggered. Obviously this is not expected. So shortcuts without modifiers have to be ignored, when focus is on some form input field.
  • Sometimes the element is not visible (for example, drop down menus). In such cases, click should be triggered only when the element is visible.
There are lots of complications involved. Since every page requires some shortcuts to be defined, it was time for a custom knockout binding. So I am going to show you how I developed the shortcut binding handling all the complexities mentioned above. It's not perfect and may require more tweaks.

Version 1:

I started with a simple approach of adding a jquery event handler for the 'keypress' event. The event handler is added to 'body' tag so that it works whatever element has focus. I used a regex to handle 'ctrl' modifier. The binding triggers a 'click' on the element attached to it.

 /*short cut binding handler. Defines a keyboard short for 'click' event */  
 ko.bindingHandlers.shortcut = {  
   init: function(element, valueAccessor, allBindingsAccessor,viewModel) {      
     var key = valueAccessor();      
     var regx = /ctrl\+/gi;  
     key = key.replace(regx,'');  
     var handler = function(event) {  
       if((event.metaKey || event.ctrlKey) && event.charCode == key.charCodeAt(0)) {      
         event.preventDefault();      
         event.stopPropagation();  
         $(element).click();  
         return false;    
       }  
       return true;  
     }  
     $('body').on('keypress', handler);  
   }  
 }  

Version 2:

This version handles 'alt' modifier as well. Sometimes the element is not visible (for example, drop down menus). In such cases, click should be triggered only when the element is visible. Hence I added a check to ensure that element is visible.

 ko.bindingHandlers.shortcut = {  
   init: function(element, valueAccessor, allBindingsAccessor,viewModel) {  
     var key = valueAccessor();  
     key = key.toLowerCase();  
     var match = key.match(/ctrl\+/gi);  
     var ctrl_modifier = (match && match.length > 0);  
     match = key.match(/alt\+/gi);  
     var alt_modifier = (match && match.length > 0);  
     key = key.replace(/(alt\+|ctrl\+)/gi,'');  
     key = key.charCodeAt(0);  
     var handler = function(event) {  
       //first check if the element is visible. Do not trigger clicks  
       // on invisible elements. This way I can add short cuts on  
       // drop down menus.  
       if($(element).is(':visible')) {  
         var modifier_match = ( ctrl_modifier && (event.metaKey || event.ctrlKey))  
           || ( alt_modifier && event.altKey );  
         if( modifier_match &&event.charCode == key) {        
           event.preventDefault();  
           event.stopPropagation();  
           $(element).click();  
           return false;  
         }  
       }  
       return true;  
     }    
     $('body').on('keypress', handler);  
   }  
 }  

Version 3:

Still the special keys like 'enter' and 'escape' are not handled. Also Safari/Chrome do not trigger 'keypress' event for special keys like 'escape'. So based on if shortcut is an alpha numeric key or special key, 'keypress' or 'keydown' event is used.

 var special_key_map = { 'enter': 13, 'esc': 27}  
 ko.bindingHandlers.shortcut = {  
   init: function(element, valueAccessor, allBindingsAccessor,viewModel) {  
     var key = valueAccessor();  
     key = key.toLowerCase();  
     var match = key.match(/ctrl\+/gi);  
     var ctrl_modifier = Boolean(match && match.length > 0);  
     match = key.match(/alt\+/gi);  
     var alt_modifier = Boolean(match && match.length > 0);  
     key = key.replace(/(alt\+|ctrl\+)/gi,'');  
     var keycode = null;      
     if( key in special_key_map) {      
       keycode = special_key_map[key];      
     }else {    
       keycode = key.charCodeAt(0);  
     }  
     var handler = function(event) {  
       //first check if the element is visible. Do not trigger clicks      
       // on invisible elements. This way I can add short cuts on      
       // drop down menus. if($(element).is(':visible')){  
       var modifier_match = ( ctrl_modifier == (event.metaKey || event.ctrlKey))  
         && ( alt_modifier == event.altKey );  
       if( modifier_match && (event.charCode == keycode || event.keyCode == keycode)) {  
         event.preventDefault();  
         event.stopPropagation();  
         $(element).click();  
         return false;    
       }  
       return true;  
     }  
     if( key in special_key_map) {  
       $('body').on('keydown', handler);  
     }else {  
       $('body').on('keypress', handler);  
     }  
   }  
 }  

Version 4:

Now shortcuts like just 'n' has to be ignored if the focus is on a form input field. However, shortcut like 'escape' or 'ctrl+n' (i.e. special key or with modifier) should work even when the focus is on form input. So lets add that condition as well.

 var special_key_map = { 'enter': 13, 'esc': 27}  
 ko.bindingHandlers.shortcut = {  
   init: function(element, valueAccessor, allBindingsAccessor,viewModel) {  
     var key = valueAccessor();      
     key = key.toLowerCase();  
     var match = key.match(/ctrl\+/gi);      
     var ctrl_modifier = Boolean(match && match.length > 0);  
     match = key.match(/alt\+/gi);      
     var alt_modifier = Boolean(match && match.length > 0);  
     key = key.replace(/(alt\+|ctrl\+)/gi,'');  
     var keycode = null;  
     if( key in special_key_map) {  
       keycode = special_key_map[key];   
     }else {  
       keycode = key.charCodeAt(0);  
     }  
     // if no modifiers are specified in the shortcut (.e.g shortcut is just 'n')  
     // in such cases, do not trigger the shortcut if the focus is on  
     // form field.  
     // if modifier are specified, then even if focus is on form field  
     // trigger the shortcut (e.g. ctrl+enter)  
     var ignore_form_input=Boolean(ctrl_modifier || alt_modifier || key in special_key_map);  
     var handler = function(event) {  
       //first check if the element is visible. Do not trigger clicks  
       // on invisible elements. This way I can add short cuts on  
       // drop down menus.  
       var $element = $(element);        
       var $target = $(event.target);  
       var is_forminput = Boolean($target.is('button')==false && $target.is(':input')==true);  
       if($element.is(':visible') && (ignore_form_input==true || is_forminput==false)) {  
         var modifier_match = ( ctrl_modifier == (event.metaKey || event.ctrlKey))  
           && ( alt_modifier == event.altKey );  
         if( modifier_match && (event.charCode == keycode || event.keyCode == keycode)) {  
           event.preventDefault();  
           event.stopPropagation();  
           $element.click();  
           return false;  
         }  
       }  
     return true;  
     }  
     if( key in special_key_map) {  
       $('body').on('keydown', handler);  
     }else {  
       $('body').on('keypress', handler);  
     }  
   }  
 }  

Version 5 (Final):

So far we are not removing the attached event handlers. Obviously it's not a good idea especially if binding get evaluated multiple times and event handler gets attached multiple times. To remove the event handler, we have to define a 'dispose' callback which gets called, whenever the element (to which the binding is attached) is disposed/deleted from the DOM.

 /*  
 * Copyright 2012 Sensible Softwares Pvt. Ltd, India. (http://bootstraptoday.com)  
 *  
 * Licensed under the MIT License. (http://opensource.org/licenses/mit-license.php)  
 *  
 */  
 var special_key_map = { 'enter': 13, 'esc': 27}  
 ko.bindingHandlers.shortcut = {  
   init: function(element, valueAccessor, allBindingsAccessor,viewModel) {  
     var key = valueAccessor();  
     key = key.toLowerCase();  
     var match = key.match(/ctrl\+/gi);    
     var ctrl_modifier = Boolean(match && match.length > 0);  
     match = key.match(/alt\+/gi);  
     var alt_modifier = Boolean(match && match.length > 0);  
     key = key.replace(/(alt\+|ctrl\+)/gi,'');  
     var keycode = null;  
     if( key in special_key_map) {  
       keycode = special_key_map[key];  
     }else {  
       keycode = key.charCodeAt(0);  
     }  
     // if no modifiers are specified in the shortcut (.e.g shortcut is just 'n')  
     // in such cases, do not trigger the shortcut if the focus is on  
     // form field.  
     // if modifier are specified, then even if focus is on form field  
     // trigger the shortcut (e.g. ctrl+enter)  
     var ignore_form_input=Boolean(ctrl_modifier || alt_modifier || key in special_key_map);  
     var handler = function(event) {  
       //first check if the element is visible. Do not trigger clicks  
       // on invisible elements. This way I can add short cuts on  
       // drop down menus.  
       var $element = $(element);  
       var $target = $(event.target);  
       var is_forminput = Boolean($target.is('button')==false && $target.is(':input')==true)  
       if($element.is(':visible') && (ignore_form_input==true || is_forminput==false)) {  
         var modifier_match = ( ctrl_modifier == (event.metaKey || event.ctrlKey))  
           && ( alt_modifier == event.altKey );  
          if( modifier_match && (event.charCode == keycode || event.keyCode == keycode)) {  
           event.preventDefault();   
           event.stopPropagation();  
           $element.click();  
           // event is handled so return false so that any further propagation is stopped.  
           return false;    
         }  
       }  
       // event is not handled. Hence return true so that propagation continues.  
       return true;  
     }  
     var eventname='keypress';  
     if( key in special_key_map) {    
       eventname = 'keydown';  
     }  
     $('body').on(eventname, handler);  
     var removeHandlerCallback = function() {    
       $('body').off(eventname, handler);  
     }  
     // Now add a callback on the element so that when the element is 'disposed'  
     // we can remove the event handler from the 'body'  
     ko.utils.domNodeDisposal.addDisposeCallback(element, removeHandlerCallback);    
   }  
 }  

That's the final version. So far it's working out very nicely in BootStrapToday. If we find any bugs or enhancements we will update here. Please feel free to suggest improvements.

You can check live by signing up for free from here.

License : Shortcut binding code above is licensed under MIT License.Same as KnockoutJS. So feel free to use it anyway that you want.

1 comment:

Adam Connelly said...

Not sure if anyone's still looking at this, but the handler worked great for me with one small issue - it doesn't check whether the item is disabled or not. To fix it, I just altered the if statement that starts 'if ($element.is(":visible")' and added ' && !$element.is(':disabled')'.

Cheers,
Adam