8
0
Pārlūkot izejas kodu

Merge pull request #16 from ckeditor/t/9

Add event namespaces for EmitterMixin.
Piotr Jasiun 9 gadi atpakaļ
vecāks
revīzija
849ed0c3c5

+ 152 - 38
packages/ckeditor5-utils/src/emittermixin.js

@@ -20,7 +20,14 @@ let eventsCounter = 0;
  */
 const EmitterMixin = {
 	/**
-	 * Registers a callback function to be executed when an event is fired.
+	 * Registers a callback function to be executed when an event is fired. Events can be grouped in namespaces using `:`.
+	 * When namespaced event is fired, it additionaly fires all callbacks for that namespace.
+	 *
+	 *		myEmitter.on( 'myGroup', genericCallback );
+	 *		myEmitter.on( 'myGroup:myEvent', specificCallback );
+	 *		myEmitter.fire( 'myGroup' ); // genericCallback is fired.
+	 *		myEmitter.fire( 'myGroup:myEvent' ); // both genericCallback and specificCallback are fired.
+	 *		myEmitter.fire( 'myGroup:foo' ); // genericCallback is fired even though there are no callbacks for "foo".
 	 *
 	 * @param {String} event The name of the event.
 	 * @param {Function} callback The function to be called on event.
@@ -31,7 +38,8 @@ const EmitterMixin = {
 	 * @method utils.EmitterMixin#on
 	 */
 	on( event, callback, ctx, priority ) {
-		const callbacks = getCallbacks( this, event );
+		createEventNamespace( this, event );
+		const lists = getCallbacksListsForNamespace( this, event );
 
 		// Set the priority defaults.
 		if ( typeof priority != 'number' ) {
@@ -41,21 +49,31 @@ const EmitterMixin = {
 		callback = {
 			callback: callback,
 			ctx: ctx || this,
-			priority: priority,
-			// Save counter value as unique id.
-			counter: ++eventsCounter
+			priority: priority
 		};
 
-		// Add the callback to the list in the right priority position.
-		for ( let i = callbacks.length - 1; i >= 0; i-- ) {
-			if ( callbacks[ i ].priority <= priority ) {
-				callbacks.splice( i + 1, 0, callback );
+		// Add the callback to all callbacks list.
+		for ( let callbacks of lists ) {
+			// Save counter value as unique id.
+			callback.counter = ++eventsCounter;
+
+			// Add the callback to the list in the right priority position.
+			let added = false;
+
+			for ( let i = callbacks.length - 1; i >= 0; i-- ) {
+				if ( callbacks[ i ].priority <= priority ) {
+					callbacks.splice( i + 1, 0, callback );
+					added = true;
 
-				return;
+					break;
+				}
 			}
-		}
 
-		callbacks.unshift( callback );
+			// Add to the beginning if right place was not found.
+			if ( !added ) {
+				callbacks.unshift( callback );
+			}
+		}
 	},
 
 	/**
@@ -79,7 +97,7 @@ const EmitterMixin = {
 			callback.apply( this, arguments );
 		};
 
-		// Make the a similar on() call, simply replacing the callback.
+		// Make a similar on() call, simply replacing the callback.
 		this.on( event, onceCallback, ctx, priority );
 	},
 
@@ -93,18 +111,16 @@ const EmitterMixin = {
 	 * @method utils.EmitterMixin#off
 	 */
 	off( event, callback, ctx ) {
-		const callbacks = getCallbacksIfAny( this, event );
-
-		if ( !callbacks ) {
-			return;
-		}
-
-		for ( let i = 0; i < callbacks.length; i++ ) {
-			if ( callbacks[ i ].callback == callback ) {
-				if ( !ctx || ctx == callbacks[ i ].ctx ) {
-					// Remove the callback from the list (fixing the next index).
-					callbacks.splice( i, 1 );
-					i--;
+		const lists = getCallbacksListsForNamespace( this, event );
+
+		for ( let callbacks of lists ) {
+			for ( let i = 0; i < callbacks.length; i++ ) {
+				if ( callbacks[ i ].callback == callback ) {
+					if ( !ctx || ctx == callbacks[ i ].ctx ) {
+						// Remove the callback from the list (fixing the next index).
+						callbacks.splice( i, 1 );
+						i--;
+					}
 				}
 			}
 		}
@@ -227,7 +243,7 @@ const EmitterMixin = {
 	 * @method utils.EmitterMixin#fire
 	 */
 	fire( event, args ) {
-		const callbacks = getCallbacksIfAny( this, event );
+		const callbacks = getCallbacksForEvent( this, event );
 
 		if ( !callbacks ) {
 			return;
@@ -270,7 +286,9 @@ const EmitterMixin = {
 
 export default EmitterMixin;
 
-// Gets the internal events hash of a give object.
+// Gets the internal `_events` property of the given object.
+// `_events` property store all lists with callbacks for registered event names.
+// If there were no events registered on the object, empty `_events` object is created.
 function getEvents( source ) {
 	if ( !source._events ) {
 		Object.defineProperty( source, '_events', {
@@ -281,26 +299,122 @@ function getEvents( source ) {
 	return source._events;
 }
 
-// Gets the list of callbacks for a given event.
-function getCallbacks( source, eventName ) {
+// Creates event node for generic-specific events relation architecture.
+function makeEventNode() {
+	return {
+		callbacks: [],
+		childEvents: []
+	};
+}
+
+// Creates an architecture for generic-specific events relation.
+// If needed, creates all events for given eventName, i.e. if the first registered event
+// is foo:bar:abc, it will create foo:bar:abc, foo:bar and foo event and tie them together.
+// It also copies callbacks from more generic events to more specific events when
+// specific events are created.
+function createEventNamespace( source, eventName ) {
 	const events = getEvents( source );
 
-	if ( !events[ eventName ] ) {
-		events[ eventName ] = [];
+	// First, check if the event we want to add to the structure already exists.
+	if ( events[ eventName ] ) {
+		// If it exists, we don't have to do anything.
+		return;
 	}
 
-	return events[ eventName ];
+	// In other case, we have to create the structure for the event.
+	// Note, that we might need to create intermediate events too.
+	// I.e. if foo:bar:abc is being registered and we only have foo in the structure,
+	// we need to also register foo:bar.
+
+	// Currently processed event name.
+	let name = eventName;
+	// Name of the event that is a child event for currently processed event.
+	let childEventName = null;
+
+	// Array containing all newly created specific events.
+	const newEventNodes = [];
+
+	// While loop can't check for ':' index because we have to handle generic events too.
+	// In each loop, we truncate event name, going from the most specific name to the generic one.
+	// I.e. foo:bar:abc -> foo:bar -> foo.
+	while ( name !== '' ) {
+		if ( events[ name ] ) {
+			// If the currently processed event name is already registered, we can be sure
+			// that it already has all the structure created, so we can break the loop here
+			// as no more events need to be registered.
+			break;
+		}
+
+		// If this event is not yet registered, create a new object for it.
+		events[ name ] = makeEventNode();
+		// Add it to the array with newly created events.
+		newEventNodes.push( events[ name ] );
+
+		// Add previously processed event name as a child of this event.
+		if ( childEventName ) {
+			events[ name ].childEvents.push( childEventName );
+		}
+
+		childEventName = name;
+		// If `.lastIndexOf()` returns -1, `.substr()` will return '' which will break the loop.
+		name = name.substr( 0, name.lastIndexOf( ':' ) );
+	}
+
+	if ( name !== '' ) {
+		// If name is not empty, we found an already registered event that was a parent of the
+		// event we wanted to register.
+
+		// Copy that event's callbacks to newly registered events.
+		for ( let node of newEventNodes ) {
+			node.callbacks = events[ name ].callbacks.slice();
+		}
+
+		// Add last newly created event to the already registered event.
+		events[ name ].childEvents.push( childEventName );
+	}
 }
 
-// Get the list of callbacks for a given event only if there is any available.
-function getCallbacksIfAny( source, event ) {
-	let callbacks;
+// Gets an array containing callbacks list for a given event and it's more specific events.
+// I.e. if given event is foo:bar and there is also foo:bar:abc event registered, this will
+// return callback list of foo:bar and foo:bar:abc (but not foo).
+// Returns empty array if given event has not been yet registered.
+function getCallbacksListsForNamespace( source, eventName ) {
+	const eventNode = getEvents( source )[ eventName ];
 
-	if ( !source._events || !( callbacks = source._events[ event ] ) || !callbacks.length ) {
-		return null;
+	if ( !eventNode ) {
+		return [];
+	}
+
+	let callbacksLists = [ eventNode.callbacks ];
+
+	for ( let i = 0; i < eventNode.childEvents.length; i++ ) {
+		let childCallbacksLists = getCallbacksListsForNamespace( source, eventNode.childEvents[ i ] );
+
+		callbacksLists = callbacksLists.concat( childCallbacksLists );
+	}
+
+	return callbacksLists;
+}
+
+// Get the list of callbacks for a given event, but only if there any callbacks have been registered.
+// If there are no callbacks registered for given event, it checks if this is a specific event and looks
+// for callbacks for it's more generic version.
+function getCallbacksForEvent( source, eventName ) {
+	let event;
+
+	if ( !source._events || !( event = source._events[ eventName ] ) || !event.callbacks.length ) {
+		// There are no callbacks registered for specified eventName.
+		// But this could be a specific-type event that is in a namespace.
+		if ( eventName.indexOf( ':' ) > -1 ) {
+			// If the eventName is specific, try to find callback lists for more generic event.
+			return getCallbacksForEvent( source, eventName.substr( 0, eventName.indexOf( ':' ) ) );
+		} else {
+			// If this is a top-level generic event, return null;
+			return null;
+		}
 	}
 
-	return callbacks;
+	return event.callbacks;
 }
 
 /**

+ 1 - 2
packages/ckeditor5-utils/src/observablemixin.js

@@ -85,8 +85,7 @@ const ObservableMixin = {
 				// Note: When attributes map has no such own property, then its value is undefined.
 				if ( oldValue !== value || !attributes.has( name ) ) {
 					attributes.set( name, value );
-					this.fire( 'change', name, value, oldValue );
-					this.fire( 'change:' + name, value, oldValue );
+					this.fire( 'change:' + name, name, value, oldValue );
 				}
 			}
 		} );

+ 113 - 4
packages/ckeditor5-utils/tests/emittermixin.js

@@ -130,6 +130,47 @@ describe( 'fire', () => {
 
 		sinon.assert.notCalled( spy );
 	} );
+
+	it( 'should correctly fire callbacks for namespaced events', () => {
+		let spyFoo = sinon.spy();
+		let spyBar = sinon.spy();
+		let spyAbc = sinon.spy();
+		let spyFoo2 = sinon.spy();
+
+		// Mess up with callbacks order to check whether they are called in adding order.
+		emitter.on( 'foo', spyFoo );
+		emitter.on( 'foo:bar:abc', spyAbc );
+		emitter.on( 'foo:bar', spyBar );
+
+		// This tests whether generic callbacks are also added to specific callbacks lists.
+		emitter.on( 'foo', spyFoo2 );
+
+		// All four callbacks should be fired.
+		emitter.fire( 'foo:bar:abc' );
+
+		sinon.assert.callOrder( spyFoo, spyAbc, spyBar, spyFoo2 );
+		sinon.assert.calledOnce( spyFoo );
+		sinon.assert.calledOnce( spyAbc );
+		sinon.assert.calledOnce( spyBar );
+		sinon.assert.calledOnce( spyFoo2 );
+
+		// Only callbacks for foo and foo:bar event should be called.
+		emitter.fire( 'foo:bar' );
+
+		sinon.assert.calledOnce( spyAbc );
+		sinon.assert.calledTwice( spyFoo );
+		sinon.assert.calledTwice( spyBar );
+		sinon.assert.calledTwice( spyFoo2 );
+
+		// Only callback for foo should be called as foo:abc has not been registered.
+		// Still, foo is a valid, existing namespace.
+		emitter.fire( 'foo:abc' );
+
+		sinon.assert.calledOnce( spyAbc );
+		sinon.assert.calledTwice( spyBar );
+		sinon.assert.calledThrice( spyFoo );
+		sinon.assert.calledThrice( spyFoo2 );
+	} );
 } );
 
 describe( 'on', () => {
@@ -279,7 +320,7 @@ describe( 'off', () => {
 		sinon.assert.callCount( spy2, 4 );
 	} );
 
-	it( 'should remove the callback for a specific context only', () => {
+	it( 'should remove the callback for given context only', () => {
 		let spy = sinon.spy().named( 1 );
 
 		let ctx1 = { ctx: 1 };
@@ -299,6 +340,41 @@ describe( 'off', () => {
 		sinon.assert.calledOnce( spy );
 		sinon.assert.calledOn( spy, ctx2 );
 	} );
+
+	it( 'should properly remove callbacks for namespaced events', () => {
+		let spyFoo = sinon.spy();
+		let spyAbc = sinon.spy();
+		let spyBar = sinon.spy();
+		let spyFoo2 = sinon.spy();
+
+		emitter.on( 'foo', spyFoo );
+		emitter.on( 'foo:bar:abc', spyAbc );
+		emitter.on( 'foo:bar', spyBar );
+		emitter.on( 'foo', spyFoo2 );
+
+		emitter.off( 'foo', spyFoo );
+
+		emitter.fire( 'foo:bar:abc' );
+
+		sinon.assert.calledOnce( spyAbc );
+		sinon.assert.calledOnce( spyBar );
+		sinon.assert.calledOnce( spyFoo2 );
+		sinon.assert.notCalled( spyFoo );
+
+		emitter.fire( 'foo:bar' );
+
+		sinon.assert.notCalled( spyFoo );
+		sinon.assert.calledOnce( spyAbc );
+		sinon.assert.calledTwice( spyBar );
+		sinon.assert.calledTwice( spyFoo2 );
+
+		emitter.fire( 'foo' );
+
+		sinon.assert.notCalled( spyFoo );
+		sinon.assert.calledOnce( spyAbc );
+		sinon.assert.calledTwice( spyBar );
+		sinon.assert.calledThrice( spyFoo2 );
+	} );
 } );
 
 describe( 'listenTo', () => {
@@ -313,12 +389,30 @@ describe( 'listenTo', () => {
 
 		sinon.assert.called( spy );
 	} );
+
+	it( 'should correctly listen to namespaced events', () => {
+		let spyFoo = sinon.spy();
+		let spyBar = sinon.spy();
+
+		listener.listenTo( emitter, 'foo', spyFoo );
+		listener.listenTo( emitter, 'foo:bar', spyBar );
+
+		emitter.fire( 'foo:bar' );
+
+		sinon.assert.calledOnce( spyFoo );
+		sinon.assert.calledOnce( spyBar );
+
+		emitter.fire( 'foo' );
+
+		sinon.assert.calledTwice( spyFoo );
+		sinon.assert.calledOnce( spyBar );
+	} );
 } );
 
 describe( 'stopListening', () => {
 	beforeEach( refreshListener );
 
-	it( 'should stop listening to a specific event callback', () => {
+	it( 'should stop listening to given event callback', () => {
 		let spy1 = sinon.spy();
 		let spy2 = sinon.spy();
 
@@ -337,7 +431,7 @@ describe( 'stopListening', () => {
 		sinon.assert.calledTwice( spy2 );
 	} );
 
-	it( 'should stop listening to an specific event', () => {
+	it( 'should stop listening to given event', () => {
 		let spy1a = sinon.spy();
 		let spy1b = sinon.spy();
 		let spy2 = sinon.spy();
@@ -359,7 +453,7 @@ describe( 'stopListening', () => {
 		sinon.assert.calledTwice( spy2 );
 	} );
 
-	it( 'should stop listening to all events from a specific emitter', () => {
+	it( 'should stop listening to all events from given emitter', () => {
 		let spy1 = sinon.spy();
 		let spy2 = sinon.spy();
 
@@ -418,6 +512,21 @@ describe( 'stopListening', () => {
 
 		sinon.assert.called( spy );
 	} );
+
+	it( 'should correctly stop listening to namespaced events', () => {
+		let spyFoo = sinon.spy();
+		let spyBar = sinon.spy();
+
+		listener.listenTo( emitter, 'foo', spyFoo );
+		listener.listenTo( emitter, 'foo:bar', spyBar );
+
+		listener.stopListening( emitter, 'foo' );
+
+		emitter.fire( 'foo:bar' );
+
+		sinon.assert.notCalled( spyFoo );
+		sinon.assert.calledOnce( spyBar );
+	} );
 } );
 
 function refreshEmitter() {

+ 3 - 3
packages/ckeditor5-utils/tests/observablemixin.js

@@ -122,9 +122,9 @@ describe( 'Observable', () => {
 			sinon.assert.calledWithExactly( spy, sinon.match.instanceOf( EventInfo ), 'color', 'blue', 'red' );
 			sinon.assert.calledWithExactly( spy, sinon.match.instanceOf( EventInfo ), 'year', 2003, 2015 );
 			sinon.assert.calledWithExactly( spy, sinon.match.instanceOf( EventInfo ), 'wheels', 4, sinon.match.typeOf( 'undefined' ) );
-			sinon.assert.calledWithExactly( spyColor, sinon.match.instanceOf( EventInfo ), 'blue', 'red' );
-			sinon.assert.calledWithExactly( spyYear, sinon.match.instanceOf( EventInfo ), 2003, 2015 );
-			sinon.assert.calledWithExactly( spyWheels, sinon.match.instanceOf( EventInfo ), 4, sinon.match.typeOf( 'undefined' ) );
+			sinon.assert.calledWithExactly( spyColor, sinon.match.instanceOf( EventInfo ), 'color', 'blue', 'red' );
+			sinon.assert.calledWithExactly( spyYear, sinon.match.instanceOf( EventInfo ), 'year', 2003, 2015 );
+			sinon.assert.calledWithExactly( spyWheels, sinon.match.instanceOf( EventInfo ), 'wheels', 4, sinon.match.typeOf( 'undefined' ) );
 		} );
 
 		it( 'should not fire the "change" event for the same attribute value', () => {