Explorar el Código

Merge branch 't/795' into t/857

Szymon Kupś hace 8 años
padre
commit
b9170a4372
Se han modificado 22 ficheros con 682 adiciones y 107 borrados
  1. 1 0
      packages/ckeditor5-engine/.travis.yml
  2. 10 2
      packages/ckeditor5-engine/src/controller/insertcontent.js
  3. 17 1
      packages/ckeditor5-engine/src/conversion/model-to-view-converters.js
  4. 12 2
      packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js
  5. 41 2
      packages/ckeditor5-engine/src/dev-utils/enableenginedebug.js
  6. 7 2
      packages/ckeditor5-engine/src/model/delta/attributedelta.js
  7. 2 0
      packages/ckeditor5-engine/src/model/delta/basic-transformations.js
  8. 31 49
      packages/ckeditor5-engine/src/model/range.js
  9. 1 1
      packages/ckeditor5-engine/src/model/selection.js
  10. 2 4
      packages/ckeditor5-engine/src/view/observer/focusobserver.js
  11. 23 1
      packages/ckeditor5-engine/src/view/observer/mutationobserver.js
  12. 34 6
      packages/ckeditor5-engine/tests/controller/insertcontent.js
  13. 99 0
      packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js
  14. 81 0
      packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js
  15. 40 0
      packages/ckeditor5-engine/tests/dev-utils/enableenginedebug.js
  16. 14 0
      packages/ckeditor5-engine/tests/model/delta/attributedelta.js
  17. 3 3
      packages/ckeditor5-engine/tests/model/liverange.js
  18. 1 1
      packages/ckeditor5-engine/tests/model/liveselection.js
  19. 88 6
      packages/ckeditor5-engine/tests/model/range.js
  20. 72 20
      packages/ckeditor5-engine/tests/view/observer/focusobserver.js
  21. 99 0
      packages/ckeditor5-engine/tests/view/observer/mutationobserver.js
  22. 4 7
      packages/ckeditor5-engine/tests/view/observer/selectionobserver.js

+ 1 - 0
packages/ckeditor5-engine/.travis.yml

@@ -21,3 +21,4 @@ script:
   - ckeditor5-dev-tests-travis
 after_success:
   - codeclimate-test-reporter < coverage/lcov.info
+  - ckeditor5-dev-tests-save-revision

+ 10 - 2
packages/ckeditor5-engine/src/controller/insertcontent.js

@@ -24,7 +24,7 @@ import log from '@ckeditor/ckeditor5-utils/src/log';
  *
  * @param {module:engine/controller/datacontroller~DataController} dataController The data controller in context of which the insertion
  * should be performed.
- * @param {module:engine/model/documentfragment~DocumentFragment} content The content to insert.
+ * @param {module:engine/model/documentfragment~DocumentFragment|module:engine/model/item~Item} content The content to insert.
  * @param {module:engine/model/selection~Selection} selection Selection into which the content should be inserted.
  * @param {module:engine/model/batch~Batch} [batch] Batch to which deltas will be added. If not specified, then
  * changes will be added to a new batch.
