import isBrowser from './utils/isBrowser.js'; import throttle from './utils/throttle.js'; // Minimum delay before invoking the update of observers. const REFRESH_DELAY = 20; // A list of substrings of CSS properties used to find transition events that // might affect dimensions of observed elements. const transitionKeys = ['top', 'right', 'bottom', 'left', 'width', 'height', 'size', 'weight']; // Check if MutationObserver is available. const mutationObserverSupported = typeof MutationObserver !== 'undefined'; /** * Singleton controller class which handles updates of ResizeObserver instances. */ export default class ResizeObserverController { /** * Indicates whether DOM listeners have been added. * * @private {boolean} */ connected_ = false; /** * Tells that controller has subscribed for Mutation Events. * * @private {boolean} */ mutationEventsAdded_ = false; /** * Keeps reference to the instance of MutationObserver. * * @private {MutationObserver} */ mutationsObserver_ = null; /** * A list of connected observers. * * @private {Array} */ observers_ = []; /** * Holds reference to the controller's instance. * * @private {ResizeObserverController} */ static instance_ = null; /** * Creates a new instance of ResizeObserverController. * * @private */ constructor() { this.onTransitionEnd_ = this.onTransitionEnd_.bind(this); this.refresh = throttle(this.refresh.bind(this), REFRESH_DELAY); } /** * Adds observer to observers list. * * @param {ResizeObserverSPI} observer - Observer to be added. * @returns {void} */ addObserver(observer) { if (!~this.observers_.indexOf(observer)) { this.observers_.push(observer); } // Add listeners if they haven't been added yet. if (!this.connected_) { this.connect_(); } } /** * Removes observer from observers list. * * @param {ResizeObserverSPI} observer - Observer to be removed. * @returns {void} */ removeObserver(observer) { const observers = this.observers_; const index = observers.indexOf(observer); // Remove observer if it's present in registry. if (~index) { observers.splice(index, 1); } // Remove listeners if controller has no connected observers. if (!observers.length && this.connected_) { this.disconnect_(); } } /** * Invokes the update of observers. It will continue running updates insofar * it detects changes. * * @returns {void} */ refresh() { const changesDetected = this.updateObservers_(); // Continue running updates if changes have been detected as there might // be future ones caused by CSS transitions. if (changesDetected) { this.refresh(); } } /** * Updates every observer from observers list and notifies them of queued * entries. * * @private * @returns {boolean} Returns "true" if any observer has detected changes in * dimensions of it's elements. */ updateObservers_() { // Collect observers that have active observations. const activeObservers = this.observers_.filter(observer => { return observer.gatherActive(), observer.hasActive(); }); // Deliver notifications in a separate cycle in order to avoid any // collisions between observers, e.g. when multiple instances of // ResizeObserver are tracking the same element and the callback of one // of them changes content dimensions of the observed target. Sometimes // this may result in notifications being blocked for the rest of observers. activeObservers.forEach(observer => observer.broadcastActive()); return activeObservers.length > 0; } /** * Initializes DOM listeners. * * @private * @returns {void} */ connect_() { // Do nothing if running in a non-browser environment or if listeners // have been already added. if (!isBrowser || this.connected_) { return; } // Subscription to the "Transitionend" event is used as a workaround for // delayed transitions. This way it's possible to capture at least the // final state of an element. document.addEventListener('transitionend', this.onTransitionEnd_); window.addEventListener('resize', this.refresh); if (mutationObserverSupported) { this.mutationsObserver_ = new MutationObserver(this.refresh); this.mutationsObserver_.observe(document, { attributes: true, childList: true, characterData: true, subtree: true }); } else { document.addEventListener('DOMSubtreeModified', this.refresh); this.mutationEventsAdded_ = true; } this.connected_ = true; } /** * Removes DOM listeners. * * @private * @returns {void} */ disconnect_() { // Do nothing if running in a non-browser environment or if listeners // have been already removed. if (!isBrowser || !this.connected_) { return; } document.removeEventListener('transitionend', this.onTransitionEnd_); window.removeEventListener('resize', this.refresh); if (this.mutationsObserver_) { this.mutationsObserver_.disconnect(); } if (this.mutationEventsAdded_) { document.removeEventListener('DOMSubtreeModified', this.refresh); } this.mutationsObserver_ = null; this.mutationEventsAdded_ = false; this.connected_ = false; } /** * "Transitionend" event handler. * * @private * @param {TransitionEvent} event * @returns {void} */ onTransitionEnd_({propertyName = ''}) { // Detect whether transition may affect dimensions of an element. const isReflowProperty = transitionKeys.some(key => { return !!~propertyName.indexOf(key); }); if (isReflowProperty) { this.refresh(); } } /** * Returns instance of the ResizeObserverController. * * @returns {ResizeObserverController} */ static getInstance() { if (!this.instance_) { this.instance_ = new ResizeObserverController(); } return this.instance_; } }