Przeglądaj źródła

Merge branch 'temp' into t/828

Maciej Bukowski 8 lat temu
rodzic
commit
3dc371a3b7
48 zmienionych plików z 2008 dodań i 409 usunięć
  1. 11 3
      packages/ckeditor5-engine/src/controller/insertcontent.js
  2. 113 95
      packages/ckeditor5-engine/src/conversion/mapper.js
  3. 59 5
      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. 11 3
      packages/ckeditor5-engine/src/model/delta/insertdelta.js
  9. 7 1
      packages/ckeditor5-engine/src/model/operation/reinsertoperation.js
  10. 21 33
      packages/ckeditor5-engine/src/model/operation/removeoperation.js
  11. 17 6
      packages/ckeditor5-engine/src/model/operation/transform.js
  12. 31 49
      packages/ckeditor5-engine/src/model/range.js
  13. 1 1
      packages/ckeditor5-engine/src/model/selection.js
  14. 12 0
      packages/ckeditor5-engine/src/view/domconverter.js
  15. 26 0
      packages/ckeditor5-engine/src/view/observer/focusobserver.js
  16. 23 1
      packages/ckeditor5-engine/src/view/observer/mutationobserver.js
  17. 5 49
      packages/ckeditor5-engine/src/view/observer/selectionobserver.js
  18. 126 0
      packages/ckeditor5-engine/src/view/placeholder.js
  19. 54 0
      packages/ckeditor5-engine/src/view/renderer.js
  20. 54 6
      packages/ckeditor5-engine/tests/controller/insertcontent.js
  21. 46 4
      packages/ckeditor5-engine/tests/conversion/mapper.js
  22. 124 1
      packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js
  23. 81 0
      packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js
  24. 40 0
      packages/ckeditor5-engine/tests/dev-utils/enableenginedebug.js
  25. 5 0
      packages/ckeditor5-engine/tests/manual/nestededitable.md
  26. 1 0
      packages/ckeditor5-engine/tests/manual/placeholder.html
  27. 32 0
      packages/ckeditor5-engine/tests/manual/placeholder.js
  28. 9 0
      packages/ckeditor5-engine/tests/manual/placeholder.md
  29. 3 0
      packages/ckeditor5-engine/tests/manual/tickets/880/1.html
  30. 27 0
      packages/ckeditor5-engine/tests/manual/tickets/880/1.js
  31. 14 0
      packages/ckeditor5-engine/tests/manual/tickets/880/1.md
  32. 3 0
      packages/ckeditor5-engine/tests/manual/tickets/887/1.html
  33. 22 0
      packages/ckeditor5-engine/tests/manual/tickets/887/1.js
  34. 10 0
      packages/ckeditor5-engine/tests/manual/tickets/887/1.md
  35. 14 0
      packages/ckeditor5-engine/tests/model/delta/attributedelta.js
  36. 11 0
      packages/ckeditor5-engine/tests/model/delta/insertdelta.js
  37. 3 3
      packages/ckeditor5-engine/tests/model/liverange.js
  38. 1 1
      packages/ckeditor5-engine/tests/model/liveselection.js
  39. 10 2
      packages/ckeditor5-engine/tests/model/operation/reinsertoperation.js
  40. 17 53
      packages/ckeditor5-engine/tests/model/operation/removeoperation.js
  41. 41 0
      packages/ckeditor5-engine/tests/model/operation/transform.js
  42. 88 6
      packages/ckeditor5-engine/tests/model/range.js
  43. 81 2
      packages/ckeditor5-engine/tests/view/observer/focusobserver.js
  44. 99 0
      packages/ckeditor5-engine/tests/view/observer/mutationobserver.js
  45. 66 79
      packages/ckeditor5-engine/tests/view/observer/selectionobserver.js
  46. 169 0
      packages/ckeditor5-engine/tests/view/placeholder.js
  47. 350 0
      packages/ckeditor5-engine/tests/view/renderer.js
  48. 8 0
      packages/ckeditor5-engine/theme/placeholder.scss

