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

Merge pull request #229 from ckeditor/ot-for-deltas

Deltas transformations
Piotr Jasiun 10 лет назад
Родитель
Сommit
bd3b48d9d8
54 измененных файлов с 3965 добавлено и 421 удалено
  1. 0 123
      packages/ckeditor5-engine/src/treemodel/batch-base.js
  2. 116 20
      packages/ckeditor5-engine/src/treemodel/batch.js
  3. 58 2
      packages/ckeditor5-engine/src/treemodel/delta/attributedelta.js
  4. 22 0
      packages/ckeditor5-engine/src/treemodel/delta/basic-deltas.js
  5. 262 0
      packages/ckeditor5-engine/src/treemodel/delta/basic-transformations.js
  6. 64 0
      packages/ckeditor5-engine/src/treemodel/delta/delta.js
  7. 47 5
      packages/ckeditor5-engine/src/treemodel/delta/insertdelta.js
  8. 34 2
      packages/ckeditor5-engine/src/treemodel/delta/mergedelta.js
  9. 56 2
      packages/ckeditor5-engine/src/treemodel/delta/movedelta.js
  10. 3 3
      packages/ckeditor5-engine/src/treemodel/delta/removedelta.js
  11. 55 4
      packages/ckeditor5-engine/src/treemodel/delta/splitdelta.js
  12. 221 0
      packages/ckeditor5-engine/src/treemodel/delta/transform.js
  13. 36 4
      packages/ckeditor5-engine/src/treemodel/delta/unwrapdelta.js
  14. 5 5
      packages/ckeditor5-engine/src/treemodel/delta/weakinsertdelta.js
  15. 38 2
      packages/ckeditor5-engine/src/treemodel/delta/wrapdelta.js
  16. 5 0
      packages/ckeditor5-engine/src/treemodel/document.js
  17. 5 5
      packages/ckeditor5-engine/src/treemodel/liveposition.js
  18. 4 4
      packages/ckeditor5-engine/src/treemodel/liverange.js
  19. 2 2
      packages/ckeditor5-engine/src/treemodel/nodelist.js
  20. 20 1
      packages/ckeditor5-engine/src/treemodel/operation/moveoperation.js
  21. 17 0
      packages/ckeditor5-engine/src/treemodel/operation/operation.js
  22. 10 0
      packages/ckeditor5-engine/src/treemodel/operation/reinsertoperation.js
  23. 10 0
      packages/ckeditor5-engine/src/treemodel/operation/removeoperation.js
  24. 62 77
      packages/ckeditor5-engine/src/treemodel/operation/transform.js
  25. 32 26
      packages/ckeditor5-engine/src/treemodel/position.js
  26. 26 19
      packages/ckeditor5-engine/src/treemodel/range.js
  27. 1 1
      packages/ckeditor5-engine/src/ui/template.js
  28. 5 1
      packages/ckeditor5-engine/tests/treemodel/batch.js
  29. 102 3
      packages/ckeditor5-engine/tests/treemodel/delta/attributedelta.js
  30. 47 0
      packages/ckeditor5-engine/tests/treemodel/delta/delta.js
  31. 73 0
      packages/ckeditor5-engine/tests/treemodel/delta/insertdelta.js
  32. 65 0
      packages/ckeditor5-engine/tests/treemodel/delta/mergedelta.js
  33. 80 0
      packages/ckeditor5-engine/tests/treemodel/delta/movedelta.js
  34. 82 0
      packages/ckeditor5-engine/tests/treemodel/delta/splitdelta.js
  35. 190 0
      packages/ckeditor5-engine/tests/treemodel/delta/transform/_utils/utils.js
  36. 145 0
      packages/ckeditor5-engine/tests/treemodel/delta/transform/attributedelta.js
  37. 73 0
      packages/ckeditor5-engine/tests/treemodel/delta/transform/delta.js
  38. 204 0
      packages/ckeditor5-engine/tests/treemodel/delta/transform/insertdelta.js
  39. 269 0
      packages/ckeditor5-engine/tests/treemodel/delta/transform/mergedelta.js
  40. 141 0
      packages/ckeditor5-engine/tests/treemodel/delta/transform/movedelta.js
  41. 112 0
      packages/ckeditor5-engine/tests/treemodel/delta/transform/removedelta.js
  42. 316 0
      packages/ckeditor5-engine/tests/treemodel/delta/transform/splitdelta.js
  43. 156 0
      packages/ckeditor5-engine/tests/treemodel/delta/transform/unwrapdelta.js
  44. 134 0
      packages/ckeditor5-engine/tests/treemodel/delta/transform/weakinsertdelta.js
  45. 155 0
      packages/ckeditor5-engine/tests/treemodel/delta/transform/wrapdelta.js
  46. 69 0
      packages/ckeditor5-engine/tests/treemodel/delta/unwrapdelta.js
  47. 66 0
      packages/ckeditor5-engine/tests/treemodel/delta/wrapdelta.js
  48. 22 9
      packages/ckeditor5-engine/tests/treemodel/operation/moveoperation.js
  49. 33 0
      packages/ckeditor5-engine/tests/treemodel/operation/operation.js
  50. 4 0
      packages/ckeditor5-engine/tests/treemodel/operation/reinsertoperation.js
  51. 10 0
      packages/ckeditor5-engine/tests/treemodel/operation/removeoperation.js
  52. 132 96
      packages/ckeditor5-engine/tests/treemodel/operation/transform.js
  53. 16 2
      packages/ckeditor5-engine/tests/treemodel/position.js
  54. 53 3
      packages/ckeditor5-engine/tests/treemodel/range.js

+ 0 - 123
packages/ckeditor5-engine/src/treemodel/batch-base.js

@@ -1,123 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-'use strict';
-
-import CKEditorError from '../ckeditorerror.js';
-
-/**
- * The Batch class groups document changes (deltas). All deltas grouped in a single Batch can be
- * reverted together, so you can think about the Batch as a single undo step. If you want to extend one
- * undo step you can call another method on the same Batch object. If you want to create a separate undo step
- * you can create a new Batch.
- *
- * For example to create two separate undo steps you can call:
- *
- *		doc.batch().insert( firstPosition, 'foo' );
- *		doc.batch().insert( secondPosition, 'bar' );
- *
- * To create a single undo step:
- *
- *		const batch = doc.batch();
- *		batch.insert( firstPosition, 'foo' );
- *		batch.insert( secondPosition, 'bar' );
- *
- * Note that all document modification methods (insert, remove, split, etc.) are chainable so you can shorten code to:
- *
- *		doc.batch().insert( firstPosition, 'foo' ).insert( secondPosition, 'bar' );
- *
- * @memberOf core.treeModel
- */
-export default class Batch {
-	/**
-	 * Creates Batch instance. Not recommended to use directly, use {@link core.treeModel.Document#batch} instead.
-	 *
-	 * @param {core.treeModel.Document} doc Document which this Batch changes.
-	 */
-	constructor( doc ) {
-		/**
-		 * Document which this Batch changes.
-		 *
-		 * @member core.treeModel.Batch#doc
-		 * @readonly
-		 * @type {core.treeModel.Document}
-		 */
-		this.doc = doc;
-
-		/**
-		 * Array of deltas which compose Batch.
-		 *
-		 * @member core.treeModel.Batch#deltas
-		 * @readonly
-		 * @type {Array.<core.treeModel.delta.Delta>}
-		 */
-		this.deltas = [];
-	}
-
-	/**
-	 * Adds delta to the Batch instance. All modification methods (insert, remove, split, etc.) use this method
-	 * to add created deltas.
-	 *
-	 * @param {core.treeModel.delta.Delta} delta Delta to add.
-	 * @return {core.treeModel.delta.Delta} Added delta.
-	 */
-	addDelta( delta ) {
-		delta.batch = this;
-		this.deltas.push( delta );
-
-		return delta;
-	}
-}
-
-/**
- * Function to register Batch methods. To make code scalable Batch do not have modification
- * methods built in. They can be registered using this method.
- *
- * This method checks if there is no naming collision and throws `batch-register-taken` if the method name
- * is already taken.
- *
- * Besides that no magic happens here, the method is added to the `Batch` class prototype.
- *
- * For example:
- *
- *		Batch.register( 'insert', function( position, nodes ) {
- *			// You can use a class inherit from Delta if that class should handle OT in the special way.
- *			const delta = new Delta();
- *
- *			// Create operations which should be components of this delta.
- *			const operation = new InsertOperation( position, nodes, this.doc.version );
- *
- *			// Remember to apply every operation, no magic, you need to do it manually.
- *			this.doc.applyOperation( operation );
- *
- *			// Add operation to the delta.
- *			delta.addOperation( operation );
- *
- *			// Add delta to the Batch instance.
- *			this.addDelta( delta );
- *
- *			// Make this method chainable.
- *			return this;
- *		} );
- *
- * @method core.treeModel.Batch.register
- * @param {String} name Method name.
- * @param {Function} creator Method body.
- */
-export function register( name, creator ) {
-	if ( Batch.prototype[ name ] ) {
-		/**
-		 * This batch method name is already taken.
-		 *
-		 * @error batch-register-taken
-		 * @param {String} name
-		 */
-		throw new CKEditorError(
-			'batch-register-taken: This batch method name is already taken.',
-			{ name: name } );
-	}
-
-	Batch.prototype[ name ] = creator;
-}

+ 116 - 20
packages/ckeditor5-engine/src/treemodel/batch.js

@@ -5,23 +5,119 @@
 
 'use strict';
 
-// Batch is split into two files because of circular dependencies reasons.
-
-// Deltas require `register` method that require `Batch` class and is defined in batch-base.js.
-// We would like to group all deltas files in one place, so we would only have to include batch.js
-// which would already have all default deltas registered.
-
-// Import default suite of deltas so a feature have to include only Batch class file.
-import d1 from './delta/insertdelta.js';
-import d2 from './delta/weakinsertdelta.js';
-import d3 from './delta/movedelta.js';
-import d4 from './delta/removedelta.js';
-import d5 from './delta/attributedelta.js';
-import d6 from './delta/splitdelta.js';
-import d7 from './delta/mergedelta.js';
-import d8 from './delta/wrapdelta.js';
-import d9 from './delta/unwrapdelta.js';
-/*jshint unused: false*/
-
-import Batch from './batch-base.js';
-export default Batch;
+import CKEditorError from '../ckeditorerror.js';
+
+/**
+ * The Batch class groups document changes (deltas). All deltas grouped in a single Batch can be
+ * reverted together, so you can think about the Batch as a single undo step. If you want to extend one
+ * undo step you can call another method on the same Batch object. If you want to create a separate undo step
+ * you can create a new Batch.
+ *
+ * For example to create two separate undo steps you can call:
+ *
+ *		doc.batch().insert( firstPosition, 'foo' );
+ *		doc.batch().insert( secondPosition, 'bar' );
+ *
+ * To create a single undo step:
+ *
+ *		const batch = doc.batch();
+ *		batch.insert( firstPosition, 'foo' );
+ *		batch.insert( secondPosition, 'bar' );
+ *
+ * Note that all document modification methods (insert, remove, split, etc.) are chainable so you can shorten code to:
+ *
+ *		doc.batch().insert( firstPosition, 'foo' ).insert( secondPosition, 'bar' );
+ *
+ * @memberOf core.treeModel
+ */
+export default class Batch {
+	/**
+	 * Creates Batch instance. Not recommended to use directly, use {@link core.treeModel.Document#batch} instead.
+	 *
+	 * @param {core.treeModel.Document} doc Document which this Batch changes.
+	 */
+	constructor( doc ) {
+		/**
+		 * Document which this Batch changes.
+		 *
+		 * @member core.treeModel.Batch#doc
+		 * @readonly
+		 * @type {core.treeModel.Document}
+		 */
+		this.doc = doc;
+
+		/**
+		 * Array of deltas which compose Batch.
+		 *
+		 * @member core.treeModel.Batch#deltas
+		 * @readonly
+		 * @type {Array.<core.treeModel.delta.Delta>}
+		 */
+		this.deltas = [];
+	}
+
+	/**
+	 * Adds delta to the Batch instance. All modification methods (insert, remove, split, etc.) use this method
+	 * to add created deltas.
+	 *
+	 * @param {core.treeModel.delta.Delta} delta Delta to add.
+	 * @return {core.treeModel.delta.Delta} Added delta.
+	 */
+	addDelta( delta ) {
+		delta.batch = this;
+		this.deltas.push( delta );
+
+		return delta;
+	}
+}
+
+/**
+ * Function to register Batch methods. To make code scalable Batch do not have modification
+ * methods built in. They can be registered using this method.
+ *
+ * This method checks if there is no naming collision and throws `batch-register-taken` if the method name
+ * is already taken.
+ *
+ * Besides that no magic happens here, the method is added to the `Batch` class prototype.
+ *
+ * For example:
+ *
+ *		Batch.register( 'insert', function( position, nodes ) {
+ *			// You can use a class inherit from Delta if that class should handle OT in the special way.
+ *			const delta = new Delta();
+ *
+ *			// Create operations which should be components of this delta.
+ *			const operation = new InsertOperation( position, nodes, this.doc.version );
+ *
+ *			// Remember to apply every operation, no magic, you need to do it manually.
+ *			this.doc.applyOperation( operation );
+ *
+ *			// Add operation to the delta.
+ *			delta.addOperation( operation );
+ *
+ *			// Add delta to the Batch instance.
+ *			this.addDelta( delta );
+ *
+ *			// Make this method chainable.
+ *			return this;
+ *		} );
+ *
+ * @method core.treeModel.Batch.register
+ * @param {String} name Method name.
+ * @param {Function} creator Method body.
+ */
+export function register( name, creator ) {
+	if ( Batch.prototype[ name ] ) {
+		/**
+		 * This batch method name is already taken.
+		 *
+		 * @error batch-register-taken
+		 * @param {String} name
+		 */
+		throw new CKEditorError(
+			'batch-register-taken: This batch method name is already taken.',
+			{ name: name } );
+	}
+
+	Batch.prototype[ name ] = creator;
+}

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

@@ -4,8 +4,9 @@
  */
 
 'use strict';
+
 import Delta from './delta.js';
-import { register } from '../batch-base.js';
+import { register } from '../batch.js';
 import AttributeOperation from '../operation/attributeoperation.js';
 import Position from '../position.js';
 import Range from '../range.js';
@@ -19,7 +20,62 @@ import Element from '../element.js';
  * @memberOf core.treeModel.delta
  * @extends core.treeModel.delta.Delta
  */
-export default class AttributeDelta extends Delta {}
+export default class AttributeDelta extends Delta {
+	/**
+	 * The attribute key that is changed by the delta or `null` if the delta has no operations.
+	 *
+	 * @type {String|null}
+	 */
+	get key() {
+		return this.operations[ 0 ] ? this.operations[ 0 ].key : null;
+	}
+
+	/**
+	 * The attribute value that is set by the delta or `null` if the delta has no operations.
+	 *
+	 * @type {*|null}
+	 */
+	get value() {
+		return this.operations[ 0 ] ? this.operations[ 0 ].newValue : null;
+	}
+
+	/**
+	 * The range on which delta operates or `null` if the delta has no operations.
+	 *
+	 * @type {core.treeModel.Range|null}
+	 */
+	get range() {
+		// Check if it is cached.
+		if ( this._range ) {
+			return this._range;
+		}
+
+		// If it is not cached we will evaluate it and cache it.
+		let firstOperation = this.operations[ 0 ];
+		let lastOperation = this.operations[ this.operations.length - 1 ];
+
+		if ( firstOperation ) {
+			this._range = new Range( firstOperation.range.start, lastOperation.range.end );
+
+			return this._range;
+		}
+
+		return null;
+	}
+
+	/**
+	 * @see core.treeModel.delta.Delta#_reverseDeltaClass
+	 * @private
+	 * @type {Object}
+	 */
+	get _reverseDeltaClass() {
+		return AttributeDelta;
+	}
+
+	static get _priority() {
+		return 20;
+	}
+}
 
 /**
  * Sets the value of the attribute of the node or on the range.

+ 22 - 0
packages/ckeditor5-engine/src/treemodel/delta/basic-deltas.js

@@ -0,0 +1,22 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+// Deltas require `register` method that require `Batch` class and is defined in batch-base.js.
+// We would like to group all deltas files in one place, so we would only have to include batch.js
+// which would already have all default deltas registered.
+
+// Import default suite of deltas so a feature have to include only Batch class file.
+import d1 from './insertdelta.js';
+import d2 from './weakinsertdelta.js';
+import d3 from './movedelta.js';
+import d4 from './removedelta.js';
+import d5 from './attributedelta.js';
+import d6 from './splitdelta.js';
+import d7 from './mergedelta.js';
+import d8 from './wrapdelta.js';
+import d9 from './unwrapdelta.js';
+/*jshint unused: false*/

+ 262 - 0
packages/ckeditor5-engine/src/treemodel/delta/basic-transformations.js

