All files / src ve.EventSequencer.js

97.85% Statements 137/140
78.57% Branches 22/28
86.36% Functions 19/22
98.37% Lines 121/123

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425                                                                                                1x 374x 374x 374x                 2981x                 374x         374x         374x         374x   374x 2981x 2981x 2981x 2981x 2981x           374x         374x         374x         374x         374x                   1x 81x 81x 81x                   1x 466x 385x   81x 81x 81x 81x                   1x 1x 1x   1x 1x                   1x 374x 2236x   374x                   1x 374x 376x   374x                   1x 47x 47x   47x                   1x 1x 1x   1x 1x                   1x 1x 1x   1x 1x                   1x 1374x 1374x 596x 596x       1374x   1374x 1326x 1326x             1374x 1374x 618x   4x   614x 614x 614x   1374x 1374x                   1x   1374x   1374x   1374x 414x     1374x 47x                 1x   596x 2x                   1x 597x   1x   596x     596x   596x   596x 2x     596x 1x   596x               1x 614x 18x   614x 597x   614x                 1x 1455x   1455x       1294x 1294x   448x   846x   86x 86x     760x 760x     760x     1455x 1455x                     1x                     1x                         1x 1792x    
/*!
 * VisualEditor EventSequencer class.
 *
 * @copyright See AUTHORS.txt
 */
 
/**
 * EventSequencer class with on-event and after-event listeners.
 *
 * After-event listeners are fired as soon as possible after the
 * corresponding native event. They are similar to the setTimeout(f, 0)
 * idiom, except that they are guaranteed to execute before any subsequent
 * on-event listener. Therefore, events are executed in the 'right order'.
 *
 * This matters when many events are added to the task queue in one go.
 * For instance, browsers often queue 'keydown' and 'keypress' in immediate
 * sequence, so a setTimeout(f, 0) defined in the keydown listener will run
 * **after** the keypress listener (i.e. in the 'wrong' order). EventSequencer
 * ensures that this does not happen.
 *
 * All these listeners receive the jQuery event as an argument. If an on-event
 * listener needs to pass information to a corresponding after-event listener,
 * it can do so by adding properties into the jQuery event itself.
 *
 * There are also 'onLoop' and 'afterLoop' listeners, which only fire once per
 * Javascript event loop iteration, respectively before and after all the
 * other listeners fire.
 *
 * There is special handling for sequences (keydown,keypress), where the
 * keypress handler is called before the native keydown action happens. In
 * this case, after-keydown handlers fire after on-keypress handlers.
 *
 * For further event loop / task queue information, see:
 * http://www.whatwg.org/specs/web-apps/current-work/multipage/webappapis.html#event-loops
 *
 * @class ve.EventSequencer
 */
 
/**
 * To fire after-event listeners promptly, the EventSequencer may need to
 * listen to some events for which it has no registered on-event or
 * after-event listeners. For instance, to ensure an after-keydown listener
 * is be fired before the native keyup action, you must include both
 * 'keydown' and 'keyup' in the eventNames Array.
 *
 * @constructor
 * @param {string[]} eventNames List of event Names to listen to
 */
ve.EventSequencer = function VeEventSequencer( eventNames ) {
	this.$node = null;
	this.eventNames = eventNames;
	this.eventHandlers = {};
 
	/**
	 * Generate an event handler for a specific event
	 *
	 * @private
	 * @param {string} name The event's name
	 * @return {Function} An event handler
	 */
	const makeEventHandler = ( name ) => ( ev ) => this.onEvent( name, ev );
 
	/**
	 * @property {Object[]} Pending calls
	 *  - id {number} Id for setTimeout
	 *  - func {Function} Post-event listener
	 *  - ev {jQuery.Event} Browser event
	 *  - eventName {string} Name, such as keydown
	 */
	this.pendingCalls = [];
 
	/**
	 * @property {Object.<string,Function[]>}
	 */
	this.onListenersForEvent = {};
 
	/**
	 * @property {Object.<string,Function[]>}
	 */
	this.afterListenersForEvent = {};
 
	/**
	 * @property {Object.<string,Function[]>}
	 */
	this.afterOneListenersForEvent = {};
 
	for ( let i = 0, len = eventNames.length; i < len; i++ ) {
		const eventName = eventNames[ i ];
		this.onListenersForEvent[ eventName ] = [];
		this.afterListenersForEvent[ eventName ] = [];
		this.afterOneListenersForEvent[ eventName ] = [];
		this.eventHandlers[ eventName ] = makeEventHandler( eventName );
	}
 
	/**
	 * @property {Function[]}
	 */
	this.onLoopListeners = [];
 
	/**
	 * @property {Function[]}
	 */
	this.afterLoopListeners = [];
 
	/**
	 * @property {Function[]}
	 */
	this.afterLoopOneListeners = [];
 
	/**
	 * @property {boolean}
	 */
	this.doneOnLoop = false;
 
	/**
	 * @property {number}
	 */
	this.afterLoopTimeoutId = null;
};
 
/**
 * Attach to a node, to listen to its jQuery events
 *
 * @param {jQuery} $node The node to attach to
 * @return {ve.EventSequencer}
 * @chainable
 */
