Selaa lähdekoodia

Merge branch 'master' into t/1015

Szymon Kupś 8 vuotta sitten
vanhempi
sitoutus
d342ddbc16
29 muutettua tiedostoa jossa 914 lisäystä ja 206 poistoa
  1. 3 8
      packages/ckeditor5-engine/src/controller/deletecontent.js
  2. 1 1
      packages/ckeditor5-engine/src/controller/modifyselection.js
  3. 24 0
      packages/ckeditor5-engine/src/conversion/model-selection-to-view-converters.js
  4. 11 1
      packages/ckeditor5-engine/src/conversion/model-to-view-converters.js
  5. 16 3
      packages/ckeditor5-engine/src/model/delta/basic-transformations.js
  6. 5 2
      packages/ckeditor5-engine/src/model/liverange.js
  7. 15 0
      packages/ckeditor5-engine/src/model/range.js
  8. 61 10
      packages/ckeditor5-engine/src/model/selection.js
  9. 16 0
      packages/ckeditor5-engine/src/view/document.js
  10. 4 1
      packages/ckeditor5-engine/src/view/element.js
  11. 2 2
      packages/ckeditor5-engine/src/view/observer/mutationobserver.js
  12. 16 1
      packages/ckeditor5-engine/src/view/range.js
  13. 45 12
      packages/ckeditor5-engine/src/view/selection.js
  14. 64 0
      packages/ckeditor5-engine/tests/conversion/model-selection-to-view-converters.js
  15. 61 4
      packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js
  16. 7 7
      packages/ckeditor5-engine/tests/dev-utils/model.js
  17. 24 0
      packages/ckeditor5-engine/tests/model/delta/transform/removedelta.js
  18. 29 0
      packages/ckeditor5-engine/tests/model/delta/transform/splitdelta.js
  19. 10 10
      packages/ckeditor5-engine/tests/model/documentselection.js
  20. 5 1
      packages/ckeditor5-engine/tests/model/liverange.js
  21. 22 0
      packages/ckeditor5-engine/tests/model/range.js
  22. 153 26
      packages/ckeditor5-engine/tests/model/selection.js
  23. 21 0
      packages/ckeditor5-engine/tests/view/document/document.js
  24. 148 81
      packages/ckeditor5-engine/tests/view/document/jumpoveruielement.js
  25. 4 4
      packages/ckeditor5-engine/tests/view/element.js
  26. 27 2
      packages/ckeditor5-engine/tests/view/manual/uielement.js
  27. 2 2
      packages/ckeditor5-engine/tests/view/observer/selectionobserver.js
  28. 22 0
      packages/ckeditor5-engine/tests/view/range.js
  29. 96 28
      packages/ckeditor5-engine/tests/view/selection.js

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

@@ -70,7 +70,7 @@ export default function deleteContent( selection, batch, options = {} ) {
 		mergeBranches( batch, startPos, endPos );
 	}
 
-	selection.collapse( startPos );
+	selection.setCollapsedAt( startPos );
 
 	// 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).