@@ -0,0 +1,262 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import { addTransformationCase, defaultTransform } from './transform.js';
+
+import Range from '../range.js';
+
+import AttributeOperation from '../operation/attributeoperation.js';
+
+import Delta from './delta.js';
+import AttributeDelta from './attributedelta.js';
+import InsertDelta from './insertdelta.js';
+import MergeDelta from './mergedelta.js';
+import MoveDelta from './movedelta.js';
+import SplitDelta from './splitdelta.js';
+import WeakInsertDelta from './weakinsertdelta.js';
+import WrapDelta from './wrapdelta.js';
+import UnwrapDelta from './unwrapdelta.js';
+
+import utils from '../../utils.js';
+
+// Provide transformations for default deltas.
+
+// Add special case for AttributeDelta x WeakInsertDelta transformation.
+addTransformationCase( AttributeDelta, WeakInsertDelta, ( a, b, isStrong ) => {
+	// If nodes are weak-inserted into attribute delta range, we need to apply changes from attribute delta on them.
+	// So first we do the normal transformation and if this special cases happens, we will add an extra delta.
+	const deltas = defaultTransform( a, b, isStrong );
+
+	if ( a.range.containsPosition( b.position ) ) {
+		deltas.push( _getComplementaryAttrDelta( b, a ) );
+	}
+
+	return deltas;
+} );
+
+// Add special case for InsertDelta x MergeDelta transformation.
+addTransformationCase( InsertDelta, MergeDelta, ( a, b, isStrong ) => {
+	// If insert is applied at the same position where merge happened, we reverse the merge (we treat it like it
+	// didn't happen) and then apply the original insert operation. This is "mirrored" in MergeDelta x InsertDelta
+	// transformation below, where we simply do not apply MergeDelta.
+	if ( a.position.isEqual( b.position ) ) {
+		return [
+			b.getReversed(),
+			a.clone()
+		];
+	}
+
+	return defaultTransform( a, b, isStrong );
+} );
+
+// Add special case for MoveDelta x MergeDelta transformation.
+addTransformationCase( MoveDelta, MergeDelta, ( a, b, isStrong ) => {
+	// If move delta is supposed to move a node that has been merged, we reverse the merge (we treat it like it
+	// didn't happen) and then apply the original move operation. This is "mirrored" in MergeDelta x MoveDelta
+	// transformation below, where we simply do not apply MergeDelta.
+
+	const operateInSameParent = utils.compareArrays( a.sourcePosition.getParentPath(), b.position.getParentPath() ) === 'SAME';
+	const mergeInsideMoveRange = a.sourcePosition.offset <= b.position.offset && a.sourcePosition.offset + a.howMany > b.position.offset;
+
+	if ( operateInSameParent && mergeInsideMoveRange ) {
+		return [
+			b.getReversed(),
+			a.clone()
+		];
+	}
+
+	return defaultTransform( a, b, isStrong );
+} );
+
+// Add special case for MergeDelta x InsertDelta transformation.
+addTransformationCase( MergeDelta, InsertDelta, ( a, b, isStrong ) => {
+	// If merge is applied at the same position where we inserted a range of nodes we cancel the merge as it's results
+	// may be unexpected and very weird. Even if we do some "magic" we don't know what really are users' expectations.
+	if ( a.position.isEqual( b.position ) ) {
+		// This is "no-op" delta, it has no type and no operations, it basically does nothing.
+		// It is used when we don't want to apply changes but still we need to return a delta.
+		return [ new Delta() ];
+	}
+
+	return defaultTransform( a, b, isStrong );
+} );
+
+// Add special case for MergeDelta x MoveDelta transformation.
+addTransformationCase( MergeDelta, MoveDelta, ( a, b, isStrong ) => {
+	// If merge is applied at the position between moved nodes we cancel the merge as it's results may be unexpected and
+	// very weird. Even if we do some "magic" we don't know what really are users' expectations.
+
+	const operateInSameParent = utils.compareArrays( a.position.getParentPath(), b.sourcePosition.getParentPath() ) === 'SAME';
+	const mergeInsideMoveRange = b.sourcePosition.offset <= a.position.offset && b.sourcePosition.offset + b.howMany > a.position.offset;
+
+	if ( operateInSameParent && mergeInsideMoveRange ) {
+		// This is "no-op" delta, it has no type and no operations, it basically does nothing.
+		// It is used when we don't want to apply changes but still we need to return a delta.
+		return [ new Delta() ];
+	}
+
+	return defaultTransform( a, b, isStrong );
+} );
+
+// Add special case for SplitDelta x SplitDelta transformation.
+addTransformationCase( SplitDelta, SplitDelta, ( a, b, isStrong ) => {
+	const pathA = a.position.getParentPath();
+	const pathB = b.position.getParentPath();
+
+	// The special case is for splits inside the same parent.
+	if ( utils.compareArrays( pathA, pathB ) == 'SAME' ) {
+		if ( a.position.offset == b.position.offset ) {
+			// We are applying split at the position where split already happened. Additional split is not needed.
+			return [ new Delta() ];
+		} else if ( a.position.offset < b.position.offset ) {
+			// Incoming split delta splits at closer offset. So we simply have to once again split the same node,
+			// but since it was already split (at further offset) there are less child nodes in the split node.
+			// This means that we have to update `howMany` parameter of `MoveOperation` for that delta.
+
+			const delta = a.clone();
+			delta._moveOperation.howMany = b.position.offset - a.position.offset;
+
+			return [ delta ];
+		} else {
+			// Incoming split delta splits at further offset. We have to simulate that we are not splitting the
+			// original split node but the node after it, which got created by the other split delta.
+			// To do so, we increment offsets so it looks like the split delta was created in the next node.
+
+			const delta = a.clone();
+
+			delta._cloneOperation.position.offset++;
+			delta._moveOperation.sourcePosition.path[ delta._moveOperation.sourcePosition.path.length - 2 ]++;
+			delta._moveOperation.targetPosition.path[ delta._moveOperation.targetPosition.path.length - 2 ]++;
+			delta._moveOperation.sourcePosition.offset = a.position.offset - b.position.offset;
+
+			return [ delta ];
+		}
+	}
+
+	return defaultTransform( a, b, isStrong );
+} );
+
+// Add special case for SplitDelta x UnwrapDelta transformation.
+addTransformationCase( SplitDelta, UnwrapDelta, ( a, b, isStrong ) => {
+	// If incoming split delta tries to split a node that just got unwrapped, there is actually nothing to split,
+	// so we discard that delta.
+	if ( utils.compareArrays( b.position.path, a.position.getParentPath() ) === 'SAME' ) {
+		// This is "no-op" delta, it has no type and no operations, it basically does nothing.
+		// It is used when we don't want to apply changes but still we need to return a delta.
+		return [ new Delta() ];
+	}
+
+	return defaultTransform( a, b, isStrong );
+} );
+
+// Add special case for SplitDelta x WrapDelta transformation.
+addTransformationCase( SplitDelta, WrapDelta, ( a, b, isStrong ) => {
+	// If split is applied at the position between wrapped nodes, we cancel the split as it's results may be unexpected and
+	// very weird. Even if we do some "magic" we don't know what really are users' expectations.
+
+	const operateInSameParent = utils.compareArrays( a.position.getParentPath(), b.range.start.getParentPath() ) === 'SAME';
+	const splitInsideWrapRange = b.range.start.offset < a.position.offset && b.range.end.offset >= a.position.offset;
+
+	if ( operateInSameParent && splitInsideWrapRange ) {
+		// This is "no-op" delta, it has no type and no operations, it basically does nothing.
+		// It is used when we don't want to apply changes but still we need to return a delta.
+		return [ new Delta() ];
+	}
+
+	return defaultTransform( a, b, isStrong );
+} );
+
+// Add special case for UnwrapDelta x SplitDelta transformation.
+addTransformationCase( UnwrapDelta, SplitDelta, ( a, b, isStrong ) => {
+	// If incoming unwrap delta tries to unwrap node that got split we should unwrap the original node and the split copy.
+	// This can be achieved either by reverting split and applying unwrap to singular node, or creating additional unwrap delta.
+	if ( utils.compareArrays( a.position.path, b.position.getParentPath() ) === 'SAME' ) {
+		return [
+			b.getReversed(),
+			a.clone()
+		];
+	}
+
+	return defaultTransform( a, b, isStrong );
+} );
+
+// Add special case for WeakInsertDelta x AttributeDelta transformation.
+addTransformationCase( WeakInsertDelta, AttributeDelta, ( a, b, isStrong ) => {
+	// If nodes are weak-inserted into attribute delta range, we need to apply changes from attribute delta on them.
+	// So first we do the normal transformation and if this special cases happens, we will add an extra delta.
+	const deltas = defaultTransform( a, b, isStrong );
+
+	if ( b.range.containsPosition( a.position ) ) {
+		deltas.push( _getComplementaryAttrDelta( a, b ) );
+	}
+
+	return deltas;
+} );
+
+// Add special case for WrapDelta x SplitDelta transformation.
+addTransformationCase( WrapDelta, SplitDelta, ( a, b, isStrong ) => {
+	// If incoming wrap delta tries to wrap range that contains split position, we have to cancel the split and apply
+	// the wrap. Since split was already applied, we have to revert it.
+
+	const operateInSameParent = utils.compareArrays( a.range.start.getParentPath(), b.position.getParentPath() ) === 'SAME';
+	const splitInsideWrapRange = a.range.start.offset < b.position.offset && a.range.end.offset >= b.position.offset;
+
+	if ( operateInSameParent && splitInsideWrapRange ) {
+		return [
+			b.getReversed(),
+			a.clone()
+		];
+	}
+
+	return defaultTransform( a, b, isStrong );
+} );
+
+// Helper function for `AttributeDelta` class transformations.
+// Creates an attribute delta that sets attribute from given `attributeDelta` on nodes from given `weakInsertDelta`.
+function _getComplementaryAttrDelta( weakInsertDelta, attributeDelta ) {
+	const complementaryAttrDelta = new AttributeDelta();
+
+	// At the beginning we store the attribute value from the first node on `weakInsertDelta` node list.
+	let val = weakInsertDelta.nodeList.get( 0 ).getAttribute( attributeDelta.key );
+
+	// This stores the last index of `weakInsertDelta` node list where the attribute value was different
+	// than in the previous node. We need it to create separate `AttributeOperation`s for nodes with different attributes.
+	let lastIndex = 0;
+
+	for ( let i = 0; i < weakInsertDelta.nodeList.length; i++ ) {
+		const node = weakInsertDelta.nodeList.get( i );
+		const nodeAttrVal = node.getAttribute( attributeDelta.key );
+
+		// If previous node has different attribute value, we will create an operation to the point before current node.
+		// So all nodes with the same attributes up to this point will be included in one `AttributeOperation`.
+		if ( nodeAttrVal != val ) {
+			// New operation is created only when it is needed. If given node already has proper value for this
+			// attribute we simply skip it without adding a new operation.
+			if ( val != attributeDelta.value ) {
+				const range = new Range( weakInsertDelta.position.getShiftedBy( lastIndex ), weakInsertDelta.position.getShiftedBy( i ) );
+
+				// We don't care about base version because it will be updated after transformations anyway.
+				const attrOperation = new AttributeOperation( range, attributeDelta.key, val, attributeDelta.value, 0 );
+				complementaryAttrDelta.addOperation( attrOperation );
+			}
+
+			val = nodeAttrVal;
+			lastIndex = i;
+		}
+	}
+
+	// At the end we have to add additional `AttributeOperation` for the last part of node list. If all nodes on the
+	// node list had same attributes, this will be the only operation added to the delta.
+	const range = new Range(
+		weakInsertDelta.position.getShiftedBy( lastIndex ),
+		weakInsertDelta.position.getShiftedBy( weakInsertDelta.nodeList.length )
+	);
+
+	complementaryAttrDelta.addOperation( new AttributeOperation( range, attributeDelta.key, val, attributeDelta.value, 0 ) );
+
+	return complementaryAttrDelta;
+}

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

@@ -38,6 +38,16 @@ export default class Delta {
 		this.operations = [];
 	}
 
+	/**
+	 * A class that will be used when creating reversed delta.
+	 *
+	 * @private
+	 * @type {Object}
+	 */
+	get _reverseDeltaClass() {
+		return Delta;
+	}
+
 	/**
 	 * Add operation to the delta.
 	 *
@@ -49,4 +59,58 @@ export default class Delta {
 
 		return operation;
 	}
+
+	/**
+	 * Creates and returns a delta that has the same parameters as this delta.
+	 *
+	 * @returns {core.treeModel.delta.Delta} Clone of this delta.
+	 */
+	clone() {
+		let delta = new this.constructor();
+		delta.batch = this.batch;
+
+		for ( let op of this.operations ) {
+			delta.addOperation( op.clone() );
+		}
+
+		return delta;
+	}
+
+	/**
+	 * Creates and returns a reverse delta. Reverse delta when executed right after the original delta will bring back
+	 * tree model state to the point before the original delta execution. In other words, it reverses changes done
+	 * by the original delta.
+	 *
+	 * Keep in mind that tree model state may change since executing the original delta, so reverse delta may be "outdated".
+	 * In that case you will need to {@link core.treeModel.delta.transform} it by all deltas that were executed after
+	 * the original delta.
+	 *
+	 * @returns {core.treeModel.delta.Delta} Reversed delta.
+	 */
+	getReversed() {
+		let delta = new this._reverseDeltaClass();
+
+		for ( let op of this.operations ) {
+			let reversedOp = op.getReversed();
+			reversedOp.baseVersion += this.operations.length - 1;
+
+			delta.addOperation( reversedOp );
+		}
+
+		delta.operations.reverse();
+
+		return delta;
+	}
+
+	/**
+	 * Delta priority. Used in {@link core.treeModel.delta.transform delta transformations}. Delta with the higher
+	 * priority will be treated as more important when resolving transformation conflicts. If deltas have same
+	 * priority, other factors will be used to determine which delta is more important.
+	 *
+	 * @private
+	 * @type {Number}
+	 */
+	static get _priority() {
+		return 0;
+	}
 }

+ 47 - 5
packages/ckeditor5-engine/src/treemodel/delta/insertdelta.js

@@ -6,7 +6,8 @@
 'use strict';
 
 import Delta from './delta.js';
-import { register } from '../batch-base.js';
+import RemoveDelta from './removedelta.js';
+import { register } from '../batch.js';
 import InsertOperation from '../operation/insertoperation.js';
 
 /**
@@ -16,7 +17,48 @@ import InsertOperation from '../operation/insertoperation.js';
  *
  * @memberOf core.treeModel.delta
  */
