Browse Source

Merge branch 'master' into t/1055

Piotrek Koszuliński 8 years ago
parent
commit
ce7972f647
23 changed files with 638 additions and 122 deletions
  1. 3 3
      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. 5 2
      packages/ckeditor5-engine/src/model/liverange.js
  5. 15 0
      packages/ckeditor5-engine/src/model/range.js
  6. 43 11
      packages/ckeditor5-engine/src/model/selection.js
  7. 34 0
      packages/ckeditor5-engine/src/view/element.js
  8. 2 2
      packages/ckeditor5-engine/src/view/observer/mutationobserver.js
  9. 16 1
      packages/ckeditor5-engine/src/view/range.js
  10. 45 12
      packages/ckeditor5-engine/src/view/selection.js
  11. 20 2
      packages/ckeditor5-engine/src/view/writer.js
  12. 64 0
      packages/ckeditor5-engine/tests/conversion/model-selection-to-view-converters.js
  13. 8 8
      packages/ckeditor5-engine/tests/dev-utils/model.js
  14. 10 10
      packages/ckeditor5-engine/tests/model/documentselection.js
  15. 5 1
      packages/ckeditor5-engine/tests/model/liverange.js
  16. 22 0
      packages/ckeditor5-engine/tests/model/range.js
  17. 103 34
      packages/ckeditor5-engine/tests/model/selection.js
  18. 47 0
      packages/ckeditor5-engine/tests/view/element.js
  19. 2 2
      packages/ckeditor5-engine/tests/view/observer/selectionobserver.js
  20. 22 0
      packages/ckeditor5-engine/tests/view/range.js
  21. 96 28
      packages/ckeditor5-engine/tests/view/selection.js
  22. 46 2
      packages/ckeditor5-engine/tests/view/writer/wrap.js
  23. 5 3
      packages/ckeditor5-engine/tests/view/writer/wrapposition.js

