mutationobserver.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. /**
  2. * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module engine/view/observer/mutationobserver
  7. */
  8. /* globals window */
  9. import Observer from './observer';
  10. import ViewSelection from '../selection';
  11. import { startsWithFiller, getDataWithoutFiller } from '../filler';
  12. import { isEqualWith } from 'lodash-es';
  13. /**
  14. * Mutation observer class observes changes in the DOM, fires {@link module:engine/view/document~Document#event:mutations} event, mark view
  15. * elements as changed and call {@link module:engine/view/renderer~Renderer#render}.
  16. * Because all mutated nodes are marked as "to be rendered" and the
  17. * {@link module:engine/view/renderer~Renderer#render} is called, all changes will be reverted, unless the mutation will be handled by the
  18. * {@link module:engine/view/document~Document#event:mutations} event listener. It means user will see only handled changes, and the editor
  19. * will block all changes which are not handled.
  20. *
  21. * Mutation Observer also take care of reducing number of mutations which are fired. It removes duplicates and
  22. * mutations on elements which do not have corresponding view elements. Also
  23. * {@link module:engine/view/observer/mutationobserver~MutatedText text mutation} is fired only if parent element do not change child list.
  24. *
  25. * Note that this observer is attached by the {@link module:engine/view/view~View} and is available by default.
  26. *
  27. * @extends module:engine/view/observer/observer~Observer
  28. */
  29. export default class MutationObserver extends Observer {
  30. constructor( view ) {
  31. super( view );
  32. /**
  33. * Native mutation observer config.
  34. *
  35. * @private
  36. * @member {Object}
  37. */
  38. this._config = {
  39. childList: true,
  40. characterData: true,
  41. characterDataOldValue: true,
  42. subtree: true
  43. };
  44. /**
  45. * Reference to the {@link module:engine/view/view~View#domConverter}.
  46. *
  47. * @member {module:engine/view/domconverter~DomConverter}
  48. */
  49. this.domConverter = view.domConverter;
  50. /**
  51. * Reference to the {@link module:engine/view/view~View#renderer}.
  52. *
  53. * @member {module:engine/view/renderer~Renderer}
  54. */
  55. this.renderer = view._renderer;
  56. /**
  57. * Observed DOM elements.
  58. *
  59. * @private
  60. * @member {Array.<HTMLElement>}
  61. */
  62. this._domElements = [];
  63. /**
  64. * Native mutation observer.
  65. *
  66. * @private
  67. * @member {MutationObserver}
  68. */
  69. this._mutationObserver = new window.MutationObserver( this._onMutations.bind( this ) );
  70. }
  71. /**
  72. * Synchronously fires {@link module:engine/view/document~Document#event:mutations} event with all mutations in record queue.
  73. * At the same time empties the queue so mutations will not be fired twice.
  74. */
  75. flush() {
  76. this._onMutations( this._mutationObserver.takeRecords() );
  77. }
  78. /**
  79. * @inheritDoc
  80. */
  81. observe( domElement ) {
  82. this._domElements.push( domElement );
  83. if ( this.isEnabled ) {
  84. this._mutationObserver.observe( domElement, this._config );
  85. }
  86. }
  87. /**
  88. * @inheritDoc
  89. */
  90. enable() {
  91. super.enable();
  92. for ( const domElement of this._domElements ) {
  93. this._mutationObserver.observe( domElement, this._config );
  94. }
  95. }
  96. /**
  97. * @inheritDoc
  98. */
  99. disable() {
  100. super.disable();
  101. this._mutationObserver.disconnect();
  102. }
  103. /**
  104. * @inheritDoc
  105. */
  106. destroy() {
  107. super.destroy();
  108. this._mutationObserver.disconnect();
  109. }
  110. /**
  111. * Handles mutations. Deduplicates, mark view elements to sync, fire event and call render.
  112. *
  113. * @private
  114. * @param {Array.<Object>} domMutations Array of native mutations.
  115. */
  116. _onMutations( domMutations ) {
  117. // As a result of this.flush() we can have an empty collection.
  118. if ( domMutations.length === 0 ) {
  119. return;
  120. }
  121. const domConverter = this.domConverter;
  122. // Use map and set for deduplication.
  123. const mutatedTexts = new Map();
  124. const mutatedElements = new Set();
  125. // Handle `childList` mutations first, so we will be able to check if the `characterData` mutation is in the
  126. // element with changed structure anyway.
  127. for ( const mutation of domMutations ) {
  128. if ( mutation.type === 'childList' ) {
  129. const element = domConverter.mapDomToView( mutation.target );
  130. // Do not collect mutations from UIElements.
  131. if ( element && element.is( 'uiElement' ) ) {
  132. continue;
  133. }
  134. if ( element && !this._isBogusBrMutation( mutation ) ) {
  135. mutatedElements.add( element );
  136. }
  137. }
  138. }
  139. // Handle `characterData` mutations later, when we have the full list of nodes which changed structure.
  140. for ( const mutation of domMutations ) {
  141. const element = domConverter.mapDomToView( mutation.target );
  142. // Do not collect mutations from UIElements.
  143. if ( element && element.is( 'uiElement' ) ) {
  144. continue;
  145. }
  146. if ( mutation.type === 'characterData' ) {
  147. const text = domConverter.findCorrespondingViewText( mutation.target );
  148. if ( text && !mutatedElements.has( text.parent ) ) {
  149. // Use text as a key, for deduplication. If there will be another mutation on the same text element
  150. // we will have only one in the map.
  151. mutatedTexts.set( text, {
  152. type: 'text',
  153. oldText: text.data,
  154. newText: getDataWithoutFiller( mutation.target ),
  155. node: text
  156. } );
  157. }
  158. // When we added first letter to the text node which had only inline filler, for the DOM it is mutation
  159. // on text, but for the view, where filler text node did not existed, new text node was created, so we
  160. // need to fire 'children' mutation instead of 'text'.
  161. else if ( !text && startsWithFiller( mutation.target ) ) {
  162. mutatedElements.add( domConverter.mapDomToView( mutation.target.parentNode ) );
  163. }
  164. }
  165. }
  166. // Now we build the list of mutations to fire and mark elements. We did not do it earlier to avoid marking the
  167. // same node multiple times in case of duplication.
  168. // List of mutations we will fire.
  169. const viewMutations = [];
  170. for ( const mutatedText of mutatedTexts.values() ) {
  171. this.renderer.markToSync( 'text', mutatedText.node );
  172. viewMutations.push( mutatedText );
  173. }
  174. for ( const viewElement of mutatedElements ) {
  175. const domElement = domConverter.mapViewToDom( viewElement );
  176. const viewChildren = Array.from( viewElement.getChildren() );
  177. const newViewChildren = Array.from( domConverter.domChildrenToView( domElement, { withChildren: false } ) );
  178. // It may happen that as a result of many changes (sth was inserted and then removed),
  179. // both elements haven't really changed. #1031
  180. if ( !isEqualWith( viewChildren, newViewChildren, sameNodes ) ) {
  181. this.renderer.markToSync( 'children', viewElement );
  182. viewMutations.push( {
  183. type: 'children',
  184. oldChildren: viewChildren,
  185. newChildren: newViewChildren,
  186. node: viewElement
  187. } );
  188. }
  189. }
  190. // Retrieve `domSelection` using `ownerDocument` of one of mutated nodes.
  191. // There should not be simultaneous mutation in multiple documents, so it's fine.
  192. const domSelection = domMutations[ 0 ].target.ownerDocument.getSelection();
  193. let viewSelection = null;
  194. if ( domSelection && domSelection.anchorNode ) {
  195. // If `domSelection` is inside a dom node that is already bound to a view node from view tree, get
  196. // corresponding selection in the view and pass it together with `viewMutations`. The `viewSelection` may
  197. // be used by features handling mutations.
  198. // Only one range is supported.
  199. const viewSelectionAnchor = domConverter.domPositionToView( domSelection.anchorNode, domSelection.anchorOffset );
  200. const viewSelectionFocus = domConverter.domPositionToView( domSelection.focusNode, domSelection.focusOffset );
  201. // Anchor and focus has to be properly mapped to view.
  202. if ( viewSelectionAnchor && viewSelectionFocus ) {
  203. viewSelection = new ViewSelection( viewSelectionAnchor );
  204. viewSelection.setFocus( viewSelectionFocus );
  205. }
  206. }
  207. this.document.fire( 'mutations', viewMutations, viewSelection );
  208. // If nothing changes on `mutations` event, at this point we have "dirty DOM" (changed) and de-synched
  209. // view (which has not been changed). In order to "reset DOM" we render the view again.
  210. this.view.render();
  211. function sameNodes( child1, child2 ) {
  212. // First level of comparison (array of children vs array of children) – use the Lodash's default behavior.
  213. if ( Array.isArray( child1 ) ) {
  214. return;
  215. }
  216. // Elements.
  217. if ( child1 === child2 ) {
  218. return true;
  219. }
  220. // Texts.
  221. else if ( child1.is( 'text' ) && child2.is( 'text' ) ) {
  222. return child1.data === child2.data;
  223. }
  224. // Not matching types.
  225. return false;
  226. }
  227. }
  228. /**
  229. * Checks if mutation was generated by the browser inserting bogus br on the end of the block element.
  230. * Such mutations are generated while pressing space or performing native spellchecker correction
  231. * on the end of the block element in Firefox browser.
  232. *
  233. * @private
  234. * @param {Object} mutation Native mutation object.
  235. * @returns {Boolean}
  236. */
  237. _isBogusBrMutation( mutation ) {
  238. let addedNode = null;
  239. // Check if mutation added only one node on the end of its parent.
  240. if ( mutation.nextSibling === null && mutation.removedNodes.length === 0 && mutation.addedNodes.length == 1 ) {
  241. addedNode = this.domConverter.domToView( mutation.addedNodes[ 0 ], {
  242. withChildren: false
  243. } );
  244. }
  245. return addedNode && addedNode.is( 'element', 'br' );
  246. }
  247. }
  248. /**
  249. * Fired when mutation occurred. If tree view is not changed on this event, DOM will be reverted to the state before
  250. * mutation, so all changes which should be applied, should be handled on this event.
  251. *
  252. * Introduced by {@link module:engine/view/observer/mutationobserver~MutationObserver}.
  253. *
  254. * Note that because {@link module:engine/view/observer/mutationobserver~MutationObserver} is attached by the
  255. * {@link module:engine/view/view~View} this event is available by default.
  256. *
  257. * @see module:engine/view/observer/mutationobserver~MutationObserver
  258. * @event module:engine/view/document~Document#event:mutations
  259. * @param {Array.<module:engine/view/observer/mutationobserver~MutatedText|module:engine/view/observer/mutationobserver~MutatedChildren>}
  260. * viewMutations Array of mutations.
  261. * For mutated texts it will be {@link module:engine/view/observer/mutationobserver~MutatedText} and for mutated elements it will be
  262. * {@link module:engine/view/observer/mutationobserver~MutatedChildren}. You can recognize the type based on the `type` property.
  263. * @param {module:engine/view/selection~Selection|null} viewSelection View selection that is a result of converting DOM selection to view.
  264. * Keep in
  265. * mind that the DOM selection is already "updated", meaning that it already acknowledges changes done in mutation.
  266. */
  267. /**
  268. * Mutation item for text.
  269. *
  270. * @see module:engine/view/document~Document#event:mutations
  271. * @see module:engine/view/observer/mutationobserver~MutatedChildren
  272. *
  273. * @typedef {Object} module:engine/view/observer/mutationobserver~MutatedText
  274. *
  275. * @property {String} type For text mutations it is always 'text'.
  276. * @property {module:engine/view/text~Text} node Mutated text node.
  277. * @property {String} oldText Old text.
  278. * @property {String} newText New text.
  279. */
  280. /**
  281. * Mutation item for child nodes.
  282. *
  283. * @see module:engine/view/document~Document#event:mutations
  284. * @see module:engine/view/observer/mutationobserver~MutatedText
  285. *
  286. * @typedef {Object} module:engine/view/observer/mutationobserver~MutatedChildren
  287. *
  288. * @property {String} type For child nodes mutations it is always 'children'.
  289. * @property {module:engine/view/element~Element} node Parent of the mutated children.
  290. * @property {Array.<module:engine/view/node~Node>} oldChildren Old child nodes.
  291. * @property {Array.<module:engine/view/node~Node>} newChildren New child nodes.
  292. */