8
0
فهرست منبع

Merge branch 'master' into t/ckeditor5/1096

Piotrek Koszuliński 7 سال پیش
والد
کامیت
7d2b8de982
51فایلهای تغییر یافته به همراه1380 افزوده شده و 463 حذف شده
  1. 3 6
      packages/ckeditor5-engine/src/controller/editingcontroller.js
  2. 68 77
      packages/ckeditor5-engine/src/conversion/conversion.js
  3. 40 0
      packages/ckeditor5-engine/src/conversion/conversionhelpers.js
  4. 2 2
      packages/ckeditor5-engine/src/conversion/downcasthelpers.js
  5. 0 0
      packages/ckeditor5-engine/src/conversion/upcast-converters.js
  6. 2 2
      packages/ckeditor5-engine/src/conversion/upcasthelpers.js
  7. 9 0
      packages/ckeditor5-engine/src/model/differ.js
  8. 2 2
      packages/ckeditor5-engine/src/model/document.js
  9. 38 11
      packages/ckeditor5-engine/src/model/documentselection.js
  10. 2 2
      packages/ckeditor5-engine/src/model/element.js
  11. 0 4
      packages/ckeditor5-engine/src/model/markercollection.js
  12. 1 8
      packages/ckeditor5-engine/src/model/model.js
  13. 33 1
      packages/ckeditor5-engine/src/model/selection.js
  14. 1 3
      packages/ckeditor5-engine/src/model/writer.js
  15. 3 0
      packages/ckeditor5-engine/src/view/document.js
  16. 18 0
      packages/ckeditor5-engine/src/view/documentselection.js
  17. 8 2
      packages/ckeditor5-engine/src/view/element.js
  18. 1 2
      packages/ckeditor5-engine/src/view/node.js
  19. 2 2
      packages/ckeditor5-engine/src/view/observer/focusobserver.js
  20. 2 2
      packages/ckeditor5-engine/src/view/observer/mutationobserver.js
  21. 2 2
      packages/ckeditor5-engine/src/view/observer/observer.js
  22. 1 1
      packages/ckeditor5-engine/src/view/observer/selectionobserver.js
  23. 191 84
      packages/ckeditor5-engine/src/view/placeholder.js
  24. 17 0
      packages/ckeditor5-engine/src/view/selection.js
  25. 145 32
      packages/ckeditor5-engine/src/view/view.js
  26. 2 2
      packages/ckeditor5-engine/tests/controller/datacontroller.js
  27. 2 2
      packages/ckeditor5-engine/tests/controller/editingcontroller.js
  28. 41 67
      packages/ckeditor5-engine/tests/conversion/conversion.js
  29. 41 0
      packages/ckeditor5-engine/tests/conversion/conversionhelpers.js
  30. 6 11
      packages/ckeditor5-engine/tests/conversion/downcasthelpers.js
  31. 2 5
      packages/ckeditor5-engine/tests/conversion/upcasthelpers.js
  32. 13 3
      packages/ckeditor5-engine/tests/manual/placeholder.js
  33. 29 0
      packages/ckeditor5-engine/tests/model/differ.js
  34. 82 0
      packages/ckeditor5-engine/tests/model/documentselection.js
  35. 47 0
      packages/ckeditor5-engine/tests/model/operation/transform/undo.js
  36. 53 16
      packages/ckeditor5-engine/tests/model/selection.js
  37. 5 3
      packages/ckeditor5-engine/tests/tickets/1653.js
  38. 18 0
      packages/ckeditor5-engine/tests/view/documentselection.js
  39. 1 1
      packages/ckeditor5-engine/tests/view/manual/immutable.js
  40. 1 1
      packages/ckeditor5-engine/tests/view/manual/noselection.js
  41. 1 1
      packages/ckeditor5-engine/tests/view/observer/domeventobserver.js
  42. 8 5
      packages/ckeditor5-engine/tests/view/observer/focusobserver.js
  43. 12 12
      packages/ckeditor5-engine/tests/view/observer/mutationobserver.js
  44. 3 2
      packages/ckeditor5-engine/tests/view/observer/selectionobserver.js
  45. 245 35
      packages/ckeditor5-engine/tests/view/placeholder.js
  46. 10 10
      packages/ckeditor5-engine/tests/view/renderer.js
  47. 15 0
      packages/ckeditor5-engine/tests/view/selection.js
  48. 3 3
      packages/ckeditor5-engine/tests/view/view/jumpoverinlinefiller.js
  49. 1 1
      packages/ckeditor5-engine/tests/view/view/jumpoveruielement.js
  50. 144 34
      packages/ckeditor5-engine/tests/view/view/view.js
  51. 4 4
      packages/ckeditor5-engine/theme/placeholder.css

+ 3 - 6
packages/ckeditor5-engine/src/controller/editingcontroller.js

