/*! Menu Hotkeys - v0.1.0 - 2016-04-08
* https://github.com/jonmbake/menu-hotkeys
* Copyright (c) 2016 Jon Bake; Licensed MIT */
/* globals define */
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
define(['jquery'], factory);
} else {
factory(root.jQuery);
}
}(this, function($) {
var jQuery = $;
/**
* Default initialization options.
* Other valid options are:
* menuHotkeyUrl - the url to GET and PUT hotkeys to
* @type {Object}
*/
var defaultOptions = {
hotkeyPrefix: 'alt'
};
/**
* A simple jQuery Event Dispatcher (Event Bus). Any registered event will have every registered element notified
* (triggerd) when the event is triggered through the dispatcher.
* This allows components to remain decoupled and allows event to easily propogate up to the main element.
* @constructor
*/
var Dispatcher = function () {
this.reset();
};
$.extend(Dispatcher.prototype, {
/**
* Clear all registered events.
* @return {undefined}
*/
reset: function () {
//hash of event to elements registered
this.registered = {};
},
/**
* Register an element to one or multiple events. Optionally provided a callback to be invoked when the event is
* triggered through the dispatcher.
* @param {jQuqery Object} $element - to register the event on
* @param {String} or {Array} events - or single event to register the $element under
* @param {Function} callback - optional callback to be invoked when event is triggered
* @return {undefined}
*/
register: function ($element, events, callback) {
events = Array.isArray(events) ? events : [events];
events.forEach(function (event) {
if (!this.registered.hasOwnProperty(event)) {
this.registered[event] = [{element: $element, callback: callback}];
} else {
this.registered[event].push({element: $element, callback: callback});
}
}, this);
},
/**
* Unregister an element from an event.
* @param {jQuqery Object} $element - to unregister from the event
* @param {String} or {Array} events or single event to unregister the $element from. If no events specified, remove element from every registerd event.
* @return {undefined}
*/
unregister: function ($element, events) {
if (events) {
events = Array.isArray(events) ? events : [events];
} else {
events = Object.keys(this.registered);
}
events.forEach(function (event) {
var reg = this.registered[event];
if (reg) {
var index = -1;
for (var i = 0; i < reg.length; i++) {
if ($element.is(reg[i].element)) {
index = i;
break;
}
}
if (index !== -1) {
reg.splice(index, 1);
}
}
}, this);
},
/**
* Trigger an event.
* @param {String} event -
* @return {undefined}
*/
trigger: function (event) {
var reg = this.registered[event];
if (reg) {
var args = Array.prototype.slice.call(arguments, 1);
reg.forEach(function(r) {
r.element.trigger(event, args);
if (r.callback) {
r.callback.apply(null, args);
}
});
}
}
});
/**
* Hotkey Prompt.
* @constructor
* @param {MenuItem} menuItem - menu item the prompt is attached to
*/
var HotkeyPrompt = function (menuItem) {
this.menuItem = menuItem;
var $a = this.menuItem.$a;
this.menuItem.hotkeyDispatcher.register($a, 'menu-hotkey-input-error', function (errorMsg) {
$('.hotkey-error-msg').text(errorMsg).show();
$('.hotkey-input').focus();
});
this.menuItem.hotkeyDispatcher.register($a, 'menu-hotkey-input-close', this.close.bind(this));
this.menuItem.hotkeyDispatcher.register($a, 'menu-hotkey-input-open');
};
$.extend(HotkeyPrompt.prototype, {
/**
* Close the prompt
* @return {undefined}
*/
close: function () {
this.menuItem.$a.popover('destroy');
},
/**
* Open the prompt.
* @return {undefined}
*/
open: function () {
var menuItem = this.menuItem;
var $a = menuItem.$a;
//close any prompts that are open
menuItem.hotkeyDispatcher.trigger('menu-hotkey-input-close');
$a.popover({
animation: false,
placement: 'bottom',
html: true,
title: (menuItem.hotkey ? 'Update' : 'Add') + ' Hotkey',
content: '
' + this.menuItem.hotkeyPrefix + '+\
\
\
\
\
\
'
});
$a.popover('show');
$('.popover-title').css({'white-space': 'nowrap'});
if (menuItem.hotkey) {
$('.hotkey-input').val(menuItem.hotkey);
$('.remove-shortcut-btn').on('click', function () {
menuItem.hotkeyDispatcher.trigger('update-menu-shortcut', {name: menuItem.name, hotkey: null});
});
} else {
$('.remove-shortcut-btn').hide();
}
$('.cancel-shortcut-btn').on('click', function () {
menuItem.hotkeyDispatcher.trigger('menu-hotkey-input-close');
});
$('.add-shortcut-btn').on('click', function () {
var hotkey = $('.hotkey-input').val();
if (this.validateHotkeyInput(hotkey)) {
menuItem.hotkeyDispatcher.trigger('update-menu-shortcut', {name: menuItem.name, hotkey: hotkey});
}
}.bind(this));
$('.hotkey-input').focus();
menuItem.hotkeyDispatcher.trigger('menu-hotkey-input-open');
},
/**
* Validate input and trigger 'menu-hotkey-input-error' if invalid.
* Checks that a value is chosen, has change and that the value is one char. long. Additonal validation can be done by
* listening for 'update-menu-shortcut'.
* @param {String} hotkey - hotkey text
* @return {boolean} true if valid, false otherwise
*/
validateHotkeyInput: function (hotkey) {
if (hotkey.length === 0) {
this.menuItem.hotkeyDispatcher.trigger('menu-hotkey-input-error', 'Please enter a Shortcut value.');
return false;
} else if (hotkey.length > 1) {
this.menuItem.hotkeyDispatcher.trigger('menu-hotkey-input-error', 'Shortcut must be one character long.');
return false;
} else if (hotkey === this.menuItem.hotkey) {
this.menuItem.hotkeyDispatcher.trigger('menu-hotkey-input-error', 'Value has not changed.');
return false;
}
return true;
}
});
/**
* A Menu Item managed by {@link HotkeyMenu}.
* @param {jQuery Object} $a - menu link element
* @param {Dispatcher} hotkeyDispatcher - event dispatcher
* @param {String} hotkeyPrefix - hot key prefix (passed in as option)
* @param {String} hotkey - existing hotkey
*/
var MenuItem = function ($a, hotkeyDispatcher, hotkeyPrefix, hotkey) {
$.extend(this, { $a: $a, hotkeyDispatcher: hotkeyDispatcher, hotkeyPrefix: hotkeyPrefix});
this.name = $a.text();
this.linkClicker = function () {
var a = $a[0];
if (a) {
a.click();
}
};
hotkeyDispatcher.register($a, 'menu-hotkey-updated', function (shortcut) {
if (shortcut.name === this.name) {
this.updateHotkey(shortcut.hotkey);
hotkeyDispatcher.trigger('menu-hotkey-input-close');
}
}.bind(this));
this.updateHotkey(hotkey);
this.boundClickHandler = this.clickHandler.bind(this);
this.$a.on('click', this.boundClickHandler);
};
$.extend(MenuItem.prototype, {
destroy: function () {
this.hotkeyDispatcher.trigger('menu-hotkey-input-close');
this.removeHotkeyIndicator();
this.hotkeyDispatcher.unregister(this.$a);
if (this.hotkey) {
$(document).unbind('keydown', this.linkClicker);
}
this.$a.off('click', this.boundClickHandler);
},
/**
* Add menu item click handlers. If shift key is down, open the prompt; otherwise preform default click action.
*/
clickHandler: function (e) {
if (e.altKey) {
e.preventDefault();
e.stopImmediatePropagation();
//lazily construct prompt
this.hotkeyPrompt = this.hotkeyPrompt || new HotkeyPrompt(this);
this.hotkeyPrompt.open();
}
},
/**
* Update hotkey with new value.
* Unbind hotkey, update UI and trigger 'update-menu-shortcut' event.
* @param {String} hotkey -
* @return {undefined}
*/
updateHotkey: function (hotkey) {
if (this.hotkey) {
$(document).unbind('keydown', this.linkClicker);
}
this.hotkey = hotkey;
this.addHotkeyIndicator(hotkey);
if (!hotkey) {
return;
}
var keyCombination = this.hotkeyPrefix + '+' + hotkey;
$(document).bind('keydown', keyCombination, this.linkClicker);
},
addHotkeyIndicator: function (hotkey) {
this.removeHotkeyIndicator();
if (hotkey) {
this.$a.append($('').text(hotkey));
}
},
removeHotkeyIndicator: function () {
this.$a.find('sup').remove();
}
});
/**
* Hotkey menu - repsponsible for setting up menu items, including loading persisted data.
* @param {jQuery Object} $menu menu element - should contain links
* @param {object} options - options to plugin @see defaultOptions above
*/
var HotkeyMenu = function ($menu, options) {
$.extend(this, {$menu: $menu}, defaultOptions, options);
this.init();
};
$.extend(HotkeyMenu.prototype, {
LOCAL_STORAGE_ITEM_NAME: 'MENU_SHORTCUTS',
/**
* Initialize the hotkey menu. This can also be used to re-initialize when the base menu changes.
* @return {undefined}
*/
init: function () {
var $menu = this.$menu;
//if re-initializing, destroy any existing menu items.
if (this.menuItems) {
this.menuItems.forEach(function (mi) {
mi.destroy();
});
}
var items = this.menuItems = [];
var hotkeyPrefix = this.hotkeyPrefix;
var dispatcher = this.dispatcher = new Dispatcher();
dispatcher.register($menu, 'update-menu-shortcut', this.saveShortcut.bind(this));
dispatcher.register($menu, ['menu-hotkey-input-open', 'menu-hotkey-input-close', 'menu-hotkey-input-error']);
this.loadSavedShortcuts().then(function (shortcuts) {
$menu.find('a').each(function () {
var $a = $(this);
for (var scName in shortcuts) {
if (scName === $a.text()) {
items.push(new MenuItem($a, dispatcher, hotkeyPrefix, shortcuts[scName]));
return;
}
}
items.push(new MenuItem($a, dispatcher, hotkeyPrefix));
});
$menu.trigger('menu-hotkeys-loaded', shortcuts);
});
},
/**
* Save shortcut either in local storage or by PUTing to url if menuHotkeyUrl option is supplied.
* @param {Object} shortcut - shortcut object with name and hotkey properties
* @return {undefined}
*/
saveShortcut: function (shortcut) {
var existing = this.getNameForHotkey(shortcut.hotkey);
if (existing) {
this.dispatcher.trigger('menu-hotkey-input-error', 'Shortcut already exists for ' + existing + '.');
return;
}
if (shortcut.hotkey) {
this.shortcuts[shortcut.name] = shortcut.hotkey;
} else {
delete this.shortcuts[shortcut.name];
}
if (this.menuHotkeyUrl) {
$.ajax({
dataType: "json",
method: shortcut.hotkey ? "PUT" : "DELETE",
url: this.menuHotkeyUrl + '/' + encodeURI(shortcut.name),
data: shortcut,
success: function () {
this.dispatcher.trigger('menu-hotkey-updated', shortcut);
}.bind(this)
});
} else {
window.localStorage.setItem(this.LOCAL_STORAGE_ITEM_NAME, JSON.stringify(this.shortcuts));
this.dispatcher.trigger('menu-hotkey-updated', shortcut);
}
},
/**
* Load saved shorcuts from either Local Storage or url if menuHotkeyUrl option is supplied.
* @return {promise} - will reject if loading fails
*/
loadSavedShortcuts: function () {
this.shortcuts = {};
if (this.menuHotkeyUrl) {
return $.ajax({
dataType: "json",
url: this.menuHotkeyUrl,
success: function (data) {
this.shortcuts = data;
}.bind(this)
});
} else {
var deferred = $.Deferred();
var shortcuts = window.localStorage.getItem(this.LOCAL_STORAGE_ITEM_NAME);
if (shortcuts) {
try {
this.shortcuts = JSON.parse(shortcuts);
} catch (e) {
return deferred.reject('Error while attempting to load menu shortcuts. Unable to parse: ', shortcuts);
}
}
return deferred.resolve(this.shortcuts);
}
},
/**
* Returns Menu Item name for given hotkey.
* @param {String} hotkey - hotkey name
* @return {Object} Shortcut Object
*/
getNameForHotkey: function (hotkey) {
for (var sc in this.shortcuts) {
if (this.shortcuts[sc] === hotkey) {
return sc;
}
}
}
});
/**
* Public API.
*
* @type {Object} api
*/
var api = {
/**
* Inititialize this element to track revisions.
*
* @param {Object} options revision
* @return {[type]} [description]
*/
init: function (options) {
var d = this.data('hotkeys');
if (d) {
d.init();
} else {
d = new HotkeyMenu(this, options);
this.data('hotkeys', d);
}
return this;
}
};
//Register this plugin.
$.fn.menuHotkeys = function(firstArg) {
var pluginArgs = arguments;
var isApiCall = typeof firstArg === 'string';
var r = this.map(function () {
if (firstArg === void 0 || typeof firstArg === 'object') { //calling the constructor
return api.init.call($(this), firstArg);
} else if (isApiCall && api[firstArg]) { //calling an API method
return api[firstArg].apply($(this), Array.prototype.slice.call(pluginArgs, 1));
} else { //calling a method that is not part of the API -- throw an error
throw new Error("Calling method that is not part of the API");
}
});
//if API call, "un-jquery" the return value
if (isApiCall) {
//if a "get" call just return a single element
if (firstArg.indexOf('get') === 0) {
return r[0];
}
return r.toArray();
}
return r;
};
/*
* Everything after here is **jQuery Hotkeys Plugin** source.
* Copyright 2010, John Resig
*/
jQuery.hotkeys = {
version: "0.8",
specialKeys: {
8: "backspace",
9: "tab",
10: "return",
13: "return",
16: "shift",
17: "ctrl",
18: "alt",
19: "pause",
20: "capslock",
27: "esc",
32: "space",
33: "pageup",
34: "pagedown",
35: "end",
36: "home",
37: "left",
38: "up",
39: "right",
40: "down",
45: "insert",
46: "del",
59: ";",
61: "=",
96: "0",
97: "1",
98: "2",
99: "3",
100: "4",
101: "5",
102: "6",
103: "7",
104: "8",
105: "9",
106: "*",
107: "+",
109: "-",
110: ".",
111: "/",
112: "f1",
113: "f2",
114: "f3",
115: "f4",
116: "f5",
117: "f6",
118: "f7",
119: "f8",
120: "f9",
121: "f10",
122: "f11",
123: "f12",
144: "numlock",
145: "scroll",
173: "-",
186: ";",
187: "=",
188: ",",
189: "-",
190: ".",
191: "/",
192: "`",
219: "[",
220: "\\",
221: "]",
222: "'"
},
shiftNums: {
"`": "~",
"1": "!",
"2": "@",
"3": "#",
"4": "$",
"5": "%",
"6": "^",
"7": "&",
"8": "*",
"9": "(",
"0": ")",
"-": "_",
"=": "+",
";": ": ",
"'": "\"",
",": "<",
".": ">",
"/": "?",
"\\": "|"
},
// excludes: button, checkbox, file, hidden, image, password, radio, reset, search, submit, url
textAcceptingInputTypes: [
"text", "password", "number", "email", "url", "range", "date", "month", "week", "time", "datetime",
"datetime-local", "search", "color", "tel"],
// default input types not to bind to unless bound directly
textInputTypes: /textarea|input|select/i,
options: {
filterInputAcceptingElements: false,
filterTextInputs: false,
filterContentEditable: false
}
};
function keyHandler(handleObj) {
if (typeof handleObj.data === "string") {
handleObj.data = {
keys: handleObj.data
};
}
// Only care when a possible input has been specified
if (!handleObj.data || !handleObj.data.keys || typeof handleObj.data.keys !== "string") {
return;
}
var origHandler = handleObj.handler,
keys = handleObj.data.keys.toLowerCase().split(" ");
handleObj.handler = function(event) {
// Don't fire in text-accepting inputs that we didn't directly bind to
if (this !== event.target &&
(jQuery.hotkeys.options.filterInputAcceptingElements &&
jQuery.hotkeys.textInputTypes.test(event.target.nodeName) ||
(jQuery.hotkeys.options.filterContentEditable && jQuery(event.target).attr('contenteditable')) ||
(jQuery.hotkeys.options.filterTextInputs &&
jQuery.inArray(event.target.type, jQuery.hotkeys.textAcceptingInputTypes) > -1))) {
return;
}
var special = event.type !== "keypress" && jQuery.hotkeys.specialKeys[event.which],
character = String.fromCharCode(event.which).toLowerCase(),
modif = "",
possible = {};
jQuery.each(["alt", "ctrl", "shift"], function(index, specialKey) {
if (event[specialKey + 'Key'] && special !== specialKey) {
modif += specialKey + '+';
}
});
// metaKey is triggered off ctrlKey erronously
if (event.metaKey && !event.ctrlKey && special !== "meta") {
modif += "meta+";
}
if (event.metaKey && special !== "meta" && modif.indexOf("alt+ctrl+shift+") > -1) {
modif = modif.replace("alt+ctrl+shift+", "hyper+");
}
if (special) {
possible[modif + special] = true;
}
else {
possible[modif + character] = true;
possible[modif + jQuery.hotkeys.shiftNums[character]] = true;
// "$" can be triggered as "Shift+4" or "Shift+$" or just "$"
if (modif === "shift+") {
possible[jQuery.hotkeys.shiftNums[character]] = true;
}
}
for (var i = 0, l = keys.length; i < l; i++) {
if (possible[keys[i]]) {
return origHandler.apply(this, arguments);
}
}
};
}
jQuery.each(["keydown", "keyup", "keypress"], function() {
jQuery.event.special[this] = {
add: keyHandler
};
});
}));