8
0

resizeobserver.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  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. // Do not execute callbacks for elements that are invisible.
  150. // https://github.com/ckeditor/ckeditor5/issues/6570
  151. if ( !entry.target.offsetParent ) {
  152. continue;
  153. }
  154. const callbacks = ResizeObserver._getElementCallbacks( entry.target );
  155. if ( callbacks ) {
  156. for ( const callback of callbacks ) {
  157. callback( entry );
  158. }
  159. }
  160. }
  161. } );
  162. }
  163. }
  164. /**
  165. * The single native observer instance (or polyfill in browsers that do not support the API)
  166. * shared across all {@link module:utils/dom/resizeobserver~ResizeObserver} instances.
  167. *
  168. * @static
  169. * @protected
  170. * @readonly
  171. * @property {Object|null} module:utils/dom/resizeobserver~ResizeObserver#_observerInstance
  172. */
  173. ResizeObserver._observerInstance = null;
  174. /**
  175. * A mapping of native DOM elements and their callbacks shared across all
  176. * {@link module:utils/dom/resizeobserver~ResizeObserver} instances.
  177. *
  178. * @static
  179. * @private
  180. * @readonly
  181. * @property {Map.<HTMLElement,Set>|null} module:utils/dom/resizeobserver~ResizeObserver#_elementCallbacks
  182. */
  183. ResizeObserver._elementCallbacks = null;
  184. /**
  185. * A polyfill class for the native [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver).
  186. *
  187. * @private
  188. * @mixes module:utils/domemittermixin~DomEmitterMixin
  189. */
  190. class ResizeObserverPolyfill {
  191. /**
  192. * Creates an instance of the {@link module:utils/dom/resizeobserver~ResizeObserverPolyfill} class.
  193. *
  194. * It synchronously reacts to resize of the window to check if observed elements' geometry changed.
  195. *
  196. * Additionally, the polyfilled observer uses a timeout to check if observed elements' geometry has changed
  197. * in some other way (dynamic layouts, scrollbars showing up, etc.), so its response can also be asynchronous.
  198. *
  199. * @param {Function} callback A function called when any observed element was resized. Refer to the
  200. * native [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) API to
  201. * learn more.
  202. */
  203. constructor( callback ) {
  204. /**
  205. * A function called when any observed {@link #_elements element} was resized.
  206. *
  207. * @readonly
  208. * @protected
  209. * @member {Function}
  210. */
  211. this._callback = callback;
  212. /**
  213. * DOM elements currently observed by the observer instance.
  214. *
  215. * @readonly
  216. * @protected
  217. * @member {Set}
  218. */
  219. this._elements = new Set();
  220. /**
  221. * Cached DOM {@link #_elements elements} bounding rects to compare to upon the next check.
  222. *
  223. * @readonly
  224. * @protected
  225. * @member {Map.<HTMLElement,module:utils/dom/rect~Rect>}
  226. */
  227. this._previousRects = new Map();
  228. /**
  229. * An UID of the current timeout upon which the observed elements rects
  230. * will be compared to the {@link #_previousRects previous rects} from the past.
  231. *
  232. * @readonly
  233. * @protected
  234. * @member {Map.<HTMLElement,module:utils/dom/rect~Rect>}
  235. */
  236. this._periodicCheckTimeout = null;
  237. }
  238. /**
  239. * Starts observing a DOM element.
  240. *
  241. * Learn more in the
  242. * [native method documentation](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/observe).
  243. *
  244. * @param {HTMLElement} element
  245. */
  246. observe( element ) {
  247. this._elements.add( element );
  248. this._checkElementRectsAndExecuteCallback();
  249. if ( this._elements.size === 1 ) {
  250. this._startPeriodicCheck();
  251. }
  252. }
  253. /**
  254. * Stops observing a DOM element.
  255. *
  256. * Learn more in the
  257. * [native method documentation](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/unobserve).
  258. *
  259. * @param {HTMLElement} element
  260. */
  261. unobserve( element ) {
  262. this._elements.delete( element );
  263. this._previousRects.delete( element );
  264. if ( !this._elements.size ) {
  265. this._stopPeriodicCheck();
  266. }
  267. }
  268. /**
  269. * When called, the observer calls the {@link #_callback resize callback} for all observed
  270. * {@link #_elements elements} but also starts checking periodically for changes in the elements' geometry.
  271. * If some are detected, {@link #_callback resize callback} is called for relevant elements that were resized.
  272. *
  273. * @protected
  274. */
  275. _startPeriodicCheck() {
  276. const periodicCheck = () => {
  277. this._checkElementRectsAndExecuteCallback();
  278. this._periodicCheckTimeout = setTimeout( periodicCheck, RESIZE_CHECK_INTERVAL );
  279. };
  280. this.listenTo( global.window, 'resize', () => {
  281. this._checkElementRectsAndExecuteCallback();
  282. } );
  283. this._periodicCheckTimeout = setTimeout( periodicCheck, RESIZE_CHECK_INTERVAL );
  284. }
  285. /**
  286. * Stops checking for changes in all observed {@link #_elements elements} geometry.
  287. *
  288. * @protected
  289. */
  290. _stopPeriodicCheck() {
  291. clearTimeout( this._periodicCheckTimeout );
  292. this.stopListening();
  293. this._previousRects.clear();
  294. }
  295. /**
  296. * Checks if the geometry of any of the {@link #_elements element} has changed. If so, executes
  297. * the {@link #_callback resize callback} with element geometry data.
  298. *
  299. * @protected
  300. */
  301. _checkElementRectsAndExecuteCallback() {
  302. const entries = [];
  303. for ( const element of this._elements ) {
  304. if ( this._hasRectChanged( element ) ) {
  305. entries.push( {
  306. target: element,
  307. contentRect: this._previousRects.get( element )
  308. } );
  309. }
  310. }
  311. if ( entries.length ) {
  312. this._callback( entries );
  313. }
  314. }
  315. /**
  316. * Compares the DOM element geometry to the {@link #_previousRects cached geometry} from the past.
  317. * Returns `true` if geometry has changed or the element is checked for the first time.
  318. *
  319. * @protected
  320. * @param {HTMLElement} element
  321. * @returns {Boolean}
  322. */
  323. _hasRectChanged( element ) {
  324. if ( !element.ownerDocument.body.contains( element ) ) {
  325. return false;
  326. }
  327. const currentRect = new Rect( element );
  328. const previousRect = this._previousRects.get( element );
  329. // The first check should always yield true despite no Previous rect to compare to.
  330. // The native ResizeObserver does that and... that makes sense. Sort of.
  331. const hasChanged = !previousRect || !previousRect.isEqual( currentRect );
  332. this._previousRects.set( element, currentRect );
  333. return hasChanged;
  334. }
  335. }
  336. mix( ResizeObserverPolyfill, DomEmitterMixin );