Kaynağa Gözat

DomEmitterMixin was moved from ckeditor5-ui.

Piotrek Koszuliński 9 yıl önce
ebeveyn
işleme
5e80768296

+ 294 - 0
packages/ckeditor5-utils/src/dom/emittermixin.js

@@ -0,0 +1,294 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import EmitterMixin from '../emittermixin.js';
+import uid from '../uid.js';
+import extend from '../lib/lodash/extend.js';
+import isNative from '../lib/lodash/isNative.js';
+
+/**
+ * Mixin that injects the DOM events API into its host. It provides the API
+ * compatible with {@link utils.EmitterMixin}.
+ *
+ * DOM emitter mixin is by default available in the {@link ui.View} class,
+ * but it can also be mixed into any other class:
+ *
+ *		import mix from '../utils/mix.js';
+ *		import DomEmitterMixin from '../utils/dom/emittermixin.js';
+ *
+ *		class SomeView {}
+ *		mix( SomeView, DomEmitterMixin );
+ *
+ *		const view = new SomeView();
+ *		view.listenTo( domElement, ( evt, domEvt ) => {
+ *			console.log( evt, domEvt );
+ *		} );
+ *
+ * @mixin utils.dom.EmitterMixin
+ * @mixes utils.EmitterMixin
+ * @implements ui.DomEmitter
+ */
+const DomEmitterMixin = extend( {}, EmitterMixin, {
+	/**
+	 * Registers a callback function to be executed when an event is fired in a specific Emitter or DOM Node.
+	 * It is backwards compatible with {@link utils.EmitterMixin#listenTo}.
+	 *
+	 * @param {utils.Emitter|Node} emitter The object that fires the event.
+	 * @param {String} event The name of the event.
+	 * @param {Function} callback The function to be called on event.
+	 * @param {Object} [options={}] Additional options.
+	 * @param {utils.PriorityString|Number} [options.priority='normal'] The priority of this event callback. The higher
+	 * the priority value the sooner the callback will be fired. Events having the same priority are called in the
+	 * order they were added.
+	 * @param {Object} [options.context] The object that represents `this` in the callback. Defaults to the object firing the event.
+	 * @param {Boolean} [options.useCapture=false] Indicates that events of this type will be dispatched to the registered
+	 * listener before being dispatched to any EventTarget beneath it in the DOM tree.
+	 *
+	 * @method utils.dom.EmitterMixin#listenTo
+	 */
+	listenTo( ...args ) {
+		const emitter = args[ 0 ];
+
+		// Check if emitter is an instance of DOM Node. If so, replace the argument with
+		// corresponding ProxyEmitter (or create one if not existing).
+		if ( isDomNode( emitter ) ) {
+			args[ 0 ] = this._getProxyEmitter( emitter ) || new ProxyEmitter( emitter );
+		}
+
+		// Execute parent class method with Emitter (or ProxyEmitter) instance.
+		EmitterMixin.listenTo.apply( this, args );
+	},
+
+	/**
+	 * Stops listening for events. It can be used at different levels:
+	 * It is backwards compatible with {@link utils.EmitterMixin#listenTo}.
+	 *
+	 * * To stop listening to a specific callback.
+	 * * To stop listening to a specific event.
+	 * * To stop listening to all events fired by a specific object.
+	 * * To stop listening to all events fired by all object.
+	 *
+	 * @param {utils.Emitter|Node} [emitter] The object to stop listening to. If omitted, stops it for all objects.
+	 * @param {String} [event] (Requires the `emitter`) The name of the event to stop listening to. If omitted, stops it
+	 * for all events from `emitter`.
+	 * @param {Function} [callback] (Requires the `event`) The function to be removed from the call list for the given
+	 * `event`.
+	 *
+	 * @method utils.dom.EmitterMixin#stopListening
+	 */
+	stopListening( ...args ) {
+		const emitter = args[ 0 ];
+
+		// Check if emitter is an instance of DOM Node. If so, replace the argument with corresponding ProxyEmitter.
+		if ( isDomNode( emitter ) ) {
+			let proxy = this._getProxyEmitter( emitter );
+
+			// Element has no listeners.
+			if ( !proxy ) {
+				return;
+			}
+
+			args[ 0 ] = proxy;
+		}
+
+		// Execute parent class method with Emitter (or ProxyEmitter) instance.
+		EmitterMixin.stopListening.apply( this, args );
+	},
+
+	/**
+	 * Retrieves ProxyEmitter instance for given DOM Node residing in this Host.
+	 *
+	 * @param {Node} node DOM Node of the ProxyEmitter.
+	 * @method utils.dom.EmitterMixin#_getProxyEmitter
+	 * @return {ProxyEmitter} ProxyEmitter instance or null.
+	 */
+	_getProxyEmitter( node ) {
+		let proxy, emitters, emitterInfo;
+
+		// Get node UID. It allows finding Proxy Emitter for this DOM Node.
+		const uid = getNodeUID( node );
+
+		// Find existing Proxy Emitter for this DOM Node among emitters.
+		if ( ( emitters = this._listeningTo ) ) {
+			if ( ( emitterInfo = emitters[ uid ] ) ) {
+				proxy = emitterInfo.emitter;
+			}
+		}
+
+		return proxy || null;
+	}
+} );
+
+export default DomEmitterMixin;
+
+/**
+ * Creates a ProxyEmitter instance. Such an instance is a bridge between a DOM Node firing events
+ * and any Host listening to them. It is backwards compatible with {@link utils.EmitterMixin#on}.
+ *
+ *                                  listenTo( click, ... )
+ *                    +-----------------------------------------+
+ *                    |              stopListening( ... )       |
+ *     +----------------------------+                           |             addEventListener( click, ... )
+ *     | Host                       |                           |   +---------------------------------------------+
+ *     +----------------------------+                           |   |       removeEventListener( click, ... )     |
+ *     | _listeningTo: {            |                +----------v-------------+                                   |
+ *     |   UID: {                   |                | ProxyEmitter           |                                   |
+ *     |     emitter: ProxyEmitter, |                +------------------------+                      +------------v----------+
+ *     |     callbacks: {           |                | events: {              |                      | Node (HTMLElement)    |
+ *     |       click: [ callbacks ] |                |   click: [ callbacks ] |                      +-----------------------+
+ *     |     }                      |                | },                     |                      | data-cke-expando: UID |
+ *     |   }                        |                | _domNode: Node,        |                      +-----------------------+
+ *     | }                          |                | _domListeners: {},     |                                   |
+ *     | +------------------------+ |                | _emitterId: UID        |                                   |
+ *     | | DomEmitterMixin        | |                +--------------^---------+                                   |
+ *     | +------------------------+ |                           |   |                                             |
+ *     +--------------^-------------+                           |   +---------------------------------------------+
+ *                    |                                         |                  click (DOM Event)
+ *                    +-----------------------------------------+
+ *                                fire( click, DOM Event )
+ *
+ * @memberOf ui
+ * @mixes utils.EmitterMixin
+ * @implements utils.dom.Emitter
+ */
+class ProxyEmitter {
+	/**
+	 * @param {Node} node DOM Node that fires events.
+	 * @returns {Object} ProxyEmitter instance bound to the DOM Node.
+	 */
+	constructor( node ) {
+		// Set emitter ID to match DOM Node "expando" property.
+		this._emitterId = getNodeUID( node );
+
+		// Remember the DOM Node this ProxyEmitter is bound to.
+		this._domNode = node;
+	}
+}
+
+extend( ProxyEmitter.prototype, EmitterMixin, {
+	/**
+	 * Collection of native DOM listeners.
+	 *
+	 * @private
+	 * @member {Object} ui.ProxyEmitter#_domListeners
+	 */
+
+	/**
+	 * Registers a callback function to be executed when an event is fired.
+	 *
+	 * It attaches a native DOM listener to the DOM Node. When fired,
+	 * a corresponding Emitter event will also fire with DOM Event object as an argument.
+	 *
+	 * @param {String} event The name of the event.
+	 * @param {Function} callback The function to be called on event.
+	 * @param {Object} [options={}] Additional options.
+	 * @param {utils.PriorityString|Number} [options.priority='normal'] The priority of this event callback. The higher
+	 * the priority value the sooner the callback will be fired. Events having the same priority are called in the
+	 * order they were added.
+	 * @param {Object} [options.context] The object that represents `this` in the callback. Defaults to the object firing the event.
+	 * @param {Boolean} [options.useCapture=false] Indicates that events of this type will be dispatched to the registered
+	 * listener before being dispatched to any EventTarget beneath it in the DOM tree.
+	 *
+	 * @method ui.ProxyEmitter#on
+	 */
+	on( event, callback, options = {} ) {
+		// Execute parent class method first.
+		EmitterMixin.on.apply( this, arguments );
+
+		// If the DOM Listener for given event already exist it is pointless
+		// to attach another one.
+		if ( this._domListeners && this._domListeners[ event ] ) {
+			return;
+		}
+
+		const domListener = this._createDomListener( event );
+
+		// Attach the native DOM listener to DOM Node.
+		this._domNode.addEventListener( event, domListener, !!options.useCapture );
+
+		if ( !this._domListeners ) {
+			this._domListeners = {};
+		}
+
+		// Store the native DOM listener in this ProxyEmitter. It will be helpful
+		// when stopping listening to the event.
+		this._domListeners[ event ] = domListener;
+	},
+
+	/**
+	 * Stops executing the callback on the given event.
+	 *
+	 * @param {String} event The name of the event.
+	 * @param {Function} callback The function to stop being called.
+	 * @param {Object} [context] The context object to be removed, pared with the given callback. To handle cases where
+	 * the same callback is used several times with different contexts.
+	 *
+	 * @method ui.ProxyEmitter#off
+	 */
+	off( event ) {
+		// Execute parent class method first.
+		EmitterMixin.off.apply( this, arguments );
+
+		let events;
+
+		// Remove native DOM listeners which are orphans. If no callbacks
+		// are awaiting given event, detach native DOM listener from DOM Node.
+		// See: {@link on}.
+
+		if ( this._domListeners[ event ] && ( !( events = this._events[ event ] ) || !events.callbacks.length ) ) {
+			this._domListeners[ event ].removeListener();
+		}
+	},
+
+	/**
+	 * Create a native DOM listener callback. When the native DOM event
+	 * is fired it will fire corresponding event on this ProxyEmitter.
+	 * Note: A native DOM Event is passed as an argument.
+	 *
+	 * @param {String} event
+	 *
+	 * @method ui.ProxyEmitter#_createDomListener
+	 * @returns {Function} The DOM listener callback.
+	 */
+	_createDomListener( event ) {
+		const domListener = domEvt => {
+			this.fire( event, domEvt );
+		};
+
+		// Supply the DOM listener callback with a function that will help
+		// detach it from the DOM Node, when it is no longer necessary.
+		// See: {@link off}.
+		domListener.removeListener = () => {
+			this._domNode.removeEventListener( event, domListener );
+			delete this._domListeners[ event ];
+		};
+
+		return domListener;
+	}
+} );
+
+// Gets an unique DOM Node identifier. The identifier will be set if not defined.
+//
+// @private
+// @param {Node} node
+// @return {String} UID for given DOM Node.
+function getNodeUID( node ) {
+	return node[ 'data-ck-expando' ] || ( node[ 'data-ck-expando' ] = uid() );
+}
+
+// Checks (naively) if given node is native DOM Node.
+//
+// @private
+// @param {Node} node
+// @return {Boolean} True when native DOM Node.
+function isDomNode( node ) {
+	return node && isNative( node.addEventListener );
+}
+
+/**
+ * Interface representing classes which mix in {@link utils.dom.Emitter}.
+ *
+ * @interface utils.dom.Emitter
+ */

