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

Merge pull request #119 from ckeditor/t/86

T/86 Introduce Deltas
Piotr Jasiun 10 лет назад
Родитель
Сommit
a7a043f48c
20 измененных файлов с 951 добавлено и 72 удалено
  1. 5 1
      packages/ckeditor5-engine/src/treemodel/batch.js
  2. 63 0
      packages/ckeditor5-engine/src/treemodel/delta/movedelta.js
  3. 23 13
      packages/ckeditor5-engine/src/treemodel/delta/removedelta.js
  4. 65 0
      packages/ckeditor5-engine/src/treemodel/delta/unwrapdelta.js
  5. 59 0
      packages/ckeditor5-engine/src/treemodel/delta/weakinsertdelta.js
  6. 82 0
      packages/ckeditor5-engine/src/treemodel/delta/wrapdelta.js
  7. 2 2
      packages/ckeditor5-engine/src/treemodel/operation/attributeoperation.js
  8. 2 2
      packages/ckeditor5-engine/src/treemodel/operation/moveoperation.js
  9. 18 2
      packages/ckeditor5-engine/src/treemodel/position.js
  10. 103 7
      packages/ckeditor5-engine/src/treemodel/range.js
  11. 28 0
      packages/ckeditor5-engine/tests/_tools/tools.js
  12. 2 2
      packages/ckeditor5-engine/tests/treemodel/delta/attributedelta.js
  13. 24 17
      packages/ckeditor5-engine/tests/treemodel/delta/insertdelta.js
  14. 80 0
      packages/ckeditor5-engine/tests/treemodel/delta/movedelta.js
  15. 42 24
      packages/ckeditor5-engine/tests/treemodel/delta/removedelta.js
  16. 67 0
      packages/ckeditor5-engine/tests/treemodel/delta/unwrapdelta.js
  17. 63 0
      packages/ckeditor5-engine/tests/treemodel/delta/weakinsertdelta.js
  18. 103 0
      packages/ckeditor5-engine/tests/treemodel/delta/wrapdelta.js
  19. 25 0
      packages/ckeditor5-engine/tests/treemodel/position.js
  20. 95 2
      packages/ckeditor5-engine/tests/treemodel/range.js

+ 5 - 1
packages/ckeditor5-engine/src/treemodel/batch.js

@@ -12,10 +12,14 @@
 CKEDITOR.define( [
 	'treemodel/delta/batch-base',
 	'treemodel/delta/insertdelta',
+	'treemodel/delta/weakinsertdelta',
+	'treemodel/delta/movedelta',
 	'treemodel/delta/removedelta',
 	'treemodel/delta/attributedelta',
 	'treemodel/delta/splitdelta',
-	'treemodel/delta/mergedelta'
+	'treemodel/delta/mergedelta',
+	'treemodel/delta/wrapdelta',
+	'treemodel/delta/unwrapdelta'
 ], ( Batch ) => {
 	return Batch;
 } );

+ 63 - 0
packages/ckeditor5-engine/src/treemodel/delta/movedelta.js

@@ -0,0 +1,63 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+CKEDITOR.define( [
+	'treemodel/delta/delta',
+	'treemodel/delta/register',
+	'treemodel/operation/moveoperation',
+	'treemodel/position',
+	'treemodel/range',
+	'ckeditorerror'
+], ( Delta, register, MoveOperation, Position, Range, CKEditorError ) => {
+	/**
+	 * To provide specific OT behavior and better collisions solving, {@link treeModel.Batch#move} method
+	 * uses the `MoveDelta` class which inherits from the `Delta` class and may overwrite some methods.
+	 *
+	 * @class treeModel.delta.MoveDelta
+	 */
+	class MoveDelta extends Delta {}
+
+	function addMoveOperation( batch, delta, sourcePosition, howMany, targetPosition ) {
+		const operation = new MoveOperation( sourcePosition, howMany, targetPosition, batch.doc.version );
+		batch.doc.applyOperation( operation );
+		delta.addOperation( operation );
+	}
+
+	/**
+	 * Moves given node or given range of nodes to target position.
+	 *
+	 * @chainable
+	 * @method move
+	 * @memberOf treeModel.Batch
+	 * @param {treeModel.Node|treeModel.Range} nodeOrRange Node or range of nodes to move.
+	 * @param {treeModel.Position} targetPosition Position where moved nodes will be inserted.
+	 */
+	register( 'move', function( nodeOrRange, targetPosition ) {
+		const delta = new MoveDelta();
+
+		if ( nodeOrRange instanceof Range ) {
+			if ( !nodeOrRange.isFlat ) {
+				/**
+				 * Range to move is not flat.
+				 *
+				 * @error batch-move-range-not-flat
+				 */
+				throw new CKEditorError( 'batch-move-range-not-flat: Range to move is not flat.' );
+			}
+
+			addMoveOperation( this, delta, nodeOrRange.start, nodeOrRange.end.offset - nodeOrRange.start.offset, targetPosition );
+		} else {
+			addMoveOperation( this, delta, Position.createBefore( nodeOrRange ), 1, targetPosition );
+		}
+
+		this.addDelta( delta );
+
+		return this;
+	} );
+
+	return MoveDelta;
+} );

+ 23 - 13
packages/ckeditor5-engine/src/treemodel/delta/removedelta.js

