| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342 |
- /**
- * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
- /**
- * @module engine/view/observer/mutationobserver
- */
- /* globals window */
- import Observer from './observer';
- import ViewSelection from '../selection';
- import { startsWithFiller, getDataWithoutFiller } from '../filler';
- import { isEqualWith } from 'lodash-es';
- /**
- * Mutation observer class observes changes in the DOM, fires {@link module:engine/view/document~Document#event:mutations} event, mark view
- * elements as changed and call {@link module:engine/view/renderer~Renderer#render}.
- * Because all mutated nodes are marked as "to be rendered" and the
- * {@link module:engine/view/renderer~Renderer#render} is called, all changes will be reverted, unless the mutation will be handled by the
- * {@link module:engine/view/document~Document#event:mutations} event listener. It means user will see only handled changes, and the editor
- * will block all changes which are not handled.
- *
- * Mutation Observer also take care of reducing number of mutations which are fired. It removes duplicates and
- * mutations on elements which do not have corresponding view elements. Also
- * {@link module:engine/view/observer/mutationobserver~MutatedText text mutation} is fired only if parent element do not change child list.
- *
- * Note that this observer is attached by the {@link module:engine/view/view~View} and is available by default.
- *
- * @extends module:engine/view/observer/observer~Observer
- */
- export default class MutationObserver extends Observer {
- constructor( view ) {
- super( view );
- /**
- * Native mutation observer config.
- *
- * @private
- * @member {Object}
- */
- this._config = {
- childList: true,
- characterData: true,
- characterDataOldValue: true,
- subtree: true
- };
- /**
- * Reference to the {@link module:engine/view/view~View#domConverter}.
- *
- * @member {module:engine/view/domconverter~DomConverter}
- */
- this.domConverter = view.domConverter;
- /**
- * Reference to the {@link module:engine/view/view~View#renderer}.
- *
- * @member {module:engine/view/renderer~Renderer}
- */
- this.renderer = view._renderer;
- /**
- * Observed DOM elements.
- *
- * @private
- * @member {Array.<HTMLElement>}
- */
- this._domElements = [];
- /**
- * Native mutation observer.
- *
- * @private
- * @member {MutationObserver}
- */
- this._mutationObserver = new window.MutationObserver( this._onMutations.bind( this ) );
- }
- /**
- * Synchronously fires {@link module:engine/view/document~Document#event:mutations} event with all mutations in record queue.
- * At the same time empties the queue so mutations will not be fired twice.
- */
- flush() {
- this._onMutations( this._mutationObserver.takeRecords() );
- }
- /**
- * @inheritDoc
- */
- observe( domElement ) {
- this._domElements.push( domElement );
- if ( this.isEnabled ) {
- this._mutationObserver.observe( domElement, this._config );
- }
- }
- /**
- * @inheritDoc
- */
- enable() {
- super.enable();
- for ( const domElement of this._domElements ) {
- this._mutationObserver.observe( domElement, this._config );
- }
- }
- /**
- * @inheritDoc
- */
- disable() {
- super.disable();
- this._mutationObserver.disconnect();
- }
- /**
- * @inheritDoc
- */
- destroy() {
- super.destroy();
- this._mutationObserver.disconnect();
- }
- /**
- * Handles mutations. Deduplicates, mark view elements to sync, fire event and call render.
- *
- * @private
- * @param {Array.<Object>} domMutations Array of native mutations.
- */
- _onMutations( domMutations ) {
- // As a result of this.flush() we can have an empty collection.
- if ( domMutations.length === 0 ) {
- return;
- }
- const domConverter = this.domConverter;
- // Use map and set for deduplication.
- const mutatedTexts = new Map();
- const mutatedElements = new Set();
- // Handle `childList` mutations first, so we will be able to check if the `characterData` mutation is in the
- // element with changed structure anyway.
- for ( const mutation of domMutations ) {
- if ( mutation.type === 'childList' ) {
- const element = domConverter.mapDomToView( mutation.target );
- // Do not collect mutations from UIElements.
- if ( element && element.is( 'uiElement' ) ) {
- continue;
- }
- if ( element && !this._isBogusBrMutation( mutation ) ) {
- mutatedElements.add( element );
- }
- }
- }
- // Handle `characterData` mutations later, when we have the full list of nodes which changed structure.
- for ( const mutation of domMutations ) {
- const element = domConverter.mapDomToView( mutation.target );
- // Do not collect mutations from UIElements.
- if ( element && element.is( 'uiElement' ) ) {
- continue;
- }
- if ( mutation.type === 'characterData' ) {
- const text = domConverter.findCorrespondingViewText( mutation.target );
- if ( text && !mutatedElements.has( text.parent ) ) {
- // Use text as a key, for deduplication. If there will be another mutation on the same text element
- // we will have only one in the map.
- mutatedTexts.set( text, {
- type: 'text',
- oldText: text.data,
- newText: getDataWithoutFiller( mutation.target ),
- node: text
- } );
- }
- // When we added first letter to the text node which had only inline filler, for the DOM it is mutation
- // on text, but for the view, where filler text node did not existed, new text node was created, so we
- // need to fire 'children' mutation instead of 'text'.
- else if ( !text && startsWithFiller( mutation.target ) ) {
- mutatedElements.add( domConverter.mapDomToView( mutation.target.parentNode ) );
- }
- }
- }
- // Now we build the list of mutations to fire and mark elements. We did not do it earlier to avoid marking the
- // same node multiple times in case of duplication.
- // List of mutations we will fire.
- const viewMutations = [];
- for ( const mutatedText of mutatedTexts.values() ) {
- this.renderer.markToSync( 'text', mutatedText.node );
- viewMutations.push( mutatedText );
- }
- for ( const viewElement of mutatedElements ) {
- const domElement = domConverter.mapViewToDom( viewElement );
- const viewChildren = Array.from( viewElement.getChildren() );
- const newViewChildren = Array.from( domConverter.domChildrenToView( domElement, { withChildren: false } ) );
- // It may happen that as a result of many changes (sth was inserted and then removed),
- // both elements haven't really changed. #1031
- if ( !isEqualWith( viewChildren, newViewChildren, sameNodes ) ) {
- this.renderer.markToSync( 'children', viewElement );
- viewMutations.push( {
- type: 'children',
- oldChildren: viewChildren,
- newChildren: newViewChildren,
- node: viewElement
- } );
- }
- }
- // Retrieve `domSelection` using `ownerDocument` of one of mutated nodes.
- // There should not be simultaneous mutation in multiple documents, so it's fine.
- const domSelection = domMutations[ 0 ].target.ownerDocument.getSelection();
- let viewSelection = null;
- if ( domSelection && domSelection.anchorNode ) {
- // If `domSelection` is inside a dom node that is already bound to a view node from view tree, get
- // corresponding selection in the view and pass it together with `viewMutations`. The `viewSelection` may
- // be used by features handling mutations.
- // Only one range is supported.
- const viewSelectionAnchor = domConverter.domPositionToView( domSelection.anchorNode, domSelection.anchorOffset );
- const viewSelectionFocus = domConverter.domPositionToView( domSelection.focusNode, domSelection.focusOffset );
- // Anchor and focus has to be properly mapped to view.
- if ( viewSelectionAnchor && viewSelectionFocus ) {
- viewSelection = new ViewSelection( viewSelectionAnchor );
- viewSelection.setFocus( viewSelectionFocus );
- }
- }
- this.document.fire( 'mutations', viewMutations, viewSelection );
- // If nothing changes on `mutations` event, at this point we have "dirty DOM" (changed) and de-synched
- // view (which has not been changed). In order to "reset DOM" we render the view again.
- this.view.render();
- function sameNodes( child1, child2 ) {
- // First level of comparison (array of children vs array of children) – use the Lodash's default behavior.
- if ( Array.isArray( child1 ) ) {
- return;
- }
- // Elements.
- if ( child1 === child2 ) {
- return true;
- }
- // Texts.
- else if ( child1.is( 'text' ) && child2.is( 'text' ) ) {
- return child1.data === child2.data;
- }
- // Not matching types.
- return false;
- }
- }
- /**
- * Checks if mutation was generated by the browser inserting bogus br on the end of the block element.
- * Such mutations are generated while pressing space or performing native spellchecker correction
- * on the end of the block element in Firefox browser.
- *
- * @private
- * @param {Object} mutation Native mutation object.
- * @returns {Boolean}
- */
- _isBogusBrMutation( mutation ) {
- let addedNode = null;
- // Check if mutation added only one node on the end of its parent.
- if ( mutation.nextSibling === null && mutation.removedNodes.length === 0 && mutation.addedNodes.length == 1 ) {
- addedNode = this.domConverter.domToView( mutation.addedNodes[ 0 ], {
- withChildren: false
- } );
- }
- return addedNode && addedNode.is( 'element', 'br' );
- }
- }
- /**
- * Fired when mutation occurred. If tree view is not changed on this event, DOM will be reverted to the state before
- * mutation, so all changes which should be applied, should be handled on this event.
- *
- * Introduced by {@link module:engine/view/observer/mutationobserver~MutationObserver}.
- *
- * Note that because {@link module:engine/view/observer/mutationobserver~MutationObserver} is attached by the
- * {@link module:engine/view/view~View} this event is available by default.
- *
- * @see module:engine/view/observer/mutationobserver~MutationObserver
- * @event module:engine/view/document~Document#event:mutations
- * @param {Array.<module:engine/view/observer/mutationobserver~MutatedText|module:engine/view/observer/mutationobserver~MutatedChildren>}
- * viewMutations Array of mutations.
- * For mutated texts it will be {@link module:engine/view/observer/mutationobserver~MutatedText} and for mutated elements it will be
- * {@link module:engine/view/observer/mutationobserver~MutatedChildren}. You can recognize the type based on the `type` property.
- * @param {module:engine/view/selection~Selection|null} viewSelection View selection that is a result of converting DOM selection to view.
- * Keep in
- * mind that the DOM selection is already "updated", meaning that it already acknowledges changes done in mutation.
- */
- /**
- * Mutation item for text.
- *
- * @see module:engine/view/document~Document#event:mutations
- * @see module:engine/view/observer/mutationobserver~MutatedChildren
- *
- * @typedef {Object} module:engine/view/observer/mutationobserver~MutatedText
- *
- * @property {String} type For text mutations it is always 'text'.
- * @property {module:engine/view/text~Text} node Mutated text node.
- * @property {String} oldText Old text.
- * @property {String} newText New text.
- */
- /**
- * Mutation item for child nodes.
- *
- * @see module:engine/view/document~Document#event:mutations
- * @see module:engine/view/observer/mutationobserver~MutatedText
- *
- * @typedef {Object} module:engine/view/observer/mutationobserver~MutatedChildren
- *
- * @property {String} type For child nodes mutations it is always 'children'.
- * @property {module:engine/view/element~Element} node Parent of the mutated children.
- * @property {Array.<module:engine/view/node~Node>} oldChildren Old child nodes.
- * @property {Array.<module:engine/view/node~Node>} newChildren New child nodes.
- */
|