+ 3 - 3
packages/ckeditor5-utils/src/focustracker.js

@@ -5,7 +5,7 @@
 
 /* global setTimeout, clearTimeout */
 
-import DOMEmitterMixin from '../ui/domemittermixin.js';
+import DomEmitterMixin from './dom/emittermixin.js';
 import ObservableMixin from './observablemixin.js';
 import CKEditorError from './ckeditorerror.js';
 import mix from './mix.js';
@@ -21,7 +21,7 @@ import mix from './mix.js';
  * (have e.g. `tabindex="-1"`).
  *
  * @memberOf utils
- * @mixes utils.DOMEmitterMixin
+ * @mixes utils.dom.EmitterMixin
  * @mixes utils.ObservableMixin
  */
 export default class FocusTracker {
@@ -119,5 +119,5 @@ export default class FocusTracker {
 	}
 }
 
-mix( FocusTracker, DOMEmitterMixin );
+mix( FocusTracker, DomEmitterMixin );
 mix( FocusTracker, ObservableMixin );

+ 468 - 0
packages/ckeditor5-utils/tests/dom/emittermixin.js

@@ -0,0 +1,468 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document, window, Event, MouseEvent */
+/* bender-tags: ui */
+
+import testUtils from 'tests/core/_utils/utils.js';
+import DomEmitterMixin from 'ckeditor5/utils/dom/emittermixin.js';
+import EmitterMixin from 'ckeditor5/utils/emittermixin.js';
+
+testUtils.createSinonSandbox();
+
+describe( 'DomEmitterMixin', () => {
+	let emitter, domEmitter, node;
+
+	beforeEach( () => {
+		emitter = Object.create( EmitterMixin );
+		domEmitter = Object.create( DomEmitterMixin );
+		node = document.createElement( 'div' );
+	} );
+
+	afterEach( () => {
+		domEmitter.stopListening();
+	} );
+
+	describe( 'listenTo', () => {
+		it( 'should listen to EmitterMixin events', () => {
+			const spy = testUtils.sinon.spy();
+
+			domEmitter.listenTo( emitter, 'test', spy );
+			emitter.fire( 'test' );
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should listen to native DOM events', () => {
+			const spy = testUtils.sinon.spy();
+
+			domEmitter.listenTo( node, 'test', spy );
+
+			node.dispatchEvent( new Event( 'test' ) );
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should listen to native DOM events - window as source', () => {
+			const spy = testUtils.sinon.spy();
+
+			domEmitter.listenTo( window, 'test', spy );
+
+			window.dispatchEvent( new Event( 'test' ) );
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should listen to native DOM events - document as source', () => {
+			const spy = testUtils.sinon.spy();
+
+			domEmitter.listenTo( document, 'test', spy );
+
+			document.dispatchEvent( new Event( 'test' ) );
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		// #187
+		it( 'should work for DOM Nodes belonging to another window', () => {
+			const spy = testUtils.sinon.spy();
+			const iframe = document.createElement( 'iframe' );
+
+			document.body.appendChild( iframe );
+			const iframeNode = iframe.contentWindow.document.createElement( 'div' );
+
+			domEmitter.listenTo( iframeNode, 'test', spy );
+			iframeNode.dispatchEvent( new Event( 'test' ) );
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		describe( 'event capturing', () => {
+			beforeEach( () => {
+				document.body.appendChild( node );
+			} );
+
+			afterEach( () => {
+				document.body.removeChild( node );
+			} );
+
+			it( 'should not use capturing at default', () => {
+				const spy = testUtils.sinon.spy();
+
+				domEmitter.listenTo( document, 'test', spy );
+
+				node.dispatchEvent( new Event( 'test', { bubbles: false } ) );
+
+				sinon.assert.notCalled( spy );
+			} );
+
+			it( 'should optionally use capturing', () => {
+				const spy = testUtils.sinon.spy();
+
+				domEmitter.listenTo( document, 'test', spy, { useCapture: true } );
+
+				node.dispatchEvent( new Event( 'test', { bubbles: false } ) );
+
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+	} );
+
+	describe( 'stopListening', () => {
+		it( 'should stop listening to a specific event callback', () => {
+			const spy1 = testUtils.sinon.spy();
+			const spy2 = testUtils.sinon.spy();
+
+			domEmitter.listenTo( node, 'event1', spy1 );
+			domEmitter.listenTo( node, 'event2', spy2 );
+
+			node.dispatchEvent( new Event( 'event1' ) );
+			node.dispatchEvent( new Event( 'event2' ) );
+
+			domEmitter.stopListening( node, 'event1', spy1 );
+
+			node.dispatchEvent( new Event( 'event1' ) );
+			node.dispatchEvent( new Event( 'event2' ) );
+
+			sinon.assert.calledOnce( spy1 );
+			sinon.assert.calledTwice( spy2 );
+		} );
+
+		it( 'should stop listening to an specific event', () => {
+			const spy1a = testUtils.sinon.spy();
+			const spy1b = testUtils.sinon.spy();
+			const spy2 = testUtils.sinon.spy();
+
+			domEmitter.listenTo( node, 'event1', spy1a );
+			domEmitter.listenTo( node, 'event1', spy1b );
+			domEmitter.listenTo( node, 'event2', spy2 );
+
+			node.dispatchEvent( new Event( 'event1' ) );
+			node.dispatchEvent( new Event( 'event2' ) );
+
+			sinon.assert.calledOnce( spy1a );
+			sinon.assert.calledOnce( spy1b );
+			sinon.assert.calledOnce( spy2 );
+
+			domEmitter.stopListening( node, 'event1' );
+
+			node.dispatchEvent( new Event( 'event1' ) );
+			node.dispatchEvent( new Event( 'event2' ) );
+
+			sinon.assert.calledOnce( spy1a );
+			sinon.assert.calledOnce( spy1b );
+			sinon.assert.calledTwice( spy2 );
+		} );
+
+		it( 'should stop listening to all events from a specific node', () => {
+			const spy1 = testUtils.sinon.spy();
+			const spy2 = testUtils.sinon.spy();
+
+			domEmitter.listenTo( node, 'event1', spy1 );
+			domEmitter.listenTo( node, 'event2', spy2 );
+
+			node.dispatchEvent( new Event( 'event1' ) );
+			node.dispatchEvent( new Event( 'event2' ) );
+
+			domEmitter.stopListening( node );
+
+			node.dispatchEvent( new Event( 'event1' ) );
+			node.dispatchEvent( new Event( 'event2' ) );
+
+			sinon.assert.calledOnce( spy1 );
+			sinon.assert.calledOnce( spy2 );
+		} );
+
+		it( 'should stop listening to everything', () => {
+			const spy1 = testUtils.sinon.spy();
+			const spy2 = testUtils.sinon.spy();
+
+			const node1 = document.createElement( 'div' );
+			const node2 = document.createElement( 'div' );
+
+			domEmitter.listenTo( node1, 'event1', spy1 );
+			domEmitter.listenTo( node2, 'event2', spy2 );
+
+			expect( domEmitter ).to.have.property( '_listeningTo' );
+
+			node1.dispatchEvent( new Event( 'event1' ) );
+			node2.dispatchEvent( new Event( 'event2' ) );
+
+			domEmitter.stopListening();
+
+			node1.dispatchEvent( new Event( 'event1' ) );
+			node2.dispatchEvent( new Event( 'event2' ) );
+
+			sinon.assert.calledOnce( spy1 );
+			sinon.assert.calledOnce( spy2 );
+
+			expect( domEmitter ).to.not.have.property( '_listeningTo' );
+		} );
+
+		it( 'should stop listening to everything what left', () => {
+			const spy1 = testUtils.sinon.spy();
+			const spy2 = testUtils.sinon.spy();
+			const spy3 = testUtils.sinon.spy();
+			const spy4 = testUtils.sinon.spy();
+
+			const node1 = document.createElement( 'div' );
+			const node2 = document.createElement( 'div' );
+
+			domEmitter.listenTo( node1, 'event1', spy1 );
+			domEmitter.listenTo( node1, 'event2', spy2 );
+			domEmitter.listenTo( node2, 'event1', spy3 );
+			domEmitter.listenTo( node2, 'event2', spy4 );
+
+			expect( domEmitter ).to.have.property( '_listeningTo' );
+
+			domEmitter.stopListening( node1, 'event1', spy1 );
+			domEmitter.stopListening( node2, 'event1' );
+
+			node1.dispatchEvent( new Event( 'event1' ) );
+			node1.dispatchEvent( new Event( 'event2' ) );
+			node2.dispatchEvent( new Event( 'event1' ) );
+			node2.dispatchEvent( new Event( 'event2' ) );
+
+			sinon.assert.notCalled( spy1 );
+			sinon.assert.calledOnce( spy2 );
+			sinon.assert.notCalled( spy3 );
+			sinon.assert.calledOnce( spy4 );
+
+			domEmitter.stopListening();
+
+			node1.dispatchEvent( new Event( 'event1' ) );
+			node1.dispatchEvent( new Event( 'event2' ) );
+			node2.dispatchEvent( new Event( 'event1' ) );
+			node2.dispatchEvent( new Event( 'event2' ) );
+
+			sinon.assert.notCalled( spy1 );
+			sinon.assert.calledOnce( spy2 );
+			sinon.assert.notCalled( spy3 );
+			sinon.assert.calledOnce( spy4 );
+
+			expect( domEmitter ).to.not.have.property( '_listeningTo' );
+		} );
+
+		it( 'should not stop other nodes when a non-listened node is provided', () => {
+			const spy = testUtils.sinon.spy();
+
+			const node1 = document.createElement( 'div' );
+			const node2 = document.createElement( 'div' );
+
+			domEmitter.listenTo( node1, 'test', spy );
+
+			domEmitter.stopListening( node2 );
+
+			node1.dispatchEvent( new Event( 'test' ) );
+
+			sinon.assert.called( spy );
+		} );
+
+		it( 'should pass DOM Event data to the listener', () => {
+			const spy = testUtils.sinon.spy();
+
+			domEmitter.listenTo( node, 'click', spy );
+
+			const mouseEvent = new MouseEvent( 'click', {
+				screenX: 10,
+				screenY: 20
+			} );
+
+			node.dispatchEvent( mouseEvent );
+
+			sinon.assert.calledOnce( spy );
+			expect( spy.args[ 0 ][ 1 ] ).to.be.equal( mouseEvent );
+		} );
+
+		it( 'should detach native DOM event listener proxy, specific event', () => {
+			const spy1a = testUtils.sinon.spy();
+			const spy1b = testUtils.sinon.spy();
+
+			domEmitter.listenTo( node, 'test', spy1a );
+
+			const proxyEmitter = domEmitter._getProxyEmitter( node );
+			const spy2 = testUtils.sinon.spy( proxyEmitter, 'fire' );
+
+			node.dispatchEvent( new Event( 'test' ) );
+
+			sinon.assert.calledOnce( spy1a );
+			sinon.assert.calledOnce( spy2 );
+
+			domEmitter.stopListening( node, 'test' );
+			node.dispatchEvent( new Event( 'test' ) );
+
+			sinon.assert.calledOnce( spy1a );
+			sinon.assert.calledOnce( spy2 );
+
+			// Attach same event again.
+			domEmitter.listenTo( node, 'test', spy1b );
+			node.dispatchEvent( new Event( 'test' ) );
+
+			expect( proxyEmitter ).to.be.equal( domEmitter._getProxyEmitter( node ) );
+
+			sinon.assert.calledOnce( spy1a );
+			sinon.assert.calledOnce( spy1b );
+			sinon.assert.calledTwice( spy2 );
+		} );
+
+		it( 'should detach native DOM event listener proxy, specific callback', () => {
+			const spy1a = testUtils.sinon.spy();
+			const spy1b = testUtils.sinon.spy();
+			const spy1c = testUtils.sinon.spy();
+
+			domEmitter.listenTo( node, 'test', spy1a );
+			domEmitter.listenTo( node, 'test', spy1b );
+
+			const proxyEmitter = domEmitter._getProxyEmitter( node );
+			const spy2 = testUtils.sinon.spy( proxyEmitter, 'fire' );
+
+			node.dispatchEvent( new Event( 'test' ) );
+
+			sinon.assert.calledOnce( spy1a );
+			sinon.assert.calledOnce( spy1b );
+			sinon.assert.calledOnce( spy2 );
+
+			domEmitter.stopListening( node, 'test', spy1a );
+			node.dispatchEvent( new Event( 'test' ) );
+
+			sinon.assert.calledOnce( spy1a );
+			sinon.assert.calledTwice( spy1b );
+			sinon.assert.calledTwice( spy2 );
+
+			domEmitter.stopListening( node, 'test', spy1b );
+			node.dispatchEvent( new Event( 'test' ) );
+
+			sinon.assert.calledOnce( spy1a );
+			sinon.assert.calledTwice( spy1b );
+			sinon.assert.calledTwice( spy2 );
+
+			// Attach same event again.
+			domEmitter.listenTo( node, 'test', spy1c );
+			node.dispatchEvent( new Event( 'test' ) );
+
+			expect( proxyEmitter ).to.be.equal( domEmitter._getProxyEmitter( node ) );
+
+			sinon.assert.calledOnce( spy1a );
+			sinon.assert.calledTwice( spy1b );
+			sinon.assert.calledOnce( spy1c );
+			sinon.assert.calledThrice( spy2 );
+		} );
+
+		it( 'should detach native DOM event listener proxy, specific emitter', () => {
+			const spy1a = testUtils.sinon.spy();
+			const spy1b = testUtils.sinon.spy();
+			const spy1c = testUtils.sinon.spy();
+			const spy1d = testUtils.sinon.spy();
+
+			domEmitter.listenTo( node, 'test1', spy1a );
+			domEmitter.listenTo( node, 'test2', spy1b );
+
+			const proxyEmitter = domEmitter._getProxyEmitter( node );
+			const spy2 = testUtils.sinon.spy( proxyEmitter, 'fire' );
+
+			node.dispatchEvent( new Event( 'test1' ) );
+			node.dispatchEvent( new Event( 'test2' ) );
+
+			sinon.assert.calledOnce( spy1a );
+			sinon.assert.calledOnce( spy1b );
+			sinon.assert.calledTwice( spy2 );
+
+			domEmitter.stopListening( node );
+
+			node.dispatchEvent( new Event( 'test1' ) );
+			node.dispatchEvent( new Event( 'test2' ) );
+
+			sinon.assert.calledOnce( spy1a );
+			sinon.assert.calledOnce( spy1b );
+			sinon.assert.calledTwice( spy2 );
+
+			// Attach same event again.
+			domEmitter.listenTo( node, 'test1', spy1c );
+			domEmitter.listenTo( node, 'test2', spy1d );
+
+			// Old proxy emitter died when stopped listening to the node.
+			const proxyEmitter2 = domEmitter._getProxyEmitter( node );
+			const spy3 = testUtils.sinon.spy( proxyEmitter2, 'fire' );
+
+			node.dispatchEvent( new Event( 'test1' ) );
+			node.dispatchEvent( new Event( 'test2' ) );
+
+			expect( proxyEmitter ).to.not.be.equal( proxyEmitter2 );
+
+			sinon.assert.calledOnce( spy1a );
+			sinon.assert.calledOnce( spy1b );
+			sinon.assert.calledOnce( spy1c );
+			sinon.assert.calledOnce( spy1d );
+			sinon.assert.calledTwice( spy2 );
+			sinon.assert.calledTwice( spy3 );
+		} );
+
+		it( 'should detach native DOM event listener proxy, everything', () => {
+			const spy1a = testUtils.sinon.spy();
+			const spy1b = testUtils.sinon.spy();
+			const spy1c = testUtils.sinon.spy();
+			const spy1d = testUtils.sinon.spy();
+
+			domEmitter.listenTo( node, 'test1', spy1a );
+			domEmitter.listenTo( node, 'test2', spy1b );
+
+			const proxyEmitter = domEmitter._getProxyEmitter( node );
+			const spy2 = testUtils.sinon.spy( proxyEmitter, 'fire' );
+
+			node.dispatchEvent( new Event( 'test1' ) );
+			node.dispatchEvent( new Event( 'test2' ) );
+
+			sinon.assert.calledOnce( spy1a );
+			sinon.assert.calledOnce( spy1b );
+			sinon.assert.calledTwice( spy2 );
+
+			domEmitter.stopListening();
+
+			node.dispatchEvent( new Event( 'test1' ) );
+			node.dispatchEvent( new Event( 'test2' ) );
+
+			sinon.assert.calledOnce( spy1a );
+			sinon.assert.calledOnce( spy1b );
+			sinon.assert.calledTwice( spy2 );
+
+			// Attach same event again.
+			domEmitter.listenTo( node, 'test1', spy1c );
+			domEmitter.listenTo( node, 'test2', spy1d );
+
+			// Old proxy emitter died when stopped listening to the node.
+			const proxyEmitter2 = domEmitter._getProxyEmitter( node );
+			const spy3 = testUtils.sinon.spy( proxyEmitter2, 'fire' );
+
+			node.dispatchEvent( new Event( 'test1' ) );
+			node.dispatchEvent( new Event( 'test2' ) );
+
+			expect( proxyEmitter ).to.not.be.equal( proxyEmitter2 );
+
+			sinon.assert.calledOnce( spy1a );
+			sinon.assert.calledOnce( spy1b );
+			sinon.assert.calledOnce( spy1c );
+			sinon.assert.calledOnce( spy1d );
+			sinon.assert.calledTwice( spy2 );
+			sinon.assert.calledTwice( spy3 );
+		} );
+
+		// #187
+		it( 'should work for DOM Nodes belonging to another window', () => {
+			const spy = testUtils.sinon.spy();
+			const iframe = document.createElement( 'iframe' );
+
+			document.body.appendChild( iframe );
+			const iframeNode = iframe.contentWindow.document.createElement( 'div' );
+
+			domEmitter.listenTo( iframeNode, 'test', spy );
+
+			iframeNode.dispatchEvent( new Event( 'test' ) );
+			domEmitter.stopListening( iframeNode );
+			iframeNode.dispatchEvent( new Event( 'test' ) );
+
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
+} );