Kaynağa Gözat

Merge pull request #328 from ckeditor/t/308

treeView.Selection class
Piotr Jasiun 9 yıl önce
ebeveyn
işleme
5629cc967a

+ 10 - 5
packages/ckeditor5-engine/src/treemodel/selection.js

@@ -146,13 +146,18 @@ export default class Selection {
 	}
 
 	/**
-	 * Returns an array of ranges added to the selection. The method returns a copy of internal array, so
-	 * it will not change when ranges get added or removed from selection.
+	 * Returns an iterator that contains copies of all ranges added to the selection.
 	 *
-	 * @returns {Array.<LiveRange>}
+	 * @returns {Iterator.<engine.treeModel.Range>}
 	 */
-	getRanges() {
-		return this._ranges.length ? this._ranges.slice() : [ this._getDefaultRange() ];
+	*getRanges() {
+		if ( this._ranges.length ) {
+			for ( let range of this._ranges ) {
+				yield Range.createFromRange( range );
+			}
+		} else {
+			yield this._getDefaultRange();
+		}
 	}
 
 	/**

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

@@ -105,6 +105,27 @@ export default class Node {
 		}
 	}
 
+	/**
+	 * Returns ancestors array of this node.
+	 *
+	 * @param {Object} options Options object.
+	 * @param {Boolean} [options.includeNode=false] When set to `true` this node will be also included in parent's array.
+	 * @param {Boolean} [options.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 !== null ) {
+			ancestors[ options.parentFirst ? 'push' : 'unshift' ]( parent );
+			parent = parent.parent;
+		}
+
+		return ancestors;
+	}
+
 	/**
 	 * Sets the {@link engine.treeView.TreeView} of the node. Note that not all of nodes need to have {@link engine.treeView.TreeView}
 	 * assigned, see {@link engine.treeView.Node#getTreeView}.

+ 104 - 2
packages/ckeditor5-engine/src/treeview/position.js

@@ -5,6 +5,8 @@
 
 'use strict';
 
+import utils from '../../utils/utils.js';
+
 /**
  * Position in the tree. Position is always located before or after a node.
  *
@@ -19,9 +21,9 @@ export default class Position {
 	 */
 	constructor( parent, offset ) {
 		/**
-		 * Position parent element.
+		 * Position parent node.
 		 *
-		 * @member {engine.treeView.Element} engine.treeView.Position#parent
+		 * @member {engine.treeView.Node} engine.treeView.Position#parent
 		 */
 		this.parent = parent;
 
@@ -58,6 +60,99 @@ export default class Position {
 		return this == otherPosition || ( this.parent == otherPosition.parent && this.offset == otherPosition.offset );
 	}
 
+	/**
+	 * Checks whether this position is located before given position. When method returns `false` it does not mean that
+	 * this position is after give one. Two positions may be located inside separate roots and in that situation this
+	 * method will still return `false`.
+	 *
+	 * @see engine.treeView.Position#isAfter
+	 * @see engine.treeView.Position#compareWith
+	 * @param {engine.treeView.Position} otherPosition Position to compare with.
+	 * @returns {Boolean} Returns `true` if this position is before given position.
+	 */
+	isBefore( otherPosition ) {
+		return this.compareWith( otherPosition ) == 'BEFORE';
+	}
+
+	/**
+	 * Checks whether this position is located after given position. When method returns `false` it does not mean that
+	 * this position is before give one. Two positions may be located inside separate roots and in that situation this
+	 * method will still return `false`.
+	 *
+	 * @see engine.treeView.Position#isBefore
+	 * @see engine.treeView.Position#compareWith
+	 * @param {engine.treeView.Position} otherPosition Position to compare with.
+	 * @returns {Boolean} Returns `true` if this position is after given position.
+	 */
+	isAfter( otherPosition ) {
+		return this.compareWith( otherPosition ) == 'AFTER';
+	}
+
+	/**
+	 * 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.
+	 *
+	 * @param {engine.treeView.Position} otherPosition Position to compare with.
+	 * @returns {engine.treeView.PositionRelation}
+	 */
+	compareWith( otherPosition ) {
+		if ( this.isEqual( otherPosition ) ) {
+			return 'SAME';
+		}
+
+		// If positions have same parent.
+		if ( this.parent === otherPosition.parent ) {
+			return this.offset - otherPosition.offset < 0 ? 'BEFORE' : 'AFTER';
+		}
+
+		// Get path from root to position's parent element.
+		const path = this.parent.getAncestors( { includeNode: true } );
+		const otherPath = otherPosition.parent.getAncestors( { includeNode: true } );
+
+		// Compare both path arrays to find common ancestor.
+		const result = utils.compareArrays( path, otherPath );
+
+		let commonAncestorIndex;
+
+		switch ( result ) {
+			case 0:
+				// No common ancestors found.
+				return 'DIFFERENT';
+
+			case 'PREFIX':
+				commonAncestorIndex = path.length - 1;
+				break;
+
+			case 'EXTENSION':
+				commonAncestorIndex = otherPath.length - 1;
+				break;
+
+			default:
+				commonAncestorIndex = result - 1;
+		}
+
+		// Common ancestor of two positions.
+		const commonAncestor = path[ commonAncestorIndex ];
+		const nextAncestor1 = path[ commonAncestorIndex + 1 ];
+		const nextAncestor2 = otherPath[ commonAncestorIndex + 1 ];
+
+		// Check if common ancestor is not one of the parents.
+		if ( commonAncestor === this.parent ) {
+			const index = this.offset - nextAncestor2.getIndex();
+
+			return index <= 0 ? 'BEFORE' : 'AFTER';
+		} else if ( commonAncestor === otherPosition.parent ) {
+			const index = nextAncestor1.getIndex() - otherPosition.offset;
+
+			return index < 0 ? 'BEFORE' : 'AFTER';
+		}
+
+		const index = nextAncestor1.getIndex() - nextAncestor2.getIndex();
+
+		// Compare indexes of next ancestors inside common one.
+		return index < 0 ? 'BEFORE' : 'AFTER';
+	}
+
 	/**
 	 * Creates and returns a new instance of Position, which is equal to passed position.
 	 *
@@ -68,3 +163,10 @@ export default class Position {
 		return new this( position.parent, position.offset );
 	}
 }
+
+/**
+ * A flag indicating whether this position is `'BEFORE'` or `'AFTER'` or `'SAME'` as given position.
+ * If positions are in different roots `'DIFFERENT'` flag is returned.
+ *
+ * @typedef {String} engine.treeView.PositionRelation
+ */

+ 20 - 0
packages/ckeditor5-engine/src/treeview/range.js

@@ -57,6 +57,16 @@ export default class Range {
 		return this == otherRange || ( this.start.isEqual( otherRange.start ) && this.end.isEqual( otherRange.end ) );
 	}
 
+	/**
+	 * Checks and returns whether this range intersects with given range.
+	 *
+	 * @param {engine.treeView.Range} otherRange Range to compare with.
+	 * @returns {Boolean} True if ranges intersect.
+	 */
+	isIntersecting( otherRange ) {
+		return this.start.isBefore( otherRange.end ) && this.end.isAfter( otherRange.start );
+	}
+
 	/**
 	 * Creates a range from given parents and offsets.
 	 *
@@ -72,4 +82,14 @@ export default class Range {
 			new Position( endElement, endOffset )
 		);
 	}
+
+	/**
+	 * Creates and returns a new instance of Range which is equal to passed range.
+	 *
+	 * @param {engine.treeView.Range} range Range to clone.
+	 * @returns {engine.treeView.Range}
+	 */
+	static createFromRange( range ) {
+		return new this( range.start, range.end );
+	}
 }

+ 311 - 0
packages/ckeditor5-engine/src/treeview/selection.js

@@ -0,0 +1,311 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import CKEditorError from '../../utils/ckeditorerror.js';
+import Range from './range.js';
+import Position from './position.js';
+import utils from '../../utils/utils.js';
+import EmitterMixin from '../../utils/emittermixin.js';
+
+/**
+ * Class representing selection in tree view.
+ *
+ * Selection can consist of {@link engine.treeView.Range ranges} that can be added using
+ * {@link engine.treeView.Selection#addRange addRange} and {@link engine.treeView.Selection#setRanges setRanges} methods.
+ * Both methods create copies of provided ranges and store those copies internally. Further modifications to passed
+ * ranges will not change selection's state.
+ * Selection's ranges can be obtained via {@link engine.treeView.Selection#getRanges getRanges},
+ * {@link engine.treeView.Selection#getFirstRange getFirstRange} and {@link engine.treeView.Selection#getLastRange getLastRange}
+ * methods, which return copies of ranges stored inside selection. Modifications made on these copies will not change
+ * selection's state. Similar situation occurs when getting {@link engine.treeView.Selection#anchor anchor},
+ * {@link engine.treeView.Selection#focus focus}, {@link engine.treeView.Selection#getFirstPosition first} and
+ * {@link engine.treeView.Selection#getLastPosition last} positions - all will return copies of requested positions.
+ *
+ * @memberOf engine.treeView
+ */
+export default class Selection {
+	/**
+	 * Creates new selection instance.
+	 */
+	constructor() {
+		/**
+		 * Stores all ranges that are selected.
+		 *
+		 * @private
+		 * @member {Array.<engine.treeView.Range>} engine.treeView.Selection#_ranges
+		 */
+		this._ranges = [];
+
+		/**
+		 * Specifies whether the last added range was added as a backward or forward range.
+		 *
+		 * @private
+		 * @member {Boolean} engine.treeView.Selection#_lastRangeBackward
+		 */
+		this._lastRangeBackward = false;
+	}
+
+	/**
+	 * Selection anchor. Anchor may be described as a position where the selection starts. Together with
+	 * {@link engine.treeView.Selection#focus focus} they define the direction of selection, which is important
+	 * when expanding/shrinking selection. Anchor is always the start or end of the most recent added range.
+	 * It may be a bit unintuitive when there are multiple ranges in selection.
+	 *
+	 * @see engine.treeView.Selection#focus
+	 * @type {engine.treeView.Position}
+	 */
+	get anchor() {
+		if ( !this._ranges.length ) {
+			return null;
+		}
+		const range = this._ranges[ this._ranges.length - 1 ];
+		const anchor = this._lastRangeBackward ? range.end : range.start;
+
+		return Position.createFromPosition( anchor );
+	}
+
+	/**
+	 * Selection focus. Focus is a position where the selection ends.
+	 *
+	 * @see engine.treeView.Selection#anchor
+	 * @type {engine.treeView.Position}
+	 */
+	get focus() {
+		if ( !this._ranges.length ) {
+			return null;
+		}
+		const range = this._ranges[ this._ranges.length - 1 ];
+		const focus = this._lastRangeBackward ? range.start : range.end;
+
+		return Position.createFromPosition( focus );
+	}
+
+	/**
+	 * Returns whether the selection is collapsed. Selection is collapsed when all it's ranges are collapsed.
+	 *
+	 * @type {Boolean}
+	 */
+	get isCollapsed() {
+		for ( let range of this._ranges ) {
+			if ( !range.isCollapsed ) {
+				return false;
+			}
+		}
+
+		return true;
+	}
+
+	/**
+	 * Returns number of ranges in selection.
+	 *
+	 * @type {Number}
+     */
+	get rangeCount() {
+		return this._ranges.length;
+	}
+
+	/**
+	 * Adds a range to the selection. Added range is copied. This means that passed range is not saved in the
+	 * selection instance and you can safely operate on it.
+	 *
+	 * Accepts a flag describing in which way the selection is made - passed range might be selected from
+	 * {@link engine.treeView.Range#start start} to {@link engine.treeView.Range#end end}
+	 * or from {@link engine.treeView.Range#end end} to {@link engine.treeView.Range#start start}.
+	 * The flag is used to set {@link engine.treeView.Selection#anchor anchor} and
+	 * {@link engine.treeView.Selection#focus focus} properties.
+	 *
+	 * Throws {@link utils.CKEditorError CKEditorError} `view-selection-range-intersects` if added range intersects
+	 * with ranges already stored in Selection instance.
+	 *
+	 * @fires engine.treeView.Selection#change
+	 * @param {engine.treeView.Range} range
+	 */
+	addRange( range, isBackward ) {
+		this._pushRange( range );
+		this._lastRangeBackward = !!isBackward;
+		this.fire( 'change' );
+	}
+
+	/**
+	 * Returns an iterator that contains copies of all ranges added to the selection.
+	 *
+	 * @returns {Iterator.<engine.treeView.Range>}
+	 */
+	*getRanges() {
+		for ( let range of this._ranges ) {
+			yield Range.createFromRange( range );
+		}
+	}
+
+	/**
+	 * Returns copy of the first range in the selection. First range is the one which
+	 * {@link engine.treeView.Range#start start} position {@link engine.treeView.Position#isBefore is before} start
+	 * position of all other ranges (not to confuse with the first range added to the selection).
+	 * Returns `null` if no ranges are added to selection.
+	 *
+	 * @returns {engine.treeView.Range|null}
+	 */
+	getFirstRange() {
+		let first = null;
+
+		for ( let range of this._ranges ) {
+			if ( !first || range.start.isBefore( first.start ) ) {
+				first = range;
+			}
+		}
+
+		return first ? Range.createFromRange( first ) : null;
+	}
+
+	/**
+	 * Returns copy of the last range in the selection. Last range is the one which {@link engine.treeView.Range#end end}
+	 * position {@link engine.treeView.Position#isAfter is after} end position of all other ranges (not to confuse
+	 * with the last range added to the selection). Returns `null` if no ranges are added to selection.
+	 *
+	 * @returns {engine.treeView.Range|null}
+	 */
+	getLastRange() {
+		let last = null;
+
+		for ( let range of this._ranges ) {
+			if ( !last || range.end.isAfter( last.end ) ) {
+				last = range;
+			}
+		}
+
+		return last ? Range.createFromRange( last ) : null;
+	}
+
+	/**
+	 * Returns copy of the first position in the selection. First position is the position that
+	 * {@link engine.treeView.Position#isBefore is before} any other position in the selection ranges.
+	 * Returns `null` if no ranges are added to selection.
+	 *
+	 * @returns {engine.treeView.Position|null}
+	 */
+	getFirstPosition() {
+		const firstRange = this.getFirstRange();
+
+		return firstRange ? Position.createFromPosition( firstRange.start ) : null;
+	}
+
+	/**
+	 * Returns copy of the last position in the selection. Last position is the position that
+	 * {@link engine.treeView.Position#isAfter is after} any other position in the selection ranges.
+	 * Returns `null` if no ranges are added to selection.
+	 *
+	 * @returns {engine.treeView.Position|null}
+	 */
+	getLastPosition() {
+		const lastRange = this.getLastRange();
+
+		return lastRange ? Position.createFromPosition( lastRange.end ) : null;
+	}
+
+	/**
+	 * Removes all ranges that were added to the selection.
+	 *
+	 * @fires engine.treeView.Selection#change
+	 */
+	removeAllRanges() {
+		if ( this._ranges.length ) {
+			this._ranges = [];
+			this.fire( 'change' );
+		}
+	}
+
+	/**
+	 * Replaces all ranges that were added to the selection with given array of ranges. Last range of the array
+	 * is treated like the last added range and is used to set {@link engine.treeView.Selection#anchor anchor} and
+	 * {@link engine.treeView.Selection#focus focus}. Accepts a flag describing in which way the selection is made
+	 * (see {@link engine.treeView.Selection#addRange addRange}).
+	 *
+	 * @fires engine.treeView.Selection#change
+	 * @param {Array.<engine.treeView.Range>} newRanges Array of ranges to set.
+	 * @param {Boolean} [isLastBackward] Flag describing if last added range was selected forward - from start to end
+	 * (`false`) or backward - from end to start (`true`). Defaults to `false`.
+	 */
+	setRanges( newRanges, isLastBackward ) {
+		this._ranges = [];
+
+		for ( let range of newRanges ) {
+			this._pushRange( range );
+		}
+
+		this._lastRangeBackward = !!isLastBackward;
+		this.fire( 'change' );
+	}
+
+	/**
+	 * Collapses selection to the {@link engine.treeView.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
+	 * inside selection.
+	 *
+	 * @fires engine.treeView.Selection#change
+	 */
+	collapseToStart() {
+		const startPosition = this.getFirstPosition();
+
+		if ( startPosition !== null ) {
+			this.setRanges( [ new Range( startPosition, startPosition ) ] );
+			this.fire( 'change' );
+		}
+	}
+
+	/**
+	 * Collapses selection to the {@link engine.treeView.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
+	 * inside selection.
+	 *
+	 * @fires engine.treeView.Selection#change
+	 */
+	collapseToEnd() {
+		const endPosition = this.getLastPosition();
+
+		if ( endPosition !== null ) {
+			this.setRanges( [ new Range( endPosition, endPosition ) ] );
+			this.fire( 'change' );
+		}
+	}
+
+	/**
+	 * Adds range to selection - creates copy of given range so it can be safely used and modified.
+	 *
+	 * Throws {@link utils.CKEditorError CKEditorError} `view-selection-range-intersects` if added range intersects
+	 * with ranges already stored in selection instance.
+	 *
+	 * @private
+	 * @param {engine.treeView.Range} range
+	 */
+	_pushRange( range ) {
+		for ( let storedRange of this._ranges ) {
+			if ( range.isIntersecting( storedRange ) ) {
+				/**
+				 * Trying to add a range that intersects with another range from selection.
+				 *
+				 * @error view-selection-range-intersects
+				 * @param {engine.treeView.Range} addedRange Range that was added to the selection.
+				 * @param {engine.treeView.Range} intersectingRange Range from selection that intersects with `addedRange`.
+				 */
+				throw new CKEditorError(
+					'view-selection-range-intersects: Trying to add a range that intersects with another range from selection.',
+					{ addedRange: range, intersectingRange: storedRange }
+				);
+			}
+		}
+
+		this._ranges.push( Range.createFromRange( range ) );
+	}
+}
+
+utils.mix( Selection, EmitterMixin );
+
+/**
+ * Fired whenever selection ranges are changed through {@link engine.treeView.Selection Selection API}.
+ *
+ * @event engine.treeView.Selection#change
+ */

+ 1 - 1
packages/ckeditor5-engine/tests/treemodel/document/document.js

@@ -26,7 +26,7 @@ describe( 'Document', () => {
 			expect( doc._roots.size ).to.equal( 1 );
 			expect( doc.graveyard ).to.be.instanceof( RootElement );
 			expect( doc.graveyard.getChildCount() ).to.equal( 0 );
-			expect( doc.selection.getRanges().length ).to.equal( 1 );
+			expect( utils.count( doc.selection.getRanges() ) ).to.equal( 1 );
 		} );
 	} );
 