-export default class InsertDelta extends Delta {}
+export default class InsertDelta extends Delta {
+	/**
+	 * Position where the delta inserts nodes or `null` if there are no operations in the delta.
+	 *
+	 * @type {core.treeModel.Position|null}
+	 */
+	get position() {
+		return this._insertOperation ? this._insertOperation.position : null;
+	}
+
+	/**
+	 * Node list containing all the nodes inserted by the delta or `null` if there are no operations in the delta.
+	 *
+	 * @type {core.treeModel.NodeList|null}
+	 */
+	get nodeList() {
+		return this._insertOperation ? this._insertOperation.nodeList : null;
+	}
+
+	/**
+	 * Insert operation that is saved in this delta or `null` if there are no operations in the delta.
+	 *
+	 * @protected
+	 * @type {core.treeModel.operation.InsertOperation|null}
+	 */
+	get _insertOperation() {
+		return this.operations[ 0 ] || null;
+	}
+
+	/**
+	 * @see core.treeModel.delta.Delta#_reverseDeltaClass
+	 * @private
+	 * @type {Object}
+	 */
+	get _reverseDeltaClass() {
+		return RemoveDelta;
+	}
+
+	static get _priority() {
+		return 20;
+	}
+}
 
 /**
  * Inserts a node or nodes at the given position.
@@ -29,10 +71,10 @@ export default class InsertDelta extends Delta {}
  */
 register( 'insert', function( position, nodes ) {
 	const delta = new InsertDelta();
+	const insert = new InsertOperation( position, nodes, this.doc.version );
 
-	const operation = new InsertOperation( position, nodes, this.doc.version );
-	this.doc.applyOperation( operation );
-	delta.addOperation( operation );
+	delta.addOperation( insert );
+	this.doc.applyOperation( insert );
 
 	this.addDelta( delta );
 

+ 34 - 2
packages/ckeditor5-engine/src/treemodel/delta/mergedelta.js

@@ -6,7 +6,8 @@
 'use strict';
 
 import Delta from './delta.js';
-import { register } from '../batch-base.js';
+import SplitDelta from './splitdelta.js';
+import { register } from '../batch.js';
 import Position from '../position.js';
 import Element from '../element.js';
 import RemoveOperation from '../operation/removeoperation.js';
@@ -20,7 +21,38 @@ import CKEditorError from '../../ckeditorerror.js';
  *
  * @memberOf core.treeModel.delta
  */
-export default class MergeDelta extends Delta {}
+export default class MergeDelta extends Delta {
+	/**
+	 * Position between to merged nodes or `null` if the delta has no operations.
+	 *
+	 * @type {core.treeModel.Position|null}
+	 */
+	get position() {
+		return this._removeOperation ? this._removeOperation.sourcePosition : null;
+	}
+
+	/**
+	 * Operation in this delta that removes the node after merge position (which will be empty at that point) or
+	 * `null` if the delta has no operations. Note, that after {@link core.treeModel.delta.transform transformation}
+	 * this might be an instance of {@link core.treeModel.operation.MoveOperation} instead of
+	 * {@link core.treeModel.operation.RemoveOperation}.
+	 *
+	 * @protected
+	 * @type {core.treeModel.operation.MoveOperation|null}
+	 */
+	get _removeOperation() {
+		return this.operations[ 1 ] || null;
+	}
+
+	/**
+	 * @see core.treeModel.delta.Delta#_reverseDeltaClass
+	 * @protected
+	 * @type {Object}
+	 */
+	get _reverseDeltaClass() {
+		return SplitDelta;
+	}
+}
 
 /**
  * Merges two siblings at the given position.

+ 56 - 2
packages/ckeditor5-engine/src/treemodel/delta/movedelta.js

@@ -6,7 +6,7 @@
 'use strict';
 
 import Delta from './delta.js';
-import { register } from '../batch-base.js';
+import { register } from '../batch.js';
 import MoveOperation from '../operation/moveoperation.js';
 import Position from '../position.js';
 import Range from '../range.js';
@@ -19,7 +19,61 @@ import CKEditorError from '../../ckeditorerror.js';
  *
  * @memberOf core.treeModel.delta
  */
-export default class MoveDelta extends Delta {}
+export default class MoveDelta extends Delta {
+	/**
+	 * How many nodes are moved by the delta or `null` if there are no operations in the delta.
+	 *
+	 * @type {Number|null}
+	 */
+	get howMany() {
+		return this._moveOperation ? this._moveOperation.howMany : null;
+	}
+
+	/**
+	 * {@link core.treeModel.delta.MoveDelta#_moveOperation Move operation}
+	 * {@link core.treeModel.operation.MoveOperation#sourcePosition source position} or `null` if there are
+	 * no operations in the delta.
+	 *
+	 * @type {core.treeModel.Position|null}
+	 */
+	get sourcePosition() {
+		return this._moveOperation ? this._moveOperation.sourcePosition : null;
+	}
+
+	/**
+	 * {@link core.treeModel.delta.MoveDelta#_moveOperation Move operation}
+	 * {@link core.treeModel.operation.MoveOperation#targetPosition target position} or `null` if there are
+	 * no operations in the delta.
+	 *
+	 * @type {core.treeModel.Position|null}
+	 */
+	get targetPosition() {
+		return this._moveOperation ? this._moveOperation.targetPosition : null;
+	}
+
+	/**
+	 * Move operation that is saved in this delta or `null` if there are no operations in the delta.
+	 *
+	 * @protected
+	 * @type {core.treeModel.operation.MoveOperation|null}
+	 */
+	get _moveOperation() {
+		return this.operations[ 0 ] || null;
+	}
+
+	/**
+	 * @see core.treeModel.delta.Delta#_reverseDeltaClass
+	 * @private
+	 * @type {Object}
+	 */
+	get _reverseDeltaClass() {
+		return MoveDelta;
+	}
+
+	static get _priority() {
+		return 20;
+	}
+}
 
 function addMoveOperation( batch, delta, sourcePosition, howMany, targetPosition ) {
 	const operation = new MoveOperation( sourcePosition, howMany, targetPosition, batch.doc.version );

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

@@ -5,8 +5,8 @@
 
 'use strict';
 
-import Delta from './delta.js';
-import { register } from '../batch-base.js';
+import MoveDelta from './movedelta.js';
+import { register } from '../batch.js';
 import RemoveOperation from '../operation/removeoperation.js';
 import Position from '../position.js';
 import Range from '../range.js';
@@ -18,7 +18,7 @@ import Range from '../range.js';
  *
  * @memberOf core.treeModel.delta
  */
-export default class RemoveDelta extends Delta {}
+export default class RemoveDelta extends MoveDelta {}
 
 function addRemoveOperation( batch, delta, position, howMany ) {
 	const operation = new RemoveOperation( position, howMany, batch.doc.version );

+ 55 - 4
packages/ckeditor5-engine/src/treemodel/delta/splitdelta.js

@@ -6,12 +6,13 @@
 'use strict';
 
 import Delta from './delta.js';
-import { register } from '../batch-base.js';
+import { register } from '../batch.js';
 import Position from '../position.js';
 import Element from '../element.js';
 import InsertOperation from '../operation/insertoperation.js';
 import MoveOperation from '../operation/moveoperation.js';
 import CKEditorError from '../../ckeditorerror.js';
+import MergeDelta from '../delta/mergedelta.js';
 
 /**
  * @classdesc
@@ -20,7 +21,55 @@ import CKEditorError from '../../ckeditorerror.js';
  *
  * @memberOf core.treeModel.delta
  */
-export default class SplitDelta extends Delta {}
+export default class SplitDelta extends Delta {
+	/**
+	 * Position of split or `null` if there are no operations in the delta.
+	 *
+	 * @type {core.treeModel.Position|null}
+	 */
+	get position() {
+		return this._moveOperation ? this._moveOperation.sourcePosition : null;
+	}
+
+	/**
+	 * Operation in the delta that adds a node to the tree model where split elements will be moved to or `null` if
+	 * there are no operations in the delta.
+	 *
+	 * Most commonly this will be insert operation, as `SplitDelta` has to create a new node. If `SplitDelta` was created
+	 * through {@link core.treeModel.delta.MergeDelta MergeDelta} {@link core.treeModel.delta.Delta#getReversed reversing},
+	 * this will be a reinsert operation, as we will want to "insert-back" the node that was removed by `MergeDelta`.
+	 *
+	 * @protected
+	 * @type {core.treeModel.operation.InsertOpertaion|core.treeModel.operation.ReinsertOperation|null}
+	 */
+	get _cloneOperation() {
+		return this.operations[ 0 ] || null;
+	}
+
+	/**
+	 * Operation in the delta that moves nodes from after split position to their new parent
+	 * or `null` if there are no operations in the delta.
+	 *
+	 * @protected
+	 * @type {core.treeModel.operation.MoveOperation|null}
+	 */
+	get _moveOperation() {
+		return this.operations[ 1 ] || null;
+	}
+
+	/**
+	 * @see core.treeModel.delta.Delta#_reverseDeltaClass
+	 * @private
+	 * @type {Object}
+	 */
+	get _reverseDeltaClass() {
+		return MergeDelta;
+	}
+
+	static get _priority() {
+		return 10;
+	}
+}
 
 /**
  * Splits a node at the given position.
@@ -34,6 +83,7 @@ export default class SplitDelta extends Delta {}
  */
 register( 'split', function( position ) {
 	const delta = new SplitDelta();
+
 	const splitElement = position.parent;
 
 	if ( !splitElement.parent ) {
@@ -46,10 +96,11 @@ register( 'split', function( position ) {
 	}
 
 	const copy = new Element( splitElement.name, splitElement._attrs );
+
 	const insert = new InsertOperation( Position.createAfter( splitElement ), copy, this.doc.version );
 
-	this.doc.applyOperation( insert );
 	delta.addOperation( insert );
+	this.doc.applyOperation( insert );
 
 	const move = new MoveOperation(
 		position,
@@ -58,8 +109,8 @@ register( 'split', function( position ) {
 		this.doc.version
 	);
 
-	this.doc.applyOperation( move );
 	delta.addOperation( move );
+	this.doc.applyOperation( move );
 
 	this.addDelta( delta );
 

+ 221 - 0
packages/ckeditor5-engine/src/treemodel/delta/transform.js

@@ -0,0 +1,221 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import operationTransform from '../operation/transform.js';
+import arrayUtils from '../../lib/lodash/array.js';
+
+const specialCases = new Map();
+
+/**
+ * Transforms given {@link core.treeModel.delta.Delta delta} by another {@link core.treeModel.delta.Delta delta} and
+ * returns the result of that transformation as an array containing one or more {@link core.treeModel.delta.Delta delta}
+ * instances.
+ *
+ * Delta transformations heavily base on {@link core.treeModel.operation.transform operational transformations}. Since
+ * delta is a list of operations most situations can be handled thanks to operational transformation. Unfortunately,
+ * deltas are more complicated than operations and have they semantic meaning, as they represent user's editing intentions.
+ *
+ * Sometimes, simple operational transformation on deltas' operations might result in some unexpected results. Those
+ * results would be fine from OT point of view, but would not reflect user's intentions. Because of such conflicts
+ * we need to handle transformations in special cases in a custom way.
+ *
+ * The function itself looks whether two given delta types have a special case function registered. If so, the deltas are
+ * transformed using that function. If not, {@link core.treeModel.delta.defaultTransform default transformation algorithm}
+ * is used.
+ *
+ * @see core.treeModel.operation.transform
+ *
+ * @external core.treeModel.delta.transform
+ * @function core.treeModel.delta.transform.transform
+ * @param {core.treeModel.delta.Delta} a Delta that will be transformed.
+ * @param {core.treeModel.delta.Delta} b Delta to transform by.
+ * @param {Boolean} isAMoreImportantThanB Flag indicating whether the delta which will be transformed (`a`) should be treated
+ * as more important when resolving conflicts. Note that this flag is used only if provided deltas have same
+ * {@link core.treeModel.delta.priorities priority}. If deltas have different priorities, their importance is resolved
+ * automatically and overwrites this flag.
+ * @returns {Array.<core.treeModel.delta.Delta>} Result of the transformation.
+ */
+export default function transform( a, b, isAMoreImportantThanB ) {
+	const transformAlgorithm = getTransformationCase( a, b ) || defaultTransform;
+
+	const transformed = transformAlgorithm( a, b, isAMoreImportantThanB );
+	const baseVersion = arrayUtils.last( b.operations ).baseVersion;
+
+	return updateBaseVersion( baseVersion, transformed );
+}
+
+// Updates base versions of operations inside deltas (which are the results of delta transformation).
+function updateBaseVersion( baseVersion, deltas ) {
+	for ( let delta of deltas ) {
+		for ( let op of delta.operations ) {
+			op.baseVersion = ++baseVersion;
+		}
+	}
+
+	return deltas;
+}
+
+/**
+ * The default delta transformation function. It is used for those deltas that are not in special case conflict.
+ *
+ * This algorithm is similar to popular `dOPT` algorithm used in operational transformation, as we are in fact
+ * transforming two sets of operations by each other.
+ *
+ * @param {core.treeModel.delta.Delta} a Delta that will be transformed.
+ * @param {core.treeModel.delta.Delta} b Delta to transform by.
+ * @param {Boolean} isAMoreImportantThanB Flag indicating whether the delta which will be transformed (`a`) should be treated
+ * as more important when resolving conflicts. Note that this flag is used only if provided deltas have same
+ * {@link core.treeModel.delta.priorities priority}. If deltas have different priorities, their importance is resolved
+ * automatically and overwrites this flag.
+ * @returns {Array.<core.treeModel.delta.Delta>} Result of the transformation, that is an array with single delta instance.
+ */
+export function defaultTransform( a, b, isAMoreImportantThanB ) {
+	// First, resolve the flag real value.
+	isAMoreImportantThanB = getPriority( a.constructor, b.constructor, isAMoreImportantThanB );
+
+	// Create a new delta instance. Make sure that the new delta is of same type as transformed delta.
+	// We will transform operations in that delta but it doesn't mean the delta's "meaning" which is connected to
+	// the delta's type. Since the delta's type is heavily used in transformations and probably other parts
+	// of system it is important to keep proper delta type through all transformation process.
+	const transformed = new a.constructor();
+
+	// Array containing operations that we will transform by. At the beginning these are just operations from
+	let byOps = b.operations;
+
+	// This array is storing operations from `byOps` which got transformed by operation from delta `a`.
+	let newByOps = [];
+
+	// We take each operation from original set of operations to transform.
+	for ( let opA of a.operations ) {
+		// We wrap the operation in the array. This is important, because operation transformation algorithm returns
+		// an array of operations so we need to make sure that our algorithm is ready to handle arrays.
+		const ops = [ opA ];
+
+		// Now the real algorithm takes place.
+		for ( let opB of byOps ) {
+			// For each operation that we need transform by...
+			for ( let i = 0; i < ops.length; i++ ) {
+				// We take each operation to transform...
+				const op = ops[ i ];
+
+				// And transform both of them by themselves.
+
+				// The result of transforming operation from delta B by operation from delta A is saved in
+				// `newByOps` array. We will use that array for transformations in next loops. We need delta B
+				// operations after transformed by delta A operations to get correct results of transformations
+				// of next operations from delta A.
+				//
+				// It's like this because 2nd operation from delta A assumes that 1st operation from delta A
+				// is "already applied". When we transform 2nd operation from delta A by operations from delta B
+				// we have to be sure that operations from delta B are in a state that acknowledges 1st operation
+				// from delta A.
+				//
+				// This can be easier understood when operations sets to transform are represented by diamond diagrams:
+				// http://www.codecommit.com/blog/java/understanding-and-applying-operational-transformation
+
+				// Using push.apply because operationTransform function is returning an array with one or multiple results.
+				Array.prototype.push.apply( newByOps, operationTransform( opB, op, !isAMoreImportantThanB ) );
+
+				// Then, we transform operation from delta A by operation from delta B.
+				const results = operationTransform( op, opB, isAMoreImportantThanB );
+
+				// We replace currently processed operation from `ops` array by the results of transformation.
+				// Note, that we process single operation but the operationTransform result might be an array, so we
+				// might splice-in more operations. We will process them further in next iterations. Right now we
+				// just save them in `ops` array and move `i` pointer by proper offset.
+				Array.prototype.splice.apply( ops, [ i, 1 ].concat( results ) );
+
+				i += results.length - 1;
+			}
+
+			// At this point a single operation from delta A got transformed by a single operation from delta B.
+			// The transformation result is in `ops` array and it may be one or more operations. This was just the first step.
+			// Operation from delta A has to be further transformed by the other operations from delta B.
+			// So in next iterator loop we will take another operation from delta B and use transformed delta A (`ops`)
+			// to transform it further.
+		}
+
+		// We got through all delta B operations and have a final transformed state of an operation from delta A.
+
+		// As previously mentioned, we substitute operations from delta B by their transformed equivalents.
+		byOps = newByOps;
+		newByOps = [];
+
+		// We add transformed operation from delta A to newly created delta.
+		// Remember that transformed operation from delta A may consist of multiple operations.
+		for ( let op of ops ) {
+			transformed.addOperation( op );
+		}
+
+		// In next loop, we will take another operation from delta A and transform it through (transformed) operations
+		// from delta B...
+	}
+
+	return [ transformed ];
+}
+
+/**
+ * Adds a special case callback for given delta classes.
+ *
+ * @param {Function} A Delta constructor which instance will get transformed.
+ * @param {Function} B Delta constructor which instance will be transformed by.
+ * @param {Function} resolver A callback that will handle custom special case transformation for instances of given delta classes.
+ * @external core.treeModel.delta.transform
+ * @function core.treeModel.delta.transform.addTransformationCase
+ */
+export function addTransformationCase( A, B, resolver ) {
+	let casesA = specialCases.get( A );
+
+	if ( !casesA ) {
+		casesA = new Map();
+		specialCases.set( A, casesA );
+	}
+
+	casesA.set( B, resolver );
+}
+
+/**
+ * Gets a special case callback which was previously {@link core.treeModel.delta.transform.addTransformationCase added}.
+ *
+ * @param {core.treeModel.delta.Delta} a Delta to transform.
+ * @param {core.treeModel.delta.Delta} b Delta to be transformed by.
+ * @external core.treeModel.delta.transform
+ * @function core.treeModel.delta.transform.getTransformationCase
+ */
+export function getTransformationCase( a, b ) {
+	let casesA = specialCases.get( a.constructor );
+
+	// If there are no special cases registered for class which `a` is instance of, we will
+	// check if there are special cases registered for any parent class.
+	if ( !casesA || !casesA.get( b.constructor ) ) {
+		const cases = specialCases.keys();
+
+		for ( let caseClass of cases ) {
+			if ( a instanceof caseClass ) {
+				casesA = specialCases.get( caseClass );
+			}
+		}
+	}
+
+	if ( casesA ) {
+		return casesA.get( b.constructor );
+	}
+
+	return undefined;
+}
+
+// Checks priorities of passed constructors and decides which one is more important.
+// If both priorities are same, value passed in `isAMoreImportantThanB` parameter is used.
+function getPriority( A, B, isAMoreImportantThanB ) {
+	if ( A._priority > B._priority ) {
+		return true;
+	} else if ( A._priority < B._priority ) {
+		return false;
+	} else {
+		return isAMoreImportantThanB;
+	}
+}

+ 36 - 4
packages/ckeditor5-engine/src/treemodel/delta/unwrapdelta.js

@@ -6,7 +6,8 @@
 'use strict';
 
 import Delta from './delta.js';
-import { register } from '../batch-base.js';
+import WrapDelta from './wrapdelta.js';
+import { register } from '../batch.js';
 import Position from '../position.js';
 import RemoveOperation from '../operation/removeoperation.js';
 import MoveOperation from '../operation/moveoperation.js';
@@ -19,7 +20,39 @@ import CKEditorError from '../../ckeditorerror.js';
  *
  * @memberOf core.treeModel.delta
  */
-export default class UnwrapDelta extends Delta {}
+export default class UnwrapDelta extends Delta {
+	/**
+	 * Position before unwrapped element or `null` if there are no operations in the delta.
+	 *
+	 * @type {core.treeModel.Position|null}
+	 */
+	get position() {
+		return this._moveOperation ? this._moveOperation.targetPosition : null;
+	}
+
+	/**
+	 * Operation in the delta that moves unwrapped nodes to their new parent or `null` if there are no operations in the delta.
+	 *
+	 * @protected
+	 * @type {core.treeModel.operation.MoveOperation|null}
+	 */
+	get _moveOperation() {
+		return this.operations[ 0 ] || null;
+	}
+
+	/**
+	 * @see core.treeModel.delta.Delta#_reverseDeltaClass
+	 * @private
+	 * @type {Object}
+	 */
+	get _reverseDeltaClass() {
+		return WrapDelta;
+	}
+
+	static get _priority() {
+		return 10;
+	}
+}
 
 /**
  * Unwraps specified element, that is moves all it's children before it and then removes it. Throws
@@ -36,8 +69,7 @@ register( 'unwrap', function( element ) {
 		 *
 		 * @error batch-unwrap-element-no-parent
 		 */
-		throw new CKEditorError(
-			'batch-unwrap-element-no-parent: Trying to unwrap an element that has no parent.' );
+		throw new CKEditorError( 'batch-unwrap-element-no-parent: Trying to unwrap an element that has no parent.' );
 	}
 
 	const delta = new UnwrapDelta();

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

@@ -5,8 +5,8 @@
 
 'use strict';
 
-import Delta from './delta.js';
-import { register } from '../batch-base.js';
+import InsertDelta from './insertdelta.js';
+import { register } from '../batch.js';
 import InsertOperation from '../operation/insertoperation.js';
 import NodeList from '../nodelist.js';
 
@@ -17,7 +17,7 @@ import NodeList from '../nodelist.js';
  *
  * @memberOf core.treeModel.delta
  */
-export default class WeakInsertDelta extends Delta {}
+export default class WeakInsertDelta extends InsertDelta {}
 
 /**
  * Inserts a node or nodes at the given position. {@link core.treeModel.Batch#weakInsert weakInsert} is commonly used for actions
@@ -25,10 +25,10 @@ export default class WeakInsertDelta extends Delta {}
  * {@link core.treeModel.Batch#insert insert} and {@link core.treeModel.Batch#weakInsert weakInsert}:
  * * When using `weakInsert`, inserted nodes will have same attributes as the current attributes of
  * {@link core.treeModel.Document#selection document selection}.
- * * The above has to be reflected during {@link core.treeModel.operation.transform operational transformation}. Normal
- * behavior is that inserting inside range changed by {@link core.treeModel.operation.AttributeOperation AttributeOperation} splits
+ * * Normal behavior is that inserting inside range changed by {@link core.treeModel.operation.AttributeOperation AttributeOperation} splits
  * the operation into two operations, which "omit" the inserted nodes. The correct behavior for `WeakInsertDelta` is that
  * {@link core.treeModel.operation.AttributeOperation AttributeOperation} does not "break" and also applies attributes for inserted nodes.
+ * The above has to be reflected during {@link core.treeModel.operation.transform operational transformation}.
  *
  * @chainable
  * @method core.treeModel.Batch#weakInsert

+ 38 - 2
packages/ckeditor5-engine/src/treemodel/delta/wrapdelta.js

@@ -6,8 +6,10 @@
 'use strict';
 
 import Delta from './delta.js';
-import { register } from '../batch-base.js';
+import UnwrapDelta from './unwrapdelta.js';
+import { register } from '../batch.js';
 import Position from '../position.js';
+import Range from '../range.js';
 import Element from '../element.js';
 import InsertOperation from '../operation/insertoperation.js';
 import MoveOperation from '../operation/moveoperation.js';
@@ -20,7 +22,41 @@ import CKEditorError from '../../ckeditorerror.js';
  *
  * @memberOf core.treeModel.delta
  */
-export default class WrapDelta extends Delta {}
+export default class WrapDelta extends Delta {
+	/**
+	 * Range to wrap or `null` if there are no operations in the delta.
+	 *
+	 * @type {core.treeModel.Range|null}
+	 */
+	get range() {
+		let moveOp = this._moveOperation;
+
+		return moveOp ? Range.createFromPositionAndShift( moveOp.sourcePosition, moveOp.howMany ) : null;
+	}
+
+	/**
+	 * Operation that moves wrapped nodes to their new parent or `null` if there are no operations in the delta.
+	 *
+	 * @protected
+	 * @type {core.treeModel.operation.MoveOperation|null}
+	 */
+	get _moveOperation() {
+		return this.operations[ 1 ] || null;
+	}
+
+	/**
+	 * @see core.treeModel.delta.Delta#_reverseDeltaClass
+	 * @private
+	 * @type {Object}
+	 */
+	get _reverseDeltaClass() {
+		return UnwrapDelta;
+	}
+
+	static get _priority() {
+		return 10;
+	}
+}
 
 /**
  * Wraps given range with given element or with a new element of specified name if string has been passed.

+ 5 - 0
packages/ckeditor5-engine/src/treemodel/document.js

@@ -5,6 +5,11 @@
 
 'use strict';
 
+// Load all basic deltas and transformations, they register themselves, but they need to be imported somewhere.
+import deltas from './delta/basic-deltas.js';
+import transformations from './delta/basic-transformations.js';
+/*jshint unused: false*/
+
 import RootElement from './rootelement.js';
 import Batch from './batch.js';
 import Selection from './selection.js';

+ 5 - 5
packages/ckeditor5-engine/src/treemodel/liveposition.js

@@ -22,7 +22,7 @@ export default class LivePosition extends Position {
 	/**
 	 * Creates a live position.
 	 *
-	 * @see @link core.treeModel.Position
+	 * @see core.treeModel.Position
 	 * @param {core.treeModel.RootElement} root
 	 * @param {Array.<Number>} path
 	 * @param {core.treeModel.PositionStickiness} [stickiness] Defaults to `'STICKS_TO_NEXT'`. See
@@ -69,7 +69,7 @@ export default class LivePosition extends Position {
 	/**
 	 * @static
 	 * @method core.treeModel.LivePosition.createAfter
-	 * @see {@link core.treeModel.Position#createAfter}
+	 * @see core.treeModel.Position.createAfter
 	 * @param {core.treeModel.Node} node
 	 * @returns {core.treeModel.LivePosition}
 	 */
@@ -77,7 +77,7 @@ export default class LivePosition extends Position {
 	/**
 	 * @static
 	 * @method core.treeModel.LivePosition.createBefore
-	 * @see {@link core.treeModel.Position#createBefore}
+	 * @see core.treeModel.Position.createBefore
 	 * @param {core.treeModel.Node} node
 	 * @returns {core.treeModel.LivePosition}
 	 */
@@ -85,7 +85,7 @@ export default class LivePosition extends Position {
 	/**
 	 * @static
 	 * @method core.treeModel.LivePosition.createFromParentAndOffset
-	 * @see {@link core.treeModel.Position#createFromParentAndOffset}
+	 * @see core.treeModel.Position.createFromParentAndOffset
 	 * @param {core.treeModel.Element} parent
 	 * @param {Number} offset
 	 * @returns {core.treeModel.LivePosition}
@@ -94,7 +94,7 @@ export default class LivePosition extends Position {
 	/**
 	 * @static
 	 * @method core.treeModel.LivePosition.createFromPosition
-	 * @see {@link core.treeModel.Position#createFromPosition}
+	 * @see core.treeModel.Position.createFromPosition
 	 * @param {core.treeModel.Position} position
 	 * @returns {core.treeModel.LivePosition}
 	 */

+ 4 - 4
packages/ckeditor5-engine/src/treemodel/liverange.js

@@ -45,7 +45,7 @@ export default class LiveRange extends Range {
 	}
 
 	/**
-	 * @see {@link core.treeModel.Range#createFromElement}
+	 * @see core.treeModel.Range.createFromElement
 	 * @static
 	 * @method core.treeModel.LiveRange.createFromElement
 	 * @param {core.treeModel.Element} element
@@ -53,7 +53,7 @@ export default class LiveRange extends Range {
 	 */
 
 	/**
-	 * @see {@link core.treeModel.Range#createFromPositionAndShift}
+	 * @see core.treeModel.Range.createFromPositionAndShift
 	 * @static
 	 * @method core.treeModel.LiveRange.createFromPositionAndShift
 	 * @param {core.treeModel.Position} position
@@ -62,7 +62,7 @@ export default class LiveRange extends Range {
 	 */
 
 	/**
-	 * @see {@link core.treeModel.Range#createFromParentsAndOffsets}
+	 * @see core.treeModel.Range.createFromParentsAndOffsets
 	 * @static
 	 * @method core.treeModel.LiveRange.createFromParentsAndOffsets
 	 * @param {core.treeModel.Element} startElement
@@ -73,7 +73,7 @@ export default class LiveRange extends Range {
 	 */
 
 	/**
-	 * @see {@link core.treeModel.Range#createFromRange}
+	 * @see core.treeModel.Range.createFromRange
 	 * @static
 	 * @method core.treeModel.LiveRange.createFromRange
 	 * @param {core.treeModel.Range} range

+ 2 - 2
packages/ckeditor5-engine/src/treemodel/nodelist.js

@@ -20,7 +20,7 @@ import CKEditorError from '../ckeditorerror.js';
  */
 class NodeListText extends Text {
 	/**
-	 * @see {@link core.treeModel.Text#constructor}
+	 * @see core.treeModel.Text#constructor
 	 * @protected
 	 * @constructor
 	 */
@@ -94,7 +94,7 @@ export default class NodeList {
 	 *		nodeListA === nodeListB // true
 	 *		nodeListB.length // 3
 	 *
-	 * @see {@link core.treeModel.NodeSet} for more explanation.
+	 * @see core.treeModel.NodeSet
 	 *
 	 * @param {core.treeModel.NodeSet} nodes List of nodes.
 	 * @constructor

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

@@ -51,10 +51,26 @@ export default class MoveOperation extends Operation {
 		this.targetPosition = Position.createFromPosition( targetPosition );
 	}
 
+	/**
+	 * @see core.treeModel.operation.Operation#type
+	 */
 	get type() {
 		return 'move';
 	}
 
+	/**
+	 * Defines whether `MoveOperation` is sticky. If `MoveOperation` is sticky, during
+	 * {@link core.treeModel.operation.transform operational transformation} if there will be an operation that
+	 * inserts some nodes at the position equal to the boundary of this `MoveOperation`, that operation will
+	 * get their insertion path updated to the position where this `MoveOperation` moves the range.
+	 *
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get isSticky() {
+		return true;
+	}
+
 	/**
 	 * @returns {core.treeModel.operation.MoveOperation}
 	 */
@@ -66,7 +82,10 @@ export default class MoveOperation extends Operation {
 	 * @returns {core.treeModel.operation.MoveOperation}
 	 */
 	getReversed() {
-		return new this.constructor( this.targetPosition, this.howMany, this.sourcePosition, this.baseVersion + 1 );
+		let newSourcePosition = this.targetPosition.getTransformedByDeletion( this.sourcePosition, this.howMany );
+		let newTargetPosition = this.sourcePosition.getTransformedByInsertion( this.targetPosition, this.howMany );
+
+		return new this.constructor( newSourcePosition, this.howMany, newTargetPosition, this.baseVersion + 1 );
 	}
 
 	_execute() {

+ 17 - 0
packages/ckeditor5-engine/src/treemodel/operation/operation.js

@@ -5,6 +5,8 @@
 
 'use strict';
 
+import clone from '../../lib/lodash/clone.js';
+
 /**
  * Abstract base operation class.
  *
@@ -71,4 +73,19 @@ export default class Operation {
 		 * property containing changed nodes. May have additional properties depending on the operation type.
 		 */
 	}
+
+	/**
+	 * Custom toJSON method to solve child-parent circular dependencies.
+	 *
+	 * @method core.treeModel.operation.Operation#toJSON
+	 * @returns {Object} Clone of this object with the delta property replaced with string.
+	 */
+	toJSON() {
+		const json = clone( this );
+
+		// Due to circular references we need to remove parent reference.
+		json.delta = this.delta ? '[core.treeModel.Delta]' : null;
+
+		return json;
+	}
 }

+ 10 - 0
packages/ckeditor5-engine/src/treemodel/operation/reinsertoperation.js

@@ -28,7 +28,17 @@ export default class ReinsertOperation extends MoveOperation {
 		return new RemoveOperation( this.targetPosition, this.howMany, this.baseVersion + 1 );
 	}
 
+	/**
+	 * @see core.treeModel.operation.Operation#type
+	 */
 	get type() {
 		return 'reinsert';
 	}
+
+	/**
+	 * @see core.treeModel.operation.MoveOperation#isSticky
+	 */
+	get isSticky() {
+		return false;
+	}
 }

+ 10 - 0
packages/ckeditor5-engine/src/treemodel/operation/removeoperation.js

@@ -31,10 +31,20 @@ export default class RemoveOperation extends MoveOperation {
 		super( position, howMany, graveyardPosition, baseVersion );
 	}
 
+	/**
+	 * @see core.treeModel.operation.Operation#type
+	 */
 	get type() {
 		return 'remove';
 	}
 
+	/**
+	 * @see core.treeModel.operation.MoveOperation#isSticky
+	 */
+	get isSticky() {
+		return false;
+	}
+
 	/**
 	 * @returns {core.treeModel.operation.ReinsertOperation}
 	 */

+ 62 - 77
packages/ckeditor5-engine/src/treemodel/operation/transform.js

@@ -8,8 +8,8 @@
 import InsertOperation from './insertoperation.js';
 import AttributeOperation from './attributeoperation.js';
 import MoveOperation from './moveoperation.js';
+import RemoveOperation from './removeoperation.js';
 import NoOperation from './nooperation.js';
-import Position from '../position.js';
 import Range from '../range.js';
 import isEqual from '../../lib/lodash/isEqual.js';
 import utils from '../../utils.js';
@@ -76,9 +76,10 @@ const ot = {
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
 		MoveOperation( a, b, isStrong ) {
 			const transformed = a.clone();
+			const moveTargetPosition = b.targetPosition.getTransformedByDeletion( b.sourcePosition, b.howMany );
 
 			// Transform insert position by the other operation parameters.
-			transformed.position = a.position.getTransformedByMove( b.sourcePosition, b.targetPosition, b.howMany, !isStrong );
+			transformed.position = a.position.getTransformedByMove( b.sourcePosition, moveTargetPosition, b.howMany, !isStrong, b.isSticky );
 
 			return [ transformed ];
 		}
@@ -88,7 +89,7 @@ const ot = {
 		// Transforms AttributeOperation `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 );
+			const ranges = a.range.getTransformedByInsertion( b.position, b.nodeList.length, true, false );
 
 			// Map transformed range(s) to operations and return them.
 			return ranges.reverse().map( ( range ) => {
@@ -101,19 +102,17 @@ const ot = {
 		AttributeOperation( a, b, isStrong ) {
 			if ( a.key === b.key ) {
 				// 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 AttributeOperation( range, a.key, a.oldValue, a.newValue, a.baseVersion );
-					} )
-				);
+				let operations = a.range.getDifference( b.range ).map( ( range ) => {
+					return new AttributeOperation( range, a.key, a.oldValue, a.newValue, a.baseVersion );
+				} );
 
+				// Then we take care of the common part of ranges, but only if operations has different `newValue`.
 				if ( isStrong && !isEqual( a.newValue, b.newValue ) ) {
-					// If this operation is more important, we want also want to apply change to the part of the
+					// If this operation is more important, we 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.
+					// got changed we also have to update `oldValue`.
 					const common = a.range.getIntersection( b.range );
 
 					if ( common !== null ) {
@@ -145,10 +144,9 @@ const ot = {
 			// This will aggregate transformed ranges.
 			let ranges = [];
 
-			// Difference is a part of changed range that is modified by AttributeOperation but are not affected
+			// Difference is a part of changed range that is modified by AttributeOperation but is 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.
+			// Right now we will make a simplification and join difference ranges and transform them as one. We will cover rangeB later.
 			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.
@@ -167,7 +165,7 @@ const ot = {
 				// 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();
+				ranges = difference.getTransformedByInsertion( newTargetPosition, b.howMany, true, false ).reverse();
 			}
 
 			if ( common !== null ) {
@@ -191,22 +189,18 @@ const ot = {
 		// 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.createFromPositionAndShift( a.sourcePosition, a.howMany );
-			const ranges = rangeB.getTransformedByInsertion( b.position, b.nodeList.length, true );
+			// Create range from MoveOperation properties and transform it by insertion.
+			let range = Range.createFromPositionAndShift( a.sourcePosition, a.howMany );
+			range = range.getTransformedByInsertion( b.position, b.nodeList.length, false, a.isSticky )[ 0 ];
 
-			// Map transformed range(s) to operations and return them.
-			return ranges.reverse().map( ( range ) => {
-				return new MoveOperation(
+			return [
+				new a.constructor(
 					range.start,
 					range.end.offset - range.start.offset,
-					Position.createFromPosition( newTargetPosition ),
-					a.baseVersion
-				);
-			} );
+					a instanceof RemoveOperation ? a.baseVersion : a.targetPosition.getTransformedByInsertion( b.position, b.nodeList.length, !isStrong ),
+					a instanceof RemoveOperation ? undefined : a.baseVersion
+				)
+			];
 		},
 
 		AttributeOperation: doNotUpdate,
@@ -223,58 +217,39 @@ const ot = {
 				return [ b.getReversed() ];
 			}
 
+			// If one of operations is actually a remove operation, we force remove operation to be the "stronger" one
+			// to provide more expected results.
+			if ( a instanceof RemoveOperation && !( b instanceof RemoveOperation ) ) {
+				isStrong = true;
+			} else if ( !( a instanceof RemoveOperation ) && b instanceof RemoveOperation ) {
+				isStrong = false;
+			}
+
+			let isSticky = a.isSticky && b.isSticky;
+
 			// Create ranges from MoveOperations properties.
 			const rangeA = Range.createFromPositionAndShift( a.sourcePosition, a.howMany );
 			const rangeB = Range.createFromPositionAndShift( 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.howMany,
-						a.targetPosition._getCombined( b.sourcePosition, b.targetPosition ),
-						a.baseVersion
-					)
-				];
-			}
-
-			// All the other non-special cases are treated by generic algorithm below.
-
-			const differenceSet = rangeA.getDifference( rangeB );
-			const common = rangeA.getIntersection( rangeB );
+			// Get target position from the state "after" nodes specified by other MoveOperation are "detached".
+			const moveTargetPosition = b.targetPosition.getTransformedByDeletion( b.sourcePosition, b.howMany );
 
 			// 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 );
+			// All the other non-special cases are treated by generic algorithm below.
+			let difference = joinRanges( rangeA.getDifference( rangeB ) );
 
-			// 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 );
+			if ( difference ) {
+				difference.start = difference.start.getTransformedByMove( b.sourcePosition, moveTargetPosition, b.howMany, !isSticky, false );
+				difference.end = difference.end.getTransformedByMove( b.sourcePosition, moveTargetPosition, b.howMany, isSticky, false );
 
-				// 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 ) );
+				ranges.push( difference );
 			}
 
 			// Then, we have to manage the common part of both move ranges.
+			const common = rangeA.getIntersection( rangeB );
+
 			// 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
@@ -282,7 +257,19 @@ const ot = {
 			// 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() ) == 'PREFIX';
 
-			if ( common !== null && ( isDeeper || isStrong ) ) {
+			// If the `b` MoveOperation points inside the `a` MoveOperation range, the common part will be included in
+			// range(s) that (is) are results of processing `difference`. If that's the case, we cannot include it again.
+			let bIsIncluded = rangeA.containsPosition( b.targetPosition ) ||
+				( rangeA.start.isEqual( b.targetPosition ) && isSticky ) ||
+				( rangeA.end.isEqual( b.targetPosition ) && isSticky );
+
+			// If the `b` MoveOperation range contains both whole `a` range and target position we do an exception and
+			// transform `a` operation. Normally, when same nodes are moved, we stick with stronger operation's target.
+			// Here it is a move inside larger range so there is no conflict because after all, all nodes from
+			// smaller range will be moved to larger range target. The effect of this transformation feels natural.
+			let aIsInside = rangeB.containsRange( rangeA ) && rangeB.containsPosition( a.targetPosition );
+
+			if ( common !== null && ( isDeeper || isStrong || aIsInside ) && !bIsIncluded ) {
 				// 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.
@@ -290,13 +277,10 @@ const ot = {
 				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 ) ) {
+				if ( difference && difference.start.isBefore( common.start ) ) {
 					ranges.push( common );
-				} else if ( rangeB.start.isBefore( rangeA.start ) ) {
-					ranges.unshift( common );
 				} else {
-					ranges.splice( 1, 0, common );
+					ranges.unshift( common );
 				}
 			}
 
@@ -307,15 +291,16 @@ const ot = {
 			}
 
 			// 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 );
+			let newTargetPosition = a.targetPosition.getTransformedByMove( b.sourcePosition, moveTargetPosition, b.howMany, !isStrong, isSticky );
 
 			// Map transformed range(s) to operations and return them.
 			return ranges.reverse().map( ( range ) => {
-				return new MoveOperation(
+				// We want to keep correct operation class.
+				return new a.constructor(
 					range.start,
 					range.end.offset - range.start.offset,
-					newTargetPosition,
-					a.baseVersion
+					a instanceof RemoveOperation ? a.baseVersion : newTargetPosition,
+					a instanceof RemoveOperation ? undefined : a.baseVersion
 				);
 			} );
 		}

+ 32 - 26
packages/ckeditor5-engine/src/treemodel/position.js

@@ -188,6 +188,7 @@ export default class Position {
 	 * 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.
 	 *
+	 * @protected
 	 * @param {core.treeModel.Position} deletePosition Position before the first removed node.
 	 * @param {Number} howMany How many nodes are removed.
 	 * @returns {core.treeModel.Position|null} Transformed position or `null`.
@@ -204,10 +205,11 @@ export default class Position {
 			// 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;
+					// Position is in removed range, it's no longer in the tree.
+					return null;
 				} else {
+					// Decrement the offset accordingly.
 					transformed.offset -= howMany;
 				}
 			}
@@ -215,7 +217,7 @@ export default class Position {
 			// 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 ] ) {
+			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
@@ -234,6 +236,7 @@ export default class Position {
 	/**
 	 * Returns this position after being updated by inserting `howMany` nodes at `insertPosition`.
 	 *
+	 * @protected
 	 * @param {core.treeModel.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`.
@@ -271,28 +274,31 @@ export default class Position {
 	}
 
 	/**
-	 * Returns this position after being updated by moving `howMany` attributes from `sourcePosition` to `targetPosition`.
+	 * Returns this position after being updated by moving `howMany` nodes from `sourcePosition` to `targetPosition`.
 	 *
+	 * @protected
 	 * @param {core.treeModel.Position} sourcePosition Position before the first element to move.
 	 * @param {core.treeModel.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.
+	 * @param {Boolean} [sticky] Flag indicating whether this position "sticks" to range, that is if it should be moved
+	 * with the moved range if it is equal to one of range's boundaries.
 	 * @returns {core.treeModel.Position} Transformed position.
 	 */
-	getTransformedByMove( sourcePosition, targetPosition, howMany, insertBefore ) {
+	getTransformedByMove( sourcePosition, targetPosition, howMany, insertBefore, sticky ) {
 		// 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.
+		if ( transformed === null || ( transformed.isEqual( sourcePosition ) && sticky ) ) {
+			// This position is inside moved range (or sticks to it).
+			// In this case, we calculate a combination of this position, move source position and target position.
 			transformed = this._getCombined( sourcePosition, targetPosition );
+		} else {
+			// This position is not inside a removed range.
+			// In next step, we simply reflect inserting `howMany` nodes, which might further affect the position.
+			transformed = transformed.getTransformedByInsertion( targetPosition, howMany, insertBefore );
 		}
 
 		return transformed;
@@ -301,7 +307,7 @@ export default class Position {
 	/**
 	 * Checks whether this position is after given position.
 	 *
-	 * **Note:** see {@link core.treeModel.Position#isBefore}.
+	 * @see core.treeModel.Position#isBefore
 	 *
 	 * @param {core.treeModel.Position} otherPosition Position to compare with.
 	 * @returns {Boolean} True if this position is after given position.
@@ -318,25 +324,25 @@ export default class Position {
 	 * `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.
-	 *  }
+	 *		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.
-	 *  }
+	 *		if ( !( a.isBefore( b ) && c.isAfter( d ) ) {
+	 *			// do B.
+	 *		}
 	 *
 	 * rather than:
 	 *
-	 *  if ( !a.isBefore( b ) || && !c.isAfter( d ) ) {
-	 *    // do B.
-	 *  } else {
-	 *    // do A.
-	 *  }
+	 *		if ( !a.isBefore( b ) || && !c.isAfter( d ) ) {
+	 *			// do B.
+	 *		} else {
+	 *			// do A.
+	 *		}
 	 *
 	 * @param {core.treeModel.Position} otherPosition Position to compare with.
 	 * @returns {Boolean} True if this position is before given position.

+ 26 - 19
packages/ckeditor5-engine/src/treemodel/range.js

@@ -26,15 +26,15 @@ export default class Range {
 		/**
 		 * Start position.
 		 *
-		 * @private
-		 * @member {core.treeModel.Position}  core.treeModel.Range#start
+		 * @readonly
+		 * @member {core.treeModel.Position} core.treeModel.Range#start
 		 */
 		this.start = Position.createFromPosition( start );
 
 		/**
 		 * End position.
 		 *
-		 * @private
+		 * @readonly
 		 * @member {core.treeModel.Position} core.treeModel.Range#end
 		 */
 		this.end = Position.createFromPosition( end );
@@ -337,7 +337,7 @@ export default class Range {
 	/**
 	 * Returns an array containing one or two {core.treeModel.Range ranges} that are a result of transforming this
 	 * {@link core.treeModel.Range range} by inserting `howMany` nodes at `insertPosition`. Two {@link core.treeModel.Range ranges} are
-	 * returned if the insertion was inside this {@link core.treeModel.Range range}.
+	 * returned if the insertion was inside this {@link core.treeModel.Range range} and `spread` is set to `true`.
 	 *
 	 * Examples:
 	 *
@@ -345,24 +345,34 @@ export default class Range {
 	 *		let transformed = range.getTransformedByInsertion( new Position( root, [ 1 ] ), 2 );
 	 *		// transformed array has one range from [ 4, 7 ] to [ 6, 0, 1 ]
 	 *
+	 *		transformed = range.getTransformedByInsertion( new Position( root, [ 4, 0, 0 ] ), 4 );
+	 *		// transformed array has one range from [ 2, 7 ] to [ 4, 0, 5 ]
+	 *
 	 *		transformed = range.getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 4 );
-	 *		// transformed array has two ranges: from [ 2, 7 ] to [ 3, 2 ] and from [ 3, 6 ] to [ 4, 0, 1 ]
+	 *		// transformed array has one range, which is equal to original range
 	 *
 	 *		transformed = range.getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 4, true );
-	 *		// transformed array has one range which is equal to `range`. This is because of spreadOnlyOnSameLevel flag.
+	 *		// transformed array has two ranges: from [ 2, 7 ] to [ 3, 2 ] and from [ 3, 6 ] to [ 4, 0, 1 ]
 	 *
+	 *		transformed = range.getTransformedByInsertion( new Position( root, [ 4, 0, 1 ] ), 4, false, false );
+	 *		// transformed array has one range which is equal to original range because insertion is after the range boundary
+	 *
+	 *		transformed = range.getTransformedByInsertion( new Position( root, [ 4, 0, 1 ] ), 4, false, true );
+	 *		// transformed array has one range: from [ 2, 7 ] to [ 4, 0, 5 ] because range was expanded
+	 *
+	 * @protected
 	 * @param {core.treeModel.Position} insertPosition Position where nodes are inserted.
 	 * @param {Number} howMany How many nodes are inserted.
-	 * @param {Boolean} spreadOnlyOnSameLevel Flag indicating whether this {core.treeModel.Range range} should be spread
-	 * if insertion was inside a node from this {core.treeModel.Range range} but not in the range itself.
+	 * @param {Boolean} [spread] Flag indicating whether this {core.treeModel.Range range} should be spread if insertion
+	 * was inside the range. Defaults to `false`.
+	 * @param {Boolean} [isSticky] Flag indicating whether insertion should expand a range if it is in a place of
+	 * range boundary. Defaults to `false`.
 	 * @returns {Array.<core.treeModel.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() ) == 'SAME' &&
-			utils.compareArrays( this.start.getParentPath(), insertPosition.getParentPath() ) == 'SAME';
+	getTransformedByInsertion( insertPosition, howMany, spread, isSticky ) {
+		isSticky = !!isSticky;
 
-		if ( this.containsPosition( insertPosition ) && ( !spreadOnlyOnSameLevel || areOnSameLevel ) ) {
+		if ( spread && this.containsPosition( insertPosition ) ) {
 			// 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.
@@ -371,17 +381,14 @@ export default class Range {
 				new Range( this.start, insertPosition ),
 				new Range(
 					insertPosition.getTransformedByInsertion( insertPosition, howMany, true ),
-					this.end.getTransformedByInsertion( insertPosition, howMany, true )
+					this.end.getTransformedByInsertion( insertPosition, howMany, false )
 				)
 			];
 		} 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 = Range.createFromRange( this );
 
-			range.start = range.start.getTransformedByInsertion( insertPosition, howMany, true );
-			range.end = range.end.getTransformedByInsertion( insertPosition, howMany, false );
+			range.start = range.start.getTransformedByInsertion( insertPosition, howMany, !isSticky );
+			range.end = range.end.getTransformedByInsertion( insertPosition, howMany, isSticky );
 
 			return [ range ];
 		}

+ 1 - 1
packages/ckeditor5-engine/src/ui/template.js

@@ -46,7 +46,7 @@ export default class Template {
 	 * **Note:** No new DOM nodes (elements, text nodes) will be created.
 	 *
 	 * @see core.ui.Template#render
-	 * @see View#applyTemplateToElement.
+	 * @see core.ui.View#applyTemplateToElement.
 	 *
 	 * @param {Node} element Root element for template to apply.
 	 */

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

@@ -6,8 +6,12 @@
 /* bender-tags: treemodel, delta */
 
 'use strict';
+
+import deltas from '/ckeditor5/core/treemodel/delta/basic-deltas.js';
+/*jshint unused: false*/
+
 import Batch from '/ckeditor5/core/treemodel/batch.js';
-import { register } from '/ckeditor5/core/treemodel/batch-base.js';
+import { register } from '/ckeditor5/core/treemodel/batch.js';
 import Delta from '/ckeditor5/core/treemodel/delta/delta.js';
 import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
 

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

@@ -14,14 +14,22 @@ import Range from '/ckeditor5/core/treemodel/range.js';
 import Position from '/ckeditor5/core/treemodel/position.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
 
+import AttributeDelta from '/ckeditor5/core/treemodel/delta/attributedelta.js';
+import AttributeOperation from '/ckeditor5/core/treemodel/operation/attributeoperation.js';
+
 const getIteratorCount = coreTestUtils.getIteratorCount;
 
+let doc, root;
+
+beforeEach( () => {
+	doc = new Document();
+	root = doc.createRoot( 'root' );
+} );
+
 describe( 'Batch', () => {
-	let doc, root, batch;
+	let batch;
 
 	beforeEach( () => {
-		doc = new Document();
-		root = doc.createRoot( 'root' );
 		batch = doc.batch();
 	} );
 
@@ -328,3 +336,94 @@ describe( 'Batch', () => {
 		} );
 	} );
 } );
+
+describe( 'AttributeDelta', () => {
+	let delta;
+
+	beforeEach( () => {
+		delta = new AttributeDelta();
+	} );
+
+	describe( 'key', () => {
+		it( 'should be null if there are no operations in delta', () => {
+			expect( delta.key ).to.be.null;
+		} );
+
+		it( 'should be equal to attribute operations key that are in delta', () => {
+			let range = new Range( new Position( root, [ 1 ] ), new Position( root, [ 2 ] ) );
+			delta.addOperation( new AttributeOperation( range, 'key', 'old', 'new', 0 ) );
+
+			expect( delta.key ).to.equal( 'key' );
+		} );
+	} );
+
+	describe( 'value', () => {
+		it( 'should be null if there are no operations in delta', () => {
+			expect( delta.value ).to.be.null;
+		} );
+
+		it( 'should be equal to the value set by the delta operations', () => {
+			let range = new Range( new Position( root, [ 1 ] ), new Position( root, [ 2 ] ) );
+			delta.addOperation( new AttributeOperation( range, 'key', 'old', 'new', 0 ) );
+
+			expect( delta.value ).to.equal( 'new' );
+		} );
+	} );
+
+	describe( 'range', () => {
+		it( 'should be null if there are no operations in delta', () => {
+			expect( delta.range ).to.be.null;
+		} );
+
+		it( 'should be equal to the range on which delta operates', () => {
+			// Delta operates on range [ 1 ] to [ 6 ] but omits [ 4 ] - [ 5 ] for "a reason".
+			// Still the range should be from [ 1 ] to [ 6 ]. Delta may not apply anything on [ 4 ] - [ 5 ]
+			// because it already has proper attribute.
+			let rangeA = new Range( new Position( root, [ 1 ] ), new Position( root, [ 2 ] ) );
+			let rangeB = new Range( new Position( root, [ 2 ] ), new Position( root, [ 4 ] ) );
+			let rangeC = new Range( new Position( root, [ 5 ] ), new Position( root, [ 6 ] ) );
+
+			delta.addOperation( new AttributeOperation( rangeA, 'key', 'oldA', 'new', 0 ) );
+			delta.addOperation( new AttributeOperation( rangeB, 'key', 'oldB', 'new', 1 ) );
+			delta.addOperation( new AttributeOperation( rangeC, 'key', 'oldC', 'new', 2 ) );
+
+			expect( delta.range.start.path ).to.deep.equal( [ 1 ] );
+			expect( delta.range.end.path ).to.deep.equal( [ 6 ] );
+		} );
+	} );
+
+	describe( 'getReversed', () => {
+		it( 'should return empty AttributeDelta if there are no operations in delta', () => {
+			let reversed = delta.getReversed();
+
+			expect( reversed ).to.be.instanceof( AttributeDelta );
+			expect( reversed.operations.length ).to.equal( 0 );
+		} );
+
+		it( 'should return correct AttributeDelta', () => {
+			let rangeA = new Range( new Position( root, [ 1 ] ), new Position( root, [ 2 ] ) );
+			let rangeB = new Range( new Position( root, [ 2 ] ), new Position( root, [ 4 ] ) );
+
+			delta.addOperation( new AttributeOperation( rangeA, 'key', 'oldA', 'new', 0 ) );
+			delta.addOperation( new AttributeOperation( rangeB, 'key', 'oldB', 'new', 1 ) );
+
+			let reversed = delta.getReversed();
+
+			expect( reversed ).to.be.instanceof( AttributeDelta );
+			expect( reversed.operations.length ).to.equal( 2 );
+
+			// Remember about reversed operations order.
+			expect( reversed.operations[ 0 ] ).to.be.instanceof( AttributeOperation );
+			expect( reversed.operations[ 0 ].range.isEqual( rangeB ) ).to.be.true;
+			expect( reversed.operations[ 0 ].key ).to.equal( 'key' );
+			expect( reversed.operations[ 0 ].oldValue ).to.equal( 'new' );
+			expect( reversed.operations[ 0 ].newValue ).to.equal( 'oldB' );
+
+			expect( reversed.operations[ 1 ] ).to.be.instanceof( AttributeOperation );
+			expect( reversed.operations[ 1 ].range.isEqual( rangeA ) ).to.be.true;
+			expect( reversed.operations[ 1 ].key ).to.equal( 'key' );
+			expect( reversed.operations[ 1 ].oldValue ).to.equal( 'new' );
+			expect( reversed.operations[ 1 ].newValue ).to.equal( 'oldA' );
+		} );
+	} );
+} );

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

@@ -9,6 +9,27 @@
 
 import coreTestUtils from '/tests/core/_utils/utils.js';
 import Delta from '/ckeditor5/core/treemodel/delta/delta.js';
+import Operation from '/ckeditor5/core/treemodel/operation/operation.js';
+
+// Some test examples of operations.
+class FooOperation extends Operation {
+	constructor( string, baseVersion ) {
+		super( baseVersion );
+		this.string = string;
+	}
+
+	getReversed() {
+		/* jshint ignore:start */
+		return new BarOperation( this.string, this.baseVersion );
+		/* jshint ignore:end */
+	}
+}
+
+class BarOperation extends FooOperation {
+	getReversed() {
+		return new FooOperation( this.string, this.baseVersion );
+	}
+}
 
 const getIteratorCount = coreTestUtils.getIteratorCount;
 
@@ -56,4 +77,30 @@ describe( 'Delta', () => {
 			expect( count ).to.equal( 3 );
 		} );
 	} );
+
+	describe( 'getReversed', () => {
+		it( 'should return empty Delta if there are no operations in delta', () => {
+			const delta = new Delta();
+			let reversed = delta.getReversed();
+
+			expect( reversed ).to.be.instanceof( Delta );
+			expect( reversed.operations.length ).to.equal( 0 );
+		} );
+
+		it( 'should return Delta with all operations reversed and their order reversed', () => {
+			const delta = new Delta();
+			delta.addOperation( new FooOperation( 'a', 1 ) );
+			delta.addOperation( new BarOperation( 'b', 2 ) );
+
+			let reversed = delta.getReversed();
+
+			expect( reversed ).to.be.instanceof( Delta );
+			expect( reversed.operations.length ).to.equal( 2 );
+
+			expect( reversed.operations[ 0 ] ).to.be.instanceOf( FooOperation );
+			expect( reversed.operations[ 0 ].string ).to.equal( 'b' );
+			expect( reversed.operations[ 1 ] ).to.be.instanceOf( BarOperation );
+			expect( reversed.operations[ 1 ].string ).to.equal( 'a' );
+		} );
+	} );
 } );

+ 73 - 0
packages/ckeditor5-engine/tests/treemodel/delta/insertdelta.js

@@ -11,6 +11,12 @@ import Document from '/ckeditor5/core/treemodel/document.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
 import Position from '/ckeditor5/core/treemodel/position.js';
 
+import InsertOperation from '/ckeditor5/core/treemodel/operation/insertoperation.js';
+import InsertDelta from '/ckeditor5/core/treemodel/delta/insertdelta.js';
+
+import RemoveDelta from '/ckeditor5/core/treemodel/delta/removedelta.js';
+import RemoveOperation from '/ckeditor5/core/treemodel/operation/removeoperation.js';
+
 describe( 'Batch', () => {
 	let doc, root, batch, p, ul, chain;
 
@@ -39,3 +45,70 @@ describe( 'Batch', () => {
 		} );
 	} );
 } );
+
+describe( 'InsertDelta', () => {
+	let insertDelta, doc, root;
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+		insertDelta = new InsertDelta();
+	} );
+
+	describe( 'constructor', () => {
+		it( 'should create insert delta with no operations added', () => {
+			expect( insertDelta.operations.length ).to.equal( 0 );
+		} );
+	} );
+
+	describe( 'position', () => {
+		it( 'should be null if there are no operations in delta', () => {
+			expect( insertDelta.position ).to.be.null;
+		} );
+
+		it( 'should be equal to the position where node is inserted', () => {
+			insertDelta.operations.push( new InsertOperation( new Position( root, [ 1, 2, 3 ] ), new Element( 'x' ), 0 ) );
+
+			expect( insertDelta.position.root ).to.equal( root );
+			expect( insertDelta.position.path ).to.deep.equal( [ 1, 2, 3 ] );
+		} );
+	} );
+
+	describe( 'nodeList', () => {
+		it( 'should be null if there are no operations in delta', () => {
+			expect( insertDelta.nodeList ).to.be.null;
+		} );
+
+		it( 'should be equal to the node list inserted by the delta', () => {
+			let elementX = new Element( 'x' );
+			insertDelta.operations.push( new InsertOperation( new Position( root, [ 1, 2, 3 ] ), elementX, 0 ) );
+
+			expect( insertDelta.nodeList.length ).to.equal( 1 );
+			expect( insertDelta.nodeList.get( 0 ) ).to.equal( elementX );
+		} );
+	} );
+
+	describe( 'getReversed', () => {
+		it( 'should return empty RemoveDelta if there are no operations in delta', () => {
+			let reversed = insertDelta.getReversed();
+
+			expect( reversed ).to.be.instanceof( RemoveDelta );
+			expect( reversed.operations.length ).to.equal( 0 );
+		} );
+
+		it( 'should return correct RemoveDelta', () => {
+			let position = new Position( root, [ 1, 2, 3 ] );
+			let elementX = new Element( 'x' );
+			insertDelta.operations.push( new InsertOperation( position, elementX, 0 ) );
+
+			let reversed = insertDelta.getReversed();
+
+			expect( reversed ).to.be.instanceof( RemoveDelta );
+			expect( reversed.operations.length ).to.equal( 1 );
+
+			expect( reversed.operations[ 0 ] ).to.be.instanceof( RemoveOperation );
+			expect( reversed.operations[ 0 ].sourcePosition.isEqual( position ) ).to.be.true;
+			expect( reversed.operations[ 0 ].howMany ).to.equal( 1 );
+		} );
+	} );
+} );

+ 65 - 0
packages/ckeditor5-engine/tests/treemodel/delta/mergedelta.js

@@ -12,6 +12,13 @@ import Position from '/ckeditor5/core/treemodel/position.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
 import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
 
+import MergeDelta from '/ckeditor5/core/treemodel/delta/mergedelta.js';
+import SplitDelta from '/ckeditor5/core/treemodel/delta/splitdelta.js';
+
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+import RemoveOperation from '/ckeditor5/core/treemodel/operation/removeoperation.js';
+import ReinsertOperation from '/ckeditor5/core/treemodel/operation/reinsertoperation.js';
+
 describe( 'Batch', () => {
 	let doc, root, p1, p2;
 
@@ -62,3 +69,61 @@ describe( 'Batch', () => {
 		} );
 	} );
 } );
+
+describe( 'MergeDelta', () => {
+	let mergeDelta, doc, root;
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+		mergeDelta = new MergeDelta();
+	} );
+
+	describe( 'constructor', () => {
+		it( 'should create merge delta with no operations added', () => {
+			expect( mergeDelta.operations.length ).to.equal( 0 );
+		} );
+	} );
+
+	describe( 'position', () => {
+		it( 'should be null if there are no operations in delta', () => {
+			expect( mergeDelta.position ).to.be.null;
+		} );
+
+		it( 'should be equal to the position between merged nodes', () => {
+			mergeDelta.operations.push( new MoveOperation( new Position( root, [ 1, 2, 0 ] ), 4, new Position( root, [ 1, 1, 4 ] ) ) );
+			mergeDelta.operations.push( new RemoveOperation( new Position( root, [ 1, 2, 0 ] ), 1 ) );
+
+			expect( mergeDelta.position.root ).to.equal( root );
+			expect( mergeDelta.position.path ).to.deep.equal( [ 1, 2, 0 ] );
+		} );
+	} );
+
+	describe( 'getReversed', () => {
+		it( 'should return empty SplitDelta if there are no operations in delta', () => {
+			let reversed = mergeDelta.getReversed();
+
+			expect( reversed ).to.be.instanceof( SplitDelta );
+			expect( reversed.operations.length ).to.equal( 0 );
+		} );
+
+		it( 'should return correct SplitDelta', () => {
+			mergeDelta.operations.push( new MoveOperation( new Position( root, [ 1, 2, 0 ] ), 4, new Position( root, [ 1, 1, 4 ] ) ) );
+			mergeDelta.operations.push( new RemoveOperation( new Position( root, [ 1, 2, 0 ] ), 1 ) );
+
+			let reversed = mergeDelta.getReversed();
+
+			expect( reversed ).to.be.instanceof( SplitDelta );
+			expect( reversed.operations.length ).to.equal( 2 );
+
+			expect( reversed.operations[ 0 ] ).to.be.instanceof( ReinsertOperation );
+			expect( reversed.operations[ 0 ].howMany ).to.equal( 1 );
+			expect( reversed.operations[ 0 ].targetPosition.path ).to.deep.equal( [ 1, 2, 0 ] );
+
+			expect( reversed.operations[ 1 ] ).to.be.instanceof( MoveOperation );
+			expect( reversed.operations[ 1 ].sourcePosition.path ).to.deep.equal( [ 1, 1, 4 ] );
+			expect( reversed.operations[ 1 ].howMany ).to.equal( 4 );
+			expect( reversed.operations[ 1 ].targetPosition.path ).to.deep.equal( [ 1, 2, 0 ] );
+		} );
+	} );
+} );

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

@@ -14,6 +14,9 @@ import Range from '/ckeditor5/core/treemodel/range.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
 import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
 
+import MoveDelta from '/ckeditor5/core/treemodel/delta/movedelta.js';
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+
 const getNodesAndText = treeModelTestUtils.getNodesAndText;
 
 describe( 'Batch', () => {
@@ -66,3 +69,80 @@ describe( 'Batch', () => {
 		} );
 	} );
 } );
+
+describe( 'MoveDelta', () => {
+	let moveDelta, doc, root;
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+		moveDelta = new MoveDelta();
+	} );
+
+	describe( 'constructor', () => {
+		it( 'should create move delta with no operations added', () => {
+			expect( moveDelta.operations.length ).to.equal( 0 );
+		} );
+	} );
+
+	describe( 'sourcePosition', () => {
+		it( 'should be null if there are no operations in delta', () => {
+			expect( moveDelta.sourcePosition ).to.be.null;
+		} );
+
+		it( 'should be equal to the position where node is inserted', () => {
+			moveDelta.operations.push( new MoveOperation( new Position( root, [ 1, 1 ] ), 2, new Position( root, [ 2, 2 ] ), 0 ) );
+
+			expect( moveDelta.sourcePosition.root ).to.equal( root );
+			expect( moveDelta.sourcePosition.path ).to.deep.equal( [ 1, 1 ] );
+		} );
+	} );
+
+	describe( 'howMany', () => {
+		it( 'should be null if there are no operations in delta', () => {
+			expect( moveDelta.howMany ).to.be.null;
+		} );
+
+		it( 'should be equal to the position where node is inserted', () => {
+			moveDelta.operations.push( new MoveOperation( new Position( root, [ 1, 1 ] ), 2, new Position( root, [ 2, 2 ] ), 0 ) );
+
+			expect( moveDelta.howMany ).to.equal( 2 );
+		} );
+	} );
+
+	describe( 'targetPosition', () => {
+		it( 'should be null if there are no operations in delta', () => {
+			expect( moveDelta.targetPosition ).to.be.null;
+		} );
+
+		it( 'should be equal to the move operation\'s target position', () => {
+			moveDelta.operations.push( new MoveOperation( new Position( root, [ 1, 1 ] ), 2, new Position( root, [ 2, 2 ] ), 0 ) );
+
+			expect( moveDelta.targetPosition.root ).to.equal( root );
+			expect( moveDelta.targetPosition.path ).to.deep.equal( [ 2, 2 ] );
+		} );
+	} );
+
+	describe( 'getReversed', () => {
+		it( 'should return empty MoveDelta if there are no operations in delta', () => {
+			let reversed = moveDelta.getReversed();
+
+			expect( reversed ).to.be.instanceof( MoveDelta );
+			expect( reversed.operations.length ).to.equal( 0 );
+		} );
+
+		it( 'should return correct MoveDelta', () => {
+			moveDelta.operations.push( new MoveOperation( new Position( root, [ 1, 1 ] ), 2, new Position( root, [ 2, 2 ] ), 0 ) );
+
+			let reversed = moveDelta.getReversed();
+
+			expect( reversed ).to.be.instanceof( MoveDelta );
+			expect( reversed.operations.length ).to.equal( 1 );
+
+			expect( reversed.operations[ 0 ] ).to.be.instanceof( MoveOperation );
+			expect( reversed.operations[ 0 ].sourcePosition.path ).to.deep.equal( [ 2, 2 ] );
+			expect( reversed.operations[ 0 ].howMany ).to.equal( 2 );
+			expect( reversed.operations[ 0 ].targetPosition.path ).to.deep.equal( [ 1, 1 ] );
+		} );
+	} );
+} );

+ 82 - 0
packages/ckeditor5-engine/tests/treemodel/delta/splitdelta.js

@@ -12,6 +12,13 @@ import Position from '/ckeditor5/core/treemodel/position.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
 import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
 
+import MergeDelta from '/ckeditor5/core/treemodel/delta/mergedelta.js';
+import SplitDelta from '/ckeditor5/core/treemodel/delta/splitdelta.js';
+
+import InsertOperation from '/ckeditor5/core/treemodel/operation/insertoperation.js';
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+import RemoveOperation from '/ckeditor5/core/treemodel/operation/removeoperation.js';
+
 describe( 'Batch', () => {
 	let doc, root, p;
 
@@ -83,3 +90,78 @@ describe( 'Batch', () => {
 		} );
 	} );
 } );
+
+describe( 'SplitDelta', () => {
+	let splitDelta, doc, root;
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+		splitDelta = new SplitDelta();
+	} );
+
+	describe( 'constructor', () => {
+		it( 'should create split delta with no operations added', () => {
+			expect( splitDelta.operations.length ).to.equal( 0 );
+		} );
+	} );
+
+	describe( 'position', () => {
+		it( 'should be null if there are no operations in delta', () => {
+			expect( splitDelta.position ).to.be.null;
+		} );
+
+		it( 'should be equal to the position where node is split', () => {
+			splitDelta.operations.push( new InsertOperation( new Position( root, [ 1, 2 ] ), new Element( 'p' ), 0 ) );
+			splitDelta.operations.push( new MoveOperation( new Position( root, [ 1, 1, 4 ] ), 4, new Position( root, [ 1, 2, 0 ] ), 1 ) );
+
+			expect( splitDelta.position.root ).to.equal( root );
+			expect( splitDelta.position.path ).to.deep.equal( [ 1, 1, 4 ] );
+		} );
+	} );
+
+	describe( 'getReversed', () => {
+		it( 'should return empty MergeDelta if there are no operations in delta', () => {
+			let reversed = splitDelta.getReversed();
+
+			expect( reversed ).to.be.instanceof( MergeDelta );
+			expect( reversed.operations.length ).to.equal( 0 );
+		} );
+
+		it( 'should return correct SplitDelta', () => {
+			splitDelta.operations.push( new InsertOperation( new Position( root, [ 1, 2 ] ), new Element( 'p' ), 0 ) );
+			splitDelta.operations.push( new MoveOperation( new Position( root, [ 1, 1, 4 ] ), 4, new Position( root, [ 1, 2, 0 ] ), 1 ) );
+
+			let reversed = splitDelta.getReversed();
+
+			expect( reversed ).to.be.instanceof( MergeDelta );
+			expect( reversed.operations.length ).to.equal( 2 );
+
+			expect( reversed.operations[ 0 ] ).to.be.instanceof( MoveOperation );
+			expect( reversed.operations[ 0 ].sourcePosition.path ).to.deep.equal( [ 1, 2, 0 ] );
+			expect( reversed.operations[ 0 ].howMany ).to.equal( 4 );
+			expect( reversed.operations[ 0 ].targetPosition.path ).to.deep.equal( [ 1, 1, 4 ] );
+
+			expect( reversed.operations[ 1 ] ).to.be.instanceof( RemoveOperation );
+			expect( reversed.operations[ 1 ].sourcePosition.path ).to.deep.equal( [ 1, 2 ] );
+			expect( reversed.operations[ 1 ].howMany ).to.equal( 1 );
+		} );
+	} );
+
+	describe( '_cloneOperation', () => {
+		it( 'should return null if delta has no operations', () => {
+			expect( splitDelta._cloneOperation ).to.be.null;
+		} );
+
+		it( 'should return the first operation in the delta, which is InsertOperation or ReinsertOperation', () => {
+			let p = new Element( 'p' );
+			splitDelta.operations.push( new InsertOperation( new Position( root, [ 1, 2 ] ), p, 0 ) );
+			splitDelta.operations.push( new MoveOperation( new Position( root, [ 1, 1, 4 ] ), 4, new Position( root, [ 1, 2, 0 ] ), 1 ) );
+
+			expect( splitDelta._cloneOperation ).to.be.instanceof( InsertOperation );
+			expect( splitDelta._cloneOperation.nodeList.get( 0 ) ).to.equal( p );
+			expect( splitDelta._cloneOperation.position.path ).to.deep.equal( [ 1, 2 ] );
+		} );
+	} );
+} );
+

+ 190 - 0
packages/ckeditor5-engine/tests/treemodel/delta/transform/_utils/utils.js

@@ -0,0 +1,190 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import Document from '/ckeditor5/core/treemodel/document.js';
+import Element from '/ckeditor5/core/treemodel/element.js';
+
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import AttributeDelta from '/ckeditor5/core/treemodel/delta/attributedelta.js';
+import InsertDelta from '/ckeditor5/core/treemodel/delta/insertdelta.js';
+import WeakInsertDelta from '/ckeditor5/core/treemodel/delta/weakinsertdelta.js';
+import RemoveDelta from '/ckeditor5/core/treemodel/delta/removedelta.js';
+import MoveDelta from '/ckeditor5/core/treemodel/delta/movedelta.js';
+import MergeDelta from '/ckeditor5/core/treemodel/delta/mergedelta.js';
+import SplitDelta from '/ckeditor5/core/treemodel/delta/splitdelta.js';
+import WrapDelta from '/ckeditor5/core/treemodel/delta/wrapdelta.js';
+import UnwrapDelta from '/ckeditor5/core/treemodel/delta/unwrapdelta.js';
+
+import AttributeOperation from '/ckeditor5/core/treemodel/operation/attributeoperation.js';
+import InsertOperation from '/ckeditor5/core/treemodel/operation/insertoperation.js';
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+import RemoveOperation from '/ckeditor5/core/treemodel/operation/removeoperation.js';
+
+export function getAttributeDelta( range, key, oldValue, newValue, version ) {
+	let delta = new AttributeDelta();
+	delta.addOperation( new AttributeOperation( range, key, oldValue, newValue, version ) );
+
+	return delta;
+}
+
+export function getInsertDelta( position, nodes, version ) {
+	let delta = new InsertDelta();
+	delta.addOperation( new InsertOperation( position, nodes, version ) );
+
+	return delta;
+}
+
+export function getWeakInsertDelta( position, nodes, version ) {
+	let delta = new WeakInsertDelta();
+	delta.addOperation( new InsertOperation( position, nodes, version ) );
+
+	return delta;
+}
+
+export function getMergeDelta( position, howManyInPrev, howManyInNext, version ) {
+	let delta = new MergeDelta();
+
+	let sourcePosition = Position.createFromPosition( position );
+	sourcePosition.path.push( 0 );
+
+	let targetPosition = Position.createFromPosition( position );
+	targetPosition.offset--;
+	targetPosition.path.push( howManyInPrev );
+
+	delta.addOperation( new MoveOperation( sourcePosition, howManyInNext, targetPosition, version ) );
+	delta.addOperation( new RemoveOperation( position, 1, version + 1 ) );
+
+	return delta;
+}
+
+export function getMoveDelta( sourcePosition, howMany, targetPosition, baseVersion ) {
+	let delta = new MoveDelta();
+
+	let move = new MoveOperation( sourcePosition, howMany, targetPosition, baseVersion );
+	delta.addOperation( move );
+
+	return delta;
+}
+
+export function getRemoveDelta( sourcePosition, howMany, baseVersion ) {
+	let delta = new RemoveDelta();
+
+	let remove = new RemoveOperation( sourcePosition, howMany, baseVersion );
+	delta.addOperation( remove );
+
+	return delta;
+}
+
+export function getSplitDelta( position, nodeCopy, howManyMove, version ) {
+	let delta = new SplitDelta();
+
+	let insertPosition = Position.createFromPosition( position );
+	insertPosition.path = insertPosition.getParentPath();
+	insertPosition.offset++;
+
+	let targetPosition = Position.createFromPosition( insertPosition );
+	targetPosition.path.push( 0 );
+
+	delta.addOperation( new InsertOperation( insertPosition, [ nodeCopy ], version ) );
+	delta.addOperation( new MoveOperation( position, howManyMove, targetPosition, version + 1 ) );
+
+	return delta;
+}
+
+export function getWrapDelta( range, element, version ) {
+	let delta = new WrapDelta();
+
+	let insert = new InsertOperation( range.end, element, version );
+
+	let targetPosition = Position.createFromPosition( range.end );
+	targetPosition.path.push( 0 );
+	let move = new MoveOperation( range.start, range.end.offset - range.start.offset, targetPosition, version + 1 );
+
+	delta.addOperation( insert );
+	delta.addOperation( move );
+
+	return delta;
+}
+
+export function getUnwrapDelta( positionBefore, howManyChildren, version ) {
+	let delta = new UnwrapDelta();
+
+	let sourcePosition = Position.createFromPosition( positionBefore );
+	sourcePosition.path.push( 0 );
+
+	let move = new MoveOperation( sourcePosition, howManyChildren, positionBefore, version );
+
+	let removePosition = Position.createFromPosition( positionBefore );
+	removePosition.offset += howManyChildren;
+
+	let remove = new RemoveOperation( removePosition, 1, version + 1 );
+
+	delta.addOperation( move );
+	delta.addOperation( remove );
+
+	return delta;
+}
+
+export function expectDelta( delta, expected ) {
+	expect( delta ).to.be.instanceof( expected.type );
+	expect( delta.operations.length ).to.equal( expected.operations.length );
+
+	for ( let i = 0; i < delta.operations.length; i++ ) {
+		expectOperation( delta.operations[ i ], expected.operations[ i ] );
+	}
+}
+
+export 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 ( i == 'nodes' ) {
+				expect( op.nodeList._nodes ).to.deep.equal( params[ i ] );
+			} 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 ] );
+			}
+		}
+	}
+}
+
+export function applyDelta( delta, document ) {
+	for ( let op of delta.operations ) {
+		document.applyOperation( op );
+	}
+}
+
+export function getFilledDocument() {
+	const doc = new Document();
+	const root = doc.createRoot( 'root' );
+
+	root.insertChildren( 0, [
+		new Element( 'x' ),
+		new Element( 'x' ),
+		new Element( 'x', [], 'a' ),
+		new Element( 'div', [], [
+			new Element( 'x' ),
+			new Element( 'x' ),
+			new Element( 'x', [], 'a' ),
+			new Element( 'div', [], [
+				new Element( 'x' ),
+				new Element( 'x' ),
+				new Element( 'x', [], 'abcd' ),
+				new Element( 'p', [], 'abcfoobarxyz' )
+			] )
+		] )
+	] );
+
+	return doc;
+}