@@ -76,14 +76,11 @@ export default class EditingController {
 		//
 		// See  https://github.com/ckeditor/ckeditor5-engine/issues/1528
 		this.listenTo( this.model, '_beforeChanges', () => {
-			this.view._renderingDisabled = true;
+			this.view._disableRendering( true );
 		}, { priority: 'highest' } );
 
-		this.listenTo( this.model, '_afterChanges', ( evt, { hasModelDocumentChanged } ) => {
-			this.view._renderingDisabled = false;
-			if ( hasModelDocumentChanged ) {
-				this.view.render();
-			}
+		this.listenTo( this.model, '_afterChanges', () => {
+			this.view._disableRendering( false );
 		}, { priority: 'lowest' } );
 
 		// Whenever model document is changed, convert those changes to the view (using model.Document#differ).

+ 68 - 77
packages/ckeditor5-engine/src/conversion/conversion.js

@@ -8,6 +8,8 @@
  */
 
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import UpcastHelpers from './upcasthelpers';
+import DowncastHelpers from './downcasthelpers';
 
 /**
  * A utility class that helps add converters to upcast and downcast dispatchers.
@@ -56,45 +58,68 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 export default class Conversion {
 	/**
 	 * Creates a new conversion instance.
+	 *
+	 * @param {module:engine/conversion/downcastdispatcher~DowncastDispatcher|
+	 * Array.<module:engine/conversion/downcastdispatcher~DowncastDispatcher>} downcastDispatchers
+	 * @param {module:engine/conversion/upcastdispatcher~UpcastDispatcher|
+	 * Array.<module:engine/conversion/upcastdispatcher~UpcastDispatcher>} upcastDispatchers
 	 */
-	constructor() {
+	constructor( downcastDispatchers, upcastDispatchers ) {
 		/**
+		 * Maps dispatchers group name to ConversionHelpers instances.
+		 *
 		 * @private
-		 * @member {Map}
+		 * @member {Map.<String,module:engine/conversion/conversionhelpers~ConversionHelpers>}
 		 */
-		this._conversionHelpers = new Map();
+		this._helpers = new Map();
+
+		// Define default 'downcast' & 'upcast' dispatchers groups. Those groups are always available as two-way converters needs them.
+		this._downcast = Array.isArray( downcastDispatchers ) ? downcastDispatchers : [ downcastDispatchers ];
+		this._createConversionHelpers( { name: 'downcast', dispatchers: this._downcast, isDowncast: true } );
+
+		this._upcast = Array.isArray( upcastDispatchers ) ? upcastDispatchers : [ upcastDispatchers ];
+		this._createConversionHelpers( { name: 'upcast', dispatchers: this._upcast, isDowncast: false } );
 	}
 
 	/**
-	 * Registers one or more converters under a given group name. The group name can then be used to assign a converter
-	 * to multiple dispatchers at once.
+	 * Define an alias for registered dispatcher.
+	 *
+	 *		const conversion = new Conversion(
+	 *			[ dataDowncastDispatcher, editingDowncastDispatcher ],
+	 *			upcastDispatcher
+	 *		);
 	 *
-	 * If a given group name is used for the second time, the
-	 * {@link module:utils/ckeditorerror~CKEditorError `conversion-register-group-exists` error} is thrown.
+	 *		conversion.addAlias( 'dataDowncast', dataDowncastDispatcher );
 	 *
-	 * @param {String} name The name for dispatchers group.
-	 * @param {module:engine/conversion/downcasthelpers~DowncastHelpers|
-	 * module:engine/conversion/upcasthelpers~UpcastHelpers} conversionHelpers
+	 * @param {String} alias An alias of a dispatcher.
+	 * @param {module:engine/conversion/downcastdispatcher~DowncastDispatcher|
+	 * module:engine/conversion/upcastdispatcher~UpcastDispatcher} dispatcher Dispatcher which should have an alias.
 	 */
-	register( name, conversionHelpers ) {
-		if ( this._conversionHelpers.has( name ) ) {
+	addAlias( alias, dispatcher ) {
+		const isDowncast = this._downcast.includes( dispatcher );
+		const isUpcast = this._upcast.includes( dispatcher );
+
+		if ( !isUpcast && !isDowncast ) {
 			/**
-			 * Trying to register a group name that has already been registered.
+			 * Trying to register and alias for a dispatcher that nas not been registered.
 			 *
-			 * @error conversion-register-group-exists
+			 * @error conversion-add-alias-dispatcher-not-registered
 			 */
-			throw new CKEditorError( 'conversion-register-group-exists: Trying to register' +
-				'a group name that has already been registered.' );
+			throw new CKEditorError( 'conversion-add-alias-dispatcher-not-registered: ' +
+				'Trying to register and alias for a dispatcher that nas not been registered.' );
 		}
 
-		this._conversionHelpers.set( name, conversionHelpers );
+		this._createConversionHelpers( { name: alias, dispatchers: [ dispatcher ], isDowncast } );
 	}
 
 	/**
-	 * Provides a chainable API to assign converters to conversion dispatchers.
+	 * Provides a chainable API to assign converters to conversion dispatchers group.
+	 *
+	 * If the given group name has not been registered, the
+	 * {@link module:utils/ckeditorerror~CKEditorError `conversion-for-unknown-group` error} is thrown.
 	 *
 	 * You can use conversion helpers available directly in the `for()` chain or your custom ones via
-	 * the {@link module:engine/conversion/conversion~ConversionHelpers#add `add()`} method.
+	 * the {@link module:engine/conversion/conversionhelpers~ConversionHelpers#add `add()`} method.
 	 *
 	 * # Using bulit-in conversion helpers
 	 *
@@ -149,7 +174,16 @@ export default class Conversion {
 	 * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers|module:engine/conversion/upcasthelpers~UpcastHelpers}
 	 */
 	for( groupName ) {
-		return this._getConversionHelpers( groupName );
+		if ( !this._helpers.has( groupName ) ) {
+			/**
+			 * Trying to add a converter to an unknown dispatchers group.
+			 *
+			 * @error conversion-for-unknown-group
+			 */
+			throw new CKEditorError( 'conversion-for-unknown-group: Trying to add a converter to an unknown dispatchers group.' );
+		}
+
+		return this._helpers.get( groupName );
 	}
 
 	/**
@@ -535,26 +569,28 @@ export default class Conversion {
 	}
 
 	/**
-	 * Returns conversion helpers registered under a given name.
-	 *
-	 * If the given group name has not been registered, the
-	 * {@link module:utils/ckeditorerror~CKEditorError `conversion-for-unknown-group` error} is thrown.
+	 * Creates and caches conversion helpers for given dispatchers group.
 	 *
 	 * @private
-	 * @param {String} groupName
-	 * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers|module:engine/conversion/upcasthelpers~UpcastHelpers}
+	 * @param {Object} options
+	 * @param {String} options.name Group name.
+	 * @param {Array.<module:engine/conversion/downcastdispatcher~DowncastDispatcher|
+	 * module:engine/conversion/upcastdispatcher~UpcastDispatcher>} options.dispatchers
+	 * @param {Boolean} options.isDowncast
 	 */
-	_getConversionHelpers( groupName ) {
-		if ( !this._conversionHelpers.has( groupName ) ) {
+	_createConversionHelpers( { name, dispatchers, isDowncast } ) {
+		if ( this._helpers.has( name ) ) {
 			/**
-			 * Trying to add a converter to an unknown dispatchers group.
+			 * Trying to register a group name that has already been registered.
 			 *
-			 * @error conversion-for-unknown-group
+			 * @error conversion-group-exists
 			 */
-			throw new CKEditorError( 'conversion-for-unknown-group: Trying to add a converter to an unknown dispatchers group.' );
+			throw new CKEditorError( 'conversion-group-exists: Trying to register a group name that has already been registered.' );
 		}
 
-		return this._conversionHelpers.get( groupName );
+		const helpers = isDowncast ? new DowncastHelpers( dispatchers ) : new UpcastHelpers( dispatchers );
+
+		this._helpers.set( name, helpers );
 	}
 }
 
@@ -605,48 +641,3 @@ function* _getUpcastDefinition( model, view, upcastAlso ) {
 		}
 	}
 }
-
-/**
- * Base class for conversion helpers.
- */
-export class ConversionHelpers {
-	/**
-	 * Creates a conversion helpers instance.
-	 *
-	 * @param {Array.<module:engine/conversion/downcastdispatcher~DowncastDispatcher|
-	 * module:engine/conversion/upcastdispatcher~UpcastDispatcher>} dispatcher
-	 */
-	constructor( dispatcher ) {
-		this._dispatchers = Array.isArray( dispatcher ) ? dispatcher : [ dispatcher ];
-	}
-
-	/**
-	 * Registers a conversion helper.
-	 *
-	 * **Note**: See full usage example in the `{@link module:engine/conversion/conversion~Conversion#for conversion.for()}`
-	 * method description.
-	 *
-	 * @param {Function} conversionHelper The function to be called on event.
-	 * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers|module:engine/conversion/upcasthelpers~UpcastHelpers}
-	 */
-	add( conversionHelper ) {
-		this._addToDispatchers( conversionHelper );
-
-		return this;
-	}
-
-	/**
-	 * Helper function for the `Conversion` `.add()` method.
-	 *
-	 * Calls `conversionHelper` on each dispatcher from the group specified earlier in the `.for()` call, effectively
-	 * adding converters to all specified dispatchers.
-	 *
-	 * @private
-	 * @param {Function} conversionHelper
-	 */
-	_addToDispatchers( conversionHelper ) {
-		for ( const dispatcher of this._dispatchers ) {
-			conversionHelper( dispatcher );
-		}
-	}
-}

+ 40 - 0
packages/ckeditor5-engine/src/conversion/conversionhelpers.js

@@ -0,0 +1,40 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module engine/conversion/conversionhelpers
+ */
+
+/**
+ * Base class for conversion helpers.
+ */
+export default class ConversionHelpers {
+	/**
+	 * Creates a conversion helpers instance.
+	 *
+	 * @param {Array.<module:engine/conversion/downcastdispatcher~DowncastDispatcher|
+	 * module:engine/conversion/upcastdispatcher~UpcastDispatcher>} dispatchers
+	 */
+	constructor( dispatchers ) {
+		this._dispatchers = dispatchers;
+	}
+
+	/**
+	 * Registers a conversion helper.
+	 *
+	 * **Note**: See full usage example in the `{@link module:engine/conversion/conversion~Conversion#for conversion.for()}`
+	 * method description.
+	 *
+	 * @param {Function} conversionHelper The function to be called on event.
+	 * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers|module:engine/conversion/upcasthelpers~UpcastHelpers}
+	 */
+	add( conversionHelper ) {
+		for ( const dispatcher of this._dispatchers ) {
+			conversionHelper( dispatcher );
+		}
+
+		return this;
+	}
+}

+ 2 - 2
packages/ckeditor5-engine/src/conversion/downcasthelpers.js

@@ -9,7 +9,7 @@ import ModelElement from '../model/element';
 
 import ViewAttributeElement from '../view/attributeelement';
 import DocumentSelection from '../model/documentselection';
-import { ConversionHelpers } from './conversion';
+import ConversionHelpers from './conversionhelpers';
 
 import log from '@ckeditor/ckeditor5-utils/src/log';
 import { cloneDeep } from 'lodash-es';
@@ -23,7 +23,7 @@ import { cloneDeep } from 'lodash-es';
 /**
  * Downcast conversion helper functions.
  *
- * @extends module:engine/conversion/conversion~ConversionHelpers
+ * @extends module:engine/conversion/conversionhelpers~ConversionHelpers
  */
 export default class DowncastHelpers extends ConversionHelpers {
 	/**

+ 0 - 0
packages/ckeditor5-engine/src/conversion/upcast-converters.js


+ 2 - 2
packages/ckeditor5-engine/src/conversion/upcasthelpers.js

@@ -5,7 +5,7 @@
 
 import Matcher from '../view/matcher';
 import ModelRange from '../model/range';
-import { ConversionHelpers } from './conversion';
+import ConversionHelpers from './conversionhelpers';
 
 import { cloneDeep } from 'lodash-es';
 import ModelSelection from '../model/selection';
@@ -20,7 +20,7 @@ import ModelSelection from '../model/selection';
 /**
  * Upcast conversion helper functions.
  *
- * @extends module:engine/conversion/conversion~ConversionHelpers
+ * @extends module:engine/conversion/conversionhelpers~ConversionHelpers
  */
 export default class UpcastHelpers extends ConversionHelpers {
 	/**

+ 9 - 0
packages/ckeditor5-engine/src/model/differ.js

@@ -149,6 +149,15 @@ export default class Differ {
 			case 'remove':
 			case 'move':
 			case 'reinsert': {
+				// When range is moved to the same position then not mark it as a change.
+				// See: https://github.com/ckeditor/ckeditor5-engine/issues/1664.
+				if (
+					operation.sourcePosition.isEqual( operation.targetPosition ) ||
+					operation.sourcePosition.getShiftedBy( operation.howMany ).isEqual( operation.targetPosition )
+				) {
+					return;
+				}
+
 				const sourceParentInserted = this._isInInsertedElement( operation.sourcePosition.parent );
 				const targetParentInserted = this._isInInsertedElement( operation.targetPosition.parent );
 

+ 2 - 2
packages/ckeditor5-engine/src/model/document.js

@@ -240,8 +240,8 @@ export default class Document {
 	}
 
 	/**
-	 * Used to register a post-fixer callback. A post-fixer mechanism guarantees that the features that listen to
-	 * the {@link module:engine/model/model~Model#event:_change model's change event} will operate on a correct model state.
+	 * Used to register a post-fixer callback. A post-fixer mechanism guarantees that the features
+	 * will operate on a correct model state.
 	 *
 	 * An execution of a feature may lead to an incorrect document tree state. The callbacks are used to fix the document tree after
 	 * it has changed. Post-fixers are fired just after all changes from the outermost change block were applied but

+ 38 - 11
packages/ckeditor5-engine/src/model/documentselection.js

@@ -359,6 +359,24 @@ export default class DocumentSelection {
 		return this._selection.hasAttribute( key );
 	}
 
+	/**
+	 * Checks whether object is of given type following the convention set by
+	 * {@link module:engine/model/node~Node#is `Node#is()`}.
+	 *
+	 *		const selection = new DocumentSelection( ... );
+	 *
+	 *		selection.is( 'selection' ); // true
+	 *		selection.is( 'documentSelection' ); // true
+	 *		selection.is( 'node' ); // false
+	 *		selection.is( 'element' ); // false
+	 *
+	 * @param {String} type
+	 * @returns {Boolean}
+	 */
+	is( type ) {
+		return type == 'selection' || type == 'documentSelection';
+	}
+
 	/**
 	 * Moves {@link module:engine/model/documentselection~DocumentSelection#focus} to the specified location.
 	 * Should be used only within the {@link module:engine/model/writer~Writer#setSelectionFocus} method.
@@ -580,7 +598,7 @@ class LiveSelection extends Selection {
 		// @type {Set}
 		this._overriddenGravityRegister = new Set();
 
-		// Add events that will ensure selection correctness.
+		// Ensure selection is correct and up to date after each range change.
 		this.on( 'change:range', () => {
 			for ( const range of this.getRanges() ) {
 				if ( !this._document._validateSelectionRange( range ) ) {
@@ -597,20 +615,22 @@ class LiveSelection extends Selection {
 					);
 				}
 			}
-		} );
 
-		this.listenTo( this._document, 'change', ( evt, batch ) => {
-			// Update selection's markers.
 			this._updateMarkers();
-
-			// Update selection's attributes.
 			this._updateAttributes( false );
-
-			// Clear selection attributes from element if no longer empty.
-			clearAttributesStoredInElement( this._model, batch );
 		} );
 
-		this.listenTo( this._model, 'applyOperation', () => {
+		// Update markers data stored by the selection after each marker change.
+		this.listenTo( this._model.markers, 'update', () => this._updateMarkers() );
+
+		// Ensure selection is correct and up to date after each operation.
+		this.listenTo( this._model, 'applyOperation', ( evt, args ) => {
+			const operation = args[ 0 ];
+
+			if ( !operation.isDocumentOperation || operation.type == 'marker' || operation.type == 'rename' || operation.type == 'noop' ) {
+				return;
+			}
+
 			while ( this._fixGraveyardRangesData.length ) {
 				const { liveRange, sourcePosition } = this._fixGraveyardRangesData.shift();
 
@@ -619,10 +639,17 @@ class LiveSelection extends Selection {
 
 			if ( this._hasChangedRange ) {
 				this._hasChangedRange = false;
-
 				this.fire( 'change:range', { directChange: false } );
 			}
+
+			this._updateMarkers();
+			this._updateAttributes( false );
 		}, { priority: 'lowest' } );
+
+		// Clear selection attributes from element if no longer empty.
+		this.listenTo( this._document, 'change', ( evt, batch ) => {
+			clearAttributesStoredInElement( this._model, batch );
+		} );
 	}
 
 	get isCollapsed() {

+ 2 - 2
packages/ckeditor5-engine/src/model/element.js

@@ -89,7 +89,7 @@ export default class Element extends Node {
 	}
 
 	/**
-	 * Checks whether given model tree object is of given type.
+	 * Checks whether this model object is of the given type.
 	 *
 	 *		obj.name; // 'listItem'
 	 *		obj instanceof Element; // true
@@ -100,7 +100,7 @@ export default class Element extends Node {
 	 *		obj.is( 'text' ); // false
 	 *		obj.is( 'element', 'image' ); // false
 	 *
-	 * Read more in {@link module:engine/model/node~Node#is}.
+	 * Read more in {@link module:engine/model/node~Node#is `Node#is()`}.
 	 *
 	 * @param {String} type Type to check when `name` parameter is present.
 	 * Otherwise, it acts like the `name` parameter.

+ 0 - 4
packages/ckeditor5-engine/src/model/markercollection.js

@@ -305,10 +305,6 @@ mix( MarkerCollection, EmitterMixin );
  * Then, it can be upcasted back to a marker. Again, use {@link module:engine/conversion/upcasthelpers upcast converters} or
  * attach a custom converter to {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element}.
  *
- * Another upside of markers is that finding marked part of document is fast and easy. Using attributes to mark some nodes
- * and then trying to find that part of document would require traversing whole document tree. Marker gives instant access
- * to the range which it is marking at the moment.
- *
  * `Marker` instances are created and destroyed only by {@link ~MarkerCollection MarkerCollection}.
  */
 class Marker {

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

@@ -684,7 +684,6 @@ export default class Model {
 	 */
 	_runPendingChanges() {
 		const ret = [];
-		let hasModelDocumentChanged = false;
 
 		this.fire( '_beforeChanges' );
 
@@ -697,9 +696,6 @@ export default class Model {
 			const callbackReturnValue = this._pendingChanges[ 0 ].callback( this._currentWriter );
 			ret.push( callbackReturnValue );
 
-			// Collect an information whether the model document has changed during from the last pending change.
-			hasModelDocumentChanged = hasModelDocumentChanged || this.document._hasDocumentChangedFromTheLastChangeBlock();
-
 			// Fire '_change' event before resetting differ.
 			this.fire( '_change', this._currentWriter );
 
@@ -709,7 +705,7 @@ export default class Model {
 			this._currentWriter = null;
 		}
 
-		this.fire( '_afterChanges', { hasModelDocumentChanged } );
+		this.fire( '_afterChanges' );
 
 		return ret;
 	}
@@ -740,9 +736,6 @@ export default class Model {
 	 *
 	 * @protected
 	 * @event _afterChanges
-	 * @param {Object} options
-	 * @param {Boolean} options.hasModelDocumentChanged `true` if the model document has changed during the
-	 * {@link module:engine/model/model~Model#change} or {@link module:engine/model/model~Model#enqueueChange} blocks.
 	 */
 
 	/**

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

@@ -608,6 +608,23 @@ export default class Selection {
 		return ( nodeAfterStart instanceof Element && nodeAfterStart == nodeBeforeEnd ) ? nodeAfterStart : null;
 	}
 
+	/**
+	 * Checks whether object is of given type following the convention set by
+	 * {@link module:engine/model/node~Node#is `Node#is()`}.
+	 *
+	 *		const selection = new Selection( ... );
+	 *
+	 *		selection.is( 'selection' ); // true
+	 *		selection.is( 'node' ); // false
+	 *		selection.is( 'element' ); // false
+	 *
+	 * @param {String} type
+	 * @returns {Boolean}
+	 */
+	is( type ) {
+		return type == 'selection';
+	}
+
 	/**
 	 * Gets elements of type "block" touched by the selection.
 	 *
@@ -809,10 +826,25 @@ function isUnvisitedBlockContainer( element, visited ) {
 }
 
 // Finds the lowest element in position's ancestors which is a block.
+// It will search until first ancestor that is a limit element.
 // Marks all ancestors as already visited to not include any of them later on.
 function getParentBlock( position, visited ) {
+	const schema = position.parent.document.model.schema;
+
 	const ancestors = position.parent.getAncestors( { parentFirst: true, includeSelf: true } );
-	const block = ancestors.find( element => isUnvisitedBlockContainer( element, visited ) );
+
+	let hasParentLimit = false;
+
+	const block = ancestors.find( element => {
+		// Stop searching after first parent node that is limit element.
+		if ( hasParentLimit ) {
+			return false;
+		}
+
+		hasParentLimit = schema.isLimit( element );
+
+		return !hasParentLimit && isUnvisitedBlockContainer( element, visited );
+	} );
 
 	// Mark all ancestors of this position's parent, because find() might've stopped early and
 	// the found block may be a child of another block.

+ 1 - 3
packages/ckeditor5-engine/src/model/writer.js

@@ -1317,13 +1317,11 @@ export default class Writer {
 			let isAffected = false;
 
 			if ( type == 'move' ) {
-				const intersecting =
+				isAffected =
 					positionOrRange.containsPosition( markerRange.start ) ||
 					positionOrRange.start.isEqual( markerRange.start ) ||
 					positionOrRange.containsPosition( markerRange.end ) ||
 					positionOrRange.end.isEqual( markerRange.end );
-
-				isAffected = intersecting && !positionOrRange.containsRange( markerRange );
 			} else {
 				// if type == 'merge'.
 				const elementBefore = positionOrRange.nodeBefore;

+ 3 - 0
packages/ckeditor5-engine/src/view/document.js

@@ -115,6 +115,9 @@ export default class Document {
 	 * As a parameter, a post-fixer callback receives a {@link module:engine/view/downcastwriter~DowncastWriter downcast writer}
 	 * instance connected with the executed changes block.
 	 *
+	 * Note that registering a post-fixer won't re-render the editor's view. If the view should change after registering the post-fixer then
+	 * it should be done manually calling `view.forceRender();`.
+	 *
 	 * @param {Function} postFixer
 	 */
 	registerPostFixer( postFixer ) {

+ 18 - 0
packages/ckeditor5-engine/src/view/documentselection.js

@@ -274,6 +274,24 @@ export default class DocumentSelection {
 		return this._selection.isSimilar( otherSelection );
 	}
 
+	/**
+	 * Checks whether object is of given type following the convention set by
+	 * {@link module:engine/view/node~Node#is `Node#is()`}.
+	 *
+	 *		const selection = new DocumentSelection( ... );
+	 *
+	 *		selection.is( 'selection' ); // true
+	 *		selection.is( 'documentSelection' ); // true
+	 *		selection.is( 'node' ); // false
+	 *		selection.is( 'element' ); // false
+	 *
+	 * @param {String} type
+	 * @returns {Boolean}
+	 */
+	is( type ) {
+		return type == 'selection' || type == 'documentSelection';
+	}
+
 	/**
 	 * Sets this selection's ranges and direction to the specified location based on the given
 	 * {@link module:engine/view/selection~Selectable selectable}.

+ 8 - 2
packages/ckeditor5-engine/src/view/element.js

@@ -147,9 +147,15 @@ export default class Element extends Node {
 	}
 
 	/**
-	 * Checks whether given view tree object is of given type.
+	 * Checks whether this view object is of the given type.
 	 *
-	 * Read more in {@link module:engine/view/node~Node#is}.
+	 *		obj.is( 'element' ); // true
+	 *		obj.is( 'li' ); // true
+	 *		obj.is( 'element', 'li' ); // true
+	 *		obj.is( 'text' ); // false
+	 *		obj.is( 'element', 'img' ); // false
+	 *
+	 * Read more in {@link module:engine/view/node~Node#is `Node#is()`}.
 	 *
 	 * @param {String} type
 	 * @param {String} [name] Element name.

+ 1 - 2
packages/ckeditor5-engine/src/view/node.js

@@ -287,7 +287,7 @@ export default class Node {
 	}
 
 	/**
-	 * Checks whether given view tree object is of given type.
+	 * Checks whether this view object is of the given type.
 	 *
 	 * This method is useful when processing view tree objects that are of unknown type. For example, a function
 	 * may return {@link module:engine/view/documentfragment~DocumentFragment} or {@link module:engine/view/node~Node}
@@ -300,7 +300,6 @@ export default class Node {
 	 *		obj.is( 'p' ); // shortcut for obj.is( 'element', 'p' )
 	 *		obj.is( 'text' ); // true for text node, false for element and document fragment
 	 *
-	 * @method #is
 	 * @param {'element'|'containerElement'|'attributeElement'|'emptyElement'|'uiElement'|
 	 * 'rootElement'|'documentFragment'|'text'|'textProxy'} type
 	 * @returns {Boolean}

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

@@ -37,7 +37,7 @@ export default class FocusObserver extends DomEventObserver {
 			// overwrite new DOM selection with selection from the view.
 			// See https://github.com/ckeditor/ckeditor5-engine/issues/795 for more details.
 			// Long timeout is needed to solve #676 and https://github.com/ckeditor/ckeditor5-engine/issues/1157 issues.
-			this._renderTimeoutId = setTimeout( () => view.render(), 50 );
+			this._renderTimeoutId = setTimeout( () => view.forceRender(), 50 );
 		} );
 
 		document.on( 'blur', ( evt, data ) => {
@@ -47,7 +47,7 @@ export default class FocusObserver extends DomEventObserver {
 				document.isFocused = false;
 
 				// Re-render the document to update view elements.
-				view.render();
+				view.forceRender();
 			}
 		} );
 

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

@@ -55,7 +55,7 @@ export default class MutationObserver extends Observer {
 		this.domConverter = view.domConverter;
 
 		/**
-		 * Reference to the {@link module:engine/view/view~View#renderer}.
+		 * Reference to the {@link module:engine/view/view~View#_renderer}.
 		 *
 		 * @member {module:engine/view/renderer~Renderer}
 		 */
@@ -248,7 +248,7 @@ export default class MutationObserver extends Observer {
 
 		// If nothing changes on `mutations` event, at this point we have "dirty DOM" (changed) and de-synched
 		// view (which has not been changed). In order to "reset DOM" we render the view again.
-		this.view.render();
+		this.view.forceRender();
 
 		function sameNodes( child1, child2 ) {
 			// First level of comparison (array of children vs array of children) – use the Lodash's default behavior.

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

@@ -52,7 +52,7 @@ export default class Observer {
 
 	/**
 	 * Enables the observer. This method is called when the observer is registered to the
-	 * {@link module:engine/view/view~View} and after {@link module:engine/view/view~View#render rendering}
+	 * {@link module:engine/view/view~View} and after {@link module:engine/view/view~View#forceRender rendering}
 	 * (all observers are {@link #disable disabled} before rendering).
 	 *
 	 * A typical use case for disabling observers is that mutation observers need to be disabled for the rendering.
@@ -66,7 +66,7 @@ export default class Observer {
 
 	/**
 	 * Disables the observer. This method is called before
-	 * {@link module:engine/view/view~View#render rendering} to prevent firing events during rendering.
+	 * {@link module:engine/view/view~View#forceRender rendering} to prevent firing events during rendering.
 	 *
 	 * @see module:engine/view/observer/observer~Observer#enable
 	 */

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

@@ -166,7 +166,7 @@ export default class SelectionObserver extends Observer {
 		if ( this.selection.isSimilar( newViewSelection ) ) {
 			// If selection was equal and we are at this point of algorithm, it means that it was incorrect.
 			// Just re-render it, no need to fire any events, etc.
-			this.view.render();
+			this.view.forceRender();
 		} else {
 			const data = {
 				oldSelection: this.selection,

+ 191 - 84
packages/ckeditor5-engine/src/view/placeholder.js

@@ -13,135 +13,242 @@ import '../../theme/placeholder.css';
 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.
+ * A helper that enables a placeholder on the provided view element (also updates its visibility).
+ * The placeholder is a CSS pseudo–element (with a text content) attached to the element.
  *
- * @param {module:engine/view/view~View} view View controller.
- * @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.
+ * To change the placeholder text, simply call this method again with new options.
+ *
+ * To disable the placeholder, use {@link module:engine/view/placeholder~disablePlaceholder `disablePlaceholder()`} helper.
+ *
+ * @param {Object} [options] Configuration options of the placeholder.
+ * @param {module:engine/view/view~View} options.view Editing view instance.
+ * @param {module:engine/view/element~Element} options.element Element that will gain a placeholder.
+ * See `options.isDirectHost` to learn more.
+ * @param {String} options.text Placeholder text.
+ * @param {Boolean} [options.isDirectHost=true] If set `false`, the placeholder will not be enabled directly
+ * in the passed `element` but in one of its children (selected automatically, i.e. a first empty child element).
+ * Useful when attaching placeholders to elements that can host other elements (not just text), for instance,
+ * editable root elements.
  */
-export function attachPlaceholder( view, element, placeholderText, checkFunction ) {
-	const document = view.document;
+export function enablePlaceholder( options ) {
+	const { view, element, text, isDirectHost = true } = options;
+	const doc = view.document;
 
-	// Single listener per document.
-	if ( !documentPlaceholders.has( document ) ) {
-		documentPlaceholders.set( document, new Map() );
+	// Use a single a single post fixer per—document to update all placeholders.
+	if ( !documentPlaceholders.has( doc ) ) {
+		documentPlaceholders.set( doc, new Map() );
 
-		// Create view post-fixer that will add placeholder where needed.
-		document.registerPostFixer( writer => updateAllPlaceholders( document, writer ) );
+		// If a post-fixer callback makes a change, it should return `true` so other post–fixers
+		// can re–evaluate the document again.
+		doc.registerPostFixer( writer => updateDocumentPlaceholders( doc, writer ) );
 	}
 
-	// Store information about element with placeholder.
-	documentPlaceholders.get( document ).set( element, {
-		placeholderText,
-		checkFunction
+	// Store information about the element placeholder under its document.
+	documentPlaceholders.get( doc ).set( element, {
+		text,
+		isDirectHost
 	} );
 
-	// Update view right away.
-	view.render();
+	// Update the placeholders right away.
+	view.change( writer => updateDocumentPlaceholders( doc, writer ) );
 }
 
 /**
- * Removes placeholder functionality from given element.
+ * Disables the placeholder functionality from a given element.
+ *
+ * See {@link module:engine/view/placeholder~enablePlaceholder `enablePlaceholder()`} to learn more.
  *
  * @param {module:engine/view/view~View} view
  * @param {module:engine/view/element~Element} element
  */
-export function detachPlaceholder( view, element ) {
+export function disablePlaceholder( view, element ) {
 	const doc = element.document;
 
 	view.change( writer => {
-		if ( documentPlaceholders.has( doc ) ) {
-			documentPlaceholders.get( doc ).delete( element );
+		if ( !documentPlaceholders.has( doc ) ) {
+			return;
 		}
 
-		writer.removeClass( 'ck-placeholder', element );
-		writer.removeAttribute( 'data-placeholder', element );
+		const placeholders = documentPlaceholders.get( doc );
+		const config = placeholders.get( element );
+
+		writer.removeAttribute( 'data-placeholder', config.hostElement );
+		hidePlaceholder( writer, config.hostElement );
+
+		placeholders.delete( element );
 	} );
 }
 
-// Updates all placeholders of given document.
+/**
+ * Shows a placeholder in the provided element by changing related attributes and CSS classes.
+ *
+ * **Note**: This helper will not update the placeholder visibility nor manage the
+ * it in any way in the future. What it does is a one–time state change of an element. Use
+ * {@link module:engine/view/placeholder~enablePlaceholder `enablePlaceholder()`} and
+ * {@link module:engine/view/placeholder~disablePlaceholder `disablePlaceholder()`} for full
+ * placeholder functionality.
+ *
+ * **Note**: This helper will blindly show the placeholder directly in the root editable element if
+ * one is passed, which could result in a visual clash if the editable element has some children
+ * (for instance, an empty paragraph). Use {@link module:engine/view/placeholder~enablePlaceholder `enablePlaceholder()`}
+ * in that case or make sure the correct element is passed to the helper.
+ *
+ * @param {module:engine/view/downcastwriter~DowncastWriter} writer
+ * @param {module:engine/view/element~Element} element
+ * @returns {Boolean} `true`, if any changes were made to the `element`.
+ */
+export function showPlaceholder( writer, element ) {
+	if ( !element.hasClass( 'ck-placeholder' ) ) {
+		writer.addClass( 'ck-placeholder', element );
+
+		return true;
+	}
+
+	return false;
+}
+
+/**
+ * Hides a placeholder in the element by changing related attributes and CSS classes.
+ *
+ * **Note**: This helper will not update the placeholder visibility nor manage the
+ * it in any way in the future. What it does is a one–time state change of an element. Use
+ * {@link module:engine/view/placeholder~enablePlaceholder `enablePlaceholder()`} and
+ * {@link module:engine/view/placeholder~disablePlaceholder `disablePlaceholder()`} for full
+ * placeholder functionality.
+ *
+ * @param {module:engine/view/downcastwriter~DowncastWriter} writer
+ * @param {module:engine/view/element~Element} element
+ * @returns {Boolean} `true`, if any changes were made to the `element`.
+ */
+export function hidePlaceholder( writer, element ) {
+	if ( element.hasClass( 'ck-placeholder' ) ) {
+		writer.removeClass( 'ck-placeholder', element );
+
+		return true;
+	}
+
+	return false;
+}
+
+/**
+ * Checks if a placeholder should be displayed in the element.
+ *
+ * **Note**: This helper will blindly check the possibility of showing a placeholder directly in the
+ * root editable element if one is passed, which may not be the expected result. If an element can
+ * host other elements (not just text), most likely one of its children should be checked instead
+ * because it will be the final host for the placeholder. Use
+ * {@link module:engine/view/placeholder~enablePlaceholder `enablePlaceholder()`} in that case or make
+ * sure the correct element is passed to the helper.
+ *
+ * @param {module:engine/view/downcastwriter~DowncastWriter} writer
+ * @param {module:engine/view/element~Element} element
+ * @param {String} text
+ * @returns {Boolean}
+ */
+export function needsPlaceholder( element ) {
+	const doc = element.document;
+
+	// The element was removed from document.
+	if ( !doc ) {
+		return false;
+	}
+
+	// The element is empty only as long as it contains nothing but uiElements.
+	const isEmptyish = !Array.from( element.getChildren() )
+		.some( element => !element.is( 'uiElement' ) );
+
+	// If the element is empty and the document is blurred.
+	if ( !doc.isFocused && isEmptyish ) {
+		return true;
+	}
+
+	const viewSelection = doc.selection;
+	const selectionAnchor = viewSelection.anchor;
+
+	// If document is focused and the element is empty but the selection is not anchored inside it.
+	if ( isEmptyish && selectionAnchor && selectionAnchor.parent !== element ) {
+		return true;
+	}
+
+	return false;
+}
+
+// Updates all placeholders associated with a document in a post–fixer callback.
 //
 // @private
-// @param {module:engine/view/document~Document} view
+// @param { module:engine/model/document~Document} doc
 // @param {module:engine/view/downcastwriter~DowncastWriter} writer
-function updateAllPlaceholders( document, writer ) {
-	const placeholders = documentPlaceholders.get( document );
-	let changed = false;
-
-	for ( const [ element, info ] of placeholders ) {
-		if ( updateSinglePlaceholder( writer, element, info ) ) {
-			changed = true;
+// @returns {Boolean} True if any changes were made to the view document.
+function updateDocumentPlaceholders( doc, writer ) {
+	const placeholders = documentPlaceholders.get( doc );
+	let wasViewModified = false;
+
+	for ( const [ element, config ] of placeholders ) {
+		if ( updatePlaceholder( writer, element, config ) ) {
+			wasViewModified = true;
 		}
 	}
 
-	return changed;
+	return wasViewModified;
 }
 
-// Updates placeholder class of given element.
+// Updates a single placeholder in a post–fixer callback.
 //
 // @private
 // @param {module:engine/view/downcastwriter~DowncastWriter} writer
 // @param {module:engine/view/element~Element} element
-// @param {Object} info
-function updateSinglePlaceholder( writer, element, info ) {
-	const document = element.document;
-	const text = info.placeholderText;
-	let changed = false;
-
-	// Element was removed from document.
-	if ( !document ) {
+// @param {Object} config Configuration of the placeholder
+// @param {String} config.text
+// @param {Boolean} config.isDirectHost
+// @returns {Boolean} True if any changes were made to the view document.
+function updatePlaceholder( writer, element, config ) {
+	const { text, isDirectHost } = config;
+	const hostElement = isDirectHost ? element : getChildPlaceholderHostSubstitute( element );
+	let wasViewModified = false;
+
+	// When not a direct host, it could happen that there is no child element
+	// capable of displaying a placeholder.
+	if ( !hostElement ) {
 		return false;
 	}
 
-	// Update data attribute if needed.
-	if ( element.getAttribute( 'data-placeholder' ) !== text ) {
-		writer.setAttribute( 'data-placeholder', text, element );
-		changed = true;
-	}
-
-	const viewSelection = document.selection;
-	const anchor = viewSelection.anchor;
-	const checkFunction = info.checkFunction;
-
-	// If checkFunction is provided and returns false - remove placeholder.
-	if ( checkFunction && !checkFunction() ) {
-		if ( element.hasClass( 'ck-placeholder' ) ) {
-			writer.removeClass( 'ck-placeholder', element );
-			changed = true;
-		}
+	// Cache the host element. It will be necessary for disablePlaceholder() to know
+	// which element should have class and attribute removed because, depending on
+	// the config.isDirectHost value, it could be the element or one of its descendants.
+	config.hostElement = hostElement;
 
-		return changed;
+	// This may be necessary when updating the placeholder text to something else.
+	if ( hostElement.getAttribute( 'data-placeholder' ) !== text ) {
+		writer.setAttribute( 'data-placeholder', text, hostElement );
+		wasViewModified = true;
 	}
 
-	// Element is empty for placeholder purposes when it has no children or only ui elements.
-	// This check is taken from `view.ContainerElement#getFillerOffset`.
-	const isEmptyish = !Array.from( element.getChildren() ).some( element => !element.is( 'uiElement' ) );
-
-	// If element is empty and editor is blurred.
-	if ( !document.isFocused && isEmptyish ) {
-		if ( !element.hasClass( 'ck-placeholder' ) ) {
-			writer.addClass( 'ck-placeholder', element );
-			changed = true;
+	if ( needsPlaceholder( hostElement ) ) {
+		if ( showPlaceholder( writer, hostElement ) ) {
+			wasViewModified = true;
 		}
-
-		return changed;
+	} else if ( hidePlaceholder( writer, hostElement ) ) {
+		wasViewModified = true;
 	}
 
-	// It there are no child elements and selection is not placed inside element.
-	if ( isEmptyish && anchor && anchor.parent !== element ) {
-		if ( !element.hasClass( 'ck-placeholder' ) ) {
-			writer.addClass( 'ck-placeholder', element );
-			changed = true;
-		}
-	} else {
-		if ( element.hasClass( 'ck-placeholder' ) ) {
-			writer.removeClass( 'ck-placeholder', element );
-			changed = true;
+	return wasViewModified;
+}
+
+// Gets a child element capable of displaying a placeholder if a parent element can host more
+// than just text (for instance, when it is a root editable element). The child element
+// can then be used in other placeholder helpers as a substitute of its parent.
+//
+// @private
+// @param {module:engine/view/element~Element} parent
+// @returns {module:engine/view/element~Element|null}
+function getChildPlaceholderHostSubstitute( parent ) {
+	if ( parent.childCount === 1 ) {
+		const firstChild = parent.getChild( 0 );
+
+		if ( firstChild.is( 'element' ) && !firstChild.is( 'uiElement' ) ) {
+			return firstChild;
 		}
 	}
 
-	return changed;
+	return null;
 }

+ 17 - 0
packages/ckeditor5-engine/src/view/selection.js

@@ -577,6 +577,23 @@ export default class Selection {
 		this.fire( 'change' );
 	}
 
+	/**
+	 * Checks whether object is of given type following the convention set by
+	 * {@link module:engine/view/node~Node#is `Node#is()`}.
+	 *
+	 *		const selection = new Selection( ... );
+	 *
+	 *		selection.is( 'selection' ); // true
+	 *		selection.is( 'node' ); // false
+	 *		selection.is( 'element' ); // false
+	 *
+	 * @param {String} type
+	 * @returns {Boolean}
+	 */
+	is( type ) {
+		return type == 'selection';
+	}
+
 	/**
 	 * Replaces all ranges that were added to the selection with given array of ranges. Last range of the array
 	 * is treated like the last added range and is used to set {@link #anchor anchor} and {@link #focus focus}.

+ 145 - 32
packages/ckeditor5-engine/src/view/view.js

@@ -66,42 +66,53 @@ export default class View {
 		 * Instance of the {@link module:engine/view/document~Document} associated with this view controller.
 		 *
 		 * @readonly
-		 * @member {module:engine/view/document~Document} module:engine/view/view~View#document
+		 * @type {module:engine/view/document~Document}
 		 */
 		this.document = new Document();
 
 		/**
-		 * Instance of the {@link module:engine/view/domconverter~DomConverter domConverter} use by
-		 * {@link module:engine/view/view~View#renderer renderer}
+		 * Instance of the {@link module:engine/view/domconverter~DomConverter domConverter} used by
+		 * {@link module:engine/view/view~View#_renderer renderer}
 		 * and {@link module:engine/view/observer/observer~Observer observers}.
 		 *
 		 * @readonly
-		 * @member {module:engine/view/domconverter~DomConverter} module:engine/view/view~View#domConverter
+		 * @type {module:engine/view/domconverter~DomConverter}
 		 */
 		this.domConverter = new DomConverter();
 
+		/**
+		 * Roots of the DOM tree. Map on the `HTMLElement`s with roots names as keys.
+		 *
+		 * @readonly
+		 * @type {Map.<String, HTMLElement>}
+		 */
+		this.domRoots = new Map();
+
 		/**
 		 * Instance of the {@link module:engine/view/renderer~Renderer renderer}.
 		 *
 		 * @protected
-		 * @member {module:engine/view/renderer~Renderer} module:engine/view/view~View#renderer
+		 * @type {module:engine/view/renderer~Renderer}
 		 */
 		this._renderer = new Renderer( this.domConverter, this.document.selection );
 		this._renderer.bind( 'isFocused' ).to( this.document );
 
 		/**
-		 * Roots of the DOM tree. Map on the `HTMLElement`s with roots names as keys.
+		 * A DOM root attributes cache. It saves the initial values of DOM root attributes before the DOM element
+		 * is {@link module:engine/view/view~View#attachDomRoot attached} to the view so later on, when
+		 * the view is destroyed ({@link module:engine/view/view~View#detachDomRoot}), they can be easily restored.
+		 * This way, the DOM element can go back to the (clean) state as if the editing view never used it.
 		 *
-		 * @readonly
-		 * @member {Map} module:engine/view/view~View#domRoots
+		 * @private
+		 * @member {WeakMap.<HTMLElement,Object>}
 		 */
-		this.domRoots = new Map();
+		this._initialDomRootAttributes = new WeakMap();
 
 		/**
 		 * Map of registered {@link module:engine/view/observer/observer~Observer observers}.
 		 *
 		 * @private
-		 * @member {Map.<Function, module:engine/view/observer/observer~Observer>} module:engine/view/view~View#_observers
+		 * @type {Map.<Function, module:engine/view/observer/observer~Observer>}
 		 */
 		this._observers = new Map();
 
@@ -109,39 +120,48 @@ export default class View {
 		 * Is set to `true` when {@link #change view changes} are currently in progress.
 		 *
 		 * @private
-		 * @member {Boolean} module:engine/view/view~View#_ongoingChange
+		 * @type {Boolean}
 		 */
 		this._ongoingChange = false;
 
 		/**
-		 * Used to prevent calling {@link #render} and {@link #change} during rendering view to the DOM.
+		 * Used to prevent calling {@link #forceRender} and {@link #change} during rendering view to the DOM.
 		 *
 		 * @private
-		 * @member {Boolean} module:engine/view/view~View#_renderingInProgress
+		 * @type {Boolean}
 		 */
 		this._renderingInProgress = false;
 
 		/**
-		 * Used to prevent calling {@link #render} and {@link #change} during rendering view to the DOM.
+		 * Used to prevent calling {@link #forceRender} and {@link #change} during rendering view to the DOM.
 		 *
 		 * @private
-		 * @member {Boolean} module:engine/view/view~View#_renderingInProgress
+		 * @type {Boolean}
 		 */
 		this._postFixersInProgress = false;
 
 		/**
-		 * Internal flag to temporary disable rendering. See usage in the editing controller.
+		 * Internal flag to temporary disable rendering. See the usage in the {@link #_disableRendering}.
 		 *
-		 * @protected
-		 * @member {Boolean} module:engine/view/view~View#_renderingDisabled
+		 * @private
+		 * @type {Boolean}
 		 */
 		this._renderingDisabled = false;
 
 		/**
-		 * DowncastWriter instance used in {@link #change change method) callbacks.
+		 * Internal flag that disables rendering when there are no changes since the last rendering.
+		 * It stores information about changed selection and changed elements from attached document roots.
 		 *
 		 * @private
-		 * @member {module:engine/view/downcastwriter~DowncastWriter} module:engine/view/view~View#_writer
+		 * @type {Boolean}
+		 */
+		this._hasChangedSinceTheLastRendering = false;
+
+		/**
+		 * DowncastWriter instance used in {@link #change change method} callbacks.
+		 *
+		 * @private
+		 * @type {module:engine/view/downcastwriter~DowncastWriter}
 		 */
 		this._writer = new DowncastWriter( this.document );
 
@@ -163,17 +183,27 @@ export default class View {
 
 			// Informs that layout has changed after render.
 			this.document.fire( 'layoutChanged' );
+
+			// Reset the `_hasChangedSinceTheLastRendering` flag after rendering.
+			this._hasChangedSinceTheLastRendering = false;
+		} );
+
+		// Listen to the document selection changes directly.
+		this.listenTo( this.document.selection, 'change', () => {
+			this._hasChangedSinceTheLastRendering = true;
 		} );
 	}
 
 	/**
-	 * Attaches DOM root element to the view element and enable all observers on that element.
-	 * Also {@link module:engine/view/renderer~Renderer#markToSync mark element} to be synchronized with the view
-	 * what means that all child nodes will be removed and replaced with content of the view root.
+	 * Attaches a DOM root element to the view element and enable all observers on that element.
+	 * Also {@link module:engine/view/renderer~Renderer#markToSync mark element} to be synchronized
+	 * with the view what means that all child nodes will be removed and replaced with content of the view root.
 	 *
 	 * This method also will change view element name as the same as tag name of given dom root.
 	 * Name is always transformed to lower case.
 	 *
+	 * **Note:** Use {@link #detachDomRoot `detachDomRoot()`} to revert this action.
+	 *
 	 * @param {Element} domRoot DOM root element.
 	 * @param {String} [name='main'] Name of the root.
 	 */
@@ -183,20 +213,81 @@ export default class View {
 		// Set view root name the same as DOM root tag name.
 		viewRoot._name = domRoot.tagName.toLowerCase();
 
+		const initialDomRootAttributes = {};
+
+		// 1. Copy and cache the attributes to remember the state of the element before attaching.
+		//    The cached attributes will be restored in detachDomRoot() so the element goes to the
+		//    clean state as if the editing view never used it.
+		// 2. Apply the attributes using the view writer, so they all go under the control of the engine.
+		//    The editing view takes over the attribute management completely because various
+		//    features (e.g. addPlaceholder()) require dynamic changes of those attributes and they
+		//    cannot be managed by the engine and the UI library at the same time.
+		for ( const { name, value } of Array.from( domRoot.attributes ) ) {
+			initialDomRootAttributes[ name ] = value;
+
+			// Do not use writer.setAttribute() for the class attribute. The EditableUIView class
+			// and its descendants could have already set some using the writer.addClass() on the view
+			// document root. They haven't been rendered yet so they are not present in the DOM root.
+			// Using writer.setAttribute( 'class', ... ) would override them completely.
+			if ( name === 'class' ) {
+				this._writer.addClass( value.split( ' ' ), viewRoot );
+			} else {
+				this._writer.setAttribute( name, value, viewRoot );
+			}
+		}
+
+		this._initialDomRootAttributes.set( domRoot, initialDomRootAttributes );
+
+		const updateContenteditableAttribute = () => {
+			this._writer.setAttribute( 'contenteditable', !viewRoot.isReadOnly, viewRoot );
+		};
+
+		// Set initial value.
+		updateContenteditableAttribute();
+
 		this.domRoots.set( name, domRoot );
 		this.domConverter.bindElements( domRoot, viewRoot );
 		this._renderer.markToSync( 'children', viewRoot );
+		this._renderer.markToSync( 'attributes', viewRoot );
 		this._renderer.domDocuments.add( domRoot.ownerDocument );
 
 		viewRoot.on( 'change:children', ( evt, node ) => this._renderer.markToSync( 'children', node ) );
 		viewRoot.on( 'change:attributes', ( evt, node ) => this._renderer.markToSync( 'attributes', node ) );
 		viewRoot.on( 'change:text', ( evt, node ) => this._renderer.markToSync( 'text', node ) );
+		viewRoot.on( 'change:isReadOnly', () => this.change( updateContenteditableAttribute ) );
+
+		viewRoot.on( 'change', () => {
+			this._hasChangedSinceTheLastRendering = true;
+		} );
 
 		for ( const observer of this._observers.values() ) {
 			observer.observe( domRoot, name );
 		}
 	}
 
+	/**
+	 * Detaches a DOM root element from the view element and restores its attributes to the state before
+	 * {@link #attachDomRoot `attachDomRoot()`}.
+	 *
+	 * @param {String} name Name of the root to detach.
+	 */
+	detachDomRoot( name ) {
+		const domRoot = this.domRoots.get( name );
+
+		// Remove all root attributes so the DOM element is "bare".
+		Array.from( domRoot.attributes ).forEach( ( { name } ) => domRoot.removeAttribute( name ) );
+
+		const initialDomRootAttributes = this._initialDomRootAttributes.get( domRoot );
+
+		// Revert all view root attributes back to the state before attachDomRoot was called.
+		for ( const attribute in initialDomRootAttributes ) {
+			domRoot.setAttribute( attribute, initialDomRootAttributes[ attribute ] );
+		}
+
+		this.domRoots.delete( name );
+		this.domConverter.unbindDomElement( domRoot );
+	}
+
 	/**
 	 * Gets DOM root element.
 	 *
@@ -293,7 +384,7 @@ export default class View {
 
 			if ( editable ) {
 				this.domConverter.focus( editable );
-				this.render();
+				this.forceRender();
 			} else {
 				/**
 				 * Before focusing view document, selection should be placed inside one of the view's editables.
@@ -309,9 +400,10 @@ export default class View {
 
 	/**
 	 * The `change()` method is the primary way of changing the view. You should use it to modify any node in the view tree.
-	 * It makes sure that after all changes are made the view is rendered to the DOM. It prevents situations when the DOM is updated
-	 * when the view state is not yet correct. It allows to nest calls one inside another and still performs a single rendering
-	 * after all those changes are made. It also returns the return value of its callback.
+	 * It makes sure that after all changes are made the view is rendered to the DOM (assuming that the view will be changed
+	 * inside the callback). It prevents situations when the DOM is updated when the view state is not yet correct. It allows
+	 * to nest calls one inside another and still performs a single rendering after all those changes are made.
+	 * It also returns the return value of its callback.
 	 *
 	 *		const text = view.change( writer => {
 	 *			const newText = writer.createText( 'foo' );
@@ -342,8 +434,8 @@ export default class View {
 			 * cause some unexpected behaviour and inconsistency between the DOM and the view.
 			 * This may be caused by:
 			 *
-			 * * calling {@link #change} or {@link #render} during rendering process,
-			 * * calling {@link #change} or {@link #render} inside of
+			 * * calling {@link #change} or {@link #forceRender} during rendering process,
+			 * * calling {@link #change} or {@link #forceRender} inside of
 			 *   {@link module:engine/view/document~Document#registerPostFixer post-fixer function}.
 			 *
 			 * @error cannot-change-view-tree
@@ -368,7 +460,8 @@ export default class View {
 
 		// This lock is used by editing controller to render changes from outer most model.change() once. As plugins might call
 		// view.change() inside model.change() block - this will ensures that postfixers and rendering are called once after all changes.
-		if ( !this._renderingDisabled ) {
+		// Also, we don't need to render anything if there're no changes since last rendering.
+		if ( !this._renderingDisabled && this._hasChangedSinceTheLastRendering ) {
 			this._postFixersInProgress = true;
 			this.document._callPostFixers( this._writer );
 			this._postFixersInProgress = false;
@@ -380,13 +473,17 @@ export default class View {
 	}
 
 	/**
-	 * Renders {@link module:engine/view/document~Document view document} to DOM. If any view changes are
+	 * Forces rendering {@link module:engine/view/document~Document view document} to DOM. If any view changes are
 	 * currently in progress, rendering will start after all {@link #change change blocks} are processed.
 	 *
+	 * Note that this method is dedicated for special cases. All view changes should be wrapped in the {@link #change}
+	 * block and the view will automatically check whether it needs to render DOM or not.
+	 *
 	 * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `applying-view-changes-on-rendering` when
 	 * trying to re-render when rendering to DOM has already started.
 	 */
-	render() {
+	forceRender() {
+		this._hasChangedSinceTheLastRendering = true;
 		this.change( () => {} );
 	}
 
@@ -542,6 +639,22 @@ export default class View {
 		return new Selection( selectable, placeOrOffset, options );
 	}
 
+	/**
+	 * Disables or enables rendering. If the flag is set to `true` then the rendering will be disabled.
+	 * If the flag is set to `false` and if there was some change in the meantime, then the rendering action will be performed.
+	 *
+	 * @protected
+	 * @param {Boolean} flag A flag indicates whether the rendering should be disabled.
+	 */
+	_disableRendering( flag ) {
+		this._renderingDisabled = flag;
+
+		if ( flag == false ) {
+			// Render when you stop blocking rendering.
+			this.change( () => {} );
+		}
+	}
+
 	/**
 	 * Renders all changes. In order to avoid triggering the observers (e.g. mutations) all observers are disabled
 	 * before rendering and re-enabled after that.

+ 2 - 2
packages/ckeditor5-engine/tests/controller/datacontroller.js

@@ -39,8 +39,8 @@ describe( 'DataController', () => {
 
 		data = new DataController( model, htmlDataProcessor );
 
-		upcastHelpers = new UpcastHelpers( data.upcastDispatcher );
-		downcastHelpers = new DowncastHelpers( data.downcastDispatcher );
+		upcastHelpers = new UpcastHelpers( [ data.upcastDispatcher ] );
+		downcastHelpers = new DowncastHelpers( [ data.downcastDispatcher ] );
 	} );
 
 	describe( 'constructor()', () => {

+ 2 - 2
packages/ckeditor5-engine/tests/controller/editingcontroller.js

@@ -90,7 +90,7 @@ describe( 'EditingController', () => {
 			model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 			model.schema.register( 'div', { inheritAllFrom: '$block' } );
 
-			const downcastHelpers = new DowncastHelpers( editing.downcastDispatcher );
+			const downcastHelpers = new DowncastHelpers( [ editing.downcastDispatcher ] );
 
 			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
 			downcastHelpers.elementToElement( { model: 'div', view: 'div' } );
@@ -186,7 +186,7 @@ describe( 'EditingController', () => {
 			} );
 
 			editing.view.document.isFocused = true;
-			editing.view.render();
+			editing.view.forceRender();
 
 			const domSelection = document.getSelection();
 			domSelection.removeAllRanges();

+ 41 - 67
packages/ckeditor5-engine/tests/conversion/conversion.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md.
  */
 
-import Conversion, { ConversionHelpers } from '../../src/conversion/conversion';
+import Conversion from '../../src/conversion/conversion';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 import UpcastDispatcher from '../../src/conversion/upcastdispatcher';
@@ -17,35 +17,39 @@ import Model from '../../src/model/model';
 
 import { parse as viewParse, stringify as viewStringify } from '../../src/dev-utils/view';
 import { stringify as modelStringify } from '../../src/dev-utils/model';
+import ConversionHelpers from '../../src/conversion/conversionhelpers';
 
 describe( 'Conversion', () => {
-	let conversion, dispA, dispB, dispC;
+	let conversion, downcastDispA, upcastDispaA, downcastDispB;
 
 	beforeEach( () => {
-		conversion = new Conversion();
-
 		// Placeholders. Will be used only to see if their were given as attribute for a spy function.
-		dispA = Symbol( 'dispA' );
-		dispB = Symbol( 'dispB' );
-		dispC = Symbol( 'dispC' );
-
-		conversion.register( 'ab', new UpcastHelpers( [ dispA, dispB ] ) );
-		conversion.register( 'a', new UpcastHelpers( dispA ) );
-		conversion.register( 'b', new UpcastHelpers( dispB ) );
-		conversion.register( 'c', new DowncastHelpers( dispC ) );
+		downcastDispA = Symbol( 'downA' );
+		downcastDispB = Symbol( 'downB' );
+
+		upcastDispaA = Symbol( 'upA' );
+
+		conversion = new Conversion( [ downcastDispA, downcastDispB ], upcastDispaA );
 	} );
 
-	describe( 'register()', () => {
+	describe( 'addAlias()', () => {
 		it( 'should throw when trying to use same group name twice', () => {
 			expect( () => {
-				conversion.register( 'ab' );
-			} ).to.throw( CKEditorError, /conversion-register-group-exists/ );
+				conversion.addAlias( 'upcast', upcastDispaA );
+			} ).to.throw( CKEditorError, /conversion-group-exists/ );
+		} );
+
+		it( 'should throw when trying to add not registered dispatcher', () => {
+			expect( () => {
+				conversion.addAlias( 'foo', {} );
+			} ).to.throw( CKEditorError, /conversion-add-alias-dispatcher-not-registered/ );
 		} );
 	} );
 
 	describe( 'for()', () => {
 		it( 'should return ConversionHelpers', () => {
-			expect( conversion.for( 'ab' ) ).to.be.instanceof( ConversionHelpers );
+			expect( conversion.for( 'upcast' ) ).to.be.instanceof( ConversionHelpers );
+			expect( conversion.for( 'downcast' ) ).to.be.instanceof( ConversionHelpers );
 		} );
 
 		it( 'should throw if non-existing group name has been used', () => {
@@ -55,10 +59,15 @@ describe( 'Conversion', () => {
 		} );
 
 		it( 'should return proper helpers for group', () => {
-			expect( conversion.for( 'ab' ) ).to.be.instanceof( UpcastHelpers );
-			expect( conversion.for( 'a' ) ).to.be.instanceof( UpcastHelpers );
-			expect( conversion.for( 'b' ) ).to.be.instanceof( UpcastHelpers );
-			expect( conversion.for( 'c' ) ).to.be.instanceof( DowncastHelpers );
+			expect( conversion.for( 'upcast' ) ).to.be.instanceof( UpcastHelpers );
+
+			conversion.addAlias( 'foo', upcastDispaA );
+			expect( conversion.for( 'foo' ) ).to.be.instanceof( UpcastHelpers );
+
+			expect( conversion.for( 'downcast' ) ).to.be.instanceof( DowncastHelpers );
+
+			conversion.addAlias( 'bar', downcastDispB );
+			expect( conversion.for( 'bar' ) ).to.be.instanceof( DowncastHelpers );
 		} );
 	} );
 
@@ -71,22 +80,24 @@ describe( 'Conversion', () => {
 		} );
 
 		it( 'should be chainable', () => {
-			const forResult = conversion.for( 'ab' );
-			const addResult = forResult.add( () => {} );
+			const helpers = conversion.for( 'upcast' );
+			const addResult = helpers.add( () => {} );
 
-			expect( addResult ).to.equal( addResult.add( () => {} ) );
+			expect( addResult ).to.equal( helpers );
 		} );
 
 		it( 'should fire given helper for every dispatcher in given group', () => {
-			conversion.for( 'ab' ).add( helperA );
+			conversion.for( 'downcast' ).add( helperA );
 
-			expect( helperA.calledWithExactly( dispA ) ).to.be.true;
-			expect( helperA.calledWithExactly( dispB ) ).to.be.true;
+			expect( helperA.calledWithExactly( downcastDispA ) ).to.be.true;
+			expect( helperA.calledWithExactly( downcastDispB ) ).to.be.true;
+			expect( helperA.calledWithExactly( upcastDispaA ) ).to.be.false;
 
-			conversion.for( 'b' ).add( helperB );
+			conversion.for( 'upcast' ).add( helperB );
 
-			expect( helperB.calledWithExactly( dispA ) ).to.be.false;
-			expect( helperB.calledWithExactly( dispB ) ).to.be.true;
+			expect( helperB.calledWithExactly( downcastDispA ) ).to.be.false;
+			expect( helperB.calledWithExactly( downcastDispB ) ).to.be.false;
+			expect( helperB.calledWithExactly( upcastDispaA ) ).to.be.true;
 		} );
 	} );
 
@@ -121,9 +132,7 @@ describe( 'Conversion', () => {
 			viewDispatcher.on( 'element', convertToModelFragment(), { priority: 'lowest' } );
 			viewDispatcher.on( 'documentFragment', convertToModelFragment(), { priority: 'lowest' } );
 
-			conversion = new Conversion();
-			conversion.register( 'upcast', new UpcastHelpers( [ viewDispatcher ] ) );
-			conversion.register( 'downcast', new DowncastHelpers( controller.downcastDispatcher ) );
+			conversion = new Conversion( controller.downcastDispatcher, viewDispatcher );
 		} );
 
 		describe( 'elementToElement', () => {
@@ -696,38 +705,3 @@ describe( 'Conversion', () => {
 		}
 	} );
 } );
-
-describe( 'ConversionHelpers', () => {
-	describe( 'add()', () => {
-		const dispA = Symbol( 'dispA' );
-		const dispB = Symbol( 'dispB' );
-
-		it( 'should call a helper for one defined dispatcher', () => {
-			const spy = sinon.spy();
-			const helpers = new ConversionHelpers( dispA );
-
-			helpers.add( spy );
-
-			sinon.assert.calledOnce( spy );
-			sinon.assert.calledWithExactly( spy, dispA );
-		} );
-
-		it( 'should call helper for all defined dispatcherers', () => {
-			const spy = sinon.spy();
-			const helpers = new ConversionHelpers( [ dispA, dispB ] );
-
-			helpers.add( spy );
-
-			sinon.assert.calledTwice( spy );
-			sinon.assert.calledWithExactly( spy, dispA );
-			sinon.assert.calledWithExactly( spy, dispB );
-		} );
-
-		it( 'should be chainable', () => {
-			const spy = sinon.spy();
-			const helpers = new ConversionHelpers( dispA );
-
-			expect( helpers.add( spy ) ).to.equal( helpers );
-		} );
-	} );
-} );

+ 41 - 0
packages/ckeditor5-engine/tests/conversion/conversionhelpers.js

@@ -0,0 +1,41 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ConversionHelpers from '../../src/conversion/conversionhelpers';
+
+describe( 'ConversionHelpers', () => {
+	describe( 'add()', () => {
+		const dispA = Symbol( 'dispA' );
+		const dispB = Symbol( 'dispB' );
+
+		it( 'should call a helper for one defined dispatcher', () => {
+			const spy = sinon.spy();
+			const helpers = new ConversionHelpers( [ dispA ] );
+
+			helpers.add( spy );
+
+			sinon.assert.calledOnce( spy );
+			sinon.assert.calledWithExactly( spy, dispA );
+		} );
+
+		it( 'should call helper for all defined dispatcherers', () => {
+			const spy = sinon.spy();
+			const helpers = new ConversionHelpers( [ dispA, dispB ] );
+
+			helpers.add( spy );
+
+			sinon.assert.calledTwice( spy );
+			sinon.assert.calledWithExactly( spy, dispA );
+			sinon.assert.calledWithExactly( spy, dispB );
+		} );
+
+		it( 'should be chainable', () => {
+			const spy = sinon.spy();
+			const helpers = new ConversionHelpers( [ dispA ] );
+
+			expect( helpers.add( spy ) ).to.equal( helpers );
+		} );
+	} );
+} );

+ 6 - 11
packages/ckeditor5-engine/tests/conversion/downcasthelpers.js

@@ -5,8 +5,6 @@
 
 import EditingController from '../../src/controller/editingcontroller';
 
-import Conversion from '../../src/conversion/conversion';
-
 import Model from '../../src/model/model';
 import ModelElement from '../../src/model/element';
 import ModelText from '../../src/model/text';
@@ -36,7 +34,7 @@ import createViewRoot from '../view/_utils/createroot';
 import { setData as setModelData } from '../../src/dev-utils/model';
 
 describe( 'DowncastHelpers', () => {
-	let conversion, model, modelRoot, viewRoot, downcastHelpers, controller;
+	let model, modelRoot, viewRoot, downcastHelpers, controller;
 
 	let modelRootStart;
 
@@ -53,10 +51,7 @@ describe( 'DowncastHelpers', () => {
 
 		viewRoot = controller.view.document.getRoot();
 
-		downcastHelpers = new DowncastHelpers( controller.downcastDispatcher );
-
-		conversion = new Conversion();
-		conversion.register( 'downcast', downcastHelpers );
+		downcastHelpers = new DowncastHelpers( [ controller.downcastDispatcher ] );
 
 		modelRootStart = model.createPositionAt( modelRoot, 0 );
 	} );
@@ -1474,7 +1469,7 @@ describe( 'downcast converters', () => {
 		controller.view.document.getRoot()._name = 'div';
 
 		dispatcher = controller.downcastDispatcher;
-		const downcastHelpers = new DowncastHelpers( dispatcher );
+		const downcastHelpers = new DowncastHelpers( [ dispatcher ] );
 		downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
 
 		modelRootStart = model.createPositionAt( modelRoot, 0 );
@@ -1630,7 +1625,7 @@ describe( 'downcast converters', () => {
 			const modelP = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) );
 			const modelWidget = new ModelElement( 'widget', null, modelP );
 
-			const downcastHelpers = new DowncastHelpers( dispatcher );
+			const downcastHelpers = new DowncastHelpers( [ dispatcher ] );
 			downcastHelpers.elementToElement( { model: 'widget', view: 'widget' } );
 
 			model.change( writer => {
@@ -1806,7 +1801,7 @@ describe( 'downcast selection converters', () => {
 
 		dispatcher.on( 'insert:$text', insertText() );
 
-		downcastHelpers = new DowncastHelpers( dispatcher );
+		downcastHelpers = new DowncastHelpers( [ dispatcher ] );
 		downcastHelpers.attributeToElement( { model: 'bold', view: 'strong' } );
 		downcastHelpers.markerToHighlight( { model: 'marker', view: { classes: 'marker' }, converterPriority: 1 } );
 
@@ -2253,7 +2248,7 @@ describe( 'downcast selection converters', () => {
 			model.schema.extend( 'td', { allowIn: 'tr' } );
 			model.schema.extend( '$text', { allowIn: 'td' } );
 
-			const downcastHelpers = new DowncastHelpers( dispatcher );
+			const downcastHelpers = new DowncastHelpers( [ dispatcher ] );
 
 			// "Universal" converter to convert table structure.
 			downcastHelpers.elementToElement( { model: 'table', view: 'table' } );

+ 2 - 5
packages/ckeditor5-engine/tests/conversion/upcasthelpers.js

@@ -3,7 +3,6 @@
  * For licensing, see LICENSE.md.
  */
 
-import Conversion from '../../src/conversion/conversion';
 import UpcastDispatcher from '../../src/conversion/upcastdispatcher';
 
 import ViewContainerElement from '../../src/view/containerelement';
@@ -30,7 +29,7 @@ import ViewSelection from '../../src/view/selection';
 import ViewRange from '../../src/view/range';
 
 describe( 'UpcastHelpers', () => {
-	let upcastDispatcher, model, schema, conversion, upcastHelpers;
+	let upcastDispatcher, model, schema, upcastHelpers;
 
 	beforeEach( () => {
 		model = new Model();
@@ -51,9 +50,7 @@ describe( 'UpcastHelpers', () => {
 		upcastDispatcher.on( 'element', convertToModelFragment(), { priority: 'lowest' } );
 		upcastDispatcher.on( 'documentFragment', convertToModelFragment(), { priority: 'lowest' } );
 
-		conversion = new Conversion();
-		upcastHelpers = new UpcastHelpers( upcastDispatcher );
-		conversion.register( 'upcast', upcastHelpers );
+		upcastHelpers = new UpcastHelpers( [ upcastDispatcher ] );
 	} );
 
 	describe( '.elementToElement()', () => {

+ 13 - 3
packages/ckeditor5-engine/tests/manual/placeholder.js

@@ -12,7 +12,7 @@ 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';
+import { enablePlaceholder } from '../../src/view/placeholder';
 
 ClassicEditor
 	.create( global.document.querySelector( '#editor' ), {
@@ -25,8 +25,18 @@ ClassicEditor
 		const header = viewDoc.getRoot().getChild( 0 );
 		const paragraph = viewDoc.getRoot().getChild( 1 );
 
-		attachPlaceholder( view, header, 'Type some header text...' );
-		attachPlaceholder( view, paragraph, 'Type some paragraph text...' );
+		enablePlaceholder( {
+			view,
+			element: header,
+			text: 'Type some header text...'
+		} );
+
+		enablePlaceholder( {
+			view,
+			element: paragraph,
+			text: 'Type some paragraph text...'
+		} );
+
 		view.render();
 	} )
 	.catch( err => {

+ 29 - 0
packages/ckeditor5-engine/tests/model/differ.js

@@ -629,6 +629,35 @@ describe( 'Differ', () => {
 				] );
 			} );
 		} );
+
+		// https://github.com/ckeditor/ckeditor5-engine/issues/1664
+		it( 'move to the same position #1', () => {
+			const position = new Position( root, [ 0 ] );
+
+			model.change( () => {
+				move( position, 1, position );
+
+				expectChanges( [] );
+			} );
+		} );
+
+		// https://github.com/ckeditor/ckeditor5-engine/issues/1664
+		it( 'move to the same position #2', () => {
+			const sourcePosition = new Position( root, [ 0 ] );
+			const targetPosition = new Position( root, [ 2 ] );
+
+			// Add two more elements to the root, now there are 4 paragraphs.
+			root._appendChild( [
+				new Element( 'paragraph' ),
+				new Element( 'paragraph' )
+			] );
+
+			model.change( () => {
+				move( sourcePosition, 2, targetPosition );
+
+				expectChanges( [] );
+			} );
+		} );
 	} );
 
 	describe( 'rename', () => {

+ 82 - 0
packages/ckeditor5-engine/tests/model/documentselection.js

@@ -490,6 +490,24 @@ describe( 'DocumentSelection', () => {
 		} );
 	} );
 
+	describe( 'is', () => {
+		it( 'should return true for selection', () => {
+			expect( selection.is( 'selection' ) ).to.be.true;
+		} );
+
+		it( 'should return true for documentSelection', () => {
+			expect( selection.is( 'documentSelection' ) ).to.be.true;
+		} );
+
+		it( 'should return false for other values', () => {
+			expect( selection.is( 'node' ) ).to.be.false;
+			expect( selection.is( 'text' ) ).to.be.false;
+			expect( selection.is( 'textProxy' ) ).to.be.false;
+			expect( selection.is( 'element' ) ).to.be.false;
+			expect( selection.is( 'rootElement' ) ).to.be.false;
+		} );
+	} );
+
 	describe( '_setTo() - set collapsed at', () => {
 		it( 'detaches all existing ranges', () => {
 			selection._setTo( [ range, liveRange ] );
@@ -1123,6 +1141,70 @@ describe( 'DocumentSelection', () => {
 		} );
 	} );
 
+	// https://github.com/ckeditor/ckeditor5-engine/issues/1673
+	describe( 'refreshing selection data', () => {
+		it( 'should be up to date when post fixers are called', done => {
+			model.schema.extend( '$text', { allowAttributes: 'foo' } );
+
+			const p = doc.getRoot().getChild( 0 );
+
+			doc.registerPostFixer( () => {
+				expect( model.document.selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
+				expect( Array.from( model.document.selection.markers, m => m.name ) ).to.deep.equal( [ 'marker' ] );
+				done();
+			} );
+
+			model.change( writer => {
+				writer.insertText( 'abcdef', { foo: 'bar' }, p );
+
+				writer.addMarker( 'marker', {
+					range: writer.createRange(
+						writer.createPositionFromPath( p, [ 1 ] ),
+						writer.createPositionFromPath( p, [ 5 ] )
+					),
+					usingOperation: false
+				} );
+
+				writer.setSelection( writer.createPositionFromPath( p, [ 3 ] ) );
+			} );
+		} );
+
+		it( 'should be up to date when post fixers have changed the data', () => {
+			model.schema.extend( '$text', { allowAttributes: 'foo' } );
+
+			const p = doc.getRoot().getChild( 0 );
+
+			doc.registerPostFixer( writer => {
+				writer.setAttribute( 'foo', 'biz', p.getChild( 0 ) );
+				writer.removeMarker( 'marker-1' );
+				writer.addMarker( 'marker-2', {
+					range: writer.createRange(
+						writer.createPositionFromPath( p, [ 1 ] ),
+						writer.createPositionFromPath( p, [ 5 ] )
+					),
+					usingOperation: false
+				} );
+			} );
+
+			model.change( writer => {
+				writer.insertText( 'abcdef', { foo: 'bar' }, p );
+
+				writer.addMarker( 'marker-1', {
+					range: writer.createRange(
+						writer.createPositionFromPath( p, [ 1 ] ),
+						writer.createPositionFromPath( p, [ 5 ] )
+					),
+					usingOperation: false
+				} );
+
+				writer.setSelection( writer.createPositionFromPath( p, [ 3 ] ) );
+			} );
+
+			expect( model.document.selection.getAttribute( 'foo' ) ).to.equal( 'biz' );
+			expect( Array.from( model.document.selection.markers, m => m.name ) ).to.deep.equal( [ 'marker-2' ] );
+		} );
+	} );
+
 	// DocumentSelection uses LiveRanges so here are only simple test to see if integration is
 	// working well, without getting into complicated corner cases.
 	describe( 'after applying an operation should get updated and fire events', () => {

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

@@ -527,4 +527,51 @@ describe( 'transform', () => {
 			'<paragraph>B<m1:end></m1:end>ar</paragraph>'
 		);
 	} );
+
+	// https://github.com/ckeditor/ckeditor5-engine/issues/1668
+	it( 'marker and moves with undo-redo-undo', () => {
+		john.setData( '<paragraph>X[]Y</paragraph>' );
+
+		const inputBufferBatch = john.editor.commands.get( 'input' ).buffer.batch;
+
+		john.editor.model.enqueueChange( inputBufferBatch, () => {
+			john.type( 'a' );
+			john.type( 'b' );
+			john.type( 'c' );
+
+			john.setSelection( [ 0, 1 ], [ 0, 4 ] );
+			john.setMarker( 'm1' );
+		} );
+
+		expectClients( '<paragraph>X<m1:start></m1:start>abc<m1:end></m1:end>Y</paragraph>' );
+
+		john.setSelection( [ 0, 0 ], [ 0, 5 ] );
+		john._processExecute( 'delete' );
+
+		expectClients( '<paragraph></paragraph>' );
+
+		john.undo();
+
+		expectClients( '<paragraph>X<m1:start></m1:start>abc<m1:end></m1:end>Y</paragraph>' );
+
+		john.undo();
+
+		expectClients( '<paragraph>XY</paragraph>' );
+
+		john.redo();
+
+		expectClients( '<paragraph>X<m1:start></m1:start>abc<m1:end></m1:end>Y</paragraph>' );
+
+		john.redo();
+
+		expectClients( '<paragraph></paragraph>' );
+
+		john.undo();
+
+		expectClients( '<paragraph>X<m1:start></m1:start>abc<m1:end></m1:end>Y</paragraph>' );
+
+		john.undo();
+
+		expectClients( '<paragraph>XY</paragraph>' );
+	} );
 } );

+ 53 - 16
packages/ckeditor5-engine/tests/model/selection.js

@@ -806,6 +806,21 @@ describe( 'Selection', () => {
 		} );
 	} );
 
+	describe( 'is', () => {
+		it( 'should return true for selection', () => {
+			expect( selection.is( 'selection' ) ).to.be.true;
+		} );
+
+		it( 'should return false for other values', () => {
+			expect( selection.is( 'documentSelection' ) ).to.be.false;
+			expect( selection.is( 'node' ) ).to.be.false;
+			expect( selection.is( 'text' ) ).to.be.false;
+			expect( selection.is( 'textProxy' ) ).to.be.false;
+			expect( selection.is( 'element' ) ).to.be.false;
+			expect( selection.is( 'rootElement' ) ).to.be.false;
+		} );
+	} );
+
 	describe( 'setTo - used to collapse at start', () => {
 		it( 'should collapse to start position and fire change event', () => {
 			selection.setTo( [ range2, range1, range3 ] );
@@ -1102,6 +1117,20 @@ describe( 'Selection', () => {
 			);
 		} );
 
+		it( 'does not go beyond limit elements', () => {
+			model.schema.register( 'table', { isBlock: true, isLimit: true, isObject: true, allowIn: '$root' } );
+			model.schema.register( 'tableRow', { allowIn: 'table', isLimit: true } );
+			model.schema.register( 'tableCell', { allowIn: 'tableRow', isObject: true } );
+
+			model.schema.register( 'blk', { allowIn: [ '$root', 'tableCell' ], isObject: true, isBlock: true } );
+
+			model.schema.extend( 'p', { allowIn: 'tableCell' } );
+
+			setData( model, '<table><tableRow><tableCell><p>foo</p>[<blk></blk><p>bar]</p></tableCell></tableRow></table>' );
+
+			expect( stringifyBlocks( doc.selection.getTopMostBlocks() ) ).to.deep.equal( [ 'blk', 'p#bar' ] );
+		} );
+
 		// Map all elements to data of its first child text node.
 		function toText( elements ) {
 			return Array.from( elements ).map( el => {
@@ -1113,11 +1142,11 @@ describe( 'Selection', () => {
 	describe( 'getTopMostBlocks()', () => {
 		beforeEach( () => {
 			model.schema.register( 'p', { inheritAllFrom: '$block' } );
-			model.schema.register( 'lvl0', { isBlock: true, isLimit: true, isObject: true, allowIn: '$root' } );
-			model.schema.register( 'lvl1', { allowIn: 'lvl0', isLimit: true } );
-			model.schema.register( 'lvl2', { allowIn: 'lvl1', isObject: true } );
+			model.schema.register( 'table', { isBlock: true, isLimit: true, isObject: true, allowIn: '$root' } );
+			model.schema.register( 'tableRow', { allowIn: 'table', isLimit: true } );
+			model.schema.register( 'tableCell', { allowIn: 'tableRow', isObject: true } );
 
-			model.schema.extend( 'p', { allowIn: 'lvl2' } );
+			model.schema.extend( 'p', { allowIn: 'tableCell' } );
 		} );
 
 		it( 'returns an iterator', () => {
@@ -1154,28 +1183,24 @@ describe( 'Selection', () => {
 		} );
 
 		it( 'returns only top most blocks', () => {
-			setData( model, '[<p>foo</p><lvl0><lvl1><lvl2><p>bar</p></lvl2></lvl1></lvl0><p>baz</p>]' );
+			setData( model, '[<p>foo</p><table><tableRow><tableCell><p>bar</p></tableCell></tableRow></table><p>baz</p>]' );
 
-			expect( stringifyBlocks( doc.selection.getTopMostBlocks() ) ).to.deep.equal( [ 'p#foo', 'lvl0', 'p#baz' ] );
+			expect( stringifyBlocks( doc.selection.getTopMostBlocks() ) ).to.deep.equal( [ 'p#foo', 'table', 'p#baz' ] );
 		} );
 
 		it( 'returns only selected blocks even if nested in other blocks', () => {
-			setData( model, '<p>foo</p><lvl0><lvl1><lvl2><p>[b]ar</p></lvl2></lvl1></lvl0><p>baz</p>' );
+			setData( model, '<p>foo</p><table><tableRow><tableCell><p>[b]ar</p></tableCell></tableRow></table><p>baz</p>' );
 
 			expect( stringifyBlocks( doc.selection.getTopMostBlocks() ) ).to.deep.equal( [ 'p#bar' ] );
 		} );
 
-		// Map all elements to names. If element contains child text node it will be appended to name with '#'.
-		function stringifyBlocks( elements ) {
-			return Array.from( elements ).map( el => {
-				const name = el.name;
+		it( 'returns only selected blocks even if nested in other blocks (selection on the block)', () => {
+			model.schema.register( 'blk', { allowIn: [ '$root', 'tableCell' ], isObject: true, isBlock: true } );
 
-				const firstChild = el.getChild( 0 );
-				const hasText = firstChild && firstChild.data;
+			setData( model, '<table><tableRow><tableCell><p>foo</p>[<blk></blk><p>bar]</p></tableCell></tableRow></table>' );
 
-				return hasText ? `${ name }#${ firstChild.data }` : name;
-			} );
-		}
+			expect( stringifyBlocks( doc.selection.getTopMostBlocks() ) ).to.deep.equal( [ 'blk', 'p#bar' ] );
+		} );
 	} );
 
 	describe( 'attributes interface', () => {
@@ -1351,4 +1376,16 @@ describe( 'Selection', () => {
 			expect( doc.selection.containsEntireContent() ).to.equal( false );
 		} );
 	} );
+
+	// Map all elements to names. If element contains child text node it will be appended to name with '#'.
+	function stringifyBlocks( elements ) {
+		return Array.from( elements ).map( el => {
+			const name = el.name;
+
+			const firstChild = el.getChild( 0 );
+			const hasText = firstChild && firstChild.data;
+
+			return hasText ? `${ name }#${ firstChild.data }` : name;
+		} );
+	}
 } );

+ 5 - 3
packages/ckeditor5-engine/tests/tickets/1653.js

@@ -9,7 +9,7 @@ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictest
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 
 describe( 'Bug ckeditor5-engine#1653', () => {
-	it( '`DataController.parse()` should not invoke `editing.view.render()`', () => {
+	it( '`DataController.parse()` should not fire `editing.view#render`', () => {
 		let editor;
 
 		const element = document.createElement( 'div' );
@@ -20,10 +20,12 @@ describe( 'Bug ckeditor5-engine#1653', () => {
 			.then( newEditor => {
 				editor = newEditor;
 
-				const spy = sinon.spy( editor.editing.view, 'render' );
+				const editingViewSpy = sinon.spy();
+
+				editor.editing.view.on( 'fire', editingViewSpy );
 				editor.data.parse( '<p></p>' );
 
-				sinon.assert.notCalled( spy );
+				sinon.assert.notCalled( editingViewSpy );
 			} )
 			.then( () => {
 				element.remove();

+ 18 - 0
packages/ckeditor5-engine/tests/view/documentselection.js

@@ -725,6 +725,24 @@ describe( 'DocumentSelection', () => {
 		} );
 	} );
 
+	describe( 'is', () => {
+		it( 'should return true for selection', () => {
+			expect( documentSelection.is( 'selection' ) ).to.be.true;
+		} );
+
+		it( 'should return true for documentSelection', () => {
+			expect( documentSelection.is( 'documentSelection' ) ).to.be.true;
+		} );
+
+		it( 'should return false for other values', () => {
+			expect( documentSelection.is( 'node' ) ).to.be.false;
+			expect( documentSelection.is( 'text' ) ).to.be.false;
+			expect( documentSelection.is( 'textProxy' ) ).to.be.false;
+			expect( documentSelection.is( 'element' ) ).to.be.false;
+			expect( documentSelection.is( 'rootElement' ) ).to.be.false;
+		} );
+	} );
+
 	describe( '_setTo()', () => {
 		describe( 'simple scenarios', () => {
 			it( 'should set selection ranges from the given selection', () => {

+ 1 - 1
packages/ckeditor5-engine/tests/view/manual/immutable.js

@@ -24,7 +24,7 @@ setData( view,
 viewDocument.on( 'selectionChange', () => {
 	// Re-render view selection each time selection is changed.
 	// See https://github.com/ckeditor/ckeditor5-engine/issues/796.
-	view.render();
+	view.forceRender();
 } );
 
 view.focus();

+ 1 - 1
packages/ckeditor5-engine/tests/view/manual/noselection.js

@@ -17,7 +17,7 @@ view.attachDomRoot( document.getElementById( 'editor' ) );
 viewDocument.on( 'selectionChange', () => {
 	// Re-render view selection each time selection is changed.
 	// See https://github.com/ckeditor/ckeditor5-engine/issues/796.
-	view.render();
+	view.forceRender();
 } );
 
 setData( view,

+ 1 - 1
packages/ckeditor5-engine/tests/view/observer/domeventobserver.js

@@ -183,7 +183,7 @@ describe( 'DomEventObserver', () => {
 			view.attachDomRoot( domRoot );
 			uiElement = createUIElement( 'p' );
 			viewRoot._appendChild( uiElement );
-			view.render();
+			view.forceRender();
 
 			domEvent = new MouseEvent( 'click', { bubbles: true } );
 			evtSpy = sinon.spy();

+ 8 - 5
packages/ckeditor5-engine/tests/view/observer/focusobserver.js

@@ -58,7 +58,8 @@ describe( 'FocusObserver', () => {
 		} );
 
 		it( 'should render document after blurring', () => {
-			const renderSpy = sinon.spy( view, 'render' );
+			const renderSpy = sinon.spy();
+			view.on( 'render', renderSpy );
 
 			observer.onDomEvent( { type: 'blur', target: document.body } );
 
@@ -122,7 +123,8 @@ describe( 'FocusObserver', () => {
 		} );
 
 		it( 'should delay rendering by 50ms', () => {
-			const renderSpy = sinon.spy( view, 'render' );
+			const renderSpy = sinon.spy();
+			view.on( 'render', renderSpy );
 			const clock = sinon.useFakeTimers();
 
 			observer.onDomEvent( { type: 'focus', target: domMain } );
@@ -134,7 +136,8 @@ describe( 'FocusObserver', () => {
 		} );
 
 		it( 'should not call render if destroyed', () => {
-			const renderSpy = sinon.spy( view, 'render' );
+			const renderSpy = sinon.spy();
+			view.on( 'render', renderSpy );
 			const clock = sinon.useFakeTimers();
 
 			observer.onDomEvent( { type: 'focus', target: domMain } );
@@ -167,7 +170,7 @@ describe( 'FocusObserver', () => {
 			const renderSpy = sinon.spy();
 
 			setData( view, '<div contenteditable="true">foo bar</div>' );
-			view.render();
+			view.forceRender();
 
 			viewDocument.on( 'selectionChange', selectionChangeSpy );
 			view.on( 'render', renderSpy );
@@ -188,7 +191,7 @@ describe( 'FocusObserver', () => {
 			const renderSpy = sinon.spy();
 
 			setData( view, '<div contenteditable="true">foo bar</div>' );
-			view.render();
+			view.forceRender();
 			const domEditable = domRoot.childNodes[ 0 ];
 
 			viewDocument.on( 'selectionChange', selectionChangeSpy );

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

@@ -39,7 +39,7 @@ describe( 'MutationObserver', () => {
 
 		viewRoot._appendChild( parse( '<container:p>foo</container:p><container:p>bar</container:p>' ) );
 
-		view.render();
+		view.forceRender();
 	} );
 
 	afterEach( () => {
@@ -98,7 +98,7 @@ describe( 'MutationObserver', () => {
 	it( 'should handle unbold', () => {
 		viewRoot._removeChildren( 0, viewRoot.childCount );
 		viewRoot._appendChild( parse( '<container:p><attribute:b>foo</attribute:b></container:p>' ) );
-		view.render();
+		view.forceRender();
 
 		const domP = domEditor.childNodes[ 0 ];
 		const domB = domP.childNodes[ 0 ];
@@ -207,7 +207,7 @@ describe( 'MutationObserver', () => {
 			parse( '<container:p>foo</container:p><container:p>bar</container:p>' ) );
 
 		// Render AdditionalEditor (first editor has been rendered in the beforeEach function)
-		view.render();
+		view.forceRender();
 
 		domEditor.childNodes[ 0 ].childNodes[ 0 ].data = 'foom';
 		domAdditionalEditor.childNodes[ 0 ].childNodes[ 0 ].data = 'foom';
@@ -378,7 +378,7 @@ describe( 'MutationObserver', () => {
 	it( 'should have no block filler in mutation', () => {
 		viewRoot._appendChild( parse( '<container:p></container:p>' ) );
 
-		view.render();
+		view.forceRender();
 
 		const domP = domEditor.childNodes[ 2 ];
 		domP.removeChild( domP.childNodes[ 0 ] );
@@ -397,7 +397,7 @@ describe( 'MutationObserver', () => {
 	it( 'should ignore mutation with bogus br inserted on the end of the empty paragraph', () => {
 		viewRoot._appendChild( parse( '<container:p></container:p>' ) );
 
-		view.render();
+		view.forceRender();
 
 		const domP = domEditor.childNodes[ 2 ];
 		domP.appendChild( document.createElement( 'br' ) );
@@ -410,7 +410,7 @@ describe( 'MutationObserver', () => {
 	it( 'should ignore mutation with bogus br inserted on the end of the paragraph with text', () => {
 		viewRoot._appendChild( parse( '<container:p>foo</container:p>' ) );
 
-		view.render();
+		view.forceRender();
 
 		const domP = domEditor.childNodes[ 2 ];
 		domP.appendChild( document.createElement( 'br' ) );
@@ -423,7 +423,7 @@ describe( 'MutationObserver', () => {
 	it( 'should ignore mutation with bogus br inserted on the end of the paragraph while processing text mutations', () => {
 		viewRoot._appendChild( parse( '<container:p>foo</container:p>' ) );
 
-		view.render();
+		view.forceRender();
 
 		const domP = domEditor.childNodes[ 2 ];
 		domP.childNodes[ 0 ].data = 'foo ';
@@ -440,7 +440,7 @@ describe( 'MutationObserver', () => {
 	it( 'should ignore child mutations which resulted in no changes – when element contains elements', () => {
 		viewRoot._appendChild( parse( '<container:p><container:x></container:x></container:p>' ) );
 
-		view.render();
+		view.forceRender();
 
 		const domP = domEditor.childNodes[ 2 ];
 		const domY = document.createElement( 'y' );
@@ -474,7 +474,7 @@ describe( 'MutationObserver', () => {
 	it( 'should not ignore mutation with br inserted not on the end of the paragraph', () => {
 		viewRoot._appendChild( parse( '<container:p>foo</container:p>' ) );
 
-		view.render();
+		view.forceRender();
 
 		const domP = domEditor.childNodes[ 2 ];
 		domP.insertBefore( document.createElement( 'br' ), domP.childNodes[ 0 ] );
@@ -493,7 +493,7 @@ describe( 'MutationObserver', () => {
 	it( 'should not ignore mutation inserting element different than br on the end of the empty paragraph', () => {
 		viewRoot._appendChild( parse( '<container:p></container:p>' ) );
 
-		view.render();
+		view.forceRender();
 
 		const domP = domEditor.childNodes[ 2 ];
 		domP.appendChild( document.createElement( 'span' ) );
@@ -511,7 +511,7 @@ describe( 'MutationObserver', () => {
 	it( 'should not ignore mutation inserting element different than br on the end of the paragraph with text', () => {
 		viewRoot._appendChild( parse( '<container:p>foo</container:p>' ) );
 
-		view.render();
+		view.forceRender();
 
 		const domP = domEditor.childNodes[ 2 ];
 		domP.appendChild( document.createElement( 'span' ) );
@@ -545,7 +545,7 @@ describe( 'MutationObserver', () => {
 			const uiElement = createUIElement( 'div' );
 			viewRoot._appendChild( uiElement );
 
-			view.render();
+			view.forceRender();
 		} );
 
 		it( 'should not collect text mutations from UIElement', () => {

+ 3 - 2
packages/ckeditor5-engine/tests/view/observer/selectionobserver.js

@@ -311,6 +311,7 @@ describe( 'SelectionObserver', () => {
 		const domParagraph = domMain.childNodes[ 0 ];
 		const domText = domParagraph.childNodes[ 0 ];
 		const domUI = domParagraph.childNodes[ 1 ];
+		const viewRenderSpy = sinon.spy();
 
 		// Add rendering on selectionChange event to check this feature.
 		viewDocument.on( 'selectionChange', () => {
@@ -330,7 +331,7 @@ describe( 'SelectionObserver', () => {
 
 			selectionObserver.listenTo( domDocument, 'selectionchange', () => {
 				// 4. Check if view was re-rendered.
-				expect( view.render.called ).to.be.true;
+				sinon.assert.calledOnce( viewRenderSpy );
 
 				done();
 			}, { priority: 'lowest' } );
@@ -339,7 +340,7 @@ describe( 'SelectionObserver', () => {
 			// Current and new selection position are similar in view (but not equal!).
 			// Also add a spy to `viewDocument#render` to see if view will be re-rendered.
 			sel.collapse( domUI, 0 );
-			sinon.spy( view, 'render' );
+			view.on( 'render', viewRenderSpy );
 
 			// Some browsers like Safari won't allow to put selection inside empty ui element.
 			// In that situation selection should stay in correct place.

+ 245 - 35
packages/ckeditor5-engine/tests/view/placeholder.js

@@ -3,7 +3,13 @@
  * For licensing, see LICENSE.md.
  */
 
-import { attachPlaceholder, detachPlaceholder } from '../../src/view/placeholder';
+import {
+	enablePlaceholder,
+	disablePlaceholder,
+	showPlaceholder,
+	hidePlaceholder,
+	needsPlaceholder
+} from '../../src/view/placeholder';
 import createViewRoot from './_utils/createroot';
 import View from '../../src/view/view';
 import ViewRange from '../../src/view/range';
@@ -19,22 +25,45 @@ describe( 'placeholder', () => {
 		viewDocument.isFocused = true;
 	} );
 
-	describe( 'createPlaceholder', () => {
+	describe( 'enablePlaceholder', () => {
 		it( 'should attach proper CSS class and data attribute', () => {
 			setData( view, '<div></div><div>{another div}</div>' );
 			const element = viewRoot.getChild( 0 );
 
-			attachPlaceholder( view, element, 'foo bar baz' );
+			enablePlaceholder( {
+				view,
+				element,
+				text: 'foo bar baz'
+			} );
 
 			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
 			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
 		} );
 
+		it( 'should attach proper CSS class and data attribute (isDirectHost=false)', () => {
+			setData( view, '<p></p>' );
+			viewDocument.isFocused = false;
+
+			enablePlaceholder( {
+				view,
+				element: viewRoot,
+				text: 'foo bar baz',
+				isDirectHost: false
+			} );
+
+			expect( viewRoot.getChild( 0 ).getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
+			expect( viewRoot.getChild( 0 ).hasClass( 'ck-placeholder' ) ).to.be.true;
+		} );
+
 		it( 'if element has children set only data attribute', () => {
 			setData( view, '<div>first div</div><div>{another div}</div>' );
 			const element = viewRoot.getChild( 0 );
 
-			attachPlaceholder( view, element, 'foo bar baz' );
+			enablePlaceholder( {
+				view,
+				element,
+				text: 'foo bar baz'
+			} );
 
 			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
 			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
@@ -44,7 +73,11 @@ describe( 'placeholder', () => {
 			setData( view, '<div><ui:span></ui:span><ui:span></ui:span></div><div>{another div}</div>' );
 			const element = viewRoot.getChild( 0 );
 
-			attachPlaceholder( view, element, 'foo bar baz' );
+			enablePlaceholder( {
+				view,
+				element,
+				text: 'foo bar baz'
+			} );
 
 			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
 			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
@@ -54,7 +87,11 @@ describe( 'placeholder', () => {
 			setData( view, '<div>[]</div><div>another div</div>' );
 			const element = viewRoot.getChild( 0 );
 
-			attachPlaceholder( view, element, 'foo bar baz' );
+			enablePlaceholder( {
+				view,
+				element,
+				text: 'foo bar baz'
+			} );
 
 			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
 			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
@@ -65,34 +102,27 @@ describe( 'placeholder', () => {
 			const element = viewRoot.getChild( 0 );
 			viewDocument.isFocused = false;
 
-			attachPlaceholder( view, 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( view, '<div></div><div>{another div}</div>' );
-			const element = viewRoot.getChild( 0 );
-			let result = true;
-			const spy = sinon.spy( () => result );
+			enablePlaceholder( {
+				view,
+				element,
+				text: 'foo bar baz'
+			} );
 
-			attachPlaceholder( view, element, 'foo bar baz', spy );
+			view.forceRender();
 
-			sinon.assert.called( spy );
 			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
 			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
-
-			result = false;
-			view.render();
-			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
 		} );
 
 		it( 'should remove CSS class if selection is moved inside', () => {
 			setData( view, '<div></div><div>{another div}</div>' );
 			const element = viewRoot.getChild( 0 );
 
-			attachPlaceholder( view, element, 'foo bar baz' );
+			enablePlaceholder( {
+				view,
+				element,
+				text: 'foo bar baz'
+			} );
 
 			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
 			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
@@ -108,8 +138,17 @@ describe( 'placeholder', () => {
 			setData( view, '<div></div><div>{another div}</div>' );
 			const element = viewRoot.getChild( 0 );
 
-			attachPlaceholder( view, element, 'foo bar baz' );
-			attachPlaceholder( view, element, 'new text' );
+			enablePlaceholder( {
+				view,
+				element,
+				text: 'foo bar baz'
+			} );
+
+			enablePlaceholder( {
+				view,
+				element,
+				text: 'new text'
+			} );
 
 			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'new text' );
 			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
@@ -119,10 +158,14 @@ describe( 'placeholder', () => {
 			setData( view, '<div></div><div>{another div}</div>' );
 			const element = viewRoot.getChild( 0 );
 
-			attachPlaceholder( view, element, 'foo bar baz' );
+			enablePlaceholder( {
+				view,
+				element,
+				text: 'foo bar baz'
+			} );
 			setData( view, '<p>paragraph</p>' );
 
-			view.render();
+			view.forceRender();
 		} );
 
 		it( 'should allow to add placeholder to elements from different documents', () => {
@@ -136,8 +179,17 @@ describe( 'placeholder', () => {
 			setData( secondView, '<div></div><div>{another div}</div>' );
 			const secondElement = secondRoot.getChild( 0 );
 
-			attachPlaceholder( view, element, 'first placeholder' );
-			attachPlaceholder( secondView, secondElement, 'second placeholder' );
+			enablePlaceholder( {
+				view,
+				element,
+				text: 'first placeholder'
+			} );
+
+			enablePlaceholder( {
+				view: secondView,
+				element: secondElement,
+				text: 'second placeholder'
+			} );
 
 			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'first placeholder' );
 			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
@@ -165,7 +217,11 @@ describe( 'placeholder', () => {
 			setData( view, '<div></div><div>{another div}</div>' );
 			const element = viewRoot.getChild( 0 );
 
-			attachPlaceholder( view, element, 'foo bar baz' );
+			enablePlaceholder( {
+				view,
+				element,
+				text: 'foo bar baz'
+			} );
 
 			view.change( writer => {
 				writer.setSelection( ViewRange._createIn( element ) );
@@ -178,19 +234,53 @@ describe( 'placeholder', () => {
 			// After rendering - placeholder should be invisible since selection is moved there.
 			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
 		} );
+
+		it( 'should not set attributes/class when multiple children (isDirectHost=false)', () => {
+			setData( view, '<p></p><p></p>' );
+			viewDocument.isFocused = false;
+
+			enablePlaceholder( {
+				view,
+				element: viewRoot,
+				text: 'foo bar baz',
+				isDirectHost: false
+			} );
+
+			expect( viewRoot.getChild( 0 ).hasAttribute( 'data-placeholder' ) ).to.be.false;
+			expect( viewRoot.getChild( 0 ).hasClass( 'ck-placeholder' ) ).to.be.false;
+		} );
+
+		it( 'should not set attributes/class when first child is not element (isDirectHost=false)', () => {
+			setData( view, '<ui:span></ui:span>' );
+			viewDocument.isFocused = false;
+
+			enablePlaceholder( {
+				view,
+				element: viewRoot,
+				text: 'foo bar baz',
+				isDirectHost: false
+			} );
+
+			expect( viewRoot.getChild( 0 ).hasAttribute( 'data-placeholder' ) ).to.be.false;
+			expect( viewRoot.getChild( 0 ).hasClass( 'ck-placeholder' ) ).to.be.false;
+		} );
 	} );
 
-	describe( 'detachPlaceholder', () => {
+	describe( 'disablePlaceholder', () => {
 		it( 'should remove placeholder from element', () => {
 			setData( view, '<div></div><div>{another div}</div>' );
 			const element = viewRoot.getChild( 0 );
 
-			attachPlaceholder( view, element, 'foo bar baz' );
+			enablePlaceholder( {
+				view,
+				element,
+				text: 'foo bar baz'
+			} );
 
 			expect( element.getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
 			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
 
-			detachPlaceholder( view, element );
+			disablePlaceholder( view, element );
 
 			expect( element.hasAttribute( 'data-placeholder' ) ).to.be.false;
 			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
@@ -200,10 +290,130 @@ describe( 'placeholder', () => {
 			setData( view, '<div></div><div>{another div}</div>' );
 			const element = viewRoot.getChild( 0 );
 
-			detachPlaceholder( view, element );
+			disablePlaceholder( view, element );
 
 			expect( element.hasAttribute( 'data-placeholder' ) ).to.be.false;
 			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
 		} );
+
+		it( 'should remove placeholder from element (isDirectHost=false)', () => {
+			setData( view, '<p></p>' );
+			viewDocument.isFocused = false;
+
+			enablePlaceholder( {
+				view,
+				element: viewRoot,
+				text: 'foo bar baz',
+				isDirectHost: false
+			} );
+
+			expect( viewRoot.getChild( 0 ).getAttribute( 'data-placeholder' ) ).to.equal( 'foo bar baz' );
+			expect( viewRoot.getChild( 0 ).hasClass( 'ck-placeholder' ) ).to.be.true;
+
+			disablePlaceholder( view, viewRoot );
+
+			expect( viewRoot.getChild( 0 ).hasAttribute( 'data-placeholder' ) ).to.be.false;
+			expect( viewRoot.getChild( 0 ).hasClass( 'ck-placeholder' ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'showPlaceholder', () => {
+		it( 'should add the ck-placholder class if an element does not have it', () => {
+			setData( view, '<div></div>' );
+			const element = viewRoot.getChild( 0 );
+
+			const result = view.change( writer => showPlaceholder( writer, element ) );
+
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
+			expect( result ).to.be.true;
+		} );
+
+		it( 'should do nothing if an element already has the ck-placeholder class', () => {
+			setData( view, '<div class="ck-placeholder"></div>' );
+			const element = viewRoot.getChild( 0 );
+			let spy;
+
+			const result = view.change( writer => {
+				spy = sinon.spy( writer, 'addClass' );
+
+				return showPlaceholder( writer, element );
+			} );
+
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
+			expect( result ).to.be.false;
+			sinon.assert.notCalled( spy );
+		} );
+	} );
+
+	describe( 'hidePlaceholder', () => {
+		it( 'should remove the ck-placholder class if an element has it', () => {
+			setData( view, '<div class="ck-placeholder"></div>' );
+			const element = viewRoot.getChild( 0 );
+
+			const result = view.change( writer => hidePlaceholder( writer, element ) );
+
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
+			expect( result ).to.be.true;
+		} );
+
+		it( 'should do nothing if an element has no ck-placeholder class', () => {
+			setData( view, '<div></div>' );
+			const element = viewRoot.getChild( 0 );
+			let spy;
+
+			const result = view.change( writer => {
+				spy = sinon.spy( writer, 'removeClass' );
+
+				return hidePlaceholder( writer, element );
+			} );
+
+			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
+			expect( result ).to.be.false;
+			sinon.assert.notCalled( spy );
+		} );
+	} );
+
+	describe( 'needsPlaceholder', () => {
+		it( 'should return false if element was removed from the document', () => {
+			setData( view, '<p></p>' );
+			viewDocument.isFocused = false;
+
+			const element = viewRoot.getChild( 0 );
+
+			expect( needsPlaceholder( element ) ).to.be.true;
+
+			view.change( writer => {
+				writer.remove( element );
+			} );
+
+			expect( needsPlaceholder( element ) ).to.be.false;
+		} );
+
+		it( 'should return true if element is empty and document is blurred', () => {
+			setData( view, '<p></p>' );
+			viewDocument.isFocused = false;
+
+			const element = viewRoot.getChild( 0 );
+
+			expect( needsPlaceholder( element ) ).to.be.true;
+		} );
+
+		it( 'should return true if element hosts UI elements only and document is blurred', () => {
+			setData( view, '<p><ui:span></ui:span></p>' );
+			viewDocument.isFocused = false;
+
+			const element = viewRoot.getChild( 0 );
+
+			expect( needsPlaceholder( element ) ).to.be.true;
+		} );
+
+		it( 'should return true when document is focused but selection anchored somewhere else', () => {
+			setData( view, '<p></p><p>{moo}</p>' );
+			viewDocument.isFocused = true;
+
+			const element = viewRoot.getChild( 0 );
+
+			expect( needsPlaceholder( element ) ).to.be.true;
+		} );
 	} );
 } );

+ 10 - 10
packages/ckeditor5-engine/tests/view/renderer.js

@@ -3224,7 +3224,7 @@ describe( 'Renderer', () => {
 			);
 
 			// Render it to DOM to create initial DOM <-> view mappings.
-			view.render();
+			view.forceRender();
 
 			// Unwrap italic attribute element.
 			view.change( writer => {
@@ -3234,7 +3234,7 @@ describe( 'Renderer', () => {
 			expect( getViewData( view ) ).to.equal( '<p>[<strong>foo</strong>]</p>' );
 
 			// Re-render changes in view to DOM.
-			view.render();
+			view.forceRender();
 
 			// Check if DOM is rendered correctly.
 			expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( '<p><strong>foo</strong></p>' );
@@ -3250,7 +3250,7 @@ describe( 'Renderer', () => {
 				'</container:p>' );
 
 			// Render it to DOM to create initial DOM <-> view mappings.
-			view.render();
+			view.forceRender();
 
 			// Unwrap italic attribute element and change text inside.
 			view.change( writer => {
@@ -3261,7 +3261,7 @@ describe( 'Renderer', () => {
 			expect( getViewData( view ) ).to.equal( '<p>[<strong>bar</strong>]</p>' );
 
 			// Re-render changes in view to DOM.
-			view.render();
+			view.forceRender();
 
 			// Check if DOM is rendered correctly.
 			expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( '<p><strong>bar</strong></p>' );
@@ -3274,7 +3274,7 @@ describe( 'Renderer', () => {
 			);
 
 			// Render it to DOM to create initial DOM <-> view mappings.
-			view.render();
+			view.forceRender();
 
 			// Change text and insert new element into paragraph.
 			const textNode = viewRoot.getChild( 0 ).getChild( 0 );
@@ -3287,7 +3287,7 @@ describe( 'Renderer', () => {
 			expect( getViewData( view ) ).to.equal( '<p>foobar<img></img></p>' );
 
 			// Re-render changes in view to DOM.
-			view.render();
+			view.forceRender();
 
 			// Check if DOM is rendered correctly.
 			expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( '<p>foobar<img></img></p>' );
@@ -3300,7 +3300,7 @@ describe( 'Renderer', () => {
 			);
 
 			// Render it to DOM to create initial DOM <-> view mappings.
-			view.render();
+			view.forceRender();
 
 			// Change text and insert new element into paragraph.
 			const textNode = viewRoot.getChild( 0 ).getChild( 0 );
@@ -3313,7 +3313,7 @@ describe( 'Renderer', () => {
 			expect( getViewData( view ) ).to.equal( '<p><img></img>foobar</p>' );
 
 			// Re-render changes in view to DOM.
-			view.render();
+			view.forceRender();
 
 			// Check if DOM is rendered correctly.
 			expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( '<p><img></img>foobar</p>' );
@@ -3330,7 +3330,7 @@ describe( 'Renderer', () => {
 			);
 
 			// Render it to DOM to create initial DOM <-> view mappings.
-			view.render();
+			view.forceRender();
 
 			// Remove first element and reinsert it at the end.
 			const container = viewRoot.getChild( 0 );
@@ -3344,7 +3344,7 @@ describe( 'Renderer', () => {
 			expect( getViewData( view ) ).to.equal( '<p><i></i><span></span><b></b></p>' );
 
 			// Re-render changes in view to DOM.
-			view.render();
+			view.forceRender();
 
 			// Check if DOM is rendered correctly.
 			expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( '<p><i></i><span></span><b></b></p>' );

+ 15 - 0
packages/ckeditor5-engine/tests/view/selection.js

@@ -599,6 +599,21 @@ describe( 'Selection', () => {
 		} );
 	} );
 
+	describe( 'is', () => {
+		it( 'should return true for selection', () => {
+			expect( selection.is( 'selection' ) ).to.be.true;
+		} );
+
+		it( 'should return false for other values', () => {
+			expect( selection.is( 'documentSelection' ) ).to.be.false;
+			expect( selection.is( 'node' ) ).to.be.false;
+			expect( selection.is( 'text' ) ).to.be.false;
+			expect( selection.is( 'textProxy' ) ).to.be.false;
+			expect( selection.is( 'element' ) ).to.be.false;
+			expect( selection.is( 'rootElement' ) ).to.be.false;
+		} );
+	} );
+
 	describe( 'setTo()', () => {
 		describe( 'simple scenarios', () => {
 			it( 'should set selection ranges from the given selection', () => {

+ 3 - 3
packages/ckeditor5-engine/tests/view/view/jumpoverinlinefiller.js

@@ -42,7 +42,7 @@ describe( 'View', () => {
 	describe( 'jump over inline filler hack', () => {
 		it( 'should jump over inline filler when left arrow is pressed after inline filler', () => {
 			setData( view, '<container:p>foo<attribute:b>[]</attribute:b>bar</container:p>' );
-			view.render();
+			view.forceRender();
 
 			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowleft, domTarget: view.domRoots.get( 'main' ) } );
 
@@ -64,7 +64,7 @@ describe( 'View', () => {
 
 		it( 'should do nothing when another key is pressed', () => {
 			setData( view, '<container:p>foo<attribute:b>[]</attribute:b>bar</container:p>' );
-			view.render();
+			view.forceRender();
 
 			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowright, domTarget: view.domRoots.get( 'main' ) } );
 
@@ -77,7 +77,7 @@ describe( 'View', () => {
 
 		it( 'should do nothing if range is not collapsed', () => {
 			setData( view, '<container:p>foo<attribute:b>{x}</attribute:b>bar</container:p>' );
-			view.render();
+			view.forceRender();
 
 			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowleft, domTarget: view.domRoots.get( 'main' ) } );
 

+ 1 - 1
packages/ckeditor5-engine/tests/view/view/jumpoveruielement.js

@@ -62,7 +62,7 @@ describe( 'View', () => {
 	} );
 
 	function renderAndFireKeydownEvent( options ) {
-		view.render();
+		view.forceRender();
 
 		const eventData = Object.assign( { keyCode: keyCodes.arrowright, domTarget: view.domRoots.get( 'main' ) }, options );
 		viewDocument.fire( 'keydown', eventData );

+ 144 - 34
packages/ckeditor5-engine/tests/view/view/view.js

@@ -119,6 +119,19 @@ describe( 'view', () => {
 			expect( view._renderer.markedChildren.has( viewH1 ) ).to.be.true;
 		} );
 
+		it( 'should handle the "contenteditable" attribute management on #isReadOnly change', () => {
+			const domDiv = document.createElement( 'div' );
+			const viewRoot = createViewRoot( viewDocument, 'div', 'main' );
+
+			view.attachDomRoot( domDiv );
+
+			viewRoot.isReadOnly = false;
+			expect( viewRoot.getAttribute( 'contenteditable' ) ).to.equal( 'true' );
+
+			viewRoot.isReadOnly = true;
+			expect( viewRoot.getAttribute( 'contenteditable' ) ).to.equal( 'false' );
+		} );
+
 		it( 'should call observe on each observer', () => {
 			// The variable will be overwritten.
 			view.destroy();
@@ -144,6 +157,73 @@ describe( 'view', () => {
 		} );
 	} );
 
+	describe( 'detachDomRoot()', () => {
+		it( 'should remove DOM root and unbind DOM element', () => {
+			const domDiv = document.createElement( 'div' );
+			const viewRoot = createViewRoot( viewDocument, 'div', 'main' );
+
+			view.attachDomRoot( domDiv );
+			expect( count( view.domRoots ) ).to.equal( 1 );
+			expect( view.domConverter.mapViewToDom( viewRoot ) ).to.equal( domDiv );
+
+			view.detachDomRoot( 'main' );
+			expect( count( view.domRoots ) ).to.equal( 0 );
+			expect( view.domConverter.mapViewToDom( viewRoot ) ).to.be.undefined;
+
+			domDiv.remove();
+		} );
+
+		it( 'should restore the DOM root attributes to the state before attachDomRoot()', () => {
+			const domDiv = document.createElement( 'div' );
+			const viewRoot = createViewRoot( viewDocument, 'div', 'main' );
+
+			domDiv.setAttribute( 'foo', 'bar' );
+			domDiv.setAttribute( 'data-baz', 'qux' );
+			domDiv.classList.add( 'foo-class' );
+
+			view.attachDomRoot( domDiv );
+
+			view.change( writer => {
+				writer.addClass( 'addedClass', viewRoot );
+				writer.setAttribute( 'added-attribute', 'foo', viewRoot );
+				writer.setAttribute( 'foo', 'changed the value', viewRoot );
+			} );
+
+			view.detachDomRoot( 'main' );
+
+			const attributes = {};
+
+			for ( const attribute of Array.from( domDiv.attributes ) ) {
+				attributes[ attribute.name ] = attribute.value;
+			}
+
+			expect( attributes ).to.deep.equal( {
+				foo: 'bar',
+				'data-baz': 'qux',
+				class: 'foo-class'
+			} );
+
+			domDiv.remove();
+		} );
+
+		it( 'should remove the "contenteditable" attribute from the DOM root', () => {
+			const domDiv = document.createElement( 'div' );
+			const viewRoot = createViewRoot( viewDocument, 'div', 'main' );
+
+			view.attachDomRoot( domDiv );
+			view.forceRender();
+
+			viewRoot.isReadOnly = false;
+			expect( domDiv.getAttribute( 'contenteditable' ) ).to.equal( 'true' );
+
+			view.detachDomRoot( 'main' );
+
+			expect( domDiv.hasAttribute( 'contenteditable' ) ).to.be.false;
+
+			domDiv.remove();
+		} );
+	} );
+
 	describe( 'addObserver()', () => {
 		beforeEach( () => {
 			// The variable will be overwritten.
@@ -204,7 +284,7 @@ describe( 'view', () => {
 
 		it( 'should be disabled and re-enabled on render', () => {
 			const observerMock = view.addObserver( ObserverMock );
-			view.render();
+			view.forceRender();
 
 			sinon.assert.calledOnce( observerMock.disable );
 			sinon.assert.calledOnce( view._renderer.render );
@@ -260,6 +340,10 @@ describe( 'view', () => {
 
 			view.attachDomRoot( domRoot );
 
+			view.change( writer => {
+				writer.setSelection( range );
+			} );
+
 			// Make sure the window will have to scroll to the domRoot.
 			Object.assign( domRoot.style, {
 				position: 'absolute',
@@ -267,10 +351,6 @@ describe( 'view', () => {
 				left: '-1000px'
 			} );
 
-			view.change( writer => {
-				writer.setSelection( range );
-			} );
-
 			view.scrollToTheSelection();
 			sinon.assert.calledWithMatch( stub, sinon.match.number, sinon.match.number );
 		} );
@@ -327,7 +407,7 @@ describe( 'view', () => {
 
 		it( 'should focus editable with selection', () => {
 			const converterFocusSpy = testUtils.sinon.spy( view.domConverter, 'focus' );
-			const renderSpy = testUtils.sinon.spy( view, 'render' );
+			const renderSpy = testUtils.sinon.spy( view, 'forceRender' );
 
 			view.focus();
 
@@ -344,7 +424,7 @@ describe( 'view', () => {
 
 		it( 'should not focus if document is already focused', () => {
 			const converterFocusSpy = testUtils.sinon.spy( view.domConverter, 'focus' );
-			const renderSpy = testUtils.sinon.spy( view, 'render' );
+			const renderSpy = testUtils.sinon.spy( view, 'forceRender' );
 			viewDocument.isFocused = true;
 
 			view.focus();
@@ -377,28 +457,41 @@ describe( 'view', () => {
 		} );
 	} );
 
-	describe( 'render()', () => {
+	describe( 'forceRender()', () => {
 		it( 'disable observers, renders and enable observers', () => {
 			const observerMock = view.addObserver( ObserverMock );
 			const renderStub = sinon.stub( view._renderer, 'render' );
 
-			view.render();
+			view.forceRender();
 
 			sinon.assert.callOrder( observerMock.disable, renderStub, observerMock.enable );
 		} );
 
-		it( 'should fire view.document.layoutChanged event on render', () => {
-			const spy = sinon.spy();
+		it( 'should fire `render` and `layoutChanged` even if there were no changes', () => {
+			const renderSpy = sinon.spy();
+			const layoutChangedSpy = sinon.spy();
+
+			view.on( 'render', renderSpy );
+			view.document.on( 'layoutChanged', layoutChangedSpy );
+
+			view.forceRender();
 
-			view.document.on( 'layoutChanged', spy );
+			sinon.assert.calledOnce( renderSpy );
+			sinon.assert.calledOnce( layoutChangedSpy );
+		} );
 
-			view.render();
+		it( 'should fire `render` and `layoutChanged` if there is some buffered change', () => {
+			const renderSpy = sinon.spy();
+			const layoutChangedSpy = sinon.spy();
 
-			sinon.assert.calledOnce( spy );
+			view.on( 'render', renderSpy );
+			view.document.on( 'layoutChanged', layoutChangedSpy );
 
-			view.render();
+			view.document.selection._setTo( null );
+			view.forceRender();
 
-			sinon.assert.calledTwice( spy );
+			sinon.assert.calledOnce( renderSpy );
+			sinon.assert.calledOnce( layoutChangedSpy );
 		} );
 	} );
 
@@ -414,7 +507,7 @@ describe( 'view', () => {
 
 			createViewRoot( viewDocument, 'div', 'main' );
 			view.attachDomRoot( domDiv );
-			view.render();
+			view.forceRender();
 
 			expect( domDiv.childNodes.length ).to.equal( 1 );
 			expect( isBlockFiller( domDiv.childNodes[ 0 ], BR_FILLER ) ).to.be.true;
@@ -432,7 +525,7 @@ describe( 'view', () => {
 			view.attachDomRoot( domDiv );
 
 			viewDocument.getRoot()._appendChild( new ViewElement( 'p' ) );
-			view.render();
+			view.forceRender();
 
 			expect( domDiv.childNodes.length ).to.equal( 1 );
 			expect( domDiv.childNodes[ 0 ].tagName ).to.equal( 'P' );
@@ -451,13 +544,13 @@ describe( 'view', () => {
 
 			const viewP = new ViewElement( 'p', { class: 'foo' } );
 			viewRoot._appendChild( viewP );
-			view.render();
+			view.forceRender();
 
 			expect( domRoot.childNodes.length ).to.equal( 1 );
 			expect( domRoot.childNodes[ 0 ].getAttribute( 'class' ) ).to.equal( 'foo' );
 
 			viewP._setAttribute( 'class', 'bar' );
-			view.render();
+			view.forceRender();
 
 			expect( domRoot.childNodes.length ).to.equal( 1 );
 			expect( domRoot.childNodes[ 0 ].getAttribute( 'class' ) ).to.equal( 'bar' );
@@ -503,7 +596,7 @@ describe( 'view', () => {
 				} ).to.throw( CKEditorError, /^cannot-change-view-tree/ );
 			} );
 
-			view.render();
+			view.forceRender();
 			domDiv.remove();
 		} );
 
@@ -513,7 +606,9 @@ describe( 'view', () => {
 
 			view.on( 'render', eventSpy );
 
-			view.change( () => {} );
+			view.change( writer => {
+				writer.setSelection( null );
+			} );
 
 			sinon.assert.callOrder( renderSpy, eventSpy );
 		} );
@@ -521,16 +616,24 @@ describe( 'view', () => {
 		it( 'should fire render event once for nested change blocks', () => {
 			const renderSpy = sinon.spy( view._renderer, 'render' );
 			const eventSpy = sinon.spy();
+			const viewEditable = createViewRoot( viewDocument, 'div', 'main' );
 
 			view.on( 'render', eventSpy );
 
-			view.change( () => {
-				view.change( () => {} );
-				view.change( () => {
-					view.change( () => {} );
-					view.change( () => {} );
+			view.change( writer => {
+				writer.setSelection( null );
+				view.change( writer => {
+					writer.setSelection( viewEditable, 0 );
+				} );
+				view.change( writer => {
+					writer.setSelection( null );
+					view.change( writer => {
+						writer.setSelection( viewEditable, 0 );
+					} );
+				} );
+				view.change( writer => {
+					writer.setSelection( null );
 				} );
-				view.change( () => {} );
 			} );
 
 			sinon.assert.calledOnce( renderSpy );
@@ -545,11 +648,12 @@ describe( 'view', () => {
 			view.on( 'render', eventSpy );
 
 			view.change( () => {
-				view.render();
-				view.change( () => {
-					view.render();
+				view.forceRender();
+				view.change( writer => {
+					writer.setSelection( null );
+					view.forceRender();
 				} );
-				view.render();
+				view.forceRender();
 			} );
 
 			sinon.assert.calledOnce( renderSpy );
@@ -567,7 +671,10 @@ describe( 'view', () => {
 			viewDocument.registerPostFixer( postFixer2 );
 			view.on( 'render', eventSpy );
 
-			view.change( changeSpy );
+			view.change( writer => {
+				changeSpy();
+				writer.setSelection( null );
+			} );
 
 			sinon.assert.calledOnce( postFixer1 );
 			sinon.assert.calledOnce( postFixer2 );
@@ -598,7 +705,10 @@ describe( 'view', () => {
 			} );
 			view.on( 'render', eventSpy );
 
-			view.change( changeSpy );
+			view.change( writer => {
+				changeSpy();
+				writer.setSelection( null );
+			} );
 
 			sinon.assert.calledOnce( postFixer1 );
 			sinon.assert.calledOnce( postFixer2 );

+ 4 - 4
packages/ckeditor5-engine/theme/placeholder.css

@@ -6,9 +6,9 @@
 /* See ckeditor/ckeditor5#936. */
 .ck.ck-placeholder, .ck .ck-placeholder {
 	&::before {
-		 content: attr(data-placeholder);
+		content: attr(data-placeholder);
 
-		 /* See ckeditor/ckeditor5#469. */
-		 pointer-events: none;
-	 }
+		/* See ckeditor/ckeditor5#469. */
+		pointer-events: none;
+	}
 }