Browse Source

Other: Replaced the getResizeObserver() helper with a ResizeObserver class for performance reasons. Closes ckeditor/ckeditor5#6145.

Aleksander Nowodzinski 6 years ago
parent
commit
cbb8e88f82

+ 0 - 233
packages/ckeditor5-utils/src/dom/getresizeobserver.js

@@ -1,233 +0,0 @@
-/**
- * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
- */
-
-/**
- * @module utils/dom/getresizeobserver
- */
-
-/* globals setTimeout, clearTimeout */
-
-import mix from '../mix';
-import global from './global';
-import Rect from './rect';
-import DomEmitterMixin from './emittermixin';
-
-const RESIZE_CHECK_INTERVAL = 100;
-
-/**
- * Returns an instance of [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver).
- * In browsers that support the `ResizeObserver` API, the native observer instance is returned.
- * In other browsers, a polyfilled instance is returned instead with a compatible API.
- *
- * [Learn more](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) about the native API.
- *
- * @param {Function} callback A function called when any observed element was resized. Refer to the
- * native [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) API to
- * learn more.
- * @returns {module:utils/dom/getresizeobserver~ResizeObserver} An observer instance.
- */
-export default function getResizeObserver( callback ) {
-	// TODO: One day, the `ResizeObserver` API will be supported in all modern web browsers.
-	// When it happens, this module will no longer make sense and should be removed and
-	// the native implementation should be used across the project to save bytes.
-	// Check out https://caniuse.com/#feat=resizeobserver.
-	if ( typeof global.window.ResizeObserver === 'function' ) {
-		return new global.window.ResizeObserver( callback );
-	} else {
-		return new ResizeObserverPolyfill( callback );
-	}
-}
-
-/**
- * A polyfill class for the native [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver).
- *
- * @private
- * @mixes module:utils/domemittermixin~DomEmitterMixin
- */
-class ResizeObserverPolyfill {
-	/**
-	 * Creates an instance of the {@link module:utils/dom/getresizeobserver~ResizeObserverPolyfill} class.
-	 *
-	 * It synchronously reacts to resize of the window to check if observed elements' geometry changed.
-	 *
-	 * Additionally, the polyfilled observer uses a timeout to check if observed elements' geometry has changed
-	 * in some other way (dynamic layouts, scrollbars showing up, etc.), so its response can also be asynchronous.
-	 *
-	 * @param {Function} callback A function called when any observed element was resized. Refer to the
-	 * native [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) API to
-	 * learn more.
-	 */
-	constructor( callback ) {
-		/**
-		 * A function called when any observed {@link #_elements element} was resized.
-		 *
-		 * @readonly
-		 * @protected
-		 * @member {Function}
-		 */
-		this._callback = callback;
-
-		/**
-		 * DOM elements currently observed by the observer instance.
-		 *
-		 * @readonly
-		 * @protected
-		 * @member {Set}
-		 */
-		this._elements = new Set();
-
-		/**
-		 * Cached DOM {@link #_elements elements} bounding rects to compare to upon the next check.
-		 *
-		 * @readonly
-		 * @protected
-		 * @member {Map.<HTMLElement,module:utils/dom/rect~Rect>}
-		 */
-		this._previousRects = new Map();
-
-		/**
-		 * An UID of the current timeout upon which the observed elements rects
-		 * will be compared to the {@link #_previousRects previous rects} from the past.
-		 *
-		 * @readonly
-		 * @protected
-		 * @member {Map.<HTMLElement,module:utils/dom/rect~Rect>}
-		 */
-		this._periodicCheckTimeout = null;
-	}
-
-	/**
-	 * Starts observing a DOM element.
-	 *
-	 * Learn more in the
-	 * [native method documentation](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/observe).
-	 *
-	 * @param {HTMLElement} element
-	 */
-	observe( element ) {
-		this._elements.add( element );
-
-		if ( this._elements.size === 1 ) {
-			this._startPeriodicCheck();
-		}
-	}
-
-	/**
-	 * Stops observing a DOM element.
-	 *
-	 * Learn more in the
-	 * [native method documentation](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/unobserve).
-	 *
-	 * @param {HTMLElement} element
-	 */
-	unobserve( element ) {
-		this._elements.delete( element );
-		this._previousRects.delete( element );
-
-		if ( !this._elements.size ) {
-			this._stopPeriodicCheck();
-		}
-	}
-
-	/**
-	 * Stops observing all observed DOM elements.
-	 *
-	 * Learn more in the
-	 * [native method documentation](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/disconnect).
-	 *
-	 * @param {HTMLElement} element
-	 */
-	disconnect() {
-		this._elements.forEach( element => this.unobserve( element ) );
-	}
-
-	/**
-	 * When called, the observer calls the {@link #_callback resize callback} for all observed
-	 * {@link #_elements elements} but also starts checking periodically for changes in the elements' geometry.
-	 * If some are detected, {@link #_callback resize callback} is called for relevant elements that were resized.
-	 *
-	 * @protected
-	 */
-	_startPeriodicCheck() {
-		const periodicCheck = () => {
-			this._checkElementRectsAndExecuteCallback();
-			this._periodicCheckTimeout = setTimeout( periodicCheck, RESIZE_CHECK_INTERVAL );
-		};
-
-		this.listenTo( global.window, 'resize', () => {
-			this._checkElementRectsAndExecuteCallback();
-		} );
-
-		periodicCheck();
-	}
-
-	/**
-	 * Stops checking for changes in all observed {@link #_elements elements} geometry.
-	 *
-	 * @protected
-	 */
-	_stopPeriodicCheck() {
-		clearTimeout( this._periodicCheckTimeout );
-		this.stopListening();
-		this._previousRects.clear();
-	}
-
-	/**
-	 * Checks if the geometry of any of the {@link #_elements element} has changed. If so, executes
-	 * the {@link #_callback resize callback} with element geometry data.
-	 *
-	 * @protected
-	 */
-	_checkElementRectsAndExecuteCallback() {
-		const entries = [];
-
-		for ( const element of this._elements ) {
-			if ( this._hasRectChanged( element ) ) {
-				entries.push( {
-					target: element,
-					contentRect: this._previousRects.get( element )
-				} );
-			}
-		}
-
-		if ( entries.length ) {
-			this._callback( entries );
-		}
-	}
-
-	/**
-	 * Compares the DOM element geometry to the {@link #_previousRects cached geometry} from the past.
-	 * Returns `true` if geometry has changed or the element is checked for the first time.
-	 *
-	 * @protected
-	 * @param {HTMLElement} element
-	 * @returns {Boolean}
-	 */
-	_hasRectChanged( element ) {
-		if ( !element.ownerDocument.body.contains( element ) ) {
-			return false;
-		}
-
-		const currentRect = new Rect( element );
-		const previousRect = this._previousRects.get( element );
-
-		// The first check should always yield true despite no Previous rect to compare to.
-		// The native ResizeObserver does that and... that makes sense. Sort of.
-		const hasChanged = !previousRect || !previousRect.isEqual( currentRect );
-
-		this._previousRects.set( element, currentRect );
-
-		return hasChanged;
-	}
-}
-
-mix( ResizeObserverPolyfill, DomEmitterMixin );
-
-/**
- * A resize observer object (either native or {@link module:utils/dom/getresizeobserver~getResizeObserver polyfilled})
- * offering the [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) API.
- *
- * @typedef {Function} module:utils/dom/getresizeobserver~ResizeObserver
- */

