Browse Source

Merge pull request #90 from ckeditor/t/59-r

T/59: OT for Tree Model.
Piotrek Koszuliński 10 years ago
parent
commit
334e71b0e2
23 changed files with 4259 additions and 401 deletions
  1. 21 21
      packages/ckeditor5-utils/src/document/document.js
  2. 30 32
      packages/ckeditor5-utils/src/document/element.js
  3. 85 85
      packages/ckeditor5-utils/src/document/node.js
  4. 25 25
      packages/ckeditor5-utils/src/document/nodelist.js
  5. 12 5
      packages/ckeditor5-utils/src/document/operation/changeoperation.js
  6. 6 2
      packages/ckeditor5-utils/src/document/operation/insertoperation.js
  7. 11 11
      packages/ckeditor5-utils/src/document/operation/moveoperation.js
  8. 37 0
      packages/ckeditor5-utils/src/document/operation/nooperation.js
  9. 8 23
      packages/ckeditor5-utils/src/document/operation/operation.js
  10. 422 0
      packages/ckeditor5-utils/src/document/operation/transform.js
  11. 352 49
      packages/ckeditor5-utils/src/document/position.js
  12. 6 6
      packages/ckeditor5-utils/src/document/positioniterator.js
  13. 220 21
      packages/ckeditor5-utils/src/document/range.js
  14. 13 17
      packages/ckeditor5-utils/src/utils.js
  15. 1 1
      packages/ckeditor5-utils/tests/document/document.js
  16. 52 39
      packages/ckeditor5-utils/tests/document/operation/changeoperation.js
  17. 28 1
      packages/ckeditor5-utils/tests/document/operation/insertoperation.js
  18. 34 29
      packages/ckeditor5-utils/tests/document/operation/moveoperation.js
  19. 48 0
      packages/ckeditor5-utils/tests/document/operation/nooperation.js
  20. 2369 0
      packages/ckeditor5-utils/tests/document/operation/transform.js
  21. 284 22
      packages/ckeditor5-utils/tests/document/position.js
  22. 193 10
      packages/ckeditor5-utils/tests/document/range.js
  23. 2 2
      packages/ckeditor5-utils/tests/utils/utils.js

+ 21 - 21
packages/ckeditor5-utils/src/document/document.js