@@ -8,8 +8,10 @@
 CKEDITOR.define( [
 	'treemodel/delta/delta',
 	'treemodel/delta/register',
-	'treemodel/operation/removeoperation'
-], ( Delta, register, RemoveOperation ) => {
+	'treemodel/operation/removeoperation',
+	'treemodel/position',
+	'treemodel/range'
+], ( Delta, register, RemoveOperation, Position, Range ) => {
 	/**
 	 * To provide specific OT behavior and better collisions solving, {@link treeModel.Batch#remove} method
 	 * uses the `RemoveDelta` class which inherits from the `Delta` class and may overwrite some methods.
@@ -18,25 +20,33 @@ CKEDITOR.define( [
 	 */
 	class RemoveDelta extends Delta {}
 
+	function addRemoveOperation( batch, delta, position, howMany ) {
+		const operation = new RemoveOperation( position, howMany, batch.doc.version );
+		batch.doc.applyOperation( operation );
+		delta.addOperation( operation );
+	}
+
 	/**
-	 * Removes nodes starting from the given position.
+	 * Removes given node or range of nodes.
 	 *
 	 * @chainable
 	 * @method remove
 	 * @memberOf treeModel.Batch
-	 * @param {treeModel.Position} position Position before the first node to remove.
-	 * @param {Number} howMany How many nodes to remove.
+	 * @param {treeModel.Node|treeModel.Range} nodeOrRange Node or range of nodes to remove.
 	 */
-	register( 'remove', function( position, howMany ) {
-		if ( typeof howMany !== 'number' ) {
-			howMany = 1;
-		}
-
+	register( 'remove', function( nodeOrRange ) {
 		const delta = new RemoveDelta();
 
-		const operation = new RemoveOperation( position, howMany, this.doc.version );
-		this.doc.applyOperation( operation );
-		delta.addOperation( operation );
+		if ( nodeOrRange instanceof Range ) {
+			// The array is reversed, so the ranges are correct and do not have to be updated.
+			let ranges = nodeOrRange.getMinimalFlatRanges().reverse();
+
+			for ( let flat of ranges ) {
+				addRemoveOperation( this, delta, flat.start, flat.end.offset - flat.start.offset );
+			}
+		} else {
+			addRemoveOperation( this, delta, Position.createBefore( nodeOrRange ), 1 );
+		}
 
 		this.addDelta( delta );
 

+ 65 - 0
packages/ckeditor5-engine/src/treemodel/delta/unwrapdelta.js

@@ -0,0 +1,65 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+CKEDITOR.define( [
+	'treemodel/delta/delta',
+	'treemodel/delta/register',
+	'treemodel/position',
+	'treemodel/element',
+	'treemodel/operation/removeoperation',
+	'treemodel/operation/moveoperation',
+	'ckeditorerror'
+], ( Delta, register, Position, Element, RemoveOperation, MoveOperation, CKEditorError ) => {
+	/**
+	 * To provide specific OT behavior and better collisions solving, {@link treeModel.Batch#merge} method
+	 * uses the `UnwrapDelta` class which inherits from the `Delta` class and may overwrite some methods.
+	 *
+	 * @class treeModel.delta.UnwrapDelta
+	 */
+	class UnwrapDelta extends Delta {}
+
+	/**
+	 * Unwraps specified element, that is moves all it's children before it and then removes it. Throws
+	 * error if you try to unwrap an element that does not have a parent.
+	 *
+	 * @chainable
+	 * @method unwrap
+	 * @memberOf treeModel.Batch
+	 * @param {treeModel.Element} position Element to unwrap.
+	 */
+	register( 'unwrap', function( element ) {
+		if ( element.parent === null ) {
+			/**
+			 * Trying to unwrap an element that has no parent.
+			 *
+			 * @error batch-unwrap-element-no-parent
+			 */
+			throw new CKEditorError(
+				'batch-unwrap-element-no-parent: Trying to unwrap an element that has no parent.' );
+		}
+
+		const delta = new UnwrapDelta();
+
+		let sourcePosition = Position.createFromParentAndOffset( element, 0 );
+
+		const move = new MoveOperation( sourcePosition, element.getChildCount(), Position.createBefore( element ), this.doc.version );
+		this.doc.applyOperation( move );
+		delta.addOperation( move );
+
+		// Computing new position because we moved some nodes before `element`.
+		// If we would cache `Position.createBefore( element )` we remove wrong node.
+		const remove = new RemoveOperation( Position.createBefore( element ), 1, this.doc.version );
+		this.doc.applyOperation( remove );
+		delta.addOperation( remove );
+
+		this.addDelta( delta );
+
+		return this;
+	} );
+
+	return UnwrapDelta;
+} );

+ 59 - 0
packages/ckeditor5-engine/src/treemodel/delta/weakinsertdelta.js

@@ -0,0 +1,59 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+CKEDITOR.define( [
+	'treemodel/delta/delta',
+	'treemodel/delta/register',
+	'treemodel/operation/insertoperation',
+	'treemodel/nodelist'
+], ( Delta, register, InsertOperation, NodeList ) => {
+	/**
+	 * To provide specific OT behavior and better collisions solving, the {@link treeModel.Batch#insert} method
+	 * uses the `WeakInsertDelta` class which inherits from the `Delta` class and may overwrite some methods.
+	 *
+	 * @class treeModel.delta.WeakInsertDelta
+	 */
+	class WeakInsertDelta extends Delta {}
+
+	/**
+	 * Inserts a node or nodes at the given position. {@link treeModel.Batch#weakInsert} is commonly used for actions
+	 * like typing or plain-text paste (without formatting). There are two differences between
+	 * {@link treeModel.Batch#insert} and {@link treeModel.Batch#weakInsert}:
+	 * * When using `weakInsert`, inserted nodes will have same attributes as the current attributes of
+	 * {@link treeModel.Document#selection document selection}.
+	 * * The above has to be reflected during {@link treeModel.operation.transform operational transformation}. Normal
+	 * behavior is that inserting inside range changed by {@link treeModel.operation.AttributeOperation} splits the operation
+	 * into two operations, which "omit" the inserted nodes. The correct behavior for `WeakInsertDelta` is that
+	 * {@link treeModel.operation.AttributeOperation} does not "break" and also applies attributes for inserted nodes.
+	 *
+	 * @chainable
+	 * @memberOf treeModel.Batch
+	 * @method weakInsert
+	 * @param {treeModel.Position} position Position of insertion.
+	 * @param {treeModel.Node|treeModel.Text|treeModel.NodeList|String|Iterable} nodes The list of nodes to be inserted.
+	 * List of nodes can be of any type accepted by the {@link treeModel.NodeList} constructor.
+	 */
+	register( 'weakInsert', function( position, nodes ) {
+		const delta = new WeakInsertDelta();
+
+		nodes = new NodeList( nodes );
+
+		for ( let node of nodes ) {
+			node.setAttrsTo( this.doc.selection.getAttrs() );
+		}
+
+		const operation = new InsertOperation( position, nodes, this.doc.version );
+		this.doc.applyOperation( operation );
+		delta.addOperation( operation );
+
+		this.addDelta( delta );
+
+		return this;
+	} );
+
+	return WeakInsertDelta;
+} );

+ 82 - 0
packages/ckeditor5-engine/src/treemodel/delta/wrapdelta.js

@@ -0,0 +1,82 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+CKEDITOR.define( [
+	'treemodel/delta/delta',
+	'treemodel/delta/register',
+	'treemodel/position',
+	'treemodel/element',
+	'treemodel/operation/insertoperation',
+	'treemodel/operation/moveoperation',
+	'ckeditorerror'
+], ( Delta, register, Position, Element, InsertOperation, MoveOperation, CKEditorError ) => {
+	/**
+	 * To provide specific OT behavior and better collisions solving, {@link treeModel.Batch#merge} method
+	 * uses the `WrapDelta` class which inherits from the `Delta` class and may overwrite some methods.
+	 *
+	 * @class treeModel.delta.WrapDelta
+	 */
+	class WrapDelta extends Delta {}
+
+	/**
+	 * Wraps given range with given element or with a new element of specified name if string has been passed.
+	 * **Note:** given range should be a "flat range" (see {@link treeModel.Range#isFlat}). If not, error will be thrown.
+	 *
+	 * @chainable
+	 * @method wrap
+	 * @memberOf treeModel.Batch
+	 * @param {treeModel.Range} range Range to wrap.
+	 * @param {treeModel.Element|String} elementOrString Element or name of element to wrap the range with.
+	 */
+	register( 'wrap', function( range, elementOrString ) {
+		if ( !range.isFlat ) {
+			/**
+			 * Range to wrap is not flat.
+			 *
+			 * @error batch-wrap-range-not-flat
+			 */
+			throw new CKEditorError( 'batch-wrap-range-not-flat: Range to wrap is not flat.' );
+		}
+
+		let element = elementOrString instanceof Element ? elementOrString : new Element( elementOrString );
+
+		if ( element.getChildCount() > 0 ) {
+			/**
+			 * Element to wrap with is not empty.
+			 *
+			 * @error batch-wrap-element-not-empty
+			 */
+			throw new CKEditorError( 'batch-wrap-element-not-empty: Element to wrap with is not empty.' );
+		}
+
+		if ( element.parent !== null ) {
+			/**
+			 * Element to wrap with is already attached to a tree model.
+			 *
+			 * @error batch-wrap-element-attached
+			 */
+			throw new CKEditorError( 'batch-wrap-element-attached: Element to wrap with is already attached to tree model.' );
+		}
+
+		const delta = new WrapDelta();
+
+		let insert = new InsertOperation( range.end, element, this.doc.version );
+		this.doc.applyOperation( insert );
+		delta.addOperation( insert );
+
+		let targetPosition = Position.createFromParentAndOffset( element, 0 );
+		let move = new MoveOperation( range.start, range.end.offset - range.start.offset, targetPosition, this.doc.version );
+		this.doc.applyOperation( move );
+		delta.addOperation( move );
+
+		this.addDelta( delta );
+
+		return this;
+	} );
+
+	return WrapDelta;
+} );

+ 2 - 2
packages/ckeditor5-engine/src/treemodel/operation/attributeoperation.js

@@ -93,7 +93,7 @@ CKEDITOR.define( [
 
 			// Remove or change.
 			if ( oldAttr !== null ) {
-				for ( let node of this.range.getNodes() ) {
+				for ( let node of this.range.getAllNodes() ) {
 					if ( !node.hasAttr( oldAttr ) ) {
 						/**
 						 * The attribute which should be removed does not exists for the given node.
@@ -117,7 +117,7 @@ CKEDITOR.define( [
 
 			// Insert or change.
 			if ( newAttr !== null ) {
-				for ( let node of this.range.getNodes() ) {
+				for ( let node of this.range.getAllNodes() ) {
 					if ( oldAttr === null && node.hasAttr( newAttr.key ) ) {
 						/**
 						 * The attribute with given key already exists for the given node.

+ 2 - 2
packages/ckeditor5-engine/src/treemodel/operation/moveoperation.js

@@ -21,9 +21,9 @@ CKEDITOR.define( [
 		/**
 		 * Creates a move operation.
 		 *
-		 * @param {treeModel.Position} sourcePosition Position before the first element to move.
+		 * @param {treeModel.Position} sourcePosition Position before the first node to move.
 		 * @param {Number} howMany How many consecutive nodes to move, starting from `sourcePosition`.
-		 * @param {treeModel.Position} targetPosition Position where moved elements will be inserted.
+		 * @param {treeModel.Position} targetPosition Position where moved nodes will be inserted.
 		 * @param {Number} baseVersion {@link treeModel.Document#version} on which operation can be applied.
 		 * @constructor
 		 */

+ 18 - 2
packages/ckeditor5-engine/src/treemodel/position.js

@@ -173,6 +173,21 @@ CKEDITOR.define( [ 'treemodel/rootelement', 'utils', 'ckeditorerror' ], ( RootEl
 			return this.path.slice( 0, -1 );
 		}
 
+		/**
+		 * Returns a new instance of Position with offset incremented by `shift` value.
+		 *
+		 * @param {Number} shift How position offset should get changed. Accepts negative values.
+		 * @returns {treeModel.Position} Shifted position.
+		 */
+		getShiftedBy( shift ) {
+			let shifted = Position.createFromPosition( this );
+
+			let offset = shifted.offset + shift;
+			shifted.offset = offset < 0 ? 0 : offset;
+
+			return shifted;
+		}
+
 		/**
 		 * Returns this position after being updated by removing `howMany` nodes starting from `deletePosition`.
 		 * It may happen that this position is in a removed node. If that is the case, `null` is returned instead.
@@ -385,13 +400,14 @@ CKEDITOR.define( [ 'treemodel/rootelement', 'utils', 'ckeditorerror' ], ( RootEl
 						return false;
 					}
 
-					left = Position.createAfter( left.parent );
+					left.path = left.path.slice( 0, -1 );
+					left.offset++;
 				} else {
 					if ( right.nodeBefore !== null ) {
 						return false;
 					}
 
-					right = Position.createBefore( right.parent );
+					right.path = right.path.slice( 0, -1 );
 				}
 			}
 		}

+ 103 - 7
packages/ckeditor5-engine/src/treemodel/range.js

@@ -45,6 +45,15 @@ CKEDITOR.define( [ 'treemodel/position', 'treemodel/positioniterator', 'utils' ]
 			return this.start.isEqual( this.end );
 		}
 
+		/**
+		 * Returns whether this range is flat, that is if start position and end position are in the same parent.
+		 *
+		 * @returns {Boolean}
+		 */
+		get isFlat() {
+			return this.start.parent === this.end.parent;
+		}
+
 		/**
 		 * Range root element. Equals to the root of start position (which should be same as root of end position).
 		 *
@@ -172,21 +181,90 @@ CKEDITOR.define( [ 'treemodel/position', 'treemodel/positioniterator', 'utils' ]
 			return null;
 		}
 
+		/**
+		 * Computes and returns the smallest set of {@link #isFlat flat} ranges, that covers this range in whole.
+		 * Assuming that tree model model structure is ("[" and "]" are range boundaries):
+		 *
+		 *		root                                                            root
+		 *		 |- element DIV                         DIV             P2              P3             DIV
+		 *		 |   |- element H                   H        P1        f o o           b a r       H         P4
+		 *		 |   |   |- "fir[st"             fir[st     lorem                               se]cond     ipsum
+		 *		 |   |- element P1
+		 *		 |   |   |- "lorem"                                              ||
+		 *		 |- element P2                                                   ||
+		 *		 |   |- "foo"                                                    VV
+		 *		 |- element P3
+		 *		 |   |- "bar"                                                   root
+		 *		 |- element DIV                         DIV             [P2             P3]             DIV
+		 *		 |   |- element H                   H       [P1]       f o o           b a r        H         P4
+		 *		 |   |   |- "se]cond"            fir[st]    lorem                               [se]cond     ipsum
+		 *		 |   |- element P4
+		 *		 |   |   |- "ipsum"
+		 *
+		 * Flat ranges for range `( [ 0, 0, 3 ], [ 3, 0, 2 ] )` would be:
+		 *
+		 *		( [ 0, 0, 3 ], [ 0, 0, 5 ] ) = "st"
+		 *		( [ 0, 1 ], [ 0, 2 ] ) = element P1
+		 *		( [ 1 ], [ 3 ] ) = element P2, element P3
+		 *		( [ 3, 0, 0 ], [ 3, 0, 2 ] ) = "se"
+		 *
+		 * **Note:** this method is not returning flat ranges that contain no nodes. It may also happen that not-collapsed
+		 * range will return an empty array of flat ranges.
+		 *
+		 * @returns {Array.<treeModel.Range>} Array of flat ranges.
+		 */
+		getMinimalFlatRanges() {
+			let ranges = [];
+
+			// We find on which tree-level start and end have the lowest common ancestor
+			let cmp = utils.compareArrays( this.start.path, this.end.path );
+			let diffAt = ( cmp < 0 ) ? Math.min( this.start.path.length, this.end.path.length ) : cmp;
+
+			let pos = Position.createFromPosition( this.start );
+
+			// go up
+			while ( pos.path.length > diffAt + 1 ) {
+				let howMany = pos.parent.getChildCount() - pos.offset;
+
+				if ( howMany !== 0 ) {
+					ranges.push( new Range( pos, pos.getShiftedBy( howMany ) ) );
+				}
+
+				pos.path = pos.path.slice( 0, -1 );
+				pos.offset++;
+			}
+
+			// go down
+			while ( pos.path.length <= this.end.path.length ) {
+				let offset = this.end.path[ pos.path.length - 1 ];
+				let howMany = offset - pos.offset;
+
+				if ( howMany !== 0 ) {
+					ranges.push( new Range( pos, pos.getShiftedBy( howMany ) ) );
+				}
+
+				pos.offset = offset;
+				pos.path.push( 0 );
+			}
+
+			return ranges;
+		}
+
 		/**
 		 * Returns an iterator that iterates over all {@link treeModel.Node nodes} that are in this range and returns them.
 		 * A node is in the range when it is after a {@link treeModel.Position position} contained in this range.
 		 * In other words, this iterates over all {@link treeModel.Character}s that are inside the range and
 		 * all the {@link treeModel.Element}s we enter into when iterating over this range.
 		 *
-		 * Note, that this method will not return a parent node of start position. This is in contrary to {@link treeModel.PositionIterator}
+		 * **Note:** this method will not return a parent node of start position. This is in contrary to {@link treeModel.PositionIterator}
 		 * which will return that node with {@link treeModel.PositionIterator#ELEMENT_LEAVE} type. This method, also, returns each
 		 * {@link treeModel.Element} once, while iterator return it twice: for {@link treeModel.PositionIterator#ELEMENT_ENTER} and
 		 * {@link treeModel.PositionIterator#ELEMENT_LEAVE}.
 		 *
 		 * @see {treeModel.PositionIterator}
-		 * @returns {treeModel.Node}
+		 * @returns {Iterable.<treeModel.Node>}
 		 */
-		*getNodes() {
+		*getAllNodes() {
 			for ( let value of this ) {
 				if ( value.type != PositionIterator.ELEMENT_LEAVE ) {
 					yield value.node;
@@ -194,6 +272,27 @@ CKEDITOR.define( [ 'treemodel/position', 'treemodel/positioniterator', 'utils' ]
 			}
 		}
 
+		/**
+		 * Returns an iterator that iterates over all {@link treeModel.Node nodes} that are top-level nodes in this range
+		 * and returns them. A node is a top-level node when it is in the range but it's parent is not. In other words,
+		 * this function splits the range into separate sub-trees and iterates over their roots.
+		 *
+		 * @returns {Iterable.<treeModel.Node>}
+		 */
+		*getTopLevelNodes() {
+			let flatRanges = this.getMinimalFlatRanges();
+
+			for ( let range of flatRanges ) {
+				let node = range.start.nodeAfter;
+				let offset = range.end.offset - range.start.offset;
+
+				for ( let i = 0; i < offset; i++ ) {
+					yield node;
+					node = node.nextSibling;
+				}
+			}
+		}
+
 		/**
 		 * Returns an array containing one or two {treeModel.Range ranges} that are a result of transforming this
 		 * {@link treeModel.Range range} by inserting `howMany` nodes at `insertPosition`. Two {@link treeModel.Range ranges} are
@@ -285,10 +384,7 @@ CKEDITOR.define( [ 'treemodel/position', 'treemodel/positioniterator', 'utils' ]
 		 * @returns {treeModel.Range}
 		 */
 		static createFromPositionAndShift( position, shift ) {
-			let endPosition = Position.createFromPosition( position );
-			endPosition.offset += shift;
-
-			return new this( position, endPosition );
+			return new this( position, position.getShiftedBy( shift ) );
 		}
 
 		/**

+ 28 - 0
packages/ckeditor5-engine/tests/_tools/tools.js

@@ -67,4 +67,32 @@
 			return count;
 		}
 	};
+
+	bender.tools.treemodel = {
+		/**
+		 * Returns tree structure as a simplified string. Elements are uppercase and characters are lowercase.
+		 * Start and end of an element is marked the same way, by the element's name (in uppercase).
+		 *
+		 *		let element = new Element( 'div', [], [ 'abc', new Element( 'p', [], 'foo' ), 'xyz' ] );
+		 *		bender.tools.treemodel.getNodesAndText( element ); // abcPfooPxyz
+		 *
+		 * @param {treeModel.Range} range Range to stringify.
+		 * @returns {String} String representing element inner structure.
+		 */
+		getNodesAndText: ( range ) => {
+			let txt = '';
+
+			for ( let step of range ) {
+				let node = step.node;
+
+				if ( node.character ) {
+					txt += node.character.toLowerCase();
+				} else if ( node.name ) {
+					txt += node.name.toUpperCase();
+				}
+			}
+
+			return txt;
+		}
+	};
 } )();

+ 2 - 2
packages/ckeditor5-engine/tests/treemodel/delta/attributedelta.js

@@ -150,7 +150,7 @@ describe( 'Batch', () => {
 
 			for ( let delta of batch.deltas ) {
 				for ( let operation of delta.operations ) {
-					count += getIteratorCount( operation.range.getNodes() );
+					count += getIteratorCount( operation.range.getAllNodes() );
 				}
 			}
 
@@ -161,7 +161,7 @@ describe( 'Batch', () => {
 			// default: 111---111222---1112------
 			const range = Range.createFromElement( root );
 
-			return Array.from( range.getNodes() ).map( node => node.getAttr( 'a' ) || '-' ).join( '' );
+			return Array.from( range.getAllNodes() ).map( node => node.getAttr( 'a' ) || '-' ).join( '' );
 		}
 
 		describe( 'setAttr', () => {

+ 24 - 17
packages/ckeditor5-engine/tests/treemodel/delta/insertdelta.js

@@ -9,38 +9,45 @@
 
 const modules = bender.amd.require(
 	'treemodel/document',
-	'treemodel/position' );
+	'treemodel/element',
+	'treemodel/position',
+	'treemodel/delta/insertdelta'
+);
 
 describe( 'Batch', () => {
-	let Document, Position;
-
-	let doc, root;
+	let Document, Element, Position, InsertDelta;
 
 	before( () => {
 		Document = modules[ 'treemodel/document' ];
+		Element = modules[ 'treemodel/element' ];
 		Position = modules[ 'treemodel/position' ];
+		InsertDelta = modules[ 'treemodel/delta/insertdelta' ];
 	} );
 
+	let doc, root, batch, p, ul, chain;
+
 	beforeEach( () => {
 		doc = new Document();
 		root = doc.createRoot( 'root' );
-	} );
+		root.insertChildren( 0, 'abc' );
+
+		batch = doc.batch();
 
-	it( 'should insert text', () => {
-		const position = new Position( root, [ 0 ] );
-		doc.batch().insert( position, 'foo' );
+		p = new Element( 'p' );
+		ul = new Element( 'ul' );
 
-		expect( root.getChildCount() ).to.equal( 3 );
-		expect( root.getChild( 0 ).character ).to.equal( 'f' );
-		expect( root.getChild( 1 ).character ).to.equal( 'o' );
-		expect( root.getChild( 2 ).character ).to.equal( 'o' );
+		chain = batch.insert( new Position( root, [ 2 ] ), [ p, ul ] );
 	} );
 
-	it( 'should be chainable', () => {
-		const position = new Position( root, [ 0 ] );
-		const batch = doc.batch();
+	describe( 'insert', () => {
+		it( 'should insert given nodes at given position', () => {
+			expect( root.getChildCount() ).to.equal( 5 );
+			expect( root.getChild( 2 ) ).to.equal( p );
+			expect( root.getChild( 3 ) ).to.equal( ul );
+		} );
 
-		const chain = batch.insert( position, 'foo' );
-		expect( chain ).to.equal( batch );
+		it( 'should be chainable', () => {
+			expect( chain ).to.equal( batch );
+		} );
 	} );
 } );

+ 80 - 0
packages/ckeditor5-engine/tests/treemodel/delta/movedelta.js

@@ -0,0 +1,80 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, delta */
+/* bender-include: ../../_tools/tools.js */
+
+'use strict';
+
+const getNodesAndText = bender.tools.treemodel.getNodesAndText;
+
+const modules = bender.amd.require(
+	'treemodel/document',
+	'treemodel/position',
+	'treemodel/range',
+	'treemodel/element',
+	'ckeditorerror'
+);
+
+describe( 'Batch', () => {
+	let Document, Position, Range, Element, CKEditorError;
+
+	let doc, root, div, p, batch, chain;
+
+	before( () => {
+		Document = modules[ 'treemodel/document' ];
+		Position = modules[ 'treemodel/position' ];
+		Range = modules[ 'treemodel/range' ];
+		Element = modules[ 'treemodel/element' ];
+		CKEditorError = modules.ckeditorerror;
+	} );
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+
+		div = new Element( 'div', [], 'foobar' );
+		p = new Element( 'p', [], 'abcxyz' );
+
+		div.insertChildren( 4, [ new Element( 'p', [], 'gggg' ) ] );
+		div.insertChildren( 2, [ new Element( 'p', [], 'hhhh' ) ] );
+
+		root.insertChildren( 0, [ div, p ] );
+
+		batch = doc.batch();
+	} );
+
+	describe( 'move', () => {
+		it( 'should move specified node', () => {
+			batch.move( div, new Position( root, [ 2 ] ) );
+
+			expect( root.getChildCount() ).to.equal( 2 );
+			expect( getNodesAndText( Range.createFromElement( root.getChild( 0 ) ) ) ).to.equal( 'abcxyz' );
+			expect( getNodesAndText( Range.createFromElement( root.getChild( 1 ) ) ) ).to.equal( 'foPhhhhPobPggggPar' );
+		} );
+
+		it( 'should move flat range of nodes', () => {
+			let range = new Range( new Position( root, [ 0, 3 ] ), new Position( root, [ 0, 7 ] ) );
+			batch.move( range, new Position( root, [ 1, 3 ] ) );
+
+			expect( getNodesAndText( Range.createFromElement( root.getChild( 0 ) ) ) ).to.equal( 'foPhhhhPr' );
+			expect( getNodesAndText( Range.createFromElement( root.getChild( 1 ) ) ) ).to.equal( 'abcobPggggPaxyz' );
+		} );
+
+		it( 'should throw if given range is not flat', () => {
+			let notFlatRange = new Range( new Position( root, [ 0, 2, 2 ] ), new Position( root, [ 0, 6 ] ) );
+
+			expect( () => {
+				doc.batch().move( notFlatRange, new Position( root, [ 1, 3 ] ) );
+			} ).to.throw( CKEditorError, /^batch-move-range-not-flat/ );
+		} );
+
+		it( 'should be chainable', () => {
+			chain = batch.move( div, new Position( root, [ 1, 3 ] ) );
+
+			expect( chain ).to.equal( batch );
+		} );
+	} );
+} );

+ 42 - 24
packages/ckeditor5-engine/tests/treemodel/delta/removedelta.js

@@ -4,57 +4,75 @@
  */
 
 /* bender-tags: treemodel, delta */
+/* bender-include: ../../_tools/tools.js */
 
 'use strict';
 
+const getNodesAndText = bender.tools.treemodel.getNodesAndText;
+
 const modules = bender.amd.require(
 	'treemodel/document',
-	'treemodel/position' );
+	'treemodel/position',
+	'treemodel/range',
+	'treemodel/element'
+);
 
 describe( 'Batch', () => {
-	let Document, Position;
+	let Document, Position, Range, Element;
 
-	let doc, root;
+	let doc, root, div, p, batch, chain, range;
 
 	before( () => {
 		Document = modules[ 'treemodel/document' ];
 		Position = modules[ 'treemodel/position' ];
+		Range = modules[ 'treemodel/range' ];
+		Element = modules[ 'treemodel/element' ];
 	} );
 
 	beforeEach( () => {
 		doc = new Document();
 		root = doc.createRoot( 'root' );
-		root.insertChildren( 0, 'foobar' );
+
+		div = new Element( 'div', [], 'foobar' );
+		p = new Element( 'p', [], 'abcxyz' );
+
+		div.insertChildren( 4, [ new Element( 'p', [], 'gggg' ) ] );
+		div.insertChildren( 2, [ new Element( 'p', [], 'hhhh' ) ] );
+
+		root.insertChildren( 0, [ div, p ] );
+
+		batch = doc.batch();
+
+		// Range starts in ROOT > DIV > P > gg|gg.
+		// Range ends in ROOT > DIV > ...|ar.
+		range = new Range( new Position( root, [ 0, 2, 2 ] ), new Position( root, [ 0, 6 ] ) );
 	} );
 
 	describe( 'remove', () => {
-		it( 'should remove one element', () => {
-			const position = new Position( root, [ 1 ] );
-			doc.batch().remove( position );
-
-			expect( root.getChildCount() ).to.equal( 5 );
-			expect( root.getChild( 0 ).character ).to.equal( 'f' );
-			expect( root.getChild( 1 ).character ).to.equal( 'o' );
-			expect( root.getChild( 2 ).character ).to.equal( 'b' );
-			expect( root.getChild( 3 ).character ).to.equal( 'a' );
-			expect( root.getChild( 4 ).character ).to.equal( 'r' );
+		it( 'should remove specified node', () => {
+			batch.remove( div );
+
+			expect( root.getChildCount() ).to.equal( 1 );
+			expect( getNodesAndText( Range.createFromElement( root.getChild( 0 ) ) ) ).to.equal( 'abcxyz' );
+		} );
+
+		it( 'should move any range of nodes', () => {
+			batch.remove( range );
+
+			expect( getNodesAndText( Range.createFromElement( root.getChild( 0 ) ) ) ).to.equal( 'foPhhPar' );
+			expect( getNodesAndText( Range.createFromElement( root.getChild( 1 ) ) ) ).to.equal( 'abcxyz' );
 		} );
 
-		it( 'should remove 3 elements', () => {
-			const position = new Position( root, [ 1 ] );
-			doc.batch().remove( position, 3 );
+		it( 'should create minimal number of operations when removing a range', () => {
+			batch.remove( range );
 
-			expect( root.getChildCount() ).to.equal( 3 );
-			expect( root.getChild( 0 ).character ).to.equal( 'f' );
-			expect( root.getChild( 1 ).character ).to.equal( 'a' );
-			expect( root.getChild( 2 ).character ).to.equal( 'r' );
+			expect( batch.deltas.length ).to.equal( 1 );
+			expect( batch.deltas[ 0 ].operations.length ).to.equal( 2 );
 		} );
 
 		it( 'should be chainable', () => {
-			const position = new Position( root, [ 1 ] );
-			const batch = doc.batch();
+			chain = batch.remove( range );
 
-			const chain = batch.remove( position );
 			expect( chain ).to.equal( batch );
 		} );
 	} );

+ 67 - 0
packages/ckeditor5-engine/tests/treemodel/delta/unwrapdelta.js

@@ -0,0 +1,67 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, delta */
+
+/* bender-include: ../../_tools/tools.js */
+
+'use strict';
+
+const modules = bender.amd.require(
+	'treemodel/document',
+	'treemodel/position',
+	'treemodel/range',
+	'treemodel/element',
+	'ckeditorerror'
+);
+
+describe( 'Batch', () => {
+	let Document, Position, Element, CKEditorError;
+
+	let doc, root, p;
+
+	before( () => {
+		Document = modules[ 'treemodel/document' ];
+		Position = modules[ 'treemodel/position' ];
+		Element = modules[ 'treemodel/element' ];
+		CKEditorError = modules.ckeditorerror;
+	} );
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+
+		p = new Element( 'p', [], 'xyz' );
+		root.insertChildren( 0, [ 'a', p, 'b' ] );
+	} );
+
+	describe( 'unwrap', () => {
+		it( 'should unwrap given element', () => {
+			doc.batch().unwrap( p );
+
+			expect( root.getChildCount() ).to.equal( 5 );
+			expect( root.getChild( 0 ).character ).to.equal( 'a' );
+			expect( root.getChild( 1 ).character ).to.equal( 'x' );
+			expect( root.getChild( 2 ).character ).to.equal( 'y' );
+			expect( root.getChild( 3 ).character ).to.equal( 'z' );
+			expect( root.getChild( 4 ).character ).to.equal( 'b' );
+		} );
+
+		it( 'should throw if element to unwrap has no parent', () => {
+			let element = new Element( 'p' );
+
+			expect( () => {
+				doc.batch().unwrap( element );
+			} ).to.throw( CKEditorError, /^batch-unwrap-element-no-parent/ );
+		} );
+
+		it( 'should be chainable', () => {
+			const batch = doc.batch();
+
+			const chain = batch.unwrap( p );
+			expect( chain ).to.equal( batch );
+		} );
+	} );
+} );

+ 63 - 0
packages/ckeditor5-engine/tests/treemodel/delta/weakinsertdelta.js

@@ -0,0 +1,63 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, delta */
+
+'use strict';
+
+const modules = bender.amd.require(
+	'treemodel/document',
+	'treemodel/attribute',
+	'treemodel/position'
+);
+
+describe( 'Batch', () => {
+	let Document, Attribute, Position;
+
+	before( () => {
+		Document = modules[ 'treemodel/document' ];
+		Attribute = modules[ 'treemodel/attribute' ];
+		Position = modules[ 'treemodel/position' ];
+	} );
+
+	let doc, root, batch, chain, attrs;
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+
+		root.insertChildren( 0, 'abc' );
+
+		batch = doc.batch();
+
+		attrs = [
+			new Attribute( 'bold', true ),
+			new Attribute( 'foo', 'bar' )
+		];
+
+		doc.selection.setAttrsTo( attrs );
+
+		chain = batch.weakInsert( new Position( root, [ 2 ] ), 'xyz' );
+	} );
+
+	describe( 'insert', () => {
+		it( 'should insert given nodes at given position', () => {
+			expect( root.getChildCount() ).to.equal( 6 );
+			expect( root.getChild( 2 ).character ).to.equal( 'x' );
+			expect( root.getChild( 3 ).character ).to.equal( 'y' );
+			expect( root.getChild( 4 ).character ).to.equal( 'z' );
+		} );
+
+		it( 'should set inserted nodes attributes to same as current selection attributes', () => {
+			expect( Array.from( root.getChild( 2 ).getAttrs() ) ).to.deep.equal( attrs );
+			expect( Array.from( root.getChild( 3 ).getAttrs() ) ).to.deep.equal( attrs );
+			expect( Array.from( root.getChild( 4 ).getAttrs() ) ).to.deep.equal( attrs );
+		} );
+
+		it( 'should be chainable', () => {
+			expect( chain ).to.equal( batch );
+		} );
+	} );
+} );

+ 103 - 0
packages/ckeditor5-engine/tests/treemodel/delta/wrapdelta.js

@@ -0,0 +1,103 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, delta */
+
+/* bender-include: ../../_tools/tools.js */
+
+'use strict';
+
+const modules = bender.amd.require(
+	'treemodel/document',
+	'treemodel/position',
+	'treemodel/range',
+	'treemodel/element',
+	'ckeditorerror'
+);
+
+describe( 'Batch', () => {
+	let Document, Position, Range, Element, CKEditorError;
+
+	let doc, root, range;
+
+	before( () => {
+		Document = modules[ 'treemodel/document' ];
+		Position = modules[ 'treemodel/position' ];
+		Range = modules[ 'treemodel/range' ];
+		Element = modules[ 'treemodel/element' ];
+		CKEditorError = modules.ckeditorerror;
+	} );
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+
+		root.insertChildren( 0, 'foobar' );
+
+		range = new Range( new Position( root, [ 2 ] ), new Position( root, [ 4 ] ) );
+	} );
+
+	describe( 'wrap', () => {
+		it( 'should wrap flat range with given element', () => {
+			let p = new Element( 'p' );
+			doc.batch().wrap( range, p );
+
+			expect( root.getChildCount() ).to.equal( 5 );
+			expect( root.getChild( 0 ).character ).to.equal( 'f' );
+			expect( root.getChild( 1 ).character ).to.equal( 'o' );
+			expect( root.getChild( 2 ) ).to.equal( p );
+			expect( p.getChild( 0 ).character ).to.equal( 'o' );
+			expect( p.getChild( 1 ).character ).to.equal( 'b' );
+			expect( root.getChild( 3 ).character ).to.equal( 'a' );
+			expect( root.getChild( 4 ).character ).to.equal( 'r' );
+		} );
+
+		it( 'should wrap flat range with an element of given name', () => {
+			doc.batch().wrap( range, 'p' );
+
+			expect( root.getChildCount() ).to.equal( 5 );
+			expect( root.getChild( 0 ).character ).to.equal( 'f' );
+			expect( root.getChild( 1 ).character ).to.equal( 'o' );
+			expect( root.getChild( 2 ).name ).to.equal( 'p' );
+			expect( root.getChild( 2 ).getChild( 0 ).character ).to.equal( 'o' );
+			expect( root.getChild( 2 ).getChild( 1 ).character ).to.equal( 'b' );
+			expect( root.getChild( 3 ).character ).to.equal( 'a' );
+			expect( root.getChild( 4 ).character ).to.equal( 'r' );
+		} );
+
+		it( 'should throw if range to wrap is not flat', () => {
+			root.insertChildren( 6, [ new Element( 'p', [], 'xyz' ) ] );
+			let notFlatRange = new Range( new Position( root, [ 3 ] ), new Position( root, [ 6, 2 ] ) );
+
+			expect( () => {
+				doc.batch().wrap( notFlatRange, 'p' );
+			} ).to.throw( CKEditorError, /^batch-wrap-range-not-flat/ );
+		} );
+
+		it( 'should throw if element to wrap with has children', () => {
+			let p = new Element( 'p', [], 'a' );
+
+			expect( () => {
+				doc.batch().wrap( range, p );
+			} ).to.throw( CKEditorError, /^batch-wrap-element-not-empty/ );
+		} );
+
+		it( 'should throw if element to wrap with has children', () => {
+			let p = new Element( 'p' );
+			root.insertChildren( 0, p );
+
+			expect( () => {
+				doc.batch().wrap( range, p );
+			} ).to.throw( CKEditorError, /^batch-wrap-element-attached/ );
+		} );
+
+		it( 'should be chainable', () => {
+			const batch = doc.batch();
+
+			const chain = batch.wrap( range, 'p' );
+			expect( chain ).to.equal( batch );
+		} );
+	} );
+} );

+ 25 - 0
packages/ckeditor5-engine/tests/treemodel/position.js

@@ -555,4 +555,29 @@ describe( 'position', () => {
 			expect( combined.path ).to.deep.equal( [ 2, 7, 4, 2 ] );
 		} );
 	} );
+
+	describe( 'getShiftedBy', () => {
+		it( 'should return a new instance of Position with offset changed by shift value', () => {
+			let position = new Position( root, [ 1, 2, 3 ] );
+			let shifted = position.getShiftedBy( 2 );
+
+			expect( shifted ).to.be.instanceof( Position );
+			expect( shifted ).to.not.equal( position );
+			expect( shifted.path ).to.deep.equal( [ 1, 2, 5 ] );
+		} );
+
+		it( 'should accept negative values', () => {
+			let position = new Position( root, [ 1, 2, 3 ] );
+			let shifted = position.getShiftedBy( -2 );
+
+			expect( shifted.path ).to.deep.equal( [ 1, 2, 1 ] );
+		} );
+
+		it( 'should not let setting offset lower than zero', () => {
+			let position = new Position( root, [ 1, 2, 3 ] );
+			let shifted = position.getShiftedBy( -7 );
+
+			expect( shifted.path ).to.deep.equal( [ 1, 2, 0 ] );
+		} );
+	} );
 } );

+ 95 - 2
packages/ckeditor5-engine/tests/treemodel/range.js

@@ -198,7 +198,7 @@ describe( 'Range', () => {
 		} );
 	} );
 
-	describe( 'getNodes', () => {
+	describe( 'getAllNodes', () => {
 		it( 'should iterate over all nodes which "starts" in the range', () => {
 			let nodes = [];
 
@@ -219,7 +219,7 @@ describe( 'Range', () => {
 				new Position( root, [ 1, 1 ] )
 			);
 
-			for ( let node of range.getNodes() ) {
+			for ( let node of range.getAllNodes() ) {
 				nodes.push( node );
 			}
 
@@ -386,4 +386,97 @@ describe( 'Range', () => {
 			expect( common.end.path ).to.deep.equal( [ 4, 7 ] );
 		} );
 	} );
+
+	describe( 'getMinimalFlatRanges', () => {
+		beforeEach( () => {
+			prepareRichRoot( root );
+		} );
+
+		it( 'should return empty array if range is collapsed', () => {
+			let range = new Range( new Position( root, [ 1, 3 ] ), new Position( root, [ 1, 3 ] ) );
+			let flat = range.getMinimalFlatRanges();
+
+			expect( flat.length ).to.equal( 0 );
+		} );
+
+		it( 'should return empty array if range does not contain any node', () => {
+			let range = new Range( new Position( root, [ 1, 3 ] ), new Position( root, [ 2, 0 ] ) );
+			let flat = range.getMinimalFlatRanges();
+
+			expect( flat.length ).to.equal( 0 );
+		} );
+
+		it( 'should return a minimal set of flat ranges that covers the range (start and end in different sub-trees)', () => {
+			let range = new Range( new Position( root, [ 0, 0, 3 ] ), new Position( root, [ 3, 0, 2 ] ) );
+			let flat = range.getMinimalFlatRanges();
+
+			expect( flat.length ).to.equal( 4 );
+			expect( flat[ 0 ].start.path ).to.deep.equal( [ 0, 0, 3 ] );
+			expect( flat[ 0 ].end.path ).to.deep.equal( [ 0, 0, 5 ] );
+			expect( flat[ 1 ].start.path ).to.deep.equal( [ 0, 1 ] );
+			expect( flat[ 1 ].end.path ).to.deep.equal( [ 0, 2 ] );
+			expect( flat[ 2 ].start.path ).to.deep.equal( [ 1 ] );
+			expect( flat[ 2 ].end.path ).to.deep.equal( [ 3 ] );
+			expect( flat[ 3 ].start.path ).to.deep.equal( [ 3, 0, 0 ] );
+			expect( flat[ 3 ].end.path ).to.deep.equal( [ 3, 0, 2 ] );
+		} );
+
+		it( 'should return a minimal set of flat ranges that covers the range (start.path is prefix of end.path)', () => {
+			let range = new Range( new Position( root, [ 0 ] ), new Position( root, [ 0, 1, 4 ] ) );
+			let flat = range.getMinimalFlatRanges();
+
+			expect( flat.length ).to.equal( 2 );
+			expect( flat[ 0 ].start.path ).to.deep.equal( [ 0, 0 ] );
+			expect( flat[ 0 ].end.path ).to.deep.equal( [ 0, 1 ] );
+			expect( flat[ 1 ].start.path ).to.deep.equal( [ 0, 1, 0 ] );
+			expect( flat[ 1 ].end.path ).to.deep.equal( [ 0, 1, 4 ] );
+		} );
+	} );
+
+	describe( 'getTopLevelNodes', () => {
+		beforeEach( () => {
+			prepareRichRoot( root );
+		} );
+
+		it( 'should iterate over all top-level nodes of this range', () => {
+			let range = new Range( new Position( root, [ 0, 0, 3 ] ), new Position( root, [ 3, 0, 2 ] ) );
+			let nodes = Array.from( range.getTopLevelNodes() );
+			let nodeNames = nodes.map( ( node ) => {
+				return ( node instanceof Element ) ? 'E:' + node.name : 'C:' + node.character;
+			} );
+
+			expect( nodeNames ).to.deep.equal( [ 'C:s', 'C:t', 'E:p', 'E:p', 'E:p', 'C:s', 'C:e' ] );
+		} );
+	} );
+
+	describe( 'isFlat', () => {
+		beforeEach( () => {
+			prepareRichRoot( root );
+		} );
+
+		it( 'should be true if start and end position are in the same parent', () => {
+			let range = new Range( new Position( root, [ 0, 0 ] ), new Position( root, [ 0, 2 ] ) );
+			expect( range.isFlat ).to.be.true;
+		} );
+
+		it( 'should be false if start and end position are in different parents', () => {
+			let range = new Range( new Position( root, [ 0, 0 ] ), new Position( root, [ 3, 0, 1 ] ) );
+			expect( range.isFlat ).to.be.false;
+		} );
+	} );
+
+	function prepareRichRoot() {
+		root.insertChildren( 0, [
+			new Element( 'div', [], [
+				new Element( 'h', [], 'first' ),
+				new Element( 'p', [], 'lorem ipsum' )
+			] ),
+			new Element( 'p', [], 'foo' ),
+			new Element( 'p', [], 'bar' ),
+			new Element( 'div', [], [
+				new Element( 'h', [], 'second' ),
+				new Element( 'p', [], 'lorem' )
+			] )
+		] );
+	}
 } );