8
0
Просмотр исходного кода

Merge branch 'master' into t/1015

Szymon Kupś 8 лет назад
Родитель
Сommit
139de0689d
39 измененных файлов с 716 добавлено и 148 удалено
  1. 70 8
      packages/ckeditor5-engine/src/controller/deletecontent.js
  2. 12 16
      packages/ckeditor5-engine/src/controller/editingcontroller.js
  3. 26 3
      packages/ckeditor5-engine/src/model/node.js
  4. 1 1
      packages/ckeditor5-engine/src/model/position.js
  5. 2 0
      packages/ckeditor5-engine/src/model/schema.js
  6. 1 1
      packages/ckeditor5-engine/src/model/selection.js
  7. 3 3
      packages/ckeditor5-engine/src/model/textproxy.js
  8. 2 1
      packages/ckeditor5-engine/src/view/attributeelement.js
  9. 9 1
      packages/ckeditor5-engine/src/view/containerelement.js
  10. 11 1
      packages/ckeditor5-engine/src/view/document.js
  11. 5 1
      packages/ckeditor5-engine/src/view/editableelement.js
  12. 2 0
      packages/ckeditor5-engine/src/view/element.js
  13. 26 3
      packages/ckeditor5-engine/src/view/node.js
  14. 18 3
      packages/ckeditor5-engine/src/view/observer/keyobserver.js
  15. 4 1
      packages/ckeditor5-engine/src/view/observer/selectionobserver.js
  16. 1 1
      packages/ckeditor5-engine/src/view/position.js
  17. 52 32
      packages/ckeditor5-engine/src/view/renderer.js
  18. 3 3
      packages/ckeditor5-engine/src/view/textproxy.js
  19. 94 2
      packages/ckeditor5-engine/tests/controller/deletecontent.js
  20. 22 5
      packages/ckeditor5-engine/tests/controller/editingcontroller.js
  21. 91 16
      packages/ckeditor5-engine/tests/model/node.js
  22. 4 0
      packages/ckeditor5-engine/tests/model/schema/schema.js
  23. 3 3
      packages/ckeditor5-engine/tests/model/textproxy.js
  24. 1 0
      packages/ckeditor5-engine/tests/view/_utils/createdocumentmock.js
  25. 5 5
      packages/ckeditor5-engine/tests/view/attributeelement.js
  26. 2 2
      packages/ckeditor5-engine/tests/view/containerelement.js
  27. 4 3
      packages/ckeditor5-engine/tests/view/document/document.js
  28. 13 0
      packages/ckeditor5-engine/tests/view/editableelement.js
  29. 1 0
      packages/ckeditor5-engine/tests/view/manual/keyobserver.js
  30. 2 0
      packages/ckeditor5-engine/tests/view/manual/keyobserver.md
  31. 1 3
      packages/ckeditor5-engine/tests/view/manual/uielement.html
  32. 2 2
      packages/ckeditor5-engine/tests/view/manual/uielement.js
  33. 90 15
      packages/ckeditor5-engine/tests/view/node.js
  34. 50 4
      packages/ckeditor5-engine/tests/view/observer/keyobserver.js
  35. 20 0
      packages/ckeditor5-engine/tests/view/observer/selectionobserver.js
  36. 8 0
      packages/ckeditor5-engine/tests/view/position.js
  37. 39 0
      packages/ckeditor5-engine/tests/view/renderer.js
  38. 4 4
      packages/ckeditor5-engine/tests/view/textproxy.js
  39. 12 5
      packages/ckeditor5-engine/tests/view/utils-tests/createdocumentmock.js

+ 70 - 8
packages/ckeditor5-engine/src/controller/deletecontent.js

@@ -32,17 +32,24 @@ export default function deleteContent( selection, batch, options = {} ) {
 		return;
 	}
 
-	const selRange = selection.getFirstRange();
+	// 1. Replace the entire content with paragraph.
+	// See: https://github.com/ckeditor/ckeditor5-engine/issues/1012#issuecomment-315017594.
+	if ( shouldEntireContentBeReplacedWithParagraph( batch.document.schema, selection ) ) {
+		replaceEntireContentWithParagraph( batch, selection );
+
+		return;
+	}
 
+	const selRange = selection.getFirstRange();
 	const startPos = selRange.start;
 	const endPos = LivePosition.createFromPosition( selRange.end );
 
-	// 1. Remove the content if there is any.
+	// 2. Remove the content if there is any.
 	if ( !selRange.start.isTouching( selRange.end ) ) {
 		batch.remove( selRange );
 	}
 
-	// 2. Merge elements in the right branch to the elements in the left branch.
+	// 3. Merge elements in the right branch to the elements in the left branch.
 	// The only reasonable (in terms of data and selection correctness) case in which we need to do that is:
 	//
 	// <heading type=1>Fo[</heading><paragraph>]ar</paragraph> => <heading type=1>Fo^ar</heading>