+ 377 - 0
packages/ckeditor5-utils/src/dom/resizeobserver.js

@@ -0,0 +1,377 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module utils/dom/resizeobserver
+ */
+
+/* globals setTimeout, clearTimeout */
+
+import mix from '../mix';
+import global from './global';
+import Rect from './rect';
+import DomEmitterMixin from './emittermixin';
+
+const RESIZE_CHECK_INTERVAL = 100;
+
+/**
+ * A helper class which instances allow performing custom actions when native DOM elements are resized.
+ *
+ *		const editableElement = editor.editing.view.getDomRoot();
+ *
+ *		const observer = new ResizeObserver( editableElement, entry => {
+ *			console.log( 'The editable element has been resized in DOM.' );
+ *			console.log( entry.target ); // -> editableElement
+ *			console.log( entry.contentRect.width ); // -> e.g. 423px
+ *		} )
+ *
+ * By default, it uses the [native DOM resize observer](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver)
+ * under the hood and in browsers that do not support the native API yet, a polyfilled observer is
+ * used instead.
+ */
+export default class ResizeObserver {
+	/**
+	 * Creates an instance of the `ResizeObserver` class.
+	 *
+	 * @param {HTMLElement} element A DOM element that is to be observed for resizing. Note that
+	 * the element must be visible (i.e. not detached from DOM) for the observer to work.
+	 * @param {Function} callback A function called when the observed element was resized. It passes
+	 * the [`ResizeObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry)
+	 * object with information about the resize event.
+	 */
+	constructor( element, callback ) {
+		// **Note**: For the maximum performance, this class ensures only a single instance of the native
+		// (or polyfilled) observer is used no matter how many instances of this class were created.
+		if ( !ResizeObserver._observerInstance ) {
+			ResizeObserver._createObserver();
+		}
+
+		/**
+		 * The element observer by this observer.
+		 *
+		 * @readonly
+		 * @private
+		 * @member {HTMLElement}
+		 */
+		this._element = element;
+
+		/**
+		 * The callback executed each time {@link #_element} is resized.
+		 *
+		 * @readonly
+		 * @private
+		 * @member {Function}
+		 */
+		this._callback = callback;
+
+		ResizeObserver._addElementCallback( element, callback );
+		ResizeObserver._observerInstance.observe( element );
+	}
+
+	/**
+	 * Destroys the observer which disables the `callback` passed to the {@link #constructor}.
+	 */
+	destroy() {
+		ResizeObserver._deleteElementCallback( this._element, this._callback );
+
+		this._element = this._callback = null;
+	}
+
+	/**
+	 * Registers a new resize callback for the DOM element.
+	 *
+	 * @private
+	 * @static
+	 * @param {HTMLElement} element
+	 * @param {Function} callback
+	 */
+	static _addElementCallback( element, callback ) {
+		if ( !ResizeObserver._elementCallbacks ) {
+			ResizeObserver._elementCallbacks = new Map();
+		}
+
+		let callbacks = ResizeObserver._elementCallbacks.get( element );
+
+		if ( !callbacks ) {
+			callbacks = new Set();
+			ResizeObserver._elementCallbacks.set( element, callbacks );
+		}
+
+		callbacks.add( callback );
+	}
+
+	/**
+	 * Removes a resize callback from the DOM element. If no callbacks are left
+	 * for the element, it removes the element from the native observer.
+	 *
+	 * @private
+	 * @static
+	 * @param {HTMLElement} element
+	 * @param {Function} callback
+	 */
+	static _deleteElementCallback( element, callback ) {
+		const callbacks = ResizeObserver._getElementCallbacks( element );
+
+		// Remove the element callback.
+		callbacks.delete( callback );
+
+		// If no callbacks left for the element, also remove the element.
+		if ( !callbacks.size ) {
+			ResizeObserver._elementCallbacks.delete( element );
+			ResizeObserver._observerInstance.unobserve( element );
+		}
+
+		if ( !ResizeObserver._elementCallbacks.size ) {
+			ResizeObserver._observerInstance = null;
+			ResizeObserver._elementCallbacks = null;
+		}
+	}
+
+	/**
+	 * Returns are registered resize callbacks for the DOM element.
+	 *
+	 * @private
+	 * @static
+	 * @param {HTMLElement} element
+	 * @returns {Set.<HTMLElement>|null}
+	 */
+	static _getElementCallbacks( element ) {
+		if ( !ResizeObserver._elementCallbacks ) {
+			return null;
+		}
+
+		return ResizeObserver._elementCallbacks.get( element );
+	}
+
+	/**
+	 * Creates the single native observer shared across all `ResizeObserver` instances.
+	 * If the browser does not support the native API, it creates a polyfill.
+	 *
+	 * @private
+	 * @static
+	 */
+	static _createObserver() {
+		let ObserverConstructor;
+
+		// TODO: One day, the `ResizeObserver` API will be supported in all modern web browsers.
+		// When it happens, this module will no longer make sense and should be removed and
+		// the native implementation should be used across the project to save bytes.
+		// Check out https://caniuse.com/#feat=resizeobserver.
+		if ( typeof global.window.ResizeObserver === 'function' ) {
+			ObserverConstructor = global.window.ResizeObserver;
+		} else {
+			ObserverConstructor = ResizeObserverPolyfill;
+		}
+
+		ResizeObserver._observerInstance = new ObserverConstructor( entries => {
+			for ( const entry of entries ) {
+				const callbacks = ResizeObserver._getElementCallbacks( entry.target );
+
+				if ( callbacks ) {
+					for ( const callback of callbacks ) {
+						callback( entry );
+					}
+				}
+			}
+		} );
+	}
+}
+
+/**
+ * The single native observer instance (or polyfill in browsers that do not support the API)
+ * shared across all {@link module:utils/dom/resizeobserver~ResizeObserver} instances.
+ *
+ * @static
+ * @protected
+ * @readonly
+ * @property {Object|null} module:utils/dom/resizeobserver~ResizeObserver#_observerInstance
+ */
+ResizeObserver._observerInstance = null;
+
+/**
+ * A mapping of native DOM elements and their callbacks shared across all
+ * {@link module:utils/dom/resizeobserver~ResizeObserver} instances.
+ *
+ * @static
+ * @private
+ * @readonly
+ * @property {Map.<HTMLElement,Set>|null} module:utils/dom/resizeobserver~ResizeObserver#_elementCallbacks
+ */
+ResizeObserver._elementCallbacks = null;
+
+/**
+ * A polyfill class for the native [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver).
+ *
+ * @private
+ * @mixes module:utils/domemittermixin~DomEmitterMixin
+ */
+class ResizeObserverPolyfill {
+	/**
+	 * Creates an instance of the {@link module:utils/dom/resizeobserver~ResizeObserverPolyfill} class.
+	 *
+	 * It synchronously reacts to resize of the window to check if observed elements' geometry changed.
+	 *
+	 * Additionally, the polyfilled observer uses a timeout to check if observed elements' geometry has changed
+	 * in some other way (dynamic layouts, scrollbars showing up, etc.), so its response can also be asynchronous.
+	 *
+	 * @param {Function} callback A function called when any observed element was resized. Refer to the
+	 * native [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) API to
+	 * learn more.
+	 */
+	constructor( callback ) {
+		/**
+		 * A function called when any observed {@link #_elements element} was resized.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {Function}
+		 */
+		this._callback = callback;
+
+		/**
+		 * DOM elements currently observed by the observer instance.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {Set}
+		 */
+		this._elements = new Set();
+
+		/**
+		 * Cached DOM {@link #_elements elements} bounding rects to compare to upon the next check.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {Map.<HTMLElement,module:utils/dom/rect~Rect>}
+		 */
+		this._previousRects = new Map();
+
+		/**
+		 * An UID of the current timeout upon which the observed elements rects
+		 * will be compared to the {@link #_previousRects previous rects} from the past.
+		 *
+		 * @readonly
+		 * @protected
+		 * @member {Map.<HTMLElement,module:utils/dom/rect~Rect>}
+		 */
+		this._periodicCheckTimeout = null;
+	}
+
+	/**
+	 * Starts observing a DOM element.
+	 *
+	 * Learn more in the
+	 * [native method documentation](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/observe).
+	 *
+	 * @param {HTMLElement} element
+	 */
+	observe( element ) {
+		this._elements.add( element );
+
+		this._checkElementRectsAndExecuteCallback();
+
+		if ( this._elements.size === 1 ) {
+			this._startPeriodicCheck();
+		}
+	}
+
+	/**
+	 * Stops observing a DOM element.
+	 *
+	 * Learn more in the
+	 * [native method documentation](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/unobserve).
+	 *
+	 * @param {HTMLElement} element
+	 */
+	unobserve( element ) {
+		this._elements.delete( element );
+		this._previousRects.delete( element );
+
+		if ( !this._elements.size ) {
+			this._stopPeriodicCheck();
+		}
+	}
+
+	/**
+	 * When called, the observer calls the {@link #_callback resize callback} for all observed
+	 * {@link #_elements elements} but also starts checking periodically for changes in the elements' geometry.
+	 * If some are detected, {@link #_callback resize callback} is called for relevant elements that were resized.
+	 *
+	 * @protected
+	 */
+	_startPeriodicCheck() {
+		const periodicCheck = () => {
+			this._checkElementRectsAndExecuteCallback();
+			this._periodicCheckTimeout = setTimeout( periodicCheck, RESIZE_CHECK_INTERVAL );
+		};
+
+		this.listenTo( global.window, 'resize', () => {
+			this._checkElementRectsAndExecuteCallback();
+		} );
+
+		this._periodicCheckTimeout = setTimeout( periodicCheck, RESIZE_CHECK_INTERVAL );
+	}
+
+	/**
+	 * Stops checking for changes in all observed {@link #_elements elements} geometry.
+	 *
+	 * @protected
+	 */
+	_stopPeriodicCheck() {
+		clearTimeout( this._periodicCheckTimeout );
+		this.stopListening();
+		this._previousRects.clear();
+	}
+
+	/**
+	 * Checks if the geometry of any of the {@link #_elements element} has changed. If so, executes
+	 * the {@link #_callback resize callback} with element geometry data.
+	 *
+	 * @protected
+	 */
+	_checkElementRectsAndExecuteCallback() {
+		const entries = [];
+
+		for ( const element of this._elements ) {
+			if ( this._hasRectChanged( element ) ) {
+				entries.push( {
+					target: element,
+					contentRect: this._previousRects.get( element )
+				} );
+			}
+		}
+
+		if ( entries.length ) {
+			this._callback( entries );
+		}
+	}
+
+	/**
+	 * Compares the DOM element geometry to the {@link #_previousRects cached geometry} from the past.
+	 * Returns `true` if geometry has changed or the element is checked for the first time.
+	 *
+	 * @protected
+	 * @param {HTMLElement} element
+	 * @returns {Boolean}
+	 */
+	_hasRectChanged( element ) {
+		if ( !element.ownerDocument.body.contains( element ) ) {
+			return false;
+		}
+
+		const currentRect = new Rect( element );
+		const previousRect = this._previousRects.get( element );
+
+		// The first check should always yield true despite no Previous rect to compare to.
+		// The native ResizeObserver does that and... that makes sense. Sort of.
+		const hasChanged = !previousRect || !previousRect.isEqual( currentRect );
+
+		this._previousRects.set( element, currentRect );
+
+		return hasChanged;
+	}
+}
+
+mix( ResizeObserverPolyfill, DomEmitterMixin );

