mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/VisualEditor
synced 2024-11-27 15:50:29 +00:00
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
This commit is contained in:
parent
5b8aa91fe8
commit
34cbc729db
|
@ -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.
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -153,6 +153,7 @@ $html = '<div>' . file_get_contents( $page ) . '</div>';
|
|||
<script src="../../modules/ve/ce/nodes/ve.ce.TableSectionNode.js"></script>
|
||||
<script src="../../modules/ve/ce/nodes/ve.ce.TextNode.js"></script>
|
||||
<script src="../../modules/ve/ui/ve.ui.js"></script>
|
||||
<script src="../../modules/ve/ui/ve.ui.CommandFactory.js"></script>
|
||||
<script src="../../modules/ve/ui/ve.ui.Inspector.js"></script>
|
||||
<script src="../../modules/ve/ui/ve.ui.Tool.js"></script>
|
||||
<script src="../../modules/ve/ui/ve.ui.Toolbar.js"></script>
|
||||
|
|
|
@ -102,6 +102,7 @@
|
|||
<script src="../../ve/ce/nodes/ve.ce.TableSectionNode.js"></script>
|
||||
<script src="../../ve/ce/nodes/ve.ce.TextNode.js"></script>
|
||||
<script src="../../ve/ui/ve.ui.js"></script>
|
||||
<script src="../../ve/ui/ve.ui.CommandFactory.js"></script>
|
||||
<script src="../../ve/ui/ve.ui.Inspector.js"></script>
|
||||
<script src="../../ve/ui/ve.ui.Tool.js"></script>
|
||||
<script src="../../ve/ui/ve.ui.Toolbar.js"></script>
|
||||
|
@ -151,6 +152,7 @@
|
|||
<script src="ce/ve.ce.BranchNode.test.js"></script>
|
||||
<script src="ce/ve.ce.LeafNode.test.js"></script>
|
||||
<script src="ce/nodes/ve.ce.TextNode.test.js"></script>
|
||||
<script src="ui/ve.ui.CommandFactory.test.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="qunit"></div>
|
||||
|
|
163
modules/ve/test/ui/ve.ui.CommandFactory.test.js
Normal file
163
modules/ve/test/ui/ve.ui.CommandFactory.test.js
Normal file
|
@ -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 } );
|
||||
} );
|
||||
|
||||
}() );
|
749
modules/ve/ui/ve.ui.CommandFactory.js
Normal file
749
modules/ve/ui/ve.ui.CommandFactory.js
Normal file
|
@ -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
|
||||
* <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 ) );
|
Loading…
Reference in a new issue