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

Merge pull request #977 from ckeditor/remove-refactor

Feature: OT will use context information to achieve better conflict resolution.

This change includes refactoring of: `History`, `RemoveOperation`, operational transformation algorithms, delta transformation algorithms and more.

Context information will be used instead of removing deltas from history, which caused bugs in more complicated scenarios. This mostly affects undo algorithms.

BREAKING CHANGE: `History` API for deleting undone deltas has been removed.
BREAKING CHANGE: `deltaTransform#transformDeltaSets()` is now an internal method. Use `Document#transformDeltas()` instead.
Piotrek Koszuliński 8 лет назад
Родитель
Сommit
23e61d5a80
57 измененных файлов с 2070 добавлено и 1255 удалено
  1. 1 1
      packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js
  2. 6 6
      packages/ckeditor5-engine/src/dev-utils/enableenginedebug.js
  3. 5 7
      packages/ckeditor5-engine/src/model/delta/attributedelta.js
  4. 63 95
      packages/ckeditor5-engine/src/model/delta/basic-transformations.js
  5. 0 12
      packages/ckeditor5-engine/src/model/delta/delta.js
  6. 0 7
      packages/ckeditor5-engine/src/model/delta/insertdelta.js
  7. 4 14
      packages/ckeditor5-engine/src/model/delta/mergedelta.js
  8. 0 7
      packages/ckeditor5-engine/src/model/delta/movedelta.js
  9. 11 8
      packages/ckeditor5-engine/src/model/delta/removedelta.js
  10. 1 21
      packages/ckeditor5-engine/src/model/delta/splitdelta.js
  11. 417 54
      packages/ckeditor5-engine/src/model/delta/transform.js
  12. 4 8
      packages/ckeditor5-engine/src/model/delta/unwrapdelta.js
  13. 0 7
      packages/ckeditor5-engine/src/model/delta/wrapdelta.js
  14. 21 0
      packages/ckeditor5-engine/src/model/document.js
  15. 58 78
      packages/ckeditor5-engine/src/model/history.js
  16. 15 6
      packages/ckeditor5-engine/src/model/liverange.js
  17. 31 17
      packages/ckeditor5-engine/src/model/liveselection.js
  18. 4 1
      packages/ckeditor5-engine/src/model/operation/insertoperation.js
  19. 2 6
      packages/ckeditor5-engine/src/model/operation/reinsertoperation.js
  20. 3 111
      packages/ckeditor5-engine/src/model/operation/removeoperation.js
  21. 261 179
      packages/ckeditor5-engine/src/model/operation/transform.js
  22. 67 54
      packages/ckeditor5-engine/src/model/range.js
  23. 12 2
      packages/ckeditor5-engine/src/view/range.js
  24. 37 10
      packages/ckeditor5-engine/tests/controller/editingcontroller.js
  25. 4 5
      packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js
  26. 36 20
      packages/ckeditor5-engine/tests/dev-utils/enableenginedebug.js
  27. 13 0
      packages/ckeditor5-engine/tests/model/delta/attributedelta.js
  28. 1 0
      packages/ckeditor5-engine/tests/model/delta/delta.js
  29. 1 0
      packages/ckeditor5-engine/tests/model/delta/deltafactory.js
  30. 2 2
      packages/ckeditor5-engine/tests/model/delta/mergedelta.js
  31. 4 3
      packages/ckeditor5-engine/tests/model/delta/removedelta.js
  32. 27 4
      packages/ckeditor5-engine/tests/model/delta/splitdelta.js
  33. 13 3
      packages/ckeditor5-engine/tests/model/delta/transform/_utils/utils.js
  34. 75 5
      packages/ckeditor5-engine/tests/model/delta/transform/attributedelta.js
  35. 6 2
      packages/ckeditor5-engine/tests/model/delta/transform/delta.js
  36. 9 6
      packages/ckeditor5-engine/tests/model/delta/transform/insertdelta.js
  37. 9 8
      packages/ckeditor5-engine/tests/model/delta/transform/markerdelta.js
  38. 9 8
      packages/ckeditor5-engine/tests/model/delta/transform/mergedelta.js
  39. 33 5
      packages/ckeditor5-engine/tests/model/delta/transform/movedelta.js
  40. 36 5
      packages/ckeditor5-engine/tests/model/delta/transform/removedelta.js
  41. 32 3
      packages/ckeditor5-engine/tests/model/delta/transform/renamedelta.js
  42. 61 24
      packages/ckeditor5-engine/tests/model/delta/transform/splitdelta.js
  43. 276 132
      packages/ckeditor5-engine/tests/model/delta/transform/transform.js
  44. 7 6
      packages/ckeditor5-engine/tests/model/delta/transform/unwrapdelta.js
  45. 4 3
      packages/ckeditor5-engine/tests/model/delta/transform/weakinsertdelta.js
  46. 6 5
      packages/ckeditor5-engine/tests/model/delta/transform/wrapdelta.js
  47. 2 2
      packages/ckeditor5-engine/tests/model/delta/unwrapdelta.js
  48. 3 5
      packages/ckeditor5-engine/tests/model/document/change-event.js
  49. 40 0
      packages/ckeditor5-engine/tests/model/document/document.js
  50. 50 70
      packages/ckeditor5-engine/tests/model/history.js
  51. 26 0
      packages/ckeditor5-engine/tests/model/liverange.js
  52. 3 0
      packages/ckeditor5-engine/tests/model/liveselection.js
  53. 13 13
      packages/ckeditor5-engine/tests/model/operation/reinsertoperation.js
  54. 30 43
      packages/ckeditor5-engine/tests/model/operation/removeoperation.js
  55. 163 162
      packages/ckeditor5-engine/tests/model/operation/transform.js
  56. 27 0
      packages/ckeditor5-engine/tests/model/range.js
  57. 26 0
      packages/ckeditor5-engine/tests/view/range.js

+ 1 - 1
packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js

@@ -224,7 +224,7 @@ export default class ModelConversionDispatcher {
 			}
 
 			// Check if inserted content contains a marker.
-			if ( range.containsRange( markerRange ) || range.isEqual( markerRange ) ) {
+			if ( range.containsRange( markerRange, true ) ) {
 				this.convertMarker( 'addMarker', marker.name, markerRange );
 			}
 		}

+ 6 - 6
packages/ckeditor5-engine/src/dev-utils/enableenginedebug.js