+ 11 - 3
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,
@@ -127,7 +135,7 @@ class Insertion {
 		nodes = Array.from( nodes );
 
 		for ( let i = 0; i < nodes.length; i++ ) {
-			const node = nodes[ i ].clone();
+			const node = nodes[ i ];
 
 			this._handleNode( node, {
 				isFirst: i === 0 && parentContext.isFirst,

+ 113 - 95
packages/ckeditor5-engine/src/conversion/mapper.js

@@ -61,6 +61,36 @@ export default class Mapper {
 		 * @member {Map}
 		 */
 		this._viewToModelLengthCallbacks = new Map();
+
+		// Default mapper algorithm for mapping model position to view position.
+		this.on( 'modelToViewPosition', ( evt, data ) => {
+			if ( data.viewPosition ) {
+				return;
+			}
+
+			let viewContainer = this._modelToViewMapping.get( data.modelPosition.parent );
+
+			data.viewPosition = this._findPositionIn( viewContainer, data.modelPosition.offset );
+		}, { priority: 'low' } );
+
+		// Default mapper algorithm for mapping view position to model position.
+		this.on( 'viewToModelPosition', ( evt, data ) => {
+			if ( data.modelPosition ) {
+				return;
+			}
+
+			let viewBlock = data.viewPosition.parent;
+			let modelParent = this._viewToModelMapping.get( viewBlock );
+
+			while ( !modelParent ) {
+				viewBlock = viewBlock.parent;
+				modelParent = this._viewToModelMapping.get( viewBlock );
+			}
+
+			let modelOffset = this._toModelOffset( data.viewPosition.parent, data.viewPosition.offset, viewBlock );
+
+			data.modelPosition = ModelPosition.createFromParentAndOffset( modelParent, modelOffset );
+		}, { priority: 'low' } );
 	}
 
 	/**
@@ -159,7 +189,6 @@ export default class Mapper {
 	toModelPosition( viewPosition ) {
 		const data = {
 			viewPosition: viewPosition,
-			modelPosition: this._defaultToModelPosition( viewPosition ),
 			mapper: this
 		};
 
@@ -168,19 +197,6 @@ export default class Mapper {
 		return data.modelPosition;
 	}
 
-	/**
-	 * Maps model position to view position using default mapper algorithm.
-	 *
-	 * @private
-	 * @param {module:engine/model/position~Position} modelPosition
-	 * @returns {module:engine/view/position~Position} View position mapped from model position.
-	 */
-	_defaultToViewPosition( modelPosition ) {
-		let viewContainer = this._modelToViewMapping.get( modelPosition.parent );
-
-		return this._findPositionIn( viewContainer, modelPosition.offset );
-	}
-
 	/**
 	 * Gets the corresponding view position.
 	 *
@@ -190,7 +206,6 @@ export default class Mapper {
 	 */
 	toViewPosition( modelPosition ) {
 		const data = {
-			viewPosition: this._defaultToViewPosition( modelPosition ),
 			modelPosition: modelPosition,
 			mapper: this
 		};
@@ -200,27 +215,6 @@ export default class Mapper {
 		return data.viewPosition;
 	}
 
-	/**
-	 * Maps view position to model position using default mapper algorithm.
-	 *
-	 * @private
-	 * @param {module:engine/view/position~Position} viewPosition
-	 * @returns {module:engine/model/position~Position} Model position mapped from view position.
-	 */
-	_defaultToModelPosition( viewPosition ) {
-		let viewBlock = viewPosition.parent;
-		let modelParent = this._viewToModelMapping.get( viewBlock );
-
-		while ( !modelParent ) {
-			viewBlock = viewBlock.parent;
-			modelParent = this._viewToModelMapping.get( viewBlock );
-		}
-
-		let modelOffset = this._toModelOffset( viewPosition.parent, viewPosition.offset, viewBlock );
-
-		return ModelPosition.createFromParentAndOffset( modelParent, modelOffset );
-	}
-
 	/**
 	 * Registers a callback that evaluates the length in the model of a view element with given name.
 	 *
@@ -442,65 +436,89 @@ export default class Mapper {
 		// Otherwise, just return the given position.
 		return viewPosition;
 	}
-}
 
-mix( Mapper, EmitterMixin );
+	/**
+	 * Fired for each model-to-view position mapping request. The purpose of this event is to enable custom model-to-view position
+	 * mapping. Callbacks added to this event take {@link module:engine/model/position~Position model position} and are expected to calculate
+	 * {@link module:engine/view/position~Position view position}. Calculated view position should be added as `viewPosition` value in
+	 * `data` object that is passed as one of parameters to the event callback.
+	 *
+	 * 		// Assume that "captionedImage" model element is converted to <img> and following <span> elements in view,
+	 * 		// and the model element is bound to <img> element. Force mapping model positions inside "captionedImage" to that <span> element.
+	 *		mapper.on( 'modelToViewPosition', ( evt, data ) => {
+	 *			const positionParent = modelPosition.parent;
+	 *
+	 *			if ( positionParent.name == 'captionedImage' ) {
+	 *				const viewImg = data.mapper.toViewElement( positionParent );
+	 *				const viewCaption = viewImg.nextSibling; // The <span> element.
+	 *
+	 *				data.viewPosition = new ViewPosition( viewCaption, modelPosition.offset );
+	 *
+	 *				// Stop the event if other callbacks should not modify calculated value.
+	 *				evt.stop();
+	 *			}
+	 *		} );
+	 *
+	 * **Note:** keep in mind that custom callback provided for this event should use provided `data.modelPosition` only to check
+	 * what is before the position (or position's parent). This is important when model-to-view position mapping is used in
+	 * remove change conversion. Model after the removed position (that is being mapped) does not correspond to view, so it cannot be used:
+	 *
+	 *		// Incorrect:
+	 *		const modelElement = data.modelPosition.nodeAfter;
+	 *		const viewElement = data.mapper.toViewElement( modelElement );
+	 *		// ... Do something with `viewElement` and set `data.viewPosition`.
+	 *
+	 *		// Correct:
+	 *		const prevModelElement = data.modelPosition.nodeBefore;
+	 *		const prevViewElement = data.mapper.toViewElement( prevModelElement );
+	 *		// ... Use `prevViewElement` to find correct `data.viewPosition`.
+	 *
+	 * **Note:** default mapping callback is provided with `low` priority setting and does not cancel the event, so it is possible to attach
+	 * a custom callback after default callback and also use `data.viewPosition` calculated by default callback (for example to fix it).
+	 *
+	 * **Note:** default mapping callback will not fire if `data.viewPosition` is already set.
+	 *
+	 * **Note:** these callbacks are called **very often**. For efficiency reasons, it is advised to use them only when position
+	 * mapping between given model and view elements is unsolvable using just elements mapping and default algorithm. Also,
+	 * the condition that checks if special case scenario happened should be as simple as possible.
+	 *
+	 * @event modelToViewPosition
+	 * @param {Object} data Data pipeline object that can store and pass data between callbacks. The callback should add
+	 * `viewPosition` value to that object with calculated {@link module:engine/view/position~Position view position}.
+	 * @param {module:engine/conversion/mapper~Mapper} data.mapper Mapper instance that fired the event.
+	 */
 
-/**
- * Fired for each model-to-view position mapping request. The purpose of this event is to enable custom model-to-view position
- * mapping. Callbacks added to this event take {@link module:engine/model/position~Position model position} and are expected to calculate
- * {@link module:engine/view/position~Position view position}. Calculated view position should be added as `viewPosition` value in
- * `data` object that is passed as one of parameters to the event callback.
- *
- * 		// Assume that "captionedImage" model element is converted to <img> and following <span> elements in view,
- * 		// and the model element is bound to <img> element. Force mapping model positions inside "captionedImage" to that <span> element.
- *		mapper.on( 'modelToViewPosition', ( evt, data ) => {
- *			const positionParent = modelPosition.parent;
- *
- *			if ( positionParent.name == 'captionedImage' ) {
- *				const viewImg = mapper.toViewElement( positionParent );
- *				const viewCaption = viewImg.nextSibling; // The <span> element.
- *
- *				data.viewPosition = new ViewPosition( viewCaption, modelPosition.offset );
- *				evt.stop();
- *			}
- *		} );
- *
- * **Note:** these callbacks are called **very often**. For efficiency reasons, it is advised to use them only when position
- * mapping between given model and view elements is unsolvable using just elements mapping and default algorithm. Also,
- * the condition that checks if special case scenario happened should be as simple as possible.
- *
- * @event modelToViewPosition
- * @param {Object} data Data pipeline object that can store and pass data between callbacks. The callback should add
- * `viewPosition` value to that object with calculated {@link module:engine/view/position~Position view position}.
- * @param {module:engine/model/position~Position} data.modelPosition Model position to be mapped.
- * @param {module:engine/view/position~Position} data.viewPosition View position that is a result of mapping
- * `modelPosition` using `Mapper` default algorithm.
- * @param {module:engine/conversion/mapper~Mapper} data.mapper Mapper instance that fired the event.
- */
+	/**
+	 * Fired for each view-to-model position mapping request. See {@link module:engine/conversion/mapper~Mapper#event:modelToViewPosition}.
+	 *
+	 * 		// See example in `modelToViewPosition` event description.
+	 * 		// This custom mapping will map positions from <span> element next to <img> to the "captionedImage" element.
+	 *		mapper.on( 'viewToModelPosition', ( evt, data ) => {
+	 *			const positionParent = viewPosition.parent;
+	 *
+	 *			if ( positionParent.hasClass( 'image-caption' ) ) {
+	 *				const viewImg = positionParent.previousSibling;
+	 *				const modelImg = data.mapper.toModelElement( viewImg );
+	 *
+	 *				data.modelPosition = new ModelPosition( modelImg, viewPosition.offset );
+	 *				evt.stop();
+	 *			}
+	 *		} );
+	 *
+	 * **Note:** default mapping callback is provided with `low` priority setting and does not cancel the event, so it is possible to attach
+	 * a custom callback after default callback and also use `data.modelPosition` calculated by default callback (for example to fix it).
+	 *
+	 * **Note:** default mapping callback will not fire if `data.modelPosition` is already set.
+	 *
+	 * **Note:** these callbacks are called **very often**. For efficiency reasons, it is advised to use them only when position
+	 * mapping between given model and view elements is unsolvable using just elements mapping and default algorithm. Also,
+	 * the condition that checks if special case scenario happened should be as simple as possible.
+	 *
+	 * @event viewToModelPosition
+	 * @param {Object} data Data pipeline object that can store and pass data between callbacks. The callback should add
+	 * `modelPosition` value to that object with calculated {@link module:engine/model/position~Position model position}.
+	 * @param {module:engine/conversion/mapper~Mapper} data.mapper Mapper instance that fired the event.
+	 */
+}
 
-/**
- * Fired for each view-to-model position mapping request. See {@link module:engine/conversion/mapper~Mapper#event:modelToViewPosition}.
- *
- * 		// See example in `modelToViewPosition` event description.
- * 		// This custom mapping will map positions from <span> element next to <img> to the "captionedImage" element.
- *		mapper.on( 'viewToModelPosition', ( evt, data ) => {
- *			const positionParent = viewPosition.parent;
- *
- *			if ( positionParent.hasClass( 'image-caption' ) ) {
- *				const viewImg = positionParent.previousSibling;
- *				const modelImg = mapper.toModelElement( viewImg );
- *
- *				data.modelPosition = new ModelPosition( modelImg, viewPosition.offset );
- *				evt.stop();
- *			}
- *		} );
- *
- * @event viewToModelPosition
- * @param {Object} data Data pipeline object that can store and pass data between callbacks. The callback should add
- * `modelPosition` value to that object with calculated {@link module:engine/model/position~Position model position}.
- * @param {module:engine/view/position~Position} data.viewPosition View position to be mapped.
- * @param {module:engine/model/position~Position} data.modelPosition Model position that is a result of mapping
- * `viewPosition` using `Mapper` default algorithm.
- * @param {module:engine/conversion/mapper~Mapper} data.mapper Mapper instance that fired the event.
- */
+mix( Mapper, EmitterMixin );

+ 59 - 5
packages/ckeditor5-engine/src/conversion/model-to-view-converters.js

@@ -3,10 +3,10 @@
  * For licensing, see LICENSE.md.
  */
 
-import ModelRange from '../model/range';
-
 import ViewElement from '../view/element';
 import ViewText from '../view/text';
+import ViewRange from '../view/range';
+import ViewTreeWalker from '../view/treewalker';
 import viewWriter from '../view/writer';
 
 /**
@@ -436,14 +436,68 @@ export function remove() {
 			return;
 		}
 
-		const modelRange = ModelRange.createFromPositionAndShift( data.sourcePosition, data.item.offsetSize );
-		const viewRange = conversionApi.mapper.toViewRange( modelRange );
+		// We cannot map non-existing positions from model to view. Since a range was removed
+		// from the model, we cannot recreate that range and map it to view, because
+		// end of that range is incorrect.
+		// Instead we will use `data.sourcePosition` as this is the last correct model position and
+		// it is a position before the removed item. Then, we will calculate view range to remove "manually".
+		const viewPosition = conversionApi.mapper.toViewPosition( data.sourcePosition );
+		let viewRange;
+
+		if ( data.item.is( 'element' ) ) {
+			// Note: in remove conversion we cannot use model-to-view element mapping because `data.item` may be
+			// already mapped to another element (this happens when move change is converted).
+			// In this case however, `viewPosition` is the position before view element that corresponds to removed model element.
+			viewRange = ViewRange.createOn( viewPosition.nodeAfter );
+		} else {
+			// If removed item is a text node, we need to traverse view tree to find the view range to remove.
+			// Range to remove will start `viewPosition` and should contain amount of characters equal to the amount of removed characters.
+			const viewRangeEnd = _shiftViewPositionByCharacters( viewPosition, data.item.offsetSize );
+			viewRange = new ViewRange( viewPosition, viewRangeEnd );
+		}
 
+		// Trim the range to remove in case some UI elements are on the view range boundaries.
 		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 );
+		}
 	};
 }
 
+// Helper function that shifts given view `position` in a way that returned position is after `howMany` characters compared
+// to the original `position`.
+// Because in view there might be view ui elements splitting text nodes, we cannot simply use `ViewPosition#getShiftedBy()`.
+function _shiftViewPositionByCharacters( position, howMany ) {
+	// Create a walker that will walk the view tree starting from given position and walking characters one-by-one.
+	const walker = new ViewTreeWalker( { startPosition: position, singleCharacters: true } );
+	// We will count visited characters and return the position after `howMany` characters.
+	let charactersFound = 0;
+
+	for ( let value of walker ) {
+		if ( value.type == 'text' ) {
+			charactersFound++;
+
+			if ( charactersFound == howMany ) {
+				return walker.position;
+			}
+		}
+	}
+}
+
 /**
  * Function factory, creates a default model-to-view converter for removing {@link module:engine/view/uielement~UIElement ui element}
  * basing on marker remove change.

+ 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';
@@ -135,13 +138,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 }>`;
 	};
@@ -151,7 +166,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() {
@@ -369,6 +384,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 ) => {

+ 11 - 3
packages/ckeditor5-engine/src/model/delta/insertdelta.js

@@ -8,10 +8,11 @@
  */
 
 import Delta from './delta';
-import DeltaFactory from './deltafactory';
 import RemoveDelta from './removedelta';
-import { register } from '../batch';
+import DeltaFactory from './deltafactory';
 import InsertOperation from '../operation/insertoperation';
+import { register } from '../batch';
+import { normalizeNodes } from './../writer';
 
 import DocumentFragment from '../documentfragment';
 import Range from '../../model/range.js';
@@ -95,8 +96,15 @@ export default class InsertDelta extends Delta {
  * @param {module:engine/model/node~NodeSet} nodes The list of nodes to be inserted.
  */
 register( 'insert', function( position, nodes ) {
+	const normalizedNodes = normalizeNodes( nodes );
+
+	// If nothing is inserted do not create delta and operation.
+	if ( normalizedNodes.length === 0 ) {
+		return this;
+	}
+
 	const delta = new InsertDelta();
-	const insert = new InsertOperation( position, nodes, this.document.version );
+	const insert = new InsertOperation( position, normalizedNodes, this.document.version );
 
 	this.addDelta( delta );
 	delta.addOperation( insert );

+ 7 - 1
packages/ckeditor5-engine/src/model/operation/reinsertoperation.js

@@ -45,7 +45,13 @@ export default class ReinsertOperation extends MoveOperation {
 	 * @returns {module:engine/model/operation/removeoperation~RemoveOperation}
 	 */
 	getReversed() {
-		return new RemoveOperation( this.targetPosition, this.howMany, this.baseVersion + 1 );
+		const removeOp = new RemoveOperation( this.targetPosition, this.howMany, this.baseVersion + 1 );
+
+		// Make sure that nodes are put back into the `$graveyardHolder` from which they got reinserted.
+		removeOp.targetPosition = this.sourcePosition;
+		removeOp._needsHolderElement = false;
+
+		return removeOp;
 	}
 
 	/**

+ 21 - 33
packages/ckeditor5-engine/src/model/operation/removeoperation.js

@@ -30,6 +30,25 @@ export default class RemoveOperation extends MoveOperation {
 		const graveyardPosition = new Position( graveyard, [ graveyard.maxOffset, 0 ] );
 
 		super( position, howMany, graveyardPosition, baseVersion );
+
+		/**
+		 * Flag informing whether this operation should insert "holder" element (`true`) or should move removed nodes
+		 * into existing "holder" element (`false`).
+		 *
+		 * The flag should be set to `true` for each "new" `RemoveOperation` that is each `RemoveOperation` originally
+		 * created to remove some nodes from document (most likely created through `Batch` API).
+		 *
+		 * The flag should be set to `false` for each `RemoveOperation` that got created by splitting the original
+		 * `RemoveOperation`, for example during operational transformation.
+		 *
+		 * The flag should be set to `false` whenever removing nodes that were re-inserted from graveyard. This will
+		 * ensure correctness of all other operations that might change something on those nodes. This will also ensure
+		 * that redundant empty graveyard holder elements are not created.
+		 *
+		 * @protected
+		 * @type {Boolean}
+		 */
+		this._needsHolderElement = true;
 	}
 
 	/**
@@ -59,39 +78,6 @@ export default class RemoveOperation extends MoveOperation {
 		this.targetPosition.path[ 0 ] = offset;
 	}
 
-	/**
-	 * Flag informing whether this operation should insert "holder" element (`true`) or should move removed nodes
-	 * into existing "holder" element (`false`).
-	 *
-	 * It is `true` for each `RemoveOperation` that is the first `RemoveOperation` in it's delta that points to given holder element.
-	 * This way only one `RemoveOperation` in given delta will insert "holder" element.
-	 *
-	 * @protected
-	 * @type {Boolean}
-	 */
-	get _needsHolderElement() {
-		if ( this.delta ) {
-			// Let's look up all operations from this delta in the same order as they are in the delta.
-			for ( let operation of this.delta.operations ) {
-				// We are interested only in `RemoveOperation`s.
-				if ( operation instanceof RemoveOperation ) {
-					// If the first `RemoveOperation` in the delta is this operation, this operation
-					// needs to insert holder element in the graveyard.
-					if ( operation == this ) {
-						return true;
-					} else if ( operation._holderElementOffset == this._holderElementOffset ) {
-						// If there is a `RemoveOperation` in this delta that "points" to the same holder element offset,
-						// that operation will already insert holder element at that offset. We should not create another holder.
-						return false;
-					}
-				}
-			}
-		}
-
-		// By default `RemoveOperation` needs holder element, so set it so, if the operation does not have delta.
-		return true;
-	}
-
 	/**
 	 * @inheritDoc
 	 * @returns {module:engine/model/operation/reinsertoperation~ReinsertOperation}
@@ -152,7 +138,9 @@ export default class RemoveOperation extends MoveOperation {
 		let sourcePosition = Position.fromJSON( json.sourcePosition, document );
 
 		const removeOp = new RemoveOperation( sourcePosition, json.howMany, json.baseVersion );
+
 		removeOp.targetPosition = Position.fromJSON( json.targetPosition, document );
+		removeOp._needsHolderElement = json._needsHolderElement;
 
 		return removeOp;
 	}

+ 17 - 6
packages/ckeditor5-engine/src/model/operation/transform.js

@@ -355,7 +355,11 @@ const ot = {
 			);
 
 			result.isSticky = a.isSticky;
-			result._holderElementOffset = a._holderElementOffset;
+
+			if ( a instanceof RemoveOperation ) {
+				result._needsHolderElement = a._needsHolderElement;
+				result._holderElementOffset = a._holderElementOffset;
+			}
 
 			return [ result ];
 		},
@@ -388,7 +392,7 @@ const ot = {
 				const aTarget = a.targetPosition.path[ 0 ];
 				const bTarget = b.targetPosition.path[ 0 ];
 
-				if ( aTarget >= bTarget && isStrong ) {
+				if ( aTarget > bTarget || ( aTarget == bTarget && isStrong ) ) {
 					// Do not change original operation!
 					a = a.clone();
 					a.targetPosition.path[ 0 ]++;
@@ -462,9 +466,10 @@ const ot = {
 				}
 			}
 
-			// At this point we transformed this operation's source ranges it means that nothing should be changed.
-			// But since we need to return an instance of Operation we return an array with NoOperation.
 			if ( ranges.length === 0 ) {
+				// At this point we transformed this operation's source ranges it means that nothing should be changed.
+				// But since we need to return an instance of Operation we return an array with NoOperation.
+
 				if ( a instanceof RemoveOperation ) {
 					// If `a` operation was RemoveOperation, we cannot convert it to NoOperation.
 					// This is because RemoveOperation creates a holder in graveyard.
@@ -492,7 +497,7 @@ const ot = {
 			);
 
 			// Map transformed range(s) to operations and return them.
-			return ranges.reverse().map( ( range ) => {
+			return ranges.reverse().map( ( range, i ) => {
 				// We want to keep correct operation class.
 				let result = new a.constructor(
 					range.start,
@@ -502,7 +507,13 @@ const ot = {
 				);
 
 				result.isSticky = a.isSticky;
-				result._holderElementOffset = a._holderElementOffset;
+
+				if ( a instanceof RemoveOperation ) {
+					// Transformed `RemoveOperation` needs graveyard holder only when the original operation needed it.
+					// If `RemoveOperation` got split into two or more operations, only first operation needs graveyard holder.
+					result._needsHolderElement = a._needsHolderElement && i === 0;
+					result._holderElementOffset = a._holderElementOffset;
+				}
 
 				return result;
 			} );

+ 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).
 	 *

+ 12 - 0
packages/ckeditor5-engine/src/view/domconverter.js

@@ -133,6 +133,18 @@ export default class DomConverter {
 		this._viewToDomMapping.set( viewElement, domElement );
 	}
 
+	/**
+	 * Unbinds given `domElement` from the view element it was bound to.
+	 *
+	 * @param {HTMLElement} domElement DOM element to unbind.
+	 */
+	unbindDomElement( domElement ) {
+		const viewElement = this._domToViewMapping.get( domElement );
+
+		this._domToViewMapping.delete( domElement );
+		this._viewToDomMapping.delete( viewElement );
+	}
+
 	/**
 	 * Binds DOM and View document fragments, so it will be possible to get corresponding document fragments using
 	 * {@link module:engine/view/domconverter~DomConverter#getCorrespondingViewDocumentFragment getCorrespondingViewDocumentFragment} and

+ 26 - 0
packages/ckeditor5-engine/src/view/observer/focusobserver.js

@@ -7,6 +7,8 @@
  * @module engine/view/observer/focusobserver
  */
 
+/* globals setTimeout, clearTimeout */
+
 import DomEventObserver from './domeventobserver';
 
 /**
@@ -28,6 +30,12 @@ export default class FocusObserver extends DomEventObserver {
 
 		document.on( 'focus', () => {
 			document.isFocused = true;
+
+			// Unfortunately native `selectionchange` event is fired asynchronously.
+			// We need to wait until `SelectionObserver` handle the event and then render. Otherwise rendering will
+			// overwrite new DOM selection with selection from the view.
+			// See https://github.com/ckeditor/ckeditor5-engine/issues/795 for more details.
+			this._renderTimeoutId = setTimeout( () => document.render(), 0 );
 		} );
 
 		document.on( 'blur', ( evt, data ) => {
@@ -40,11 +48,29 @@ export default class FocusObserver extends DomEventObserver {
 				document.render();
 			}
 		} );
+
+		/**
+		 * Identifier of the timeout currently used by focus listener to delay rendering execution.
+		 *
+		 * @private
+		 * @member {Number} #_renderTimeoutId
+		 */
 	}
 
 	onDomEvent( domEvent ) {
 		this.fire( domEvent.type, domEvent );
 	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		if ( this._renderTimeoutId ) {
+			clearTimeout( this._renderTimeoutId );
+		}
+
+		super.destroy();
+	}
 }
 
 /**

+ 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' );
+	}
 }
 
 /**

+ 5 - 49
packages/ckeditor5-engine/src/view/observer/selectionobserver.js

@@ -83,21 +83,7 @@ export default class SelectionObserver extends Observer {
 		 */
 		this._fireSelectionChangeDoneDebounced = debounce( data => this.document.fire( 'selectionChangeDone', data ), 200 );
 
-		this._clearInfiniteLoopInterval = setInterval( () => this._clearInfiniteLoop(), 2000 );
-
-		/**
-		 * Private property to store the last selection, to check if the code does not enter infinite loop.
-		 *
-		 * @private
-		 * @member {module:engine/view/selection~Selection} module:engine/view/observer/selectionobserver~SelectionObserver#_lastSelection
-		 */
-
-		/**
-		 * Private property to store the last but one selection, to check if the code does not enter infinite loop.
-		 *
-		 * @private
-		 * @member {module:engine/view/selection~Selection} module:engine/view/observer/selectionobserver~SelectionObserver#_lastButOneSelection
-		 */
+		this._clearInfiniteLoopInterval = setInterval( () => this._clearInfiniteLoop(), 1000 );
 
 		/**
 		 * Private property to check if the code does not enter infinite loop.
@@ -105,6 +91,7 @@ export default class SelectionObserver extends Observer {
 		 * @private
 		 * @member {Number} module:engine/view/observer/selectionobserver~SelectionObserver#_loopbackCounter
 		 */
+		this._loopbackCounter = 0;
 	}
 
 	/**
@@ -161,7 +148,9 @@ export default class SelectionObserver extends Observer {
 		}
 
 		// Ensure we are not in the infinite loop (#400).
-		if ( this._isInfiniteLoop( newViewSelection ) ) {
+		// This counter is reset each second. 60 selection changes in 1 second is enough high number
+		// to be very difficult (impossible) to achieve using just keyboard keys (during normal editor use).
+		if ( ++this._loopbackCounter > 60 ) {
 			/**
 			 * Selection change observer detected an infinite rendering loop.
 			 * Most probably you try to put the selection in the position which is not allowed
@@ -191,45 +180,12 @@ export default class SelectionObserver extends Observer {
 		this._fireSelectionChangeDoneDebounced( data );
 	}
 
-	/**
-	 * Checks if selection rendering entered an infinite loop.
-	 *
-	 * See https://github.com/ckeditor/ckeditor5-engine/issues/400.
-	 *
-	 * @private
-	 * @param {module:engine/view/selection~Selection} newSelection DOM selection converted to view.
-	 * @returns {Boolean} True is the same selection repeat more then 10 times.
-	 */
-	_isInfiniteLoop( newSelection ) {
-		// If the position is the same a the last one or the last but one we increment the counter.
-		// We need to check last two selections because the browser will first fire a selectionchange event
-		// for an incorrect selection and then for a corrected one.
-		if ( this._lastSelection && this._lastButOneSelection &&
-			( newSelection.isEqual( this._lastSelection ) || newSelection.isEqual( this._lastButOneSelection ) ) ) {
-			this._loopbackCounter++;
-		} else {
-			this._lastButOneSelection = this._lastSelection;
-			this._lastSelection = newSelection;
-			this._loopbackCounter = 0;
-		}
-
-		// This counter is reset every 2 seconds. 50 selection changes in 2 seconds is enough high number
-		// to be very difficult (impossible) to achieve using just keyboard keys (during normal editor use).
-		if ( this._loopbackCounter > 50 ) {
-			return true;
-		}
-
-		return false;
-	}
-
 	/**
 	 * Clears `SelectionObserver` internal properties connected with preventing infinite loop.
 	 *
 	 * @protected
 	 */
 	_clearInfiniteLoop() {
-		this._lastSelection = null;
-		this._lastButOneSelection = null;
 		this._loopbackCounter = 0;
 	}
 }

+ 126 - 0
packages/ckeditor5-engine/src/view/placeholder.js

@@ -0,0 +1,126 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module engine/view/placeholder
+ */
+
+import extend from '@ckeditor/ckeditor5-utils/src/lib/lodash/extend';
+import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import '../../theme/placeholder.scss';
+
+const listener = {};
+extend( listener, EmitterMixin );
+
+// Each document stores information about its placeholder elements and check functions.
+const documentPlaceholders = new WeakMap();
+
+/**
+ * Attaches placeholder to provided element and updates it's visibility. To change placeholder simply call this method
+ * once again with new parameters.
+ *
+ * @param {module:engine/view/element~Element} element Element to attach placeholder to.
+ * @param {String} placeholderText Placeholder text to use.
+ * @param {Function} [checkFunction] If provided it will be called before checking if placeholder should be displayed.
+ * If function returns `false` placeholder will not be showed.
+ */
+export function attachPlaceholder( element, placeholderText, checkFunction ) {
+	const document = element.document;
+
+	if ( !document ) {
+		/**
+		 * Provided element is not placed in any {@link module:engine/view/document~Document}.
+		 *
+		 * @error view-placeholder-element-is-detached
+		 */
+		throw new CKEditorError( 'view-placeholder-element-is-detached: Provided element is not placed in document.' );
+	}
+
+	// Detach placeholder if was used before.
+	detachPlaceholder( element );
+
+	// Single listener per document.
+	if ( !documentPlaceholders.has( document ) ) {
+		documentPlaceholders.set( document, new Map() );
+		listener.listenTo( document, 'render', () => updateAllPlaceholders( document ), { priority: 'high' } );
+	}
+
+	// Store text in element's data attribute.
+	// This data attribute is used in CSS class to show the placeholder.
+	element.setAttribute( 'data-placeholder', placeholderText );
+
+	// Store information about placeholder.
+	documentPlaceholders.get( document ).set( element, checkFunction );
+
+	// Update right away too.
+	updateSinglePlaceholder( element, checkFunction );
+}
+
+/**
+ * Removes placeholder functionality from given element.
+ *
+ * @param {module:engine/view/element~Element} element
+ */
+export function detachPlaceholder( element ) {
+	const document = element.document;
+
+	element.removeClass( 'ck-placeholder' );
+	element.removeAttribute( 'data-placeholder' );
+
+	if ( documentPlaceholders.has( document ) ) {
+		documentPlaceholders.get( document ).delete( element );
+	}
+}
+
+// Updates all placeholders of given document.
+//
+// @private
+// @param {module:engine/view/document~Document} document
+function updateAllPlaceholders( document ) {
+	const placeholders = documentPlaceholders.get( document );
+
+	for ( let [ element, checkFunction ] of placeholders ) {
+		updateSinglePlaceholder( element, checkFunction );
+	}
+}
+
+// Updates placeholder class of given element.
+//
+// @private
+// @param {module:engine/view/element~Element} element
+// @param {Function} checkFunction
+function updateSinglePlaceholder( element, checkFunction ) {
+	const document = element.document;
+
+	// Element was removed from document.
+	if ( !document ) {
+		return;
+	}
+
+	const viewSelection = document.selection;
+	const anchor = viewSelection.anchor;
+
+	// If checkFunction is provided and returns false - remove placeholder.
+	if ( checkFunction && !checkFunction() ) {
+		element.removeClass( 'ck-placeholder' );
+
+		return;
+	}
+
+	// If element is empty and editor is blurred.
+	if ( !document.isFocused && !element.childCount ) {
+		element.addClass( 'ck-placeholder' );
+
+		return;
+	}
+
+	// It there are no child elements and selection is not placed inside element.
+	if ( !element.childCount && anchor && anchor.parent !== element ) {
+		element.addClass( 'ck-placeholder' );
+	} else {
+		element.removeClass( 'ck-placeholder' );
+	}
+}

+ 54 - 0
packages/ckeditor5-engine/src/view/renderer.js

@@ -9,12 +9,14 @@
 
 import ViewText from './text';
 import ViewPosition from './position';
+import Selection from './selection';
 import { INLINE_FILLER, INLINE_FILLER_LENGTH, startsWithFiller, isInlineFiller, isBlockFiller } from './filler';
 
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import diff from '@ckeditor/ckeditor5-utils/src/diff';
 import insertAt from '@ckeditor/ckeditor5-utils/src/dom/insertat';
 import remove from '@ckeditor/ckeditor5-utils/src/dom/remove';
+import log from '@ckeditor/ckeditor5-utils/src/log';
 import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
@@ -437,6 +439,13 @@ export default class Renderer {
 	_updateChildren( viewElement, options ) {
 		const domConverter = this.domConverter;
 		const domElement = domConverter.getCorrespondingDom( viewElement );
+
+		if ( !domElement ) {
+			// If there is no `domElement` it means that it was already removed from DOM.
+			// There is no need to update it. It will be updated when re-inserted.
+			return;
+		}
+
 		const domDocument = domElement.ownerDocument;
 
 		const filler = options.inlineFillerPosition;
@@ -463,6 +472,8 @@ export default class Renderer {
 				insertAt( domElement, i, expectedDomChildren[ i ] );
 				i++;
 			} else if ( action === 'delete' ) {
+				// Whenever element is removed from DOM, unbind it.
+				this.domConverter.unbindDomElement( actualDomChildren[ i ] );
 				remove( actualDomChildren[ i ] );
 			} else { // 'equal'
 				i++;
@@ -571,6 +582,17 @@ export default class Renderer {
 			return;
 		}
 
+		if ( oldViewSelection && areSimilarSelections( oldViewSelection, this.selection ) ) {
+			const data = {
+				oldSelection: oldViewSelection,
+				currentSelection: this.selection
+			};
+
+			log.warn( 'renderer-skipped-selection-rendering: The selection was not rendered due to its similarity to the current one.', data );
+
+			return;
+		}
+
 		// Multi-range selection is not available in most browsers, and, at least in Chrome, trying to
 		// set such selection, that is not continuous, throws an error. Because of that, we will just use anchor
 		// and focus of view selection.
@@ -633,3 +655,35 @@ export default class Renderer {
 }
 
 mix( Renderer, ObservableMixin );
+
+// Checks if two given selections are similar. Selections are considered similar if they are non-collapsed
+// and their trimmed (see {@link #_trimSelection}) representations are equal.
+//
+// @private
+// @param {module:engine/view/selection~Selection} selection1
+// @param {module:engine/view/selection~Selection} selection2
+// @returns {Boolean}
+function areSimilarSelections( selection1, selection2 ) {
+	return !selection1.isCollapsed && trimSelection( selection1 ).isEqual( trimSelection( selection2 ) );
+}
+
+// Creates a copy of a given selection with all of its ranges
+// trimmed (see {@link module:engine/view/range~Range#getTrimmed getTrimmed}).
+//
+// @private
+// @param {module:engine/view/selection~Selection} selection
+// @returns {module:engine/view/selection~Selection} Selection copy with all ranges trimmed.
+function trimSelection( selection ) {
+	const newSelection = Selection.createFromSelection( selection );
+	const ranges = newSelection.getRanges();
+
+	let trimmedRanges = [];
+
+	for ( let range of ranges ) {
+		trimmedRanges.push( range.getTrimmed() );
+	}
+
+	newSelection.setRanges( trimmedRanges, newSelection.isBackward );
+
+	return newSelection;
+}

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

@@ -9,6 +9,7 @@ import insertContent from '../../src/controller/insertcontent';
 
 import DocumentFragment from '../../src/model/documentfragment';
 import Text from '../../src/model/text';
+import Element from '../../src/model/element';
 
 import { setData, getData, parse } from '../../src/dev-utils/model';
 
@@ -17,11 +18,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 +33,55 @@ 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' );
+		} );
+
+		it( 'should save the reference to the original object', () => {
+			const doc = new Document();
+			const dataController = new DataController( doc );
+			const batch = doc.batch();
+			const content = new Element( 'image' );
+
+			doc.createRoot();
+
+			doc.schema.registerItem( 'paragraph', '$block' );
+			doc.schema.registerItem( 'image', '$inline' );
+			doc.schema.objects.add( 'image' );
+
+			setData( doc, '<paragraph>foo[]</paragraph>' );
+
+			insertContent( dataController, content, doc.selection, batch );
+
+			expect( doc.getRoot().getChild( 0 ).getChild( 1 ) ).to.equal( content );
+		} );
+
 		describe( 'in simple scenarios', () => {
 			beforeEach( () => {
 				doc = new Document();
@@ -604,6 +654,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 +664,6 @@ describe( 'DataController', () => {
 			} );
 		}
 
-		if ( !( content instanceof DocumentFragment ) ) {
-			content = new DocumentFragment( [ content ] );
-		}
-
 		insertContent( dataController, content, doc.selection );
 	}
 } );

+ 46 - 4
packages/ckeditor5-engine/tests/conversion/mapper.js

@@ -211,10 +211,10 @@ describe( 'Mapper', () => {
 				const stub = {};
 
 				mapper.on( 'viewToModelPosition', ( evt, data ) => {
-					expect( data.viewPosition ).to.equal( viewPosition );
+					expect( data.viewPosition.isEqual( viewPosition ) ).to.be.true;
 
 					data.modelPosition = stub;
-					evt.stop();
+					// Do not stop the event. Test whether default algorithm was not called if data.modelPosition is already set.
 				} );
 
 				const result = mapper.toModelPosition( viewPosition );
@@ -222,6 +222,27 @@ describe( 'Mapper', () => {
 				expect( result ).to.equal( stub );
 			} );
 
+			it( 'should be possible to add custom position mapping callback after default callback', () => {
+				const viewPosition = new ViewPosition( viewDiv, 0 );
+
+				// Model position to which default algorithm should map `viewPosition`.
+				// This mapping is tested in a test below.
+				const modelPosition = new ModelPosition( modelDiv, [ 0 ] );
+				const stub = {};
+
+				mapper.on( 'viewToModelPosition', ( evt, data ) => {
+					expect( data.viewPosition.isEqual( viewPosition ) ).to.be.true;
+					expect( data.modelPosition.isEqual( modelPosition ) ).to.be.true;
+
+					data.modelPosition = stub;
+				}, { priority: 'low' } );
+
+				const result = mapper.toModelPosition( viewPosition );
+
+				expect( result ).to.equal( stub );
+			} );
+
+			// Default algorithm tests.
 			it( 'should transform viewDiv 0', () => createToModelTest( viewDiv, 0, modelDiv, 0 ) );
 			it( 'should transform viewDiv 1', () => createToModelTest( viewDiv, 1, modelDiv, 1 ) );
 			it( 'should transform viewDiv 2', () => createToModelTest( viewDiv, 2, modelDiv, 2 ) );
@@ -284,10 +305,10 @@ describe( 'Mapper', () => {
 				const stub = {};
 
 				mapper.on( 'modelToViewPosition', ( evt, data ) => {
-					expect( data.modelPosition ).to.equal( modelPosition );
+					expect( data.modelPosition.isEqual( modelPosition ) ).to.be.true;
 
 					data.viewPosition = stub;
-					evt.stop();
+					// Do not stop the event. Test whether default algorithm was not called if data.viewPosition is already set.
 				} );
 
 				const result = mapper.toViewPosition( modelPosition );
@@ -295,6 +316,27 @@ describe( 'Mapper', () => {
 				expect( result ).to.equal( stub );
 			} );
 
+			it( 'should be possible to add custom position mapping callback after default callback', () => {
+				const modelPosition = new ModelPosition( modelDiv, [ 0 ] );
+
+				// View position to which default algorithm should map `viewPosition`.
+				// This mapping is tested in a test below.
+				const viewPosition = new ViewPosition( viewTextX, 0 );
+				const stub = {};
+
+				mapper.on( 'modelToViewPosition', ( evt, data ) => {
+					expect( data.modelPosition.isEqual( modelPosition ) ).to.be.true;
+					expect( data.viewPosition.isEqual( viewPosition ) ).to.be.true;
+
+					data.viewPosition = stub;
+				}, { priority: 'low' } );
+
+				const result = mapper.toViewPosition( modelPosition );
+
+				expect( result ).to.equal( stub );
+			} );
+
+			// Default algorithm tests.
 			it( 'should transform modelDiv 0', () => createToViewTest( modelDiv, 0, viewTextX, 0 ) );
 			it( 'should transform modelDiv 1', () => createToViewTest( modelDiv, 1, viewTextX, 1 ) );
 			it( 'should transform modelDiv 2', () => createToViewTest( modelDiv, 2, viewTextZZ, 0 ) );

+ 124 - 1
packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js

@@ -904,7 +904,7 @@ describe( 'model-to-view-converters', () => {
 	} );
 
 	describe( 'remove', () => {
-		it( 'should remove items from view accordingly to changes in model', () => {
+		it( 'should remove items from view accordingly to changes in model #1', () => {
 			const modelDiv = new ModelElement( 'div', null, [
 				new ModelText( 'foo' ),
 				new ModelElement( 'image' ),
@@ -1002,5 +1002,128 @@ describe( 'model-to-view-converters', () => {
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div>foo<span></span>ar</div>' );
 		} );
+
+		it( 'should remove correct amount of text when it is split by view ui element', () => {
+			modelRoot.appendChildren( new ModelText( 'foobar' ) );
+			viewRoot.appendChildren( [
+				new ViewText( 'foo' ),
+				new ViewUIElement( 'span' ),
+				new ViewText( 'bar' )
+			] );
+
+			dispatcher.on( 'remove', remove() );
+
+			// Remove 'o<span></span>b'.
+			modelWriter.move(
+				ModelRange.createFromParentsAndOffsets( modelRoot, 2, modelRoot, 4 ),
+				ModelPosition.createAt( modelDoc.graveyard, 'end' )
+			);
+
+			dispatcher.convertRemove(
+				ModelPosition.createFromParentAndOffset( modelRoot, 2 ),
+				ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 2 )
+			);
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div>foar</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' ) ] );

+ 5 - 0
packages/ckeditor5-engine/tests/manual/nestededitable.md

@@ -3,3 +3,8 @@
 * Put selection inside `foo bar baz` nested editable. Main editable and nested one should be focused (blue outline should be visible).
 * Change selection inside nested editable and see if `Model contents` change accordingly.
 * Click outside the editor. Outline from main editable and nested editable should be removed.
+* Check following scenario:
+  * put selection inside nested editable: `foo bar baz{}`,
+  * click outside the editor (outlines should be removed),
+  * put selection at exact same place as before: `foo bar baz{}`,
+  * both editables should be focused (blue outline should be visible).

+ 1 - 0
packages/ckeditor5-engine/tests/manual/placeholder.html

@@ -0,0 +1 @@
+<div id="editor"><h2></h2><p></p></div>

+ 32 - 0
packages/ckeditor5-engine/tests/manual/placeholder.js

@@ -0,0 +1,32 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global console */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Typing from '@ckeditor/ckeditor5-typing/src/typing';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import { attachPlaceholder } from '../../src/view/placeholder';
+
+ClassicEditor.create( global.document.querySelector( '#editor' ), {
+	plugins: [ Enter, Typing, Paragraph, Undo, Heading ],
+	toolbar: [ 'headings', 'undo', 'redo' ]
+} )
+.then( editor => {
+	const viewDoc = editor.editing.view;
+	const header = viewDoc.getRoot().getChild( 0 );
+	const paragraph = viewDoc.getRoot().getChild( 1 );
+
+	attachPlaceholder( header, 'Type some header text...' );
+	attachPlaceholder( paragraph, 'Type some paragraph text...' );
+	viewDoc.render();
+} )
+.catch( err => {
+	console.error( err.stack );
+} );

+ 9 - 0
packages/ckeditor5-engine/tests/manual/placeholder.md

@@ -0,0 +1,9 @@
+### Placeholder creation
+
+* You should see two placeholders:
+  * for heading: `Type some header text...`,
+  * and for paragraph: `Type some paragraph text...`.
+* Clicking on header and paragraph should remove placeholder.
+* Clicking outside the editor should show both placeholders.
+* Type some text into paragraph, and click outside. Paragraph placeholder should be hidden.
+* Remove added text and click outside - paragraph placeholder should now be visible again.

+ 3 - 0
packages/ckeditor5-engine/tests/manual/tickets/880/1.html

@@ -0,0 +1,3 @@
+<div id="editor">
+	<p>This is an <strong>editor</strong> instance.</p>
+</div>

+ 27 - 0
packages/ckeditor5-engine/tests/manual/tickets/880/1.js

@@ -0,0 +1,27 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic';
+import EssentialsPreset from '@ckeditor/ckeditor5-presets/src/essentials';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+
+ClassicEditor.create( document.querySelector( '#editor' ), {
+	plugins: [ EssentialsPreset, Paragraph, Bold ],
+	toolbar: [ 'undo', 'redo' ]
+} )
+.then( editor => {
+	window.editor = editor;
+
+	editor.editing.view.on( 'selectionChange', () => {
+		editor.document.enqueueChanges( () => {} );
+		console.log( 'selectionChange', ( new Date() ).getTime() );
+	} );
+} )
+.catch( err => {
+	console.error( err.stack );
+} );

+ 14 - 0
packages/ckeditor5-engine/tests/manual/tickets/880/1.md

@@ -0,0 +1,14 @@
+## Renderer - infinite `selectionChange` event
+
+Place the selection like this:
+
+```
+This is {an <strong>editor}</strong> instance.
+```
+
+(it must end at the end of the inline style)
+
+**Expected**:
+
+* Every time selection is changed, console log with `selectionChange` is printed once.
+* While creating selection from **2.**, there might be `renderer-selection-similar` warning visible in the console.

+ 3 - 0
packages/ckeditor5-engine/tests/manual/tickets/887/1.html

@@ -0,0 +1,3 @@
+<div id="editor">
+	<p>This is an <strong>editor</strong> instance.</p>
+</div>

+ 22 - 0
packages/ckeditor5-engine/tests/manual/tickets/887/1.js

@@ -0,0 +1,22 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic';
+import EssentialsPreset from '@ckeditor/ckeditor5-presets/src/essentials';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+
+ClassicEditor.create( document.querySelector( '#editor' ), {
+	plugins: [ EssentialsPreset, Paragraph, Bold ],
+	toolbar: [ 'undo', 'redo' ]
+} )
+.then( editor => {
+	window.editor = editor;
+} )
+.catch( err => {
+	console.error( err.stack );
+} );

+ 10 - 0
packages/ckeditor5-engine/tests/manual/tickets/887/1.md

@@ -0,0 +1,10 @@
+## Renderer - MacOS accent balloon on inline element boundary
+
+1. Place the selection like this:
+
+   `This is an <strong>editor{}</strong> instance.`
+2. Open MacOS accent balloon (e.g. long `a` press).
+3. Navigate through the balloon panel using arrow keys.
+
+**Expected**: It is possible to navigate with arrow keys inside the MacOS balloon panel. While navigating, there
+might be `renderer-selection-similar` warning visible in the console.

+ 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', () => {

+ 11 - 0
packages/ckeditor5-engine/tests/model/delta/insertdelta.js

@@ -87,6 +87,17 @@ describe( 'Batch', () => {
 			expect( doc.applyOperation.secondCall.calledWith( sinon.match( ( operation ) => operation instanceof MarkerOperation ) ) );
 			expect( doc.applyOperation.thirdCall.calledWith( sinon.match( ( operation ) => operation instanceof MarkerOperation ) ) );
 		} );
+
+		it( 'should not create a delta and an operation if no nodes were inserted', () => {
+			sinon.spy( doc, 'applyOperation' );
+
+			batch = doc.batch();
+
+			batch.insert( new Position( root, [ 0 ] ), [] );
+
+			expect( batch.deltas.length ).to.equal( 0 );
+			expect( doc.applyOperation.called ).to.be.false;
+		} );
 	} );
 } );
 

+ 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;
 			} );
 

+ 10 - 2
packages/ckeditor5-engine/tests/model/operation/reinsertoperation.js

@@ -61,14 +61,22 @@ describe( 'ReinsertOperation', () => {
 		expect( clone.baseVersion ).to.equal( operation.baseVersion );
 	} );
 
-	it( 'should create a RemoveOperation as a reverse', () => {
+	it( 'should create a correct RemoveOperation as a reverse', () => {
+		// Test reversed operation's target position.
+		graveyard.appendChildren( new Element( '$graveyardHolder' ) );
+
 		let reverse = operation.getReversed();
 
 		expect( reverse ).to.be.an.instanceof( RemoveOperation );
 		expect( reverse.baseVersion ).to.equal( 1 );
 		expect( reverse.howMany ).to.equal( 2 );
 		expect( reverse.sourcePosition.isEqual( rootPosition ) ).to.be.true;
-		expect( reverse.targetPosition.root ).to.equal( graveyardPosition.root );
+
+		// Reversed `ReinsertOperation` should target back to the same graveyard holder.
+		expect( reverse.targetPosition.isEqual( graveyardPosition ) ).to.be.true;
+
+		// Reversed `ReinsertOperation` should not create new graveyard holder.
+		expect( reverse._needsHolderElement ).to.be.false;
 	} );
 
 	it( 'should undo reinsert set of nodes by applying reverse operation', () => {

+ 17 - 53
packages/ckeditor5-engine/tests/model/operation/removeoperation.js

@@ -10,7 +10,6 @@ import MoveOperation from '../../../src/model/operation/moveoperation';
 import Position from '../../../src/model/position';
 import Text from '../../../src/model/text';
 import Element from '../../../src/model/element';
-import Delta from '../../../src/model/delta/delta';
 import { jsonParseStringify, wrapInDelta } from '../../../tests/model/_utils/utils';
 
 describe( 'RemoveOperation', () => {
@@ -52,7 +51,7 @@ describe( 'RemoveOperation', () => {
 		expect( operation ).to.be.instanceof( MoveOperation );
 	} );
 
-	it( 'should remove set of nodes and append them to holder element in graveyard root', () => {
+	it( 'should be able to remove set of nodes and append them to holder element in graveyard root', () => {
 		root.insertChildren( 0, new Text( 'fozbar' ) );
 
 		doc.applyOperation( wrapInDelta(
@@ -71,64 +70,24 @@ describe( 'RemoveOperation', () => {
 		expect( graveyard.getChild( 0 ).getChild( 0 ).data ).to.equal( 'zb' );
 	} );
 
-	it( 'should create new holder element for remove operations in different deltas', () => {
+	it( 'should be able to remove set of nodes and append them to existing element in graveyard root', () => {
 		root.insertChildren( 0, new Text( 'fozbar' ) );
+		graveyard.appendChildren( new Element( '$graveyardHolder' ) );
 
-		doc.applyOperation( wrapInDelta(
-			new RemoveOperation(
-				new Position( root, [ 0 ] ),
-				1,
-				doc.version
-			)
-		) );
-
-		doc.applyOperation( wrapInDelta(
-			new RemoveOperation(
-				new Position( root, [ 0 ] ),
-				1,
-				doc.version
-			)
-		) );
-
-		doc.applyOperation( wrapInDelta(
-			new RemoveOperation(
-				new Position( root, [ 0 ] ),
-				1,
-				doc.version
-			)
-		) );
-
-		expect( graveyard.maxOffset ).to.equal( 3 );
-		expect( graveyard.getChild( 0 ).getChild( 0 ).data ).to.equal( 'f' );
-		expect( graveyard.getChild( 1 ).getChild( 0 ).data ).to.equal( 'o' );
-		expect( graveyard.getChild( 2 ).getChild( 0 ).data ).to.equal( 'z' );
-	} );
-
-	it( 'should not create new holder element for remove operation if it was already created for given delta', () => {
-		root.insertChildren( 0, new Text( 'fozbar' ) );
-
-		let delta = new Delta();
-
-		// This simulates i.e. RemoveOperation that got split into two operations during OT.
-		let removeOpA = new RemoveOperation(
-			new Position( root, [ 1 ] ),
-			1,
-			doc.version
-		);
-		let removeOpB = new RemoveOperation(
+		const op = new RemoveOperation(
 			new Position( root, [ 0 ] ),
 			1,
-			doc.version + 1
+			doc.version
 		);
 
-		delta.addOperation( removeOpA );
-		delta.addOperation( removeOpB );
+		// Manually set holder element properties.
+		op._needsHolderElement = false;
+		op._holderElementOffset = 0;
 
-		doc.applyOperation( removeOpA );
-		doc.applyOperation( removeOpB );
+		doc.applyOperation( wrapInDelta( op ) );
 
-		expect( graveyard.childCount ).to.equal( 1 );
-		expect( graveyard.getChild( 0 ).getChild( 0 ).data ).to.equal( 'fo' );
+		expect( graveyard.maxOffset ).to.equal( 1 );
+		expect( graveyard.getChild( 0 ).getChild( 0 ).data ).to.equal( 'f' );
 	} );
 
 	it( 'should create RemoveOperation with same parameters when cloned', () => {
@@ -197,6 +156,8 @@ describe( 'RemoveOperation', () => {
 				doc.version
 			);
 
+			op._needsHolderElement = false;
+
 			const serialized = jsonParseStringify( op );
 
 			expect( serialized ).to.deep.equal( {
@@ -205,7 +166,8 @@ describe( 'RemoveOperation', () => {
 				howMany: 2,
 				isSticky: false,
 				sourcePosition: jsonParseStringify( op.sourcePosition ),
-				targetPosition: jsonParseStringify( op.targetPosition )
+				targetPosition: jsonParseStringify( op.targetPosition ),
+				_needsHolderElement: false
 			} );
 		} );
 	} );
@@ -218,6 +180,8 @@ describe( 'RemoveOperation', () => {
 				doc.version
 			);
 
+			op._needsHolderElement = false;
+
 			doc.graveyard.appendChildren( [ new Element( '$graveyardHolder' ), new Element( '$graveyardHolder' ) ] );
 
 			const serialized = jsonParseStringify( op );

+ 41 - 0
packages/ckeditor5-engine/tests/model/operation/transform.js

@@ -2757,6 +2757,47 @@ describe( 'transform', () => {
 	} );
 
 	describe( 'RemoveOperation', () => {
+		describe( 'by InsertOperation', () => {
+			it( 'should not need new graveyard holder if original operation did not needed it either', () => {
+				let op = new RemoveOperation( new Position( root, [ 1 ] ), 1, baseVersion );
+				op._needsHolderElement = false;
+
+				let transformBy = new InsertOperation( new Position( root, [ 0 ] ), [ new Node() ], baseVersion );
+
+				let transOp = transform( op, transformBy )[ 0 ];
+
+				expect( transOp._needsHolderElement ).to.be.false;
+			} );
+		} );
+
+		describe( 'by MoveOperation', () => {
+			it( 'should create not more than RemoveOperation that needs new graveyard holder', () => {
+				let op = new RemoveOperation( new Position( root, [ 1 ] ), 4, baseVersion );
+				let transformBy = new MoveOperation( new Position( root, [ 0 ] ), 2, new Position( root, [ 8 ] ), baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 2 );
+
+				expect( transOp[ 0 ]._needsHolderElement ).to.be.true;
+				expect( transOp[ 1 ]._needsHolderElement ).to.be.false;
+			} );
+
+			it( 'should not need new graveyard holder if original operation did not needed it either', () => {
+				let op = new RemoveOperation( new Position( root, [ 1 ] ), 4, baseVersion );
+				op._needsHolderElement = false;
+
+				let transformBy = new MoveOperation( new Position( root, [ 0 ] ), 2, new Position( root, [ 8 ] ), baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 2 );
+
+				expect( transOp[ 0 ]._needsHolderElement ).to.be.false;
+				expect( transOp[ 1 ]._needsHolderElement ).to.be.false;
+			} );
+		} );
+
 		describe( 'by RemoveOperation', () => {
 			it( 'removes same nodes and transformed is weak: change howMany to 0', () => {
 				let position = new Position( root, [ 2, 1 ] );

+ 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', () => {

+ 81 - 2
packages/ckeditor5-engine/tests/view/observer/focusobserver.js

@@ -3,11 +3,11 @@
  * For licensing, see LICENSE.md.
  */
 
-/* globals document */
-
+/* globals document, window */
 import FocusObserver from '../../../src/view/observer/focusobserver';
 import ViewDocument from '../../../src/view/document';
 import ViewRange from '../../../src/view/range';
+import { setData } from '../../../src/dev-utils/view';
 
 describe( 'FocusObserver', () => {
 	let viewDocument, observer;
@@ -115,5 +115,84 @@ describe( 'FocusObserver', () => {
 
 			expect( viewDocument.isFocused ).to.be.true;
 		} );
+
+		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 );
+
+			clock.restore();
+		} );
+
+		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 );
+
+				done();
+			}, { 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' );

+ 66 - 79
packages/ckeditor5-engine/tests/view/observer/selectionobserver.js

@@ -11,23 +11,24 @@ import ViewSelection from '../../../src/view/selection';
 import ViewDocument from '../../../src/view/document';
 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 { parse } from '../../../src/dev-utils/view';
 
 testUtils.createSinonSandbox();
 
 describe( 'SelectionObserver', () => {
-	let viewDocument, viewRoot, mutationObserver, selectionObserver, domRoot;
+	let viewDocument, viewRoot, mutationObserver, selectionObserver, domRoot, domMain, domDocument;
 
 	beforeEach( ( done ) => {
-		domRoot = document.createElement( 'div' );
-		domRoot.innerHTML = `<div contenteditable="true" id="main"></div><div contenteditable="true" id="additional"></div>`;
-		document.body.appendChild( domRoot );
+		domDocument = document;
+		domRoot = domDocument.createElement( 'div' );
+		domRoot.innerHTML = `<div contenteditable="true"></div><div contenteditable="true" id="additional"></div>`;
+		domMain = domRoot.childNodes[ 0 ];
+		domDocument.body.appendChild( domRoot );
 
 		viewDocument = new ViewDocument();
-		viewDocument.createRoot( document.getElementById( 'main' ) );
+		viewDocument.createRoot( domMain );
 
 		mutationObserver = viewDocument.getObserver( MutationObserver );
 		selectionObserver = viewDocument.getObserver( SelectionObserver );
@@ -41,7 +42,7 @@ describe( 'SelectionObserver', () => {
 		viewDocument.render();
 
 		viewDocument.selection.removeAllRanges();
-		document.getSelection().removeAllRanges();
+		domDocument.getSelection().removeAllRanges();
 
 		viewDocument.isFocused = true;
 
@@ -59,7 +60,7 @@ describe( 'SelectionObserver', () => {
 
 	it( 'should fire selectionChange when it is the only change', ( done ) => {
 		viewDocument.on( 'selectionChange', ( evt, data ) => {
-			expect( data ).to.have.property( 'domSelection' ).that.equals( document.getSelection() );
+			expect( data ).to.have.property( 'domSelection' ).that.equals( domDocument.getSelection() );
 
 			expect( data ).to.have.property( 'oldSelection' ).that.is.instanceof( ViewSelection );
 			expect( data.oldSelection.rangeCount ).to.equal( 0 );
@@ -83,7 +84,7 @@ describe( 'SelectionObserver', () => {
 
 	it( 'should add only one listener to one document', ( done ) => {
 		// Add second roots to ensure that listener is added once.
-		viewDocument.createRoot( document.getElementById( 'additional' ), 'additional' );
+		viewDocument.createRoot( domDocument.getElementById( 'additional' ), 'additional' );
 
 		viewDocument.on( 'selectionChange', () => {
 			done();
@@ -135,64 +136,51 @@ describe( 'SelectionObserver', () => {
 		changeDomSelection();
 	} );
 
-	it( 'should warn and not enter infinite loop', ( done ) => {
-		// Reset infinite loop counters so other tests won't mess up with this test.
-		selectionObserver._clearInfiniteLoop();
-
-		let counter = 100;
+	it( 'should warn and not enter infinite loop', () => {
+		// Selectionchange event is called twice per `changeDomSelection()` execution.
+		let counter = 35;
 
 		const viewFoo = viewDocument.getRoot().getChild( 0 ).getChild( 0 );
 		viewDocument.selection.addRange( ViewRange.createFromParentsAndOffsets( viewFoo, 0, viewFoo, 0 ) );
 
-		viewDocument.on( 'selectionChange', () => {
-			counter--;
-
-			if ( counter > 0 ) {
-				setTimeout( changeDomSelection );
-			} else {
-				throw 'Infinite loop!';
-			}
-		} );
+		return new Promise( ( resolve, reject ) => {
+			testUtils.sinon.stub( log, 'warn', ( msg ) => {
+				expect( msg ).to.match( /^selectionchange-infinite-loop/ );
 
-		let warnedOnce = false;
+				resolve();
+			} );
 
-		testUtils.sinon.stub( log, 'warn', ( msg ) => {
-			if ( !warnedOnce ) {
-				warnedOnce = true;
+			viewDocument.on( 'selectionChangeDone', () => {
+				if ( !counter ) {
+					reject( new Error( 'Infinite loop warning was not logged.' ) );
+				}
+			} );
 
-				setTimeout( () => {
-					expect( msg ).to.match( /^selectionchange-infinite-loop/ );
-					done();
-				}, 200 );
+			while ( counter > 0 ) {
+				changeDomSelection();
+				counter--;
 			}
 		} );
-
-		changeDomSelection();
 	} );
 
 	it( 'should not be treated as an infinite loop if selection is changed only few times', ( done ) => {
 		const viewFoo = viewDocument.getRoot().getChild( 0 ).getChild( 0 );
-
-		// Reset infinite loop counters so other tests won't mess up with this test.
-		selectionObserver._clearInfiniteLoop();
-
 		viewDocument.selection.addRange( ViewRange.createFromParentsAndOffsets( viewFoo, 0, viewFoo, 0 ) );
-
 		const spy = testUtils.sinon.spy( log, 'warn' );
 
+		viewDocument.on( 'selectionChangeDone', () => {
+			expect( spy.called ).to.be.false;
+			done();
+		} );
+
 		for ( let i = 0; i < 10; i++ ) {
 			changeDomSelection();
 		}
-
-		setTimeout( () => {
-			expect( spy.called ).to.be.false;
-			done();
-		}, 400 );
 	} );
 
-	it( 'should not be treated as an infinite loop if changes are not often', ( done ) => {
+	it( 'should not be treated as an infinite loop if changes are not often', () => {
 		const clock = testUtils.sinon.useFakeTimers( 'setInterval', 'clearInterval' );
-		const spy = testUtils.sinon.spy( log, 'warn' );
+		const stub = testUtils.sinon.stub( log, 'warn' );
 
 		// We need to recreate SelectionObserver, so it will use mocked setInterval.
 		selectionObserver.disable();
@@ -200,31 +188,27 @@ describe( 'SelectionObserver', () => {
 		viewDocument._observers.delete( SelectionObserver );
 		viewDocument.addObserver( SelectionObserver );
 
-		// Inf-loop kicks in after 50th time the selection is changed in 2s.
-		// We will test 30 times, tick sinon clock to clean counter and then test 30 times again.
-		// Note that `changeDomSelection` fires two events.
-		let changeCount = 15;
-
-		for ( let i = 0; i < changeCount; i++ ) {
-			setTimeout( () => {
-				changeDomSelection();
-			}, i * 20 );
-		}
-
-		setTimeout( () => {
-			// Move the clock by 2100ms which will trigger callback added to `setInterval` and reset the inf-loop counter.
-			clock.tick( 2100 );
-
-			for ( let i = 0; i < changeCount; i++ ) {
-				changeDomSelection();
-			}
-
-			setTimeout( () => {
-				expect( spy.called ).to.be.false;
+		return doChanges()
+			.then( doChanges )
+			.then( () => {
+				sinon.assert.notCalled( stub );
 				clock.restore();
-				done();
-			}, 200 );
-		}, 400 );
+			} );
+
+		// Selectionchange event is called twice per `changeDomSelection()` execution. We call it 25 times to get
+		// 50 events. Infinite loop counter is reset, so calling this method twice should not show any warning.
+		function doChanges() {
+			return new Promise( resolve => {
+				viewDocument.once( 'selectionChangeDone', () => {
+					clock.tick( 1100 );
+					resolve();
+				} );
+
+				for ( let i = 0; i < 30; i++ ) {
+					changeDomSelection();
+				}
+			} );
+		}
 	} );
 
 	it( 'should fire `selectionChangeDone` event after selection stop changing', ( done ) => {
@@ -232,6 +216,9 @@ describe( 'SelectionObserver', () => {
 
 		viewDocument.on( 'selectionChangeDone', spy );
 
+		// Disable focus observer to not re-render view on each focus.
+		viewDocument.getObserver( FocusObserver ).disable();
+
 		// Change selection.
 		changeDomSelection();
 
@@ -250,7 +237,7 @@ describe( 'SelectionObserver', () => {
 				const data = spy.firstCall.args[ 1 ];
 
 				expect( spy.calledOnce ).to.true;
-				expect( data ).to.have.property( 'domSelection' ).to.equal( document.getSelection() );
+				expect( data ).to.have.property( 'domSelection' ).to.equal( domDocument.getSelection() );
 
 				expect( data ).to.have.property( 'oldSelection' ).to.instanceof( ViewSelection );
 				expect( data.oldSelection.rangeCount ).to.equal( 0 );
@@ -295,13 +282,13 @@ describe( 'SelectionObserver', () => {
 			}, 110 );
 		}, 100 );
 	} );
-} );
 
-function changeDomSelection() {
-	const domSelection = document.getSelection();
-	const domFoo = document.getElementById( 'main' ).childNodes[ 0 ].childNodes[ 0 ];
-	const offset = domSelection.anchorOffset;
+	function changeDomSelection() {
+		const domSelection = domDocument.getSelection();
+		const domFoo = domMain.childNodes[ 0 ].childNodes[ 0 ];
+		const offset = domSelection.anchorOffset;
 
-	domSelection.removeAllRanges();
-	domSelection.collapse( domFoo, offset == 2 ? 3 : 2 );
-}
+		domSelection.removeAllRanges();
+		domSelection.collapse( domFoo, offset == 2 ? 3 : 2 );
+	}
+} );

+ 169 - 0
packages/ckeditor5-engine/tests/view/placeholder.js

@@ -0,0 +1,169 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import { attachPlaceholder, detachPlaceholder } from '../../src/view/placeholder';
+import ViewContainerElement from '../../src/view/containerelement';
+import ViewDocument from '../../src/view/document';
+import ViewRange from '../../src/view/range';
+import { setData } from '../../src/dev-utils/view';
+
+describe( 'placeholder', () => {
+	let viewDocument, viewRoot;
+
+	beforeEach( () => {
+		viewDocument = new ViewDocument();
+		viewRoot = viewDocument.createRoot( 'main' );
+		viewDocument.isFocused = true;
+	} );
+
+	describe( 'createPlaceholder', () => {
+		it( 'should throw if element is not inside document', () => {
+			const element = new ViewContainerElement( 'div' );
+
+			expect( () => {
+				attachPlaceholder( element, 'foo bar baz' );
+			} ).to.throw( 'view-placeholder-element-is-detached' );
+		} );
+
+		it( 'should attach proper CSS class and data attribute', () => {
+			setData( viewDocument, '<div></div><div>{another div}</div>' );
+			const element = viewRoot.getChild( 0 );
+
+			attachPlaceholder( element, 'foo bar baz' );
+
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
+		} );
+
+		it( 'if element has children set only data attribute', () => {
+			setData( viewDocument, '<div>first div</div><div>{another div}</div>' );
+			const element = viewRoot.getChild( 0 );
+
+			attachPlaceholder( element, 'foo bar baz' );
+
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
+		} );
+
+		it( 'if element has selection inside set only data attribute', () => {
+			setData( viewDocument, '<div>[]</div><div>another div</div>' );
+			const element = viewRoot.getChild( 0 );
+
+			attachPlaceholder( element, 'foo bar baz' );
+
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
+		} );
+
+		it( 'if element has selection inside but document is blurred should contain placeholder CSS class', () => {
+			setData( viewDocument, '<div>[]</div><div>another div</div>' );
+			const element = viewRoot.getChild( 0 );
+			viewDocument.isFocused = false;
+
+			attachPlaceholder( element, 'foo bar baz' );
+
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
+		} );
+
+		it( 'use check function if one is provided', () => {
+			setData( viewDocument, '<div></div><div>{another div}</div>' );
+			const element = viewRoot.getChild( 0 );
+			const spy = sinon.spy( () => false );
+
+			attachPlaceholder( element, 'foo bar baz', spy );
+
+			sinon.assert.calledOnce( spy );
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
+		} );
+
+		it( 'should remove CSS class if selection is moved inside', () => {
+			setData( viewDocument, '<div></div><div>{another div}</div>' );
+			const element = viewRoot.getChild( 0 );
+
+			attachPlaceholder( element, 'foo bar baz' );
+
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
+
+			viewDocument.selection.setRanges( [ ViewRange.createIn( element ) ] );
+			viewDocument.render();
+
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
+		} );
+
+		it( 'should change placeholder settings when called twice', () => {
+			setData( viewDocument, '<div></div><div>{another div}</div>' );
+			const element = viewRoot.getChild( 0 );
+
+			attachPlaceholder( element, 'foo bar baz' );
+			attachPlaceholder( element, 'new text' );
+
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'new text' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
+		} );
+
+		it( 'should not throw when element is no longer in document', () => {
+			setData( viewDocument, '<div></div><div>{another div}</div>' );
+			const element = viewRoot.getChild( 0 );
+
+			attachPlaceholder( element, 'foo bar baz' );
+			setData( viewDocument, '<p>paragraph</p>' );
+
+			viewDocument.render();
+		} );
+
+		it( 'should allow to add placeholder to elements from different documents', () => {
+			setData( viewDocument, '<div></div><div>{another div}</div>' );
+			const element = viewRoot.getChild( 0 );
+			const secondDocument = new ViewDocument();
+			secondDocument.isFocused = true;
+			const secondRoot = secondDocument.createRoot( 'main' );
+			setData( secondDocument, '<div></div><div>{another div}</div>' );
+			const secondElement = secondRoot.getChild( 0 );
+
+			attachPlaceholder( element, 'first placeholder' );
+			attachPlaceholder( secondElement, 'second placeholder' );
+
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'first placeholder' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
+
+			expect( secondElement.getAttribute( 'data-placeholder' ) ).to.equal( 'second placeholder' );
+			expect( secondElement.hasClass( 'ck-placeholder' ) ).to.be.true;
+
+			// Move selection to the elements with placeholders.
+			viewDocument.selection.setRanges( [ ViewRange.createIn( element ) ] );
+			secondDocument.selection.setRanges( [ ViewRange.createIn( secondElement ) ] );
+
+			// Render changes.
+			viewDocument.render();
+			secondDocument.render();
+
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'first placeholder' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
+
+			expect( secondElement.getAttribute( 'data-placeholder' ) ).to.equal( 'second placeholder' );
+			expect( secondElement.hasClass( 'ck-placeholder' ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'detachPlaceholder', () => {
+		it( 'should remove placeholder from element', () => {
+			setData( viewDocument, '<div></div><div>{another div}</div>' );
+			const element = viewRoot.getChild( 0 );
+
+			attachPlaceholder( element, 'foo bar baz' );
+
+			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
+
+			detachPlaceholder( element );
+
+			expect( element.hasAttribute( 'data-placeholder' ) ).to.be.false;
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
+		} );
+	} );
+} );

+ 350 - 0
packages/ckeditor5-engine/tests/view/renderer.js

@@ -8,6 +8,7 @@
 import ViewElement from '../../src/view/element';
 import ViewText from '../../src/view/text';
 import ViewRange from '../../src/view/range';
+import ViewPosition from '../../src/view/position';
 import Selection from '../../src/view/selection';
 import DomConverter from '../../src/view/domconverter';
 import Renderer from '../../src/view/renderer';
@@ -16,6 +17,7 @@ import { parse } from '../../src/dev-utils/view';
 import { INLINE_FILLER, INLINE_FILLER_LENGTH, isBlockFiller, BR_FILLER } from '../../src/view/filler';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import createElement from '@ckeditor/ckeditor5-utils/src/dom/createelement';
+import log from '@ckeditor/ckeditor5-utils/src/log';
 
 testUtils.createSinonSandbox();
 
@@ -290,6 +292,63 @@ describe( 'Renderer', () => {
 			expect( domRoot.childNodes[ 0 ].tagName ).to.equal( 'P' );
 		} );
 
+		it( 'should update removed item when it is reinserted', () => {
+			const viewFoo = new ViewText( 'foo' );
+			const viewP = new ViewElement( 'p', null, viewFoo );
+			const viewDiv = new ViewElement( 'div', null, viewP );
+
+			viewRoot.appendChildren( viewDiv );
+
+			renderer.markToSync( 'children', viewRoot );
+			renderer.render();
+
+			viewDiv.removeChildren( 0, 1 );
+			renderer.markToSync( 'children', viewDiv );
+			renderer.render();
+
+			viewP.removeChildren( 0, 1 );
+
+			viewDiv.appendChildren( viewP );
+			renderer.markToSync( 'children', viewDiv );
+			renderer.render();
+
+			expect( domRoot.childNodes.length ).to.equal( 1 );
+
+			const domDiv = domRoot.childNodes[ 0 ];
+
+			expect( domDiv.tagName ).to.equal( 'DIV' );
+			expect( domDiv.childNodes.length ).to.equal( 1 );
+
+			const domP = domDiv.childNodes[ 0 ];
+
+			expect( domP.tagName ).to.equal( 'P' );
+			expect( domP.childNodes.length ).to.equal( 0 );
+		} );
+
+		it( 'should not throw when trying to update children of view element that got removed and lost its binding', () => {
+			const viewFoo = new ViewText( 'foo' );
+			const viewP = new ViewElement( 'p', null, viewFoo );
+			const viewDiv = new ViewElement( 'div', null, viewP );
+
+			viewRoot.appendChildren( viewDiv );
+
+			renderer.markToSync( 'children', viewRoot );
+			renderer.render();
+
+			viewRoot.removeChildren( 0, 1 );
+			renderer.markToSync( 'children', viewRoot );
+
+			viewDiv.removeChildren( 0, 1 );
+			renderer.markToSync( 'children', viewDiv );
+
+			viewP.removeChildren( 0, 1 );
+			renderer.markToSync( 'children', viewP );
+
+			renderer.render();
+
+			expect( domRoot.childNodes.length ).to.equal( 0 );
+		} );
+
 		it( 'should not care about filler if there is no DOM', () => {
 			selectionEditable = null;
 
@@ -1292,6 +1351,297 @@ describe( 'Renderer', () => {
 				expect( bindSelection.isEqual( selection ) ).to.be.true;
 			} );
 		} );
+
+		// #887
+		describe( 'similar selection', () => {
+			// Use spies to check selection updates. Some selection positions are not achievable in some
+			// browsers (e.g. <p>Foo<b>{}Bar</b></p> in Chrome) so asserting dom selection after rendering would fail.
+			let selectionCollapseSpy, selectionExtendSpy, logWarnStub;
+
+			before( () => {
+				logWarnStub = sinon.stub( log, 'warn' );
+			} );
+
+			afterEach( () => {
+				if ( selectionCollapseSpy ) {
+					selectionCollapseSpy.restore();
+					selectionCollapseSpy = null;
+				}
+
+				if ( selectionExtendSpy ) {
+					selectionExtendSpy.restore();
+					selectionExtendSpy = null;
+				}
+				logWarnStub.reset();
+			} );
+
+			after( () => {
+				logWarnStub.restore();
+			} );
+
+			it( 'should always render collapsed selection even if it is similar', () => {
+				const domSelection = document.getSelection();
+
+				const { view: viewP, selection: newSelection } = parse(
+					'<container:p>foo{}<attribute:b>bar</attribute:b></container:p>' );
+
+				viewRoot.appendChildren( viewP );
+				selection.setTo( newSelection );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				const domP = domRoot.childNodes[ 0 ];
+				const domB = domP.childNodes[ 1 ];
+				const viewB = viewRoot.getChild( 0 ).getChild( 1 );
+
+				expect( domSelection.isCollapsed ).to.true;
+				expect( domSelection.rangeCount ).to.equal( 1 );
+				expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP.childNodes[ 0 ] );
+				expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 3 );
+				expect( domSelection.getRangeAt( 0 ).endContainer ).to.equal( domP.childNodes[ 0 ] );
+				expect( domSelection.getRangeAt( 0 ).endOffset ).to.equal( 3 );
+
+				selectionCollapseSpy = sinon.spy( window.Selection.prototype, 'collapse' );
+				selectionExtendSpy = sinon.spy( window.Selection.prototype, 'extend' );
+
+				// <container:p>foo<attribute:b>{}bar</attribute:b></container:p>
+				selection.setRanges( [ new ViewRange( new ViewPosition( viewB.getChild( 0 ), 0 ), new ViewPosition( viewB.getChild( 0 ), 0 ) ) ] );
+
+				renderer.markToSync( 'children', viewP );
+				renderer.render();
+
+				expect( selectionCollapseSpy.calledOnce ).to.true;
+				expect( selectionCollapseSpy.calledWith( domB.childNodes[ 0 ], 0 ) ).to.true;
+				expect( selectionExtendSpy.calledOnce ).to.true;
+				expect( selectionExtendSpy.calledWith( domB.childNodes[ 0 ], 0 ) ).to.true;
+				expect( logWarnStub.notCalled ).to.true;
+			} );
+
+			it( 'should always render collapsed selection even if it is similar (with empty element)', () => {
+				const domSelection = document.getSelection();
+
+				const { view: viewP, selection: newSelection } = parse(
+					'<container:p>foo<attribute:b>[]</attribute:b></container:p>' );
+
+				viewRoot.appendChildren( viewP );
+				selection.setTo( newSelection );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				const domP = domRoot.childNodes[ 0 ];
+				const domB = domP.childNodes[ 1 ];
+
+				expect( domSelection.isCollapsed ).to.true;
+				expect( domSelection.rangeCount ).to.equal( 1 );
+				expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domB.childNodes[ 0 ] );
+				expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( INLINE_FILLER_LENGTH );
+				expect( domSelection.getRangeAt( 0 ).endContainer ).to.equal( domB.childNodes[ 0 ] );
+				expect( domSelection.getRangeAt( 0 ).endOffset ).to.equal( INLINE_FILLER_LENGTH );
+
+				selectionCollapseSpy = sinon.spy( window.Selection.prototype, 'collapse' );
+				selectionExtendSpy = sinon.spy( window.Selection.prototype, 'extend' );
+
+				// <container:p>foo{}<attribute:b></attribute:b></container:p>
+				selection.setRanges( [ new ViewRange( new ViewPosition( viewP.getChild( 0 ), 3 ), new ViewPosition( viewP.getChild( 0 ), 3 ) ) ] );
+
+				renderer.markToSync( 'children', viewP );
+				renderer.render();
+
+				expect( selectionCollapseSpy.calledOnce ).to.true;
+				expect( selectionCollapseSpy.calledWith( domP.childNodes[ 0 ], 3 ) ).to.true;
+				expect( selectionExtendSpy.calledOnce ).to.true;
+				expect( selectionExtendSpy.calledWith( domP.childNodes[ 0 ], 3 ) ).to.true;
+				expect( logWarnStub.notCalled ).to.true;
+			} );
+
+			it( 'should always render non-collapsed selection if it not is similar', () => {
+				const domSelection = document.getSelection();
+
+				const { view: viewP, selection: newSelection } = parse(
+					'<container:p>fo{o}<attribute:b>bar</attribute:b></container:p>' );
+
+				viewRoot.appendChildren( viewP );
+				selection.setTo( newSelection );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				const domP = domRoot.childNodes[ 0 ];
+				const domB = domP.childNodes[ 1 ];
+				const viewB = viewRoot.getChild( 0 ).getChild( 1 );
+
+				expect( domSelection.isCollapsed ).to.false;
+				expect( domSelection.rangeCount ).to.equal( 1 );
+				expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP.childNodes[ 0 ] );
+				expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 2 );
+				expect( domSelection.getRangeAt( 0 ).endContainer ).to.equal( domP.childNodes[ 0 ] );
+				expect( domSelection.getRangeAt( 0 ).endOffset ).to.equal( 3 );
+
+				selectionCollapseSpy = sinon.spy( window.Selection.prototype, 'collapse' );
+				selectionExtendSpy = sinon.spy( window.Selection.prototype, 'extend' );
+
+				// <container:p>fo{o<attribute:b>b}ar</attribute:b></container:p>
+				selection.setRanges( [ new ViewRange( new ViewPosition( viewP.getChild( 0 ), 2 ), new ViewPosition( viewB.getChild( 0 ), 1 ) ) ] );
+
+				renderer.markToSync( 'children', viewP );
+				renderer.render();
+
+				expect( selectionCollapseSpy.calledOnce ).to.true;
+				expect( selectionCollapseSpy.calledWith( domP.childNodes[ 0 ], 2 ) ).to.true;
+				expect( selectionExtendSpy.calledOnce ).to.true;
+				expect( selectionExtendSpy.calledWith( domB.childNodes[ 0 ], 1 ) ).to.true;
+				expect( logWarnStub.notCalled ).to.true;
+			} );
+
+			it( 'should not render non-collapsed selection it is similar (element start)', () => {
+				const domSelection = document.getSelection();
+
+				const { view: viewP, selection: newSelection } = parse(
+					'<container:p>foo<attribute:b>{ba}r</attribute:b></container:p>' );
+
+				viewRoot.appendChildren( viewP );
+				selection.setTo( newSelection );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				const domP = domRoot.childNodes[ 0 ];
+				const domB = domP.childNodes[ 1 ];
+				const viewB = viewRoot.getChild( 0 ).getChild( 1 );
+
+				expect( domSelection.isCollapsed ).to.false;
+				expect( domSelection.rangeCount ).to.equal( 1 );
+				expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domB.childNodes[ 0 ] );
+				expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 0 );
+				expect( domSelection.getRangeAt( 0 ).endContainer ).to.equal( domB.childNodes[ 0 ] );
+				expect( domSelection.getRangeAt( 0 ).endOffset ).to.equal( 2 );
+
+				selectionCollapseSpy = sinon.spy( window.Selection.prototype, 'collapse' );
+				selectionExtendSpy = sinon.spy( window.Selection.prototype, 'extend' );
+
+				// <container:p>foo{<attribute:b>ba}r</attribute:b></container:p>
+				selection.setRanges( [ new ViewRange( new ViewPosition( viewP.getChild( 0 ), 3 ), new ViewPosition( viewB.getChild( 0 ), 2 ) ) ] );
+
+				renderer.markToSync( 'children', viewP );
+				renderer.render();
+
+				expect( selectionCollapseSpy.notCalled ).to.true;
+				expect( selectionExtendSpy.notCalled ).to.true;
+				expect( logWarnStub.called ).to.true;
+			} );
+
+			it( 'should not render non-collapsed selection it is similar (element end)', () => {
+				const domSelection = document.getSelection();
+
+				const { view: viewP, selection: newSelection } = parse(
+					'<container:p>foo<attribute:b>b{ar}</attribute:b>baz</container:p>' );
+
+				viewRoot.appendChildren( viewP );
+				selection.setTo( newSelection );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				const domP = domRoot.childNodes[ 0 ];
+				const domB = domP.childNodes[ 1 ];
+				const viewB = viewRoot.getChild( 0 ).getChild( 1 );
+
+				expect( domSelection.isCollapsed ).to.false;
+				expect( domSelection.rangeCount ).to.equal( 1 );
+				expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domB.childNodes[ 0 ] );
+				expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 1 );
+				expect( domSelection.getRangeAt( 0 ).endContainer ).to.equal( domB.childNodes[ 0 ] );
+				expect( domSelection.getRangeAt( 0 ).endOffset ).to.equal( 3 );
+
+				selectionCollapseSpy = sinon.spy( window.Selection.prototype, 'collapse' );
+				selectionExtendSpy = sinon.spy( window.Selection.prototype, 'extend' );
+
+				// <container:p>foo<attribute:b>b{ar</attribute:b>}baz</container:p>
+				selection.setRanges( [ new ViewRange( new ViewPosition( viewB.getChild( 0 ), 1 ), new ViewPosition( viewP.getChild( 2 ), 0 ) ) ] );
+
+				renderer.markToSync( 'children', viewP );
+				renderer.render();
+
+				expect( selectionCollapseSpy.notCalled ).to.true;
+				expect( selectionExtendSpy.notCalled ).to.true;
+				expect( logWarnStub.called ).to.true;
+			} );
+
+			it( 'should not render non-collapsed selection it is similar (element start - nested)', () => {
+				const domSelection = document.getSelection();
+
+				const { view: viewP, selection: newSelection } = parse(
+					'<container:p>foo<attribute:b><attribute:i>{ba}r</attribute:i></attribute:b></container:p>' );
+
+				viewRoot.appendChildren( viewP );
+				selection.setTo( newSelection );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				const domP = domRoot.childNodes[ 0 ];
+				const domB = domP.childNodes[ 1 ];
+				const viewI = viewRoot.getChild( 0 ).getChild( 1 ).getChild( 0 );
+
+				expect( domSelection.isCollapsed ).to.false;
+				expect( domSelection.rangeCount ).to.equal( 1 );
+				expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domB.childNodes[ 0 ].childNodes[ 0 ] );
+				expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 0 );
+				expect( domSelection.getRangeAt( 0 ).endContainer ).to.equal( domB.childNodes[ 0 ].childNodes[ 0 ] );
+				expect( domSelection.getRangeAt( 0 ).endOffset ).to.equal( 2 );
+
+				selectionCollapseSpy = sinon.spy( window.Selection.prototype, 'collapse' );
+				selectionExtendSpy = sinon.spy( window.Selection.prototype, 'extend' );
+
+				// <container:p>foo{<attribute:b><attribute:i>ba}r</attribute:i></attribute:b></container:p>
+				selection.setRanges( [ new ViewRange( new ViewPosition( viewP.getChild( 0 ), 3 ), new ViewPosition( viewI.getChild( 0 ), 2 ) ) ] );
+
+				renderer.markToSync( 'children', viewP );
+				renderer.render();
+
+				expect( selectionCollapseSpy.notCalled ).to.true;
+				expect( selectionExtendSpy.notCalled ).to.true;
+				expect( logWarnStub.called ).to.true;
+			} );
+
+			it( 'should not render non-collapsed selection it is similar (element end - nested)', () => {
+				const domSelection = document.getSelection();
+
+				const { view: viewP, selection: newSelection } = parse(
+					'<container:p>f{oo<attribute:b><attribute:i>bar}</attribute:i></attribute:b>baz</container:p>' );
+
+				viewRoot.appendChildren( viewP );
+				selection.setTo( newSelection );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				const domP = domRoot.childNodes[ 0 ];
+				const domB = domP.childNodes[ 1 ];
+
+				expect( domSelection.isCollapsed ).to.false;
+				expect( domSelection.rangeCount ).to.equal( 1 );
+				expect( domSelection.getRangeAt( 0 ).startContainer ).to.equal( domP.childNodes[ 0 ] );
+				expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 1 );
+				expect( domSelection.getRangeAt( 0 ).endContainer ).to.equal( domB.childNodes[ 0 ].childNodes[ 0 ] );
+				expect( domSelection.getRangeAt( 0 ).endOffset ).to.equal( 3 );
+
+				selectionCollapseSpy = sinon.spy( window.Selection.prototype, 'collapse' );
+				selectionExtendSpy = sinon.spy( window.Selection.prototype, 'extend' );
+
+				// <container:p>f{oo<attribute:b><attribute:i>bar</attribute:i></attribute:b>}baz</container:p>
+				selection.setRanges( [ new ViewRange( new ViewPosition( viewP.getChild( 0 ), 1 ), new ViewPosition( viewP.getChild( 2 ), 0 ) ) ] );
+
+				renderer.markToSync( 'children', viewP );
+				renderer.render();
+
+				expect( selectionCollapseSpy.notCalled ).to.true;
+				expect( selectionExtendSpy.notCalled ).to.true;
+				expect( logWarnStub.called ).to.true;
+			} );
+		} );
 	} );
 } );
 

+ 8 - 0
packages/ckeditor5-engine/theme/placeholder.scss

@@ -0,0 +1,8 @@
+// Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+// For licensing, see LICENSE.md or http://ckeditor.com/license
+
+.ck-placeholder::before {
+	content: attr( data-placeholder );
+	cursor: text;
+	color: #c2c2c2;
+}