UNPKG

ckeditor5-image-upload-base64

Version:

The development environment of CKEditor 5 – the best browser-based rich text editor.

346 lines (295 loc) 12 kB
/** * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ /** * @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 and RawElements. if ( element && ( element.is( 'uiElement' ) || element.is( 'rawElement' ) ) ) { 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 and RawElements. if ( element && ( element.is( 'uiElement' ) || element.is( 'rawElement' ) ) ) { 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 ); } } // In case only non-relevant mutations were recorded it skips the event and force render (#5600). if ( viewMutations.length ) { 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.forceRender(); } 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. */