@@ -287,7 +287,7 @@ function enableLoggingTools() {
 		const range = ModelRange.createFromPositionAndShift( this.sourcePosition, this.howMany );
 
 		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
-			`${ range } -> ${ this.targetPosition }`;
+			`${ range } -> ${ this.targetPosition }${ this.isSticky ? ' (sticky)' : '' }`;
 	};
 
 	NoOperation.prototype.toString = function() {
@@ -334,15 +334,15 @@ function enableLoggingTools() {
 
 	const _deltaTransformTransform = deltaTransform.transform;
 
-	deltaTransform.transform = function( a, b, isAMoreImportantThanB ) {
+	deltaTransform.transform = function( a, b, context ) {
 		let results;
 
 		try {
-			results = _deltaTransformTransform( a, b, isAMoreImportantThanB );
+			results = _deltaTransformTransform( a, b, context );
 		} catch ( e ) {
 			logger.error( 'Error during delta transformation!' );
-			logger.error( a.toString() + ( isAMoreImportantThanB ? ' (important)' : '' ) );
-			logger.error( b.toString() + ( isAMoreImportantThanB ? '' : ' (important)' ) );
+			logger.error( a.toString() + ( context.isStrong ? ' (important)' : '' ) );
+			logger.error( b.toString() + ( context.isStrong ? '' : ' (important)' ) );
 
 			throw e;
 		}
@@ -351,7 +351,7 @@ function enableLoggingTools() {
 			results[ i ]._saveHistory( {
 				before: a,
 				transformedBy: b,
-				wasImportant: !!isAMoreImportantThanB,
+				wasImportant: !!context.isStrong,
 				resultIndex: i,
 				resultsTotal: results.length
 			} );

+ 5 - 7
packages/ckeditor5-engine/src/model/delta/attributedelta.js

@@ -12,6 +12,7 @@ import DeltaFactory from './deltafactory';
 import { register } from '../batch';
 import AttributeOperation from '../operation/attributeoperation';
 import RootAttributeOperation from '../operation/rootattributeoperation';
+import NoOperation from '../operation/nooperation';
 import Position from '../position';
 import Range from '../range';
 
@@ -65,6 +66,10 @@ export default class AttributeDelta extends Delta {
 		let end = null;
 
 		for ( const operation of this.operations ) {
+			if ( operation instanceof NoOperation ) {
+				continue;
+			}
+
 			if ( start === null || start.isAfter( operation.range.start ) ) {
 				start = operation.range.start;
 			}
@@ -104,13 +109,6 @@ export default class AttributeDelta extends Delta {
 	static get className() {
 		return 'engine.model.delta.AttributeDelta';
 	}
-
-	/**
-	 * @inheritDoc
-	 */
-	static get _priority() {
-		return 20;
-	}
 }
 
 /**

+ 63 - 95
packages/ckeditor5-engine/src/model/delta/basic-transformations.js

@@ -17,7 +17,6 @@ import Position from '../position';
 import NoOperation from '../operation/nooperation';
 import AttributeOperation from '../operation/attributeoperation';
 import InsertOperation from '../operation/insertoperation';
-import ReinsertOperation from '../operation/reinsertoperation';
 
 import Delta from './delta';
 import AttributeDelta from './attributedelta';
@@ -37,10 +36,10 @@ import compareArrays from '@ckeditor/ckeditor5-utils/src/comparearrays';
 // Provide transformations for default deltas.
 
 // Add special case for AttributeDelta x WeakInsertDelta transformation.
-addTransformationCase( AttributeDelta, WeakInsertDelta, ( a, b, isStrong ) => {
+addTransformationCase( AttributeDelta, WeakInsertDelta, ( a, b, context ) => {
 	// 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 );
+	const deltas = defaultTransform( a, b, context );
 
 	if ( a.range.containsPosition( b.position ) ) {
 		deltas.push( _getComplementaryAttrDelta( b, a ) );
@@ -50,10 +49,10 @@ addTransformationCase( AttributeDelta, WeakInsertDelta, ( a, b, isStrong ) => {
 } );
 
 // Add special case for AttributeDelta x SplitDelta transformation.
-addTransformationCase( AttributeDelta, SplitDelta, ( a, b, isStrong ) => {
+addTransformationCase( AttributeDelta, SplitDelta, ( a, b, context ) => {
 	const splitPosition = new Position( b.position.root, b.position.path.slice( 0, -1 ) );
 
-	const deltas = defaultTransform( a, b, isStrong );
+	const deltas = defaultTransform( a, b, context );
 
 	for ( const operation of a.operations ) {
 		// If a node that has been split has it's attribute updated, we should also update attribute of
@@ -85,7 +84,7 @@ addTransformationCase( AttributeDelta, SplitDelta, ( a, b, isStrong ) => {
 } );
 
 // Add special case for InsertDelta x MergeDelta transformation.
-addTransformationCase( InsertDelta, MergeDelta, ( a, b, isStrong ) => {
+addTransformationCase( InsertDelta, MergeDelta, ( a, b, context ) => {
 	// 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.
@@ -96,7 +95,7 @@ addTransformationCase( InsertDelta, MergeDelta, ( a, b, isStrong ) => {
 		];
 	}
 
-	return defaultTransform( a, b, isStrong );
+	return defaultTransform( a, b, context );
 } );
 
 function transformMarkerDelta( a, b ) {
@@ -122,7 +121,7 @@ addTransformationCase( MarkerDelta, MoveDelta, transformMarkerDelta );
 addTransformationCase( MarkerDelta, RenameDelta, transformMarkerDelta );
 
 // Add special case for MoveDelta x MergeDelta transformation.
-addTransformationCase( MoveDelta, MergeDelta, ( a, b, isStrong ) => {
+addTransformationCase( MoveDelta, MergeDelta, ( a, b, context ) => {
 	// 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.
@@ -140,22 +139,22 @@ addTransformationCase( MoveDelta, MergeDelta, ( a, b, isStrong ) => {
 		];
 	}
 
-	return defaultTransform( a, b, isStrong );
+	return defaultTransform( a, b, context );
 } );
 
 // Add special case for MergeDelta x InsertDelta transformation.
-addTransformationCase( MergeDelta, InsertDelta, ( a, b, isStrong ) => {
+addTransformationCase( MergeDelta, InsertDelta, ( a, b, context ) => {
 	// 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 ) ) {
 		return [ noDelta() ];
 	}
 
-	return defaultTransform( a, b, isStrong );
+	return defaultTransform( a, b, context );
 } );
 
 // Add special case for MergeDelta x MoveDelta transformation.
-addTransformationCase( MergeDelta, MoveDelta, ( a, b, isStrong ) => {
+addTransformationCase( MergeDelta, MoveDelta, ( a, b, context ) => {
 	// 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.
 
@@ -169,87 +168,61 @@ addTransformationCase( MergeDelta, MoveDelta, ( a, b, isStrong ) => {
 		return [ noDelta() ];
 	}
 
-	return defaultTransform( a, b, isStrong );
+	return defaultTransform( a, b, context );
 } );
 
 // Add special case for SplitDelta x SplitDelta transformation.
-addTransformationCase( SplitDelta, SplitDelta, ( a, b, isStrong ) => {
+addTransformationCase( SplitDelta, SplitDelta, ( a, b, context ) => {
 	const pathA = a.position.getParentPath();
 	const pathB = b.position.getParentPath();
 
 	// The special case is for splits inside the same parent.
-	if ( 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 [ noDelta() ];
-		} 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;
-
-			// If both SplitDeltas are taking their nodes from graveyard, we have to transform their ReinsertOperations.
-			if (
-				a._cloneOperation instanceof ReinsertOperation &&
-				b._cloneOperation instanceof ReinsertOperation &&
-				a._cloneOperation.sourcePosition.offset > b._cloneOperation.sourcePosition.offset
-			) {
-				delta._cloneOperation.sourcePosition.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;
-
-			// If both SplitDeltas are taking their nodes from graveyard, we have to transform their ReinsertOperations.
-			if (
-				a._cloneOperation instanceof ReinsertOperation &&
-				b._cloneOperation instanceof ReinsertOperation &&
-				a._cloneOperation.sourcePosition.offset > b._cloneOperation.sourcePosition.offset
-			) {
-				delta._cloneOperation.sourcePosition.offset--;
-			}
+	if ( a.position.root == b.position.root && compareArrays( pathA, pathB ) == 'same' ) {
+		const newContext = Object.assign( {}, context );
+
+		// If `a` delta splits in further location, make sure that it will move some nodes by forcing it to be strong.
+		// Similarly, if `a` splits closer, make sure that it is transformed accordingly.
+		if ( a.position.offset != b.position.offset ) {
+			// We need to ensure that incoming operation is strong / weak.
+			newContext.isStrong = a.position.offset > b.position.offset;
+		}
 
-			return [ delta ];
+		// Then, default transformation is almost good.
+		// We need to change insert operations offsets, though.
+		// We will use `context.insertBefore` for this (but only if it is not set!)
+		if ( context.insertBefore === undefined ) {
+			newContext.insertBefore = newContext.isStrong;
 		}
+
+		return defaultTransform( a, b, newContext );
 	}
 
-	return defaultTransform( a, b, isStrong );
+	return defaultTransform( a, b, context );
 } );
 
 // Add special case for SplitDelta x UnwrapDelta transformation.
-addTransformationCase( SplitDelta, UnwrapDelta, ( a, b, isStrong ) => {
+addTransformationCase( SplitDelta, UnwrapDelta, ( a, b, context ) => {
 	// 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 ( compareArrays( b.position.path, a.position.getParentPath() ) === 'same' ) {
+	if ( a.position.root == b.position.root && compareArrays( b.position.path, a.position.getParentPath() ) === 'same' ) {
 		return [ noDelta() ];
 	}
 
-	return defaultTransform( a, b, isStrong );
+	return defaultTransform( a, b, context );
 } );
 
 // Add special case for SplitDelta x WrapDelta transformation.
-addTransformationCase( SplitDelta, WrapDelta, ( a, b, isStrong ) => {
+addTransformationCase( SplitDelta, WrapDelta, ( a, b, context ) => {
 	// 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 = compareArrays( a.position.getParentPath(), b.range.start.getParentPath() ) === 'same';
+	const sameRoot = a.position.root == b.range.start.root;
+	const operateInSameParent = sameRoot && 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 ) {
 		return [ noDelta() ];
-	} else if ( compareArrays( a.position.getParentPath(), b.range.end.getShiftedBy( -1 ).path ) === 'same' ) {
+	} else if ( sameRoot && compareArrays( a.position.getParentPath(), b.range.end.getShiftedBy( -1 ).path ) === 'same' ) {
 		// Split position is directly inside the last node from wrap range.
 		// If that's the case, we manually change split delta so it will "target" inside the wrapping element.
 		// By doing so we will be inserting split node right to the original node which feels natural and is a good UX.
@@ -287,7 +260,7 @@ addTransformationCase( SplitDelta, WrapDelta, ( a, b, isStrong ) => {
 		return [ delta ];
 	}
 
-	return defaultTransform( a, b, isStrong );
+	return defaultTransform( a, b, context );
 } );
 
 // Add special case for SplitDelta x WrapDelta transformation.
@@ -316,26 +289,17 @@ addTransformationCase( SplitDelta, AttributeDelta, ( a, b ) => {
 } );
 
 // Add special case for UnwrapDelta x SplitDelta transformation.
-addTransformationCase( UnwrapDelta, SplitDelta, ( a, b, isStrong ) => {
+addTransformationCase( UnwrapDelta, SplitDelta, ( a, b, context ) => {
 	// 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 ( compareArrays( a.position.path, b.position.getParentPath() ) === 'same' ) {
-		const transformed = [
+	if ( a.position.root == b.position.root && compareArrays( a.position.path, b.position.getParentPath() ) === 'same' ) {
+		return [
 			b.getReversed(),
 			a.clone()
 		];
-
-		// It's a kind of magic-magic-magic-maaaaagiiic!
-		transformed[ 1 ].operations[ 1 ].targetPosition.path[ 0 ]++;
-		// But seriously, we have to fix RemoveOperation in the second delta because reversed UnwrapDelta creates
-		// MergeDelta which also has RemoveOperation. Those two operations cannot point to the same "holder" element
-		// in the graveyard, so we fix it by hand. This is the only case where it happens in "special" transformation
-		// cases, and it won't happen for "default" transformation apart of RemoveDelta, where it is okay.
-
-		return transformed;
 	}
 
-	return defaultTransform( a, b, isStrong );
+	return defaultTransform( a, b, context );
 } );
 
 // Add special case for WeakInsertDelta x AttributeDelta transformation.
@@ -351,11 +315,12 @@ addTransformationCase( WeakInsertDelta, AttributeDelta, ( a, b ) => {
 } );
 
 // Add special case for WrapDelta x SplitDelta transformation.
-addTransformationCase( WrapDelta, SplitDelta, ( a, b, isStrong ) => {
+addTransformationCase( WrapDelta, SplitDelta, ( a, b, context ) => {
 	// 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 = compareArrays( a.range.start.getParentPath(), b.position.getParentPath() ) === 'same';
+	const sameRoot = a.range.start.root == b.position.root;
+	const operateInSameParent = sameRoot && 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 ) {
@@ -363,7 +328,7 @@ addTransformationCase( WrapDelta, SplitDelta, ( a, b, isStrong ) => {
 			b.getReversed(),
 			a.clone()
 		];
-	} else if ( compareArrays( b.position.getParentPath(), a.range.end.getShiftedBy( -1 ).path ) === 'same' ) {
+	} else if ( sameRoot && compareArrays( b.position.getParentPath(), a.range.end.getShiftedBy( -1 ).path ) === 'same' ) {
 		const delta = a.clone();
 
 		// Move wrapping element insert position one node further so it is after the split node insertion.
@@ -378,14 +343,14 @@ addTransformationCase( WrapDelta, SplitDelta, ( a, b, isStrong ) => {
 		return [ delta ];
 	}
 
-	return defaultTransform( a, b, isStrong );
+	return defaultTransform( a, b, context );
 } );
 
 // Add special case for RenameDelta x SplitDelta transformation.
-addTransformationCase( RenameDelta, SplitDelta, ( a, b, isStrong ) => {
+addTransformationCase( RenameDelta, SplitDelta, ( a, b, context ) => {
 	const splitPosition = new Position( b.position.root, b.position.path.slice( 0, -1 ) );
 
-	const deltas = defaultTransform( a, b, isStrong );
+	const deltas = defaultTransform( a, b, context );
 
 	if ( a.operations[ 0 ].position.isEqual( splitPosition ) ) {
 		// If a node that has been split has it's name changed, we should also change name of
@@ -417,13 +382,15 @@ addTransformationCase( SplitDelta, RenameDelta, ( a, b ) => {
 } );
 
 // Add special case for RemoveDelta x SplitDelta transformation.
-addTransformationCase( RemoveDelta, SplitDelta, ( a, b, isStrong ) => {
-	const deltas = defaultTransform( a, b, isStrong );
+addTransformationCase( RemoveDelta, SplitDelta, ( a, b, context ) => {
+	const deltas = defaultTransform( a, b, context );
 	const insertPosition = b._cloneOperation.position;
 
 	// In case if `defaultTransform` returned more than one delta.
 	for ( const delta of deltas ) {
-		for ( const operation of delta.operations ) {
+		// "No delta" may be returned in some cases.
+		if ( delta instanceof RemoveDelta ) {
+			const operation = delta._moveOperation;
 			const rangeEnd = operation.sourcePosition.getShiftedBy( operation.howMany );
 
 			if ( rangeEnd.isEqual( insertPosition ) ) {
@@ -436,20 +403,21 @@ addTransformationCase( RemoveDelta, SplitDelta, ( a, b, isStrong ) => {
 } );
 
 // Add special case for SplitDelta x RemoveDelta transformation.
-addTransformationCase( SplitDelta, RemoveDelta, ( a, b, isStrong ) => {
+addTransformationCase( SplitDelta, RemoveDelta, ( a, b, context ) => {
+	// This case is very trickily solved.
+	// Instead of fixing `a` delta, we change `b` delta for a while and fire default transformation with fixed `b` delta.
+	// Thanks to that fixing `a` delta will be differently (correctly) transformed.
 	b = b.clone();
 
 	const insertPosition = a._cloneOperation.position;
+	const operation = b._moveOperation;
+	const rangeEnd = operation.sourcePosition.getShiftedBy( operation.howMany );
 
-	for ( const operation of b.operations ) {
-		const rangeEnd = operation.sourcePosition.getShiftedBy( operation.howMany );
-
-		if ( rangeEnd.isEqual( insertPosition ) ) {
-			operation.howMany += 1;
-		}
+	if ( rangeEnd.isEqual( insertPosition ) ) {
+		operation.howMany += 1;
 	}
 
-	return defaultTransform( a, b, isStrong );
+	return defaultTransform( a, b, context );
 } );
 
 // Helper function for `AttributeDelta` class transformations.

+ 0 - 12
packages/ckeditor5-engine/src/model/delta/delta.js

@@ -162,18 +162,6 @@ export default class Delta {
 	static get className() {
 		return 'engine.model.delta.Delta';
 	}
-
-	/**
-	 * Delta priority. Used in {@link module:engine/model/delta/transform~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;
-	}
 }
 
 DeltaFactory.register( Delta );

+ 0 - 7
packages/ckeditor5-engine/src/model/delta/insertdelta.js

@@ -75,13 +75,6 @@ export default class InsertDelta extends Delta {
 	static get className() {
 		return 'engine.model.delta.InsertDelta';
 	}
-
-	/**
-	 * @inheritDoc
-	 */
-	static get _priority() {
-		return 20;
-	}
 }
 
 /**

+ 4 - 14
packages/ckeditor5-engine/src/model/delta/mergedelta.js

@@ -40,19 +40,6 @@ export default class MergeDelta extends Delta {
 		return this._removeOperation ? this._removeOperation.sourcePosition : null;
 	}
 
-	/**
-	 * @inheritDoc
-	 */
-	getReversed() {
-		const delta = super.getReversed();
-
-		if ( delta.operations.length > 0 ) {
-			delta.operations[ 1 ].isSticky = false;
-		}
-
-		return delta;
-	}
-
 	/**
 	 * 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 module:engine/model/delta/transform~transform transformation}
@@ -131,7 +118,10 @@ register( 'merge', function( position ) {
 	delta.addOperation( move );
 	this.document.applyOperation( move );
 
-	const remove = new RemoveOperation( position, 1, this.document.version );
+	const graveyard = this.document.graveyard;
+	const gyPosition = new Position( graveyard, [ 0 ] );
+
+	const remove = new RemoveOperation( position, 1, gyPosition, this.document.version );
 	delta.addOperation( remove );
 	this.document.applyOperation( remove );
 

+ 0 - 7
packages/ckeditor5-engine/src/model/delta/movedelta.js

@@ -83,13 +83,6 @@ export default class MoveDelta extends Delta {
 	static get className() {
 		return 'engine.model.delta.MoveDelta';
 	}
-
-	/**
-	 * @inheritDoc
-	 */
-	static get _priority() {
-		return 20;
-	}
 }
 
 function addMoveOperation( batch, delta, sourcePosition, howMany, targetPosition ) {

+ 11 - 8
packages/ckeditor5-engine/src/model/delta/removedelta.js

@@ -28,8 +28,14 @@ export default class RemoveDelta extends MoveDelta {
 	}
 }
 
-function addRemoveOperation( batch, delta, position, howMany ) {
-	const operation = new RemoveOperation( position, howMany, batch.document.version );
+function addRemoveDelta( batch, position, howMany ) {
+	const delta = new RemoveDelta();
+	batch.addDelta( delta );
+
+	const graveyard = batch.document.graveyard;
+	const gyPosition = new Position( graveyard, [ 0 ] );
+
+	const operation = new RemoveOperation( position, howMany, gyPosition, batch.document.version );
 	delta.addOperation( operation );
 	batch.document.applyOperation( operation );
 }
@@ -42,18 +48,15 @@ function addRemoveOperation( batch, delta, position, howMany ) {
  * @param {module:engine/model/item~Item|module:engine/model/range~Range} itemOrRange Model item or range to remove.
  */
 register( 'remove', function( itemOrRange ) {
-	const delta = new RemoveDelta();
-	this.addDelta( delta );
-
 	if ( itemOrRange instanceof Range ) {
-		// The array is reversed, so the ranges are correct and do not have to be updated.
+		// The array is reversed, so the ranges to remove are in correct order and do not have to be updated.
 		const ranges = itemOrRange.getMinimalFlatRanges().reverse();
 
 		for ( const flat of ranges ) {
-			addRemoveOperation( this, delta, flat.start, flat.end.offset - flat.start.offset );
+			addRemoveDelta( this, flat.start, flat.end.offset - flat.start.offset );
 		}
 	} else {
-		addRemoveOperation( this, delta, Position.createBefore( itemOrRange ), 1 );
+		addRemoveDelta( this, Position.createBefore( itemOrRange ), 1 );
 	}
 
 	return this;

+ 1 - 21
packages/ckeditor5-engine/src/model/delta/splitdelta.js

@@ -39,19 +39,6 @@ export default class SplitDelta extends Delta {
 		return this._moveOperation ? this._moveOperation.sourcePosition : null;
 	}
 
-	/**
-	 * @inheritDoc
-	 */
-	getReversed() {
-		const delta = super.getReversed();
-
-		if ( delta.operations.length > 0 ) {
-			delta.operations[ 0 ].isSticky = true;
-		}
-
-		return delta;
-	}
-
 	/**
 	 * Operation in the delta that adds to model an element into which split nodes will be moved, or `null` if
 	 * there are no operations in the delta.
@@ -79,7 +66,7 @@ export default class SplitDelta extends Delta {
 	 * @type {module:engine/model/operation/moveoperation~MoveOperation|null}
 	 */
 	get _moveOperation() {
-		return this.operations[ 1 ] || null;
+		return this.operations[ 1 ] && this.operations[ 1 ] instanceof MoveOperation ? this.operations[ 1 ] : null;
 	}
 
 	/**
@@ -95,13 +82,6 @@ export default class SplitDelta extends Delta {
 	static get className() {
 		return 'engine.model.delta.SplitDelta';
 	}
-
-	/**
-	 * @inheritDoc
-	 */
-	static get _priority() {
-		return 5;
-	}
 }
 
 /**

+ 417 - 54
packages/ckeditor5-engine/src/model/delta/transform.js

@@ -4,13 +4,25 @@
  */
 
 /**
+ * @protected
  * @module engine/model/delta/transform
  */
 
 import Delta from './delta';
+import MoveDelta from './movedelta';
+import RemoveDelta from './removedelta';
+import MergeDelta from './mergedelta';
+import SplitDelta from './splitdelta';
+import WrapDelta from './wrapdelta';
+import UnwrapDelta from './unwrapdelta';
+import RenameDelta from './renamedelta';
+import AttributeDelta from './attributedelta';
 import operationTransform from '../operation/transform';
 import NoOperation from '../operation/nooperation';
+import MoveOperation from '../operation/moveoperation';
+import RemoveOperation from '../operation/removeoperation';
 import arrayUtils from '@ckeditor/ckeditor5-utils/src/lib/lodash/array';
+import compareArrays from '@ckeditor/ckeditor5-utils/src/comparearrays';
 
 const specialCases = new Map();
 
@@ -37,16 +49,14 @@ const transform = {
 	 *
 	 * @param {module:engine/model/delta/delta~Delta} a Delta that will be transformed.
 	 * @param {module:engine/model/delta/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 module:engine/model/delta/delta~Delta._priority priority}. If deltas have different priorities, their importance is resolved
-	 * automatically and overwrites this flag.
+	 * @param {module:engine/model/delta/transform~transformationContext} context Transformation context object.
 	 * @returns {Array.<module:engine/model/delta/delta~Delta>} Result of the transformation.
 	 */
-	transform( a, b, isAMoreImportantThanB ) {
+	transform( a, b, context ) {
 		const transformAlgorithm = transform.getTransformationCase( a, b ) || transform.defaultTransform;
 
-		const transformed = transformAlgorithm( a, b, isAMoreImportantThanB );
+		// Make new instance of context object, so all changes done during transformation are not saved in original object.
+		const transformed = transformAlgorithm( a, b, Object.assign( {}, context ) );
 		const baseVersion = arrayUtils.last( b.operations ).baseVersion;
 
 		return updateBaseVersion( baseVersion, transformed );
@@ -60,21 +70,13 @@ const transform = {
 	 *
 	 * @param {module:engine/model/delta/delta~Delta} a Delta that will be transformed.
 	 * @param {module:engine/model/delta/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 module:engine/model/delta/delta~Delta._priority priority}. If deltas have different priorities, their importance is resolved
-	 * automatically and overwrites this flag.
-	 * @returns {Array.<module:engine/model/delta/delta~Delta>} Result of the transformation, that is an array with single delta instance.
+	 * @param {module:engine/model/delta/transform~transformationContext} context Transformation context object.
+	 * @returns {Array.<module:engine/model/delta/delta~Delta>} Result of the transformation.
 	 */
-	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();
+	defaultTransform( a, b, context ) {
+		// This will hold operations from delta `a` that will be transformed by operations from delta `b`.
+		// Eventually, those operations will be used to create result delta(s).
+		const transformed = [];
 
 		// Array containing operations that we will transform by. At the beginning these are just operations from
 		let byOps = b.operations;
@@ -110,19 +112,28 @@ const transform = {
 					// 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 );
+					// Transform operation from delta A by operation from delta B.
+					const results = operationTransform( op, opB, context );
 
 					// 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.
+					// Note, that we process single operation but `operationTransform` result is an array, so we
+					// might have to splice-in more than one operation. Save them in `ops` array and move `i` pointer by a proper offset.
 					Array.prototype.splice.apply( ops, [ i, 1 ].concat( results ) );
 
 					i += results.length - 1;
+
+					// Then, transform operation from delta B by operation from delta A.
+					// Since this is a "mirror" transformation, first, we "mirror" some of context values.
+					const reverseContext = Object.assign( {}, context );
+					reverseContext.isStrong = !context.isStrong;
+					reverseContext.insertBefore = context.insertBefore !== undefined ? !context.insertBefore : undefined;
+
+					// Transform operations.
+					const updatedOpB = operationTransform( opB, op, reverseContext );
+
+					// Update `newByOps` by transformed, updated `opB`.
+					// Using push.apply because `operationTransform` returns an array with one or multiple results.
+					Array.prototype.push.apply( newByOps, updatedOpB );
 				}
 
 				// At this point a single operation from delta A got transformed by a single operation from delta B.
@@ -141,14 +152,14 @@ const transform = {
 			// We add transformed operation from delta A to newly created delta.
 			// Remember that transformed operation from delta A may consist of multiple operations.
 			for ( const op of ops ) {
-				transformed.addOperation( op );
+				transformed.push( op );
 			}
 
 			// In next loop, we will take another operation from delta A and transform it through (transformed) operations
 			// from delta B...
 		}
 
-		return [ transformed ];
+		return getNormalizedDeltas( a.constructor, transformed );
 	},
 
 	/**
@@ -200,22 +211,41 @@ const transform = {
 	},
 
 	/**
-	 * Transforms two sets of deltas by themselves. Returns both transformed sets. Does not modify passed parameters.
+	 * Transforms two sets of deltas by themselves. Returns both transformed sets.
 	 *
-	 * @param {Array.<module:engine/model/delta/delta~Delta>} deltasA Array with first set of deltas to transform.
-	 * @param {Array.<module:engine/model/delta/delta~Delta>} deltasB Array with second set of deltas to transform.
-	 * @param {Boolean} isAMoreImportantThanB Flag indicating whether the deltas from `deltasA` set should be treated as more
-	 * important when resolving conflicts.
+	 * @param {Array.<module:engine/model/delta/delta~Delta>} deltasA Array with the first set of deltas to transform. These
+	 * deltas are considered more important (than `deltasB`) when resolving conflicts.
+	 * @param {Array.<module:engine/model/delta/delta~Delta>} deltasB Array with the second set of deltas to transform. These
+	 * deltas are considered less important (than `deltasA`) when resolving conflicts.
+	 * @param {module:engine/model/document~Document} [document=null] If set, deltas will be transformed in "context mode"
+	 * and given `document` will be used to determine relations between deltas. If not set (default), deltas will be
+	 * transforming without additional context information.
 	 * @returns {Object}
-	 * @returns {Array.<module:engine/model/delta/delta~Delta>} return.deltasA The first set of deltas transformed by the second
-	 * set of deltas.
-	 * @returns {Array.<module:engine/model/delta/delta~Delta>} return.deltasB The second set of deltas transformed by the first
-	 * set of deltas.
+	 * @returns {Array.<module:engine/model/delta/delta~Delta>} return.deltasA The first set of deltas transformed
+	 * by the second set of deltas.
+	 * @returns {Array.<module:engine/model/delta/delta~Delta>} return.deltasB The second set of deltas transformed
+	 * by the first set of deltas.
 	 */
-	transformDeltaSets( deltasA, deltasB, isAMoreImportantThanB ) {
+	transformDeltaSets( deltasA, deltasB, document = null ) {
 		const transformedDeltasA = Array.from( deltasA );
 		const transformedDeltasB = Array.from( deltasB );
 
+		const useAdditionalContext = document !== null;
+
+		const contextAB = {
+			isStrong: true
+		};
+
+		if ( useAdditionalContext ) {
+			contextAB.wasAffected = new Map();
+			contextAB.originalDelta = new Map();
+			contextAB.document = document;
+
+			for ( const delta of transformedDeltasB ) {
+				contextAB.originalDelta.set( delta, delta );
+			}
+		}
+
 		for ( let i = 0; i < transformedDeltasA.length; i++ ) {
 			const deltaA = [ transformedDeltasA[ i ] ];
 
@@ -224,8 +254,33 @@ const transform = {
 
 				for ( let k = 0; k < deltaA.length; k++ ) {
 					for ( let l = 0; l < deltaB.length; l++ ) {
-						const resultAB = transform.transform( deltaA[ k ], deltaB[ l ], isAMoreImportantThanB );
-						const resultBA = transform.transform( deltaB[ l ], deltaA[ k ], !isAMoreImportantThanB );
+						if ( useAdditionalContext ) {
+							_setContext( deltaA[ k ], deltaB[ l ], contextAB );
+						}
+
+						const resultAB = transform.transform( deltaA[ k ], deltaB[ l ], {
+							insertBefore: contextAB.insertBefore,
+							forceNotSticky: contextAB.forceNotSticky,
+							isStrong: contextAB.isStrong,
+							forceWeakRemove: contextAB.forceWeakRemove
+						} );
+
+						const resultBA = transform.transform( deltaB[ l ], deltaA[ k ], {
+							insertBefore: !contextAB.insertBefore,
+							forceNotSticky: contextAB.forceNotSticky,
+							isStrong: !contextAB.isStrong,
+							forceWeakRemove: contextAB.forceWeakRemove
+						} );
+
+						if ( useAdditionalContext ) {
+							_updateContext( deltaA[ k ], resultAB, contextAB );
+
+							const originalDelta = contextAB.originalDelta.get( deltaB[ l ] );
+
+							for ( const deltaBA of resultBA ) {
+								contextAB.originalDelta.set( deltaBA, originalDelta );
+							}
+						}
 
 						deltaA.splice( k, 1, ...resultAB );
 						k += resultAB.length - 1;
@@ -269,18 +324,6 @@ function updateBaseVersion( baseVersion, deltas ) {
 	return deltas;
 }
 
-// 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;
-	}
-}
-
 // Returns number of operations in given array of deltas.
 function getOpsCount( deltas ) {
 	return deltas.reduce( ( current, delta ) => {
@@ -302,3 +345,323 @@ function padWithNoOps( deltas, howMany ) {
 
 	deltas.push( noDelta );
 }
+
+// Sets context data before delta `a` by delta `b` transformation.
+// Using data given in `context` object, sets `context.insertBefore` and `context.forceNotSticky` flags.
+// Also updates `context.wasAffected`.
+function _setContext( a, b, context ) {
+	_setWasAffected( a, b, context );
+	_setInsertBeforeContext( a, b, context );
+	_setForceWeakRemove( b, context );
+	_setForceNotSticky( b, context );
+}
+
+// Sets `context.insertBefore` basing on `context.document` history for `a` by `b` transformation.
+//
+// Simply saying, if `b` is "undoing delta" it means that `a` might already be transformed by the delta
+// which was undone by `b` (let's call it `oldB`). If this is true, `a` by `b` transformation has to consider
+// how `a` was transformed by `oldB` to get an expected result.
+//
+// This is used to resolve conflict when two operations want to insert nodes at the same position. If the operations
+// are not related, it doesn't matter in what order operations insert those nodes. However if the operations are
+// related (for example, in undo) we need to keep the same order.
+//
+// For example, assume that editor has two letters: 'ab'. Then, both letters are removed, creating two operations:
+// (op. 1) REM [ 1 ] - [ 2 ] => (graveyard) [ 0 ]
+// (op. 2) REM [ 0 ] - [ 1 ] => (graveyard) [ 1 ]
+// Then, we undo operation 2:
+// REM [ 0 ] - [ 1 ] => (graveyard) [ 1 ] is reversed to REI (graveyard) [ 1 ] => [ 0 ] - [ 1 ] and is applied.
+// History stack is:
+// (op. 1) REM [ 1 ] - [ 2 ] => (graveyard) [ 0 ]
+// (op. 2) REM [ 0 ] - [ 1 ] => (graveyard) [ 1 ]
+// (op. 3) REI (graveyard) [ 1 ] => [ 0 ] - [ 1 ]
+// Then, we undo operation 1:
+// REM [ 1 ] - [ 2 ] => (graveyard) [ 0 ] is reversed to REI (graveyard) [ 0 ] => [ 1 ] - [ 2 ] then,
+// is transformed by (op. 2) REM [ 0 ] - [ 1 ] => (graveyard) [ 1 ] and becomes REI (graveyard) [ 0 ] => [ 0 ] - [ 1 ] then,
+// is transformed by (op. 3) REI (graveyard) [ 1 ] => [ 0 ] - [ 1 ] and we have a conflict because both operations
+// insert at the same position, but thanks to keeping the context, we know that in this case, the transformed operation should
+// insert the node after operation 3.
+//
+// Keep in mind, that `context.insertBefore` may be either `Boolean` or `undefined`. If it is `Boolean` then the order is
+// known (deltas are related and `a` should insert nodes before or after `b`). However, if deltas were not related,
+// `context.isBefore` is `undefined` and other factors will be taken into consideration when resolving the order
+// (this, however, happens in operational transformation algorithms).
+//
+// This affects both `MoveOperation` (and its derivatives) and `InsertOperation`.
+function _setInsertBeforeContext( a, b, context ) {
+	// If `b` is a delta that undoes other delta...
+	const originalDelta = context.originalDelta.get( b );
+
+	if ( context.document.history.isUndoingDelta( originalDelta ) ) {
+		// Get the undone delta...
+		const undoneDelta = context.document.history.getUndoneDelta( originalDelta );
+		// Get a map with deltas related to `a` delta...
+		const aWasAffectedBy = context.wasAffected.get( a );
+		// And check if the undone delta is related with delta `a`.
+		const affected = aWasAffectedBy.get( undoneDelta );
+
+		if ( affected !== undefined ) {
+			// If deltas are related, set `context.insertBefore` basing on whether `a` was affected by the undone delta.
+			context.insertBefore = affected;
+		}
+	}
+}
+
+// Sets `context.forceNotSticky` basing on `context.document` history for transformation by `b` delta.
+//
+// `MoveOperation` may be "sticky" which means, that anything that was inserted at the boundary of moved range, should
+// also be moved. This is particularly helpful for actions like splitting or merging a node. However, this behavior
+// sometimes leads to an error, for example in undo.
+//
+// Simply saying, if delta is going to be transformed by delta `b`, stickiness should not be taken into consideration
+// if delta `b` was already undone or if delta `b` is an undoing delta.
+//
+// This affects `MoveOperation` (and its derivatives).
+function _setForceNotSticky( b, context ) {
+	const history = context.document.history;
+	const originalB = context.originalDelta.get( b );
+
+	// If `b` delta is undoing or undone delta, stickiness should not be taken into consideration.
+	if ( history.isUndoingDelta( originalB ) || history.isUndoneDelta( originalB ) ) {
+		context.forceNotSticky = true;
+	}
+}
+
+// Sets `context.forceWeakRemove` basing on `context.document` history for transformation by `b` delta.
+//
+// When additional context is not used, default `MoveOperation` x `RemoveOperation` transformation
+// always treats `RemoveOperation` as a stronger one, no matter how `context.isStrong` is set. It is like this
+// to provide better results when transformations happen.
+//
+// This is however works fine only when additional context is not used.
+//
+// When additional context is used, we need a better way to decide whether `RemoveOperation` is "dominating" (or in other
+// words, whether nodes removed by given operation should stay in graveyard if other operation wants to move them).
+//
+// The answer to this is easy: if `RemoveOperation` has been already undone, we are not forcing given nodes to stay
+// in graveyard. In such scenario, we set `context.forceWeakRemove` to `true`. However, if the `RemoveOperation` has
+// not been undone, we set `context.forceWeakRemove` to `false` because we want remove to be "dominating".
+function _setForceWeakRemove( b, context ) {
+	const history = context.document.history;
+	const originalB = context.originalDelta.get( b );
+
+	// If `b` delta is a remove delta, that has not been undone yet, forceWeakRemove should be `false`.
+	// It should be `true` in any other case, if additional context is used.
+	context.forceWeakRemove = !( originalB instanceof RemoveDelta && !history.isUndoneDelta( originalB ) );
+}
+
+// Sets `context.wasAffected` which holds context information about how transformed deltas are related. `context.wasAffected`
+// is used by `_setInsertBeforeContext` helper function.
+function _setWasAffected( a, b, context ) {
+	if ( !context.wasAffected.get( a ) ) {
+		// Create a new map with relations for `a` delta.
+		context.wasAffected.set( a, new Map() );
+	}
+
+	const originalDelta = context.originalDelta.get( b );
+	let wasAffected = !!context.wasAffected.get( a ).get( originalDelta );
+
+	// Cross-check all operations from both deltas...
+	for ( const opA of a.operations ) {
+		for ( const opB of b.operations ) {
+			if ( opA instanceof MoveOperation && opB instanceof MoveOperation ) {
+				if ( _isOperationAffected( opA, opB ) ) {
+					// If any of them are move operations that affect each other, set the relation accordingly.
+					wasAffected = true;
+
+					break;
+				}
+			}
+		}
+
+		// Break both loops if affecting pair has been found.
+		if ( wasAffected ) {
+			break;
+		}
+	}
+
+	context.wasAffected.get( a ).set( originalDelta, wasAffected );
+}
+
+// Checks whether `opA` is affected by `opB`. It is assumed that both operations are `MoveOperation`.
+// Operation is affected only if the other operation's source range is before that operation's source range.
+function _isOperationAffected( opA, opB ) {
+	const target = opA.targetPosition;
+	const source = opB.sourcePosition;
+
+	const cmpResult = compareArrays( source.getParentPath(), target.getParentPath() );
+
+	if ( target.root != source.root ) {
+		return false;
+	}
+
+	return cmpResult == 'same' && source.offset < target.offset;
+}
+
+// Updates `context` object after delta by delta transformation is done.
+//
+// This means two things:
+// 1. Some information are removed from context (those that apply only to the transformation that just happened).
+// 2. `context.wasAffected` is updated because `oldDelta` has been transformed to one or many `newDeltas` and we
+// need to update entries in `context.wasAffected`. Basically, anything that was in `context.wasAffected` under
+// `oldDelta` key should be rewritten to `newDeltas`. This way in next transformation steps, `newDeltas` "remember"
+// the context of `oldDelta`.
+function _updateContext( oldDelta, newDeltas, context ) {
+	delete context.insertBefore;
+	delete context.forceNotSticky;
+	delete context.forceWeakRemove;
+
+	const wasAffected = context.wasAffected.get( oldDelta );
+
+	context.wasAffected.delete( oldDelta );
+
+	for ( const delta of newDeltas ) {
+		context.wasAffected.set( delta, new Map( wasAffected ) );
+	}
+}
+
+// Takes base delta class (`DeltaClass`) and a set of `operations` that are transformation results and creates
+// one or more deltas, acknowledging that the result is a transformation of a delta that is of `DeltaClass`.
+//
+// The normalization ensures that each delta has it's "normal" state, that is, for example, `MoveDelta` has
+// just one `MoveOperation`, `SplitDelta` has just two operations of which first is `InsertOperation` and second
+// is `MoveOperation` or `NoOperation`, etc.
+function getNormalizedDeltas( DeltaClass, operations ) {
+	let deltas = [];
+	let delta = null;
+	let attributeOperationIndex;
+
+	switch ( DeltaClass ) {
+		case MoveDelta:
+		case RemoveDelta:
+			// Normal MoveDelta has just one MoveOperation.
+			// Take all operations and create MoveDelta for each of them.
+			for ( const o of operations ) {
+				if ( o instanceof NoOperation ) {
+					// An operation may be instance of NoOperation and this may be correct.
+					// If that's the case, do not create a MoveDelta with singular NoOperation.
+					// Create "no delta" instead, that is Delta instance with NoOperation.
+					delta = new Delta();
+				} else {
+					if ( o instanceof RemoveOperation ) {
+						delta = new RemoveDelta();
+					} else {
+						delta = new MoveDelta();
+					}
+
+					// Unsticky the operation. Only operations in "special" deltas can be sticky.
+					o.isSticky = false;
+				}
+
+				delta.addOperation( o );
+				deltas.push( delta );
+			}
+
+			// Return all created MoveDeltas.
+			return deltas;
+		case SplitDelta:
+		case WrapDelta:
+			// Normal SplitDelta and WrapDelta have two operations: first is InsertOperation and second is MoveOperation.
+			// The MoveOperation may be split into multiple MoveOperations.
+			// If that's the case, convert additional MoveOperations into MoveDeltas.
+			// First, create normal SplitDelta or WrapDelta, using first two operations.
+			delta = new DeltaClass();
+			delta.addOperation( operations[ 0 ] );
+			delta.addOperation( operations[ 1 ] );
+			// Then, take all but last two operations and use them to create normalized MoveDeltas.
+			deltas = getNormalizedDeltas( MoveDelta, operations.slice( 2 ) );
+
+			// Return all deltas as one array, in proper order.
+			return [ delta ].concat( deltas );
+		case MergeDelta:
+		case UnwrapDelta:
+			// Normal MergeDelta and UnwrapDelta have two operations: first is MoveOperation and second is RemoveOperation.
+			// The MoveOperation may be split into multiple MoveOperations.
+			// If that's the case, convert additional MoveOperations into MoveDeltas.
+			// Take all but last two operations and use them to create normalized MoveDeltas.
+			deltas = getNormalizedDeltas( MoveDelta, operations.slice( 0, -2 ) );
+			// Then, create normal MergeDelta or UnwrapDelta, using last two operations.
+			delta = new DeltaClass();
+			delta.addOperation( operations[ operations.length - 2 ] );
+			delta.addOperation( operations[ operations.length - 1 ] );
+
+			// Return all deltas as one array, in proper order.
+			return deltas.concat( delta );
+		case RenameDelta:
+			// RenameDelta may become a "no delta" if it's only operation is transformed to NoOperation.
+			// This may happen when RenameOperation is transformed by RenameOperation.
+			// Keep in mind that RenameDelta always have just one operation.
+			if ( operations[ 0 ] instanceof NoOperation ) {
+				delta = new Delta();
+			} else {
+				delta = new RenameDelta();
+			}
+
+			delta.addOperation( operations[ 0 ] );
+
+			return [ delta ];
+		case AttributeDelta:
+			// AttributeDelta is allowed to have multiple AttributeOperations and also NoOperations but
+			// the first operation has to be an AttributeOperation as it is used as a reference for deltas properties.
+			// Keep in mind that we cannot simply remove NoOperations cause that would mess up base versions.
+			// Find an index of first operation that is not a NoOperation.
+			for ( attributeOperationIndex = 0; attributeOperationIndex < operations.length; attributeOperationIndex++ ) {
+				if ( !( operations[ attributeOperationIndex ] instanceof NoOperation ) ) {
+					break;
+				}
+			}
+
+			// No AttributeOperations has been found. Convert AttributeDelta to "no delta".
+			if ( attributeOperationIndex == operations.length ) {
+				delta = new Delta();
+			}
+			// AttributeOperation found.
+			else {
+				delta = new AttributeDelta();
+
+				// AttributeOperation wasn't the first operation.
+				if ( attributeOperationIndex != 0 ) {
+					// Move AttributeOperation to the beginning.
+					operations.unshift( operations.splice( attributeOperationIndex, 1 )[ 0 ] );
+					// No need to update base versions - they are updated at the end of transformation algorithm anyway.
+				}
+			}
+
+			// Add all operations to the delta (even if it is just a couple of NoOperations we have to keep them all).
+			for ( const o of operations ) {
+				delta.addOperation( o );
+			}
+
+			return [ delta ];
+		default:
+			// For all other deltas no normalization is needed.
+			delta = new DeltaClass();
+
+			for ( const o of operations ) {
+				delta.addOperation( o );
+			}
+
+			return [ delta ];
+	}
+}
+
+/**
+ * Object containing values and flags describing context of a transformation.
+ *
+ * @typedef {Object} module:engine/model/delta/transform~transformationContext
+ * @property {Boolean} useAdditionalContext Whether additional context should be evaluated and used during transformations.
+ * @property {Boolean} isStrong Whether transformed deltas are more (`true`) or less (`false`) important than deltas to transform by.
+ * @property {module:engine/model/document~Document} [document] Model document which is a context for transformations.
+ * Available only if `useAdditionalContext` is `true`.
+ * @property {Boolean|undefined} forceWeakRemove Whether {@link module:engine/model/operation/removeoperation~RemoveOperation}
+ * should be always more important than other operations. Available only if `useAdditionalContext` is `true`.
+ * @property {Boolean|undefined} insertBefore Used when transforming {@link module:engine/model/operation/moveoperation~MoveOperation}s
+ * If two `MoveOperation`s target to the same position, `insertBefore` is used to resolve such conflict. This flag
+ * is set and used internally by transformation algorithms. Available only if `useAdditionalContext` is `true`.
+ * @property {Boolean|undefined} forceNotSticky Used when transforming
+ * {@link module:engine/model/operation/moveoperation~MoveOperation#isSticky sticky MoveOperation}. If set to `true`,
+ * `isSticky` flag is discarded during transformations. This flag is set and used internally by transformation algorithms.
+ * Available only if `useAdditionalContext` is `true`.
+ * @property {Map|undefined} wasAffected Used to evaluate `insertBefore` flag. This map is set and used internally by
+ * transformation algorithms. Available only if `useAdditionalContext` is `true`.
+ */

+ 4 - 8
packages/ckeditor5-engine/src/model/delta/unwrapdelta.js

@@ -61,13 +61,6 @@ export default class UnwrapDelta extends Delta {
 	static get className() {
 		return 'engine.model.delta.UnwrapDelta';
 	}
-
-	/**
-	 * @inheritDoc
-	 */
-	static get _priority() {
-		return 10;
-	}
 }
 
 /**
@@ -106,7 +99,10 @@ register( 'unwrap', function( element ) {
 
 	// Computing new position because we moved some nodes before `element`.
 	// If we would cache `Position.createBefore( element )` we remove wrong node.
-	const remove = new RemoveOperation( Position.createBefore( element ), 1, this.document.version );
+	const graveyard = this.document.graveyard;
+	const gyPosition = new Position( graveyard, [ 0 ] );
+
+	const remove = new RemoveOperation( Position.createBefore( element ), 1, gyPosition, this.document.version );
 	delta.addOperation( remove );
 	this.document.applyOperation( remove );
 

+ 0 - 7
packages/ckeditor5-engine/src/model/delta/wrapdelta.js

@@ -88,13 +88,6 @@ export default class WrapDelta extends Delta {
 	static get className() {
 		return 'engine.model.delta.WrapDelta';
 	}
-
-	/**
-	 * @inheritDoc
-	 */
-	static get _priority() {
-		return 10;
-	}
 }
 
 /**

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

@@ -22,6 +22,7 @@ import LiveSelection from './liveselection';
 import Schema from './schema';
 import TreeWalker from './treewalker';
 import MarkerCollection from './markercollection';
+import deltaTransform from './delta/transform';
 import clone from '@ckeditor/ckeditor5-utils/src/lib/lodash/clone';
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
@@ -338,6 +339,26 @@ export default class Document {
 		return null;
 	}
 
+	/**
+	 * Transforms two sets of deltas by themselves. Returns both transformed sets.
+	 *
+	 * @param {Array.<module:engine/model/delta/delta~Delta>} deltasA Array with the first set of deltas to transform. These
+	 * deltas are considered more important (than `deltasB`) when resolving conflicts.
+	 * @param {Array.<module:engine/model/delta/delta~Delta>} deltasB Array with the second set of deltas to transform. These
+	 * deltas are considered less important (than `deltasA`) when resolving conflicts.
+	 * @param {Boolean} [useContext=false] When set to `true`, transformation will store and use additional context
+	 * information to guarantee more expected results. Should be used whenever deltas related to already applied
+	 * deltas are transformed (for example when undoing changes).
+	 * @returns {Object}
+	 * @returns {Array.<module:engine/model/delta/delta~Delta>} return.deltasA The first set of deltas transformed
+	 * by the second set of deltas.
+	 * @returns {Array.<module:engine/model/delta/delta~Delta>} return.deltasB The second set of deltas transformed
+	 * by the first set of deltas.
+	 */
+	transformDeltas( deltasA, deltasB, useContext = false ) {
+		return deltaTransform.transformDeltaSets( deltasA, deltasB, useContext ? this : null );
+	}
+
 	/**
 	 * Custom toJSON method to solve child-parent circular dependencies.
 	 *

+ 58 - 78
packages/ckeditor5-engine/src/model/history.js

@@ -10,14 +10,7 @@
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 /**
- * `History` keeps the track of all the deltas applied to the {@link module:engine/model/document~Document document}. Deltas stored in
- * `History` might get updated, split into more deltas or even removed. This is used mostly to compress history, instead
- * of keeping all deltas in a state in which they were applied.
- *
- * **Note:** deltas kept in `History` should be used only to transform deltas. It's not advised to use `History` to get
- * original delta basing on it's {@link module:engine/model/delta/delta~Delta#baseVersion baseVersion}. Also, after transforming a
- * delta by deltas from `History`,
- * fix it's base version accordingly (set it to {@link module:engine/model/document~Document#version document version}).
+ * `History` keeps the track of all the deltas applied to the {@link module:engine/model/document~Document document}.
  */
 export default class History {
 	/**
@@ -40,6 +33,26 @@ export default class History {
 		 * @member {Map} module:engine/model/history~History#_historyPoints
 		 */
 		this._historyPoints = new Map();
+
+		/**
+		 * Holds an information which {@link module:engine/model/delta/delta~Delta delta} undoes which
+		 * {@link module:engine/model/delta/delta~Delta delta}.
+		 *
+		 * Keys of the map are "undoing deltas", that is deltas that undone some other deltas. For each key, the
+		 * value is a delta that has been undone by the "undoing delta".
+		 *
+		 * @private
+		 * @member {Map} module:engine/model/history~History#_undoPairs
+		 */
+		this._undoPairs = new Map();
+
+		/**
+		 * Holds all undone deltas.
+		 *
+		 * @private
+		 * @member {Set.<module:engine/model/delta/delta~Delta>} module:engine/model/history~History#_undoneDeltas
+		 */
+		this._undoneDeltas = new Set();
 	}
 
 	/**
@@ -63,7 +76,7 @@ export default class History {
 	 * that deltas from the first one will be returned.
 	 * @param {Number} [to=Number.POSITIVE_INFINITY] Base version up to which deltas should be returned (exclusive).
 	 * Defaults to `Number.POSITIVE_INFINITY` which means that deltas up to the last one will be returned.
-	 * @returns {Iterator.<module:engine/model/delta/delta~Delta>} Deltas added to the history.
+	 * @returns {Iterator.<module:engine/model/delta/delta~Delta>} Deltas added to the history from given base versions range.
 	 */
 	* getDeltas( from = 0, to = Number.POSITIVE_INFINITY ) {
 		// No deltas added, nothing to yield.
@@ -92,90 +105,58 @@ export default class History {
 	}
 
 	/**
-	 * Returns one or more deltas from history that bases on given `baseVersion`. Most often it will be just
-	 * one delta, but if that delta got updated by multiple deltas, all of those updated deltas will be returned.
+	 * Returns delta from history that bases on given `baseVersion`.
 	 *
-	 * @see module:engine/model/history~History#updateDelta
-	 * @param {Number} baseVersion Base version of the delta to retrieve.
-	 * @returns {Array.<module:engine/model/delta/delta~Delta>|null} Delta with given base version or null if no such delta is in history.
+	 * @param {Number} baseVersion Base version of the delta to get.
+	 * @returns {module:engine/model/delta/delta~Delta|null} Delta with given base version or `null` if there is no such delta in history.
 	 */
 	getDelta( baseVersion ) {
-		let index = this._historyPoints.get( baseVersion );
-
-		if ( index === undefined ) {
-			return null;
-		}
-
-		const deltas = [];
-
-		for ( index; index < this._deltas.length; index++ ) {
-			const delta = this._deltas[ index ];
-
-			if ( delta.baseVersion != baseVersion ) {
-				break;
-			}
-
-			deltas.push( delta );
-		}
+		const index = this._historyPoints.get( baseVersion );
 
-		return deltas.length === 0 ? null : deltas;
+		return index === undefined ? null : this._deltas[ index ];
 	}
 
 	/**
-	 * Removes delta from the history. This happens i.e., when a delta is undone by another delta. Both undone delta and
-	 * undoing delta should be removed so they won't have an impact on transforming other deltas.
-	 *
-	 * **Note:** using this method does not change the state of {@link module:engine/model/document~Document model}. It just affects
-	 * the state of `History`.
+	 * Marks in history that one delta is a delta that is undoing the other delta. By marking deltas this way,
+	 * history is keeping more context information about deltas which helps in operational transformation.
 	 *
-	 * **Note:** when some deltas are removed, deltas between them should probably get updated. See
-	 * {@link module:engine/model/history~History#updateDelta}.
-	 *
-	 * **Note:** if delta with `baseVersion` got {@link module:engine/model/history~History#updateDelta updated} by multiple
-	 * deltas, all updated deltas will be removed.
-	 *
-	 * @param {Number} baseVersion Base version of a delta to be removed.
+	 * @param {module:engine/model/delta/delta~Delta} undoneDelta Delta which is undone by `undoingDelta`.
+	 * @param {module:engine/model/delta/delta~Delta} undoingDelta Delta which undoes `undoneDelta`.
 	 */
-	removeDelta( baseVersion ) {
-		this.updateDelta( baseVersion, [] );
+	setDeltaAsUndone( undoneDelta, undoingDelta ) {
+		this._undoPairs.set( undoingDelta, undoneDelta );
+		this._undoneDeltas.add( undoneDelta );
 	}
 
 	/**
-	 * Substitutes delta in history by one or more given deltas.
-	 *
-	 * **Note:** if delta with `baseVersion` was already updated by multiple deltas, all updated deltas will be removed
-	 * and new deltas will be inserted at their position.
+	 * Checks whether given `delta` is undoing by any other delta.
 	 *
-	 * **Note:** removed delta won't get updated.
-	 *
-	 * @param {Number} baseVersion Base version of a delta to update.
-	 * @param {Iterable.<module:engine/model/delta/delta~Delta>} updatedDeltas Deltas to be inserted in place of updated delta.
+	 * @param {module:engine/model/delta/delta~Delta} delta Delta to check.
+	 * @returns {Boolean} `true` if given `delta` is undoing any other delta, `false` otherwise.
 	 */
-	updateDelta( baseVersion, updatedDeltas ) {
-		const deltas = this.getDelta( baseVersion );
-
-		// If there are no deltas, stop executing function as there is nothing to update.
-		if ( deltas === null ) {
-			return;
-		}
-
-		// Make sure that every updated delta has correct `baseVersion`.
-		// This is crucial for algorithms in `History` and algorithms using `History`.
-		for ( const delta of updatedDeltas ) {
-			delta.baseVersion = baseVersion;
-		}
-
-		// Put updated deltas in place of old deltas.
-		this._deltas.splice( this._getIndex( baseVersion ), deltas.length, ...updatedDeltas );
+	isUndoingDelta( delta ) {
+		return this._undoPairs.has( delta );
+	}
 
-		// Update history points.
-		const changeBy = updatedDeltas.length - deltas.length;
+	/**
+	 * Checks whether given `delta` has been undone by any other delta.
+	 *
+	 * @param {module:engine/model/delta/delta~Delta} delta Delta to check.
+	 * @returns {Boolean} `true` if given `delta` has been undone any other delta, `false` otherwise.
+	 */
+	isUndoneDelta( delta ) {
+		return this._undoneDeltas.has( delta );
+	}
 
-		for ( const key of this._historyPoints.keys() ) {
-			if ( key > baseVersion ) {
-				this._historyPoints.set( key, this._historyPoints.get( key ) + changeBy );
-			}
-		}
+	/**
+	 * For given `undoingDelta`, returns the delta which has been undone by it.
+	 *
+	 * @param {module:engine/model/delta/delta~Delta} undoingDelta
+	 * @returns {module:engine/model/delta/delta~Delta|undefined} Delta that has been undone by given `undoingDelta` or `undefined`
+	 * if given `undoingDelta` is not undoing any other delta.
+	 */
+	getUndoneDelta( undoingDelta ) {
+		return this._undoPairs.get( undoingDelta );
 	}
 
 	/**
@@ -194,7 +175,6 @@ export default class History {
 
 			if ( baseVersion < 0 || baseVersion >= nextBaseVersion ) {
 				// Base version is too high or too low - it's acceptable situation.
-				// Return -1 because `baseVersion` was correct.
 				return -1;
 			}
 

+ 15 - 6
packages/ckeditor5-engine/src/model/liverange.js

@@ -77,11 +77,16 @@ export default class LiveRange extends Range {
 	 */
 
 	/**
-	 * Fired when `LiveRange` instance is changed due to changes on {@link module:engine/model/document~Document}.
+	 * Fired when `LiveRange` instance is changed due to changes in the {@link module:engine/model/document~Document document}.
 	 *
 	 * @event change
-	 * @param {module:engine/model/range~Range} oldRange
-	 * Range with start and end position equal to start and end position of this live range before it got changed.
+	 * @param {module:engine/model/range~Range} oldRange Range with start and end position equal to start and end position of this live
+	 * range before it got changed.
+	 * @param {Object} data Object with additional information about the change. Those parameters are passed from
+	 * {@link module:engine/model/document~Document#event:change document change event}.
+	 * @param {String} data.type Change type.
+	 * @param {module:engine/model/range~Range} data.range Range containing the result of applied change.
+	 * @param {module:engine/model/position~Position} data.sourcePosition Source position for move, remove and reinsert change types.
 	 */
 }
 
@@ -124,7 +129,7 @@ function transform( changeType, deltaType, targetRange, sourcePosition ) {
 	const howMany = targetRange.end.offset - targetRange.start.offset;
 	let targetPosition = targetRange.start;
 
-	if ( changeType == 'move' ) {
+	if ( changeType == 'move' || changeType == 'remove' || changeType == 'reinsert' ) {
 		// Range._getTransformedByDocumentChange is expecting `targetPosition` to be "before" move
 		// (before transformation). `targetRange.start` is already after the move happened.
 		// We have to revert `targetPosition` to the state before the move.
@@ -137,7 +142,7 @@ function transform( changeType, deltaType, targetRange, sourcePosition ) {
 	//
 	// First, this concerns only `move` change, because insert change includes inserted part always (changeType == 'move').
 	// Second, this is a case only if moved range was intersecting with this range and was inserted into this range (result.length == 3).
-	if ( changeType == 'move' && result.length == 3 ) {
+	if ( ( changeType == 'move' || changeType == 'remove' || changeType == 'reinsert' ) && result.length == 3 ) {
 		// `result[ 2 ]` is a "common part" of this range and moved range. We substitute that common part with the whole
 		// `targetRange` because we want to include whole `targetRange` in this range.
 		result[ 2 ] = targetRange;
@@ -152,7 +157,11 @@ function transform( changeType, deltaType, targetRange, sourcePosition ) {
 		this.start = updated.start;
 		this.end = updated.end;
 
-		this.fire( 'change', oldRange );
+		this.fire( 'change', oldRange, {
+			type: changeType,
+			range: targetRange,
+			sourcePosition
+		} );
 	}
 }
 

+ 31 - 17
packages/ckeditor5-engine/src/model/liveselection.js

@@ -302,11 +302,18 @@ export default class LiveSelection extends Selection {
 		this._checkRange( range );
 
 		const liveRange = LiveRange.createFromRange( range );
-		this.listenTo( liveRange, 'change', ( evt, oldRange ) => {
+
+		this.listenTo( liveRange, 'change', ( evt, oldRange, data ) => {
+			// If `LiveRange` is in whole moved to the graveyard, fix that range.
 			if ( liveRange.root == this._document.graveyard ) {
-				this._fixGraveyardSelection( liveRange, oldRange );
+				const sourceStart = data.sourcePosition;
+				const howMany = data.range.end.offset - data.range.start.offset;
+				const sourceRange = Range.createFromPositionAndShift( sourceStart, howMany );
+
+				this._fixGraveyardSelection( liveRange, sourceRange );
 			}
 
+			// Whenever a live range from selection changes, fire an event informing about that change.
 			this.fire( 'change:range', { directChange: false } );
 		} );
 
@@ -624,29 +631,36 @@ export default class LiveSelection extends Selection {
 	 * Fixes a selection range after it ends up in graveyard root.
 	 *
 	 * @private
-	 * @param {module:engine/model/liverange~LiveRange} gyRange The range added in selection, that ended up in graveyard root.
-	 * @param {module:engine/model/range~Range} oldRange The state of that range before it was added to graveyard root.
+	 * @param {module:engine/model/liverange~LiveRange} liveRange The range from selection, that ended up in the graveyard root.
+	 * @param {module:engine/model/range~Range} removedRange Range which removing had caused moving `liveRange` to the graveyard root.
 	 */
-	_fixGraveyardSelection( gyRange, oldRange ) {
-		const gyPath = gyRange.start.path;
-
-		const newPathLength = oldRange.start.path.length - ( gyPath.length - 2 );
-		const newPath = oldRange.start.path.slice( 0, newPathLength );
-		newPath[ newPath.length - 1 ] -= gyPath[ 1 ];
+	_fixGraveyardSelection( liveRange, removedRange ) {
+		// The start of the removed range is the closest position to the `liveRange` - the original selection range.
+		// This is a good candidate for a fixed selection range.
+		const positionCandidate = Position.createFromPosition( removedRange.start );
 
-		const newPosition = new Position( oldRange.root, newPath );
-		let selectionRange = this._document.getNearestSelectionRange( newPosition );
+		// Find a range that is a correct selection range and is closest to the start of removed range.
+		let selectionRange = this._document.getNearestSelectionRange( positionCandidate );
 
-		// If nearest valid selection range cannot be found - use one created at root beginning.
+		// If nearest valid selection range cannot be found - use one created at the beginning of the root.
 		if ( !selectionRange ) {
-			selectionRange = new Range( new Position( newPosition.root, [ 0 ] ) );
+			selectionRange = new Range( new Position( positionCandidate.root, [ 0 ] ) );
 		}
 
+		// Remove the old selection range before preparing and adding new selection range. This order is important,
+		// because new range, in some cases, may intersect with old range (it depends on `getNearestSelectionRange()` result).
+		const index = this._ranges.indexOf( liveRange );
+		this._ranges.splice( index, 1 );
+		liveRange.detach();
+
+		// Check the range, convert it to live range, bind events, etc.
 		const newRange = this._prepareRange( selectionRange );
-		const index = this._ranges.indexOf( gyRange );
-		gyRange.detach();
 
-		this._ranges.splice( index, 1, newRange );
+		// Add new range in the place of old range.
+		this._ranges.splice( index, 0, newRange );
+
+		// Fire an event informing about selection change.
+		this.fire( 'change:range', { directChange: false } );
 	}
 }
 

+ 4 - 1
packages/ckeditor5-engine/src/model/operation/insertoperation.js

@@ -72,7 +72,10 @@ export default class InsertOperation extends Operation {
 	 * @returns {module:engine/model/operation/removeoperation~RemoveOperation}
 	 */
 	getReversed() {
-		return new RemoveOperation( this.position, this.nodes.maxOffset, this.baseVersion + 1 );
+		const graveyard = this.position.root.document.graveyard;
+		const gyPosition = new Position( graveyard, [ 0 ] );
+
+		return new RemoveOperation( this.position, this.nodes.maxOffset, gyPosition, this.baseVersion + 1 );
 	}
 
 	/**

+ 2 - 6
packages/ckeditor5-engine/src/model/operation/reinsertoperation.js

@@ -46,13 +46,9 @@ export default class ReinsertOperation extends MoveOperation {
 	 * @returns {module:engine/model/operation/removeoperation~RemoveOperation}
 	 */
 	getReversed() {
-		const removeOp = new RemoveOperation( this.targetPosition, this.howMany, this.baseVersion + 1 );
+		const newTargetPosition = this.sourcePosition._getTransformedByInsertion( this.targetPosition, this.howMany );
 
-		// Make sure that nodes are put back into the `$graveyardHolder` from which they got reinserted.
-		removeOp.targetPosition = this.sourcePosition;
-		removeOp._needsHolderElement = false;
-
-		return removeOp;
+		return new RemoveOperation( this.getMovedRangeStart(), this.howMany, newTargetPosition, this.baseVersion + 1 );
 	}
 
 	/**

+ 3 - 111
packages/ckeditor5-engine/src/model/operation/removeoperation.js

@@ -8,49 +8,12 @@
  */
 
 import MoveOperation from './moveoperation';
-import Position from '../position';
-import Element from '../element';
 import ReinsertOperation from './reinsertoperation';
 
 /**
  * Operation to remove a range of nodes.
  */
 export default class RemoveOperation extends MoveOperation {
-	/**
-	 * Creates a remove operation.
-	 *
-	 * @param {module:engine/model/position~Position} position Position before the first {@link module:engine/model/item~Item model item} to
-	 * remove.
-	 * @param {Number} howMany Offset size of removed range. {@link module:engine/model/item~Item Model items} will be removed starting
-	 * from `sourcePosition`, up to a `sourcePosition` with offset shifted by `howMany`.
-	 * @param {Number} baseVersion {@link module:engine/model/document~Document#version} on which operation can be applied.
-	 */
-	constructor( position, howMany, baseVersion ) {
-		const graveyard = position.root.document.graveyard;
-		const graveyardPosition = new Position( graveyard, [ graveyard.maxOffset, 0 ] );
-
-		super( position, howMany, graveyardPosition, baseVersion );
-
-		/**
-		 * Flag informing whether this operation should insert "holder" element (`true`) or should move removed nodes
-		 * into existing "holder" element (`false`).
-		 *
-		 * The flag should be set to `true` for each "new" `RemoveOperation` that is each `RemoveOperation` originally
-		 * created to remove some nodes from document (most likely created through `Batch` API).
-		 *
-		 * The flag should be set to `false` for each `RemoveOperation` that got created by splitting the original
-		 * `RemoveOperation`, for example during operational transformation.
-		 *
-		 * The flag should be set to `false` whenever removing nodes that were re-inserted from graveyard. This will
-		 * ensure correctness of all other operations that might change something on those nodes. This will also ensure
-		 * that redundant empty graveyard holder elements are not created.
-		 *
-		 * @protected
-		 * @type {Boolean}
-		 */
-		this._needsHolderElement = true;
-	}
-
 	/**
 	 * @inheritDoc
 	 */
@@ -58,68 +21,15 @@ export default class RemoveOperation extends MoveOperation {
 		return 'remove';
 	}
 
-	/**
-	 * Offset of the graveyard "holder" element, in which nodes removed by this operation are stored.
-	 *
-	 * @protected
-	 * @type {Number}
-	 */
-	get _holderElementOffset() {
-		return this.targetPosition.path[ 0 ];
-	}
-
-	/**
-	 * Sets {@link module:engine/model/operation/removeoperation~RemoveOperation#_holderElementOffset}.
-	 *
-	 * @protected
-	 * @param {Number} offset
-	 */
-	set _holderElementOffset( offset ) {
-		this.targetPosition.path[ 0 ] = offset;
-	}
-
 	/**
 	 * See {@link module:engine/model/operation/operation~Operation#getReversed `Operation#getReversed()`}.
 	 *
-	 * @returns {module:engine/model/operation/reinsertoperation~ReinsertOperation}
+	 * @returns {module:engine/model/operation/reinsertoperation~ReinsertOperation|module:engine/model/operation/nooperation~NoOperation}
 	 */
 	getReversed() {
-		return new ReinsertOperation( this.targetPosition, this.howMany, this.sourcePosition, this.baseVersion + 1 );
-	}
-
-	/**
-	 * Creates and returns an operation that has the same parameters as this operation.
-	 *
-	 * @returns {module:engine/model/operation/removeoperation~RemoveOperation} Clone of this operation.
-	 */
-	clone() {
-		const removeOperation = new RemoveOperation( this.sourcePosition, this.howMany, this.baseVersion );
-		removeOperation.targetPosition = Position.createFromPosition( this.targetPosition );
+		const newTargetPosition = this.sourcePosition._getTransformedByInsertion( this.targetPosition, this.howMany );
 
-		return removeOperation;
-	}
-
-	/**
-	 * @inheritDoc
-	 */
-	_execute() {
-		// Insert "holder" element in graveyard root, if the operation needs it.
-		if ( this._needsHolderElement ) {
-			const graveyard = this.targetPosition.root;
-			const holderElement = new Element( '$graveyardHolder' );
-
-			graveyard.insertChildren( this._holderElementOffset, holderElement );
-
-			// If the operation removes nodes that are already in graveyard, it may happen that
-			// the operation's source position is invalidated by inserting new holder element into the graveyard.
-			// If that's the case, we need to fix source position path.
-			if ( this.sourcePosition.root == graveyard && this.sourcePosition.path[ 0 ] >= this._holderElementOffset ) {
-				this.sourcePosition.path[ 0 ]++;
-			}
-		}
-
-		// Then, execute as a move operation.
-		return super._execute();
+		return new ReinsertOperation( this.getMovedRangeStart(), this.howMany, newTargetPosition, this.baseVersion + 1 );
 	}
 
 	/**
@@ -128,22 +38,4 @@ export default class RemoveOperation extends MoveOperation {
 	static get className() {
 		return 'engine.model.operation.RemoveOperation';
 	}
-
-	/**
-	 * Creates `RemoveOperation` object from deserilized object, i.e. from parsed JSON string.
-	 *
-	 * @param {Object} json Deserialized JSON object.
-	 * @param {module:engine/model/document~Document} document Document on which this operation will be applied.
-	 * @returns {module:engine/model/operation/removeoperation~RemoveOperation}
-	 */
-	static fromJSON( json, document ) {
-		const sourcePosition = Position.fromJSON( json.sourcePosition, document );
-
-		const removeOp = new RemoveOperation( sourcePosition, json.howMany, json.baseVersion );
-
-		removeOp.targetPosition = Position.fromJSON( json.targetPosition, document );
-		removeOp._needsHolderElement = json._needsHolderElement;
-
-		return removeOp;
-	}
 }

+ 261 - 179
packages/ckeditor5-engine/src/model/operation/transform.js

@@ -14,6 +14,7 @@ import RenameOperation from './renameoperation';
 import MarkerOperation from './markeroperation';
 import MoveOperation from './moveoperation';
 import RemoveOperation from './removeoperation';
+import ReinsertOperation from './reinsertoperation';
 import NoOperation from './nooperation';
 import Range from '../range';
 import compareArrays from '@ckeditor/ckeditor5-utils/src/comparearrays';
@@ -52,12 +53,10 @@ import compareArrays from '@ckeditor/ckeditor5-utils/src/comparearrays';
  * `a` by `b` and also `b` by `a`. In both transformations the same operation has to be the important one. If we assume
  * that first or the second passed operation is always more important we won't be able to solve this case.
  *
- * @external module:engine/model/model.operation
  * @function module:engine/model/operation/transform~transform
  * @param {module:engine/model/operation/operation~Operation} a Operation that will be transformed.
  * @param {module:engine/model/operation/operation~Operation} b Operation to transform by.
- * @param {Boolean} isAMoreImportantThanB Flag indicating whether the operation which will be transformed (`a`) should be treated
- * as more important when resolving conflicts.
+ * @param {module:engine/model/delta/transform~transformationContext} [context] Transformation context.
  * @returns {Array.<module:engine/model/operation/operation~Operation>} Result of the transformation.
  */
 
@@ -67,12 +66,15 @@ const ot = {
 	InsertOperation: {
 		// Transforms InsertOperation `a` by InsertOperation `b`. Accepts a flag stating whether `a` is more important
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
-		InsertOperation( a, b, isStrong ) {
+		InsertOperation( a, b, context ) {
 			// Transformed operations are always new instances, not references to the original operations.
 			const transformed = a.clone();
 
+			// Check whether there is a forced order of nodes or use `context.isStrong` flag for conflict resolving.
+			const insertBefore = context.insertBefore === undefined ? !context.isStrong : context.insertBefore;
+
 			// Transform insert position by the other operation position.
-			transformed.position = transformed.position._getTransformedByInsertion( b.position, b.nodes.maxOffset, !isStrong );
+			transformed.position = transformed.position._getTransformedByInsertion( b.position, b.nodes.maxOffset, insertBefore );
 
 			return [ transformed ];
 		},
@@ -87,11 +89,20 @@ const ot = {
 
 		// Transforms InsertOperation `a` by MoveOperation `b`. Accepts a flag stating whether `a` is more important
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
-		MoveOperation( a, b, isStrong ) {
+		MoveOperation( a, b, context ) {
 			const transformed = a.clone();
 
+			// Check whether there is a forced order of nodes or use `context.isStrong` flag for conflict resolving.
+			const insertBefore = context.insertBefore === undefined ? !context.isStrong : context.insertBefore;
+
 			// Transform insert position by the other operation parameters.
-			transformed.position = a.position._getTransformedByMove( b.sourcePosition, b.targetPosition, b.howMany, !isStrong, b.isSticky );
+			transformed.position = a.position._getTransformedByMove(
+				b.sourcePosition,
+				b.targetPosition,
+				b.howMany,
+				insertBefore,
+				b.isSticky && !context.forceNotSticky
+			);
 
 			return [ transformed ];
 		}
@@ -111,7 +122,7 @@ const ot = {
 
 		// Transforms AttributeOperation `a` by AttributeOperation `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.
-		AttributeOperation( a, b, isStrong ) {
+		AttributeOperation( a, b, context ) {
 			if ( a.key === b.key ) {
 				// If operations attributes are in conflict, check if their ranges intersect and manage them properly.
 
@@ -127,7 +138,7 @@ const ot = {
 					// 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 also have to update `oldValue`.
-					if ( isStrong ) {
+					if ( context.isStrong ) {
 						operations.push( new AttributeOperation( common, b.key, b.newValue, a.newValue, a.baseVersion ) );
 					} else if ( operations.length === 0 ) {
 						operations.push( new NoOperation( 0 ) );
@@ -155,18 +166,6 @@ const ot = {
 			// This will aggregate transformed ranges.
 			let ranges = [];
 
-			// Special case when MoveOperation is in fact a RemoveOperation. RemoveOperation not only moves nodes but also
-			// creates a "holder" element for them in graveyard. If there was a RemoveOperation pointing to an offset
-			// before this AttributeOperation, we have to increment AttributeOperation's offset.
-			if ( b instanceof RemoveOperation && b._needsHolderElement &&
-				a.range.root == b.targetPosition.root && a.range.start.path[ 0 ] >= b._holderElementOffset
-			) {
-				// Do not change original operation!
-				a = a.clone();
-				a.range.start.path[ 0 ]++;
-				a.range.end.path[ 0 ]++;
-			}
-
 			// 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).
 			// Right now we will make a simplification and join difference ranges and transform them as one. We will cover rangeB later.
@@ -215,9 +214,9 @@ const ot = {
 
 		// Transforms RootAttributeOperation `a` by RootAttributeOperation `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.
-		RootAttributeOperation( a, b, isStrong ) {
+		RootAttributeOperation( a, b, context ) {
 			if ( a.root === b.root && a.key === b.key ) {
-				if ( ( a.newValue !== b.newValue && !isStrong ) || a.newValue === b.newValue ) {
+				if ( ( a.newValue !== b.newValue && !context.isStrong ) || a.newValue === b.newValue ) {
 					return [ new NoOperation( a.baseVersion ) ];
 				}
 			}
@@ -250,12 +249,12 @@ const ot = {
 
 		// Transforms RenameOperation `a` by RenameOperation `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.
-		RenameOperation( a, b, isStrong ) {
+		RenameOperation( a, b, context ) {
 			// Clone the operation, we don't want to alter the original operation.
 			const clone = a.clone();
 
 			if ( a.position.isEqual( b.position ) ) {
-				if ( isStrong ) {
+				if ( context.isStrong ) {
 					clone.oldName = b.newName;
 				} else {
 					return [ new NoOperation( a.baseVersion ) ];
@@ -303,12 +302,12 @@ const ot = {
 
 		// Transforms MarkerOperation `a` by MarkerOperation `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.
-		MarkerOperation( a, b, isStrong ) {
+		MarkerOperation( a, b, context ) {
 			// Clone the operation, we don't want to alter the original operation.
 			const clone = a.clone();
 
 			if ( a.name == b.name ) {
-				if ( isStrong ) {
+				if ( context.isStrong ) {
 					clone.oldRange = b.newRange;
 				} else {
 					return [ new NoOperation( a.baseVersion ) ];
@@ -340,27 +339,23 @@ const ot = {
 	MoveOperation: {
 		// Transforms MoveOperation `a` by InsertOperation `b`. Accepts a flag stating whether `a` is more important
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
-		InsertOperation( a, b, isStrong ) {
+		InsertOperation( a, b, context ) {
 			// Create range from MoveOperation properties and transform it by insertion.
 			let range = Range.createFromPositionAndShift( a.sourcePosition, a.howMany );
-			range = range._getTransformedByInsertion( b.position, b.nodes.maxOffset, false, a.isSticky )[ 0 ];
+			range = range._getTransformedByInsertion( b.position, b.nodes.maxOffset, false, a.isSticky && !context.forceNotSticky )[ 0 ];
+
+			// Check whether there is a forced order of nodes or use `context.isStrong` flag for conflict resolving.
+			const insertBefore = context.insertBefore === undefined ? !context.isStrong : context.insertBefore;
 
 			const result = new a.constructor(
 				range.start,
 				range.end.offset - range.start.offset,
-				a instanceof RemoveOperation ?
-					a.baseVersion :
-					a.targetPosition._getTransformedByInsertion( b.position, b.nodes.maxOffset, !isStrong ),
-				a instanceof RemoveOperation ? undefined : a.baseVersion
+				a.targetPosition._getTransformedByInsertion( b.position, b.nodes.maxOffset, insertBefore ),
+				a.baseVersion
 			);
 
 			result.isSticky = a.isSticky;
 
-			if ( a instanceof RemoveOperation ) {
-				result._needsHolderElement = a._needsHolderElement;
-				result._holderElementOffset = a._holderElementOffset;
-			}
-
 			return [ result ];
 		},
 
@@ -374,7 +369,34 @@ const ot = {
 
 		// Transforms MoveOperation `a` by MoveOperation `b`. Accepts a flag stating whether `a` is more important
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
-		MoveOperation( a, b, isStrong ) {
+		MoveOperation( a, b, context ) {
+			//
+			// Setting and evaluating some variables that will be used in special cases and default algorithm.
+			//
+			// Create ranges from `MoveOperations` properties.
+			const rangeA = Range.createFromPositionAndShift( a.sourcePosition, a.howMany );
+			const rangeB = Range.createFromPositionAndShift( b.sourcePosition, b.howMany );
+
+			// Whether range moved by operation `b` is includable in operation `a` move range.
+			// For this, `a` operation has to be sticky (so `b` sticks to the range) and context has to allow stickiness.
+			const includeB = a.isSticky && !context.forceNotSticky;
+
+			// Evaluate new target position for transformed operation.
+			// Check whether there is a forced order of nodes or use `context.isStrong` flag for conflict resolving.
+			const insertBefore = context.insertBefore === undefined ? !context.isStrong : context.insertBefore;
+
+			// `a.targetPosition` could be affected by the `b` operation. We will transform it.
+			const newTargetPosition = a.targetPosition._getTransformedByMove(
+				b.sourcePosition,
+				b.targetPosition,
+				b.howMany,
+				insertBefore,
+				b.isSticky && !context.forceNotSticky
+			);
+
+			//
+			// Special case #1 + mirror.
+			//
 			// Special case when both move operations' target positions are inside nodes that are
 			// being moved by the other move operation. So in other words, we move ranges into inside of each other.
 			// This case can't be solved reasonably (on the other hand, it should not happen often).
@@ -383,173 +405,206 @@ const ot = {
 				// So when the results of this "transformation" will be applied, `b` MoveOperation will get reversed.
 				return [ b.getReversed() ];
 			}
+			//
+			// End of special case #1.
+			//
+
+			//
+			// Special case #2.
+			//
+			// Check if `b` operation targets inside `rangeA`. Use stickiness if possible.
+			const bTargetsToA = rangeA.containsPosition( b.targetPosition ) ||
+				( rangeA.start.isEqual( b.targetPosition ) && includeB ) ||
+				( rangeA.end.isEqual( b.targetPosition ) && includeB );
+
+			// If `b` targets to `rangeA` and `rangeA` contains `rangeB`, `b` operation has no influence on `a` operation.
+			// You might say that operation `b` is captured inside operation `a`.
+			if ( bTargetsToA && rangeA.containsRange( rangeB, true ) ) {
+				// There is a mini-special case here, where `rangeB` is on other level than `rangeA`. That's why
+				// we need to transform `a` operation anyway.
+				rangeA.start = rangeA.start._getTransformedByMove( b.sourcePosition, b.targetPosition, b.howMany, !includeB );
+				rangeA.end = rangeA.end._getTransformedByMove( b.sourcePosition, b.targetPosition, b.howMany, includeB );
+
+				return makeMoveOperationsFromRanges( a, [ rangeA ], newTargetPosition );
+			}
 
-			// Special case when operation transformed by is RemoveOperation. RemoveOperation not only moves nodes but also
-			// (usually) creates a "holder" element for them in graveyard. This has to be taken into consideration when
-			// transforming operations that operate in graveyard root.
-			if ( b instanceof RemoveOperation && b._needsHolderElement ) {
-				a = a.clone();
-
-				const aSourceOffset = a.sourcePosition.path[ 0 ];
-				const aTargetOffset = a.targetPosition.path[ 0 ];
-
-				// We can't use Position#_getTransformedByInsertion because it works a bit differently and we would get wrong results.
-				// That's because `sourcePosition`/`targetPosition` is, for example, `[ 1, 0 ]`, while `insertionPosition` is
-				// `[ 1 ]`. In this case, `sourcePosition`/`targetPosition` would always be transformed but in fact, only
-				// "holderElement" offsets should be looked at (so `[ 1 ]` in case of `sourcePosition`/`targetPosition`, not `[ 1, 0 ]` ).
-				// This is why we are doing it "by hand".
-				if ( a.sourcePosition.root == b.targetPosition.root ) {
-					if ( aSourceOffset > b._holderElementOffset || aSourceOffset == b._holderElementOffset ) {
-						a.sourcePosition.path[ 0 ]++;
-					}
-				}
+			//
+			// Special case #2 mirror.
+			//
+			const aTargetsToB = rangeB.containsPosition( a.targetPosition ) ||
+				( rangeB.start.isEqual( a.targetPosition ) && b.isSticky && !context.forceNotSticky ) ||
+				( rangeB.end.isEqual( a.targetPosition ) && b.isSticky && !context.forceNotSticky );
 
-				if ( a.targetPosition.root == b.targetPosition.root ) {
-					if ( aTargetOffset > b._holderElementOffset || ( aTargetOffset == b._holderElementOffset && isStrong ) ) {
-						a.targetPosition.path[ 0 ]++;
-					}
-				}
+			if ( aTargetsToB && rangeB.containsRange( rangeA, true ) ) {
+				// `a` operation is "moved together" with `b` operation.
+				// Here, just move `rangeA` "inside" `rangeB`.
+				rangeA.start = rangeA.start._getCombined( b.sourcePosition, b.getMovedRangeStart() );
+				rangeA.end = rangeA.end._getCombined( b.sourcePosition, b.getMovedRangeStart() );
+
+				return makeMoveOperationsFromRanges( a, [ rangeA ], newTargetPosition );
 			}
+			//
+			// End of special case #2.
+			//
+
+			//
+			// Special case #3 + mirror.
+			//
+			// `rangeA` has a node which is an ancestor of `rangeB`. In other words, `rangeB` is inside `rangeA`
+			// but not on the same tree level. In such case ranges have common part but we have to treat it
+			// differently, because in such case those ranges are not really conflicting and should be treated like
+			// two separate ranges. Also we have to discard two difference parts.
+			const aCompB = compareArrays( a.sourcePosition.getParentPath(), b.sourcePosition.getParentPath() );
+
+			if ( aCompB == 'prefix' || aCompB == 'extension' ) {
+				// Transform `rangeA` by `b` operation and make operation out of it, and that's all.
+				// Note that this is a simplified version of default case, but here we treat the common part (whole `rangeA`)
+				// like a one difference part.
+				rangeA.start = rangeA.start._getTransformedByMove( b.sourcePosition, b.targetPosition, b.howMany, !includeB );
+				rangeA.end = rangeA.end._getTransformedByMove( b.sourcePosition, b.targetPosition, b.howMany, includeB );
 
+				return makeMoveOperationsFromRanges( a, [ rangeA ], newTargetPosition );
+			}
+			//
+			// End of special case #3.
+			//
+
+			//
+			// Default case - ranges are on the same level or are not connected with each other.
+			//
+			// Modifier for default case.
+			// Modifies `context.isStrong` in certain conditions.
+			//
 			// If only one of operations is 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;
+			// to provide more expected results. This is done only if `context.forceWeakRemove` is set to `false`.
+			// `context.forceWeakRemove` is set to `true` in certain conditions when transformation takes place during undo.
+			if ( !context.forceWeakRemove ) {
+				if ( a instanceof RemoveOperation && !( b instanceof RemoveOperation ) ) {
+					context.isStrong = true;
+				} else if ( !( a instanceof RemoveOperation ) && b instanceof RemoveOperation ) {
+					context.isStrong = false;
+				}
 			}
 
-			// Create ranges from MoveOperations properties.
-			const rangeA = Range.createFromPositionAndShift( a.sourcePosition, a.howMany );
-			const rangeB = Range.createFromPositionAndShift( b.sourcePosition, b.howMany );
-
+			// Handle operation's source ranges - check how `rangeA` is affected by `b` operation.
 			// This will aggregate transformed ranges.
-			const ranges = [];
-
-			// All the other non-special cases are treated by generic algorithm below.
-			const difference = joinRanges( rangeA.getDifference( rangeB ) );
-
-			if ( difference ) {
-				difference.start = difference.start._getTransformedByMove(
-					b.sourcePosition,
-					b.targetPosition,
-					b.howMany,
-					!a.isSticky,
-					false
-				);
-				difference.end = difference.end._getTransformedByMove(
-					b.sourcePosition,
-					b.targetPosition,
-					b.howMany,
-					a.isSticky,
-					false
-				);
-
-				ranges.push( difference );
+			let ranges = [];
+
+			// Get the "difference part" of `a` operation source range.
+			// This is an array with one or two ranges. Two ranges if `rangeB` is inside `rangeA`.
+			const difference = rangeA.getDifference( rangeB );
+
+			for ( const range of difference ) {
+				// Transform those ranges by `b` operation. For example if `b` moved range from before those ranges, fix those ranges.
+				range.start = range.start._getTransformedByMove( b.sourcePosition, b.targetPosition, b.howMany, !includeB );
+				range.end = range.end._getTransformedByMove( b.sourcePosition, b.targetPosition, b.howMany, includeB );
+
+				ranges.push( range );
 			}
 
-			// Then, we have to manage the common part of both move ranges.
+			// 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
-			// The operations are conflicting only if they try to move exactly same nodes, so only in the first case.
-			// That means that we transform common part in two cases:
-			// * `rangeA` is "deeper" than `rangeB` so it does not collide
-			// * `rangeA` is at the same level but is stronger than `rangeB`.
-			const aCompB = compareArrays( a.sourcePosition.getParentPath(), b.sourcePosition.getParentPath() );
-
-			// 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.
-			const bTargetsToA = rangeA.containsPosition( b.targetPosition ) ||
-				( rangeA.start.isEqual( b.targetPosition ) && a.isSticky ) ||
-				( rangeA.end.isEqual( b.targetPosition ) && a.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.
-			// Also if we wouldn't do that, we would get different results on both sides of transformation (i.e. in
-			// collaborative editing).
-			const aIsInside = rangeB.containsRange( rangeA ) &&
-				(
-					rangeB.containsPosition( a.targetPosition ) ||
-					rangeB.start.isEqual( a.targetPosition ) ||
-					rangeB.end.isEqual( a.targetPosition )
-				);
-
-			if ( common !== null && ( aCompB === 'extension' || ( aCompB === 'same' && isStrong ) || aIsInside ) && !bTargetsToA ) {
-				// 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.
+			// // If MoveOperations has common range it can be one of two:
+			// //
+			// // * on the same tree level - it means that we move exactly same nodes into different places (conflict),
+			// // * on deeper tree level - it means that we move nodes that are inside moved nodes (no conflict).
+			// const commonPartConflicts = compareArrays( a.sourcePosition.getParentPath(), b.sourcePosition.getParentPath() ) == 'same';
+
+			// // Handle common part of ranges if ranges have common part and they are conflicting and `a` operation is strong.
+			// // Don't handle common part if operation `b` targets inside `rangeA` - it will be already included in "difference ranges".
+			// if ( common !== null && commonPartConflicts && context.isStrong && !bTargetsToA ) {
+			if ( common !== null && context.isStrong && !bTargetsToA ) {
+				// Calculate the new position of that part of original range.
 				common.start = common.start._getCombined( b.sourcePosition, b.getMovedRangeStart() );
 				common.end = common.end._getCombined( b.sourcePosition, b.getMovedRangeStart() );
 
-				// We have to take care of proper range order.
-				if ( difference && rangeA.start.isBefore( rangeB.start ) ) {
+				// Take care of proper range order.
+				//
+				// Put `common` at appropriate place. Keep in mind that we are interested in original order.
+				// Basically there are only three cases: there is zero, one or two difference ranges.
+				//
+				// If there is zero difference ranges, just push `common` in the array.
+				if ( ranges.length === 0 ) {
 					ranges.push( common );
-				} else {
-					ranges.unshift( common );
+				}
+				// If there is one difference range, we need to check whether common part was before it or after it.
+				else if ( ranges.length == 1 ) {
+					if ( rangeB.start.isBefore( rangeA.start ) ) {
+						ranges.unshift( common );
+					} else {
+						ranges.push( common );
+					}
+				}
+				// If there are more ranges (which means two), put common part between them. This is the only scenario
+				// where there could be two difference ranges so we don't have to make any comparisons.
+				else {
+					ranges.splice( 1, 0, common );
 				}
 			}
 
 			if ( ranges.length === 0 ) {
-				// At this point we transformed this operation's source ranges it means that nothing should be changed.
-				// But since we need to return an instance of Operation we return an array with NoOperation.
-
-				if ( a instanceof RemoveOperation ) {
-					// If `a` operation was RemoveOperation, we cannot convert it to NoOperation.
-					// This is because RemoveOperation creates a holder in graveyard.
-					// Even if we "remove nothing" we need a RemoveOperation to create holder element
-					// so that the tree structure is synchronised between clients.
-					// Note that this can happen only if both operations are remove operations, because in
-					// other case RemoveOperation would be forced to be stronger and there would be a common range to move.
-					a = a.clone();
-					a.howMany = 0;
-					a.sourcePosition = b.targetPosition;
-
-					return [ a ];
-				} else {
-					return [ new NoOperation( a.baseVersion ) ];
-				}
+				// If there are no "source ranges", nothing should be changed.
+				// Note that this can happen only if `context.isStrong == false` and `rangeA.isEqual( rangeB )`.
+				return [ new NoOperation( a.baseVersion ) ];
 			}
 
-			// Target position also could be affected by the other MoveOperation. We will transform it.
-			const newTargetPosition = a.targetPosition._getTransformedByMove(
-				b.sourcePosition,
-				b.targetPosition,
-				b.howMany,
-				!isStrong,
-				b.isSticky || aIsInside
-			);
-
-			// Map transformed range(s) to operations and return them.
-			return ranges.reverse().map( ( range, i ) => {
-				// We want to keep correct operation class.
-				const result = new a.constructor(
-					range.start,
-					range.end.offset - range.start.offset,
-					a instanceof RemoveOperation ? a.baseVersion : newTargetPosition,
-					a instanceof RemoveOperation ? undefined : a.baseVersion
-				);
-
-				result.isSticky = a.isSticky;
-
-				if ( a instanceof RemoveOperation ) {
-					// Transformed `RemoveOperation` needs graveyard holder only when the original operation needed it.
-					// If `RemoveOperation` got split into two or more operations, only first operation needs graveyard holder.
-					result._needsHolderElement = a._needsHolderElement && i === 0;
-					result._holderElementOffset = a._holderElementOffset;
+			// At this moment we have some ranges and a target position, to which those ranges should be moved.
+			// Order in `ranges` array is the go-to order of after transformation. We can reverse the `ranges`
+			// array so the last range will be moved first. This will ensure that each transformed `MoveOperation`
+			// can have same `targetPosition` and the order of ranges will be kept.
+			ranges = ranges.reverse();
+
+			// We are almost done. We have `ranges` and `targetPosition` to make operations from.
+			// Unfortunately, those operations may affect each other. Precisely, first operation after move
+			// may affect source range of second and third operation. Same with second operation affecting third.
+			// We need to fix those source ranges once again, before converting `ranges` to operations.
+			// Keep in mind that `targetPosition` is already set in stone thanks to the trick above.
+			for ( let i = 1; i < ranges.length; i++ ) {
+				for ( let j = 0; j < i; j++ ) {
+					const howMany = ranges[ j ].end.offset - ranges[ j ].start.offset;
+
+					// Thankfully, all ranges in `ranges` array are:
+					// * non-intersecting (these are part of original `a` operation source range), and
+					// * `newTargetPosition` does not target into them (opposite would mean that `a` operation targets "inside itself").
+					// This means that the transformation will be "clean" and always return one result.
+					ranges[ i ] = ranges[ i ]._getTransformedByMove( ranges[ j ].start, newTargetPosition, howMany )[ 0 ];
 				}
+			}
 
-				return result;
-			} );
+			return makeMoveOperationsFromRanges( a, ranges, newTargetPosition );
+
+			// // Map transformed range(s) to operations and return them.
+			// const transformed = ranges.map( range => {
+			// 	// We want to keep correct operation class.
+			// 	const result = new a.constructor(
+			// 		range.start,
+			// 		range.end.offset - range.start.offset,
+			// 		newTargetPosition,
+			// 		a.baseVersion
+			// 	);
+			//
+			// 	result.isSticky = a.isSticky;
+			//
+			// 	return result;
+			// } );
+			//
+			// // At this moment we have one, two or three operations in `transformed` array.
+			// // Unfortunately, those operations, may affect each other!
+			// // So, each operation has to be transformed by the operations that are before it in the array.
+			// // Thankfully, those operations have non-intersecting ranges and non-conflicting target position.
+			// // This means that each operation will be transformed to exactly one move operation and
+			// // we don't get into some kind of messy situation or infinite loop.
+			// for ( let i = 1; i < transformed.length; i++ ) {
+			// 	for ( let j = 0; j < i; j++ ) {
+			// 		transformed[ i ] = ot.MoveOperation.MoveOperation(  );
+			// 	}
+			// }
 		}
 	}
 };
 
-function transform( a, b, isStrong ) {
+function transform( a, b, context = { isStrong: false } ) {
 	let group, algorithm;
 
 	if ( a instanceof InsertOperation ) {
@@ -586,7 +641,7 @@ function transform( a, b, isStrong ) {
 		}
 	}
 
-	const transformed = algorithm( a, b, isStrong );
+	const transformed = algorithm( a, b, context );
 
 	return updateBaseVersions( a.baseVersion, transformed );
 }
@@ -626,3 +681,30 @@ function joinRanges( ranges ) {
 		return ranges[ 0 ];
 	}
 }
+
+// Map transformed range(s) to operations and return them.
+function makeMoveOperationsFromRanges( a, ranges, targetPosition ) {
+	return ranges.map( range => {
+		// We want to keep correct operation class.
+		let OperationClass;
+
+		if ( targetPosition.root.rootName == '$graveyard' ) {
+			OperationClass = RemoveOperation;
+		} else if ( range.start.root.rootName == '$graveyard' ) {
+			OperationClass = ReinsertOperation;
+		} else {
+			OperationClass = MoveOperation;
+		}
+
+		const result = new OperationClass(
+			range.start,
+			range.end.offset - range.start.offset,
+			targetPosition,
+			0 // Is corrected anyway later.
+		);
+
+		result.isSticky = a.isSticky;
+
+		return result;
+	} );
+}

+ 67 - 54
packages/ckeditor5-engine/src/model/range.js

@@ -58,8 +58,8 @@ export default class Range {
 	}
 
 	/**
-	 * Returns whether the range is collapsed, that is if {@link #start start} and
-	 * {@link #end end} positions are equal.
+	 * Returns whether the range is collapsed, that is if {@link #start} and
+	 * {@link #end} positions are equal.
 	 *
 	 * @type {Boolean}
 	 */
@@ -68,8 +68,8 @@ export default class Range {
 	}
 
 	/**
-	 * Returns whether this range is flat, that is if {@link #start start} position and
-	 * {@link #end end} position are in the same {@link module:engine/model/position~Position#parent parent}.
+	 * Returns whether this range is flat, that is if {@link #start} position and
+	 * {@link #end} position are in the same {@link module:engine/model/position~Position#parent}.
 	 *
 	 * @type {Boolean}
 	 */
@@ -90,8 +90,8 @@ export default class Range {
 	 * Checks whether this range contains given {@link module:engine/model/position~Position position}.
 	 *
 	 * @param {module:engine/model/position~Position} position Position to check.
-	 * @returns {Boolean} `true` if given {@link module:engine/model/position~Position position} is contained in this range,
-	 * `false` otherwise.
+	 * @returns {Boolean} `true` if given {@link module:engine/model/position~Position position} is contained
+	 * in this range,`false` otherwise.
 	 */
 	containsPosition( position ) {
 		return position.isAfter( this.start ) && position.isBefore( this.end );
@@ -101,15 +101,24 @@ export default class Range {
 	 * Checks whether this range contains given {@link ~Range range}.
 	 *
 	 * @param {module:engine/model/range~Range} otherRange Range to check.
+	 * @param {Boolean} [loose=false] Whether the check is loose or strict. If the check is strict (`false`), compared range cannot
+	 * start or end at the same position as this range boundaries. If the check is loose (`true`), compared range can start, end or
+	 * even be equal to this range. Note that collapsed ranges are always compared in strict mode.
 	 * @returns {Boolean} `true` if given {@link ~Range range} boundaries are contained by this range, `false` otherwise.
 	 */
-	containsRange( otherRange ) {
-		return this.containsPosition( otherRange.start ) && this.containsPosition( otherRange.end );
+	containsRange( otherRange, loose = false ) {
+		if ( otherRange.isCollapsed ) {
+			loose = false;
+		}
+
+		const containsStart = this.containsPosition( otherRange.start ) || ( loose && this.start.isEqual( otherRange.start ) );
+		const containsEnd = this.containsPosition( otherRange.end ) || ( loose && this.end.isEqual( otherRange.end ) );
+
+		return containsStart && containsEnd;
 	}
 
 	/**
-	 * Two ranges are equal if their {@link #start start} and
-	 * {@link #end end} positions are equal.
+	 * Two ranges are equal if their {@link #start} and {@link #end} positions are equal.
 	 *
 	 * @param {module:engine/model/range~Range} otherRange Range to compare with.
 	 * @returns {Boolean} `true` if ranges are equal, `false` otherwise.
@@ -418,8 +427,8 @@ export default class Range {
 		}
 
 		// It may happen that a range is split into two, and then the part of second "piece" is moved into first
-		// "piece". In this case we will have incorrect third rage, which should not be included in the result --
-		// because it is already included in first "piece". In this loop we are looking for all such ranges that
+		// "piece". In this case we will have incorrect third range, which should not be included in the result --
+		// because it is already included in the first "piece". In this loop we are looking for all such ranges that
 		// are inside other ranges and we simply remove them.
 		for ( let i = 0; i < ranges.length; i++ ) {
 			const range = ranges[ i ];
@@ -453,6 +462,7 @@ export default class Range {
 		} else {
 			const sourceRange = Range.createFromPositionAndShift( sourcePosition, howMany );
 
+			// Edge case for merge detla.
 			if (
 				deltaType == 'merge' &&
 				this.isCollapsed &&
@@ -461,51 +471,54 @@ export default class Range {
 				// Collapsed range is in merged element.
 				// Without fix, the range would end up in the graveyard, together with removed element.
 				// <p>foo</p><p>[]bar</p> -> <p>foobar</p><p>[]</p> -> <p>foobar</p> -> <p>foo[]bar</p>
+				// <p>foo</p><p>bar[]</p>
 				return [ new Range( targetPosition.getShiftedBy( this.start.offset ) ) ];
-			} else if ( type == 'move' ) {
-				// In all examples `[]` is `this` and `{}` is `sourceRange`, while `^` is move target position.
-				//
-				// Example:
-				// <p>xx</p>^<w>{<p>a[b</p>}</w><p>c]d</p>   -->   <p>xx</p><p>a[b</p><w></w><p>c]d</p>
-				// ^<p>xx</p><w>{<p>a[b</p>}</w><p>c]d</p>   -->   <p>a[b</p><p>xx</p><w></w><p>c]d</p>  // Note <p>xx</p> inclusion.
-				// <w>{<p>a[b</p>}</w>^<p>c]d</p>            -->   <w></w><p>a[b</p><p>c]d</p>
-				if (
-					sourceRange.containsPosition( this.start ) &&
-					this.containsPosition( sourceRange.end ) &&
-					this.end.isAfter( targetPosition )
-				) {
-					const start = this.start._getCombined(
-						sourcePosition,
-						targetPosition._getTransformedByDeletion( sourcePosition, howMany )
-					);
-					const end = this.end._getTransformedByMove( sourcePosition, targetPosition, howMany, false, false );
-
-					return [ new Range( start, end ) ];
-				}
+			}
+			//
+			// Other edge cases:
+			//
+			// In all examples `[]` is `this` and `{}` is `sourceRange`, while `^` is move target position.
+			//
+			// Example:
+			// <p>xx</p>^<w>{<p>a[b</p>}</w><p>c]d</p>   -->   <p>xx</p><p>a[b</p><w></w><p>c]d</p>
+			// ^<p>xx</p><w>{<p>a[b</p>}</w><p>c]d</p>   -->   <p>a[b</p><p>xx</p><w></w><p>c]d</p>  // Note <p>xx</p> inclusion.
+			// <w>{<p>a[b</p>}</w>^<p>c]d</p>            -->   <w></w><p>a[b</p><p>c]d</p>
+			if (
+				sourceRange.containsPosition( this.start ) &&
+				this.containsPosition( sourceRange.end ) &&
+				this.end.isAfter( targetPosition )
+			) {
+				const start = this.start._getCombined(
+					sourcePosition,
+					targetPosition._getTransformedByDeletion( sourcePosition, howMany )
+				);
+				const end = this.end._getTransformedByMove( sourcePosition, targetPosition, howMany, false, false );
 
-				// Example:
-				// <p>c[d</p><w>{<p>a]b</p>}</w>^<p>xx</p>   -->   <p>c[d</p><w></w><p>a]b</p><p>xx</p>
-				// <p>c[d</p><w>{<p>a]b</p>}</w><p>xx</p>^   -->   <p>c[d</p><w></w><p>xx</p><p>a]b</p>  // Note <p>xx</p> inclusion.
-				// <p>c[d</p>^<w>{<p>a]b</p>}</w>            -->   <p>c[d</p><p>a]b</p><w></w>
-				if (
-					sourceRange.containsPosition( this.end ) &&
-					this.containsPosition( sourceRange.start ) &&
-					this.start.isBefore( targetPosition )
-				) {
-					const start = this.start._getTransformedByMove(
-						sourcePosition,
-						targetPosition,
-						howMany,
-						true,
-						false
-					);
-					const end = this.end._getCombined(
-						sourcePosition,
-						targetPosition._getTransformedByDeletion( sourcePosition, howMany )
-					);
+				return [ new Range( start, end ) ];
+			}
 
-					return [ new Range( start, end ) ];
-				}
+			// Example:
+			// <p>c[d</p><w>{<p>a]b</p>}</w>^<p>xx</p>   -->   <p>c[d</p><w></w><p>a]b</p><p>xx</p>
+			// <p>c[d</p><w>{<p>a]b</p>}</w><p>xx</p>^   -->   <p>c[d</p><w></w><p>xx</p><p>a]b</p>  // Note <p>xx</p> inclusion.
+			// <p>c[d</p>^<w>{<p>a]b</p>}</w>            -->   <p>c[d</p><p>a]b</p><w></w>
+			if (
+				sourceRange.containsPosition( this.end ) &&
+				this.containsPosition( sourceRange.start ) &&
+				this.start.isBefore( targetPosition )
+			) {
+				const start = this.start._getTransformedByMove(
+					sourcePosition,
+					targetPosition,
+					howMany,
+					true,
+					false
+				);
+				const end = this.end._getCombined(
+					sourcePosition,
+					targetPosition._getTransformedByDeletion( sourcePosition, howMany )
+				);
+
+				return [ new Range( start, end ) ];
 			}
 
 			return this._getTransformedByMove( sourcePosition, targetPosition, howMany );

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

@@ -172,11 +172,21 @@ export default class Range {
 	 * Checks whether this range contains given {@link module:engine/view/range~Range range}.
 	 *
 	 * @param {module:engine/view/range~Range} otherRange Range to check.
+	 * @param {Boolean} [loose=false] Whether the check is loose or strict. If the check is strict (`false`), compared range cannot
+	 * start or end at the same position as this range boundaries. If the check is loose (`true`), compared range can start, end or
+	 * even be equal to this range. Note that collapsed ranges are always compared in strict mode.
 	 * @returns {Boolean} `true` if given {@link module:engine/view/range~Range range} boundaries are contained by this range, `false`
 	 * otherwise.
 	 */
-	containsRange( otherRange ) {
-		return this.containsPosition( otherRange.start ) && this.containsPosition( otherRange.end );
+	containsRange( otherRange, loose = false ) {
+		if ( otherRange.isCollapsed ) {
+			loose = false;
+		}
+
+		const containsStart = this.containsPosition( otherRange.start ) || ( loose && this.start.isEqual( otherRange.start ) );
+		const containsEnd = this.containsPosition( otherRange.end ) || ( loose && this.end.isEqual( otherRange.end ) );
+
+		return containsStart && containsEnd;
 	}
 
 	/**

+ 37 - 10
packages/ckeditor5-engine/tests/controller/editingcontroller.js

@@ -289,22 +289,49 @@ describe( 'EditingController', () => {
 			editing.modelToView.convertMarker.restore();
 		} );
 
-		it( 'should forward add marker event if inserted content has a marker (inserted element from outside of tree)', () => {
-			const element = new ModelElement( new ModelText( 'foo' ) );
-			modelRoot.appendChildren( element );
+		describe( 'should forward add marker event if inserted content has a marker', () => {
+			let element, outerRange;
 
-			const markerRange = ModelRange.createFromParentsAndOffsets( element, 1, element, 2 );
-			const outerRange = ModelRange.createOn( element );
+			beforeEach( () => {
+				element = new ModelElement( new ModelText( 'foo' ) );
+				modelRoot.appendChildren( element );
 
-			model.markers.set( 'name', markerRange );
+				outerRange = ModelRange.createOn( element );
 
-			sinon.spy( editing.modelToView, 'convertMarker' );
+				sinon.spy( editing.modelToView, 'convertMarker' );
+			} );
 
-			editing.modelToView.convertInsertion( outerRange );
+			afterEach( () => {
+				editing.modelToView.convertMarker.restore();
+			} );
 
-			expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true;
+			it( 'marker strictly contained', () => {
+				const markerRange = ModelRange.createFromParentsAndOffsets( element, 1, element, 2 );
+				model.markers.set( 'name', markerRange );
+				editing.modelToView.convertInsertion( outerRange );
+				expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true;
+			} );
 
-			editing.modelToView.convertMarker.restore();
+			it( 'marker starts at same position', () => {
+				const markerRange = ModelRange.createFromParentsAndOffsets( element, 0, element, 2 );
+				model.markers.set( 'name', markerRange );
+				editing.modelToView.convertInsertion( outerRange );
+				expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true;
+			} );
+
+			it( 'marker ends at same position', () => {
+				const markerRange = ModelRange.createFromParentsAndOffsets( element, 1, element, 3 );
+				model.markers.set( 'name', markerRange );
+				editing.modelToView.convertInsertion( outerRange );
+				expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true;
+			} );
+
+			it( 'marker is same as range', () => {
+				const markerRange = ModelRange.createFromParentsAndOffsets( element, 0, element, 3 );
+				model.markers.set( 'name', markerRange );
+				editing.modelToView.convertInsertion( outerRange );
+				expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true;
+			} );
 		} );
 
 		it( 'should not start marker conversion if content is not inserted into any marker range', () => {

+ 4 - 5
packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js

@@ -13,12 +13,14 @@ import RemoveOperation from '../../src/model/operation/removeoperation';
 import { wrapInDelta } from '../../tests/model/_utils/utils';
 
 describe( 'ModelConversionDispatcher', () => {
-	let dispatcher, doc, root;
+	let dispatcher, doc, root, gyPos;
 
 	beforeEach( () => {
 		doc = new ModelDocument();
 		dispatcher = new ModelConversionDispatcher( doc );
 		root = doc.createRoot();
+
+		gyPos = new ModelPosition( doc.graveyard, [ 0 ] );
 	} );
 
 	describe( 'constructor()', () => {
@@ -69,7 +71,7 @@ describe( 'ModelConversionDispatcher', () => {
 
 			// We will just create reinsert operation by reverting remove operation
 			// because creating reinsert change is tricky and not available through batch API.
-			const removeOperation = new RemoveOperation( imagePos, 1, 0 );
+			const removeOperation = new RemoveOperation( imagePos, 1, gyPos, 0 );
 
 			// Let's apply remove operation so reinsert operation won't break.
 			doc.applyOperation( wrapInDelta( removeOperation ) );
@@ -391,9 +393,6 @@ describe( 'ModelConversionDispatcher', () => {
 		} );
 	} );
 
-	describe( 'convertRename', () => {
-	} );
-
 	describe( 'convertAttribute', () => {
 		it( 'should fire event for every item in passed range', () => {
 			root.appendChildren( [

+ 36 - 20
packages/ckeditor5-engine/tests/dev-utils/enableenginedebug.js

@@ -236,6 +236,16 @@ describe( 'debug tools', () => {
 				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
 			} );
 
+			it( 'MoveOperation sticky', () => {
+				const op = new MoveOperation( ModelPosition.createAt( modelRoot, 1 ), 2, ModelPosition.createAt( modelRoot, 6 ), 0 );
+				op.isSticky = true;
+
+				expect( op.toString() ).to.equal( 'MoveOperation( 0 ): main [ 1 ] - [ 3 ] -> main [ 6 ] (sticky)' );
+
+				op.log();
+				expect( log.calledWithExactly( op.toString() ) ).to.be.true;
+			} );
+
 			it( 'NoOperation', () => {
 				const op = new NoOperation( 0 );
 
@@ -324,9 +334,15 @@ describe( 'debug tools', () => {
 
 				otherRoot.appendChildren( [ firstEle, removedEle ] );
 
+				const graveyard = modelDoc.graveyard;
 				const delta = new MergeDelta();
 				const move = new MoveOperation( ModelPosition.createAt( removedEle, 0 ), 3, ModelPosition.createAt( firstEle, 0 ), 0 );
-				const remove = new RemoveOperation( ModelPosition.createBefore( removedEle ), 1, 1 );
+				const remove = new RemoveOperation(
+					ModelPosition.createBefore( removedEle ),
+					1,
+					ModelPosition.createAt( graveyard, 0 ),
+					1
+				);
 
 				delta.addOperation( move );
 				delta.addOperation( remove );
@@ -400,9 +416,10 @@ describe( 'debug tools', () => {
 
 				otherRoot.appendChildren( [ unwrapEle ] );
 
+				const graveyard = modelDoc.graveyard;
 				const delta = new UnwrapDelta();
 				const move = new MoveOperation( ModelPosition.createAt( unwrapEle, 0 ), 3, ModelPosition.createAt( otherRoot, 0 ), 0 );
-				const remove = new RemoveOperation( ModelPosition.createAt( otherRoot, 3 ), 1, 1 );
+				const remove = new RemoveOperation( ModelPosition.createAt( otherRoot, 3 ), 1, ModelPosition.createAt( graveyard, 0 ), 1 );
 
 				delta.addOperation( move );
 				delta.addOperation( remove );
@@ -609,7 +626,8 @@ describe( 'debug tools', () => {
 			const insert = new InsertOperation( ModelPosition.createAt( modelRoot, 0 ), new ModelText( 'foobar' ), 0 );
 			model.applyOperation( wrapInDelta( insert ) );
 
-			const remove = new RemoveOperation( ModelPosition.createAt( modelRoot, 1 ), 2, 1 );
+			const graveyard = model.graveyard;
+			const remove = new RemoveOperation( ModelPosition.createAt( modelRoot, 1 ), 2, ModelPosition.createAt( graveyard, 0 ), 1 );
 			model.applyOperation( wrapInDelta( remove ) );
 
 			log.reset();
@@ -631,9 +649,7 @@ describe( 'debug tools', () => {
 			model.log( 2 );
 			expectLog(
 				'<$graveyard>' +
-				'\n\t<$graveyardHolder>' +
-				'\n\t\too' +
-				'\n\t</$graveyardHolder>' +
+				'\n\too' +
 				'\n</$graveyard>' +
 				'\n<main>' +
 				'\n\tfbar' +
@@ -643,9 +659,7 @@ describe( 'debug tools', () => {
 			model.log();
 			expectLog(
 				'<$graveyard>' +
-				'\n\t<$graveyardHolder>' +
-				'\n\t\too' +
-				'\n\t</$graveyardHolder>' +
+				'\n\too' +
 				'\n</$graveyard>' +
 				'\n<main>' +
 				'\n\tfbar' +
@@ -728,8 +742,9 @@ describe( 'debug tools', () => {
 			otherRoot.appendChildren( [ firstEle, removedEle ] );
 
 			const delta = new MergeDelta();
+			const graveyard = modelDoc.graveyard;
 			const move = new MoveOperation( ModelPosition.createAt( removedEle, 0 ), 3, ModelPosition.createAt( firstEle, 0 ), 0 );
-			const remove = new RemoveOperation( ModelPosition.createBefore( removedEle ), 1, 1 );
+			const remove = new RemoveOperation( ModelPosition.createBefore( removedEle ), 1, ModelPosition.createAt( graveyard, 0 ), 1 );
 
 			delta.addOperation( move );
 			delta.addOperation( remove );
@@ -752,8 +767,9 @@ describe( 'debug tools', () => {
 			otherRoot.appendChildren( [ firstEle, removedEle ] );
 
 			const delta = new MergeDelta();
+			const graveyard = modelDoc.graveyard;
 			const move = new MoveOperation( ModelPosition.createAt( removedEle, 0 ), 3, ModelPosition.createAt( firstEle, 0 ), 0 );
-			const remove = new RemoveOperation( ModelPosition.createBefore( removedEle ), 1, 1 );
+			const remove = new RemoveOperation( ModelPosition.createBefore( removedEle ), 1, ModelPosition.createAt( graveyard, 0 ), 1 );
 
 			delta.addOperation( move );
 			delta.addOperation( remove );
@@ -855,7 +871,7 @@ describe( 'debug tools', () => {
 			const opC = new MoveOperation( ModelPosition.createAt( root, 4 ), 2, ModelPosition.createAt( root, 0 ), 1 );
 			deltaC.addOperation( opC );
 
-			let result = deltaTransform.transform( deltaA, deltaB, false );
+			let result = deltaTransform.transform( deltaA, deltaB, { document, wasAffected: new Map() } );
 
 			expect( result[ 0 ].history ).not.to.be.undefined;
 			expect( result[ 0 ].history.length ).to.equal( 1 );
@@ -871,7 +887,7 @@ describe( 'debug tools', () => {
 			const firstResultWithoutHistory = result[ 0 ].clone();
 			delete firstResultWithoutHistory.history;
 
-			result = deltaTransform.transform( result[ 0 ], deltaC, true );
+			result = deltaTransform.transform( result[ 0 ], deltaC, { isStrong: true, document, wasAffected: new Map() } );
 			expect( result[ 0 ].history ).not.to.be.undefined;
 			expect( result[ 0 ].history.length ).to.equal( 2 );
 
@@ -888,7 +904,7 @@ describe( 'debug tools', () => {
 				transformedBy: JSON.stringify( deltaC ),
 				wasImportant: true,
 				resultIndex: 0,
-				resultsTotal: 1
+				resultsTotal: 2
 			} );
 		} );
 
@@ -905,15 +921,15 @@ describe( 'debug tools', () => {
 			const opC = new MoveOperation( ModelPosition.createAt( root, 4 ), 2, ModelPosition.createAt( root, 0 ), 1 );
 			deltaC.addOperation( opC );
 
-			let original = deltaTransform.transform( deltaA, deltaB, false );
-			original = deltaTransform.transform( original[ 0 ], deltaC, true )[ 0 ];
+			let original = deltaTransform.transform( deltaA, deltaB, { document, wasAffected: new Map() } );
+			original = deltaTransform.transform( original[ 0 ], deltaC, { isStrong: true, document, wasAffected: new Map() } )[ 0 ];
 
 			const history = original.history;
 
 			let recreated = deltaTransform.transform(
 				DeltaFactory.fromJSON( JSON.parse( history[ 0 ].before ), document ),
 				DeltaFactory.fromJSON( JSON.parse( history[ 0 ].transformedBy ), document ),
-				history[ 0 ].wasImportant
+				{ isStrong: history[ 0 ].wasImportant, document, wasAffected: new Map() }
 			);
 
 			recreated = recreated[ history[ 0 ].resultIndex ];
@@ -921,7 +937,7 @@ describe( 'debug tools', () => {
 			recreated = deltaTransform.transform(
 				recreated,
 				DeltaFactory.fromJSON( JSON.parse( history[ 1 ].transformedBy ), document ),
-				history[ 1 ].wasImportant
+				{ isStrong: history[ 1 ].wasImportant, document, wasAffected: new Map() }
 			);
 
 			recreated = recreated[ history[ 1 ].resultIndex ];
@@ -946,7 +962,7 @@ describe( 'debug tools', () => {
 					throw new Error();
 				};
 
-				expect( () => deltaTransform.transform( deltaA, deltaB, true ) ).to.throw( Error );
+				expect( () => deltaTransform.transform( deltaA, deltaB, { isStrong: true } ) ).to.throw( Error );
 				expect( error.calledWith( deltaA.toString() + ' (important)' ) ).to.be.true;
 				expect( error.calledWith( deltaB.toString() ) ).to.be.true;
 			} );
@@ -964,7 +980,7 @@ describe( 'debug tools', () => {
 					throw new Error();
 				};
 
-				expect( () => deltaTransform.transform( deltaA, deltaB, false ) ).to.throw( Error );
+				expect( () => deltaTransform.transform( deltaA, deltaB, { isStrong: false } ) ).to.throw( Error );
 				expect( error.calledWith( deltaA.toString() ) ).to.be.true;
 				expect( error.calledWith( deltaB.toString() + ' (important)' ) ).to.be.true;
 			} );

+ 13 - 0
packages/ckeditor5-engine/tests/model/delta/attributedelta.js

@@ -11,6 +11,7 @@ import Position from '../../../src/model/position';
 import Element from '../../../src/model/element';
 import { default as AttributeDelta, RootAttributeDelta } from '../../../src/model/delta/attributedelta';
 import AttributeOperation from '../../../src/model/operation/attributeoperation';
+import NoOperation from '../../../src/model/operation/nooperation';
 import { jsonParseStringify } from '../../../tests/model/_utils/utils';
 
 describe( 'Batch', () => {
@@ -466,6 +467,18 @@ describe( 'AttributeDelta', () => {
 			expect( delta.range.start.path ).to.deep.equal( [ 1 ] );
 			expect( delta.range.end.path ).to.deep.equal( [ 6 ] );
 		} );
+
+		it( 'should return correct values when some operations are NoOperations', () => {
+			const rangeA = new Range( new Position( root, [ 2 ] ), new Position( root, [ 4 ] ) );
+			const rangeB = new Range( new Position( root, [ 5 ] ), new Position( root, [ 6 ] ) );
+
+			delta.addOperation( new AttributeOperation( rangeA, 'key', 'oldA', 'new', 0 ) );
+			delta.addOperation( new NoOperation( 1 ) );
+			delta.addOperation( new AttributeOperation( rangeB, 'key', 'oldC', 'new', 2 ) );
+
+			expect( delta.range.start.path ).to.deep.equal( [ 2 ] );
+			expect( delta.range.end.path ).to.deep.equal( [ 6 ] );
+		} );
 	} );
 
 	describe( 'getReversed', () => {

+ 1 - 0
packages/ckeditor5-engine/tests/model/delta/delta.js

@@ -240,6 +240,7 @@ describe( 'Delta', () => {
 			const operation = new RemoveOperation(
 				new Position( root, [ 2 ] ),
 				2,
+				new Position( doc.graveyard, [ 0 ] ),
 				doc.version
 			);
 

+ 1 - 0
packages/ckeditor5-engine/tests/model/delta/deltafactory.js

@@ -120,6 +120,7 @@ describe( 'DeltaFactory', () => {
 			delta.addOperation( new RemoveOperation(
 				new Position( root, [ 2 ] ),
 				2,
+				new Position( doc.graveyard, [ 0 ] ),
 				doc.version
 			) );
 

+ 2 - 2
packages/ckeditor5-engine/tests/model/delta/mergedelta.js

@@ -103,7 +103,7 @@ describe( 'MergeDelta', () => {
 
 		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 ) );
+			mergeDelta.operations.push( new RemoveOperation( new Position( root, [ 1, 2, 0 ] ), 1, new Position( doc.graveyard, [ 0 ] ) ) );
 
 			expect( mergeDelta.position.root ).to.equal( root );
 			expect( mergeDelta.position.path ).to.deep.equal( [ 1, 2, 0 ] );
@@ -120,7 +120,7 @@ describe( 'MergeDelta', () => {
 
 		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 ) );
+			mergeDelta.operations.push( new RemoveOperation( new Position( root, [ 1, 2, 0 ] ), 1, new Position( doc.graveyard, [ 0 ] ) ) );
 
 			const reversed = mergeDelta.getReversed();
 

+ 4 - 3
packages/ckeditor5-engine/tests/model/delta/removedelta.js

@@ -49,11 +49,12 @@ describe( 'Batch', () => {
 			expect( getNodesAndText( Range.createIn( root.getChild( 1 ) ) ) ).to.equal( 'abcxyz' );
 		} );
 
-		it( 'should create minimal number of operations when removing a range', () => {
+		it( 'should create minimal number of remove deltas, each with only one operation', () => {
 			batch.remove( range );
 
-			expect( batch.deltas.length ).to.equal( 1 );
-			expect( batch.deltas[ 0 ].operations.length ).to.equal( 2 );
+			expect( batch.deltas.length ).to.equal( 2 );
+			expect( batch.deltas[ 0 ].operations.length ).to.equal( 1 );
+			expect( batch.deltas[ 1 ].operations.length ).to.equal( 1 );
 		} );
 
 		it( 'should be chainable', () => {

+ 27 - 4
packages/ckeditor5-engine/tests/model/delta/splitdelta.js

@@ -14,6 +14,7 @@ import SplitDelta from '../../../src/model/delta/splitdelta';
 
 import InsertOperation from '../../../src/model/operation/insertoperation';
 import MoveOperation from '../../../src/model/operation/moveoperation';
+import NoOperation from '../../../src/model/operation/nooperation';
 import RemoveOperation from '../../../src/model/operation/removeoperation';
 
 import count from '@ckeditor/ckeditor5-utils/src/count';
@@ -162,12 +163,34 @@ describe( 'SplitDelta', () => {
 
 		it( 'should return the first operation in the delta, which is InsertOperation or ReinsertOperation', () => {
 			const p = new Element( 'p' );
-			splitDelta.operations.push( new InsertOperation( new Position( root, [ 1, 2 ] ), p, 0 ) );
+			const insert = new InsertOperation( new Position( root, [ 1, 2 ] ), p, 0 );
+			splitDelta.operations.push( insert );
 			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.nodes.getNode( 0 ) ).to.equal( p );
-			expect( splitDelta._cloneOperation.position.path ).to.deep.equal( [ 1, 2 ] );
+			expect( splitDelta._cloneOperation ).to.equal( insert );
+		} );
+	} );
+
+	describe( '_moveOperation', () => {
+		it( 'should return null if delta has no operations', () => {
+			expect( splitDelta._moveOperation ).to.be.null;
+		} );
+
+		it( 'should return null if second operation is NoOperation', () => {
+			const p = new Element( 'p' );
+			splitDelta.operations.push( new InsertOperation( new Position( root, [ 1, 2 ] ), p, 0 ) );
+			splitDelta.operations.push( new NoOperation( 1 ) );
+
+			expect( splitDelta._moveOperation ).to.be.null;
+		} );
+
+		it( 'should return second operation if it is MoveOperation', () => {
+			const p = new Element( 'p' );
+			const move = new MoveOperation( new Position( root, [ 1, 1, 4 ] ), 4, new Position( root, [ 1, 2, 0 ] ), 1 );
+			splitDelta.operations.push( new InsertOperation( new Position( root, [ 1, 2 ] ), p, 0 ) );
+			splitDelta.operations.push( move );
+
+			expect( splitDelta._moveOperation ).to.equal( move );
 		} );
 	} );
 

+ 13 - 3
packages/ckeditor5-engine/tests/model/delta/transform/_utils/utils.js

@@ -71,7 +71,11 @@ export function getMergeDelta( position, howManyInPrev, howManyInNext, version )
 	move.isSticky = true;
 
 	delta.addOperation( move );
-	delta.addOperation( new RemoveOperation( position, 1, version + 1 ) );
+
+	const gy = sourcePosition.root.document.graveyard;
+	const gyPos = Position.createAt( gy, 0 );
+
+	delta.addOperation( new RemoveOperation( position, 1, gyPos, version + 1 ) );
 
 	return delta;
 }
@@ -88,7 +92,10 @@ export function getMoveDelta( sourcePosition, howMany, targetPosition, baseVersi
 export function getRemoveDelta( sourcePosition, howMany, baseVersion ) {
 	const delta = new RemoveDelta();
 
-	const remove = new RemoveOperation( sourcePosition, howMany, baseVersion );
+	const gy = sourcePosition.root.document.graveyard;
+	const gyPos = Position.createAt( gy, 0 );
+
+	const remove = new RemoveOperation( sourcePosition, howMany, gyPos, baseVersion );
 	delta.addOperation( remove );
 
 	return delta;
@@ -150,7 +157,10 @@ export function getUnwrapDelta( positionBefore, howManyChildren, version ) {
 	const removePosition = Position.createFromPosition( positionBefore );
 	removePosition.offset += howManyChildren;
 
-	const remove = new RemoveOperation( removePosition, 1, version + 1 );
+	const gy = sourcePosition.root.document.graveyard;
+	const gyPos = Position.createAt( gy, 0 );
+
+	const remove = new RemoveOperation( removePosition, 1, gyPos, version + 1 );
 
 	delta.addOperation( move );
 	delta.addOperation( remove );

+ 75 - 5
packages/ckeditor5-engine/tests/model/delta/transform/attributedelta.js

@@ -14,7 +14,9 @@ import Position from '../../../../src/model/position';
 import Range from '../../../../src/model/range';
 
 import AttributeDelta from '../../../../src/model/delta/attributedelta';
+import Delta from '../../../../src/model/delta/delta';
 import AttributeOperation from '../../../../src/model/operation/attributeoperation';
+import NoOperation from '../../../../src/model/operation/nooperation';
 
 import {
 	expectDelta,
@@ -25,12 +27,13 @@ import {
 } from '../../../../tests/model/delta/transform/_utils/utils';
 
 describe( 'transform', () => {
-	let doc, root, baseVersion;
+	let doc, root, baseVersion, context;
 
 	beforeEach( () => {
 		doc = getFilledDocument();
 		root = doc.getRoot();
 		baseVersion = doc.version;
+		context = { isStrong: false };
 	} );
 
 	describe( 'AttributeDelta by', () => {
@@ -51,7 +54,7 @@ describe( 'transform', () => {
 					baseVersion
 				);
 
-				const transformed = transform( attrDelta, insertDelta );
+				const transformed = transform( attrDelta, insertDelta, context );
 
 				expect( transformed.length ).to.equal( 2 );
 
@@ -117,7 +120,7 @@ describe( 'transform', () => {
 				const insertPosition = new Position( root, [ 5 ] );
 				const insertDelta = getWeakInsertDelta( insertPosition, 'abc', baseVersion );
 
-				const transformed = transform( attrDelta, insertDelta );
+				const transformed = transform( attrDelta, insertDelta, context );
 
 				expect( transformed.length ).to.equal( 1 );
 
@@ -151,7 +154,7 @@ describe( 'transform', () => {
 				const splitPosition = new Position( root, [ 3, 3, 3, 3 ] );
 				const splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
 
-				const transformed = transform( attrDelta, splitDelta );
+				const transformed = transform( attrDelta, splitDelta, context );
 
 				expect( transformed.length ).to.equal( 2 );
 
@@ -219,7 +222,7 @@ describe( 'transform', () => {
 				const splitPosition = new Position( root, [ 3, 3, 3, 3 ] );
 				const splitDelta = getSplitDelta( splitPosition, new Element( 'p', { foo: 'old' } ), 9, baseVersion );
 
-				const transformed = transform( attrDelta, splitDelta );
+				const transformed = transform( attrDelta, splitDelta, context );
 
 				expect( transformed.length ).to.equal( 2 );
 
@@ -254,5 +257,72 @@ describe( 'transform', () => {
 				} );
 			} );
 		} );
+
+		describe( 'AttributeDelta', () => {
+			it( 'should be converted to no delta if all operations are NoOperations', () => {
+				const rangeA = new Range( new Position( root, [ 2 ] ), new Position( root, [ 4 ] ) );
+				const rangeB = new Range( new Position( root, [ 4 ] ), new Position( root, [ 7 ] ) );
+
+				const attrDeltaA = new AttributeDelta();
+				attrDeltaA.addOperation( new AttributeOperation( rangeA, 'key', 'old', 'new', 0 ) );
+				attrDeltaA.addOperation( new AttributeOperation( rangeB, 'key', null, 'new', 1 ) );
+
+				const attrDeltaB = new AttributeDelta();
+				attrDeltaB.addOperation( new AttributeOperation( rangeA, 'key', 'old', 'other', 0 ) );
+				attrDeltaB.addOperation( new AttributeOperation( rangeB, 'key', null, 'other', 1 ) );
+
+				const transformed = transform( attrDeltaA, attrDeltaB, context );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: Delta,
+					operations: [
+						{
+							type: NoOperation,
+							baseVersion: 2
+						},
+						{
+							type: NoOperation,
+							baseVersion: 3
+						}
+					]
+				} );
+			} );
+
+			it( 'should put AttributeOperation as first operation in the delta', () => {
+				const rangeA = new Range( new Position( root, [ 2 ] ), new Position( root, [ 4 ] ) );
+				const rangeB = new Range( new Position( root, [ 4 ] ), new Position( root, [ 7 ] ) );
+
+				const attrDeltaA = new AttributeDelta();
+				attrDeltaA.addOperation( new AttributeOperation( rangeA, 'key', 'old', 'new', 0 ) );
+				attrDeltaA.addOperation( new AttributeOperation( rangeB, 'key', null, 'new', 1 ) );
+
+				const attrDeltaB = new AttributeDelta();
+				attrDeltaB.addOperation( new AttributeOperation( rangeA, 'key', 'old', 'other', 0 ) );
+
+				const transformed = transform( attrDeltaA, attrDeltaB, context );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: AttributeDelta,
+					operations: [
+						{
+							type: AttributeOperation,
+							range: rangeB,
+							key: 'key',
+							oldValue: null,
+							newValue: 'new',
+							baseVersion: 1
+						},
+						{
+							type: NoOperation,
+							baseVersion: 2
+						}
+					]
+				} );
+			} );
+		} );
 	} );
 } );

+ 6 - 2
packages/ckeditor5-engine/tests/model/delta/transform/delta.js

@@ -42,11 +42,15 @@ describe( 'Delta', () => {
 		const deltaA = new Delta();
 		const deltaB = new Delta();
 
+		const context = {
+			isStrong: false
+		};
+
 		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 ) );
 
-		const deltaAbyB = transform( deltaA, deltaB );
-		const deltaBbyA = transform( deltaB, deltaA );
+		const deltaAbyB = transform( deltaA, deltaB, context );
+		const deltaBbyA = transform( deltaB, deltaA, context );
 
 		expect( deltaAbyB.length ).to.equal( 1 );
 

+ 9 - 6
packages/ckeditor5-engine/tests/model/delta/transform/insertdelta.js

@@ -30,13 +30,16 @@ import {
 } from '../../../../tests/model/delta/transform/_utils/utils';
 
 describe( 'transform', () => {
-	let doc, root, gy, baseVersion;
+	let doc, root, gy, baseVersion, context;
 
 	beforeEach( () => {
 		doc = getFilledDocument();
 		root = doc.getRoot();
 		gy = doc.graveyard;
 		baseVersion = doc.version;
+		context = {
+			isStrong: false
+		};
 	} );
 
 	describe( 'InsertDelta by', () => {
@@ -55,7 +58,7 @@ describe( 'transform', () => {
 				const insertPositionB = new Position( root, [ 3, 1 ] );
 				const insertDeltaB = getInsertDelta( insertPositionB, [ new Element( 'c' ), new Element( 'd' ) ], baseVersion );
 
-				const transformed = transform( insertDelta, insertDeltaB );
+				const transformed = transform( insertDelta, insertDeltaB, context );
 
 				expect( transformed.length ).to.equal( 1 );
 
@@ -76,7 +79,7 @@ describe( 'transform', () => {
 		describe( 'MergeDelta', () => {
 			it( 'merge in same position as insert', () => {
 				const mergeDelta = getMergeDelta( insertPosition, 4, 12, baseVersion );
-				const transformed = transform( insertDelta, mergeDelta );
+				const transformed = transform( insertDelta, mergeDelta, context );
 
 				baseVersion = mergeDelta.operations.length;
 
@@ -90,7 +93,7 @@ describe( 'transform', () => {
 					operations: [
 						{
 							type: ReinsertOperation,
-							sourcePosition: new Position( gy, [ 0, 0 ] ),
+							sourcePosition: new Position( gy, [ 0 ] ),
 							howMany: 1,
 							targetPosition: new Position( root, [ 3, 3, 3 ] ),
 							baseVersion
@@ -131,7 +134,7 @@ describe( 'transform', () => {
 
 			it( 'merge the node that is parent of insert position (sticky move test)', () => {
 				const mergeDelta = getMergeDelta( new Position( root, [ 3, 3 ] ), 1, 4, baseVersion );
-				const transformed = transform( insertDelta, mergeDelta );
+				const transformed = transform( insertDelta, mergeDelta, context );
 
 				baseVersion = mergeDelta.operations.length;
 
@@ -164,7 +167,7 @@ describe( 'transform', () => {
 
 			it( 'merge at affected position but resolved by default OT', () => {
 				const mergeDelta = getMergeDelta( new Position( root, [ 3 ] ), 1, 4, baseVersion );
-				const transformed = transform( insertDelta, mergeDelta );
+				const transformed = transform( insertDelta, mergeDelta, context );
 
 				baseVersion = mergeDelta.operations.length;
 

+ 9 - 8
packages/ckeditor5-engine/tests/model/delta/transform/markerdelta.js

@@ -26,12 +26,13 @@ import {
 } from '../../../model/delta/transform/_utils/utils';
 
 describe( 'transform', () => {
-	let doc, root, baseVersion;
+	let doc, root, baseVersion, context;
 
 	beforeEach( () => {
 		doc = getFilledDocument();
 		root = doc.getRoot();
 		baseVersion = doc.version;
+		context = { isStrong: false };
 	} );
 
 	describe( 'MarkerDelta by', () => {
@@ -47,7 +48,7 @@ describe( 'transform', () => {
 
 			it( 'split inside oldRange', () => {
 				const splitDelta = getSplitDelta( new Position( root, [ 3, 1 ] ), new Element( 'div' ), 3, baseVersion );
-				const transformed = transform( markerDelta, splitDelta );
+				const transformed = transform( markerDelta, splitDelta, context );
 
 				baseVersion = splitDelta.operations.length;
 
@@ -72,7 +73,7 @@ describe( 'transform', () => {
 
 			it( 'split inside newRange', () => {
 				const splitDelta = getSplitDelta( new Position( root, [ 3, 3, 3, 4 ] ), new Element( 'p' ), 8, baseVersion );
-				const transformed = transform( markerDelta, splitDelta );
+				const transformed = transform( markerDelta, splitDelta, context );
 
 				baseVersion = splitDelta.operations.length;
 
@@ -107,7 +108,7 @@ describe( 'transform', () => {
 				// MergeDelta merges the element in which is collapsed marker range with the previous element.
 				const mergeDelta = getMergeDelta( new Position( root, [ 3, 3, 3 ] ), 4, 12, baseVersion );
 
-				const transformed = transform( markerDelta, mergeDelta );
+				const transformed = transform( markerDelta, mergeDelta, context );
 
 				// It is expected, that ranges in MarkerDelta got correctly transformed:
 				// from start of merged element to the place where merged nodes where moved in the previous element,
@@ -143,7 +144,7 @@ describe( 'transform', () => {
 				const wrapRange = new Range( new Position( root, [ 1 ] ), new Position( root, [ 2 ] ) );
 				const wrapDelta = getWrapDelta( wrapRange, wrapElement, baseVersion );
 
-				const transformed = transform( markerDelta, wrapDelta );
+				const transformed = transform( markerDelta, wrapDelta, context );
 
 				// It is expected, that ranges in MarkerDelta got correctly transformed:
 				// `oldRange` end is in wrapped element,
@@ -178,7 +179,7 @@ describe( 'transform', () => {
 				const unwrapPosition = new Position( root, [ 1, 0 ] );
 				const unwrapDelta = getUnwrapDelta( unwrapPosition, 4, baseVersion );
 
-				const transformed = transform( markerDelta, unwrapDelta );
+				const transformed = transform( markerDelta, unwrapDelta, context );
 
 				// It is expected, that ranges in MarkerDelta got correctly transformed.
 				const expectedOldRange = new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 1, 2 ] ) );
@@ -209,7 +210,7 @@ describe( 'transform', () => {
 				const unwrapPosition = new Position( root, [ 1 ] );
 				const unwrapDelta = getUnwrapDelta( unwrapPosition, 4, baseVersion );
 
-				const transformed = transform( markerDelta, unwrapDelta );
+				const transformed = transform( markerDelta, unwrapDelta, context );
 
 				// It is expected, that ranges in MarkerDelta got correctly transformed.
 				const expectedOldRange = new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 3 ] ) );
@@ -239,7 +240,7 @@ describe( 'transform', () => {
 		const wrapRange = new Range( new Position( root, [ 1 ] ), new Position( root, [ 2 ] ) );
 		const wrapDelta = getWrapDelta( wrapRange, wrapElement, baseVersion );
 
-		const transformed = transform( markerDelta, wrapDelta );
+		const transformed = transform( markerDelta, wrapDelta, context );
 
 		expect( transformed.length ).to.equal( 1 );
 		expect( transformed[ 0 ].operations.length ).to.equal( 1 );

+ 9 - 8
packages/ckeditor5-engine/tests/model/delta/transform/mergedelta.js

@@ -31,13 +31,14 @@ import {
 } from '../../../../tests/model/delta/transform/_utils/utils';
 
 describe( 'transform', () => {
-	let doc, root, gy, baseVersion;
+	let doc, root, gy, baseVersion, context;
 
 	beforeEach( () => {
 		doc = getFilledDocument();
 		root = doc.getRoot();
 		gy = doc.graveyard;
 		baseVersion = doc.version;
+		context = { isStrong: false };
 	} );
 
 	describe( 'MergeDelta by', () => {
@@ -58,7 +59,7 @@ describe( 'transform', () => {
 
 			it( 'insert at same position as merge', () => {
 				const insertDelta = getInsertDelta( mergePosition, [ nodeA, nodeB ], baseVersion );
-				const transformed = transform( mergeDelta, insertDelta );
+				const transformed = transform( mergeDelta, insertDelta, context );
 
 				// Expected: MergeDelta gets ignored and is not applied.
 
@@ -89,7 +90,7 @@ describe( 'transform', () => {
 
 			it( 'insert inside merged node (sticky move test)', () => {
 				const insertDelta = getInsertDelta( new Position( root, [ 3, 3, 3, 12 ] ), [ nodeA, nodeB ], baseVersion );
-				const transformed = transform( mergeDelta, insertDelta );
+				const transformed = transform( mergeDelta, insertDelta, context );
 
 				baseVersion = insertDelta.operations.length;
 
@@ -111,7 +112,7 @@ describe( 'transform', () => {
 							type: RemoveOperation,
 							sourcePosition: new Position( root, [ 3, 3, 3 ] ),
 							howMany: 1,
-							targetPosition: new Position( gy, [ 0, 0 ] ),
+							targetPosition: new Position( gy, [ 0 ] ),
 							baseVersion: baseVersion + 1
 						}
 					]
@@ -132,7 +133,7 @@ describe( 'transform', () => {
 		describe( 'MoveDelta', () => {
 			it( 'node on the right side of merge was moved', () => {
 				const moveDelta = getMoveDelta( new Position( root, [ 3, 3, 3 ] ), 1, new Position( root, [ 3, 3, 0 ] ), baseVersion );
-				const transformed = transform( mergeDelta, moveDelta );
+				const transformed = transform( mergeDelta, moveDelta, context );
 
 				baseVersion = moveDelta.operations.length;
 
@@ -160,7 +161,7 @@ describe( 'transform', () => {
 
 			it( 'node on the left side of merge was moved', () => {
 				const moveDelta = getMoveDelta( new Position( root, [ 3, 3, 2 ] ), 1, new Position( root, [ 3, 3, 0 ] ), baseVersion );
-				const transformed = transform( mergeDelta, moveDelta );
+				const transformed = transform( mergeDelta, moveDelta, context );
 
 				expect( transformed.length ).to.equal( 1 );
 
@@ -199,7 +200,7 @@ describe( 'transform', () => {
 		describe( 'MergeDelta', () => {
 			it( 'merge two consecutive elements, transformed merge is after', () => {
 				const mergeDeltaB = getMergeDelta( new Position( root, [ 3, 3, 2 ] ), 0, 4, baseVersion );
-				const transformed = transform( mergeDelta, mergeDeltaB );
+				const transformed = transform( mergeDelta, mergeDeltaB, context );
 
 				expect( transformed.length ).to.equal( 1 );
 
@@ -237,7 +238,7 @@ describe( 'transform', () => {
 				mergeDelta = getMergeDelta( new Position( root, [ 3, 3, 2 ] ), 0, 4, baseVersion );
 				const mergeDeltaB = getMergeDelta( new Position( root, [ 3, 3, 3 ] ), 4, 12, baseVersion );
 
-				const transformed = transform( mergeDelta, mergeDeltaB );
+				const transformed = transform( mergeDelta, mergeDeltaB, context );
 
 				expect( transformed.length ).to.equal( 1 );
 

+ 33 - 5
packages/ckeditor5-engine/tests/model/delta/transform/movedelta.js

@@ -27,13 +27,14 @@ import {
 } from '../../../../tests/model/delta/transform/_utils/utils';
 
 describe( 'transform', () => {
-	let doc, root, gy, baseVersion;
+	let doc, root, gy, baseVersion, context;
 
 	beforeEach( () => {
 		doc = getFilledDocument();
 		root = doc.getRoot();
 		gy = doc.graveyard;
 		baseVersion = doc.version;
+		context = { isStrong: false };
 	} );
 
 	describe( 'MoveDelta by', () => {
@@ -52,7 +53,7 @@ describe( 'transform', () => {
 				const mergePosition = new Position( root, [ 3, 3, 3 ] );
 				const mergeDelta = getMergeDelta( mergePosition, 4, 12, baseVersion );
 
-				const transformed = transform( moveDelta, mergeDelta );
+				const transformed = transform( moveDelta, mergeDelta, context );
 
 				expect( transformed.length ).to.equal( 2 );
 
@@ -68,7 +69,7 @@ describe( 'transform', () => {
 							// 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, 0 ] ),
+							sourcePosition: new Position( gy, [ 0 ] ),
 							howMany: 1,
 							targetPosition: new Position( root, [ 3, 3, 3 ] ),
 							baseVersion
@@ -108,11 +109,11 @@ describe( 'transform', () => {
 				expect( nodesAndText ).to.equal( 'DIVPabcfoobarxyzPXXXXXabcdXDIV' );
 			} );
 
-			it( 'move range in merged node', () => {
+			it( 'move range in merged node #1', () => {
 				const mergePosition = new Position( root, [ 3, 3 ] );
 				const mergeDelta = getMergeDelta( mergePosition, 1, 4, baseVersion );
 
-				const transformed = transform( moveDelta, mergeDelta );
+				const transformed = transform( moveDelta, mergeDelta, context );
 
 				expect( transformed.length ).to.equal( 1 );
 
@@ -131,6 +132,33 @@ describe( 'transform', () => {
 					]
 				} );
 			} );
+
+			it( 'move range in merged node #2', () => {
+				moveDelta._moveOperation.sourcePosition.path = [ 3, 3, 1 ];
+				moveDelta._moveOperation.targetPosition.path = [ 3, 3, 4 ];
+
+				const mergePosition = new Position( root, [ 3, 3 ] );
+				const mergeDelta = getMergeDelta( mergePosition, 1, 4, baseVersion );
+
+				const transformed = transform( moveDelta, mergeDelta, context );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = mergeDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: MoveDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 2, 2 ] ),
+							howMany: 1,
+							targetPosition: new Position( root, [ 3, 2, 5 ] ),
+							baseVersion
+						}
+					]
+				} );
+			} );
 		} );
 	} );
 } );

+ 36 - 5
packages/ckeditor5-engine/tests/model/delta/transform/removedelta.js

@@ -12,9 +12,11 @@ import Element from '../../../../src/model/element';
 import Position from '../../../../src/model/position';
 import Range from '../../../../src/model/range';
 
+import Delta from '../../../../src/model/delta/delta';
 import RemoveDelta from '../../../../src/model/delta/removedelta';
 import SplitDelta from '../../../../src/model/delta/splitdelta';
 
+import NoOperation from '../../../../src/model/operation/nooperation';
 import MoveOperation from '../../../../src/model/operation/moveoperation';
 import RemoveOperation from '../../../../src/model/operation/removeoperation';
 
@@ -30,13 +32,14 @@ import {
 } from '../../../../tests/model/delta/transform/_utils/utils';
 
 describe( 'transform', () => {
-	let doc, root, gy, baseVersion;
+	let doc, root, gy, baseVersion, context;
 
 	beforeEach( () => {
 		doc = getFilledDocument();
 		root = doc.getRoot();
 		gy = doc.graveyard;
 		baseVersion = doc.version;
+		context = { isStrong: false };
 	} );
 
 	describe( 'RemoveDelta by', () => {
@@ -49,7 +52,7 @@ describe( 'transform', () => {
 				const mergePosition = new Position( root, [ 3, 3, 3 ] );
 				const mergeDelta = getMergeDelta( mergePosition, 4, 12, baseVersion );
 
-				const transformed = transform( removeDelta, mergeDelta );
+				const transformed = transform( removeDelta, mergeDelta, context );
 
 				expect( transformed.length ).to.equal( 2 );
 
@@ -60,7 +63,7 @@ describe( 'transform', () => {
 					operations: [
 						{
 							type: MoveOperation,
-							sourcePosition: new Position( gy, [ 0, 0 ] ),
+							sourcePosition: new Position( gy, [ 0 ] ),
 							howMany: 1,
 							targetPosition: new Position( root, [ 3, 3, 3 ] ),
 							baseVersion
@@ -108,7 +111,7 @@ describe( 'transform', () => {
 				const nodeCopy = new Element( 'x' );
 				const splitDelta = getSplitDelta( splitPosition, nodeCopy, 2, baseVersion );
 
-				const transformed = transform( removeDelta, splitDelta );
+				const transformed = transform( removeDelta, splitDelta, context );
 
 				expect( transformed.length ).to.equal( 1 );
 
@@ -127,6 +130,34 @@ describe( 'transform', () => {
 				} );
 			} );
 
+			it( 'removed nodes in split node, after split position, isStrong = false', () => {
+				const sourcePosition = new Position( root, [ 3, 3, 2, 4 ] );
+				const removeDelta = getRemoveDelta( sourcePosition, 3, baseVersion );
+
+				const splitPosition = new Position( root, [ 3, 3, 2, 2 ] );
+				const nodeCopy = new Element( 'x' );
+				const splitDelta = getSplitDelta( splitPosition, nodeCopy, 8, baseVersion );
+
+				const transformed = transform( removeDelta, splitDelta, {
+					isStrong: false,
+					forceWeakRemove: true
+				} );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = splitDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: Delta,
+					operations: [
+						{
+							type: NoOperation,
+							baseVersion
+						}
+					]
+				} );
+			} );
+
 			it( 'last node in the removed range was a node that has been split', () => {
 				const sourcePosition = new Position( root, [ 3, 2 ] );
 				const removeDelta = getRemoveDelta( sourcePosition, 2, baseVersion );
@@ -135,7 +166,7 @@ describe( 'transform', () => {
 				const nodeCopy = new Element( 'div' );
 				const splitDelta = getSplitDelta( splitPosition, nodeCopy, 2, baseVersion );
 
-				const transformed = transform( removeDelta, splitDelta );
+				const transformed = transform( removeDelta, splitDelta, context );
 
 				expect( transformed.length ).to.equal( 1 );
 

+ 32 - 3
packages/ckeditor5-engine/tests/model/delta/transform/renamedelta.js

@@ -11,7 +11,9 @@ import Element from '../../../../src/model/element';
 import Position from '../../../../src/model/position';
 
 import RenameDelta from '../../../../src/model/delta/renamedelta';
+import Delta from '../../../../src/model/delta/delta';
 import RenameOperation from '../../../../src/model/operation/renameoperation';
+import NoOperation from '../../../../src/model/operation/nooperation';
 
 import {
 	getFilledDocument,
@@ -20,12 +22,13 @@ import {
 } from '../../../../tests/model/delta/transform/_utils/utils';
 
 describe( 'transform', () => {
-	let doc, root, baseVersion;
+	let doc, root, baseVersion, context;
 
 	beforeEach( () => {
 		doc = getFilledDocument();
 		root = doc.getRoot();
 		baseVersion = doc.version;
+		context = { isStrong: false };
 	} );
 
 	describe( 'RenameDelta by', () => {
@@ -42,7 +45,7 @@ describe( 'transform', () => {
 				const splitPosition = new Position( root, [ 3, 3, 3 ] );
 				const splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
 
-				const transformed = transform( renameDelta, splitDelta );
+				const transformed = transform( renameDelta, splitDelta, context );
 
 				baseVersion = splitDelta.length;
 
@@ -85,7 +88,7 @@ describe( 'transform', () => {
 				const splitPosition = new Position( root, [ 3, 2, 1 ] );
 				const splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
 
-				const transformed = transform( renameDelta, splitDelta );
+				const transformed = transform( renameDelta, splitDelta, context );
 
 				baseVersion = splitDelta.length;
 
@@ -104,5 +107,31 @@ describe( 'transform', () => {
 				} );
 			} );
 		} );
+
+		describe( 'RenameDelta', () => {
+			it( 'should be transformed to NoDelta if its operation is transformed to NoOperation', () => {
+				const renameDeltaA = new RenameDelta();
+				const renameDeltaB = new RenameDelta();
+
+				const op = new RenameOperation( new Position( root, [ 3 ] ), 'p', 'li', baseVersion );
+
+				renameDeltaA.addOperation( op );
+				renameDeltaB.addOperation( op.clone() );
+
+				const transformed = transform( renameDeltaA, renameDeltaB, context );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: Delta,
+					operations: [
+						{
+							type: NoOperation,
+							baseVersion: 1
+						}
+					]
+				} );
+			} );
+		} );
 	} );
 } );

+ 61 - 24
packages/ckeditor5-engine/tests/model/delta/transform/splitdelta.js

@@ -37,13 +37,14 @@ import {
 } from '../../../../tests/model/delta/transform/_utils/utils';
 
 describe( 'transform', () => {
-	let doc, root, gy, baseVersion;
+	let doc, root, gy, baseVersion, context;
 
 	beforeEach( () => {
 		doc = getFilledDocument();
 		root = doc.getRoot();
 		gy = doc.graveyard;
 		baseVersion = doc.version;
+		context = { isStrong: false };
 	} );
 
 	describe( 'SplitDelta by', () => {
@@ -57,18 +58,23 @@ describe( 'transform', () => {
 		describe( 'SplitDelta', () => {
 			it( 'split in same parent and offset', () => {
 				const splitDeltaB = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
-				const transformed = transform( splitDelta, splitDeltaB );
+				const transformed = transform( splitDelta, splitDeltaB, context );
 
 				baseVersion = splitDeltaB.operations.length;
 
 				expect( transformed.length ).to.equal( 1 );
 
 				expectDelta( transformed[ 0 ], {
-					type: Delta,
+					type: SplitDelta,
 					operations: [
 						{
-							type: NoOperation,
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 4 ] ),
 							baseVersion
+						},
+						{
+							type: NoOperation,
+							baseVersion: baseVersion + 1
 						}
 					]
 				} );
@@ -78,16 +84,47 @@ describe( 'transform', () => {
 				applyDelta( splitDeltaB, doc );
 				applyDelta( transformed[ 0 ], doc );
 
-				const nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 5 ) );
+				const nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 0 ] ), 6 ) );
 
 				// 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' );
+				expect( nodesAndText ).to.equal( 'XXXXXabcdXPabcPPPPfoobarxyzP' );
+			} );
+
+			it( 'should not change context.insertBefore if it was set', () => {
+				// SplitDelta x SplitDelta transformation case sets `context.insertBefore` on its own if it is not set.
+				// It is to achieve better transformation results from UX point of view.
+				// However, if `context.insertBefore` was already set, it should not be changed. This might be important for undo.
+				const splitDeltaB = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
+				const transformed = transform( splitDelta, splitDeltaB, {
+					isStrong: false,
+					insertBefore: true
+				} );
+
+				baseVersion = splitDeltaB.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							// If `context.insertBefore` would not be set, the offset would be 4. See an example above.
+							position: new Position( root, [ 3, 3, 5 ] ),
+							baseVersion
+						},
+						{
+							type: NoOperation,
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
 			} );
 
 			it( 'split in same parent, incoming delta splits closer', () => {
 				const splitDeltaB = getSplitDelta( new Position( root, [ 3, 3, 3, 5 ] ), new Element( 'p' ), 7, baseVersion );
-				const transformed = transform( splitDelta, splitDeltaB );
+				const transformed = transform( splitDelta, splitDeltaB, context );
 
 				baseVersion = splitDeltaB.operations.length;
 
@@ -140,7 +177,7 @@ describe( 'transform', () => {
 				);
 				splitDeltaB.operations[ 0 ] = reOp;
 
-				const transformed = transform( splitDelta, splitDeltaB );
+				const transformed = transform( splitDelta, splitDeltaB, context );
 
 				baseVersion = splitDeltaB.operations.length;
 
@@ -169,7 +206,7 @@ describe( 'transform', () => {
 
 			it( 'split in same parent, incoming delta splits further', () => {
 				const splitDeltaB = getSplitDelta( new Position( root, [ 3, 3, 3, 1 ] ), new Element( 'p' ), 11, baseVersion );
-				const transformed = transform( splitDelta, splitDeltaB );
+				const transformed = transform( splitDelta, splitDeltaB, context );
 
 				baseVersion = splitDeltaB.operations.length;
 
@@ -222,7 +259,7 @@ describe( 'transform', () => {
 				);
 				splitDeltaB.operations[ 0 ] = reOp;
 
-				const transformed = transform( splitDelta, splitDeltaB );
+				const transformed = transform( splitDelta, splitDeltaB, context );
 
 				baseVersion = splitDeltaB.operations.length;
 
@@ -251,7 +288,7 @@ describe( 'transform', () => {
 
 			it( 'split in split parent', () => {
 				const splitDeltaB = getSplitDelta( new Position( root, [ 3, 3, 3 ] ), new Element( 'div' ), 1, baseVersion );
-				const transformed = transform( splitDelta, splitDeltaB );
+				const transformed = transform( splitDelta, splitDeltaB, context );
 
 				baseVersion = splitDeltaB.operations.length;
 
@@ -290,7 +327,7 @@ describe( 'transform', () => {
 		describe( 'UnwrapDelta', () => {
 			it( 'split position directly in unwrapped node', () => {
 				const unwrapDelta = getUnwrapDelta( new Position( root, [ 3, 3, 3 ] ), 12, baseVersion );
-				const transformed = transform( splitDelta, unwrapDelta );
+				const transformed = transform( splitDelta, unwrapDelta, context );
 
 				baseVersion = unwrapDelta.operations.length;
 
@@ -319,7 +356,7 @@ describe( 'transform', () => {
 			it( 'split position indirectly in unwrapped node', () => {
 				const unwrapDelta = getUnwrapDelta( new Position( root, [ 3, 3 ] ), 4, baseVersion );
 
-				const transformed = transform( splitDelta, unwrapDelta );
+				const transformed = transform( splitDelta, unwrapDelta, context );
 
 				expect( transformed.length ).to.equal( 1 );
 
@@ -360,7 +397,7 @@ describe( 'transform', () => {
 				const wrapElement = new Element( 'E' );
 				const wrapDelta = getWrapDelta( wrapRange, wrapElement, baseVersion );
 
-				const transformed = transform( splitDelta, wrapDelta );
+				const transformed = transform( splitDelta, wrapDelta, context );
 
 				baseVersion = wrapDelta.operations.length;
 
@@ -391,7 +428,7 @@ describe( 'transform', () => {
 				const wrapElement = new Element( 'E' );
 				const wrapDelta = getWrapDelta( wrapRange, wrapElement, baseVersion );
 
-				const transformed = transform( splitDelta, wrapDelta );
+				const transformed = transform( splitDelta, wrapDelta, context );
 
 				expect( transformed.length ).to.equal( 1 );
 
@@ -430,7 +467,7 @@ describe( 'transform', () => {
 				const wrapElement = new Element( 'E' );
 				const wrapDelta = getWrapDelta( wrapRange, wrapElement, baseVersion );
 
-				const transformed = transform( splitDelta, wrapDelta );
+				const transformed = transform( splitDelta, wrapDelta, context );
 
 				expect( transformed.length ).to.equal( 1 );
 
@@ -477,7 +514,7 @@ describe( 'transform', () => {
 					Range.createFromPositionAndShift( new Position( root, [ 3, 3, 2 ] ), 3 ), 'key', null, 'newValue', baseVersion + 1
 				) );
 
-				const transformed = transform( splitDelta, attributeDelta );
+				const transformed = transform( splitDelta, attributeDelta, context );
 
 				baseVersion = attributeDelta.operations.length;
 
@@ -516,7 +553,7 @@ describe( 'transform', () => {
 					Range.createFromPositionAndShift( new Position( root, [ 3, 3, 2 ] ), 3 ), 'key', 'oldValue', null, baseVersion + 1
 				) );
 
-				const transformed = transform( splitDelta, attributeDelta );
+				const transformed = transform( splitDelta, attributeDelta, context );
 
 				baseVersion = attributeDelta.operations.length;
 
@@ -557,7 +594,7 @@ describe( 'transform', () => {
 					Range.createFromParentsAndOffsets( root, 0, root, 4 ), 'key', 'oldValue', 'newValue', baseVersion
 				) );
 
-				const transformed = transform( splitDelta, attributeDelta );
+				const transformed = transform( splitDelta, attributeDelta, context );
 
 				baseVersion = attributeDelta.operations.length;
 				expect( transformed.length ).to.equal( 1 );
@@ -591,7 +628,7 @@ describe( 'transform', () => {
 					new Position( root, [ 3, 3, 3 ] ), 'p', 'li', baseVersion
 				) );
 
-				const transformed = transform( splitDelta, renameDelta );
+				const transformed = transform( splitDelta, renameDelta, context );
 
 				baseVersion = renameDelta.operations.length;
 
@@ -624,7 +661,7 @@ describe( 'transform', () => {
 					new Position( root, [ 4 ] ), 'p', 'li', baseVersion
 				) );
 
-				const transformed = transform( splitDelta, renameDelta );
+				const transformed = transform( splitDelta, renameDelta, context );
 
 				baseVersion = renameDelta.operations.length;
 
@@ -662,7 +699,7 @@ describe( 'transform', () => {
 					new Position( root, [ 3, 3, 3 ] ), 'p', 'li', baseVersion
 				) );
 
-				const transformed = transform( splitDelta, renameDelta );
+				const transformed = transform( splitDelta, renameDelta, context );
 
 				baseVersion = renameDelta.operations.length;
 
@@ -699,7 +736,7 @@ describe( 'transform', () => {
 				const removeDelta = getRemoveDelta( removePosition, 3, baseVersion );
 				const removeOperation = removeDelta.operations[ 0 ];
 
-				const transformed = transform( splitDelta, removeDelta );
+				const transformed = transform( splitDelta, removeDelta, context );
 
 				expect( transformed.length ).to.equal( 1 );
 
@@ -735,7 +772,7 @@ describe( 'transform', () => {
 				const removeDelta = getRemoveDelta( removePosition, 2, baseVersion );
 				const removeOperation = removeDelta.operations[ 0 ];
 
-				const transformed = transform( splitDelta, removeDelta );
+				const transformed = transform( splitDelta, removeDelta, context );
 
 				expect( transformed.length ).to.equal( 1 );
 

+ 276 - 132
packages/ckeditor5-engine/tests/model/delta/transform/transform.js

@@ -15,18 +15,22 @@ import Position from '../../../../src/model/position';
 import Delta from '../../../../src/model/delta/delta';
 import InsertDelta from '../../../../src/model/delta/insertdelta';
 import RemoveDelta from '../../../../src/model/delta/removedelta';
+import MoveDelta from '../../../../src/model/delta/movedelta';
 import SplitDelta from '../../../../src/model/delta/splitdelta';
 
 import NoOperation from '../../../../src/model/operation/nooperation';
 import MoveOperation from '../../../../src/model/operation/moveoperation';
 import RemoveOperation from '../../../../src/model/operation/removeoperation';
+import ReinsertOperation from '../../../../src/model/operation/reinsertoperation';
 import InsertOperation from '../../../../src/model/operation/insertoperation';
 
 import {
 	expectDelta,
 	getInsertDelta,
 	getSplitDelta,
-	getRemoveDelta
+	getRemoveDelta,
+	getMoveDelta,
+	getMergeDelta
 } from '../../../../tests/model/delta/transform/_utils/utils';
 
 describe( 'transform', () => {
@@ -55,72 +59,6 @@ describe( 'transform', () => {
 			deltaTransform.transform.restore();
 		} );
 
-		it( 'should transform two deltas', () => {
-			const insertDelta = getInsertDelta( new Position( root, [ 0, 4 ] ), new Text( 'xxx' ), baseVersion );
-			const removeDelta = getRemoveDelta( new Position( root, [ 0, 0 ] ), 2, baseVersion );
-
-			const { deltasA, deltasB } = transformDeltaSets( [ insertDelta ], [ removeDelta ] );
-
-			expect( deltasA.length ).to.equal( 1 );
-			expect( deltasB.length ).to.equal( 1 );
-
-			expectDelta( deltasA[ 0 ], {
-				type: InsertDelta,
-				operations: [
-					{
-						type: InsertOperation,
-						position: new Position( root, [ 0, 2 ] ),
-						baseVersion: 1
-					}
-				]
-			} );
-
-			expectDelta( deltasB[ 0 ], {
-				type: RemoveDelta,
-				operations: [
-					{
-						type: RemoveOperation,
-						sourcePosition: new Position( root, [ 0, 0 ] ),
-						howMany: 2,
-						baseVersion: 1
-					}
-				]
-			} );
-		} );
-
-		it( 'should transform two deltas - reverse', () => {
-			const insertDelta = getInsertDelta( new Position( root, [ 0, 4 ] ), new Text( 'xxx' ), baseVersion );
-			const removeDelta = getRemoveDelta( new Position( root, [ 0, 0 ] ), 2, baseVersion );
-
-			const { deltasA, deltasB } = transformDeltaSets( [ removeDelta ], [ insertDelta ] );
-
-			expect( deltasA.length ).to.equal( 1 );
-			expect( deltasB.length ).to.equal( 1 );
-
-			expectDelta( deltasA[ 0 ], {
-				type: RemoveDelta,
-				operations: [
-					{
-						type: RemoveOperation,
-						sourcePosition: new Position( root, [ 0, 0 ] ),
-						howMany: 2,
-						baseVersion: 1
-					}
-				]
-			} );
-
-			expectDelta( deltasB[ 0 ], {
-				type: InsertDelta,
-				operations: [
-					{
-						type: InsertOperation,
-						position: new Position( root, [ 0, 2 ] ),
-						baseVersion: 1
-					}
-				]
-			} );
-		} );
-
 		it( 'should transform two arrays of deltas', () => {
 			const splitDelta = getSplitDelta( new Position( root, [ 0, 3 ] ), new Element( 'p' ), 3, baseVersion );
 			const insertDeltaX = getInsertDelta( new Position( root, [ 0, 3 ] ), new Text( 'xxx' ), baseVersion + 2 );
@@ -128,10 +66,10 @@ describe( 'transform', () => {
 			const removeDelta = getRemoveDelta( new Position( root, [ 0, 2 ] ), 2, baseVersion );
 			const insertDeltaY = getInsertDelta( new Position( root, [ 0, 4 ] ), new Text( 'yyy' ), baseVersion + 1 );
 
-			const { deltasA, deltasB } = transformDeltaSets( [ splitDelta, insertDeltaX ], [ removeDelta, insertDeltaY ], true );
+			const { deltasA, deltasB } = transformDeltaSets( [ splitDelta, insertDeltaX ], [ removeDelta, insertDeltaY ] );
 
 			expect( deltasA.length ).to.equal( 3 );
-			expect( deltasB.length ).to.equal( 2 );
+			expect( deltasB.length ).to.equal( 3 );
 
 			expectDelta( deltasA[ 0 ], {
 				type: SplitDelta,
@@ -180,7 +118,13 @@ describe( 'transform', () => {
 						sourcePosition: new Position( root, [ 1, 0 ] ),
 						howMany: 1,
 						baseVersion: 3
-					},
+					}
+				]
+			} );
+
+			expectDelta( deltasB[ 1 ], {
+				type: RemoveDelta,
+				operations: [
 					{
 						type: RemoveOperation,
 						sourcePosition: new Position( root, [ 0, 2 ] ),
@@ -190,7 +134,7 @@ describe( 'transform', () => {
 				]
 			} );
 
-			expectDelta( deltasB[ 1 ], {
+			expectDelta( deltasB[ 2 ], {
 				type: InsertDelta,
 				operations: [
 					{
@@ -209,9 +153,9 @@ describe( 'transform', () => {
 			const removeDelta = getRemoveDelta( new Position( root, [ 0, 2 ] ), 2, baseVersion );
 			const insertDeltaY = getInsertDelta( new Position( root, [ 0, 4 ] ), new Text( 'yyy' ), baseVersion + 1 );
 
-			const { deltasA, deltasB } = transformDeltaSets( [ removeDelta, insertDeltaY ], [ splitDelta, insertDeltaX ], true );
+			const { deltasA, deltasB } = transformDeltaSets( [ removeDelta, insertDeltaY ], [ splitDelta, insertDeltaX ] );
 
-			expect( deltasA.length ).to.equal( 2 );
+			expect( deltasA.length ).to.equal( 3 );
 			expect( deltasB.length ).to.equal( 3 );
 
 			expectDelta( deltasA[ 0 ], {
@@ -222,7 +166,13 @@ describe( 'transform', () => {
 						sourcePosition: new Position( root, [ 1, 0 ] ),
 						howMany: 1,
 						baseVersion: 3
-					},
+					}
+				]
+			} );
+
+			expectDelta( deltasA[ 1 ], {
+				type: RemoveDelta,
+				operations: [
 					{
 						type: RemoveOperation,
 						sourcePosition: new Position( root, [ 0, 2 ] ),
@@ -232,7 +182,7 @@ describe( 'transform', () => {
 				]
 			} );
 
-			expectDelta( deltasA[ 1 ], {
+			expectDelta( deltasA[ 2 ], {
 				type: InsertDelta,
 				operations: [
 					{
@@ -283,77 +233,271 @@ describe( 'transform', () => {
 			} );
 		} );
 
-		it( 'importance flag - first set is important', () => {
-			const insertDeltaA = getInsertDelta( new Position( root, [ 0, 4 ] ), new Text( 'xxx' ), baseVersion );
-			const insertDeltaB = getInsertDelta( new Position( root, [ 0, 4 ] ), new Text( 'yyy' ), baseVersion );
+		it( 'should not modify original deltas or arrays', () => {
+			const insertDeltaA = getInsertDelta( new Position( root, [ 0, 0 ] ), new Text( 'x' ), baseVersion );
+			const insertDeltaB = getInsertDelta( new Position( root, [ 1, 0 ] ), new Text( 'y' ), baseVersion );
 
-			const { deltasA, deltasB } = transformDeltaSets( [ insertDeltaA ], [ insertDeltaB ], true );
+			const originalDeltasA = [ insertDeltaA ];
+			const originalDeltasB = [ insertDeltaB ];
 
-			expectDelta( deltasA[ 0 ], {
-				type: InsertDelta,
-				operations: [
-					{
-						type: InsertOperation,
-						position: new Position( root, [ 0, 4 ] ),
-						baseVersion: 1
-					}
-				]
-			} );
+			const { deltasA, deltasB } = transformDeltaSets( originalDeltasA, originalDeltasB );
 
-			expectDelta( deltasB[ 0 ], {
-				type: InsertDelta,
-				operations: [
-					{
-						type: InsertOperation,
-						position: new Position( root, [ 0, 7 ] ),
-						baseVersion: 1
-					}
-				]
-			} );
+			expect( deltasA ).to.not.equal( originalDeltasA );
+			expect( deltasB ).to.not.equal( originalDeltasB );
+			expect( deltasA[ 0 ] ).to.not.equal( originalDeltasA[ 0 ] );
+			expect( deltasB[ 0 ] ).to.not.equal( originalDeltasB[ 0 ] );
 		} );
 
-		it( 'importance flag - second set is important', () => {
-			const insertDeltaA = getInsertDelta( new Position( root, [ 0, 4 ] ), new Text( 'xxx' ), baseVersion );
-			const insertDeltaB = getInsertDelta( new Position( root, [ 0, 4 ] ), new Text( 'yyy' ), baseVersion );
+		describe( 'context', () => {
+			it( 'first set is important', () => {
+				const insertDeltaA = getInsertDelta( new Position( root, [ 0, 4 ] ), new Text( 'xxx' ), baseVersion );
+				const insertDeltaB = getInsertDelta( new Position( root, [ 0, 4 ] ), new Text( 'yyy' ), baseVersion );
+
+				const { deltasA, deltasB } = transformDeltaSets( [ insertDeltaA ], [ insertDeltaB ] );
+
+				expectDelta( deltasA[ 0 ], {
+					type: InsertDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 0, 4 ] ),
+							baseVersion: 1
+						}
+					]
+				} );
+
+				expectDelta( deltasB[ 0 ], {
+					type: InsertDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 0, 7 ] ),
+							baseVersion: 1
+						}
+					]
+				} );
+			} );
 
-			const { deltasA, deltasB } = transformDeltaSets( [ insertDeltaA ], [ insertDeltaB ], false );
+			it( 'remove delta is more important if additional context is not used', () => {
+				const moveDelta = getMoveDelta( new Position( root, [ 0, 4 ] ), 3, new Position( root, [ 1, 0 ] ), baseVersion );
+				const removeDelta = getRemoveDelta( new Position( root, [ 0, 4 ] ), 3, baseVersion );
+
+				const { deltasA, deltasB } = transformDeltaSets( [ moveDelta ], [ removeDelta ] );
+
+				expectDelta( deltasA[ 0 ], {
+					type: Delta,
+					operations: [
+						{
+							type: NoOperation,
+							baseVersion: 1
+						}
+					]
+				} );
+
+				expectDelta( deltasB[ 0 ], {
+					type: RemoveDelta,
+					operations: [
+						{
+							type: RemoveOperation,
+							sourcePosition: new Position( root, [ 1, 0 ] ),
+							howMany: 3,
+							targetPosition: new Position( doc.graveyard, [ 0 ] ),
+							baseVersion: 1
+						}
+					]
+				} );
+			} );
 
-			expectDelta( deltasA[ 0 ], {
-				type: InsertDelta,
-				operations: [
-					{
-						type: InsertOperation,
-						position: new Position( root, [ 0, 7 ] ),
-						baseVersion: 1
-					}
-				]
+			it( 'remove delta may be less important if additional context is used and the delta was undone', () => {
+				const moveDelta = getMoveDelta( new Position( root, [ 0, 4 ] ), 3, new Position( root, [ 1, 0 ] ), baseVersion );
+				const removeDelta = getRemoveDelta( new Position( root, [ 0, 4 ] ), 3, baseVersion );
+
+				// "Fake" delta undoing.
+				doc.history.setDeltaAsUndone( removeDelta, new Delta() );
+
+				const { deltasA, deltasB } = transformDeltaSets( [ moveDelta ], [ removeDelta ], doc );
+
+				expectDelta( deltasA[ 0 ], {
+					type: MoveDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( doc.graveyard, [ 0 ] ),
+							howMany: 3,
+							targetPosition: new Position( root, [ 1, 0 ] ),
+							baseVersion: 1
+						}
+					]
+				} );
+
+				expectDelta( deltasB[ 0 ], {
+					type: Delta,
+					operations: [
+						{
+							type: NoOperation,
+							baseVersion: 1
+						}
+					]
+				} );
 			} );
 
-			expectDelta( deltasB[ 0 ], {
-				type: InsertDelta,
-				operations: [
-					{
-						type: InsertOperation,
-						position: new Position( root, [ 0, 4 ] ),
-						baseVersion: 1
-					}
-				]
+			it( 'should keep correct order of nodes if additional context is used (insert before)', () => {
+				// Assume document state: 'a^bcd'. User presses 'delete' key twice.
+				const removeDelta1 = getRemoveDelta( new Position( root, [ 1 ] ), 1, baseVersion ); // 'acd'.
+				const removeDelta2 = getRemoveDelta( new Position( root, [ 1 ] ), 1, baseVersion + 1 ); // 'ad'.
+				const reinsertDelta2 = removeDelta2.getReversed(); // 'acd'.
+
+				doc.history.addDelta( removeDelta1 );
+				doc.history.addDelta( removeDelta2 );
+				doc.history.addDelta( reinsertDelta2 );
+
+				doc.history.setDeltaAsUndone( removeDelta2, reinsertDelta2 );
+
+				// This should insert 'b' at offset 1.
+				// However this delta will be transformed by `reinsertDelta2` which also inserts a node at offset 1.
+				// This leads to a conflict and history needs to be check how deltas relate to each other.
+				// Compare this scenario to the scenario below.
+				const reinsertDelta1 = removeDelta1.getReversed();
+
+				const { deltasA } = transformDeltaSets( [ reinsertDelta1 ], [ removeDelta2, reinsertDelta2 ], doc );
+
+				expectDelta( deltasA[ 0 ], {
+					type: MoveDelta,
+					operations: [
+						{
+							type: ReinsertOperation,
+							targetPosition: new Position( root, [ 1 ] ),
+							howMany: 1,
+							baseVersion: 3
+						}
+					]
+				} );
 			} );
-		} );
 
-		it( 'should not modify original deltas or arrays', () => {
-			const insertDeltaA = getInsertDelta( new Position( root, [ 0, 0 ] ), new Text( 'x' ), baseVersion );
-			const insertDeltaB = getInsertDelta( new Position( root, [ 1, 0 ] ), new Text( 'y' ), baseVersion );
+			it( 'should keep correct order of nodes if additional context is used (insert after)', () => {
+				// Assume document state: 'abc^d'. User presses 'backspace' key twice.
+				const removeDelta1 = getRemoveDelta( new Position( root, [ 2 ] ), 1, baseVersion ); // 'abd'.
+				const removeDelta2 = getRemoveDelta( new Position( root, [ 1 ] ), 1, baseVersion + 1 ); // 'ad'.
+				const reinsertDelta2 = removeDelta2.getReversed(); // 'abd'.
+
+				doc.history.addDelta( removeDelta1 );
+				doc.history.addDelta( removeDelta2 );
+				doc.history.addDelta( reinsertDelta2 );
+
+				doc.history.setDeltaAsUndone( removeDelta2, reinsertDelta2 );
+
+				// This should insert 'c' at offset 2.
+				// However this delta will be first transformed by `removeDelta2`. After that transformation
+				// the delta will try to insert 'c' at offset 1. Then, it will be transformed by `reinsertDelta2`,
+				// which also tries to insert node at offset 1.
+				// This leads to a conflict and history needs to be check how deltas relate to each other.
+				// Compare this scenario to the scenario below.
+				// In both cases transformed delta was inserting something at the same position. However, in
+				// both cases the expected outcome is different.
+				const reinsertDelta1 = removeDelta1.getReversed();
+
+				const { deltasA } = transformDeltaSets( [ reinsertDelta1 ], [ removeDelta2, reinsertDelta2 ], doc );
+
+				expectDelta( deltasA[ 0 ], {
+					type: MoveDelta,
+					operations: [
+						{
+							type: ReinsertOperation,
+							targetPosition: new Position( root, [ 2 ] ),
+							howMany: 1,
+							baseVersion: 3
+						}
+					]
+				} );
+			} );
 
-			const originalDeltasA = [ insertDeltaA ];
-			const originalDeltasB = [ insertDeltaB ];
+			it( 'should not throw if delta transformed by undoing delta was not transformed by undone delta', () => {
+				// This scenario may happen when deltas are undone in different order than usual LIFO order.
+				const removeDelta1 = getRemoveDelta( new Position( root, [ 0 ] ), 1, baseVersion );
+				const removeDelta2 = getRemoveDelta( new Position( root, [ 2 ] ), 1, baseVersion + 1 );
+				const reinsertDelta1 = removeDelta1.getReversed();
+				reinsertDelta1.baseVersion = 2;
+
+				doc.history.addDelta( removeDelta1 );
+				doc.history.addDelta( removeDelta2 );
+				doc.history.addDelta( reinsertDelta1 );
+
+				doc.history.setDeltaAsUndone( removeDelta1, reinsertDelta1 );
+
+				const reinsertDelta2 = removeDelta2.getReversed();
+
+				const { deltasA } = transformDeltaSets( [ reinsertDelta2 ], [ reinsertDelta1 ], doc );
+
+				expectDelta( deltasA[ 0 ], {
+					type: MoveDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 0 ] ),
+							targetPosition: new Position( root, [ 3 ] ),
+							howMany: 1,
+							baseVersion: 3
+						}
+					]
+				} );
+			} );
 
-			const { deltasA, deltasB } = transformDeltaSets( originalDeltasA, originalDeltasB, false );
+			it( 'should use stickiness if additional context is not used', () => {
+				const moveDeltaA = getMoveDelta( new Position( root, [ 0, 0 ] ), 1, new Position( root, [ 1, 0 ] ), baseVersion );
+				const moveDeltaB = getMoveDelta( new Position( root, [ 1, 0 ] ), 3, new Position( root, [ 2, 2 ] ), baseVersion );
+				moveDeltaB.operations[ 0 ].isSticky = true;
+
+				const { deltasA } = transformDeltaSets( [ moveDeltaA ], [ moveDeltaB ], doc );
+
+				expectDelta( deltasA[ 0 ], {
+					type: MoveDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							targetPosition: new Position( root, [ 2, 2 ] ),
+							howMany: 1,
+							baseVersion: 1
+						}
+					]
+				} );
+			} );
 
-			expect( deltasA ).to.not.equal( originalDeltasA );
-			expect( deltasB ).to.not.equal( originalDeltasB );
-			expect( deltasA[ 0 ] ).to.not.equal( originalDeltasA[ 0 ] );
-			expect( deltasB[ 0 ] ).to.not.equal( originalDeltasB[ 0 ] );
+			it( 'should discard stickiness if additional context is used and delta with sticky move operation has been undone', () => {
+				// Assume following data model:	<paragraph>ab</paragraph><paragraph>cd</paragraph>
+				// Then we remove 'b':			<paragraph>a</paragraph><paragraph>cd</paragraph>
+				const removeDelta = getRemoveDelta( new Position( root, [ 0, 1 ] ), 1, baseVersion );
+				// Then we merge paragraphs:	<paragraph>acd</paragraph>
+				// Keep in mind that merging has sticky move operation ('cd' is sticky-moved to the first paragraph).
+				const mergeDelta = getMergeDelta( new Position( root, [ 1 ] ), 2, 1, baseVersion + 1 );
+				// Then we reverse merging:		<paragraph>a</paragraph><paragraph>cd</paragraph>
+				// Keep in mind that now we have two deltas with sticky moves in a history.
+				const splitDelta = mergeDelta.getReversed();
+
+				doc.history.addDelta( removeDelta );
+				doc.history.addDelta( mergeDelta );
+				doc.history.addDelta( splitDelta );
+
+				doc.history.setDeltaAsUndone( mergeDelta, splitDelta );
+
+				// This delta should insert 'b' at position [ 0, 1 ].
+				// However, without context data, after transforming `reinsertDelta` by `splitDelta`,
+				// 'b' would "go away" together with 'cd'.
+				const reinsertDelta = removeDelta.getReversed();
+
+				const { deltasA } = transformDeltaSets( [ reinsertDelta ], [ mergeDelta, splitDelta ], doc );
+
+				expectDelta( deltasA[ 0 ], {
+					type: MoveDelta,
+					operations: [
+						{
+							type: ReinsertOperation,
+							targetPosition: new Position( root, [ 0, 1 ] ),
+							howMany: 1,
+							baseVersion: 5
+						}
+					]
+				} );
+			} );
 		} );
 	} );
 } );

+ 7 - 6
packages/ckeditor5-engine/tests/model/delta/transform/unwrapdelta.js

@@ -28,13 +28,14 @@ import {
 } from '../../../../tests/model/delta/transform/_utils/utils';
 
 describe( 'transform', () => {
-	let doc, root, gy, baseVersion;
+	let doc, root, gy, baseVersion, context;
 
 	beforeEach( () => {
 		doc = getFilledDocument();
 		root = doc.getRoot();
 		gy = doc.graveyard;
 		baseVersion = doc.version;
+		context = { isStrong: false };
 	} );
 
 	describe( 'UnwrapDelta by', () => {
@@ -49,7 +50,7 @@ describe( 'transform', () => {
 				const splitPosition = new Position( root, [ 3, 3, 3, 3 ] );
 				const splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
 
-				const transformed = transform( unwrapDelta, splitDelta );
+				const transformed = transform( unwrapDelta, splitDelta, context );
 
 				expect( transformed.length ).to.equal( 2 );
 
@@ -69,7 +70,7 @@ describe( 'transform', () => {
 							type: MoveOperation,
 							sourcePosition: new Position( root, [ 3, 3, 4 ] ),
 							howMany: 1,
-							targetPosition: new Position( gy, [ 0, 0 ] ),
+							targetPosition: new Position( gy, [ 0 ] ),
 							baseVersion: baseVersion + 1
 						}
 					]
@@ -89,7 +90,7 @@ describe( 'transform', () => {
 							type: MoveOperation,
 							sourcePosition: new Position( root, [ 3, 3, 15 ] ),
 							howMany: 1,
-							targetPosition: new Position( gy, [ 1, 0 ] ),
+							targetPosition: new Position( gy, [ 0 ] ),
 							baseVersion: baseVersion + 3
 						}
 					]
@@ -110,7 +111,7 @@ describe( 'transform', () => {
 				const splitPosition = new Position( root, [ 3, 3, 3 ] );
 				const splitDelta = getSplitDelta( splitPosition, new Element( 'div' ), 1, baseVersion );
 
-				const transformed = transform( unwrapDelta, splitDelta );
+				const transformed = transform( unwrapDelta, splitDelta, context );
 
 				expect( transformed.length ).to.equal( 1 );
 
@@ -130,7 +131,7 @@ describe( 'transform', () => {
 							type: MoveOperation,
 							sourcePosition: new Position( root, [ 3, 4, 12 ] ),
 							howMany: 1,
-							targetPosition: new Position( gy, [ 0, 0 ] ),
+							targetPosition: new Position( gy, [ 0 ] ),
 							baseVersion: baseVersion + 1
 						}
 					]

+ 4 - 3
packages/ckeditor5-engine/tests/model/delta/transform/weakinsertdelta.js

@@ -26,12 +26,13 @@ import {
 } from '../../../../tests/model/delta/transform/_utils/utils';
 
 describe( 'transform', () => {
-	let doc, root, baseVersion;
+	let doc, root, baseVersion, context;
 
 	beforeEach( () => {
 		doc = getFilledDocument();
 		root = doc.getRoot();
 		baseVersion = doc.version;
+		context = { isStrong: false };
 	} );
 
 	describe( 'AttributeDelta by', () => {
@@ -57,7 +58,7 @@ describe( 'transform', () => {
 				const attrRange = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 3, 3, 9 ] ) );
 				const attrDelta = getAttributeDelta( attrRange, 'key', 'old', 'new', baseVersion );
 
-				const transformed = transform( insertDelta, attrDelta );
+				const transformed = transform( insertDelta, attrDelta, context );
 
 				expect( transformed.length ).to.equal( 2 );
 
@@ -109,7 +110,7 @@ describe( 'transform', () => {
 				const attrRange = new Range( new Position( root, [ 5 ] ), new Position( root, [ 7 ] ) );
 				const attrDelta = getAttributeDelta( attrRange, 'key', 'old', 'new', baseVersion );
 
-				const transformed = transform( insertDelta, attrDelta );
+				const transformed = transform( insertDelta, attrDelta, context );
 
 				expect( transformed.length ).to.equal( 1 );
 

+ 6 - 5
packages/ckeditor5-engine/tests/model/delta/transform/wrapdelta.js

@@ -29,13 +29,14 @@ import {
 } from '../../../../tests/model/delta/transform/_utils/utils';
 
 describe( 'transform', () => {
-	let doc, root, gy, baseVersion;
+	let doc, root, gy, baseVersion, context;
 
 	beforeEach( () => {
 		doc = getFilledDocument();
 		root = doc.getRoot();
 		gy = doc.graveyard;
 		baseVersion = doc.version;
+		context = { isStrong: false };
 	} );
 
 	describe( 'WrapDelta by', () => {
@@ -53,7 +54,7 @@ describe( 'transform', () => {
 				const splitPosition = new Position( root, [ 3, 3, 3, 3 ] );
 				const splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
 
-				const transformed = transform( wrapDelta, splitDelta );
+				const transformed = transform( wrapDelta, splitDelta, context );
 
 				expect( transformed.length ).to.equal( 2 );
 
@@ -73,7 +74,7 @@ describe( 'transform', () => {
 							type: MoveOperation,
 							sourcePosition: new Position( root, [ 3, 3, 4 ] ),
 							howMany: 1,
-							targetPosition: new Position( gy, [ 0, 0 ] ),
+							targetPosition: new Position( gy, [ 0 ] ),
 							baseVersion: baseVersion + 1
 						}
 					]
@@ -112,7 +113,7 @@ describe( 'transform', () => {
 				const splitPosition = new Position( root, [ 3, 3, 3, 1 ] );
 				const splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 11, baseVersion );
 
-				const transformed = transform( wrapDelta, splitDelta );
+				const transformed = transform( wrapDelta, splitDelta, context );
 
 				expect( transformed.length ).to.equal( 1 );
 
@@ -156,7 +157,7 @@ describe( 'transform', () => {
 				const splitPosition = new Position( root, [ 3, 3, 3, 3 ] );
 				const splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
 
-				const transformed = transform( wrapDelta, splitDelta );
+				const transformed = transform( wrapDelta, splitDelta, context );
 
 				expect( transformed.length ).to.equal( 1 );
 

+ 2 - 2
packages/ckeditor5-engine/tests/model/delta/unwrapdelta.js

@@ -91,7 +91,7 @@ describe( 'UnwrapDelta', () => {
 
 		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 ) );
+			unwrapDelta.operations.push( new RemoveOperation( new Position( root, [ 1, 6 ] ), 1, new Position( doc.graveyard, [ 0 ] ) ) );
 
 			expect( unwrapDelta.position.root ).to.equal( root );
 			expect( unwrapDelta.position.path ).to.deep.equal( [ 1, 2 ] );
@@ -108,7 +108,7 @@ describe( 'UnwrapDelta', () => {
 
 		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 ) );
+			unwrapDelta.operations.push( new RemoveOperation( new Position( root, [ 1, 6 ] ), 1, new Position( doc.graveyard, [ 0 ] ) ) );
 
 			const reversed = unwrapDelta.getReversed();
 

+ 3 - 5
packages/ckeditor5-engine/tests/model/document/change-event.js

@@ -83,7 +83,7 @@ describe( 'Document change event', () => {
 	it( 'should be fired when multiple nodes are removed and reinserted', () => {
 		root.insertChildren( 0, new Text( 'foo' ) );
 
-		const removeOperation = new RemoveOperation( new Position( root, [ 0 ] ), 3, doc.version );
+		const removeOperation = new RemoveOperation( new Position( root, [ 0 ] ), 3, new Position( doc.graveyard, [ 0 ] ), doc.version );
 		doc.applyOperation( wrapInDelta( removeOperation ) );
 
 		const reinsertOperation = removeOperation.getReversed();
@@ -91,15 +91,13 @@ describe( 'Document change event', () => {
 
 		expect( changes ).to.have.length( 2 );
 
-		const holderElement = graveyard.getChild( 0 );
-
 		expect( types[ 0 ] ).to.equal( 'remove' );
-		expect( changes[ 0 ].range ).to.deep.equal( Range.createFromParentsAndOffsets( holderElement, 0, holderElement, 3 ) );
+		expect( changes[ 0 ].range ).to.deep.equal( Range.createFromParentsAndOffsets( graveyard, 0, graveyard, 3 ) );
 		expect( changes[ 0 ].sourcePosition ).to.deep.equal( Position.createFromParentAndOffset( root, 0 ) );
 
 		expect( types[ 1 ] ).to.equal( 'reinsert' );
 		expect( changes[ 1 ].range ).to.deep.equal( Range.createFromParentsAndOffsets( root, 0, root, 3 ) );
-		expect( changes[ 1 ].sourcePosition ).to.deep.equal( Position.createFromParentAndOffset( holderElement, 0 ) );
+		expect( changes[ 1 ].sourcePosition ).to.deep.equal( Position.createFromParentAndOffset( graveyard, 0 ) );
 	} );
 
 	it( 'should be fired when attribute is inserted', () => {

+ 40 - 0
packages/ckeditor5-engine/tests/model/document/document.js

@@ -8,6 +8,8 @@ import Schema from '../../../src/model/schema';
 import RootElement from '../../../src/model/rootelement';
 import Batch from '../../../src/model/batch';
 import Delta from '../../../src/model/delta/delta';
+import NoOperation from '../../../src/model/operation/nooperation';
+import deltaTransform from '../../../src/model/delta/transform';
 import Range from '../../../src/model/range';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import count from '@ckeditor/ckeditor5-utils/src/count';
@@ -503,6 +505,44 @@ describe( 'Document', () => {
 		}
 	} );
 
+	describe( 'transformDeltas', () => {
+		it( 'should use deltaTransform.transformDeltaSets', () => {
+			sinon.spy( deltaTransform, 'transformDeltaSets' );
+
+			// Prepare some empty-ish deltas so the transformation won't throw an error.
+			const deltasA = [ new Delta() ];
+			deltasA[ 0 ].addOperation( new NoOperation( 0 ) );
+
+			const deltasB = [ new Delta() ];
+			deltasB[ 0 ].addOperation( new NoOperation( 0 ) );
+
+			doc.transformDeltas( deltasA, deltasB );
+
+			expect( deltaTransform.transformDeltaSets.calledOnce ).to.be.true;
+			expect( deltaTransform.transformDeltaSets.calledWith( deltasA, deltasB, null ) ).to.be.true;
+
+			deltaTransform.transformDeltaSets.restore();
+		} );
+
+		it( 'should pass itself to transformDeltaSets if useContext was set to true', () => {
+			sinon.spy( deltaTransform, 'transformDeltaSets' );
+
+			// Prepare some empty-ish deltas so the transformation won't throw an error.
+			const deltasA = [ new Delta() ];
+			deltasA[ 0 ].addOperation( new NoOperation( 0 ) );
+
+			const deltasB = [ new Delta() ];
+			deltasB[ 0 ].addOperation( new NoOperation( 0 ) );
+
+			doc.transformDeltas( deltasA, deltasB, true );
+
+			expect( deltaTransform.transformDeltaSets.calledOnce ).to.be.true;
+			expect( deltaTransform.transformDeltaSets.calledWith( deltasA, deltasB, doc ) ).to.be.true;
+
+			deltaTransform.transformDeltaSets.restore();
+		} );
+	} );
+
 	describe( '_getDefaultRoot()', () => {
 		it( 'should return graveyard root if there are no other roots in the document', () => {
 			expect( doc._getDefaultRoot() ).to.equal( doc.graveyard );

+ 50 - 70
packages/ckeditor5-engine/tests/model/history.js

@@ -67,23 +67,12 @@ describe( 'History', () => {
 	} );
 
 	describe( 'getDelta', () => {
-		it( 'should return array with one delta with given base version', () => {
+		it( 'should return delta with given base version', () => {
 			const delta = getDelta( 0 );
 			history.addDelta( delta );
 
 			const historyDelta = history.getDelta( 0 );
-			expect( historyDelta ).to.deep.equal( [ delta ] );
-		} );
-
-		it( 'should return array with all updated deltas of delta with given base version', () => {
-			const delta = getDelta( 0 );
-			history.addDelta( delta );
-
-			const deltas = getDeltaSet();
-			history.updateDelta( 0, deltas );
-
-			const historyDelta = history.getDelta( 0 );
-			expect( historyDelta ).to.deep.equal( deltas );
+			expect( historyDelta ).to.equal( delta );
 		} );
 
 		it( 'should return null if delta has not been found in history', () => {
@@ -91,14 +80,6 @@ describe( 'History', () => {
 			expect( history.getDelta( 2 ) ).to.be.null;
 			expect( history.getDelta( 20 ) ).to.be.null;
 		} );
-
-		it( 'should return null if delta has been removed by removeDelta', () => {
-			const delta = getDelta( 0 );
-			history.addDelta( delta );
-			history.removeDelta( 0 );
-
-			expect( history.getDelta( 0 ) ).to.be.null;
-		} );
 	} );
 
 	describe( 'getDeltas', () => {
@@ -134,79 +115,78 @@ describe( 'History', () => {
 		} );
 	} );
 
-	describe( 'updateDelta', () => {
-		it( 'should substitute delta from history by given deltas', () => {
-			history.addDelta( getDelta( 0 ) );
+	describe( 'isUndoingDelta', () => {
+		let undoing, undone;
 
-			const deltas = getDeltaSet();
-			history.updateDelta( 0, deltas );
+		beforeEach( () => {
+			undoing = new Delta();
+			undone = new Delta();
 
-			const historyDeltas = Array.from( history.getDeltas() );
-			expect( historyDeltas ).to.deep.equal( deltas );
-		} );
+			history.addDelta( undone );
+			history.addDelta( undoing );
 
-		it( 'should substitute all updated deltas by new deltas', () => {
-			history.addDelta( getDelta( 0 ) );
+			history.setDeltaAsUndone( undone, undoing );
+		} );
 
-			// Change original single delta to three deltas generated by `getDeltaSet`.
-			// All those deltas should now be seen under base version 0.
-			history.updateDelta( 0, getDeltaSet() );
+		it( 'should return true if delta is an undoing delta', () => {
+			expect( history.isUndoingDelta( undoing ) ).to.be.true;
+		} );
 
-			const deltas = getDeltaSet();
-			// Change all three deltas from base version 0 to new set of deltas.
-			history.updateDelta( 0, deltas );
+		it( 'should return false if delta is not an undoing delta', () => {
+			const delta = new Delta();
 
-			const historyDeltas = Array.from( history.getDeltas() );
-			expect( historyDeltas ).to.deep.equal( deltas );
+			expect( history.isUndoingDelta( undone ) ).to.be.false;
+			expect( history.isUndoingDelta( delta ) ).to.be.false;
 		} );
+	} );
 
-		it( 'should do nothing if deltas for given base version has not been found in history', () => {
-			history.addDelta( getDelta( 0 ) );
-			history.removeDelta( 0 );
+	describe( 'isUndoneDelta', () => {
+		let undoing, undone;
 
-			const deltas = getDeltaSet();
+		beforeEach( () => {
+			undoing = new Delta();
+			undone = new Delta();
 
-			history.updateDelta( 0, deltas );
+			history.addDelta( undone );
+			history.addDelta( undoing );
 
-			expect( Array.from( history.getDeltas() ).length ).to.equal( 0 );
+			history.setDeltaAsUndone( undone, undoing );
 		} );
-	} );
 
-	describe( 'removeDelta', () => {
-		it( 'should remove deltas that do not have graveyard related operations', () => {
-			for ( const delta of getDeltaSet() ) {
-				history.addDelta( delta );
-			}
+		it( 'should return true if delta has been set as undone', () => {
+			expect( history.isUndoneDelta( undone ) ).to.be.true;
+		} );
 
-			history.removeDelta( 3 );
+		it( 'should return false if delta has not been set as undone', () => {
+			const delta = new Delta();
 
-			const deltas = Array.from( history.getDeltas() );
-			expect( deltas.length ).to.equal( 2 );
+			expect( history.isUndoneDelta( undoing ) ).to.be.false;
+			expect( history.isUndoneDelta( delta ) ).to.be.false;
 		} );
+	} );
 
-		it( 'should remove multiple updated deltas', () => {
-			const delta = getDelta( 0 );
-			history.addDelta( delta );
+	describe( 'getUndoneDelta', () => {
+		let undoing, undone;
 
-			const updatedDeltas = getDeltaSet( 0 );
+		beforeEach( () => {
+			undoing = new Delta();
+			undone = new Delta();
 
-			history.updateDelta( 0, updatedDeltas );
-			history.removeDelta( 0 );
+			history.addDelta( undone );
+			history.addDelta( undoing );
 
-			const deltas = Array.from( history.getDeltas() );
-			expect( deltas.length ).to.equal( 0 );
+			history.setDeltaAsUndone( undone, undoing );
 		} );
 
-		it( 'should do nothing if deltas for given base version has not been found in history', () => {
-			const deltas = getDeltaSet();
-
-			for ( const delta of deltas ) {
-				history.addDelta( delta );
-			}
+		it( 'should return undone delta basing on undoing delta', () => {
+			expect( history.getUndoneDelta( undoing ) ).to.equal( undone );
+		} );
 
-			history.removeDelta( 12 );
+		it( 'should return undefined if given delta is not an undoing delta', () => {
+			const delta = new Delta();
 
-			expect( Array.from( history.getDeltas() ) ).to.deep.equal( deltas );
+			expect( history.getUndoneDelta( undone ) ).to.be.undefined;
+			expect( history.getUndoneDelta( delta ) ).to.be.undefined;
 		} );
 	} );
 } );

+ 26 - 0
packages/ckeditor5-engine/tests/model/liverange.js

@@ -88,6 +88,32 @@ describe( 'LiveRange', () => {
 		range.detach();
 	} );
 
+	it( 'should fire change event with proper data when it is changed', () => {
+		const live = new LiveRange( new Position( root, [ 0, 1, 4 ] ), new Position( root, [ 0, 2, 2 ] ) );
+		const copy = Range.createFromRange( live );
+
+		const spy = sinon.spy();
+		live.on( 'change', spy );
+
+		const moveSource = new Position( root, [ 2 ] );
+		const moveRange = new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 3 ] ) );
+
+		const changes = {
+			range: moveRange,
+			sourcePosition: moveSource
+		};
+		doc.fire( 'change', 'move', changes, null );
+
+		expect( spy.calledOnce ).to.be.true;
+
+		// First parameter available in event should be a range that is equal to the live range before the live range changed.
+		expect( spy.args[ 0 ][ 1 ].isEqual( copy ) ).to.be.true;
+
+		// Second parameter is an object with data about model changes that caused the live range to change.
+		expect( spy.args[ 0 ][ 2 ].range.isEqual( moveRange ) ).to.be.true;
+		expect( spy.args[ 0 ][ 2 ].sourcePosition.isEqual( moveSource ) ).to.be.true;
+	} );
+
 	// Examples may seem weird when you compare them with the tree structure generated at the beginning of tests.
 	// Since change event is fired _after_ operation is executed on tree model, you have to imagine that generated
 	// structure is representing what is _after_ operation is executed. So live LiveRange properties are describing

+ 3 - 0
packages/ckeditor5-engine/tests/model/liveselection.js

@@ -635,6 +635,7 @@ describe( 'LiveSelection', () => {
 					new RemoveOperation(
 						new Position( root, [ 1, 2 ] ),
 						2,
+						new Position( doc.graveyard, [ 0 ] ),
 						doc.version
 					)
 				) );
@@ -649,6 +650,7 @@ describe( 'LiveSelection', () => {
 					new RemoveOperation(
 						new Position( root, [ 1, 2 ] ),
 						2,
+						new Position( doc.graveyard, [ 0 ] ),
 						doc.version
 					)
 				) );
@@ -663,6 +665,7 @@ describe( 'LiveSelection', () => {
 					new RemoveOperation(
 						new Position( root, [ 1 ] ),
 						2,
+						new Position( doc.graveyard, [ 0 ] ),
 						doc.version
 					)
 				) );

+ 13 - 13
packages/ckeditor5-engine/tests/model/operation/reinsertoperation.js

@@ -20,7 +20,7 @@ describe( 'ReinsertOperation', () => {
 		root = doc.createRoot();
 		graveyard = doc.graveyard;
 
-		graveyardPosition = new Position( graveyard, [ 0, 0 ] );
+		graveyardPosition = new Position( graveyard, [ 0 ] );
 		rootPosition = new Position( root, [ 0 ] );
 
 		operation = new ReinsertOperation(
@@ -61,9 +61,8 @@ describe( 'ReinsertOperation', () => {
 		expect( clone.baseVersion ).to.equal( operation.baseVersion );
 	} );
 
-	it( 'should create a correct RemoveOperation as a reverse', () => {
-		// Test reversed operation's target position.
-		graveyard.appendChildren( new Element( '$graveyardHolder' ) );
+	it( 'should create RemoveOperation as a reverse', () => {
+		graveyard.appendChildren( new Element( 'x' ) );
 
 		const reverse = operation.getReversed();
 
@@ -71,32 +70,33 @@ describe( 'ReinsertOperation', () => {
 		expect( reverse.baseVersion ).to.equal( 1 );
 		expect( reverse.howMany ).to.equal( 2 );
 		expect( reverse.sourcePosition.isEqual( rootPosition ) ).to.be.true;
-
-		// Reversed `ReinsertOperation` should target back to the same graveyard holder.
 		expect( reverse.targetPosition.isEqual( graveyardPosition ) ).to.be.true;
+	} );
+
+	it( 'should create correct RemoveOperation when reversed if target position was in graveyard', () => {
+		const operation = new ReinsertOperation( new Position( doc.graveyard, [ 0 ] ), 1, new Position( doc.graveyard, [ 3 ] ), 0 );
+		const reverse = operation.getReversed();
 
-		// Reversed `ReinsertOperation` should not create new graveyard holder.
-		expect( reverse._needsHolderElement ).to.be.false;
+		expect( reverse.sourcePosition.path ).to.deep.equal( [ 2 ] );
+		expect( reverse.targetPosition.path ).to.deep.equal( [ 0 ] );
 	} );
 
 	it( 'should undo reinsert set of nodes by applying reverse operation', () => {
 		const reverse = operation.getReversed();
 
-		const element = new Element();
-		element.insertChildren( 0, new Text( 'xx' ) );
-		graveyard.insertChildren( 0, element );
+		graveyard.insertChildren( 0, new Text( 'xx' ) );
 
 		doc.applyOperation( wrapInDelta( operation ) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.maxOffset ).to.equal( 2 );
-		expect( element.maxOffset ).to.equal( 0 );
+		expect( graveyard.maxOffset ).to.equal( 0 );
 
 		doc.applyOperation( wrapInDelta( reverse ) );
 
 		expect( doc.version ).to.equal( 2 );
 		expect( root.maxOffset ).to.equal( 0 );
-		// Don't check `element` - nodes are moved to new holder element.
+		expect( graveyard.maxOffset ).to.equal( 2 );
 	} );
 
 	describe( 'toJSON', () => {

+ 30 - 43
packages/ckeditor5-engine/tests/model/operation/removeoperation.js

@@ -25,6 +25,7 @@ describe( 'RemoveOperation', () => {
 		const op = new RemoveOperation(
 			new Position( root, [ 2 ] ),
 			2,
+			new Position( doc.graveyard, [ 0 ] ),
 			doc.version
 		);
 
@@ -35,6 +36,7 @@ describe( 'RemoveOperation', () => {
 		const op = new RemoveOperation(
 			new Position( root, [ 2 ] ),
 			2,
+			new Position( doc.graveyard, [ 0 ] ),
 			doc.version
 		);
 
@@ -45,19 +47,21 @@ describe( 'RemoveOperation', () => {
 		const operation = new RemoveOperation(
 			new Position( root, [ 2 ] ),
 			2,
+			new Position( doc.graveyard, [ 0 ] ),
 			doc.version
 		);
 
 		expect( operation ).to.be.instanceof( MoveOperation );
 	} );
 
-	it( 'should be able to remove set of nodes and append them to holder element in graveyard root', () => {
+	it( 'should be able to remove set of nodes and append them to graveyard root', () => {
 		root.insertChildren( 0, new Text( 'fozbar' ) );
 
 		doc.applyOperation( wrapInDelta(
 			new RemoveOperation(
 				new Position( root, [ 2 ] ),
 				2,
+				new Position( doc.graveyard, [ 0 ] ),
 				doc.version
 			)
 		) );
@@ -66,45 +70,26 @@ describe( 'RemoveOperation', () => {
 		expect( root.maxOffset ).to.equal( 4 );
 		expect( root.getChild( 0 ).data ).to.equal( 'foar' );
 
-		expect( graveyard.maxOffset ).to.equal( 1 );
-		expect( graveyard.getChild( 0 ).getChild( 0 ).data ).to.equal( 'zb' );
-	} );
-
-	it( 'should be able to remove set of nodes and append them to existing element in graveyard root', () => {
-		root.insertChildren( 0, new Text( 'fozbar' ) );
-		graveyard.appendChildren( new Element( '$graveyardHolder' ) );
-
-		const op = new RemoveOperation(
-			new Position( root, [ 0 ] ),
-			1,
-			doc.version
-		);
-
-		// Manually set holder element properties.
-		op._needsHolderElement = false;
-		op._holderElementOffset = 0;
-
-		doc.applyOperation( wrapInDelta( op ) );
-
-		expect( graveyard.maxOffset ).to.equal( 1 );
-		expect( graveyard.getChild( 0 ).getChild( 0 ).data ).to.equal( 'f' );
+		expect( graveyard.maxOffset ).to.equal( 2 );
+		expect( graveyard.getChild( 0 ).data ).to.equal( 'zb' );
 	} );
 
 	it( 'should create RemoveOperation with same parameters when cloned', () => {
 		const pos = new Position( root, [ 2 ] );
 
-		const operation = new RemoveOperation( pos, 2, doc.version );
+		const operation = new RemoveOperation( pos, 2, new Position( doc.graveyard, [ 0 ] ), doc.version );
 		const clone = operation.clone();
 
 		expect( clone ).to.be.instanceof( RemoveOperation );
 		expect( clone.sourcePosition.isEqual( pos ) ).to.be.true;
+		expect( clone.targetPosition.isEqual( operation.targetPosition ) ).to.be.true;
 		expect( clone.howMany ).to.equal( operation.howMany );
 		expect( clone.baseVersion ).to.equal( operation.baseVersion );
 	} );
 
-	it( 'should create a ReinsertOperation as a reverse', () => {
+	it( 'should create ReinsertOperation when reversed', () => {
 		const position = new Position( root, [ 0 ] );
-		const operation = new RemoveOperation( position, 2, 0 );
+		const operation = new RemoveOperation( position, 2, new Position( doc.graveyard, [ 0 ] ), 0 );
 		const reverse = operation.getReversed();
 
 		expect( reverse ).to.be.an.instanceof( ReinsertOperation );
@@ -114,9 +99,17 @@ describe( 'RemoveOperation', () => {
 		expect( reverse.targetPosition.isEqual( position ) ).to.be.true;
 	} );
 
+	it( 'should create correct ReinsertOperation when reversed if source range was in graveyard', () => {
+		const operation = new RemoveOperation( new Position( doc.graveyard, [ 2 ] ), 1, new Position( doc.graveyard, [ 0 ] ), 0 );
+		const reverse = operation.getReversed();
+
+		expect( reverse.sourcePosition.path ).to.deep.equal( [ 0 ] );
+		expect( reverse.targetPosition.path ).to.deep.equal( [ 3 ] );
+	} );
+
 	it( 'should undo remove set of nodes by applying reverse operation', () => {
 		const position = new Position( root, [ 0 ] );
-		const operation = new RemoveOperation( position, 3, 0 );
+		const operation = new RemoveOperation( position, 3, new Position( doc.graveyard, [ 0 ] ), 0 );
 		const reverse = operation.getReversed();
 
 		root.insertChildren( 0, new Text( 'bar' ) );
@@ -134,18 +127,17 @@ describe( 'RemoveOperation', () => {
 	} );
 
 	it( 'should properly remove a node that is already in a graveyard', () => {
-		doc.graveyard.appendChildren( new Element( '$graveyardHolder', {}, [ new Text( 'foo' ) ] ) );
+		doc.graveyard.appendChildren( [ new Element( 'x' ), new Element( 'y' ), new Element( 'z' ) ] );
 
-		const position = new Position( doc.graveyard, [ 0, 0 ] );
-		const operation = new RemoveOperation( position, 1, 0 );
-
-		operation.targetPosition.path = [ 0, 0 ];
+		const position = new Position( doc.graveyard, [ 2 ] );
+		const operation = new RemoveOperation( position, 1, new Position( doc.graveyard, [ 0 ] ), 0 );
 
 		doc.applyOperation( wrapInDelta( operation ) );
 
-		expect( doc.graveyard.childCount ).to.equal( 2 );
-		expect( doc.graveyard.getChild( 0 ).getChild( 0 ).data ).to.equal( 'f' );
-		expect( doc.graveyard.getChild( 1 ).getChild( 0 ).data ).to.equal( 'oo' );
+		expect( doc.graveyard.childCount ).to.equal( 3 );
+		expect( doc.graveyard.getChild( 0 ).name ).to.equal( 'z' );
+		expect( doc.graveyard.getChild( 1 ).name ).to.equal( 'x' );
+		expect( doc.graveyard.getChild( 2 ).name ).to.equal( 'y' );
 	} );
 
 	describe( 'toJSON', () => {
@@ -153,11 +145,10 @@ describe( 'RemoveOperation', () => {
 			const op = new RemoveOperation(
 				new Position( root, [ 2 ] ),
 				2,
+				new Position( doc.graveyard, [ 0 ] ),
 				doc.version
 			);
 
-			op._needsHolderElement = false;
-
 			const serialized = jsonParseStringify( op );
 
 			expect( serialized ).to.deep.equal( {
@@ -166,8 +157,7 @@ describe( 'RemoveOperation', () => {
 				howMany: 2,
 				isSticky: false,
 				sourcePosition: jsonParseStringify( op.sourcePosition ),
-				targetPosition: jsonParseStringify( op.targetPosition ),
-				_needsHolderElement: false
+				targetPosition: jsonParseStringify( op.targetPosition )
 			} );
 		} );
 	} );
@@ -177,13 +167,10 @@ describe( 'RemoveOperation', () => {
 			const op = new RemoveOperation(
 				new Position( root, [ 2 ] ),
 				2,
+				new Position( doc.graveyard, [ 0 ] ),
 				doc.version
 			);
 
-			op._needsHolderElement = false;
-
-			doc.graveyard.appendChildren( [ new Element( '$graveyardHolder' ), new Element( '$graveyardHolder' ) ] );
-
 			const serialized = jsonParseStringify( op );
 			const deserialized = RemoveOperation.fromJSON( serialized, doc );
 

+ 163 - 162
packages/ckeditor5-engine/tests/model/operation/transform.js

@@ -18,7 +18,6 @@ import MarkerOperation from '../../../src/model/operation/markeroperation';
 import MoveOperation from '../../../src/model/operation/moveoperation';
 import RemoveOperation from '../../../src/model/operation/removeoperation';
 import RenameOperation from '../../../src/model/operation/renameoperation';
-import ReinsertOperation from '../../../src/model/operation/reinsertoperation';
 import NoOperation from '../../../src/model/operation/nooperation';
 
 describe( 'transform', () => {
@@ -122,7 +121,34 @@ describe( 'transform', () => {
 					baseVersion
 				);
 
-				const transOp = transform( transformBy, true );
+				const transOp = transform( transformBy, { isStrong: true } );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target at same offset and context.insertBefore = true: no position update', () => {
+				const transformBy = new InsertOperation(
+					new Position( root, [ 0, 2, 2 ] ),
+					[ nodeC, nodeD ],
+					baseVersion
+				);
+
+				const transOp = transform( transformBy, { isStrong: false, insertBefore: false } );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target at same offset and context.insertBefore = false: increment offset', () => {
+				const transformBy = new InsertOperation(
+					new Position( root, [ 0, 2, 2 ] ),
+					[ nodeC, nodeD ],
+					baseVersion
+				);
+
+				const transOp = transform( op, transformBy );
+				expected.position.offset += 2;
 
 				expect( transOp.length ).to.equal( 1 );
 				expectOperation( transOp[ 0 ], expected );
@@ -299,7 +325,36 @@ describe( 'transform', () => {
 					baseVersion
 				);
 
-				const transOp = transform( op, transformBy, true );
+				const transOp = transform( op, transformBy, { isStrong: true } );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target offset same as insert position offset and context.insertBefore = true: increment offset', () => {
+				const transformBy = new MoveOperation(
+					new Position( root, [ 1, 1 ] ),
+					2,
+					new Position( root, [ 0, 2, 2 ] ),
+					baseVersion
+				);
+
+				const transOp = transform( op, transformBy, { isStrong: true, insertBefore: true } );
+				expected.position.offset += 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target offset same as insert position offset and context.insertBefore = false: no position update', () => {
+				const transformBy = new MoveOperation(
+					new Position( root, [ 1, 1 ] ),
+					2,
+					new Position( root, [ 0, 2, 2 ] ),
+					baseVersion
+				);
+
+				const transOp = transform( op, transformBy, { isStrong: false, insertBefore: false } );
 
 				expect( transOp.length ).to.equal( 1 );
 				expectOperation( transOp[ 0 ], expected );
@@ -610,7 +665,7 @@ describe( 'transform', () => {
 							baseVersion
 						);
 
-						const transOp = transform( op, transformBy, true );
+						const transOp = transform( op, transformBy, { isStrong: true } );
 
 						expected.oldValue = null;
 
@@ -626,7 +681,7 @@ describe( 'transform', () => {
 							baseVersion
 						);
 
-						const transOp = transform( op, transformBy, true );
+						const transOp = transform( op, transformBy, { isStrong: true } );
 
 						expect( transOp.length ).to.equal( 1 );
 						expectOperation( transOp[ 0 ], expected );
@@ -641,7 +696,7 @@ describe( 'transform', () => {
 							baseVersion
 						);
 
-						const transOp = transform( op, transformBy, true );
+						const transOp = transform( op, transformBy, { isStrong: true } );
 
 						expected.oldValue = null;
 
@@ -664,7 +719,7 @@ describe( 'transform', () => {
 							baseVersion
 						);
 
-						const transOp = transform( op, transformBy, true );
+						const transOp = transform( op, transformBy, { isStrong: true } );
 
 						expect( transOp.length ).to.equal( 2 );
 
@@ -690,7 +745,7 @@ describe( 'transform', () => {
 							baseVersion
 						);
 
-						const transOp = transform( op, transformBy, true );
+						const transOp = transform( op, transformBy, { isStrong: true } );
 
 						expect( transOp.length ).to.equal( 2 );
 
@@ -715,7 +770,7 @@ describe( 'transform', () => {
 							baseVersion
 						);
 
-						const transOp = transform( op, transformBy, true );
+						const transOp = transform( op, transformBy, { isStrong: true } );
 
 						expect( transOp.length ).to.equal( 3 );
 
@@ -1410,33 +1465,35 @@ describe( 'transform', () => {
 				expected.range = new Range( start, end );
 			} );
 
-			it( 'remove operation inserted holder element before attribute operation range: increment path', () => {
+			it( 'remove operation inserted elements before attribute operation range: increment path', () => {
 				const transformBy = new RemoveOperation(
 					new Position( root, [ 0 ] ),
 					2,
+					new Position( doc.graveyard, [ 0 ] ),
 					baseVersion
 				);
 
-				transformBy.targetPosition.path = [ 0, 0 ];
+				transformBy.targetPosition.path = [ 0 ];
 
 				const transOp = transform( op, transformBy );
 
 				expect( transOp.length ).to.equal( 1 );
 
-				expected.range.start.path = [ 3, 0 ];
-				expected.range.end.path = [ 3, 4 ];
+				expected.range.start.path = [ 4, 0 ];
+				expected.range.end.path = [ 4, 4 ];
 
 				expectOperation( transOp[ 0 ], expected );
 			} );
 
-			it( 'remove operation inserted holder element after attribute operation range: do nothing', () => {
+			it( 'remove operation inserted elements after attribute operation range: do nothing', () => {
 				const transformBy = new RemoveOperation(
 					new Position( root, [ 0 ] ),
 					2,
+					new Position( doc.graveyard, [ 0 ] ),
 					baseVersion
 				);
 
-				transformBy.targetPosition.path = [ 4, 0 ];
+				transformBy.targetPosition.path = [ 4 ];
 
 				const transOp = transform( op, transformBy );
 
@@ -1573,7 +1630,7 @@ describe( 'transform', () => {
 					baseVersion
 				);
 
-				const transOp = transform( op, transformBy, true );
+				const transOp = transform( op, transformBy, { isStrong: true } );
 
 				expect( transOp.length ).to.equal( 1 );
 				expectOperation( transOp[ 0 ], expected );
@@ -1766,7 +1823,7 @@ describe( 'transform', () => {
 
 			it( 'target before node from move target position path: increment index on move target position path', () => {
 				const transformBy = new InsertOperation(
-					new Position( root, [ 3, 0 ] ),
+					new Position( root, [ 3, 3 ] ),
 					[ nodeA, nodeB ],
 					baseVersion
 				);
@@ -1814,22 +1871,35 @@ describe( 'transform', () => {
 					baseVersion
 				);
 
-				const transOp = transform( op, transformBy, true );
+				const transOp = transform( op, transformBy, { isStrong: true } );
 
 				expect( transOp.length ).to.equal( 1 );
 				expectOperation( transOp[ 0 ], expected );
 			} );
 
-			it( 'target offset same as move target offset: increment target offset', () => {
+			it( 'target offset same as move target offset and context.insertBefore = true: increment target offset', () => {
 				const transformBy = new InsertOperation(
-					new Position( root, [ 3, 3 ] ),
+					new Position( root, [ 3, 3, 3 ] ),
 					[ nodeA, nodeB ],
 					baseVersion
 				);
 
-				const transOp = transform( op, transformBy );
+				const transOp = transform( op, transformBy, { isStrong: true, insertBefore: true } );
 
-				expected.targetPosition.path[ 1 ] += 2;
+				expected.targetPosition.offset += 2;
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target offset same as move target offset and context.insertBefore = false: no operation update', () => {
+				const transformBy = new InsertOperation(
+					new Position( root, [ 3, 3, 3 ] ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				const transOp = transform( op, transformBy, { isStrong: false, insertBefore: false } );
 
 				expect( transOp.length ).to.equal( 1 );
 				expectOperation( transOp[ 0 ], expected );
@@ -1985,7 +2055,7 @@ describe( 'transform', () => {
 					baseVersion
 				);
 
-				const transOp = transform( op, transformBy, true );
+				const transOp = transform( op, transformBy, { isStrong: true } );
 
 				expect( transOp.length ).to.equal( 1 );
 				expectOperation( transOp[ 0 ], expected );
@@ -2347,7 +2417,7 @@ describe( 'transform', () => {
 					baseVersion
 				);
 
-				const transOp = transform( op, transformBy, true );
+				const transOp = transform( op, transformBy, { isStrong: true } );
 
 				expected.sourcePosition.path = [ 4, 1, 0 ];
 
@@ -2380,7 +2450,7 @@ describe( 'transform', () => {
 					baseVersion
 				);
 
-				const transOp = transform( op, transformBy, true );
+				const transOp = transform( op, transformBy, { isStrong: true } );
 
 				expected.sourcePosition.path = [ 4, 1, 1 ];
 
@@ -2418,7 +2488,7 @@ describe( 'transform', () => {
 					baseVersion
 				);
 
-				const transOp = transform( op, transformBy, true );
+				const transOp = transform( op, transformBy, { isStrong: true } );
 
 				expect( transOp.length ).to.equal( 1 );
 
@@ -2456,7 +2526,7 @@ describe( 'transform', () => {
 					baseVersion
 				);
 
-				const transOp = transform( op, transformBy, true );
+				const transOp = transform( op, transformBy, { isStrong: true } );
 
 				expect( transOp.length ).to.equal( 2 );
 
@@ -2495,7 +2565,7 @@ describe( 'transform', () => {
 					baseVersion
 				);
 
-				const transOp = transform( op, transformBy, true );
+				const transOp = transform( op, transformBy, { isStrong: true } );
 
 				expect( transOp.length ).to.equal( 2 );
 
@@ -2540,7 +2610,7 @@ describe( 'transform', () => {
 					baseVersion
 				);
 
-				const transOp = transform( op, transformBy, true );
+				const transOp = transform( op, transformBy, { isStrong: true } );
 
 				expect( transOp.length ).to.equal( 1 );
 
@@ -2574,7 +2644,7 @@ describe( 'transform', () => {
 					baseVersion
 				);
 
-				const transOp = transform( op, transformBy, true );
+				const transOp = transform( op, transformBy, { isStrong: true } );
 
 				expect( transOp.length ).to.equal( 2 );
 
@@ -2599,7 +2669,7 @@ describe( 'transform', () => {
 					baseVersion
 				);
 
-				const transOp = transform( op, transformBy, true );
+				const transOp = transform( op, transformBy, { isStrong: true } );
 
 				expect( transOp.length ).to.equal( 2 );
 
@@ -2638,7 +2708,7 @@ describe( 'transform', () => {
 				expectOperation( transOp[ 0 ], expected );
 			} );
 
-			it( 'range inside transforming range and is important: shrink range', () => {
+			it( 'range inside transforming range and is important: split into two operations', () => {
 				op.howMany = 4;
 
 				const transformBy = new MoveOperation(
@@ -2650,14 +2720,21 @@ describe( 'transform', () => {
 
 				const transOp = transform( op, transformBy );
 
-				expect( transOp.length ).to.equal( 1 );
+				expect( transOp.length ).to.equal( 2 );
 
-				expected.howMany = 2;
+				expected.howMany = 1;
+				expected.sourcePosition.path = [ 2, 2, 5 ];
 
 				expectOperation( transOp[ 0 ], expected );
+
+				expected.howMany = 1;
+				expected.sourcePosition.path = [ 2, 2, 4 ];
+				expected.baseVersion++;
+
+				expectOperation( transOp[ 1 ], expected );
 			} );
 
-			it( 'range inside transforming range and is less important: split into two operations', () => {
+			it( 'range inside transforming range and is less important: split into three operations', () => {
 				op.howMany = 4;
 
 				const transformBy = new MoveOperation(
@@ -2667,20 +2744,26 @@ describe( 'transform', () => {
 					baseVersion
 				);
 
-				const transOp = transform( op, transformBy, true );
+				const transOp = transform( op, transformBy, { isStrong: true } );
 
-				expect( transOp.length ).to.equal( 2 );
+				expect( transOp.length ).to.equal( 3 );
 
-				expected.sourcePosition.path = [ 4, 1, 0 ];
-				expected.howMany = 2;
+				expected.sourcePosition.path = [ 2, 2, 5 ];
+				expected.howMany = 1;
 
 				expectOperation( transOp[ 0 ], expected );
 
-				expected.sourcePosition.path = [ 2, 2, 4 ];
+				expected.sourcePosition.path = [ 4, 1, 0 ];
 				expected.howMany = 2;
 				expected.baseVersion++;
 
 				expectOperation( transOp[ 1 ], expected );
+
+				expected.sourcePosition.path = [ 2, 2, 4 ];
+				expected.howMany = 1;
+				expected.baseVersion++;
+
+				expectOperation( transOp[ 2 ], expected );
 			} );
 
 			it( 'range and target inside transforming range and is important: no operation update', () => {
@@ -2712,7 +2795,7 @@ describe( 'transform', () => {
 					baseVersion
 				);
 
-				const transOp = transform( op, transformBy, true );
+				const transOp = transform( op, transformBy, { isStrong: true } );
 
 				expect( transOp.length ).to.equal( 1 );
 
@@ -2722,6 +2805,40 @@ describe( 'transform', () => {
 			} );
 		} );
 
+		describe( 'by RemoveOperation', () => {
+			let transformBy;
+
+			beforeEach( () => {
+				transformBy = new RemoveOperation(
+					Position.createFromPosition( op.sourcePosition ),
+					op.howMany,
+					new Position( doc.graveyard, [ 0 ] ),
+					baseVersion
+				);
+			} );
+
+			it( 'should skip context.isStrong and be less important than RemoveOperation', () => {
+				const transOp = transform( op, transformBy, { isStrong: true } );
+
+				expect( transOp.length ).to.equal( 1 );
+
+				expectOperation( transOp[ 0 ], {
+					type: NoOperation,
+					baseVersion: baseVersion + 1
+				} );
+			} );
+
+			it( 'should use context.isStrong if forceWeakRemove flag is used', () => {
+				const transOp = transform( op, transformBy, { isStrong: true, forceWeakRemove: true } );
+
+				expect( transOp.length ).to.equal( 1 );
+
+				expected.sourcePosition = transformBy.targetPosition;
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
+
 		describe( 'by NoOperation', () => {
 			it( 'no operation update', () => {
 				const transformBy = new NoOperation( baseVersion );
@@ -2758,48 +2875,9 @@ describe( 'transform', () => {
 	} );
 
 	describe( 'RemoveOperation', () => {
-		describe( 'by InsertOperation', () => {
-			it( 'should not need new graveyard holder if original operation did not needed it either', () => {
-				const op = new RemoveOperation( new Position( root, [ 1 ] ), 1, baseVersion );
-				op._needsHolderElement = false;
-
-				const transformBy = new InsertOperation( new Position( root, [ 0 ] ), [ new Node() ], baseVersion );
-
-				const transOp = transform( op, transformBy )[ 0 ];
-
-				expect( transOp._needsHolderElement ).to.be.false;
-			} );
-		} );
-
 		describe( 'by MoveOperation', () => {
-			it( 'should create not more than one RemoveOperation that needs new graveyard holder', () => {
-				const op = new RemoveOperation( new Position( root, [ 1 ] ), 4, baseVersion );
-				const transformBy = new MoveOperation( new Position( root, [ 0 ] ), 2, new Position( root, [ 8 ] ), baseVersion );
-
-				const transOp = transform( op, transformBy );
-
-				expect( transOp.length ).to.equal( 2 );
-
-				expect( transOp[ 0 ]._needsHolderElement ).to.be.true;
-				expect( transOp[ 1 ]._needsHolderElement ).to.be.false;
-			} );
-
-			it( 'should not need new graveyard holder if original operation did not needed it either', () => {
-				const op = new RemoveOperation( new Position( root, [ 1 ] ), 4, baseVersion );
-				op._needsHolderElement = false;
-
-				const transformBy = new MoveOperation( new Position( root, [ 0 ] ), 2, new Position( root, [ 8 ] ), baseVersion );
-
-				const transOp = transform( op, transformBy );
-
-				expect( transOp.length ).to.equal( 2 );
-
-				expect( transOp[ 0 ]._needsHolderElement ).to.be.false;
-				expect( transOp[ 1 ]._needsHolderElement ).to.be.false;
-			} );
-
 			it( 'should force removing content even if was less important', () => {
-				const op = new RemoveOperation( new Position( root, [ 8 ] ), 2, baseVersion );
+				const op = new RemoveOperation( new Position( root, [ 8 ] ), 2, new Position( doc.graveyard, [ 0 ] ), baseVersion );
 
 				const targetPosition = Position.createFromPosition( op.targetPosition );
 
@@ -2807,7 +2885,7 @@ describe( 'transform', () => {
 
 				const sourcePosition = Position.createFromPosition( transformBy.targetPosition );
 
-				const transOp = transform( op, transformBy, false );
+				const transOp = transform( op, transformBy );
 
 				expect( transOp.length ).to.equal( 1 );
 
@@ -2820,83 +2898,6 @@ describe( 'transform', () => {
 				} );
 			} );
 		} );
-
-		describe( 'by RemoveOperation', () => {
-			it( 'removes same nodes and transformed is weak: change howMany to 0', () => {
-				const position = new Position( root, [ 2, 1 ] );
-				const op = new RemoveOperation( position, 3, baseVersion );
-				const transformBy = new RemoveOperation( position, 3, baseVersion );
-
-				const transOp = transform( op, transformBy );
-
-				expect( transOp.length ).to.equal( 1 );
-				expectOperation( transOp[ 0 ], {
-					type: RemoveOperation,
-					howMany: 0,
-					sourcePosition: new Position( doc.graveyard, [ 0, 0 ] ),
-					targetPosition: new Position( doc.graveyard, [ 0, 0 ] ),
-					baseVersion: baseVersion + 1
-				} );
-			} );
-
-			it( 'removes same nodes and transformed is strong: change source position', () => {
-				const position = new Position( root, [ 2, 1 ] );
-				const op = new RemoveOperation( position, 3, baseVersion );
-				const transformBy = new RemoveOperation( position, 3, baseVersion );
-
-				const transOp = transform( op, transformBy, true );
-
-				expect( transOp.length ).to.equal( 1 );
-				expectOperation( transOp[ 0 ], {
-					type: RemoveOperation,
-					howMany: 3,
-					sourcePosition: new Position( doc.graveyard, [ 0, 0 ] ),
-					targetPosition: new Position( doc.graveyard, [ 1, 0 ] ),
-					baseVersion: baseVersion + 1
-				} );
-			} );
-		} );
-	} );
-
-	describe( 'ReinsertOperation', () => {
-		describe( 'by RemoveOperation', () => {
-			it( 'should update sourcePosition if remove operation inserted holder offset before reinsert operation holder', () => {
-				const position = new Position( root, [ 2, 1 ] );
-				const transformBy = new RemoveOperation( position, 3, baseVersion );
-				const op = new ReinsertOperation( new Position( doc.graveyard, [ 0, 0 ] ), 3, position, baseVersion );
-
-				const transOp = transform( op, transformBy );
-
-				expect( transOp.length ).to.equal( 1 );
-
-				expectOperation( transOp[ 0 ], {
-					type: ReinsertOperation,
-					howMany: 3,
-					sourcePosition: new Position( doc.graveyard, [ 1, 0 ] ),
-					targetPosition: position,
-					baseVersion: baseVersion + 1
-				} );
-			} );
-
-			it( 'should not update sourcePosition if remove operation inserted holder offset after reinsert operation source', () => {
-				const position = new Position( root, [ 2, 1 ] );
-				const transformBy = new RemoveOperation( position, 3, baseVersion );
-				transformBy.targetPosition = new Position( doc.graveyard, [ 3, 0 ] );
-				const op = new ReinsertOperation( new Position( doc.graveyard, [ 2, 0 ] ), 3, position, baseVersion );
-
-				const transOp = transform( op, transformBy );
-
-				expect( transOp.length ).to.equal( 1 );
-
-				expectOperation( transOp[ 0 ], {
-					type: ReinsertOperation,
-					howMany: 3,
-					sourcePosition: new Position( doc.graveyard, [ 2, 0 ] ),
-					targetPosition: position,
-					baseVersion: baseVersion + 1
-				} );
-			} );
-		} );
 	} );
 
 	describe( 'NoOperation', () => {
@@ -3158,7 +3159,7 @@ describe( 'transform', () => {
 					baseVersion
 				);
 
-				const transOp = transform( op, transformBy, false );
+				const transOp = transform( op, transformBy );
 
 				expect( transOp.length ).to.equal( 1 );
 				expectOperation( transOp[ 0 ], {
@@ -3175,7 +3176,7 @@ describe( 'transform', () => {
 					baseVersion
 				);
 
-				const transOp = transform( op, transformBy, true );
+				const transOp = transform( op, transformBy, { isStrong: true } );
 
 				expect( transOp.length ).to.equal( 1 );
 
@@ -3470,7 +3471,7 @@ describe( 'transform', () => {
 				const anotherRange = Range.createFromParentsAndOffsets( root, 2, root, 2 );
 				const transformBy = new MarkerOperation( 'name', oldRange, anotherRange, doc.markers, baseVersion );
 
-				const transOp = transform( op, transformBy, true );
+				const transOp = transform( op, transformBy, { isStrong: true } );
 
 				expected.oldRange = anotherRange;
 

+ 27 - 0
packages/ckeditor5-engine/tests/model/range.js

@@ -390,6 +390,33 @@ describe( 'Range', () => {
 
 			expect( range.containsPosition( position ) ).to.be.true;
 		} );
+
+		it( 'should return true if ranges are equal and check is not strict', () => {
+			const otherRange = Range.createFromRange( range );
+
+			expect( range.containsRange( otherRange, true ) ).to.be.true;
+		} );
+
+		it( 'should return true if ranges start at the same position and check is not strict', () => {
+			const otherRange = new Range( range.start, new Position( root, [ 2 ] ) );
+
+			expect( range.containsRange( otherRange, true ) ).to.be.true;
+		} );
+
+		it( 'should return true if ranges end at the same position and check is not strict', () => {
+			const otherRange = new Range( new Position( root, [ 2 ] ), range.end );
+
+			expect( range.containsRange( otherRange, true ) ).to.be.true;
+		} );
+
+		it( 'should return false if given range is collapsed and starts or ends at another range boundary', () => {
+			expect( range.containsRange( new Range( range.start, range.start ) ) ).to.be.false;
+			expect( range.containsRange( new Range( range.end, range.end ) ) ).to.be.false;
+
+			// Collapsed range should always be checked in strict mode.
+			expect( range.containsRange( new Range( range.start, range.start ), true ) ).to.be.false;
+			expect( range.containsRange( new Range( range.end, range.end ), true ) ).to.be.false;
+		} );
 	} );
 
 	describe( '_getTransformedByInsertion', () => {

+ 26 - 0
packages/ckeditor5-engine/tests/view/range.js

@@ -301,6 +301,32 @@ describe( 'Range', () => {
 
 			expect( range.containsRange( otherRange ) ).to.be.true;
 		} );
+
+		it( 'should return true if ranges are equal and check is not strict', () => {
+			const otherRange = Range.createFromRange( range );
+
+			expect( range.containsRange( otherRange, true ) ).to.be.true;
+		} );
+
+		it( 'should return true if ranges start at the same position and check is not strict', () => {
+			const otherRange = new Range( range.start, afterX );
+
+			expect( range.containsRange( otherRange, true ) ).to.be.true;
+		} );
+
+		it( 'should return true if ranges end at the same position and check is not strict', () => {
+			const otherRange = new Range( beforeB, range.end );
+
+			expect( range.containsRange( otherRange, true ) ).to.be.true;
+		} );
+
+		it( 'should return false if given range is collapsed and starts or ends at another range boundary', () => {
+			expect( range.containsRange( new Range( range.start, range.start ) ) ).to.be.false;
+			expect( range.containsRange( new Range( range.end, range.end ) ) ).to.be.false;
+
+			expect( range.containsRange( new Range( range.start, range.start ), true ) ).to.be.false;
+			expect( range.containsRange( new Range( range.end, range.end ), true ) ).to.be.false;
+		} );
 	} );
 
 	describe( 'other range interaction', () => {