@@ -181,7 +181,7 @@ function insertParagraph( batch, position, selection ) {
 	const paragraph = new Element( 'paragraph' );
 	batch.insert( position, paragraph );
 
-	selection.collapse( paragraph );
+	selection.setCollapsedAt( paragraph );
 }
 
 function replaceEntireContentWithParagraph( batch, selection ) {
@@ -197,13 +197,8 @@ function replaceEntireContentWithParagraph( batch, selection ) {
 // * whether the paragraph is allowed in schema in the common ancestor.
 function shouldEntireContentBeReplacedWithParagraph( schema, selection ) {
 	const limitElement = schema.getLimitElement( selection );
-	const limitStartPosition = Position.createAt( limitElement );
-	const limitEndPosition = Position.createAt( limitElement, 'end' );
 
-	if (
-		!limitStartPosition.isTouching( selection.getFirstPosition() ) ||
-		!limitEndPosition.isTouching( selection.getLastPosition() )
-	) {
+	if ( !selection.isEntireContentSelected( limitElement ) ) {
 		return false;
 	}
 

+ 1 - 1
packages/ckeditor5-engine/src/controller/modifyselection.js

@@ -64,7 +64,7 @@ export default function modifySelection( dataController, selection, options = {}
 		const position = tryExtendingTo( data, next.value );
 
 		if ( position ) {
-			selection.setFocus( position );
+			selection.moveFocusTo( position );
 
 			return;
 		}

+ 24 - 0
packages/ckeditor5-engine/src/conversion/model-selection-to-view-converters.js

@@ -194,12 +194,36 @@ function wrapCollapsedSelectionPosition( modelSelection, viewSelection, viewElem
 	}
 
 	let viewPosition = viewSelection.getFirstPosition();
+
+	// This hack is supposed to place attribute element *after* all ui elements if the attribute element would be
+	// the only non-ui child and thus receive a block filler.
+	// This is needed to properly render ui elements. Block filler is a <br /> element. If it is placed before
+	// UI element, the ui element will most probably be incorrectly rendered (in next line). #1072.
+	if ( shouldPushAttributeElement( viewPosition.parent ) ) {
+		viewPosition = viewPosition.getLastMatchingPosition( value => value.item.is( 'uiElement' ) );
+	}
+	// End of hack.
+
 	viewPosition = viewWriter.wrapPosition( viewPosition, viewElement );
 
 	viewSelection.removeAllRanges();
 	viewSelection.addRange( new ViewRange( viewPosition, viewPosition ) );
 }
 
+function shouldPushAttributeElement( parent ) {
+	if ( !parent.is( 'element' ) ) {
+		return false;
+	}
+
+	for ( const child of parent.getChildren() ) {
+		if ( !child.is( 'uiElement' ) ) {
+			return false;
+		}
+	}
+
+	return true;
+}
+
 /**
  * Function factory, creates a converter that clears artifacts after the previous
  * {@link module:engine/model/selection~Selection model selection} conversion. It removes all empty

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

@@ -7,6 +7,7 @@ import ViewElement from '../view/element';
 import ViewAttributeElement from '../view/attributeelement';
 import ViewText from '../view/text';
 import ViewRange from '../view/range';
+import ViewPosition from '../view/position';
 import ViewTreeWalker from '../view/treewalker';
 import viewWriter from '../view/writer';
 import ModelRange from '../model/range';
@@ -355,13 +356,22 @@ export function remove() {
 		// end of that range is incorrect.
 		// Instead we will use `data.sourcePosition` as this is the last correct model position and
 		// it is a position before the removed item. Then, we will calculate view range to remove "manually".
-		const viewPosition = conversionApi.mapper.toViewPosition( data.sourcePosition );
+		let viewPosition = conversionApi.mapper.toViewPosition( data.sourcePosition );
 		let viewRange;
 
 		if ( data.item.is( 'element' ) ) {
 			// Note: in remove conversion we cannot use model-to-view element mapping because `data.item` may be
 			// already mapped to another element (this happens when move change is converted).
 			// In this case however, `viewPosition` is the position before view element that corresponds to removed model element.
+			//
+			// First, fix the position. Traverse the tree forward until the container element is found. The `viewPosition`
+			// may be before a ui element, before attribute element or at the end of text element.
+			viewPosition = viewPosition.getLastMatchingPosition( value => !value.item.is( 'containerElement' ) );
+
+			if ( viewPosition.parent.is( 'text' ) && viewPosition.isAtEnd ) {
+				viewPosition = ViewPosition.createAfter( viewPosition.parent );
+			}
+
 			viewRange = ViewRange.createOn( viewPosition.nodeAfter );
 		} else {
 			// If removed item is a text node, we need to traverse view tree to find the view range to remove.

+ 16 - 3
packages/ckeditor5-engine/src/model/delta/basic-transformations.js

@@ -390,7 +390,13 @@ addTransformationCase( SplitDelta, RenameDelta, ( a, b, context ) => {
 // Add special case for RemoveDelta x SplitDelta transformation.
 addTransformationCase( RemoveDelta, SplitDelta, ( a, b, context ) => {
 	const deltas = defaultTransform( a, b, context );
-	const insertPosition = b._cloneOperation.position;
+	// The "clone operation" may be InsertOperation, ReinsertOperation, MoveOperation or NoOperation.
+	const insertPosition = b._cloneOperation.position || b._cloneOperation.targetPosition;
+
+	// NoOperation.
+	if ( !insertPosition ) {
+		return defaultTransform( a, b, context );
+	}
 
 	// In case if `defaultTransform` returned more than one delta.
 	for ( const delta of deltas ) {
@@ -413,9 +419,16 @@ addTransformationCase( SplitDelta, RemoveDelta, ( a, b, context ) => {
 	// This case is very trickily solved.
 	// Instead of fixing `a` delta, we change `b` delta for a while and fire default transformation with fixed `b` delta.
 	// Thanks to that fixing `a` delta will be differently (correctly) transformed.
-	b = b.clone();
+	//
+	// The "clone operation" may be InsertOperation, ReinsertOperation, MoveOperation or NoOperation.
+	const insertPosition = a._cloneOperation.position || a._cloneOperation.targetPosition;
 
-	const insertPosition = a._cloneOperation.position;
+	// NoOperation.
+	if ( !insertPosition ) {
+		return defaultTransform( a, b, context );
+	}
+
+	b = b.clone();
 	const operation = b._moveOperation;
 	const rangeEnd = operation.sourcePosition.getShiftedBy( operation.howMany );
 

+ 5 - 2
packages/ckeditor5-engine/src/model/liverange.js

@@ -85,6 +85,7 @@ export default class LiveRange extends Range {
 	 * @param {Object} data Object with additional information about the change. Those parameters are passed from
 	 * {@link module:engine/model/document~Document#event:change document change event}.
 	 * @param {String} data.type Change type.
+	 * @param {module:engine/model/batch~Batch} data.batch Batch which changed the live range.
 	 * @param {module:engine/model/range~Range} data.range Range containing the result of applied change.
 	 * @param {module:engine/model/position~Position} data.sourcePosition Source position for move, remove and reinsert change types.
 	 */
@@ -107,7 +108,7 @@ function bindWithDocument() {
 		'change',
 		( event, type, changes, batch, deltaType ) => {
 			if ( supportedTypes.has( type ) ) {
-				transform.call( this, type, deltaType, changes.range, changes.sourcePosition );
+				transform.call( this, type, deltaType, batch, changes.range, changes.sourcePosition );
 			}
 		},
 		{ priority: 'high' }
@@ -122,10 +123,11 @@ function bindWithDocument() {
  * @method transform
  * @param {String} [changeType] Type of change applied to the model document.
  * @param {String} [deltaType] Type of delta which introduced the change.
+ * @param {module:engine/model/batch~Batch} batch Batch which changes the live range.
  * @param {module:engine/model/range~Range} targetRange Range containing the result of applied change.
  * @param {module:engine/model/position~Position} [sourcePosition] Source position for move, remove and reinsert change types.
  */
-function transform( changeType, deltaType, targetRange, sourcePosition ) {
+function transform( changeType, deltaType, batch, targetRange, sourcePosition ) {
 	const howMany = targetRange.end.offset - targetRange.start.offset;
 	let targetPosition = targetRange.start;
 
@@ -159,6 +161,7 @@ function transform( changeType, deltaType, targetRange, sourcePosition ) {
 
 		this.fire( 'change', oldRange, {
 			type: changeType,
+			batch,
 			range: targetRange,
 			sourcePosition
 		} );

+ 15 - 0
packages/ckeditor5-engine/src/model/range.js

@@ -716,6 +716,21 @@ export default class Range {
 		return this.createFromPositionAndShift( Position.createBefore( item ), item.offsetSize );
 	}
 
+	/**
+	 * Creates a collapsed range at given {@link module:engine/model/position~Position position}
+	 * or on the given {@link module:engine/model/item~Item item}.
+	 *
+	 * @param {module:engine/model/item~Item|module:engine/model/position~Position} itemOrPosition
+	 * @param {Number|'end'|'before'|'after'} [offset=0] Offset or one of the flags. Used only when
+	 * first parameter is a {@link module:engine/model/item~Item model item}.
+	 */
+	static createCollapsedAt( itemOrPosition, offset ) {
+		const start = Position.createAt( itemOrPosition, offset );
+		const end = Position.createFromPosition( start );
+
+		return new Range( start, end );
+	}
+
 	/**
 	 * Combines all ranges from the passed array into a one range. At least one range has to be passed.
 	 * Passed ranges must not have common parts.

+ 61 - 10
packages/ckeditor5-engine/src/model/selection.js

@@ -15,6 +15,7 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
 import mapsEqual from '@ckeditor/ckeditor5-utils/src/mapsequal';
+import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable';
 
 /**
  * `Selection` is a group of {@link module:engine/model/range~Range ranges} which has a direction specified by
@@ -334,16 +335,47 @@ export default class Selection {
 	}
 
 	/**
-	 * Sets this selection's ranges and direction to the ranges and direction of the given selection.
+	 * Sets this selection's ranges and direction to the specified location based on the given
+	 * {@link module:engine/model/selection~Selection selection}, {@link module:engine/model/position~Position position},
+	 * {@link module:engine/model/range~Range range} or an iterable of {@link module:engine/model/range~Range ranges}.
 	 *
-	 * @param {module:engine/model/selection~Selection} otherSelection
+	 * @param {module:engine/model/selection~Selection|module:engine/model/position~Position|
+	 * Iterable.<module:engine/model/range~Range>|module:engine/model/range~Range} selectable
 	 */
-	setTo( otherSelection ) {
-		this.setRanges( otherSelection.getRanges(), otherSelection.isBackward );
+	setTo( selectable ) {
+		if ( selectable instanceof Selection ) {
+			this.setRanges( selectable.getRanges(), selectable.isBackward );
+		} else if ( selectable instanceof Range ) {
+			this.setRanges( [ selectable ] );
+		} else if ( isIterable( selectable ) ) {
+			// We assume that the selectable is an iterable of ranges.
+			this.setRanges( selectable );
+		} else {
+			// We assume that the selectable is a position.
+			this.setRanges( [ new Range( selectable ) ] );
+		}
+	}
+
+	/**
+	 * Sets this selection in the provided element.
+	 *
+	 * @param {module:engine/model/element~Element} element
+	 */
+	setIn( element ) {
+		this.setRanges( [ Range.createIn( element ) ] );
 	}
 
 	/**
-	 * Sets collapsed selection in the specified location.
+	 * Sets this selection on the provided item.
+	 *
+	 * @param {module:engine/model/item~Item} item
+	 */
+	setOn( item ) {
+		this.setRanges( [ Range.createOn( item ) ] );
+	}
+
+	/**
+	 * Sets collapsed selection at the specified location.
 	 *
 	 * The location can be specified in the same form as {@link module:engine/model/position~Position.createAt} parameters.
 	 *
@@ -352,7 +384,7 @@ export default class Selection {
 	 * @param {Number|'end'|'before'|'after'} [offset=0] Offset or one of the flags. Used only when
 	 * first parameter is a {@link module:engine/model/item~Item model item}.
 	 */
-	collapse( itemOrPosition, offset ) {
+	setCollapsedAt( itemOrPosition, offset ) {
 		const pos = Position.createAt( itemOrPosition, offset );
 		const range = new Range( pos, pos );
 
@@ -390,7 +422,7 @@ export default class Selection {
 	}
 
 	/**
-	 * Sets {@link module:engine/model/selection~Selection#focus} to the specified location.
+	 * Moves {@link module:engine/model/selection~Selection#focus} to the specified location.
 	 *
 	 * The location can be specified in the same form as {@link module:engine/model/position~Position.createAt} parameters.
 	 *
@@ -399,15 +431,15 @@ export default class Selection {
 	 * @param {Number|'end'|'before'|'after'} [offset=0] Offset or one of the flags. Used only when
 	 * first parameter is a {@link module:engine/model/item~Item model item}.
 	 */
-	setFocus( itemOrPosition, offset ) {
+	moveFocusTo( itemOrPosition, offset ) {
 		if ( this.anchor === null ) {
 			/**
 			 * Cannot set selection focus if there are no ranges in selection.
 			 *
-			 * @error model-selection-setFocus-no-ranges
+			 * @error model-selection-moveFocusTo-no-ranges
 			 */
 			throw new CKEditorError(
-				'model-selection-setFocus-no-ranges: Cannot set selection focus if there are no ranges in selection.'
+				'model-selection-moveFocusTo-no-ranges: Cannot set selection focus if there are no ranges in selection.'
 			);
 		}
 
@@ -624,6 +656,25 @@ export default class Selection {
 		}
 	}
 
+	/**
+	 * Checks whether the selection contains the entire content of the given element. This means that selection must start
+	 * at a position {@link module:engine/model/position~Position#isTouching touching} the element's start and ends at position
+	 * touching the element's end.
+	 *
+	 * By default, this method will check whether the entire content of the selection's current root is selected.
+	 * Useful to check if e.g. the user has just pressed <kbd>Ctrl</kbd> + <kbd>A</kbd>.
+	 *
+	 * @param {module:engine/model/element~Element} [element=this.anchor.root]
+	 * @returns {Boolean}
+	 */
+	isEntireContentSelected( element = this.anchor.root ) {
+		const limitStartPosition = Position.createAt( element );
+		const limitEndPosition = Position.createAt( element, 'end' );
+
+		return limitStartPosition.isTouching( this.getFirstPosition() ) &&
+			limitEndPosition.isTouching( this.getLastPosition() );
+	}
+
 	/**
 	 * Creates and returns an instance of `Selection` that is a clone of given selection, meaning that it has same
 	 * ranges and same direction as this selection.

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

@@ -21,6 +21,7 @@ import KeyObserver from './observer/keyobserver';
 import FakeSelectionObserver from './observer/fakeselectionobserver';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
+import { scrollViewportToShowTarget } from '@ckeditor/ckeditor5-utils/src/dom/scroll';
 
 /**
  * Document class creates an abstract layer over the content editable area.
@@ -301,6 +302,21 @@ export default class Document {
 		}
 	}
 
+	/**
+	 * Scrolls the page viewport and {@link #domRoots} with their ancestors to reveal the
+	 * caret, if not already visible to the user.
+	 */
+	scrollToTheSelection() {
+		const range = this.selection.getFirstRange();
+
+		if ( range ) {
+			scrollViewportToShowTarget( {
+				target: this.domConverter.viewRangeToDom( range ),
+				viewportOffset: 20
+			} );
+		}
+	}
+
 	/**
 	 * Disables all added observers.
 	 */

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

@@ -701,7 +701,10 @@ export default class Element extends Node {
 		const styles = Array.from( this._styles ).map( i => `${ i[ 0 ] }:${ i[ 1 ] }` ).sort().join( ';' );
 		const attributes = Array.from( this._attrs ).map( i => `${ i[ 0 ] }="${ i[ 1 ] }"` ).sort().join( ' ' );
 
-		return `${ this.name } class="${ classes }" style="${ styles }"${ attributes == '' ? '' : ' ' + attributes }`;
+		return this.name +
+			( classes == '' ? '' : ` class="${ classes }"` ) +
+			( styles == '' ? '' : ` style="${ styles }"` ) +
+			( attributes == '' ? '' : ` ${ attributes }` );
 	}
 
 	/**

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

@@ -240,8 +240,8 @@ export default class MutationObserver extends Observer {
 			// Anchor and focus has to be properly mapped to view.
 			if ( viewSelectionAnchor && viewSelectionFocus ) {
 				viewSelection = new ViewSelection();
-				viewSelection.collapse( viewSelectionAnchor );
-				viewSelection.setFocus( viewSelectionFocus );
+				viewSelection.setCollapsedAt( viewSelectionAnchor );
+				viewSelection.moveFocusTo( viewSelectionFocus );
 			}
 		}
 

+ 16 - 1
packages/ckeditor5-engine/src/view/range.js

@@ -440,9 +440,24 @@ export default class Range {
 	static createOn( item ) {
 		return this.createFromPositionAndShift( Position.createBefore( item ), 1 );
 	}
+
+	/**
+	 * Creates a collapsed range at given {@link module:engine/view/position~Position position}
+	 * or on the given {@link module:engine/view/item~Item item}.
+	 *
+	 * @param {module:engine/view/item~Item|module:engine/view/position~Position} itemOrPosition
+	 * @param {Number|'end'|'before'|'after'} [offset=0] Offset or one of the flags. Used only when
+	 * first parameter is a {@link module:engine/view/item~Item view item}.
+	 */
+	static createCollapsedAt( itemOrPosition, offset ) {
+		const start = Position.createAt( itemOrPosition, offset );
+		const end = Position.createFromPosition( start );
+
+		return new Range( start, end );
+	}
 }
 
-// Function used by getEnlagred and getTrimmed methods.
+// Function used by getEnlarged and getTrimmed methods.
 function enlargeTrimSkip( value ) {
 	if ( value.item.is( 'attributeElement' ) || value.item.is( 'uiElement' ) ) {
 		return true;

+ 45 - 12
packages/ckeditor5-engine/src/view/selection.js

@@ -14,6 +14,7 @@ import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import Element from './element';
 import count from '@ckeditor/ckeditor5-utils/src/count';
+import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable';
 
 /**
  * Class representing selection in tree view.
@@ -429,19 +430,49 @@ export default class Selection {
 	}
 
 	/**
-	 * Sets this selection's ranges and direction to the ranges and direction of the given selection.
+	 * Sets this selection's ranges and direction to the specified location based on the given
+	 * {@link module:engine/view/selection~Selection selection}, {@link module:engine/view/position~Position position},
+	 * {@link module:engine/view/range~Range range} or an iterable of {@link module:engine/view/range~Range ranges}.
 	 *
-	 * @param {module:engine/view/selection~Selection} otherSelection
+	 * @param {module:engine/view/selection~Selection|module:engine/view/position~Position|
+	 * Iterable.<module:engine/view/range~Range>|module:engine/view/range~Range} selectable
 	 */
-	setTo( otherSelection ) {
-		this._isFake = otherSelection._isFake;
-		this._fakeSelectionLabel = otherSelection._fakeSelectionLabel;
+	setTo( selectable ) {
+		if ( selectable instanceof Selection ) {
+			this._isFake = selectable._isFake;
+			this._fakeSelectionLabel = selectable._fakeSelectionLabel;
+			this.setRanges( selectable.getRanges(), selectable.isBackward );
+		} else if ( selectable instanceof Range ) {
+			this.setRanges( [ selectable ] );
+		} else if ( isIterable( selectable ) ) {
+			// We assume that the selectable is an iterable of ranges.
+			this.setRanges( selectable );
+		} else {
+			// We assume that the selectable is a position.
+			this.setRanges( [ new Range( selectable ) ] );
+		}
+	}
 
-		this.setRanges( otherSelection.getRanges(), otherSelection.isBackward );
+	/**
+	 * Sets this selection in the provided element.
+	 *
+	 * @param {module:engine/view/element~Element} element
+	 */
+	setIn( element ) {
+		this.setRanges( [ Range.createIn( element ) ] );
+	}
+
+	/**
+	 * Sets this selection on the provided item.
+	 *
+	 * @param {module:engine/view/item~Item} item
+	 */
+	setOn( item ) {
+		this.setRanges( [ Range.createOn( item ) ] );
 	}
 
 	/**
-	 * Sets collapsed selection in the specified location.
+	 * Sets collapsed selection at the specified location.
 	 *
 	 * The location can be specified in the same form as {@link module:engine/view/position~Position.createAt} parameters.
 	 *
@@ -450,7 +481,7 @@ export default class Selection {
 	 * @param {Number|'end'|'before'|'after'} [offset=0] Offset or one of the flags. Used only when
 	 * first parameter is a {@link module:engine/view/item~Item view item}.
 	 */
-	collapse( itemOrPosition, offset ) {
+	setCollapsedAt( itemOrPosition, offset ) {
 		const pos = Position.createAt( itemOrPosition, offset );
 		const range = new Range( pos, pos );
 
@@ -488,7 +519,7 @@ export default class Selection {
 	}
 
 	/**
-	 * Sets {@link #focus} to the specified location.
+	 * Moves {@link #focus} to the specified location.
 	 *
 	 * The location can be specified in the same form as {@link module:engine/view/position~Position.createAt} parameters.
 	 *
@@ -497,14 +528,16 @@ export default class Selection {
 	 * @param {Number|'end'|'before'|'after'} [offset=0] Offset or one of the flags. Used only when
 	 * first parameter is a {@link module:engine/view/item~Item view item}.
 	 */
-	setFocus( itemOrPosition, offset ) {
+	moveFocusTo( itemOrPosition, offset ) {
 		if ( this.anchor === null ) {
 			/**
 			 * Cannot set selection focus if there are no ranges in selection.
 			 *
-			 * @error view-selection-setFocus-no-ranges
+			 * @error view-selection-moveFocusTo-no-ranges
 			 */
-			throw new CKEditorError( 'view-selection-setFocus-no-ranges: Cannot set selection focus if there are no ranges in selection.' );
+			throw new CKEditorError(
+				'view-selection-moveFocusTo-no-ranges: Cannot set selection focus if there are no ranges in selection.'
+			);
 		}
 
 		const newFocus = Position.createAt( itemOrPosition, offset );

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

@@ -11,6 +11,7 @@ import ModelPosition from '../../src/model/position';
 import ViewDocument from '../../src/view/document';
 import ViewContainerElement from '../../src/view/containerelement';
 import ViewAttributeElement from '../../src/view/attributeelement';
+import ViewUIElement from '../../src/view/uielement';
 import { mergeAttributes } from '../../src/view/writer';
 
 import Mapper from '../../src/conversion/mapper';
@@ -317,6 +318,69 @@ describe( 'model-selection-to-view-converters', () => {
 					.to.equal( '<div>foo{}bar</div>' );
 			} );
 
+			// #1072 - if the container has only ui elements, collapsed selection attribute should be rendered after those ui elements.
+			it( 'selection with attribute before ui element - no non-ui children', () => {
+				setModelData( modelDoc, '' );
+
+				// Add two ui elements to view.
+				viewRoot.appendChildren( [
+					new ViewUIElement( 'span' ),
+					new ViewUIElement( 'span' )
+				] );
+
+				modelSelection.setRanges( [ new ModelRange( new ModelPosition( modelRoot, [ 0 ] ) ) ] );
+				modelSelection.setAttribute( 'bold', true );
+
+				// Convert model to view.
+				dispatcher.convertSelection( modelSelection, [] );
+
+				// Stringify view and check if it is same as expected.
+				expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
+					.to.equal( '<div><span></span><span></span><strong>[]</strong></div>' );
+			} );
+
+			// #1072.
+			it( 'selection with attribute before ui element - has non-ui children #1', () => {
+				setModelData( modelDoc, 'x' );
+
+				modelSelection.setRanges( [ new ModelRange( new ModelPosition( modelRoot, [ 1 ] ) ) ] );
+				modelSelection.setAttribute( 'bold', true );
+
+				// Convert model to view.
+				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+
+				// Add ui element to view.
+				const uiElement = new ViewUIElement( 'span' );
+				viewRoot.insertChildren( 1, uiElement );
+
+				dispatcher.convertSelection( modelSelection, [] );
+
+				// Stringify view and check if it is same as expected.
+				expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
+					.to.equal( '<div>x<strong>[]</strong><span></span></div>' );
+			} );
+
+			// #1072.
+			it( 'selection with attribute before ui element - has non-ui children #2', () => {
+				setModelData( modelDoc, '<$text bold="true">x</$text>y' );
+
+				modelSelection.setRanges( [ new ModelRange( new ModelPosition( modelRoot, [ 1 ] ) ) ] );
+				modelSelection.setAttribute( 'bold', true );
+
+				// Convert model to view.
+				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+
+				// Add ui element to view.
+				const uiElement = new ViewUIElement( 'span' );
+				viewRoot.insertChildren( 1, uiElement );
+
+				dispatcher.convertSelection( modelSelection, [] );
+
+				// Stringify view and check if it is same as expected.
+				expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
+					.to.equal( '<div><strong>x{}</strong><span></span>y</div>' );
+			} );
+
 			it( 'consumes consumable values properly', () => {
 				// Add callbacks that will fire before default ones.
 				// This should prevent default callbacks doing anything.

+ 61 - 4
packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js

@@ -1036,8 +1036,8 @@ describe( 'model-to-view-converters', () => {
 		} );
 
 		it( 'should not unbind element that has not been moved to graveyard', () => {
-			const modelElement = new ModelElement( 'a' );
-			const viewElement = new ViewElement( 'a' );
+			const modelElement = new ModelElement( 'paragraph' );
+			const viewElement = new ViewContainerElement( 'p' );
 
 			modelRoot.appendChildren( [ modelElement, new ModelText( 'b' ) ] );
 			viewRoot.appendChildren( [ viewElement, new ViewText( 'b' ) ] );
@@ -1064,8 +1064,8 @@ describe( 'model-to-view-converters', () => {
 		} );
 
 		it( 'should unbind elements if model element was moved to graveyard', () => {
-			const modelElement = new ModelElement( 'a' );
-			const viewElement = new ViewElement( 'a' );
+			const modelElement = new ModelElement( 'paragraph' );
+			const viewElement = new ViewContainerElement( 'p' );
 
 			modelRoot.appendChildren( [ modelElement, new ModelText( 'b' ) ] );
 			viewRoot.appendChildren( [ viewElement, new ViewText( 'b' ) ] );
@@ -1133,6 +1133,63 @@ describe( 'model-to-view-converters', () => {
 			expect( mapper.toModelElement( viewWElement ) ).to.be.undefined;
 			expect( mapper.toViewElement( modelWElement ) ).to.be.undefined;
 		} );
+
+		it( 'should work correctly if container element after ui element is removed', () => {
+			const modelP1 = new ModelElement( 'paragraph' );
+			const modelP2 = new ModelElement( 'paragraph' );
+
+			const viewP1 = new ViewContainerElement( 'p' );
+			const viewUi1 = new ViewUIElement( 'span' );
+			const viewUi2 = new ViewUIElement( 'span' );
+			const viewP2 = new ViewContainerElement( 'p' );
+
+			modelRoot.appendChildren( [ modelP1, modelP2 ] );
+			viewRoot.appendChildren( [ viewP1, viewUi1, viewUi2, viewP2 ] );
+
+			mapper.bindElements( modelP1, viewP1 );
+			mapper.bindElements( modelP2, viewP2 );
+
+			dispatcher.on( 'remove', remove() );
+
+			modelWriter.move(
+				ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 2 ),
+				ModelPosition.createAt( modelDoc.graveyard, 'end' )
+			);
+
+			dispatcher.convertRemove(
+				ModelPosition.createFromParentAndOffset( modelRoot, 1 ),
+				ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 1 )
+			);
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p></p><span></span><span></span></div>' );
+		} );
+
+		it( 'should work correctly if container element after text node is removed', () => {
+			const modelText = new ModelText( 'foo' );
+			const modelP = new ModelElement( 'paragraph' );
+
+			const viewText = new ViewText( 'foo' );
+			const viewP = new ViewContainerElement( 'p' );
+
+			modelRoot.appendChildren( [ modelText, modelP ] );
+			viewRoot.appendChildren( [ viewText, viewP ] );
+
+			mapper.bindElements( modelP, viewP );
+
+			dispatcher.on( 'remove', remove() );
+
+			modelWriter.move(
+				ModelRange.createFromParentsAndOffsets( modelRoot, 3, modelRoot, 4 ),
+				ModelPosition.createAt( modelDoc.graveyard, 'end' )
+			);
+
+			dispatcher.convertRemove(
+				ModelPosition.createFromParentAndOffset( modelRoot, 3 ),
+				ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 1 )
+			);
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div>foo</div>' );
+		} );
 	} );
 
 	describe( 'virtualSelectionDescriptorToAttributeElement()', () => {

+ 7 - 7
packages/ckeditor5-engine/tests/dev-utils/model.js

@@ -272,7 +272,7 @@ describe( 'model test utils', () => {
 
 			it( 'writes selection in an empty root', () => {
 				const root = document.createRoot( '$root', 'empty' );
-				selection.collapse( root );
+				selection.setCollapsedAt( root );
 
 				expect( stringify( root, selection ) ).to.equal(
 					'[]'
@@ -280,7 +280,7 @@ describe( 'model test utils', () => {
 			} );
 
 			it( 'writes selection collapsed in an element', () => {
-				selection.collapse( root );
+				selection.setCollapsedAt( root );
 
 				expect( stringify( root, selection ) ).to.equal(
 					'[]<a></a>foo<$text bold="true">bar</$text><b></b>'
@@ -288,7 +288,7 @@ describe( 'model test utils', () => {
 			} );
 
 			it( 'writes selection collapsed in a text', () => {
-				selection.collapse( root, 3 );
+				selection.setCollapsedAt( root, 3 );
 
 				expect( stringify( root, selection ) ).to.equal(
 					'<a></a>fo[]o<$text bold="true">bar</$text><b></b>'
@@ -296,7 +296,7 @@ describe( 'model test utils', () => {
 			} );
 
 			it( 'writes selection collapsed at the text left boundary', () => {
-				selection.collapse( elA, 'after' );
+				selection.setCollapsedAt( elA, 'after' );
 
 				expect( stringify( root, selection ) ).to.equal(
 					'<a></a>[]foo<$text bold="true">bar</$text><b></b>'
@@ -304,7 +304,7 @@ describe( 'model test utils', () => {
 			} );
 
 			it( 'writes selection collapsed at the text right boundary', () => {
-				selection.collapse( elB, 'before' );
+				selection.setCollapsedAt( elB, 'before' );
 
 				expect( stringify( root, selection ) ).to.equal(
 					'<a></a>foo<$text bold="true">bar[]</$text><b></b>'
@@ -312,7 +312,7 @@ describe( 'model test utils', () => {
 			} );
 
 			it( 'writes selection collapsed at the end of the root', () => {
-				selection.collapse( root, 'end' );
+				selection.setCollapsedAt( root, 'end' );
 
 				// Needed due to https://github.com/ckeditor/ckeditor5-engine/issues/320.
 				selection.clearAttributes();
@@ -323,7 +323,7 @@ describe( 'model test utils', () => {
 			} );
 
 			it( 'writes selection collapsed selection in a text with attributes', () => {
-				selection.collapse( root, 5 );
+				selection.setCollapsedAt( root, 5 );
 
 				expect( stringify( root, selection ) ).to.equal(
 					'<a></a>foo<$text bold="true">b[]ar</$text><b></b>'

+ 24 - 0
packages/ckeditor5-engine/tests/model/delta/transform/removedelta.js

@@ -184,6 +184,30 @@ describe( 'transform', () => {
 					]
 				} );
 			} );
+
+			it( 'should not throw if clone operation is NoOperation and use default transformation in that case', () => {
+				const noOpSplitDelta = new SplitDelta();
+				noOpSplitDelta.addOperation( new NoOperation( 0 ) );
+				noOpSplitDelta.addOperation( new MoveOperation( new Position( root, [ 1, 2 ] ), 3, new Position( root, [ 2, 0 ] ), 1 ) );
+
+				const removeDelta = getRemoveDelta( new Position( root, [ 3 ] ), 1, 0 );
+
+				const transformed = transform( removeDelta, noOpSplitDelta, context );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: RemoveDelta,
+					operations: [
+						{
+							type: RemoveOperation,
+							sourcePosition: new Position( root, [ 3 ] ),
+							howMany: 1,
+							baseVersion: 2
+						}
+					]
+				} );
+			} );
 		} );
 	} );
 } );

+ 29 - 0
packages/ckeditor5-engine/tests/model/delta/transform/splitdelta.js

@@ -814,6 +814,35 @@ describe( 'transform', () => {
 					]
 				} );
 			} );
+
+			it( 'should not throw if clone operation is NoOperation and use default transformation in that case', () => {
+				const noOpSplitDelta = new SplitDelta();
+				noOpSplitDelta.addOperation( new NoOperation( 0 ) );
+				noOpSplitDelta.addOperation( new MoveOperation( new Position( root, [ 1, 2 ] ), 3, new Position( root, [ 2, 0 ] ), 1 ) );
+
+				const removeDelta = getRemoveDelta( new Position( root, [ 0 ] ), 1, 0 );
+
+				const transformed = transform( noOpSplitDelta, removeDelta, context );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: NoOperation,
+							baseVersion: 1
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 0, 2 ] ),
+							howMany: 3,
+							targetPosition: new Position( root, [ 1, 0 ] ),
+							baseVersion: 2
+						}
+					]
+				} );
+			} );
 		} );
 	} );
 } );

+ 10 - 10
packages/ckeditor5-engine/tests/model/documentselection.js

@@ -212,13 +212,13 @@ describe( 'DocumentSelection', () => {
 		} );
 	} );
 
-	describe( 'collapse()', () => {
+	describe( 'setCollapsedAt()', () => {
 		it( 'detaches all existing ranges', () => {
 			selection.addRange( range );
 			selection.addRange( liveRange );
 
 			const spy = testUtils.sinon.spy( LiveRange.prototype, 'detach' );
-			selection.collapse( root );
+			selection.setCollapsedAt( root );
 
 			expect( spy.calledTwice ).to.be.true;
 		} );
@@ -244,12 +244,12 @@ describe( 'DocumentSelection', () => {
 		} );
 	} );
 
-	describe( 'setFocus()', () => {
+	describe( 'moveFocusTo()', () => {
 		it( 'modifies default range', () => {
 			const startPos = selection.getFirstPosition();
 			const endPos = Position.createAt( root, 'end' );
 
-			selection.setFocus( endPos );
+			selection.moveFocusTo( endPos );
 
 			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'same' );
 			expect( selection.focus.compareWith( endPos ) ).to.equal( 'same' );
@@ -263,7 +263,7 @@ describe( 'DocumentSelection', () => {
 
 			selection.addRange( new Range( startPos, endPos ) );
 
-			selection.setFocus( newEndPos );
+			selection.moveFocusTo( newEndPos );
 
 			expect( spy.calledOnce ).to.be.true;
 		} );
@@ -637,7 +637,7 @@ describe( 'DocumentSelection', () => {
 
 		describe( 'RemoveOperation', () => {
 			it( 'fix selection range if it ends up in graveyard #1', () => {
-				selection.collapse( new Position( root, [ 1, 3 ] ) );
+				selection.setCollapsedAt( new Position( root, [ 1, 3 ] ) );
 
 				doc.applyOperation( wrapInDelta(
 					new RemoveOperation(
@@ -876,14 +876,14 @@ describe( 'DocumentSelection', () => {
 			} );
 
 			it( 'should overwrite any previously set attributes', () => {
-				selection.collapse( new Position( root, [ 5, 0 ] ) );
+				selection.setCollapsedAt( new Position( root, [ 5, 0 ] ) );
 
 				selection.setAttribute( 'x', true );
 				selection.setAttribute( 'y', true );
 
 				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'd', true ], [ 'x', true ], [ 'y', true ] ] );
 
-				selection.collapse( new Position( root, [ 1 ] ) );
+				selection.setCollapsedAt( new Position( root, [ 1 ] ) );
 
 				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'a', true ] ] );
 			} );
@@ -898,14 +898,14 @@ describe( 'DocumentSelection', () => {
 			} );
 
 			it( 'should not fire change:attribute event if attributes did not change', () => {
-				selection.collapse( new Position( root, [ 5, 0 ] ) );
+				selection.setCollapsedAt( new Position( root, [ 5, 0 ] ) );
 
 				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'd', true ] ] );
 
 				const spy = sinon.spy();
 				selection.on( 'change:attribute', spy );
 
-				selection.collapse( new Position( root, [ 5, 1 ] ) );
+				selection.setCollapsedAt( new Position( root, [ 5, 1 ] ) );
 
 				expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'd', true ] ] );
 				expect( spy.called ).to.be.false;

+ 5 - 1
packages/ckeditor5-engine/tests/model/liverange.js

@@ -102,7 +102,9 @@ describe( 'LiveRange', () => {
 			range: moveRange,
 			sourcePosition: moveSource
 		};
-		doc.fire( 'change', 'move', changes, null );
+		const batch = {};
+
+		doc.fire( 'change', 'move', changes, batch );
 
 		expect( spy.calledOnce ).to.be.true;
 
@@ -110,6 +112,8 @@ describe( 'LiveRange', () => {
 		expect( spy.args[ 0 ][ 1 ].isEqual( copy ) ).to.be.true;
 
 		// Second parameter is an object with data about model changes that caused the live range to change.
+		expect( spy.args[ 0 ][ 2 ].type ).to.equal( 'move' );
+		expect( spy.args[ 0 ][ 2 ].batch ).to.equal( batch );
 		expect( spy.args[ 0 ][ 2 ].range.isEqual( moveRange ) ).to.be.true;
 		expect( spy.args[ 0 ][ 2 ].sourcePosition.isEqual( moveSource ) ).to.be.true;
 	} );

+ 22 - 0
packages/ckeditor5-engine/tests/model/range.js

@@ -176,6 +176,28 @@ describe( 'Range', () => {
 			} );
 		} );
 
+		describe( 'createCollapsedAt()', () => {
+			it( 'should return new collapsed range at the given item position', () => {
+				const item = new Element( 'p', null, new Text( 'foo' ) );
+				const range = Range.createCollapsedAt( item );
+
+				expect( range.start.parent ).to.equal( item );
+				expect( range.start.offset ).to.equal( 0 );
+
+				expect( range.isCollapsed ).to.be.true;
+			} );
+
+			it( 'should return new collapse range at the given item position and offset', () => {
+				const item = new Element( 'p', null, new Text( 'foo' ) );
+				const range = Range.createCollapsedAt( item, 1 );
+
+				expect( range.start.parent ).to.equal( item );
+				expect( range.start.offset ).to.equal( 1 );
+
+				expect( range.isCollapsed ).to.be.true;
+			} );
+		} );
+
 		describe( 'createFromParentsAndOffsets()', () => {
 			it( 'should return range', () => {
 				const range = Range.createFromParentsAndOffsets( root, 0, p, 2 );

+ 153 - 26
packages/ckeditor5-engine/tests/model/selection.js

@@ -234,19 +234,52 @@ describe( 'Selection', () => {
 		} );
 	} );
 
-	describe( 'collapse()', () => {
+	describe( 'setIn()', () => {
+		it( 'should set selection inside an element', () => {
+			const element = new Element( 'p', null, [ new Text( 'foo' ), new Text( 'bar' ) ] );
+
+			selection.setIn( element );
+
+			const ranges = Array.from( selection.getRanges() );
+			expect( ranges.length ).to.equal( 1 );
+			expect( ranges[ 0 ].start.parent ).to.equal( element );
+			expect( ranges[ 0 ].start.offset ).to.deep.equal( 0 );
+			expect( ranges[ 0 ].end.parent ).to.equal( element );
+			expect( ranges[ 0 ].end.offset ).to.deep.equal( 6 );
+		} );
+	} );
+
+	describe( 'setOn()', () => {
+		it( 'should set selection on an item', () => {
+			const textNode1 = new Text( 'foo' );
+			const textNode2 = new Text( 'bar' );
+			const textNode3 = new Text( 'baz' );
+			const element = new Element( 'p', null, [ textNode1, textNode2, textNode3 ] );
+
+			selection.setOn( textNode2 );
+
+			const ranges = Array.from( selection.getRanges() );
+			expect( ranges.length ).to.equal( 1 );
+			expect( ranges[ 0 ].start.parent ).to.equal( element );
+			expect( ranges[ 0 ].start.offset ).to.deep.equal( 3 );
+			expect( ranges[ 0 ].end.parent ).to.equal( element );
+			expect( ranges[ 0 ].end.offset ).to.deep.equal( 6 );
+		} );
+	} );
+
+	describe( 'setCollapsedAt()', () => {
 		it( 'fires change:range', () => {
 			const spy = sinon.spy();
 
 			selection.on( 'change:range', spy );
 
-			selection.collapse( root );
+			selection.setCollapsedAt( root );
 
 			expect( spy.calledOnce ).to.be.true;
 		} );
 
 		it( 'sets selection at the 0 offset if second parameter not passed', () => {
-			selection.collapse( root );
+			selection.setCollapsedAt( root );
 
 			expect( selection ).to.have.property( 'isCollapsed', true );
 
@@ -256,7 +289,7 @@ describe( 'Selection', () => {
 		} );
 
 		it( 'sets selection at given offset in given parent', () => {
-			selection.collapse( root, 3 );
+			selection.setCollapsedAt( root, 3 );
 
 			expect( selection ).to.have.property( 'isCollapsed', true );
 
@@ -266,7 +299,7 @@ describe( 'Selection', () => {
 		} );
 
 		it( 'sets selection at the end of the given parent', () => {
-			selection.collapse( root, 'end' );
+			selection.setCollapsedAt( root, 'end' );
 
 			expect( selection ).to.have.property( 'isCollapsed', true );
 
@@ -276,7 +309,7 @@ describe( 'Selection', () => {
 		} );
 
 		it( 'sets selection before the specified element', () => {
-			selection.collapse( root.getChild( 1 ), 'before' );
+			selection.setCollapsedAt( root.getChild( 1 ), 'before' );
 
 			expect( selection ).to.have.property( 'isCollapsed', true );
 
@@ -286,7 +319,7 @@ describe( 'Selection', () => {
 		} );
 
 		it( 'sets selection after the specified element', () => {
-			selection.collapse( root.getChild( 1 ), 'after' );
+			selection.setCollapsedAt( root.getChild( 1 ), 'after' );
 
 			expect( selection ).to.have.property( 'isCollapsed', true );
 
@@ -298,7 +331,7 @@ describe( 'Selection', () => {
 		it( 'sets selection at the specified position', () => {
 			const pos = Position.createFromParentAndOffset( root, 3 );
 
-			selection.collapse( pos );
+			selection.setCollapsedAt( pos );
 
 			expect( selection ).to.have.property( 'isCollapsed', true );
 
@@ -308,7 +341,7 @@ describe( 'Selection', () => {
 		} );
 	} );
 
-	describe( 'setFocus()', () => {
+	describe( 'moveFocusTo()', () => {
 		it( 'keeps all existing ranges and fires no change:range when no modifications needed', () => {
 			selection.addRange( range );
 			selection.addRange( liveRange );
@@ -316,7 +349,7 @@ describe( 'Selection', () => {
 			const spy = sinon.spy();
 			selection.on( 'change:range', spy );
 
-			selection.setFocus( selection.focus );
+			selection.moveFocusTo( selection.focus );
 
 			expect( count( selection.getRanges() ) ).to.equal( 2 );
 			expect( spy.callCount ).to.equal( 0 );
@@ -328,7 +361,7 @@ describe( 'Selection', () => {
 			const spy = sinon.spy();
 			selection.on( 'change:range', spy );
 
-			selection.setFocus( Position.createAt( root, 'end' ) );
+			selection.moveFocusTo( Position.createAt( root, 'end' ) );
 
 			expect( spy.calledOnce ).to.be.true;
 		} );
@@ -337,17 +370,17 @@ describe( 'Selection', () => {
 			const endPos = Position.createAt( root, 'end' );
 
 			expect( () => {
-				selection.setFocus( endPos );
-			} ).to.throw( CKEditorError, /model-selection-setFocus-no-ranges/ );
+				selection.moveFocusTo( endPos );
+			} ).to.throw( CKEditorError, /model-selection-moveFocusTo-no-ranges/ );
 		} );
 
 		it( 'modifies existing collapsed selection', () => {
 			const startPos = Position.createAt( root, 1 );
 			const endPos = Position.createAt( root, 2 );
 
-			selection.collapse( startPos );
+			selection.setCollapsedAt( startPos );
 
-			selection.setFocus( endPos );
+			selection.moveFocusTo( endPos );
 
 			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'same' );
 			expect( selection.focus.compareWith( endPos ) ).to.equal( 'same' );
@@ -357,9 +390,9 @@ describe( 'Selection', () => {
 			const startPos = Position.createAt( root, 1 );
 			const endPos = Position.createAt( root, 0 );
 
-			selection.collapse( startPos );
+			selection.setCollapsedAt( startPos );
 
-			selection.setFocus( endPos );
+			selection.moveFocusTo( endPos );
 
 			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'same' );
 			expect( selection.focus.compareWith( endPos ) ).to.equal( 'same' );
@@ -373,7 +406,7 @@ describe( 'Selection', () => {
 
 			selection.addRange( new Range( startPos, endPos ) );
 
-			selection.setFocus( newEndPos );
+			selection.moveFocusTo( newEndPos );
 
 			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'same' );
 			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'same' );
@@ -386,7 +419,7 @@ describe( 'Selection', () => {
 
 			selection.addRange( new Range( startPos, endPos ) );
 
-			selection.setFocus( newEndPos );
+			selection.moveFocusTo( newEndPos );
 
 			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'same' );
 			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'same' );
@@ -400,7 +433,7 @@ describe( 'Selection', () => {
 
 			selection.addRange( new Range( startPos, endPos ), true );
 
-			selection.setFocus( newEndPos );
+			selection.moveFocusTo( newEndPos );
 
 			expect( selection.anchor.compareWith( endPos ) ).to.equal( 'same' );
 			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'same' );
@@ -414,7 +447,7 @@ describe( 'Selection', () => {
 
 			selection.addRange( new Range( startPos, endPos ), true );
 
-			selection.setFocus( newEndPos );
+			selection.moveFocusTo( newEndPos );
 
 			expect( selection.anchor.compareWith( endPos ) ).to.equal( 'same' );
 			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'same' );
@@ -437,7 +470,7 @@ describe( 'Selection', () => {
 
 			selection.on( 'change:range', spy );
 
-			selection.setFocus( newEndPos );
+			selection.moveFocusTo( newEndPos );
 
 			const ranges = Array.from( selection.getRanges() );
 
@@ -458,7 +491,7 @@ describe( 'Selection', () => {
 
 			selection.addRange( new Range( startPos, endPos ) );
 
-			selection.setFocus( startPos );
+			selection.moveFocusTo( startPos );
 
 			expect( selection.focus.compareWith( startPos ) ).to.equal( 'same' );
 			expect( selection.isCollapsed ).to.be.true;
@@ -472,7 +505,7 @@ describe( 'Selection', () => {
 
 			selection.addRange( new Range( startPos, endPos ) );
 
-			selection.setFocus( root, 'end' );
+			selection.moveFocusTo( root, 'end' );
 
 			expect( spy.calledOnce ).to.be.true;
 			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'same' );
@@ -588,6 +621,42 @@ describe( 'Selection', () => {
 			expect( selection.setRanges.calledOnce ).to.be.true;
 			spy.restore();
 		} );
+
+		it( 'should set selection on the given Range using setRanges method', () => {
+			const spy = sinon.spy( selection, 'setRanges' );
+
+			selection.setTo( range1 );
+
+			expect( Array.from( selection.getRanges() ) ).to.deep.equal( [ range1 ] );
+			expect( selection.isBackward ).to.be.false;
+			expect( selection.setRanges.calledOnce ).to.be.true;
+			spy.restore();
+		} );
+
+		it( 'should set selection on the given iterable of Ranges using setRanges method', () => {
+			const spy = sinon.spy( selection, 'setRanges' );
+
+			selection.setTo( new Set( [ range1, range2 ] ) );
+
+			expect( Array.from( selection.getRanges() ) ).to.deep.equal( [ range1, range2 ] );
+			expect( selection.isBackward ).to.be.false;
+			expect( selection.setRanges.calledOnce ).to.be.true;
+			spy.restore();
+		} );
+
+		it( 'should set collapsed selection on the given Position using setRanges method', () => {
+			const spy = sinon.spy( selection, 'setRanges' );
+			const position = new Position( root, [ 4 ] );
+
+			selection.setTo( position );
+
+			expect( Array.from( selection.getRanges() ).length ).to.equal( 1 );
+			expect( Array.from( selection.getRanges() )[ 0 ].start ).to.deep.equal( position );
+			expect( selection.isBackward ).to.be.false;
+			expect( selection.isCollapsed ).to.be.true;
+			expect( selection.setRanges.calledOnce ).to.be.true;
+			spy.restore();
+		} );
 	} );
 
 	describe( 'getFirstRange()', () => {
@@ -740,7 +809,7 @@ describe( 'Selection', () => {
 		} );
 
 		it( 'should do nothing if selection was already collapsed', () => {
-			selection.collapse( range1.start );
+			selection.setCollapsedAt( range1.start );
 
 			const spy = sinon.spy( selection, 'fire' );
 
@@ -776,7 +845,7 @@ describe( 'Selection', () => {
 		} );
 
 		it( 'should do nothing if selection was already collapsed', () => {
-			selection.collapse( range1.start );
+			selection.setCollapsedAt( range1.start );
 
 			const spy = sinon.spy( selection, 'fire' );
 
@@ -1237,4 +1306,62 @@ describe( 'Selection', () => {
 			} );
 		} );
 	} );
+
+	describe( 'isEntireContentSelected()', () => {
+		beforeEach( () => {
+			doc.schema.registerItem( 'p', '$block' );
+			doc.schema.allow( { name: 'p', inside: '$root' } );
+		} );
+
+		it( 'returns true if the entire content in $root is selected', () => {
+			setData( doc, '<p>[Foo</p><p>Bom</p><p>Bar]</p>' );
+
+			expect( doc.selection.isEntireContentSelected() ).to.equal( true );
+		} );
+
+		it( 'returns false when only a fragment of the content in $root is selected', () => {
+			setData( doc, '<p>Fo[o</p><p>Bom</p><p>Bar]</p>' );
+
+			expect( doc.selection.isEntireContentSelected() ).to.equal( false );
+		} );
+
+		it( 'returns true if the entire content in specified element is selected', () => {
+			setData( doc, '<p>Foo</p><p>[Bom]</p><p>Bar</p>' );
+
+			const root = doc.getRoot();
+			const secondParagraph = root.getNodeByPath( [ 1 ] );
+
+			expect( doc.selection.isEntireContentSelected( secondParagraph ) ).to.equal( true );
+		} );
+
+		it( 'returns false if the entire content in specified element is not selected', () => {
+			setData( doc, '<p>Foo</p><p>[Bom</p><p>B]ar</p>' );
+
+			const root = doc.getRoot();
+			const secondParagraph = root.getNodeByPath( [ 1 ] );
+
+			expect( doc.selection.isEntireContentSelected( secondParagraph ) ).to.equal( false );
+		} );
+
+		it( 'returns false when the entire content except an empty element is selected', () => {
+			doc.schema.registerItem( 'img', '$inline' );
+			doc.schema.allow( { name: 'img', inside: 'p' } );
+
+			setData( doc, '<p><img></img>[Foo]</p>' );
+
+			expect( doc.selection.isEntireContentSelected() ).to.equal( false );
+		} );
+
+		it( 'returns true if the content is empty', () => {
+			setData( doc, '[]' );
+
+			expect( doc.selection.isEntireContentSelected() ).to.equal( true );
+		} );
+
+		it( 'returns false if empty selection is at the end of non-empty content', () => {
+			setData( doc, '<p>Foo bar bom.</p>[]' );
+
+			expect( doc.selection.isEntireContentSelected() ).to.equal( false );
+		} );
+	} );
 } );

+ 21 - 0
packages/ckeditor5-engine/tests/view/document/document.js

@@ -19,6 +19,7 @@ import DomConverter from '../../../src/view/domconverter';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import count from '@ckeditor/ckeditor5-utils/src/count';
 import log from '@ckeditor/ckeditor5-utils/src/log';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 
 testUtils.createSinonSandbox();
 
@@ -323,6 +324,26 @@ describe( 'Document', () => {
 		} );
 	} );
 
+	describe( 'scrollToTheSelection()', () => {
+		it( 'does nothing when there are no ranges in the selection', () => {
+			const stub = testUtils.sinon.stub( global.window, 'scrollTo' );
+
+			viewDocument.scrollToTheSelection();
+			sinon.assert.notCalled( stub );
+		} );
+
+		it( 'scrolls to the first range in selection with an offset', () => {
+			const stub = testUtils.sinon.stub( global.window, 'scrollTo' );
+			const root = viewDocument.createRoot( document.createElement( 'div' ) );
+			const range = ViewRange.createIn( root );
+
+			viewDocument.selection.addRange( range );
+
+			viewDocument.scrollToTheSelection();
+			sinon.assert.calledWithMatch( stub, sinon.match.number, sinon.match.number );
+		} );
+	} );
+
 	describe( 'disableObservers()', () => {
 		it( 'should disable observers', () => {
 			const addedObserverMock = viewDocument.addObserver( ObserverMock );

+ 148 - 81
packages/ckeditor5-engine/tests/view/document/jumpoveruielement.js

@@ -8,10 +8,10 @@
 import ViewDocument from '../../../src/view/document';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import createElement from '@ckeditor/ckeditor5-utils/src/dom/createelement';
-import { setData } from '../../../src/dev-utils/view';
+import { setData as setViewData } from '../../../src/dev-utils/view';
 
 describe( 'Document', () => {
-	let viewDocument, domRoot;
+	let viewDocument, domRoot, domSelection;
 
 	beforeEach( () => {
 		domRoot = createElement( document, 'div', {
@@ -22,7 +22,8 @@ describe( 'Document', () => {
 		viewDocument = new ViewDocument();
 		viewDocument.createRoot( domRoot );
 
-		document.getSelection().removeAllRanges();
+		domSelection = document.getSelection();
+		domSelection.removeAllRanges();
 
 		viewDocument.isFocused = true;
 	} );
@@ -33,92 +34,158 @@ describe( 'Document', () => {
 		domRoot.parentElement.removeChild( domRoot );
 	} );
 
-	describe( 'jump over ui element handler', () => {
-		it( 'jump over ui element when right arrow is pressed before ui element', () => {
-			setData( viewDocument, '<container:p>foo{}<ui:span></ui:span>bar</container:p>' );
-			viewDocument.render();
-
-			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowright, domTarget: viewDocument.domRoots.get( 'main' ) } );
-
-			const domSelection = document.getSelection();
-
-			expect( domSelection.anchorNode.nodeName.toUpperCase() ).to.equal( 'P' );
-			expect( domSelection.anchorOffset ).to.equal( 2 );
-			expect( domSelection.isCollapsed ).to.be.true;
-		} );
-
-		it( 'should do nothing when another key is pressed', () => {
-			setData( viewDocument, '<container:p>foo<ui:span></ui:span>{}bar</container:p>' );
-			viewDocument.render();
+	function prepare( view, options ) {
+		setViewData( viewDocument, view );
+		viewDocument.render();
 
-			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowleft, domTarget: viewDocument.domRoots.get( 'main' ) } );
+		const eventData = Object.assign( { keyCode: keyCodes.arrowright, domTarget: viewDocument.domRoots.get( 'main' ) }, options );
+		viewDocument.fire( 'keydown', eventData );
+	}
 
-			const domSelection = document.getSelection();
-
-			expect( domSelection.anchorNode.data ).to.equal( 'bar' );
-			expect( domSelection.anchorOffset ).to.equal( 0 );
-			expect( domSelection.isCollapsed ).to.be.true;
-		} );
-
-		it( 'should do nothing if range is not collapsed', () => {
-			setData( viewDocument, '<container:p>f{oo}<ui:span></ui:span>bar</container:p>' );
-			viewDocument.render();
+	function check( anchorNode, anchorOffset, focusNode, focusOffset ) {
+		const anchor = domSelection.anchorNode.data ? domSelection.anchorNode.data : domSelection.anchorNode.nodeName.toUpperCase();
 
-			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowright, domTarget: viewDocument.domRoots.get( 'main' ) } );
-
-			const domSelection = document.getSelection();
+		expect( anchor, 'anchorNode' ).to.equal( anchorNode );
+		expect( domSelection.anchorOffset, 'anchorOffset' ).to.equal( anchorOffset );
 
-			expect( domSelection.anchorNode.data ).to.equal( 'foo' );
-			expect( domSelection.anchorOffset ).to.equal( 1 );
-			expect( domSelection.focusNode.data ).to.equal( 'foo' );
-			expect( domSelection.focusOffset ).to.equal( 3 );
-			expect( domSelection.isCollapsed ).to.be.false;
-		} );
+		if ( focusNode ) {
+			const focus = domSelection.focusNode.data ? domSelection.focusNode.data : domSelection.focusNode.nodeName.toUpperCase();
 
-		it( 'jump over ui element if selection is not collapsed but shift key is pressed', () => {
-			setData( viewDocument, '<container:p>fo{o}<ui:span></ui:span>bar</container:p>' );
-			viewDocument.render();
+			expect( focus, 'focusNode' ).to.equal( focusNode );
+			expect( domSelection.focusOffset, 'focusOffset' ).to.equal( focusOffset );
+		} else {
+			expect( domSelection.isCollapsed, 'isCollapsed' ).to.be.true;
+		}
+	}
 
-			viewDocument.fire(
-				'keydown',
-				{ keyCode: keyCodes.arrowright, shiftKey: true, domTarget: viewDocument.domRoots.get( 'main' ) }
-			);
-
-			const domSelection = document.getSelection();
-
-			expect( domSelection.anchorNode.nodeName.toUpperCase() ).to.equal( '#TEXT' );
-			expect( domSelection.anchorOffset ).to.equal( 2 );
-			expect( domSelection.focusNode.nodeName.toUpperCase() ).to.equal( 'P' );
-			expect( domSelection.focusOffset ).to.equal( 2 );
-		} );
-
-		it( 'jump over ui element if selection is in attribute element', () => {
-			setData( viewDocument, '<container:p><attribute:b>foo{}</attribute:b><ui:span></ui:span>bar</container:p>' );
-			viewDocument.render();
-
-			viewDocument.fire(
-				'keydown',
-				{ keyCode: keyCodes.arrowright, shiftKey: true, domTarget: viewDocument.domRoots.get( 'main' ) }
-			);
-
-			const domSelection = document.getSelection();
-
-			expect( domSelection.anchorNode.nodeName.toUpperCase() ).to.equal( 'P' );
-			expect( domSelection.anchorOffset ).to.equal( 2 );
-			expect( domSelection.isCollapsed ).to.be.true;
+	describe( 'jump over ui element handler', () => {
+		describe( 'collapsed selection', () => {
+			it( 'do nothing when another key is pressed', () => {
+				prepare( '<container:p>foo<ui:span></ui:span>{}bar</container:p>', { keyCode: keyCodes.arrowleft } );
+				check( 'bar', 0 );
+			} );
+
+			it( 'jump over ui element when right arrow is pressed before ui element - directly before ui element', () => {
+				prepare( '<container:p>foo[]<ui:span></ui:span>bar</container:p>' );
+				check( 'P', 2 );
+			} );
+
+			it( 'jump over ui element when right arrow is pressed before ui element - not directly before ui element', () => {
+				prepare( '<container:p>foo{}<ui:span></ui:span>bar</container:p>' );
+				check( 'P', 2 );
+			} );
+
+			it( 'jump over multiple ui elements when right arrow is pressed before ui element', () => {
+				prepare( '<container:p>foo{}<ui:span></ui:span><ui:span></ui:span>bar</container:p>' );
+				check( 'P', 3 );
+			} );
+
+			it( 'jump over ui elements at the end of container element', () => {
+				prepare( '<container:p>foo{}<ui:span></ui:span><ui:span></ui:span></container:p><container:div></container:div>' );
+				check( 'P', 3 );
+			} );
+
+			it( 'jump over ui element if selection is in attribute element - case 1', () => {
+				prepare( '<container:p><attribute:b>foo{}</attribute:b><ui:span></ui:span>bar</container:p>' );
+				check( 'P', 2 );
+			} );
+
+			it( 'jump over ui element if selection is in attribute element - case 2', () => {
+				prepare( '<container:p><attribute:b>foo{}</attribute:b><ui:span></ui:span>bar</container:p>' );
+				check( 'P', 2 );
+			} );
+
+			it( 'jump over ui element if selection is in multiple attribute elements', () => {
+				prepare( '<container:p><attribute:i><attribute:b>foo{}</attribute:b></attribute:i><ui:span></ui:span>bar</container:p>' );
+				check( 'P', 2 );
+			} );
+
+			it( 'jump over empty attribute elements and ui elements', () => {
+				prepare(
+					'<container:p>' +
+						'foo{}<attribute:b></attribute:b><ui:span></ui:span><ui:span></ui:span><attribute:b></attribute:b>bar' +
+					'</container:p>'
+				);
+
+				check( 'P', 5 );
+			} );
+
+			it( 'jump over empty attribute elements and ui elements if shift key is pressed', () => {
+				prepare(
+					'<container:p>' +
+						'foo{}<attribute:b></attribute:b><ui:span></ui:span><ui:span></ui:span><attribute:b></attribute:b>bar' +
+					'</container:p>',
+					{ shiftKey: true }
+				);
+
+				check( 'P', 5 );
+			} );
+
+			it( 'do nothing if selection is not directly before ui element', () => {
+				prepare( '<container:p>fo{}o<ui:span></ui:span>bar</container:p>' );
+				check( 'foo', 2 );
+			} );
+
+			it( 'do nothing if selection is in attribute element but not before ui element', () => {
+				prepare( '<container:p><attribute:b>foo{}</attribute:b>bar</container:p>' );
+				check( 'foo', 3 );
+			} );
+
+			it( 'do nothing if selection is before non-empty attribute element', () => {
+				prepare( '<container:p>fo{}<attribute:b>o</attribute:b><ui:span></ui:span>bar</container:p>' );
+				check( 'fo', 2 );
+			} );
+
+			it( 'do nothing if selection is before container element - case 1', () => {
+				prepare( '<container:p>foo{}</container:p><ui:span></ui:span><container:div>bar</container:div>' );
+				check( 'foo', 3 );
+			} );
+
+			it( 'do nothing if selection is before container element - case 2', () => {
+				prepare( '<container:div>foo{}<container:p></container:p><ui:span></ui:span></container:div>' );
+				check( 'foo', 3 );
+			} );
+
+			it( 'do nothing if selection is at the end of last container element', () => {
+				prepare( '<container:p>foo{}</container:p>' );
+				check( 'foo', 3 );
+			} );
 		} );
 
-		it( 'should do nothing if caret is not directly before ui element', () => {
-			setData( viewDocument, '<container:p>fo{}o<ui:span></ui:span>bar</container:p>' );
-			viewDocument.render();
-
-			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowright, domTarget: viewDocument.domRoots.get( 'main' ) } );
-
-			const domSelection = document.getSelection();
-
-			expect( domSelection.anchorNode.data ).to.equal( 'foo' );
-			expect( domSelection.anchorOffset ).to.equal( 2 );
-			expect( domSelection.isCollapsed ).to.be.true;
+		describe( 'non-collapsed selection', () => {
+			it( 'should do nothing', () => {
+				prepare( '<container:p>f{oo}<ui:span></ui:span>bar</container:p>' );
+				check( 'foo', 1, 'foo', 3 );
+			} );
+
+			it( 'should do nothing if selection is not before ui element - shift key pressed', () => {
+				prepare( '<container:p>f{o}o<ui:span></ui:span>bar</container:p>', { shiftKey: true } );
+				check( 'foo', 1, 'foo', 2 );
+			} );
+
+			it( 'jump over ui element if shift key is pressed', () => {
+				prepare( '<container:p>fo{o}<ui:span></ui:span>bar</container:p>', { shiftKey: true } );
+				check( 'foo', 2, 'P', 2 );
+			} );
+
+			it( 'jump over ui element if selection is in multiple attribute elements', () => {
+				prepare(
+					'<container:p><attribute:i><attribute:b>fo{o}</attribute:b></attribute:i><ui:span></ui:span>bar</container:p>',
+					{ shiftKey: true }
+				);
+				check( 'foo', 2, 'P', 2 );
+			} );
+
+			it( 'jump over empty attribute elements and ui elements if shift key is pressed', () => {
+				prepare(
+					'<container:p>' +
+						'fo{o}<attribute:b></attribute:b><ui:span></ui:span><ui:span></ui:span><attribute:b></attribute:b>bar' +
+					'</container:p>',
+					{ shiftKey: true }
+				);
+
+				check( 'foo', 2, 'P', 5 );
+			} );
 		} );
 
 		it( 'should do nothing if dom position cannot be converted to view position', () => {

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

@@ -986,14 +986,14 @@ describe( 'Element', () => {
 		it( 'should return only name if no other attributes are present', () => {
 			const el = new Element( 'foo' );
 
-			expect( el.getIdentity() ).to.equal( 'foo class="" style=""' );
+			expect( el.getIdentity() ).to.equal( 'foo' );
 		} );
 
 		it( 'should return classes in sorted order', () => {
 			const el = new Element( 'fruit' );
 			el.addClass( 'banana', 'lemon', 'apple' );
 
-			expect( el.getIdentity() ).to.equal( 'fruit class="apple,banana,lemon" style=""' );
+			expect( el.getIdentity() ).to.equal( 'fruit class="apple,banana,lemon"' );
 		} );
 
 		it( 'should return styles in sorted order', () => {
@@ -1001,7 +1001,7 @@ describe( 'Element', () => {
 				style: 'border: 1px solid red; background-color: red'
 			} );
 
-			expect( el.getIdentity() ).to.equal( 'foo class="" style="background-color:red;border:1px solid red"' );
+			expect( el.getIdentity() ).to.equal( 'foo style="background-color:red;border:1px solid red"' );
 		} );
 
 		it( 'should return attributes in sorted order', () => {
@@ -1011,7 +1011,7 @@ describe( 'Element', () => {
 				b: 3
 			} );
 
-			expect( el.getIdentity() ).to.equal( 'foo class="" style="" a="1" b="3" d="4"' );
+			expect( el.getIdentity() ).to.equal( 'foo a="1" b="3" d="4"' );
 		} );
 
 		it( 'should return classes, styles and attributes', () => {

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

@@ -14,8 +14,10 @@ import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
 import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 import Undo from '@ckeditor/ckeditor5-undo/src/undo';
 import UIElement from '../../../src/view/uielement';
+import Position from '../../../src/view/position';
+import writer from '../../../src/view/writer';
 
-class MyUIElement extends UIElement {
+class EndingUIElement extends UIElement {
 	render( domDocument ) {
 		const root = super.render( domDocument );
 
@@ -26,6 +28,17 @@ class MyUIElement extends UIElement {
 	}
 }
 
+class MiddleUIElement extends UIElement {
+	render( domDocument ) {
+		const root = super.render( domDocument );
+
+		root.classList.add( 'ui-element' );
+		root.innerHTML = 'X';
+
+		return root;
+	}
+}
+
 class UIElementTestPlugin extends Plugin {
 	init() {
 		const editor = this.editor;
@@ -34,7 +47,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( 'span' ) );
+			viewP.appendChildren( new EndingUIElement( 'span' ) );
 		}, { priority: 'lowest' } );
 	}
 }
@@ -46,6 +59,18 @@ ClassicEditor
 	} )
 	.then( editor => {
 		window.editor = editor;
+
+		// Add some UI elements.
+		const viewRoot = editor.editing.view.getRoot();
+		const viewText1 = viewRoot.getChild( 0 ).getChild( 0 );
+		const viewText2 = viewRoot.getChild( 1 ).getChild( 0 );
+
+		writer.insert( new Position( viewText1, 20 ), new MiddleUIElement( 'span' ) );
+		writer.insert( new Position( viewText1, 20 ), new MiddleUIElement( 'span' ) );
+		writer.insert( new Position( viewText2, 0 ), new MiddleUIElement( 'span' ) );
+		writer.insert( new Position( viewText2, 6 ), new MiddleUIElement( 'span' ) );
+
+		editor.editing.view.render();
 	} )
 	.catch( err => {
 		console.error( err.stack );

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

@@ -315,8 +315,8 @@ describe( 'SelectionObserver', () => {
 			const viewAnchor = viewDocument.domConverter.domPositionToView( sel.anchorNode, sel.anchorOffset );
 			const viewFocus = viewDocument.domConverter.domPositionToView( sel.focusNode, sel.focusOffset );
 
-			viewSel.collapse( viewAnchor );
-			viewSel.setFocus( viewFocus );
+			viewSel.setCollapsedAt( viewAnchor );
+			viewSel.moveFocusTo( viewFocus );
 
 			viewDocument.render();
 		} );

+ 22 - 0
packages/ckeditor5-engine/tests/view/range.js

@@ -675,6 +675,28 @@ describe( 'Range', () => {
 			} );
 		} );
 
+		describe( 'createCollapsedAt()', () => {
+			it( 'should return new collapsed range at the given item position', () => {
+				const item = new Element( 'p', null, new Text( 'foo' ) );
+				const range = Range.createCollapsedAt( item );
+
+				expect( range.start.parent ).to.equal( item );
+				expect( range.start.offset ).to.equal( 0 );
+
+				expect( range.isCollapsed ).to.be.true;
+			} );
+
+			it( 'should return new collapse range at the given item position and offset', () => {
+				const item = new Element( 'p', null, new Text( 'foo' ) );
+				const range = Range.createCollapsedAt( item, 1 );
+
+				expect( range.start.parent ).to.equal( item );
+				expect( range.start.offset ).to.equal( 1 );
+
+				expect( range.isCollapsed ).to.be.true;
+			} );
+		} );
+
 		describe( 'createFromParentsAndOffsets', () => {
 			it( 'should return range', () => {
 				const range = Range.createFromParentsAndOffsets( div, 0, foz, 1 );

+ 96 - 28
packages/ckeditor5-engine/tests/view/selection.js

@@ -106,10 +106,10 @@ describe( 'Selection', () => {
 		} );
 	} );
 
-	describe( 'setFocus', () => {
+	describe( 'moveFocusTo', () => {
 		it( 'keeps all existing ranges when no modifications needed', () => {
 			selection.addRange( range1 );
-			selection.setFocus( selection.focus );
+			selection.moveFocusTo( selection.focus );
 
 			expect( count( selection.getRanges() ) ).to.equal( 1 );
 		} );
@@ -118,17 +118,17 @@ describe( 'Selection', () => {
 			const endPos = Position.createAt( el, 'end' );
 
 			expect( () => {
-				selection.setFocus( endPos );
-			} ).to.throw( CKEditorError, /view-selection-setFocus-no-ranges/ );
+				selection.moveFocusTo( endPos );
+			} ).to.throw( CKEditorError, /view-selection-moveFocusTo-no-ranges/ );
 		} );
 
 		it( 'modifies existing collapsed selection', () => {
 			const startPos = Position.createAt( el, 1 );
 			const endPos = Position.createAt( el, 2 );
 
-			selection.collapse( startPos );
+			selection.setCollapsedAt( startPos );
 
-			selection.setFocus( endPos );
+			selection.moveFocusTo( endPos );
 
 			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'same' );
 			expect( selection.focus.compareWith( endPos ) ).to.equal( 'same' );
@@ -138,9 +138,9 @@ describe( 'Selection', () => {
 			const startPos = Position.createAt( el, 1 );
 			const endPos = Position.createAt( el, 0 );
 
-			selection.collapse( startPos );
+			selection.setCollapsedAt( startPos );
 
-			selection.setFocus( endPos );
+			selection.moveFocusTo( endPos );
 
 			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'same' );
 			expect( selection.focus.compareWith( endPos ) ).to.equal( 'same' );
@@ -154,7 +154,7 @@ describe( 'Selection', () => {
 
 			selection.addRange( new Range( startPos, endPos ) );
 
-			selection.setFocus( newEndPos );
+			selection.moveFocusTo( newEndPos );
 
 			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'same' );
 			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'same' );
@@ -167,7 +167,7 @@ describe( 'Selection', () => {
 
 			selection.addRange( new Range( startPos, endPos ) );
 
-			selection.setFocus( newEndPos );
+			selection.moveFocusTo( newEndPos );
 
 			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'same' );
 			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'same' );
@@ -181,7 +181,7 @@ describe( 'Selection', () => {
 
 			selection.addRange( new Range( startPos, endPos ), true );
 
-			selection.setFocus( newEndPos );
+			selection.moveFocusTo( newEndPos );
 
 			expect( selection.anchor.compareWith( endPos ) ).to.equal( 'same' );
 			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'same' );
@@ -195,7 +195,7 @@ describe( 'Selection', () => {
 
 			selection.addRange( new Range( startPos, endPos ), true );
 
-			selection.setFocus( newEndPos );
+			selection.moveFocusTo( newEndPos );
 
 			expect( selection.anchor.compareWith( endPos ) ).to.equal( 'same' );
 			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'same' );
@@ -214,7 +214,7 @@ describe( 'Selection', () => {
 			selection.addRange( new Range( startPos1, endPos1 ) );
 			selection.addRange( new Range( startPos2, endPos2 ) );
 
-			selection.setFocus( newEndPos );
+			selection.moveFocusTo( newEndPos );
 
 			const ranges = Array.from( selection.getRanges() );
 
@@ -233,7 +233,7 @@ describe( 'Selection', () => {
 
 			selection.addRange( new Range( startPos, endPos ) );
 
-			selection.setFocus( startPos );
+			selection.moveFocusTo( startPos );
 
 			expect( selection.focus.compareWith( startPos ) ).to.equal( 'same' );
 			expect( selection.isCollapsed ).to.be.true;
@@ -247,7 +247,7 @@ describe( 'Selection', () => {
 			const spy = sinon.stub( Position, 'createAt' ).returns( newEndPos );
 
 			selection.addRange( new Range( startPos, endPos ) );
-			selection.setFocus( el, 'end' );
+			selection.moveFocusTo( el, 'end' );
 
 			expect( spy.calledOnce ).to.be.true;
 			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'same' );
@@ -600,7 +600,7 @@ describe( 'Selection', () => {
 		} );
 	} );
 
-	describe( 'removeAllRanges', () => {
+	describe( 'removeAllRanges()', () => {
 		it( 'should remove all ranges and fire change event', done => {
 			selection.addRange( range1 );
 			selection.addRange( range2 );
@@ -622,7 +622,7 @@ describe( 'Selection', () => {
 		} );
 	} );
 
-	describe( 'setRanges', () => {
+	describe( 'setRanges()', () => {
 		it( 'should throw an error when range is invalid', () => {
 			expect( () => {
 				selection.setRanges( [ { invalid: 'range' } ] );
@@ -645,8 +645,8 @@ describe( 'Selection', () => {
 		} );
 	} );
 
-	describe( 'setTo', () => {
-		it( 'should return true if selections equal', () => {
+	describe( 'setTo()', () => {
+		it( 'should set selection ranges from the given selection', () => {
 			selection.addRange( range1 );
 
 			const otherSelection = new Selection();
@@ -664,6 +664,41 @@ describe( 'Selection', () => {
 			expect( selection.anchor.isEqual( range3.end ) ).to.be.true;
 		} );
 
+		it( 'should set selection on the given Range using setRanges method', () => {
+			const spy = sinon.spy( selection, 'setRanges' );
+
+			selection.setTo( range1 );
+
+			expect( Array.from( selection.getRanges() ) ).to.deep.equal( [ range1 ] );
+			expect( selection.isBackward ).to.be.false;
+			expect( selection.setRanges.calledOnce ).to.be.true;
+			spy.restore();
+		} );
+
+		it( 'should set selection on the given iterable of Ranges using setRanges method', () => {
+			const spy = sinon.spy( selection, 'setRanges' );
+
+			selection.setTo( new Set( [ range1, range2 ] ) );
+
+			expect( Array.from( selection.getRanges() ) ).to.deep.equal( [ range1, range2 ] );
+			expect( selection.isBackward ).to.be.false;
+			expect( selection.setRanges.calledOnce ).to.be.true;
+			spy.restore();
+		} );
+
+		it( 'should set collapsed selection on the given Position using setRanges method', () => {
+			const spy = sinon.spy( selection, 'setRanges' );
+
+			selection.setTo( range1.start );
+
+			expect( Array.from( selection.getRanges() ).length ).to.equal( 1 );
+			expect( Array.from( selection.getRanges() )[ 0 ].start ).to.deep.equal( range1.start );
+			expect( selection.isBackward ).to.be.false;
+			expect( selection.isCollapsed ).to.be.true;
+			expect( selection.setRanges.calledOnce ).to.be.true;
+			spy.restore();
+		} );
+
 		it( 'should fire change event', done => {
 			selection.on( 'change', () => {
 				expect( selection.rangeCount ).to.equal( 1 );
@@ -688,7 +723,40 @@ describe( 'Selection', () => {
 		} );
 	} );
 
-	describe( 'collapse', () => {
+	describe( 'setIn()', () => {
+		it( 'should set selection inside an element', () => {
+			const element = new Element( 'p', null, [ new Text( 'foo' ), new Text( 'bar' ) ] );
+
+			selection.setIn( element );
+
+			const ranges = Array.from( selection.getRanges() );
+			expect( ranges.length ).to.equal( 1 );
+			expect( ranges[ 0 ].start.parent ).to.equal( element );
+			expect( ranges[ 0 ].start.offset ).to.deep.equal( 0 );
+			expect( ranges[ 0 ].end.parent ).to.equal( element );
+			expect( ranges[ 0 ].end.offset ).to.deep.equal( 2 );
+		} );
+	} );
+
+	describe( 'setOn()', () => {
+		it( 'should set selection on an item', () => {
+			const textNode1 = new Text( 'foo' );
+			const textNode2 = new Text( 'bar' );
+			const textNode3 = new Text( 'baz' );
+			const element = new Element( 'p', null, [ textNode1, textNode2, textNode3 ] );
+
+			selection.setOn( textNode2 );
+
+			const ranges = Array.from( selection.getRanges() );
+			expect( ranges.length ).to.equal( 1 );
+			expect( ranges[ 0 ].start.parent ).to.equal( element );
+			expect( ranges[ 0 ].start.offset ).to.deep.equal( 1 );
+			expect( ranges[ 0 ].end.parent ).to.equal( element );
+			expect( ranges[ 0 ].end.offset ).to.deep.equal( 2 );
+		} );
+	} );
+
+	describe( 'setCollapsedAt()', () => {
 		beforeEach( () => {
 			selection.setRanges( [ range1, range2 ] );
 		} );
@@ -696,7 +764,7 @@ describe( 'Selection', () => {
 		it( 'should collapse selection at position', () => {
 			const position = new Position( el, 4 );
 
-			selection.collapse( position );
+			selection.setCollapsedAt( position );
 			const range = selection.getFirstRange();
 
 			expect( range.start.parent ).to.equal( el );
@@ -708,14 +776,14 @@ describe( 'Selection', () => {
 			const foo = new Text( 'foo' );
 			const p = new Element( 'p', null, foo );
 
-			selection.collapse( foo );
+			selection.setCollapsedAt( foo );
 			let range = selection.getFirstRange();
 
 			expect( range.start.parent ).to.equal( foo );
 			expect( range.start.offset ).to.equal( 0 );
 			expect( range.start.isEqual( range.end ) ).to.be.true;
 
-			selection.collapse( p, 1 );
+			selection.setCollapsedAt( p, 1 );
 			range = selection.getFirstRange();
 
 			expect( range.start.parent ).to.equal( p );
@@ -727,21 +795,21 @@ describe( 'Selection', () => {
 			const foo = new Text( 'foo' );
 			const p = new Element( 'p', null, foo );
 
-			selection.collapse( foo, 'end' );
+			selection.setCollapsedAt( foo, 'end' );
 			let range = selection.getFirstRange();
 
 			expect( range.start.parent ).to.equal( foo );
 			expect( range.start.offset ).to.equal( 3 );
 			expect( range.start.isEqual( range.end ) ).to.be.true;
 
-			selection.collapse( foo, 'before' );
+			selection.setCollapsedAt( foo, 'before' );
 			range = selection.getFirstRange();
 
 			expect( range.start.parent ).to.equal( p );
 			expect( range.start.offset ).to.equal( 0 );
 			expect( range.start.isEqual( range.end ) ).to.be.true;
 
-			selection.collapse( foo, 'after' );
+			selection.setCollapsedAt( foo, 'after' );
 			range = selection.getFirstRange();
 
 			expect( range.start.parent ).to.equal( p );
@@ -750,7 +818,7 @@ describe( 'Selection', () => {
 		} );
 	} );
 
-	describe( 'collapseToStart', () => {
+	describe( 'collapseToStart()', () => {
 		it( 'should collapse to start position and fire change event', done => {
 			selection.setRanges( [ range1, range2, range3 ] );
 			selection.once( 'change', () => {
@@ -773,7 +841,7 @@ describe( 'Selection', () => {
 		} );
 	} );
 
-	describe( 'collapseToEnd', () => {
+	describe( 'collapseToEnd()', () => {
 		it( 'should collapse to end position and fire change event', done => {
 			selection.setRanges( [ range1, range2, range3 ] );
 			selection.once( 'change', () => {