8
0
Просмотр исходного кода

Merge pull request #59 from ckeditor/t/50b

t/50b: Implement event delegation mechanism
Szymon Cofalik 9 лет назад
Родитель
Сommit
6d1e1b3254

+ 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 );
 			}
 		}
 	}

+ 7 - 0
packages/ckeditor5-utils/src/eventinfo.js

@@ -27,6 +27,13 @@ export default class EventInfo {
 		 */
 		this.name = name;
 
+		/**
+		 * Path this event has followed. See {@link utils.EmitterMixin#delegate}.
+		 *
+		 * @member utils.EventInfo#path
+		 */
+		this.path = [];
+
 		// The following methods are defined in the constructor because they must be re-created per instance.
 
 		/**

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

@@ -526,6 +526,266 @@ 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 spyFoo = sinon.spy();
+			const spyBar = sinon.spy();
+			const spyBaz = sinon.spy();
+			const dataA = {};
+			const dataB = {};
+
+			emitterB.delegate( 'foo', 'bar', 'baz' ).to( emitterA );
+
+			emitterA.on( 'foo', spyFoo );
+			emitterA.on( 'bar', spyBar );
+			emitterA.on( 'baz', spyBaz );
+
+			emitterB.fire( 'foo', dataA, dataB );
+
+			sinon.assert.calledOnce( spyFoo );
+			sinon.assert.notCalled( spyBar );
+			sinon.assert.notCalled( spyBaz );
+
+			assertDelegated( spyFoo.args[ 0 ], {
+				expectedSource: emitterB,
+				expectedName: 'foo',
+				expectedPath: [ emitterB, emitterA ],
+				expectedData: [ dataA, dataB ]
+			} );
+
+			emitterB.fire( 'bar' );
+
+			sinon.assert.calledOnce( spyFoo );
+			sinon.assert.calledOnce( spyBar );
+			sinon.assert.notCalled( spyBaz );
+
+			assertDelegated( spyBar.args[ 0 ], {
+				expectedSource: emitterB,
+				expectedName: 'bar',
+				expectedPath: [ emitterB, emitterA ],
+				expectedData: []
+			} );
+
+			emitterB.fire( 'baz' );
+
+			sinon.assert.calledOnce( spyFoo );
+			sinon.assert.calledOnce( spyBar );
+			sinon.assert.calledOnce( spyBaz );
+
+			assertDelegated( spyBaz.args[ 0 ], {
+				expectedSource: emitterB,
+				expectedName: 'baz',
+				expectedPath: [ emitterB, emitterA ],
+				expectedData: []
+			} );
+
+			emitterB.fire( 'not-delegated' );
+
+			sinon.assert.calledOnce( spyFoo );
+			sinon.assert.calledOnce( spyBar );
+			sinon.assert.calledOnce( spyBaz );
+		} );
+
+		it( 'does not forward events which are not supposed to be delegated', () => {
+			const emitterA = getEmitterInstance();
+			const emitterB = getEmitterInstance();
+			const spyFoo = sinon.spy();
+			const spyBar = sinon.spy();
+			const spyBaz = sinon.spy();
+
+			emitterB.delegate( 'foo', 'bar', 'baz' ).to( emitterA );
+
+			emitterA.on( 'foo', spyFoo );
+			emitterA.on( 'bar', spyBar );
+			emitterA.on( 'baz', spyBaz );
+
+			emitterB.fire( 'foo' );
+			emitterB.fire( 'bar' );
+			emitterB.fire( 'baz' );
+			emitterB.fire( 'not-delegated' );
+
+			sinon.assert.callOrder( spyFoo, spyBar, spyBaz );
+			sinon.assert.callCount( spyFoo, 1 );
+			sinon.assert.callCount( spyBar, 1 );
+			sinon.assert.callCount( spyBaz, 1 );
+		} );
+
+		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', () => {
+			const emitterA = getEmitterInstance();
+			const emitterB = getEmitterInstance();
+			const spyA = sinon.spy();
+			const spyB = sinon.spy();
+
+			emitterB.delegate( 'foo' ).to( emitterA );
+
+			emitterA.on( 'foo', spyA );
+			emitterB.on( 'foo', spyB );
+
+			emitterB.fire( 'foo' );
+
+			sinon.assert.callOrder( spyB, spyA );
+		} );
+	} );
+} );
+
+describe( 'stopDelegating', () => {
+	it( 'passes if no delegation was set', () => {
+		expect( () => {
+			getEmitterInstance().stopDelegating();
+		} ).to.not.throw();
+	} );
+
+	it( 'stops delegating all events to all emitters', () => {
+		const emitterA = getEmitterInstance();
+		const emitterB = getEmitterInstance();
+		const emitterC = getEmitterInstance();
+		const spyFoo = sinon.spy();
+		const spyBar = sinon.spy();
+
+		emitterA.delegate( 'foo' ).to( emitterB );
+		emitterA.delegate( 'bar' ).to( emitterC );
+
+		emitterB.on( 'foo', spyFoo );
+		emitterC.on( 'bar', spyBar );
+
+		emitterA.fire( 'foo' );
+		emitterA.fire( 'bar' );
+
+		sinon.assert.callOrder( spyFoo, spyBar );
+
+		emitterA.stopDelegating();
+
+		emitterA.fire( 'foo' );
+		emitterA.fire( 'bar' );
+
+		sinon.assert.callOrder( spyFoo, spyBar );
+	} );
+
+	it( 'stops delegating a specific event to all emitters', () => {
+		const emitterA = getEmitterInstance();
+		const emitterB = getEmitterInstance();
+		const emitterC = getEmitterInstance();
+		const spyFooB = sinon.spy();
+		const spyFooC = sinon.spy();
+		const spyBarC = sinon.spy();
+
+		emitterA.delegate( 'foo' ).to( emitterB );
+		emitterA.delegate( 'foo' ).to( emitterC );
+		emitterA.delegate( 'bar' ).to( emitterC );
+
+		emitterB.on( 'foo', spyFooB );
+		emitterC.on( 'foo', spyFooC );
+		emitterC.on( 'bar', spyBarC );
+
+		emitterA.fire( 'foo' );
+		emitterA.fire( 'bar' );
+
+		sinon.assert.callOrder( spyFooB, spyFooC, spyBarC );
+
+		emitterA.stopDelegating( 'foo' );
+
+		emitterA.fire( 'foo' );
+		emitterA.fire( 'bar' );
+
+		sinon.assert.callOrder( spyFooB, spyFooC, spyBarC, spyBarC );
+	} );
+
+	it( 'stops delegating a specific event to a specific emitter', () => {
+		const emitterA = getEmitterInstance();
+		const emitterB = getEmitterInstance();
+		const emitterC = getEmitterInstance();
+		const spyFooB = sinon.spy();
+		const spyFooC = sinon.spy();
+
+		emitterA.delegate( 'foo' ).to( emitterB );
+		emitterA.delegate( 'foo' ).to( emitterC );
+
+		emitterB.on( 'foo', spyFooB );
+		emitterC.on( 'foo', spyFooC );
+
+		emitterA.fire( 'foo' );
+
+		sinon.assert.callOrder( spyFooB, spyFooC );
+
+		emitterA.stopDelegating( 'foo', emitterC );
+		emitterA.fire( 'foo' );
+
+		sinon.assert.callOrder( spyFooB, spyFooC, spyFooB );
+	} );
+
+	it( 'passes when stopping delegation of a specific event to an emitter which wasn\'t a destination', () => {
+		const emitterA = getEmitterInstance();
+		const emitterB = getEmitterInstance();
+		const emitterC = getEmitterInstance();
+
+		emitterA.delegate( 'foo' ).to( emitterB );
+
+		expect( () => {
+			emitterA.stopDelegating( 'foo', emitterC );
+		} ).to.not.throw();
+	} );
+} );
+
 function refreshEmitter() {
 	emitter = getEmitterInstance();
 }
@@ -537,3 +797,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 );
+}

+ 1 - 0
packages/ckeditor5-utils/tests/eventinfo.js

@@ -11,6 +11,7 @@ describe( 'EventInfo', () => {
 
 		expect( event.source ).to.equal( this );
 		expect( event.name ).to.equal( 'test' );
+		expect( event.path ).to.deep.equal( [] );
 		expect( event.stop.called ).to.not.be.true;
 		expect( event.off.called ).to.not.be.true;
 	} );