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

Docs and tests for the getResizeObserver helper.

Aleksander Nowodzinski 6 лет назад
Родитель
Сommit
2385da0ab7

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

@@ -0,0 +1,223 @@
+/**
+ * @license Copyright (c) 2003-2019, 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 '@ckeditor/ckeditor5-utils/src/mix';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/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.
+ *
+ * See https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver to learn more about the
+ * 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 {Function|module:utils/dom/getresizeobserver~ResizeObserverPolyfill} 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 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 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 past rects}.
+		 *
+		 * @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 ) {
+		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 - 110
packages/ckeditor5-utils/src/dom/resizeobserver.js

@@ -1,110 +0,0 @@
-/**
- * @license Copyright (c) 2003-2019, 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 '@ckeditor/ckeditor5-utils/src/mix';
-import global from '@ckeditor/ckeditor5-utils/src/dom/global';
-import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
-import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin';
-
-const RESIZE_CHECK_INTERVAL = 500;
-
-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 );
-	}
-}
-
-class ResizeObserverPolyfill {
-	constructor( callback ) {
-		this.callback = callback;
-		this.elements = new Set();
-
-		this._startPeriodicCheck();
-	}
-
-	observe( element ) {
-		this.elements.add( element );
-	}
-
-	unobserve( element ) {
-		this.elements.remove( element );
-		this._previousRects.delete( element );
-
-		if ( !this.elements.size ) {
-			this._stopPeriodicCheck();
-		}
-	}
-
-	disconnect() {
-		this.elements.forEach( element => this.unobserve( element ) );
-
-		this._stopPeriodicCheck();
-	}
-
-	_startPeriodicCheck() {
-		this._previousRects = new Map();
-
-		const periodicCheck = () => {
-			this._checkElementRectsAndExecuteCallbacks();
-			this._periodicCheckTimeout = setTimeout( periodicCheck, RESIZE_CHECK_INTERVAL );
-		};
-
-		this.listenTo( global.window, 'resize', () => {
-			this._checkElementRectsAndExecuteCallbacks();
-		} );
-
-		periodicCheck();
-	}
-
-	_stopPeriodicCheck() {
-		clearTimeout( this._periodicCheckTimeout );
-		this.stopListening();
-		this._previousRects.clear();
-	}
-
-	_checkElementRectsAndExecuteCallbacks() {
-		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 );
-		}
-	}
-
-	_hasRectChanged( element ) {
-		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 );

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

@@ -0,0 +1,289 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document, setTimeout, Event */
+
+import getResizeObserver from '../../src/dom/getresizeobserver';
+import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin';
+
+describe.only( '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( '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 );
+			} );
+		} );
+	} );
+} );