ve.EventSequencer.prototype.attach = function ( $node ) {
	this.detach();
	this.$node = $node.on( this.eventHandlers );
	return this;
};
 
// eslint-disable-next-line jsdoc/require-returns-check
/**
 * Detach from a node (if attached), to stop listen to its jQuery events
 *
 * @return {ve.EventSequencer}
 * @chainable
 */
ve.EventSequencer.prototype.detach = function () {
	if ( this.$node === null ) {
		return;
	}
	this.runPendingCalls();
	this.$node.off( this.eventHandlers );
	this.$node = null;
	return this;
};
 
/**
 * Add listeners to be fired at the start of the Javascript event loop iteration
 *
 * @param {Function|Function[]} listeners Listener(s) that take no arguments
 * @return {ve.EventSequencer}
 * @chainable
 */
ve.EventSequencer.prototype.onLoop = function ( listeners ) {
	Eif ( !Array.isArray( listeners ) ) {
		listeners = [ listeners ];
	}
	ve.batchPush( this.onLoopListeners, listeners );
	return this;
};
 
/**
 * Add listeners to be fired just before the browser native action
 *
 * @param {Object.<string,Function>} listeners Function for each event
 * @return {ve.EventSequencer}
 * @chainable
 */
ve.EventSequencer.prototype.on = function ( listeners ) {
	for ( const eventName in listeners ) {
		this.onListenersForEvent[ eventName ].push( listeners[ eventName ] );
	}
	return this;
};
 
/**
 * Add listeners to be fired as soon as possible after the native action
 *
 * @param {Object.<string,Function>} listeners Function for each event
 * @return {ve.EventSequencer}
 * @chainable
 */
ve.EventSequencer.prototype.after = function ( listeners ) {
	for ( const eventName in listeners ) {
		this.afterListenersForEvent[ eventName ].push( listeners[ eventName ] );
	}
	return this;
};
 
/**
 * Add listeners to be fired once, as soon as possible after the native action
 *
 * @param {Object.<string,Function[]>} listeners Function for each event
 * @return {ve.EventSequencer}
 * @chainable
 */
ve.EventSequencer.prototype.afterOne = function ( listeners ) {
	for ( const eventName in listeners ) {
		this.afterOneListenersForEvent[ eventName ].push( listeners[ eventName ] );
	}
	return this;
};
 
/**
 * Add listeners to be fired at the end of the Javascript event loop iteration
 *
 * @param {Function|Function[]} listeners Listener(s) that take no arguments
 * @return {ve.EventSequencer}
 * @chainable
 */
ve.EventSequencer.prototype.afterLoop = function ( listeners ) {
	Eif ( !Array.isArray( listeners ) ) {
		listeners = [ listeners ];
	}
	ve.batchPush( this.afterLoopListeners, listeners );
	return this;
};
 
/**
 * Add listeners to be fired once, at the end of the Javascript event loop iteration
 *
 * @param {Function|Function[]} listeners Listener(s) that take no arguments
 * @return {ve.EventSequencer}
 * @chainable
 */
ve.EventSequencer.prototype.afterLoopOne = function ( listeners ) {
	Eif ( !Array.isArray( listeners ) ) {
		listeners = [ listeners ];
	}
	ve.batchPush( this.afterLoopOneListeners, listeners );
	return this;
};
 
/**
 * Generic listener method which does the sequencing
 *
 * @private
 * @param {string} eventName Javascript name of the event, e.g. 'keydown'
 * @param {jQuery.Event} ev The browser event
 */
ve.EventSequencer.prototype.onEvent = function ( eventName, ev ) {
	this.runPendingCalls( eventName );
	if ( !this.doneOnLoop ) {
		this.doneOnLoop = true;
		this.doOnLoop();
	}
 
	// Listener list: take snapshot (for immutability if a listener adds another listener)
	const onListeners = ( this.onListenersForEvent[ eventName ] || [] ).slice();
 
	for ( let i = 0, len = onListeners.length; i < len; i++ ) {
		const onListener = onListeners[ i ];
		this.callListener( 'on', eventName, i, onListener, ev );
	}
	// Create a cancellable pending call. We need one even if there are no after*Listeners, to
	// call resetAfterLoopTimeout which resets doneOneLoop to false.
	// - Create the pendingCall object first
	// - then create the setTimeout invocation to modify pendingCall.id
	// - then set pendingCall.id to the setTimeout id, so the call can cancel itself
	const pendingCall = { id: null, ev: ev, eventName: eventName };
	const id = this.postpone( () => {
		if ( pendingCall.id === null ) {
			// clearTimeout seems not always to work immediately
			return;
		}
		this.resetAfterLoopTimeout();
		pendingCall.id = null;
		this.afterEvent( eventName, ev );
	} );
	pendingCall.id = id;
	this.pendingCalls.push( pendingCall );
};
 
/**
 * Generic after listener method which gets queued
 *
 * @private
 * @param {string} eventName Javascript name of the event, e.g. 'keydown'
 * @param {jQuery.Event} ev The browser event
 */