+ 0 - 301
packages/ckeditor5-utils/tests/dom/getresizeobserver.js

@@ -1,301 +0,0 @@
-/**
- * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
- */
-
-/* globals document, setTimeout, Event, console */
-
-import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
-
-import Rect from '../../src/dom/rect';
-import global from '../../src/dom/global';
-import DomEmitterMixin from '../../src/dom/emittermixin';
-
-import getResizeObserver from '../../src/dom/getresizeobserver';
-
-describe( 'getResizeObserver()', () => {
-	testUtils.createSinonSandbox();
-
-	it( 'returns the native implementation if available', () => {
-		testUtils.sinon.stub( global.window, 'ResizeObserver' ).callsFake( function( callback ) {
-			this.callback = callback;
-		} );
-
-		expect( getResizeObserver( 'foo' ).callback ).to.equal( 'foo' );
-	} );
-
-	it( 'returns the polyfill when no native implementation available', () => {
-		testUtils.sinon.stub( global.window, 'ResizeObserver' ).value( null );
-
-		expect( getResizeObserver().constructor.name ).to.equal( 'ResizeObserverPolyfill' );
-	} );
-
-	describe( 'ResizeObserverPolyfill', () => {
-		let elementA, elementB, observer, callback, elementRectA, elementRectB;
-
-		beforeEach( () => {
-			testUtils.sinon.stub( global.window, 'ResizeObserver' ).value( null );
-
-			callback = sinon.spy();
-			observer = getResizeObserver( callback );
-
-			elementA = document.createElement( 'div' );
-			elementB = document.createElement( 'div' );
-
-			elementRectA = {
-				top: 10,
-				right: 20,
-				bottom: 20,
-				left: 0,
-				height: 10,
-				width: 20
-			};
-
-			elementRectB = {
-				top: 0,
-				right: 10,
-				bottom: 10,
-				left: 0,
-				height: 10,
-				width: 10
-			};
-
-			elementA.getBoundingClientRect = () => elementRectA;
-			elementB.getBoundingClientRect = () => elementRectB;
-
-			document.body.appendChild( elementA );
-			document.body.appendChild( elementB );
-		} );
-
-		afterEach( () => {
-			observer.disconnect();
-
-			elementA.remove();
-			elementB.remove();
-		} );
-
-		it( 'mixes DomEmitterMixin', () => {
-			expect( testUtils.isMixed( getResizeObserver().constructor, DomEmitterMixin ) ).to.be.true;
-		} );
-
-		describe( 'observe()', () => {
-			it( 'calls the callback immediatelly', () => {
-				observer.observe( elementA );
-
-				sinon.assert.calledOnce( callback );
-
-				const { target, contentRect } = callback.firstCall.args[ 0 ][ 0 ];
-
-				expect( target ).to.equal( elementA );
-				expect( contentRect ).to.be.instanceOf( Rect );
-				expect( contentRect ).to.deep.equal( elementRectA );
-			} );
-
-			it( 'does not execute the callback if element has no parent in DOM', () => {
-				const warnSpy = testUtils.sinon.spy( console, 'warn' );
-
-				elementA.remove();
-				observer.observe( elementA );
-
-				sinon.assert.notCalled( callback );
-				sinon.assert.notCalled( warnSpy );
-			} );
-
-			it( 'starts periodic check and asynchronously does not execute the callback if the element rect is the same', done => {
-				observer.observe( elementA );
-
-				setTimeout( () => {
-					sinon.assert.calledOnce( callback );
-					done();
-				}, 200 );
-			} );
-
-			it( 'starts periodic check and asynchronously executes the callback if the element rect changed', done => {
-				observer.observe( elementA );
-				sinon.assert.calledOnce( callback );
-
-				const newRect = {
-					top: 30,
-					right: 10,
-					bottom: 40,
-					left: 0,
-					height: 10,
-					width: 10
-				};
-
-				elementA.getBoundingClientRect = () => newRect;
-
-				setTimeout( () => {
-					sinon.assert.calledTwice( callback );
-
-					const { target, contentRect } = callback.secondCall.args[ 0 ][ 0 ];
-
-					expect( target ).to.equal( elementA );
-					expect( contentRect ).to.deep.equal( newRect );
-
-					done();
-				}, 200 );
-			} );
-
-			it( 'starts periodic check and asynchronously executes the callback if multiple element rects changed', done => {
-				observer.observe( elementA );
-				observer.observe( elementB );
-				sinon.assert.calledOnce( callback );
-
-				const newRectA = {
-					top: 30,
-					right: 10,
-					bottom: 40,
-					left: 0,
-					height: 10,
-					width: 10
-				};
-
-				const newRectB = {
-					top: 30,
-					right: 100,
-					bottom: 40,
-					left: 0,
-					height: 10,
-					width: 100
-				};
-
-				elementA.getBoundingClientRect = () => newRectA;
-				elementB.getBoundingClientRect = () => newRectB;
-
-				setTimeout( () => {
-					sinon.assert.calledTwice( callback );
-
-					const { target: targetA, contentRect: contentRectA } = callback.secondCall.args[ 0 ][ 0 ];
-					const { target: targetB, contentRect: contentRectB } = callback.secondCall.args[ 0 ][ 1 ];
-
-					expect( targetA ).to.equal( elementA );
-					expect( contentRectA ).to.deep.equal( newRectA );
-
-					expect( targetB ).to.equal( elementB );
-					expect( contentRectB ).to.deep.equal( newRectB );
-
-					done();
-				}, 200 );
-			} );
-
-			it( 'starts periodic check and synchronously responds to window resize', () => {
-				observer.observe( elementA );
-				sinon.assert.calledOnce( callback );
-
-				const newRectA = {
-					top: 30,
-					right: 10,
-					bottom: 40,
-					left: 0,
-					height: 10,
-					width: 10
-				};
-
-				elementA.getBoundingClientRect = () => newRectA;
-
-				global.window.dispatchEvent( new Event( 'resize' ) );
-
-				sinon.assert.calledTwice( callback );
-
-				const { target: targetA, contentRect: contentRectA } = callback.secondCall.args[ 0 ][ 0 ];
-
-				expect( targetA ).to.equal( elementA );
-				expect( contentRectA ).to.deep.equal( newRectA );
-			} );
-		} );
-
-		describe( 'unobserve()', () => {
-			it( 'removes the element from the observer so no future changes to the element execute the callback', done => {
-				observer.observe( elementA );
-				sinon.assert.calledOnce( callback );
-
-				const newRect = {
-					top: 30,
-					right: 10,
-					bottom: 40,
-					left: 0,
-					height: 10,
-					width: 10
-				};
-
-				observer.unobserve( elementA );
-
-				elementA.getBoundingClientRect = () => newRect;
-
-				setTimeout( () => {
-					sinon.assert.calledOnce( callback );
-
-					done();
-				}, 200 );
-			} );
-
-			it( 'does not affect the callback being executed for other elements in the observer', done => {
-				observer.observe( elementA );
-				observer.observe( elementB );
-				sinon.assert.calledOnce( callback );
-
-				const newRectA = {
-					top: 30,
-					right: 10,
-					bottom: 40,
-					left: 0,
-					height: 10,
-					width: 10
-				};
-
-				const newRectB = {
-					top: 30,
-					right: 100,
-					bottom: 40,
-					left: 0,
-					height: 10,
-					width: 100
-				};
-
-				elementA.getBoundingClientRect = () => newRectA;
-				elementB.getBoundingClientRect = () => newRectB;
-
-				observer.unobserve( elementA );
-
-				setTimeout( () => {
-					sinon.assert.calledTwice( callback );
-
-					const { target: targetB, contentRect: contentRectB } = callback.secondCall.args[ 0 ][ 0 ];
-
-					expect( callback.secondCall.args[ 0 ] ).to.have.length( 1 );
-
-					expect( targetB ).to.equal( elementB );
-					expect( contentRectB ).to.deep.equal( newRectB );
-
-					done();
-				}, 200 );
-			} );
-
-			it( 'disables the Emitter when no elements left in the observer', () => {
-				const stopCheckSpy = testUtils.sinon.spy( observer, '_stopPeriodicCheck' );
-
-				observer.observe( elementA );
-				sinon.assert.calledOnce( callback );
-
-				observer.unobserve( elementA );
-				sinon.assert.calledOnce( stopCheckSpy );
-			} );
-		} );
-
-		describe( 'disconnect()', () => {
-			it( 'calls unobserve() for all observed elements', () => {
-				observer.observe( elementA );
-				observer.observe( elementB );
-
-				const unobserveSpy = testUtils.sinon.spy( observer, 'unobserve' );
-
-				observer.disconnect();
-
-				sinon.assert.calledTwice( unobserveSpy );
-				sinon.assert.calledWith( unobserveSpy.firstCall, elementA );
-				sinon.assert.calledWith( unobserveSpy.secondCall, elementB );
-			} );
-		} );
-	} );
-} );