+ 3 - 3
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 ) {
@@ -198,7 +198,7 @@ function replaceEntireContentWithParagraph( batch, selection ) {
 function shouldEntireContentBeReplacedWithParagraph( schema, selection ) {
 	const limitElement = schema.getLimitElement( selection );
 
-	if ( !selection.isEntireContentSelected( limitElement ) ) {
+	if ( !selection.containsEntireContent( 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

@@ -200,12 +200,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

+ 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

@@ -717,6 +717,21 @@ export default class Range {
 	}
 
 	/**
+	 * 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.
 	 *

+ 43 - 11
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 this selection on the provided item.
+	 *
+	 * @param {module:engine/model/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/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.'
 			);
 		}
 
@@ -635,7 +667,7 @@ export default class Selection {
 	 * @param {module:engine/model/element~Element} [element=this.anchor.root]
 	 * @returns {Boolean}
 	 */
-	isEntireContentSelected( element = this.anchor.root ) {
+	containsEntireContent( element = this.anchor.root ) {
 		const limitStartPosition = Position.createAt( element );
 		const limitEndPosition = Position.createAt( element, 'end' );
 

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

@@ -674,6 +674,40 @@ export default class Element extends Node {
 	}
 
 	/**
+	 * Returns identity string based on element's name, styles, classes and other attributes.
+	 * Two elements that {@link #isSimilar are similar} will have same identity string.
+	 * It has the following format:
+	 *
+	 *		'name class="class1,class2" style="style1:value1;style2:value2" attr1="val1" attr2="val2"'
+ 	 *
+	 * For example:
+	 *
+	 *		const element = new ViewElement( 'foo' );
+	 *		element.setAttribute( 'banana', '10' );
+	 *		element.setAttribute( 'apple', '20' );
+	 *		element.setStyle( 'color', 'red' );
+	 *		element.setStyle( 'border-color', 'white' );
+	 *		element.addClass( 'baz' );
+	 *
+	 *		// returns 'foo class="baz" style="border-color:white;color:red" apple="20" banana="10"'
+	 *		element.getIdentity();
+	 *
+	 * NOTE: Classes, styles and other attributes are sorted alphabetically.
+	 *
+	 * @returns {String}
+	 */
+	getIdentity() {
+		const classes = Array.from( this._classes ).sort().join( ',' );
+		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 +
+			( classes == '' ? '' : ` class="${ classes }"` ) +
+			( styles == '' ? '' : ` style="${ styles }"` ) +
+			( attributes == '' ? '' : ` ${ attributes }` );
+	}
+
+	/**
 	 * Returns block {@link module:engine/view/filler filler} offset or `null` if block filler is not needed.
 	 *
 	 * @abstract

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

+ 20 - 2
packages/ckeditor5-engine/src/view/writer.js

@@ -933,7 +933,7 @@ function wrapChildren( parent, startOffset, endOffset, attribute ) {
 		const isUI = child.is( 'uiElement' );
 
 		// Wrap text, empty elements, ui elements or attributes with higher or equal priority.
-		if ( isText || isEmpty || isUI || ( isAttribute && attribute.priority <= child.priority ) ) {
+		if ( isText || isEmpty || isUI || ( isAttribute && shouldABeOutsideB( attribute, child ) ) ) {
 			// Clone attribute.
 			const newAttribute = attribute.clone();
 
@@ -975,6 +975,25 @@ function wrapChildren( parent, startOffset, endOffset, attribute ) {
 	return Range.createFromParentsAndOffsets( parent, startOffset, parent, endOffset );
 }
 
+// Checks if first {@link module:engine/view/attributeelement~AttributeElement AttributeElement} provided to the function
+// can be wrapped otuside second element. It is done by comparing elements'
+// {@link module:engine/view/attributeelement~AttributeElement#priority priorities}, if both have same priority
+// {@link module:engine/view/element~Element#getIdentity identities} are compared.
+//
+// @param {module:engine/view/attributeelement~AttributeElement} a
+// @param {module:engine/view/attributeelement~AttributeElement} b
+// @returns {Boolean}
+function shouldABeOutsideB( a, b ) {
+	if ( a.priority < b.priority ) {
+		return true;
+	} else if ( a.priority > b.priority ) {
+		return false;
+	}
+
+	// When priorities are equal and names are different - use identities.
+	return a.getIdentity() < b.getIdentity();
+}
+
 // Returns new position that is moved to near text node. Returns same position if there is no text node before of after
 // specified position.
 //
@@ -1046,7 +1065,6 @@ function mergeTextNodes( t1, t2 ) {
 }
 
 // Wraps one {@link module:engine/view/attributeelement~AttributeElement AttributeElement} into another by merging them if possible.
-// Two AttributeElements can be merged when there is no attribute or style conflicts between them.
 // When merging is possible - all attributes, styles and classes are moved from wrapper element to element being
 // wrapped.
 //

+ 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';
@@ -314,6 +315,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.

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

@@ -244,7 +244,7 @@ describe( 'model test utils', () => {
 
 			expect( stringify( root ) ).to.equal(
 				// Because of https://github.com/ckeditor/ckeditor5-engine/issues/562 attributes are not merged
-				'<$text bold="true">foo</$text>bar<$text italic="true"><$text bold="true">bom</$text></$text>' +
+				'<$text bold="true">foo</$text>bar<$text bold="true"><$text italic="true">bom</$text></$text>' +
 				'<a><$text bold="true" underline="true">pom</$text></a>'
 			);
 		} );
@@ -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>'

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

+ 103 - 34
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' );
 
@@ -1238,7 +1307,7 @@ describe( 'Selection', () => {
 		} );
 	} );
 
-	describe( 'isEntireContentSelected()', () => {
+	describe( 'containsEntireContent()', () => {
 		beforeEach( () => {
 			doc.schema.registerItem( 'p', '$block' );
 			doc.schema.allow( { name: 'p', inside: '$root' } );
@@ -1247,13 +1316,13 @@ describe( 'Selection', () => {
 		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 );
+			expect( doc.selection.containsEntireContent() ).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 );
+			expect( doc.selection.containsEntireContent() ).to.equal( false );
 		} );
 
 		it( 'returns true if the entire content in specified element is selected', () => {
@@ -1262,7 +1331,7 @@ describe( 'Selection', () => {
 			const root = doc.getRoot();
 			const secondParagraph = root.getNodeByPath( [ 1 ] );
 
-			expect( doc.selection.isEntireContentSelected( secondParagraph ) ).to.equal( true );
+			expect( doc.selection.containsEntireContent( secondParagraph ) ).to.equal( true );
 		} );
 
 		it( 'returns false if the entire content in specified element is not selected', () => {
@@ -1271,7 +1340,7 @@ describe( 'Selection', () => {
 			const root = doc.getRoot();
 			const secondParagraph = root.getNodeByPath( [ 1 ] );
 
-			expect( doc.selection.isEntireContentSelected( secondParagraph ) ).to.equal( false );
+			expect( doc.selection.containsEntireContent( secondParagraph ) ).to.equal( false );
 		} );
 
 		it( 'returns false when the entire content except an empty element is selected', () => {
@@ -1280,19 +1349,19 @@ describe( 'Selection', () => {
 
 			setData( doc, '<p><img></img>[Foo]</p>' );
 
-			expect( doc.selection.isEntireContentSelected() ).to.equal( false );
+			expect( doc.selection.containsEntireContent() ).to.equal( false );
 		} );
 
 		it( 'returns true if the content is empty', () => {
 			setData( doc, '[]' );
 
-			expect( doc.selection.isEntireContentSelected() ).to.equal( true );
+			expect( doc.selection.containsEntireContent() ).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 );
+			expect( doc.selection.containsEntireContent() ).to.equal( false );
 		} );
 	} );
 } );

+ 47 - 0
packages/ckeditor5-engine/tests/view/element.js

@@ -981,4 +981,51 @@ describe( 'Element', () => {
 			expect( properties[ 2 ][ 1 ] ).to.equal( 3 );
 		} );
 	} );
+
+	describe( 'getIdentity()', () => {
+		it( 'should return only name if no other attributes are present', () => {
+			const el = new Element( 'foo' );
+
+			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"' );
+		} );
+
+		it( 'should return styles in sorted order', () => {
+			const el = new Element( 'foo', {
+				style: 'border: 1px solid red; background-color: red'
+			} );
+
+			expect( el.getIdentity() ).to.equal( 'foo style="background-color:red;border:1px solid red"' );
+		} );
+
+		it( 'should return attributes in sorted order', () => {
+			const el = new Element( 'foo', {
+				a: 1,
+				d: 4,
+				b: 3
+			} );
+
+			expect( el.getIdentity() ).to.equal( 'foo a="1" b="3" d="4"' );
+		} );
+
+		it( 'should return classes, styles and attributes', () => {
+			const el = new Element( 'baz', {
+				foo: 'one',
+				bar: 'two',
+				style: 'text-align:center;border-radius:10px'
+			} );
+
+			el.addClass( 'three', 'two', 'one' );
+
+			expect( el.getIdentity() ).to.equal(
+				'baz class="one,three,two" style="border-radius:10px;text-align:center" bar="two" foo="one"'
+			);
+		} );
+	} );
 } );

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

+ 46 - 2
packages/ckeditor5-engine/tests/view/writer/wrap.js

@@ -215,10 +215,10 @@ describe( 'writer', () => {
 
 		it( 'should not merge attributes when they differ', () => {
 			test(
-				'<container:p>[<attribute:b view-priority="1" foo="bar"></attribute:b>]</container:p>',
+				'<container:p>[<attribute:b view-priority="1" foo="bar">text</attribute:b>]</container:p>',
 				'<attribute:b view-priority="1" foo="baz"></attribute:b>',
 				'<container:p>' +
-					'[<attribute:b view-priority="1" foo="baz"><attribute:b view-priority="1" foo="bar"></attribute:b></attribute:b>]' +
+					'[<attribute:b view-priority="1" foo="bar"><attribute:b view-priority="1" foo="baz">text</attribute:b></attribute:b>]' +
 				'</container:p>'
 			);
 		} );
@@ -318,5 +318,49 @@ describe( 'writer', () => {
 				wrap( range, new AttributeElement( 'b' ) );
 			} ).to.throw( CKEditorError, 'view-writer-cannot-break-ui-element' );
 		} );
+
+		it( 'should keep stable hierarchy when wrapping with attribute with same priority', () => {
+			test(
+				'<container:p>[<attribute:span>foo</attribute:span>]</container:p>',
+				'<attribute:b></attribute:b>',
+				'<container:p>' +
+					'[<attribute:b view-priority="10">' +
+						'<attribute:span view-priority="10">foo</attribute:span>' +
+					'</attribute:b>]' +
+				'</container:p>'
+			);
+
+			test(
+				'<container:p>[<attribute:b>foo</attribute:b>]</container:p>',
+				'<attribute:span></attribute:span>',
+				'<container:p>' +
+					'[<attribute:b view-priority="10">' +
+						'<attribute:span view-priority="10">foo</attribute:span>' +
+					'</attribute:b>]' +
+				'</container:p>'
+			);
+		} );
+
+		it( 'should keep stable hierarchy when wrapping with attribute with same priority that can\'t be merged', () => {
+			test(
+				'<container:p>[<attribute:span name="foo">foo</attribute:span>]</container:p>',
+				'<attribute:span name="bar"></attribute:span>',
+				'<container:p>' +
+					'[<attribute:span view-priority="10" name="bar">' +
+						'<attribute:span view-priority="10" name="foo">foo</attribute:span>' +
+					'</attribute:span>]' +
+				'</container:p>'
+			);
+
+			test(
+				'<container:p>[<attribute:span name="bar">foo</attribute:span>]</container:p>',
+				'<attribute:span name="foo"></attribute:span>',
+				'<container:p>' +
+					'[<attribute:span view-priority="10" name="bar">' +
+						'<attribute:span view-priority="10" name="foo">foo</attribute:span>' +
+					'</attribute:span>]' +
+				'</container:p>'
+			);
+		} );
 	} );
 } );

+ 5 - 3
packages/ckeditor5-engine/tests/view/writer/wrapposition.js

@@ -101,9 +101,11 @@ describe( 'wrapPosition', () => {
 			'<container:p><attribute:b view-priority="1">foo{}bar</attribute:b></container:p>',
 			'<attribute:u view-priority="1"></attribute:u>',
 			'<container:p>' +
-				'<attribute:b view-priority="1">foo</attribute:b>' +
-				'<attribute:u view-priority="1"><attribute:b view-priority="1">[]</attribute:b></attribute:u>' +
-				'<attribute:b view-priority="1">bar</attribute:b>' +
+				'<attribute:b view-priority="1">' +
+					'foo' +
+					'<attribute:u view-priority="1">[]</attribute:u>' +
+					'bar' +
+				'</attribute:b>' +
 			'</container:p>'
 		);
 	} );