@@ -12,7 +12,7 @@ CKEDITOR.define( [
 	'emittermixin',
 	'utils',
 	'ckeditorerror'
-], ( Element, RootElement, Tranaction, EmitterMixin, utils, CKEditorError ) => {
+], ( Element, RootElement, Transaction, EmitterMixin, utils, CKEditorError ) => {
 	const graveyardSymbol = Symbol( 'graveyard' );
 
 	/**
@@ -57,6 +57,17 @@ CKEDITOR.define( [
 			this.version = 0;
 		}
 
+		/**
+		 * Graveyard tree root. Document always have a graveyard root, which stores removed nodes.
+		 *
+		 * @protected
+		 * @readonly
+		 * @property {document.RootElement} _graveyard
+		 */
+		get _graveyard() {
+			return this.getRoot( graveyardSymbol );
+		}
+
 		/**
 		 * This is the entry point for all document changes. All changes on the document are done using
 		 * {@link document.operation.Operation operations}. To create operations in the simple way use the
@@ -109,6 +120,15 @@ CKEDITOR.define( [
 			return root;
 		}
 
+		/**
+		 * Creates a {@link document.Transaction} instance which allows to change the document.
+		 *
+		 * @returns {document.Transaction} Transaction instance.
+		 */
+		createTransaction() {
+			return new Transaction( this );
+		}
+
 		/**
 		 * Returns top-level root by it's name.
 		 *
@@ -131,26 +151,6 @@ CKEDITOR.define( [
 
 			return this.roots.get( name );
 		}
-
-		/**
-		 * Graveyard tree root. Document always have a graveyard root, which stores removed nodes.
-		 *
-		 * @protected
-		 * @readonly
-		 * @property {document.RootElement} _graveyard
-		 */
-		get _graveyard() {
-			return this.getRoot( graveyardSymbol );
-		}
-
-		/**
-		 * Creates a {@link document.Transaction} instance which allows to change the document.
-		 *
-		 * @returns {document.Transaction} Transaction instance.
-		 */
-		createTransaction() {
-			return new Tranaction( this );
-		}
 	}
 
 	utils.extend( Document.prototype, EmitterMixin );

+ 30 - 32
packages/ckeditor5-utils/src/document/element.js

@@ -7,9 +7,7 @@
 
 CKEDITOR.define( [
 	'document/node',
-	'document/nodelist',
-	'document/range',
-	'document/position'
+	'document/nodelist'
 ], ( Node, NodeList ) => {
 	/**
 	 * Tree data model element.
@@ -52,6 +50,35 @@ CKEDITOR.define( [
 			}
 		}
 
+		/**
+		 * Gets child at the given index.
+		 *
+		 * @param {Number} index Index of child.
+		 * @returns {document.Node} Child node.
+		 */
+		getChild( index ) {
+			return this._children.get( index );
+		}
+
+		/**
+		 * Gets the number of element's children.
+		 *
+		 * @returns {Number} The number of element's children.
+		 */
+		getChildCount() {
+			return this._children.length;
+		}
+
+		/**
+		 * Gets index of the given child node.
+		 *
+		 * @param {document.Node} node Child node.
+		 * @returns {Number} Index of the child node.
+		 */
+		getChildIndex( node ) {
+			return this._children.indexOf( node );
+		}
+
 		/**
 		 * Inserts a list of child nodes on the given index and sets the parent of these nodes to this element.
 		 *
@@ -88,35 +115,6 @@ CKEDITOR.define( [
 
 			return this._children.remove( index, number );
 		}
-
-		/**
-		 * Gets child at the given index.
-		 *
-		 * @param {Number} index Index of child.
-		 * @returns {document.Node} Child node.
-		 */
-		getChild( index ) {
-			return this._children.get( index );
-		}
-
-		/**
-		 * Gets index of the given child node.
-		 *
-		 * @param {document.Node} node Child node.
-		 * @returns {Number} Index of the child node.
-		 */
-		getChildIndex( node ) {
-			return this._children.indexOf( node );
-		}
-
-		/**
-		 * Gets the number of element's children.
-		 *
-		 * @returns {Number} The number of element's children.
-		 */
-		getChildCount() {
-			return this._children.length;
-		}
 	}
 
 	return Element;

+ 85 - 85
packages/ckeditor5-utils/src/document/node.js

@@ -41,33 +41,6 @@ CKEDITOR.define( [ 'document/attribute', 'utils', 'ckeditorerror' ], ( Attribute
 			this._attrs = new Set( attrs );
 		}
 
-		/**
-		 * Index of the node in the parent element or null if the node has no parent.
-		 *
-		 * Throws error if the parent element does not contain this node.
-		 *
-		 * @returns {Number|Null} Index of the node in the parent element or null if the node has not parent.
-		 */
-		getIndex() {
-			let pos;
-
-			if ( !this.parent ) {
-				return null;
-			}
-
-			// No parent or child doesn't exist in parent's children.
-			if ( ( pos = this.parent.getChildIndex( this ) ) == -1 ) {
-				/**
-				 * The node's parent does not contain this node. It means that the document tree is corrupted.
-				 *
-				 * @error node-not-found-in-parent
-				 */
-				throw new CKEditorError( 'node-not-found-in-parent: The node\'s parent does not contain this node.' );
-			}
-
-			return pos;
-		}
-
 		/**
 		 * Depth of the node, which equals to total number of its parents.
 		 *
@@ -87,6 +60,30 @@ CKEDITOR.define( [ 'document/attribute', 'utils', 'ckeditorerror' ], ( Attribute
 			return depth;
 		}
 
+		/**
+		 * Nodes next sibling or `null` if it is the last child.
+		 *
+		 * @readonly
+		 * @property {document.Node|null} nextSibling
+		 */
+		get nextSibling() {
+			const index = this.getIndex();
+
+			return ( index !== null && this.parent.getChild( index + 1 ) ) || null;
+		}
+
+		/**
+		 * Nodes previous sibling or null if it is the last child.
+		 *
+		 * @readonly
+		 * @property {document.Node|null} previousSibling
+		 */
+		get previousSibling() {
+			const index = this.getIndex();
+
+			return ( index !== null && this.parent.getChild( index - 1 ) ) || null;
+		}
+
 		/**
 		 * The top parent for the node. If node has no parent it is the root itself.
 		 *
@@ -104,34 +101,82 @@ CKEDITOR.define( [ 'document/attribute', 'utils', 'ckeditorerror' ], ( Attribute
 		}
 
 		/**
-		 * Nodes next sibling or `null` if it is the last child.
+		 * Finds an attribute by a key.
 		 *
-		 * @readonly
-		 * @property {document.Node|null} nextSibling
+		 * @param {String} attr The attribute key.
+		 * @returns {document.Attribute} The found attribute.
 		 */
-		get nextSibling() {
-			const index = this.getIndex();
+		getAttr( key ) {
+			for ( let attr of this._attrs ) {
+				if ( attr.key == key ) {
+					return attr.value;
+				}
+			}
 
-			return ( index !== null && this.parent.getChild( index + 1 ) ) || null;
+			return null;
 		}
 
 		/**
-		 * Nodes previous sibling or null if it is the last child.
+		 * Returns attribute iterator. It can be use to create a new element with the same attributes:
 		 *
-		 * @readonly
-		 * @property {document.Node|null} previousSibling
+		 *		const copy = new Element( element.name, element.getAttrs() );
+		 *
+		 * @returns {Iterable.<document.Attribute>} Attribute iterator.
 		 */
-		get previousSibling() {
-			const index = this.getIndex();
+		getAttrs() {
+			return this._attrs[ Symbol.iterator ]();
+		}
 
-			return ( index !== null && this.parent.getChild( index - 1 ) ) || null;
+		/**
+		 * Index of the node in the parent element or null if the node has no parent.
+		 *
+		 * Throws error if the parent element does not contain this node.
+		 *
+		 * @returns {Number|Null} Index of the node in the parent element or null if the node has not parent.
+		 */
+		getIndex() {
+			let pos;
+
+			if ( !this.parent ) {
+				return null;
+			}
+
+			// No parent or child doesn't exist in parent's children.
+			if ( ( pos = this.parent.getChildIndex( this ) ) == -1 ) {
+				/**
+				 * The node's parent does not contain this node. It means that the document tree is corrupted.
+				 *
+				 * @error node-not-found-in-parent
+				 */
+				throw new CKEditorError( 'node-not-found-in-parent: The node\'s parent does not contain this node.' );
+			}
+
+			return pos;
+		}
+
+		/**
+		 * Gets path to the node. For example if the node is the second child of the first child of the root then the path
+		 * will be `[ 1, 2 ]`. This path can be used as a parameter of {@link document.Position}.
+		 *
+		 * @returns {Number[]} The path.
+		 */
+		getPath() {
+			const path = [];
+			let node = this; // jscs:ignore safeContextKeyword
+
+			while ( node.parent ) {
+				path.unshift( node.getIndex() );
+				node = node.parent;
+			}
+
+			return path;
 		}
 
 		/**
 		 * Returns `true` if the node contains an attribute with the same key and value as given or the same key if the
 		 * given parameter is a string.
 		 *
-		 * @param {document.Attribute|String} attr An attribute or a key to compare.
+		 * @param {document.Attribute|String} key An attribute or a key to compare.
 		 * @returns {Boolean} True if node contains given attribute or an attribute with the given key.
 		 */
 		hasAttr( key ) {
@@ -157,22 +202,6 @@ CKEDITOR.define( [ 'document/attribute', 'utils', 'ckeditorerror' ], ( Attribute
 			return false;
 		}
 
-		/**
-		 * Finds an attribute by a key.
-		 *
-		 * @param {String} attr The attribute key.
-		 * @returns {document.Attribute} The found attribute.
-		 */
-		getAttr( key ) {
-			for ( let attr of this._attrs ) {
-				if ( attr.key == key ) {
-					return attr.value;
-				}
-			}
-
-			return null;
-		}
-
 		/**
 		 * Removes attribute from the list of attributes.
 		 *
@@ -199,24 +228,6 @@ CKEDITOR.define( [ 'document/attribute', 'utils', 'ckeditorerror' ], ( Attribute
 			this._attrs.add( attr );
 		}
 
-		/**
-		 * Gets path to the node. For example if the node is the second child of the first child of the root then the path
-		 * will be `[ 1, 2 ]`. This path can be used as a parameter of {@link document.Position}.
-		 *
-		 * @returns {Number[]} The path.
-		 */
-		getPath() {
-			const path = [];
-			let node = this; // jscs:ignore safeContextKeyword
-
-			while ( node.parent ) {
-				path.unshift( node.getIndex() );
-				node = node.parent;
-			}
-
-			return path;
-		}
-
 		/**
 		 * Custom toJSON method to solve child-parent circular dependencies.
 		 *
@@ -230,17 +241,6 @@ CKEDITOR.define( [ 'document/attribute', 'utils', 'ckeditorerror' ], ( Attribute
 
 			return json;
 		}
-
-		/**
-		 * Returns attribute iterator. It can be use to create a new element with the same attributes:
-		 *
-		 *		const copy = new Element( element.name, element.getAttrs() );
-		 *
-		 * @returns {Iterable.<document.Attribute>} Attribute iterator.
-		 */
-		getAttrs() {
-			return this._attrs[ Symbol.iterator ]();
-		}
 	}
 
 	return Node;

+ 25 - 25
packages/ckeditor5-utils/src/document/nodelist.js

@@ -94,34 +94,30 @@ CKEDITOR.define( [
 		}
 
 		/**
-		 * Returns node at the given index.
+		 * Number of nodes in the node list.
 		 *
-		 * @param {Number} index Node index.
-		 * @returns {document.Node} Node at given index.
+		 * @readonly
+		 * @property {Number} length
 		 */
-		get( index ) {
-			return this._nodes[ index ];
+		get length() {
+			return this._nodes.length;
 		}
 
 		/**
-		 * Inserts nodes from the given node list into this node list at the given index.
-		 *
-		 * @param {Number} index Position where nodes should be inserted.
-		 * @param {document.NodeList} nodeList List of nodes to insert.
+		 * Node list iterator.
 		 */
-		insert( index, nodeList ) {
-			this._nodes.splice.apply( this._nodes, [ index, 0 ].concat( nodeList._nodes ) );
+		[ Symbol.iterator ]() {
+			return this._nodes[ Symbol.iterator ]();
 		}
 
 		/**
-		 * Removes number of nodes starting at the given index.
+		 * Returns node at the given index.
 		 *
-		 * @param {Number} index Position of the first node to remove.
-		 * @param {Number} number Number of nodes to remove.
-		 * @returns {document.NodeList} List of removed nodes.
+		 * @param {Number} index Node index.
+		 * @returns {document.Node} Node at given index.
 		 */
-		remove( index, number ) {
-			return new NodeList( this._nodes.splice( index, number ) );
+		get( index ) {
+			return this._nodes[ index ];
 		}
 
 		/**
@@ -135,20 +131,24 @@ CKEDITOR.define( [
 		}
 
 		/**
-		 * Number of nodes in the node list.
+		 * Inserts nodes from the given node list into this node list at the given index.
 		 *
-		 * @readonly
-		 * @property {Number} length
+		 * @param {Number} index Position where nodes should be inserted.
+		 * @param {document.NodeList} nodeList List of nodes to insert.
 		 */
-		get length() {
-			return this._nodes.length;
+		insert( index, nodeList ) {
+			this._nodes.splice.apply( this._nodes, [ index, 0 ].concat( nodeList._nodes ) );
 		}
 
 		/**
-		 * Node list iterator.
+		 * Removes number of nodes starting at the given index.
+		 *
+		 * @param {Number} index Position of the first node to remove.
+		 * @param {Number} number Number of nodes to remove.
+		 * @returns {document.NodeList} List of removed nodes.
 		 */
-		[ Symbol.iterator ]() {
-			return this._nodes[ Symbol.iterator ]();
+		remove( index, number ) {
+			return new NodeList( this._nodes.splice( index, number ) );
 		}
 	}
 

+ 12 - 5
packages/ckeditor5-utils/src/document/operation/changeoperation.js

@@ -5,7 +5,10 @@
 
 'use strict';
 
-CKEDITOR.define( [ 'document/operation/operation', 'ckeditorerror' ], ( Operation, CKEditorError ) => {
+CKEDITOR.define( [
+	'document/operation/operation',
+	'ckeditorerror'
+], ( Operation, CKEditorError ) => {
 	/**
 	 * Operation to change nodes' attribute. Using this class you can add, remove or change value of the attribute.
 	 *
@@ -58,6 +61,14 @@ CKEDITOR.define( [ 'document/operation/operation', 'ckeditorerror' ], ( Operatio
 			this.newAttr = newAttr;
 		}
 
+		clone() {
+			return new ChangeOperation( this.range.clone(), this.oldAttr, this.newAttr, this.baseVersion );
+		}
+
+		getReversed() {
+			return new ChangeOperation( this.range, this.newAttr, this.oldAttr, this.baseVersion + 1 );
+		}
+
 		_execute() {
 			const oldAttr = this.oldAttr;
 			const newAttr = this.newAttr;
@@ -120,10 +131,6 @@ CKEDITOR.define( [ 'document/operation/operation', 'ckeditorerror' ], ( Operatio
 				}
 			}
 		}
-
-		getReversed() {
-			return new ChangeOperation( this.range, this.newAttr, this.oldAttr, this.baseVersion + 1 );
-		}
 	}
 
 	return ChangeOperation;

+ 6 - 2
packages/ckeditor5-utils/src/document/operation/insertoperation.js

@@ -45,8 +45,8 @@ CKEDITOR.define( [
 			this.nodeList = new NodeList( nodes );
 		}
 
-		_execute() {
-			this.position.parent.insertChildren( this.position.offset, this.nodeList );
+		clone() {
+			return new InsertOperation( this.position, this.nodeList, this.baseVersion );
 		}
 
 		getReversed() {
@@ -55,6 +55,10 @@ CKEDITOR.define( [
 
 			return new RemoveOperation( this.position, this.nodeList.length, this.baseVersion + 1 );
 		}
+
+		_execute() {
+			this.position.parent.insertChildren( this.position.offset, this.nodeList );
+		}
 	}
 
 	return InsertOperation;

+ 11 - 11
packages/ckeditor5-utils/src/document/operation/moveoperation.js

@@ -7,10 +7,9 @@
 
 CKEDITOR.define( [
 	'document/operation/operation',
-	'document/nodelist',
 	'ckeditorerror',
 	'utils'
-], ( Operation, NodeList, CKEditorError, utils ) => {
+], ( Operation, CKEditorError, utils ) => {
 	/**
 	 * Operation to move list of subsequent nodes from one position in the document to another.
 	 *
@@ -51,6 +50,14 @@ CKEDITOR.define( [
 			this.howMany = howMany;
 		}
 
+		clone() {
+			return new MoveOperation( this.sourcePosition.clone(), this.targetPosition.clone(), this.howMany, this.baseVersion );
+		}
+
+		getReversed() {
+			return new MoveOperation( this.targetPosition.clone(), this.sourcePosition.clone(), this.howMany, this.baseVersion + 1 );
+		}
+
 		_execute() {
 			let sourceElement = this.sourcePosition.parent;
 			let targetElement = this.targetPosition.parent;
@@ -88,11 +95,8 @@ CKEDITOR.define( [
 					'operation-move-range-into-itself: Trying to move a range of nodes to the inside of that range.'
 				);
 			} else {
-				const sourcePath = this.sourcePosition.getParentPath();
-				const targetPath = this.targetPosition.getParentPath();
-
-				if ( utils.compareArrays( sourcePath, targetPath ) == utils.compareArrays.PREFIX ) {
-					let i = sourcePath.length;
+				if ( utils.compareArrays( this.sourcePosition.getParentPath(), this.targetPosition.getParentPath() ) == utils.compareArrays.PREFIX ) {
+					let i = this.sourcePosition.path.length - 1;
 
 					if ( this.targetPosition.path[ i ] >= sourceOffset && this.targetPosition.path[ i ] < sourceOffset + this.howMany ) {
 						/**
@@ -118,10 +122,6 @@ CKEDITOR.define( [
 
 			targetElement.insertChildren( targetOffset, removedNodes );
 		}
-
-		getReversed() {
-			return new MoveOperation( this.targetPosition, this.sourcePosition, this.howMany, this.baseVersion + 1 );
-		}
 	}
 
 	return MoveOperation;

+ 37 - 0
packages/ckeditor5-utils/src/document/operation/nooperation.js

@@ -0,0 +1,37 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+CKEDITOR.define( [
+	'document/operation/operation'
+], ( Operation ) => {
+	/**
+	 * Operation which is doing nothing ("empty operation", "do-nothing operation", "noop").
+	 * This is an operation, which executed does not change the tree model.
+	 * It still has some parameters defined for transformation purposes.
+	 *
+	 * In most cases this operation is a result of transforming operations. When transformation returns
+	 * {@link document.operation.NoOperation} it means that changes done by the transformed operation
+	 * have already been applied.
+	 *
+	 * @class document.operation.NoOperation
+	 */
+	class NoOperation extends Operation {
+		clone() {
+			return new NoOperation( this.baseVersion );
+		}
+
+		getReversed() {
+			return new NoOperation( this.baseVersion + 1 );
+		}
+
+		_execute() {
+			// Do nothing.
+		}
+	}
+
+	return NoOperation;
+} );

+ 8 - 23
packages/ckeditor5-utils/src/document/operation/operation.js

@@ -39,11 +39,10 @@ CKEDITOR.define( [], () => {
 			 */
 
 			/**
-			 * Executes the operation - modifications described by the operation attributes
-			 * will be applied to the tree model.
+			 * Creates and returns an operation that has the same parameters as this operation.
 			 *
-			 * @method _execute
-			 * @protected
+			 * @method clone
+			 * @returns {document.operation.Operation} Clone of this operation.
 			 */
 
 			/**
@@ -53,32 +52,18 @@ CKEDITOR.define( [], () => {
 			 *
 			 * Keep in mind that tree model state may change since executing the original operation,
 			 * so reverse operation will be "outdated". In that case you will need to
-			 * {@link #getTransformedBy transform} it by all operations that were executed after the original operation.
+			 * {@link document.operation.transform} it by all operations that were executed after the original operation.
 			 *
 			 * @method getReversed
 			 * @returns {document.operation.Operation} Reversed operation.
 			 */
 
 			/**
-			 * Creates and returns a clone of this operation which is transformed by the given operation.
-			 * When operation is transformed its parameters may change accordingly to the operation which it is
-			 * transformed by. If the given operation applied to the tree model any modifications which are
-			 * affecting ranges/positions/nodes connected with this operation, those changes will be reflected
-			 * in the parameters of the returned operation.
-			 *
-			 * Whenever the {@link document.Document document} has different {@link document.Document#baseVersion}
-			 * than an operation you want to {@link document.Document#applyOperation apply}, you need to transform
-			 * all the operations that were executed on the {@link document.Document document} since it has
-			 * {@link document.Document#baseVersion} same as the operation (transform in the same order as those
-			 * operations were executed). This way all modifications done to the tree model will be reflected
-			 * in the operation parameters and the operation will "work" on "up-to-date" version of the tree model.
-			 *
-			 * **TODO**: So far we think that transforming one operation by another one may result in more than one
-			 * operation. This needs to be clarified in this documentation.
+			 * Executes the operation - modifications described by the operation attributes
+			 * will be applied to the tree model.
 			 *
-			 * @method getTransformedBy
-			 * @param {document.operation.Operation} operation Operation by which this operation will be transformed.
-			 * @returns {document.operation.Operation[]} A result of transformation of this operation by the given operation.
+			 * @method _execute
+			 * @protected
 			 */
 		}
 	}

+ 422 - 0
packages/ckeditor5-utils/src/document/operation/transform.js

@@ -0,0 +1,422 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+/**
+ * Transforms given {document.operation.Operation} by another {document.operation.Operation} and returns the result of
+ * that transformation as an array containing one or more {document.operation.Operation} elements.
+ *
+ * Operations work on specified positions, passed to them when they are created. Whenever {@link document.Document document}
+ * changes, we have to reflect those modifications by updating or "transforming" operations which are not yet applied.
+ * When an operation is transformed, its parameters may change based on the operation by which it is transformed.
+ * If the transform-by operation applied any modifications to the Tree Data Model which affect positions or nodes
+ * connected with transformed operation, those changes will be reflected in the parameters of the returned operation(s).
+ *
+ * Whenever the {@link document.Document document} has different {@link document.Document#baseVersion}
+ * than the operation you want to {@link document.Document#applyOperation apply}, you need to transform that
+ * operation by all operations which were already applied to the {@link document.Document document} and have greater
+ * {@link document.Document#baseVersion} than the operation being applied. Transform them in the same order as those
+ * operations which were applied. This way all modifications done to the Tree Data Model will be reflected
+ * in the operation parameters and the operation will "operate" on "up-to-date" version of the Tree Data Model.
+ * This is mostly the case with Operational Transformations but it might be needed in particular features as well.
+ *
+ * In some cases, when given operation apply changes to the same nodes as this operation, two or more operations need
+ * to be created as one would not be able to reflect the combination of these operations.
+ * This is why an array is returned instead of a single object. All returned operations have to be applied
+ * (or further transformed) to get an effect which was intended in pre-transformed operation.
+ *
+ * Sometimes two operations are in conflict. This happens when they modify the same node in a different way, i.e.
+ * set different value for the same attribute or move the node into different positions. When this happens,
+ * we need to decide which operation is more important. We can't assume that operation `a` or operation `b` is always
+ * more important. In Operational Transformations algorithms we often need to get a result of transforming
+ * `a` by `b` and also `b` by `a`. In both transformations the same operation has to be the important one. If we assume
+ * that first or the second passed operation is always more important we won't be able to solve this case.
+ *
+ * @function document.operation.transform
+ * @param {document.operation.Operation} a Operation that will be transformed.
+ * @param {document.operation.Operation} b Operation to transform by.
+ * @param {Boolean} isAMoreImportantThanB Flag indicating whether the operation which will be transformed (`a`) should be treated
+ * as more important when resolving conflicts.
+ * @returns {Array.<document.operation.Operation>} Result of the transformation.
+ */
+
+CKEDITOR.define( [
+	'document/operation/insertoperation',
+	'document/operation/changeoperation',
+	'document/operation/moveoperation',
+	'document/operation/nooperation',
+	'document/range',
+	'utils'
+], ( InsertOperation, ChangeOperation, MoveOperation, NoOperation, Range, utils ) => {
+	const ot = {
+		InsertOperation: {
+			// Transforms InsertOperation `a` by InsertOperation `b`. Accepts a flag stating whether `a` is more important
+			// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
+			InsertOperation( a, b, isStrong ) {
+				// Transformed operations are always new instances, not references to the original operations.
+				const transformed = a.clone();
+
+				// Transform insert position by the other operation position.
+				transformed.position = transformed.position.getTransformedByInsertion( b.position, b.nodeList.length, !isStrong );
+
+				return [ transformed ];
+			},
+
+			ChangeOperation: doNotUpdate,
+
+			// Transforms InsertOperation `a` by MoveOperation `b`. Accepts a flag stating whether `a` is more important
+			// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
+			MoveOperation( a, b, isStrong ) {
+				const transformed = a.clone();
+
+				// Transform insert position by the other operation parameters.
+				transformed.position = a.position.getTransformedByMove( b.sourcePosition, b.targetPosition, b.howMany, !isStrong );
+
+				return [ transformed ];
+			}
+		},
+
+		ChangeOperation: {
+			// Transforms ChangeOperation `a` by InsertOperation `b`. Returns results as an array of operations.
+			InsertOperation( a, b ) {
+				// Transform this operation's range.
+				const ranges = a.range.getTransformedByInsertion( b.position, b.nodeList.length );
+
+				// Map transformed range(s) to operations and return them.
+				return ranges.reverse().map( ( range ) => {
+					return new ChangeOperation(
+						range,
+						a.oldAttr,
+						a.newAttr,
+						a.baseVersion
+					);
+				} );
+			},
+
+			// Transforms ChangeOperation `a` by ChangeOperation `b`. Accepts a flag stating whether `a` is more important
+			// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
+			ChangeOperation( a, b, isStrong ) {
+				if ( haveConflictingAttributes( a, b ) ) {
+					// If operations attributes are in conflict, check if their ranges intersect and manage them properly.
+					let operations = [];
+
+					// First, we want to apply change to the part of a range that has not been changed by the other operation.
+					operations = operations.concat(
+						a.range.getDifference( b.range ).map( ( range ) => {
+							return new ChangeOperation( range, a.oldAttr, a.newAttr, a.baseVersion );
+						} )
+					);
+
+					if ( isStrong ) {
+						// If this operation is more important, we want also want to apply change to the part of the
+						// original range that has already been changed by the other operation. Since that range
+						// got changed we have to update oldAttr.
+						const common = a.range.getIntersection( b.range );
+
+						if ( common !== null ) {
+							operations.push( new ChangeOperation( common, b.oldAttr, a.newAttr, a.baseVersion ) );
+						}
+					}
+
+					// If no operations has been added nothing should get updated, but since we need to return
+					// an instance of Operation we add NoOperation to the array.
+					if ( operations.length === 0 ) {
+						operations.push( new NoOperation( a.baseVersion ) );
+					}
+
+					return operations;
+				} else {
+					// If operations don't conflict simply, return an array containing just a clone of this operation.
+					return [ a.clone() ];
+				}
+			},
+
+			// Transforms ChangeOperation `a` by MoveOperation `b`. Returns results as an array of operations.
+			MoveOperation( a, b ) {
+				// Convert MoveOperation properties into a range.
+				const rangeB = Range.createFromPositionAndOffset( b.sourcePosition, b.howMany );
+
+				// Get target position from the state "after" nodes specified by MoveOperation are "detached".
+				const newTargetPosition = b.targetPosition.getTransformedByDeletion( b.sourcePosition, b.howMany );
+
+				// This will aggregate transformed ranges.
+				let ranges = [];
+
+				// Difference is a part of changed range that is modified by ChangeOperation but are not affected
+				// by MoveOperation. This can be zero, one or two ranges (if moved range is inside changed range).
+				// If two ranges were returned it means that rangeB was inside rangeA. We will cover rangeB later.
+				// Right now we will make a simplification and join difference ranges and transform them as one.
+				const difference = joinRanges( a.range.getDifference( rangeB ) );
+
+				// Common is a range of nodes that is affected by MoveOperation. So it got moved to other place.
+				const common = a.range.getIntersection( rangeB );
+
+				if ( difference !== null ) {
+					// MoveOperation removes nodes from their original position. We acknowledge this by proper transformation.
+					// Take the start and the end of the range and transform them by deletion of moved nodes.
+					// Note that if rangeB was inside ChangeOperation range, only difference.end will be transformed.
+					// This nicely covers the joining simplification we did in the previous step.
+					difference.start = difference.start.getTransformedByDeletion( b.sourcePosition, b.howMany );
+					difference.end = difference.end.getTransformedByDeletion( b.sourcePosition, b.howMany );
+
+					// MoveOperation pastes nodes into target position. We acknowledge this by proper transformation.
+					// Note that since we operate on transformed difference range, we should transform by
+					// previously transformed target position.
+					// Note that we do not use Position.getTransformedByMove on range boundaries because we need to
+					// transform by insertion a range as a whole, since newTargetPosition might be inside that range.
+					ranges = difference.getTransformedByInsertion( newTargetPosition, b.howMany, false ).reverse();
+				}
+
+				if ( common !== null ) {
+					// Here we do not need to worry that newTargetPosition is inside moved range, because that
+					// would mean that the MoveOperation targets into itself, and that is incorrect operation.
+					// Instead, we calculate the new position of that part of original range.
+					common.start = common.start._getCombined( b.sourcePosition, newTargetPosition );
+					common.end = common.end._getCombined( b.sourcePosition, newTargetPosition );
+
+					ranges.push( common );
+				}
+
+				// Map transformed range(s) to operations and return them.
+				return ranges.map( ( range ) => {
+					return new ChangeOperation(
+						range,
+						a.oldAttr,
+						a.newAttr,
+						a.baseVersion
+					);
+				} );
+			}
+		},
+
+		MoveOperation: {
+			// Transforms MoveOperation `a` by InsertOperation `b`. Accepts a flag stating whether `a` is more important
+			// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
+			InsertOperation( a, b, isStrong ) {
+				// Get target position from the state "after" nodes are inserted by InsertOperation.
+				const newTargetPosition = a.targetPosition.getTransformedByInsertion( b.position, b.nodeList.length, !isStrong );
+
+				// Create range from MoveOperation properties and transform it by insertion as well.
+				const rangeB = Range.createFromPositionAndOffset( a.sourcePosition, a.howMany );
+				const ranges = rangeB.getTransformedByInsertion( b.position, b.nodeList.length, true );
+
+				// Map transformed range(s) to operations and return them.
+				return ranges.reverse().map( ( range ) => {
+					return new MoveOperation(
+						range.start,
+						newTargetPosition.clone(),
+						range.end.offset - range.start.offset,
+						a.baseVersion
+					);
+				} );
+			},
+
+			ChangeOperation: doNotUpdate,
+
+			// Transforms MoveOperation `a` by MoveOperation `b`. Accepts a flag stating whether `a` is more important
+			// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
+			MoveOperation( a, b, isStrong ) {
+				// Special case when both move operations' target positions are inside nodes that are
+				// being moved by the other move operation. So in other words, we move ranges into inside of each other.
+				// This case can't be solved reasonably (on the other hand, it should not happen often).
+				if ( moveTargetIntoMovedRange( a, b ) && moveTargetIntoMovedRange( b, a ) ) {
+					// Instead of transforming operation, we return a reverse of the operation that we transform by.
+					// So when the results of this "transformation" will be applied, `b` MoveOperation will get reversed.
+					return [ b.getReversed() ];
+				}
+
+				// Create ranges from MoveOperations properties.
+				const rangeA = Range.createFromPositionAndOffset( a.sourcePosition, a.howMany );
+				const rangeB = Range.createFromPositionAndOffset( b.sourcePosition, b.howMany );
+
+				// Special case when transformed range contains both the other operation's whole range and target.
+				// In such case, operations are not really conflicting and we should leave transformed operation as it is.
+				// Without this we would have 3 or 4 operations and the transformation result would probably be not intuitive.
+				if ( rangeA.containsRange( rangeB ) && rangeA.containsPosition( b.targetPosition ) ) {
+					return [ a.clone() ];
+				}
+				// Mirror situation for the case above - now transformed range is wholly contained in the other
+				// operation's range and also targets to that range. Without this special treatment we would
+				// transform this operation into NoOperation, but this would not be compatible with the result
+				// generated by the special case above.
+				else if ( rangeB.containsRange( rangeA ) && rangeB.containsPosition( a.targetPosition ) ) {
+					return [
+						new MoveOperation(
+							a.sourcePosition._getCombined( b.sourcePosition, b.targetPosition ),
+							a.targetPosition._getCombined( b.sourcePosition, b.targetPosition ),
+							a.howMany,
+							a.baseVersion
+						)
+					];
+				}
+
+				// All the other non-special cases are treated by generic algorithm below.
+
+				const differenceSet = rangeA.getDifference( rangeB );
+				const common = rangeA.getIntersection( rangeB );
+
+				// This will aggregate transformed ranges.
+				let ranges = [];
+
+				// Get target position from the state "after" nodes specified by other MoveOperation are "detached".
+				const moveTargetPosition = b.targetPosition.getTransformedByDeletion( b.sourcePosition, b.howMany );
+
+				// First, we take care of that part of the range that is only modified by transformed operation.
+				for ( let i = 0; i < differenceSet.length; i++ ) {
+					// MoveOperation removes nodes from their original position. We acknowledge this by proper transformation.
+					// Take the start and the end of the range and transform them by deletion of moved nodes.
+					differenceSet[ i ].start = differenceSet[ i ].start.getTransformedByDeletion( b.sourcePosition, b.howMany );
+					differenceSet[ i ].end = differenceSet[ i ].end.getTransformedByDeletion( b.sourcePosition, b.howMany );
+
+					// MoveOperation pastes nodes into target position. We acknowledge this by proper transformation.
+					// Note that since we operate on transformed difference range, we should transform by
+					// previously transformed target position.
+					// Note that we do not use Position.getTransformedByMove on range boundaries because we need to
+					// transform by insertion a range as a whole, since newTargetPosition might be inside that range.
+					ranges = ranges.concat( differenceSet[ i ].getTransformedByInsertion( moveTargetPosition, b.howMany, true ) );
+				}
+
+				// Then, we have to manage the common part of both move ranges.
+				// If MoveOperations has common range it can be one of two:
+				// * on the same tree level - it means that we move the same nodes into different places
+				// * on deeper tree level - it means that we move nodes that are inside moved nodes
+				// The operations are conflicting only if they try to move exactly same nodes, so only in the first case.
+				// So, we will handle common range if it is "deeper" or if transformed operation is more important.
+				let isDeeper = utils.compareArrays( b.sourcePosition.getParentPath(), a.sourcePosition.getParentPath() ) == utils.compareArrays.PREFIX;
+
+				if ( common !== null && ( isDeeper || isStrong ) ) {
+					// Here we do not need to worry that newTargetPosition is inside moved range, because that
+					// would mean that the MoveOperation targets into itself, and that is incorrect operation.
+					// Instead, we calculate the new position of that part of original range.
+					common.start = common.start._getCombined( b.sourcePosition, moveTargetPosition );
+					common.end = common.end._getCombined( b.sourcePosition, moveTargetPosition );
+
+					// We have to take care of proper range order.
+					// Note that both push, splice and unshift do the same if there are no ranges in the array.
+					if ( rangeB.end.isAfter( rangeA.end ) ) {
+						ranges.push( common );
+					} else if ( rangeB.start.isBefore( rangeA.start ) ) {
+						ranges.unshift( common );
+					} else {
+						ranges.splice( 1, 0, common );
+					}
+				}
+
+				// At this point we transformed this operation's source ranges it means that nothing should be changed.
+				// But since we need to return an instance of Operation we return an array with NoOperation.
+				if ( ranges.length === 0 ) {
+					return [ new NoOperation( a.baseVersion ) ];
+				}
+
+				// Target position also could be affected by the other MoveOperation. We will transform it.
+				let newTargetPosition = a.targetPosition.getTransformedByMove( b.sourcePosition, moveTargetPosition, b.howMany, !isStrong );
+
+				// Map transformed range(s) to operations and return them.
+				return ranges.reverse().map( ( range ) => {
+					return new MoveOperation(
+						range.start,
+						newTargetPosition,
+						range.end.offset - range.start.offset,
+						a.baseVersion
+					);
+				} );
+			}
+		}
+	};
+
+	return ( a, b, isStrong ) => {
+		let group;
+		let algorithm;
+
+		if ( a instanceof InsertOperation ) {
+			group = ot.InsertOperation;
+		} else if ( a instanceof ChangeOperation ) {
+			group = ot.ChangeOperation;
+		} else if ( a instanceof MoveOperation ) {
+			group = ot.MoveOperation;
+		} else {
+			algorithm = doNotUpdate;
+		}
+
+		if ( group ) {
+			if ( b instanceof InsertOperation ) {
+				algorithm = group.InsertOperation;
+			} else if ( b instanceof ChangeOperation ) {
+				algorithm = group.ChangeOperation;
+			} else if ( b instanceof MoveOperation ) {
+				algorithm = group.MoveOperation;
+			} else {
+				algorithm = doNotUpdate;
+			}
+		}
+
+		let transformed = algorithm( a, b, isStrong );
+
+		return updateBaseVersions( a.baseVersion, transformed );
+	};
+
+	// When we don't want to update an operation, we create and return a clone of it.
+	// Returns the operation in "unified format" - wrapped in an Array.
+	function doNotUpdate( operation ) {
+		return [ operation.clone() ];
+	}
+
+	// Takes an Array of operations and sets consecutive base versions for them, starting from given base version.
+	// Returns the passed array.
+	function updateBaseVersions( baseVersion, operations ) {
+		for ( let i = 0; i < operations.length; i++ ) {
+			operations[ i ].baseVersion = baseVersion + i + 1;
+		}
+
+		return operations;
+	}
+
+	// Checks whether MoveOperation targetPosition is inside a node from the moved range of the other MoveOperation.
+	function moveTargetIntoMovedRange( a, b ) {
+		return a.targetPosition.getTransformedByDeletion( b.sourcePosition, b.howMany ) === null;
+	}
+
+	// Takes two ChangeOperations and checks whether their attributes are in conflict.
+	// This happens when both operations changes an attribute with the same key and they either set different
+	// values for this attribute or one of them removes it while the other one sets it.
+	// Returns true if attributes are in conflict.
+	function haveConflictingAttributes( a, b ) {
+		// Keeping in mind that newAttr or oldAttr might be null.
+		// We will retrieve the key from whichever parameter is set.
+		const keyA = ( a.newAttr || a.oldAttr ).key;
+		const keyB = ( b.newAttr || b.oldAttr ).key;
+
+		if ( keyA != keyB ) {
+			// Different keys - not conflicting.
+			return false;
+		}
+
+		if ( a.newAttr === null && b.newAttr === null ) {
+			// Both remove the attribute - not conflicting.
+			return false;
+		}
+
+		// Check if they set different value or one of them removes the attribute.
+		return ( a.newAttr === null && b.newAttr !== null ) ||
+			( a.newAttr !== null && b.newAttr === null ) ||
+			( !a.newAttr.isEqual( b.newAttr ) );
+	}
+
+	// Gets an array of Ranges and produces one Range out of it. The root of a new range will be same as
+	// the root of the first range in the array. If any of given ranges has different root than the first range,
+	// it will be discarded.
+	function joinRanges( ranges ) {
+		if ( ranges.length === 0 ) {
+			return null;
+		} else if ( ranges.length == 1 ) {
+			return ranges[ 0 ];
+		} else {
+			ranges[ 0 ].end = ranges[ ranges.length - 1 ].end;
+
+			return ranges[ 0 ];
+		}
+	}
+} );

+ 352 - 49
packages/ckeditor5-utils/src/document/position.js

@@ -5,7 +5,12 @@
 
 'use strict';
 
-CKEDITOR.define( [ 'utils', 'ckeditorerror' ], ( utils, CKEditorError ) => {
+CKEDITOR.define( [ 'document/rootelement', 'utils', 'ckeditorerror' ], ( RootElement, utils, CKEditorError ) => {
+	const SAME = 0;
+	const AFTER = 1;
+	const BEFORE = 2;
+	const DIFFERENT = 3;
+
 	/**
 	 * Position in the tree. Position is always located before or after a node.
 	 * See {@link #path} property for more information.
@@ -17,7 +22,7 @@ CKEDITOR.define( [ 'utils', 'ckeditorerror' ], ( utils, CKEditorError ) => {
 		 * Creates a position.
 		 *
 		 * @param {Array} path Position path. See {@link #path} property for more information.
-		 * @param {document.Element} root Root element for the path. Note that this element can not have a parent.
+		 * @param {document.RootElement} root Root element for the path. Note that this element can not have a parent.
 		 * @constructor
 		 */
 		constructor( path, root ) {
@@ -40,30 +45,42 @@ CKEDITOR.define( [ 'utils', 'ckeditorerror' ], ( utils, CKEditorError ) => {
 			 */
 			this.path = path;
 
+			if ( !( root instanceof RootElement ) ) {
+				/**
+				 * Position root has to be an instance of RootElement.
+				 *
+				 * @error position-root-not-rootelement
+				 * @param root
+				 */
+				throw new CKEditorError( 'position-root-not-rootelement: Position root has to be an instance of RootElement.', { root: root } );
+			}
+
 			/**
 			 * Root element for the path. Note that this element can not have a parent.
 			 *
-			 * @type {document.Element}
+			 * @type {document.RootElement}
 			 */
 			this.root = root;
 		}
 
 		/**
-		 * Parent element of the position. The position is located at {@link #offset} in this element.
+		 * Node directly after the position.
 		 *
 		 * @readonly
-		 * @property {document.Element} parent
+		 * @property {document.Node}
 		 */
-		get parent() {
-			let parent = this.root;
-
-			let i, len;
-
-			for ( i = 0, len = this.path.length - 1; i < len; i++ ) {
-				parent = parent.getChild( this.path[ i ] );
-			}
+		get nodeAfter() {
+			return this.parent.getChild( this.offset ) || null;
+		}
 
-			return parent;
+		/**
+		 * Node directly before the position.
+		 *
+		 * @readonly
+		 * @type {document.Node}
+		 */
+		get nodeBefore() {
+			return this.parent.getChild( this.offset - 1 ) || null;
 		}
 
 		/**
@@ -77,33 +94,72 @@ CKEDITOR.define( [ 'utils', 'ckeditorerror' ], ( utils, CKEditorError ) => {
 		}
 
 		/**
-		 * Node directly before the position.
+		 * Sets offset in the parent, which is the last element of the path.
+		 */
+		set offset( newOffset ) {
+			this.path[ this.path.length - 1 ] = newOffset;
+		}
+
+		/**
+		 * Parent element of the position. The position is located at {@link #offset} in this element.
 		 *
 		 * @readonly
-		 * @type {document.Node}
+		 * @property {document.Element} parent
 		 */
-		get nodeBefore() {
-			return this.parent.getChild( this.offset - 1 ) || null;
+		get parent() {
+			let parent = this.root;
+
+			let i, len;
+
+			for ( i = 0, len = this.path.length - 1; i < len; i++ ) {
+				parent = parent.getChild( this.path[ i ] );
+			}
+
+			return parent;
 		}
 
 		/**
-		 * Node directly after the position.
+		 * Creates and returns a new instance of {@link document.Position}
+		 * which is equal to this {@link document.Position position}.
 		 *
-		 * @readonly
-		 * @property {document.Node}
+		 * @returns {document.Position} Cloned {@link document.Position position}.
 		 */
-		get nodeAfter() {
-			return this.parent.getChild( this.offset ) || null;
+		clone() {
+			return new Position( this.path.slice(), this.root );
 		}
 
 		/**
-		 * Two positions equal if paths are equal.
+		 * Checks whether this position is before or after given position.
 		 *
-		 * @param {document.Position} otherPosition Position to compare.
-		 * @returns {Boolean} True if positions equal.
+		 * @param {document.Position} otherPosition Position to compare with.
+		 * @returns {Number} A flag indicating whether this position is {@link #BEFORE} or
+		 * {@link #AFTER} or {@link #SAME} as given position. If positions are in different roots,
+		 * {@link #DIFFERENT} flag is returned.
 		 */
-		isEqual( otherPosition ) {
-			return utils.isEqual( this.path, otherPosition.path );
+		compareWith( otherPosition ) {
+			if ( this.root != otherPosition.root ) {
+				return DIFFERENT;
+			}
+
+			const result = utils.compareArrays( this.path, otherPosition.path );
+
+			switch ( result ) {
+				case utils.compareArrays.SAME:
+					return SAME;
+
+				case utils.compareArrays.PREFIX:
+					return BEFORE;
+
+				case utils.compareArrays.EXTENSION:
+					return AFTER;
+
+				default:
+					if ( this.path[ result ] < otherPosition.path[ result ] ) {
+						return BEFORE;
+					} else {
+						return AFTER;
+					}
+			}
 		}
 
 		/**
@@ -118,38 +174,174 @@ CKEDITOR.define( [ 'utils', 'ckeditorerror' ], ( utils, CKEditorError ) => {
 		}
 
 		/**
-		 * Creates a new position from the parent element and the offset in that element.
+		 * 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.
 		 *
-		 * @param {document.Element} parent Position parent element.
-		 * @param {Number} offset Position offset.
-		 * @returns {document.Position}
+		 * @param {document.Position} deletePosition Position before the first removed node.
+		 * @param {Number} howMany How many nodes are removed.
+		 * @returns {document.Position|null} Transformed position or `null`.
 		 */
-		static createFromParentAndOffset( parent, offset ) {
-			const path = parent.getPath();
+		getTransformedByDeletion( deletePosition, howMany ) {
+			let transformed = this.clone();
 
-			path.push( offset );
+			// This position can't be affected if deletion was in a different root.
+			if ( this.root != deletePosition.root ) {
+				return transformed;
+			}
 
-			return new Position( path, parent.root );
+			if ( utils.compareArrays( deletePosition.getParentPath(), this.getParentPath() ) == utils.compareArrays.SAME ) {
+				// If nodes are removed from the node that is pointed by this position...
+				if ( deletePosition.offset < this.offset ) {
+					// And are removed from before an offset of that position...
+					// Decrement the offset accordingly.
+					if ( deletePosition.offset + howMany > this.offset ) {
+						transformed.offset = deletePosition.offset;
+					} else {
+						transformed.offset -= howMany;
+					}
+				}
+			} else if ( utils.compareArrays( deletePosition.getParentPath(), this.getParentPath() ) == utils.compareArrays.PREFIX ) {
+				// If nodes are removed from a node that is on a path to this position...
+				const i = deletePosition.path.length - 1;
+
+				if ( deletePosition.offset < this.path[ i ] ) {
+					// And are removed from before next node of that path...
+					if ( deletePosition.offset + howMany > this.path[ i ] ) {
+						// If the next node of that path is removed return null
+						// because the node containing this position got removed.
+						return null;
+					} else {
+						// Otherwise, decrement index on that path.
+						transformed.path[ i ] -= howMany;
+					}
+				}
+			}
+
+			return transformed;
 		}
 
 		/**
-		 * Creates a new position before the given node.
+		 * Returns this position after being updated by inserting `howMany` nodes at `insertPosition`.
 		 *
-		 * @param {document.node} node Node the position should be directly before.
-		 * @returns {document.Position}
+		 * @param {document.Position} insertPosition Position where nodes are inserted.
+		 * @param {Number} howMany How many nodes are inserted.
+		 * @param {Boolean} insertBefore Flag indicating whether nodes are inserted before or after `insertPosition`.
+		 * This is important only when `insertPosition` and this position are same. If that is the case and the flag is
+		 * set to `true`, this position will get transformed. If the flag is set to `false`, it won't.
+		 * @returns {document.Position} Transformed position.
 		 */
-		static createBefore( node ) {
-			if ( !node.parent ) {
-				/**
-				 * You can not make position before root.
-				 *
-				 * @error position-before-root
-				 * @param {document.Node} root
-				 */
-				throw new CKEditorError( 'position-before-root: You can not make position before root.', { root: node } );
+		getTransformedByInsertion( insertPosition, howMany, insertBefore ) {
+			let transformed = this.clone();
+
+			// This position can't be affected if insertion was in a different root.
+			if ( this.root != insertPosition.root ) {
+				return transformed;
 			}
 
-			return Position.createFromParentAndOffset( node.parent, node.getIndex() );
+			if ( utils.compareArrays( insertPosition.getParentPath(), this.getParentPath() ) == utils.compareArrays.SAME ) {
+				// If nodes are inserted in the node that is pointed by this position...
+				if ( insertPosition.offset < this.offset || ( insertPosition.offset == this.offset && insertBefore ) ) {
+					// And are inserted before an offset of that position...
+					// "Push" this positions offset.
+					transformed.offset += howMany;
+				}
+			} else if ( utils.compareArrays( insertPosition.getParentPath(), this.getParentPath() ) == utils.compareArrays.PREFIX ) {
+				// If nodes are inserted in a node that is on a path to this position...
+				const i = insertPosition.path.length - 1;
+
+				if ( insertPosition.offset <= this.path[ i ] ) {
+					// And are inserted before next node of that path...
+					// "Push" the index on that path.
+					transformed.path[ i ] += howMany;
+				}
+			}
+
+			return transformed;
+		}
+
+		/**
+		 * Returns this position after being updated by moving `howMany` attributes from `sourcePosition` to `targetPosition`.
+		 *
+		 * @param {document.Position} sourcePosition Position before the first element to move.
+		 * @param {document.Position} targetPosition Position where moved elements will be inserted.
+		 * @param {Number} howMany How many consecutive nodes to move, starting from `sourcePosition`.
+		 * @param {Boolean} insertBefore Flag indicating whether moved nodes are pasted before or after `insertPosition`.
+		 * This is important only when `targetPosition` and this position are same. If that is the case and the flag is
+		 * set to `true`, this position will get transformed by range insertion. If the flag is set to `false`, it won't.
+		 * @returns {document.Position} Transformed position.
+		 */
+		getTransformedByMove( sourcePosition, targetPosition, howMany, insertBefore ) {
+			// Moving a range removes nodes from their original position. We acknowledge this by proper transformation.
+			let transformed = this.getTransformedByDeletion( sourcePosition, howMany );
+
+			if ( transformed !== null ) {
+				// This position is not inside a removed node.
+				// Next step is to reflect pasting nodes, which might further affect the position.
+				transformed = transformed.getTransformedByInsertion( targetPosition, howMany, insertBefore );
+			} else {
+				// This position is inside a removed node. In this case, we are unable to simply transform it by range insertion.
+				// Instead, we calculate a combination of this position, move source position and target position.
+				transformed = this._getCombined( sourcePosition, targetPosition );
+			}
+
+			return transformed;
+		}
+
+		/**
+		 * Checks whether this position is after given position.
+		 *
+		 * **Note:** see {document.Position#isBefore}.
+		 *
+		 * @param {document.Position} otherPosition Position to compare with.
+		 * @returns {Boolean} True if this position is after given position.
+		 */
+		isAfter( otherPosition ) {
+			return this.compareWith( otherPosition ) == AFTER;
+		}
+
+		/**
+		 * Checks whether this position is before given position.
+		 *
+		 * **Note:** watch out when using negation of the value returned by this method, because the negation will also
+		 * be `true` if positions are in different roots and you might not expect this. You should probably use
+		 * `a.isAfter( b ) || a.isEqual( b )` or `!a.isBefore( p ) && a.root == b.root` in most scenarios. If your
+		 * condition uses multiple `isAfter` and `isBefore` checks, build them so they do not use negated values, i.e.:
+		 *
+		 *  if ( a.isBefore( b ) && c.isAfter( d ) ) {
+		 *    // do A.
+		 *  } else {
+		 *    // do B.
+		 *  }
+		 *
+		 * or, if you have only one if-branch:
+		 *
+		 *  if ( !( a.isBefore( b ) && c.isAfter( d ) ) {
+		 *    // do B.
+		 *  }
+		 *
+		 * rather than:
+		 *
+		 *  if ( !a.isBefore( b ) || && !c.isAfter( d ) ) {
+		 *    // do B.
+		 *  } else {
+		 *    // do A.
+		 *  }
+		 *
+		 * @param {document.Position} otherPosition Position to compare with.
+		 * @returns {Boolean} True if this position is before given position.
+		 */
+		isBefore( otherPosition ) {
+			return this.compareWith( otherPosition ) == BEFORE;
+		}
+
+		/**
+		 * Checks whether this position equals given position.
+		 *
+		 * @param {document.Position} otherPosition Position to compare with.
+		 * @returns {Boolean} True if positions are same.
+		 */
+		isEqual( otherPosition ) {
+			return this.compareWith( otherPosition ) == SAME;
 		}
 
 		/**
@@ -171,7 +363,118 @@ CKEDITOR.define( [ 'utils', 'ckeditorerror' ], ( utils, CKEditorError ) => {
 
 			return Position.createFromParentAndOffset( node.parent, node.getIndex() + 1 );
 		}
+
+		/**
+		 * Creates a new position before the given node.
+		 *
+		 * @param {document.node} node Node the position should be directly before.
+		 * @returns {document.Position}
+		 */
+		static createBefore( node ) {
+			if ( !node.parent ) {
+				/**
+				 * You can not make position before root.
+				 *
+				 * @error position-before-root
+				 * @param {document.Node} root
+				 */
+				throw new CKEditorError( 'position-before-root: You can not make position before root.', { root: node } );
+			}
+
+			return Position.createFromParentAndOffset( node.parent, node.getIndex() );
+		}
+
+		/**
+		 * Creates a new position from the parent element and the offset in that element.
+		 *
+		 * @param {document.Element} parent Position parent element.
+		 * @param {Number} offset Position offset.
+		 * @returns {document.Position}
+		 */
+		static createFromParentAndOffset( parent, offset ) {
+			const path = parent.getPath();
+
+			path.push( offset );
+
+			return new Position( path, parent.root );
+		}
+
+		/**
+		 * Returns a new position that is a combination of this position and given positions. The combined
+		 * position is this position transformed by moving a range starting at `from` to `to` position.
+		 * It is expected that this position is inside the moved range.
+		 *
+		 * In other words, this method in a smart way "cuts out" `source` path from this position and
+		 * injects `target` path in it's place, while doing necessary fixes in order to get a correct path.
+		 *
+		 * Example:
+		 *
+		 * 	let original = new Position( [ 2, 3, 1 ], root );
+		 * 	let source = new Position( [ 2, 2 ], root );
+		 * 	let target = new Position( [ 1, 1, 3 ], otherRoot );
+		 * 	let combined = original.getCombined( source, target );
+		 * 	// combined.path is [ 1, 1, 4, 1 ], combined.root is otherRoot
+		 *
+		 * Explanation:
+		 *
+		 * We have a position `[ 2, 3, 1 ]` and move some nodes from `[ 2, 2 ]` to `[ 1, 1, 3 ]`. The original position
+		 * was inside moved nodes and now should point to the new place. The moved nodes will be after
+		 * positions `[ 1, 1, 3 ]`, `[ 1, 1, 4 ]`, `[ 1, 1, 5 ]`. Since our position was in the second moved node,
+		 * the transformed position will be in a sub-tree of a node at `[ 1, 1, 4 ]`. Looking at original path, we
+		 * took care of `[ 2, 3 ]` part of it. Now we have to add the rest of the original path to the transformed path.
+		 * Finally, the transformed position will point to `[ 1, 1, 4, 1 ]`.
+		 *
+		 * @protected
+		 * @param {document.Position} source Beginning of the moved range.
+		 * @param {document.Position} target Position where the range is moved.
+		 * @returns {document.Position} Combined position.
+		 */
+		_getCombined( source, target ) {
+			const i = source.path.length - 1;
+
+			// The first part of a path to combined position is a path to the place where nodes were moved.
+			let combined = target.clone();
+
+			// Then we have to update the rest of the path.
+
+			// Fix the offset because this position might be after `from` position and we have to reflect that.
+			combined.offset = combined.offset + this.path[ i ] - source.offset;
+
+			// Then, add the rest of the path.
+			// If this position is at the same level as `from` position nothing will get added.
+			combined.path = combined.path.concat( this.path.slice( i + 1 ) );
+
+			return combined;
+		}
 	}
 
+	/**
+	 * Flag for "is after" relation between Positions.
+	 *
+	 * @type {Number}
+	 */
+	Position.AFTER = AFTER;
+
+	/**
+	 * Flag for "is before" relation between Positions.
+	 *
+	 * @type {Number}
+	 */
+	Position.BEFORE = BEFORE;
+
+	/**
+	 * Flag for "are in different roots" relation between Positions.
+	 *
+	 * @type {Number}
+	 */
+	Position.DIFFERENT = DIFFERENT;
+
+	/**
+	 * Flag for "are same" relation between Positions.
+	 *
+	 * @type {Number}
+	 */
+	Position.SAME = SAME;
+
 	return Position;
 } );

+ 6 - 6
packages/ckeditor5-utils/src/document/positioniterator.js

@@ -148,31 +148,31 @@ CKEDITOR.define( [
 	}
 
 	/**
-	 * Flag for entering element.
+	 * Flag for character.
 	 *
 	 * @static
 	 * @readonly
 	 * @property {Number}
 	 */
-	PositionIterator.ELEMENT_ENTER = ELEMENT_ENTER;
+	PositionIterator.CHARACTER = CHARACTER;
 
 	/**
-	 * Flag for leaving element.
+	 * Flag for entering element.
 	 *
 	 * @static
 	 * @readonly
 	 * @property {Number}
 	 */
-	PositionIterator.ELEMENT_LEAVE = ELEMENT_LEAVE;
+	PositionIterator.ELEMENT_ENTER = ELEMENT_ENTER;
 
 	/**
-	 * Flag for character.
+	 * Flag for leaving element.
 	 *
 	 * @static
 	 * @readonly
 	 * @property {Number}
 	 */
-	PositionIterator.CHARACTER = CHARACTER;
+	PositionIterator.ELEMENT_LEAVE = ELEMENT_LEAVE;
 
 	return PositionIterator;
 } );

+ 220 - 21
packages/ckeditor5-utils/src/document/range.js

@@ -5,7 +5,7 @@
 
 'use strict';
 
-CKEDITOR.define( [ 'document/positioniterator', 'document/position' ], ( PositionIterator, Position ) => {
+CKEDITOR.define( [ 'document/position', 'document/positioniterator', 'utils' ], ( Position, PositionIterator, utils ) => {
 	/**
 	 * Range class. Range is iterable.
 	 *
@@ -36,29 +36,197 @@ CKEDITOR.define( [ 'document/positioniterator', 'document/position' ], ( Positio
 		}
 
 		/**
-		 * Creates a range inside an element which starts before the first child and ends after the last child.
+		 * Range iterator.
 		 *
-		 * @param {document.Element} element Element which is a parent for the range.
-		 * @returns {document.Range} Created range.
+		 * @see document.PositionIterator
 		 */
-		static createFromElement( element ) {
-			return Range.createFromParentsAndOffsets( element, 0, element, element.getChildCount() );
+		[ Symbol.iterator ]() {
+			return new PositionIterator( this );
 		}
 
 		/**
-		 * Creates a range from given parents and offsets.
+		 * Creates and returns a new instance of {@link document.Range}
+		 * which is equal to this {@link document.Range range}.
 		 *
-		 * @param {document.Element} startElement Start position parent element.
-		 * @param {Number} startOffset Start position offset.
-		 * @param {document.Element} endElement End position parent element.
-		 * @param {Number} endOffset End position offset.
-		 * @returns {document.Range} Created range.
+		 * @returns {document.Position} Cloned {@link document.Range range}.
 		 */
-		static createFromParentsAndOffsets( startElement, startOffset, endElement, endOffset ) {
-			return new Range(
-					Position.createFromParentAndOffset( startElement, startOffset ),
-					Position.createFromParentAndOffset( endElement, endOffset )
-				);
+		clone() {
+			return new Range( this.start.clone(), this.end.clone() );
+		}
+
+		/**
+		 * Checks whether this contains given {@link document.Position position}.
+		 *
+		 * @param {document.Position} position Position to check.
+		 * @returns {Boolean} True if given {@link document.Position position} is contained.
+		 */
+		containsPosition( position ) {
+			return position.isAfter( this.start ) && position.isBefore( this.end );
+		}
+
+		/**
+		 * Checks whether this range contains given {@link document.Range range}.
+		 *
+		 * @param {document.Range} otherRange Range to check.
+		 * @returns {Boolean} True if given {@link document.Range range} boundaries are contained by this range.
+		 */
+		containsRange( otherRange ) {
+			return this.containsPosition( otherRange.start ) && this.containsPosition( otherRange.end );
+		}
+
+		/**
+		 * Gets a part of this {@link document.Range range} which is not a part of given {@link document.Range range}. Returned
+		 * array contains zero, one or two {@link document.Range ranges}.
+		 *
+		 * Examples:
+		 *
+		 * 	let range = new Range( new Position( [ 2, 7 ], root ), new Position( [ 4, 0, 1 ], root ) );
+		 * 	let otherRange = new Range( new Position( [ 1 ], root ), new Position( [ 5 ], root ) );
+		 * 	let transformed = range.getDifference( otherRange );
+		 * 	// transformed array has no ranges because `otherRange` contains `range`
+		 *
+		 * 	otherRange = new Range( new Position( [ 1 ], root ), new Position( [ 3 ], root ) );
+		 * 	transformed = range.getDifference( otherRange );
+		 * 	// transformed array has one range: from [ 3 ] to [ 4, 0, 1 ]
+		 *
+		 * 	otherRange = new Range( new Position( [ 3 ], root ), new Position( [ 4 ], root ) );
+		 * 	transformed = range.getDifference( otherRange );
+		 * 	// transformed array has two ranges: from [ 2, 7 ] to [ 3 ] and from [ 4 ] to [ 4, 0, 1 ]
+		 *
+		 * @param {document.Range} otherRange Range to differentiate against.
+		 * @returns {Array.<document.Range>} The difference between ranges.
+		 */
+		getDifference( otherRange ) {
+			const ranges = [];
+
+			if ( this.start.isBefore( otherRange.end ) && this.end.isAfter( otherRange.start ) ) {
+				// 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.clone(),
+							otherRange.start.clone()
+						)
+					);
+				}
+
+				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.clone(),
+							this.end.clone()
+						)
+					);
+				}
+			} else {
+				// Ranges do not intersect, return the original range.
+				ranges.push( this.clone() );
+			}
+
+			return ranges;
+		}
+
+		/**
+		 * Returns an intersection of this {@link document.Range range} and given {@link document.Range range}. Intersection
+		 * is a common part of both of those ranges. If ranges has no common part, returns `null`.
+		 *
+		 * Examples:
+		 *
+		 * 	let range = new Range( new Position( [ 2, 7 ], root ), new Position( [ 4, 0, 1 ], root ) );
+		 * 	let otherRange = new Range( new Position( [ 1 ], root ), new Position( [ 2 ], root ) );
+		 * 	let transformed = range.getIntersection( otherRange ); // null - ranges have no common part
+		 *
+		 * 	otherRange = new Range( new Position( [ 3 ], root ), new Position( [ 5 ], root ) );
+		 * 	transformed = range.getIntersection( otherRange ); // range from [ 3 ] to [ 4, 0, 1 ]
+		 *
+		 * @param {document.Range} otherRange Range to check for intersection.
+		 * @returns {document.Range|null} A common part of given ranges or null if ranges have no common part.
+		 */
+		getIntersection( otherRange ) {
+			if ( this.start.isBefore( otherRange.end ) && this.end.isAfter( otherRange.start ) ) {
+				// 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.clone(), commonRangeEnd.clone() );
+			}
+
+			// Ranges do not intersect, so they do not have common part.
+			return null;
+		}
+
+		/**
+		 * Returns an array containing one or two {document.Range ranges} that are a result of transforming this
+		 * {@link document.Range range} by inserting `howMany` nodes at `insertPosition`. Two {@link document.Range ranges} are
+		 * returned if the insertion was inside this {@link document.Range range}.
+		 *
+		 * Examples:
+		 *
+		 * 	let range = new Range( new Position( [ 2, 7 ], root ), new Position( [ 4, 0, 1 ], root ) );
+		 * 	let transformed = range.getTransformedByInsertion( new Position( [ 1 ], root ), 2 );
+		 * 	// transformed array has one range from [ 4, 7 ] to [ 6, 0, 1 ]
+		 *
+		 * 	transformed = range.getTransformedByInsertion( new Position( [ 3, 2 ], root ), 4 );
+		 * 	// transformed array has two ranges: from [ 2, 7 ] to [ 3, 2 ] and from [ 3, 6 ] to [ 4, 0, 1 ]
+		 *
+		 * 	transformed = range.getTransformedByInsertion( new Position( [ 3, 2 ], root ), 4, true );
+		 * 	// transformed array has one range which is equal to `range`. This is because of spreadOnlyOnSameLevel flag.
+		 *
+		 * @param {document.Position} insertPosition Position where nodes are inserted.
+		 * @param {Number} howMany How many nodes are inserted.
+		 * @param {Boolean} spreadOnlyOnSameLevel Flag indicating whether this {document.Range range} should be spread
+		 * if insertion was inside a node from this {document.Range range} but not in the range itself.
+		 * @returns {Array.<document.Range>} Result of the transformation.
+		 */
+		getTransformedByInsertion( insertPosition, howMany, spreadOnlyOnSameLevel ) {
+			// Flag indicating whether this whole range and given insertPosition is on the same tree level.
+			const areOnSameLevel = utils.compareArrays( this.start.getParentPath(), this.end.getParentPath() ) == utils.compareArrays.SAME &&
+				utils.compareArrays( this.start.getParentPath(), insertPosition.getParentPath() ) == utils.compareArrays.SAME;
+
+			if ( this.containsPosition( insertPosition ) && ( !spreadOnlyOnSameLevel || areOnSameLevel ) ) {
+				// Range has to be spread. The first part is from original start to the spread point.
+				// The other part is from spread point to the original end, but transformed by
+				// insertion to reflect insertion changes.
+
+				return [
+					new Range(
+						this.start.clone(),
+						insertPosition.clone()
+					),
+					new Range(
+						insertPosition.getTransformedByInsertion( insertPosition, howMany, true ),
+						this.end.getTransformedByInsertion( insertPosition, howMany, true )
+					)
+				];
+			} else {
+				// If insertion is not inside the range, simply transform range boundaries (positions) by the insertion.
+				// Both, one or none of them might be affected by the insertion.
+
+				const range = this.clone();
+
+				range.start = range.start.getTransformedByInsertion( insertPosition, howMany, true );
+				range.end = range.end.getTransformedByInsertion( insertPosition, howMany, false );
+
+				return [ range ];
+			}
 		}
 
 		/**
@@ -72,12 +240,43 @@ CKEDITOR.define( [ 'document/positioniterator', 'document/position' ], ( Positio
 		}
 
 		/**
-		 * Range iterator.
+		 * Creates a range inside an element which starts before the first child and ends after the last child.
 		 *
-		 * @see document.PositionIterator
+		 * @param {document.Element} element Element which is a parent for the range.
+		 * @returns {document.Range} Created range.
 		 */
-		[ Symbol.iterator ]() {
-			return new PositionIterator( this );
+		static createFromElement( element ) {
+			return Range.createFromParentsAndOffsets( element, 0, element, element.getChildCount() );
+		}
+
+		/**
+		 * Creates a new range spreading from specified position to the same position moved by given offset.
+		 *
+		 * @param {document.Position} position Beginning of the range.
+		 * @param {Number} offset How long the range should be.
+		 * @returns {document.Range}
+		 */
+		static createFromPositionAndOffset( position, offset ) {
+			let endPosition = position.clone();
+			endPosition.offset += offset;
+
+			return new Range( position, endPosition );
+		}
+
+		/**
+		 * Creates a range from given parents and offsets.
+		 *
+		 * @param {document.Element} startElement Start position parent element.
+		 * @param {Number} startOffset Start position offset.
+		 * @param {document.Element} endElement End position parent element.
+		 * @param {Number} endOffset End position offset.
+		 * @returns {document.Range} Created range.
+		 */
+		static createFromParentsAndOffsets( startElement, startOffset, endElement, endOffset ) {
+			return new Range(
+				Position.createFromParentAndOffset( startElement, startOffset ),
+				Position.createFromParentAndOffset( endElement, endOffset )
+			);
 		}
 	}
 

+ 13 - 17
packages/ckeditor5-utils/src/utils.js

@@ -55,18 +55,21 @@ CKEDITOR.define( [ 'utils-lodash', 'lib/lodash/lodash-ckeditor' ], ( lodashInclu
 
 		/**
 		 * Compares how given arrays relate to each other. One array can be: same as another array, prefix of another array
-		 * or completely different.
+		 * or completely different. If arrays are different, first index at which they differ is returned. Otherwise,
+		 * a flag specifying the relation is returned. Flags are negative numbers, so whenever a number >= 0 is returned
+		 * it means that arrays differ.
 		 *
 		 *   compareArrays( [ 0, 2 ], [ 0, 2 ] ); // SAME
 		 *   compareArrays( [ 0, 2 ], [ 0, 2, 1 ] ); // PREFIX
 		 *   compareArrays( [ 0, 2 ], [ 0 ] ); // EXTENSION
-		 *   compareArrays( [ 0, 2 ], [ 1, 2 ] ); // DIFFERENT
+		 *   compareArrays( [ 0, 2 ], [ 1, 2 ] ); // 0
+		 *   compareArrays( [ 0, 2 ], [ 0, 1 ] ); // 1
 		 *
 		 * @param {Array} a Array that is compared.
 		 * @param {Array} b Array to compare with.
-		 * @returns {Number} How array `a` is related to array `b`. Represented by one of flags:
-		 * `a` is {@link utils.compareArrays#SAME same}, `a` is a {@link utils.compareArrays#PREFIX prefix),
-		 * `a` is a {@link utils.compareArrays#EXTENSION extension}, or `a` is {@link utils.compareArrays#DIFFERENT different}.
+		 * @returns {Number} An index at which arrays differ, or if they do not differ, how array `a` is related to array `b`.
+		 * This is represented by one of flags: `a` is {@link utils.compareArrays#SAME same}, `a` is
+		 * a {@link utils.compareArrays#PREFIX prefix) or `a` is an {@link utils.compareArrays#EXTENSION extension}.
 		 */
 		compareArrays( a, b ) {
 			const minLen = Math.min( a.length, b.length );
@@ -74,7 +77,7 @@ CKEDITOR.define( [ 'utils-lodash', 'lib/lodash/lodash-ckeditor' ], ( lodashInclu
 			for ( let i = 0; i < minLen; i++ ) {
 				if ( a[ i ] != b[ i ] ) {
 					// The arrays are different.
-					return utils.compareArrays.DIFFERENT;
+					return i;
 				}
 			}
 
@@ -86,7 +89,7 @@ CKEDITOR.define( [ 'utils-lodash', 'lib/lodash/lodash-ckeditor' ], ( lodashInclu
 				// Compared array is shorter so it is a prefix of the other array.
 				return utils.compareArrays.PREFIX;
 			} else {
-				// Compared array is longer so it is a suffix of the other array.
+				// Compared array is longer so it is an extension of the other array.
 				return utils.compareArrays.EXTENSION;
 			}
 		},
@@ -115,28 +118,21 @@ CKEDITOR.define( [ 'utils-lodash', 'lib/lodash/lodash-ckeditor' ], ( lodashInclu
 	 *
 	 * @type {Number}
 	 */
-	utils.compareArrays.SAME = 0;
+	utils.compareArrays.SAME = -1;
 
 	/**
 	 * Flag for "is a prefix of" relation between arrays.
 	 *
 	 * @type {Number}
 	 */
-	utils.compareArrays.PREFIX = 1;
+	utils.compareArrays.PREFIX = -2;
 
 	/**
 	 * Flag for "is a suffix of" relation between arrays.
 	 *
 	 * @type {number}
 	 */
-	utils.compareArrays.EXTENSION = 2;
-
-	/**
-	 * Flag for "is different than" relation between arrays.
-	 *
-	 * @type {Number}
-	 */
-	utils.compareArrays.DIFFERENT = 3;
+	utils.compareArrays.EXTENSION = -3;
 
 	// Extend "utils" with Lo-Dash methods.
 	for ( let i = 0; i < lodashIncludes.length; i++ ) {

+ 1 - 1
packages/ckeditor5-utils/tests/document/document.js

@@ -31,7 +31,7 @@ describe( 'Document', () => {
 	} );
 
 	describe( 'constructor', () => {
-		it( 'should create Document with no data', () => {
+		it( 'should create Document with no data and empty graveyard', () => {
 			expect( document ).to.have.property( 'roots' ).that.is.instanceof( Map );
 			expect( document.roots.size ).to.equal( 1 );
 			expect( document._graveyard ).to.be.instanceof( RootElement );

+ 52 - 39
packages/ckeditor5-utils/tests/document/operation/changeoperation.js

@@ -4,6 +4,7 @@
  */
 
 /* bender-tags: document */
+/* global describe, before, beforeEach, it, expect */
 
 /* bender-include: ../../_tools/tools.js */
 
@@ -18,13 +19,12 @@ const modules = bender.amd.require(
 	'document/range',
 	'document/character',
 	'document/attribute',
-	'document/nodelist',
 	'document/text',
 	'ckeditorerror'
 );
 
 describe( 'ChangeOperation', () => {
-	let Document, ChangeOperation, Position, Range, Character, Attribute, NodeList, Text, CKEditorError;
+	let Document, ChangeOperation, Position, Range, Character, Attribute, Text, CKEditorError;
 
 	before( () => {
 		Document = modules[ 'document/document' ];
@@ -33,7 +33,6 @@ describe( 'ChangeOperation', () => {
 		Range = modules[ 'document/range' ];
 		Character = modules[ 'document/character' ];
 		Attribute = modules[ 'document/attribute' ];
-		NodeList = modules[ 'document/nodelist' ];
 		Text = modules[ 'document/text' ];
 		CKEditorError = modules.ckeditorerror;
 	} );
@@ -263,18 +262,16 @@ describe( 'ChangeOperation', () => {
 
 		root.insertChildren( 0, 'x' );
 
-		expect(
-			() => {
-				doc.applyOperation(
-					new ChangeOperation(
-						new Range( new Position( [ 0 ], root ), new Position( [ 1 ], root ) ),
-						fooAttr,
-						null,
-						doc.version
-					)
-				);
-			}
-		).to.throw( CKEditorError, /operation-change-no-attr-to-remove/ );
+		expect( () => {
+			doc.applyOperation(
+				new ChangeOperation(
+					new Range( new Position( [ 0 ], root ), new Position( [ 1 ], root ) ),
+					fooAttr,
+					null,
+					doc.version
+				)
+			);
+		} ).to.throw( CKEditorError, /operation-change-no-attr-to-remove/ );
 	} );
 
 	it( 'should throw an error when one try to insert and the attribute already exists', () => {
@@ -283,18 +280,16 @@ describe( 'ChangeOperation', () => {
 
 		root.insertChildren( 0, new Character( 'x', [ x1Attr ] ) );
 
-		expect(
-			() => {
-				doc.applyOperation(
-					new ChangeOperation(
-						new Range( new Position( [ 0 ], root ), new Position( [ 1 ], root ) ),
-						null,
-						x2Attr,
-						doc.version
-					)
-				);
-			}
-		).to.throw( CKEditorError, /operation-change-attr-exists/ );
+		expect( () => {
+			doc.applyOperation(
+				new ChangeOperation(
+					new Range( new Position( [ 0 ], root ), new Position( [ 1 ], root ) ),
+					null,
+					x2Attr,
+					doc.version
+				)
+			);
+		} ).to.throw( CKEditorError, /operation-change-attr-exists/ );
 	} );
 
 	it( 'should throw an error when one try to change and the new and old attributes have different keys', () => {
@@ -303,17 +298,35 @@ describe( 'ChangeOperation', () => {
 
 		root.insertChildren( 0, 'x' );
 
-		expect(
-			() => {
-				doc.applyOperation(
-					new ChangeOperation(
-						new Range( new Position( [ 0 ], root ), new Position( [ 1 ], root ) ),
-						fooAttr,
-						barAttr,
-						doc.version
-					)
-				);
-			}
-		).to.throw( CKEditorError, /operation-change-different-keys/ );
+		expect( () => {
+			doc.applyOperation(
+				new ChangeOperation(
+					new Range( new Position( [ 0 ], root ), new Position( [ 1 ], root ) ),
+					fooAttr,
+					barAttr,
+					doc.version
+				)
+			);
+		} ).to.throw( CKEditorError, /operation-change-different-keys/ );
+	} );
+
+	it( 'should create operation with the same parameters when cloned', () => {
+		let range = new Range( new Position( [ 0 ], root ), new Position( [ 1 ], root ) );
+		let oldAttr = new Attribute( 'foo', 'old' );
+		let newAttr = new Attribute( 'foo', 'bar' );
+		let baseVersion = doc.version;
+
+		let op = new ChangeOperation( range, oldAttr, newAttr, baseVersion );
+
+		let clone = op.clone();
+
+		// New instance rather than a pointer to the old instance.
+		expect( clone ).not.to.be.equal( op );
+
+		expect( clone ).to.be.instanceof( ChangeOperation );
+		expect( clone.range.isEqual( range ) ).to.be.true;
+		expect( clone.oldAttr.isEqual( oldAttr ) ).to.be.true;
+		expect( clone.newAttr.isEqual( newAttr ) ).to.be.true;
+		expect( clone.baseVersion ).to.equal( baseVersion );
 	} );
 } );

+ 28 - 1
packages/ckeditor5-utils/tests/document/operation/insertoperation.js

@@ -4,11 +4,14 @@
  */
 
 /* bender-tags: document */
+/* global describe, before, beforeEach, it, expect */
 
 'use strict';
 
 const modules = bender.amd.require(
 	'document/document',
+	'document/node',
+	'document/nodelist',
 	'document/operation/insertoperation',
 	'document/operation/removeoperation',
 	'document/position',
@@ -17,10 +20,12 @@ const modules = bender.amd.require(
 );
 
 describe( 'InsertOperation', () => {
-	let Document, InsertOperation, RemoveOperation, Position, Character;
+	let Document, Node, NodeList, InsertOperation, RemoveOperation, Position, Character;
 
 	before( () => {
 		Document = modules[ 'document/document' ];
+		Node = modules[ 'document/node' ];
+		NodeList = modules[ 'document/nodelist' ];
 		InsertOperation = modules[ 'document/operation/insertoperation' ];
 		RemoveOperation = modules[ 'document/operation/removeoperation' ];
 		Position = modules[ 'document/position' ];
@@ -157,4 +162,26 @@ describe( 'InsertOperation', () => {
 		expect( doc.version ).to.equal( 2 );
 		expect( root.getChildCount() ).to.equal( 0 );
 	} );
+
+	it( 'should create operation with the same parameters when cloned', () => {
+		let position = new Position( [ 0 ], root );
+		let nodeA = new Node();
+		let nodeB = new Node();
+		let nodes = new NodeList( [ nodeA, nodeB ] );
+		let baseVersion = doc.version;
+
+		let op = new InsertOperation( position, nodes, baseVersion );
+
+		let clone = op.clone();
+
+		// New instance rather than a pointer to the old instance.
+		expect( clone ).not.to.be.equal( op );
+
+		expect( clone ).to.be.instanceof( InsertOperation );
+		expect( clone.position.isEqual( position ) ).to.be.true;
+		expect( clone.nodeList.get( 0 ) ).to.equal( nodeA );
+		expect( clone.nodeList.get( 1 ) ).to.equal( nodeB );
+		expect( clone.nodeList.length ).to.equal( 2 );
+		expect( clone.baseVersion ).to.equal( baseVersion );
+	} );
 } );

+ 34 - 29
packages/ckeditor5-utils/tests/document/operation/moveoperation.js

@@ -4,6 +4,7 @@
  */
 
 /* bender-tags: document */
+/* global describe, before, beforeEach, it, expect */
 
 'use strict';
 
@@ -114,8 +115,8 @@ describe( 'MoveOperation', () => {
 		expect( reverse ).to.be.an.instanceof( MoveOperation );
 		expect( reverse.baseVersion ).to.equal( 1 );
 		expect( reverse.howMany ).to.equal( nodeList.length );
-		expect( reverse.sourcePosition ).to.equal( targetPosition );
-		expect( reverse.targetPosition ).to.equal( sourcePosition );
+		expect( reverse.sourcePosition.isEqual( targetPosition ) ).to.be.true;
+		expect( reverse.targetPosition.isEqual( sourcePosition ) ).to.be.true;
 	} );
 
 	it( 'should undo move node by applying reverse operation', () => {
@@ -151,18 +152,14 @@ describe( 'MoveOperation', () => {
 	it( 'should throw an error if number of nodes to move exceeds the number of existing nodes in given element', () => {
 		root.insertChildren( 0, 'xbarx' );
 
-		expect(
-			() => {
-				doc.applyOperation(
-					new MoveOperation(
-						new Position( [ 3 ], root ),
-						new Position( [ 1 ], root ),
-						3,
-						doc.version
-					)
-				);
-			}
-		).to.throw( CKEditorError, /operation-move-nodes-do-not-exist/ );
+		let operation = new MoveOperation(
+			new Position( [ 3 ], root ),
+			new Position( [ 1 ], root ),
+			3,
+			doc.version
+		);
+
+		expect( () => doc.applyOperation( operation ) ).to.throw( CKEditorError, /operation-move-nodes-do-not-exist/ );
 	} );
 
 	it( 'should throw an error if target or source parent-element specified by position does not exist', () => {
@@ -179,11 +176,7 @@ describe( 'MoveOperation', () => {
 
 		root.removeChildren( 2, 1 );
 
-		expect(
-			() => {
-				doc.applyOperation( operation );
-			}
-		).to.throw( CKEditorError, /operation-move-position-invalid/ );
+		expect( () => doc.applyOperation( operation ) ).to.throw( CKEditorError, /operation-move-position-invalid/ );
 	} );
 
 	it( 'should throw an error if operation tries to move a range between the beginning and the end of that range', () => {
@@ -196,11 +189,7 @@ describe( 'MoveOperation', () => {
 			doc.version
 		);
 
-		expect(
-			() => {
-				doc.applyOperation( operation );
-			}
-		).to.throw( CKEditorError, /operation-move-range-into-itself/ );
+		expect( () => doc.applyOperation( operation ) ).to.throw( CKEditorError, /operation-move-range-into-itself/ );
 	} );
 
 	it( 'should throw an error if operation tries to move a range into a sub-tree of a node that is in that range', () => {
@@ -214,11 +203,7 @@ describe( 'MoveOperation', () => {
 			doc.version
 		);
 
-		expect(
-			() => {
-				doc.applyOperation( operation );
-			}
-		).to.throw( CKEditorError, /operation-move-node-into-itself/ );
+		expect( () => doc.applyOperation( operation ) ).to.throw( CKEditorError, /operation-move-node-into-itself/ );
 	} );
 
 	it( 'should not throw an error if operation move a range into a sibling', () => {
@@ -242,4 +227,24 @@ describe( 'MoveOperation', () => {
 		expect( p.getChildCount() ).to.equal( 1 );
 		expect( p.getChild( 0 ).character ).to.equal( 'b' );
 	} );
+
+	it( 'should create operation with the same parameters when cloned', () => {
+		let sourcePosition = new Position( [ 0 ], root );
+		let targetPosition = new Position( [ 1 ], root );
+		let howMany = 4;
+		let baseVersion = doc.version;
+
+		let op = new MoveOperation( sourcePosition, targetPosition, howMany, baseVersion );
+
+		let clone = op.clone();
+
+		// New instance rather than a pointer to the old instance.
+		expect( clone ).not.to.be.equal( op );
+
+		expect( clone ).to.be.instanceof( MoveOperation );
+		expect( clone.sourcePosition.isEqual( sourcePosition ) ).to.be.true;
+		expect( clone.targetPosition.isEqual( targetPosition ) ).to.be.true;
+		expect( clone.howMany ).to.equal( howMany );
+		expect( clone.baseVersion ).to.equal( baseVersion );
+	} );
 } );

+ 48 - 0
packages/ckeditor5-utils/tests/document/operation/nooperation.js

@@ -0,0 +1,48 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: document */
+
+'use strict';
+
+const modules = bender.amd.require(
+	'document/document',
+	'document/operation/nooperation'
+);
+
+describe( 'NoOperation', () => {
+	let Document, NoOperation;
+
+	before( function() {
+		Document = modules[ 'document/document' ];
+		NoOperation = modules[ 'document/operation/nooperation' ];
+	} );
+
+	let noop, doc, root;
+
+	beforeEach( () => {
+		noop = new NoOperation( 0 );
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+	} );
+
+	it( 'should not throw an error when applied', () => {
+		expect( () => doc.applyOperation( noop ) ).to.not.throw( Error );
+	} );
+
+	it( 'should create a do-nothing operation as a reverse', () => {
+		const reverse = noop.getReversed();
+
+		expect( reverse ).to.be.an.instanceof( NoOperation );
+		expect( reverse.baseVersion ).to.equal( 1 );
+	} );
+
+	it( 'should create a do-nothing operation having same parameters when cloned', () => {
+		const clone = noop.clone();
+
+		expect( clone ).to.be.an.instanceof( NoOperation );
+		expect( clone.baseVersion ).to.equal( 0 );
+	} );
+} );

+ 2369 - 0
packages/ckeditor5-utils/tests/document/operation/transform.js

@@ -0,0 +1,2369 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* jshint expr: true */
+
+/* bender-tags: document */
+/* global describe, before, beforeEach, it, expect */
+
+'use strict';
+
+const modules = bender.amd.require(
+	'document/rootelement',
+	'document/node',
+	'document/position',
+	'document/range',
+	'document/attribute',
+	'document/operation/transform',
+	'document/operation/insertoperation',
+	'document/operation/changeoperation',
+	'document/operation/moveoperation',
+	'document/operation/nooperation'
+);
+
+describe( 'transform', () => {
+	let RootElement, Node, Position, Range, Attribute, InsertOperation, ChangeOperation, MoveOperation, NoOperation;
+	let transform;
+
+	before( () => {
+		RootElement = modules[ 'document/rootelement' ];
+		Node = modules[ 'document/node' ];
+		Position = modules[ 'document/position' ];
+		Range = modules[ 'document/range' ];
+		Attribute = modules[ 'document/attribute' ];
+		InsertOperation = modules[ 'document/operation/insertoperation' ];
+		ChangeOperation = modules[ 'document/operation/changeoperation' ];
+		MoveOperation = modules[ 'document/operation/moveoperation' ];
+		NoOperation = modules[ 'document/operation/nooperation' ];
+
+		transform = modules[ 'document/operation/transform' ];
+	} );
+
+	let root, op, nodeA, nodeB, expected, baseVersion;
+
+	beforeEach( () => {
+		root = new RootElement( null );
+
+		nodeA = new Node();
+		nodeB = new Node();
+
+		baseVersion = 0;
+	} );
+
+	function expectOperation( op, params ) {
+		for ( let i in params ) {
+			if ( params.hasOwnProperty( i ) ) {
+				if ( i == 'type' ) {
+					expect( op ).to.be.instanceof( params[ i ] );
+				}
+				else if ( params[ i ] instanceof Array ) {
+					expect( op[ i ].length ).to.equal( params[ i ].length );
+
+					for ( let j = 0; j < params[ i ].length; j++ ) {
+						expect( op[ i ][ j ] ).to.equal( params[ i ][ j ] );
+					}
+				} else if ( params[ i ] instanceof Position || params[ i ] instanceof Range ) {
+					expect( op[ i ].isEqual( params[ i ] ) ).to.be.true;
+				} else {
+					expect( op[ i ] ).to.equal( params[ i ] );
+				}
+			}
+		}
+	}
+
+	describe( 'InsertOperation', () => {
+		let nodeC, nodeD, position;
+
+		beforeEach( () => {
+			nodeC = new Node();
+			nodeD = new Node();
+
+			position = new Position( [ 0, 2, 1 ], root );
+
+			op = new InsertOperation( position, [ nodeA, nodeB ], baseVersion );
+
+			expected = {
+				type: InsertOperation,
+				position: position.clone(),
+				baseVersion: baseVersion + 1
+			};
+		} );
+
+		describe( 'by InsertOperation', () => {
+			it( 'target at different position: no position update', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 1, 3, 2 ], root ),
+					[ nodeC, nodeD ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target at offset before: increment offset', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 0, 2, 0 ], root ),
+					[ nodeC, nodeD ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+				expected.position.offset += 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target at same offset and is important: increment offset', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 0, 2, 1 ], root ),
+					[ nodeC, nodeD ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+				expected.position.offset += 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target at same offset and is less important: no position update', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 0, 2, 1 ], root ),
+					[ nodeC, nodeD ],
+					baseVersion
+				);
+
+				let transOp = transform( transformBy, true );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target at offset after: no position update', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 0, 2, 2 ], root ),
+					[ nodeC, nodeD ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target before node from path: increment index on path', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 0, 1 ], root ),
+					[ nodeC, nodeD ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+				expected.position.path[ 1 ] += 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target after node from path: no position update', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 0, 6 ], root ),
+					[ nodeC, nodeD ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by ChangeOperation', () => {
+			it( 'no position update', () => {
+				let rangeStart = position.clone();
+				let rangeEnd = position.clone();
+				rangeEnd.offset += 2;
+
+				let transformBy = new ChangeOperation(
+					new Range( rangeStart, rangeEnd ),
+					null,
+					new Attribute( 'foo', 'bar' ),
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by MoveOperation', () => {
+			it( 'range and target are different than insert position: no position update', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 1, 3, 2 ], root ),
+					new Position( [ 2, 1 ], root ),
+					2
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range offset is before insert position offset: decrement offset', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 0, 2, 0 ], root ),
+					new Position( [ 1, 1 ], root ),
+					1
+				);
+
+				let transOp = transform( op, transformBy );
+				expected.position.offset--;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range offset is after insert position offset: no position update', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 0, 2, 4 ], root ),
+					new Position( [ 1, 1 ], root ),
+					1
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target offset before insert position offset: increment offset', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 1, 1 ], root ),
+					new Position( [ 0, 2, 0 ], root ),
+					2
+				);
+
+				let transOp = transform( op, transformBy );
+				expected.position.offset += 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target offset after insert position offset: no position update', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 1, 1 ], root ),
+					new Position( [ 0, 2, 4 ], root ),
+					2
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target offset same as insert position offset and is important: increment offset', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 1, 1 ], root ),
+					new Position( [ 0, 2, 1 ], root ),
+					2
+				);
+
+				let transOp = transform( op, transformBy );
+				expected.position.offset += 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target offset same as insert position offset and is less important: no position update', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 1, 1 ], root ),
+					new Position( [ 0, 2, 1 ], root ),
+					2
+				);
+
+				let transOp = transform( op, transformBy, true );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range is before node from insert position path: decrement index on path', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 0, 0 ], root ),
+					new Position( [ 1, 0 ], root ),
+					2
+				);
+
+				let transOp = transform( op, transformBy );
+				expected.position.path[ 1 ] -= 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range is after node from insert position path: no position update', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 0, 4 ], root ),
+					new Position( [ 1, 0 ], root ),
+					2
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target before node from insert position path: increment index on path', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 1, 0 ], root ),
+					new Position( [ 0, 0 ], root ),
+					2
+				);
+
+				let transOp = transform( op, transformBy );
+				expected.position.path[ 1 ] += 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target after node from insert position path: no position update', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 1, 0 ], root ),
+					new Position( [ 0, 4 ], root ),
+					2
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range has node that contains insert position: update position', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 0, 1 ], root ),
+					new Position( [ 1, 1 ], root ),
+					2
+				);
+
+				let transOp = transform( op, transformBy );
+				expected.position.path = [ 1, 2, 1 ];
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range contains insert position (on same level): set position offset to range start', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 0, 2, 0 ], root ),
+					new Position( [ 1, 0 ], root ),
+					3
+				);
+
+				let transOp = transform( op, transformBy );
+				expected.position.offset = 0;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by NoOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new NoOperation( baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+	} );
+
+	describe( 'ChangeOperation', () => {
+		let start, end, range, oldAttr, newAttr, anotherOldAttr, anotherNewAttr;
+
+		beforeEach( () => {
+			oldAttr = new Attribute( 'foo', 'abc' );
+			newAttr = new Attribute( 'foo', 'bar' );
+
+			anotherOldAttr = new Attribute( oldAttr.key, 'another' );
+			anotherNewAttr = new Attribute( oldAttr.key, 'anothernew' );
+
+			expected = {
+				type: ChangeOperation,
+				oldAttr: oldAttr,
+				newAttr: newAttr,
+				baseVersion: baseVersion + 1
+			};
+		} );
+
+		describe( 'with multi-level range', () => {
+			beforeEach( () => {
+				start = new Position( [ 1, 2 ], root );
+				end = new Position( [ 2, 2, 4 ], root );
+
+				range = new Range( start, end );
+
+				op = new ChangeOperation( range, oldAttr, newAttr, baseVersion );
+
+				expected.range = new Range( start.clone(), end.clone() );
+			} );
+
+			describe( 'by InsertOperation', () => {
+				it( 'target at different position: no operation update', () => {
+					let transformBy = new InsertOperation(
+						new Position( [ 3, 3, 2 ], root ),
+						[ nodeA, nodeB ],
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'target at offset before: increment offset', () => {
+					let transformBy = new InsertOperation(
+						new Position( [ 1, 1 ], root ),
+						[ nodeA, nodeB ],
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expected.range.start.offset += 2;
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'target at same offset: increment offset', () => {
+					let transformBy = new InsertOperation(
+						new Position( [ 1, 2 ], root ),
+						[ nodeA, nodeB ],
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expected.range.start.offset += 2;
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'target at offset after: no operation update', () => {
+					let transformBy = new InsertOperation(
+						new Position( [ 3, 2 ], root ),
+						[ nodeA, nodeB ],
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'target before node from path: increment index on path', () => {
+					let transformBy = new InsertOperation(
+						new Position( [ 0 ], root ),
+						[ nodeA, nodeB ],
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expected.range.start.path[ 0 ] += 2;
+					expected.range.end.path[ 0 ] += 2;
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'target after node from path: no position change', () => {
+					let transformBy = new InsertOperation(
+						new Position( [ 2, 6 ], root ),
+						[ nodeA, nodeB ],
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'target inside change range: split into two operations', () => {
+					let transformBy = new InsertOperation(
+						new Position( [ 1, 3, 1 ], root ),
+						[ nodeA, nodeB ],
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 2 );
+
+					expected.range.start.path = [ 1, 3, 3 ];
+
+					expectOperation( transOp[ 0 ], expected );
+
+					expected.range.start = op.range.start;
+					expected.range.end.path = [ 1, 3, 1 ];
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 1 ], expected );
+				} );
+			} );
+
+			describe( 'by ChangeOperation', () => {
+				it( 'attributes have different key: no operation update', () => {
+					let transformBy = new ChangeOperation(
+						range.clone(),
+						new Attribute( 'abc', true ),
+						new Attribute( 'abc', false ),
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'attributes set same value: no operation update', () => {
+					let transformBy = new ChangeOperation(
+						range.clone(),
+						oldAttr,
+						newAttr,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'both operations removes attribute: no operation update', () => {
+					op.newAttr = null;
+
+					let transformBy = new ChangeOperation(
+						new Range( new Position( [ 1, 1, 4 ], root ), new Position( [ 3 ], root ) ),
+						anotherOldAttr,
+						null,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy, true );
+
+					expected.newAttr = null;
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				describe( 'that is less important and', () => {
+					it( 'range does not intersect original range: no operation update', () => {
+						let transformBy = new ChangeOperation(
+							new Range( new Position( [ 3, 0 ], root ), new Position( [ 4 ], root ) ),
+							anotherOldAttr,
+							null,
+							baseVersion
+						);
+
+						let transOp = transform( op, transformBy, true );
+
+						expect( transOp.length ).to.equal( 1 );
+						expectOperation( transOp[ 0 ], expected );
+					} );
+
+					it( 'range contains original range: update oldAttr', () => {
+						let transformBy = new ChangeOperation(
+							new Range( new Position( [ 1, 1, 4 ], root ), new Position( [ 3 ], root ) ),
+							anotherOldAttr,
+							null,
+							baseVersion
+						);
+
+						let transOp = transform( op, transformBy, true );
+
+						expected.oldAttr = anotherOldAttr;
+
+						expect( transOp.length ).to.equal( 1 );
+						expectOperation( transOp[ 0 ], expected );
+					} );
+
+					// [ original range   <   ]   range transformed by >
+					it( 'range intersects on left: split into two operations, update oldAttr', () => {
+						// Get more test cases and better code coverage
+						op.newAttr = null;
+
+						let transformBy = new ChangeOperation(
+							new Range( new Position( [ 1, 4, 2 ], root ), new Position( [ 3 ], root ) ),
+							anotherOldAttr,
+							// Get more test cases and better code coverage
+							anotherNewAttr,
+							baseVersion
+						);
+
+						let transOp = transform( op, transformBy, true );
+
+						expect( transOp.length ).to.equal( 2 );
+
+						expected.newAttr = null;
+
+						expected.range.end.path = [ 1, 4, 2 ];
+
+						expectOperation( transOp[ 0 ], expected );
+
+						expected.range.start.path = [ 1, 4, 2 ];
+						expected.range.end = op.range.end;
+						expected.oldAttr = anotherOldAttr;
+						expected.baseVersion++;
+
+						expectOperation( transOp[ 1 ], expected );
+					} );
+
+					// [  range transformed by  <   ]  original range  >
+					it( 'range intersects on right: split into two operations, update oldAttr', () => {
+						let transformBy = new ChangeOperation(
+							new Range( new Position( [ 1 ], root ), new Position( [ 2, 1 ], root ) ),
+							anotherOldAttr,
+							null,
+							baseVersion
+						);
+
+						let transOp = transform( op, transformBy, true );
+
+						expect( transOp.length ).to.equal( 2 );
+
+						expected.range.start.path = [ 2, 1 ];
+
+						expectOperation( transOp[ 0 ], expected );
+
+						expected.range.start = op.range.start;
+						expected.range.end.path = [ 2, 1 ];
+						expected.oldAttr = anotherOldAttr;
+						expected.baseVersion++;
+
+						expectOperation( transOp[ 1 ], expected );
+					} );
+
+					it( 'range is inside original range: split into three operations, update oldAttr', () => {
+						let transformBy = new ChangeOperation(
+							new Range( new Position( [ 1, 4, 1 ], root ), new Position( [ 2, 1 ], root ) ),
+							anotherOldAttr,
+							null,
+							baseVersion
+						);
+
+						let transOp = transform( op, transformBy, true );
+
+						expect( transOp.length ).to.equal( 3 );
+
+						expected.range.end.path = [ 1, 4, 1 ];
+
+						expectOperation( transOp[ 0 ], expected );
+
+						expected.range.start.path = [ 2, 1 ];
+						expected.range.end = op.range.end;
+						expected.baseVersion++;
+
+						expectOperation( transOp[ 1 ], expected );
+
+						expected.range.start.path = [ 1, 4, 1 ];
+						expected.range.end.path = [ 2, 1 ];
+						expected.oldAttr = anotherOldAttr;
+						expected.baseVersion++;
+
+						expectOperation( transOp[ 2 ], expected );
+					} );
+				} );
+
+				describe( 'that is more important and', () => {
+					it( 'range does not intersect original range: no operation update', () => {
+						let transformBy = new ChangeOperation(
+							new Range( new Position( [ 3, 0 ], root ), new Position( [ 4 ], root ) ),
+							oldAttr,
+							null,
+							baseVersion
+						);
+
+						let transOp = transform( op, transformBy );
+
+						expect( transOp.length ).to.equal( 1 );
+						expectOperation( transOp[ 0 ], expected );
+					} );
+
+					it( 'range contains original range: convert to NoOperation', () => {
+						let transformBy = new ChangeOperation(
+							new Range( new Position( [ 1, 1, 4 ], root ), new Position( [ 3 ], root ) ),
+							oldAttr,
+							null,
+							baseVersion
+						);
+
+						let transOp = transform( op, transformBy );
+
+						expect( transOp.length ).to.equal( 1 );
+						expectOperation( transOp[ 0 ], {
+							type: NoOperation,
+							baseVersion: baseVersion + 1
+						} );
+					} );
+
+					// [ original range   <   ]   range transformed by >
+					it( 'range intersects on left: shrink original range', () => {
+						let transformBy = new ChangeOperation(
+							new Range( new Position( [ 1, 4, 2 ], root ), new Position( [ 3 ], root ) ),
+							oldAttr,
+							null,
+							baseVersion
+						);
+
+						let transOp = transform( op, transformBy );
+
+						expected.range.end.path = [ 1, 4, 2 ];
+
+						expect( transOp.length ).to.equal( 1 );
+						expectOperation( transOp[ 0 ], expected );
+					} );
+
+					// [  range transformed by  <   ]  original range  >
+					it( 'range intersects on right: shrink original range', () => {
+						let transformBy = new ChangeOperation(
+							new Range( new Position( [ 1 ], root ), new Position( [ 2, 1 ], root ) ),
+							oldAttr,
+							null,
+							baseVersion
+						);
+
+						let transOp = transform( op, transformBy );
+
+						expected.range.start.path = [ 2, 1 ];
+
+						expect( transOp.length ).to.equal( 1 );
+						expectOperation( transOp[ 0 ], expected );
+					} );
+
+					it( 'range is inside original range: split into two operations', () => {
+						let transformBy = new ChangeOperation(
+							new Range( new Position( [ 1, 4, 1 ], root ), new Position( [ 2, 1 ], root ) ),
+							oldAttr,
+							null,
+							baseVersion
+						);
+
+						let transOp = transform( op, transformBy );
+
+						expect( transOp.length ).to.equal( 2 );
+
+						expected.range.end.path = [ 1, 4, 1 ];
+
+						expectOperation( transOp[ 0 ], expected );
+
+						expected.range.start.path = [ 2, 1 ];
+						expected.range.end = op.range.end;
+						expected.baseVersion++;
+
+						expectOperation( transOp[ 1 ], expected );
+					} );
+				} );
+			} );
+
+			describe( 'by MoveOperation', () => {
+				it( 'range and target are different than change range: no operation update', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 1, 1, 2 ], root ),
+						new Position( [ 3, 4 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'range offset is before change range start offset: decrement offset', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 1, 0 ], root ),
+						new Position( [ 3, 4, 1 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expected.range.start.offset -= 2;
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'target offset is before change range start offset: increment offset', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 3, 4, 1 ], root ),
+						new Position( [ 1, 0 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expected.range.start.offset += 2;
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'range is before node from path to change range: decrement index on path', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 0 ], root ),
+						new Position( [ 2, 4, 1 ], root ),
+						1,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expected.range.start.path[ 0 ]--;
+					expected.range.end.path[ 0 ]--;
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'range is after node from path to change range: no position change', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 3 ], root ),
+						new Position( [ 0, 1 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'target before node from path to change range: increment index on path', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 3, 4, 1 ], root ),
+						new Position( [ 1, 0 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expected.range.start.path[ 1 ] += 2;
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'target after node from path to change range: no position change', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 3, 4, 1 ], root ),
+						new Position( [ 3 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'range intersects on left with change range: split into two operations', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 1 ], root ),
+						new Position( [ 4 ], root ),
+						3,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 2 );
+
+					expected.range.end.path = [ 2, 1 ];
+
+					expectOperation( transOp[ 0 ], expected );
+
+					expected.range.start.path = [ 4 ];
+					expected.range.end.path = [ 5, 4 ];
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 1 ], expected );
+				} );
+
+				it( 'range intersects on right with change range: split into two operation', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 1, 1 ], root ),
+						new Position( [ 0, 0 ], root ),
+						3,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 2 );
+
+					expected.range.start.path = [ 1, 1 ];
+
+					expectOperation( transOp[ 0 ], expected );
+
+					expected.range.start.path = [ 0, 1 ];
+					expected.range.end.path = [ 0, 3 ];
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 1 ], expected );
+				} );
+
+				it( 'range contains change range: update change range', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 1 ], root ),
+						new Position( [ 3, 4, 1 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expected.range.start.path = [ 1, 4, 1, 2 ];
+					expected.range.end.path = [ 1, 4, 2, 2, 4 ];
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'range is inside change range: split into two operations', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 1, 4 ], root ),
+						new Position( [ 3, 2 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 2 );
+
+					expectOperation( transOp[ 0 ], expected );
+
+					expected.range.start.path = [ 3, 2 ];
+					expected.range.end.path = [ 3, 4 ];
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 1 ], expected );
+				} );
+
+				it( 'target inside change range: split into two operations', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 3, 4, 1 ], root ),
+						new Position( [ 1, 4 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 2 );
+
+					expected.range.start.path = [ 1, 6 ];
+
+					expectOperation( transOp[ 0 ], expected );
+
+					expected.range.start = op.range.start;
+					expected.range.end.path = [ 1, 4 ];
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 1 ], expected );
+				} );
+
+				it( 'range intersects change range and target inside change range: split into three operations', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 1, 1 ], root ),
+						new Position( [ 2 ], root ),
+						3,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 3 );
+
+					expected.range.start.path = [ 5 ];
+					expected.range.end.path = [ 5, 2, 4 ];
+
+					expectOperation( transOp[ 0 ], expected );
+
+					expected.range.start.path = [ 1, 1 ];
+					expected.range.end.path = [ 2 ];
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 1 ], expected );
+
+					expected.range.start.path = [ 3 ];
+					expected.range.end.path = [ 5 ];
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 2 ], expected );
+				} );
+			} );
+
+			describe( 'by NoOperation', () => {
+				it( 'no operation update', () => {
+					let transformBy = new NoOperation( baseVersion );
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+			} );
+		} );
+
+		// Some extra cases for a ChangeOperation that operates on single tree level range.
+		// This means that the change range start and end differs only on offset value.
+		// This test suite also have some modifications to the original operation
+		// to get more test cases covered and better code coverage.
+		describe( 'with single-level range', () => {
+			beforeEach( () => {
+				start = new Position( [ 0, 2, 1 ], root );
+				end = new Position( [ 0, 2, 4 ], root );
+
+				range = new Range( start, end );
+
+				op = new ChangeOperation( range, oldAttr, newAttr, baseVersion );
+
+				expected.range = new Range( start.clone(), end.clone() );
+			} );
+
+			describe( 'by InsertOperation', () => {
+				it( 'target at offset before: increment offset', () => {
+					let transformBy = new InsertOperation(
+						new Position( [ 0, 2, 0 ], root ),
+						[ nodeA, nodeB ],
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expected.range.start.offset += 2;
+					expected.range.end.offset += 2;
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'target at same offset: increment offset', () => {
+					let transformBy = new InsertOperation(
+						new Position( [ 0, 2, 1 ], root ),
+						[ nodeA, nodeB ],
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expected.range.start.offset += 2;
+					expected.range.end.offset += 2;
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+			} );
+
+			describe( 'by MoveOperation', () => {
+				it( 'range offset is before change range start offset: decrement offset', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 0, 2, 0 ], root ),
+						new Position( [ 2, 4, 1 ], root ),
+						1,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expected.range.start.offset--;
+					expected.range.end.offset--;
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'target offset is before change range start offset: increment offset', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 4, 1 ], root ),
+						new Position( [ 0, 2, 0 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expected.range.start.offset += 2;
+					expected.range.end.offset += 2;
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'range intersects on left with change range: split into two operations', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 0, 2, 2 ], root ),
+						new Position( [ 2, 4, 1 ], root ),
+						4,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 2 );
+
+					expected.range.end.offset -= 2;
+
+					expectOperation( transOp[ 0 ], expected );
+
+					expected.range.start.path = [ 2, 4, 1 ];
+					expected.range.end.path = [ 2, 4, 3 ];
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 1 ], expected );
+				} );
+
+				it( 'range intersects on right with change range: split into two operation', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 0, 2, 0 ], root ),
+						new Position( [ 2, 4, 1 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 2 );
+
+					expected.range.start.offset -= 1;
+					expected.range.end.offset -= 2;
+
+					expectOperation( transOp[ 0 ], expected );
+
+					expected.range.start.path = [ 2, 4, 2 ];
+					expected.range.end.path = [ 2, 4, 3 ];
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 1 ], expected );
+				} );
+
+				it( 'range contains change range: update change range', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 0, 1 ], root ),
+						new Position( [ 2, 4, 1 ], root ),
+						3,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expected.range.start.path = [ 2, 4, 2, 1 ];
+					expected.range.end.path = [ 2, 4, 2, 4 ];
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'range is inside change range: split into two operations', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 0, 2, 2 ], root ),
+						new Position( [ 2, 4, 1 ], root ),
+						1,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 2 );
+
+					expected.range.end.offset--;
+
+					expectOperation( transOp[ 0 ], expected );
+
+					expected.range.start.path = [ 2, 4, 1 ];
+					expected.range.end.path = [ 2, 4, 2 ];
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 1 ], expected );
+				} );
+
+				it( 'range is same as change range: update change range', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 0, 2, 1 ], root ),
+						new Position( [ 2, 4, 1 ], root ),
+						3,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expected.range.start.path = [ 2, 4, 1 ];
+					expected.range.end.path = [ 2, 4, 4 ];
+
+					expect( transOp.length ).to.equal( 1 );
+					expectOperation( transOp[ 0 ], expected );
+				} );
+
+				it( 'target inside change range: split into two operations', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 2, 4, 1 ], root ),
+						new Position( [ 0, 2, 2 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 2 );
+
+					expected.range.start.offset = 4;
+					expected.range.end.offset = 6;
+
+					expectOperation( transOp[ 0 ], expected );
+
+					expected.range.start.offset = op.range.start.offset;
+					expected.range.end.offset = 2;
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 1 ], expected );
+				} );
+
+				it( 'range intersects change range and target inside change range: split into three operations', () => {
+					let transformBy = new MoveOperation(
+						new Position( [ 0, 2, 0 ], root ),
+						new Position( [ 0, 2, 3 ], root ),
+						2,
+						baseVersion
+					);
+
+					let transOp = transform( op, transformBy );
+
+					expect( transOp.length ).to.equal( 3 );
+
+					expected.range.start.offset = 3;
+					expected.range.end.offset = 4;
+
+					expectOperation( transOp[ 0 ], expected );
+
+					expected.range.start.offset = 0;
+					expected.range.end.offset = 1;
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 1 ], expected );
+
+					expected.range.start.offset = 2;
+					expected.range.end.offset = 3;
+					expected.baseVersion++;
+
+					expectOperation( transOp[ 2 ], expected );
+				} );
+			} );
+		} );
+	} );
+
+	describe( 'MoveOperation', () => {
+		let sourcePosition, targetPosition, rangeEnd, howMany;
+
+		beforeEach( () => {
+			sourcePosition = new Position( [ 2, 2, 4 ], root );
+			targetPosition = new Position( [ 3, 3, 3 ], root );
+			howMany = 2;
+
+			rangeEnd = sourcePosition.clone();
+			rangeEnd.offset += howMany;
+
+			op = new MoveOperation( sourcePosition, targetPosition, howMany, baseVersion );
+
+			expected = {
+				type: MoveOperation,
+				sourcePosition: sourcePosition.clone(),
+				targetPosition: targetPosition.clone(),
+				howMany: howMany,
+				baseVersion: baseVersion + 1
+			};
+		} );
+
+		describe( 'by InsertOperation', () => {
+			it( 'target at different position than move range and target: no operation update', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 1, 3, 2 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target inside node from move range: no operation update', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 2, 2, 3, 1 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target at offset before range offset: increment range offset', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 2, 2, 0 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expected.sourcePosition.offset += 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target at offset after range offset: no operation update', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 2, 2, 6 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target before node from range start path: increment index on range start path', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 2, 0 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expected.sourcePosition.path[ 1 ] += 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target after node from range start path: no operation update', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 2, 3 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target offset before move target offset: increment target offset', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 3, 3, 2 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expected.targetPosition.offset += 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target offset after move target offset: no operation update', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 3, 3, 4 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target before node from move target position path: increment index on move target position path', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 3, 0 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expected.targetPosition.path[ 1 ] += 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target after node from move target position path: no operation update', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 3, 6 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target offset same as move target offset and is important: increment target offset', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 3, 3, 3 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expected.targetPosition.offset += 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target offset same as move target offset and is less important: no operation update', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 3, 3, 3 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy, true );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target offset same as move target offset and is important: increment target offset', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 3, 3 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expected.targetPosition.path[ 1 ] += 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target inside move range: split into two operations', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 2, 2, 5 ], root ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 2 );
+
+				expected.sourcePosition.path = [ 2, 2, 7 ];
+				expected.howMany = 1;
+
+				expectOperation( transOp[ 0 ], expected );
+
+				expected.sourcePosition.path = [ 2, 2, 4 ];
+				expected.howMany = 1;
+				expected.baseVersion++;
+
+				expectOperation( transOp[ 1 ], expected );
+			} );
+		} );
+
+		describe( 'by ChangeOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new ChangeOperation(
+					new Range( sourcePosition, rangeEnd ),
+					new Attribute( 'abc', true ),
+					new Attribute( 'abc', false ),
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by MoveOperation', () => {
+			it( 'range and target different than transforming range and target: no operation update', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 1, 2 ], root ),
+					new Position( [ 4, 1, 0 ], root ),
+					3,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target offset before transforming range start offset: increment range offset', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 4, 1, 0 ], root ),
+					new Position( [ 2, 2, 0 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expected.sourcePosition.offset += 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target offset after transforming range start offset: no operation update', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 4, 1, 0 ], root ),
+					new Position( [ 2, 2, 7 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range start offset before transforming range start offset: decrement range offset', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 2, 0 ], root ),
+					new Position( [ 4, 1, 0 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expected.sourcePosition.offset -= 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range start offset after transforming range start offset: no operation update', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 2, 9 ], root ),
+					new Position( [ 4, 1, 0 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy, true );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target before node from transforming range start path: increment index on range start path', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 4, 1, 0 ], root ),
+					new Position( [ 2, 1 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expected.sourcePosition.path[ 1 ] += 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target after node from transforming range start path: no operation update', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 4, 1, 0 ], root ),
+					new Position( [ 2, 5 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range before node from transforming range start path: decrement index on range start path', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 0 ], root ),
+					new Position( [ 4, 1, 0 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expected.sourcePosition.path[ 1 ] -= 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range after node from transforming range start path: no operation update', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 3 ], root ),
+					new Position( [ 4, 1, 0 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target offset before transforming target offset: increment target offset', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 4, 1, 0 ], root ),
+					new Position( [ 3, 3, 0 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expected.targetPosition.offset += 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target offset after transforming target offset: no operation update', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 4, 1, 0 ], root ),
+					new Position( [ 3, 3, 5 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range offset before transforming target offset: decrement target offset', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 3, 3, 0 ], root ),
+					new Position( [ 4, 1, 0 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expected.targetPosition.offset -= 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range offset after transforming target offset: no operation update', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 3, 3, 5 ], root ),
+					new Position( [ 4, 1, 0 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target before node from transforming target path: increment index on target path', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 4, 1, 0 ], root ),
+					new Position( [ 3, 1 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expected.targetPosition.path[ 1 ] += 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target after node from transforming target path: no operation update', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 4, 1, 0 ], root ),
+					new Position( [ 3, 5 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range before node from transforming target path: decrement index on target path', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 3, 0 ], root ),
+					new Position( [ 4, 1, 0 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expected.targetPosition.path[ 1 ] -= 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range after node from transforming target path: no operation update', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 3, 5 ], root ),
+					new Position( [ 4, 1, 0 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target inside transforming move range: split into two operations', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 4, 1, 0 ], root ),
+					new Position( [ 2, 2, 5 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 2 );
+
+				expected.howMany = 1;
+				expected.sourcePosition.offset = 7;
+
+				expectOperation( transOp[ 0 ], expected );
+
+				expected.sourcePosition.offset = 4;
+				expected.baseVersion++;
+
+				expectOperation( transOp[ 1 ], expected );
+			} );
+
+			it( 'target inside a node from transforming range: no operation update', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 4, 1, 0 ], root ),
+					new Position( [ 2, 2, 5, 1 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range has node that contains transforming range: update range path', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 1 ], root ),
+					new Position( [ 4, 2 ], root ),
+					3,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expected.sourcePosition.path = [ 4, 3, 4 ];
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range has node that contains transforming target: update target path', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 3, 2 ], root ),
+					new Position( [ 0, 1 ], root ),
+					3,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expected.targetPosition.path = [ 0, 2, 3 ];
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target inside a node from transforming range and vice versa: reverse transform-by operation', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 3, 2 ], root ),
+					new Position( [ 2, 2, 5, 0 ], root ),
+					3,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+				let reversed = transformBy.getReversed();
+
+				expected.sourcePosition = reversed.sourcePosition;
+				expected.targetPosition = reversed.targetPosition;
+				expected.howMany = reversed.howMany;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range is same as transforming range and is important: convert to NoOperation', () => {
+				let transformBy = new MoveOperation(
+					op.sourcePosition.clone(),
+					new Position( [ 4, 1, 0 ], root ),
+					op.howMany,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], {
+					type: NoOperation,
+					baseVersion: baseVersion + 1
+				} );
+			} );
+
+			it( 'range is same as transforming range and is less important: update range path', () => {
+				let transformBy = new MoveOperation(
+					op.sourcePosition.clone(),
+					new Position( [ 4, 1, 0 ], root ),
+					op.howMany,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy, true );
+
+				expected.sourcePosition.path = [ 4, 1, 0 ];
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range contains transforming range and is important: convert to NoOperation', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 2, 3 ], root ),
+					new Position( [ 4, 1, 0 ], root ),
+					4,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], {
+					type: NoOperation,
+					baseVersion: baseVersion + 1
+				} );
+			} );
+
+			it( 'range contains transforming range and is less important: update range path', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 2, 3 ], root ),
+					new Position( [ 4, 1, 0 ], root ),
+					4,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy, true );
+
+				expected.sourcePosition.path = [ 4, 1, 1 ];
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range contains transforming range and target and is important: update range path and target', () => {
+				op.targetPosition.path = [ 2, 2, 7 ];
+
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 2, 3 ], root ),
+					new Position( [ 4, 1, 0 ], root ),
+					5,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+
+				expected.sourcePosition.path = [ 4, 1, 1 ];
+				expected.targetPosition.path = [ 4, 1, 4 ];
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range contains transforming range and target and is less important: update range path and target', () => {
+				op.targetPosition.path = [ 2, 2, 7 ];
+
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 2, 3 ], root ),
+					new Position( [ 4, 1, 0 ], root ),
+					5,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy, true );
+
+				expect( transOp.length ).to.equal( 1 );
+
+				expected.sourcePosition.path = [ 4, 1, 1 ];
+				expected.targetPosition.path = [ 4, 1, 4 ];
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range intersects on left side of transforming range and is important: shrink range', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 2, 3 ], root ),
+					new Position( [ 4, 1, 0 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expected.sourcePosition.path = [ 2, 2, 3 ];
+				expected.howMany = 1;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range intersects on left side of transforming range and is less important: split into two operations', () => {
+				// Get more test cases and better code coverage
+				let otherRoot = new RootElement( null );
+
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 2, 3 ], root ),
+					new Position( [ 4, 1, 0 ], otherRoot ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy, true );
+
+				expect( transOp.length ).to.equal( 2 );
+
+				expected.sourcePosition.path = [ 2, 2, 3 ];
+				expected.howMany = 1;
+
+				expectOperation( transOp[ 0 ], expected );
+
+				expected.sourcePosition = new Position( [ 4, 1, 1 ], otherRoot );
+				expected.baseVersion++;
+
+				expectOperation( transOp[ 1 ], expected );
+			} );
+
+			it( 'range intersects on right side of transforming range and is important: shrink range', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 2, 5 ], root ),
+					new Position( [ 4, 1, 0 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expected.howMany = 1;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range intersects on right side of transforming range and is less important: split into two operations', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 2, 5 ], root ),
+					new Position( [ 4, 1, 0 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy, true );
+
+				expect( transOp.length ).to.equal( 2 );
+
+				expected.sourcePosition.path = [ 4, 1, 0 ];
+				expected.howMany = 1;
+
+				expectOperation( transOp[ 0 ], expected );
+
+				expected.sourcePosition.path = op.sourcePosition.path;
+				expected.baseVersion++;
+
+				expectOperation( transOp[ 1 ], expected );
+			} );
+
+			it( 'range intersects on left side, target inside transforming range and is important: split into two operations', () => {
+				op.howMany = 4;
+
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 2, 3 ], root ),
+					new Position( [ 2, 2, 6 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 2 );
+
+				expected.sourcePosition.path = [ 2, 2, 6 ];
+				expected.howMany = 2;
+
+				expectOperation( transOp[ 0 ], expected );
+
+				expected.sourcePosition.path = [ 2, 2, 3 ];
+				expected.howMany = 1;
+				expected.baseVersion++;
+
+				expectOperation( transOp[ 1 ], expected );
+			} );
+
+			it( 'range intersects on left side, target inside transforming range and is less important: split into three operations', () => {
+				op.howMany = 4;
+
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 2, 3 ], root ),
+					new Position( [ 2, 2, 6 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy, true );
+
+				expect( transOp.length ).to.equal( 3 );
+
+				expected.sourcePosition.path = [ 2, 2, 6 ];
+				expected.howMany = 2;
+
+				expectOperation( transOp[ 0 ], expected );
+
+				expected.sourcePosition.path = [ 2, 2, 3 ];
+				expected.howMany = 1;
+				expected.baseVersion++;
+
+				expectOperation( transOp[ 1 ], expected );
+
+				expected.sourcePosition.path = [ 2, 2, 5 ];
+				expected.howMany = 1;
+				expected.baseVersion++;
+
+				expectOperation( transOp[ 2 ], expected );
+			} );
+
+			it( 'range intersects on right side, target inside transforming range and is important: split into two operations', () => {
+				op.howMany = 4;
+
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 2, 7 ], root ),
+					new Position( [ 2, 2, 5 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 2 );
+
+				expected.sourcePosition.path = [ 2, 2, 7 ];
+				expected.howMany = 2;
+
+				expectOperation( transOp[ 0 ], expected );
+
+				expected.sourcePosition.path = [ 2, 2, 4 ];
+				expected.howMany = 1;
+				expected.baseVersion++;
+
+				expectOperation( transOp[ 1 ], expected );
+			} );
+
+			it( 'range intersects on right side, target inside transforming range and is less important: split into three operations', () => {
+				op.howMany = 4;
+
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 2, 7 ], root ),
+					new Position( [ 2, 2, 5 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy, true );
+
+				expect( transOp.length ).to.equal( 3 );
+
+				expected.sourcePosition.path = [ 2, 2, 5 ];
+				expected.howMany = 1;
+
+				expectOperation( transOp[ 0 ], expected );
+
+				expected.sourcePosition.path = [ 2, 2, 7 ];
+				expected.howMany = 2;
+				expected.baseVersion++;
+
+				expectOperation( transOp[ 1 ], expected );
+
+				expected.sourcePosition.path = [ 2, 2, 4 ];
+				expected.howMany = 1;
+				expected.baseVersion++;
+
+				expectOperation( transOp[ 2 ], expected );
+			} );
+
+			it( 'range inside transforming range and is important: split into two operations', () => {
+				op.howMany = 4;
+
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 2, 5 ], root ),
+					new Position( [ 4, 1, 0 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 2 );
+
+				expected.sourcePosition.path = [ 2, 2, 5 ];
+				expected.howMany = 1;
+
+				expectOperation( transOp[ 0 ], expected );
+
+				expected.sourcePosition.path = [ 2, 2, 4 ];
+				expected.baseVersion++;
+
+				expectOperation( transOp[ 1 ], expected );
+			} );
+
+			it( 'range inside transforming range and is less important: split into three operations', () => {
+				op.howMany = 4;
+
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 2, 5 ], root ),
+					new Position( [ 4, 1, 0 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy, true );
+
+				expect( transOp.length ).to.equal( 3 );
+
+				expected.sourcePosition.path = [ 2, 2, 5 ];
+				expected.howMany = 1;
+
+				expectOperation( transOp[ 0 ], expected );
+
+				expected.sourcePosition.path = [ 4, 1, 0 ];
+				expected.howMany = 2;
+				expected.baseVersion++;
+
+				expectOperation( transOp[ 1 ], expected );
+
+				expected.sourcePosition.path = [ 2, 2, 4 ];
+				expected.howMany = 1;
+				expected.baseVersion++;
+
+				expectOperation( transOp[ 2 ], expected );
+			} );
+
+			it( 'range and target inside transforming range and is important: no operation update', () => {
+				op.howMany = 6;
+
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 2, 5 ], root ),
+					new Position( [ 2, 2, 9 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+
+				expected.howMany = 6;
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'range and target inside transforming range and is less important: no operation update', () => {
+				op.howMany = 6;
+
+				let transformBy = new MoveOperation(
+					new Position( [ 2, 2, 5 ], root ),
+					new Position( [ 2, 2, 9 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy, true );
+
+				expect( transOp.length ).to.equal( 1 );
+
+				expected.howMany = 6;
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by NoOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new NoOperation( baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+	} );
+
+	describe( 'NoOperation', () => {
+		beforeEach( () => {
+			op = new NoOperation( baseVersion );
+
+			expected = {
+				type: NoOperation,
+				baseVersion: baseVersion + 1
+			};
+		} );
+
+		describe( 'by InsertOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new InsertOperation(
+					new Position( [ 0 ], root ),
+					'a',
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by ChangeOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new ChangeOperation(
+					new Range(
+						new Position( [ 0 ], root ),
+						new Position( [ 1 ], root )
+					),
+					new Attribute( 'foo', 'bar' ),
+					new Attribute( 'foo', 'xyz' ),
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by MoveOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new MoveOperation(
+					new Position( [ 0 ], root ),
+					new Position( [ 1 ], root ),
+					2,
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
+		describe( 'by NoOperation', () => {
+			it( 'no operation update', () => {
+				let transformBy = new NoOperation( baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+	} );
+} );

+ 284 - 22
packages/ckeditor5-utils/tests/document/position.js

@@ -19,7 +19,7 @@ const modules = bender.amd.require(
 describe( 'position', () => {
 	let Element, Character, Document, NodeList, Position, CKEditorError;
 
-	let doc, root, p, ul, li1, li2, f, o, z, b, a, r;
+	let doc, root, otherRoot, p, ul, li1, li2, f, o, z, b, a, r;
 
 	// root
 	//  |- p         Before: [ 0 ]       After: [ 1 ]
@@ -43,6 +43,7 @@ describe( 'position', () => {
 		doc = new Document();
 
 		root = doc.createRoot( 'root' );
+		otherRoot = doc.createRoot( 'otherRoot' );
 
 		f = new Character( 'f' );
 		o = new Character( 'o' );
@@ -70,6 +71,16 @@ describe( 'position', () => {
 		expect( position ).to.have.property( 'root' ).that.equals( root );
 	} );
 
+	it( 'should throw error if given root is not a RootElement instance', () => {
+		expect( () => {
+			new Position( [ 0 ] );
+		} ).to.throw( CKEditorError, /position-root-not-rootelement/ );
+
+		expect( () => {
+			new Position( [ 0 ], new Element( 'p' ) );
+		} ).to.throw( CKEditorError, /position-root-not-rootelement/ );
+	} );
+
 	it( 'should make positions form node and offset', () => {
 		expect( Position.createFromParentAndOffset( root, 0 ) ).to.have.property( 'path' ).that.deep.equals( [ 0 ] );
 		expect( Position.createFromParentAndOffset( root, 1 ) ).to.have.property( 'path' ).that.deep.equals( [ 1 ] );
@@ -106,11 +117,9 @@ describe( 'position', () => {
 	} );
 
 	it( 'should throw error if one try to make positions before root', () => {
-		expect(
-			() => {
-				Position.createBefore( root );
-			}
-		).to.throw( CKEditorError, /position-before-root/ );
+		expect( () => {
+			Position.createBefore( root );
+		} ).to.throw( CKEditorError, /position-before-root/ );
 	} );
 
 	it( 'should make positions after elements', () => {
@@ -132,11 +141,9 @@ describe( 'position', () => {
 	} );
 
 	it( 'should throw error if one try to make positions after root', () => {
-		expect(
-			() => {
-				Position.createAfter( root );
-			}
-		).to.throw( CKEditorError, /position-after-root/ );
+		expect( () => {
+			Position.createAfter( root );
+		} ).to.throw( CKEditorError, /position-after-root/ );
 	} );
 
 	it( 'should have parent', () => {
@@ -173,6 +180,14 @@ describe( 'position', () => {
 		expect( new Position( [ 1, 0, 3 ], root ) ).to.have.property( 'offset' ).that.equals( 3 );
 	} );
 
+	it( 'should be able to set offset', () => {
+		let position = new Position( [ 1, 0, 2 ], root );
+		position.offset = 4;
+
+		expect( position.offset ).to.equal( 4 );
+		expect( position.path ).to.deep.equal( [ 1, 0, 4 ] );
+	} );
+
 	it( 'should have nodeBefore', () => {
 		expect( new Position( [ 0 ], root ) ).to.have.property( 'nodeBefore' ).that.is.null;
 		expect( new Position( [ 1 ], root ) ).to.have.property( 'nodeBefore' ).that.equals( p );
@@ -207,23 +222,270 @@ describe( 'position', () => {
 		expect( new Position( [ 1, 0, 3 ], root ) ).to.have.property( 'nodeAfter' ).that.is.null;
 	} );
 
-	it( 'should equals another position with the same path', () => {
-		let position = new Position( [ 1, 1, 2 ], root );
-		let samePosition = new Position( [ 1, 1, 2 ], root );
+	it( 'should have proper parent path', () => {
+		let position = new Position( [ 1, 2, 3 ], root );
 
-		expect( position.isEqual( samePosition ) ).to.be.true;
+		expect( position.getParentPath() ).to.deep.equal( [ 1, 2 ] );
 	} );
 
-	it( 'should not equals another position with the different path', () => {
-		let position = new Position( [ 1, 1, 1 ], root );
-		let differentNode = new Position( [ 1, 2, 2 ], root );
+	it( 'should return a new, equal position when cloned', () => {
+		const position = new Position( [ 1, 2, 3 ], root );
+		const clone = position.clone();
 
-		expect( position.isEqual( differentNode ) ).to.be.false;
+		expect( clone ).not.to.be.equal( position ); // clone is not pointing to the same object as position
+		expect( clone.isEqual( position ) ).to.be.true; // but they are equal in the position-sense
+		expect( clone.path ).not.to.be.equal( position.path ); // make sure the paths are not the same array
 	} );
 
-	it( 'should have proper parent path', () => {
-		let position = new Position( [ 1, 2, 3 ], root );
+	describe( 'isBefore', () => {
+		it( 'should return true if given position has same root and is before this position', () => {
+			let position = new Position( [ 1, 1, 2 ], root );
+			let beforePosition = new Position( [ 1, 0 ], root );
 
-		expect( position.getParentPath() ).to.deep.equal( [ 1, 2 ] );
+			expect( position.isAfter( beforePosition ) ).to.be.true;
+		} );
+
+		it( 'should return false if given position has same root and is not before this position', () => {
+			let position = new Position( [ 1, 1, 2 ], root );
+			let afterPosition = new Position( [ 1, 2 ], root );
+
+			expect( position.isAfter( afterPosition ) ).to.be.false;
+		} );
+
+		it( 'should return false if given position has different root', () => {
+			let position = new Position( [ 1, 1, 2 ], root );
+			let differentPosition = new Position( [ 1, 0 ], otherRoot );
+
+			expect( position.isAfter( differentPosition ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'isEqual', () => {
+		it( 'should return true if given position has same path and root', () => {
+			let position = new Position( [ 1, 1, 2 ], root );
+			let samePosition = new Position( [ 1, 1, 2 ], root );
+
+			expect( position.isEqual( samePosition ) ).to.be.true;
+		} );
+
+		it( 'should return false if given position has different path', () => {
+			let position = new Position( [ 1, 1, 1 ], root );
+			let differentPosition = new Position( [ 1, 2, 2 ], root );
+
+			expect( position.isEqual( differentPosition ) ).to.be.false;
+		} );
+
+		it( 'should return false if given position has different root', () => {
+			let position = new Position( [ 1, 1, 1 ], root );
+			let differentPosition = new Position( [ 1, 1, 1 ], otherRoot );
+
+			expect( position.isEqual( differentPosition ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'isAfter', () => {
+		it( 'should return true if given position has same root and is after this position', () => {
+			let position = new Position( [ 1, 1, 2 ], root );
+			let afterPosition = new Position( [ 1, 2 ], root );
+
+			expect( position.isBefore( afterPosition ) ).to.be.true;
+		} );
+
+		it( 'should return false if given position has same root and is not after this position', () => {
+			let position = new Position( [ 1, 1, 2 ], root );
+			let beforePosition = new Position( [ 1, 0 ], root );
+
+			expect( position.isBefore( beforePosition ) ).to.be.false;
+		} );
+
+		it( 'should return false if given position has different root', () => {
+			let position = new Position( [ 1, 1, 2 ], root );
+			let differentPosition = new Position( [ 1, 2 ], otherRoot );
+
+			expect( position.isBefore( differentPosition ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'compareWith', () => {
+		it( 'should return Position.SAME if positions are same', () => {
+			const position = new Position( [ 1, 2, 3 ], root );
+			const compared = new Position( [ 1, 2, 3 ], root );
+
+			expect( position.compareWith( compared ) ).to.equal( Position.SAME );
+		} );
+
+		it( 'should return Position.BEFORE if the position is before compared one', () => {
+			const position = new Position( [ 1, 2, 3 ], root );
+			const compared = new Position( [ 1, 3 ], root );
+
+			expect( position.compareWith( compared ) ).to.equal( Position.BEFORE );
+		} );
+
+		it( 'should return Position.AFTER if the position is after compared one', () => {
+			const position = new Position( [ 1, 2, 3, 4 ], root );
+			const compared = new Position( [ 1, 2, 3 ], root );
+
+			expect( position.compareWith( compared ) ).to.equal( Position.AFTER );
+		} );
+
+		it( 'should return Position.DIFFERENT if positions are in different roots', () => {
+			const position = new Position( [ 1, 2, 3 ], root );
+			const compared = new Position( [ 1, 2, 3 ], otherRoot );
+
+			expect( position.compareWith( compared ) ).to.equal( Position.DIFFERENT );
+		} );
+	} );
+
+	describe( 'getTransformedByInsertion', () => {
+		it( 'should return a new Position instance', () => {
+			const position = new Position( [ 0 ], root );
+			const transformed = position.getTransformedByInsertion( new Position( [ 2 ], root ), 4, false );
+
+			expect( transformed ).not.to.equal( position );
+			expect( transformed ).to.be.instanceof( Position );
+		} );
+
+		it( 'should increment offset if insertion is in the same parent and closer offset', () => {
+			const position = new Position( [ 1, 2, 3 ], root );
+			const transformed = position.getTransformedByInsertion( new Position( [ 1, 2, 2 ], root ), 2, false );
+
+			expect( transformed.offset ).to.equal( 5 );
+		} );
+
+		it( 'should not increment offset if insertion position is in different root', () => {
+			const position = new Position( [ 1, 2, 3 ], root );
+			const transformed = position.getTransformedByInsertion( new Position( [ 1, 2, 2 ], otherRoot ), 2, false );
+
+			expect( transformed.offset ).to.equal( 3 );
+		} );
+
+		it( 'should not increment offset if insertion is in the same parent and the same offset', () => {
+			const position = new Position( [ 1, 2, 3 ], root );
+			const transformed = position.getTransformedByInsertion( new Position( [ 1, 2, 3 ], root ), 2, false );
+
+			expect( transformed.offset ).to.equal( 3 );
+		} );
+
+		it( 'should increment offset if insertion is in the same parent and the same offset and it is inserted before', () => {
+			const position = new Position( [ 1, 2, 3 ], root );
+			const transformed = position.getTransformedByInsertion( new Position( [ 1, 2, 3 ], root ), 2, true );
+
+			expect( transformed.offset ).to.equal( 5 );
+		} );
+
+		it( 'should not increment offset if insertion is in the same parent and further offset', () => {
+			const position = new Position( [ 1, 2, 3 ], root );
+			const transformed = position.getTransformedByInsertion( new Position( [ 1, 2, 4 ], root ), 2, false );
+
+			expect( transformed.offset ).to.equal( 3 );
+		} );
+
+		it( 'should update path if insertion position parent is a node from that path and offset is before next node on that path', () => {
+			const position = new Position( [ 1, 2, 3 ], root );
+			const transformed = position.getTransformedByInsertion( new Position( [ 1, 2 ], root ), 2, false );
+
+			expect( transformed.path ).to.deep.equal( [ 1, 4, 3 ] );
+		} );
+
+		it( 'should not update path if insertion position parent is a node from that path and offset is after next node on that path', () => {
+			const position = new Position( [ 1, 2, 3 ], root );
+			const transformed = position.getTransformedByInsertion( new Position( [ 1, 3 ], root ), 2, false );
+
+			expect( transformed.path ).to.deep.equal( [ 1, 2, 3 ] );
+		} );
+	} );
+
+	describe( 'getTransformedByDeletion', () => {
+		it( 'should return a new Position instance', () => {
+			const position = new Position( [ 0 ], root );
+			const transformed = position.getTransformedByDeletion( new Position( [ 2 ], root ), 4 );
+
+			expect( transformed ).not.to.equal( position );
+			expect( transformed ).to.be.instanceof( Position );
+		} );
+
+		it( 'should return null if original position is inside one of removed nodes', () => {
+			const position = new Position( [ 1, 2 ], root );
+			const transformed = position.getTransformedByDeletion( new Position( [ 0 ], root ), 2 );
+
+			expect( transformed ).to.be.null;
+		} );
+
+		it( 'should decrement offset if deletion is in the same parent and closer offset', () => {
+			const position = new Position( [ 1, 2, 7 ], root );
+			const transformed = position.getTransformedByDeletion( new Position( [ 1, 2, 2 ], root ), 2 );
+
+			expect( transformed.offset ).to.equal( 5 );
+		} );
+
+		it( 'should set position offset to deletion offset if position is between removed nodes', () => {
+			const position = new Position( [ 1, 2, 4 ], root );
+			const transformed = position.getTransformedByDeletion( new Position( [ 1, 2, 3 ], root ), 5 );
+
+			expect( transformed.offset ).to.equal( 3 );
+		} );
+
+		it( 'should not decrement offset if deletion position is in different root', () => {
+			const position = new Position( [ 1, 2, 3 ], root );
+			const transformed = position.getTransformedByDeletion( new Position( [ 1, 2, 1 ], otherRoot ), 2 );
+
+			expect( transformed.offset ).to.equal( 3 );
+		} );
+
+		it( 'should not decrement offset if deletion is in the same parent and further offset', () => {
+			const position = new Position( [ 1, 2, 3 ], root );
+			const transformed = position.getTransformedByDeletion( new Position( [ 1, 2, 4 ], root ), 2 );
+
+			expect( transformed.offset ).to.equal( 3 );
+		} );
+
+		it( 'should update path if deletion position parent is a node from that path and offset is before next node on that path', () => {
+			const position = new Position( [ 1, 2, 3 ], root );
+			const transformed = position.getTransformedByDeletion( new Position( [ 1, 0 ], root ), 2 );
+
+			expect( transformed.path ).to.deep.equal( [ 1, 0, 3 ] );
+		} );
+
+		it( 'should not update path if deletion position parent is a node from that path and offset is after next node on that path', () => {
+			const position = new Position( [ 1, 2, 3 ], root );
+			const transformed = position.getTransformedByDeletion( new Position( [ 1, 3 ], root ), 2 );
+
+			expect( transformed.path ).to.deep.equal( [ 1, 2, 3 ] );
+		} );
+	} );
+
+	describe( 'getTransformedByMove', () => {
+		it( 'should increment offset if a range was moved to the same parent and closer offset', () => {
+			const position = new Position( [ 1, 2, 3 ], root );
+			const transformed = position.getTransformedByMove( new Position( [ 2 ], root ), new Position( [ 1, 2, 0 ], root ), 3, false );
+
+			expect( transformed.path ).to.deep.equal( [ 1, 2, 6 ] );
+		} );
+
+		it( 'should decrement offset if a range was moved from the same parent and closer offset', () => {
+			const position = new Position( [ 1, 2, 3 ], root );
+			const transformed = position.getTransformedByMove( new Position( [ 1, 2, 0 ], root ), new Position( [ 2 ], root ), 3, false );
+
+			expect( transformed.path ).to.deep.equal( [ 1, 2, 0 ] );
+		} );
+
+		it( 'should update path if a range contained this position', () => {
+			const position = new Position( [ 1, 2, 3 ], root );
+			const transformed = position.getTransformedByMove( new Position( [ 1, 1 ], root ), new Position( [ 2, 1 ], root ), 3, false );
+
+			expect( transformed.path ).to.deep.equal( [ 2, 2, 3 ] );
+		} );
+	} );
+
+	describe( '_getCombined', () => {
+		it( 'should return correct combination of this and given positions', () => {
+			const position = new Position( [ 1, 3, 4, 2 ], root );
+			const sourcePosition = new Position( [ 1, 1 ], root );
+			const targetPosition = new Position( [ 2, 5 ], root );
+
+			const combined = position._getCombined( sourcePosition, targetPosition );
+
+			expect( combined.path ).to.deep.equal( [ 2, 7, 4, 2 ] );
+		} );
 	} );
 } );

+ 193 - 10
packages/ckeditor5-utils/tests/document/range.js

@@ -17,8 +17,7 @@ const modules = bender.amd.require(
 
 describe( 'Range', () => {
 	let Range, Position, Element, Character, Document;
-
-	let start, end;
+	let start, end, root;
 
 	before( () => {
 		Position = modules[ 'document/position' ];
@@ -27,8 +26,11 @@ describe( 'Range', () => {
 		Character = modules[ 'document/character' ];
 		Document = modules[ 'document/document' ];
 
-		start = new Position( [ 0 ] );
-		end = new Position( [ 1 ] );
+		let doc = new Document();
+		root = doc.createRoot( 'root' );
+
+		start = new Position( [ 0 ], root );
+		end = new Position( [ 1 ], root );
 	} );
 
 	let range;
@@ -46,8 +48,8 @@ describe( 'Range', () => {
 
 	describe( 'isEqual', () => {
 		it( 'should return true if the ranges are the same', () => {
-			let sameStart = new Position( [ 0 ] );
-			let sameEnd = new Position( [ 1 ] );
+			let sameStart = new Position( [ 0 ], root );
+			let sameEnd = new Position( [ 1 ], root );
 
 			let sameRange = new Range( sameStart, sameEnd );
 
@@ -57,8 +59,8 @@ describe( 'Range', () => {
 		it( 'should return false if the start position is different', () => {
 			let range = new Range( start, end );
 
-			let diffStart = new Position( [ 1 ] );
-			let sameEnd = new Position( [ 1 ] );
+			let diffStart = new Position( [ 1 ], root );
+			let sameEnd = new Position( [ 1 ], root );
 
 			let diffRange = new Range( diffStart, sameEnd );
 
@@ -66,8 +68,8 @@ describe( 'Range', () => {
 		} );
 
 		it( 'should return false if the end position is different', () => {
-			let sameStart = new Position( [ 0 ] );
-			let diffEnd = new Position( [ 0 ] );
+			let sameStart = new Position( [ 0 ], root );
+			let diffEnd = new Position( [ 0 ], root );
 
 			let diffRange = new Range( sameStart, diffEnd );
 
@@ -114,5 +116,186 @@ describe( 'Range', () => {
 				expect( range.end.path ).to.deep.equal( [ 0, 2 ] );
 			} );
 		} );
+
+		describe( 'createFromPositionAndOffset', () => {
+			it( 'should make range from start position and offset', () => {
+				const position = new Position( [ 1, 2, 3 ], root );
+				const range = Range.createFromPositionAndOffset( position, 4 );
+
+				expect( range ).to.be.instanceof( Range );
+				expect( range.start.isEqual( position ) ).to.be.true;
+				expect( range.end.root ).to.equal( range.start.root );
+				expect( range.end.path ).to.deep.equal( [ 1, 2, 7 ] );
+			} );
+		} );
+	} );
+
+	describe( 'clone', () => {
+		it( 'should return a new, equal position', () => {
+			const clone = range.clone();
+
+			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
+		} );
+	} );
+
+	describe( 'containsPosition', () => {
+		beforeEach( () => {
+			range = new Range( new Position( [ 1 ], root ), new Position( [ 3 ], root ) );
+		} );
+
+		it( 'should return false if position is before range', () => {
+			const position = new Position( [ 0, 4 ], root );
+
+			expect( range.containsPosition( position ) ).to.be.false;
+		} );
+
+		it( 'should return false if position is after range', () => {
+			const position = new Position( [ 3, 0 ], root );
+
+			expect( range.containsPosition( position ) ).to.be.false;
+		} );
+
+		it( 'should return true if position is inside range', () => {
+			const position = new Position( [ 2, 2 ], root );
+
+			expect( range.containsPosition( position ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'getTransformedByInsertion', () => {
+		it( 'should return an array of Range objects', () => {
+			const range = new Range( new Position( [ 0 ], root ), new Position( [ 1 ], root ) );
+			const transformed = range.getTransformedByInsertion( new Position( [ 2 ], root ), 2 );
+
+			expect( transformed ).to.be.instanceof( Array );
+			expect( transformed[ 0 ] ).to.be.instanceof( Range );
+		} );
+
+		it( 'should update it\'s positions offsets if insertion is before range and they are affected', () => {
+			const range = new Range( new Position( [ 3, 2 ], root ), new Position( [ 3, 4 ], root ) );
+			const transformed = range.getTransformedByInsertion( new Position( [ 3, 1 ], root ), 2 );
+
+			expect( transformed[ 0 ].start.offset ).to.equal( 4 );
+			expect( transformed[ 0 ].end.offset ).to.equal( 6 );
+		} );
+
+		it( 'should update it\'s positions paths if insertion is before range and they are affected', () => {
+			const range = new Range( new Position( [ 3, 2 ], root ), new Position( [ 4, 4 ], root ) );
+			const transformed = range.getTransformedByInsertion( new Position( [ 0 ], root ), 2 );
+
+			expect( transformed[ 0 ].start.path[ 0 ] ).to.equal( 5 );
+			expect( transformed[ 0 ].end.path[ 0 ] ).to.equal( 6 );
+		} );
+
+		it( 'should return array with two ranges and updated positions if insertion was in the middle of range', () => {
+			const range = new Range( new Position( [ 3, 2 ], root ), new Position( [ 5, 4 ], root ) );
+			const transformed = range.getTransformedByInsertion( new Position( [ 4, 1, 6 ], root ), 4 );
+
+			expect( transformed.length ).to.equal( 2 );
+
+			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
+			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 4, 1, 6 ] );
+
+			expect( transformed[ 1 ].start.path ).to.deep.equal( [ 4, 1, 10 ] );
+			expect( transformed[ 1 ].end.path ).to.deep.equal( [ 5, 4 ] );
+		} );
+
+		it( 'should not updated positions if insertion is after range', () => {
+			const range = new Range( new Position( [ 3, 2 ], root ), new Position( [ 4, 4 ], root ) );
+			const transformed = range.getTransformedByInsertion( new Position( [ 4, 4 ], root ), 3 );
+
+			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
+			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 4, 4 ] );
+		} );
+	} );
+
+	describe( 'getDifference', () => {
+		let range;
+
+		beforeEach( () => {
+			range = new Range( new Position( [ 3, 2 ], root ), new Position( [ 5, 4 ], root ) );
+		} );
+
+		it( 'should return an array of Range objects', () => {
+			const otherRange = new Range( new Position( [ 6 ], root ), new Position( [ 7 ], root ) );
+			const diff = range.getDifference( otherRange );
+
+			expect( diff ).to.be.instanceof( Array );
+			expect( diff[ 0 ] ).to.be.instanceof( Range );
+		} );
+
+		it( 'should return original range if other range does not intersect with it', () => {
+			const otherRange = new Range( new Position( [ 6 ], root ), new Position( [ 7 ], root ) );
+			const diff = range.getDifference( otherRange );
+
+			expect( diff[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
+			expect( diff[ 0 ].end.path ).to.deep.equal( [ 5, 4 ] );
+		} );
+
+		it( 'should return shrunken range if other range intersects with it', () => {
+			const otherRange = new Range( new Position( [ 4, 1 ], root ), new Position( [ 7 ], root ) );
+			const diff = range.getDifference( otherRange );
+
+			expect( diff[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
+			expect( diff[ 0 ].end.path ).to.deep.equal( [ 4, 1 ] );
+		} );
+
+		it( 'should return an empty array if other range contains or is same as the original range', () => {
+			const otherRange = new Range( new Position( [ 2 ], root ), new Position( [ 6 ], root ) );
+			const diff = range.getDifference( otherRange );
+
+			expect( diff.length ).to.equal( 0 );
+		} );
+
+		it( 'should two ranges if other range is contained by the original range', () => {
+			const otherRange = new Range( new Position( [ 3, 7 ], root ), new Position( [ 4, 1 ], root ) );
+			const diff = range.getDifference( otherRange );
+
+			expect( diff.length ).to.equal( 2 );
+
+			expect( diff[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
+			expect( diff[ 0 ].end.path ).to.deep.equal( [ 3, 7 ] );
+
+			expect( diff[ 1 ].start.path ).to.deep.equal( [ 4, 1 ] );
+			expect( diff[ 1 ].end.path ).to.deep.equal( [ 5, 4 ] );
+		} );
+	} );
+
+	describe( 'getIntersection', () => {
+		let range;
+
+		beforeEach( () => {
+			range = new Range( new Position( [ 3, 2 ], root ), new Position( [ 5, 4 ], root ) );
+		} );
+
+		it( 'should return null if ranges do not intersect', () => {
+			const otherRange = new Range( new Position( [ 5, 4 ], root ), new Position( [ 7 ], root ) );
+			const common = range.getIntersection( otherRange );
+
+			expect( common ).to.be.null;
+		} );
+
+		it( 'should return a range equal to the common part of ranges - original range contains the other range', () => {
+			const otherRange = new Range( new Position( [ 4 ], root ), new Position( [ 5 ], root ) );
+			const common = range.getIntersection( otherRange );
+
+			expect( common.isEqual( otherRange ) ).to.be.true;
+		} );
+
+		it( 'should return a range equal to the common part of ranges - original range is contained by the other range', () => {
+			const otherRange = new Range( new Position( [ 3 ], root ), new Position( [ 6 ], root ) );
+			const common = range.getIntersection( otherRange );
+
+			expect( common.isEqual( range ) ).to.be.true;
+		} );
+
+		it( 'should return a range equal to the common part of ranges - original range intersects with the other range', () => {
+			const otherRange = new Range( new Position( [ 3 ], root ), new Position( [ 4, 7 ], root ) );
+			const common = range.getIntersection( otherRange );
+
+			expect( common.start.path ).to.deep.equal( [ 3, 2 ] );
+			expect( common.end.path ).to.deep.equal( [ 4, 7 ] );
+		} );
 	} );
 } );

+ 2 - 2
packages/ckeditor5-utils/tests/utils/utils.js

@@ -140,13 +140,13 @@ describe( 'utils', () => {
 			expect( result ).to.equal( utils.compareArrays.EXTENSION );
 		} );
 
-		it( 'should return DIFFERENT flag, when arrays are not same', () => {
+		it( 'should return index on which arrays differ, when arrays are not the same', () => {
 			let a = [ 'abc', 0, 3 ];
 			let b = [ 'abc', 1, 3 ];
 
 			let result = utils.compareArrays( a, b );
 
-			expect( result ).to.equal( utils.compareArrays.DIFFERENT );
+			expect( result ).to.equal( 1 );
 		} );
 	} );