Przeglądaj źródła

Implemented EmitterMixin#delegate and EmitterMixin#stopDelegating methods.

Aleksander Nowodzinski 9 lat temu
rodzic
commit
248ec85f25

+ 112 - 25
packages/ckeditor5-utils/src/emittermixin.js

@@ -227,44 +227,131 @@ const EmitterMixin = {
 	 * The first parameter passed to callbacks is an {@link EventInfo} object, followed by the optional `args` provided in
 	 * the `fire()` method call.
 	 *
-	 * @param {String} event The name of the event.
+	 * @param {String|utils.EventInfo} eventOrInfo The name of the event or `EventInfo` object if event is delegated.
 	 * @param {...*} [args] Additional arguments to be passed to the callbacks.
 	 * @method utils.EmitterMixin#fire
 	 */
-	fire( event, args ) {
+	fire( eventOrInfo, ...args ) {
+		const eventInfo = eventOrInfo instanceof EventInfo ? eventOrInfo : new EventInfo( this, eventOrInfo );
+		const event = eventInfo.name;
 		let callbacks = getCallbacksForEvent( this, event );
 
-		if ( !callbacks ) {
-			return;
-		}
+		// Record that the event passed this emitter on its path.
+		eventInfo.path.push( this );
+
+		// Handle event listener callbacks first.
+		if ( callbacks ) {
+			// Arguments passed to each callback.
+			const callbackArgs = [ eventInfo, ...args ];
+
+			// Copying callbacks array is the easiest and most secure way of preventing infinite loops, when event callbacks
+			// are added while processing other callbacks. Previous solution involved adding counters (unique ids) but
+			// failed if callbacks were added to the queue before currently processed callback.
+			// If this proves to be too inefficient, another method is to change `.on()` so callbacks are stored if same
+			// event is currently processed. Then, `.fire()` at the end, would have to add all stored events.
+			callbacks = Array.from( callbacks );
+
+			for ( let i = 0; i < callbacks.length; i++ ) {
+				callbacks[ i ].callback.apply( callbacks[ i ].ctx, callbackArgs );
+
+				// Remove the callback from future requests if off() has been called.
+				if ( eventInfo.off.called ) {
+					// Remove the called mark for the next calls.
+					delete eventInfo.off.called;
+
+					this.off( event, callbacks[ i ].callback, callbacks[ i ].ctx );
+				}
 
-		// Copying callbacks array is the easiest and most secure way of preventing infinite loops, when event callbacks
-		// are added while processing other callbacks. Previous solution involved adding counters (unique ids) but
-		// failed if callbacks were added to the queue before currently processed callback.
-		// If this proves to be too inefficient, another method is to change `.on()` so callbacks are stored if same
-		// event is currently processed. Then, `.fire()` at the end, would have to add all stored events.
-		callbacks = Array.from( callbacks );
+				// Do not execute next callbacks if stop() was called.
+				if ( eventInfo.stop.called ) {
+					break;
+				}
+			}
+		}
 
-		let eventInfo = new EventInfo( this, event );
+		// Delegate event to other emitters if needed.
+		if ( this._delegations ) {
+			const destinations = this._delegations.get( event );
 
-		// Take the list of arguments to pass to the callbacks.
-		args = Array.prototype.slice.call( arguments, 1 );
-		args.unshift( eventInfo );
+			if ( destinations ) {
+				for ( let dest of destinations ) {
+					dest.fire( eventInfo, ...args );
+				}
+			}
+		}
+	},
 
-		for ( let i = 0; i < callbacks.length; i++ ) {
-			callbacks[ i ].callback.apply( callbacks[ i ].ctx, args );
+	/**
+	 * Delegates selected events to another {@link utils.Emitter}. For instance:
+	 *
+	 *		emitterA.delegate( 'eventX' ).to( emitterB );
+	 *		emitterA.delegate( 'eventX', 'eventY' ).to( emitterC );
+	 *
+	 * then `eventX` is delegated (fired by) `emitterB` and `emitterC` along with `data`:
+	 *
+	 *		emitterA.fire( 'eventX', data );
+	 *
+	 * and `eventY` is delegated (fired by) `emitterC` along with `data`:
+	 *
+	 *		emitterA.fire( 'eventY', data );
+	 *
+	 * @method utils.EmitterMixin#delegate
+	 * @param {String...} events Event names that will be delegated to another emitter.
+	 * @returns {utils.EmitterMixin.delegate#to}
+	 */
+	delegate( ...events ) {
+		return {
+			/**
+			 * Selects destination for {@link utils.EmitterMixin#delegate} events.
+			 *
+			 * @method utils.EmitterMixin.delegate#to
+			 * @param {utils.Emitter} emitter An `EmitterMixin` instance which is the destination for delegated events.
+			 */
+			to: ( emitter ) => {
+				if ( !this._delegations ) {
+					this._delegations = new Map();
+				}
 
-			// Remove the callback from future requests if off() has been called.
-			if ( eventInfo.off.called ) {
-				// Remove the called mark for the next calls.
-				delete eventInfo.off.called;
+				for ( let eventName of events ) {
+					let destinations = this._delegations.get( eventName );
 
-				this.off( event, callbacks[ i ].callback, callbacks[ i ].ctx );
+					if ( !destinations ) {
+						this._delegations.set( eventName, [ emitter ] );
+					} else {
+						destinations.push( emitter );
+					}
+				}
 			}
+		};
+	},
+
+	/**
+	 * Stops delegating events. It can be used at different levels:
+	 *
+	 * * To stop delegating all events.
+	 * * To stop delegating a specific event to all emitters.
+	 * * To stop delegating a specific event to a specific emitter.
+	 *
+	 * @param {String} [event] The name of the event to stop delegating. If omitted, stops it all delegations.
+	 * @param {utils.Emitter} [emitter] (requires `event`) The object to stop delegating a particular event to. If omitted,
+	 * stops delegation of `event` to all emitters.
+	 * @method utils.EmitterMixin#stopDelegating
+	 */
+	stopDelegating( event, emitter ) {
+		if ( !this._delegations ) {
+			return;
+		}
+
+		if ( !event ) {
+			this._delegations.clear();
+		} else if ( !emitter ) {
+			this._delegations.delete( event );
+		} else {
+			const destinations = this._delegations.get( event );
+			const index = destinations.indexOf( emitter );
 
-			// Do not execute next callbacks if stop() was called.
-			if ( eventInfo.stop.called ) {
-				break;
+			if ( index !== -1 ) {
+				destinations.splice( index, 1 );
 			}
 		}
 	}

+ 237 - 0
packages/ckeditor5-utils/tests/emittermixin.js

@@ -526,6 +526,234 @@ describe( 'stopListening', () => {
 	} );
 } );
 
+describe( 'delegate', () => {
+	it( 'should chain for a single event', () => {
+		const emitter = getEmitterInstance();
+
+		expect( emitter.delegate( 'foo' ) ).to.contain.keys( 'to' );
+	} );
+
+	it( 'should chain for multiple events', () => {
+		const emitter = getEmitterInstance();
+
+		expect( emitter.delegate( 'foo', 'bar' ) ).to.contain.keys( 'to' );
+	} );
+
+	describe( 'to', () => {
+		it( 'forwards an event to another emitter', ( done ) => {
+			const emitterA = getEmitterInstance();
+			const emitterB = getEmitterInstance();
+			const dataA = {};
+			const dataB = {};
+
+			emitterB.delegate( 'foo' ).to( emitterA );
+
+			emitterA.on( 'foo', ( ...args ) => {
+				assertDelegated( args, {
+					expectedSource: emitterB,
+					expectedName: 'foo',
+					expectedPath: [ emitterB, emitterA ],
+					expectedData: [ dataA, dataB ]
+				} );
+
+				done();
+			} );
+
+			emitterB.fire( 'foo', dataA, dataB );
+		} );
+
+		it( 'forwards multiple events to another emitter', () => {
+			const emitterA = getEmitterInstance();
+			const emitterB = getEmitterInstance();
+			const dataA = {};
+			const dataB = {};
+
+			emitterB.delegate( 'foo', 'bar', 'baz' ).to( emitterA );
+
+			const evts = [];
+
+			function recordEvent( ...args ) {
+				evts.push( args );
+			}
+
+			emitterA.on( 'foo', recordEvent );
+			emitterA.on( 'bar', recordEvent );
+			emitterA.on( 'baz', recordEvent );
+
+			emitterB.fire( 'foo', dataA, dataB );
+
+			expect( evts ).to.have.length( 1 );
+			assertDelegated( evts[ 0 ], {
+				expectedSource: emitterB,
+				expectedName: 'foo',
+				expectedPath: [ emitterB, emitterA ],
+				expectedData: [ dataA, dataB ]
+			} );
+
+			emitterB.fire( 'bar' );
+
+			expect( evts ).to.have.length( 2 );
+			assertDelegated( evts[ 1 ], {
+				expectedSource: emitterB,
+				expectedName: 'bar',
+				expectedPath: [ emitterB, emitterA ],
+				expectedData: []
+			} );
+
+			emitterB.fire( 'baz' );
+
+			expect( evts ).to.have.length( 3 );
+			assertDelegated( evts[ 2 ], {
+				expectedSource: emitterB,
+				expectedName: 'baz',
+				expectedPath: [ emitterB, emitterA ],
+				expectedData: []
+			} );
+
+			emitterB.fire( 'not-delegated' );
+			expect( evts ).to.have.length( 3 );
+		} );
+
+		it( 'does not forward events which are not supposed to be delegated', () => {
+			const emitterA = getEmitterInstance();
+			const emitterB = getEmitterInstance();
+
+			emitterB.delegate( 'foo', 'bar', 'baz' ).to( emitterA );
+
+			let fireLog = [];
+			emitterA.on( 'foo', () => fireLog.push( 'A#foo' ) );
+			emitterA.on( 'bar', () => fireLog.push( 'A#bar' ) );
+			emitterA.on( 'baz', () => fireLog.push( 'A#baz' ) );
+
+			emitterB.fire( 'foo' );
+			emitterB.fire( 'bar' );
+			emitterB.fire( 'baz' );
+			emitterB.fire( 'not-delegated' );
+
+			expect( fireLog ).to.deep.equal( [ 'A#foo', 'A#bar', 'A#baz' ] );
+		} );
+
+		it( 'supports deep chain event delegation', ( done ) => {
+			const emitterA = getEmitterInstance();
+			const emitterB = getEmitterInstance();
+			const emitterC = getEmitterInstance();
+			const data = {};
+
+			emitterC.delegate( 'foo' ).to( emitterB );
+			emitterB.delegate( 'foo' ).to( emitterA );
+
+			emitterA.on( 'foo', ( ...args ) => {
+				assertDelegated( args, {
+					expectedSource: emitterC,
+					expectedName: 'foo',
+					expectedPath: [ emitterC, emitterB, emitterA ],
+					expectedData: [ data ]
+				} );
+
+				done();
+			} );
+
+			emitterC.fire( 'foo', data );
+		} );
+
+		it( 'executes callbacks first, then delegates further', ( done ) => {
+			const emitterA = getEmitterInstance();
+			const emitterB = getEmitterInstance();
+			const callOrder = [];
+
+			emitterB.delegate( 'foo' ).to( emitterA );
+
+			emitterA.on( 'foo', () => {
+				callOrder.push( 'emitterA' );
+				expect( callOrder ).to.deep.equal( [ 'emitterB', 'emitterA' ] );
+				done();
+			} );
+
+			emitterB.on( 'foo', () => {
+				callOrder.push( 'emitterB' );
+			} );
+
+			emitterB.fire( 'foo' );
+		} );
+	} );
+} );
+
+describe( 'stopDelegating', () => {
+	it( 'stops delegating all events to all emitters', () => {
+		const emitterA = getEmitterInstance();
+		const emitterB = getEmitterInstance();
+		const emitterC = getEmitterInstance();
+
+		emitterA.delegate( 'foo' ).to( emitterB );
+		emitterA.delegate( 'bar' ).to( emitterC );
+
+		let fireLog = [];
+		emitterB.on( 'foo', () => fireLog.push( 'B#foo' ) );
+		emitterC.on( 'bar', () => fireLog.push( 'C#bar' ) );
+
+		emitterA.fire( 'foo' );
+		emitterA.fire( 'bar' );
+
+		expect( fireLog ).to.deep.equal( [ 'B#foo', 'C#bar' ] );
+
+		emitterA.stopDelegating();
+
+		emitterA.fire( 'foo' );
+		emitterA.fire( 'bar' );
+
+		expect( fireLog ).to.deep.equal( [ 'B#foo', 'C#bar' ] );
+	} );
+
+	it( 'stops delegating a specific event to all emitters', () => {
+		const emitterA = getEmitterInstance();
+		const emitterB = getEmitterInstance();
+		const emitterC = getEmitterInstance();
+
+		emitterA.delegate( 'foo' ).to( emitterB );
+		emitterA.delegate( 'foo' ).to( emitterC );
+		emitterA.delegate( 'bar' ).to( emitterC );
+
+		let fireLog = [];
+		emitterB.on( 'foo', () => fireLog.push( 'B#foo' ) );
+		emitterC.on( 'foo', () => fireLog.push( 'C#foo' ) );
+		emitterC.on( 'bar', () => fireLog.push( 'C#bar' ) );
+
+		emitterA.fire( 'foo' );
+		emitterA.fire( 'bar' );
+
+		expect( fireLog ).to.deep.equal( [ 'B#foo', 'C#foo', 'C#bar' ] );
+
+		emitterA.stopDelegating( 'foo' );
+
+		emitterA.fire( 'foo' );
+		emitterA.fire( 'bar' );
+
+		expect( fireLog ).to.deep.equal( [ 'B#foo', 'C#foo', 'C#bar', 'C#bar' ] );
+	} );
+
+	it( 'stops delegating a specific event to a specific emitter', () => {
+		const emitterA = getEmitterInstance();
+		const emitterB = getEmitterInstance();
+		const emitterC = getEmitterInstance();
+
+		emitterA.delegate( 'foo' ).to( emitterB );
+		emitterA.delegate( 'foo' ).to( emitterC );
+
+		let fireLog = [];
+		emitterB.on( 'foo', () => fireLog.push( 'B#foo' ) );
+		emitterC.on( 'foo', () => fireLog.push( 'C#foo' ) );
+
+		emitterA.fire( 'foo' );
+
+		expect( fireLog ).to.deep.equal( [ 'B#foo', 'C#foo' ] );
+
+		emitterA.stopDelegating( 'foo', emitterC );
+		emitterA.fire( 'foo' );
+
+		expect( fireLog ).to.deep.equal( [ 'B#foo', 'C#foo', 'B#foo' ] );
+	} );
+} );
+
 function refreshEmitter() {
 	emitter = getEmitterInstance();
 }
@@ -537,3 +765,12 @@ function refreshListener() {
 function getEmitterInstance() {
 	return Object.create( EmitterMixin );
 }
+
+function assertDelegated( evtArgs, { expectedName, expectedSource, expectedPath, expectedData } ) {
+	const evtInfo = evtArgs[ 0 ];
+
+	expect( evtInfo.name ).to.equal( expectedName );
+	expect( evtInfo.source ).to.equal( expectedSource );
+	expect( evtInfo.path ).to.deep.equal( expectedPath );
+	expect( evtArgs.slice( 1 ) ).to.deep.equal( expectedData );
+}