From 34cbc729dbc12c0102b3be0c20dd60c5c12f758f Mon Sep 17 00:00:00 2001 From: Timo Tijhof Date: Tue, 23 Oct 2012 00:27:42 +0200 Subject: [PATCH] ve.ui.CommandFactory: Initial implementation Based on https://github.com/ccampbell/mousetrap. Cleaned up to fit our coding standards, pass JSHint, and assume jQuery's fixes where possible (e.g. no need for an addEvent utility, no need for filling e.target, e.which, etc. cross-browser which jQuery.Event already does). Initially all were local functions in the constructor, but to allow some customisations in subclasses moved various methods to the prototype instead and marked with @private. Really, only .register() must be called from the outside. The rest assumes normalisation etc. or might break things if called incorrectly. Change-Id: Ic69a3c70759052094aefbeab623d885f8b118e14 --- CODING.md | 2 + VisualEditor.php | 1 + demos/ve/index.php | 1 + modules/ve/test/index.html | 2 + .../ve/test/ui/ve.ui.CommandFactory.test.js | 163 ++++ modules/ve/ui/ve.ui.CommandFactory.js | 749 ++++++++++++++++++ 6 files changed, 918 insertions(+) create mode 100644 modules/ve/test/ui/ve.ui.CommandFactory.test.js create mode 100644 modules/ve/ui/ve.ui.CommandFactory.js diff --git a/CODING.md b/CODING.md index 3bfa127c71..68247e2034 100644 --- a/CODING.md +++ b/CODING.md @@ -24,10 +24,12 @@ here for consistency. * @abstract * @constructor * @extends {Type} +* @private * @static * @method * @until Text: Optional details. * @source +* @context {Type} The type of the `this` value. * @param {Type} varName Optional description. * @returns {Type} Optional description. diff --git a/VisualEditor.php b/VisualEditor.php index bed7dc455e..e868da8ef2 100644 --- a/VisualEditor.php +++ b/VisualEditor.php @@ -233,6 +233,7 @@ $wgResourceModules += array( // ui 've/ui/ve.ui.js', + 've/ui/ve.ui.CommandFactory.js', 've/ui/ve.ui.Inspector.js', 've/ui/ve.ui.Tool.js', 've/ui/ve.ui.Toolbar.js', diff --git a/demos/ve/index.php b/demos/ve/index.php index f97c9e7900..b679a7e19f 100644 --- a/demos/ve/index.php +++ b/demos/ve/index.php @@ -153,6 +153,7 @@ $html = '
' . file_get_contents( $page ) . '
'; + diff --git a/modules/ve/test/index.html b/modules/ve/test/index.html index 5299c96e88..27b41bba05 100644 --- a/modules/ve/test/index.html +++ b/modules/ve/test/index.html @@ -102,6 +102,7 @@ + @@ -151,6 +152,7 @@ +
diff --git a/modules/ve/test/ui/ve.ui.CommandFactory.test.js b/modules/ve/test/ui/ve.ui.CommandFactory.test.js new file mode 100644 index 0000000000..e983c3a906 --- /dev/null +++ b/modules/ve/test/ui/ve.ui.CommandFactory.test.js @@ -0,0 +1,163 @@ +/** + * VisualEditor CommandFactory tests. + * + * @copyright 2011-2012 VisualEditor Team and others; see AUTHORS.txt + * @license The MIT License (MIT); see LICENSE.txt + */ + +( function () { + // When runSequence is called multiple times, + // be sure to keep track of how far in the future we've scheduled, + // since mixed sequences are (intentionally) invalidated. + var sequenceSimulatorOffset = 0; + + QUnit.module( 've.ui.CommandFactory', { + setup: function () { + // Clear registry after each test + ve.ui.commandFactory = new ve.ui.CommandFactory(); + }, + teardown: function () { + // Can't restore because the constructor does .off(), so the + // old one is destroyed + ve.ui.commandFactory = new ve.ui.CommandFactory(); + } + } ); + + /** + * @param {string|undefined} action + * @param {string} char Single character. + * @param {Object} props [optional] + */ + function runAction( action, char, props ) { + props = props || {}; + props.which = char.charCodeAt( 0 ); + + $( 'body' ).trigger( + $.Event( action || 'keypress', props ) + ); + } + + /** + * @param {string} action + * @param {number} interval Time between actions (in milliseconds). + * @param {string} chars + */ + function runSequence( action, interval, chars ) { + var i; + + chars = chars.split( ' ' ); + + /** + * Utility function to avoid making functions + * in a loop, causing scope issues with 'i'. + */ + function schedule( char, delay ) { + setTimeout( function () { + runAction( action, char ); + }, delay ); + } + + for ( i = 0; i < chars.length; i++ ) { + sequenceSimulatorOffset += interval; + schedule( chars[i], sequenceSimulatorOffset ); + } + } + + QUnit.test( 'register: Single characters', 18, function ( assert ) { + $.each( { + // Default should work with keypress + 'default': [ undefined, 'keypress' ], + 'keypress': [ 'keypress', 'keypress' ] + }, function ( action, event ) { + $.each( '0 9 a Z ! > - +'.split( ' ' ), function ( i, char ) { + ve.ui.commandFactory.register( char, function () { + assert.ok( true, action + ': "' + char + '" - ' + event[1] + ' trigger without modifier keys' ); + }, event[0] ); + + runAction( event[1], char ); + + } ); + } ); + + ve.ui.commandFactory.register( 'a', function () { + assert.ok( false, 'keypress: "a" - trigger A is ignored' ); + }, 'keypress' ); + runAction( 'keypress', 'A' ); + + ve.ui.commandFactory.register( 'a', function () { + assert.ok( false, 'keypress: "a" - trigger A with Shift is ignored' ); + }, 'keypress' ); + runAction( 'keypress', 'A', { shiftKey: true } ); + + ve.ui.commandFactory.register( 'Z', function () { + assert.ok( true, 'keypress: "Z" - trigger Z with Shift' ); + }, 'keypress' ); + runAction( 'keypress', 'Z', { shiftKey: true } ); + + ve.ui.commandFactory.register( 'g', function () { + assert.ok( false, 'keypress: "g" - register removes old callbacks' ); + }, 'keypress' ); + ve.ui.commandFactory.register( 'g', function () { + // Undocumented behavior, tested so we detect if/when it changes + assert.ok( true, 'keypress: "g" - register keeps only the last callback' ); + }, 'keypress' ); + runAction( 'keypress', 'g' ); + + } ); + + QUnit.asyncTest( 'register: Sequences', 3, function ( assert ) { + ve.ui.commandFactory.register( '1 2 3', function () { + assert.ok( true, 'Number sequence with 20ms interval' ); + } ); + runSequence( 'keypress', 20, '1 2 3' ); + + ve.ui.commandFactory.register( 'a b c Z', function () { + assert.ok( true, 'Letter sequence with 10ms interval' ); + } ); + runSequence( 'keypress', 10, 'a b c Z' ); + + ve.ui.commandFactory.register( 'o r d e r', function () { + assert.ok( false, 'Out of order is invalid' ); + } ); + runSequence( 'keypress', 10, 'o r d r e' ); + + ve.ui.commandFactory.register( 'm i s s', function () { + assert.ok( false, 'Missing in-between is invalid' ); + } ); + runSequence( 'keypress', 10, 'm s s' ); + + ve.ui.commandFactory.register( 'e x t', function () { + assert.ok( false, 'Extra in-between is invalid' ); + } ); + runSequence( 'keypress', 10, 'e r x t' ); + + ve.ui.commandFactory.register( 's l', function () { + assert.ok( true, 'Total sequence may take over a second (2 * 600ms)' ); + } ); + runSequence( 'keypress', 600, 's l' ); + + ve.ui.commandFactory.register( 'g a', function () { + assert.ok( false, 'Sequences with gaps > 1 second are invalid (2 x 1100ms)' ); + } ); + runSequence( 'keypress', 1100, 'g a' ); + + setTimeout( QUnit.start, sequenceSimulatorOffset + 1 ); + } ); + + QUnit.test( 'register: Combinations', 2, function ( assert ) { + ve.ui.commandFactory.register( 'ctrl+i', function ( e, combo ) { + assert.ok( true, combo ); + } ); + + // XXX: Apparently i/ctrl doesn't work, needs to be uppercase I. + // This is internally normalized back to 'a'. + // Google Chrome (and others) use keycode for 'A' when typing a + modifier. + runAction( 'keydown', 'I', { ctrlKey: true } ); + + ve.ui.commandFactory.register( 'cmd+b', function ( e, combo ) { + assert.ok( true, combo ); + } ); + runAction( 'keydown', 'B', { metaKey: true } ); + } ); + +}() ); diff --git a/modules/ve/ui/ve.ui.CommandFactory.js b/modules/ve/ui/ve.ui.CommandFactory.js new file mode 100644 index 0000000000..bbe5753490 --- /dev/null +++ b/modules/ve/ui/ve.ui.CommandFactory.js @@ -0,0 +1,749 @@ +/** + * VisualEditor user interface CommandFactory class. + * + * @copyright 2011-2012 VisualEditor Team and others; see AUTHORS.txt + * @license The MIT License (MIT); see LICENSE.txt + */ +( function ( ve ) { + + /* Private static */ + + var + i, + + /** + * @var {Object} + * Mapping of special keycodes to the internal symolic names we use for binding. + * + * Everything in this map cannot use keypress events + * so it has to be here to map to the correct keycodes for + * keyup/keydown events (???). + */ + mapSpecialFromCode, + + /** + * @var {undefined|Object} + * Inversed version of mapSpecialFromCode. + * Lazy-loaded, use getSpecialFromNameMap() + */ + mapSpecialFromName, + + /** + * @var {Object} + * This is a map to allow usage of lesser common or cross-platform + * varying names of special keys. These are used to normalize + * commands for detection through mapSpecialFromCode. + */ + mapSpecialAliases, + + /** + * @var {Object} + * Mapping of special characters (only used for binding in a + * keyup or keydown event to one of these keys). + */ + + mapSpecialChars, + + /** + * @var {Object} + * Mapping from keys that require shift on a US keypad + * back to the non shift equivalents. + * + * This is so you can use keyup events with these keys. + */ + mapShiftChars; + + mapSpecialFromCode = { + 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' + }; + + // Add F1 to F19 programatically + for ( i = 1; i <= 19; i++ ) { + mapSpecialFromCode[111 + i] = 'f' + i; + } + + // Add numbers from the numeric keypad + for ( i = 0; i <= 9; i++ ) { + mapSpecialFromCode[96 + i] = i; + } + + mapSpecialAliases = { + 'option': 'alt', + 'delete': 'del', + 'return': 'enter', + 'escape': 'esc', + 'apple': 'meta', + 'cmd': 'meta', + 'command': 'meta' + }; + + mapSpecialChars = { + 106: '*', + 107: '+', + 109: '-', + 110: '.', + 111 : '/', + 186: ';', + 187: '=', + 188: ',', + 189: '-', + 190: '.', + 191: '/', + 192: '`', + 219: '[', + 220: '\\', + 221: ']', + 222: '\'' + }; + + mapShiftChars = { + '~': '`', + '!': '1', + '@': '2', + '#': '3', + '$': '4', + '%': '5', + '^': '6', + '&': '7', + '*': '8', + '(': '9', + ')': '0', + '_': '-', + '+': '=', + ':': ';', + '\"': '\'', + '<': ',', + '>': '.', + '?': '/', + '|': '\\' + }; + + /** + * @return {Object} + */ + function getSpecialFromNameMap() { + if ( !mapSpecialFromName ) { + mapSpecialFromName = {}; + for ( var key in mapSpecialFromCode ) { + + // Pull out the numeric keypad from here because keypress should + // be able to detect the keys from the character. + if ( key > 95 && key < 112 ) { + continue; + } + + if (mapSpecialFromCode.hasOwnProperty(key)) { + mapSpecialFromName[mapSpecialFromCode[key]] = key; + } + } + } + return mapSpecialFromName; + } + + /** + * Extract the key character (as a string) from the event. + * + * @param {jQuery.Event} e + * @return {String} + */ + function characterFromEvent( e ) { + var char; + + // Keypress play nice and set e.which correctly. + if ( e.type === 'keypress' ) { + return String.fromCharCode( e.which ); + } + + // For non-keypress events we need the special maps. + char = mapSpecialFromCode[ e.which ]; + if ( char !== undefined ) { + return char; + } + + char = mapSpecialChars[ e.which ]; + if ( char !== undefined ) { + return char; + } + + // Fallback for non-keypress events for non-special stuff. + return String.fromCharCode( e.which ).toLowerCase(); + } + + /** + * Asserts that two arrays have equal values + * (non-recursive, uses toString to assert equality). + * + * @param {Array} a + * @param {Array} b + * @returns {boolean} + */ + function arrayEqual( a, b ) { + return a.sort().join( ',' ) === b.sort().join( ',' ); + } + + /** + * Get a list of names of any modifier keys pressed during this key event. + * + * @param {jQuery.Event} e + * @returns {Array} + */ + function getModifiersFromEvent( e ) { + var modifiers = []; + + if ( e.shiftKey ) { + modifiers.push('shift'); + } + + if ( e.altKey ) { + modifiers.push('alt'); + } + + if ( e.ctrlKey ) { + modifiers.push('ctrl'); + } + + if ( e.metaKey ) { + modifiers.push('meta'); + } + + return modifiers; + } + + /** + * Determine if the key is a modifier key or not. + * + * @param {string} key Name of key (lower case). + * @returns {boolean} + */ + function isModifier( key ) { + return key === 'shift' || key === 'ctrl' || key === 'alt' || key === 'meta'; + } + + /** + * Guess the best action based on the key combo. + * This is used for the binding if `register()` is called without a specific action. + * + * @param {number} key Code for key. + * @param {Array} modifiers + * @param {string=} action passed in + */ + function guessAction( key, modifiers, action ) { + + // if no action was picked in we should try to pick the one + // that we think would work best for this key + if ( !action ) { + action = getSpecialFromNameMap()[ key ] ? 'keydown' : 'keypress'; + } + + // modifier keys don't work as expected with keypress, + // switch to keydown + if ( action === 'keypress' && modifiers.length ) { + action = 'keydown'; + } + + return action; + } + + + /** + * Creates a command factory. + * + * @class + * @constructor + */ + ve.ui.CommandFactory = function VeUiCommandFactory() { + var cf = this; + + /* Private properties */ + + /** + * @var {Object} + * List of callbacks keyed by character. + */ + cf.callbackList = {}, + + /** + * @var {undefined|number} + * ID of the setTimeout for the sequence detection. + */ + cf.sequenceTimer = undefined; + + /** + * @var {Object} + * + * Keep track of what level each sequence is at since multiple + * sequences can start out with the same sequence. + */ + cf.sequenceTracker = {}; + + /** + * @var {Boolean} + * Whether we are currently witnissing a sequence being entered. + */ + cf.isInsideSequence = false; + + /** + * @var {Boolean|String} + * Temporary state where we will ignore the next keyup (???). + */ + cf.ignoreNextKeyup = false; + + /** + * Handler for the character from a (valid) key event. + * + * @param {string} character A single character + * @param {jQuery.Event} e + */ + function handleCharacter( character, e ) { + var callbacks = cf.getMatches( character, getModifiersFromEvent( e ), e ), + i, + doNotReset = {}, + processedSequenceCallback = false; + + // 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 ) { + processedSequenceCallback = true; + + // keep a list of which sequences were matches for later + doNotReset[callbacks[i].seq] = 1; + cf.fireCallback( callbacks[i].callback, e, callbacks[i].combo ); + 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 && !cf.isInsideSequence ) { + cf.fireCallback( callbacks[i].callback, e, callbacks[i].combo ); + } + } + + // if you are inside of a sequence and the key you are pressing + // is not a modifier key then we should reset all sequences + // that were not matched by this key event + if ( e.type === cf.isInsideSequence && !isModifier( character ) ) { + cf.resetSequences(doNotReset); + } + } + + /** + * Wrapper handler for key events. + * + * @context {HTMLElement} + * @param {jQuery.Event} e + */ + function handleKey( e ) { + var char = characterFromEvent( e ); + + // Stop if this key event is for a character we can't detect. + if ( !char ) { + return; + } + + if ( e.type === 'keyup' && cf.ignoreNextKeyup === char ) { + cf.ignoreNextKeyup = false; + return; + } + + handleCharacter( char, e ); + } + + $( 'body' ) + .off( '.ve-commandfactory' ) + .on( 'keypress.ve-commandfactory keydown.ve-commandfactory keyup.ve-commandfactory', handleKey ); + }; + + /* Methods */ + + /** + * Reset all sequence counters except for the ones passed in. + * + * @private + * @method + * @param {Object} [doNotReset] + */ + ve.ui.CommandFactory.prototype.resetSequences = function ( doNotReset ) { + var key, activeSequences; + + doNotReset = doNotReset || {}; + activeSequences = false; + for ( key in this.sequenceTracker ) { + if (doNotReset[key]) { + activeSequences = true; + continue; + } + this.sequenceTracker[key] = 0; + } + + if ( !activeSequences ) { + this.isInsideSequence = false; + } + }; + + /** + * 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. + * + * @private + * @method + */ + ve.ui.CommandFactory.prototype.resetSequenceTimer = function () { + clearTimeout( this.sequenceTimer ); + this.sequenceTimer = setTimeout( ve.bind( this.resetSequences, this ), 1000 ); + }; + + /** + * Find all callbacks that match based on the keycode, modifiers, + * and action. + * + * @private + * @method + * @param {string} character + * @param {Array} modifiers + * @param {jQuery.Event|Object} e + * @param {boolean=} remove - should we remove any matches + * @param {string=} combo + * @returns {Array} + */ + ve.ui.CommandFactory.prototype.getMatches = function ( character, modifiers, e, remove, combo ) { + var i, + callback, + matches = [], + action = e.type; + + // if there are no events related to this keycode + if ( !this.callbackList[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 < this.callbackList[character].length; i++ ) { + callback = this.callbackList[character][i]; + + // if this is a sequence but it is not at the right level + // then move onto the next match + if ( callback.seq && this.sequenceTracker[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) || arrayEqual( modifiers, callback.modifiers )) { + + // remove is used so if you change your mind and call bind a + // second time with a new function the first one is overwritten + if ( remove && callback.combo === combo ) { + this.callbackList[ character ].splice( i, 1 ); + } + + matches.push(callback); + } + } + + return matches; + }; + + /** + * Internal helper for binding a callback to a key sequence. + * Uses `bindSingle` underneath to track progress on the sequence. + * + * @private + * @method + * @param {string} combo The sequence as passed to `register()`. + * @param {Array} keys The individual characters in the sequence, in order. + * @param {Function} callback See `register()`. + * @param {string=guessAction()} action [optional] See `register()`. + */ + ve.ui.CommandFactory.prototype.bindSequence = function ( combo, keys, callback, action ) { + var i, + cf = this; + + /** + * callback to increase the sequence level for this sequence and reset + * all other sequences that were active + * + * @param {jQuery.Event} e + */ + function increaseSequence() { + cf.isInsideSequence = action; + cf.sequenceTracker[combo]++; + cf.resetSequenceTimer(); + } + + /** + * wraps the specified callback inside of another function in order + * to reset all sequence counters as soon as this sequence is done + * + * @param {jQuery.Event} e + */ + function callbackAndReset( e ) { + cf.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' ) { + cf.ignoreNextKeyup = characterFromEvent( e ); + } + + // weird race condition if a sequence ends with the key + // another sequence begins with + setTimeout( cf.resetSequences, 10 ); + } + + // start off by adding a sequence level record for this combo + // and setting the level to 0 + cf.sequenceTracker[combo] = 0; + + // if there is no action guess the best one for the first key + // in the sequence + if ( !action ) { + action = guessAction( keys[0], [] ); + } + + // 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 + for ( i = 0; i < keys.length; i++ ) { + cf.bindSingle( + keys[i], + i < keys.length - 1 ? increaseSequence : callbackAndReset, + action, + combo, + i + ); + } + }; + + /** + * Internal helper for binding a (single) key command. + * + * @private + * @method + * @param {string} combo + * @param {Function} callback + * @param {string} action [optional] + * @param {string} sequenceName [optional] (internal) Name of sequence if part of sequence. + * @param {number} level [optional] (internal) What part of the sequence the command is. + */ + ve.ui.CommandFactory.prototype.bindSingle = function ( combo, callback, action, sequenceName, level ) { + // Normalize: Make sure multiple spaces in a row become a single space. + combo = combo.replace( /\s+/g, ' ' ); + + var i, + key, + keys, + cf = this, + sequence = combo.split( ' ' ), + modifiers = []; + + // 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 will call bindSingle again to track progress on each + // individual key in the sequence. + this.bindSequence( combo, sequence, callback, action ); + return; + } + + // take the keys from this pattern and figure out what the actual + // pattern is all about + keys = combo === '+' ? [ '+' ] : combo.split( '+' ); + + // More normalization + for ( i = 0; i < keys.length; i++ ) { + key = keys[ i ]; + + // Aliases for certain names + if ( mapSpecialAliases[ key ] ) { + key = mapSpecialAliases[ key ]; + } + + // If this is not a keypress event then we should be smart about using shift keys. + // E.g. "shift+1" will never match browers will give keycode for "!" instead. + // This will only work for US keyboards however. + if (action && action !== 'keypress' && mapShiftChars[key]) { + key = mapShiftChars[ key ]; + modifiers.push( 'shift' ); + } + + // if this key is a modifier then add it to the list of modifiers + if ( isModifier( key ) ) { + modifiers.push( key ); + } + } + + // depending on what the key combo is + // we will try to pick the best event for it + action = guessAction( key, modifiers, action ); + + // make sure to initialize array if this is the first time + // a callback is added for this key + if ( !cf.callbackList[ key ] ) { + cf.callbackList[ key ] = []; + } + + // remove an existing match if there is one + cf.getMatches( key, modifiers, { type: action }, !sequenceName, combo ); + + // 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 + cf.callbackList[ key ][ sequenceName ? 'unshift' : 'push' ]( { + callback: callback, + modifiers: modifiers, + action: action, + seq: sequenceName, + level: level, + combo: combo + } ); + }; + + /* + * Whether this event should be rejected before further processing and + * (eventually) firing off callbacks. + * + * @method + * @param {jQuery.Event} e + * @param {HTMLElement} element + * @param {string} combo + * @return {boolean} Return false to reject, true to keep. For + * compatibility with Mousetrap's isRejected and Mousetrap.stopCallback, + * returning undefined will trigger the 'keep' behavior. Only strict false + * will reject it. + */ + ve.ui.CommandFactory.prototype.filter = function ( e, element ) { + var tag; + + // if the element has the class "mousetrap" then no need to stop + if ( $( element ).hasClass( 'mousetrap' ) ) { + return true; + } + + tag = element.nodeName && element.nodeName.toLowerCase(); + if ( + // Reject key events from input contexts. + tag === 'input' || tag === 'select' || tag === 'textarea' || + ( element.contentEditable && element.contentEditable === 'true' ) + ) { + return false; + } + + return true; + }; + + /** + * Register a command. This is the main method to be used from the outside. + * + * @example + * + * ve.ui.commandFactory.register( 'ctrl+b', fn ); + * ve.ui.commandFactory.register( ['a b c', '1 2 3'], fn ); + * ve.ui.commandFactory.register( 'a ctrl+b c', fn ); + * + * + * @method + * @param {String|String[]} commands Keyboard command or array of keyboard commands. + * A command is either a combo (keys separated by plus sign) or a sequence of keys + * (separated by a space). + * + * NB: Do *NOT* add superfluous spaces around the plus sign, as it will trigger a + * sequence instead of a combination (e.g. 'a + b' will normalize to 'a b', or + * something else unexpected). + * + * NB: Don't use uppercase characters except for single characters like 'A' or 'A B'. + * (e.g. 'ctrl+B' will not work as expected, instead use 'ctrl+b' or 'ctrl+shift+b'). + * @param {Function} callback. + * @param {String} action One of 'keypress', 'keydown' or 'keyup'. Default determined + * by `guessAction()`. + */ + ve.ui.CommandFactory.prototype.register = function ( commands, callback, action ) { + if ( commands ) { + if ( !$.isArray( commands ) ) { + commands = [commands]; + } + for ( var i = 0; i < commands.length; i++ ) { + this.bindSingle( commands[i], callback, action ); + } + } + }; + + /** + * Internal method for firing a callback function from the callback list. + * + * If your callback function returns false this will apply the same + * behavior as jQuery does: prevent default and stop propogation. + * + * @private + * @method + * @param {Function} callback + * @param {jQuery.Event} e + * @param {string} combo + */ + ve.ui.CommandFactory.prototype.fireCallback = function ( callback, e, combo ) { + // If this event should not happen, stop here. + if ( this.filter( e, e.target, combo ) === false ) { + return; + } + + if ( callback( e, combo ) === false ) { + e.preventDefault(); + e.stopPropagation(); + } + }; + + /* Initialization */ + + // TODO: Move instantiation to a different file + ve.ui.commandFactory = new ve.ui.CommandFactory(); + +}( ve ) );