+ 145 - 0
packages/ckeditor5-engine/tests/treemodel/delta/transform/attributedelta.js

@@ -0,0 +1,145 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transformations from '/ckeditor5/core/treemodel/delta/basic-transformations.js';
+/*jshint unused: false*/
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Text from '/ckeditor5/core/treemodel/text.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import AttributeDelta from '/ckeditor5/core/treemodel/delta/attributedelta.js';
+import AttributeOperation from '/ckeditor5/core/treemodel/operation/attributeoperation.js';
+
+import {
+	expectDelta,
+	getFilledDocument,
+	getAttributeDelta,
+	getWeakInsertDelta,
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		baseVersion = doc.version;
+	} );
+
+	describe( 'AttributeDelta by', () => {
+		let attrDelta;
+
+		beforeEach( () => {
+			let attrRange = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 3, 3, 9 ] ) );
+			attrDelta = getAttributeDelta( attrRange, 'key', 'old', 'new', baseVersion );
+		} );
+
+		describe( 'WeakInsertDelta', () => {
+			it( 'weak insert inside attribute range should "fix" splitting the range', () => {
+				let insertPosition = new Position( root, [ 3, 3, 0 ] );
+				let insertDelta = getWeakInsertDelta(
+					insertPosition,
+					[
+						'a',
+						new Text( 'b', { key: 'new' } ),
+						new Text( 'c', { key: 'different' } ),
+						'de'
+					],
+					baseVersion
+				);
+
+				let transformed = transform( attrDelta, insertDelta );
+
+				expect( transformed.length ).to.equal( 2 );
+
+				baseVersion = insertDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: AttributeDelta,
+					operations: [
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 3, 5 ] ), new Position( root, [ 3, 3, 8, 9 ] ) ),
+							key: 'key',
+							oldValue: 'old',
+							newValue: 'new',
+							baseVersion: baseVersion
+						},
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 3, 0 ] ) ),
+							key: 'key',
+							oldValue: 'old',
+							newValue: 'new',
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				expectDelta( transformed[ 1 ], {
+					type: AttributeDelta,
+					operations: [
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 3, 0 ] ), new Position( root, [ 3, 3, 1 ] ) ),
+							key: 'key',
+							oldValue: undefined,
+							newValue: 'new',
+							baseVersion: baseVersion + 2
+						},
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 3, 2 ] ), new Position( root, [ 3, 3, 3 ] ) ),
+							key: 'key',
+							oldValue: 'different',
+							newValue: 'new',
+							baseVersion: baseVersion + 3
+						},
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 3, 3 ] ), new Position( root, [ 3, 3, 5 ] ) ),
+							key: 'key',
+							oldValue: undefined,
+							newValue: 'new',
+							baseVersion: baseVersion + 4
+						}
+					]
+				} );
+			} );
+
+			it( 'should be normally transformed if weak insert is not in the attribute range', () => {
+				let insertPosition = new Position( root, [ 5 ] );
+				let insertDelta = getWeakInsertDelta( insertPosition, 'abc', baseVersion );
+
+				let transformed = transform( attrDelta, insertDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = insertDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: AttributeDelta,
+					operations: [
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 3, 3, 9 ] ) ),
+							key: 'key',
+							oldValue: 'old',
+							newValue: 'new',
+							baseVersion: baseVersion
+						}
+					]
+				} );
+			} );
+		} );
+	} );
+} );

