/** * Title: KeyboardJS * Version: v0.4.1 * Description: KeyboardJS is a flexible and easy to use keyboard binding * library. * Author: Robert Hurst. * * Copyright 2011, Robert William Hurst * Licenced under the BSD License. * See https://raw.github.com/RobertWHurst/KeyboardJS/master/license.txt */ (function(context, factory) { //INDEXOF POLLYFILL [].indexOf||(Array.prototype.indexOf=function(a,b,c){for(c=this.length,b=(c+~~b)%c;b"]], ['shift + /', ["questionmark", "?"]] ] }; //a-z and A-Z for (aI = 65; aI <= 90; aI += 1) { usLocale.map[aI] = String.fromCharCode(aI + 32); usLocale.macros.push(['shift + ' + String.fromCharCode(aI + 32) + ', capslock + ' + String.fromCharCode(aI + 32), [String.fromCharCode(aI)]]); } registerLocale('us', usLocale); getSetLocale('us'); ////////// // INIT // ////////// //enable the library enable(); ///////// // API // ///////// //assemble the library and return it KeyboardJS.enable = enable; KeyboardJS.disable = disable; KeyboardJS.activeKeys = getActiveKeys; KeyboardJS.on = createBinding; KeyboardJS.clear = removeBindingByKeyCombo; KeyboardJS.clear.key = removeBindingByKeyName; KeyboardJS.locale = getSetLocale; KeyboardJS.locale.register = registerLocale; KeyboardJS.macro = createMacro; KeyboardJS.macro.remove = removeMacro; KeyboardJS.key = {}; KeyboardJS.key.name = getKeyName; KeyboardJS.key.code = getKeyCode; KeyboardJS.combo = {}; KeyboardJS.combo.active = isSatisfiedCombo; KeyboardJS.combo.parse = parseKeyCombo; KeyboardJS.combo.stringify = stringifyKeyCombo; return KeyboardJS; ////////////////////// // INSTANCE METHODS // ////////////////////// /** * Enables KeyboardJS */ function enable() { if(window.addEventListener) { document.addEventListener('keydown', keydown, false); document.addEventListener('keyup', keyup, false); window.addEventListener('blur', reset, false); window.addEventListener('webkitfullscreenchange', reset, false); window.addEventListener('mozfullscreenchange', reset, false); } else if(window.attachEvent) { document.attachEvent('onkeydown', keydown); document.attachEvent('onkeyup', keyup); window.attachEvent('onblur', reset); } } /** * Exits all active bindings and disables KeyboardJS */ function disable() { reset(); if(window.removeEventListener) { document.removeEventListener('keydown', keydown, false); document.removeEventListener('keyup', keyup, false); window.removeEventListener('blur', reset, false); window.removeEventListener('webkitfullscreenchange', reset, false); window.removeEventListener('mozfullscreenchange', reset, false); } else if(window.detachEvent) { document.detachEvent('onkeydown', keydown); document.detachEvent('onkeyup', keyup); window.detachEvent('onblur', reset); } } //////////////////// // EVENT HANDLERS // //////////////////// /** * Exits all active bindings. Optionally passes an event to all binding * handlers. * @param {KeyboardEvent} event [Optional] */ function reset(event) { activeKeys = []; pruneMacros(); pruneBindings(event); } /** * Key down event handler. * @param {KeyboardEvent} event */ function keydown(event) { var keyNames, keyName, kI; keyNames = getKeyName(event.keyCode); if(keyNames.length < 1) { return; } event.isRepeat = false; for(kI = 0; kI < keyNames.length; kI += 1) { keyName = keyNames[kI]; if (getActiveKeys().indexOf(keyName) != -1) event.isRepeat = true; addActiveKey(keyName); } executeMacros(); executeBindings(event); } /** * Key up event handler. * @param {KeyboardEvent} event */ function keyup(event) { var keyNames, kI; keyNames = getKeyName(event.keyCode); if(keyNames.length < 1) { return; } for(kI = 0; kI < keyNames.length; kI += 1) { removeActiveKey(keyNames[kI]); } pruneMacros(); pruneBindings(event); } /** * Accepts a key code and returns the key names defined by the current * locale. * @param {Number} keyCode * @return {Array} keyNames An array of key names defined for the key * code as defined by the current locale. */ function getKeyName(keyCode) { return map[keyCode] || []; } /** * Accepts a key name and returns the key code defined by the current * locale. * @param {Number} keyName * @return {Number|false} */ function getKeyCode(keyName) { var keyCode; for(keyCode in map) { if(!map.hasOwnProperty(keyCode)) { continue; } if(map[keyCode].indexOf(keyName) > -1) { return keyCode; } } return false; } //////////// // MACROS // //////////// /** * Accepts a key combo and an array of key names to inject once the key * combo is satisfied. * @param {String} combo * @param {Array} injectedKeys */ function createMacro(combo, injectedKeys) { if(typeof combo !== 'string' && (typeof combo !== 'object' || typeof combo.push !== 'function')) { throw new Error("Cannot create macro. The combo must be a string or array."); } if(typeof injectedKeys !== 'object' || typeof injectedKeys.push !== 'function') { throw new Error("Cannot create macro. The injectedKeys must be an array."); } macros.push([combo, injectedKeys]); } /** * Accepts a key combo and clears any and all macros bound to that key * combo. * @param {String} combo */ function removeMacro(combo) { var macro; if(typeof combo !== 'string' && (typeof combo !== 'object' || typeof combo.push !== 'function')) { throw new Error("Cannot remove macro. The combo must be a string or array."); } for(mI = 0; mI < macros.length; mI += 1) { macro = macros[mI]; if(compareCombos(combo, macro[0])) { removeActiveKey(macro[1]); macros.splice(mI, 1); break; } } } /** * Executes macros against the active keys. Each macro's key combo is * checked and if found to be satisfied, the macro's key names are injected * into active keys. */ function executeMacros() { var mI, combo, kI; for(mI = 0; mI < macros.length; mI += 1) { combo = parseKeyCombo(macros[mI][0]); if(activeMacros.indexOf(macros[mI]) === -1 && isSatisfiedCombo(combo)) { activeMacros.push(macros[mI]); for(kI = 0; kI < macros[mI][1].length; kI += 1) { addActiveKey(macros[mI][1][kI]); } } } } /** * Prunes active macros. Checks each active macro's key combo and if found * to no longer to be satisfied, each of the macro's key names are removed * from active keys. */ function pruneMacros() { var mI, combo, kI; for(mI = 0; mI < activeMacros.length; mI += 1) { combo = parseKeyCombo(activeMacros[mI][0]); if(isSatisfiedCombo(combo) === false) { for(kI = 0; kI < activeMacros[mI][1].length; kI += 1) { removeActiveKey(activeMacros[mI][1][kI]); } activeMacros.splice(mI, 1); mI -= 1; } } } ////////////// // BINDINGS // ////////////// /** * Creates a binding object, and, if provided, binds a key down hander and * a key up handler. Returns a binding object that emits keyup and * keydown events. * @param {String} keyCombo * @param {Function} keyDownCallback [Optional] * @param {Function} keyUpCallback [Optional] * @return {Object} binding */ function createBinding(keyCombo, keyDownCallback, keyUpCallback) { var api = {}, binding, subBindings = [], bindingApi = {}, kI, subCombo; //break the combo down into a combo array if(typeof keyCombo === 'string') { keyCombo = parseKeyCombo(keyCombo); } //bind each sub combo contained within the combo string for(kI = 0; kI < keyCombo.length; kI += 1) { binding = {}; //stringify the combo again subCombo = stringifyKeyCombo([keyCombo[kI]]); //validate the sub combo if(typeof subCombo !== 'string') { throw new Error('Failed to bind key combo. The key combo must be string.'); } //create the binding binding.keyCombo = subCombo; binding.keyDownCallback = []; binding.keyUpCallback = []; //inject the key down and key up callbacks if given if(keyDownCallback) { binding.keyDownCallback.push(keyDownCallback); } if(keyUpCallback) { binding.keyUpCallback.push(keyUpCallback); } //stash the new binding bindings.push(binding); subBindings.push(binding); } //build the binding api api.clear = clear; api.on = on; return api; /** * Clears the binding */ function clear() { var bI; for(bI = 0; bI < subBindings.length; bI += 1) { bindings.splice(bindings.indexOf(subBindings[bI]), 1); } } /** * Accepts an event name. and any number of callbacks. When the event is * emitted, all callbacks are executed. Available events are key up and * key down. * @param {String} eventName * @return {Object} subBinding */ function on(eventName ) { var api = {}, callbacks, cI, bI; //validate event name if(typeof eventName !== 'string') { throw new Error('Cannot bind callback. The event name must be a string.'); } if(eventName !== 'keyup' && eventName !== 'keydown') { throw new Error('Cannot bind callback. The event name must be a "keyup" or "keydown".'); } //gather the callbacks callbacks = Array.prototype.slice.apply(arguments, [1]); //stash each the new binding for(cI = 0; cI < callbacks.length; cI += 1) { if(typeof callbacks[cI] === 'function') { if(eventName === 'keyup') { for(bI = 0; bI < subBindings.length; bI += 1) { subBindings[bI].keyUpCallback.push(callbacks[cI]); } } else if(eventName === 'keydown') { for(bI = 0; bI < subBindings.length; bI += 1) { subBindings[bI].keyDownCallback.push(callbacks[cI]); } } } } //construct and return the sub binding api api.clear = clear; return api; /** * Clears the binding */ function clear() { var cI, bI; for(cI = 0; cI < callbacks.length; cI += 1) { if(typeof callbacks[cI] === 'function') { if(eventName === 'keyup') { for(bI = 0; bI < subBindings.length; bI += 1) { subBindings[bI].keyUpCallback.splice(subBindings[bI].keyUpCallback.indexOf(callbacks[cI]), 1); } } else { for(bI = 0; bI < subBindings.length; bI += 1) { subBindings[bI].keyDownCallback.splice(subBindings[bI].keyDownCallback.indexOf(callbacks[cI]), 1); } } } } } } } /** * Clears all binding attached to a given key combo. Key name order does not * matter as long as the key combos equate. * @param {String} keyCombo */ function removeBindingByKeyCombo(keyCombo) { var bI, binding, keyName; for(bI = 0; bI < bindings.length; bI += 1) { binding = bindings[bI]; if(compareCombos(keyCombo, binding.keyCombo)) { bindings.splice(bI, 1); bI -= 1; } } } /** * Clears all binding attached to key combos containing a given key name. * @param {String} keyName */ function removeBindingByKeyName(keyName) { var bI, kI, binding; if(keyName) { for(bI = 0; bI < bindings.length; bI += 1) { binding = bindings[bI]; for(kI = 0; kI < binding.keyCombo.length; kI += 1) { if(binding.keyCombo[kI].indexOf(keyName) > -1) { bindings.splice(bI, 1); bI -= 1; break; } } } } else { bindings = []; } } /** * Executes bindings that are active. Only allows the keys to be used once * as to prevent binding overlap. * @param {KeyboardEvent} event The keyboard event. */ function executeBindings(event) { var bI, sBI, binding, bindingKeys, remainingKeys, cI, killEventBubble, kI, bindingKeysSatisfied, index, sortedBindings = [], bindingWeight; remainingKeys = [].concat(activeKeys); for(bI = 0; bI < bindings.length; bI += 1) { bindingWeight = extractComboKeys(bindings[bI].keyCombo).length; if(!sortedBindings[bindingWeight]) { sortedBindings[bindingWeight] = []; } sortedBindings[bindingWeight].push(bindings[bI]); } for(sBI = sortedBindings.length - 1; sBI >= 0; sBI -= 1) { if(!sortedBindings[sBI]) { continue; } for(bI = 0; bI < sortedBindings[sBI].length; bI += 1) { binding = sortedBindings[sBI][bI]; bindingKeys = extractComboKeys(binding.keyCombo); bindingKeysSatisfied = true; for(kI = 0; kI < bindingKeys.length; kI += 1) { if(remainingKeys.indexOf(bindingKeys[kI]) === -1) { bindingKeysSatisfied = false; break; } } if(bindingKeysSatisfied && isSatisfiedCombo(binding.keyCombo)) { activeBindings.push(binding); for(kI = 0; kI < bindingKeys.length; kI += 1) { index = remainingKeys.indexOf(bindingKeys[kI]); if(index > -1) { remainingKeys.splice(index, 1); kI -= 1; } } for(cI = 0; cI < binding.keyDownCallback.length; cI += 1) { if (binding.keyDownCallback[cI](event, getActiveKeys(), binding.keyCombo) === false) { killEventBubble = true; } } if(killEventBubble === true) { event.preventDefault(); event.stopPropagation(); } } } } } /** * Removes bindings that are no longer satisfied by the active keys. Also * fires the key up callbacks. * @param {KeyboardEvent} event */ function pruneBindings(event) { var bI, cI, binding, killEventBubble; for(bI = 0; bI < activeBindings.length; bI += 1) { binding = activeBindings[bI]; if(isSatisfiedCombo(binding.keyCombo) === false) { for(cI = 0; cI < binding.keyUpCallback.length; cI += 1) { if (binding.keyUpCallback[cI](event, getActiveKeys(), binding.keyCombo) === false) { killEventBubble = true; } } if(killEventBubble === true) { event.preventDefault(); event.stopPropagation(); } activeBindings.splice(bI, 1); bI -= 1; } } } /////////////////// // COMBO STRINGS // /////////////////// /** * Compares two key combos returning true when they are functionally * equivalent. * @param {String} keyComboArrayA keyCombo A key combo string or array. * @param {String} keyComboArrayB keyCombo A key combo string or array. * @return {Boolean} */ function compareCombos(keyComboArrayA, keyComboArrayB) { var cI, sI, kI; keyComboArrayA = parseKeyCombo(keyComboArrayA); keyComboArrayB = parseKeyCombo(keyComboArrayB); if(keyComboArrayA.length !== keyComboArrayB.length) { return false; } for(cI = 0; cI < keyComboArrayA.length; cI += 1) { if(keyComboArrayA[cI].length !== keyComboArrayB[cI].length) { return false; } for(sI = 0; sI < keyComboArrayA[cI].length; sI += 1) { if(keyComboArrayA[cI][sI].length !== keyComboArrayB[cI][sI].length) { return false; } for(kI = 0; kI < keyComboArrayA[cI][sI].length; kI += 1) { if(keyComboArrayB[cI][sI].indexOf(keyComboArrayA[cI][sI][kI]) === -1) { return false; } } } } return true; } /** * Checks to see if a key combo string or key array is satisfied by the * currently active keys. It does not take into account spent keys. * @param {String} keyCombo A key combo string or array. * @return {Boolean} */ function isSatisfiedCombo(keyCombo) { var cI, sI, stage, kI, stageOffset = 0, index, comboMatches; keyCombo = parseKeyCombo(keyCombo); for(cI = 0; cI < keyCombo.length; cI += 1) { comboMatches = true; stageOffset = 0; for(sI = 0; sI < keyCombo[cI].length; sI += 1) { stage = [].concat(keyCombo[cI][sI]); for(kI = stageOffset; kI < activeKeys.length; kI += 1) { index = stage.indexOf(activeKeys[kI]); if(index > -1) { stage.splice(index, 1); stageOffset = kI; } } if(stage.length !== 0) { comboMatches = false; break; } } if(comboMatches) { return true; } } return false; } /** * Accepts a key combo array or string and returns a flat array containing all keys referenced by * the key combo. * @param {String} keyCombo A key combo string or array. * @return {Array} */ function extractComboKeys(keyCombo) { var cI, sI, kI, keys = []; keyCombo = parseKeyCombo(keyCombo); for(cI = 0; cI < keyCombo.length; cI += 1) { for(sI = 0; sI < keyCombo[cI].length; sI += 1) { keys = keys.concat(keyCombo[cI][sI]); } } return keys; } /** * Parses a key combo string into a 3 dimensional array. * - Level 1 - sub combos. * - Level 2 - combo stages. A stage is a set of key name pairs that must * be satisfied in the order they are defined. * - Level 3 - each key name to the stage. * @param {String|Array} keyCombo A key combo string. * @return {Array} */ function parseKeyCombo(keyCombo) { var s = keyCombo, i = 0, op = 0, ws = false, nc = false, combos = [], combo = [], stage = [], key = ''; if(typeof keyCombo === 'object' && typeof keyCombo.push === 'function') { return keyCombo; } if(typeof keyCombo !== 'string') { throw new Error('Cannot parse "keyCombo" because its type is "' + (typeof keyCombo) + '". It must be a "string".'); } //remove leading whitespace while(s.charAt(i) === ' ') { i += 1; } while(true) { if(s.charAt(i) === ' ') { //white space & next combo op while(s.charAt(i) === ' ') { i += 1; } ws = true; } else if(s.charAt(i) === ',') { if(op || nc) { throw new Error('Failed to parse key combo. Unexpected , at character index ' + i + '.'); } nc = true; i += 1; } else if(s.charAt(i) === '+') { //next key if(key.length) { stage.push(key); key = ''; } if(op || nc) { throw new Error('Failed to parse key combo. Unexpected + at character index ' + i + '.'); } op = true; i += 1; } else if(s.charAt(i) === '>') { //next stage op if(key.length) { stage.push(key); key = ''; } if(stage.length) { combo.push(stage); stage = []; } if(op || nc) { throw new Error('Failed to parse key combo. Unexpected > at character index ' + i + '.'); } op = true; i += 1; } else if(i < s.length - 1 && s.charAt(i) === '!' && (s.charAt(i + 1) === '>' || s.charAt(i + 1) === ',' || s.charAt(i + 1) === '+')) { key += s.charAt(i + 1); op = false; ws = false; nc = false; i += 2; } else if(i < s.length && s.charAt(i) !== '+' && s.charAt(i) !== '>' && s.charAt(i) !== ',' && s.charAt(i) !== ' ') { //end combo if(op === false && ws === true || nc === true) { if(key.length) { stage.push(key); key = ''; } if(stage.length) { combo.push(stage); stage = []; } if(combo.length) { combos.push(combo); combo = []; } } op = false; ws = false; nc = false; //key while(i < s.length && s.charAt(i) !== '+' && s.charAt(i) !== '>' && s.charAt(i) !== ',' && s.charAt(i) !== ' ') { key += s.charAt(i); i += 1; } } else { //unknown char i += 1; continue; } //end of combos string if(i >= s.length) { if(key.length) { stage.push(key); key = ''; } if(stage.length) { combo.push(stage); stage = []; } if(combo.length) { combos.push(combo); combo = []; } break; } } return combos; } /** * Stringifys a key combo. * @param {Array|String} keyComboArray A key combo array. If a key * combo string is given it will be returned. * @return {String} */ function stringifyKeyCombo(keyComboArray) { var cI, ccI, output = []; if(typeof keyComboArray === 'string') { return keyComboArray; } if(typeof keyComboArray !== 'object' || typeof keyComboArray.push !== 'function') { throw new Error('Cannot stringify key combo.'); } for(cI = 0; cI < keyComboArray.length; cI += 1) { output[cI] = []; for(ccI = 0; ccI < keyComboArray[cI].length; ccI += 1) { output[cI][ccI] = keyComboArray[cI][ccI].join(' + '); } output[cI] = output[cI].join(' > '); } return output.join(' '); } ///////////////// // ACTIVE KEYS // ///////////////// /** * Returns the a copy of the active keys array. * @return {Array} */ function getActiveKeys() { return [].concat(activeKeys); } /** * Adds a key to the active keys array, but only if it has not already been * added. * @param {String} keyName The key name string. */ function addActiveKey(keyName) { if(keyName.match(/\s/)) { throw new Error('Cannot add key name ' + keyName + ' to active keys because it contains whitespace.'); } if(activeKeys.indexOf(keyName) > -1) { return; } activeKeys.push(keyName); } /** * Removes a key from the active keys array. * @param {String} keyNames The key name string. */ function removeActiveKey(keyName) { var keyCode = getKeyCode(keyName); if(keyCode === '91' || keyCode === '92') { activeKeys = []; } //remove all key on release of super. else { activeKeys.splice(activeKeys.indexOf(keyName), 1); } } ///////////// // LOCALES // ///////////// /** * Registers a new locale. This is useful if you would like to add support for a new keyboard layout. It could also be useful for * alternative key names. For example if you program games you could create a locale for your key mappings. Instead of key 65 mapped * to 'a' you could map it to 'jump'. * @param {String} localeName The name of the new locale. * @param {Object} localeMap The locale map. */ function registerLocale(localeName, localeMap) { //validate arguments if(typeof localeName !== 'string') { throw new Error('Cannot register new locale. The locale name must be a string.'); } if(typeof localeMap !== 'object') { throw new Error('Cannot register ' + localeName + ' locale. The locale map must be an object.'); } if(typeof localeMap.map !== 'object') { throw new Error('Cannot register ' + localeName + ' locale. The locale map is invalid.'); } //stash the locale if(!localeMap.macros) { localeMap.macros = []; } locales[localeName] = localeMap; } /** * Swaps the current locale. * @param {String} localeName The locale to activate. * @return {Object} */ function getSetLocale(localeName) { //if a new locale is given then set it if(localeName) { if(typeof localeName !== 'string') { throw new Error('Cannot set locale. The locale name must be a string.'); } if(!locales[localeName]) { throw new Error('Cannot set locale to ' + localeName + ' because it does not exist. If you would like to submit a ' + localeName + ' locale map for KeyboardJS please submit it at https://github.com/RobertWHurst/KeyboardJS/issues.'); } //set the current map and macros map = locales[localeName].map; macros = locales[localeName].macros; //set the current locale locale = localeName; } //return the current locale return locale; } });