+ 447 - 0
packages/ckeditor5-utils/tests/dom/resizeobserver.js

@@ -0,0 +1,447 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document, setTimeout, Event, console */
+
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+import Rect from '../../src/dom/rect';
+import global from '../../src/dom/global';
+import DomEmitterMixin from '../../src/dom/emittermixin';
+import ResizeObserver from '../../src/dom/resizeobserver';
+
+describe( 'ResizeObserver()', () => {
+	let elementA, elementB;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		elementA = document.createElement( 'div' );
+		elementA.id = 'A';
+		elementB = document.createElement( 'div' );
+		elementB.id = 'B';
+	} );
+
+	afterEach( () => {
+		// Make it look like the module was loaded from scratch.
+		ResizeObserver._observerInstance = null;
+		ResizeObserver._elementCallbacks = null;
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should use the native implementation if available', () => {
+			const spy = sinon.spy();
+
+			testUtils.sinon.stub( global.window, 'ResizeObserver' ).callsFake( () => {
+				return {
+					observe: spy,
+					unobserve: sinon.spy()
+				};
+			} );
+
+			const observer = new ResizeObserver( elementA, () => {} );
+
+			sinon.assert.calledOnce( spy );
+
+			observer.destroy();
+		} );
+
+		it( 'should use the polyfill when no native implementation available', () => {
+			testUtils.sinon.stub( global.window, 'ResizeObserver' ).value( null );
+
+			const observer = new ResizeObserver( elementA, () => {} );
+
+			expect( ResizeObserver._observerInstance.constructor.name ).to.equal( 'ResizeObserverPolyfill' );
+
+			observer.destroy();
+		} );
+
+		it( 'should re-use the same native observer instance over and over again', () => {
+			const elementA = document.createElement( 'div' );
+			const elementB = document.createElement( 'div' );
+
+			testUtils.sinon.stub( global.window, 'ResizeObserver' ).callsFake( () => {
+				return {
+					observe() {},
+					unobserve() {}
+				};
+			} );
+
+			const observerA = new ResizeObserver( elementA, () => {} );
+			const observerB = new ResizeObserver( elementB, () => {} );
+
+			sinon.assert.calledOnce( global.window.ResizeObserver );
+
+			observerA.destroy();
+			observerB.destroy();
+		} );
+
+		it( 'should react to resizing of an element', () => {
+			const callbackA = sinon.spy();
+			let resizeCallback;
+
+			testUtils.sinon.stub( global.window, 'ResizeObserver' ).callsFake( callback => {
+				resizeCallback = callback;
+
+				return {
+					observe() {},
+					unobserve() {}
+				};
+			} );
+
+			const observerA = new ResizeObserver( elementA, callbackA );
+
+			resizeCallback( [
+				{ target: elementA }
+			] );
+
+			sinon.assert.calledOnce( callbackA );
+			sinon.assert.calledWithExactly( callbackA.firstCall, { target: elementA } );
+
+			observerA.destroy();
+		} );
+
+		it( 'should be able to observe the same element along with other observers', () => {
+			const callbackA = sinon.spy();
+			const callbackB = sinon.spy();
+			let resizeCallback;
+
+			testUtils.sinon.stub( global.window, 'ResizeObserver' ).callsFake( callback => {
+				resizeCallback = callback;
+
+				return {
+					observe() {},
+					unobserve() {}
+				};
+			} );
+
+			const observerA = new ResizeObserver( elementA, callbackA );
+			const observerB = new ResizeObserver( elementA, callbackB );
+
+			resizeCallback( [
+				{ target: elementA }
+			] );
+
+			sinon.assert.calledOnce( callbackA );
+			sinon.assert.calledWithExactly( callbackA, { target: elementA } );
+			sinon.assert.calledOnce( callbackB );
+			sinon.assert.calledWithExactly( callbackB, { target: elementA } );
+
+			observerA.destroy();
+			observerB.destroy();
+		} );
+
+		it( 'should not be affected by other observers being destroyed', () => {
+			const callbackA = sinon.spy();
+			const callbackB = sinon.spy();
+			let resizeCallback;
+
+			testUtils.sinon.stub( global.window, 'ResizeObserver' ).callsFake( callback => {
+				resizeCallback = callback;
+
+				return {
+					observe() {},
+					unobserve() {}
+				};
+			} );
+
+			const observerA = new ResizeObserver( elementA, callbackA );
+			const observerB = new ResizeObserver( elementA, callbackB );
+
+			resizeCallback( [
+				{ target: elementA }
+			] );
+
+			sinon.assert.calledOnce( callbackA );
+			sinon.assert.calledWithExactly( callbackA, { target: elementA } );
+			sinon.assert.calledOnce( callbackB );
+			sinon.assert.calledWithExactly( callbackB, { target: elementA } );
+
+			observerB.destroy();
+
+			resizeCallback( [
+				{ target: elementA }
+			] );
+
+			sinon.assert.calledTwice( callbackA );
+			sinon.assert.calledWithExactly( callbackA.secondCall, { target: elementA } );
+			sinon.assert.calledOnce( callbackB );
+			sinon.assert.calledWithExactly( callbackB, { target: elementA } );
+
+			observerA.destroy();
+		} );
+	} );
+
+	describe( 'destroy()', () => {
+		it( 'should make the observer stop responding to resize of an element', () => {
+			const callbackA = sinon.spy();
+			let resizeCallback;
+
+			testUtils.sinon.stub( global.window, 'ResizeObserver' ).callsFake( callback => {
+				resizeCallback = callback;
+
+				return {
+					observe() {},
+					unobserve() {}
+				};
+			} );
+
+			const observerA = new ResizeObserver( elementA, callbackA );
+
+			resizeCallback( [
+				{ target: elementA }
+			] );
+
+			sinon.assert.calledOnce( callbackA );
+
+			observerA.destroy();
+
+			resizeCallback( [
+				{ target: elementA }
+			] );
+
+			sinon.assert.calledOnce( callbackA );
+		} );
+
+		it( 'should not throw if called multiple times', () => {
+			const callbackA = sinon.spy();
+			const observerA = new ResizeObserver( elementA, callbackA );
+
+			expect( () => {
+				observerA.destroy();
+				observerA.destroy();
+			} ).should.not.throw;
+		} );
+	} );
+
+	describe( 'ResizeObserverPolyfill', () => {
+		let callback, elementRectA, elementRectB;
+
+		beforeEach( () => {
+			testUtils.sinon.stub( global.window, 'ResizeObserver' ).value( null );
+
+			callback = sinon.spy();
+
+			elementRectA = {
+				top: 10,
+				right: 20,
+				bottom: 20,
+				left: 0,
+				height: 10,
+				width: 20
+			};
+
+			elementRectB = {
+				top: 0,
+				right: 10,
+				bottom: 10,
+				left: 0,
+				height: 10,
+				width: 10
+			};
+
+			elementA.getBoundingClientRect = () => elementRectA;
+			elementB.getBoundingClientRect = () => elementRectB;
+
+			document.body.appendChild( elementA );
+			document.body.appendChild( elementB );
+		} );
+
+		afterEach( () => {
+			elementA.remove();
+			elementB.remove();
+		} );
+
+		it( 'mixes DomEmitterMixin', () => {
+			const observer = new ResizeObserver( elementA, () => {} );
+
+			expect( testUtils.isMixed( ResizeObserver._observerInstance.constructor, DomEmitterMixin ) ).to.be.true;
+
+			observer.destroy();
+		} );
+
+		describe( 'observe()', () => {
+			it( 'calls the callback immediatelly', () => {
+				const observer = new ResizeObserver( elementA, callback );
+
+				sinon.assert.calledOnce( callback );
+
+				const { target, contentRect } = callback.firstCall.args[ 0 ];
+
+				expect( target ).to.equal( elementA );
+				expect( contentRect ).to.be.instanceOf( Rect );
+				expect( contentRect ).to.deep.equal( elementRectA );
+
+				observer.destroy();
+			} );
+
+			it( 'does not execute the callback if element has no parent in DOM', () => {
+				const warnSpy = testUtils.sinon.spy( console, 'warn' );
+
+				elementA.remove();
+
+				const observer = new ResizeObserver( elementA, callback );
+
+				sinon.assert.notCalled( callback );
+				sinon.assert.notCalled( warnSpy );
+
+				observer.destroy();
+			} );
+
+			it( 'starts periodic check and asynchronously does not execute the callback if the element rect is the same', done => {
+				const observer = new ResizeObserver( elementA, callback );
+
+				setTimeout( () => {
+					sinon.assert.calledOnce( callback );
+
+					observer.destroy();
+					done();
+				}, 200 );
+			} );
+
+			it( 'starts periodic check and asynchronously executes the callback if the element rect changed', done => {
+				const observer = new ResizeObserver( elementA, callback );
+
+				sinon.assert.calledOnce( callback );
+
+				const newRect = {
+					top: 30,
+					right: 10,
+					bottom: 40,
+					left: 0,
+					height: 10,
+					width: 10
+				};
+
+				elementA.getBoundingClientRect = () => newRect;
+
+				setTimeout( () => {
+					sinon.assert.calledTwice( callback );
+
+					const { target, contentRect } = callback.secondCall.args[ 0 ];
+
+					expect( target ).to.equal( elementA );
+					expect( contentRect ).to.deep.equal( newRect );
+
+					observer.destroy();
+					done();
+				}, 200 );
+			} );
+
+			it( 'starts periodic check and asynchronously executes the callback if multiple element rects changed', done => {
+				const callbackA = sinon.spy();
+				const callbackB = sinon.spy();
+
+				const observerA = new ResizeObserver( elementA, callbackA );
+				const observerB = new ResizeObserver( elementB, callbackB );
+
+				sinon.assert.calledOnce( callbackA );
+				sinon.assert.calledOnce( callbackB );
+
+				const newRectA = {
+					top: 30,
+					right: 10,
+					bottom: 40,
+					left: 0,
+					height: 10,
+					width: 10
+				};
+
+				const newRectB = {
+					top: 30,
+					right: 100,
+					bottom: 40,
+					left: 0,
+					height: 10,
+					width: 100
+				};
+
+				elementA.getBoundingClientRect = () => newRectA;
+				elementB.getBoundingClientRect = () => newRectB;
+
+				setTimeout( () => {
+					sinon.assert.calledTwice( callbackA );
+					sinon.assert.calledTwice( callbackB );
+
+					const { target: targetA, contentRect: contentRectA } = callbackA.secondCall.args[ 0 ];
+					const { target: targetB, contentRect: contentRectB } = callbackB.secondCall.args[ 0 ];
+
+					expect( targetA ).to.equal( elementA );
+					expect( contentRectA ).to.deep.equal( newRectA );
+
+					expect( targetB ).to.equal( elementB );
+					expect( contentRectB ).to.deep.equal( newRectB );
+
+					observerA.destroy();
+					observerB.destroy();
+					done();
+				}, 200 );
+			} );
+
+			it( 'starts periodic check and synchronously responds to window resize', () => {
+				const observer = new ResizeObserver( elementA, callback );
+				sinon.assert.calledOnce( callback );
+
+				const newRectA = {
+					top: 30,
+					right: 10,
+					bottom: 40,
+					left: 0,
+					height: 10,
+					width: 10
+				};
+
+				elementA.getBoundingClientRect = () => newRectA;
+
+				global.window.dispatchEvent( new Event( 'resize' ) );
+
+				sinon.assert.calledTwice( callback );
+
+				const { target: targetA, contentRect: contentRectA } = callback.secondCall.args[ 0 ];
+
+				expect( targetA ).to.equal( elementA );
+				expect( contentRectA ).to.deep.equal( newRectA );
+
+				observer.destroy();
+			} );
+		} );
+
+		describe( 'unobserve()', () => {
+			it( 'removes the element from the observer so no future changes to the element execute the callback', done => {
+				const observer = new ResizeObserver( elementA, callback );
+				sinon.assert.calledOnce( callback );
+
+				const newRect = {
+					top: 30,
+					right: 10,
+					bottom: 40,
+					left: 0,
+					height: 10,
+					width: 10
+				};
+
+				observer.destroy();
+
+				elementA.getBoundingClientRect = () => newRect;
+
+				setTimeout( () => {
+					sinon.assert.calledOnce( callback );
+
+					done();
+				}, 200 );
+			} );
+
+			it( 'disables the Emitter when no elements left in the observer', () => {
+				const observer = new ResizeObserver( elementA, callback );
+				const stopCheckSpy = testUtils.sinon.spy( ResizeObserver._observerInstance, '_stopPeriodicCheck' );
+
+				sinon.assert.calledOnce( callback );
+
+				observer.destroy();
+				sinon.assert.calledOnce( stopCheckSpy );
+			} );
+		} );
+	} );
+} );