+ 73 - 0
packages/ckeditor5-engine/tests/treemodel/delta/transform/delta.js

@@ -0,0 +1,73 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transformations from '/ckeditor5/core/treemodel/delta/basic-transformations.js';
+/*jshint unused: false*/
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Position from '/ckeditor5/core/treemodel/position.js';
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+import Delta from '/ckeditor5/core/treemodel/delta/delta.js';
+
+import {
+	expectDelta,
+	getFilledDocument,
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		baseVersion = doc.version;
+	} );
+
+	it( 'should transform delta by transforming it\'s operations', () => {
+		let deltaA = new Delta();
+		let deltaB = new Delta();
+
+		deltaA.addOperation( new MoveOperation( new Position( root, [ 1, 2, 3 ] ), 4, new Position( root, [ 4, 0 ] ), baseVersion ) );
+		deltaB.addOperation( new MoveOperation( new Position( root, [ 1, 2, 0 ] ), 2, new Position( root, [ 4, 1 ] ), baseVersion ) );
+
+		let deltaAbyB = transform( deltaA, deltaB );
+		let deltaBbyA = transform( deltaB, deltaA );
+
+		expect( deltaAbyB.length ).to.equal( 1 );
+
+		expectDelta( deltaAbyB[ 0 ], {
+			type: Delta,
+			operations: [
+				{
+					type: MoveOperation,
+					sourcePosition: new Position( root, [ 1, 2, 1 ] ),
+					howMany: 4,
+					targetPosition: new Position( root, [ 4, 0 ] ),
+					baseVersion: 1
+				}
+			]
+		} );
+
+		expect( deltaBbyA.length ).to.equal( 1 );
+
+		expectDelta( deltaBbyA[ 0 ], {
+			type: Delta,
+			operations: [
+				{
+					type: MoveOperation,
+					sourcePosition: new Position( root, [ 1, 2, 0 ] ),
+					howMany: 2,
+					targetPosition: new Position( root, [ 4, 5 ] ),
+					baseVersion: 1
+				}
+			]
+		} );
+	} );
+} );