+ 16 - 16
packages/ckeditor5-engine/tests/treemodel/selection.js

@@ -54,7 +54,7 @@ describe( 'Selection', () => {
 	} );
 
 	it( 'should be set to default range when just created', () => {
-		let ranges = selection.getRanges();
+		const ranges = Array.from( selection.getRanges() );
 
 		expect( ranges.length ).to.equal( 1 );
 		expect( selection.anchor.isEqual( new Position( root, [ 0 ] ) ) ).to.be.true;
@@ -87,7 +87,7 @@ describe( 'Selection', () => {
 			selection.addRange( liveRange );
 			selection.addRange( range );
 
-			let ranges = selection.getRanges();
+			const ranges = selection._ranges;
 
 			expect( ranges.length ).to.equal( 2 );
 			expect( ranges[ 0 ].isEqual( liveRange ) ).to.be.true;
@@ -131,7 +131,7 @@ describe( 'Selection', () => {
 		it( 'should return a copy of (not a reference to) array of stored ranges', () => {
 			selection.addRange( liveRange );
 
-			let ranges = selection.getRanges();
+			const ranges = Array.from( selection.getRanges() );
 
 			selection.addRange( range );
 
@@ -142,7 +142,7 @@ describe( 'Selection', () => {
 		it( 'should convert added Range to LiveRange', () => {
 			selection.addRange( range );
 
-			let ranges = selection.getRanges();
+			const ranges = selection._ranges;
 
 			expect( ranges[ 0 ] ).to.be.instanceof( LiveRange );
 		} );
@@ -160,7 +160,7 @@ describe( 'Selection', () => {
 			selection.addRange( liveRange );
 			selection.addRange( range );
 
-			let ranges = selection.getRanges();
+			const ranges = selection._ranges;
 
 			sinon.spy( ranges[ 0 ], 'detach' );
 			sinon.spy( ranges[ 1 ], 'detach' );
@@ -270,7 +270,7 @@ describe( 'Selection', () => {
 			spy = sinon.spy();
 			selection.on( 'change:range', spy );
 
-			ranges = selection.getRanges();
+			ranges = selection._ranges;
 
 			sinon.spy( ranges[ 0 ], 'detach' );
 			sinon.spy( ranges[ 1 ], 'detach' );
@@ -284,7 +284,7 @@ describe( 'Selection', () => {
 		} );
 
 		it( 'should remove all stored ranges (and reset to default range)', () => {
-			expect( selection.getRanges().length ).to.equal( 1 );
+			expect( Array.from( selection.getRanges() ).length ).to.equal( 1 );
 			expect( selection.anchor.isEqual( new Position( root, [ 0 ] ) ) ).to.be.true;
 			expect( selection.focus.isEqual( new Position( root, [ 0 ] ) ) ).to.be.true;
 		} );
@@ -316,7 +316,7 @@ describe( 'Selection', () => {
 			spy = sinon.spy();
 			selection.on( 'change:range', spy );
 
-			oldRanges = selection.getRanges();
+			oldRanges = selection._ranges;
 
 			sinon.spy( oldRanges[ 0 ], 'detach' );
 			sinon.spy( oldRanges[ 1 ], 'detach' );
@@ -330,7 +330,7 @@ describe( 'Selection', () => {
 		it( 'should remove all ranges and add given ranges', () => {
 			selection.setRanges( newRanges );
 
-			let ranges = selection.getRanges();
+			let ranges = selection._ranges;
 
 			expect( ranges.length ).to.equal( 2 );
 			expect( ranges[ 0 ].isEqual( newRanges[ 0 ] ) ).to.be.true;
@@ -420,7 +420,7 @@ describe( 'Selection', () => {
 					)
 				);
 
-				let range = selection.getRanges()[ 0 ];
+				let range = selection._ranges[ 0 ];
 
 				expect( range.start.path ).to.deep.equal( [ 0, 5 ] );
 				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
@@ -436,7 +436,7 @@ describe( 'Selection', () => {
 					)
 				);
 
-				let range = selection.getRanges()[ 0 ];
+				let range = selection._ranges[ 0 ];
 
 				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
 				expect( range.end.path ).to.deep.equal( [ 1, 7 ] );
@@ -455,7 +455,7 @@ describe( 'Selection', () => {
 					)
 				);
 
-				let range = selection.getRanges()[ 0 ];
+				let range = selection._ranges[ 0 ];
 
 				expect( range.start.path ).to.deep.equal( [ 0, 0 ] );
 				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
@@ -472,7 +472,7 @@ describe( 'Selection', () => {
 					)
 				);
 
-				let range = selection.getRanges()[ 0 ];
+				let range = selection._ranges[ 0 ];
 
 				expect( range.start.path ).to.deep.equal( [ 0, 4 ] );
 				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
@@ -489,7 +489,7 @@ describe( 'Selection', () => {
 					)
 				);
 
-				let range = selection.getRanges()[ 0 ];
+				let range = selection._ranges[ 0 ];
 
 				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
 				expect( range.end.path ).to.deep.equal( [ 1, 2 ] );
@@ -506,7 +506,7 @@ describe( 'Selection', () => {
 					)
 				);
 
-				let range = selection.getRanges()[ 0 ];
+				let range = selection._ranges[ 0 ];
 
 				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
 				expect( range.end.path ).to.deep.equal( [ 1, 3 ] );
@@ -531,7 +531,7 @@ describe( 'Selection', () => {
 					)
 				);
 
-				let range = selection.getRanges()[ 0 ];
+				let range = selection._ranges[ 0 ];
 
 				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
 				expect( range.end.path ).to.deep.equal( [ 2, 2 ] );

+ 41 - 0
packages/ckeditor5-engine/tests/treeview/node.js

@@ -57,6 +57,47 @@ describe( 'Node', () => {
 		} );
 	} );
 
+	describe( 'getAncestors', () => {
+		it( 'should return empty array for node without ancestors', () => {
+			const result = root.getAncestors();
+			expect( result ).to.be.an( 'array' );
+			expect( result.length ).to.equal( 0 );
+		} );
+
+		it( 'should return array including node itself if requested', () => {
+			const result = root.getAncestors( { includeNode: true } );
+			expect( result ).to.be.an( 'array' );
+			expect( result.length ).to.equal( 1 );
+			expect( result[ 0 ] ).to.equal( root );
+		} );
+
+		it( 'should return array of ancestors', () => {
+			const result = charR.getAncestors();
+			expect( result.length ).to.equal( 2 );
+			expect( result[ 0 ] ).to.equal( root );
+			expect( result[ 1 ] ).to.equal( two );
+
+			const result2 = charR.getAncestors( { includeNode: true } );
+			expect( result2.length ).to.equal( 3 );
+			expect( result2[ 0 ] ).to.equal( root );
+			expect( result2[ 1 ] ).to.equal( two );
+			expect( result2[ 2 ] ).to.equal( charR );
+		} );
+
+		it( 'should return array of ancestors starting from parent', () => {
+			const result = charR.getAncestors( { parentFirst: true } );
+			expect( result.length ).to.equal( 2 );
+			expect( result[ 0 ] ).to.equal( two );
+			expect( result[ 1 ] ).to.equal( root );
+
+			const result2 = charR.getAncestors( { includeNode: true, parentFirst: true } );
+			expect( result2.length ).to.equal( 3 );
+			expect( result2[ 2 ] ).to.equal( root );
+			expect( result2[ 1 ] ).to.equal( two );
+			expect( result2[ 0 ] ).to.equal( charR );
+		} );
+	} );
+
 	describe( 'getIndex', () => {
 		it( 'should return null if the parent is null', () => {
 			expect( root.getIndex() ).to.be.null;

+ 153 - 0
packages/ckeditor5-engine/tests/treeview/position.js

@@ -8,6 +8,9 @@
 'use strict';
 
 import Position from '/ckeditor5/engine/treeview/position.js';
+import Node from '/ckeditor5/engine/treeview/node.js';
+import Element from '/ckeditor5/engine/treeview/element.js';
+import Text from '/ckeditor5/engine/treeview/text.js';
 
 describe( 'Position', () => {
 	const parentMock = {};
@@ -80,4 +83,154 @@ describe( 'Position', () => {
 			expect( position1.isEqual( position2 ) ).to.be.false;
 		} );
 	} );
+
+	describe( 'isBefore', () => {
+		it( 'should return false for same positions', () => {
+			const node = new Node();
+			const position1 = new Position( node, 10 );
+			const position2 = new Position( node, 10 );
+
+			expect( position1.isBefore( position1 ) ).to.be.false;
+			expect( position1.isBefore( position2 ) ).to.be.false;
+			expect( position2.isBefore( position1 ) ).to.be.false;
+		} );
+
+		it( 'should return false if no common ancestor is found', () => {
+			const t1 = new Text( 'foo' );
+			const t2 = new Text( 'bar' );
+			const e1 = new Element( 'p', null, [ t1 ] );
+			const e2 = new Element( 'p', null, [ t2 ] );
+			const position1 = new Position( e1, 0 );
+			const position2 = new Position( e2, 1 );
+
+			expect( position1.isBefore( position2 ) );
+			expect( position2.isBefore( position1 ) );
+		} );
+
+		it( 'should return true if position is before in same node', () => {
+			const node = new Node();
+			const p1 = new Position( node, 10 );
+			const p2 = new Position( node, 5 );
+
+			expect( p2.isBefore( p1 ) ).to.be.true;
+			expect( p1.isBefore( p2 ) ).to.be.false;
+		} );
+
+		it( 'should compare positions that have common parent', () => {
+			const t1 = new Text( 'foo' );
+			const t2 = new Text( 'bar' );
+			const root = new Element( 'p', null, [ t1, t2 ] );
+			const position1 = new Position( t1, 2 );
+			const position2 = new Position( t2, 0 );
+			const position3 = new Position( root, 0 );
+			const position4 = new Position( root, 2 );
+			const position5 = new Position( t1, 0 );
+			const position6 = new Position( root, 1 );
+
+			expect( position1.isBefore( position2 ) ).to.be.true;
+			expect( position2.isBefore( position1 ) ).to.be.false;
+			expect( position3.isBefore( position1  ) ).to.be.true;
+			expect( position3.isBefore( position2 ) ).to.be.true;
+			expect( position1.isBefore( position3 ) ).to.be.false;
+			expect( position2.isBefore( position3 ) ).to.be.false;
+			expect( position4.isBefore( position1 ) ).to.be.false;
+			expect( position4.isBefore( position3 ) ).to.be.false;
+			expect( position3.isBefore( position4 ) ).to.be.true;
+			expect( position3.isBefore( position5 ) ).to.be.true;
+			expect( position6.isBefore( position2 ) ).to.be.true;
+			expect( position1.isBefore( position6 ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'isAfter', () => {
+		it( 'should return false for same positions', () => {
+			const node = new Node();
+			const position1 = new Position( node, 10 );
+			const position2 = new Position( node, 10 );
+
+			expect( position1.isAfter( position1 ) ).to.be.false;
+			expect( position1.isAfter( position2 ) ).to.be.false;
+			expect( position2.isAfter( position1 ) ).to.be.false;
+		} );
+
+		it( 'should return false if no common ancestor is found', () => {
+			const t1 = new Text( 'foo' );
+			const t2 = new Text( 'bar' );
+			const e1 = new Element( 'p', null, [ t1 ] );
+			const e2 = new Element( 'p', null, [ t2 ] );
+			const position1 = new Position( e1, 0 );
+			const position2 = new Position( e2, 1 );
+
+			expect( position1.isAfter( position2 ) );
+			expect( position2.isAfter( position1 ) );
+		} );
+
+		it( 'should return true if position is after in same node', () => {
+			const node = new Node();
+			const p1 = new Position( node, 10 );
+			const p2 = new Position( node, 5 );
+
+			expect( p2.isAfter( p1 ) ).to.be.false;
+			expect( p1.isAfter( p2 ) ).to.be.true;
+		} );
+
+		it( 'should compare positions that have common parent', () => {
+			const t1 = new Text( 'foo' );
+			const t2 = new Text( 'bar' );
+			const root = new Element( 'p', null, [ t1, t2 ] );
+			const position1 = new Position( t1, 2 );
+			const position2 = new Position( t2, 0 );
+			const position3 = new Position( root, 0 );
+			const position4 = new Position( root, 2 );
+			const position5 = new Position( t1, 0 );
+			const position6 = new Position( root, 1 );
+
+			expect( position1.isAfter( position2 ) ).to.be.false;
+			expect( position2.isAfter( position1 ) ).to.be.true;
+			expect( position3.isAfter( position1 ) ).to.be.false;
+			expect( position3.isAfter( position2 ) ).to.be.false;
+			expect( position1.isAfter( position3 ) ).to.be.true;
+			expect( position2.isAfter( position3 ) ).to.be.true;
+			expect( position4.isAfter( position1 ) ).to.be.true;
+			expect( position4.isAfter( position3 ) ).to.be.true;
+			expect( position3.isAfter( position4 ) ).to.be.false;
+			expect( position5.isAfter( position3 ) ).to.be.true;
+			expect( position2.isAfter( position6 ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'compareWith', () => {
+		it( 'should return SAME if positions are same', () => {
+			const root = new Node();
+			const position = new Position( root, 0 );
+			const compared = new Position( root, 0 );
+
+			expect( position.compareWith( compared ) ).to.equal( 'SAME' );
+		} );
+
+		it( 'should return BEFORE if the position is before compared one', () => {
+			const root = new Node();
+			const position = new Position( root, 0 );
+			const compared = new Position( root, 1 );
+
+			expect( position.compareWith( compared ) ).to.equal( 'BEFORE' );
+		} );
+
+		it( 'should return AFTER if the position is after compared one', () => {
+			const root = new Node();
+			const position = new Position( root, 4 );
+			const compared = new Position( root, 1 );
+
+			expect( position.compareWith( compared ) ).to.equal( 'AFTER' );
+		} );
+
+		it( 'should return DIFFERENT if positions are in different roots', () => {
+			const root1 = new Node();
+			const root2 = new Node();
+			const position = new Position( root1, 4 );
+			const compared = new Position( root2, 1 );
+
+			expect( position.compareWith( compared ) ).to.equal( 'DIFFERENT' );
+		} );
+	} );
 } );

+ 79 - 0
packages/ckeditor5-engine/tests/treeview/range.js

@@ -9,6 +9,8 @@
 
 import Range from '/ckeditor5/engine/treeview/range.js';
 import Position from '/ckeditor5/engine/treeview/position.js';
+import Element from '/ckeditor5/engine/treeview/element.js';
+import Text from '/ckeditor5/engine/treeview/text.js';
 
 describe( 'Range', () => {
 	describe( 'constructor', () => {
@@ -73,4 +75,81 @@ describe( 'Range', () => {
 			expect( range1.isEqual( range2 ) ).to.be.false;
 		} );
 	} );
+
+	describe( 'isIntersecting', () => {
+		let root, p1, p2, t1, t2, t3;
+
+		//            root
+		//    __________|__________
+		//    |                   |
+		// ___p1___               p2
+		// |       |              |
+		// t1      t2             t3
+
+		beforeEach( () => {
+			t1 = new Text( 'foo' );
+			t2 = new Text( 'bar' );
+			t3 = new Text( 'baz' );
+			p1 = new Element( 'p', null, [ t1, t2 ] );
+			p2 = new Element( 'p', null, t3 );
+			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;
+		} );
+
+		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;
+		} );
+	} );
+
+	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 );
+
+			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
+		} );
+	} );
 } );

+ 311 - 0
packages/ckeditor5-engine/tests/treeview/selection.js

@@ -0,0 +1,311 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import Selection from '/ckeditor5/engine/treeview/selection.js';
+import Range from '/ckeditor5/engine/treeview/range.js';
+import Element from '/ckeditor5/engine/treeview/element.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+
+describe( 'Selection', () => {
+	let selection;
+	let el;
+	let range1, range2, range3;
+
+	beforeEach( () => {
+		selection = new Selection();
+		el = new Element( 'p' );
+		range1 = Range.createFromParentsAndOffsets( el, 5, el, 10 );
+		range2 = Range.createFromParentsAndOffsets( el, 1, el, 2 );
+		range3 = Range.createFromParentsAndOffsets( el, 12, el, 14 );
+	} );
+
+	describe( 'anchor', () => {
+		it( 'should return null if no ranges in selection', () => {
+			expect( selection.anchor ).to.be.null;
+		} );
+
+		it( 'should return start of single range in selection', () => {
+			selection.addRange( range1 );
+			const anchor = selection.anchor;
+
+			expect( anchor.isEqual( range1.start ) ).to.be.true;
+			expect( anchor ).to.not.equal( range1.start );
+		} );
+
+		it( 'should return end of single range in selection when added as backward', () => {
+			selection.addRange( range1, true );
+			const anchor = selection.anchor;
+
+			expect( anchor.isEqual( range1.end ) ).to.be.true;
+			expect( anchor ).to.not.equal( range1.end );
+		} );
+
+		it( 'should get anchor from last inserted range', () => {
+			selection.addRange( range1 );
+			selection.addRange( range2 );
+
+			expect( selection.anchor.isEqual( range2.start ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'focus', () => {
+		it( 'should return null if no ranges in selection', () => {
+			expect( selection.focus ).to.be.null;
+		} );
+
+		it( 'should return end of single range in selection', () => {
+			selection.addRange( range1 );
+			const focus = selection.focus;
+
+			expect( focus.isEqual( range1.end ) ).to.be.true;
+		} );
+
+		it( 'should return start of single range in selection when added as backward', () => {
+			selection.addRange( range1, true );
+			const focus = selection.focus;
+
+			expect( focus.isEqual( range1.start ) ).to.be.true;
+			expect( focus ).to.not.equal( range1.start );
+		} );
+
+		it( 'should get focus from last inserted range', () => {
+			selection.addRange( range1 );
+			selection.addRange( range2 );
+
+			expect( selection.focus.isEqual( range2.end ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'isCollapsed', () => {
+		it( 'should return true when all ranges are collapsed', () => {
+			const range1 = Range.createFromParentsAndOffsets( el, 5, el, 5 );
+			const range2 = Range.createFromParentsAndOffsets( el, 15, el, 15 );
+			selection.addRange( range1 );
+			selection.addRange( range2 );
+
+			expect( selection.isCollapsed ).to.be.true;
+		} );
+
+		it( 'should return false when not all ranges are collapsed', () => {
+			const range1 = Range.createFromParentsAndOffsets( el, 5, el, 5 );
+			const range2 = Range.createFromParentsAndOffsets( el, 15, el, 16 );
+			selection.addRange( range1 );
+			selection.addRange( range2 );
+
+			expect( selection.isCollapsed ).to.be.false;
+		} );
+	} );
+
+	describe( 'rangeCount', () => {
+		it( 'should return proper range count', () => {
+			expect( selection.rangeCount ).to.equal( 0 );
+			selection.addRange( range1 );
+			expect( selection.rangeCount ).to.equal( 1 );
+			selection.addRange( range2 );
+			expect( selection.rangeCount ).to.equal( 2 );
+		} );
+	} );
+
+	describe( 'addRange', () => {
+		it( 'should add range to selection ranges', () => {
+			selection.addRange( range1 );
+			expect( selection._ranges[ 0 ].isEqual( range1 ) ).to.be.true;
+		} );
+
+		it( 'should fire change event', ( done ) => {
+			selection.once( 'change', () => {
+				expect( selection._ranges[ 0 ].isEqual( range1 ) ).to.be.true;
+				done();
+			} );
+
+			selection.addRange( range1 );
+		} );
+
+		it( 'should throw when range is intersecting with already added range', () => {
+			const range2 = Range.createFromParentsAndOffsets( el, 7, el, 15 );
+			selection.addRange( range1 );
+			expect( () => {
+				selection.addRange( range2 );
+			} ).to.throw( CKEditorError, 'view-selection-range-intersects' );
+
+			expect( () => {
+				selection.addRange( range1 );
+			} ).to.throw( CKEditorError, 'view-selection-range-intersects' );
+		} );
+	} );
+
+	describe( 'getRanges', () => {
+		it( 'should return iterator with copies of all ranges', () => {
+			selection.addRange( range1 );
+			selection.addRange( range2 );
+
+			const iterable = selection.getRanges();
+			const ranges = Array.from( iterable );
+
+			expect( ranges.length ).to.equal( 2 );
+			expect( ranges[ 0 ].isEqual( range1 ) ).to.be.true;
+			expect( ranges[ 0 ] ).to.not.equal( range1 );
+			expect( ranges[ 1 ].isEqual( range2 ) ).to.be.true;
+			expect( ranges[ 1 ] ).to.not.equal( range2 );
+		} );
+	} );
+
+	describe( 'getFirstRange', () => {
+		it( 'should return copy of range with first position', () => {
+			selection.addRange( range1 );
+			selection.addRange( range2 );
+			selection.addRange( range3 );
+
+			const range = selection.getFirstRange();
+
+			expect( range.isEqual( range2 ) ).to.be.true;
+			expect( range ).to.not.equal( range2 );
+		} );
+
+		it( 'should return null if no ranges are present', () => {
+			expect( selection.getFirstRange() ).to.be.null;
+		} );
+	} );
+
+	describe( 'getLastRange', () => {
+		it( 'should return copy of range with last position', () => {
+			selection.addRange( range1 );
+			selection.addRange( range2 );
+			selection.addRange( range3 );
+
+			const range = selection.getLastRange();
+
+			expect( range.isEqual( range3 ) ).to.be.true;
+			expect( range ).to.not.equal( range3 );
+		} );
+
+		it( 'should return null if no ranges are present', () => {
+			expect( selection.getLastRange() ).to.be.null;
+		} );
+	} );
+
+	describe( 'getFirstPosition', () => {
+		it( 'should return copy of first position', () => {
+			selection.addRange( range1 );
+			selection.addRange( range2 );
+			selection.addRange( range3 );
+
+			const position = selection.getFirstPosition();
+
+			expect( position.isEqual( range2.start ) ).to.be.true;
+			expect( position ).to.not.equal( range2.start );
+		} );
+
+		it( 'should return null if no ranges are present', () => {
+			expect( selection.getFirstPosition() ).to.be.null;
+		} );
+	} );
+
+	describe( 'getLastPosition', () => {
+		it( 'should return copy of range with last position', () => {
+			selection.addRange( range1 );
+			selection.addRange( range2 );
+			selection.addRange( range3 );
+
+			const position = selection.getLastPosition();
+
+			expect( position.isEqual( range3.end ) ).to.be.true;
+			expect( position ).to.not.equal( range3.end );
+		} );
+
+		it( 'should return null if no ranges are present', () => {
+			expect( selection.getLastPosition() ).to.be.null;
+		} );
+	} );
+
+	describe( 'removeAllRanges', () => {
+		it( 'should remove all ranges and fire change event', ( done ) => {
+			selection.addRange( range1 );
+			selection.addRange( range2 );
+
+			selection.once( 'change', () => {
+				expect( selection.rangeCount ).to.equal( 0 );
+				done();
+			} );
+
+			selection.removeAllRanges();
+		} );
+
+		it( 'should do nothing when no ranges are present', () => {
+			const fireSpy = sinon.spy( selection, 'fire' );
+			selection.removeAllRanges();
+
+			fireSpy.restore();
+			expect( fireSpy.notCalled ).to.be.true;
+		} );
+	} );
+
+	describe( 'setRanges', () => {
+		it( 'should add ranges and fire change event', ( done ) => {
+			selection.addRange( range1 );
+
+			selection.once( 'change', () => {
+				expect( selection.rangeCount ).to.equal( 2 );
+				expect( selection._ranges[ 0 ].isEqual( range2 ) ).to.be.true;
+				expect( selection._ranges[ 0 ] ).is.not.equal( range2 );
+				expect( selection._ranges[ 1 ].isEqual( range3 ) ).to.be.true;
+				expect( selection._ranges[ 1 ] ).is.not.equal( range3 );
+				done();
+			} );
+
+			selection.setRanges( [ range2, range3 ] );
+		} );
+	} );
+
+	describe( 'collapseToStart', () => {
+		it( 'should collapse to start position and fire change event', ( done ) => {
+			selection.setRanges( [ range1, range2, range3 ] );
+			selection.once( 'change', () => {
+				expect( selection.rangeCount ).to.equal( 1 );
+				expect( selection.isCollapsed ).to.be.true;
+				expect( selection._ranges[ 0 ].start.isEqual( range2.start ) ).to.be.true;
+				done();
+			} );
+
+			selection.collapseToStart();
+		} );
+
+		it( 'should do nothing if no ranges present', () => {
+			const fireSpy = sinon.spy( selection, 'fire' );
+
+			selection.collapseToStart();
+
+			fireSpy.restore();
+			expect( fireSpy.notCalled ).to.be.true;
+		} );
+	} );
+
+	describe( 'collapseToEnd', () => {
+		it( 'should collapse to end position and fire change event', ( done ) => {
+			selection.setRanges( [ range1, range2, range3 ] );
+			selection.once( 'change', () => {
+				expect( selection.rangeCount ).to.equal( 1 );
+				expect( selection.isCollapsed ).to.be.true;
+				expect( selection._ranges[ 0 ].end.isEqual( range3.end ) ).to.be.true;
+				done();
+			} );
+
+			selection.collapseToEnd();
+		} );
+
+		it( 'should do nothing if no ranges present', () => {
+			const fireSpy = sinon.spy( selection, 'fire' );
+
+			selection.collapseToEnd();
+
+			fireSpy.restore();
+			expect( fireSpy.notCalled ).to.be.true;
+		} );
+	} );
+} );