From bb21902954511c66b622ce36ca3aa9afbe49db4b Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Tue, 3 Nov 2015 14:01:07 -0500 Subject: [PATCH] Add keyboard shortcuts back to d-editor --- .../admin/views/admin-customize.js.es6 | 17 +- .../discourse/components/d-editor.js.es6 | 59 +- .../initializers/enable-emoji.js.es6 | 1 + .../templates/components/d-editor.hbs | 2 +- vendor/assets/javascripts/mousetrap.js | 1327 +++++++++-------- 5 files changed, 727 insertions(+), 679 deletions(-) diff --git a/app/assets/javascripts/admin/views/admin-customize.js.es6 b/app/assets/javascripts/admin/views/admin-customize.js.es6 index faca47026..240ed0ac7 100644 --- a/app/assets/javascripts/admin/views/admin-customize.js.es6 +++ b/app/assets/javascripts/admin/views/admin-customize.js.es6 @@ -1,18 +1,3 @@ -/*global Mousetrap:true */ - export default Ember.View.extend({ - classNames: ['customize'], - - _init: function() { - var controller = this.get('controller'); - Mousetrap.bindGlobal('mod+s', function() { - controller.send("save"); - return false; - }); - }.on("didInsertElement"), - - _cleanUp: function() { - Mousetrap.unbindGlobal('mod+s'); - }.on("willDestroyElement") - + classNames: ['customize'] }); diff --git a/app/assets/javascripts/discourse/components/d-editor.js.es6 b/app/assets/javascripts/discourse/components/d-editor.js.es6 index 54f07dcd9..736d21bb6 100644 --- a/app/assets/javascripts/discourse/components/d-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/d-editor.js.es6 @@ -1,3 +1,4 @@ +/*global Mousetrap:true */ import loadScript from 'discourse/lib/load-script'; import { default as property, on } from 'ember-addons/ember-computed-decorators'; import { showSelector } from "discourse/lib/emoji/emoji-toolbar"; @@ -15,6 +16,8 @@ function getHead(head, prev) { const _createCallbacks = []; function Toolbar() { + this.shortcuts = {}; + this.groups = [ {group: 'fontStyles', buttons: []}, {group: 'insertions', buttons: []}, @@ -24,27 +27,31 @@ function Toolbar() { this.addButton({ id: 'bold', group: 'fontStyles', + shortcut: 'B', perform: e => e.applySurround('**', '**', 'bold_text') }); this.addButton({ id: 'italic', group: 'fontStyles', + shortcut: 'I', perform: e => e.applySurround('*', '*', 'italic_text') }); - this.addButton({group: 'insertions', id: 'link', action: 'showLinkModal'}); + this.addButton({id: 'link', group: 'insertions', shortcut: 'K', action: 'showLinkModal'}); this.addButton({ id: 'quote', group: 'insertions', icon: 'quote-right', + shortcut: 'Shift+9', perform: e => e.applySurround('> ', '', 'code_text') }); this.addButton({ id: 'code', group: 'insertions', + shortcut: 'Shift+C', perform(e) { if (e.selected.value.indexOf("\n") !== -1) { e.applySurround(' ', '', 'code_text'); @@ -58,6 +65,7 @@ function Toolbar() { id: 'bullet', group: 'extras', icon: 'list-ul', + shortcut: 'Shift+8', title: 'composer.ulist_title', perform: e => e.applyList('* ', 'list_item') }); @@ -66,6 +74,7 @@ function Toolbar() { id: 'list', group: 'extras', icon: 'list-ol', + shortcut: 'Shift+7', title: 'composer.olist_title', perform: e => e.applyList(i => !i ? "1. " : `${parseInt(i) + 1}. `, 'list_item') }); @@ -74,6 +83,7 @@ function Toolbar() { id: 'heading', group: 'extras', icon: 'font', + shortcut: 'Alt+1', perform: e => e.applyList('## ', 'heading_text') }); @@ -81,6 +91,7 @@ function Toolbar() { id: 'rule', group: 'extras', icon: 'minus', + shortcut: 'Alt+R', title: 'composer.hr_title', perform: e => e.addText("\n\n----------\n") }); @@ -92,15 +103,34 @@ Toolbar.prototype.addButton = function(button) { throw `Couldn't find toolbar group ${button.group}`; } - const title = button.title || `composer.${button.id}_title`; - g.buttons.push({ + const createdButton = { id: button.id, className: button.className || button.id, icon: button.icon || button.id, action: button.action || 'toolbarButton', - perform: button.perform || Ember.k, - title - }); + perform: button.perform || Ember.K + }; + + const title = I18n.t(button.title || `composer.${button.id}_title`); + if (button.shortcut) { + const mac = /Mac|iPod|iPhone|iPad/.test(navigator.platform); + const mod = mac ? 'Meta' : 'Ctrl'; + createdButton.title = `${title} (${mod}+${button.shortcut})`; + + // Mac users are used to glyphs for shortcut keys + if (mac) { + createdButton.title = createdButton.title.replace('Shift', "\u{21E7}") + .replace('Meta', "\u{2318}") + .replace('Alt', "\u{2325}") + .replace(/\+/g, ''); + } + + this.shortcuts[`${mod}+${button.shortcut}`.toLowerCase()] = createdButton; + } else { + createdButton.title = title; + } + + g.buttons.push(createdButton); }; export function onToolbarCreate(func) { @@ -115,9 +145,24 @@ export default Ember.Component.extend({ lastSel: null, @on('didInsertElement') - _loadSanitizer() { + _startUp() { this._applyEmojiAutocomplete(); loadScript('defer/html-sanitizer-bundle').then(() => this.set('ready', true)); + + const shortcuts = this.get('toolbar.shortcuts'); + Ember.keys(shortcuts).forEach(sc => { + const button = shortcuts[sc]; + Mousetrap(this.$('.d-editor-input')[0]).bind(sc, () => { + this.send(button.action, button); + }); + }); + }, + + @on('willDestroyElement') + _shutDown() { + Ember.keys(this.get('toolbar.shortcuts')).forEach(sc => { + Mousetrap(this.$('.d-editor-input')[0]).unbind(sc); + }); }, @property diff --git a/app/assets/javascripts/discourse/initializers/enable-emoji.js.es6 b/app/assets/javascripts/discourse/initializers/enable-emoji.js.es6 index d5204d172..5f23126b7 100644 --- a/app/assets/javascripts/discourse/initializers/enable-emoji.js.es6 +++ b/app/assets/javascripts/discourse/initializers/enable-emoji.js.es6 @@ -14,6 +14,7 @@ export default { group: 'extras', icon: 'smile-o', action: 'emoji', + shortcut: 'Alt+E', title: 'composer.emoji' }); }); diff --git a/app/assets/javascripts/discourse/templates/components/d-editor.hbs b/app/assets/javascripts/discourse/templates/components/d-editor.hbs index ca7146068..5375d94c1 100644 --- a/app/assets/javascripts/discourse/templates/components/d-editor.hbs +++ b/app/assets/javascripts/discourse/templates/components/d-editor.hbs @@ -10,7 +10,7 @@
{{#each toolbar.groups as |group|}} {{#each group.buttons as |b|}} - {{d-button action=b.action actionParam=b title=b.title icon=b.icon class=b.className}} + {{d-button action=b.action actionParam=b translatedTitle=b.title icon=b.icon class=b.className}} {{/each}} {{#unless group.lastGroup}}
diff --git a/vendor/assets/javascripts/mousetrap.js b/vendor/assets/javascripts/mousetrap.js index 108216bd7..48d2f625c 100644 --- a/vendor/assets/javascripts/mousetrap.js +++ b/vendor/assets/javascripts/mousetrap.js @@ -1,6 +1,6 @@ /*global define:false */ /** - * Copyright 2013 Craig Campbell + * Copyright 2015 Craig Campbell * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,7 +17,7 @@ * Mousetrap is a simple keyboard shortcut library for Javascript with * no external dependencies * - * @version 1.4.6 + * @version 1.5.3 * @url craig.is/killing/mice */ (function(window, document, undefined) { @@ -32,162 +32,112 @@ * @type {Object} */ var _MAP = { - 8: 'backspace', - 9: 'tab', - 13: 'enter', - 16: 'shift', - 17: 'ctrl', - 18: 'alt', - 20: 'capslock', - 27: 'esc', - 32: 'space', - 33: 'pageup', - 34: 'pagedown', - 35: 'end', - 36: 'home', - 37: 'left', - 38: 'up', - 39: 'right', - 40: 'down', - 45: 'ins', - 46: 'del', - 91: 'meta', - 93: 'meta', - 224: 'meta' - }, + 8: 'backspace', + 9: 'tab', + 13: 'enter', + 16: 'shift', + 17: 'ctrl', + 18: 'alt', + 20: 'capslock', + 27: 'esc', + 32: 'space', + 33: 'pageup', + 34: 'pagedown', + 35: 'end', + 36: 'home', + 37: 'left', + 38: 'up', + 39: 'right', + 40: 'down', + 45: 'ins', + 46: 'del', + 91: 'meta', + 93: 'meta', + 224: 'meta' + }; - /** - * mapping for special characters so they can support - * - * this dictionary is only used incase you want to bind a - * keyup or keydown event to one of these keys - * - * @type {Object} - */ - _KEYCODE_MAP = { - 106: '*', - 107: '+', - 109: '-', - 110: '.', - 111 : '/', - 186: ';', - 187: '=', - 188: ',', - 189: '-', - 190: '.', - 191: '/', - 192: '`', - 219: '[', - 220: '\\', - 221: ']', - 222: '\'' - }, + /** + * mapping for special characters so they can support + * + * this dictionary is only used incase you want to bind a + * keyup or keydown event to one of these keys + * + * @type {Object} + */ + var _KEYCODE_MAP = { + 106: '*', + 107: '+', + 109: '-', + 110: '.', + 111 : '/', + 186: ';', + 187: '=', + 188: ',', + 189: '-', + 190: '.', + 191: '/', + 192: '`', + 219: '[', + 220: '\\', + 221: ']', + 222: '\'' + }; - /** - * this is a mapping of keys that require shift on a US keypad - * back to the non shift equivelents - * - * this is so you can use keyup events with these keys - * - * note that this will only work reliably on US keyboards - * - * @type {Object} - */ - _SHIFT_MAP = { - '~': '`', - '!': '1', - '@': '2', - '#': '3', - '$': '4', - '%': '5', - '^': '6', - '&': '7', - '*': '8', - '(': '9', - ')': '0', - '_': '-', - '+': '=', - ':': ';', - '\"': '\'', - '<': ',', - '>': '.', - '?': '/', - '|': '\\' - }, + /** + * this is a mapping of keys that require shift on a US keypad + * back to the non shift equivelents + * + * this is so you can use keyup events with these keys + * + * note that this will only work reliably on US keyboards + * + * @type {Object} + */ + var _SHIFT_MAP = { + '~': '`', + '!': '1', + '@': '2', + '#': '3', + '$': '4', + '%': '5', + '^': '6', + '&': '7', + '*': '8', + '(': '9', + ')': '0', + '_': '-', + '+': '=', + ':': ';', + '\"': '\'', + '<': ',', + '>': '.', + '?': '/', + '|': '\\' + }; - /** - * this is a list of special strings you can use to map - * to modifier keys when you specify your keyboard shortcuts - * - * @type {Object} - */ - _SPECIAL_ALIASES = { - 'option': 'alt', - 'command': 'meta', - 'return': 'enter', - 'escape': 'esc', - 'mod': /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'meta' : 'ctrl' - }, + /** + * this is a list of special strings you can use to map + * to modifier keys when you specify your keyboard shortcuts + * + * @type {Object} + */ + var _SPECIAL_ALIASES = { + 'option': 'alt', + 'command': 'meta', + 'return': 'enter', + 'escape': 'esc', + 'plus': '+', + 'mod': /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'meta' : 'ctrl' + }; - /** - * variable to store the flipped version of _MAP from above - * needed to check if we should use keypress or not when no action - * is specified - * - * @type {Object|undefined} - */ - _REVERSE_MAP, - - /** - * a list of all the callbacks setup via Mousetrap.bind() - * - * @type {Object} - */ - _callbacks = {}, - - /** - * direct map of string combinations to callbacks used for trigger() - * - * @type {Object} - */ - _directMap = {}, - - /** - * keeps track of what level each sequence is at since multiple - * sequences can start out with the same sequence - * - * @type {Object} - */ - _sequenceLevels = {}, - - /** - * variable to store the setTimeout call - * - * @type {null|number} - */ - _resetTimer, - - /** - * temporary state where we will ignore the next keyup - * - * @type {boolean|string} - */ - _ignoreNextKeyup = false, - - /** - * temporary state where we will ignore the next keypress - * - * @type {boolean} - */ - _ignoreNextKeypress = false, - - /** - * are we currently inside of a sequence? - * type of action ("keyup" or "keydown" or "keypress") or false - * - * @type {boolean|string} - */ - _nextExpectedAction = false; + /** + * variable to store the flipped version of _MAP from above + * needed to check if we should use keypress or not when no action + * is specified + * + * @type {Object|undefined} + */ + var _REVERSE_MAP; /** * loop through the f keys, f1 to f19 and add them to the map @@ -277,103 +227,6 @@ return modifiers1.sort().join(',') === modifiers2.sort().join(','); } - /** - * resets all sequence counters except for the ones passed in - * - * @param {Object} doNotReset - * @returns void - */ - function _resetSequences(doNotReset) { - doNotReset = doNotReset || {}; - - var activeSequences = false, - key; - - for (key in _sequenceLevels) { - if (doNotReset[key]) { - activeSequences = true; - continue; - } - _sequenceLevels[key] = 0; - } - - if (!activeSequences) { - _nextExpectedAction = false; - } - } - - /** - * finds all callbacks that match based on the keycode, modifiers, - * and action - * - * @param {string} character - * @param {Array} modifiers - * @param {Event|Object} e - * @param {string=} sequenceName - name of the sequence we are looking for - * @param {string=} combination - * @param {number=} level - * @returns {Array} - */ - function _getMatches(character, modifiers, e, sequenceName, combination, level) { - var i, - callback, - matches = [], - action = e.type; - - // if there are no events related to this keycode - if (!_callbacks[character]) { - return []; - } - - // if a modifier key is coming up on its own we should allow it - if (action == 'keyup' && _isModifier(character)) { - modifiers = [character]; - } - - // loop through all callbacks for the key that was pressed - // and see if any of them match - for (i = 0; i < _callbacks[character].length; ++i) { - callback = _callbacks[character][i]; - - // if a sequence name is not specified, but this is a sequence at - // the wrong level then move onto the next match - if (!sequenceName && callback.seq && _sequenceLevels[callback.seq] != callback.level) { - continue; - } - - // if the action we are looking for doesn't match the action we got - // then we should keep going - if (action != callback.action) { - continue; - } - - // if this is a keypress event and the meta key and control key - // are not pressed that means that we need to only look at the - // character, otherwise check the modifiers as well - // - // chrome will not fire a keypress if meta or control is down - // safari will fire a keypress if meta or meta+shift is down - // firefox will fire a keypress if meta or control is down - if ((action == 'keypress' && !e.metaKey && !e.ctrlKey) || _modifiersMatch(modifiers, callback.modifiers)) { - - // when you bind a combination or sequence a second time it - // should overwrite the first one. if a sequenceName or - // combination is specified in this call it does just that - // - // @todo make deleting its own method? - var deleteCombo = !sequenceName && callback.combo == combination; - var deleteSequence = sequenceName && callback.seq == sequenceName && callback.level == level; - if (deleteCombo || deleteSequence) { - _callbacks[character].splice(i, 1); - } - - matches.push(callback); - } - } - - return matches; - } - /** * takes a key event and figures out what the modifiers are * @@ -432,147 +285,6 @@ e.cancelBubble = true; } - /** - * actually calls the callback function - * - * if your callback function returns false this will use the jquery - * convention - prevent default and stop propogation on the event - * - * @param {Function} callback - * @param {Event} e - * @returns void - */ - function _fireCallback(callback, e, combo, sequence) { - - // if this event should not happen stop here - if (Mousetrap.stopCallback(e, e.target || e.srcElement, combo, sequence)) { - return; - } - - if (callback(e, combo) === false) { - _preventDefault(e); - _stopPropagation(e); - } - } - - /** - * handles a character key event - * - * @param {string} character - * @param {Array} modifiers - * @param {Event} e - * @returns void - */ - function _handleKey(character, modifiers, e) { - var callbacks = _getMatches(character, modifiers, e), - i, - doNotReset = {}, - maxLevel = 0, - processedSequenceCallback = false; - - // Calculate the maxLevel for sequences so we can only execute the longest callback sequence - for (i = 0; i < callbacks.length; ++i) { - if (callbacks[i].seq) { - maxLevel = Math.max(maxLevel, callbacks[i].level); - } - } - - // loop through matching callbacks for this key event - for (i = 0; i < callbacks.length; ++i) { - - // fire for all sequence callbacks - // this is because if for example you have multiple sequences - // bound such as "g i" and "g t" they both need to fire the - // callback for matching g cause otherwise you can only ever - // match the first one - if (callbacks[i].seq) { - - // only fire callbacks for the maxLevel to prevent - // subsequences from also firing - // - // for example 'a option b' should not cause 'option b' to fire - // even though 'option b' is part of the other sequence - // - // any sequences that do not match here will be discarded - // below by the _resetSequences call - if (callbacks[i].level != maxLevel) { - continue; - } - - processedSequenceCallback = true; - - // keep a list of which sequences were matches for later - doNotReset[callbacks[i].seq] = 1; - _fireCallback(callbacks[i].callback, e, callbacks[i].combo, callbacks[i].seq); - continue; - } - - // if there were no sequence matches but we are still here - // that means this is a regular match so we should fire that - if (!processedSequenceCallback) { - _fireCallback(callbacks[i].callback, e, callbacks[i].combo); - } - } - - // if the key you pressed matches the type of sequence without - // being a modifier (ie "keyup" or "keypress") then we should - // reset all sequences that were not matched by this event - // - // this is so, for example, if you have the sequence "h a t" and you - // type "h e a r t" it does not match. in this case the "e" will - // cause the sequence to reset - // - // modifier keys are ignored because you can have a sequence - // that contains modifiers such as "enter ctrl+space" and in most - // cases the modifier key will be pressed before the next key - // - // also if you have a sequence such as "ctrl+b a" then pressing the - // "b" key will trigger a "keypress" and a "keydown" - // - // the "keydown" is expected when there is a modifier, but the - // "keypress" ends up matching the _nextExpectedAction since it occurs - // after and that causes the sequence to reset - // - // we ignore keypresses in a sequence that directly follow a keydown - // for the same character - var ignoreThisKeypress = e.type == 'keypress' && _ignoreNextKeypress; - if (e.type == _nextExpectedAction && !_isModifier(character) && !ignoreThisKeypress) { - _resetSequences(doNotReset); - } - - _ignoreNextKeypress = processedSequenceCallback && e.type == 'keydown'; - } - - /** - * handles a keydown event - * - * @param {Event} e - * @returns void - */ - function _handleKeyEvent(e) { - - // normalize e.which for key events - // @see http://stackoverflow.com/questions/4285627/javascript-keycode-vs-charcode-utter-confusion - if (typeof e.which !== 'number') { - e.which = e.keyCode; - } - - var character = _characterFromEvent(e); - - // no character found then stop - if (!character) { - return; - } - - // need to use === for the character check because the character can be 0 - if (e.type == 'keyup' && _ignoreNextKeyup === character) { - _ignoreNextKeyup = false; - return; - } - - Mousetrap.handleKey(character, _eventModifiers(e), e); - } - /** * determines if the keycode specified is a modifier key or not * @@ -583,19 +295,6 @@ return key == 'shift' || key == 'ctrl' || key == 'alt' || key == 'meta'; } - /** - * called to set a 1 second timeout on the specified sequence - * - * this is so after each key press in the sequence you have 1 second - * to press the next key before you have to start over - * - * @returns void - */ - function _resetSequenceTimer() { - clearTimeout(_resetTimer); - _resetTimer = setTimeout(_resetSequences, 1000); - } - /** * reverses the map lookup so that we can look for specific keys * to see what can and can't use keypress @@ -645,74 +344,6 @@ return action; } - /** - * binds a key sequence to an event - * - * @param {string} combo - combo specified in bind call - * @param {Array} keys - * @param {Function} callback - * @param {string=} action - * @returns void - */ - function _bindSequence(combo, keys, callback, action) { - - // start off by adding a sequence level record for this combination - // and setting the level to 0 - _sequenceLevels[combo] = 0; - - /** - * callback to increase the sequence level for this sequence and reset - * all other sequences that were active - * - * @param {string} nextAction - * @returns {Function} - */ - function _increaseSequence(nextAction) { - return function() { - _nextExpectedAction = nextAction; - ++_sequenceLevels[combo]; - _resetSequenceTimer(); - }; - } - - /** - * wraps the specified callback inside of another function in order - * to reset all sequence counters as soon as this sequence is done - * - * @param {Event} e - * @returns void - */ - function _callbackAndReset(e) { - _fireCallback(callback, e, combo); - - // we should ignore the next key up if the action is key down - // or keypress. this is so if you finish a sequence and - // release the key the final key will not trigger a keyup - if (action !== 'keyup') { - _ignoreNextKeyup = _characterFromEvent(e); - } - - // weird race condition if a sequence ends with the key - // another sequence begins with - setTimeout(_resetSequences, 10); - } - - // loop through keys one at a time and bind the appropriate callback - // function. for any key leading up to the final one it should - // increase the sequence. after the final, it should reset all sequences - // - // if an action is specified in the original bind call then that will - // be used throughout. otherwise we will pass the action that the - // next key in the sequence should match. this allows a sequence - // to mix and match keypress and keydown events depending on which - // ones are better suited to the key provided - for (var i = 0; i < keys.length; ++i) { - var isFinal = i + 1 === keys.length; - var wrappedCallback = isFinal ? _callbackAndReset : _increaseSequence(action || _getKeyInfo(keys[i + 1]).action); - _bindSingle(keys[i], wrappedCallback, action, combo, i); - } - } - /** * Converts from a string key combination to an array * @@ -724,6 +355,7 @@ return ['+']; } + combination = combination.replace(/\+{2}/g, '+plus'); return combination.split('+'); } @@ -735,10 +367,10 @@ * @returns {Object} */ function _getKeyInfo(combination, action) { - var keys, - key, - i, - modifiers = []; + var keys; + var key; + var i; + var modifiers = []; // take the keys from this pattern and figure out what the actual // pattern is all about @@ -777,228 +409,613 @@ }; } - /** - * binds a single keyboard combination - * - * @param {string} combination - * @param {Function} callback - * @param {string=} action - * @param {string=} sequenceName - name of sequence if part of sequence - * @param {number=} level - what part of the sequence the command is - * @returns void - */ - function _bindSingle(combination, callback, action, sequenceName, level) { - - // store a direct mapped reference for use with Mousetrap.trigger - _directMap[combination + ':' + action] = callback; - - // make sure multiple spaces in a row become a single space - combination = combination.replace(/\s+/g, ' '); - - var sequence = combination.split(' '), - info; - - // if this pattern is a sequence of keys then run through this method - // to reprocess each pattern one key at a time - if (sequence.length > 1) { - _bindSequence(combination, sequence, callback, action); - return; + function _belongsTo(element, ancestor) { + if (element === null || element === document) { + return false; } - info = _getKeyInfo(combination, action); - - // make sure to initialize array if this is the first time - // a callback is added for this key - _callbacks[info.key] = _callbacks[info.key] || []; - - // remove an existing match if there is one - _getMatches(info.key, info.modifiers, {type: info.action}, sequenceName, combination, level); - - // add this call back to the array - // if it is a sequence put it at the beginning - // if not put it at the end - // - // this is important because the way these are processed expects - // the sequence ones to come first - _callbacks[info.key][sequenceName ? 'unshift' : 'push']({ - callback: callback, - modifiers: info.modifiers, - action: info.action, - seq: sequenceName, - level: level, - combo: combination - }); - } - - /** - * binds multiple combinations to the same callback - * - * @param {Array} combinations - * @param {Function} callback - * @param {string|undefined} action - * @returns void - */ - function _bindMultiple(combinations, callback, action) { - for (var i = 0; i < combinations.length; ++i) { - _bindSingle(combinations[i], callback, action); + if (element === ancestor) { + return true; } + + return _belongsTo(element.parentNode, ancestor); } - // start! - _addEvent(document, 'keypress', _handleKeyEvent); - _addEvent(document, 'keydown', _handleKeyEvent); - _addEvent(document, 'keyup', _handleKeyEvent); + function Mousetrap(targetElement) { + var self = this; - var Mousetrap = { + targetElement = targetElement || document; + + if (!(self instanceof Mousetrap)) { + return new Mousetrap(targetElement); + } /** - * binds an event to mousetrap + * element to attach key events to * - * can be a single key, a combination of keys separated with +, - * an array of keys, or a sequence of keys separated by spaces + * @type {Element} + */ + self.target = targetElement; + + /** + * a list of all the callbacks setup via Mousetrap.bind() * - * be sure to list the modifier keys first to make sure that the - * correct key ends up getting bound (the last key in the pattern) + * @type {Object} + */ + self._callbacks = {}; + + /** + * direct map of string combinations to callbacks used for trigger() + * + * @type {Object} + */ + self._directMap = {}; + + /** + * keeps track of what level each sequence is at since multiple + * sequences can start out with the same sequence + * + * @type {Object} + */ + var _sequenceLevels = {}; + + /** + * variable to store the setTimeout call + * + * @type {null|number} + */ + var _resetTimer; + + /** + * temporary state where we will ignore the next keyup + * + * @type {boolean|string} + */ + var _ignoreNextKeyup = false; + + /** + * temporary state where we will ignore the next keypress + * + * @type {boolean} + */ + var _ignoreNextKeypress = false; + + /** + * are we currently inside of a sequence? + * type of action ("keyup" or "keydown" or "keypress") or false + * + * @type {boolean|string} + */ + var _nextExpectedAction = false; + + /** + * resets all sequence counters except for the ones passed in + * + * @param {Object} doNotReset + * @returns void + */ + function _resetSequences(doNotReset) { + doNotReset = doNotReset || {}; + + var activeSequences = false, + key; + + for (key in _sequenceLevels) { + if (doNotReset[key]) { + activeSequences = true; + continue; + } + _sequenceLevels[key] = 0; + } + + if (!activeSequences) { + _nextExpectedAction = false; + } + } + + /** + * finds all callbacks that match based on the keycode, modifiers, + * and action + * + * @param {string} character + * @param {Array} modifiers + * @param {Event|Object} e + * @param {string=} sequenceName - name of the sequence we are looking for + * @param {string=} combination + * @param {number=} level + * @returns {Array} + */ + function _getMatches(character, modifiers, e, sequenceName, combination, level) { + var i; + var callback; + var matches = []; + var action = e.type; + + // if there are no events related to this keycode + if (!self._callbacks[character]) { + return []; + } + + // if a modifier key is coming up on its own we should allow it + if (action == 'keyup' && _isModifier(character)) { + modifiers = [character]; + } + + // loop through all callbacks for the key that was pressed + // and see if any of them match + for (i = 0; i < self._callbacks[character].length; ++i) { + callback = self._callbacks[character][i]; + + // if a sequence name is not specified, but this is a sequence at + // the wrong level then move onto the next match + if (!sequenceName && callback.seq && _sequenceLevels[callback.seq] != callback.level) { + continue; + } + + // if the action we are looking for doesn't match the action we got + // then we should keep going + if (action != callback.action) { + continue; + } + + // if this is a keypress event and the meta key and control key + // are not pressed that means that we need to only look at the + // character, otherwise check the modifiers as well + // + // chrome will not fire a keypress if meta or control is down + // safari will fire a keypress if meta or meta+shift is down + // firefox will fire a keypress if meta or control is down + if ((action == 'keypress' && !e.metaKey && !e.ctrlKey) || _modifiersMatch(modifiers, callback.modifiers)) { + + // when you bind a combination or sequence a second time it + // should overwrite the first one. if a sequenceName or + // combination is specified in this call it does just that + // + // @todo make deleting its own method? + var deleteCombo = !sequenceName && callback.combo == combination; + var deleteSequence = sequenceName && callback.seq == sequenceName && callback.level == level; + if (deleteCombo || deleteSequence) { + self._callbacks[character].splice(i, 1); + } + + matches.push(callback); + } + } + + return matches; + } + + /** + * actually calls the callback function + * + * if your callback function returns false this will use the jquery + * convention - prevent default and stop propogation on the event * - * @param {string|Array} keys * @param {Function} callback - * @param {string=} action - 'keypress', 'keydown', or 'keyup' + * @param {Event} e * @returns void */ - bind: function(keys, callback, action) { - keys = keys instanceof Array ? keys : [keys]; - _bindMultiple(keys, callback, action); - return this; - }, + function _fireCallback(callback, e, combo, sequence) { + + // if this event should not happen stop here + if (self.stopCallback(e, e.target || e.srcElement, combo, sequence)) { + return; + } + + if (callback(e, combo) === false) { + _preventDefault(e); + _stopPropagation(e); + } + } /** - * unbinds an event to mousetrap + * handles a character key event * - * the unbinding sets the callback function of the specified key combo - * to an empty function and deletes the corresponding key in the - * _directMap dict. - * - * TODO: actually remove this from the _callbacks dictionary instead - * of binding an empty function - * - * the keycombo+action has to be exactly the same as - * it was defined in the bind method - * - * @param {string|Array} keys - * @param {string} action + * @param {string} character + * @param {Array} modifiers + * @param {Event} e * @returns void */ - unbind: function(keys, action) { - return Mousetrap.bind(keys, function() {}, action); - }, + self._handleKey = function(character, modifiers, e) { + var callbacks = _getMatches(character, modifiers, e); + var i; + var doNotReset = {}; + var maxLevel = 0; + var processedSequenceCallback = false; + + // Calculate the maxLevel for sequences so we can only execute the longest callback sequence + for (i = 0; i < callbacks.length; ++i) { + if (callbacks[i].seq) { + maxLevel = Math.max(maxLevel, callbacks[i].level); + } + } + + // loop through matching callbacks for this key event + for (i = 0; i < callbacks.length; ++i) { + + // fire for all sequence callbacks + // this is because if for example you have multiple sequences + // bound such as "g i" and "g t" they both need to fire the + // callback for matching g cause otherwise you can only ever + // match the first one + if (callbacks[i].seq) { + + // only fire callbacks for the maxLevel to prevent + // subsequences from also firing + // + // for example 'a option b' should not cause 'option b' to fire + // even though 'option b' is part of the other sequence + // + // any sequences that do not match here will be discarded + // below by the _resetSequences call + if (callbacks[i].level != maxLevel) { + continue; + } + + processedSequenceCallback = true; + + // keep a list of which sequences were matches for later + doNotReset[callbacks[i].seq] = 1; + _fireCallback(callbacks[i].callback, e, callbacks[i].combo, callbacks[i].seq); + continue; + } + + // if there were no sequence matches but we are still here + // that means this is a regular match so we should fire that + if (!processedSequenceCallback) { + _fireCallback(callbacks[i].callback, e, callbacks[i].combo); + } + } + + // if the key you pressed matches the type of sequence without + // being a modifier (ie "keyup" or "keypress") then we should + // reset all sequences that were not matched by this event + // + // this is so, for example, if you have the sequence "h a t" and you + // type "h e a r t" it does not match. in this case the "e" will + // cause the sequence to reset + // + // modifier keys are ignored because you can have a sequence + // that contains modifiers such as "enter ctrl+space" and in most + // cases the modifier key will be pressed before the next key + // + // also if you have a sequence such as "ctrl+b a" then pressing the + // "b" key will trigger a "keypress" and a "keydown" + // + // the "keydown" is expected when there is a modifier, but the + // "keypress" ends up matching the _nextExpectedAction since it occurs + // after and that causes the sequence to reset + // + // we ignore keypresses in a sequence that directly follow a keydown + // for the same character + var ignoreThisKeypress = e.type == 'keypress' && _ignoreNextKeypress; + if (e.type == _nextExpectedAction && !_isModifier(character) && !ignoreThisKeypress) { + _resetSequences(doNotReset); + } + + _ignoreNextKeypress = processedSequenceCallback && e.type == 'keydown'; + }; /** - * triggers an event that has already been bound + * handles a keydown event * - * @param {string} keys + * @param {Event} e + * @returns void + */ + function _handleKeyEvent(e) { + + // normalize e.which for key events + // @see http://stackoverflow.com/questions/4285627/javascript-keycode-vs-charcode-utter-confusion + if (typeof e.which !== 'number') { + e.which = e.keyCode; + } + + var character = _characterFromEvent(e); + + // no character found then stop + if (!character) { + return; + } + + // need to use === for the character check because the character can be 0 + if (e.type == 'keyup' && _ignoreNextKeyup === character) { + _ignoreNextKeyup = false; + return; + } + + self.handleKey(character, _eventModifiers(e), e); + } + + /** + * called to set a 1 second timeout on the specified sequence + * + * this is so after each key press in the sequence you have 1 second + * to press the next key before you have to start over + * + * @returns void + */ + function _resetSequenceTimer() { + clearTimeout(_resetTimer); + _resetTimer = setTimeout(_resetSequences, 1000); + } + + /** + * binds a key sequence to an event + * + * @param {string} combo - combo specified in bind call + * @param {Array} keys + * @param {Function} callback * @param {string=} action * @returns void */ - trigger: function(keys, action) { - if (_directMap[keys + ':' + action]) { - _directMap[keys + ':' + action]({}, keys); + function _bindSequence(combo, keys, callback, action) { + + // start off by adding a sequence level record for this combination + // and setting the level to 0 + _sequenceLevels[combo] = 0; + + /** + * callback to increase the sequence level for this sequence and reset + * all other sequences that were active + * + * @param {string} nextAction + * @returns {Function} + */ + function _increaseSequence(nextAction) { + return function() { + _nextExpectedAction = nextAction; + ++_sequenceLevels[combo]; + _resetSequenceTimer(); + }; } - return this; - }, + + /** + * wraps the specified callback inside of another function in order + * to reset all sequence counters as soon as this sequence is done + * + * @param {Event} e + * @returns void + */ + function _callbackAndReset(e) { + _fireCallback(callback, e, combo); + + // we should ignore the next key up if the action is key down + // or keypress. this is so if you finish a sequence and + // release the key the final key will not trigger a keyup + if (action !== 'keyup') { + _ignoreNextKeyup = _characterFromEvent(e); + } + + // weird race condition if a sequence ends with the key + // another sequence begins with + setTimeout(_resetSequences, 10); + } + + // loop through keys one at a time and bind the appropriate callback + // function. for any key leading up to the final one it should + // increase the sequence. after the final, it should reset all sequences + // + // if an action is specified in the original bind call then that will + // be used throughout. otherwise we will pass the action that the + // next key in the sequence should match. this allows a sequence + // to mix and match keypress and keydown events depending on which + // ones are better suited to the key provided + for (var i = 0; i < keys.length; ++i) { + var isFinal = i + 1 === keys.length; + var wrappedCallback = isFinal ? _callbackAndReset : _increaseSequence(action || _getKeyInfo(keys[i + 1]).action); + _bindSingle(keys[i], wrappedCallback, action, combo, i); + } + } /** - * resets the library back to its initial state. this is useful - * if you want to clear out the current keyboard shortcuts and bind - * new ones - for example if you switch to another page + * binds a single keyboard combination * + * @param {string} combination + * @param {Function} callback + * @param {string=} action + * @param {string=} sequenceName - name of sequence if part of sequence + * @param {number=} level - what part of the sequence the command is * @returns void */ - reset: function() { - _callbacks = {}; - _directMap = {}; - return this; - }, + function _bindSingle(combination, callback, action, sequenceName, level) { - /** - * should we stop this event before firing off callbacks - * - * @param {Event} e - * @param {Element} element - * @return {boolean} - */ - stopCallback: function(e, element) { + // store a direct mapped reference for use with Mousetrap.trigger + self._directMap[combination + ':' + action] = callback; - // if the element has the class "mousetrap" then no need to stop - if ((' ' + element.className + ' ').indexOf(' mousetrap ') > -1) { - return false; + // make sure multiple spaces in a row become a single space + combination = combination.replace(/\s+/g, ' '); + + var sequence = combination.split(' '); + var info; + + // if this pattern is a sequence of keys then run through this method + // to reprocess each pattern one key at a time + if (sequence.length > 1) { + _bindSequence(combination, sequence, callback, action); + return; } - // stop for input, select, and textarea - return element.tagName == 'INPUT' || element.tagName == 'SELECT' || element.tagName == 'TEXTAREA' || element.isContentEditable; - }, + info = _getKeyInfo(combination, action); + + // make sure to initialize array if this is the first time + // a callback is added for this key + self._callbacks[info.key] = self._callbacks[info.key] || []; + + // remove an existing match if there is one + _getMatches(info.key, info.modifiers, {type: info.action}, sequenceName, combination, level); + + // add this call back to the array + // if it is a sequence put it at the beginning + // if not put it at the end + // + // this is important because the way these are processed expects + // the sequence ones to come first + self._callbacks[info.key][sequenceName ? 'unshift' : 'push']({ + callback: callback, + modifiers: info.modifiers, + action: info.action, + seq: sequenceName, + level: level, + combo: combination + }); + } /** - * exposes _handleKey publicly so it can be overwritten by extensions + * binds multiple combinations to the same callback + * + * @param {Array} combinations + * @param {Function} callback + * @param {string|undefined} action + * @returns void */ - handleKey: _handleKey + self._bindMultiple = function(combinations, callback, action) { + for (var i = 0; i < combinations.length; ++i) { + _bindSingle(combinations[i], callback, action); + } + }; + + // start! + _addEvent(targetElement, 'keypress', _handleKeyEvent); + _addEvent(targetElement, 'keydown', _handleKeyEvent); + _addEvent(targetElement, 'keyup', _handleKeyEvent); + } + + /** + * binds an event to mousetrap + * + * can be a single key, a combination of keys separated with +, + * an array of keys, or a sequence of keys separated by spaces + * + * be sure to list the modifier keys first to make sure that the + * correct key ends up getting bound (the last key in the pattern) + * + * @param {string|Array} keys + * @param {Function} callback + * @param {string=} action - 'keypress', 'keydown', or 'keyup' + * @returns void + */ + Mousetrap.prototype.bind = function(keys, callback, action) { + var self = this; + keys = keys instanceof Array ? keys : [keys]; + self._bindMultiple.call(self, keys, callback, action); + return self; }; + /** + * unbinds an event to mousetrap + * + * the unbinding sets the callback function of the specified key combo + * to an empty function and deletes the corresponding key in the + * _directMap dict. + * + * TODO: actually remove this from the _callbacks dictionary instead + * of binding an empty function + * + * the keycombo+action has to be exactly the same as + * it was defined in the bind method + * + * @param {string|Array} keys + * @param {string} action + * @returns void + */ + Mousetrap.prototype.unbind = function(keys, action) { + var self = this; + return self.bind.call(self, keys, function() {}, action); + }; + + /** + * triggers an event that has already been bound + * + * @param {string} keys + * @param {string=} action + * @returns void + */ + Mousetrap.prototype.trigger = function(keys, action) { + var self = this; + if (self._directMap[keys + ':' + action]) { + self._directMap[keys + ':' + action]({}, keys); + } + return self; + }; + + /** + * resets the library back to its initial state. this is useful + * if you want to clear out the current keyboard shortcuts and bind + * new ones - for example if you switch to another page + * + * @returns void + */ + Mousetrap.prototype.reset = function() { + var self = this; + self._callbacks = {}; + self._directMap = {}; + return self; + }; + + /** + * should we stop this event before firing off callbacks + * + * @param {Event} e + * @param {Element} element + * @return {boolean} + */ + Mousetrap.prototype.stopCallback = function(e, element) { + var self = this; + + // if the element has the class "mousetrap" then no need to stop + if ((' ' + element.className + ' ').indexOf(' mousetrap ') > -1) { + return false; + } + + if (_belongsTo(element, self.target)) { + return false; + } + + // stop for input, select, and textarea + return element.tagName == 'INPUT' || element.tagName == 'SELECT' || element.tagName == 'TEXTAREA' || element.isContentEditable; + }; + + /** + * exposes _handleKey publicly so it can be overwritten by extensions + */ + Mousetrap.prototype.handleKey = function() { + var self = this; + return self._handleKey.apply(self, arguments); + }; + + /** + * Init the global mousetrap functions + * + * This method is needed to allow the global mousetrap functions to work + * now that mousetrap is a constructor function. + */ + Mousetrap.init = function() { + var documentMousetrap = Mousetrap(document); + for (var method in documentMousetrap) { + if (method.charAt(0) !== '_') { + Mousetrap[method] = (function(method) { + return function() { + return documentMousetrap[method].apply(documentMousetrap, arguments); + }; + } (method)); + } + } + }; + + Mousetrap.init(); + // expose mousetrap to the global object window.Mousetrap = Mousetrap; + // expose as a common js module + if (typeof module !== 'undefined' && module.exports) { + module.exports = Mousetrap; + } + // expose mousetrap as an AMD module if (typeof define === 'function' && define.amd) { - define(Mousetrap); + define(function() { + return Mousetrap; + }); } }) (window, document); - -/** - * adds a bindGlobal method to Mousetrap that allows you to - * bind specific keyboard shortcuts that will still work - * inside a text input field - * - * usage: - * Mousetrap.bindGlobal('ctrl+s', _saveChanges); - * Mousetrap.unbindGlobal('ctrl+s'); - */ -/* global Mousetrap:true */ -Mousetrap = (function(Mousetrap) { - var _globalCallbacks = {}, - _originalStopCallback = Mousetrap.stopCallback; - - Mousetrap.stopCallback = function(e, element, combo, sequence) { - if (_globalCallbacks[combo] || _globalCallbacks[sequence]) { - return false; - } - - return _originalStopCallback(e, element, combo); - }; - - Mousetrap.bindGlobal = function(keys, callback, action) { - Mousetrap.bind(keys, callback, action); - - if (keys instanceof Array) { - for (var i = 0; i < keys.length; i++) { - _globalCallbacks[keys[i]] = true; - } - return; - } - - _globalCallbacks[keys] = true; - }; - - Mousetrap.unbindGlobal = function(keys, action) { - Mousetrap.unbind(keys, action); - - if (keys instanceof Array) { - for (var i = 0; i < keys.length; i++) { - _globalCallbacks[keys[i]] = false; - } - return; - } - - _globalCallbacks[keys] = false; - }; - - return Mousetrap; -}) (Mousetrap);