+ 204 - 0
packages/ckeditor5-engine/tests/treemodel/delta/transform/insertdelta.js

@@ -0,0 +1,204 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transformations from '/ckeditor5/core/treemodel/delta/basic-transformations.js';
+/*jshint unused: false*/
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Element from '/ckeditor5/core/treemodel/element.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import InsertDelta from '/ckeditor5/core/treemodel/delta/insertdelta.js';
+import SplitDelta from '/ckeditor5/core/treemodel/delta/splitdelta.js';
+
+import InsertOperation from '/ckeditor5/core/treemodel/operation/insertoperation.js';
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+import ReinsertOperation from '/ckeditor5/core/treemodel/operation/reinsertoperation.js';
+
+import treeModelTestUtils from '/tests/core/treemodel/_utils/utils.js';
+const getNodesAndText = treeModelTestUtils.getNodesAndText;
+
+import {
+	applyDelta,
+	expectDelta,
+	getFilledDocument,
+	getInsertDelta,
+	getMergeDelta
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, gy, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		gy = doc.graveyard;
+		baseVersion = doc.version;
+	} );
+
+	describe( 'InsertDelta by', () => {
+		let insertDelta, insertPosition, nodeA, nodeB;
+
+		beforeEach( () => {
+			nodeA = new Element( 'a' );
+			nodeB = new Element( 'b' );
+
+			insertPosition = new Position( root, [ 3, 3, 3 ] );
+			insertDelta = getInsertDelta( insertPosition, [ nodeA, nodeB ], baseVersion );
+		} );
+
+		describe( 'InsertDelta', () => {
+			it( 'should be resolved in a same way as two insert operations', () => {
+				let insertPositionB = new Position( root, [ 3, 1 ] );
+				let insertDeltaB = getInsertDelta( insertPositionB, [ new Element( 'c' ), new Element( 'd' ) ], baseVersion );
+
+				let transformed = transform( insertDelta, insertDeltaB );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: InsertDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 5, 3 ] ),
+							nodes: [ nodeA, nodeB ],
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+			} );
+		} );
+
+		describe( 'MergeDelta', () => {
+			it( 'merge in same position as insert', () => {
+				let mergeDelta = getMergeDelta( insertPosition, 4, 12, baseVersion );
+				let transformed = transform( insertDelta, mergeDelta );
+
+				baseVersion = mergeDelta.operations.length;
+
+				// Expected: MergeOperation gets reversed (by special case of SplitDelta that has ReinsertOperation
+				// instead of InsertOperation. Then InsertDelta is applied as is.
+
+				expect( transformed.length ).to.equal( 2 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: ReinsertOperation,
+							sourcePosition: new Position( gy, [ 0 ] ),
+							howMany: 1,
+							targetPosition: new Position( root, [ 3, 3, 3 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 2, 4 ] ),
+							howMany: 12,
+							targetPosition: new Position( root, [ 3, 3, 3, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				expectDelta( transformed[ 1 ], {
+					type: InsertDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: Position.createFromPosition( insertPosition ),
+							nodes: [ nodeA, nodeB ],
+							baseVersion: baseVersion + 2
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( mergeDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+				applyDelta( transformed[ 1 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 6 ) );
+
+				// Merge between X with "abcd" and P with "abcfoobarxyz" should be reversed and AB should be inserted between X and P.
+				expect( nodesAndText ).to.equal( 'XXXXXabcdXAABBPabcfoobarxyzP' );
+			} );
+
+			it( 'merge the node that is parent of insert position (sticky move test)', () => {
+				let mergeDelta = getMergeDelta( new Position( root, [ 3, 3 ] ), 1, 4, baseVersion );
+				let transformed = transform( insertDelta, mergeDelta );
+
+				baseVersion = mergeDelta.operations.length;
+
+				// Expected: InsertOperation in InsertDelta has it's path updated.
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: InsertDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 2, 4 ] ),
+							nodes: [ nodeA, nodeB ],
+							baseVersion: baseVersion
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( mergeDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 2, 0 ] ), 7 ) );
+
+				// Merge between X with "a" and DIV should be applied. AB should be inserted in new, correct position.
+				expect( nodesAndText ).to.equal( 'aXXXXXabcdXAABBPabcfoobarxyzP' );
+			} );
+
+			it( 'merge at affected position but resolved by default OT', () => {
+				let mergeDelta = getMergeDelta( new Position( root, [ 3 ] ), 1, 4, baseVersion );
+				let transformed = transform( insertDelta, mergeDelta );
+
+				baseVersion = mergeDelta.operations.length;
+
+				// Expected: InsertOperation in InsertDelta has it's path updated.
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: InsertDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 2, 4, 3 ] ),
+							nodes: [ nodeA, nodeB ],
+							baseVersion: baseVersion
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( mergeDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 2, 0 ] ), 5 ) );
+
+				// Merge between X with "a" and DIV should be applied. AB should be inserted in new, correct position.
+				expect( nodesAndText ).to.equal( 'aXXXXXaXDIVXXXXXabcdXAABBPabcfoobarxyzPDIV' );
+			} );
+		} );
+	} );
+} );

+ 269 - 0
packages/ckeditor5-engine/tests/treemodel/delta/transform/mergedelta.js

@@ -0,0 +1,269 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transformations from '/ckeditor5/core/treemodel/delta/basic-transformations.js';
+/*jshint unused: false*/
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Element from '/ckeditor5/core/treemodel/element.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import Delta from '/ckeditor5/core/treemodel/delta/delta.js';
+import MergeDelta from '/ckeditor5/core/treemodel/delta/mergedelta.js';
+
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+import RemoveOperation from '/ckeditor5/core/treemodel/operation/removeoperation.js';
+
+import treeModelTestUtils from '/tests/core/treemodel/_utils/utils.js';
+const getNodesAndText = treeModelTestUtils.getNodesAndText;
+
+import {
+	applyDelta,
+	expectDelta,
+	getFilledDocument,
+	getInsertDelta,
+	getMergeDelta,
+	getMoveDelta
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, gy, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		gy = doc.graveyard;
+		baseVersion = doc.version;
+	} );
+
+	describe( 'MergeDelta by', () => {
+		let mergeDelta, mergePosition;
+
+		beforeEach( () => {
+			mergePosition = new Position( root, [ 3, 3, 3 ] );
+			mergeDelta = getMergeDelta( mergePosition, 4, 12, baseVersion );
+		} );
+
+		describe( 'InsertDelta', () => {
+			let nodeA, nodeB;
+
+			beforeEach( () => {
+				nodeA = new Element( 'a' );
+				nodeB = new Element( 'b' );
+			} );
+
+			it( 'insert at same position as merge', () => {
+				let insertDelta = getInsertDelta( mergePosition, [ nodeA, nodeB ], baseVersion );
+				let transformed = transform( mergeDelta, insertDelta );
+
+				// Expected: MergeDelta gets ignored and is not applied.
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: Delta,
+					operations: []
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( insertDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 6 ) );
+
+				// InsertDelta is applied. Merge between X and P is discarded.
+				expect( nodesAndText ).to.equal( 'XXXXXabcdXAABBPabcfoobarxyzP' );
+			} );
+
+			it( 'insert inside merged node (sticky move test)', () => {
+				let insertDelta = getInsertDelta( new Position( root, [ 3, 3, 3, 12 ] ), [ nodeA, nodeB ], baseVersion );
+				let transformed = transform( mergeDelta, insertDelta );
+
+				baseVersion = insertDelta.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				// Expected: MoveOperation in MergeDelta has it's "range" expanded.
+
+				expectDelta( transformed[ 0 ], {
+					type: MergeDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3, 0 ] ),
+							howMany: 14,
+							targetPosition: new Position( root, [ 3, 3, 2, 4 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							// This is `MoveOperation` instead of `RemoveOperation` because during OT,
+							// `RemoveOperation` may get converted to `MoveOperation`. Still, this expectation is
+							// correct because `RemoveOperation` is deriving from `MoveOperation`. So we can expect
+							// that something that was `RemoveOperation` is a `MoveOperation`.
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3 ] ),
+							howMany: 1,
+							targetPosition: new Position( gy, [ 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( insertDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 3 ) );
+
+				// InsertDelta is applied. Merge between X and P is discarded.
+				expect( nodesAndText ).to.equal( 'XXXXXabcdabcfoobarxyzAABBX' );
+			} );
+		} );
+
+		describe( 'MoveDelta', () => {
+			it( 'node on the right side of merge was moved', () => {
+				let moveDelta = getMoveDelta( new Position( root, [ 3, 3, 3 ] ), 1, new Position( root, [ 3, 3, 0 ] ), baseVersion );
+				let transformed = transform( mergeDelta, moveDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: Delta,
+					operations: []
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( moveDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3 ] ), 1 ) );
+
+				// MoveDelta is applied. MergeDelta is discarded.
+				expect( nodesAndText ).to.equal( 'DIVPabcfoobarxyzPXXXXXabcdXDIV' );
+			} );
+
+			it( 'node on the left side of merge was moved', () => {
+				let moveDelta = getMoveDelta( new Position( root, [ 3, 3, 2 ] ), 1, new Position( root, [ 3, 3, 0 ] ), baseVersion );
+				let transformed = transform( mergeDelta, moveDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = moveDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: MergeDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3, 0 ] ),
+							howMany: 12,
+							targetPosition: new Position( root, [ 3, 3, 0, 4 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: RemoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3 ] ),
+							howMany: 1,
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( moveDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3 ] ), 1 ) );
+
+				// MoveDelta is applied. MergeDelta is discarded.
+				expect( nodesAndText ).to.equal( 'DIVXabcdabcfoobarxyzXXXXXDIV' );
+			} );
+		} );
+
+		describe( 'MergeDelta', () => {
+			it( 'merge two consecutive elements, transformed merge is after', () => {
+				let mergeDeltaB = getMergeDelta( new Position( root, [ 3, 3, 2 ] ), 0, 4, baseVersion );
+				let transformed = transform( mergeDelta, mergeDeltaB );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = mergeDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: MergeDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 2, 0 ] ),
+							howMany: 12,
+							targetPosition: new Position( root, [ 3, 3, 1, 4 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: RemoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 2 ] ),
+							howMany: 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( mergeDeltaB, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 2 ) );
+
+				// Both merge deltas are applied and merged nodes children are together in one node.
+				expect( nodesAndText ).to.equal( 'XXXabcdabcfoobarxyzX' );
+			} );
+
+			it( 'merge two consecutive elements, transformed merge is before', () => {
+				mergeDelta = getMergeDelta( new Position( root, [ 3, 3, 2 ] ), 0, 4, baseVersion );
+				let mergeDeltaB = getMergeDelta( new Position( root, [ 3, 3, 3 ] ), 4, 12, baseVersion );
+
+				let transformed = transform( mergeDelta, mergeDeltaB );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = mergeDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: MergeDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 2, 0 ] ),
+							howMany: 16,
+							targetPosition: new Position( root, [ 3, 3, 1, 0 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: RemoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 2 ] ),
+							howMany: 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( mergeDeltaB, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 2 ) );
+
+				// Both merge deltas are applied and merged nodes children are together in one node.
+				expect( nodesAndText ).to.equal( 'XXXabcdabcfoobarxyzX' );
+			} );
+		} );
+	} );
+} );

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

@@ -0,0 +1,141 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transformations from '/ckeditor5/core/treemodel/delta/basic-transformations.js';
+/*jshint unused: false*/
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import MoveDelta from '/ckeditor5/core/treemodel/delta/movedelta.js';
+import SplitDelta from '/ckeditor5/core/treemodel/delta/splitdelta.js';
+
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+
+import treeModelTestUtils from '/tests/core/treemodel/_utils/utils.js';
+const getNodesAndText = treeModelTestUtils.getNodesAndText;
+
+import {
+	applyDelta,
+	expectDelta,
+	getFilledDocument,
+	getMergeDelta,
+	getMoveDelta
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, gy, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		gy = doc.graveyard;
+		baseVersion = doc.version;
+	} );
+
+	describe( 'MoveDelta by', () => {
+		let moveDelta;
+
+		beforeEach( () => {
+			let sourcePosition = new Position( root, [ 3, 3, 3 ] );
+			let howMany = 1;
+			let targetPosition = new Position( root, [ 3, 3, 0 ] );
+
+			moveDelta = getMoveDelta( sourcePosition, howMany, targetPosition, baseVersion );
+		} );
+
+		describe( 'MergeDelta', () => {
+			it( 'node on the right side of merge was moved', () => {
+				let mergePosition = new Position( root, [ 3, 3, 3 ] );
+				let mergeDelta = getMergeDelta( mergePosition, 4, 12, baseVersion );
+
+				let transformed = transform( moveDelta, mergeDelta );
+
+				expect( transformed.length ).to.equal( 2 );
+
+				baseVersion = mergeDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							// This `SplitDelta` got created through reversing `MergeDelta`. It means that operations in
+							// a `MergeDelta` had been reversed. One of them is `RemoveOperation` which got reversed into
+							// `ReinsertOperation` because we want to get back the node from graveyard. `ReinsertOperation`
+							// is treated in OT as `MoveOperation` and might be converted to it. This is why we have to
+							// check whether the operation type is `MoveOperation`. This is all perfectly valid.
+							type: MoveOperation,
+							sourcePosition: new Position( gy, [ 0 ] ),
+							howMany: 1,
+							targetPosition: new Position( root, [ 3, 3, 3 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 2, 4 ] ),
+							howMany: 12,
+							targetPosition: new Position( root, [ 3, 3, 3, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				expectDelta( transformed[ 1 ], {
+					type: MoveDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: moveDelta._moveOperation.sourcePosition,
+							howMany: moveDelta._moveOperation.howMany,
+							targetPosition: moveDelta._moveOperation.targetPosition,
+							baseVersion: baseVersion + 2
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( mergeDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+				applyDelta( transformed[ 1 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3 ] ), 1 ) );
+
+				// MoveDelta is applied. MergeDelta is discarded.
+				expect( nodesAndText ).to.equal( 'DIVPabcfoobarxyzPXXXXXabcdXDIV' );
+			} );
+
+			it( 'move range in merged node', () => {
+				let mergePosition = new Position( root, [ 3, 3 ] );
+				let mergeDelta = getMergeDelta( mergePosition, 1, 4, baseVersion );
+
+				let transformed = transform( moveDelta, mergeDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = mergeDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: MoveDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 2, 4 ] ),
+							howMany: 1,
+							targetPosition: new Position( root, [ 3, 2, 1 ] ),
+							baseVersion: baseVersion
+						}
+					]
+				} );
+			} );
+		} );
+	} );
+} );

+ 112 - 0
packages/ckeditor5-engine/tests/treemodel/delta/transform/removedelta.js

@@ -0,0 +1,112 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transformations from '/ckeditor5/core/treemodel/delta/basic-transformations.js';
+/*jshint unused: false*/
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import RemoveDelta from '/ckeditor5/core/treemodel/delta/movedelta.js';
+import SplitDelta from '/ckeditor5/core/treemodel/delta/splitdelta.js';
+
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+
+import treeModelTestUtils from '/tests/core/treemodel/_utils/utils.js';
+const getNodesAndText = treeModelTestUtils.getNodesAndText;
+
+import {
+	applyDelta,
+	expectDelta,
+	getFilledDocument,
+	getMergeDelta,
+	getRemoveDelta
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, gy, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		gy = doc.graveyard;
+		baseVersion = doc.version;
+	} );
+
+	describe( 'RemoveDelta by', () => {
+		let removeDelta;
+
+		beforeEach( () => {
+			let sourcePosition = new Position( root, [ 3, 3, 3 ] );
+			let howMany = 1;
+
+			removeDelta = getRemoveDelta( sourcePosition, howMany, baseVersion );
+		} );
+
+		describe( 'MergeDelta', () => {
+			it( 'node on the right side of merge was removed', () => {
+				// This special case should be handled by MoveDelta x MergeDelta special case.
+
+				let mergePosition = new Position( root, [ 3, 3, 3 ] );
+				let mergeDelta = getMergeDelta( mergePosition, 4, 12, baseVersion );
+
+				let transformed = transform( removeDelta, mergeDelta );
+
+				expect( transformed.length ).to.equal( 2 );
+
+				baseVersion = mergeDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( gy, [ 0 ] ),
+							howMany: 1,
+							targetPosition: new Position( root, [ 3, 3, 3 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 2, 4 ] ),
+							howMany: 12,
+							targetPosition: new Position( root, [ 3, 3, 3, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				expectDelta( transformed[ 1 ], {
+					type: RemoveDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: removeDelta._moveOperation.sourcePosition,
+							howMany: removeDelta._moveOperation.howMany,
+							baseVersion: baseVersion + 2
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( mergeDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+				applyDelta( transformed[ 1 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3 ] ), 1 ) );
+
+				// RemoveDelta is applied. MergeDelta is discarded.
+				expect( nodesAndText ).to.equal( 'DIVXXXXXabcdXDIV' );
+			} );
+		} );
+	} );
+} );

+ 316 - 0
packages/ckeditor5-engine/tests/treemodel/delta/transform/splitdelta.js

@@ -0,0 +1,316 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transformations from '/ckeditor5/core/treemodel/delta/basic-transformations.js';
+/*jshint unused: false*/
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Element from '/ckeditor5/core/treemodel/element.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import Delta from '/ckeditor5/core/treemodel/delta/delta.js';
+import SplitDelta from '/ckeditor5/core/treemodel/delta/splitdelta.js';
+
+import InsertOperation from '/ckeditor5/core/treemodel/operation/insertoperation.js';
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+
+import treeModelTestUtils from '/tests/core/treemodel/_utils/utils.js';
+const getNodesAndText = treeModelTestUtils.getNodesAndText;
+
+import {
+	applyDelta,
+	expectDelta,
+	getFilledDocument,
+	getSplitDelta,
+	getWrapDelta,
+	getUnwrapDelta
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, gy, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		gy = doc.graveyard;
+		baseVersion = doc.version;
+	} );
+
+	describe( 'SplitDelta by', () => {
+		let splitDelta, splitPosition;
+
+		beforeEach( () => {
+			splitPosition = new Position( root, [ 3, 3, 3, 3 ] );
+			splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
+		} );
+
+		describe( 'SplitDelta', () => {
+			it( 'split in same parent and offset', () => {
+				let splitDeltaB = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
+				let transformed = transform( splitDelta, splitDeltaB );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: Delta,
+					operations: []
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( splitDeltaB, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 5 ) );
+
+				// Incoming split delta is discarded. Only one new element is created after applying both split deltas.
+				// There are no empty P elements.
+				expect( nodesAndText ).to.equal( 'XXXXXabcdXPabcPPfoobarxyzP' );
+			} );
+
+			it( 'split in same parent, incoming delta splits closer', () => {
+				let splitDeltaB = getSplitDelta( new Position( root, [ 3, 3, 3, 5 ] ), new Element( 'p' ), 7, baseVersion );
+				let transformed = transform( splitDelta, splitDeltaB );
+
+				baseVersion = splitDeltaB.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 4 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3, 3 ] ),
+							howMany: 2,
+							targetPosition: new Position( root, [ 3, 3, 4, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( splitDeltaB, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 6 ) );
+
+				// P element is correctly split, there are three P elements, letters in P elements are in correct order.
+				expect( nodesAndText ).to.equal( 'XXXXXabcdXPabcPPfoPPobarxyzP' );
+			} );
+
+			it( 'split in same parent, incoming delta splits further', () => {
+				let splitDeltaB = getSplitDelta( new Position( root, [ 3, 3, 3, 1 ] ), new Element( 'p' ), 11, baseVersion );
+				let transformed = transform( splitDelta, splitDeltaB );
+
+				baseVersion = splitDeltaB.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 5 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 4, 2 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 3, 5, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( splitDeltaB, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 6 ) );
+
+				// P element is correctly split, there are three P elements, letters in P elements are in correct order.
+				expect( nodesAndText ).to.equal( 'XXXXXabcdXPaPPbcPPfoobarxyzP' );
+			} );
+
+			it( 'split in split parent', () => {
+				let splitDeltaB = getSplitDelta( new Position( root, [ 3, 3, 3 ] ), new Element( 'div' ), 1, baseVersion );
+				let transformed = transform( splitDelta, splitDeltaB );
+
+				baseVersion = splitDeltaB.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 4, 1 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 4, 0, 3 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 4, 1, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+
+				applyDelta( splitDeltaB, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3 ] ), 2 ) );
+
+				// DIV and P elements are correctly split.
+				expect( nodesAndText ).to.equal( 'DIVXXXXXabcdXDIVDIVPabcPPfoobarxyzPDIV' );
+			} );
+		} );
+
+		describe( 'UnwrapDelta', () => {
+			it( 'split position directly in unwrapped node', () => {
+				let unwrapDelta = getUnwrapDelta( new Position( root, [ 3, 3, 3 ] ), 12, baseVersion );
+				let transformed = transform( splitDelta, unwrapDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: Delta,
+					operations: []
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( unwrapDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3 ] ), 1 ) );
+
+				// UnwrapDelta is applied. SplitDelta is discarded.
+				expect( nodesAndText ).to.equal( 'DIVXXXXXabcdXabcfoobarxyzDIV' );
+			} );
+
+			it( 'split position indirectly in unwrapped node', () => {
+				let unwrapDelta = getUnwrapDelta( new Position( root, [ 3, 3 ] ), 4, baseVersion );
+
+				let transformed = transform( splitDelta, unwrapDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = unwrapDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 7 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 6, 3 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 7, 0 ] ),
+							baseVersion:  baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( unwrapDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3 ] ), 1 ) );
+
+				// UnwrapDelta and SplitDelta are correctly applied.
+				expect( nodesAndText ).to.equal( 'DIVXXXXXaXXXXXXabcdXPabcPPfoobarxyzPDIV' );
+			} );
+		} );
+
+		describe( 'WrapDelta', () => {
+			it( 'split position is between wrapped nodes', () => {
+				let wrapRange = new Range( new Position( root, [ 3, 3, 3, 1 ] ), new Position( root, [ 3, 3, 3, 5 ] ) );
+				let wrapElement = new Element( 'E' );
+				let wrapDelta = getWrapDelta( wrapRange, wrapElement, baseVersion );
+
+				let transformed = transform( splitDelta, wrapDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: Delta,
+					operations: []
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( wrapDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 3 ] ), 1 ) );
+
+				// WrapDelta is applied. SplitDelta is discarded.
+				expect( nodesAndText ).to.equal( 'PaEbcfoEobarxyzP' );
+			} );
+
+			it( 'split position is inside wrapped node', () => {
+				let wrapRange = new Range( new Position( root, [ 3, 3, 2 ] ), new Position( root, [ 3, 3, 4 ] ) );
+				let wrapElement = new Element( 'E' );
+				let wrapDelta = getWrapDelta( wrapRange, wrapElement, baseVersion );
+
+				let transformed = transform( splitDelta, wrapDelta, true );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = wrapDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 2, 2 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 2, 1, 3 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 3, 2, 2, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( wrapDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 2 ] ), 1 ) );
+
+				// WrapDelta and SplitDelta are correctly applied.
+				expect( nodesAndText ).to.equal( 'EXabcdXPabcPPfoobarxyzPE' );
+			} );
+		} );
+	} );
+} );

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

