8
0

resizeobserver.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /**
  6. * @module utils/dom/resizeobserver
  7. */
  8. /* globals setTimeout, clearTimeout */
  9. import mix from '../mix';
  10. import global from './global';
  11. import Rect from './rect';
  12. import DomEmitterMixin from './emittermixin';
  13. const RESIZE_CHECK_INTERVAL = 100;
  14. /**
  15. * A helper class which instances allow performing custom actions when native DOM elements are resized.
  16. *
  17. * const editableElement = editor.editing.view.getDomRoot();
  18. *
  19. * const observer = new ResizeObserver( editableElement, entry => {
  20. * console.log( 'The editable element has been resized in DOM.' );
  21. * console.log( entry.target ); // -> editableElement
  22. * console.log( entry.contentRect.width ); // -> e.g. '423px'
  23. * } );
  24. *
  25. * By default, it uses the [native DOM resize observer](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver)
  26. * under the hood and in browsers that do not support the native API yet, a polyfilled observer is
  27. * used instead.
  28. */
  29. export default class ResizeObserver {
  30. /**
  31. * Creates an instance of the `ResizeObserver` class.
  32. *
  33. * @param {HTMLElement} element A DOM element that is to be observed for resizing. Note that
  34. * the element must be visible (i.e. not detached from DOM) for the observer to work.
  35. * @param {Function} callback A function called when the observed element was resized. It passes
  36. * the [`ResizeObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry)
  37. * object with information about the resize event.
  38. */
  39. constructor( element, callback ) {
  40. // **Note**: For the maximum performance, this class ensures only a single instance of the native
  41. // (or polyfilled) observer is used no matter how many instances of this class were created.
  42. if ( !ResizeObserver._observerInstance ) {
  43. ResizeObserver._createObserver();
  44. }
  45. /**
  46. * The element observer by this observer.
  47. *
  48. * @readonly
  49. * @private
  50. * @member {HTMLElement}
  51. */
  52. this._element = element;
  53. /**
  54. * The callback executed each time {@link #_element} is resized.
  55. *
  56. * @readonly
  57. * @private
  58. * @member {Function}
  59. */
  60. this._callback = callback;
  61. ResizeObserver._addElementCallback( element, callback );
  62. ResizeObserver._observerInstance.observe( element );
  63. }
  64. /**
  65. * Destroys the observer which disables the `callback` passed to the {@link #constructor}.
  66. */
  67. destroy() {
  68. ResizeObserver._deleteElementCallback( this._element, this._callback );
  69. }
  70. /**
  71. * Registers a new resize callback for the DOM element.
  72. *
  73. * @private
  74. * @static
  75. * @param {HTMLElement} element
  76. * @param {Function} callback
  77. */
  78. static _addElementCallback( element, callback ) {
  79. if ( !ResizeObserver._elementCallbacks ) {
  80. ResizeObserver._elementCallbacks = new Map();
  81. }
  82. let callbacks = ResizeObserver._elementCallbacks.get( element );
  83. if ( !callbacks ) {
  84. callbacks = new Set();
  85. ResizeObserver._elementCallbacks.set( element, callbacks );
  86. }
  87. callbacks.add( callback );
  88. }
  89. /**
  90. * Removes a resize callback from the DOM element. If no callbacks are left
  91. * for the element, it removes the element from the native observer.
  92. *
  93. * @private
  94. * @static
  95. * @param {HTMLElement} element
  96. * @param {Function} callback
  97. */
  98. static _deleteElementCallback( element, callback ) {
  99. const callbacks = ResizeObserver._getElementCallbacks( element );
  100. // Remove the element callback. Check if exist first in case someone
  101. // called destroy() twice.
  102. if ( callbacks ) {
  103. callbacks.delete( callback );
  104. // If no callbacks left for the element, also remove the element.
  105. if ( !callbacks.size ) {
  106. ResizeObserver._elementCallbacks.delete( element );
  107. ResizeObserver._observerInstance.unobserve( element );
  108. }
  109. }
  110. if ( ResizeObserver._elementCallbacks && !ResizeObserver._elementCallbacks.size ) {
  111. ResizeObserver._observerInstance = null;
  112. ResizeObserver._elementCallbacks = null;
  113. }
  114. }
  115. /**
  116. * Returns are registered resize callbacks for the DOM element.
  117. *
  118. * @private
  119. * @static
  120. * @param {HTMLElement} element
  121. * @returns {Set.<HTMLElement>|null}
  122. */
  123. static _getElementCallbacks( element ) {
  124. if ( !ResizeObserver._elementCallbacks ) {
  125. return null;
  126. }
  127. return ResizeObserver._elementCallbacks.get( element );
  128. }
  129. /**
  130. * Creates the single native observer shared across all `ResizeObserver` instances.
  131. * If the browser does not support the native API, it creates a polyfill.
  132. *
  133. * @private
  134. * @static
  135. */
  136. static _createObserver() {
  137. let ObserverConstructor;
  138. // TODO: One day, the `ResizeObserver` API will be supported in all modern web browsers.
  139. // When it happens, this module will no longer make sense and should be removed and
  140. // the native implementation should be used across the project to save bytes.
  141. // Check out https://caniuse.com/#feat=resizeobserver.
  142. if ( typeof global.window.ResizeObserver === 'function' ) {
  143. ObserverConstructor = global.window.ResizeObserver;
  144. } else {
  145. ObserverConstructor = ResizeObserverPolyfill;
  146. }
  147. ResizeObserver._observerInstance = new ObserverConstructor( entries => {
  148. for ( const entry of entries ) {
  149. const callbacks = ResizeObserver._getElementCallbacks( entry.target );
  150. if ( callbacks ) {
  151. for ( const callback of callbacks ) {
  152. callback( entry );
  153. }
  154. }
  155. }
  156. } );
  157. }
  158. }
  159. /**
  160. * The single native observer instance (or polyfill in browsers that do not support the API)
  161. * shared across all {@link module:utils/dom/resizeobserver~ResizeObserver} instances.
  162. *
  163. * @static
  164. * @protected
  165. * @readonly
  166. * @property {Object|null} module:utils/dom/resizeobserver~ResizeObserver#_observerInstance
  167. */
  168. ResizeObserver._observerInstance = null;
  169. /**
  170. * A mapping of native DOM elements and their callbacks shared across all
  171. * {@link module:utils/dom/resizeobserver~ResizeObserver} instances.
  172. *
  173. * @static
  174. * @private
  175. * @readonly
  176. * @property {Map.<HTMLElement,Set>|null} module:utils/dom/resizeobserver~ResizeObserver#_elementCallbacks
  177. */
  178. ResizeObserver._elementCallbacks = null;
  179. /**
  180. * A polyfill class for the native [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver).
  181. *
  182. * @private
  183. * @mixes module:utils/domemittermixin~DomEmitterMixin
  184. */
  185. class ResizeObserverPolyfill {
  186. /**
  187. * Creates an instance of the {@link module:utils/dom/resizeobserver~ResizeObserverPolyfill} class.
  188. *
  189. * It synchronously reacts to resize of the window to check if observed elements' geometry changed.
  190. *
  191. * Additionally, the polyfilled observer uses a timeout to check if observed elements' geometry has changed
  192. * in some other way (dynamic layouts, scrollbars showing up, etc.), so its response can also be asynchronous.
  193. *
  194. * @param {Function} callback A function called when any observed element was resized. Refer to the
  195. * native [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) API to
  196. * learn more.
  197. */
  198. constructor( callback ) {
  199. /**
  200. * A function called when any observed {@link #_elements element} was resized.
  201. *
  202. * @readonly
  203. * @protected
  204. * @member {Function}
  205. */
  206. this._callback = callback;
  207. /**
  208. * DOM elements currently observed by the observer instance.
  209. *
  210. * @readonly
  211. * @protected
  212. * @member {Set}
  213. */
  214. this._elements = new Set();
  215. /**
  216. * Cached DOM {@link #_elements elements} bounding rects to compare to upon the next check.
  217. *
  218. * @readonly
  219. * @protected
  220. * @member {Map.<HTMLElement,module:utils/dom/rect~Rect>}
  221. */
  222. this._previousRects = new Map();
  223. /**
  224. * An UID of the current timeout upon which the observed elements rects
  225. * will be compared to the {@link #_previousRects previous rects} from the past.
  226. *
  227. * @readonly
  228. * @protected
  229. * @member {Map.<HTMLElement,module:utils/dom/rect~Rect>}
  230. */
  231. this._periodicCheckTimeout = null;
  232. }
  233. /**
  234. * Starts observing a DOM element.
  235. *
  236. * Learn more in the
  237. * [native method documentation](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/observe).
  238. *
  239. * @param {HTMLElement} element
  240. */
  241. observe( element ) {
  242. this._elements.add( element );
  243. this._checkElementRectsAndExecuteCallback();
  244. if ( this._elements.size === 1 ) {
  245. this._startPeriodicCheck();
  246. }
  247. }
  248. /**
  249. * Stops observing a DOM element.
  250. *
  251. * Learn more in the
  252. * [native method documentation](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/unobserve).
  253. *
  254. * @param {HTMLElement} element
  255. */
  256. unobserve( element ) {
  257. this._elements.delete( element );
  258. this._previousRects.delete( element );
  259. if ( !this._elements.size ) {
  260. this._stopPeriodicCheck();
  261. }
  262. }
  263. /**
  264. * When called, the observer calls the {@link #_callback resize callback} for all observed
  265. * {@link #_elements elements} but also starts checking periodically for changes in the elements' geometry.
  266. * If some are detected, {@link #_callback resize callback} is called for relevant elements that were resized.
  267. *
  268. * @protected
  269. */
  270. _startPeriodicCheck() {
  271. const periodicCheck = () => {
  272. this._checkElementRectsAndExecuteCallback();
  273. this._periodicCheckTimeout = setTimeout( periodicCheck, RESIZE_CHECK_INTERVAL );
  274. };
  275. this.listenTo( global.window, 'resize', () => {
  276. this._checkElementRectsAndExecuteCallback();
  277. } );
  278. this._periodicCheckTimeout = setTimeout( periodicCheck, RESIZE_CHECK_INTERVAL );
  279. }
  280. /**
  281. * Stops checking for changes in all observed {@link #_elements elements} geometry.
  282. *
  283. * @protected
  284. */
  285. _stopPeriodicCheck() {
  286. clearTimeout( this._periodicCheckTimeout );
  287. this.stopListening();
  288. this._previousRects.clear();
  289. }
  290. /**
  291. * Checks if the geometry of any of the {@link #_elements element} has changed. If so, executes
  292. * the {@link #_callback resize callback} with element geometry data.
  293. *
  294. * @protected
  295. */
  296. _checkElementRectsAndExecuteCallback() {
  297. const entries = [];
  298. for ( const element of this._elements ) {
  299. if ( this._hasRectChanged( element ) ) {
  300. entries.push( {
  301. target: element,
  302. contentRect: this._previousRects.get( element )
  303. } );
  304. }
  305. }
  306. if ( entries.length ) {
  307. this._callback( entries );
  308. }
  309. }
  310. /**
  311. * Compares the DOM element geometry to the {@link #_previousRects cached geometry} from the past.
  312. * Returns `true` if geometry has changed or the element is checked for the first time.
  313. *
  314. * @protected
  315. * @param {HTMLElement} element
  316. * @returns {Boolean}
  317. */
  318. _hasRectChanged( element ) {
  319. if ( !element.ownerDocument.body.contains( element ) ) {
  320. return false;
  321. }
  322. const currentRect = new Rect( element );
  323. const previousRect = this._previousRects.get( element );
  324. // The first check should always yield true despite no Previous rect to compare to.
  325. // The native ResizeObserver does that and... that makes sense. Sort of.
  326. const hasChanged = !previousRect || !previousRect.isEqual( currentRect );
  327. this._previousRects.set( element, currentRect );
  328. return hasChanged;
  329. }
  330. }
  331. mix( ResizeObserverPolyfill, DomEmitterMixin );