mediawiki-extensions-Visual.../modules/ve/ui/ve.ui.CommandFactory.js
Timo Tijhof 34cbc729db 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
2012-10-24 17:48:16 +00:00

750 lines
19 KiB
JavaScript

/**
* 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
* <code>
* 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 );
* </code>
*
* @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 ) );