Przeglądaj źródła

Merge pull request #538 from ckeditor/t/165

T/165 engine.view API unified with model
Piotr Jasiun 9 lat temu
rodzic
commit
f5d4074018

+ 21 - 0
packages/ckeditor5-engine/src/model/node.js

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

+ 28 - 0
packages/ckeditor5-engine/src/view/documentfragment.js

@@ -40,6 +40,25 @@ export default class DocumentFragment {
 		return this._children[ Symbol.iterator ]();
 	}
 
+	/**
+	 * Artificial root of `DocumentFragment`. Returns itself. Added for compatibility reasons.
+	 *
+	 * @readonly
+	 * @type {engine.model.DocumentFragment}
+	 */
+	get root() {
+		return this;
+	}
+
+	/**
+	 * Returns ancestor elements of `DocumentFragment`, which is an empty array. Added for compatibility reasons.
+	 *
+	 * @returns {Array}
+	 */
+	getAncestors() {
+		return [];
+	}
+
 	/**
 	 * {@link engine.view.DocumentFragment#insertChildren Insert} a child node or a list of child nodes at the end
 	 * and sets the parent of these nodes to this fragment.
@@ -116,6 +135,15 @@ export default class DocumentFragment {
 		return count;
 	}
 
+	/**
+	 * Returns `true` if there are no nodes inside this document fragment, `false` otherwise.
+	 *
+	 * @returns {Boolean}
+	 */
+	isEmpty() {
+		return this.getChildCount() === 0;
+	}
+
 	/**
 	 * Removes number of child nodes starting at the given index and set the parent of these nodes to `null`.
 	 *

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

@@ -537,7 +537,7 @@ export default class DomConverter {
 	 * and {@link engine.view.DomConverter#getCorrespondingDomDocumentFragment getCorrespondingDomDocumentFragment}
 	 * for document fragments.
 	 *
-	 * @param {engine.view.Node|engine.view.DomFragment} viewNode View node or document fragment.
+	 * @param {engine.view.Node|engine.view.DocumentFragment} viewNode View node or document fragment.
 	 * @returns {Node|DocumentFragment|null} Corresponding DOM node or document fragment.
 	 */
 	getCorrespondingDom( viewNode ) {

+ 30 - 3
packages/ckeditor5-engine/src/view/element.js

@@ -135,7 +135,6 @@ export default class Element extends Node {
 	 * @fires engine.view.Node#change
 	 * @param {engine.view.Node|Iterable.<engine.view.Node>} nodes Node or the list of nodes to be inserted.
 	 * @returns {Number} Number of appended nodes.
-
 	 */
 	appendChildren( nodes ) {
 		return this.insertChildren( this.getChildCount(), nodes );
@@ -180,8 +179,7 @@ export default class Element extends Node {
 	}
 
 	/**
-	 * Returns an iterator that contains the keys for attributes.
-	 * Order of inserting attributes is not preserved.
+	 * Returns an iterator that contains the keys for attributes. Order of inserting attributes is not preserved.
 	 *
 	 * @returns {Iterator.<String>} Keys for attributes.
 	 */
@@ -201,6 +199,26 @@ export default class Element extends Node {
 		}
 	}
 
+	/**
+	 * Returns iterator that iterates over this element's attributes.
+	 *
+	 * Attributes are returned as arrays containing two items. First one is attribute key and second is attribute value.
+	 * This format is accepted by native `Map` object and also can be passed in `Node` constructor.
+	 *
+	 * @returns {Iterable.<*>}
+	 */
+	*getAttributes() {
+		yield* this._attrs.entries();
+
+		if ( this._classes.size > 0 ) {
+			yield [ 'class', this.getAttribute( 'class' ) ];
+		}
+
+		if ( this._styles.size > 0 ) {
+			yield [ 'style', this.getAttribute( 'style' ) ];
+		}
+	}
+
 	/**
 	 * Gets attribute by key. If attribute is not present - returns undefined.
 	 *
@@ -352,6 +370,15 @@ export default class Element extends Node {
 		return this._children.splice( index, howMany );
 	}
 
+	/**
+	 * Returns `true` if there are no nodes inside this element, `false` otherwise.
+	 *
+	 * @returns {Boolean}
+	 */
+	isEmpty() {
+		return this._children.length === 0;
+	}
+
 	/**
 	 * Checks if this element is similar to other element.
 	 * Both elements should have the same name and attributes to be considered as similar. Two similar elements

+ 10 - 0
packages/ckeditor5-engine/src/view/item.jsdoc

@@ -0,0 +1,10 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * Item is a {@link engine.view.Node Node} or {engine.view.TextProxy TextProxy}.
+ *
+ * @typedef {engine.view.Node|engine.view.TextProxy} engine.view.Item
+ */

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

@@ -82,7 +82,7 @@ export default class Node {
 	 * Gets {@link engine.view.Document} reference, from the {@link engine.view.Node#getRoot root} or
 	 * returns null if the root has no reference to the {@link engine.view.Document}.
 	 *
-	 * @returns {engine.view.Document|null} View Document of the node or null.
+	 * @returns {engine.view.Document|null} View document of the node or null.
 	 */
 	getDocument() {
 		// Parent might be Node, null or DocumentFragment.
@@ -96,7 +96,7 @@ export default class Node {
 	/**
 	 * Gets the top parent for the node. If node has no parent it is the root itself.
 	 *
-	 * @returns {engine.view.Node}
+	 * @returns {engine.view.Node|engine.view.DocumentFragment}
 	 */
 	getRoot() {
 		let root = this;

+ 94 - 18
packages/ckeditor5-engine/src/view/position.js

@@ -83,6 +83,24 @@ export default class Position {
 		return shifted;
 	}
 
+	/**
+	 * Returns position root, that is the root of the position's parent element.
+	 *
+	 * @returns {engine.view.Node|engine.view.DocumentFragment} Position's root.
+	 */
+	getRoot() {
+		return this.parent.getRoot();
+	}
+
+	/**
+	 * Returns ancestors array of this position, that is this position's parent and it's ancestors.
+	 *
+	 * @returns {Array} Array with ancestors.
+	 */
+	getAncestors() {
+		return this.parent.getAncestors().concat( this.parent );
+	}
+
 	/**
 	 * Checks whether this position equals given position.
 	 *
@@ -121,6 +139,26 @@ export default class Position {
 		return this.compareWith( otherPosition ) == 'after';
 	}
 
+	/**
+	 * Returns `true` if position is at the beginning of its {@link engine.view.Position#parent parent}, `false` otherwise.
+	 *
+	 * @returns {Boolean}
+	 */
+	isAtStart() {
+		return this.offset === 0;
+	}
+
+	/**
+	 * Returns `true` if position is at the end of its {@link engine.view.Position#parent parent}, `false` otherwise.
+	 *
+	 * @returns {Boolean}
+	 */
+	isAtEnd() {
+		const endOffset = this.parent instanceof Text ? this.parent.data.length : this.parent.getChildCount();
+
+		return this.offset === endOffset;
+	}
+
 	/**
 	 * Checks whether this position is before, after or in same position that other position. Two positions may be also
 	 * different when they are located in separate roots.
@@ -206,53 +244,91 @@ export default class Position {
 	}
 
 	/**
-	 * Creates a new position after the given node.
+	 * Creates position at the given location. The location can be specified as:
+	 *
+	 * * a {@link engine.view.Position position},
+	 * * parent element and offset (offset defaults to `0`),
+	 * * parent element and `'end'` (sets position at the end of that element),
+	 * * {@link engine.view.Item view item} and `'before'` or `'after'` (sets position before or after given view item).
+	 *
+	 * This method is a shortcut to other constructors such as:
+	 *
+	 * * {@link engine.view.Position.createBefore},
+	 * * {@link engine.view.Position.createAfter},
+	 * * {@link engine.view.Position.createFromPosition}.
+	 *
+	 * @param {engine.view.Item|engine.model.Position} itemOrPosition
+	 * @param {Number|'end'|'before'|'after'} [offset=0] Offset or one of the flags. Used only when
+	 * first parameter is a {@link engine.view.Item view item}.
+	 */
+	static createAt( itemOrPosition, offset ) {
+		if ( itemOrPosition instanceof Position ) {
+			return this.createFromPosition( itemOrPosition );
+		} else {
+			let node = itemOrPosition;
+
+			if ( offset == 'end' ) {
+				offset = node instanceof Text ? node.data.length : node.getChildCount();
+			} else if ( offset == 'before' ) {
+				return this.createBefore( node );
+			} else if ( offset == 'after' ) {
+				return this.createAfter( node );
+			} else if ( !offset ) {
+				offset = 0;
+			}
+
+			return new Position( node, offset );
+		}
+	}
+
+	/**
+	 * Creates a new position after given view item.
 	 *
-	 * @param {engine.view.Node|engine.view.TextProxy} node Node or text proxy after which the position should be located.
+	 * @param {engine.view.Item} item View item after which the position should be located.
 	 * @returns {engine.view.Position}
 	 */
-	static createAfter( node ) {
-		// {@link engine.view.TextProxy} is not a instance of {@link engine.view.Node} so we need do handle it in specific way.
-		if ( node instanceof TextProxy ) {
-			return new Position( node.textNode, node.index + node.data.length );
+	static createAfter( item ) {
+		// TextProxy is not a instance of Node so we need do handle it in specific way.
+		if ( item instanceof TextProxy ) {
+			return new Position( item.textNode, item.offsetInText + item.data.length );
 		}
 
-		if ( !node.parent ) {
+		if ( !item.parent ) {
 			/**
 			 * You can not make a position after a root.
 			 *
 			 * @error position-after-root
 			 * @param {engine.view.Node} root
 			 */
-			throw new CKEditorError( 'position-after-root: You can not make position after root.', { root: node } );
+			throw new CKEditorError( 'position-after-root: You can not make position after root.', { root: item } );
 		}
 
-		return new Position( node.parent, node.getIndex() + 1 );
+		return new Position( item.parent, item.getIndex() + 1 );
 	}
 
 	/**
-	 * Creates a new position before the given node.
+	 * Creates a new position before given view item.
 	 *
-	 * @param {engine.view.Node|engine.view.TextProxy} node Node or text proxy before which the position should be located.
+	 * @param {engine.view.Item} item View item before which the position should be located.
 	 * @returns {engine.view.Position}
 	 */
-	static createBefore( node ) {
-		// {@link engine.view.TextProxy} is not a instance of {@link engine.view.Node} so we need do handle it in specific way.
-		if ( node instanceof TextProxy ) {
-			return new Position( node.textNode, node.index );
+	static createBefore( item ) {
+		// TextProxy is not a instance of Node so we need do handle it in specific way.
+		if ( item instanceof TextProxy ) {
+			return new Position( item.textNode, item.offsetInText );
 		}
 
-		if ( !node.parent ) {
+		if ( !item.parent ) {
 			/**
 			 * You cannot make a position before a root.
 			 *
 			 * @error position-before-root
 			 * @param {engine.view.Node} root
 			 */
-			throw new CKEditorError( 'position-before-root: You can not make position before root.', { root: node } );
+			throw new CKEditorError( 'position-before-root: You can not make position before root.', { root: item } );
 		}
 
-		return new Position( node.parent, node.getIndex() );
+		return new Position( item.parent, item.getIndex() );
 	}
 
 	/**

+ 245 - 2
packages/ckeditor5-engine/src/view/range.js

@@ -4,6 +4,7 @@
  */
 
 import Position from './position.js';
+import TreeWalker from './treewalker.js';
 
 /**
  * Tree view range.
@@ -36,6 +37,20 @@ export default class Range {
 		this.end = Position.createFromPosition( end );
 	}
 
+	/**
+	 * Returns an iterator that iterates over all {@link engine.view.Item view items} that are in this range and returns
+	 * them together with additional information like length or {@link engine.view.Position positions},
+	 * grouped as {@link engine.view.TreeWalkerValue}.
+	 *
+	 * This iterator uses {@link engine.view.TreeWalker TreeWalker} with `boundaries` set to this range and `ignoreElementEnd` option
+	 * set to `true`.
+	 *
+	 * @returns {Iterable.<engine.view.TreeWalkerValue>}
+	 */
+	*[ Symbol.iterator ]() {
+		yield* new TreeWalker( { boundaries: this, ignoreElementEnd: true } );
+	}
+
 	/**
 	 * Returns whether the range is collapsed, that is it start and end positions are equal.
 	 *
@@ -46,15 +61,217 @@ export default class Range {
 	}
 
 	/**
-	 * Two ranges equal if their start and end positions equal.
+	 * Returns whether this range is flat, that is if {@link engine.view.Range#start start} position and
+	 * {@link engine.view.Range#end end} position are in the same {@link engine.view.Position#parent parent}.
+	 *
+	 * @type {Boolean}
+	 */
+	get isFlat() {
+		return this.start.parent === this.end.parent;
+	}
+
+	/**
+	 * Range root element.
+	 *
+	 * @type {engine.view.Element|engine.view.DocumentFragment}
+	 */
+	getRoot() {
+		return this.start.getRoot();
+	}
+
+	/**
+	 * Two ranges are equal if their start and end positions are equal.
 	 *
 	 * @param {engine.view.Range} otherRange Range to compare with.
-	 * @returns {Boolean} True if ranges equal.
+	 * @returns {Boolean} `true` if ranges are equal, `false` otherwise
 	 */
 	isEqual( otherRange ) {
 		return this == otherRange || ( this.start.isEqual( otherRange.start ) && this.end.isEqual( otherRange.end ) );
 	}
 
+	/**
+	 * Checks whether this range contains given {@link engine.view.Position position}.
+	 *
+	 * @param {engine.view.Position} position Position to check.
+	 * @returns {Boolean} `true` if given {@link engine.view.Position position} is contained in this range, `false` otherwise.
+	 */
+	containsPosition( position ) {
+		return position.isAfter( this.start ) && position.isBefore( this.end );
+	}
+
+	/**
+	 * Checks whether this range contains given {@link engine.view.Range range}.
+	 *
+	 * @param {engine.view.Range} otherRange Range to check.
+	 * @returns {Boolean} `true` if given {@link engine.view.Range range} boundaries are contained by this range, `false` otherwise.
+	 */
+	containsRange( otherRange ) {
+		return this.containsPosition( otherRange.start ) && this.containsPosition( otherRange.end );
+	}
+
+	/**
+	 * Computes which part(s) of this {@link engine.view.Range range} is not a part of given {@link engine.view.Range range}.
+	 * Returned array contains zero, one or two {@link engine.view.Range ranges}.
+	 *
+	 * Examples:
+	 *
+	 *		let foo = new Text( 'foo' );
+	 *		let img = new ContainerElement( 'img' );
+	 *		let bar = new Text( 'bar' );
+	 *		let p = new ContainerElement( 'p', null, [ foo, img, bar ] );
+	 *
+	 *		let range = new Range( new Position( foo, 2 ), new Position( bar, 1 ); // "o", img, "b" are in range.
+	 *		let otherRange = new Range( new Position( foo, 1 ), new Position( bar, 2 ); "oo", img, "ba" are in range.
+	 *		let transformed = range.getDifference( otherRange );
+	 *		// transformed array has no ranges because `otherRange` contains `range`
+	 *
+	 *		otherRange = new Range( new Position( foo, 1 ), new Position( p, 2 ); // "oo", img are in range.
+	 *		transformed = range.getDifference( otherRange );
+	 *		// transformed array has one range: from ( p, 2 ) to ( bar, 1 )
+	 *
+	 *		otherRange = new Range( new Position( p, 1 ), new Position( p, 2 ) ); // img is in range.
+	 *		transformed = range.getDifference( otherRange );
+	 *		// transformed array has two ranges: from ( foo, 1 ) to ( p, 1 ) and from ( p, 2 ) to ( bar, 1 )
+	 *
+	 * @param {engine.view.Range} otherRange Range to differentiate against.
+	 * @returns {Array.<engine.view.Range>} The difference between ranges.
+	 */
+	getDifference( otherRange ) {
+		const ranges = [];
+
+		if ( this.isIntersecting( otherRange ) ) {
+			// Ranges intersect.
+
+			if ( this.containsPosition( otherRange.start ) ) {
+				// Given range start is inside this range. This means that we have to
+				// add shrunken range - from the start to the middle of this range.
+				ranges.push( new Range( this.start, otherRange.start ) );
+			}
+
+			if ( this.containsPosition( otherRange.end ) ) {
+				// Given range end is inside this range. This means that we have to
+				// add shrunken range - from the middle of this range to the end.
+				ranges.push( new Range( otherRange.end, this.end ) );
+			}
+		} else {
+			// Ranges do not intersect, return the original range.
+			ranges.push( Range.createFromRange( this ) );
+		}
+
+		return ranges;
+	}
+
+	/**
+	 * Returns an intersection of this {@link engine.view.Range range} and given {@link engine.view.Range range}.
+	 * Intersection is a common part of both of those ranges. If ranges has no common part, returns `null`.
+	 *
+	 * Examples:
+	 *
+	 *		let foo = new Text( 'foo' );
+	 *		let img = new ContainerElement( 'img' );
+	 *		let bar = new Text( 'bar' );
+	 *		let p = new ContainerElement( 'p', null, [ foo, img, bar ] );
+	 *
+	 *		let range = new Range( new Position( foo, 2 ), new Position( bar, 1 ); // "o", img, "b" are in range.
+	 *		let otherRange = new Range( new Position( foo, 1 ), new Position( p, 2 ); // "oo", img are in range.
+	 *		let transformed = range.getIntersection( otherRange ); // range from ( foo, 1 ) to ( p, 2 ).
+	 *
+	 *		otherRange = new Range( new Position( bar, 1 ), new Position( bar, 3 ); "ar" is in range.
+	 *		transformed = range.getIntersection( otherRange ); // null - no common part.
+	 *
+	 * @param {engine.view.Range} otherRange Range to check for intersection.
+	 * @returns {engine.view.Range|null} A common part of given ranges or `null` if ranges have no common part.
+	 */
+	getIntersection( otherRange ) {
+		if ( this.isIntersecting( otherRange ) ) {
+			// Ranges intersect, so a common range will be returned.
+			// At most, it will be same as this range.
+			let commonRangeStart = this.start;
+			let commonRangeEnd = this.end;
+
+			if ( this.containsPosition( otherRange.start ) ) {
+				// Given range start is inside this range. This means thaNt we have to
+				// shrink common range to the given range start.
+				commonRangeStart = otherRange.start;
+			}
+
+			if ( this.containsPosition( otherRange.end ) ) {
+				// Given range end is inside this range. This means that we have to
+				// shrink common range to the given range end.
+				commonRangeEnd = otherRange.end;
+			}
+
+			return new Range( commonRangeStart, commonRangeEnd );
+		}
+
+		// Ranges do not intersect, so they do not have common part.
+		return null;
+	}
+
+	/**
+	 * Creates a {@link engine.view.TreeWalker TreeWalker} instance with this range as a boundary.
+	 *
+	 * @param {Object} options Object with configuration options. See {@link engine.view.TreeWalker}.
+	 * @param {engine.view.Position} [options.startPosition]
+	 * @param {Boolean} [options.singleCharacters=false]
+	 * @param {Boolean} [options.shallow=false]
+	 * @param {Boolean} [options.ignoreElementEnd=false]
+	 */
+	getWalker( options = {} ) {
+		options.boundaries = this;
+
+		return new TreeWalker( options );
+	}
+
+	/**
+	 * Returns an iterator that iterates over all {@link engine.view.Items view items} that are in this range and returns
+	 * them.
+	 *
+	 * This method uses {@link engine.view.TreeWalker} with `boundaries` set to this range and `ignoreElementEnd` option
+	 * set to `true`. However it returns only {@link engine.view.Item items}, not {@link engine.view.TreeWalkerValue}.
+	 *
+	 * You may specify additional options for the tree walker. See {@link engine.view.TreeWalker} for
+	 * a full list of available options.
+	 *
+	 * @param {Object} options Object with configuration options. See {@link engine.view.TreeWalker}.
+	 * @returns {Iterable.<engine.view.Item>}
+	 */
+	*getItems( options = {} ) {
+		options.boundaries = this;
+		options.ignoreElementEnd = true;
+
+		const treeWalker = new TreeWalker( options );
+
+		for ( let value of treeWalker ) {
+			yield value.item;
+		}
+	}
+
+	/**
+	 * Returns an iterator that iterates over all {@link engine.view.Position positions} that are boundaries or
+	 * contained in this range.
+	 *
+	 * This method uses {@link engine.view.TreeWalker} with `boundaries` set to this range. However it returns only
+	 * {@link engine.view.Position positions}, not {@link engine.view.TreeWalkerValue}.
+	 *
+	 * You may specify additional options for the tree walker. See {@link engine.view.TreeWalker} for
+	 * a full list of available options.
+	 *
+	 * @param {Object} options Object with configuration options. See {@link engine.view.TreeWalker}.
+	 * @returns {Iterable.<engine.view.Position>}
+	 */
+	*getPositions( options = {} ) {
+		options.boundaries = this;
+
+		const treeWalker = new TreeWalker( options );
+
+		yield treeWalker.position;
+
+		for ( let value of treeWalker ) {
+			yield value.nextPosition;
+		}
+	}
+
 	/**
 	 * Checks and returns whether this range intersects with given range.
 	 *
@@ -90,4 +307,30 @@ export default class Range {
 	static createFromRange( range ) {
 		return new this( range.start, range.end );
 	}
+
+	/**
+	 * Creates a new range, spreading from specified {@link engine.view.Position position} to a position moved by
+	 * given `shift`. If `shift` is a negative value, shifted position is treated as the beginning of the range.
+	 *
+	 * @param {engine.view.Position} position Beginning of the range.
+	 * @param {Number} shift How long the range should be.
+	 * @returns {engine.view.Range}
+	 */
+	static createFromPositionAndShift( position, shift ) {
+		const start = position;
+		const end = position.getShiftedBy( shift );
+
+		return shift > 0 ? new this( start, end ) : new this( end, start );
+	}
+
+	/**
+	 * Creates a range inside an {@link engine.view.Element element} which starts before the first child of
+	 * that element and ends after the last child of that element.
+	 *
+	 * @param {engine.view.Element} element Element which is a parent for the range.
+	 * @returns {engine.view.Range}
+	 */
+	static createFromElement( element ) {
+		return this.createFromParentsAndOffsets( element, 0, element, element.getChildCount() );
+	}
 }

+ 38 - 16
packages/ckeditor5-engine/src/view/selection.js

@@ -213,10 +213,10 @@ export default class Selection {
 	}
 
 	/**
-	 * Two selections equal if they have the same ranges and directions.
+	 * Checks whether, this selection is equal to given selection. Selections equal if they have the same ranges and directions.
 	 *
 	 * @param {engine.view.Selection} otherSelection Selection to compare with.
-	 * @returns {Boolean} True if selections equal.
+	 * @returns {Boolean} `true` if selections are equal, `false` otherwise.
 	 */
 	isEqual( otherSelection ) {
 		const rangeCount = this.rangeCount;
@@ -273,24 +273,34 @@ export default class Selection {
 	}
 
 	/**
-	 * Set this selection's ranges and direction to the ranges and direction of the given selection.
+	 * Sets this selection's ranges and direction to the ranges and direction of the given selection.
 	 *
-	 * @param {engine.view.Selection} otherSelection Other selection.
+	 * @param {engine.view.Selection} otherSelection
 	 */
 	setTo( otherSelection ) {
-		this.removeAllRanges();
+		this.setRanges( otherSelection.getRanges(), otherSelection.isBackward );
+	}
 
-		for ( let range of otherSelection.getRanges() ) {
-			this._pushRange( range );
-		}
+	/**
+	 * Sets collapsed selection in the specified location.
+	 *
+	 * The location can be specified in the same form as {@link engine.view.Position.createAt} parameters.
+	 *
+	 * @fires engine.view.Selection#change
+	 * @param {engine.view.Item|engine.view.Position} itemOrPosition
+	 * @param {Number|'end'|'before'|'after'} [offset=0] Offset or one of the flags. Used only when
+	 * first parameter is a {@link engine.view.Item view item}.
+	 */
+	collapse( itemOrPosition, offset ) {
+		const pos = Position.createAt( itemOrPosition, offset );
+		const range = new Range( pos, pos );
 
-		this._lastRangeBackward = otherSelection._lastRangeBackward;
-		this.fire( 'change' );
+		this.setRanges( [ range ] );
 	}
 
 	/**
-	 * Collapses selection to the {@link engine.view.Selection#getFirstPosition first position} in stored ranges.
-	 * All ranges will be removed beside one collapsed range. Nothing will be changed if there are no ranges stored
+	 * Collapses selection to the selection's {@link engine.view.Selection#getFirstPosition first position}.
+	 * All ranges, besides the collapsed one, will be removed. Nothing will change if there are no ranges stored
 	 * inside selection.
 	 *
 	 * @fires engine.view.Selection#change
@@ -300,13 +310,12 @@ export default class Selection {
 
 		if ( startPosition !== null ) {
 			this.setRanges( [ new Range( startPosition, startPosition ) ] );
-			this.fire( 'change' );
 		}
 	}
 
 	/**
-	 * Collapses selection to the {@link engine.view.Selection#getLastPosition last position} in stored ranges.
-	 * All ranges will be removed beside one collapsed range. Nothing will be changed if there are no ranges stored
+	 * Collapses selection to the selection's {@link engine.view.Selection#getLastPosition last position}.
+	 * All ranges, besides the collapsed one, will be removed. Nothing will change if there are no ranges stored
 	 * inside selection.
 	 *
 	 * @fires engine.view.Selection#change
@@ -316,7 +325,6 @@ export default class Selection {
 
 		if ( endPosition !== null ) {
 			this.setRanges( [ new Range( endPosition, endPosition ) ] );
-			this.fire( 'change' );
 		}
 	}
 
@@ -333,6 +341,20 @@ export default class Selection {
 		return null;
 	}
 
+	/**
+	 * 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.
+	 *
+	 * @params {engine.view.Selection} otherSelection Selection to be cloned.
+	 * @returns {engine.view.Selection} `Selection` instance that is a clone of given selection.
+	 */
+	static createFromSelection( otherSelection ) {
+		const selection = new Selection();
+		selection.setTo( otherSelection );
+
+		return selection;
+	}
+
 	/**
 	 * Adds range to selection - creates copy of given range so it can be safely used and modified.
 	 *

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

@@ -8,8 +8,8 @@
  * {@link engine.view.TreeWalker} when only a part of {@link engine.view.Text} needs to be returned.
  *
  * **Note:** `TextProxy` instances are created on the fly basing on the current state of parent {@link engine.view.Text}.
- * Because of this it is highly unrecommended to store references to `TextProxy` instances because they might get
- * invalidated due to operations on the document. Also, `TextProxy` is not a {@link engine.view.Node} so it cannot be
+ * Because of this it is highly unrecommended to store references to `TextProxy instances because they might get
+ * invalidated due to operations on Document. Also TextProxy is not a {@link engine.view.Node} so it can not be
  * inserted as a child of {@link engine.view.Element}.
  *
  * You should never create an instance of this class by your own.
@@ -42,12 +42,12 @@ export default class TextProxy {
 		this.textNode = textNode;
 
 		/**
-		 * Index of the substring in the `textParent`.
+		 * Offset in the `textNode` where this `TextProxy` instance starts.
 		 *
 		 * @readonly
-		 * @member {Number} engine.view.TextProxy#index
+		 * @member {Number} engine.view.TextProxy#offsetInText
 		 */
-		this.index = startOffset;
+		this.offsetInText = startOffset;
 
 		/**
 		 * The text content.

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

@@ -369,7 +369,7 @@ export default class TreeWalker {
 		// we move it just before or just after Text.
 		if ( item instanceof TextProxy ) {
 			// Position is at the end of Text.
-			if ( item.index + item.data.length == item.textNode.data.length ) {
+			if ( item.offsetInText + item.data.length == item.textNode.data.length ) {
 				if ( this.direction == 'forward' ) {
 					nextPosition = Position.createAfter( item.textNode );
 					// When we change nextPosition of returned value we need also update walker current position.
@@ -380,7 +380,7 @@ export default class TreeWalker {
 			}
 
 			// Position is at the begining ot the text.
-			if ( item.index === 0 ) {
+			if ( item.offsetInText === 0 ) {
 				if ( this.direction == 'forward' ) {
 					previousPosition = Position.createBefore( item.textNode );
 				} else {

+ 24 - 0
packages/ckeditor5-engine/tests/model/node.js

@@ -220,6 +220,30 @@ describe( 'Node', () => {
 		} );
 	} );
 
+	describe( 'getAncestors', () => {
+		it( 'should return proper array of ancestor nodes', () => {
+			expect( root.getAncestors() ).to.deep.equal( [] );
+			expect( two.getAncestors() ).to.deep.equal( [ root ] );
+			expect( textBA.getAncestors() ).to.deep.equal( [ root, two ] );
+		} );
+
+		it( 'should include itself if includeNode option is set to true', () => {
+			expect( root.getAncestors( { includeNode: true } ) ).to.deep.equal( [ root ] );
+			expect( two.getAncestors( { includeNode: true } ) ).to.deep.equal( [ root, two ] );
+			expect( textBA.getAncestors( { includeNode: true } ) ).to.deep.equal( [ root, two, textBA ] );
+			expect( img.getAncestors( { includeNode: true } ) ).to.deep.equal( [ root, two, img ] );
+			expect( textR.getAncestors( { includeNode: true } ) ).to.deep.equal( [ root, two, textR ] );
+		} );
+
+		it( 'should reverse order if parentFirst option is set to true', () => {
+			expect( root.getAncestors( { includeNode: true, parentFirst: true } ) ).to.deep.equal( [ root ] );
+			expect( two.getAncestors( { includeNode: true, parentFirst: true } ) ).to.deep.equal( [ two, root ] );
+			expect( textBA.getAncestors( { includeNode: true, parentFirst: true } ) ).to.deep.equal( [ textBA, two, root ] );
+			expect( img.getAncestors( { includeNode: true, parentFirst: true } ) ).to.deep.equal( [ img, two, root ] );
+			expect( textR.getAncestors( { includeNode: true, parentFirst: true } ) ).to.deep.equal( [ textR, two, root ] );
+		} );
+	} );
+
 	describe( 'attributes interface', () => {
 		let node = new Node( { foo: 'bar' } );
 

+ 1 - 1
packages/ckeditor5-engine/tests/model/range.js

@@ -190,7 +190,7 @@ describe( 'Range', () => {
 	} );
 
 	describe( 'iterator', () => {
-		it( 'should merge characters with same attributes', () => {
+		it( 'should iterate over the range returning tree walker values', () => {
 			prepareRichRoot( root );
 
 			let range = new Range( new Position( root, [ 0, 0, 3 ] ), new Position( root, [ 3, 0, 2 ] ) );

+ 30 - 0
packages/ckeditor5-engine/tests/view/documentfragment.js

@@ -49,6 +49,36 @@ describe( 'DocumentFragment', () => {
 		} );
 	} );
 
+	describe( 'getRoot', () => {
+		it( 'should return document fragment', () => {
+			const fragment = new DocumentFragment();
+
+			expect( fragment.root ).to.equal( fragment );
+		} );
+	} );
+
+	describe( 'getAncestors', () => {
+		it( 'should return empty array', () => {
+			const fragment = new DocumentFragment();
+
+			expect( fragment.getAncestors() ).to.deep.equal( [] );
+		} );
+	} );
+
+	describe( 'isEmpty', () => {
+		it( 'should return true if there are no children in document fragment', () => {
+			const fragment = new DocumentFragment();
+
+			expect( fragment.isEmpty() ).to.be.true;
+		} );
+
+		it( 'should return false if there are children in document fragment', () => {
+			const fragment = new DocumentFragment( [ new Element( 'p' ) ] );
+
+			expect( fragment.isEmpty() ).to.be.false;
+		} );
+	} );
+
 	describe( 'children manipulation methods', () => {
 		let fragment, el1, el2, el3, el4;
 

+ 71 - 37
packages/ckeditor5-engine/tests/view/element.js

@@ -7,12 +7,12 @@
 
 import count from '/ckeditor5/utils/count.js';
 import Node from '/ckeditor5/engine/view/node.js';
-import ViewElement from '/ckeditor5/engine/view/element.js';
+import Element from '/ckeditor5/engine/view/element.js';
 
 describe( 'Element', () => {
 	describe( 'constructor', () => {
 		it( 'should create element without attributes', () => {
-			const el = new ViewElement( 'p' );
+			const el = new Element( 'p' );
 
 			expect( el ).to.be.an.instanceof( Node );
 			expect( el ).to.have.property( 'name' ).that.equals( 'p' );
@@ -21,7 +21,7 @@ describe( 'Element', () => {
 		} );
 
 		it( 'should create element with attributes as plain object', () => {
-			const el = new ViewElement( 'p', { foo: 'bar' } );
+			const el = new Element( 'p', { foo: 'bar' } );
 
 			expect( el ).to.have.property( 'name' ).that.equals( 'p' );
 			expect( count( el.getAttributeKeys() ) ).to.equal( 1 );
@@ -32,7 +32,7 @@ describe( 'Element', () => {
 			const attrs = new Map();
 			attrs.set( 'foo', 'bar' );
 
-			const el = new ViewElement( 'p', attrs );
+			const el = new Element( 'p', attrs );
 
 			expect( el ).to.have.property( 'name' ).that.equals( 'p' );
 			expect( count( el.getAttributeKeys() ) ).to.equal( 1 );
@@ -40,8 +40,8 @@ describe( 'Element', () => {
 		} );
 
 		it( 'should create element with children', () => {
-			const child = new ViewElement( 'p', { foo: 'bar' } );
-			const parent = new ViewElement( 'div', [], [ child ] );
+			const child = new Element( 'p', { foo: 'bar' } );
+			const parent = new Element( 'div', [], [ child ] );
 
 			expect( parent ).to.have.property( 'name' ).that.equals( 'div' );
 			expect( parent.getChildCount() ).to.equal( 1 );
@@ -49,7 +49,7 @@ describe( 'Element', () => {
 		} );
 
 		it( 'should move class attribute to class set ', () => {
-			const el = new ViewElement( 'p', { id: 'test', class: 'one two three' } );
+			const el = new Element( 'p', { id: 'test', class: 'one two three' } );
 
 			expect( el._attrs.has( 'class' ) ).to.be.false;
 			expect( el._attrs.has( 'id' ) ).to.be.true;
@@ -59,7 +59,7 @@ describe( 'Element', () => {
 		} );
 
 		it( 'should move style attribute to style map', () => {
-			const el = new ViewElement( 'p', { id: 'test', style: 'one: style1; two:style2 ; three : url(http://ckeditor.com)' } );
+			const el = new Element( 'p', { id: 'test', style: 'one: style1; two:style2 ; three : url(http://ckeditor.com)' } );
 
 			expect( el._attrs.has( 'style' ) ).to.be.false;
 			expect( el._attrs.has( 'id' ) ).to.be.true;
@@ -72,9 +72,23 @@ describe( 'Element', () => {
 		} );
 	} );
 
+	describe( 'isEmpty', () => {
+		it( 'should return true if there are no children in element', () => {
+			const element = new Element( 'p' );
+
+			expect( element.isEmpty() ).to.be.true;
+		} );
+
+		it( 'should return false if there are children in element', () => {
+			const fragment = new Element( 'p', null, new Element( 'img' ) );
+
+			expect( fragment.isEmpty() ).to.be.false;
+		} );
+	} );
+
 	describe( 'clone', () => {
 		it( 'should clone element', () => {
-			const el = new ViewElement( 'p', { attr1: 'foo', attr2: 'bar' } );
+			const el = new Element( 'p', { attr1: 'foo', attr2: 'bar' } );
 			const clone = el.clone();
 
 			expect( clone ).to.not.equal( el );
@@ -84,9 +98,9 @@ describe( 'Element', () => {
 		} );
 
 		it( 'should deeply clone element', () => {
-			const el = new ViewElement( 'p', { attr1: 'foo', attr2: 'bar' }, [
-				new ViewElement( 'b', { attr: 'baz' } ),
-				new ViewElement( 'span', { attr: 'qux' } )
+			const el = new Element( 'p', { attr1: 'foo', attr2: 'bar' }, [
+				new Element( 'b', { attr: 'baz' } ),
+				new Element( 'span', { attr: 'qux' } )
 			] );
 			const count = el.getChildCount();
 			const clone = el.clone( true );
@@ -108,9 +122,9 @@ describe( 'Element', () => {
 		} );
 
 		it( 'shouldn\'t clone any children when deep copy is not performed', () => {
-			const el = new ViewElement( 'p', { attr1: 'foo', attr2: 'bar' }, [
-				new ViewElement( 'b', { attr: 'baz' } ),
-				new ViewElement( 'span', { attr: 'qux' } )
+			const el = new Element( 'p', { attr1: 'foo', attr2: 'bar' }, [
+				new Element( 'b', { attr: 'baz' } ),
+				new Element( 'span', { attr: 'qux' } )
 			] );
 			const clone = el.clone( false );
 
@@ -122,7 +136,7 @@ describe( 'Element', () => {
 		} );
 
 		it( 'should clone class attribute', () => {
-			const el = new ViewElement( 'p', { foo: 'bar' } );
+			const el = new Element( 'p', { foo: 'bar' } );
 			el.addClass( 'baz', 'qux' );
 			const clone = el.clone( false );
 
@@ -133,7 +147,7 @@ describe( 'Element', () => {
 		} );
 
 		it( 'should clone style attribute', () => {
-			const el = new ViewElement( 'p', { style: 'color: red; font-size: 12px;' } );
+			const el = new Element( 'p', { style: 'color: red; font-size: 12px;' } );
 			const clone = el.clone( false );
 
 			expect( clone ).to.not.equal( el );
@@ -146,7 +160,7 @@ describe( 'Element', () => {
 	} );
 
 	describe( 'isSimilar', () => {
-		const el = new ViewElement( 'p', { foo: 'bar' } );
+		const el = new Element( 'p', { foo: 'bar' } );
 		it( 'should return false when comparing to non-element', () => {
 			expect( el.isSimilar( null ) ).to.be.false;
 			expect( el.isSimilar( {} ) ).to.be.false;
@@ -157,7 +171,7 @@ describe( 'Element', () => {
 		} );
 
 		it( 'should return true for element with same attributes and name', () => {
-			const other = new ViewElement( 'p', { foo: 'bar' } );
+			const other = new Element( 'p', { foo: 'bar' } );
 			expect( el.isSimilar( other ) ).to.be.true;
 		} );
 
@@ -181,10 +195,10 @@ describe( 'Element', () => {
 		} );
 
 		it( 'should compare class attribute', () => {
-			const el1 = new ViewElement( 'p' );
-			const el2 = new ViewElement( 'p' );
-			const el3 = new ViewElement( 'p' );
-			const el4 = new ViewElement( 'p' );
+			const el1 = new Element( 'p' );
+			const el2 = new Element( 'p' );
+			const el3 = new Element( 'p' );
+			const el4 = new Element( 'p' );
 
 			el1.addClass( 'foo', 'bar' );
 			el2.addClass( 'bar', 'foo' );
@@ -197,10 +211,10 @@ describe( 'Element', () => {
 		} );
 
 		it( 'should compare styles attribute', () => {
-			const el1 = new ViewElement( 'p' );
-			const el2 = new ViewElement( 'p' );
-			const el3 = new ViewElement( 'p' );
-			const el4 = new ViewElement( 'p' );
+			const el1 = new Element( 'p' );
+			const el2 = new Element( 'p' );
+			const el3 = new Element( 'p' );
+			const el4 = new Element( 'p' );
 
 			el1.setStyle( 'color', 'red' );
 			el1.setStyle( 'top', '10px' );
@@ -221,11 +235,11 @@ describe( 'Element', () => {
 		let parent, el1, el2, el3, el4;
 
 		beforeEach( () => {
-			parent = new ViewElement( 'p' );
-			el1 = new ViewElement( 'el1' );
-			el2 = new ViewElement( 'el2' );
-			el3 = new ViewElement( 'el3' );
-			el4 = new ViewElement( 'el4' );
+			parent = new Element( 'p' );
+			el1 = new Element( 'el1' );
+			el2 = new Element( 'el2' );
+			el3 = new Element( 'el3' );
+			el4 = new Element( 'el4' );
 		} );
 
 		describe( 'insertion', () => {
@@ -327,7 +341,7 @@ describe( 'Element', () => {
 		let el;
 
 		beforeEach( () => {
-			el = new ViewElement( 'p' );
+			el = new Element( 'p' );
 		} );
 
 		describe( 'setAttribute', () => {
@@ -407,6 +421,26 @@ describe( 'Element', () => {
 			} );
 		} );
 
+		describe( 'getAttributes', () => {
+			it( 'should return attributes', () => {
+				el.setAttribute( 'foo', 'bar' );
+				el.setAttribute( 'abc', 'xyz' );
+
+				expect( Array.from( el.getAttributes() ) ).to.deep.equal( [ [ 'foo', 'bar' ], [ 'abc', 'xyz' ] ] );
+			} );
+
+			it( 'should return class and style attribute', () => {
+				el.setAttribute( 'class', 'abc' );
+				el.setAttribute( 'style', 'width:20px;' );
+				el.addClass( 'xyz' );
+				el.setStyle( 'font-weight', 'bold' );
+
+				expect( Array.from( el.getAttributes() ) ).to.deep.equal( [
+					[ 'class', 'abc xyz' ], [ 'style', 'width:20px;font-weight:bold;' ]
+				] );
+			} );
+		} );
+
 		describe( 'hasAttribute', () => {
 			it( 'should return true if element has attribute', () => {
 				el.setAttribute( 'foo', 'bar' );
@@ -494,7 +528,7 @@ describe( 'Element', () => {
 
 			it( 'should remove class attribute', () => {
 				el.addClass( 'foo', 'bar' );
-				const el2 = new ViewElement( 'p' );
+				const el2 = new Element( 'p' );
 				const removed1 = el.removeAttribute( 'class' );
 				const removed2 = el2.removeAttribute( 'class' );
 
@@ -508,7 +542,7 @@ describe( 'Element', () => {
 			it( 'should remove style attribute', () => {
 				el.setStyle( 'color', 'red' );
 				el.setStyle( 'position', 'fixed' );
-				const el2 = new ViewElement( 'p' );
+				const el2 = new Element( 'p' );
 				const removed1 = el.removeAttribute( 'style' );
 				const removed2 = el2.removeAttribute( 'style' );
 
@@ -525,7 +559,7 @@ describe( 'Element', () => {
 		let el;
 
 		beforeEach( () => {
-			el = new ViewElement( 'p' );
+			el = new Element( 'p' );
 		} );
 
 		describe( 'addClass', () => {
@@ -623,7 +657,7 @@ describe( 'Element', () => {
 		let el;
 
 		beforeEach( () => {
-			el = new ViewElement( 'p' );
+			el = new Element( 'p' );
 		} );
 
 		describe( 'setStyle', () => {

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

@@ -8,6 +8,7 @@
 import Position from '/ckeditor5/engine/view/position.js';
 import Node from '/ckeditor5/engine/view/node.js';
 import Element from '/ckeditor5/engine/view/element.js';
+import DocumentFragment from '/ckeditor5/engine/view/documentfragment.js';
 import EditableElement from '/ckeditor5/engine/view/editableelement.js';
 import Document from '/ckeditor5/engine/view/document.js';
 import Text from '/ckeditor5/engine/view/text.js';
@@ -100,6 +101,83 @@ describe( 'Position', () => {
 		} );
 	} );
 
+	describe( 'getRoot', () => {
+		it( 'should return it\'s parent root', () => {
+			const foo = new Text( 'foo' );
+			const docFrag = new DocumentFragment( foo );
+
+			expect( new Position( foo, 1 ).getRoot() ).to.equal( docFrag );
+
+			const bar = new Text( 'bar' );
+			const p = new Element( 'p', null, bar );
+
+			expect( new Position( bar, 2 ).getRoot() ).to.equal( p );
+			expect( new Position( p, 0 ).getRoot() ).to.equal( p );
+		} );
+	} );
+
+	describe( 'getAncestors', () => {
+		it( 'should return it\'s parent and all it\'s ancestors', () => {
+			const foo = new Text( 'foo' );
+			const p = new Element( 'p', null, foo );
+			const div = new Element( 'div', null, p );
+			const docFrag = new DocumentFragment( div );
+
+			expect( new Position( foo, 1 ).getAncestors() ).to.deep.equal( [ docFrag, div, p, foo ] );
+		} );
+	} );
+
+	describe( 'createAt', () => {
+		it( 'should create positions from positions', () => {
+			const spy = sinon.spy( Position, 'createFromPosition' );
+
+			const p = new Element( 'p' );
+			const position = new Position( p, 0 );
+			const created = Position.createAt( position );
+
+			expect( created.isEqual( position ) ).to.be.true;
+			expect( spy.calledOnce ).to.be.true;
+		} );
+
+		it( 'should create positions from node and offset', () => {
+			const foo = new Text( 'foo' );
+			const p = new Element( 'p', null, foo );
+
+			expect( Position.createAt( foo ).parent ).to.equal( foo );
+			expect( Position.createAt( foo ).offset ).to.equal( 0 );
+
+			expect( Position.createAt( foo, 2 ).parent ).to.equal( foo );
+			expect( Position.createAt( foo, 2 ).offset ).to.equal( 2 );
+
+			expect( Position.createAt( p, 1 ).parent ).to.equal( p );
+			expect( Position.createAt( p, 1 ).offset ).to.equal( 1 );
+		} );
+
+		it( 'should create positions from node and flag', () => {
+			const foo = new Text( 'foo' );
+			const p = new Element( 'p', null, foo );
+
+			const fooEnd = Position.createAt( foo, 'end' );
+			const fooBefore = Position.createAt( foo, 'before' );
+			const fooAfter = Position.createAt( foo, 'after' );
+
+			const pEnd = Position.createAt( p, 'end' );
+			// pBefore and pAfter would throw.
+
+			expect( fooEnd.parent ).to.equal( foo );
+			expect( fooEnd.offset ).to.equal( 3 );
+
+			expect( fooBefore.parent ).to.equal( p );
+			expect( fooBefore.offset ).to.equal( 0 );
+
+			expect( fooAfter.parent ).to.equal( p );
+			expect( fooAfter.offset ).to.equal( 1 );
+
+			expect( pEnd.parent ).to.equal( p );
+			expect( pEnd.offset ).to.equal( 1 );
+		} );
+	} );
+
 	describe( 'createFromPosition', () => {
 		it( 'creates new Position with same parent and offset', () => {
 			const offset = 50;
@@ -254,6 +332,38 @@ describe( 'Position', () => {
 		} );
 	} );
 
+	describe( 'isAtStart', () => {
+		it( 'should return true if it is at the start of it\'s parent', () => {
+			const foo = new Text( 'foo' );
+			const position = new Position( foo, 0 );
+			expect( position.isAtStart( position ) ).to.be.true;
+		} );
+
+		it( 'should return false if it is not at the start of it\'s parent', () => {
+			const foo = new Text( 'foo' );
+			const position = new Position( foo, 1 );
+			expect( position.isAtStart( position ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'isAtEnd', () => {
+		it( 'should return true if it is at the end of it\'s parent', () => {
+			const foo = new Text( 'foo' );
+			const p = new Element( 'p', null, foo );
+
+			expect( new Position( foo, 3 ).isAtEnd() ).to.be.true;
+			expect( new Position( p, 1 ).isAtEnd() ).to.be.true;
+		} );
+
+		it( 'should return false if it is not at the end of it\'s parent', () => {
+			const foo = new Text( 'foo' );
+			const p = new Element( 'p', null, foo );
+
+			expect( new Position( foo, 2 ).isAtEnd() ).to.be.false;
+			expect( new Position( p, 0 ).isAtEnd() ).to.be.false;
+		} );
+	} );
+
 	describe( 'compareWith', () => {
 		it( 'should return same if positions are same', () => {
 			const root = new Node();

+ 431 - 37
packages/ckeditor5-engine/tests/view/range.js

@@ -8,7 +8,16 @@
 import Range from '/ckeditor5/engine/view/range.js';
 import Position from '/ckeditor5/engine/view/position.js';
 import Element from '/ckeditor5/engine/view/element.js';
+import DocumentFragment from '/ckeditor5/engine/view/documentfragment.js';
 import Text from '/ckeditor5/engine/view/text.js';
+import TreeWalker from '/ckeditor5/engine/view/treewalker.js';
+import { parse } from '/tests/engine/_utils/view.js';
+
+function getRange( view, options = {} ) {
+	const { selection } = parse( view, options );
+
+	return selection.getFirstRange();
+}
 
 describe( 'Range', () => {
 	describe( 'constructor', () => {
@@ -27,6 +36,50 @@ describe( 'Range', () => {
 		} );
 	} );
 
+	describe( 'iterator', () => {
+		it( 'should iterate over the range returning tree walker values', () => {
+			const range = getRange( '<p>fo{o</p><p>bar</p><p>xy}z</p>' );
+			const values = Array.from( range );
+
+			expect( values.length ).to.equal( 5 );
+			expect( values[ 0 ].item.data ).to.equal( 'o' );
+			expect( values[ 1 ].item.name ).to.equal( 'p' );
+			expect( values[ 2 ].item.data ).to.equal( 'bar' );
+			expect( values[ 3 ].item.name ).to.equal( 'p' );
+			expect( values[ 4 ].item.data ).to.equal( 'xy' );
+		} );
+	} );
+
+	describe( 'isFlat', () => {
+		it( 'should be true if range start and range end are in same parent', () => {
+			const range = getRange( '<p>f{oo}</p><p>bar</p>' );
+
+			expect( range.isFlat ).to.be.true;
+		} );
+
+		it( 'should be false if range start and range end are in different parents', () => {
+			const range = getRange( '<p>fo{o</p><p>b}ar</p>' );
+
+			expect( range.isFlat ).to.be.false;
+		} );
+	} );
+
+	describe( 'getRoot', () => {
+		it( 'should return root element in which range is created', () => {
+			const viewRoot = new Element( 'div' );
+			const range = getRange( '<p>f{oo</p><p>ba}r</p>', { rootElement: viewRoot } );
+
+			expect( range.getRoot() ).to.equal( viewRoot );
+		} );
+
+		it( 'should return document fragment in which range is created', () => {
+			const viewFrag = new DocumentFragment();
+			const range = getRange( '<p>f{oo</p><p>ba}r</p>', { rootElement: viewFrag } );
+
+			expect( range.getRoot() ).to.equal( viewFrag );
+		} );
+	} );
+
 	describe( 'isEqual', () => {
 		it( 'should return true for the same range', () => {
 			const start = new Position( {}, 1 );
@@ -74,7 +127,72 @@ describe( 'Range', () => {
 		} );
 	} );
 
-	describe( 'isIntersecting', () => {
+	describe( 'containsPosition', () => {
+		let viewRoot, range;
+
+		beforeEach( () => {
+			viewRoot = new Element( 'div' );
+			range = getRange( '<p>fo{o</p><p>bar</p><p>xy}z</p>', { rootElement: viewRoot } );
+		} );
+
+		it( 'should return false if position is before range', () => {
+			const position = new Position( viewRoot.getChild( 0 ).getChild( 0 ), 1 ); // After "f".
+
+			expect( range.containsPosition( position ) ).to.be.false;
+		} );
+
+		it( 'should return false if position is after range', () => {
+			const position = new Position( viewRoot.getChild( 2 ).getChild( 0 ), 3 ); // After "z".
+
+			expect( range.containsPosition( position ) ).to.be.false;
+		} );
+
+		it( 'should return true if position is inside range', () => {
+			const position = new Position( viewRoot.getChild( 1 ).getChild( 0 ), 1 ); // After "b".
+
+			expect( range.containsPosition( position ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'containsRange', () => {
+		let viewRoot, range, beforeF, afterF, beforeB, afterX;
+
+		beforeEach( () => {
+			viewRoot = new Element( 'div' );
+			range = getRange( '<p>fo{o</p><p>bar</p><p>xy}z</p>', { rootElement: viewRoot } );
+
+			beforeF = new Position( viewRoot.getChild( 0 ).getChild( 0 ), 0 );
+			afterF = new Position( viewRoot.getChild( 0 ).getChild( 0 ), 1 );
+			beforeB = new Position( viewRoot.getChild( 1 ).getChild( 0 ), 0 );
+			afterX = new Position( viewRoot.getChild( 2 ).getChild( 0 ), 1 );
+		} );
+
+		it( 'should return false if ranges do not intersect', () => {
+			const otherRange = new Range( beforeF, afterF );
+
+			expect( range.containsRange( otherRange ) ).to.be.false;
+		} );
+
+		it( 'should return false if ranges intersect but only partially', () => {
+			const otherRange = new Range( afterF, afterX );
+
+			expect( range.containsRange( otherRange ) ).to.be.false;
+		} );
+
+		it( 'should return false if ranges are equal', () => {
+			const otherRange = Range.createFromRange( range );
+
+			expect( range.containsRange( otherRange ) ).to.be.false;
+		} );
+
+		it( 'should return true if given range is inside range', () => {
+			const otherRange = new Range( beforeB, afterX );
+
+			expect( range.containsRange( otherRange ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'other range interaction', () => {
 		let root, p1, p2, t1, t2, t3;
 
 		//            root
@@ -93,61 +211,337 @@ describe( 'Range', () => {
 			root = new Element( 'div', null, [ p1, p2 ] );
 		} );
 
-		it( 'should return true if given range is equal', () => {
-			const range = Range.createFromParentsAndOffsets( t1, 0, t3, 2 );
-			const otherRange = Range.createFromRange( range );
-			expect( range.isIntersecting( otherRange ) ).to.be.true;
-			expect( otherRange.isIntersecting( range ) ).to.be.true;
+		describe( 'isIntersecting', () => {
+			it( 'should return true if given range is equal', () => {
+				const range = Range.createFromParentsAndOffsets( t1, 0, t3, 2 );
+				const otherRange = Range.createFromRange( range );
+				expect( range.isIntersecting( otherRange ) ).to.be.true;
+				expect( otherRange.isIntersecting( range ) ).to.be.true;
+			} );
+
+			it( 'should return true if given range contains this range', () => {
+				const range = Range.createFromParentsAndOffsets( t1, 0, t3, 3 );
+				const otherRange = Range.createFromParentsAndOffsets( p1, 1, t2, 2 );
+
+				expect( range.isIntersecting( otherRange ) ).to.be.true;
+				expect( otherRange.isIntersecting( range ) ).to.be.true;
+			} );
+
+			it( 'should return true if given range ends in this range', () => {
+				const range = Range.createFromParentsAndOffsets( root, 1, t3, 3 );
+				const otherRange = Range.createFromParentsAndOffsets( t1, 0, p2, 0 );
+
+				expect( range.isIntersecting( otherRange ) ).to.be.true;
+				expect( otherRange.isIntersecting( range ) ).to.be.true;
+			} );
+
+			it( 'should return true if given range starts in this range', () => {
+				const range = Range.createFromParentsAndOffsets( t1, 0, t2, 3 );
+				const otherRange = Range.createFromParentsAndOffsets( p1, 1, p2, 0 );
+
+				expect( range.isIntersecting( otherRange ) ).to.be.true;
+				expect( otherRange.isIntersecting( range ) ).to.be.true;
+			} );
+
+			it( 'should return false if given range is fully before/after this range', () => {
+				const range = Range.createFromParentsAndOffsets( t1, 0, t2, 3 );
+				const otherRange = Range.createFromParentsAndOffsets( root, 1, t3, 0 );
+
+				expect( range.isIntersecting( otherRange ) ).to.be.false;
+				expect( otherRange.isIntersecting( range ) ).to.be.false;
+			} );
+
+			it( 'should return false if ranges are in different roots', () => {
+				const range = Range.createFromParentsAndOffsets( t1, 0, t2, 3 );
+				const otherRange = Range.createFromParentsAndOffsets( new Element( 'div' ), 1, t3, 0 );
+
+				expect( range.isIntersecting( otherRange ) ).to.be.false;
+				expect( otherRange.isIntersecting( range ) ).to.be.false;
+			} );
 		} );
 
-		it( 'should return true if given range contains this range', () => {
-			const range = Range.createFromParentsAndOffsets( t1, 0, t3, 3 );
-			const otherRange = Range.createFromParentsAndOffsets( p1, 1, t2, 2 );
+		describe( 'getDifference', () => {
+			it( 'should return range equal to original range if other range does not intersect with it', () => {
+				const range = Range.createFromParentsAndOffsets( t1, 0, t2, 3 );
+				const otherRange = Range.createFromParentsAndOffsets( root, 1, t3, 0 );
+				const difference = range.getDifference( otherRange );
+
+				expect( difference.length ).to.equal( 1 );
+				expect( difference[ 0 ].isEqual( range ) ).to.be.true;
+			} );
+
+			it( 'should return shrunken range if other range intersects with it', () => {
+				const range = Range.createFromParentsAndOffsets( root, 1, t3, 3 );
+				const otherRange = Range.createFromParentsAndOffsets( t1, 0, p2, 0 );
+				const difference = range.getDifference( otherRange );
+
+				expect( difference.length ).to.equal( 1 );
+
+				expect( difference[ 0 ].start.parent ).to.equal( p2 );
+				expect( difference[ 0 ].start.offset ).to.equal( 0 );
+				expect( difference[ 0 ].end.parent ).to.equal( t3 );
+				expect( difference[ 0 ].end.offset ).to.equal( 3 );
+			} );
+
+			it( 'should return an empty array if other range contains or is same as the original range', () => {
+				const range = Range.createFromParentsAndOffsets( p1, 1, t2, 2 );
+				const otherRange = Range.createFromParentsAndOffsets( t1, 0, t3, 3 );
+				const difference = range.getDifference( otherRange );
+
+				expect( difference.length ).to.equal( 0 );
+			} );
+
+			it( 'should two ranges if other range is contained by the original range', () => {
+				const range = Range.createFromParentsAndOffsets( t1, 0, t3, 3 );
+				const otherRange = Range.createFromParentsAndOffsets( p1, 1, t2, 2 );
+				const difference = range.getDifference( otherRange );
 
-			expect( range.isIntersecting( otherRange ) ).to.be.true;
-			expect( otherRange.isIntersecting( range ) ).to.be.true;
+				expect( difference.length ).to.equal( 2 );
+
+				expect( difference[ 0 ].start.parent ).to.equal( t1 );
+				expect( difference[ 0 ].start.offset ).to.equal( 0 );
+				expect( difference[ 0 ].end.parent ).to.equal( p1 );
+				expect( difference[ 0 ].end.offset ).to.equal( 1 );
+
+				expect( difference[ 1 ].start.parent ).to.equal( t2 );
+				expect( difference[ 1 ].start.offset ).to.equal( 2 );
+				expect( difference[ 1 ].end.parent ).to.equal( t3 );
+				expect( difference[ 1 ].end.offset ).to.equal( 3 );
+			} );
 		} );
 
-		it( 'should return true if given range ends in this range', () => {
-			const range = Range.createFromParentsAndOffsets( root, 1, t3, 3 );
-			const otherRange = Range.createFromParentsAndOffsets( t1, 0, p2, 0 );
+		describe( 'getIntersection', () => {
+			it( 'should return range equal to original range if other range contains it', () => {
+				const range = Range.createFromParentsAndOffsets( t2, 0, t3, 0 );
+				const otherRange = Range.createFromParentsAndOffsets( t1, 1, t3, 1 );
+				const intersection = range.getIntersection( otherRange );
+
+				expect( intersection.isEqual( range ) ).to.be.true;
+			} );
+
+			it( 'should return range equal to other range if it is contained in original range', () => {
+				const range = Range.createFromParentsAndOffsets( t1, 1, t3, 1 );
+				const otherRange = Range.createFromParentsAndOffsets( t2, 0, t3, 0 );
+				const intersection = range.getIntersection( otherRange );
+
+				expect( intersection.isEqual( otherRange ) ).to.be.true;
+			} );
+
+			it( 'should return null if ranges do not intersect', () => {
+				const range = Range.createFromParentsAndOffsets( t1, 0, t2, 3 );
+				const otherRange = Range.createFromParentsAndOffsets( t3, 0, t3, 3 );
+				const intersection = range.getIntersection( otherRange );
+
+				expect( intersection ).to.be.null;
+			} );
+
+			it( 'should return common part if ranges intersect partially', () => {
+				const range = Range.createFromParentsAndOffsets( t1, 0, t2, 3 );
+				const otherRange = Range.createFromParentsAndOffsets( t2, 0, t3, 3 );
+				const intersection = range.getIntersection( otherRange );
+
+				expect( intersection.start.parent ).to.equal( t2 );
+				expect( intersection.start.offset ).to.equal( 0 );
+				expect( intersection.end.parent ).to.equal( t2 );
+				expect( intersection.end.offset ).to.equal( 3 );
+			} );
+		} );
+	} );
+
+	describe( 'getWalker', () => {
+		it( 'should be possible to iterate using this method', () => {
+			const range = getRange( '<p>fo{o</p><p>ba}r</p><p>xyz</p>' );
+			const values = [];
+
+			for ( let value of range.getWalker() ) {
+				values.push( value );
+			}
+
+			expect( values.length ).to.equal( 4 );
+			expect( values[ 0 ].item.data ).to.equal( 'o' );
+			expect( values[ 1 ].item.name ).to.equal( 'p' );
+			expect( values[ 1 ].type ).to.equal( 'elementEnd' );
+			expect( values[ 2 ].item.name ).to.equal( 'p' );
+			expect( values[ 2 ].type ).to.equal( 'elementStart' );
+			expect( values[ 3 ].item.data ).to.equal( 'ba' );
+		} );
+
+		it( 'should accept TreeWalker options', () => {
+			const range = getRange( '<p>foo</p><p>b{ar</p><p>xy}z</p>' );
+			const walker = range.getWalker( { singleCharacters: true, ignoreElementEnd: true } );
+			const values = [];
+
+			for ( let value of walker ) {
+				values.push( value );
+			}
+
+			expect( walker ).to.be.instanceof( TreeWalker );
+			expect( walker ).to.have.property( 'singleCharacters' ).that.is.true;
+
+			expect( values.length ).to.equal( 5 );
+			expect( values[ 0 ].item.data ).to.equal( 'a' );
+			expect( values[ 1 ].item.data ).to.equal( 'r' );
+			expect( values[ 2 ].item.name ).to.equal( 'p' );
+			expect( values[ 3 ].item.data ).to.equal( 'x' );
+			expect( values[ 4 ].item.data ).to.equal( 'y' );
+		} );
+	} );
+
+	describe( 'getItems', () => {
+		it( 'should return iterator that iterates over all view items in the range', () => {
+			const range = getRange( '<p>fo{o</p><p>bar</p><p>xy}z</p>' );
+			const nodes = [];
+
+			for ( let node of range.getItems() ) {
+				nodes.push( node );
+			}
 
-			expect( range.isIntersecting( otherRange ) ).to.be.true;
-			expect( otherRange.isIntersecting( range ) ).to.be.true;
+			expect( nodes.length ).to.equal( 5 );
+			expect( nodes[ 0 ].data ).to.equal( 'o' );
+			expect( nodes[ 1 ].name ).to.equal( 'p' );
+			expect( nodes[ 2 ].data ).to.equal( 'bar' );
+			expect( nodes[ 3 ].name ).to.equal( 'p' );
+			expect( nodes[ 4 ].data ).to.equal( 'xy' );
 		} );
 
-		it( 'should return true if given range starts in this range', () => {
-			const range = Range.createFromParentsAndOffsets( t1, 0, t2, 3 );
-			const otherRange = Range.createFromParentsAndOffsets( p1, 1, p2, 0 );
+		it( 'should accept TreeWalker options', () => {
+			const range = getRange( '<p>foo</p><p>b{ar</p><p>xy}z</p>' );
+			const nodes = [];
 
-			expect( range.isIntersecting( otherRange ) ).to.be.true;
-			expect( otherRange.isIntersecting( range ) ).to.be.true;
+			for ( let node of range.getItems( { singleCharacters: true } ) ) {
+				nodes.push( node );
+			}
+
+			expect( nodes.length ).to.equal( 5 );
+			expect( nodes[ 0 ].data ).to.equal( 'a' );
+			expect( nodes[ 1 ].data ).to.equal( 'r' );
+			expect( nodes[ 2 ].name ).to.equal( 'p' );
+			expect( nodes[ 3 ].data ).to.equal( 'x' );
+			expect( nodes[ 4 ].data ).to.equal( 'y' );
 		} );
+	} );
+
+	describe( 'getPositions', () => {
+		it( 'should return iterator that iterates over all positions in the range', () => {
+			const range = getRange( '<p>fo{o</p><p>b}ar</p><p>xyz</p>' );
+			const positions = [];
+
+			for ( let position of range.getPositions() ) {
+				positions.push( position );
+			}
+
+			expect( positions.length ).to.equal( 5 );
+
+			expect( positions[ 0 ].parent.data ).to.equal( 'foo' ); // Inside text node "foo".
+			expect( positions[ 0 ].offset ).to.equal( 2 );
+
+			expect( positions[ 1 ].parent.name ).to.equal( 'p' ); // At the end of the first P element.
+			expect( positions[ 1 ].offset ).to.equal( 1 );
+
+			expect( positions[ 2 ].parent ).to.be.instanceof( DocumentFragment ); // In document fragment, between Ps.
+			expect( positions[ 2 ].offset ).to.equal( 1 );
 
-		it( 'should return false if given range is fully before/after this range', () => {
-			const range = Range.createFromParentsAndOffsets( t1, 0, t2, 3 );
-			const otherRange = Range.createFromParentsAndOffsets( root, 1, t3, 0 );
+			expect( positions[ 3 ].parent.name ).to.equal( 'p' ); // At the beginning of the second P element.
+			expect( positions[ 3 ].offset ).to.equal( 0 );
 
-			expect( range.isIntersecting( otherRange ) ).to.be.false;
-			expect( otherRange.isIntersecting( range ) ).to.be.false;
+			expect( positions[ 4 ].parent.data ).to.equal( 'bar' ); // Inside text node "bar".
+			expect( positions[ 4 ].offset ).to.equal( 1 );
 		} );
 
-		it( 'should return false if ranges are in different roots', () => {
-			const range = Range.createFromParentsAndOffsets( t1, 0, t2, 3 );
-			const otherRange = Range.createFromParentsAndOffsets( new Element( 'div' ), 1, t3, 0 );
+		it( 'should accept TreeWalker options', () => {
+			const range = getRange( '<p>foo</p><p>b{ar</p><p>xy}z</p>' );
+			const positions = [];
+
+			for ( let position of range.getPositions( { singleCharacters: true } ) ) {
+				positions.push( position );
+			}
+
+			expect( positions.length ).to.equal( 7 );
+
+			expect( positions[ 0 ].parent.data ).to.equal( 'bar' ); // "b^ar".
+			expect( positions[ 0 ].offset ).to.equal( 1 );
+
+			expect( positions[ 1 ].parent.data ).to.equal( 'bar' ); // "ba^r".
+			expect( positions[ 1 ].offset ).to.equal( 2 );
+
+			expect( positions[ 2 ].parent.name ).to.equal( 'p' ); // <p>bar^</p> -- at the end of P node.
+			expect( positions[ 2 ].offset ).to.equal( 1 );
+
+			expect( positions[ 3 ].parent ).to.be.instanceof( DocumentFragment ); // "</p>^<p>" -- between P nodes.
+			expect( positions[ 3 ].offset ).to.equal( 2 );
+
+			expect( positions[ 4 ].parent.name ).to.equal( 'p' ); // <p>^xyz</p> -- at the start of P node.
+			expect( positions[ 4 ].offset ).to.equal( 0 );
+
+			expect( positions[ 5 ].parent.data ).to.equal( 'xyz' ); // "x^yz".
+			expect( positions[ 5 ].offset ).to.equal( 1 );
 
-			expect( range.isIntersecting( otherRange ) ).to.be.false;
-			expect( otherRange.isIntersecting( range ) ).to.be.false;
+			expect( positions[ 6 ].parent.data ).to.equal( 'xyz' ); // "xy^z".
+			expect( positions[ 6 ].offset ).to.equal( 2 );
 		} );
 	} );
 
-	describe( 'createFromRange', () => {
-		it( 'should create a new instance of Range that is equal to passed range', () => {
-			const range = new Range( new Position( {}, 0 ), new Position( {}, 1 ) );
-			const clone = Range.createFromRange( range );
+	describe( 'static constructors', () => {
+		let div, p, foz;
+
+		beforeEach( () => {
+			foz = new Text( 'foz' );
+			p = new Element( 'p', null, foz );
+			div = new Element( 'div', null, p );
+		} );
+
+		describe( 'createFromElement', () => {
+			it( 'should return range', () => {
+				const range = Range.createFromElement( p );
+
+				expect( range.start.parent ).to.deep.equal( p );
+				expect( range.start.offset ).to.deep.equal( 0 );
+				expect( range.end.parent ).to.deep.equal( p );
+				expect( range.end.offset ).to.deep.equal( 1 );
+			} );
+		} );
+
+		describe( 'createFromParentsAndOffsets', () => {
+			it( 'should return range', () => {
+				const range = Range.createFromParentsAndOffsets( div, 0, foz, 1 );
+
+				expect( range.start.parent ).to.deep.equal( div );
+				expect( range.start.offset ).to.deep.equal( 0 );
+				expect( range.end.parent ).to.deep.equal( foz );
+				expect( range.end.offset ).to.deep.equal( 1 );
+			} );
+		} );
+
+		describe( 'createFromPositionAndShift', () => {
+			it( 'should make range from start position and offset', () => {
+				const position = new Position( foz, 1 );
+				const range = Range.createFromPositionAndShift( position, 2 );
+
+				expect( range ).to.be.instanceof( Range );
+				expect( range.start.isEqual( position ) ).to.be.true;
+				expect( range.end.parent ).to.equal( foz );
+				expect( range.end.offset ).to.deep.equal( 3 );
+			} );
+
+			it( 'should accept negative shift value', () => {
+				const position = new Position( foz, 3 );
+				const range = Range.createFromPositionAndShift( position, -1 );
+
+				expect( range ).to.be.instanceof( Range );
+				expect( range.end.isEqual( position ) ).to.be.true;
+				expect( range.start.parent ).to.equal( foz );
+				expect( range.start.offset ).to.deep.equal( 2 );
+			} );
+		} );
+
+		describe( 'createFromRange', () => {
+			it( 'should create a new instance of Range that is equal to passed range', () => {
+				const range = new Range( new Position( foz, 0 ), new Position( foz, 2 ) );
+				const clone = Range.createFromRange( range );
 
-			expect( clone ).not.to.be.equal( range ); // clone is not pointing to the same object as position
-			expect( clone.isEqual( range ) ).to.be.true; // but they are equal in the position-sense
+				expect( clone ).not.to.be.equal( range ); // clone is not pointing to the same object as position
+				expect( clone.isEqual( range ) ).to.be.true; // but they are equal in the position-sense
+			} );
 		} );
 	} );
 } );

+ 84 - 1
packages/ckeditor5-engine/tests/view/selection.js

@@ -9,6 +9,8 @@ import Selection from '/ckeditor5/engine/view/selection.js';
 import Range from '/ckeditor5/engine/view/range.js';
 import Document from '/ckeditor5/engine/view/document.js';
 import Element from '/ckeditor5/engine/view/element.js';
+import Text from '/ckeditor5/engine/view/text.js';
+import Position from '/ckeditor5/engine/view/position.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 
 describe( 'Selection', () => {
@@ -303,7 +305,7 @@ describe( 'Selection', () => {
 			selection.addRange( range1 );
 
 			const otherSelection = new Selection();
-			otherSelection.addRange( range2, true );
+			otherSelection.addRange( range1, true );
 
 			expect( selection.isEqual( otherSelection ) ).to.be.false;
 		} );
@@ -387,6 +389,68 @@ describe( 'Selection', () => {
 		} );
 	} );
 
+	describe( 'collapse', () => {
+		beforeEach( () => {
+			selection.setRanges( [ range1, range2 ] );
+		} );
+
+		it( 'should collapse selection at position', () => {
+			const position = new Position( el, 4 );
+
+			selection.collapse( position );
+			const range = selection.getFirstRange();
+
+			expect( range.start.parent ).to.equal( el );
+			expect( range.start.offset ).to.equal( 4 );
+			expect( range.start.isEqual( range.end ) ).to.be.true;
+		} );
+
+		it( 'should collapse selection at node and offset', () => {
+			const foo = new Text( 'foo' );
+			const p = new Element( 'p', null, foo );
+
+			selection.collapse( 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 );
+			range = selection.getFirstRange();
+
+			expect( range.start.parent ).to.equal( p );
+			expect( range.start.offset ).to.equal( 1 );
+			expect( range.start.isEqual( range.end ) ).to.be.true;
+		} );
+
+		it( 'should collapse selection at node and flag', () => {
+			const foo = new Text( 'foo' );
+			const p = new Element( 'p', null, foo );
+
+			selection.collapse( 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' );
+			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' );
+			range = selection.getFirstRange();
+
+			expect( range.start.parent ).to.equal( p );
+			expect( range.start.offset ).to.equal( 1 );
+			expect( range.start.isEqual( range.end ) ).to.be.true;
+		} );
+	} );
+
 	describe( 'collapseToStart', () => {
 		it( 'should collapse to start position and fire change event', ( done ) => {
 			selection.setRanges( [ range1, range2, range3 ] );
@@ -456,4 +520,23 @@ describe( 'Selection', () => {
 			expect( selection.getEditableElement() ).to.equal( root );
 		} );
 	} );
+
+	describe( 'createFromSelection', () => {
+		it( 'should return a Selection instance with same ranges and direction as given selection', () => {
+			selection.setRanges( [ range1, range2 ], true );
+
+			const snapshot = Selection.createFromSelection( selection );
+
+			expect( snapshot.isBackward ).to.equal( selection.isBackward );
+
+			const selectionRanges = Array.from( selection.getRanges() );
+			const snapshotRanges = Array.from( snapshot.getRanges() );
+
+			expect( selectionRanges.length ).to.equal( snapshotRanges.length );
+
+			for ( let i = 0; i < selectionRanges.length; i++ ) {
+				expect( selectionRanges[ i ].isEqual( snapshotRanges[ i ] ) ).to.be.true;
+			}
+		} );
+	} );
 } );

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

@@ -29,14 +29,14 @@ describe( 'TextProxy', () => {
 			expect( textProxy ).to.have.property( 'parent' ).to.equal( parent );
 			expect( textProxy ).to.have.property( 'data' ).to.equal( 'cde' );
 			expect( textProxy ).to.have.property( 'textNode' ).to.equal( text );
-			expect( textProxy ).to.have.property( 'index' ).to.equal( 2 );
+			expect( textProxy ).to.have.property( 'offsetInText' ).to.equal( 2 );
 		} );
 
 		it( 'should get text from specified offset to the end of textNode if length is not defined', () => {
 			textProxy = new TextProxy( text, 2 );
 
 			expect( textProxy ).to.have.property( 'data' ).to.equal( 'cdefgh' );
-			expect( textProxy ).to.have.property( 'index' ).to.equal( 2 );
+			expect( textProxy ).to.have.property( 'offsetInText' ).to.equal( 2 );
 		} );
 	} );