@@ -0,0 +1,156 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transformations from '/ckeditor5/core/treemodel/delta/basic-transformations.js';
+/*jshint unused: false*/
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Element from '/ckeditor5/core/treemodel/element.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+
+import MergeDelta from '/ckeditor5/core/treemodel/delta/mergedelta.js';
+import UnwrapDelta from '/ckeditor5/core/treemodel/delta/unwrapdelta.js';
+
+import treeModelTestUtils from '/tests/core/treemodel/_utils/utils.js';
+const getNodesAndText = treeModelTestUtils.getNodesAndText;
+
+import {
+	applyDelta,
+	expectDelta,
+	getFilledDocument,
+	getSplitDelta,
+	getUnwrapDelta
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, gy, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		gy = doc.graveyard;
+		baseVersion = doc.version;
+	} );
+
+	describe( 'UnwrapDelta by', () => {
+		let unwrapDelta;
+
+		beforeEach( () => {
+			unwrapDelta = getUnwrapDelta( new Position( root, [ 3, 3, 3 ] ), 12, baseVersion );
+		} );
+
+		describe( 'SplitDelta', () => {
+			it( 'split position directly in unwrapped node', () => {
+				let splitPosition = new Position( root, [ 3, 3, 3, 3 ] );
+				let splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
+
+				let transformed = transform( unwrapDelta, splitDelta );
+
+				expect( transformed.length ).to.equal( 2 );
+
+				baseVersion = splitDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: MergeDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 4, 0 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 3, 3, 3 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 4 ] ),
+							howMany: 1,
+							targetPosition: new Position( gy, [ 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				expectDelta( transformed[ 1 ], {
+					type: UnwrapDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3, 0 ] ),
+							howMany: 12,
+							targetPosition: new Position( root, [ 3, 3, 3 ] ),
+							baseVersion: baseVersion + 2
+						},
+						{
+							// `RemoveOperation` as `MoveOperation`
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 15 ] ),
+							howMany: 1,
+							targetPosition: new Position( gy, [ 0 ] ),
+							baseVersion: baseVersion + 3
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( splitDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+				applyDelta( transformed[ 1 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3 ] ), 1 ) );
+
+				// UnwrapDelta is applied. SplitDelta is discarded.
+				expect( nodesAndText ).to.equal( 'DIVXXXXXabcdXabcfoobarxyzDIV' );
+			} );
+
+			it( 'split position before unwrapped node', () => {
+				let splitPosition = new Position( root, [ 3, 3, 3 ] );
+				let splitDelta = getSplitDelta( splitPosition, new Element( 'div' ), 1, baseVersion );
+
+				let transformed = transform( unwrapDelta, splitDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = splitDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: UnwrapDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 4, 0, 0 ] ),
+							howMany: 12,
+							targetPosition: new Position( root, [ 3, 4, 0 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 4, 12 ] ),
+							howMany: 1,
+							targetPosition: new Position( gy, [ 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( splitDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3 ] ), 2 ) );
+
+				// UnwrapDelta and SplitDelta are applied.
+				expect( nodesAndText ).to.equal( 'DIVXXXXXabcdXDIVDIVabcfoobarxyzDIV' );
+			} );
+		} );
+	} );
+} );

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

@@ -0,0 +1,134 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transformations from '/ckeditor5/core/treemodel/delta/basic-transformations.js';
+/*jshint unused: false*/
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Text from '/ckeditor5/core/treemodel/text.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import WeakInsertDelta from '/ckeditor5/core/treemodel/delta/weakinsertdelta.js';
+import AttributeDelta from '/ckeditor5/core/treemodel/delta/attributedelta.js';
+
+import InsertOperation from '/ckeditor5/core/treemodel/operation/insertoperation.js';
+import AttributeOperation from '/ckeditor5/core/treemodel/operation/attributeoperation.js';
+
+import {
+	expectDelta,
+	getFilledDocument,
+	getAttributeDelta,
+	getWeakInsertDelta
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		baseVersion = doc.version;
+	} );
+
+	describe( 'AttributeDelta by', () => {
+		let insertDelta;
+
+		beforeEach( () => {
+			let insertPosition = new Position( root, [ 3, 3, 0 ] );
+			insertDelta = getWeakInsertDelta(
+				insertPosition,
+				[
+					'a',
+					new Text( 'b', { key: 'new' } ),
+					new Text( 'c', { key: 'different' } ),
+					'de'
+				],
+				baseVersion
+			);
+		} );
+
+		describe( 'WeakInsertDelta', () => {
+			it( 'weak insert inside attribute range should "fix" splitting the range', () => {
+				let attrRange = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 3, 3, 9 ] ) );
+				let attrDelta = getAttributeDelta( attrRange, 'key', 'old', 'new', baseVersion );
+
+				let transformed = transform( insertDelta, attrDelta );
+
+				expect( transformed.length ).to.equal( 2 );
+
+				baseVersion = attrDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: WeakInsertDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 0 ] ),
+							baseVersion: baseVersion
+						}
+					]
+				} );
+
+				expectDelta( transformed[ 1 ], {
+					type: AttributeDelta,
+					operations: [
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 3, 0 ] ), new Position( root, [ 3, 3, 1 ] ) ),
+							key: 'key',
+							oldValue: undefined,
+							newValue: 'new',
+							baseVersion: baseVersion + 1
+						},
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 3, 2 ] ), new Position( root, [ 3, 3, 3 ] ) ),
+							key: 'key',
+							oldValue: 'different',
+							newValue: 'new',
+							baseVersion: baseVersion + 2
+						},
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 3, 3 ] ), new Position( root, [ 3, 3, 5 ] ) ),
+							key: 'key',
+							oldValue: undefined,
+							newValue: 'new',
+							baseVersion: baseVersion + 3
+						}
+					]
+				} );
+			} );
+
+			it( 'should be normally transformed if weak insert is not in the attribute range', () => {
+				let attrRange = new Range( new Position( root, [ 5 ] ), new Position( root, [ 7 ] ) );
+				let attrDelta = getAttributeDelta( attrRange, 'key', 'old', 'new', baseVersion );
+
+				let transformed = transform( insertDelta, attrDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = attrDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: WeakInsertDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 0 ] ),
+							baseVersion: baseVersion
+						}
+					]
+				} );
+			} );
+		} );
+	} );
+} );

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

@@ -0,0 +1,155 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, operation */
+
+'use strict';
+
+import transformations from '/ckeditor5/core/treemodel/delta/basic-transformations.js';
+/*jshint unused: false*/
+
+import transform from '/ckeditor5/core/treemodel/delta/transform.js';
+
+import Element from '/ckeditor5/core/treemodel/element.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+import InsertOperation from '/ckeditor5/core/treemodel/operation/insertoperation.js';
+
+import MergeDelta from '/ckeditor5/core/treemodel/delta/mergedelta.js';
+import WrapDelta from '/ckeditor5/core/treemodel/delta/wrapdelta.js';
+
+import treeModelTestUtils from '/tests/core/treemodel/_utils/utils.js';
+const getNodesAndText = treeModelTestUtils.getNodesAndText;
+
+import {
+	applyDelta,
+	expectDelta,
+	getFilledDocument,
+	getSplitDelta,
+	getWrapDelta
+} from '/tests/core/treemodel/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, gy, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot( 'root' );
+		gy = doc.graveyard;
+		baseVersion = doc.version;
+	} );
+
+	describe( 'WrapDelta by', () => {
+		let wrapDelta;
+
+		beforeEach( () => {
+			let wrapRange = new Range( new Position( root, [ 3, 3, 3, 1 ] ), new Position( root, [ 3, 3, 3, 5 ] ) );
+			let wrapElement = new Element( 'E' );
+
+			wrapDelta = getWrapDelta( wrapRange, wrapElement, baseVersion );
+		} );
+
+		describe( 'SplitDelta', () => {
+			it( 'split position is between wrapped nodes', () => {
+				let splitPosition = new Position( root, [ 3, 3, 3, 3 ] );
+				let splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
+
+				let transformed = transform( wrapDelta, splitDelta );
+
+				expect( transformed.length ).to.equal( 2 );
+
+				baseVersion = splitDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: MergeDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 4, 0 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 3, 3, 3 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 4 ] ),
+							howMany: 1,
+							targetPosition: new Position( gy, [ 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				expectDelta( transformed[ 1 ], {
+					type: WrapDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 3, 5 ] ),
+							baseVersion: baseVersion + 2
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3, 1 ] ),
+							howMany: 4,
+							targetPosition: new Position( root, [ 3, 3, 3, 5, 0 ] ),
+							baseVersion: baseVersion + 3
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( splitDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+				applyDelta( transformed[ 1 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 3 ] ), 1 ) );
+
+				// WrapDelta is applied. SplitDelta is discarded.
+				expect( nodesAndText ).to.equal( 'PaEbcfoEobarxyzP' );
+			} );
+
+			it( 'split position is before wrapped nodes', () => {
+				let splitPosition = new Position( root, [ 3, 3, 3, 1 ] );
+				let splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 11, baseVersion );
+
+				let transformed = transform( wrapDelta, splitDelta, true );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = wrapDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: WrapDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 4, 4 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 4, 0 ] ),
+							howMany: 4,
+							targetPosition: new Position( root, [ 3, 3, 4, 4, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( splitDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 3 ] ), 2 ) );
+
+				// WrapDelta and SplitDelta are correctly applied.
+				expect( nodesAndText ).to.equal( 'PaPPEbcfoEobarxyzP' );
+			} );
+		} );
+	} );
+} );

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

@@ -9,8 +9,16 @@
 
 import Document from '/ckeditor5/core/treemodel/document.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
 import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
 
+import UnwrapDelta from '/ckeditor5/core/treemodel/delta/unwrapdelta.js';
+import WrapDelta from '/ckeditor5/core/treemodel/delta/wrapdelta.js';
+
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+import RemoveOperation from '/ckeditor5/core/treemodel/operation/removeoperation.js';
+import ReinsertOperation from '/ckeditor5/core/treemodel/operation/reinsertoperation.js';
+
 describe( 'Batch', () => {
 	let doc, root, p;
 
@@ -50,3 +58,64 @@ describe( 'Batch', () => {
 		} );
 	} );
 } );
+
+describe( 'UnwrapDelta', () => {
+	let unwrapDelta, doc, root;
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+		unwrapDelta = new UnwrapDelta();
+	} );
+
+	describe( 'constructor', () => {
+		it( 'should create unwrap delta with no operations added', () => {
+			expect( unwrapDelta.operations.length ).to.equal( 0 );
+		} );
+	} );
+
+	describe( 'position', () => {
+		it( 'should be null if there are no operations in delta', () => {
+			expect( unwrapDelta.position ).to.be.null;
+		} );
+
+		it( 'should be equal to the position before unwrapped node', () => {
+			unwrapDelta.operations.push( new MoveOperation( new Position( root, [ 1, 2, 0 ] ), 4, new Position( root, [ 1, 2 ] ) ) );
+			unwrapDelta.operations.push( new RemoveOperation( new Position( root, [ 1, 6 ] ), 1 ) );
+
+			expect( unwrapDelta.position.root ).to.equal( root );
+			expect( unwrapDelta.position.path ).to.deep.equal( [ 1, 2 ] );
+		} );
+	} );
+
+	describe( 'getReversed', () => {
+		it( 'should return empty WrapDelta if there are no operations in delta', () => {
+			let reversed = unwrapDelta.getReversed();
+
+			expect( reversed ).to.be.instanceof( WrapDelta );
+			expect( reversed.operations.length ).to.equal( 0 );
+		} );
+
+		it( 'should return correct WrapDelta', () => {
+			unwrapDelta.operations.push( new MoveOperation( new Position( root, [ 1, 2, 0 ] ), 4, new Position( root, [ 1, 2 ] ) ) );
+			unwrapDelta.operations.push( new RemoveOperation( new Position( root, [ 1, 6 ] ), 1 ) );
+
+			let reversed = unwrapDelta.getReversed();
+
+			expect( reversed ).to.be.instanceof( WrapDelta );
+			expect( reversed.operations.length ).to.equal( 2 );
+
+			// WrapDelta which is an effect of reversing UnwrapDelta has ReinsertOperation instead of InsertOperation.
+			// This is because we will "wrap" nodes into the element in which they were in the first place.
+			// That element has been removed so we reinsert it from the graveyard.
+			expect( reversed.operations[ 0 ] ).to.be.instanceof( ReinsertOperation );
+			expect( reversed.operations[ 0 ].howMany ).to.equal( 1 );
+			expect( reversed.operations[ 0 ].targetPosition.path ).to.deep.equal( [ 1, 6 ] );
+
+			expect( reversed.operations[ 1 ] ).to.be.instanceof( MoveOperation );
+			expect( reversed.operations[ 1 ].sourcePosition.path ).to.deep.equal( [ 1, 2 ] );
+			expect( reversed.operations[ 1 ].howMany ).to.equal( 4 );
+			expect( reversed.operations[ 1 ].targetPosition.path ).to.deep.equal( [ 1, 6, 0 ] );
+		} );
+	} );
+} );

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

@@ -13,6 +13,13 @@ import Range from '/ckeditor5/core/treemodel/range.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
 import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
 
+import WrapDelta from '/ckeditor5/core/treemodel/delta/wrapdelta.js';
+import UnwrapDelta from '/ckeditor5/core/treemodel/delta/unwrapdelta.js';
+
+import InsertOperation from '/ckeditor5/core/treemodel/operation/insertoperation.js';
+import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
+import RemoveOperation from '/ckeditor5/core/treemodel/operation/removeoperation.js';
+
 describe( 'Batch', () => {
 	let doc, root, range;
 
@@ -87,3 +94,62 @@ describe( 'Batch', () => {
 		} );
 	} );
 } );
