瀏覽代碼

Merge pull request #1498 from ckeditor/t/1488

Internal: Added `MergeOperation#howMany` and `SplitOperation#howMany`. Improvements in `Position` and `Range` transforming functions. Improvements in OT algorithms. Closes #1488. Closes #1492. Closes #1499.
Piotr Jasiun 7 年之前
父節點
當前提交
56cd2bda07

+ 14 - 4
packages/ckeditor5-engine/src/model/operation/mergeoperation.js

@@ -30,12 +30,13 @@ export default class MergeOperation extends Operation {
 	 *
 	 * @param {module:engine/model/position~Position} sourcePosition Position inside the merged element. All nodes from that
 	 * element after that position will be moved to {@link ~#targetPosition}.
+	 * @param {Number} howMany Summary offset size of nodes which will be moved from the merged element to the new parent.
 	 * @param {module:engine/model/position~Position} targetPosition Position which the nodes from the merged elements will be moved to.
 	 * @param {module:engine/model/position~Position} graveyardPosition Position in graveyard to which the merged element will be moved.
 	 * @param {Number|null} baseVersion Document {@link module:engine/model/document~Document#version} on which operation
 	 * can be applied or `null` if the operation operates on detached (non-document) tree.
 	 */
-	constructor( sourcePosition, targetPosition, graveyardPosition, baseVersion ) {
+	constructor( sourcePosition, howMany, targetPosition, graveyardPosition, baseVersion ) {
 		super( baseVersion );
 
 		/**
@@ -56,6 +57,8 @@ export default class MergeOperation extends Operation {
 		// is it? think about reversed split operations, undo, etc.
 
 		this.graveyardPosition = Position.createFromPosition( graveyardPosition );
+
+		this.howMany = howMany;
 	}
 
 	/**
@@ -94,7 +97,7 @@ export default class MergeOperation extends Operation {
 	 * @returns {module:engine/model/operation/mergeoperation~MergeOperation} Clone of this operation.
 	 */
 	clone() {
-		return new this.constructor( this.sourcePosition, this.targetPosition, this.graveyardPosition, this.baseVersion );
+		return new this.constructor( this.sourcePosition, this.howMany, this.targetPosition, this.graveyardPosition, this.baseVersion );
 	}
 
 	/**
@@ -103,7 +106,7 @@ export default class MergeOperation extends Operation {
 	 * @returns {module:engine/model/operation/splitoperation~SplitOperation}
 	 */
 	getReversed() {
-		return new SplitOperation( this.targetPosition, this.graveyardPosition, this.baseVersion + 1 );
+		return new SplitOperation( this.targetPosition, this.howMany, this.graveyardPosition, this.baseVersion + 1 );
 	}
 
 	/**
@@ -128,6 +131,13 @@ export default class MergeOperation extends Operation {
 			 * @error merge-operation-target-position-invalid
 			 */
 			throw new CKEditorError( 'merge-operation-target-position-invalid: Merge target position is invalid.' );
+		} else if ( this.howMany != sourceElement.maxOffset ) {
+			/**
+			 * Merge operation specifies wrong number of nodes to move.
+			 *
+			 * @error merge-operation-how-many-invalid
+			 */
+			throw new CKEditorError( 'merge-operation-how-many-invalid: Merge operation specifies wrong number of nodes to move.' );
 		}
 	}
 
@@ -161,6 +171,6 @@ export default class MergeOperation extends Operation {
 		const targetPosition = Position.fromJSON( json.targetPosition, document );
 		const graveyardPosition = Position.fromJSON( json.graveyardPosition, document );
 
-		return new this( sourcePosition, targetPosition, graveyardPosition, json.baseVersion );
+		return new this( sourcePosition, json.howMany, targetPosition, graveyardPosition, json.baseVersion );
 	}
 }

+ 14 - 4
packages/ckeditor5-engine/src/model/operation/splitoperation.js

@@ -26,12 +26,13 @@ export default class SplitOperation extends Operation {
 	 * Creates a split operation.
 	 *
 	 * @param {module:engine/model/position~Position} position Position at which an element should be split.
+	 * @param {Number}
 	 * @param {module:engine/model/position~Position|null} graveyardPosition Position in graveyard before the element which
 	 * should be used as a parent of the nodes after `position`. If it is not set, a copy of the the `position` parent will be used.
 	 * @param {Number|null} baseVersion Document {@link module:engine/model/document~Document#version} on which operation
 	 * can be applied or `null` if the operation operates on detached (non-document) tree.
 	 */
-	constructor( position, graveyardPosition, baseVersion ) {
+	constructor( position, howMany, graveyardPosition, baseVersion ) {
 		super( baseVersion );
 
 		/**
@@ -47,6 +48,8 @@ export default class SplitOperation extends Operation {
 		if ( this.graveyardPosition ) {
 			this.graveyardPosition.stickiness = 'toNext';
 		}
+
+		this.howMany = howMany;
 	}
 
 	/**
@@ -104,7 +107,7 @@ export default class SplitOperation extends Operation {
 	 * @returns {module:engine/model/operation/splitoperation~SplitOperation} Clone of this operation.
 	 */
 	clone() {
-		return new this.constructor( this.position, this.graveyardPosition, this.baseVersion );
+		return new this.constructor( this.position, this.howMany, this.graveyardPosition, this.baseVersion );
 	}
 
 	/**
@@ -116,7 +119,7 @@ export default class SplitOperation extends Operation {
 		const graveyard = this.position.root.document.graveyard;
 		const graveyardPosition = new Position( graveyard, [ 0 ] );
 
-		return new MergeOperation( this.moveTargetPosition, this.position, graveyardPosition, this.baseVersion + 1 );
+		return new MergeOperation( this.moveTargetPosition, this.howMany, this.position, graveyardPosition, this.baseVersion + 1 );
 	}
 
 	/**
@@ -134,6 +137,13 @@ export default class SplitOperation extends Operation {
 			 * @error split-operation-position-invalid
 			 */
 			throw new CKEditorError( 'split-operation-position-invalid: Split position is invalid.' );
+		} else if ( this.howMany != element.maxOffset - this.position.offset ) {
+			/**
+			 * Split operation specifies wrong number of nodes to move.
+			 *
+			 * @error split-operation-how-many-invalid
+			 */
+			throw new CKEditorError( 'split-operation-how-many-invalid: Split operation specifies wrong number of nodes to move.' );
 		}
 	}
 
@@ -174,6 +184,6 @@ export default class SplitOperation extends Operation {
 		const position = Position.fromJSON( json.position, document );
 		const graveyardPosition = json.graveyardPosition ? Position.fromJSON( json.graveyardPosition, document ) : null;
 
-		return new this( position, graveyardPosition, json.baseVersion );
+		return new this( position, json.howMany, graveyardPosition, json.baseVersion );
 	}
 }

+ 314 - 192
packages/ckeditor5-engine/src/model/operation/transform.js

@@ -54,7 +54,9 @@ export function transform( a, b, context = {} ) {
 	const transformationFunction = getTransformation( a, b );
 
 	try {
-		return transformationFunction( a.clone(), b, context );
+		a = a.clone();
+
+		return transformationFunction( a, b, context );
 	} catch ( e ) {
 		log.error( 'Error during operation transformation!' );
 		log.error( 'Transformed operation', a );
@@ -77,6 +79,12 @@ export function transformSets( operationsA, operationsB, options ) {
 		return { operationsA, operationsB };
 	}
 
+	const nextTransformIndex = new WeakMap();
+
+	for ( const op of operationsA ) {
+		nextTransformIndex.set( op, 0 );
+	}
+
 	const data = {
 		nextBaseVersionA: operationsA[ operationsA.length - 1 ].baseVersion + 1,
 		nextBaseVersionB: operationsB[ operationsB.length - 1 ].baseVersion + 1,
@@ -86,70 +94,57 @@ export function transformSets( operationsA, operationsB, options ) {
 
 	const context = initializeContext( operationsA, operationsB, options );
 
-	// For each operation from `operationsA` set...
-	for ( let i = 0; i < operationsA.length; ) {
-		const opsA = [ operationsA[ i ] ];
-
-		// Get an operation from `operationsB` set, and transform them.
-		for ( let j = 0; j < operationsB.length; ) {
-			const opsB = [ operationsB[ j ] ];
-
-			// After transformation both operation `a` and `b` might be broken into two operations.
-			// When operation `a` is broken on two operations, it is needed to continue transforming both operations
-			// by operations from `operationsB` set. Hence, there is a need for additional loop, that will loop
-			// through all currently processed operation. For the same reason, additional loop is needed for operation
-			// `b` which may be broken into two operations after transforming by first operation from `opsA`.
-			for ( let k = 0; k < opsA.length; ) {
-				for ( let l = 0; l < opsB.length; ) {
-					const opA = opsA[ k ];
-					const opB = opsB[ l ];
-
-					if ( options.useContext ) {
-						updateRelations( context, opA, opB );
-					}
+	let i = 0;
 
-					const contextAB = {
-						aIsStrong: true,
-						aWasUndone: context.wasUndone( opA ),
-						bWasUndone: context.wasUndone( opB ),
-						abRelation: context.getRelation( opA, opB ),
-						baRelation: context.getRelation( opB, opA )
-					};
-
-					const contextBA = {
-						aIsStrong: false,
-						aWasUndone: context.wasUndone( opB ),
-						bWasUndone: context.wasUndone( opA ),
-						abRelation: context.getRelation( opB, opA ),
-						baRelation: context.getRelation( opA, opB )
-					};
-
-					const newOpA = transform( opA, opB, contextAB );
-					const newOpB = transform( opB, opA, contextBA );
-
-					if ( options.useContext ) {
-						updateOriginalOperation( context, opA, newOpA );
-						updateOriginalOperation( context, opB, newOpB );
-
-						updateRelations( context, opA, opB );
-					}
+	while ( i < operationsA.length ) {
+		const opA = operationsA[ i ];
 
-					opsA.splice( k, 1, ...newOpA );
-					k = k + newOpA.length;
+		const transformByIndex = nextTransformIndex.get( opA );
 
-					opsB.splice( l, 1, ...newOpB );
-					l = l + newOpB.length;
-				}
-			}
+		if ( transformByIndex == operationsB.length ) {
+			i++;
+			continue;
+		}
+
+		const opB = operationsB[ transformByIndex ];
 
-			operationsB.splice( j, 1, ...opsB );
-			j = j + opsB.length;
-			data.extraOpsB += opsB.length - 1;
+		if ( options.useContext ) {
+			updateRelations( context, opA, opB );
 		}
 
-		operationsA.splice( i, 1, ...opsA );
-		i = i + opsA.length;
-		data.extraOpsA += opsA.length - 1;
+		const contextAB = {
+			aIsStrong: true,
+			aWasUndone: context.wasUndone( opA ),
+			bWasUndone: context.wasUndone( opB ),
+			abRelation: context.getRelation( opA, opB ),
+			baRelation: context.getRelation( opB, opA )
+		};
+
+		const contextBA = {
+			aIsStrong: false,
+			aWasUndone: context.wasUndone( opB ),
+			bWasUndone: context.wasUndone( opA ),
+			abRelation: context.getRelation( opB, opA ),
+			baRelation: context.getRelation( opA, opB )
+		};
+
+		const newOpsA = transform( opA, opB, contextAB );
+		const newOpsB = transform( opB, opA, contextBA );
+
+		if ( options.useContext ) {
+			updateOriginalOperation( context, opA, newOpsA );
+			updateOriginalOperation( context, opB, newOpsB );
+		}
+
+		for ( const newOpA of newOpsA ) {
+			nextTransformIndex.set( newOpA, transformByIndex + newOpsB.length );
+		}
+
+		operationsA.splice( i, 1, ...newOpsA );
+		operationsB.splice( transformByIndex, 1, ...newOpsB );
+
+		data.extraOpsA += newOpsA.length - 1;
+		data.extraOpsB += newOpsB.length - 1;
 	}
 
 	if ( options.padWithNoOps ) {
@@ -224,6 +219,8 @@ function updateRelations( context, opA, opB ) {
 					if ( opA.targetPosition.isEqual( opB.sourcePosition ) || opB.movedRange.containsPosition( opA.targetPosition ) ) {
 						setRelation( context, opA, opB, 'insertAtSource' );
 						setRelation( context, opB, opA, 'splitBefore' );
+					} else if ( opA.targetPosition.isEqual( opB.deletionPosition ) ) {
+						setRelation( context, opA, opB, 'insertBetween' );
 					}
 
 					break;
@@ -294,6 +291,8 @@ function updateRelations( context, opA, opB ) {
 				case MergeOperation: {
 					if ( opA.position.isEqual( opB.sourcePosition ) || opB.movedRange.containsPosition( opA.position ) ) {
 						setRelation( context, opA, opB, 'insertAtSource' );
+					} else if ( opA.position.isEqual( opB.deletionPosition ) ) {
+						setRelation( context, opA, opB, 'insertBetween' );
 					}
 
 					break;
@@ -390,10 +389,10 @@ setTransformation( AttributeOperation, InsertOperation, ( a, b ) => {
 	//			operation range needs to be split. For text insertion, extra operation needs to be generated.
 	//
 	if ( a.range.start.hasSameParentAs( b.position ) && a.range.containsPosition( b.position ) ) {
-		// This will spread the range into two.
-		const ranges = a.range._getTransformedByInsertion( b.position, b.howMany, true );
-		const result = ranges.map( range => {
-			return new AttributeOperation( range, a.key, a.oldValue, a.newValue, a.baseVersion );
+		// This will return an array with a single, expanded range.
+		const range = a.range._getTransformedByInsertion( b.position, b.howMany, !b.shouldReceiveAttributes );
+		const result = range.map( r => {
+			return new AttributeOperation( r, a.key, a.oldValue, a.newValue, a.baseVersion );
 		} );
 
 		// `AttributeOperation#range` includes some newly inserted text.
@@ -408,39 +407,50 @@ setTransformation( AttributeOperation, InsertOperation, ( a, b ) => {
 		// Bold should be applied also on the new text:
 		// <p>Fo<$text bold=true>zxxb</$text>ar</p>
 		//
-		// Instead of expanding the attribute operation range, it is needed to create a new attribute operation.
-		// This is because the inserted text might have already an attribute applied and the `oldValue` property
-		// of the attribute operation might be wrong:
+		// There is a special case to consider here, which will dictate how the final solution will look like. The inserted
+		// text might have already an attribute applied and the `oldValue` property of the attribute operation might be wrong:
 		//
 		// Attribute `highlight="yellow"` should be applied on the following range:
 		// <p>Fo[zb]ar<p>
 		//
-		// New text with `highlight="red"` is typed:
+		// But before that, new text with `highlight="red"` is typed:
 		// <p>Fo[z<$text highlight="red">x</$text>a]r</p>
 		//
-		// In this case three operations are needed: `oldValue=null, newValue="yellow"` for `z`, `oldValue="red",
-		// newValue="yellow"` for `x` and `oldValue=null, newValue="yellow"` for `a`. It could even happen that
-		// there are multiple nodes with different `oldValue`s are inserted, so multiple new operations might be added.
+		// In this case we cannot simply apply operation changing the attribute value from `null` to `"yellow"` for the whole range
+		// because that would lead to an exception (`oldValue` is incorrect for `x`).
+		//
+		// We also cannot break the original range as this would mess up a scenario when there are multiple following insert operations.
+		//
+		// So, the attribute range will be expanded, no matter what attributes are set on the inserted nodes:
+		//
+		// <p>Fo[z<$text highlight="red">x</$text>a]r</p>		<--- Change from `null` to `yellow`, still throwing an exception.
+		//
+		// But before that operation would be applied, we will add an additional attribute operation that will change
+		// attributes on inserted nodes in a way which would make the original operation correct:
+		//
+		// <p>Fo[z{<$text highlight="red">}x</$text>a]r</p>		<--- Change `{}` from `red` to `null`.
+		// <p>Fo[zxa]r</p>										<--- Now change from `null` to `yellow` is completely fine.
 		//
 		if ( b.shouldReceiveAttributes ) {
-			result.splice( 1, 0, ..._getComplementaryAttributeOperations( a, b ) );
+			// Be sure to add those operations before the original operation.
+			result.unshift( ..._getComplementaryAttributeOperations( b, a.key, a.oldValue ) );
 		}
 		// If nodes should not receive new attribute, just leave the spread ranges as they are.
 		return result;
 	}
 
-	// If insert operation is not spreading the attribute operation range, simply transform the range.
-	a.range = a.range._getTransformedByInsertOperation( b )[ 0 ];
+	// If insert operation is not expanding the attribute operation range, simply transform the range.
+	a.range = a.range._getTransformedByInsertion( b.position, b.howMany, false )[ 0 ];
 
 	return [ a ];
 } );
 
-function _getComplementaryAttributeOperations( attributeOperation, insertOperation ) {
+function _getComplementaryAttributeOperations( insertOperation, key, newValue ) {
 	const nodes = insertOperation.nodes;
 	const result = [];
 
 	// At the beginning we store the attribute value from the first node.
-	let val = nodes.getNode( 0 ).getAttribute( attributeOperation.key );
+	let val = nodes.getNode( 0 ).getAttribute( key );
 
 	// This stores the last index of node list where the attribute value has changed.
 	// We need it to create separate `AttributeOperation`s for nodes with different attribute values.
@@ -451,14 +461,14 @@ function _getComplementaryAttributeOperations( attributeOperation, insertOperati
 
 	for ( let i = 1; i < nodes.length; i++ ) {
 		const node = nodes.getNode( i );
-		const nodeAttrVal = node.getAttribute( attributeOperation.key );
+		const nodeAttrVal = node.getAttribute( key );
 
 		// If previous node has different attribute value, we will create an operation to the point before current node.
 		// So all nodes with the same attributes up to this point will be included in one `AttributeOperation`.
 		if ( nodeAttrVal != val ) {
 			// New operation is created only when it is needed. If given node already has proper value for this
 			// attribute we simply skip it without adding a new operation.
-			if ( val != attributeOperation.newValue ) {
+			if ( val != newValue ) {
 				addOperation();
 			}
 
@@ -482,7 +492,7 @@ function _getComplementaryAttributeOperations( attributeOperation, insertOperati
 		);
 
 		result.push(
-			new AttributeOperation( range, attributeOperation.key, val, attributeOperation.newValue, 0 )
+			new AttributeOperation( range, key, val, newValue, 0 )
 		);
 	}
 }
@@ -522,52 +532,39 @@ setTransformation( AttributeOperation, MoveOperation, ( a, b ) => {
 } );
 
 function breakRangeByMoveOperation( range, moveOp, includeCommon ) {
-	let ranges;
-
-	const movedRange = Range.createFromPositionAndShift( moveOp.sourcePosition, moveOp.howMany );
-
-	if ( movedRange.containsRange( range, true ) ) {
-		ranges = [ ...range._getTransformedByMoveOperation( moveOp ) ];
-	} else if ( range.start.hasSameParentAs( moveOp.sourcePosition ) ) {
-		ranges = range.getDifference( movedRange );
+	let common = null;
+	let difference = [];
 
-		_flatMoveTransform( ranges, moveOp );
+	const moveRange = Range.createFromPositionAndShift( moveOp.sourcePosition, moveOp.howMany );
 
-		if ( includeCommon ) {
-			const common = range.getIntersection( movedRange );
-
-			if ( common ) {
-				ranges.push( ...common._getTransformedByMoveOperation( moveOp ) );
-			}
-		}
+	if ( moveRange.containsRange( range, true ) ) {
+		common = range;
+	} else if ( range.start.hasSameParentAs( moveRange.start ) ) {
+		difference = range.getDifference( moveRange );
+		common = includeCommon ? range.getIntersection( moveRange ) : null;
 	} else {
-		ranges = [ range ];
-
-		_flatMoveTransform( ranges, moveOp );
+		difference = [ range ];
 	}
 
-	return ranges;
-}
-
-function _flatMoveTransform( ranges, moveOp ) {
-	const targetPosition = moveOp.getMovedRangeStart();
+	const result = [];
 
-	for ( let i = 0; i < ranges.length; i++ ) {
-		let range = ranges[ i ];
+	for ( let diff of difference ) {
+		diff = diff._getTransformedByDeletion( moveOp.sourcePosition, moveOp.howMany );
 
-		if ( range.start.hasSameParentAs( moveOp.sourcePosition ) ) {
-			range = range._getTransformedByDeletion( moveOp.sourcePosition, moveOp.howMany );
-		}
+		const targetPosition = moveOp.getMovedRangeStart();
+		const spread = diff.start.hasSameParentAs( targetPosition );
+		diff = diff._getTransformedByInsertion( targetPosition, moveOp.howMany, spread );
 
-		if ( range.start.hasSameParentAs( targetPosition ) ) {
-			const result = range._getTransformedByInsertion( targetPosition, moveOp.howMany, true );
+		result.push( ...diff );
+	}
 
-			ranges.splice( i, 1, ...result );
-			i = i - 1 + result.length;
-		} else {
-			ranges[ i ] = range;
-		}
+	if ( common ) {
+		result.push(
+			common._getTransformedByMove( moveOp.sourcePosition, moveOp.targetPosition, moveOp.howMany, false )[ 0 ]
+		);
 	}
+
+	return result;
 }
 
 setTransformation( AttributeOperation, SplitOperation, ( a, b ) => {
@@ -687,8 +684,8 @@ setTransformation( AttributeOperation, UnwrapOperation, ( a, b ) => {
 setTransformation( InsertOperation, AttributeOperation, ( a, b ) => {
 	const result = [ a ];
 
-	if ( a.shouldReceiveAttributes && b.range.containsPosition( a.position ) ) {
-		result.push( ..._getComplementaryAttributeOperations( b, a ) );
+	if ( a.shouldReceiveAttributes && a.position.hasSameParentAs( b.range.start ) && b.range.containsPosition( a.position ) ) {
+		result.push( ..._getComplementaryAttributeOperations( a, b.key, b.newValue ) );
 	}
 
 	return result;
@@ -839,6 +836,10 @@ setTransformation( MarkerOperation, UnwrapOperation, ( a, b ) => {
 // -----------------------
 
 setTransformation( MergeOperation, InsertOperation, ( a, b ) => {
+	if ( a.sourcePosition.hasSameParentAs( b.position ) ) {
+		a.howMany += b.howMany;
+	}
+
 	a.sourcePosition = a.sourcePosition._getTransformedByInsertOperation( b );
 	a.targetPosition = a.targetPosition._getTransformedByInsertOperation( b );
 
@@ -855,6 +856,7 @@ setTransformation( MergeOperation, MergeOperation, ( a, b, context ) => {
 		path.push( 0 );
 
 		a.sourcePosition = new Position( b.graveyardPosition.root, path );
+		a.howMany = 0;
 
 		return [ a ];
 	}
@@ -864,6 +866,10 @@ setTransformation( MergeOperation, MergeOperation, ( a, b, context ) => {
 
 	// The default case.
 	//
+	if ( a.sourcePosition.hasSameParentAs( b.targetPosition ) ) {
+		a.howMany += b.howMany;
+	}
+
 	a.sourcePosition = a.sourcePosition._getTransformedByMergeOperation( b );
 	a.targetPosition = a.targetPosition._getTransformedByMergeOperation( b );
 
@@ -892,6 +898,14 @@ setTransformation( MergeOperation, MoveOperation, ( a, b, context ) => {
 		}
 	}
 
+	if ( a.sourcePosition.hasSameParentAs( b.targetPosition ) ) {
+		a.howMany += b.howMany;
+	}
+
+	if ( a.sourcePosition.hasSameParentAs( b.sourcePosition ) ) {
+		a.howMany -= b.howMany;
+	}
+
 	a.sourcePosition = a.sourcePosition._getTransformedByMoveOperation( b );
 	a.targetPosition = a.targetPosition._getTransformedByMoveOperation( b );
 
@@ -904,32 +918,69 @@ setTransformation( MergeOperation, MoveOperation, ( a, b, context ) => {
 
 setTransformation( MergeOperation, SplitOperation, ( a, b ) => {
 	if ( b.graveyardPosition ) {
+		if ( a.deletionPosition.isEqual( b.graveyardPosition ) ) {
+			a.howMany = b.howMany;
+		}
+
 		a.graveyardPosition = a.graveyardPosition._getTransformedByDeletion( b.graveyardPosition, 1 );
 	}
 
 	// Case 1:	Merge operation moves nodes to the place where split happens.
+	//			This is a classic situation when there are two paragraphs, and there is a split (enter) after the first
+	//			paragraph and there is a merge (delete) at the beginning of the second paragraph:
 	//
-	//			Split is after `Foo`, while merge is from `Bar` to the end of `Foo`:
-	//			<p>Foo</p><p>Bar</p>
+	//			<p>Foo{}</p><p>[]Bar</p>.
 	//
-	//			After split:
+	//			Split is after `Foo`, while merge is from `Bar` to the end of `Foo`.
+	//
+	//			State after split:
 	//			<p>Foo</p><p></p><p>Bar</p>
 	//
-	//			In this case, `Bar` should be merged to the new paragraph:
+	//			Now, `Bar` should be merged to the new paragraph:
 	//			<p>Foo</p><p>Bar</p>
 	//
+	//			Instead of merging it to the original paragraph:
+	//			<p>FooBar</p><p></p>
+	//
 	//			This means that `targetPosition` needs to be transformed. This is the default case though.
 	//			For example, if the split would be after `F`, `targetPosition` should also be transformed.
 	//
-	//			There is an exception though. It is when merge operation targets into inside of an element.
-	//			Such merge operation can be a result of merge x merge transformation, when merges are identical.
-	//			Such merge operation's source position is in graveyard and we will use that to recognize it
-	//			(although a more precise method would be more correct).
+	//			There are two exception, though, when we want to keep `targetPosition` as it was.
+	//
+	//			First exception is when the merge target position is inside an element (not at the end, as usual). This
+	//			happens when the merge operation earlier was transformed by "the same" merge operation. If merge operation
+	//			targets inside the element we want to keep the original target position (and not transform it) because
+	//			we have additional context telling us that we want to merge to the original element. We can check if the
+	//			merge operation points inside element by checking what is `SplitOperation#howMany`. Since merge target position
+	//			is same as split position, if `howMany` is non-zero, it means that the merge target position is inside an element.
+	//
+	//			Second exception is when the element to merge is in the graveyard and split operation uses it. In that case
+	//			if target position would be transformed, the merge operation would target at the source position:
+	//
+	//			root: <p>Foo</p>				graveyard: <p></p>
+	//
+	//			SplitOperation: root [ 0, 3 ] using graveyard [ 0 ] (howMany = 0)
+	//			MergeOperation: graveyard [ 0, 0 ] -> root [ 0, 3 ] (howMany = 0)
+	//
+	//			Since split operation moves the graveyard node back to the root, the merge operation source position changes.
+	//			We would like to merge from the empty <p> to the "Foo" <p>:
+	//
+	//			root: <p>Foo</p><p></p>			graveyard:
 	//
-	if ( a.targetPosition.isEqual( b.position ) && a.sourcePosition.root.rootName == '$graveyard' ) {
-		a.sourcePosition = a.sourcePosition._getTransformedBySplitOperation( b );
+	//			MergeOperation#sourcePosition = root [ 1, 0 ]
+	//
+	//			If `targetPosition` is transformed, it would become root [ 1, 0 ] as well. It has to be kept as it was.
+	//
+	if ( a.targetPosition.isEqual( b.position ) ) {
+		if ( b.howMany != 0 || ( b.graveyardPosition && a.deletionPosition.isEqual( b.graveyardPosition ) ) ) {
+			a.sourcePosition = a.sourcePosition._getTransformedBySplitOperation( b );
 
-		return [ a ];
+			return [ a ];
+		}
+	}
+
+	if ( a.sourcePosition.hasSameParentAs( b.position ) ) {
+		a.howMany = b.position.offset;
 	}
 
 	a.sourcePosition = a.sourcePosition._getTransformedBySplitOperation( b );
@@ -943,6 +994,10 @@ setTransformation( MergeOperation, WrapOperation, ( a, b ) => {
 		a.graveyardPosition = a.graveyardPosition._getTransformedByDeletion( b.graveyardPosition, 1 );
 	}
 
+	if ( a.sourcePosition.hasSameParentAs( b.position ) ) {
+		a.howMany = a.howMany + 1 - b.howMany;
+	}
+
 	// Case 1:	Wrap is with an element from graveyard, which also is merge operation target. This happens
 	//			in some undo scenarios. In this case, the target position needs to be properly transformed.
 	//
@@ -995,10 +1050,15 @@ setTransformation( MergeOperation, UnwrapOperation, ( a, b, context ) => {
 		path.push( 0 );
 
 		a.sourcePosition = new Position( b.graveyardPosition.root, path );
+		a.howMany = 0;
 
 		return [ a ];
 	}
 
+	if ( a.sourcePosition.hasSameParentAs( b.targetPosition ) ) {
+		a.howMany = a.howMany - 1 + b.howMany;
+	}
+
 	a.sourcePosition = a.sourcePosition._getTransformedByUnwrapOperation( b );
 	a.targetPosition = a.targetPosition._getTransformedByUnwrapOperation( b );
 
@@ -1038,17 +1098,18 @@ setTransformation( MoveOperation, MoveOperation, ( a, b, context ) => {
 	// Assign `context.aIsStrong` to a different variable, because the value may change during execution of
 	// this algorithm and we do not want to override original `context.aIsStrong` that will be used in later transformations.
 	let aIsStrong = context.aIsStrong;
+	let insertBefore = !context.aIsStrong;
 
 	if ( context.abRelation == 'insertBefore' ) {
-		aIsStrong = true;
+		insertBefore = true;
 	} else if ( context.abRelation == 'insertAfter' ) {
-		aIsStrong = false;
+		insertBefore = false;
 	}
 
 	// `a.targetPosition` could be affected by the `b` operation. We will transform it.
 	let newTargetPosition;
 
-	if ( a.targetPosition.isEqual( b.targetPosition ) && aIsStrong ) {
+	if ( a.targetPosition.isEqual( b.targetPosition ) && insertBefore ) {
 		newTargetPosition = a.targetPosition._getTransformedByDeletion(
 			b.sourcePosition,
 			b.howMany
@@ -1140,9 +1201,9 @@ setTransformation( MoveOperation, MoveOperation, ( a, b, context ) => {
 	//
 	// 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.type == 'remove' && b.type != 'remove' ) {
+	if ( a.type == 'remove' && b.type != 'remove' && !context.aWasUndone ) {
 		aIsStrong = true;
-	} else if ( a.type != 'remove' && b.type == 'remove' ) {
+	} else if ( a.type != 'remove' && b.type == 'remove' && !context.bWasUndone ) {
 		aIsStrong = false;
 	}
 
@@ -1255,10 +1316,13 @@ setTransformation( MoveOperation, SplitOperation, ( a, b, context ) => {
 
 	a.sourcePosition = transformed.start;
 	a.howMany = transformed.end.offset - transformed.start.offset;
-	a.targetPosition = newTargetPosition;
 
 	if ( a.targetPosition.isEqual( b.position ) && context.abRelation == 'insertAtSource' ) {
-		a.targetPosition = b.targetPosition;
+		a.targetPosition = b.moveTargetPosition;
+	} else if ( a.targetPosition.isEqual( b.insertionPosition ) && context.abRelation == 'insertBetween' ) {
+		a.targetPosition = a.targetPosition;
+	} else {
+		a.targetPosition = newTargetPosition;
 	}
 
 	return [ a ];
@@ -1284,7 +1348,14 @@ setTransformation( MoveOperation, MergeOperation, ( a, b, context ) => {
 			//			it would be a move operation that moves 0 nodes, so maybe it is better just to return no-op.
 			//
 			if ( a.howMany == 1 ) {
-				return getNoOp();
+				if ( !context.bWasUndone ) {
+					return getNoOp();
+				} else {
+					a.sourcePosition = Position.createFromPosition( b.graveyardPosition );
+					a.targetPosition = a.targetPosition._getTransformedByMergeOperation( b );
+
+					return [ a ];
+				}
 			}
 		}
 	}
@@ -1466,19 +1537,29 @@ setTransformation( RootAttributeOperation, RootAttributeOperation, ( a, b, conte
 
 setTransformation( SplitOperation, InsertOperation, ( a, b, context ) => {
 	if ( a.position.isEqual( b.position ) && context.baRelation == 'insertAtSource' ) {
+		a.howMany += b.howMany;
+
 		return [ a ];
 	}
 
+	if ( a.position.hasSameParentAs( b.position ) && a.position.offset < b.position.offset ) {
+		a.howMany += b.howMany;
+	}
+
 	a.position = a.position._getTransformedByInsertOperation( b );
 
 	return [ a ];
 } );
 
 setTransformation( SplitOperation, MergeOperation, ( a, b ) => {
+	if ( a.position.hasSameParentAs( b.targetPosition ) ) {
+		a.howMany += b.howMany;
+	}
+
 	a.position = a.position._getTransformedByMergeOperation( b );
 
 	if ( a.graveyardPosition ) {
-		a.graveyardPosition = a.graveyardPosition._getTransformedByInsertion( b.graveyardPosition, 1 );
+		a.graveyardPosition = a.graveyardPosition._getTransformedByMergeOperation( b );
 	}
 
 	return [ a ];
@@ -1507,12 +1588,20 @@ setTransformation( SplitOperation, MoveOperation, ( a, b, context ) => {
 	const rangeToMove = Range.createFromPositionAndShift( b.sourcePosition, b.howMany );
 
 	if ( a.position.hasSameParentAs( b.sourcePosition ) && rangeToMove.containsPosition( a.position ) ) {
+		const howManyRemoved = b.howMany - ( a.position.offset - b.sourcePosition.offset );
+		a.howMany -= howManyRemoved;
+
+		if ( a.position.hasSameParentAs( b.targetPosition ) && a.position.offset < b.targetPosition.offset ) {
+			a.howMany += b.howMany;
+		}
+
 		a.position = Position.createFromPosition( b.sourcePosition );
 
 		return [ a ];
 	}
 
 	if ( a.position.isEqual( b.targetPosition ) && context.abRelation == 'splitBefore' ) {
+		a.howMany += b.howMany;
 		a.position = a.position._getTransformedByDeletion( b.sourcePosition, b.howMany );
 
 		return [ a ];
@@ -1520,6 +1609,14 @@ setTransformation( SplitOperation, MoveOperation, ( a, b, context ) => {
 
 	// The default case.
 	//
+	if ( a.position.hasSameParentAs( b.sourcePosition ) && a.position.offset < b.sourcePosition.offset ) {
+		a.howMany -= b.howMany;
+	}
+
+	if ( a.position.hasSameParentAs( b.targetPosition ) && a.position.offset < b.targetPosition.offset ) {
+		a.howMany += b.howMany;
+	}
+
 	a.position = a.position._getTransformedByMoveOperation( b );
 
 	return [ a ];
@@ -1534,9 +1631,15 @@ setTransformation( SplitOperation, SplitOperation, ( a, b, context ) => {
 		if ( a.graveyardPosition && b.graveyardPosition && a.graveyardPosition.isEqual( b.graveyardPosition ) ) {
 			return getNoOp();
 		}
+
+		a.howMany = 0;
 	} else if ( a.position.isEqual( b.insertionPosition ) && context.abRelation == 'splitBefore' ) {
 		return [ a ];
 	} else {
+		if ( a.position.hasSameParentAs( b.position ) && a.position.offset < b.position.offset ) {
+			a.howMany -= b.howMany;
+		}
+
 		a.position = a.position._getTransformedBySplitOperation( b );
 	}
 
@@ -1567,7 +1670,12 @@ setTransformation( SplitOperation, WrapOperation, ( a, b, context ) => {
 
 	if ( a.position.isEqual( b.position ) && context.abRelation == 'splitInside' ) {
 		a.position = b.targetPosition;
+		a.howMany = b.howMany;
 	} else {
+		if ( a.position.hasSameParentAs( b.position ) && a.position.offset < b.position.offset ) {
+			a.howMany = a.howMany + 1 - b.howMany;
+		}
+
 		a.position = a.position._getTransformedByWrapOperation( b );
 	}
 
@@ -1586,7 +1694,12 @@ setTransformation( SplitOperation, UnwrapOperation, ( a, b, context ) => {
 		path.push( 0 );
 
 		a.position = new Position( b.graveyardPosition.root, path );
+		a.howMany = 0;
 	} else {
+		if ( a.position.hasSameParentAs( b.targetPosition ) && a.position.offset < b.targetPosition.offset ) {
+			a.howMany = a.howMany - 1 + b.howMany;
+		}
+
 		a.position = a.position._getTransformedByUnwrapOperation( b );
 	}
 
@@ -1648,14 +1761,20 @@ setTransformation( WrapOperation, MoveOperation, ( a, b, context ) => {
 		return [ a ];
 	}
 
-	const ranges = breakRangeByMoveOperation( a.wrappedRange, b, false );
+	const moveRange = Range.createFromPositionAndShift( b.sourcePosition, b.howMany );
+	const wrappedRange = a.wrappedRange;
 
-	return ranges.reverse().map( range => {
-		const howMany = range.end.offset - range.start.offset;
-		const elementOrGraveyardPosition = a.graveyardPosition ? a.graveyardPosition : a.element;
+	if ( moveRange.containsRange( wrappedRange, true ) ) {
+		a.position = a.position._getCombined( b.sourcePosition, b.getMovedRangeStart() );
+	} else {
+		let transformed = wrappedRange._getTransformedByDeletion( b.sourcePosition, b.howMany );
+		transformed = transformed._getTransformedByInsertion( b.targetPosition, b.howMany, false )[ 0 ];
 
-		return new WrapOperation( range.start, howMany, elementOrGraveyardPosition, 0 );
-	} );
+		a.position = transformed.start;
+		a.howMany = transformed.end.offset - transformed.start.offset;
+	}
+
+	return [ a ];
 } );
 
 setTransformation( WrapOperation, SplitOperation, ( a, b, context ) => {
@@ -1776,23 +1895,53 @@ setTransformation( WrapOperation, WrapOperation, ( a, b, context ) => {
 			return [ a ];
 		}
 		// Ranges intersect.
-		else if ( ranges.length == 1 ) {
-			if ( context.aIsStrong ) {
-				// If the incoming wrap operation is strong, we need to reverse the previous wrap, then apply the incoming
-				// operation as is, then re-wrap the other nodes that were wrapped in the previous wrap.
-				//
-				// Content already wrapped into `blockQuote` but that wrap is not strong:
-				// <blockQuote><p>Foo</p><p>Bar</p></blockQuote><p>Xyz</p>
-				//
-				// Unwrap:
-				// <p>Foo</p><p>Bar</p><p>Xyz</p>
-				//
-				// Wrap with stronger wrap (`a`):
-				// <p>Foo</p><div><p>Bar</p><p>Xyz</p></div>
-				//
-				// Re-wrap:
-				// <blockQuote><p>Foo</p></blockQuote><div><p>Bar</p><p>Xyz</p></div>
-				//
+		else {
+			// Range from `b` has some extra nodes other than nodes from `a`.
+			if ( !a.wrappedRange.containsRange( b.wrappedRange, true ) ) {
+				if ( context.aIsStrong ) {
+					// If the incoming wrap operation is strong, we need to reverse the previous wrap, then apply the incoming
+					// operation as is, then re-wrap the other nodes that were wrapped in the previous wrap.
+					//
+					// Content already wrapped into `blockQuote` but that wrap is not strong:
+					// <blockQuote><p>Foo</p><p>Bar</p></blockQuote><p>Xyz</p>
+					//
+					// Unwrap:
+					// <p>Foo</p><p>Bar</p><p>Xyz</p>
+					//
+					// Wrap with stronger wrap:
+					// <p>Foo</p><div><p>Bar</p><p>Xyz</p></div>
+					//
+					// Re-wrap:
+					// <blockQuote><p>Foo</p></blockQuote><div><p>Bar</p><p>Xyz</p></div>
+					//
+					const reversed = b.getReversed();
+
+					// Unwrap operation (reversed wrap) always puts a node into a graveyard. Not every wrap operation pulls a node
+					// from the graveyard, though. This means that after reversing a wrap operation, there might be a need to
+					// update a position in graveyard.
+					if ( !b.graveyardPosition && a.graveyardPosition ) {
+						a.graveyardPosition = a.graveyardPosition._getTransformedByInsertion( reversed.graveyardPosition, 1 );
+					}
+
+					const bOnlyRange = b.wrappedRange.getDifference( a.wrappedRange )[ 0 ];
+					const rewrapRange = bOnlyRange._getTransformedByWrapOperation( a );
+					const rewrapHowMany = rewrapRange.end.offset - rewrapRange.start.offset;
+					const rewrap = new WrapOperation( rewrapRange.start, rewrapHowMany, reversed.graveyardPosition, 0 );
+
+					return [ reversed, a, rewrap ];
+				} else {
+					// If the incoming wrap operation is not strong, just wrap those nodes which were not wrapped already.
+					const range = ranges[ 0 ]._getTransformedByWrapOperation( b );
+
+					a.position = range.start;
+					a.howMany = range.end.offset - range.start.offset;
+					a.graveyardPosition = newGraveyardPosition;
+
+					return [ a ];
+				}
+			}
+			// Range from `b` is contained in range from `a`. Reverse operation `b` in addition to operation `a`.
+			else {
 				const reversed = b.getReversed();
 
 				// Unwrap operation (reversed wrap) always puts a node into a graveyard. Not every wrap operation pulls a node
@@ -1802,35 +1951,8 @@ setTransformation( WrapOperation, WrapOperation, ( a, b, context ) => {
 					a.graveyardPosition = a.graveyardPosition._getTransformedByInsertion( reversed.graveyardPosition, 1 );
 				}
 
-				const bOnlyRange = b.wrappedRange.getDifference( a.wrappedRange )[ 0 ];
-				const rewrapRange = bOnlyRange._getTransformedByWrapOperation( a );
-				const rewrapHowMany = rewrapRange.end.offset - rewrapRange.start.offset;
-				const rewrap = new WrapOperation( rewrapRange.start, rewrapHowMany, reversed.graveyardPosition, 0 );
-
-				return [ reversed, a, rewrap ];
-			} else {
-				// If the incoming wrap operation is not strong, just wrap those nodes which were not wrapped already.
-				const range = ranges[ 0 ]._getTransformedByWrapOperation( b );
-
-				a.position = range.start;
-				a.howMany = range.end.offset - range.start.offset;
-				a.graveyardPosition = newGraveyardPosition;
-
-				return [ a ];
-			}
-		}
-		// Range from `b` is contained in range from `a`. Reverse operation `b` in addition to operation `a`.
-		else {
-			const reversed = b.getReversed();
-
-			// Unwrap operation (reversed wrap) always puts a node into a graveyard. Not every wrap operation pulls a node
-			// from the graveyard, though. This means that after reversing a wrap operation, there might be a need to
-			// update a position in graveyard.
-			if ( !b.graveyardPosition && a.graveyardPosition ) {
-				a.graveyardPosition = a.graveyardPosition._getTransformedByInsertion( reversed.graveyardPosition, 1 );
+				return [ reversed, a ];
 			}
-
-			return [ reversed, a ];
 		}
 	}
 
@@ -1884,11 +2006,11 @@ setTransformation( UnwrapOperation, MergeOperation, ( a, b, context ) => {
 		return [ b.getReversed(), a ];
 	}
 
-	// // Case 2:	The element to unwrap was merged-to and has new nodes.
-	// //
-	// if ( a.position.hasSameParentAs( b.targetPosition ) ) {
-	// 	// Merge operation needs `howMany`!
-	// }
+	// Case 2:	The element to unwrap was merged-to and has new nodes.
+	//
+	if ( a.position.hasSameParentAs( b.targetPosition ) ) {
+		a.howMany += b.howMany;
+	}
 
 	if ( a.position.hasSameParentAs( b.graveyardPosition ) ) {
 		a.howMany++;

+ 12 - 8
packages/ckeditor5-engine/src/model/position.js

@@ -717,12 +717,17 @@ export default class Position {
 	 * @returns {module:engine/model/position~Position} Transformed position.
 	 */
 	_getTransformedByMove( sourcePosition, targetPosition, howMany ) {
-		// Moving a range removes nodes from their original position. We acknowledge this by proper transformation.
-		let transformed = this._getTransformedByDeletion( sourcePosition, howMany );
-
-		// Then we update target position, as it could be affected by nodes removal too.
+		// Update target position, as it could be affected by nodes removal.
 		targetPosition = targetPosition._getTransformedByDeletion( sourcePosition, howMany );
 
+		if ( sourcePosition.isEqual( targetPosition ) ) {
+			// If `targetPosition` is equal to `sourcePosition` this isn't really any move. Just return position as it is.
+			return Position.createFromPosition( this );
+		}
+
+		// Moving a range removes nodes from their original position. We acknowledge this by proper transformation.
+		const transformed = this._getTransformedByDeletion( sourcePosition, howMany );
+
 		const isMoved = transformed === null ||
 			( sourcePosition.isEqual( this ) && this.stickiness == 'toNext' ) ||
 			( sourcePosition.getShiftedBy( howMany ).isEqual( this ) && this.stickiness == 'toPrevious' );
@@ -730,14 +735,13 @@ export default class Position {
 		if ( isMoved ) {
 			// This position is inside moved range (or sticks to it).
 			// In this case, we calculate a combination of this position, move source position and target position.
-			transformed = this._getCombined( sourcePosition, targetPosition );
+			return this._getCombined( sourcePosition, targetPosition );
 		} else {
 			// This position is not inside a removed range.
+			//
 			// In next step, we simply reflect inserting `howMany` nodes, which might further affect the position.
-			transformed = transformed._getTransformedByInsertion( targetPosition, howMany );
+			return transformed._getTransformedByInsertion( targetPosition, howMany );
 		}
-
-		return transformed;
 	}
 
 	/**

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

@@ -496,6 +496,18 @@ export default class Range {
 		let start = this.start._getTransformedByMergeOperation( operation );
 		let end = this.end._getTransformedByMergeOperation( operation );
 
+		if ( start.root != end.root ) {
+			// This happens when only start or end was next to the merged (deleted) element. In this case we need to fix
+			// the range cause its boundaries would be in different roots.
+			if ( start.root != this.root ) {
+				// Fix start position root at it was the only one that was moved.
+				start = this.start;
+			} else {
+				// Fix end position root.
+				end = this.end.getShiftedBy( -1 );
+			}
+		}
+
 		if ( start.isAfter( end ) ) {
 			// This happens in the following two, similar cases:
 			//

+ 3 - 2
packages/ckeditor5-engine/src/model/writer.js

@@ -567,7 +567,7 @@ export default class Writer {
 
 		const version = position.root.document.version;
 
-		const merge = new MergeOperation( sourcePosition, targetPosition, graveyardPosition, version );
+		const merge = new MergeOperation( sourcePosition, position.nodeAfter.maxOffset, targetPosition, graveyardPosition, version );
 
 		this.batch.addOperation( merge );
 		this.model.applyOperation( merge );
@@ -644,7 +644,8 @@ export default class Writer {
 
 		do {
 			const version = splitElement.root.document ? splitElement.root.document.version : null;
-			const split = new SplitOperation( position, null, version );
+			const howMany = splitElement.maxOffset - position.offset;
+			const split = new SplitOperation( position, howMany, null, version );
 
 			this.batch.addOperation( split );
 			this.model.applyOperation( split );

+ 1 - 1
packages/ckeditor5-engine/tests/model/documentselection.js

@@ -1028,7 +1028,7 @@ describe( 'DocumentSelection', () => {
 				} );
 
 				const batch = new Batch();
-				const splitOperation = new SplitOperation( new Position( root, [ 1, 2 ] ), null, 0 );
+				const splitOperation = new SplitOperation( new Position( root, [ 1, 2 ] ), 4, null, 0 );
 
 				batch.addOperation( splitOperation );
 				model.applyOperation( splitOperation );

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

@@ -178,6 +178,7 @@ describe( 'LiveRange', () => {
 
 			const merge = new MergeOperation(
 				new Position( root, [ 0, 0 ] ),
+				10,
 				new Position( gy, [ 0, 0 ] ),
 				new Position( gy, [ 0 ] ),
 				model.document.version + 1

+ 30 - 0
packages/ckeditor5-engine/tests/model/operation/transform/attribute.js

@@ -263,6 +263,36 @@ describe( 'transform', () => {
 					'<paragraph>Abc</paragraph>'
 				);
 			} );
+
+			it( 'multiple typing', () => {
+				john.setData( '<paragraph>[Foo]</paragraph>' );
+				kate.setData( '<paragraph>Fo[]o</paragraph>' );
+
+				john.setAttribute( 'bold', true );
+
+				kate.setSelection( [ 0, 2 ] );
+				kate.type( 'x' );
+				kate.setSelection( [ 0, 3 ] );
+				kate.type( 'x' );
+				kate.setSelection( [ 0, 4 ] );
+				kate.type( 'x' );
+
+				syncClients();
+
+				expectClients( '<paragraph><$text bold="true">Foxxxo</$text></paragraph>' );
+			} );
+
+			it( 'type inside element which attribute changes', () => {
+				john.setData( '[<paragraph></paragraph>]' );
+				kate.setData( '<paragraph>[]</paragraph>' );
+
+				john.setAttribute( 'bold', true );
+				kate.type( 'x' );
+
+				syncClients();
+
+				expectClients( '<paragraph bold="true">x</paragraph>' );
+			} );
 		} );
 
 		describe( 'by move', () => {

+ 18 - 0
packages/ckeditor5-engine/tests/model/operation/transform/delete.js

@@ -39,6 +39,24 @@ describe( 'transform', () => {
 
 				expectClients( '<paragraph>Fobc</paragraph>' );
 			} );
+
+			// https://github.com/ckeditor/ckeditor5-engine/issues/1492
+			it( 'delete same content from a few elements', () => {
+				john.setData( '<paragraph>F[oo</paragraph><paragraph>Bar</paragraph><paragraph>Ab]c</paragraph>' );
+				kate.setData( '<paragraph>F[oo</paragraph><paragraph>Bar</paragraph><paragraph>Ab]c</paragraph>' );
+
+				john.delete();
+				kate.delete();
+
+				syncClients();
+				expectClients( '<paragraph>Fc</paragraph>' );
+
+				john.undo();
+				kate.undo();
+
+				syncClients();
+				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph><paragraph>Abc</paragraph>' );
+			} );
 		} );
 	} );
 } );

+ 15 - 0
packages/ckeditor5-engine/tests/model/operation/transform/unwrap.js

@@ -132,6 +132,21 @@ describe( 'transform', () => {
 					'<paragraph>Bar</paragraph>'
 				);
 			} );
+
+			it( 'unwrap merge target element', () => {
+				john.setData( '<blockQuote>[]<paragraph>Foo</paragraph></blockQuote><blockQuote><paragraph>Bar</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote><paragraph>Foo</paragraph></blockQuote>[]<blockQuote><paragraph>Bar</paragraph></blockQuote>' );
+
+				john.unwrap();
+				kate.merge();
+
+				syncClients();
+
+				expectClients(
+					'<paragraph>Foo</paragraph>' +
+					'<paragraph>Bar</paragraph>'
+				);
+			} );
 		} );
 	} );
 } );

+ 70 - 0
packages/ckeditor5-engine/tests/model/operation/transform/wrap.js

@@ -80,6 +80,23 @@ describe( 'transform', () => {
 				);
 			} );
 
+			it( 'intersecting wrap #3', () => {
+				john.setData( '[<paragraph>Foo</paragraph><paragraph>Bar</paragraph>]<paragraph>Abc</paragraph>' );
+				kate.setData( '[<paragraph>Foo</paragraph>]<paragraph>Bar</paragraph><paragraph>Abc</paragraph>' );
+
+				john.wrap( 'blockQuote' );
+				kate.wrap( 'div' );
+
+				syncClients();
+				expectClients(
+					'<blockQuote>' +
+						'<paragraph>Foo</paragraph>' +
+						'<paragraph>Bar</paragraph>' +
+					'</blockQuote>' +
+					'<paragraph>Abc</paragraph>'
+				);
+			} );
+
 			it( 'intersecting wrap, then undo #1', () => {
 				john.setData( '[<paragraph>Foo</paragraph><paragraph>Bar</paragraph>]<paragraph>Abc</paragraph>' );
 				kate.setData( '<paragraph>Foo</paragraph>[<paragraph>Bar</paragraph><paragraph>Abc</paragraph>]' );
@@ -135,6 +152,26 @@ describe( 'transform', () => {
 				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph><paragraph>Abc</paragraph>' );
 			} );
 
+			it( 'intersecting wrap #3, then undo', () => {
+				john.setData( '[<paragraph>Foo</paragraph><paragraph>Bar</paragraph>]<paragraph>Abc</paragraph>' );
+				kate.setData( '[<paragraph>Foo</paragraph>]<paragraph>Bar</paragraph><paragraph>Abc</paragraph>' );
+
+				john.wrap( 'blockQuote' );
+				kate.wrap( 'div' );
+
+				syncClients();
+
+				john.undo();
+				kate.undo();
+
+				syncClients();
+				expectClients(
+					'<paragraph>Foo</paragraph>' +
+					'<paragraph>Bar</paragraph>' +
+					'<paragraph>Abc</paragraph>'
+				);
+			} );
+
 			it( 'element and text', () => {
 				john.setData( '[<paragraph>Foo</paragraph>]' );
 				kate.setData( '<paragraph>[Foo]</paragraph>' );
@@ -209,6 +246,39 @@ describe( 'transform', () => {
 					'</blockQuote>'
 				);
 			} );
+
+			it( 'delete all wrapped content', () => {
+				john.setData( '[<paragraph>Foo</paragraph><paragraph>Bar</paragraph><paragraph>Abc</paragraph>]' );
+				kate.setData( '<paragraph>[Foo</paragraph><paragraph>Bar</paragraph><paragraph>Ab]c</paragraph>' );
+
+				john.wrap( 'blockQuote' );
+				kate.delete();
+
+				syncClients();
+				expectClients( '<blockQuote><paragraph>c</paragraph></blockQuote>' );
+			} );
+
+			it.skip( 'delete all wrapped content and undo', () => {
+				john.setData( '[<paragraph>Foo</paragraph><paragraph>Bar</paragraph><paragraph>Abc</paragraph>]' );
+				kate.setData( '<paragraph>[Foo</paragraph><paragraph>Bar</paragraph><paragraph>Ab]c</paragraph>' );
+
+				john.wrap( 'blockQuote' );
+				kate.delete();
+
+				syncClients();
+				expectClients( '<blockQuote><paragraph>c</paragraph></blockQuote>' );
+
+				john.undo();
+
+				// There is a bug in undo for Kate.
+				// Kate's content is: '<blockQuote><paragraph>c</paragraph></blockQuote>'.
+				// Then goes undo and it returns "Foo" paragraph into block quote, but "Bar" goes after it.
+				// There is a move (reinsert) x wrap transformation and the move is not included inside the wrap.
+				kate.undo();
+
+				syncClients();
+				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph><paragraph>Abc</paragraph>' );
+			} );
 		} );
 
 		describe( 'by remove', () => {

+ 8 - 5
packages/ckeditor5-engine/tests/model/range.js

@@ -944,7 +944,7 @@ describe( 'Range', () => {
 			it( 'split inside range', () => {
 				range = new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 4 ] ) );
 
-				const op = new SplitOperation( new Position( root, [ 0, 3 ] ), gyPos, 1 );
+				const op = new SplitOperation( new Position( root, [ 0, 3 ] ), 6, gyPos, 1 );
 				const transformed = range.getTransformedByOperation( op );
 
 				expect( transformed.length ).to.equal( 1 );
@@ -955,7 +955,7 @@ describe( 'Range', () => {
 			it( 'split at the beginning of multi-element range', () => {
 				range = new Range( new Position( root, [ 0, 4 ] ), new Position( root, [ 1, 2 ] ) );
 
-				const op = new SplitOperation( new Position( root, [ 0, 4 ] ), gyPos, 1 );
+				const op = new SplitOperation( new Position( root, [ 0, 4 ] ), 5, gyPos, 1 );
 				const transformed = range.getTransformedByOperation( op );
 
 				expect( transformed.length ).to.equal( 1 );
@@ -966,7 +966,7 @@ describe( 'Range', () => {
 			it( 'split inside range which starts at the beginning of split element', () => {
 				range = new Range( new Position( root, [ 0, 0 ] ), new Position( root, [ 0, 4 ] ) );
 
-				const op = new SplitOperation( new Position( root, [ 0, 3 ] ), gyPos, 1 );
+				const op = new SplitOperation( new Position( root, [ 0, 3 ] ), 6, gyPos, 1 );
 				const transformed = range.getTransformedByOperation( op );
 
 				expect( transformed.length ).to.equal( 1 );
@@ -977,7 +977,7 @@ describe( 'Range', () => {
 			it( 'split inside range which end is at the end of split element', () => {
 				range = new Range( new Position( root, [ 0, 3 ] ), new Position( root, [ 0, 6 ] ) );
 
-				const op = new SplitOperation( new Position( root, [ 0, 4 ] ), gyPos, 1 );
+				const op = new SplitOperation( new Position( root, [ 0, 4 ] ), 5, gyPos, 1 );
 				const transformed = range.getTransformedByOperation( op );
 
 				expect( transformed.length ).to.equal( 1 );
@@ -988,7 +988,7 @@ describe( 'Range', () => {
 			it( 'split element which has collapsed range at the end', () => {
 				range = new Range( new Position( root, [ 0, 6 ] ), new Position( root, [ 0, 6 ] ) );
 
-				const op = new SplitOperation( new Position( root, [ 0, 3 ] ), gyPos, 1 );
+				const op = new SplitOperation( new Position( root, [ 0, 3 ] ), 6, gyPos, 1 );
 				const transformed = range.getTransformedByOperation( op );
 
 				expect( transformed.length ).to.equal( 1 );
@@ -1004,6 +1004,7 @@ describe( 'Range', () => {
 
 				const op = new MergeOperation(
 					new Position( root, [ 1, 0 ] ),
+					4,
 					new Position( root, [ 0, 3 ] ),
 					gyPos,
 					1
@@ -1022,6 +1023,7 @@ describe( 'Range', () => {
 
 				const op = new MergeOperation(
 					new Position( root, [ 0, 0 ] ),
+					4,
 					new Position( root, [ 1, 3 ] ),
 					gyPos,
 					1
@@ -1045,6 +1047,7 @@ describe( 'Range', () => {
 
 				const op = new MergeOperation(
 					new Position( root, [ 1, 0 ] ),
+					4,
 					new Position( root, [ 0, 1 ] ),
 					gyPos,
 					1