ve.EventSequencer.prototype.afterEvent = function ( eventName, ev ) {
	// Listener list: take snapshot (for immutability if a listener adds another listener)
	const afterListeners = ( this.afterListenersForEvent[ eventName ] || [] ).slice();
	// One-time listener list: take snapshot (for immutability) and blank the list
	const afterOneListeners = ( this.afterOneListenersForEvent[ eventName ] || [] ).splice( 0 );
 
	for ( let i = 0, len = afterListeners.length; i < len; i++ ) {
		this.callListener( 'after', eventName, i, afterListeners[ i ], ev );
	}
 
	for ( let i = 0, len = afterOneListeners.length; i < len; i++ ) {
		this.callListener( 'afterOne', eventName, i, afterOneListeners[ i ], ev );
	}
};
 
/**
 * Call each onLoopListener once
 *
 * @private
 */
ve.EventSequencer.prototype.doOnLoop = function () {
	// Length cache 'len' is required, as the functions called may add another listener
	for ( let i = 0, len = this.onLoopListeners.length; i < len; i++ ) {
		this.callListener( 'onLoop', null, i, this.onLoopListeners[ i ], null );
	}
};
 
/**
 * Call each afterLoopListener once, unless the setTimeout is already cancelled
 *
 * @private
 * @param {number} myTimeoutId The calling setTimeout id
 */
ve.EventSequencer.prototype.doAfterLoop = function ( myTimeoutId ) {
	if ( this.afterLoopTimeoutId !== myTimeoutId ) {
		// Cancelled; do nothing
		return;
	}
	this.afterLoopTimeoutId = null;
 
	// Loop listener list: take snapshot (for immutability if a listener adds another listener)
	const afterLoopListeners = this.afterLoopListeners.slice();
	// One-time loop listener list: take snapshot (for immutability) and blank the list
	const afterLoopOneListeners = this.afterLoopOneListeners.splice( 0 );
 
	for ( let i = 0, len = afterLoopListeners.length; i < len; i++ ) {
		this.callListener( 'afterLoop', null, i, this.afterLoopListeners[ i ], null );
	}
 
	for ( let i = 0, len = afterLoopOneListeners.length; i < len; i++ ) {
		this.callListener( 'afterLoopOne', null, i, afterLoopOneListeners[ i ], null );
	}
	this.doneOnLoop = false;
};
 
/**
 * Push any pending doAfterLoop to end of task queue (cancel, then re-set)
 *
 * @private
 */
ve.EventSequencer.prototype.resetAfterLoopTimeout = function () {
	if ( this.afterLoopTimeoutId !== null ) {
		this.cancelPostponed( this.afterLoopTimeoutId );
	}
	const timeoutId = this.postpone( () => {
		this.doAfterLoop( timeoutId );
	} );
	this.afterLoopTimeoutId = timeoutId;
};
 
/**
 * Run any pending listeners, and clear the pending queue
 *
 * @private
 * @param {string} eventName The name of the event currently being triggered
 */
ve.EventSequencer.prototype.runPendingCalls = function ( eventName ) {
	const afterKeyDownCalls = [];
 
	for ( let i = 0; i < this.pendingCalls.length; i++ ) {
		// Length cache not possible, as a pending call appends another pending call.
		// It's important that this list remains mutable, in the case that this
		// function indirectly recurses.
		const pendingCall = this.pendingCalls[ i ];
		if ( pendingCall.id === null ) {
			// The call has already run
			continue;
		}
		if ( eventName === 'keypress' && pendingCall.eventName === 'keydown' ) {
			// Delay afterKeyDown till after keypress
			afterKeyDownCalls.push( pendingCall );
			continue;
		}
 
		this.cancelPostponed( pendingCall.id );
		pendingCall.id = null;
		// Force to run now. It's important that we set id to null before running,
		// so that there's no chance a recursive call will call the listener again.
		this.afterEvent( pendingCall.eventName, pendingCall.ev );
	}
	// This is safe: we only ever appended to the list, so it's definitely exhausted now.
	this.pendingCalls.length = 0;
	this.pendingCalls.push.apply( this.pendingCalls, afterKeyDownCalls );
};
 
/**
 * Make a postponed call.
 *
 * This is a separate function because that makes it easier to replace when testing
 *
 * @param {Function} callback The function to call
 * @return {number} Unique postponed timeout id
 */
ve.EventSequencer.prototype.postpone = function ( callback ) {
	return setTimeout( callback );
};
 
/**
 * Cancel a postponed call.
 *
 * This is a separate function because that makes it easier to replace when testing
 *
 * @param {number} timeoutId Unique postponed timeout id
 */
ve.EventSequencer.prototype.cancelPostponed = function ( timeoutId ) {
	clearTimeout( timeoutId );
};
 
/**
 * Single method to perform all listener calls, for ease of debugging
 *
 * @param {string} timing on|after|afterOne|onLoop|afterLoop|afterLoopOne
 * @param {string} eventName Name of the event
 * @param {number} i The sequence of the listener
 * @param {Function} listener The listener to call
 * @param {jQuery.Event} ev The browser event
 */
ve.EventSequencer.prototype.callListener = function ( timing, eventName, i, listener, ev ) {
	listener( ev );
};