@@ -42,7 +42,15 @@ export default function insertContent( dataController, content, selection, batch
 
 	const insertion = new Insertion( dataController, batch, selection.anchor );
 
-	insertion.handleNodes( content.getChildren(), {
+	let nodesToInsert;
+
+	if ( content.is( 'documentFragment' ) ) {
+		nodesToInsert = content.getChildren();
+	} else {
+		nodesToInsert = [ content ];
+	}
+
+	insertion.handleNodes( nodesToInsert, {
 		// The set of children being inserted is the only set in this context
 		// so it's the first and last (it's a hack ;)).
 		isFirst: true,

+ 17 - 1
packages/ckeditor5-engine/src/conversion/model-to-view-converters.js

@@ -440,7 +440,23 @@ export function remove() {
 		const viewRange = conversionApi.mapper.toViewRange( modelRange );
 
 		viewWriter.remove( viewRange.getTrimmed() );
-		conversionApi.mapper.unbindModelElement( data.item );
+
+		// Unbind this element only if it was moved to graveyard.
+		// The dispatcher#remove event will also be fired if the element was moved to another place (remove+insert are fired).
+		// Let's say that <b> is moved before <a>. The view will be changed like this:
+		//
+		// 1) start:    <a></a><b></b>
+		// 2) insert:   <b (new)></b><a></a><b></b>
+		// 3) remove:   <b (new)></b><a></a>
+		//
+		// If we'll unbind the <b> element in step 3 we'll also lose binding of the <b (new)> element in the view,
+		// because unbindModelElement() cancels both bindings – (model <b> => view <b (new)>) and (view <b (new)> => model <b>).
+		// We can't lose any of these.
+		//
+		// See #847.
+		if ( data.item.root.rootName == '$graveyard' ) {
+			conversionApi.mapper.unbindModelElement( data.item );
+		}
 	};
 }
 

+ 12 - 2
packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js

@@ -241,8 +241,18 @@ export default class ModelConversionDispatcher {
 	 * @param {module:engine/model/range~Range} range The range containing the moved content.
 	 */
 	convertMove( sourcePosition, range ) {
-		this.convertRemove( sourcePosition, range );
-		this.convertInsertion( range );
+		// Move left – convert insertion first (#847).
+		if ( range.start.isBefore( sourcePosition ) ) {
+			this.convertInsertion( range );
+
+			const sourcePositionAfterInsertion
+				= sourcePosition._getTransformedByInsertion( range.start, range.end.offset - range.start.offset );
+
+			this.convertRemove( sourcePositionAfterInsertion, range );
+		} else {
+			this.convertRemove( sourcePosition, range );
+			this.convertInsertion( range );
+		}
 	}
 
 	/**

+ 41 - 2
packages/ckeditor5-engine/src/dev-utils/enableenginedebug.js

@@ -12,6 +12,7 @@
 import ModelPosition from '../model/position';
 import ModelRange from '../model/range';
 import ModelText from '../model/text';
+import ModelTextProxy from '../model/textproxy';
 import ModelElement from '../model/element';
 import Operation from '../model/operation/operation';
 import AttributeOperation from '../model/operation/attributeoperation';
@@ -38,6 +39,8 @@ import ModelRootElement from '../model/rootelement';
 
 import ViewDocument from '../view/document';
 import ViewElement from '../view/element';
+import ViewText from '../view/text';
+import ViewTextProxy from '../view/textproxy';
 import ViewDocumentFragment from '../view/documentfragment';
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
@@ -128,13 +131,25 @@ function enableLoggingTools() {
 	};
 
 	ModelText.prototype.logExtended = function() {
-		log( `ModelText: ${ this }, attrs: ${ mapString( this._attrs ) }` );
+		log( `ModelText: ${ this }, attrs: ${ mapString( this.getAttributes() ) }` );
 	};
 
 	ModelText.prototype.log = function() {
 		log( 'ModelText: ' + this );
 	};
 
+	ModelTextProxy.prototype.toString = function() {
+		return `#${ this.data }`;
+	};
+
+	ModelTextProxy.prototype.logExtended = function() {
+		log( `ModelTextProxy: ${ this }, attrs: ${ mapString( this.getAttributes() ) }` );
+	};
+
+	ModelTextProxy.prototype.log = function() {
+		log( 'ModelTextProxy: ' + this );
+	};
+
 	ModelElement.prototype.toString = function() {
 		return `<${ this.rootName || this.name }>`;
 	};
@@ -144,7 +159,7 @@ function enableLoggingTools() {
 	};
 
 	ModelElement.prototype.logExtended = function() {
-		log( `ModelElement: ${ this }, ${ this.childCount } children, attrs: ${ mapString( this._attrs ) }` );
+		log( `ModelElement: ${ this }, ${ this.childCount } children, attrs: ${ mapString( this.getAttributes() ) }` );
 	};
 
 	ModelElement.prototype.logAll = function() {
@@ -362,6 +377,30 @@ function enableLoggingTools() {
 			`${ this.range } -> ${ wrapElement }`;
 	};
 
+	ViewText.prototype.toString = function() {
+		return `#${ this.data }`;
+	};
+
+	ViewText.prototype.logExtended = function() {
+		log( 'ViewText: ' + this );
+	};
+
+	ViewText.prototype.log = function() {
+		log( 'ViewText: ' + this );
+	};
+
+	ViewTextProxy.prototype.toString = function() {
+		return `#${ this.data }`;
+	};
+
+	ViewTextProxy.prototype.logExtended = function() {
+		log( 'ViewTextProxy: ' + this );
+	};
+
+	ViewTextProxy.prototype.log = function() {
+		log( 'ViewTextProxy: ' + this );
+	};
+
 	ViewElement.prototype.printTree = function( level = 0 ) {
 		let string = '';
 

+ 7 - 2
packages/ckeditor5-engine/src/model/delta/attributedelta.js

@@ -165,9 +165,10 @@ function changeItem( batch, doc, key, value, item ) {
 	let range, operation;
 
 	const delta = item.is( 'rootElement' ) ? new RootAttributeDelta() : new AttributeDelta();
-	batch.addDelta( delta );
 
 	if ( previousValue != value ) {
+		batch.addDelta( delta );
+
 		if ( item.is( 'rootElement' ) ) {
 			// If we change attributes of root element, we have to use `RootAttributeOperation`.
 			operation = new RootAttributeOperation( item, key, previousValue, value, doc.version );
@@ -195,7 +196,6 @@ function changeItem( batch, doc, key, value, item ) {
 // into smaller parts.
 function changeRange( batch, doc, attributeKey, attributeValue, range ) {
 	const delta = new AttributeDelta();
-	batch.addDelta( delta );
 
 	// Position of the last split, the beginning of the new range.
 	let lastSplitPosition = range.start;
@@ -233,6 +233,11 @@ function changeRange( batch, doc, attributeKey, attributeValue, range ) {
 	}
 
 	function addOperation() {
+		// Add delta to the batch only if there is at least operation in the delta. Add delta only once.
+		if ( delta.operations.length === 0 ) {
+			batch.addDelta( delta );
+		}
+
 		let range = new Range( lastSplitPosition, position );
 		const operation = new AttributeOperation( range, attributeKey, attributeValueBefore, attributeValue, doc.version );
 

+ 2 - 0
packages/ckeditor5-engine/src/model/delta/basic-transformations.js

@@ -111,6 +111,8 @@ addTransformationCase( MarkerDelta, SplitDelta, transformMarkerDelta );
 addTransformationCase( MarkerDelta, MergeDelta, transformMarkerDelta );
 addTransformationCase( MarkerDelta, WrapDelta, transformMarkerDelta );
 addTransformationCase( MarkerDelta, UnwrapDelta, transformMarkerDelta );
+addTransformationCase( MarkerDelta, MoveDelta, transformMarkerDelta );
+addTransformationCase( MarkerDelta, RenameDelta, transformMarkerDelta );
 
 // Add special case for MoveDelta x MergeDelta transformation.
 addTransformationCase( MoveDelta, MergeDelta, ( a, b, isStrong ) => {

+ 31 - 49
packages/ckeditor5-engine/src/model/range.js

@@ -447,61 +447,43 @@ export default class Range {
 	 * @returns {Array.<module:engine/model/range~Range>}
 	 */
 	_getTransformedByDocumentChange( type, deltaType, targetPosition, howMany, sourcePosition ) {
-		// IMPORTANT! Every special case added here has to be reflected in MarkerDelta transformations!
-		// Check /src/model/delta/basic-transformations.js.
 		if ( type == 'insert' ) {
 			return this._getTransformedByInsertion( targetPosition, howMany, false, false );
 		} else {
-			const ranges = this._getTransformedByMove( sourcePosition, targetPosition, howMany );
-
-			// Don't ask. Just debug.
-			// Like this: https://github.com/ckeditor/ckeditor5-engine/issues/841#issuecomment-282706488.
-			//
-			// In following cases, in examples, the last step is the fix step.
-			// When there are multiple ranges in an example, ranges[] array indices are represented as follows:
-			// * [] is ranges[ 0 ],
-			// * {} is ranges[ 1 ],
-			// * () is ranges[ 2 ].
-			if ( type == 'move' ) {
-				const sourceRange = Range.createFromPositionAndShift( sourcePosition, howMany );
-
-				if ( deltaType == 'split' && this.containsPosition( sourcePosition ) ) {
-					// Range contains a position where an element is split.
-					// <p>f[ooba]r</p> -> <p>f[ooba]r</p><p></p> -> <p>f[oo]</p><p>{ba}r</p> -> <p>f[oo</p><p>ba]r</p>
-					return [ new Range( ranges[ 0 ].start, ranges[ 1 ].end ) ];
-				} else if ( deltaType == 'merge' && this.isCollapsed && ranges[ 0 ].start.isEqual( sourcePosition ) ) {
-					// Collapsed range is in merged element.
-					// Without fix, the range would end up in the graveyard, together with removed element.
-					// <p>foo</p><p>[]bar</p> -> <p>foobar</p><p>[]</p> -> <p>foobar</p> -> <p>foo[]bar</p>
-					return [ new Range( targetPosition.getShiftedBy( this.start.offset ) ) ];
-				} else if ( deltaType == 'wrap' ) {
-					// Range intersects (at the start) with wrapped element (<p>ab</p>).
-					// <p>a[b</p><p>c]d</p> -> <p>a[b</p><w></w><p>c]d</p> -> [<w>]<p>a(b</p>){</w><p>c}d</p> -> <w><p>a[b</p></w><p>c]d</p>
-					if ( sourceRange.containsPosition( this.start ) && this.containsPosition( sourceRange.end ) ) {
-						return [ new Range( ranges[ 2 ].start, ranges[ 1 ].end ) ];
-					}
-					// Range intersects (at the end) with wrapped element (<p>cd</p>).
-					// <p>a[b</p><p>c]d</p> -> <p>a[b</p><p>c]d</p><w></w> -> <p>a[b</p>]<w>{<p>c}d</p></w> -> <p>a[b</p><w><p>c]d</p></w>
-					else if ( sourceRange.containsPosition( this.end ) && this.containsPosition( sourceRange.start ) ) {
-						return [ new Range( ranges[ 0 ].start, ranges[ 1 ].end ) ];
-					}
-				} else if ( deltaType == 'unwrap' ) {
-					// Range intersects (at the beginning) with unwrapped element (<w></w>).
-					// <w><p>a[b</p></w><p>c]d</p> -> <p>a{b</p>}<w>[</w><p>c]d</p> -> <p>a[b</p><w></w><p>c]d</p>
-					// <w></w> is removed in next operation, but the remove does not mess up ranges.
-					if ( sourceRange.containsPosition( this.start ) && this.containsPosition( sourceRange.end ) ) {
-						return [ new Range( ranges[ 1 ].start, ranges[ 0 ].end ) ];
-					}
-					// Range intersects (at the end) with unwrapped element (<w></w>).
-					// <p>a[b</p><w><p>c]d</p></w> -> <p>a[b</p>](<p>c)d</p>{<w>}</w> -> <p>a[b</p><p>c]d</p><w></w>
-					// <w></w> is removed in next operation, but the remove does not mess up ranges.
-					else if ( sourceRange.containsPosition( this.end ) && this.containsPosition( sourceRange.start ) ) {
-						return [ new Range( ranges[ 0 ].start, ranges[ 2 ].end ) ];
-					}
+			const sourceRange = Range.createFromPositionAndShift( sourcePosition, howMany );
+
+			if ( deltaType == 'merge' && this.isCollapsed && ( this.start.isEqual( sourceRange.start ) || this.start.isEqual( sourceRange.end ) ) ) {
+				// Collapsed range is in merged element.
+				// Without fix, the range would end up in the graveyard, together with removed element.
+				// <p>foo</p><p>[]bar</p> -> <p>foobar</p><p>[]</p> -> <p>foobar</p> -> <p>foo[]bar</p>
+				return [ new Range( targetPosition.getShiftedBy( this.start.offset ) ) ];
+			} else if ( type == 'move' ) {
+				// In all examples `[]` is `this` and `{}` is `sourceRange`, while `^` is move target position.
+				//
+				// Example:
+				// <p>xx</p>^<w>{<p>a[b</p>}</w><p>c]d</p>   -->   <p>xx</p><p>a[b</p><w></w><p>c]d</p>
+				// ^<p>xx</p><w>{<p>a[b</p>}</w><p>c]d</p>   -->   <p>a[b</p><p>xx</p><w></w><p>c]d</p>  // Note <p>xx</p> inclusion.
+				// <w>{<p>a[b</p>}</w>^<p>c]d</p>            -->   <w></w><p>a[b</p><p>c]d</p>
+				if ( sourceRange.containsPosition( this.start ) && this.containsPosition( sourceRange.end ) && this.end.isAfter( targetPosition ) ) {
+					let start = this.start._getCombined( sourcePosition, targetPosition._getTransformedByDeletion( sourcePosition, howMany ) );
+					const end = this.end._getTransformedByMove( sourcePosition, targetPosition, howMany, false, false );
+
+					return [ new Range( start, end ) ];
+				}
+
+				// Example:
+				// <p>c[d</p><w>{<p>a]b</p>}</w>^<p>xx</p>   -->   <p>c[d</p><w></w><p>a]b</p><p>xx</p>
+				// <p>c[d</p><w>{<p>a]b</p>}</w><p>xx</p>^   -->   <p>c[d</p><w></w><p>xx</p><p>a]b</p>  // Note <p>xx</p> inclusion.
+				// <p>c[d</p>^<w>{<p>a]b</p>}</w>            -->   <p>c[d</p><p>a]b</p><w></w>
+				if ( sourceRange.containsPosition( this.end ) && this.containsPosition( sourceRange.start ) && this.start.isBefore( targetPosition ) ) {
+					const start = this.start._getTransformedByMove( sourcePosition, targetPosition, howMany, true, false );
+					let end = this.end._getCombined( sourcePosition, targetPosition._getTransformedByDeletion( sourcePosition, howMany ) );
+
+					return [ new Range( start, end ) ];
 				}
 			}
 
-			return ranges;
+			return this._getTransformedByMove( sourcePosition, targetPosition, howMany );
 		}
 	}
 

+ 1 - 1
packages/ckeditor5-engine/src/model/selection.js

@@ -177,7 +177,7 @@ export default class Selection {
 
 	/**
 	 * Returns a copy of the first range in the selection.
-	 * irst range is the one which {@link module:engine/model/range~Range#start start} position
+	 * First range is the one which {@link module:engine/model/range~Range#start start} position
 	 * {@link module:engine/model/position~Position#isBefore is before} start position of all other ranges
 	 * (not to confuse with the first range added to the selection).
 	 *

+ 2 - 4
packages/ckeditor5-engine/src/view/observer/focusobserver.js

@@ -7,11 +7,9 @@
  * @module engine/view/observer/focusobserver
  */
 
-import DomEventObserver from './domeventobserver';
-import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+/* globals setTimeout, clearTimeout */
 
-const setTimeout = global.window.setTimeout;
-const clearTimeout = global.window.clearTimeout;
+import DomEventObserver from './domeventobserver';
 
 /**
  * {@link module:engine/view/document~Document#event:focus Focus}

+ 23 - 1
packages/ckeditor5-engine/src/view/observer/mutationobserver.js

@@ -149,7 +149,7 @@ export default class MutationObserver extends Observer {
 			if ( mutation.type === 'childList' ) {
 				const element = domConverter.getCorrespondingViewElement( mutation.target );
 
-				if ( element ) {
+				if ( element && !this._isBogusBrMutation( mutation ) ) {
 					mutatedElements.add( element );
 				}
 			}
@@ -233,6 +233,28 @@ export default class MutationObserver extends Observer {
 		// view (which has not been changed). In order to "reset DOM" we render the view again.
 		this.document.render();
 	}
+
+	/**
+	 * 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' );
+	}
 }
 
 /**

+ 34 - 6
packages/ckeditor5-engine/tests/controller/insertcontent.js

@@ -17,11 +17,11 @@ describe( 'DataController', () => {
 
 	describe( 'insertContent', () => {
 		it( 'uses the passed batch', () => {
-			doc = new Document();
+			const doc = new Document();
 			doc.createRoot();
 			doc.schema.allow( { name: '$text', inside: '$root' } );
 
-			dataController = new DataController( doc );
+			const dataController = new DataController( doc );
 
 			const batch = doc.batch();
 
@@ -32,6 +32,36 @@ describe( 'DataController', () => {
 			expect( batch.deltas.length ).to.be.above( 0 );
 		} );
 
+		it( 'accepts DocumentFragment', () => {
+			const doc = new Document();
+			const dataController = new DataController( doc );
+			const batch = doc.batch();
+
+			doc.createRoot();
+			doc.schema.allow( { name: '$text', inside: '$root' } );
+
+			setData( doc, 'x[]x' );
+
+			insertContent( dataController, new DocumentFragment( [ new Text( 'a' ) ] ), doc.selection, batch );
+
+			expect( getData( doc ) ).to.equal( 'xa[]x' );
+		} );
+
+		it( 'accepts Text', () => {
+			const doc = new Document();
+			const dataController = new DataController( doc );
+			const batch = doc.batch();
+
+			doc.createRoot();
+			doc.schema.allow( { name: '$text', inside: '$root' } );
+
+			setData( doc, 'x[]x' );
+
+			insertContent( dataController, new Text( 'a' ), doc.selection, batch );
+
+			expect( getData( doc ) ).to.equal( 'xa[]x' );
+		} );
+
 		describe( 'in simple scenarios', () => {
 			beforeEach( () => {
 				doc = new Document();
@@ -604,6 +634,8 @@ describe( 'DataController', () => {
 		} );
 	} );
 
+	// Helper function that parses given content and inserts it at the cursor position.
+	//
 	// @param {module:engine/model/item~Item|String} content
 	function insertHelper( content ) {
 		if ( typeof content == 'string' ) {
@@ -612,10 +644,6 @@ describe( 'DataController', () => {
 			} );
 		}
 
-		if ( !( content instanceof DocumentFragment ) ) {
-			content = new DocumentFragment( [ content ] );
-		}
-
 		insertContent( dataController, content, doc.selection );
 	}
 } );

+ 99 - 0
packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js

@@ -1002,5 +1002,104 @@ describe( 'model-to-view-converters', () => {
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div>foo<span></span>ar</div>' );
 		} );
+
+		it( 'should not unbind element that has not been moved to graveyard', () => {
+			const modelElement = new ModelElement( 'a' );
+			const viewElement = new ViewElement( 'a' );
+
+			modelRoot.appendChildren( [ modelElement, new ModelText( 'b' ) ] );
+			viewRoot.appendChildren( [ viewElement, new ViewText( 'b' ) ] );
+
+			mapper.bindElements( modelElement, viewElement );
+
+			dispatcher.on( 'remove', remove() );
+
+			// Move <a></a> after "b". Can be e.g. a part of an unwrap delta (move + remove).
+			modelWriter.move(
+				ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ),
+				ModelPosition.createAt( modelRoot, 'end' )
+			);
+
+			dispatcher.convertRemove(
+				ModelPosition.createFromParentAndOffset( modelRoot, 0 ),
+				ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 2 )
+			);
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div>b</div>' );
+
+			expect( mapper.toModelElement( viewElement ) ).to.equal( modelElement );
+			expect( mapper.toViewElement( modelElement ) ).to.equal( viewElement );
+		} );
+
+		it( 'should unbind elements if model element was moved to graveyard', () => {
+			const modelElement = new ModelElement( 'a' );
+			const viewElement = new ViewElement( 'a' );
+
+			modelRoot.appendChildren( [ modelElement, new ModelText( 'b' ) ] );
+			viewRoot.appendChildren( [ viewElement, new ViewText( 'b' ) ] );
+
+			mapper.bindElements( modelElement, viewElement );
+
+			dispatcher.on( 'remove', remove() );
+
+			// Move <a></a> to graveyard.
+			modelWriter.move(
+				ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ),
+				ModelPosition.createAt( modelDoc.graveyard, 'end' )
+			);
+
+			dispatcher.convertRemove(
+				ModelPosition.createFromParentAndOffset( modelRoot, 0 ),
+				ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 1 )
+			);
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div>b</div>' );
+
+			expect( mapper.toModelElement( viewElement ) ).to.be.undefined;
+			expect( mapper.toViewElement( modelElement ) ).to.be.undefined;
+		} );
+
+		// TODO move to conversion/integration.js one day.
+		it( 'should not break when remove() is used as part of unwrapping', () => {
+			// The whole process looks like this:
+			// <w><a></a></w> => <a></a><w><a></a></w> => <a></a><w></w> => <a></a>
+			// The <a> is duplicated for a while in the view.
+
+			const modelAElement = new ModelElement( 'a' );
+			const modelWElement = new ModelElement( 'w' );
+			const viewAElement = new ViewContainerElement( 'a' );
+			const viewA2Element = new ViewContainerElement( 'a2' );
+			const viewWElement = new ViewContainerElement( 'w' );
+
+			modelRoot.appendChildren( modelWElement );
+			viewRoot.appendChildren( viewWElement );
+
+			modelWElement.appendChildren( modelAElement );
+			viewWElement.appendChildren( viewAElement );
+
+			mapper.bindElements( modelWElement, viewWElement );
+			mapper.bindElements( modelAElement, viewAElement );
+
+			dispatcher.on( 'remove', remove() );
+			dispatcher.on( 'insert', insertElement( () => viewA2Element ) );
+
+			modelDoc.on( 'change', ( evt, type, changes ) => {
+				dispatcher.convertChange( type, changes );
+			} );
+
+			modelDoc.batch().unwrap( modelWElement );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><a2></a2></div>' );
+
+			expect( mapper.toModelElement( viewA2Element ) ).to.equal( modelAElement );
+			expect( mapper.toViewElement( modelAElement ) ).to.equal( viewA2Element );
+
+			// This is a bit unfortunate, but we think we can live with this.
+			// The viewAElement is not in the tree and there's a high chance that all reference to it are gone.
+			expect( mapper.toModelElement( viewAElement ) ).to.equal( modelAElement );
+
+			expect( mapper.toModelElement( viewWElement ) ).to.be.undefined;
+			expect( mapper.toViewElement( modelWElement ) ).to.be.undefined;
+		} );
 	} );
 } );

+ 81 - 0
packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js

@@ -289,6 +289,87 @@ describe( 'ModelConversionDispatcher', () => {
 	} );
 
 	describe( 'convertMove', () => {
+		let loggedEvents;
+
+		beforeEach( () => {
+			loggedEvents = [];
+
+			dispatcher.on( 'remove', ( evt, data ) => {
+				const log = 'remove:' + data.sourcePosition.path + ':' + data.item.offsetSize;
+				loggedEvents.push( log );
+			} );
+
+			dispatcher.on( 'insert', ( evt, data ) => {
+				const log = 'insert:' + data.range.start.path + ':' + data.range.end.path;
+				loggedEvents.push( log );
+			} );
+		} );
+
+		it( 'should first fire remove and then insert if moving "right"', () => {
+			// <root>[ab]cd^ef</root> -> <root>cdabef</root>
+			root.appendChildren( new ModelText( 'cdabef' ) );
+
+			const sourcePosition = ModelPosition.createFromParentAndOffset( root, 0 );
+			const movedRange = ModelRange.createFromParentsAndOffsets( root, 2, root, 4 );
+
+			dispatcher.convertMove( sourcePosition, movedRange );
+
+			// after remove: cdef
+			// after insert: cd[ab]ef
+			expect( loggedEvents ).to.deep.equal( [ 'remove:0:2', 'insert:2:4' ] );
+		} );
+
+		it( 'should first fire insert and then remove if moving "left"', () => {
+			// <root>ab^cd[ef]</root> -> <root>abefcd</root>
+			root.appendChildren( new ModelText( 'abefcd' ) );
+
+			const sourcePosition = ModelPosition.createFromParentAndOffset( root, 4 );
+			const movedRange = ModelRange.createFromParentsAndOffsets( root, 2, root, 4 );
+
+			dispatcher.convertMove( sourcePosition, movedRange );
+
+			// after insert: ab[ef]cd[ef]
+			// after remove: ab[ef]cd
+			expect( loggedEvents ).to.deep.equal( [ 'insert:2:4', 'remove:6:2' ] );
+		} );
+
+		it( 'should first fire insert and then remove when moving like in unwrap', () => {
+			// <root>a^<w>[xyz]</w>b</root> -> <root>axyz<w></w>b</root>
+			root.appendChildren( [
+				new ModelText( 'axyz' ),
+				new ModelElement( 'w' ),
+				new ModelText( 'b' )
+			] );
+
+			const sourcePosition = new ModelPosition( root, [ 1, 0 ] );
+			const movedRange = ModelRange.createFromParentsAndOffsets( root, 1, root, 4 );
+
+			dispatcher.convertMove( sourcePosition, movedRange );
+
+			// before:       a<w>[xyz]</w>b
+			// after insert: a[xyz]<w>[xyz]</w>b
+			// after remove: a[xyz]<w></w>b
+			expect( loggedEvents ).to.deep.equal( [ 'insert:1:4', 'remove:4,0:3' ] );
+		} );
+
+		it( 'should first fire remove and then insert when moving like in wrap', () => {
+			// <root>a[xyz]<w>^</w>b</root> -> <root>a<w>xyz</w>b</root>
+			root.appendChildren( [
+				new ModelText( 'a' ),
+				new ModelElement( 'w', null, [ new ModelText( 'xyz' ) ] ),
+				new ModelText( 'b' )
+			] );
+
+			const sourcePosition = ModelPosition.createFromParentAndOffset( root, 1 );
+			const movedRange = ModelRange.createFromPositionAndShift( new ModelPosition( root, [ 1, 0 ] ), 3 );
+
+			dispatcher.convertMove( sourcePosition, movedRange );
+
+			// before:       a[xyz]<w></w>b
+			// after remove: a<w></w>b
+			// after insert: a<w>[xyz]</w>b
+			expect( loggedEvents ).to.deep.equal( [ 'remove:1:3', 'insert:1,0:1,3' ] );
+		} );
 	} );
 
 	describe( 'convertRemove', () => {

+ 40 - 0
packages/ckeditor5-engine/tests/dev-utils/enableenginedebug.js

@@ -10,6 +10,7 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ModelPosition from '../../src/model/position';
 import ModelRange from '../../src/model/range';
 import ModelText from '../../src/model/text';
+import ModelTextProxy from '../../src/model/textproxy';
 import ModelElement from '../../src/model/element';
 import AttributeOperation from '../../src/model/operation/attributeoperation';
 import InsertOperation from '../../src/model/operation/insertoperation';
@@ -37,6 +38,7 @@ import ViewDocument from '../../src/view/document';
 import ViewAttributeElement from '../../src/view/attributeelement';
 import ViewContainerElement from '../../src/view/containerelement';
 import ViewText from '../../src/view/text';
+import ViewTextProxy from '../../src/view/textproxy';
 import ViewDocumentFragment from '../../src/view/documentfragment';
 
 /* global document */
@@ -100,6 +102,19 @@ describe( 'debug tools', () => {
 			expect( log.calledWithExactly( 'ModelText: #foo, attrs: {"foo":"bar"}' ) ).to.be.true;
 		} );
 
+		it( 'for ModelTextProxy', () => {
+			const foo = new ModelText( 'foo', { foo: 'bar' } );
+			const proxy = new ModelTextProxy( foo, 1, 1 );
+
+			expect( proxy.toString() ).to.equal( '#o' );
+
+			proxy.log();
+			expect( log.calledWithExactly( 'ModelTextProxy: #o' ) ).to.be.true;
+
+			proxy.logExtended();
+			expect( log.calledWithExactly( 'ModelTextProxy: #o, attrs: {"foo":"bar"}' ) ).to.be.true;
+		} );
+
 		it( 'for ModelElement', () => {
 			const paragraph = new ModelElement( 'paragraph', { foo: 'bar' }, new ModelText( 'foo' ) );
 
@@ -153,6 +168,31 @@ describe( 'debug tools', () => {
 			expectLog( 'ModelRange: main [ 0 ] - [ 0 ]' );
 		} );
 
+		it( 'for ViewText', () => {
+			const foo = new ViewText( 'foo' );
+
+			expect( foo.toString() ).to.equal( '#foo' );
+
+			foo.log();
+			expect( log.calledWithExactly( 'ViewText: #foo' ) ).to.be.true;
+
+			foo.logExtended();
+			expect( log.calledWithExactly( 'ViewText: #foo' ) ).to.be.true;
+		} );
+
+		it( 'for ViewTextProxy', () => {
+			const foo = new ViewText( 'foo', { foo: 'bar' } );
+			const proxy = new ViewTextProxy( foo, 1, 1 );
+
+			expect( proxy.toString() ).to.equal( '#o' );
+
+			proxy.log();
+			expect( log.calledWithExactly( 'ViewTextProxy: #o' ) ).to.be.true;
+
+			proxy.logExtended();
+			expect( log.calledWithExactly( 'ViewTextProxy: #o' ) ).to.be.true;
+		} );
+
 		describe( 'for operations', () => {
 			beforeEach( () => {
 				modelRoot.appendChildren( [ new ModelText( 'foobar' ) ] );

+ 14 - 0
packages/ckeditor5-engine/tests/model/delta/attributedelta.js

@@ -393,6 +393,20 @@ describe( 'Batch', () => {
 			} );
 		} );
 	} );
+
+	it( 'should not add empty delta to the batch', () => {
+		let nodeA = new Element( 'p', { a: 1 } );
+		let nodeB = new Element( 'p', { b: 2 } );
+		root.insertChildren( 0, [ nodeA, nodeB ] );
+
+		batch.setAttribute( nodeA, 'a', 1 );
+
+		expect( batch.deltas.length ).to.equal( 0 );
+
+		batch.removeAttribute( Range.createIn( root ), 'x' );
+
+		expect( batch.deltas.length ).to.equal( 0 );
+	} );
 } );
 
 describe( 'AttributeDelta', () => {

+ 3 - 3
packages/ckeditor5-engine/tests/model/liverange.js

@@ -292,7 +292,7 @@ describe( 'LiveRange', () => {
 				doc.fire( 'change', 'move', changes, null );
 
 				expect( live.start.path ).to.deep.equal( [ 0, 1, 4 ] );
-				expect( live.end.path ).to.deep.equal( [ 0, 2, 1 ] );
+				expect( live.end.path ).to.deep.equal( [ 2, 1 ] ); // Included some nodes.
 				expect( spy.calledOnce ).to.be.true;
 			} );
 
@@ -307,7 +307,7 @@ describe( 'LiveRange', () => {
 				doc.fire( 'change', 'move', changes, null );
 
 				expect( live.start.path ).to.deep.equal( [ 0, 1, 4 ] );
-				expect( live.end.path ).to.deep.equal( [ 0, 2, 6 ] );
+				expect( live.end.path ).to.deep.equal( [ 0, 2, 1 ] );
 				expect( spy.calledOnce ).to.be.true;
 			} );
 
@@ -357,7 +357,7 @@ describe( 'LiveRange', () => {
 				};
 				doc.fire( 'change', 'move', changes, null );
 
-				expect( live.start.path ).to.deep.equal( [ 0, 1, 2 ] );
+				expect( live.start.path ).to.deep.equal( [ 0, 1, 9 ] );
 				expect( live.end.path ).to.deep.equal( [ 0, 1, 12 ] );
 				expect( spy.calledOnce ).to.be.true;
 			} );

+ 1 - 1
packages/ckeditor5-engine/tests/model/liveselection.js

@@ -453,7 +453,7 @@ describe( 'LiveSelection', () => {
 				let range = selection.getFirstRange();
 
 				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
-				expect( range.end.path ).to.deep.equal( [ 1, 3 ] );
+				expect( range.end.path ).to.deep.equal( [ 5 ] );
 				expect( spyRange.calledOnce ).to.be.true;
 			} );
 

+ 88 - 6
packages/ckeditor5-engine/tests/model/range.js

@@ -750,8 +750,8 @@ describe( 'Range', () => {
 		describe( 'by MoveDelta', () => {
 			it( 'move before range', () => {
 				const start = new Position( root, [ 0 ] );
-				const end = new Position( otherRoot, [ 0 ] );
-				const delta = getMoveDelta( start, 2, end, 1 );
+				const target = new Position( otherRoot, [ 0 ] );
+				const delta = getMoveDelta( start, 2, target, 1 );
 
 				const transformed = range.getTransformedByDelta( delta );
 
@@ -760,8 +760,8 @@ describe( 'Range', () => {
 
 			it( 'move intersecting with range (and targeting before range)', () => {
 				const start = new Position( root, [ 4 ] );
-				const end = new Position( root, [ 0 ] );
-				const delta = getMoveDelta( start, 2, end, 1 );
+				const target = new Position( root, [ 0 ] );
+				const delta = getMoveDelta( start, 2, target, 1 );
 
 				const transformed = range.getTransformedByDelta( delta );
 
@@ -772,8 +772,8 @@ describe( 'Range', () => {
 			it( 'move inside the range', () => {
 				range.end.offset = 6;
 				const start = new Position( root, [ 3 ] );
-				const end = new Position( root, [ 5 ] );
-				const delta = getMoveDelta( start, 1, end, 1 );
+				const target = new Position( root, [ 5 ] );
+				const delta = getMoveDelta( start, 1, target, 1 );
 
 				const transformed = range.getTransformedByDelta( delta );
 
@@ -781,6 +781,71 @@ describe( 'Range', () => {
 				expectRange( transformed[ 1 ], 5, 6 );
 				expectRange( transformed[ 2 ], 4, 5 );
 			} );
+
+			// #877.
+			it( 'moved element contains range start and is moved towards inside of range', () => {
+				// Initial state:
+				// <w><p>abc</p><p>x[x</p></w><p>d]ef</p>
+				// Expected state after moving `<p>` out of `<w>`:
+				// <w><p>abc</p></w><p>x[x</p><p>d]ef</p>
+
+				const range = new Range( new Position( root, [ 0, 1, 1 ] ), new Position( root, [ 1, 1 ] ) );
+				const delta = getMoveDelta( new Position( root, [ 0, 1 ] ), 1, new Position( root, [ 1 ] ), 1 );
+
+				const transformed = range.getTransformedByDelta( delta );
+
+				expect( transformed.length ).to.equal( 1 );
+				expect( transformed[ 0 ].start.path ).to.deep.equal( [ 1, 1 ] );
+				expect( transformed[ 0 ].end.path ).to.deep.equal( [ 2, 1 ] );
+			} );
+
+			it( 'moved element contains range start and is moved out of range', () => {
+				// Initial state:
+				// <p>abc</p><p>x[x</p><p>d]ef</p>
+				// Expected state after moving:
+				// <p>x[x</p><p>abc</p><p>d]ef</p>
+
+				const range = new Range( new Position( root, [ 1, 1 ] ), new Position( root, [ 2, 1 ] ) );
+				const delta = getMoveDelta( new Position( root, [ 1 ] ), 1, new Position( root, [ 0 ] ), 1 );
+
+				const transformed = range.getTransformedByDelta( delta );
+
+				expect( transformed.length ).to.equal( 1 );
+				expect( transformed[ 0 ].start.path ).to.deep.equal( [ 0, 1 ] );
+				expect( transformed[ 0 ].end.path ).to.deep.equal( [ 2, 1 ] );
+			} );
+
+			it( 'moved element contains range end and is moved towards range', () => {
+				// Initial state:
+				// <p>a[bc</p><p>def</p><p>x]x</p>
+				// Expected state after moving:
+				// <p>a[bc</p><p>x]x</p><p>def</p>
+
+				const range = new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 2, 1 ] ) );
+				const delta = getMoveDelta( new Position( root, [ 2 ] ), 1, new Position( root, [ 1 ] ), 1 );
+
+				const transformed = range.getTransformedByDelta( delta );
+
+				expect( transformed.length ).to.equal( 1 );
+				expect( transformed[ 0 ].start.path ).to.deep.equal( [ 0, 1 ] );
+				expect( transformed[ 0 ].end.path ).to.deep.equal( [ 1, 1 ] );
+			} );
+
+			it( 'moved element contains range end and is moved out of range', () => {
+				// Initial state:
+				// <p>a[bc</p><p>x]x</p><p>def</p>
+				// Expected state after moving:
+				// <p>a[bc</p><p>def</p><p>x]x</p>
+
+				const range = new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 1, 1 ] ) );
+				const delta = getMoveDelta( new Position( root, [ 1 ] ), 1, new Position( root, [ 3 ] ), 1 );
+
+				const transformed = range.getTransformedByDelta( delta );
+
+				expect( transformed.length ).to.equal( 1 );
+				expect( transformed[ 0 ].start.path ).to.deep.equal( [ 0, 1 ] );
+				expect( transformed[ 0 ].end.path ).to.deep.equal( [ 2, 1 ] );
+			} );
 		} );
 
 		describe( 'by RemoveDelta', () => {
@@ -858,6 +923,23 @@ describe( 'Range', () => {
 					expect( transformed[ 0 ].start.path ).to.deep.equal( [ 0, 3 ] );
 					expect( transformed[ 0 ].end.path ).to.deep.equal( [ 0, 3 ] );
 				} );
+
+				// #877.
+				it( 'merge elements that contain elements with range boundaries', () => {
+					// Initial state:
+					// <w><p>x[x</p></w><w><p>y]y</p></w>
+					// Expected state after merge:
+					// <w><p>x[x</p><p>y]y</p></w>
+
+					const range = new Range( new Position( root, [ 0, 0, 1 ] ), new Position( root, [ 1, 0, 1 ] ) );
+					const delta = getMergeDelta( new Position( root, [ 1 ] ), 1, 1, 1 );
+
+					const transformed = range.getTransformedByDelta( delta );
+
+					expect( transformed.length ).to.equal( 1 );
+					expect( transformed[ 0 ].start.path ).to.deep.equal( [ 0, 0, 1 ] );
+					expect( transformed[ 0 ].end.path ).to.deep.equal( [ 0, 1, 1 ] );
+				} );
 			} );
 
 			describe( 'by WrapDelta', () => {

+ 72 - 20
packages/ckeditor5-engine/tests/view/observer/focusobserver.js

@@ -3,13 +3,11 @@
  * For licensing, see LICENSE.md.
  */
 
+/* globals document, window */
 import FocusObserver from '../../../src/view/observer/focusobserver';
 import ViewDocument from '../../../src/view/document';
 import ViewRange from '../../../src/view/range';
-import global from '@ckeditor/ckeditor5-utils/src/dom/global';
-
-const setTimeout = global.window.setTimeout;
-const domDocument = global.document;
+import { setData } from '../../../src/dev-utils/view';
 
 describe( 'FocusObserver', () => {
 	let viewDocument, observer;
@@ -37,12 +35,12 @@ describe( 'FocusObserver', () => {
 
 			viewDocument.on( 'focus', spy );
 
-			observer.onDomEvent( { type: 'focus', target: domDocument.body } );
+			observer.onDomEvent( { type: 'focus', target: document.body } );
 
 			expect( spy.calledOnce ).to.be.true;
 
 			const data = spy.args[ 0 ][ 1 ];
-			expect( data.domTarget ).to.equal( domDocument.body );
+			expect( data.domTarget ).to.equal( document.body );
 		} );
 
 		it( 'should fire blur with the right event data', () => {
@@ -50,18 +48,18 @@ describe( 'FocusObserver', () => {
 
 			viewDocument.on( 'blur', spy );
 
-			observer.onDomEvent( { type: 'blur', target: domDocument.body } );
+			observer.onDomEvent( { type: 'blur', target: document.body } );
 
 			expect( spy.calledOnce ).to.be.true;
 
 			const data = spy.args[ 0 ][ 1 ];
-			expect( data.domTarget ).to.equal( domDocument.body );
+			expect( data.domTarget ).to.equal( document.body );
 		} );
 
 		it( 'should render document after blurring', () => {
 			const renderSpy = sinon.spy( viewDocument, 'render' );
 
-			observer.onDomEvent( { type: 'blur', target: domDocument.body } );
+			observer.onDomEvent( { type: 'blur', target: document.body } );
 
 			sinon.assert.calledOnce( renderSpy );
 		} );
@@ -71,8 +69,8 @@ describe( 'FocusObserver', () => {
 		let domMain, domHeader, viewMain, viewHeader;
 
 		beforeEach( () => {
-			domMain = domDocument.createElement( 'div' );
-			domHeader = domDocument.createElement( 'h1' );
+			domMain = document.createElement( 'div' );
+			domHeader = document.createElement( 'h1' );
 
 			viewMain = viewDocument.createRoot( domMain );
 			viewHeader = viewDocument.createRoot( domHeader, 'header' );
@@ -118,29 +116,83 @@ describe( 'FocusObserver', () => {
 			expect( viewDocument.isFocused ).to.be.true;
 		} );
 
-		it( 'should delay rendering to the next iteration of event loop', ( done ) => {
+		it( 'should delay rendering to the next iteration of event loop', () => {
 			const renderSpy = sinon.spy( viewDocument, 'render' );
+			const clock = sinon.useFakeTimers();
 
 			observer.onDomEvent( { type: 'focus', target: domMain } );
 			sinon.assert.notCalled( renderSpy );
+			clock.tick( 0 );
+			sinon.assert.called( renderSpy );
 
-			setTimeout( () => {
-				sinon.assert.called( renderSpy );
-				done();
-			}, 0 );
+			clock.restore();
 		} );
 
-		it( 'should not call render if destroyed', ( done ) => {
+		it( 'should not call render if destroyed', () => {
 			const renderSpy = sinon.spy( viewDocument, 'render' );
+			const clock = sinon.useFakeTimers();
 
 			observer.onDomEvent( { type: 'focus', target: domMain } );
 			sinon.assert.notCalled( renderSpy );
 			observer.destroy();
+			clock.tick( 0 );
+			sinon.assert.notCalled( renderSpy );
+
+			clock.restore();
+		} );
+	} );
+
+	describe( 'integration test', () => {
+		let viewDocument, viewRoot, domRoot, observer, domSelection;
+
+		beforeEach( () => {
+			domRoot = document.createElement( 'div' );
+			document.body.appendChild( domRoot );
+			viewDocument = new ViewDocument();
+			viewRoot = viewDocument.createRoot( domRoot );
+			observer = viewDocument.getObserver( FocusObserver );
+			domSelection = window.getSelection();
+		} );
+
+		it( 'should render document after selectionChange event', ( done ) => {
+			const selectionChangeSpy = sinon.spy();
+			const renderSpy = sinon.spy();
+
+			setData( viewDocument, '<div contenteditable="true">foo bar</div>' );
+			viewDocument.render();
+			const domEditable = domRoot.childNodes[ 0 ];
+
+			viewDocument.on( 'selectionChange', selectionChangeSpy );
+			viewDocument.on( 'render', renderSpy, { priority: 'low' } );
+
+			viewDocument.on( 'render', () => {
+				sinon.assert.callOrder( selectionChangeSpy, renderSpy );
+				done();
+			}, { priority: 'low' } );
+
+			observer.onDomEvent( { type: 'focus', target: domEditable } );
+			domSelection.collapse( domEditable );
+		} );
+
+		it( 'should render without selectionChange event', ( done ) => {
+			const selectionChangeSpy = sinon.spy();
+			const renderSpy = sinon.spy();
+
+			setData( viewDocument, '<div contenteditable="true">foo bar</div>' );
+			viewDocument.render();
+			const domEditable = domRoot.childNodes[ 0 ];
+
+			viewDocument.on( 'selectionChange', selectionChangeSpy );
+			viewDocument.on( 'render', renderSpy, { priority: 'low' } );
+
+			viewDocument.on( 'render', () => {
+				sinon.assert.notCalled( selectionChangeSpy );
+				sinon.assert.called( renderSpy );
 
-			setTimeout( () => {
-				sinon.assert.notCalled( renderSpy );
 				done();
-			}, 0 );
+			}, { priority: 'low' } );
+
+			observer.onDomEvent( { type: 'focus', target: domEditable } );
 		} );
 	} );
 } );

+ 99 - 0
packages/ckeditor5-engine/tests/view/observer/mutationobserver.js

@@ -245,6 +245,105 @@ describe( 'MutationObserver', () => {
 		expect( lastMutations[ 0 ].oldChildren.length ).to.equal( 0 );
 	} );
 
+	it( 'should ignore mutation with bogus br inserted on the end of the empty paragraph', () => {
+		viewRoot.appendChildren( parse( '<container:p></container:p>' ) );
+
+		viewDocument.render();
+
+		const domP = domEditor.childNodes[ 2 ];
+		domP.appendChild( document.createElement( 'br' ) );
+
+		mutationObserver.flush();
+
+		expect( lastMutations.length ).to.equal( 0 );
+	} );
+
+	it( 'should ignore mutation with bogus br inserted on the end of the paragraph with text', () => {
+		viewRoot.appendChildren( parse( '<container:p>foo</container:p>' ) );
+
+		viewDocument.render();
+
+		const domP = domEditor.childNodes[ 2 ];
+		domP.appendChild( document.createElement( 'br' ) );
+
+		mutationObserver.flush();
+
+		expect( lastMutations.length ).to.equal( 0 );
+	} );
+
+	it( 'should ignore mutation with bogus br inserted on the end of the paragraph while processing text mutations', () => {
+		viewRoot.appendChildren( parse( '<container:p>foo</container:p>' ) );
+
+		viewDocument.render();
+
+		const domP = domEditor.childNodes[ 2 ];
+		domP.childNodes[ 0 ].data = 'foo ';
+		domP.appendChild( document.createElement( 'br' ) );
+
+		mutationObserver.flush();
+
+		expect( lastMutations.length ).to.equal( 1 );
+
+		expect( lastMutations[ 0 ].oldText ).to.equal( 'foo' );
+		expect( lastMutations[ 0 ].newText ).to.equal( 'foo ' );
+	} );
+
+	it( 'should not ignore mutation with br inserted not on the end of the paragraph', () => {
+		viewRoot.appendChildren( parse( '<container:p>foo</container:p>' ) );
+
+		viewDocument.render();
+
+		const domP = domEditor.childNodes[ 2 ];
+		domP.insertBefore( document.createElement( 'br' ), domP.childNodes[ 0 ] );
+
+		mutationObserver.flush();
+
+		expect( lastMutations.length ).to.equal( 1 );
+
+		expect( lastMutations[ 0 ].newChildren.length ).to.equal( 2 );
+		expect( lastMutations[ 0 ].newChildren[ 0 ].name ).to.equal( 'br' );
+		expect( lastMutations[ 0 ].newChildren[ 1 ].data ).to.equal( 'foo' );
+
+		expect( lastMutations[ 0 ].oldChildren.length ).to.equal( 1 );
+	} );
+
+	it( 'should not ignore mutation inserting element different than br on the end of the empty paragraph', () => {
+		viewRoot.appendChildren( parse( '<container:p></container:p>' ) );
+
+		viewDocument.render();
+
+		const domP = domEditor.childNodes[ 2 ];
+		domP.appendChild( document.createElement( 'span' ) );
+
+		mutationObserver.flush();
+
+		expect( lastMutations.length ).to.equal( 1 );
+
+		expect( lastMutations[ 0 ].newChildren.length ).to.equal( 1 );
+		expect( lastMutations[ 0 ].newChildren[ 0 ].name ).to.equal( 'span' );
+
+		expect( lastMutations[ 0 ].oldChildren.length ).to.equal( 0 );
+	} );
+
+	it( 'should not ignore mutation inserting element different than br on the end of the paragraph with text', () => {
+		viewRoot.appendChildren( parse( '<container:p>foo</container:p>' ) );
+
+		viewDocument.render();
+
+		const domP = domEditor.childNodes[ 2 ];
+		domP.appendChild( document.createElement( 'span' ) );
+
+		mutationObserver.flush();
+
+		expect( lastMutations.length ).to.equal( 1 );
+
+		expect( lastMutations[ 0 ].newChildren.length ).to.equal( 2 );
+		expect( lastMutations[ 0 ].newChildren[ 0 ].data ).to.equal( 'foo' );
+		expect( lastMutations[ 0 ].newChildren[ 1 ].name ).to.equal( 'span' );
+
+		expect( lastMutations[ 0 ].oldChildren.length ).to.equal( 1 );
+	} );
+
 	function expectDomEditorNotToChange() {
 		expect( domEditor.childNodes.length ).to.equal( 2 );
 		expect( domEditor.childNodes[ 0 ].tagName ).to.equal( 'P' );

+ 4 - 7
packages/ckeditor5-engine/tests/view/observer/selectionobserver.js

@@ -3,6 +3,8 @@
  * For licensing, see LICENSE.md.
  */
 
+/* globals setTimeout, setInterval, clearInterval, document */
+
 import ViewRange from '../../../src/view/range';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import ViewSelection from '../../../src/view/selection';
@@ -11,20 +13,15 @@ import SelectionObserver from '../../../src/view/observer/selectionobserver';
 import MutationObserver from '../../../src/view/observer/mutationobserver';
 import FocusObserver from '../../../src/view/observer/focusobserver';
 import log from '@ckeditor/ckeditor5-utils/src/log';
-import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import { parse } from '../../../src/dev-utils/view';
 
-const setTimeout = global.window.setTimeout;
-const setInterval = global.window.setInterval;
-const clearInterval = global.window.clearInterval;
-const domDocument = global.document;
-
 testUtils.createSinonSandbox();
 
 describe( 'SelectionObserver', () => {
-	let viewDocument, viewRoot, mutationObserver, selectionObserver, domRoot, domMain;
+	let viewDocument, viewRoot, mutationObserver, selectionObserver, domRoot, domMain, domDocument;
 
 	beforeEach( ( done ) => {
+		domDocument = document;
 		domRoot = domDocument.createElement( 'div' );
 		domRoot.innerHTML = `<div contenteditable="true"></div><div contenteditable="true" id="additional"></div>`;
 		domMain = domRoot.childNodes[ 0 ];