+
+describe( 'WrapDelta', () => {
+	let wrapDelta, doc, root;
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+		wrapDelta = new WrapDelta();
+	} );
+
+	describe( 'constructor', () => {
+		it( 'should create wrap delta with no operations added', () => {
+			expect( wrapDelta.operations.length ).to.equal( 0 );
+		} );
+	} );
+
+	describe( 'range', () => {
+		it( 'should be equal to null if there are no operations in delta', () => {
+			expect( wrapDelta.range ).to.be.null;
+		} );
+
+		it( 'should be equal to wrapped range', () => {
+			wrapDelta.operations.push( new InsertOperation( new Position( root, [ 1, 6 ] ), 1 ) );
+			wrapDelta.operations.push( new MoveOperation( new Position( root, [ 1, 1 ] ), 5, new Position( root, [ 1, 6, 0 ] ) ) );
+
+			expect( wrapDelta.range.start.isEqual( new Position( root, [ 1, 1 ] ) ) ).to.be.true;
+			expect( wrapDelta.range.end.isEqual( new Position( root, [ 1, 6 ] ) ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'getReversed', () => {
+		it( 'should return empty UnwrapDelta if there are no operations in delta', () => {
+			let reversed = wrapDelta.getReversed();
+
+			expect( reversed ).to.be.instanceof( UnwrapDelta );
+			expect( reversed.operations.length ).to.equal( 0 );
+		} );
+
+		it( 'should return correct UnwrapDelta', () => {
+			wrapDelta.operations.push( new InsertOperation( new Position( root, [ 1, 6 ] ), 1 ) );
+			wrapDelta.operations.push( new MoveOperation( new Position( root, [ 1, 1 ] ), 5, new Position( root, [ 1, 6, 0 ] ) ) );
+
+			let reversed = wrapDelta.getReversed();
+
+			expect( reversed ).to.be.instanceof( UnwrapDelta );
+			expect( reversed.operations.length ).to.equal( 2 );
+
+			expect( reversed.operations[ 0 ] ).to.be.instanceof( MoveOperation );
+			expect( reversed.operations[ 0 ].sourcePosition.path ).to.deep.equal( [ 1, 1, 0 ] );
+			expect( reversed.operations[ 0 ].howMany ).to.equal( 5 );
+			expect( reversed.operations[ 0 ].targetPosition.path ).to.deep.equal( [ 1, 1 ] );
+
+			expect( reversed.operations[ 1 ] ).to.be.instanceof( RemoveOperation );
+			expect( reversed.operations[ 1 ].sourcePosition.path ).to.deep.equal( [ 1, 6 ] );
+			expect( reversed.operations[ 1 ].howMany ).to.equal( 1 );
+		} );
+	} );
+} );
+

+ 22 - 9
packages/ckeditor5-engine/tests/treemodel/operation/moveoperation.js

@@ -11,7 +11,6 @@ import Document from '/ckeditor5/core/treemodel/document.js';
 import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
 import Position from '/ckeditor5/core/treemodel/position.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
-import NodeList from '/ckeditor5/core/treemodel/nodelist.js';
 import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
 
 describe( 'MoveOperation', () => {
@@ -33,6 +32,17 @@ describe( 'MoveOperation', () => {
 		expect( op.type ).to.equal( 'move' );
 	} );
 
+	it( 'should be sticky', () => {
+		const op = new MoveOperation(
+			new Position( root, [ 0, 0 ] ),
+			1,
+			new Position( root, [ 1, 0 ] ),
+			doc.version
+		);
+
+		expect( op.isSticky ).to.be.true;
+	} );
+
 	it( 'should move from one node to another', () => {
 		let p1 = new Element( 'p1', [], new Element( 'x' ) );
 		let p2 = new Element( 'p2' );
@@ -99,21 +109,24 @@ describe( 'MoveOperation', () => {
 		expect( root.getChild( 4 ).character ).to.equal( 'x' );
 	} );
 
-	it( 'should create a MoveOperation as a reverse', () => {
-		let nodeList = new NodeList( 'bar' );
-
+	it( 'should create a proper MoveOperation as a reverse', () => {
 		let sourcePosition = new Position( root, [ 0 ] );
 		let targetPosition = new Position( root, [ 4 ] );
 
-		let operation = new MoveOperation( sourcePosition, nodeList.length, targetPosition, doc.version );
-
+		let operation = new MoveOperation( sourcePosition, 3, targetPosition, doc.version );
 		let reverse = operation.getReversed();
 
 		expect( reverse ).to.be.an.instanceof( MoveOperation );
 		expect( reverse.baseVersion ).to.equal( 1 );
-		expect( reverse.howMany ).to.equal( nodeList.length );
-		expect( reverse.sourcePosition.isEqual( targetPosition ) ).to.be.true;
-		expect( reverse.targetPosition.isEqual( sourcePosition ) ).to.be.true;
+		expect( reverse.howMany ).to.equal( 3 );
+		expect( reverse.sourcePosition.path ).is.deep.equal( [ 1 ] );
+		expect( reverse.targetPosition.path ).is.deep.equal( [ 0 ] );
+
+		operation = new MoveOperation( targetPosition, 3, sourcePosition, doc.version );
+		reverse = operation.getReversed();
+
+		expect( reverse.sourcePosition.path ).is.deep.equal( [ 0 ] );
+		expect( reverse.targetPosition.path ).is.deep.equal( [ 7 ] );
 	} );
 
 	it( 'should undo move node by applying reverse operation', () => {

+ 33 - 0
packages/ckeditor5-engine/tests/treemodel/operation/operation.js

@@ -0,0 +1,33 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, delta */
+
+'use strict';
+
+import Delta from '/ckeditor5/core/treemodel/delta/delta.js';
+import Operation from '/ckeditor5/core/treemodel/operation/operation.js';
+
+describe( 'Operation', () => {
+	it( 'should save its base version', () => {
+		let op = new Operation( 4 );
+
+		expect( op.baseVersion ).to.equal( 4 );
+	} );
+
+	it( 'should be correctly transformed to JSON', () => {
+		let delta = new Delta();
+		let opInDelta = new Operation( 0 );
+		delta.addOperation( opInDelta );
+
+		let opOutsideDelta = new Operation( 0 );
+
+		let parsedIn = JSON.parse( JSON.stringify( opInDelta ) );
+		let parsedOutside = JSON.parse( JSON.stringify( opOutsideDelta ) );
+
+		expect( parsedIn.delta ).to.equal( '[core.treeModel.Delta]' );
+		expect( parsedOutside.delta ).to.be.null;
+	} );
+} );

+ 4 - 0
packages/ckeditor5-engine/tests/treemodel/operation/reinsertoperation.js

@@ -36,6 +36,10 @@ describe( 'ReinsertOperation', () => {
 		expect( operation.type ).to.equal( 'reinsert' );
 	} );
 
+	it( 'should not be sticky', () => {
+		expect( operation.isSticky ).to.be.false;
+	} );
+
 	it( 'should extend MoveOperation class', () => {
 		expect( operation ).to.be.instanceof( MoveOperation );
 	} );

+ 10 - 0
packages/ckeditor5-engine/tests/treemodel/operation/removeoperation.js

@@ -32,6 +32,16 @@ describe( 'RemoveOperation', () => {
 		expect( op.type ).to.equal( 'remove' );
 	} );
 
+	it( 'should not be sticky', () => {
+		const op = new RemoveOperation(
+			new Position( root, [ 2 ] ),
+			2,
+			doc.version
+		);
+
+		expect( op.isSticky ).to.be.false;
+	} );
+
 	it( 'should extend MoveOperation class', () => {
 		let operation = new RemoveOperation(
 			new Position( root, [ 2 ] ),

+ 132 - 96
packages/ckeditor5-engine/tests/treemodel/operation/transform.js

@@ -57,7 +57,7 @@ describe( 'transform', () => {
 			nodeC = new Node();
 			nodeD = new Node();
 
-			position = new Position( root, [ 0, 2, 1 ] );
+			position = new Position( root, [ 0, 2, 2 ] );
 
 			op = new InsertOperation( position, [ nodeA, nodeB ], baseVersion );
 
@@ -98,7 +98,7 @@ describe( 'transform', () => {
 
 			it( 'target at same offset and is important: increment offset', () => {
 				let transformBy = new InsertOperation(
-					new Position( root, [ 0, 2, 1 ] ),
+					new Position( root, [ 0, 2, 2 ] ),
 					[ nodeC, nodeD ],
 					baseVersion
 				);
@@ -112,7 +112,7 @@ describe( 'transform', () => {
 
 			it( 'target at same offset and is less important: no position update', () => {
 				let transformBy = new InsertOperation(
-					new Position( root, [ 0, 2, 1 ] ),
+					new Position( root, [ 0, 2, 2 ] ),
 					[ nodeC, nodeD ],
 					baseVersion
 				);
@@ -125,7 +125,7 @@ describe( 'transform', () => {
 
 			it( 'target at offset after: no position update', () => {
 				let transformBy = new InsertOperation(
-					new Position( root, [ 0, 2, 2 ] ),
+					new Position( root, [ 0, 2, 3 ] ),
 					[ nodeC, nodeD ],
 					baseVersion
 				);
@@ -196,7 +196,7 @@ describe( 'transform', () => {
 				expectOperation( transOp[ 0 ], expected );
 			} );
 
-			it( 'range offset is before insert position offset: decrement offset', () => {
+			it( 'range is before insert position offset: decrement offset', () => {
 				let transformBy = new MoveOperation(
 					new Position( root, [ 0, 2, 0 ] ),
 					1,
@@ -213,7 +213,7 @@ describe( 'transform', () => {
 
 			it( 'range offset is after insert position offset: no position update', () => {
 				let transformBy = new MoveOperation(
-					new Position( root, [ 0, 2, 4 ] ),
+					new Position( root, [ 0, 2, 5 ] ),
 					1,
 					new Position( root, [ 1, 1 ] ),
 					baseVersion
@@ -244,7 +244,7 @@ describe( 'transform', () => {
 				let transformBy = new MoveOperation(
 					new Position( root, [ 1, 1 ] ),
 					2,
-					new Position( root, [ 0, 2, 4 ] ),
+					new Position( root, [ 0, 2, 5 ] ),
 					baseVersion
 				);
 
@@ -258,7 +258,7 @@ describe( 'transform', () => {
 				let transformBy = new MoveOperation(
 					new Position( root, [ 1, 1 ] ),
 					2,
-					new Position( root, [ 0, 2, 1 ] ),
+					new Position( root, [ 0, 2, 2 ] ),
 					baseVersion
 				);
 
@@ -273,7 +273,7 @@ describe( 'transform', () => {
 				let transformBy = new MoveOperation(
 					new Position( root, [ 1, 1 ] ),
 					2,
-					new Position( root, [ 0, 2, 1 ] ),
+					new Position( root, [ 0, 2, 2 ] ),
 					baseVersion
 				);
 
@@ -286,13 +286,13 @@ describe( 'transform', () => {
 			it( 'range is before node from insert position path: decrement index on path', () => {
 				let transformBy = new MoveOperation(
 					new Position( root, [ 0, 0 ] ),
-					2,
+					1,
 					new Position( root, [ 1, 0 ] ),
 					baseVersion
 				);
 
 				let transOp = transform( op, transformBy );
-				expected.position.path[ 1 ] -= 2;
+				expected.position.path[ 1 ] -= 1;
 
 				expect( transOp.length ).to.equal( 1 );
 				expectOperation( transOp[ 0 ], expected );
@@ -350,22 +350,22 @@ describe( 'transform', () => {
 				);
 
 				let transOp = transform( op, transformBy );
-				expected.position.path = [ 1, 2, 1 ];
+				expected.position.path = [ 1, 2, 2 ];
 
 				expect( transOp.length ).to.equal( 1 );
 				expectOperation( transOp[ 0 ], expected );
 			} );
 
-			it( 'range contains insert position (on same level): set position offset to range start', () => {
+			it( 'range contains insert position (on same level): update position', () => {
 				let transformBy = new MoveOperation(
 					new Position( root, [ 0, 2, 0 ] ),
-					3,
+					4,
 					new Position( root, [ 1, 0 ] ),
 					baseVersion
 				);
 
 				let transOp = transform( op, transformBy );
-				expected.position.offset = 0;
+				expected.position.path = [ 1, 2 ];
 
 				expect( transOp.length ).to.equal( 1 );
 				expectOperation( transOp[ 0 ], expected );
@@ -1335,7 +1335,7 @@ describe( 'transform', () => {
 
 			it( 'target inside node from move range: no operation update', () => {
 				let transformBy = new InsertOperation(
-					new Position( root, [ 2, 2, 3, 1 ] ),
+					new Position( root, [ 2, 2, 4, 1 ] ),
 					[ nodeA, nodeB ],
 					baseVersion
 				);
@@ -1363,7 +1363,7 @@ describe( 'transform', () => {
 
 			it( 'target at offset after range offset: no operation update', () => {
 				let transformBy = new InsertOperation(
-					new Position( root, [ 2, 2, 6 ] ),
+					new Position( root, [ 2, 2, 7 ] ),
 					[ nodeA, nodeB ],
 					baseVersion
 				);
@@ -1486,7 +1486,7 @@ describe( 'transform', () => {
 				expectOperation( transOp[ 0 ], expected );
 			} );
 
-			it( 'target offset same as move target offset and is important: increment target offset', () => {
+			it( 'target offset same as move target offset: increment target offset', () => {
 				let transformBy = new InsertOperation(
 					new Position( root, [ 3, 3 ] ),
 					[ nodeA, nodeB ],
@@ -1501,7 +1501,7 @@ describe( 'transform', () => {
 				expectOperation( transOp[ 0 ], expected );
 			} );
 
-			it( 'target inside move range: split into two operations', () => {
+			it( 'target inside move range: expand range', () => {
 				let transformBy = new InsertOperation(
 					new Position( root, [ 2, 2, 5 ] ),
 					[ nodeA, nodeB ],
@@ -1510,18 +1510,26 @@ describe( 'transform', () => {
 
 				let transOp = transform( op, transformBy );
 
-				expect( transOp.length ).to.equal( 2 );
+				expect( transOp.length ).to.equal( 1 );
 
-				expected.sourcePosition.path = [ 2, 2, 7 ];
-				expected.howMany = 1;
+				expected.howMany = 4;
 
 				expectOperation( transOp[ 0 ], expected );
+			} );
 
-				expected.sourcePosition.path = [ 2, 2, 4 ];
-				expected.howMany = 1;
-				expected.baseVersion++;
+			it( 'target at offset same as range end boundary: expand range', () => {
+				let transformBy = new InsertOperation(
+					new Position( root, [ 2, 2, 6 ] ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
 
-				expectOperation( transOp[ 1 ], expected );
+				let transOp = transform( op, transformBy );
+
+				expected.howMany = 4;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
 			} );
 		} );
 
@@ -1650,14 +1658,14 @@ describe( 'transform', () => {
 			it( 'range before node from transforming range start path: decrement index on range start path', () => {
 				let transformBy = new MoveOperation(
 					new Position( root, [ 2, 0 ] ),
-					2,
+					1,
 					new Position( root, [ 4, 1, 0 ] ),
 					baseVersion
 				);
 
 				let transOp = transform( op, transformBy );
 
-				expected.sourcePosition.path[ 1 ] -= 2;
+				expected.sourcePosition.path[ 1 ] -= 1;
 
 				expect( transOp.length ).to.equal( 1 );
 				expectOperation( transOp[ 0 ], expected );
@@ -1797,7 +1805,7 @@ describe( 'transform', () => {
 				expectOperation( transOp[ 0 ], expected );
 			} );
 
-			it( 'target inside transforming move range: split into two operations', () => {
+			it( 'target inside transforming move range: expand move range', () => {
 				let transformBy = new MoveOperation(
 					new Position( root, [ 4, 1, 0 ] ),
 					2,
@@ -1807,17 +1815,45 @@ describe( 'transform', () => {
 
 				let transOp = transform( op, transformBy );
 
-				expect( transOp.length ).to.equal( 2 );
+				expect( transOp.length ).to.equal( 1 );
 
-				expected.howMany = 1;
-				expected.sourcePosition.offset = 7;
+				expected.howMany = 4;
 
 				expectOperation( transOp[ 0 ], expected );
+			} );
 
-				expected.sourcePosition.offset = 4;
-				expected.baseVersion++;
+			it( 'target at start boundary of transforming move range: expand move range', () => {
+				let transformBy = new MoveOperation(
+					new Position( root, [ 4, 1, 0 ] ),
+					2,
+					new Position( root, [ 2, 2, 4 ] ),
+					baseVersion
+				);
 
-				expectOperation( transOp[ 1 ], expected );
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+
+				expected.howMany = 4;
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target at end boundary of transforming move range: expand move range', () => {
+				let transformBy = new MoveOperation(
+					new Position( root, [ 4, 1, 0 ] ),
+					2,
+					new Position( root, [ 2, 2, 6 ] ),
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+
+				expected.howMany = 4;
+
+				expectOperation( transOp[ 0 ], expected );
 			} );
 
 			it( 'target inside a node from transforming range: no operation update', () => {
@@ -2073,7 +2109,7 @@ describe( 'transform', () => {
 				expectOperation( transOp[ 1 ], expected );
 			} );
 
-			it( 'range intersects on left side, target inside transforming range and is important: split into two operations', () => {
+			it( 'range intersects on left side, target inside transforming range and is important: expand move range', () => {
 				op.howMany = 4;
 
 				let transformBy = new MoveOperation(
@@ -2085,21 +2121,15 @@ describe( 'transform', () => {
 
 				let transOp = transform( op, transformBy );
 
-				expect( transOp.length ).to.equal( 2 );
-
-				expected.sourcePosition.path = [ 2, 2, 6 ];
-				expected.howMany = 2;
-
-				expectOperation( transOp[ 0 ], expected );
+				expect( transOp.length ).to.equal( 1 );
 
 				expected.sourcePosition.path = [ 2, 2, 3 ];
-				expected.howMany = 1;
-				expected.baseVersion++;
+				expected.howMany = 5;
 
-				expectOperation( transOp[ 1 ], expected );
+				expectOperation( transOp[ 0 ], expected );
 			} );
 
-			it( 'range intersects on left side, target inside transforming range and is less important: split into three operations', () => {
+			it( 'range intersects on left side, target inside transforming range and is less important: expand move range', () => {
 				op.howMany = 4;
 
 				let transformBy = new MoveOperation(
@@ -2111,85 +2141,103 @@ describe( 'transform', () => {
 
 				let transOp = transform( op, transformBy, true );
 
-				expect( transOp.length ).to.equal( 3 );
+				expect( transOp.length ).to.equal( 1 );
 
-				expected.sourcePosition.path = [ 2, 2, 6 ];
-				expected.howMany = 2;
+				expected.sourcePosition.path = [ 2, 2, 3 ];
+				expected.howMany = 5;
 
 				expectOperation( transOp[ 0 ], expected );
+			} );
 
-				expected.sourcePosition.path = [ 2, 2, 3 ];
-				expected.howMany = 1;
-				expected.baseVersion++;
+			it( 'range intersects on right side of transforming range and is important: shrink range', () => {
+				let transformBy = new MoveOperation(
+					new Position( root, [ 2, 2, 5 ] ),
+					2,
+					new Position( root, [ 4, 1, 0 ] ),
+					baseVersion
+				);
 
-				expectOperation( transOp[ 1 ], expected );
+				let transOp = transform( op, transformBy );
 
-				expected.sourcePosition.path = [ 2, 2, 5 ];
 				expected.howMany = 1;
-				expected.baseVersion++;
 
-				expectOperation( transOp[ 2 ], expected );
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
 			} );
 
-			it( 'range intersects on right side, target inside transforming range and is important: split into two operations', () => {
-				op.howMany = 4;
-
+			it( 'range intersects on right side of transforming range and is less important: split into two operations', () => {
 				let transformBy = new MoveOperation(
-					new Position( root, [ 2, 2, 7 ] ),
-					2,
 					new Position( root, [ 2, 2, 5 ] ),
+					2,
+					new Position( root, [ 4, 1, 0 ] ),
 					baseVersion
 				);
 
-				let transOp = transform( op, transformBy );
+				let transOp = transform( op, transformBy, true );
 
 				expect( transOp.length ).to.equal( 2 );
 
-				expected.sourcePosition.path = [ 2, 2, 7 ];
-				expected.howMany = 2;
+				expected.sourcePosition.path = [ 4, 1, 0 ];
+				expected.howMany = 1;
 
 				expectOperation( transOp[ 0 ], expected );
 
-				expected.sourcePosition.path = [ 2, 2, 4 ];
-				expected.howMany = 1;
+				expected.sourcePosition.path = op.sourcePosition.path;
 				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;
+			it( 'range intersects, target inside transforming range and is important: split into two operations', () => {
+				op.targetPosition.path = [ 2, 2, 7 ];
 
 				let transformBy = new MoveOperation(
-					new Position( root, [ 2, 2, 7 ] ),
-					2,
 					new Position( root, [ 2, 2, 5 ] ),
+					4,
+					new Position( root, [ 4, 1, 0 ] ),
 					baseVersion
 				);
 
 				let transOp = transform( op, transformBy, true );
 
-				expect( transOp.length ).to.equal( 3 );
+				expect( transOp.length ).to.equal( 2 );
 
-				expected.sourcePosition.path = [ 2, 2, 5 ];
+				expected.sourcePosition.path = [ 4, 1, 0 ];
+				expected.targetPosition.path = [ 4, 1, 2 ];
 				expected.howMany = 1;
 
 				expectOperation( transOp[ 0 ], expected );
 
-				expected.sourcePosition.path = [ 2, 2, 7 ];
-				expected.howMany = 2;
+				expected.sourcePosition.path = [ 2, 2, 4 ];
+				expected.targetPosition.path = [ 4, 1, 2 ];
+				expected.howMany = 1;
 				expected.baseVersion++;
 
 				expectOperation( transOp[ 1 ], expected );
+			} );
+
+			it( 'range intersects, target inside transforming range and is less important: shrink range', () => {
+				op.targetPosition.path = [ 2, 2, 7 ];
+
+				let transformBy = new MoveOperation(
+					new Position( root, [ 2, 2, 5 ] ),
+					4,
+					new Position( root, [ 4, 1, 0 ] ),
+					baseVersion
+				);
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
 
 				expected.sourcePosition.path = [ 2, 2, 4 ];
+				expected.targetPosition.path = [ 4, 1, 2 ];
 				expected.howMany = 1;
-				expected.baseVersion++;
 
-				expectOperation( transOp[ 2 ], expected );
+				expectOperation( transOp[ 0 ], expected );
 			} );
 
-			it( 'range inside transforming range and is important: split into two operations', () => {
+			it( 'range inside transforming range and is important: shrink range', () => {
 				op.howMany = 4;
 
 				let transformBy = new MoveOperation(
@@ -2201,20 +2249,14 @@ describe( 'transform', () => {
 
 				let transOp = transform( op, transformBy );
 
-				expect( transOp.length ).to.equal( 2 );
+				expect( transOp.length ).to.equal( 1 );
 
-				expected.sourcePosition.path = [ 2, 2, 5 ];
-				expected.howMany = 1;
+				expected.howMany = 2;
 
 				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', () => {
+			it( 'range inside transforming range and is less important: split into two operations', () => {
 				op.howMany = 4;
 
 				let transformBy = new MoveOperation(
@@ -2226,24 +2268,18 @@ describe( 'transform', () => {
 
 				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 );
+				expect( transOp.length ).to.equal( 2 );
 
 				expected.sourcePosition.path = [ 4, 1, 0 ];
 				expected.howMany = 2;
-				expected.baseVersion++;
 
-				expectOperation( transOp[ 1 ], expected );
+				expectOperation( transOp[ 0 ], expected );
 
 				expected.sourcePosition.path = [ 2, 2, 4 ];
-				expected.howMany = 1;
+				expected.howMany = 2;
 				expected.baseVersion++;
 
-				expectOperation( transOp[ 2 ], expected );
+				expectOperation( transOp[ 1 ], expected );
 			} );
 
 			it( 'range and target inside transforming range and is important: no operation update', () => {

+ 16 - 2
packages/ckeditor5-engine/tests/treemodel/position.js

@@ -472,11 +472,11 @@ describe( 'position', () => {
 			expect( transformed.offset ).to.equal( 5 );
 		} );
 
-		it( 'should set position offset to deletion offset if position is between removed nodes', () => {
+		it( 'should return null if original position is between removed nodes', () => {
 			const position = new Position( root, [ 1, 2, 4 ] );
 			const transformed = position.getTransformedByDeletion( new Position( root, [ 1, 2, 3 ] ), 5 );
 
-			expect( transformed.offset ).to.equal( 3 );
+			expect( transformed ).to.be.null;
 		} );
 
 		it( 'should not decrement offset if deletion position is in different root', () => {
@@ -517,12 +517,26 @@ describe( 'position', () => {
 		} );
 
 		it( 'should decrement offset if a range was moved from the same parent and closer offset', () => {
+			const position = new Position( root, [ 1, 2, 6 ] );
+			const transformed = position.getTransformedByMove( new Position( root, [ 1, 2, 0 ] ), new Position( root, [ 2 ] ), 3, false );
+
+			expect( transformed.path ).to.deep.equal( [ 1, 2, 3 ] );
+		} );
+
+		it( 'should decrement offset if position was at the end of a range and move was not sticky', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );
 			const transformed = position.getTransformedByMove( new Position( root, [ 1, 2, 0 ] ), new Position( root, [ 2 ] ), 3, false );
 
 			expect( transformed.path ).to.deep.equal( [ 1, 2, 0 ] );
 		} );
 
+		it( 'should update path if position was at the end of a range and move was sticky', () => {
+			const position = new Position( root, [ 1, 2, 3 ] );
+			const transformed = position.getTransformedByMove( new Position( root, [ 1, 2, 0 ] ), new Position( root, [ 2 ] ), 3, false, true );
+
+			expect( transformed.path ).to.deep.equal( [ 5 ] );
+		} );
+
 		it( 'should update path if a range contained this position', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );
 			const transformed = position.getTransformedByMove( new Position( root, [ 1, 1 ] ), new Position( root, [ 2, 1 ] ), 3, false );

+ 53 - 3
packages/ckeditor5-engine/tests/treemodel/range.js

@@ -360,9 +360,19 @@ describe( 'Range', () => {
 			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', () => {
+		it( 'should expand range if insertion was in the middle of range', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 5, 4 ] ) );
-			const transformed = range.getTransformedByInsertion( new Position( root, [ 4, 1, 6 ] ), 4 );
+			const transformed = range.getTransformedByInsertion( new Position( root, [ 5, 0 ] ), 4 );
+
+			expect( transformed.length ).to.equal( 1 );
+
+			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
+			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 5, 8 ] );
+		} );
+
+		it( 'should return array with two ranges if insertion was in the middle of range and spread flag was set', () => {
+			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 5, 4 ] ) );
+			const transformed = range.getTransformedByInsertion( new Position( root, [ 4, 1, 6 ] ), 4, true );
 
 			expect( transformed.length ).to.equal( 2 );
 
@@ -373,13 +383,53 @@ describe( 'Range', () => {
 			expect( transformed[ 1 ].end.path ).to.deep.equal( [ 5, 4 ] );
 		} );
 
-		it( 'should not updated positions if insertion is after range', () => {
+		it( 'should not expand range if insertion is equal to start boundary of the range', () => {
+			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 8 ] ) );
+			const transformed = range.getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 3 );
+
+			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 5 ] );
+			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 3, 11 ] );
+		} );
+
+		it( 'should expand range if insertion is equal to start boundary of the range and sticky flag is set', () => {
+			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 8 ] ) );
+			const transformed = range.getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 3, false, true );
+
+			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
+			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 3, 11 ] );
+		} );
+
+		it( 'should not update positions if insertion is before range (but not equal to the start boundary)', () => {
+			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 8 ] ) );
+			const transformed = range.getTransformedByInsertion( new Position( root, [ 3, 1 ] ), 3 );
+
+			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 5 ] );
+			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 3, 11 ] );
+		} );
+
+		it( 'should not expand range if insertion is equal to end boundary of the range', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 4, 4 ] ) );
 			const transformed = range.getTransformedByInsertion( new Position( root, [ 4, 4 ] ), 3 );
 
 			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
 			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 4, 4 ] );
 		} );
+
+		it( 'should expand range if insertion is equal to end boundary of the range and sticky flag is set', () => {
+			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 4, 4 ] ) );
+			const transformed = range.getTransformedByInsertion( new Position( root, [ 4, 4 ] ), 3, false, true );
+
+			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
+			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 4, 7 ] );
+		} );
+
+		it( 'should not update positions if insertion is after range (but not equal to the end boundary)', () => {
+			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 4, 4 ] ) );
+			const transformed = range.getTransformedByInsertion( new Position( root, [ 4, 5 ] ), 3 );
+
+			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
+			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 4, 4 ] );
+		} );
 	} );
 
 	describe( 'getDifference', () => {