@@ -56,13 +63,10 @@ export default function deleteContent( selection, batch, options = {} ) {
 
 	selection.collapse( startPos );
 
-	// 3. Autoparagraphing.
+	// 4. Autoparagraphing.
 	// Check if a text is allowed in the new container. If not, try to create a new paragraph (if it's allowed here).
 	if ( shouldAutoparagraph( batch.document, startPos ) ) {
-		const paragraph = new Element( 'paragraph' );
-		batch.insert( startPos, paragraph );
-
-		selection.collapse( paragraph );
+		insertParagraph( batch, startPos, selection );
 	}
 
 	endPos.detach();
@@ -163,3 +167,61 @@ function checkCanBeMerged( leftPos, rightPos ) {
 
 	return true;
 }
+
+// Returns the lowest limit element defined in `Schema.limits` for passed selection.
+function getLimitElement( schema, selection ) {
+	let element = selection.getFirstRange().getCommonAncestor();
+
+	while ( !schema.limits.has( element.name ) ) {
+		if ( element.parent ) {
+			element = element.parent;
+		} else {
+			break;
+		}
+	}
+
+	return element;
+}
+
+function insertParagraph( batch, position, selection ) {
+	const paragraph = new Element( 'paragraph' );
+	batch.insert( position, paragraph );
+
+	selection.collapse( paragraph );
+}
+
+function replaceEntireContentWithParagraph( batch, selection ) {
+	const limitElement = getLimitElement( batch.document.schema, selection );
+
+	batch.remove( Range.createIn( limitElement ) );
+	insertParagraph( batch, Position.createAt( limitElement ), selection );
+}
+
+// We want to replace the entire content with a paragraph when:
+// * the entire content is selected,
+// * selection contains at least two elements,
+// * whether the paragraph is allowed in schema in the common ancestor.
+function shouldEntireContentBeReplacedWithParagraph( schema, selection ) {
+	const limitElement = getLimitElement( schema, selection );
+	const limitStartPosition = Position.createAt( limitElement );
+	const limitEndPosition = Position.createAt( limitElement, 'end' );
+
+	if (
+		!limitStartPosition.isTouching( selection.getFirstPosition() ) ||
+		!limitEndPosition.isTouching( selection.getLastPosition() )
+	) {
+		return false;
+	}
+
+	const range = selection.getFirstRange();
+
+	if ( range.start.parent == range.end.parent ) {
+		return false;
+	}
+
+	if ( !schema.check( { name: 'paragraph', inside: limitElement.name } ) ) {
+		return false;
+	}
+
+	return true;
+}

+ 12 - 16
packages/ckeditor5-engine/src/controller/editingcontroller.js

@@ -22,12 +22,15 @@ import {
 	clearFakeSelection
 } from '../conversion/model-selection-to-view-converters';
 
-import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
+import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
+import mix from '@ckeditor/ckeditor5-utils/src/mix';
 
 /**
  * Controller for the editing pipeline. The editing pipeline controls {@link ~EditingController#model model} rendering,
  * including selection handling. It also creates {@link ~EditingController#view view document} which build a
  * browser-independent virtualization over the DOM elements. Editing controller also attach default converters.
+ *
+ * @mixes module:utils/observablemixin~ObservableMixin
  */
 export default class EditingController {
 	/**
@@ -80,22 +83,13 @@ export default class EditingController {
 			viewSelection: this.view.selection
 		} );
 
-		/**
-		 * Property keeping all listenters attached by controller on other objects, so it can
-		 * stop listening on {@link #destroy}.
-		 *
-		 * @private
-		 * @member {utils.EmitterMixin} #_listener
-		 */
-		this._listener = Object.create( EmitterMixin );
-
 		// Convert changes in model to view.
-		this._listener.listenTo( this.model, 'change', ( evt, type, changes ) => {
+		this.listenTo( this.model, 'change', ( evt, type, changes ) => {
 			this.modelToView.convertChange( type, changes );
 		}, { priority: 'low' } );
 
 		// Convert model selection to view.
-		this._listener.listenTo( this.model, 'changesDone', () => {
+		this.listenTo( this.model, 'changesDone', () => {
 			const selection = this.model.selection;
 
 			this.modelToView.convertSelection( selection );
@@ -103,16 +97,16 @@ export default class EditingController {
 		}, { priority: 'low' } );
 
 		// Convert model markers changes.
-		this._listener.listenTo( this.model.markers, 'add', ( evt, marker ) => {
+		this.listenTo( this.model.markers, 'add', ( evt, marker ) => {
 			this.modelToView.convertMarker( 'addMarker', marker.name, marker.getRange() );
 		} );
 
-		this._listener.listenTo( this.model.markers, 'remove', ( evt, marker ) => {
+		this.listenTo( this.model.markers, 'remove', ( evt, marker ) => {
 			this.modelToView.convertMarker( 'removeMarker', marker.name, marker.getRange() );
 		} );
 
 		// Convert view selection to model.
-		this._listener.listenTo( this.view, 'selectionChange', convertSelectionChange( this.model, this.mapper ) );
+		this.listenTo( this.view, 'selectionChange', convertSelectionChange( this.model, this.mapper ) );
 
 		// Attach default content converters.
 		this.modelToView.on( 'insert:$text', insertText(), { priority: 'lowest' } );
@@ -158,6 +152,8 @@ export default class EditingController {
 	 */
 	destroy() {
 		this.view.destroy();
-		this._listener.stopListening();
+		this.stopListening();
 	}
 }
+
+mix( EditingController, ObservableMixin );

+ 26 - 3
packages/ckeditor5-engine/src/model/node.js

@@ -252,14 +252,14 @@ export default class Node {
 	 * Returns ancestors array of this node.
 	 *
 	 * @param {Object} options Options object.
-	 * @param {Boolean} [options.includeNode=false] When set to `true` this node will be also included in parent's array.
+	 * @param {Boolean} [options.includeSelf=false] When set to `true` this node will be also included in parent's array.
 	 * @param {Boolean} [options.parentFirst=false] When set to `true`, array will be sorted from node's parent to root element,
 	 * otherwise root element will be the first item in the array.
 	 * @returns {Array} Array with ancestors.
 	 */
-	getAncestors( options = { includeNode: false, parentFirst: false } ) {
+	getAncestors( options = { includeSelf: false, parentFirst: false } ) {
 		const ancestors = [];
-		let parent = options.includeNode ? this : this.parent;
+		let parent = options.includeSelf ? this : this.parent;
 
 		while ( parent ) {
 			ancestors[ options.parentFirst ? 'push' : 'unshift' ]( parent );
@@ -269,6 +269,29 @@ export default class Node {
 		return ancestors;
 	}
 
+	/**
+	 * Returns a {@link module:engine/model/element~Element} or {@link module:engine/model/documentfragment~DocumentFragment}
+	 * which is a common ancestor of both nodes.
+	 *
+	 * @param {module:engine/model/node~Node} node The second node.
+	 * @param {Object} options Options object.
+	 * @param {Boolean} [options.includeSelf=false] When set to `true` both nodes will be considered "ancestors" too.
+	 * Which means that if e.g. node A is inside B, then their common ancestor will be B.
+	 * @returns {module:engine/model/element~Element|module:engine/model/documentfragment~DocumentFragment|null}
+	 */
+	getCommonAncestor( node, options = {} ) {
+		const ancestorsA = this.getAncestors( options );
+		const ancestorsB = node.getAncestors( options );
+
+		let i = 0;
+
+		while ( ancestorsA[ i ] == ancestorsB[ i ] && ancestorsA[ i ] ) {
+			i++;
+		}
+
+		return i === 0 ? null : ancestorsA[ i - 1 ];
+	}
+
 	/**
 	 * Removes this node from it's parent.
 	 */

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

@@ -294,7 +294,7 @@ export default class Position {
 		if ( this.parent.is( 'documentFragment' ) ) {
 			return [ this.parent ];
 		} else {
-			return this.parent.getAncestors( { includeNode: true } );
+			return this.parent.getAncestors( { includeSelf: true } );
 		}
 	}
 

+ 2 - 0
packages/ckeditor5-engine/src/model/schema.js

@@ -83,6 +83,8 @@ export default class Schema {
 		this.allow( { name: '$block', inside: '$root' } );
 		this.allow( { name: '$inline', inside: '$block' } );
 
+		this.limits.add( '$root' );
+
 		// TMP!
 		// Create an "all allowed" context in the schema for processing the pasted content.
 		// Read: https://github.com/ckeditor/ckeditor5-engine/issues/638#issuecomment-255086588

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

@@ -742,7 +742,7 @@ function isUnvisitedBlockContainer( element, visited ) {
 // Finds the lowest element in position's ancestors which is a block.
 // Marks all ancestors as already visited to not include any of them later on.
 function getParentBlock( position, visited ) {
-	const ancestors = position.parent.getAncestors( { parentFirst: true, includeNode: true } );
+	const ancestors = position.parent.getAncestors( { parentFirst: true, includeSelf: true } );
 	const block = ancestors.find( element => isUnvisitedBlockContainer( element, visited ) );
 
 	// Mark all ancestors of this position's parent, because find() might've stopped early and

+ 3 - 3
packages/ckeditor5-engine/src/model/textproxy.js

@@ -204,14 +204,14 @@ export default class TextProxy {
 	 * Returns ancestors array of this text proxy.
 	 *
 	 * @param {Object} options Options object.
-	 * @param {Boolean} [options.includeNode=false] When set to `true` this text proxy will be also included in parent's array.
+	 * @param {Boolean} [options.includeSelf=false] When set to `true` this text proxy will be also included in parent's array.
 	 * @param {Boolean} [options.parentFirst=false] When set to `true`, array will be sorted from text proxy parent to root element,
 	 * otherwise root element will be the first item in the array.
 	 * @returns {Array} Array with ancestors.
 	 */
-	getAncestors( options = { includeNode: false, parentFirst: false } ) {
+	getAncestors( options = { includeSelf: false, parentFirst: false } ) {
 		const ancestors = [];
-		let parent = options.includeNode ? this : this.parent;
+		let parent = options.includeSelf ? this : this.parent;
 
 		while ( parent ) {
 			ancestors[ options.parentFirst ? 'push' : 'unshift' ]( parent );

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

@@ -120,7 +120,8 @@ function getFillerOffset() {
 		return null;
 	}
 
-	return 0;
+	// Render block filler at the end of element (after all ui elements).
+	return this.childCount;
 }
 
 // Returns total count of children that are not {@link module:engine/view/uielement~UIElement UIElements}.

+ 9 - 1
packages/ckeditor5-engine/src/view/containerelement.js

@@ -78,5 +78,13 @@ export default class ContainerElement extends Element {
 //
 // @returns {Number|null} Block filler offset or `null` if block filler is not needed.
 function getFillerOffset() {
-	return Array.from( this.getChildren() ).some( element => !element.is( 'uiElement' ) ) ? null : 0;
+	for ( const child of this.getChildren() ) {
+		// If there's any non-UI element – don't render the bogus.
+		if ( !child.is( 'uiElement' ) ) {
+			return null;
+		}
+	}
+
+	// If there are only UI elements – render the bogus at the end of the element.
+	return this.childCount;
 }

+ 11 - 1
packages/ckeditor5-engine/src/view/document.js

@@ -79,6 +79,16 @@ export default class Document {
 		 */
 		this.roots = new Map();
 
+		/**
+		 * Defines whether document is in read-only mode.
+		 *
+		 * When document is read-ony then all roots are read-only as well and caret placed inside this root is hidden.
+		 *
+		 * @observable
+		 * @member {Boolean} #isReadOnly
+		 */
+		this.set( 'isReadOnly', false );
+
 		/**
 		 * True if document is focused.
 		 *
@@ -98,7 +108,7 @@ export default class Document {
 		 * @member {module:engine/view/renderer~Renderer} module:engine/view/document~Document#renderer
 		 */
 		this.renderer = new Renderer( this.domConverter, this.selection );
-		this.renderer.bind( 'isFocused' ).to( this, 'isFocused' );
+		this.renderer.bind( 'isFocused' ).to( this );
 
 		/**
 		 * Map of registered {@link module:engine/view/observer/observer~Observer observers}.

+ 5 - 1
packages/ckeditor5-engine/src/view/editableelement.js

@@ -18,8 +18,10 @@ const documentSymbol = Symbol( 'document' );
  * Editable element which can be a {@link module:engine/view/rooteditableelement~RootEditableElement root}
  * or nested editable area in the editor.
  *
+ * Editable is automatically read-only when its {module:engine/view/document~Document Document} is read-only.
+ *
  * @extends module:engine/view/containerelement~ContainerElement
- * @mixes module:utils/observablemixin~ObservaleMixin
+ * @mixes module:utils/observablemixin~ObservableMixin
  */
 export default class EditableElement extends ContainerElement {
 	/**
@@ -74,6 +76,8 @@ export default class EditableElement extends ContainerElement {
 
 		this.setCustomProperty( documentSymbol, document );
 
+		this.bind( 'isReadOnly' ).to( document );
+
 		this.bind( 'isFocused' ).to(
 			document,
 			'isFocused',

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

@@ -181,6 +181,8 @@ export default class Element extends Node {
 		cloned._customProperties = new Map( this._customProperties );
 
 		// Clone filler offset method.
+		// We can't define this method in a prototype because it's behavior which
+		// is changed by e.g. toWidget() function from ckeditor5-widget. Perhaps this should be one of custom props.
 		cloned.getFillerOffset = this.getFillerOffset;
 
 		return cloned;

+ 26 - 3
packages/ckeditor5-engine/src/view/node.js

@@ -121,14 +121,14 @@ export default class Node {
 	 * Returns ancestors array of this node.
 	 *
 	 * @param {Object} options Options object.
-	 * @param {Boolean} [options.includeNode=false] When set to `true` this node will be also included in parent's array.
+	 * @param {Boolean} [options.includeSelf=false] When set to `true` this node will be also included in parent's array.
 	 * @param {Boolean} [options.parentFirst=false] When set to `true`, array will be sorted from node's parent to root element,
 	 * otherwise root element will be the first item in the array.
 	 * @returns {Array} Array with ancestors.
 	 */
-	getAncestors( options = { includeNode: false, parentFirst: false } ) {
+	getAncestors( options = { includeSelf: false, parentFirst: false } ) {
 		const ancestors = [];
-		let parent = options.includeNode ? this : this.parent;
+		let parent = options.includeSelf ? this : this.parent;
 
 		while ( parent ) {
 			ancestors[ options.parentFirst ? 'push' : 'unshift' ]( parent );
@@ -138,6 +138,29 @@ export default class Node {
 		return ancestors;
 	}
 
+	/**
+	 * Returns a {@link module:engine/view/element~Element} or {@link module:engine/view/documentfragment~DocumentFragment}
+	 * which is a common ancestor of both nodes.
+	 *
+	 * @param {module:engine/view/node~Node} node The second node.
+	 * @param {Object} options Options object.
+	 * @param {Boolean} [options.includeSelf=false] When set to `true` both nodes will be considered "ancestors" too.
+	 * Which means that if e.g. node A is inside B, then their common ancestor will be B.
+	 * @returns {module:engine/view/element~Element|module:engine/view/documentfragment~DocumentFragment|null}
+	 */
+	getCommonAncestor( node, options = {} ) {
+		const ancestorsA = this.getAncestors( options );
+		const ancestorsB = node.getAncestors( options );
+
+		let i = 0;
+
+		while ( ancestorsA[ i ] == ancestorsB[ i ] && ancestorsA[ i ] ) {
+			i++;
+		}
+
+		return i === 0 ? null : ancestorsA[ i - 1 ];
+	}
+
 	/**
 	 * Removes node from parent.
 	 */

+ 18 - 3
packages/ckeditor5-engine/src/view/observer/keyobserver.js

@@ -21,11 +21,11 @@ export default class KeyObserver extends DomEventObserver {
 	constructor( document ) {
 		super( document );
 
-		this.domEventType = 'keydown';
+		this.domEventType = [ 'keydown', 'keyup' ];
 	}
 
 	onDomEvent( domEvt ) {
-		this.fire( 'keydown', domEvt, {
+		this.fire( domEvt.type, domEvt, {
 			keyCode: domEvt.keyCode,
 
 			altKey: domEvt.altKey,
@@ -54,7 +54,22 @@ export default class KeyObserver extends DomEventObserver {
  */
 
 /**
- * The value of the {@link module:engine/view/document~Document#event:keydown} event.
+ * Fired when a key has been released.
+ *
+ * Introduced by {@link module:engine/view/observer/keyobserver~KeyObserver}.
+ *
+ * Note that because {@link module:engine/view/observer/keyobserver~KeyObserver} is attached by the
+ * {@link module:engine/view/document~Document}
+ * this event is available by default.
+ *
+ * @see module:engine/view/observer/keyobserver~KeyObserver
+ * @event module:engine/view/document~Document#event:keyup
+ * @param {module:engine/view/observer/keyobserver~KeyEventData} keyEventData
+ */
+
+/**
+ * The value of both events - {@link module:engine/view/document~Document#event:keydown} and
+ * {@link module:engine/view/document~Document#event:keyup}.
  *
  * @class module:engine/view/observer/keyobserver~KeyEventData
  * @extends module:engine/view/observer/domeventdata~DomEventData

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

@@ -133,7 +133,10 @@ export default class SelectionObserver extends Observer {
 	 * @param {Document} domDocument DOM document.
 	 */
 	_handleSelectionChange( domDocument ) {
-		if ( !this.isEnabled || !this.document.isFocused ) {
+		// Selection is handled when document is not focused but is read-only. This is because in read-only
+		// mode contenteditable is set as false and editor won't receive focus but we still need to know
+		// selection position.
+		if ( !this.isEnabled || ( !this.document.isFocused && !this.document.isReadOnly ) ) {
 			return;
 		}
 

+ 1 - 1
packages/ckeditor5-engine/src/view/position.js

@@ -171,7 +171,7 @@ export default class Position {
 		if ( this.parent.is( 'documentFragment' ) ) {
 			return [ this.parent ];
 		} else {
-			return this.parent.getAncestors( { includeNode: true } );
+			return this.parent.getAncestors( { includeSelf: true } );
 		}
 	}
 

+ 52 - 32
packages/ckeditor5-engine/src/view/renderer.js

@@ -24,7 +24,7 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  * Renderer updates DOM structure and selection, to make them a reflection of the view structure and selection.
  *
  * View nodes which may need to be rendered needs to be {@link module:engine/view/renderer~Renderer#markToSync marked}.
- * Then, on {@link module:engine/view/renderer~Renderer#render render}, renderer compares the view nodes with the DOM nodes
+ * Then, on {@link module:engine/view/renderer~Renderer#render render}, renderer compares view nodes with DOM nodes
  * in order to check which ones really need to be refreshed. Finally, it creates DOM nodes from these view nodes,
  * {@link module:engine/view/domconverter~DomConverter#bindElements binds} them and inserts into the DOM tree.
  *
@@ -81,7 +81,7 @@ export default class Renderer {
 		this.markedTexts = new Set();
 
 		/**
-		 * View selection. Renderer updates DOM Selection to make it match this one.
+		 * View selection. Renderer updates DOM selection based on the view selection.
 		 *
 		 * @readonly
 		 * @member {module:engine/view/selection~Selection}
@@ -97,7 +97,7 @@ export default class Renderer {
 		this._inlineFiller = null;
 
 		/**
-		 * Indicates if view document is focused and selection can be rendered. Selection will not be rendered if
+		 * Indicates if the view document is focused and selection can be rendered. Selection will not be rendered if
 		 * this is set to `false`.
 		 *
 		 * @member {Boolean}
@@ -212,44 +212,67 @@ export default class Renderer {
 			this._updateChildren( element, { inlineFillerPosition } );
 		}
 
+		// Check whether the inline filler is required and where it really is in the DOM.
+		// At this point in most cases it will be in the DOM, but there are exceptions.
+		// For example, if the inline filler was deep in the created DOM structure, it will not be created.
+		// Similarly, if it was removed at the beginning of this function and then neither text nor children were updated,
+		// it will not be present.
+		// Fix those and similar scenarios.
+		if ( inlineFillerPosition ) {
+			const fillerDomPosition = this.domConverter.viewPositionToDom( inlineFillerPosition );
+			const domDocument = fillerDomPosition.parent.ownerDocument;
+
+			if ( !startsWithFiller( fillerDomPosition.parent ) ) {
+				// Filler has not been created at filler position. Create it now.
+				this._inlineFiller = this._addInlineFiller( domDocument, fillerDomPosition.parent, fillerDomPosition.offset );
+			} else {
+				// Filler has been found, save it.
+				this._inlineFiller = fillerDomPosition.parent;
+			}
+		} else {
+			// There is no filler needed.
+			this._inlineFiller = null;
+		}
+
 		this._updateSelection();
 		this._updateFocus();
 
 		this.markedTexts.clear();
 		this.markedAttributes.clear();
 		this.markedChildren.clear();
-
-		// Remember the filler by its node.
-		this._inlineFiller = this._getInlineFillerNode( inlineFillerPosition );
 	}
 
 	/**
-	 * Gets the text node in which the inline filler is kept.
+	 * Adds inline filler at given position.
+	 *
+	 * The position can be given as an array of DOM nodes and an offset in that array,
+	 * or a DOM parent element and offset in that element.
 	 *
 	 * @private
-	 * @param {module:engine/view/position~Position} fillerPosition The position on which the filler is needed in the view.
-	 * @returns {Text} The text node with the filler.
+	 * @param {Document} domDocument
+	 * @param {Element|Array.<Node>} domParentOrArray
+	 * @param {Number} offset
+	 * @returns {Text} The DOM text node that contains inline filler.
 	 */
-	_getInlineFillerNode( fillerPosition ) {
-		if ( !fillerPosition ) {
-			this._inlineFiller = null;
+	_addInlineFiller( domDocument, domParentOrArray, offset ) {
+		const childNodes = domParentOrArray instanceof Array ? domParentOrArray : domParentOrArray.childNodes;
+		const nodeAfterFiller = childNodes[ offset ];
 
-			return;
-		}
+		if ( this.domConverter.isText( nodeAfterFiller ) ) {
+			nodeAfterFiller.data = INLINE_FILLER + nodeAfterFiller.data;
 
-		const domPosition = this.domConverter.viewPositionToDom( fillerPosition );
+			return nodeAfterFiller;
+		} else {
+			const fillerNode = domDocument.createTextNode( INLINE_FILLER );
 
-		/* istanbul ignore if */
-		if ( !domPosition || !startsWithFiller( domPosition.parent ) ) {
-			/**
-			 * Cannot find filler node by its position.
-			 *
-			 * @error view-renderer-cannot-find-filler
-			 */
-			throw new CKEditorError( 'view-renderer-cannot-find-filler: Cannot find filler node by its position.' );
-		}
+			if ( Array.isArray( domParentOrArray ) ) {
+				childNodes.splice( offset, 0, fillerNode );
+			} else {
+				insertAt( domParentOrArray, offset, fillerNode );
+			}
 
-		return domPosition.parent;
+			return fillerNode;
+		}
 	}
 
 	/**
@@ -449,14 +472,11 @@ export default class Renderer {
 		const actualDomChildren = domElement.childNodes;
 		const expectedDomChildren = Array.from( domConverter.viewChildrenToDom( viewElement, domDocument, { bind: true } ) );
 
+		// Inline filler element has to be created during children update because we need it to diff actual dom
+		// elements with expected dom elements. We need inline filler in expected dom elements so we won't re-render
+		// text node if it is not necessary.
 		if ( filler && filler.parent == viewElement ) {
-			const expectedNodeAfterFiller = expectedDomChildren[ filler.offset ];
-
-			if ( this.domConverter.isText( expectedNodeAfterFiller ) ) {
-				expectedNodeAfterFiller.data = INLINE_FILLER + expectedNodeAfterFiller.data;
-			} else {
-				expectedDomChildren.splice( filler.offset, 0, domDocument.createTextNode( INLINE_FILLER ) );
-			}
+			this._addInlineFiller( domDocument, expectedDomChildren, filler.offset );
 		}
 
 		const actions = diff( actualDomChildren, expectedDomChildren, sameNodes );

+ 3 - 3
packages/ckeditor5-engine/src/view/textproxy.js

@@ -146,14 +146,14 @@ export default class TextProxy {
 	 * Returns ancestors array of this text proxy.
 	 *
 	 * @param {Object} options Options object.
-	 * @param {Boolean} [options.includeNode=false] When set to `true` {#textNode} will be also included in parent's array.
+	 * @param {Boolean} [options.includeSelf=false] When set to `true` {#textNode} will be also included in parent's array.
 	 * @param {Boolean} [options.parentFirst=false] When set to `true`, array will be sorted from text proxy parent to
 	 * root element, otherwise root element will be the first item in the array.
 	 * @returns {Array} Array with ancestors.
 	 */
-	getAncestors( options = { includeNode: false, parentFirst: false } ) {
+	getAncestors( options = { includeSelf: false, parentFirst: false } ) {
 		const ancestors = [];
-		let parent = options.includeNode ? this.textNode : this.parent;
+		let parent = options.includeSelf ? this.textNode : this.parent;
 
 		while ( parent !== null ) {
 			ancestors[ options.parentFirst ? 'push' : 'unshift' ]( parent );

+ 94 - 2
packages/ckeditor5-engine/tests/controller/deletecontent.js

@@ -235,8 +235,8 @@ describe( 'DataController', () => {
 
 			test(
 				'leaves just one element when all selected',
-				'<heading1>[x</heading1><paragraph>foo</paragraph><paragraph>y]</paragraph>',
-				'<heading1>[]</heading1>'
+				'<heading1>[x</heading1><paragraph>foo</paragraph><paragraph>y]bar</paragraph>',
+				'<heading1>[]bar</heading1>'
 			);
 
 			it( 'uses remove delta instead of merge delta if merged element is empty', () => {
@@ -450,6 +450,8 @@ describe( 'DataController', () => {
 
 				const schema = doc.schema;
 
+				schema.limits.add( 'restrictedRoot' );
+
 				schema.registerItem( 'image', '$inline' );
 				schema.registerItem( 'paragraph', '$block' );
 				schema.registerItem( 'heading1', '$block' );
@@ -465,6 +467,8 @@ describe( 'DataController', () => {
 			// See also "in simple scenarios => deletes an element".
 
 			it( 'deletes two inline elements', () => {
+				doc.schema.limits.add( 'paragraph' );
+
 				setData(
 					doc,
 					'x[<image></image><image></image>]z',
@@ -659,6 +663,94 @@ describe( 'DataController', () => {
 			);
 		} );
 
+		describe( 'should leave a paragraph if the entire content was selected', () => {
+			beforeEach( () => {
+				doc = new Document();
+				doc.createRoot();
+
+				const schema = doc.schema;
+
+				schema.registerItem( 'div', '$block' );
+				schema.limits.add( 'div' );
+
+				schema.registerItem( 'article', '$block' );
+				schema.limits.add( 'article' );
+
+				schema.registerItem( 'image', '$inline' );
+				schema.objects.add( 'image' );
+
+				schema.registerItem( 'paragraph', '$block' );
+				schema.registerItem( 'heading1', '$block' );
+				schema.registerItem( 'heading2', '$block' );
+
+				schema.allow( { name: '$text', inside: '$root' } );
+
+				schema.allow( { name: 'image', inside: '$root' } );
+				schema.allow( { name: 'image', inside: 'heading1' } );
+				schema.allow( { name: 'heading1', inside: 'div' } );
+				schema.allow( { name: 'paragraph', inside: 'div' } );
+				schema.allow( { name: 'heading1', inside: 'article' } );
+				schema.allow( { name: 'heading2', inside: 'article' } );
+			} );
+
+			test(
+				'but not if only one block was selected',
+				'<heading1>[xx]</heading1>',
+				'<heading1>[]</heading1>'
+			);
+
+			test(
+				'when the entire heading and paragraph were selected',
+				'<heading1>[xx</heading1><paragraph>yy]</paragraph>',
+				'<paragraph>[]</paragraph>'
+			);
+
+			test(
+				'when the entire content was selected',
+				'<heading1>[x</heading1><paragraph>foo</paragraph><paragraph>y]</paragraph>',
+				'<paragraph>[]</paragraph>'
+			);
+
+			test(
+				'inside the limit element when the entire heading and paragraph were inside',
+				'<div><heading1>[xx</heading1><paragraph>yy]</paragraph></div>',
+				'<div><paragraph>[]</paragraph></div>'
+			);
+
+			test(
+				'but not if schema does not accept paragraph in limit element',
+				'<article><heading1>[xx</heading1><heading2>yy]</heading2></article>',
+				'<article><heading1>[]</heading1></article>'
+			);
+
+			test(
+				'but not if selection is not containing the whole content',
+				'<image></image><heading1>[xx</heading1><paragraph>yy]</paragraph>',
+				'<image></image><heading1>[]</heading1>'
+			);
+
+			test(
+				'but not if only single element is selected',
+				'<heading1>[<image></image>xx]</heading1>',
+				'<heading1>[]</heading1>'
+			);
+
+			it( 'when root element was not added as Schema.limits works fine as well', () => {
+				doc.createRoot( 'paragraph', 'paragraphRoot' );
+
+				setData(
+					doc,
+					'x[<image></image><image></image>]z',
+					{ rootName: 'paragraphRoot' }
+				);
+
+				deleteContent( doc.selection, doc.batch() );
+
+				expect( getData( doc, { rootName: 'paragraphRoot' } ) )
+					.to.equal( 'x[]z' );
+			} );
+		} );
+
 		function test( title, input, output, options ) {
 			it( title, () => {
 				setData( doc, input );

+ 22 - 5
packages/ckeditor5-engine/tests/controller/editingcontroller.js

@@ -29,10 +29,18 @@ import { getData as getViewData } from '../../src/dev-utils/view';
 
 describe( 'EditingController', () => {
 	describe( 'constructor()', () => {
-		it( 'should create controller with properties', () => {
-			const model = new ModelDocument();
-			const editing = new EditingController( model );
+		let model, editing;
+
+		beforeEach( () => {
+			model = new ModelDocument();
+			editing = new EditingController( model );
+		} );
+
+		afterEach( () => {
+			editing.destroy();
+		} );
 
+		it( 'should create controller with properties', () => {
 			expect( editing ).to.have.property( 'model' ).that.equals( model );
 			expect( editing ).to.have.property( 'view' ).that.is.instanceof( ViewDocument );
 			expect( editing ).to.have.property( 'mapper' ).that.is.instanceof( Mapper );
@@ -40,9 +48,18 @@ describe( 'EditingController', () => {
 
 			editing.destroy();
 		} );
+
+		it( 'should be observable', () => {
+			const spy = sinon.spy();
+
+			editing.on( 'change:foo', spy );
+			editing.set( 'foo', 'bar' );
+
+			sinon.assert.calledOnce( spy );
+		} );
 	} );
 
-	describe( 'createRoot', () => {
+	describe( 'createRoot()', () => {
 		let model, modelRoot, editing;
 
 		beforeEach( () => {
@@ -378,7 +395,7 @@ describe( 'EditingController', () => {
 		} );
 	} );
 
-	describe( 'destroy', () => {
+	describe( 'destroy()', () => {
 		it( 'should remove listenters', () => {
 			const model = new ModelDocument();
 			model.createRoot();

+ 91 - 16
packages/ckeditor5-engine/tests/model/node.js

@@ -108,7 +108,7 @@ describe( 'Node', () => {
 		} );
 	} );
 
-	describe( 'getIndex', () => {
+	describe( 'getIndex()', () => {
 		it( 'should return null if the parent is null', () => {
 			expect( root.index ).to.be.null;
 		} );
@@ -134,7 +134,7 @@ describe( 'Node', () => {
 		} );
 	} );
 
-	describe( 'clone', () => {
+	describe( 'clone()', () => {
 		it( 'should return a copy of cloned node', () => {
 			const node = new Node( { foo: 'bar' } );
 			const copy = node.clone();
@@ -144,7 +144,7 @@ describe( 'Node', () => {
 		} );
 	} );
 
-	describe( 'remove', () => {
+	describe( 'remove()', () => {
 		it( 'should remove node from it\'s parent', () => {
 			const element = new Element( 'p' );
 			element.appendChildren( node );
@@ -204,7 +204,7 @@ describe( 'Node', () => {
 		} );
 	} );
 
-	describe( 'getPath', () => {
+	describe( 'getPath()', () => {
 		it( 'should return proper path', () => {
 			expect( root.getPath() ).to.deep.equal( [] );
 
@@ -218,27 +218,102 @@ describe( 'Node', () => {
 		} );
 	} );
 
-	describe( 'getAncestors', () => {
+	describe( 'getAncestors()', () => {
 		it( 'should return proper array of ancestor nodes', () => {
 			expect( root.getAncestors() ).to.deep.equal( [] );
 			expect( two.getAncestors() ).to.deep.equal( [ root ] );
 			expect( textBA.getAncestors() ).to.deep.equal( [ root, two ] );
 		} );
 
-		it( 'should include itself if includeNode option is set to true', () => {
-			expect( root.getAncestors( { includeNode: true } ) ).to.deep.equal( [ root ] );
-			expect( two.getAncestors( { includeNode: true } ) ).to.deep.equal( [ root, two ] );
-			expect( textBA.getAncestors( { includeNode: true } ) ).to.deep.equal( [ root, two, textBA ] );
-			expect( img.getAncestors( { includeNode: true } ) ).to.deep.equal( [ root, two, img ] );
-			expect( textR.getAncestors( { includeNode: true } ) ).to.deep.equal( [ root, two, textR ] );
+		it( 'should include itself if includeSelf option is set to true', () => {
+			expect( root.getAncestors( { includeSelf: true } ) ).to.deep.equal( [ root ] );
+			expect( two.getAncestors( { includeSelf: true } ) ).to.deep.equal( [ root, two ] );
+			expect( textBA.getAncestors( { includeSelf: true } ) ).to.deep.equal( [ root, two, textBA ] );
+			expect( img.getAncestors( { includeSelf: true } ) ).to.deep.equal( [ root, two, img ] );
+			expect( textR.getAncestors( { includeSelf: true } ) ).to.deep.equal( [ root, two, textR ] );
 		} );
 
 		it( 'should reverse order if parentFirst option is set to true', () => {
-			expect( root.getAncestors( { includeNode: true, parentFirst: true } ) ).to.deep.equal( [ root ] );
-			expect( two.getAncestors( { includeNode: true, parentFirst: true } ) ).to.deep.equal( [ two, root ] );
-			expect( textBA.getAncestors( { includeNode: true, parentFirst: true } ) ).to.deep.equal( [ textBA, two, root ] );
-			expect( img.getAncestors( { includeNode: true, parentFirst: true } ) ).to.deep.equal( [ img, two, root ] );
-			expect( textR.getAncestors( { includeNode: true, parentFirst: true } ) ).to.deep.equal( [ textR, two, root ] );
+			expect( root.getAncestors( { includeSelf: true, parentFirst: true } ) ).to.deep.equal( [ root ] );
+			expect( two.getAncestors( { includeSelf: true, parentFirst: true } ) ).to.deep.equal( [ two, root ] );
+			expect( textBA.getAncestors( { includeSelf: true, parentFirst: true } ) ).to.deep.equal( [ textBA, two, root ] );
+			expect( img.getAncestors( { includeSelf: true, parentFirst: true } ) ).to.deep.equal( [ img, two, root ] );
+			expect( textR.getAncestors( { includeSelf: true, parentFirst: true } ) ).to.deep.equal( [ textR, two, root ] );
+		} );
+	} );
+
+	describe( 'getCommonAncestor()', () => {
+		it( 'should return the parent element for the same node', () => {
+			expect( img.getCommonAncestor( img ) ).to.equal( two );
+		} );
+
+		it( 'should return the given node for the same node if includeSelf is used', () => {
+			expect( img.getCommonAncestor( img, { includeSelf: true } ) ).to.equal( img );
+		} );
+
+		it( 'should return null for detached subtrees', () => {
+			const detached = new Element( 'foo' );
+
+			expect( img.getCommonAncestor( detached ) ).to.be.null;
+			expect( detached.getCommonAncestor( img ) ).to.be.null;
+
+			expect( img.getCommonAncestor( detached, { includeSelf: true } ) ).to.be.null;
+			expect( detached.getCommonAncestor( img, { includeSelf: true } ) ).to.be.null;
+		} );
+
+		it( 'should return null when one of the nodes is a tree root itself', () => {
+			expect( root.getCommonAncestor( img ) ).to.be.null;
+			expect( img.getCommonAncestor( root ) ).to.be.null;
+			expect( root.getCommonAncestor( root ) ).to.be.null;
+		} );
+
+		it( 'should return root when one of the nodes is a tree root itself and includeSelf is used', () => {
+			expect( root.getCommonAncestor( img, { includeSelf: true } ) ).to.equal( root );
+			expect( img.getCommonAncestor( root, { includeSelf: true } ) ).to.equal( root );
+			expect( root.getCommonAncestor( root, { includeSelf: true } ) ).to.equal( root );
+		} );
+
+		it( 'should return parent of the nodes at the same level', () => {
+			expect( img.getCommonAncestor( textBA ), 1 ).to.equal( two );
+			expect( textR.getCommonAncestor( textBA ), 2 ).to.equal( two );
+
+			expect( img.getCommonAncestor( textBA, { includeSelf: true } ), 3 ).to.equal( two );
+			expect( textR.getCommonAncestor( textBA, { includeSelf: true } ), 4 ).to.equal( two );
+		} );
+
+		it( 'should return proper element for nodes in different branches and on different levels', () => {
+			const foo = new Text( 'foo' );
+			const bar = new Text( 'bar' );
+			const bom = new Text( 'bom' );
+			const d = new Element( 'd', null, [ bar ] );
+			const c = new Element( 'c', null, [ foo, d ] );
+			const b = new Element( 'b', null, [ c ] );
+			const e = new Element( 'e', null, [ bom ] );
+			const a = new Element( 'a', null, [ b, e ] );
+
+			// <a><b><c>foo<d>bar</d></c></b><e>bom</e></a>
+
+			expect( bar.getCommonAncestor( foo ), 1 ).to.equal( c );
+			expect( foo.getCommonAncestor( d ), 2 ).to.equal( c );
+			expect( c.getCommonAncestor( b ), 3 ).to.equal( a );
+			expect( bom.getCommonAncestor( d ), 4 ).to.equal( a );
+			expect( b.getCommonAncestor( bom ), 5 ).to.equal( a );
+			expect( b.getCommonAncestor( bar ), 6 ).to.equal( a );
+
+			expect( bar.getCommonAncestor( foo, { includeSelf: true } ), 11 ).to.equal( c );
+			expect( foo.getCommonAncestor( d, { includeSelf: true } ), 12 ).to.equal( c );
+			expect( c.getCommonAncestor( b, { includeSelf: true } ), 13 ).to.equal( b );
+			expect( bom.getCommonAncestor( d, { includeSelf: true } ), 14 ).to.equal( a );
+			expect( b.getCommonAncestor( bom, { includeSelf: true } ), 15 ).to.equal( a );
+			expect( b.getCommonAncestor( bar, { includeSelf: true } ), 16 ).to.equal( b );
+		} );
+
+		it( 'should return document fragment', () => {
+			const foo = new Text( 'foo' );
+			const bar = new Text( 'bar' );
+			const df = new DocumentFragment( [ foo, bar ] );
+
+			expect( foo.getCommonAncestor( bar ) ).to.equal( df );
 		} );
 	} );
 

+ 4 - 0
packages/ckeditor5-engine/tests/model/schema/schema.js

@@ -49,6 +49,10 @@ describe( 'Schema', () => {
 			expect( schema.limits ).to.be.instanceOf( Set );
 		} );
 
+		it( 'should mark $root as a limit element', () => {
+			expect( schema.limits.has( '$root' ) ).to.be.true;
+		} );
+
 		describe( '$clipboardHolder', () => {
 			it( 'should allow $block', () => {
 				expect( schema.check( { name: '$block', inside: [ '$clipboardHolder' ] } ) ).to.be.true;

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

@@ -125,12 +125,12 @@ describe( 'TextProxy', () => {
 			expect( textProxy.getAncestors() ).to.deep.equal( [ root, element ] );
 		} );
 
-		it( 'should include itself if includeNode option is set to true', () => {
-			expect( textProxy.getAncestors( { includeNode: true } ) ).to.deep.equal( [ root, element, textProxy ] );
+		it( 'should include itself if includeSelf option is set to true', () => {
+			expect( textProxy.getAncestors( { includeSelf: true } ) ).to.deep.equal( [ root, element, textProxy ] );
 		} );
 
 		it( 'should reverse order if parentFirst option is set to true', () => {
-			expect( textProxy.getAncestors( { includeNode: true, parentFirst: true } ) ).to.deep.equal( [ textProxy, element, root ] );
+			expect( textProxy.getAncestors( { includeSelf: true, parentFirst: true } ) ).to.deep.equal( [ textProxy, element, root ] );
 		} );
 	} );
 

+ 1 - 0
packages/ckeditor5-engine/tests/view/_utils/createdocumentmock.js

@@ -14,6 +14,7 @@ import Selection from '../../../src/view/selection';
 export default function createDocumentMock() {
 	const doc = Object.create( ObservableMixin );
 	doc.set( 'isFocused', false );
+	doc.set( 'isReadOnly', false );
 	doc.selection = new Selection();
 
 	return doc;

+ 5 - 5
packages/ckeditor5-engine/tests/view/attributeelement.js

@@ -138,19 +138,19 @@ describe( 'AttributeElement', () => {
 			expect( attribute.getFillerOffset() ).to.be.null;
 		} );
 
-		it( 'should return position 0 if it is the only nested element in the container and has UIElement inside', () => {
+		it( 'should return offset after all children if it is the only nested element in the container and has UIElement inside', () => {
 			const { selection } = parse(
 				'<container:p><attribute:b><attribute:i>[]<ui:span></ui:span></attribute:i></attribute:b></container:p>' );
 			const attribute = selection.getFirstPosition().parent;
 
-			expect( attribute.getFillerOffset() ).to.equals( 0 );
+			expect( attribute.getFillerOffset() ).to.equal( 1 );
 		} );
 
-		it( 'should return 0 if there is no parent container element and has UIElement inside', () => {
-			const { selection } = parse( '<attribute:b>[]<ui:span></ui:span></attribute:b>' );
+		it( 'should return offset after all children if there is no parent container element and has UIElement inside', () => {
+			const { selection } = parse( '<attribute:b>[]<ui:span></ui:span><ui:span></ui:span></attribute:b>' );
 			const attribute = selection.getFirstPosition().parent;
 
-			expect( attribute.getFillerOffset() ).to.equal( 0 );
+			expect( attribute.getFillerOffset() ).to.equal( 2 );
 		} );
 	} );
 } );

+ 2 - 2
packages/ckeditor5-engine/tests/view/containerelement.js

@@ -52,8 +52,8 @@ describe( 'ContainerElement', () => {
 			expect( parse( '<container:p></container:p>' ).getFillerOffset() ).to.equals( 0 );
 		} );
 
-		it( 'should return position 0 if element contains only ui elements', () => {
-			expect( parse( '<container:p><ui:span></ui:span></container:p>' ).getFillerOffset() ).to.equals( 0 );
+		it( 'should return offset after all children if element contains only ui elements', () => {
+			expect( parse( '<container:p><ui:span></ui:span><ui:span></ui:span></container:p>' ).getFillerOffset() ).to.equals( 2 );
 		} );
 
 		it( 'should return null if element is not empty', () => {

+ 4 - 3
packages/ckeditor5-engine/tests/view/document/document.js

@@ -71,9 +71,10 @@ describe( 'Document', () => {
 		it( 'should create Document with all properties', () => {
 			expect( count( viewDocument.domRoots ) ).to.equal( 0 );
 			expect( count( viewDocument.roots ) ).to.equal( 0 );
-			expect( viewDocument ).to.have.property( 'renderer' ).that.is.instanceOf( Renderer );
-			expect( viewDocument ).to.have.property( 'domConverter' ).that.is.instanceOf( DomConverter );
-			expect( viewDocument ).to.have.property( 'isFocused' ).that.is.false;
+			expect( viewDocument ).to.have.property( 'renderer' ).to.instanceOf( Renderer );
+			expect( viewDocument ).to.have.property( 'domConverter' ).to.instanceOf( DomConverter );
+			expect( viewDocument ).to.have.property( 'isReadOnly' ).to.false;
+			expect( viewDocument ).to.have.property( 'isFocused' ).to.false;
 		} );
 
 		it( 'should add default observers', () => {

+ 13 - 0
packages/ckeditor5-engine/tests/view/editableelement.js

@@ -151,6 +151,19 @@ describe( 'EditableElement', () => {
 
 			expect( isReadOnlySpy.calledOnce ).to.be.true;
 		} );
+
+		it( 'should be bound to the document#isReadOnly', () => {
+			const root = new RootEditableElement( 'div' );
+			root.document = createDocumentMock();
+
+			root.document.isReadOnly = false;
+
+			expect( root.isReadOnly ).to.false;
+
+			root.document.isReadOnly = true;
+
+			expect( root.isReadOnly ).to.true;
+		} );
 	} );
 
 	describe( 'getDocument', () => {

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

@@ -11,6 +11,7 @@ import { setData } from '../../../src/dev-utils/view';
 const viewDocument = new Document();
 
 viewDocument.on( 'keydown', ( evt, data ) => console.log( 'keydown', data ) );
+viewDocument.on( 'keyup', ( evt, data ) => console.log( 'keyup', data ) );
 
 viewDocument.createRoot( document.getElementById( 'editable' ), 'editable' );
 setData( viewDocument, 'foo{}bar', { rootName: 'editable' } );

+ 2 - 0
packages/ckeditor5-engine/tests/view/manual/keyobserver.md

@@ -1,5 +1,7 @@
 * Expected initialization: `foo{}bar`.
 * Press some keys - nothing should be added to editor's contents.
+* When press some key - event `keydown` should be logged. When the key is released - event `keyup` should be logged.
+* You can hold the key in order to check whether `keydown` event is fired multiple times. After releasing the key, `keyup` event should be fired once.
 * Check whether key events are logged to the console with proper data:
   * `keyCode`,
   * `altKey`,

+ 1 - 3
packages/ckeditor5-engine/tests/view/manual/uielement.html

@@ -12,12 +12,10 @@
 		font-size: 8px;
 		border-top: 1px solid #dadada;
 		font-family: sans-serif;
-	}
-
-	.ui-element span {
 		background-color: #7a7a7a;
 		color: white;
 		padding: 1px 5px;
+		display: inline-block;
 	}
 </style>
 <div id="container">

+ 2 - 2
packages/ckeditor5-engine/tests/view/manual/uielement.js

@@ -19,7 +19,7 @@ class MyUIElement extends UIElement {
 
 		root.setAttribute( 'contenteditable', 'false' );
 		root.classList.add( 'ui-element' );
-		root.innerHTML = '<span>END OF PARAGRAPH</span>';
+		root.innerHTML = 'END OF PARAGRAPH';
 
 		return root;
 	}
@@ -33,7 +33,7 @@ class UIElementTestPlugin extends Plugin {
 		// Add some UIElement to each paragraph.
 		editing.modelToView.on( 'insert:paragraph', ( evt, data, consumable, conversionApi ) => {
 			const viewP = conversionApi.mapper.toViewElement( data.item );
-			viewP.appendChildren( new MyUIElement( 'div' ) );
+			viewP.appendChildren( new MyUIElement( 'span' ) );
 		}, { priority: 'lowest' } );
 	}
 }

+ 90 - 15
packages/ckeditor5-engine/tests/view/node.js

@@ -29,7 +29,7 @@ describe( 'Node', () => {
 		root = new Element( null, null, [ one, two, three ] );
 	} );
 
-	describe( 'getNextSibling/getPreviousSibling', () => {
+	describe( 'getNextSibling/getPreviousSibling()', () => {
 		it( 'should return next sibling', () => {
 			expect( root.nextSibling ).to.be.null;
 
@@ -57,7 +57,7 @@ describe( 'Node', () => {
 		} );
 	} );
 
-	describe( 'getAncestors', () => {
+	describe( 'getAncestors()', () => {
 		it( 'should return empty array for node without ancestors', () => {
 			const result = root.getAncestors();
 			expect( result ).to.be.an( 'array' );
@@ -65,7 +65,7 @@ describe( 'Node', () => {
 		} );
 
 		it( 'should return array including node itself if requested', () => {
-			const result = root.getAncestors( { includeNode: true } );
+			const result = root.getAncestors( { includeSelf: true } );
 			expect( result ).to.be.an( 'array' );
 			expect( result.length ).to.equal( 1 );
 			expect( result[ 0 ] ).to.equal( root );
@@ -77,7 +77,7 @@ describe( 'Node', () => {
 			expect( result[ 0 ] ).to.equal( root );
 			expect( result[ 1 ] ).to.equal( two );
 
-			const result2 = charR.getAncestors( { includeNode: true } );
+			const result2 = charR.getAncestors( { includeSelf: true } );
 			expect( result2.length ).to.equal( 3 );
 			expect( result2[ 0 ] ).to.equal( root );
 			expect( result2[ 1 ] ).to.equal( two );
@@ -90,7 +90,7 @@ describe( 'Node', () => {
 			expect( result[ 0 ] ).to.equal( two );
 			expect( result[ 1 ] ).to.equal( root );
 
-			const result2 = charR.getAncestors( { includeNode: true, parentFirst: true } );
+			const result2 = charR.getAncestors( { includeSelf: true, parentFirst: true } );
 			expect( result2.length ).to.equal( 3 );
 			expect( result2[ 2 ] ).to.equal( root );
 			expect( result2[ 1 ] ).to.equal( two );
@@ -109,7 +109,82 @@ describe( 'Node', () => {
 		} );
 	} );
 
-	describe( 'getIndex', () => {
+	describe( 'getCommonAncestor()', () => {
+		it( 'should return the parent element for the same node', () => {
+			expect( img.getCommonAncestor( img ) ).to.equal( two );
+		} );
+
+		it( 'should return the given node for the same node if includeSelf is used', () => {
+			expect( img.getCommonAncestor( img, { includeSelf: true } ) ).to.equal( img );
+		} );
+
+		it( 'should return null for detached subtrees', () => {
+			const detached = new Element( 'foo' );
+
+			expect( img.getCommonAncestor( detached ) ).to.be.null;
+			expect( detached.getCommonAncestor( img ) ).to.be.null;
+
+			expect( img.getCommonAncestor( detached, { includeSelf: true } ) ).to.be.null;
+			expect( detached.getCommonAncestor( img, { includeSelf: true } ) ).to.be.null;
+		} );
+
+		it( 'should return null when one of the nodes is a tree root itself', () => {
+			expect( root.getCommonAncestor( img ) ).to.be.null;
+			expect( img.getCommonAncestor( root ) ).to.be.null;
+			expect( root.getCommonAncestor( root ) ).to.be.null;
+		} );
+
+		it( 'should return root when one of the nodes is a tree root itself and includeSelf is used', () => {
+			expect( root.getCommonAncestor( img, { includeSelf: true } ) ).to.equal( root );
+			expect( img.getCommonAncestor( root, { includeSelf: true } ) ).to.equal( root );
+			expect( root.getCommonAncestor( root, { includeSelf: true } ) ).to.equal( root );
+		} );
+
+		it( 'should return parent of the nodes at the same level', () => {
+			expect( img.getCommonAncestor( charA ), 1 ).to.equal( two );
+			expect( charB.getCommonAncestor( charA ), 2 ).to.equal( two );
+
+			expect( img.getCommonAncestor( charA, { includeSelf: true } ), 3 ).to.equal( two );
+			expect( charB.getCommonAncestor( charA, { includeSelf: true } ), 4 ).to.equal( two );
+		} );
+
+		it( 'should return proper element for nodes in different branches and on different levels', () => {
+			const foo = new Text( 'foo' );
+			const bar = new Text( 'bar' );
+			const bom = new Text( 'bom' );
+			const d = new Element( 'd', null, [ bar ] );
+			const c = new Element( 'c', null, [ foo, d ] );
+			const b = new Element( 'b', null, [ c ] );
+			const e = new Element( 'e', null, [ bom ] );
+			const a = new Element( 'a', null, [ b, e ] );
+
+			// <a><b><c>foo<d>bar</d></c></b><e>bom</e></a>
+
+			expect( bar.getCommonAncestor( foo ), 1 ).to.equal( c );
+			expect( foo.getCommonAncestor( d ), 2 ).to.equal( c );
+			expect( c.getCommonAncestor( b ), 3 ).to.equal( a );
+			expect( bom.getCommonAncestor( d ), 4 ).to.equal( a );
+			expect( b.getCommonAncestor( bom ), 5 ).to.equal( a );
+			expect( b.getCommonAncestor( bar ), 6 ).to.equal( a );
+
+			expect( bar.getCommonAncestor( foo, { includeSelf: true } ), 11 ).to.equal( c );
+			expect( foo.getCommonAncestor( d, { includeSelf: true } ), 12 ).to.equal( c );
+			expect( c.getCommonAncestor( b, { includeSelf: true } ), 13 ).to.equal( b );
+			expect( bom.getCommonAncestor( d, { includeSelf: true } ), 14 ).to.equal( a );
+			expect( b.getCommonAncestor( bom, { includeSelf: true } ), 15 ).to.equal( a );
+			expect( b.getCommonAncestor( bar, { includeSelf: true } ), 16 ).to.equal( b );
+		} );
+
+		it( 'should return document fragment', () => {
+			const foo = new Text( 'foo' );
+			const bar = new Text( 'bar' );
+			const df = new DocumentFragment( [ foo, bar ] );
+
+			expect( foo.getCommonAncestor( bar ) ).to.equal( df );
+		} );
+	} );
+
+	describe( 'getIndex()', () => {
 		it( 'should return null if the parent is null', () => {
 			expect( root.index ).to.be.null;
 		} );
@@ -139,7 +214,7 @@ describe( 'Node', () => {
 		} );
 	} );
 
-	describe( 'getDocument', () => {
+	describe( 'getDocument()', () => {
 		it( 'should return null if any parent has not set Document', () => {
 			expect( charA.document ).to.be.null;
 		} );
@@ -164,7 +239,7 @@ describe( 'Node', () => {
 		} );
 	} );
 
-	describe( 'getRoot', () => {
+	describe( 'getRoot()', () => {
 		it( 'should return this element if it has no parent', () => {
 			const child = new Element( 'p' );
 
@@ -183,7 +258,7 @@ describe( 'Node', () => {
 		} );
 	} );
 
-	describe( 'remove', () => {
+	describe( 'remove()', () => {
 		it( 'should remove node from its parent', () => {
 			const char = new Text( 'a' );
 			const parent = new Element( 'p', null, [ char ] );
@@ -246,7 +321,7 @@ describe( 'Node', () => {
 			sinon.assert.calledWith( rootChangeSpy, 'attributes', img );
 		} );
 
-		describe( 'setAttribute', () => {
+		describe( 'setAttribute()', () => {
 			it( 'should fire change event', () => {
 				img.setAttribute( 'width', 100 );
 
@@ -255,7 +330,7 @@ describe( 'Node', () => {
 			} );
 		} );
 
-		describe( 'removeAttribute', () => {
+		describe( 'removeAttribute()', () => {
 			it( 'should fire change event', () => {
 				img.removeAttribute( 'src' );
 
@@ -264,7 +339,7 @@ describe( 'Node', () => {
 			} );
 		} );
 
-		describe( 'insertChildren', () => {
+		describe( 'insertChildren()', () => {
 			it( 'should fire change event', () => {
 				root.insertChildren( 1, new Element( 'img' ) );
 
@@ -273,7 +348,7 @@ describe( 'Node', () => {
 			} );
 		} );
 
-		describe( 'appendChildren', () => {
+		describe( 'appendChildren()', () => {
 			it( 'should fire change event', () => {
 				root.appendChildren( new Element( 'img' ) );
 
@@ -282,7 +357,7 @@ describe( 'Node', () => {
 			} );
 		} );
 
-		describe( 'removeChildren', () => {
+		describe( 'removeChildren()', () => {
 			it( 'should fire change event', () => {
 				root.removeChildren( 1, 1 );
 
@@ -291,7 +366,7 @@ describe( 'Node', () => {
 			} );
 		} );
 
-		describe( 'removeChildren', () => {
+		describe( 'removeChildren()', () => {
 			it( 'should fire change event', () => {
 				text.data = 'bar';
 

+ 50 - 4
packages/ckeditor5-engine/tests/view/observer/keyobserver.js

@@ -22,7 +22,8 @@ describe( 'KeyObserver', () => {
 	} );
 
 	it( 'should define domEventType', () => {
-		expect( observer.domEventType ).to.equal( 'keydown' );
+		expect( observer.domEventType ).to.contains( 'keydown' );
+		expect( observer.domEventType ).to.contains( 'keyup' );
 	} );
 
 	describe( 'onDomEvent', () => {
@@ -31,7 +32,15 @@ describe( 'KeyObserver', () => {
 
 			viewDocument.on( 'keydown', spy );
 
-			observer.onDomEvent( { target: document.body, keyCode: 111, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false } );
+			observer.onDomEvent( {
+				type: 'keydown',
+				target: document.body,
+				keyCode: 111,
+				altKey: false,
+				ctrlKey: false,
+				metaKey: false,
+				shiftKey: false
+			} );
 
 			expect( spy.calledOnce ).to.be.true;
 
@@ -52,7 +61,15 @@ describe( 'KeyObserver', () => {
 
 			viewDocument.on( 'keydown', spy );
 
-			observer.onDomEvent( { target: document.body, keyCode: 111, altKey: true, ctrlKey: true, metaKey: false, shiftKey: true } );
+			observer.onDomEvent( {
+				type: 'keydown',
+				target: document.body,
+				keyCode: 111,
+				altKey: true,
+				ctrlKey: true,
+				metaKey: false,
+				shiftKey: true
+			} );
 
 			const data = spy.args[ 0 ][ 1 ];
 			expect( data ).to.have.property( 'keyCode', 111 );
@@ -70,10 +87,39 @@ describe( 'KeyObserver', () => {
 
 			viewDocument.on( 'keydown', spy );
 
-			observer.onDomEvent( { target: document.body, keyCode: 111, metaKey: true } );
+			observer.onDomEvent( { type: 'keydown', target: document.body, keyCode: 111, metaKey: true } );
 
 			const data = spy.args[ 0 ][ 1 ];
 			expect( data ).to.have.property( 'ctrlKey', true );
 		} );
+
+		it( 'should fire keyup with the target and key info', () => {
+			const spy = sinon.spy();
+
+			viewDocument.on( 'keyup', spy );
+
+			observer.onDomEvent( {
+				type: 'keyup',
+				target: document.body,
+				keyCode: 111,
+				altKey: false,
+				ctrlKey: false,
+				metaKey: false,
+				shiftKey: false
+			} );
+
+			expect( spy.calledOnce ).to.be.true;
+
+			const data = spy.args[ 0 ][ 1 ];
+			expect( data ).to.have.property( 'domTarget', document.body );
+			expect( data ).to.have.property( 'keyCode', 111 );
+			expect( data ).to.have.property( 'altKey', false );
+			expect( data ).to.have.property( 'ctrlKey', false );
+			expect( data ).to.have.property( 'shiftKey', false );
+			expect( data ).to.have.property( 'keystroke', getCode( data ) );
+
+			// Just to be sure.
+			expect( getCode( data ) ).to.equal( 111 );
+		} );
 	} );
 } );

+ 20 - 0
packages/ckeditor5-engine/tests/view/observer/selectionobserver.js

@@ -134,6 +134,26 @@ describe( 'SelectionObserver', () => {
 		changeDomSelection();
 	} );
 
+	it( 'should fired if there is no focus but document is read-only', done => {
+		const spy = sinon.spy();
+
+		viewDocument.isFocused = false;
+		viewDocument.isReadOnly = true;
+
+		// changeDomSelection() may focus the editable element (happens on Chrome)
+		// so cancel this because it sets the isFocused flag.
+		viewDocument.on( 'focus', evt => evt.stop(), { priority: 'highest' } );
+
+		viewDocument.on( 'selectionChange', spy );
+
+		setTimeout( () => {
+			sinon.assert.calledOnce( spy );
+			done();
+		}, 70 );
+
+		changeDomSelection();
+	} );
+
 	it( 'should warn and not enter infinite loop', () => {
 		// Selectionchange event is called twice per `changeDomSelection()` execution.
 		let counter = 35;

+ 8 - 0
packages/ckeditor5-engine/tests/view/position.js

@@ -594,6 +594,14 @@ describe( 'Position', () => {
 			test( firstPosition, secondPosition, section );
 		} );
 
+		it( 'for two positions in different trees returns null', () => {
+			const div = new Element( 'div' );
+			const posInDiv = new Position( div, 0 );
+			const firstPosition = new Position( liOl2, 10 );
+
+			test( posInDiv, firstPosition, null );
+		} );
+
 		function test( positionA, positionB, lca ) {
 			expect( positionA.getCommonAncestor( positionB ) ).to.equal( lca );
 			expect( positionB.getCommonAncestor( positionA ) ).to.equal( lca );

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

@@ -7,6 +7,7 @@
 
 import ViewDocument from '../../src/view/document';
 import ViewElement from '../../src/view/element';
+import ViewContainerElement from '../../src/view/containerelement';
 import ViewAttributeElement from '../../src/view/attributeelement';
 import ViewText from '../../src/view/text';
 import ViewRange from '../../src/view/range';
@@ -1216,6 +1217,44 @@ describe( 'Renderer', () => {
 			} ).to.throw( CKEditorError, /^view-renderer-filler-was-lost/ );
 		} );
 
+		// #1014.
+		it( 'should create inline filler in newly created dom nodes', () => {
+			// 1. Create the view structure which needs inline filler.
+			const inputView =
+				'<container:ul>' +
+					'<container:li>Foobar.</container:li>' +
+					'<container:li>[]<container:div></container:div></container:li>' +
+				'</container:ul>';
+
+			const { view: view, selection: newSelection } = parse( inputView );
+
+			viewRoot.appendChildren( view );
+			selection.setTo( newSelection );
+
+			renderer.markToSync( 'children', viewRoot );
+			renderer.render();
+
+			// 2. Check if filler element has been (correctly) created.
+			expect( domRoot.innerHTML.indexOf( INLINE_FILLER ) ).not.to.equal( -1 );
+
+			// 3. Move the inline filler parent to a newly created element.
+			const viewLi = view.getChild( 0 );
+			const viewLiIndented = view.removeChildren( 1, 1 ); // Array with one element.
+			const viewUl = new ViewContainerElement( 'ul', null, viewLiIndented );
+			viewLi.appendChildren( viewUl );
+
+			// 4. Mark changed items and render the view.
+			renderer.markToSync( 'children', view );
+			renderer.markToSync( 'children', viewLi );
+			renderer.render();
+
+			expect( domRoot.innerHTML.indexOf( INLINE_FILLER ) ).not.to.equal( -1 );
+
+			const domSelection = document.getSelection();
+
+			expect( domSelection.getRangeAt( 0 ).startOffset ).to.equal( 7 ); // After inline filler.
+		} );
+
 		it( 'should handle focusing element', () => {
 			const domFocusSpy = testUtils.sinon.spy( domRoot, 'focus' );
 			const editable = selection.editableElement;

+ 4 - 4
packages/ckeditor5-engine/tests/view/textproxy.js

@@ -129,8 +129,8 @@ describe( 'TextProxy', () => {
 			expect( result[ 1 ] ).to.equal( wrapper );
 		} );
 
-		it( 'should return array including node itself `includeNode`', () => {
-			const result = textProxy.getAncestors( { includeNode: true } );
+		it( 'should return array including node itself `includeSelf`', () => {
+			const result = textProxy.getAncestors( { includeSelf: true } );
 
 			expect( result ).to.be.an( 'array' );
 			expect( result ).to.length( 3 );
@@ -139,8 +139,8 @@ describe( 'TextProxy', () => {
 			expect( result[ 2 ] ).to.equal( text );
 		} );
 
-		it( 'should return array of ancestors including node itself `includeNode` starting from parent `parentFirst`', () => {
-			const result = textProxy.getAncestors( { includeNode: true, parentFirst: true } );
+		it( 'should return array of ancestors including node itself `includeSelf` starting from parent `parentFirst`', () => {
+			const result = textProxy.getAncestors( { includeSelf: true, parentFirst: true } );
 
 			expect( result.length ).to.equal( 3 );
 			expect( result[ 0 ] ).to.equal( text );

+ 12 - 5
packages/ckeditor5-engine/tests/view/utils-tests/createdocumentmock.js

@@ -6,19 +6,26 @@
 import createDocumentMock from '../../../tests/view/_utils/createdocumentmock';
 
 describe( 'createDocumentMock', () => {
-	it( 'should create document mock', done => {
+	it( 'should create document mock', () => {
 		const docMock = createDocumentMock();
 		const rootMock = {};
 
+		const isFocusedSpy = sinon.spy();
+		const isReadOnlySpy = sinon.spy();
+
 		docMock.on( 'change:selectedEditable', ( evt, key, value ) => {
 			expect( value ).to.equal( rootMock );
 		} );
 
-		docMock.on( 'change:isFocused', ( evt, key, value ) => {
-			expect( value ).to.be.true;
-			done();
-		} );
+		docMock.on( 'change:isFocused', isFocusedSpy );
+		docMock.on( 'change:isReadOnly', isReadOnlySpy );
 
 		docMock.isFocused = true;
+		docMock.isReadOnly = true;
+
+		sinon.assert.calledOnce( isFocusedSpy );
+		expect( isFocusedSpy.lastCall.args[ 2 ] ).to.true;
+		sinon.assert.calledOnce( isReadOnlySpy );
+		expect( isReadOnlySpy.lastCall.args[ 2 ] ).to.true;
 	} );
 } );