Forráskód Böngészése

Merge branch 'master' into t/1546

Kamil Piechaczek 7 éve
szülő
commit
d6fd242bb4

+ 38 - 7
packages/ckeditor5-engine/src/model/model.js

@@ -275,12 +275,12 @@ export default class Model {
 	 *
 	 *		// insertContent() doesn't have to be used in a change() block. It can, though,
 	 *		// so this code could be moved to the callback defined above.
-	 *		editor.model.insertContent( docFrag, editor.model.document.selection );
+	 *		editor.model.insertContent( docFrag );
 	 *
 	 * Using `insertContent()` with HTML string converted to a model document fragment (similar to the pasting mechanism):
 	 *
 	 *		// You can create your own HtmlDataProcessor instance or use editor.data.processor
-	 *		// if you haven't overriden the default one (which is HtmlDataProcessor instance).
+	 *		// if you haven't overridden the default one (which is HtmlDataProcessor instance).
 	 *		const htmlDP = new HtmlDataProcessor();
 	 *
 	 *		// Convert an HTML string to a view document fragment.
@@ -296,15 +296,43 @@ export default class Model {
 	 *		// which has a loosened schema.
 	 *		const modelFragment = editor.data.toModel( viewFragment );
 	 *
-	 *		editor.model.insertContent( modelFragment, editor.model.document.selection );
+	 *		editor.model.insertContent( modelFragment );
+	 *
+	 * By default this method will use the document selection but it can also be used with a position, range or selection instance.
+	 *
+	 *		// Insert text at the current document selection position.
+	 *		editor.model.change( writer => {
+	 *			editor.model.insertContent( writer.createText( 'x' ) );
+	 *		} );
+	 *
+	 *		// Insert text at given position - document selection will not be modified.
+	 *		editor.model.change( writer => {
+	 *			editor.model.insertContent( writer.createText( 'x' ), Position.createAt( doc.getRoot(), 2 ) );
+	 *		} );
+	 *
+	 * If an instance of {module:engine/model/selection~Selection} is passed as `selectable`
+	 * it will be moved to the target position (where the document selection should be moved after the insertion).
+	 *
+	 *		// Insert text replacing given selection instance.
+	 *		const selection = new Selection( paragraph, 'in' );
+	 *
+	 *		editor.model.change( writer => {
+	 *			editor.model.insertContent( writer.createText( 'x' ), selection );
+	 *
+	 *			// insertContent() modifies the passed selection instance so it can be used to set the document selection.
+	 *			// Note: This is not necessary when you passed document selection to insertContent().
+	 *			writer.setSelection( selection );
+	 *		} );
 	 *
 	 * @fires insertContent
 	 * @param {module:engine/model/documentfragment~DocumentFragment|module:engine/model/item~Item} content The content to insert.
-	 * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection
-	 * Selection into which the content should be inserted.
+	 * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection|
+	 * module:engine/model/position~Position|module:engine/model/element~Element|
+	 * Iterable.<module:engine/model/range~Range>|module:engine/model/range~Range|null} [selectable=model.document.selection]
+	 * Selection into which the content should be inserted. If not provided the current model document selection will be used.
 	 */
-	insertContent( content, selection ) {
-		insertContent( this, content, selection );
+	insertContent( content, selectable ) {
+		insertContent( this, content, selectable );
 	}
 
 	/**
@@ -534,6 +562,9 @@ export default class Model {
 	 * The {@link #insertContent default action of that method} is implemented as a
 	 * listener to this event so it can be fully customized by the features.
 	 *
+	 * **Note** The `selectable` parameter for the {@link #insertContent} is optional. When `undefined` value is passed the method uses
+	 * `model.document.selection`.
+	 *
 	 * @event insertContent
 	 * @param {Array} args The arguments passed to the original method.
 	 */

+ 129 - 26
packages/ckeditor5-engine/src/model/operation/transform.js

@@ -138,7 +138,8 @@ export function transform( a, b, context = {} ) {
  * @param {Array.<module:engine/model/operation/operation~Operation>} operationsB
  * @param {Object} options Additional transformation options.
  * @param {module:engine/model/document~Document|null} options.document Document which the operations change.
- * @param {Boolean} [options.useContext=false] Whether during transformation additional context information should be gathered and used.
+ * @param {Boolean} [options.useRelations=false] Whether during transformation relations should be used (used during undo for
+ * better conflict resolution).
  * @param {Boolean} [options.padWithNoOps=false] Whether additional {@link module:engine/model/operation/nooperation~NoOperation}s
  * should be added to the transformation results to force the same last base version for both transformed sets (in case
  * if some operations got broken into multiple operations during transformation).
@@ -302,7 +303,7 @@ export function transformSets( operationsA, operationsB, options ) {
 		originalOperationsBCount: operationsB.length
 	};
 
-	const contextFactory = new ContextFactory( options.document, options.useContext );
+	const contextFactory = new ContextFactory( options.document, options.useRelations );
 	contextFactory.setOriginalOperations( operationsA );
 	contextFactory.setOriginalOperations( operationsB );
 
@@ -380,13 +381,14 @@ class ContextFactory {
 	// Creates `ContextFactory` instance.
 	//
 	// @param {module:engine/model/document~Document} document Document which the operations change.
-	// @param {Boolean} useContext Whether during transformation additional context information should be gathered and used.
-	constructor( document, useContext ) {
+	// @param {Boolean} useRelations Whether during transformation relations should be used (used during undo for
+	// better conflict resolution).
+	constructor( document, useRelations ) {
 		// `model.History` instance which information about undone operations will be taken from.
 		this._history = document.history;
 
 		// Whether additional context should be used.
-		this._useContext = useContext;
+		this._useRelations = useRelations;
 
 		// For each operation that is created during transformation process, we keep a reference to the original operation
 		// which it comes from. The original operation works as a kind of "identifier". Every contextual information
@@ -499,6 +501,24 @@ class ContextFactory {
 
 				break;
 			}
+
+			case MergeOperation: {
+				switch ( opB.constructor ) {
+					case MergeOperation: {
+						if ( !opA.targetPosition.isEqual( opB.sourcePosition ) ) {
+							this._setRelation( opA, opB, 'mergeTargetNotMoved' );
+						}
+
+						if ( opA.sourcePosition.isEqual( opB.sourcePosition ) ) {
+							this._setRelation( opA, opB, 'mergeSameElement' );
+						}
+
+						break;
+					}
+				}
+
+				break;
+			}
 		}
 	}
 
@@ -508,29 +528,17 @@ class ContextFactory {
 	// @param {module:engine/model/operation/operation~Operation} opB
 	// @returns {module:engine/model/operation/transform~TransformationContext}
 	getContext( opA, opB, aIsStrong ) {
-		if ( !this._useContext ) {
-			return {
-				aIsStrong,
-				aWasUndone: false,
-				bWasUndone: false,
-				abRelation: null,
-				baRelation: null
-			};
-		}
-
 		return {
 			aIsStrong,
 			aWasUndone: this._wasUndone( opA ),
 			bWasUndone: this._wasUndone( opB ),
-			abRelation: this._getRelation( opA, opB ),
-			baRelation: this._getRelation( opB, opA )
+			abRelation: this._useRelations ? this._getRelation( opA, opB ) : null,
+			baRelation: this._useRelations ? this._getRelation( opB, opA ) : null
 		};
 	}
 
 	// Returns whether given operation `op` has already been undone.
 	//
-	// This is only used when additional context mode is on (options.useContext == true).
-	//
 	// Information whether an operation was undone gives more context when making a decision when two operations are in conflict.
 	//
 	// @param {module:engine/model/operation/operation~Operation} op
@@ -542,14 +550,12 @@ class ContextFactory {
 		const originalOp = this._originalOperations.get( op );
 
 		// And check with the document if the original operation was undone.
-		return this._history.isUndoneOperation( originalOp );
+		return originalOp.wasUndone || this._history.isUndoneOperation( originalOp );
 	}
 
 	// Returns a relation between `opA` and an operation which is undone by `opB`. This can be `String` value if a relation
 	// was set earlier or `null` if there was no relation between those operations.
 	//
-	// This is only used when additional context mode is on (options.useContext == true).
-	//
 	// This is a little tricky to understand, so let's compare it to `ContextFactory#_wasUndone`.
 	//
 	// When `wasUndone( opB )` is used, we check if the `opB` has already been undone. It is obvious, that the
@@ -1303,7 +1309,7 @@ setTransformation( MergeOperation, MoveOperation, ( a, b, context ) => {
 	return [ a ];
 } );
 
-setTransformation( MergeOperation, SplitOperation, ( a, b ) => {
+setTransformation( MergeOperation, SplitOperation, ( a, b, context ) => {
 	if ( b.graveyardPosition ) {
 		// If `b` operation defines graveyard position, a node from graveyard will be moved. This means that we need to
 		// transform `a.graveyardPosition` accordingly.
@@ -1346,7 +1352,7 @@ setTransformation( MergeOperation, SplitOperation, ( a, b ) => {
 	// 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 are two exception, though, when we want to keep `targetPosition` as it was.
+	// There are three exceptions, 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
@@ -1372,14 +1378,44 @@ setTransformation( MergeOperation, SplitOperation, ( a, b ) => {
 	//
 	// If `targetPosition` is transformed, it would become root [ 1, 0 ] as well. It has to be kept as it was.
 	//
+	// Third exception is connected with relations. If this happens during undo and we have explicit information
+	// that target position has not been affected by the operation which is undone by this split then this split should
+	// not move the target position either.
+	//
 	if ( a.targetPosition.isEqual( b.position ) ) {
-		if ( b.howMany != 0 || ( b.graveyardPosition && a.deletionPosition.isEqual( b.graveyardPosition ) ) ) {
+		const mergeInside = b.howMany != 0;
+		const mergeSplittingElement = b.graveyardPosition && a.deletionPosition.isEqual( b.graveyardPosition );
+
+		if ( mergeInside || mergeSplittingElement || context.abRelation == 'mergeTargetNotMoved' ) {
 			a.sourcePosition = a.sourcePosition._getTransformedBySplitOperation( b );
 
 			return [ a ];
 		}
 	}
 
+	// Case 2:
+	//
+	// When merge operation source position is at the same place as split position it needs to be decided whether
+	// the source position should stay in the original node or it should be moved as well to the new parent.
+	//
+	// Split and merge happens at `[]`:
+	// <h2>Foo</h2><p>[]Bar</p>
+	//
+	// After split, two possible solutions where merge can happen:
+	// <h2>Foo</h2><p>[]</p><p>Bar</p>
+	// <h2>Foo</h2><p></p><p>[]Bar</p>
+	//
+	// For collaboration it doesn't matter, however for undo it makes a difference because target position may
+	// not be in the element on the left, so bigger precision is needed for correct undo process. We will use
+	// relations to save if the undone merge affected operation `a`, and if so, we will correctly transform `a`.
+	//
+	if ( a.sourcePosition.isEqual( b.position ) && context.abRelation == 'mergeSameElement' ) {
+		a.targetPosition = a.targetPosition._getTransformedBySplitOperation( b );
+		a.sourcePosition = Position.createFromPosition( b.moveTargetPosition );
+
+		return [ a ];
+	}
+
 	// The default case.
 	//
 	if ( a.sourcePosition.hasSameParentAs( b.position ) ) {
@@ -2043,7 +2079,74 @@ setTransformation( SplitOperation, InsertOperation, ( a, b ) => {
 	return [ a ];
 } );
 
-setTransformation( SplitOperation, MergeOperation, ( a, b ) => {
+setTransformation( SplitOperation, MergeOperation, ( a, b, context ) => {
+	// Case 1:
+	//
+	// Split element got merged. If two different elements were merged, clients will have different content.
+	//
+	// Example. Merge at `{}`, split at `[]`:
+	// <heading>Foo</heading>{}<paragraph>B[]ar</paragraph>
+	//
+	// On merge side it will look like this:
+	// <heading>FooB[]ar</heading>
+	// <heading>FooB</heading><heading>ar</heading>
+	//
+	// On split side it will look like this:
+	// <heading>Foo</heading>{}<paragraph>B</paragraph><paragraph>ar</paragraph>
+	// <heading>FooB</heading><paragraph>ar</paragraph>
+	//
+	// Clearly, the second element is different for both clients.
+	//
+	// We could use the removed merge element from graveyard as a split element but then clients would have a different
+	// model state (in graveyard), because the split side client would still have an element in graveyard (removed by merge).
+	//
+	// To overcome this, in `SplitOperation` x `MergeOperation` transformation we will add additional `SplitOperation`
+	// in the graveyard, which will actually clone the merged-and-deleted element. Then, that cloned element will be
+	// used for splitting. Example below.
+	//
+	// Original state:
+	// <heading>Foo</heading>{}<paragraph>B[]ar</paragraph>
+	//
+	// Merge side client:
+	//
+	// After merge:
+	// <heading>FooB[]ar</heading>                                 graveyard: <paragraph></paragraph>
+	//
+	// Extra split:
+	// <heading>FooB[]ar</heading>                                 graveyard: <paragraph></paragraph><paragraph></paragraph>
+	//
+	// Use the "cloned" element from graveyard:
+	// <heading>FooB</heading><paragraph>ar</paragraph>            graveyard: <paragraph></paragraph>
+	//
+	// Split side client:
+	//
+	// After split:
+	// <heading>Foo</heading>{}<paragraph>B</paragraph><paragraph>ar</paragraph>
+	//
+	// After merge:
+	// <heading>FooB</heading><paragraph>ar</paragraph>            graveyard: <paragraph></paragraph>
+	//
+	// This special case scenario only applies if the original split operation clones the split element.
+	// If the original split operation has `graveyardPosition` set, it all doesn't have sense because split operation
+	// knows exactly which element it should use. So there would be no original problem with different contents.
+	//
+	// Additionally, the special case applies only if the merge wasn't already undone.
+	//
+	if ( !a.graveyardPosition && !context.bWasUndone && a.position.hasSameParentAs( b.sourcePosition ) ) {
+		const splitPath = b.graveyardPosition.path.slice();
+		splitPath.push( 0 );
+
+		const additionalSplit = new SplitOperation( new Position( b.graveyardPosition.root, splitPath ), 0, null, 0 );
+
+		a.position = a.position._getTransformedByMergeOperation( b );
+		a.graveyardPosition = Position.createFromPosition( additionalSplit.insertionPosition );
+		a.graveyardPosition.stickiness = 'toNext';
+
+		return [ additionalSplit, a ];
+	}
+
+	// The default case.
+	//
 	if ( a.position.hasSameParentAs( b.deletionPosition ) && !a.position.isAfter( b.deletionPosition ) ) {
 		a.howMany--;
 	}

+ 16 - 16
packages/ckeditor5-engine/src/model/selection.js

@@ -35,7 +35,7 @@ export default class Selection {
 	 * or on the given {@link module:engine/model/element~Element element},
 	 * or creates an empty selection if no arguments were passed.
 	 *
-	 * 		// Creates empty selection without ranges.
+	 *		// Creates empty selection without ranges.
 	 *		const selection = new Selection();
 	 *
 	 *		// Creates selection at the given range.
@@ -43,7 +43,7 @@ export default class Selection {
 	 *		const selection = new Selection( range );
 	 *
 	 *		// Creates selection at the given ranges
-	 * 		const ranges = [ new Range( start1, end2 ), new Range( star2, end2 ) ];
+	 *		const ranges = [ new Range( start1, end2 ), new Range( star2, end2 ) ];
 	 *		const selection = new Selection( ranges );
 	 *
 	 *		// Creates selection from the other selection.
@@ -51,30 +51,30 @@ export default class Selection {
 	 *		const otherSelection = new Selection();
 	 *		const selection = new Selection( otherSelection );
 	 *
-	 * 		// Creates selection from the given document selection.
+	 *		// Creates selection from the given document selection.
 	 *		// Note: It doesn't copies selection attributes.
 	 *		const documentSelection = new DocumentSelection( doc );
 	 *		const selection = new Selection( documentSelection );
 	 *
-	 * 		// Creates selection at the given position.
+	 *		// Creates selection at the given position.
 	 *		const position = new Position( root, path );
 	 *		const selection = new Selection( position );
 	 *
-	 * 		// Creates selection at the start position of the given element.
+	 *		// Creates selection at the start position of the given element.
 	 *		const paragraph = writer.createElement( 'paragraph' );
 	 *		const selection = new Selection( paragraph, offset );
 	 *
-	 * 		// Creates a range inside an {@link module:engine/model/element~Element element} which starts before the
-	 * 		// first child of that element and ends after the last child of that element.
+	 *		// Creates a range inside an {@link module:engine/model/element~Element element} which starts before the
+	 *		// first child of that element and ends after the last child of that element.
 	 *		const selection = new Selection( paragraph, 'in' );
 	 *
-	 * 		// Creates a range on an {@link module:engine/model/item~Item item} which starts before the item and ends
-	 * 		// just after the item.
+	 *		// Creates a range on an {@link module:engine/model/item~Item item} which starts before the item and ends
+	 *		// just after the item.
 	 *		const selection = new Selection( paragraph, 'on' );
 	 *
 	 * Selection's constructor allow passing additional options (`'backward'`) as the last argument.
 	 *
-	 * 		// Creates backward selection.
+	 *		// Creates backward selection.
 	 *		const selection = new Selection( range, { backward: true } );
 	 *
 	 * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection|
@@ -326,7 +326,7 @@ export default class Selection {
 	 * {@link module:engine/model/element~Element element}, {@link module:engine/model/position~Position position},
 	 * {@link module:engine/model/range~Range range}, an iterable of {@link module:engine/model/range~Range ranges} or null.
 	 *
-	 * 		// Removes all selection's ranges.
+	 *		// Removes all selection's ranges.
 	 *		selection.setTo( null );
 	 *
 	 *		// Sets selection to the given range.
@@ -334,7 +334,7 @@ export default class Selection {
 	 *		selection.setTo( range );
 	 *
 	 *		// Sets selection to given ranges.
-	 * 		const ranges = [ new Range( start1, end2 ), new Range( star2, end2 ) ];
+	 *		const ranges = [ new Range( start1, end2 ), new Range( star2, end2 ) ];
 	 *		selection.setTo( ranges );
 	 *
 	 *		// Sets selection to other selection.
@@ -342,16 +342,16 @@ export default class Selection {
 	 *		const otherSelection = new Selection();
 	 *		selection.setTo( otherSelection );
 	 *
-	 * 		// Sets selection to the given document selection.
+	 *		// Sets selection to the given document selection.
 	 *		// Note: It doesn't copies selection attributes.
 	 *		const documentSelection = new DocumentSelection( doc );
 	 *		selection.setTo( documentSelection );
 	 *
-	 * 		// Sets collapsed selection at the given position.
+	 *		// Sets collapsed selection at the given position.
 	 *		const position = new Position( root, path );
 	 *		selection.setTo( position );
 	 *
-	 * 		// Sets collapsed selection at the position of the given node and an offset.
+	 *		// Sets collapsed selection at the position of the given node and an offset.
 	 *		selection.setTo( paragraph, offset );
 	 *
 	 * Creates a range inside an {@link module:engine/model/element~Element element} which starts before the first child of
@@ -365,7 +365,7 @@ export default class Selection {
 	 *
 	 * `Selection#setTo()`' method allow passing additional options (`backward`) as the last argument.
 	 *
-	 * 		// Sets backward selection.
+	 *		// Sets backward selection.
 	 *		const selection = new Selection( range, { backward: true } );
 	 *
 	 * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection|

+ 19 - 4
packages/ckeditor5-engine/src/model/utils/insertcontent.js

@@ -13,24 +13,39 @@ import Element from '../element';
 import Range from '../range';
 import log from '@ckeditor/ckeditor5-utils/src/log';
 import DocumentSelection from '../documentselection';
+import Selection from '../selection';
 
 /**
  * Inserts content into the editor (specified selection) as one would expect the paste
  * functionality to work.
  *
+ * If an instance of {module:engine/model/selection~Selection} is passed as `selectable` it will be modified
+ * to the insertion selection (equal to a range to be selected after insertion).
+ *
  * **Note:** Use {@link module:engine/model/model~Model#insertContent} instead of this function.
- * This function is only exposed to be reusable in algorithms
- * which change the {@link module:engine/model/model~Model#insertContent}
+ * This function is only exposed to be reusable in algorithms which change the {@link module:engine/model/model~Model#insertContent}
  * method's behavior.
  *
  * @param {module:engine/model/model~Model} model The model in context of which the insertion
  * should be performed.
  * @param {module:engine/model/documentfragment~DocumentFragment|module:engine/model/item~Item} content The content to insert.
- * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection
+ * @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection|
+ * module:engine/model/position~Position|module:engine/model/element~Element|
+ * Iterable.<module:engine/model/range~Range>|module:engine/model/range~Range|null} [selectable=model.document.selection]
  * Selection into which the content should be inserted.
  */
-export default function insertContent( model, content, selection ) {
+export default function insertContent( model, content, selectable ) {
 	model.change( writer => {
+		let selection;
+
+		if ( !selectable ) {
+			selection = model.document.selection;
+		} else if ( selectable instanceof Selection || selectable instanceof DocumentSelection ) {
+			selection = selectable;
+		} else {
+			selection = new Selection( selectable );
+		}
+
 		if ( !selection.isCollapsed ) {
 			model.deleteContent( selection );
 		}

+ 14 - 4
packages/ckeditor5-engine/tests/model/model.js

@@ -347,7 +347,7 @@ describe( 'Model', () => {
 
 			model.on( 'insertContent', spy );
 
-			model.insertContent( new ModelText( 'a' ), model.document.selection );
+			model.insertContent( new ModelText( 'a' ) );
 
 			expect( spy.calledOnce ).to.be.true;
 		} );
@@ -357,7 +357,7 @@ describe( 'Model', () => {
 
 			setData( model, '<paragraph>fo[]ar</paragraph>' );
 
-			model.insertContent( new ModelText( 'ob' ), model.document.selection );
+			model.insertContent( new ModelText( 'ob' ) );
 
 			expect( getData( model ) ).to.equal( '<paragraph>foob[]ar</paragraph>' );
 		} );
@@ -367,7 +367,17 @@ describe( 'Model', () => {
 
 			setData( model, '<paragraph>fo[]ar</paragraph>' );
 
-			model.insertContent( new ModelDocumentFragment( [ new ModelText( 'ob' ) ] ), model.document.selection );
+			model.insertContent( new ModelDocumentFragment( [ new ModelText( 'ob' ) ] ) );
+
+			expect( getData( model ) ).to.equal( '<paragraph>foob[]ar</paragraph>' );
+		} );
+
+		it( 'should use current model selection if no selectable passed', () => {
+			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
+
+			setData( model, '<paragraph>fo[]ar</paragraph>' );
+
+			model.insertContent( new ModelText( 'ob' ) );
 
 			expect( getData( model ) ).to.equal( '<paragraph>foob[]ar</paragraph>' );
 		} );
@@ -377,7 +387,7 @@ describe( 'Model', () => {
 			setData( model, '<paragraph>[]</paragraph>' );
 
 			model.change( writer => {
-				model.insertContent( new ModelText( 'abc' ), model.document.selection );
+				model.insertContent( new ModelText( 'abc' ) );
 				expect( writer.batch.operations ).to.length( 1 );
 			} );
 		} );

+ 44 - 0
packages/ckeditor5-engine/tests/model/operation/transform/merge.js

@@ -60,6 +60,50 @@ describe( 'transform', () => {
 			} );
 		} );
 
+		describe( 'by remove', () => {
+			it( 'remove merged element', () => {
+				john.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph>[<paragraph>Bar</paragraph>]' );
+
+				john.merge();
+				kate.remove();
+
+				syncClients();
+
+				expectClients( '<paragraph>Foo</paragraph>' );
+			} );
+
+			it( 'remove merged element then undo #1', () => {
+				john.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph>[<paragraph>Bar</paragraph>]' );
+
+				john.merge();
+				kate.remove();
+
+				syncClients();
+				expectClients( '<paragraph>Foo</paragraph>' );
+
+				kate.undo();
+
+				syncClients();
+
+				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+			} );
+
+			it( 'remove merged element then undo #2', () => {
+				john.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph>[<paragraph>Bar</paragraph>]' );
+
+				john.merge();
+				kate.remove();
+				kate.undo();
+
+				syncClients();
+
+				expectClients( '<paragraph>FooBar</paragraph>' );
+			} );
+		} );
+
 		describe( 'by delete', () => {
 			it( 'text from two elements', () => {
 				john.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );

+ 50 - 0
packages/ckeditor5-engine/tests/model/operation/transform/split.js

@@ -422,6 +422,56 @@ describe( 'transform', () => {
 				syncClients();
 				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
 			} );
+
+			it( 'element into heading', () => {
+				john.setData( '<heading1>Foo</heading1><paragraph>B[]ar</paragraph>' );
+				kate.setData( '<heading1>Foo</heading1>[]<paragraph>Bar</paragraph>' );
+
+				john.split();
+				kate.merge();
+
+				syncClients();
+				expectClients(
+					'<heading1>FooB</heading1>' +
+					'<paragraph>ar</paragraph>'
+				);
+			} );
+
+			it( 'element into heading with undo #1', () => {
+				john.setData( '<heading1>Foo</heading1><paragraph>B[]ar</paragraph>' );
+				kate.setData( '<heading1>Foo</heading1>[]<paragraph>Bar</paragraph>' );
+
+				john.split();
+				kate.merge();
+
+				syncClients();
+				expectClients(
+					'<heading1>FooB</heading1>' +
+					'<paragraph>ar</paragraph>'
+				);
+
+				john.undo();
+				kate.undo();
+
+				syncClients();
+				expectClients( '<heading1>Foo</heading1><paragraph>Bar</paragraph>' );
+			} );
+
+			it( 'element into heading with undo #2', () => {
+				john.setData( '<heading1>Foo</heading1><paragraph>B[]ar</paragraph>' );
+				kate.setData( '<heading1>Foo</heading1>[]<paragraph>Bar</paragraph>' );
+
+				john.split();
+				kate.merge();
+				kate.undo();
+
+				syncClients();
+				expectClients(
+					'<heading1>Foo</heading1>' +
+					'<paragraph>B</paragraph>' +
+					'<paragraph>ar</paragraph>'
+				);
+			} );
 		} );
 
 		describe( 'by delete', () => {

+ 66 - 10
packages/ckeditor5-engine/tests/model/operation/transform/undo.js

@@ -13,7 +13,7 @@ describe( 'transform', () => {
 		return john.destroy();
 	} );
 
-	it( 'split, remove, undo, undo', () => {
+	it( 'split, remove', () => {
 		john.setData( '<paragraph>Foo[]Bar</paragraph>' );
 
 		john.split();
@@ -25,7 +25,7 @@ describe( 'transform', () => {
 		expectClients( '<paragraph>FooBar</paragraph>' );
 	} );
 
-	it( 'move, merge, undo, undo', () => {
+	it( 'move, merge', () => {
 		john.setData( '[<paragraph>Foo</paragraph>]<paragraph>Bar</paragraph>' );
 
 		john.move( [ 2 ] );
@@ -37,7 +37,7 @@ describe( 'transform', () => {
 		expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
 	} );
 
-	it.skip( 'move multiple, merge, undo, undo', () => {
+	it.skip( 'move multiple, merge', () => {
 		john.setData( '[<paragraph>Foo</paragraph><paragraph>Bar</paragraph>]<paragraph>Xyz</paragraph>' );
 
 		john.move( [ 3 ] );
@@ -59,7 +59,7 @@ describe( 'transform', () => {
 		expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph><paragraph>Xyz</paragraph>' );
 	} );
 
-	it( 'move inside unwrapped content then undo', () => {
+	it( 'move inside unwrapped content', () => {
 		john.setData( '<blockQuote>[<paragraph>Foo</paragraph>]<paragraph>Bar</paragraph></blockQuote>' );
 
 		john.move( [ 0, 2 ] );
@@ -76,7 +76,7 @@ describe( 'transform', () => {
 		);
 	} );
 
-	it( 'remove node, merge then undo', () => {
+	it( 'remove node, merge', () => {
 		john.setData( '<paragraph>Foo</paragraph><paragraph>[Bar]</paragraph>' );
 
 		john.remove();
@@ -88,7 +88,7 @@ describe( 'transform', () => {
 		expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
 	} );
 
-	it( 'merge, merge then undo #1', () => {
+	it( 'merge, merge #1', () => {
 		john.setData(
 			'<blockQuote>' +
 				'<paragraph>Foo</paragraph>' +
@@ -125,7 +125,7 @@ describe( 'transform', () => {
 		);
 	} );
 
-	it( 'merge, merge then undo #2', () => {
+	it( 'merge, merge #2', () => {
 		john.setData(
 			'<blockQuote>' +
 				'<paragraph>Foo</paragraph>' +
@@ -162,7 +162,7 @@ describe( 'transform', () => {
 		);
 	} );
 
-	it( 'merge, unwrap then undo', () => {
+	it( 'merge, unwrap', () => {
 		john.setData( '<paragraph></paragraph>[]<paragraph>Foo</paragraph>' );
 
 		john.merge();
@@ -175,7 +175,7 @@ describe( 'transform', () => {
 		expectClients( '<paragraph></paragraph><paragraph>Foo</paragraph>' );
 	} );
 
-	it( 'remove node at the split position then undo #1', () => {
+	it( 'remove node at the split position #1', () => {
 		john.setData( '<paragraph>Ab</paragraph>[]<paragraph>Xy</paragraph>' );
 
 		john.merge();
@@ -188,7 +188,7 @@ describe( 'transform', () => {
 		expectClients( '<paragraph>Ab</paragraph><paragraph>Xy</paragraph>' );
 	} );
 
-	it( 'remove node at the split position then undo #2', () => {
+	it( 'remove node at the split position #2', () => {
 		john.setData( '<paragraph>Ab</paragraph>[]<paragraph>Xy</paragraph>' );
 
 		john.merge();
@@ -219,4 +219,60 @@ describe( 'transform', () => {
 
 		expectClients( '<paragraph>Foobar</paragraph>' );
 	} );
+
+	it( 'remove text from paragraph and merge it', () => {
+		john.setData( '<paragraph>Foo</paragraph><paragraph>[Bar]</paragraph>' );
+
+		john.remove();
+		john.setSelection( [ 1 ] );
+		john.merge();
+
+		expectClients( '<paragraph>Foo</paragraph>' );
+
+		john.undo();
+
+		expectClients( '<paragraph>Foo</paragraph><paragraph></paragraph>' );
+
+		john.undo();
+
+		expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+	} );
+
+	it( 'delete split paragraphs', () => {
+		john.setData( '<paragraph>Foo</paragraph><paragraph>B[]ar</paragraph>' );
+
+		john.split();
+		john.setSelection( [ 2, 1 ] );
+		john.split();
+		john.setSelection( [ 1, 0 ], [ 3, 1 ] );
+		john.delete();
+		john.setSelection( [ 1 ] );
+		john.merge();
+
+		expectClients( '<paragraph>Foo</paragraph>' );
+
+		john.undo();
+		expectClients( '<paragraph>Foo</paragraph><paragraph></paragraph>' );
+
+		john.undo();
+		expectClients( '<paragraph>Foo</paragraph><paragraph>B</paragraph><paragraph>a</paragraph><paragraph>r</paragraph>' );
+
+		john.undo();
+		expectClients( '<paragraph>Foo</paragraph><paragraph>B</paragraph><paragraph>ar</paragraph>' );
+
+		john.undo();
+		expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+		john.redo();
+		expectClients( '<paragraph>Foo</paragraph><paragraph>B</paragraph><paragraph>ar</paragraph>' );
+
+		john.redo();
+		expectClients( '<paragraph>Foo</paragraph><paragraph>B</paragraph><paragraph>a</paragraph><paragraph>r</paragraph>' );
+
+		john.redo();
+		expectClients( '<paragraph>Foo</paragraph><paragraph></paragraph>' );
+
+		john.redo();
+		expectClients( '<paragraph>Foo</paragraph>' );
+	} );
 } );

+ 26 - 7
packages/ckeditor5-engine/tests/model/operation/transform/utils.js

@@ -5,6 +5,7 @@ import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
 import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting';
+import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting';
 
 import { getData, parse } from '../../../../src/dev-utils/model';
 import { transformSets } from '../../../../src/model/operation/transform';
@@ -29,7 +30,7 @@ export class Client {
 			// Typing is needed for delete command.
 			// UndoEditing is needed for undo command.
 			// Block plugins are needed for proper data serializing.
-			plugins: [ Typing, Paragraph, ListEditing, UndoEditing, BlockQuoteEditing ]
+			plugins: [ Typing, Paragraph, ListEditing, UndoEditing, BlockQuoteEditing, HeadingEditing ]
 		} ).then( editor => {
 			this.editor = editor;
 			this.document = editor.model.document;
@@ -258,7 +259,7 @@ export class Client {
 }
 
 function bufferOperations( operations, client ) {
-	bufferedOperations.add( { operations: operations.map( operation => JSON.stringify( operation ) ), client } );
+	bufferedOperations.add( { operations, client } );
 }
 
 export function syncClients() {
@@ -281,13 +282,31 @@ export function syncClients() {
 				continue;
 			}
 
-			const remoteOperationsJson = clientsOperations[ remoteClient.name ];
-
-			if ( !remoteOperationsJson ) {
+			if ( !clientsOperations[ remoteClient.name ] ) {
 				continue;
 			}
 
-			const remoteOperations = remoteOperationsJson.map( op => OperationFactory.fromJSON( JSON.parse( op ), localClient.document ) );
+			// Stringify and rebuild operations to simulate sending operations. Set `wasUndone`.
+			const remoteOperationsJson = clientsOperations[ remoteClient.name ].map( operation => {
+				operation.wasUndone = remoteClient.document.history.isUndoneOperation( operation );
+
+				const json = JSON.stringify( operation );
+
+				delete operation.wasUndone;
+
+				return json;
+			} );
+
+			const remoteOperations = remoteOperationsJson.map( json => {
+				const parsedJson = JSON.parse( json );
+				const operation = OperationFactory.fromJSON( parsedJson, localClient.document );
+
+				if ( parsedJson.wasUndone ) {
+					operation.wasUndone = true;
+				}
+
+				return operation;
+			} );
 
 			const localOperations = Array.from( localClient.document.history.getOperations( localClient.syncedVersion ) );
 
@@ -295,7 +314,7 @@ export function syncClients() {
 
 			const options = {
 				document: localClient.document,
-				useContext: false,
+				useRelations: false,
 				padWithNoOps: true
 			};
 

+ 92 - 8
packages/ckeditor5-engine/tests/model/utils/insertcontent.js

@@ -12,6 +12,7 @@ import Selection from '../../../src/model/selection';
 import Position from '../../../src/model/position';
 
 import { setData, getData, parse } from '../../../src/dev-utils/model';
+import Range from '../../../src/model/range';
 
 describe( 'DataController utils', () => {
 	let model, doc;
@@ -26,7 +27,7 @@ describe( 'DataController utils', () => {
 			setData( model, 'x[]x' );
 
 			model.change( writer => {
-				insertContent( model, new Text( 'a' ), doc.selection );
+				insertContent( model, writer.createText( 'a' ) );
 				expect( writer.batch.operations ).to.length( 1 );
 			} );
 		} );
@@ -41,12 +42,95 @@ describe( 'DataController utils', () => {
 
 			const selection = new Selection( new Position( doc.getRoot(), [ 2 ] ) );
 
-			model.change( () => {
-				insertContent( model, new Text( 'x' ), selection );
+			model.change( writer => {
+				insertContent( model, writer.createText( 'x' ), selection );
+				expect( getData( model ) ).to.equal( 'a[]bxc' );
+			} );
+		} );
+
+		it( 'should modify passed selection instance', () => {
+			model = new Model();
+			doc = model.document;
+			doc.createRoot();
+
+			model.schema.extend( '$text', { allowIn: '$root' } );
+			setData( model, 'a[]bc' );
+
+			const selection = new Selection( new Position( doc.getRoot(), [ 2 ] ) );
+			const selectionCopy = new Selection( new Position( doc.getRoot(), [ 2 ] ) );
+
+			expect( selection.isEqual( selectionCopy ) ).to.be.true;
+
+			model.change( writer => {
+				insertContent( model, writer.createText( 'x' ), selection );
+			} );
+
+			expect( selection.isEqual( selectionCopy ) ).to.be.false;
+
+			const insertionSelection = new Selection( new Position( doc.getRoot(), [ 3 ] ) );
+			expect( selection.isEqual( insertionSelection ) ).to.be.true;
+		} );
+
+		it( 'should be able to insert content at custom position', () => {
+			model = new Model();
+			doc = model.document;
+			doc.createRoot();
+
+			model.schema.extend( '$text', { allowIn: '$root' } );
+			setData( model, 'a[]bc' );
+
+			const position = new Position( doc.getRoot(), [ 2 ] );
+
+			model.change( writer => {
+				insertContent( model, writer.createText( 'x' ), position );
 				expect( getData( model ) ).to.equal( 'a[]bxc' );
 			} );
 		} );
 
+		it( 'should be able to insert content at custom range', () => {
+			model = new Model();
+			doc = model.document;
+			doc.createRoot();
+
+			model.schema.extend( '$text', { allowIn: '$root' } );
+			setData( model, 'a[]bc' );
+
+			const range = new Range( new Position( doc.getRoot(), [ 2 ] ), new Position( doc.getRoot(), [ 3 ] ) );
+
+			model.change( writer => {
+				insertContent( model, writer.createText( 'x' ), range );
+				expect( getData( model ) ).to.equal( 'a[]bx' );
+			} );
+		} );
+
+		it( 'should be able to insert content at model selection if document selection is passed', () => {
+			model = new Model();
+			doc = model.document;
+			doc.createRoot();
+
+			model.schema.extend( '$text', { allowIn: '$root' } );
+			setData( model, 'a[]bc' );
+
+			model.change( writer => {
+				insertContent( model, writer.createText( 'x' ), model.document.selection );
+				expect( getData( model ) ).to.equal( 'ax[]bc' );
+			} );
+		} );
+
+		it( 'should be able to insert content at model selection if none passed', () => {
+			model = new Model();
+			doc = model.document;
+			doc.createRoot();
+
+			model.schema.extend( '$text', { allowIn: '$root' } );
+			setData( model, 'a[]bc' );
+
+			model.change( writer => {
+				insertContent( model, writer.createText( 'x' ) );
+				expect( getData( model ) ).to.equal( 'ax[]bc' );
+			} );
+		} );
+
 		it( 'accepts DocumentFragment', () => {
 			model = new Model();
 			doc = model.document;
@@ -56,7 +140,7 @@ describe( 'DataController utils', () => {
 
 			setData( model, 'x[]x' );
 
-			insertContent( model, new DocumentFragment( [ new Text( 'a' ) ] ), doc.selection );
+			insertContent( model, new DocumentFragment( [ new Text( 'a' ) ] ) );
 
 			expect( getData( model ) ).to.equal( 'xa[]x' );
 		} );
@@ -70,7 +154,7 @@ describe( 'DataController utils', () => {
 
 			setData( model, 'x[]x' );
 
-			insertContent( model, new Text( 'a' ), doc.selection );
+			insertContent( model, new Text( 'a' ) );
 
 			expect( getData( model ) ).to.equal( 'xa[]x' );
 		} );
@@ -90,7 +174,7 @@ describe( 'DataController utils', () => {
 
 			setData( model, '<paragraph>foo[]</paragraph>' );
 
-			insertContent( model, content, doc.selection );
+			insertContent( model, content );
 
 			expect( doc.getRoot().getChild( 0 ).getChild( 1 ) ).to.equal( content );
 		} );
@@ -303,7 +387,7 @@ describe( 'DataController utils', () => {
 				] );
 
 				setData( model, '[<heading2>foo</heading2>]' );
-				insertContent( model, content, doc.selection );
+				insertContent( model, content );
 				expect( getData( model ) ).to.equal( '<heading1>bar[]</heading1>' );
 			} );
 
@@ -854,6 +938,6 @@ describe( 'DataController utils', () => {
 			} );
 		}
 
-		insertContent( model, content, doc.selection );
+		insertContent( model, content );
 	}
 } );