瀏覽代碼

Merge pull request #110 from ckeditor/t/100

Fix: Fixed a range of issues when typing or using a spellchecker on styled words leads to errors. Closes #100. Closes ckeditor/ckeditor5#491.
Piotrek Koszuliński 8 年之前
父節點
當前提交
b039525422
共有 3 個文件被更改,包括 631 次插入56 次删除
  1. 1 0
      packages/ckeditor5-typing/package.json
  2. 226 56
      packages/ckeditor5-typing/src/input.js
  3. 404 0
      packages/ckeditor5-typing/tests/input.js

+ 1 - 0
packages/ckeditor5-typing/package.json

@@ -14,6 +14,7 @@
     "@ckeditor/ckeditor5-editor-classic": "^0.7.3",
     "@ckeditor/ckeditor5-enter": "^0.9.1",
     "@ckeditor/ckeditor5-heading": "^0.9.1",
+    "@ckeditor/ckeditor5-link": "^0.7.0",
     "@ckeditor/ckeditor5-paragraph": "^0.8.0",
     "@ckeditor/ckeditor5-undo": "^0.8.1",
     "@ckeditor/ckeditor5-presets": "^0.2.2",

+ 226 - 56
packages/ckeditor5-typing/src/input.js

@@ -14,6 +14,7 @@ import ViewText from '@ckeditor/ckeditor5-engine/src/view/text';
 import diff from '@ckeditor/ckeditor5-utils/src/diff';
 import diffToChanges from '@ckeditor/ckeditor5-utils/src/difftochanges';
 import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import DomConverter from '@ckeditor/ckeditor5-engine/src/view/domconverter';
 import InputCommand from './inputcommand';
 
 /**
@@ -142,13 +143,106 @@ class MutationHandler {
 	 * @param {module:engine/view/selection~Selection|null} viewSelection
 	 */
 	handle( mutations, viewSelection ) {
-		for ( const mutation of mutations ) {
-			// Fortunately it will never be both.
-			this._handleTextMutation( mutation, viewSelection );
-			this._handleTextNodeInsertion( mutation );
+		if ( containerChildrenMutated( mutations ) ) {
+			this._handleContainerChildrenMutations( mutations, viewSelection );
+		} else {
+			for ( const mutation of mutations ) {
+				// Fortunately it will never be both.
+				this._handleTextMutation( mutation, viewSelection );
+				this._handleTextNodeInsertion( mutation );
+			}
 		}
 	}
 
+	/**
+	 * Handles situations when container's children mutated during input. This can happen when
+	 * browser is trying to "fix" DOM in certain situations. For example, when user starts to type
+	 * in `<p><a href=""><i>Link{}</i></a></p>` browser might change order of elements
+	 * to `<p><i><a href="">Link</a>x{}</i></p>`. Similar situation happens when spell checker
+	 * replaces a word wrapped with `<strong>` to a word wrapped with `<b>` element.
+	 *
+	 * To handle such situations, DOM common ancestor of all mutations is converted to the model representation
+	 * and then compared with current model to calculate proper text change.
+	 *
+	 * NOTE: Single text node insertion is handled in {@link #_handleTextNodeInsertion} and text node mutation is handled
+	 * in {@link #_handleTextMutation}).
+	 *
+	 * @private
+	 * @param {Array.<module:engine/view/observer/mutationobserver~MutatedText|
+	 * module:engine/view/observer/mutationobserver~MutatedChildren>} mutations
+	 * @param {module:engine/view/selection~Selection|null} viewSelection
+	 */
+	_handleContainerChildrenMutations( mutations, viewSelection ) {
+		// Get common ancestor of all mutations.
+		const mutationsCommonAncestor = getMutationsContainer( mutations );
+
+		// Quit if there is no common ancestor.
+		if ( !mutationsCommonAncestor ) {
+			return;
+		}
+
+		const domConverter = this.editor.editing.view.domConverter;
+
+		// Get common ancestor in DOM.
+		const domMutationCommonAncestor = domConverter.mapViewToDom( mutationsCommonAncestor );
+
+		if ( !domMutationCommonAncestor ) {
+			return;
+		}
+
+		// Create fresh DomConverter so it will not use existing mapping and convert current DOM to model.
+		// This wouldn't be needed if DomConverter would allow to create fresh view without checking any mappings.
+		const freshDomConverter = new DomConverter();
+		const modelFromCurrentDom = this.editor.data.toModel( freshDomConverter.domToView( domMutationCommonAncestor ) ).getChild( 0 );
+
+		// Current model.
+		const currentModel = this.editor.editing.mapper.toModelElement( mutationsCommonAncestor );
+
+		// Get children from both ancestors.
+		const modelFromDomChildren = Array.from( modelFromCurrentDom.getChildren() );
+		const currentModelChildren = Array.from( currentModel.getChildren() );
+
+		// Skip situations when common ancestor has any elements (cause they are too hard).
+		if ( !hasOnlyTextNodes( modelFromDomChildren ) || !hasOnlyTextNodes( currentModelChildren ) ) {
+			return;
+		}
+
+		// Replace &nbsp; inserted by the browser with normal space.
+		// See comment in `_handleTextMutation`.
+		const newText = modelFromDomChildren.map( item => item.data ).join( '' ).replace( /\u00A0/g, ' ' );
+		const oldText = currentModelChildren.map( item => item.data ).join( '' );
+
+		// Do nothing if mutations created same text.
+		if ( oldText === newText ) {
+			return;
+		}
+
+		const diffResult = diff( oldText, newText );
+
+		const { firstChangeAt, insertions, deletions } = calculateChanges( diffResult );
+
+		// Try setting new model selection according to passed view selection.
+		let modelSelectionRange = null;
+
+		if ( viewSelection ) {
+			modelSelectionRange = this.editing.mapper.toModelRange( viewSelection.getFirstRange() );
+		}
+
+		const insertText = newText.substr( firstChangeAt, insertions );
+		const removeRange = ModelRange.createFromParentsAndOffsets(
+			currentModel,
+			firstChangeAt,
+			currentModel,
+			firstChangeAt + deletions
+		);
+
+		this.editor.execute( 'input', {
+			text: insertText,
+			range: removeRange,
+			resultRange: modelSelectionRange
+		} );
+	}
+
 	_handleTextMutation( mutation, viewSelection ) {
 		if ( mutation.type != 'text' ) {
 			return;
@@ -169,37 +263,7 @@ class MutationHandler {
 
 		const diffResult = diff( oldText, newText );
 
-		// Index where the first change happens. Used to set the position from which nodes will be removed and where will be inserted.
-		let firstChangeAt = null;
-		// Index where the last change happens. Used to properly count how many characters have to be removed and inserted.
-		let lastChangeAt = null;
-
-		// Get `firstChangeAt` and `lastChangeAt`.
-		for ( let i = 0; i < diffResult.length; i++ ) {
-			const change = diffResult[ i ];
-
-			if ( change != 'equal' ) {
-				firstChangeAt = firstChangeAt === null ? i : firstChangeAt;
-				lastChangeAt = i;
-			}
-		}
-
-		// How many characters, starting from `firstChangeAt`, should be removed.
-		let deletions = 0;
-		// How many characters, starting from `firstChangeAt`, should be inserted (basing on mutation.newText).
-		let insertions = 0;
-
-		for ( let i = firstChangeAt; i <= lastChangeAt; i++ ) {
-			// If there is no change (equal) or delete, the character is existing in `oldText`. We count it for removing.
-			if ( diffResult[ i ] != 'insert' ) {
-				deletions++;
-			}
-
-			// If there is no change (equal) or insert, the character is existing in `newText`. We count it for inserting.
-			if ( diffResult[ i ] != 'delete' ) {
-				insertions++;
-			}
-		}
+		const { firstChangeAt, insertions, deletions } = calculateChanges( diffResult );
 
 		// Try setting new model selection according to passed view selection.
 		let modelSelectionRange = null;
@@ -226,27 +290,7 @@ class MutationHandler {
 			return;
 		}
 
-		// One new node.
-		if ( mutation.newChildren.length - mutation.oldChildren.length != 1 ) {
-			return;
-		}
-
-		// Which is text.
-		const diffResult = diff( mutation.oldChildren, mutation.newChildren, compareChildNodes );
-		const changes = diffToChanges( diffResult, mutation.newChildren );
-
-		// In case of [ delete, insert, insert ] the previous check will not exit.
-		if ( changes.length > 1 ) {
-			return;
-		}
-
-		const change = changes[ 0 ];
-
-		// Which is text.
-		if ( !( change.values[ 0 ] instanceof ViewText ) ) {
-			return;
-		}
-
+		const change = getSingleTextNodeChange( mutation );
 		const viewPos = new ViewPosition( mutation.node, change.index );
 		const modelPos = this.editing.mapper.toModelPosition( viewPos );
 		const insertedText = change.values[ 0 ].data;
@@ -289,6 +333,7 @@ for ( let code = 112; code <= 135; code++ ) {
 //
 // Note: This implementation is very simple and will need to be refined with time.
 //
+// @private
 // @param {engine.view.observer.keyObserver.KeyEventData} keyData
 // @returns {Boolean}
 function isSafeKeystroke( keyData ) {
@@ -309,3 +354,128 @@ function compareChildNodes( oldChild, newChild ) {
 		return oldChild === newChild;
 	}
 }
+
+// Returns change made to a single text node. Returns `undefined` if more than a single text node was changed.
+//
+// @private
+// @param mutation
+function getSingleTextNodeChange( mutation ) {
+	// One new node.
+	if ( mutation.newChildren.length - mutation.oldChildren.length != 1 ) {
+		return;
+	}
+
+	// Which is text.
+	const diffResult = diff( mutation.oldChildren, mutation.newChildren, compareChildNodes );
+	const changes = diffToChanges( diffResult, mutation.newChildren );
+
+	// In case of [ delete, insert, insert ] the previous check will not exit.
+	if ( changes.length > 1 ) {
+		return;
+	}
+
+	const change = changes[ 0 ];
+
+	// Which is text.
+	if ( !( change.values[ 0 ] instanceof ViewText ) ) {
+		return;
+	}
+
+	return change;
+}
+
+// Returns first common ancestor of all mutations that is either {@link module:engine/view/containerelement~ContainerElement}
+// or {@link module:engine/view/rootelement~RootElement}.
+//
+// @private
+// @param {Array.<module:engine/view/observer/mutationobserver~MutatedText|
+// module:engine/view/observer/mutationobserver~MutatedChildren>} mutations
+// @returns {module:engine/view/containerelement~ContainerElement|engine/view/rootelement~RootElement|undefined}
+function getMutationsContainer( mutations ) {
+	const lca = mutations
+		.map( mutation => mutation.node )
+		.reduce( ( commonAncestor, node ) => {
+			return commonAncestor.getCommonAncestor( node, { includeSelf: true } );
+		} );
+
+	if ( !lca ) {
+		return;
+	}
+
+	// We need to look for container and root elements only, so check all LCA's
+	// ancestors (starting from itself).
+	return lca.getAncestors( { includeSelf: true, parentFirst: true } )
+		.find( element => element.is( 'containerElement' ) || element.is( 'rootElement' ) );
+}
+
+// Returns true if container children have mutated and more than a single text node was changed. Single text node
+// child insertion is handled in {@link module:typing/input~MutationHandler#_handleTextNodeInsertion} and text
+// mutation is handled in {@link module:typing/input~MutationHandler#_handleTextMutation}.
+//
+// @private
+// @param {Array.<module:engine/view/observer/mutationobserver~MutatedText|
+// module:engine/view/observer/mutationobserver~MutatedChildren>} mutations
+// @returns {Boolean}
+function containerChildrenMutated( mutations ) {
+	if ( mutations.length == 0 ) {
+		return false;
+	}
+
+	// Check if all mutations are `children` type, and there is no single text node mutation.
+	for ( const mutation of mutations ) {
+		if ( mutation.type !== 'children' || getSingleTextNodeChange( mutation ) ) {
+			return false;
+		}
+	}
+
+	return true;
+}
+
+// Returns true if provided array contains only {@link module:engine/model/text~Text model text nodes}.
+//
+// @param {Array.<module:engine/model/node~Node>} children
+// @returns {Boolean}
+function hasOnlyTextNodes( children ) {
+	return children.every( child => child.is( 'text' ) );
+}
+
+// Calculates first change index and number of characters that should be inserted and deleted starting from that index.
+//
+// @private
+// @param diffResult
+// @return {{insertions: number, deletions: number, firstChangeAt: *}}
+function calculateChanges( diffResult ) {
+	// Index where the first change happens. Used to set the position from which nodes will be removed and where will be inserted.
+	let firstChangeAt = null;
+	// Index where the last change happens. Used to properly count how many characters have to be removed and inserted.
+	let lastChangeAt = null;
+
+	// Get `firstChangeAt` and `lastChangeAt`.
+	for ( let i = 0; i < diffResult.length; i++ ) {
+		const change = diffResult[ i ];
+
+		if ( change != 'equal' ) {
+			firstChangeAt = firstChangeAt === null ? i : firstChangeAt;
+			lastChangeAt = i;
+		}
+	}
+
+	// How many characters, starting from `firstChangeAt`, should be removed.
+	let deletions = 0;
+	// How many characters, starting from `firstChangeAt`, should be inserted.
+	let insertions = 0;
+
+	for ( let i = firstChangeAt; i <= lastChangeAt; i++ ) {
+		// If there is no change (equal) or delete, the character is existing in `oldText`. We count it for removing.
+		if ( diffResult[ i ] != 'insert' ) {
+			deletions++;
+		}
+
+		// If there is no change (equal) or insert, the character is existing in `newText`. We count it for inserting.
+		if ( diffResult[ i ] != 'delete' ) {
+			insertions++;
+		}
+	}
+
+	return { insertions, deletions, firstChangeAt };
+}

+ 404 - 0
packages/ckeditor5-typing/tests/input.js

@@ -3,9 +3,15 @@
  * For licensing, see LICENSE.md.
  */
 
+/* global document */
+
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/boldengine';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italicengine';
+import LinkEngine from '@ckeditor/ckeditor5-link/src/linkengine';
 import Input from '../src/input';
 
 import Batch from '@ckeditor/ckeditor5-engine/src/model/batch';
@@ -15,7 +21,9 @@ import buildViewConverter from '@ckeditor/ckeditor5-engine/src/conversion/buildv
 
 import ViewText from '@ckeditor/ckeditor5-engine/src/view/text';
 import ViewElement from '@ckeditor/ckeditor5-engine/src/view/element';
+import ViewContainerElement from '@ckeditor/ckeditor5-engine/src/view/containerelement';
 import ViewSelection from '@ckeditor/ckeditor5-engine/src/view/selection';
+import MutationObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mutationobserver';
 
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard';
@@ -541,5 +549,401 @@ describe( 'Input feature', () => {
 			expect( getModelData( model ) ).to.equal( '<paragraph>fo[ob]ar</paragraph>' );
 		} );
 	} );
+
+	// NOTE: In all these tests we need to simulate the mutations. However, it's really tricky to tell what
+	// should be in "newChildren" because we don't have yet access to these nodes. We pass new instances,
+	// but this means that DomConverter which is used somewhere internally may return a different instance
+	// (which wouldn't happen in practice because it'd cache it). Besides, it's really hard to tell if the
+	// browser will keep the instances of the old elements when modifying the tree when the user is typing
+	// or if it will create new instances itself too.
+	// However, the code handling these mutations doesn't really care what's inside new/old children. It
+	// just needs the mutations common ancestor to understand how big fragment of the tree has changed.
+	describe( '#100', () => {
+		let domElement, domRoot;
+
+		beforeEach( () => {
+			domElement = document.createElement( 'div' );
+			document.body.appendChild( domElement );
+
+			return ClassicTestEditor.create( domElement, { plugins: [ Input, Paragraph, Bold, Italic, LinkEngine ] } )
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.document;
+					modelRoot = model.getRoot();
+					view = editor.editing.view;
+					viewRoot = view.getRoot();
+					domRoot = view.getDomRoot();
+
+					// Mock image feature.
+					newEditor.document.schema.registerItem( 'image', '$inline' );
+
+					buildModelConverter().for( newEditor.data.modelToView, newEditor.editing.modelToView )
+						.fromElement( 'image' )
+						.toElement( 'img' );
+
+					buildViewConverter().for( newEditor.data.viewToModel )
+						.fromElement( 'img' )
+						.toElement( 'image' );
+
+					// Disable MO completely and in a way it won't be reenabled on some Document#render() call.
+					const mutationObserver = view.getObserver( MutationObserver );
+
+					mutationObserver.disable();
+					mutationObserver.enable = () => {};
+				} );
+		} );
+
+		afterEach( () => {
+			domElement.remove();
+
+			return editor.destroy();
+		} );
+
+		// This happens when browser automatically switches parent and child nodes.
+		it( 'should handle mutations switching inner and outer node when adding new text node after', () => {
+			setModelData( model,
+				'<paragraph>' +
+					'<$text italic="true" linkHref="foo">' +
+						'text[]' +
+					'</$text>' +
+				'</paragraph>'
+			);
+
+			expect( getViewData( view ) ).to.equal( '<p><a href="foo"><i>text{}</i></a></p>' );
+
+			const paragraph = viewRoot.getChild( 0 );
+			const link = paragraph.getChild( 0 );
+			const italic = link.getChild( 0 );
+			const text = italic.getChild( 0 );
+
+			// Simulate mutations and DOM change.
+			domRoot.childNodes[ 0 ].innerHTML = '<i><a href="foo">text</a>x</i>';
+			view.fire( 'mutations', [
+				// First mutation - remove all children from link element.
+				{
+					type: 'children',
+					node: link,
+					oldChildren: [ italic ],
+					newChildren: []
+				},
+
+				// Second mutation - remove link from paragraph and put italic there.
+				{
+					type: 'children',
+					node: paragraph,
+					oldChildren: [ link ],
+					newChildren: [ new ViewElement( 'i' ) ]
+				},
+
+				// Third mutation - italic's new children.
+				{
+					type: 'children',
+					node: italic,
+					oldChildren: [ text ],
+					newChildren: [ new ViewElement( 'a', null, text ), new ViewText( 'x' ) ]
+				}
+			] );
+
+			expect( getViewData( view ) ).to.equal( '<p><a href="foo"><i>textx{}</i></a></p>' );
+		} );
+
+		it( 'should handle mutations switching inner and outer node when adding new text node before', () => {
+			setModelData( model,
+				'<paragraph>' +
+					'<$text italic="true" linkHref="foo">' +
+						'[]text' +
+					'</$text>' +
+				'</paragraph>'
+			);
+
+			expect( getViewData( view ) ).to.equal( '<p><a href="foo"><i>{}text</i></a></p>' );
+
+			const paragraph = viewRoot.getChild( 0 );
+			const link = paragraph.getChild( 0 );
+			const italic = link.getChild( 0 );
+			const text = italic.getChild( 0 );
+
+			// Simulate mutations and DOM change.
+			domRoot.childNodes[ 0 ].innerHTML = '<i>x<a href="foo">text</a></i>';
+			view.fire( 'mutations', [
+				// First mutation - remove all children from link element.
+				{
+					type: 'children',
+					node: link,
+					oldChildren: [ italic ],
+					newChildren: []
+				},
+
+				// Second mutation - remove link from paragraph and put italic there.
+				{
+					type: 'children',
+					node: paragraph,
+					oldChildren: [ link ],
+					newChildren: [ new ViewElement( 'i' ) ]
+				},
+
+				// Third mutation - italic's new children.
+				{
+					type: 'children',
+					node: italic,
+					oldChildren: [ text ],
+					newChildren: [ new ViewText( 'x' ), new ViewElement( 'a', null, 'text' ) ]
+				}
+			] );
+
+			expect( getViewData( view ) ).to.equal( '<p><a href="foo"><i>x{}text</i></a></p>' );
+		} );
+
+		it( 'should handle mutations switching inner and outer node - with text before', () => {
+			setModelData( model,
+				'<paragraph>' +
+					'xxx<$text italic="true" linkHref="foo">' +
+						'text[]' +
+					'</$text>' +
+				'</paragraph>'
+			);
+
+			expect( getViewData( view ) ).to.equal( '<p>xxx<a href="foo"><i>text{}</i></a></p>' );
+
+			const paragraph = viewRoot.getChild( 0 );
+			const textBefore = paragraph.getChild( 0 );
+			const link = paragraph.getChild( 1 );
+			const italic = link.getChild( 0 );
+			const text = italic.getChild( 0 );
+
+			// Simulate mutations and DOM change.
+			domRoot.childNodes[ 0 ].innerHTML = 'xxx<i><a href="foo">text</a>x</i>';
+			view.fire( 'mutations', [
+				// First mutation - remove all children from link element.
+				{
+					type: 'children',
+					node: link,
+					oldChildren: [ italic ],
+					newChildren: []
+				},
+
+				// Second mutation - remove link from paragraph and put italic there.
+				{
+					type: 'children',
+					node: paragraph,
+					oldChildren: [ textBefore, link ],
+					newChildren: [ new ViewText( 'xxx' ), new ViewElement( 'i' ) ]
+				},
+
+				// Third mutation - italic's new children.
+				{
+					type: 'children',
+					node: italic,
+					oldChildren: [ text ],
+					newChildren: [ new ViewElement( 'a', null, 'text' ), new ViewText( 'x' ) ]
+				}
+			] );
+
+			expect( getViewData( view ) ).to.equal( '<p>xxx<a href="foo"><i>textx{}</i></a></p>' );
+		} );
+
+		// This happens when spell checker is applied on <strong> element and changes it to <b>.
+		it( 'should handle mutations replacing node', () => {
+			setModelData( model,
+				'<paragraph>' +
+					'<$text bold="true">' +
+						'text[]' +
+					'</$text>' +
+				'</paragraph>'
+			);
+
+			expect( getViewData( view ) ).to.equal( '<p><strong>text{}</strong></p>' );
+
+			const paragraph = viewRoot.getChild( 0 );
+			const strong = paragraph.getChild( 0 );
+
+			// Simulate mutations and DOM change.
+			domRoot.childNodes[ 0 ].innerHTML = '<b>fixed text</b>';
+			view.fire( 'mutations', [
+				// Replace `<strong>` with `<b>`.
+				{
+					type: 'children',
+					node: paragraph,
+					oldChildren: [ strong ],
+					newChildren: [ new ViewElement( 'b', null, 'fixed text' ) ]
+				}
+			] );
+
+			expect( getViewData( view, { withoutSelection: true } ) ).to.equal( '<p><strong>fixed text</strong></p>' );
+		} );
+
+		// Spell checker splits text inside attributes to two text nodes.
+		it( 'should handle mutations inside attribute element', () => {
+			setModelData( model,
+				'<paragraph>' +
+					'<$text bold="true">' +
+						'this is foo text[]' +
+					'</$text>' +
+				'</paragraph>'
+			);
+
+			expect( getViewData( view ) ).to.equal( '<p><strong>this is foo text{}</strong></p>' );
+
+			const paragraph = viewRoot.getChild( 0 );
+			const strong = paragraph.getChild( 0 );
+			const text = strong.getChild( 0 );
+
+			// Simulate mutations and DOM change.
+			domRoot.childNodes[ 0 ].childNodes[ 0 ].innerHTML = 'this is bar text';
+			view.fire( 'mutations', [
+				{
+					type: 'children',
+					node: strong,
+					oldChildren: [ text ],
+					newChildren: [ new ViewText( 'this is bar' ), new ViewText( ' text' ) ]
+				}
+			] );
+
+			expect( getViewData( view, { withoutSelection: true } ) ).to.equal( '<p><strong>this is bar text</strong></p>' );
+		} );
+
+		it( 'should do nothing if elements mutated', () => {
+			setModelData( model,
+				'<paragraph>' +
+					'<$text bold="true">' +
+						'text[]' +
+					'</$text>' +
+				'</paragraph>'
+			);
+
+			expect( getViewData( view ) ).to.equal( '<p><strong>text{}</strong></p>' );
+
+			const paragraph = viewRoot.getChild( 0 );
+			const strong = paragraph.getChild( 0 );
+
+			// Simulate mutations and DOM change.
+			domRoot.childNodes[ 0 ].innerHTML = '<strong>text</strong><img />';
+			view.fire( 'mutations', [
+				{
+					type: 'children',
+					node: paragraph,
+					oldChildren: [ strong ],
+					newChildren: [
+						new ViewElement( 'strong', null, new ViewText( 'text' ) ),
+						new ViewElement( 'img' )
+					]
+				}
+			] );
+
+			expect( getViewData( view ) ).to.equal( '<p><strong>text{}</strong></p>' );
+		} );
+
+		it( 'should do nothing if text is not changed', () => {
+			setModelData( model,
+				'<paragraph>' +
+					'<$text bold="true">' +
+						'text[]' +
+					'</$text>' +
+				'</paragraph>'
+			);
+
+			expect( getViewData( view ) ).to.equal( '<p><strong>text{}</strong></p>' );
+
+			const paragraph = viewRoot.getChild( 0 );
+			const strong = paragraph.getChild( 0 );
+
+			// Simulate mutations and DOM change.
+			domRoot.childNodes[ 0 ].innerHTML = '<strong>text</strong>';
+			view.fire( 'mutations', [
+				{
+					type: 'children',
+					node: paragraph,
+					oldChildren: [ strong ],
+					newChildren: [ new ViewElement( 'strong', null, new ViewText( 'text' ) ) ]
+				}
+			] );
+
+			expect( getViewData( view ) ).to.equal( '<p><strong>text{}</strong></p>' );
+		} );
+
+		it( 'should do nothing on empty mutations', () => {
+			setModelData( model,
+				'<paragraph>' +
+					'<$text bold="true">' +
+						'text[]' +
+					'</$text>' +
+				'</paragraph>'
+			);
+
+			expect( getViewData( view ) ).to.equal( '<p><strong>text{}</strong></p>' );
+
+			// Simulate mutations and DOM change.
+			domRoot.childNodes[ 0 ].innerHTML = '<strong>text</strong>';
+			view.fire( 'mutations', [] );
+
+			expect( getViewData( view ) ).to.equal( '<p><strong>text{}</strong></p>' );
+		} );
+
+		it( 'should do nothing if mutations does not have common ancestor', () => {
+			setModelData( model,
+				'<paragraph>' +
+					'<$text bold="true">' +
+						'text[]' +
+					'</$text>' +
+				'</paragraph>'
+			);
+
+			expect( getViewData( view ) ).to.equal( '<p><strong>text{}</strong></p>' );
+
+			const paragraph = viewRoot.getChild( 0 );
+			const strong = paragraph.getChild( 0 );
+
+			// Simulate mutations and DOM change.
+			domRoot.childNodes[ 0 ].innerHTML = '<strong>text</strong>';
+			view.fire( 'mutations', [
+				{
+					type: 'children',
+					node: paragraph,
+					oldChildren: [ strong ],
+					newChildren: [ strong ]
+				},
+				{
+					type: 'children',
+					node: new ViewContainerElement( 'div' ),
+					oldChildren: [],
+					newChildren: [ new ViewText( 'foo' ), new ViewText( 'bar' ) ]
+				}
+			] );
+
+			expect( getViewData( view ) ).to.equal( '<p><strong>text{}</strong></p>' );
+		} );
+
+		it( 'should handle view selection if one is returned from mutations', () => {
+			setModelData( model,
+				'<paragraph>' +
+					'<$text bold="true">' +
+						'text[]' +
+					'</$text>' +
+				'</paragraph>'
+			);
+
+			expect( getViewData( view ) ).to.equal( '<p><strong>text{}</strong></p>' );
+
+			const paragraph = viewRoot.getChild( 0 );
+			const strong = paragraph.getChild( 0 );
+			const viewSelection = new ViewSelection();
+			viewSelection.collapse( paragraph, 0 );
+
+			// Simulate mutations and DOM change.
+			domRoot.childNodes[ 0 ].innerHTML = '<b>textx</b>';
+			view.fire( 'mutations', [
+				// Replace `<strong>` with `<b>`.
+				{
+					type: 'children',
+					node: paragraph,
+					oldChildren: [ strong ],
+					newChildren: [ new ViewElement( 'b', null, new ViewText( 'textx' ) ) ]
+				}
+			], viewSelection );
+
+			expect( getModelData( model ) ).to.equal( '<paragraph><$text bold="true">[]textx</$text></paragraph>' );
+			expect( getViewData( view ) ).to.equal( '<p><strong>{}textx</strong></p>' );
+		} );
+	} );
 } );