Bläddra i källkod

Merge branch 'master' into t/ckeditor5-media-embed/1

Aleksander Nowodzinski 7 år sedan
förälder
incheckning
9c8fdf91c3
68 ändrade filer med 2806 tillägg och 1834 borttagningar
  1. 2 21
      packages/ckeditor5-engine/src/dev-utils/enableenginedebug.js
  2. 2 2
      packages/ckeditor5-engine/src/model/documentselection.js
  3. 13 5
      packages/ckeditor5-engine/src/model/liverange.js
  4. 7 1
      packages/ckeditor5-engine/src/model/node.js
  5. 11 0
      packages/ckeditor5-engine/src/model/operation/attributeoperation.js
  6. 11 1
      packages/ckeditor5-engine/src/model/operation/detachoperation.js
  7. 15 3
      packages/ckeditor5-engine/src/model/operation/insertoperation.js
  8. 8 0
      packages/ckeditor5-engine/src/model/operation/markeroperation.js
  9. 27 4
      packages/ckeditor5-engine/src/model/operation/mergeoperation.js
  10. 18 0
      packages/ckeditor5-engine/src/model/operation/moveoperation.js
  11. 3 3
      packages/ckeditor5-engine/src/model/operation/operation.js
  12. 0 4
      packages/ckeditor5-engine/src/model/operation/operationfactory.js
  13. 0 76
      packages/ckeditor5-engine/src/model/operation/reinsertoperation.js
  14. 0 60
      packages/ckeditor5-engine/src/model/operation/removeoperation.js
  15. 11 0
      packages/ckeditor5-engine/src/model/operation/renameoperation.js
  16. 12 1
      packages/ckeditor5-engine/src/model/operation/rootattributeoperation.js
  17. 33 4
      packages/ckeditor5-engine/src/model/operation/splitoperation.js
  18. 463 163
      packages/ckeditor5-engine/src/model/operation/transform.js
  19. 12 0
      packages/ckeditor5-engine/src/model/operation/unwrapoperation.js
  20. 29 3
      packages/ckeditor5-engine/src/model/operation/wrapoperation.js
  21. 39 12
      packages/ckeditor5-engine/src/model/position.js
  22. 51 6
      packages/ckeditor5-engine/src/model/range.js
  23. 46 19
      packages/ckeditor5-engine/src/model/schema.js
  24. 15 1
      packages/ckeditor5-engine/src/model/selection.js
  25. 5 5
      packages/ckeditor5-engine/src/model/writer.js
  26. 9 1
      packages/ckeditor5-engine/src/view/selection.js
  27. 3 38
      packages/ckeditor5-engine/tests/dev-utils/enableenginedebug.js
  28. 0 9
      packages/ckeditor5-engine/tests/model/_utils/utils.js
  29. 1 2
      packages/ckeditor5-engine/tests/model/differ.js
  30. 1 2
      packages/ckeditor5-engine/tests/model/document.js
  31. 4 5
      packages/ckeditor5-engine/tests/model/documentfragment.js
  32. 7 8
      packages/ckeditor5-engine/tests/model/documentselection.js
  33. 10 8
      packages/ckeditor5-engine/tests/model/element.js
  34. 65 17
      packages/ckeditor5-engine/tests/model/liverange.js
  35. 2 3
      packages/ckeditor5-engine/tests/model/nodelist.js
  36. 4 4
      packages/ckeditor5-engine/tests/model/operation/attributeoperation.js
  37. 2 3
      packages/ckeditor5-engine/tests/model/operation/detachoperation.js
  38. 7 8
      packages/ckeditor5-engine/tests/model/operation/insertoperation.js
  39. 4 5
      packages/ckeditor5-engine/tests/model/operation/markeroperation.js
  40. 16 13
      packages/ckeditor5-engine/tests/model/operation/moveoperation.js
  41. 2 3
      packages/ckeditor5-engine/tests/model/operation/nooperation.js
  42. 3 4
      packages/ckeditor5-engine/tests/model/operation/operation.js
  43. 0 157
      packages/ckeditor5-engine/tests/model/operation/reinsertoperation.js
  44. 0 191
      packages/ckeditor5-engine/tests/model/operation/removeoperation.js
  45. 3 4
      packages/ckeditor5-engine/tests/model/operation/renameoperation.js
  46. 4 4
      packages/ckeditor5-engine/tests/model/operation/rootattributeoperation.js
  47. 135 151
      packages/ckeditor5-engine/tests/model/operation/transform.js
  48. 723 25
      packages/ckeditor5-engine/tests/model/operation/transform/attribute.js
  49. 18 0
      packages/ckeditor5-engine/tests/model/operation/transform/delete.js
  50. 51 93
      packages/ckeditor5-engine/tests/model/operation/transform/insert.js
  51. 93 134
      packages/ckeditor5-engine/tests/model/operation/transform/marker.js
  52. 50 119
      packages/ckeditor5-engine/tests/model/operation/transform/move.js
  53. 79 145
      packages/ckeditor5-engine/tests/model/operation/transform/remove.js
  54. 37 108
      packages/ckeditor5-engine/tests/model/operation/transform/rename.js
  55. 42 21
      packages/ckeditor5-engine/tests/model/operation/transform/split.js
  56. 67 5
      packages/ckeditor5-engine/tests/model/operation/transform/unwrap.js
  57. 31 13
      packages/ckeditor5-engine/tests/model/operation/transform/utils.js
  58. 272 24
      packages/ckeditor5-engine/tests/model/operation/transform/wrap.js
  59. 88 0
      packages/ckeditor5-engine/tests/model/operation/wrapoperation.js
  60. 2 3
      packages/ckeditor5-engine/tests/model/position.js
  61. 26 12
      packages/ckeditor5-engine/tests/model/range.js
  62. 102 70
      packages/ckeditor5-engine/tests/model/schema.js
  63. 1 1
      packages/ckeditor5-engine/tests/model/selection.js
  64. 4 5
      packages/ckeditor5-engine/tests/model/text.js
  65. 0 17
      packages/ckeditor5-engine/tests/model/utils-tests/utils.js
  66. 1 1
      packages/ckeditor5-engine/tests/model/writer.js
  67. 2 2
      packages/ckeditor5-engine/tests/view/documentselection.js
  68. 2 2
      packages/ckeditor5-engine/tests/view/selection.js

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

@@ -25,7 +25,6 @@ import MoveOperation from '../model/operation/moveoperation';
 import NoOperation from '../model/operation/nooperation';
 import NoOperation from '../model/operation/nooperation';
 import RenameOperation from '../model/operation/renameoperation';
 import RenameOperation from '../model/operation/renameoperation';
 import RootAttributeOperation from '../model/operation/rootattributeoperation';
 import RootAttributeOperation from '../model/operation/rootattributeoperation';
-import transform from '../model/operation/transform';
 import Model from '../model/model';
 import Model from '../model/model';
 import ModelDocument from '../model/document';
 import ModelDocument from '../model/document';
 import ModelDocumentFragment from '../model/documentfragment';
 import ModelDocumentFragment from '../model/documentfragment';
@@ -349,24 +348,6 @@ function enableLoggingTools() {
 			`"${ this.key }": ${ JSON.stringify( this.oldValue ) } -> ${ JSON.stringify( this.newValue ) }, ${ this.root.rootName }`;
 			`"${ this.key }": ${ JSON.stringify( this.oldValue ) } -> ${ JSON.stringify( this.newValue ) }, ${ this.root.rootName }`;
 	} );
 	} );
 
 
-	const _transformTransform = transform.transform;
-
-	sandbox.mock( transform, 'transform', function( a, b, context ) {
-		let results;
-
-		try {
-			results = _transformTransform( a, b, context );
-		} catch ( e ) {
-			logger.error( 'Error during operation transformation!' );
-			logger.error( a.toString() + ( context.isStrong ? ' (important)' : '' ) );
-			logger.error( b.toString() + ( context.isStrong ? '' : ' (important)' ) );
-
-			throw e;
-		}
-
-		return results;
-	} );
-
 	sandbox.mock( ViewText.prototype, 'toString', function() {
 	sandbox.mock( ViewText.prototype, 'toString', function() {
 		return `#${ this.data }`;
 		return `#${ this.data }`;
 	} );
 	} );
@@ -446,7 +427,7 @@ function enableReplayerTools() {
 			this._appliedOperations = [];
 			this._appliedOperations = [];
 		}
 		}
 
 
-		this._appliedOperations.push( operation.toJSON() );
+		this._appliedOperations.push( operation );
 
 
 		return _modelApplyOperation.call( this, operation );
 		return _modelApplyOperation.call( this, operation );
 	} );
 	} );
@@ -474,7 +455,7 @@ function enableDocumentTools() {
 			this._operationLogs = [];
 			this._operationLogs = [];
 		}
 		}
 
 
-		this._operationLogs.push( JSON.stringify( operation.toJSON() ) );
+		this._operationLogs.push( JSON.stringify( operation ) );
 
 
 		return _modelApplyOperation.call( this, operation );
 		return _modelApplyOperation.call( this, operation );
 	} );
 	} );

+ 2 - 2
packages/ckeditor5-engine/src/model/documentselection.js

@@ -750,14 +750,14 @@ class LiveSelection extends Selection {
 
 
 		const liveRange = LiveRange.createFromRange( range );
 		const liveRange = LiveRange.createFromRange( range );
 
 
-		liveRange.on( 'change:range', ( evt, oldRange, operation ) => {
+		liveRange.on( 'change:range', ( evt, oldRange, data ) => {
 			this._hasChangedRange = true;
 			this._hasChangedRange = true;
 
 
 			// If `LiveRange` is in whole moved to the graveyard, save necessary data. It will be fixed on `Model#applyOperation` event.
 			// If `LiveRange` is in whole moved to the graveyard, save necessary data. It will be fixed on `Model#applyOperation` event.
 			if ( liveRange.root == this._document.graveyard ) {
 			if ( liveRange.root == this._document.graveyard ) {
 				this._fixGraveyardRangesData.push( {
 				this._fixGraveyardRangesData.push( {
 					liveRange,
 					liveRange,
-					sourcePosition: operation.sourcePosition
+					sourcePosition: data.deletionPosition
 				} );
 				} );
 			}
 			}
 		} );
 		} );

+ 13 - 5
packages/ckeditor5-engine/src/model/liverange.js

@@ -86,9 +86,7 @@ export default class LiveRange extends Range {
 	 * @param {Object} data Object with additional information about the change. Those parameters are passed from
 	 * @param {Object} data Object with additional information about the change. Those parameters are passed from
 	 * {@link module:engine/model/document~Document#event:change document change event}.
 	 * {@link module:engine/model/document~Document#event:change document change event}.
 	 * @param {String} data.type Change type.
 	 * @param {String} data.type Change type.
-	 * @param {module:engine/model/batch~Batch} data.batch Batch which changed the live range.
-	 * @param {module:engine/model/range~Range} data.range Range containing the result of applied change.
-	 * @param {module:engine/model/position~Position} data.sourcePosition Source position for move, remove and reinsert change types.
+	 * @param {module:engine/model/position~Position|null} deletionPosition Source position for move, remove and reinsert change types.
 	 */
 	 */
 
 
 	/**
 	/**
@@ -145,18 +143,28 @@ function transform( operation ) {
 	const result = Range.createFromRanges( ranges );
 	const result = Range.createFromRanges( ranges );
 	const boundariesChanged = !result.isEqual( this );
 	const boundariesChanged = !result.isEqual( this );
 	const contentChanged = doesOperationChangeRangeContent( this, operation );
 	const contentChanged = doesOperationChangeRangeContent( this, operation );
+	let deletionPosition = null;
 
 
 	if ( boundariesChanged ) {
 	if ( boundariesChanged ) {
+		if ( result.root.rootName == '$graveyard' ) {
+			if ( operation.type == 'remove' ) {
+				deletionPosition = operation.sourcePosition;
+			} else {
+				// Merge operation.
+				deletionPosition = operation.deletionPosition;
+			}
+		}
+
 		// If range boundaries have changed, fire `change:range` event.
 		// If range boundaries have changed, fire `change:range` event.
 		const oldRange = Range.createFromRange( this );
 		const oldRange = Range.createFromRange( this );
 
 
 		this.start = result.start;
 		this.start = result.start;
 		this.end = result.end;
 		this.end = result.end;
 
 
-		this.fire( 'change:range', oldRange, operation );
+		this.fire( 'change:range', oldRange, { deletionPosition } );
 	} else if ( contentChanged ) {
 	} else if ( contentChanged ) {
 		// If range boundaries have not changed, but there was change inside the range, fire `change:content` event.
 		// If range boundaries have not changed, but there was change inside the range, fire `change:content` event.
-		this.fire( 'change:content', Range.createFromRange( this ), operation );
+		this.fire( 'change:content', Range.createFromRange( this ), { deletionPosition } );
 	}
 	}
 }
 }
 
 

+ 7 - 1
packages/ckeditor5-engine/src/model/node.js

@@ -380,8 +380,14 @@ export default class Node {
 	toJSON() {
 	toJSON() {
 		const json = {};
 		const json = {};
 
 
+		// Serializes attributes to the object.
+		// attributes = { a: 'foo', b: 1, c: true }.
 		if ( this._attrs.size ) {
 		if ( this._attrs.size ) {
-			json.attributes = [ ...this._attrs ];
+			json.attributes = Array.from( this._attrs ).reduce( ( result, attr ) => {
+				result[ attr[ 0 ] ] = attr[ 1 ];
+
+				return result;
+			}, {} );
 		}
 		}
 
 
 		return json;
 		return json;

+ 11 - 0
packages/ckeditor5-engine/src/model/operation/attributeoperation.js

@@ -107,6 +107,17 @@ export default class AttributeOperation extends Operation {
 		return new AttributeOperation( this.range, this.key, this.newValue, this.oldValue, this.baseVersion + 1 );
 		return new AttributeOperation( this.range, this.key, this.newValue, this.oldValue, this.baseVersion + 1 );
 	}
 	}
 
 
+	/**
+	 * @inheritDoc
+	 */
+	toJSON() {
+		const json = super.toJSON();
+
+		json.range = this.range.toJSON();
+
+		return json;
+	}
+
 	/**
 	/**
 	 * @inheritDoc
 	 * @inheritDoc
 	 */
 	 */

+ 11 - 1
packages/ckeditor5-engine/src/model/operation/detachoperation.js

@@ -53,6 +53,17 @@ export default class DetachOperation extends Operation {
 		return 'detach';
 		return 'detach';
 	}
 	}
 
 
+	/**
+	 * @inheritDoc
+	 */
+	toJSON() {
+		const json = super.toJSON();
+
+		json.sourcePosition = this.sourcePosition.toJSON();
+
+		return json;
+	}
+
 	/**
 	/**
 	 * @inheritDoc
 	 * @inheritDoc
 	 */
 	 */
@@ -60,7 +71,6 @@ export default class DetachOperation extends Operation {
 		if ( this.sourcePosition.root.document ) {
 		if ( this.sourcePosition.root.document ) {
 			/**
 			/**
 			 * Cannot detach document node.
 			 * Cannot detach document node.
-			 * Use {@link module:engine/model/operation/removeoperation~RemoveOperation remove operation} instead.
 			 *
 			 *
 			 * @error detach-operation-on-document-node
 			 * @error detach-operation-on-document-node
 			 */
 			 */

+ 15 - 3
packages/ckeditor5-engine/src/model/operation/insertoperation.js

@@ -10,7 +10,7 @@
 import Operation from './operation';
 import Operation from './operation';
 import Position from '../position';
 import Position from '../position';
 import NodeList from '../nodelist';
 import NodeList from '../nodelist';
-import RemoveOperation from './removeoperation';
+import MoveOperation from './moveoperation';
 import { _insert, _normalizeNodes } from './utils';
 import { _insert, _normalizeNodes } from './utils';
 import Text from '../text';
 import Text from '../text';
 import Element from '../element';
 import Element from '../element';
@@ -87,13 +87,13 @@ export default class InsertOperation extends Operation {
 	/**
 	/**
 	 * See {@link module:engine/model/operation/operation~Operation#getReversed `Operation#getReversed()`}.
 	 * See {@link module:engine/model/operation/operation~Operation#getReversed `Operation#getReversed()`}.
 	 *
 	 *
-	 * @returns {module:engine/model/operation/removeoperation~RemoveOperation}
+	 * @returns {module:engine/model/operation/moveoperation~MoveOperation}
 	 */
 	 */
 	getReversed() {
 	getReversed() {
 		const graveyard = this.position.root.document.graveyard;
 		const graveyard = this.position.root.document.graveyard;
 		const gyPosition = new Position( graveyard, [ 0 ] );
 		const gyPosition = new Position( graveyard, [ 0 ] );
 
 
-		return new RemoveOperation( this.position, this.nodes.maxOffset, gyPosition, this.baseVersion + 1 );
+		return new MoveOperation( this.position, this.nodes.maxOffset, gyPosition, this.baseVersion + 1 );
 	}
 	}
 
 
 	/**
 	/**
@@ -128,6 +128,18 @@ export default class InsertOperation extends Operation {
 		_insert( this.position, originalNodes );
 		_insert( this.position, originalNodes );
 	}
 	}
 
 
+	/**
+	 * @inheritDoc
+	 */
+	toJSON() {
+		const json = super.toJSON();
+
+		json.position = this.position.toJSON();
+		json.nodes = this.nodes.toJSON();
+
+		return json;
+	}
+
 	/**
 	/**
 	 * @inheritDoc
 	 * @inheritDoc
 	 */
 	 */

+ 8 - 0
packages/ckeditor5-engine/src/model/operation/markeroperation.js

@@ -109,6 +109,14 @@ export default class MarkerOperation extends Operation {
 	toJSON() {
 	toJSON() {
 		const json = super.toJSON();
 		const json = super.toJSON();
 
 
+		if ( this.oldRange ) {
+			json.oldRange = this.oldRange.toJSON();
+		}
+
+		if ( this.newRange ) {
+			json.newRange = this.newRange.toJSON();
+		}
+
 		delete json._markers;
 		delete json._markers;
 
 
 		return json;
 		return json;

+ 27 - 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
 	 * @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}.
 	 * 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} 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 {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
 	 * @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.
 	 * 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 );
 		super( baseVersion );
 
 
 		/**
 		/**
@@ -56,6 +57,8 @@ export default class MergeOperation extends Operation {
 		// is it? think about reversed split operations, undo, etc.
 		// is it? think about reversed split operations, undo, etc.
 
 
 		this.graveyardPosition = Position.createFromPosition( graveyardPosition );
 		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.
 	 * @returns {module:engine/model/operation/mergeoperation~MergeOperation} Clone of this operation.
 	 */
 	 */
 	clone() {
 	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}
 	 * @returns {module:engine/model/operation/splitoperation~SplitOperation}
 	 */
 	 */
 	getReversed() {
 	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
 			 * @error merge-operation-target-position-invalid
 			 */
 			 */
 			throw new CKEditorError( 'merge-operation-target-position-invalid: Merge target position is 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.' );
 		}
 		}
 	}
 	}
 
 
@@ -142,6 +152,19 @@ export default class MergeOperation extends Operation {
 		_move( Range.createOn( mergedElement ), this.graveyardPosition );
 		_move( Range.createOn( mergedElement ), this.graveyardPosition );
 	}
 	}
 
 
+	/**
+	 * @inheritDoc
+	 */
+	toJSON() {
+		const json = super.toJSON();
+
+		json.sourcePosition = json.sourcePosition.toJSON();
+		json.targetPosition = json.targetPosition.toJSON();
+		json.graveyardPosition = json.graveyardPosition.toJSON();
+
+		return json;
+	}
+
 	/**
 	/**
 	 * @inheritDoc
 	 * @inheritDoc
 	 */
 	 */
@@ -161,6 +184,6 @@ export default class MergeOperation extends Operation {
 		const targetPosition = Position.fromJSON( json.targetPosition, document );
 		const targetPosition = Position.fromJSON( json.targetPosition, document );
 		const graveyardPosition = Position.fromJSON( json.graveyardPosition, 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 );
 	}
 	}
 }
 }

+ 18 - 0
packages/ckeditor5-engine/src/model/operation/moveoperation.js

@@ -64,6 +64,12 @@ export default class MoveOperation extends Operation {
 	 * @inheritDoc
 	 * @inheritDoc
 	 */
 	 */
 	get type() {
 	get type() {
+		if ( this.targetPosition.root.rootName == '$graveyard' ) {
+			return 'remove';
+		} else if ( this.sourcePosition.root.rootName == '$graveyard' ) {
+			return 'reinsert';
+		}
+
 		return 'move';
 		return 'move';
 	}
 	}
 
 
@@ -169,6 +175,18 @@ export default class MoveOperation extends Operation {
 		_move( Range.createFromPositionAndShift( this.sourcePosition, this.howMany ), this.targetPosition );
 		_move( Range.createFromPositionAndShift( this.sourcePosition, this.howMany ), this.targetPosition );
 	}
 	}
 
 
+	/**
+	 * @inheritDoc
+	 */
+	toJSON() {
+		const json = super.toJSON();
+
+		json.sourcePosition = this.sourcePosition.toJSON();
+		json.targetPosition = this.targetPosition.toJSON();
+
+		return json;
+	}
+
 	/**
 	/**
 	 * @inheritDoc
 	 * @inheritDoc
 	 */
 	 */

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

@@ -7,8 +7,6 @@
  * @module engine/model/operation/operation
  * @module engine/model/operation/operation
  */
  */
 
 
-import { clone } from 'lodash-es';
-
 /**
 /**
  * Abstract base operation class.
  * Abstract base operation class.
  *
  *
@@ -94,7 +92,9 @@ export default class Operation {
 	 * @returns {Object} Clone of this object with the operation property replaced with string.
 	 * @returns {Object} Clone of this object with the operation property replaced with string.
 	 */
 	 */
 	toJSON() {
 	toJSON() {
-		const json = clone( this, true );
+		// This method creates only a shallow copy, all nested objects should be defined separately.
+		// See https://github.com/ckeditor/ckeditor5-engine/issues/1477.
+		const json = Object.assign( {}, this );
 
 
 		json.__className = this.constructor.className;
 		json.__className = this.constructor.className;
 
 

+ 0 - 4
packages/ckeditor5-engine/src/model/operation/operationfactory.js

@@ -13,8 +13,6 @@ import MarkerOperation from '../operation/markeroperation';
 import MoveOperation from '../operation/moveoperation';
 import MoveOperation from '../operation/moveoperation';
 import NoOperation from '../operation/nooperation';
 import NoOperation from '../operation/nooperation';
 import Operation from '../operation/operation';
 import Operation from '../operation/operation';
-import ReinsertOperation from '../operation/reinsertoperation';
-import RemoveOperation from '../operation/removeoperation';
 import RenameOperation from '../operation/renameoperation';
 import RenameOperation from '../operation/renameoperation';
 import RootAttributeOperation from '../operation/rootattributeoperation';
 import RootAttributeOperation from '../operation/rootattributeoperation';
 import SplitOperation from '../operation/splitoperation';
 import SplitOperation from '../operation/splitoperation';
@@ -29,8 +27,6 @@ operations[ MarkerOperation.className ] = MarkerOperation;
 operations[ MoveOperation.className ] = MoveOperation;
 operations[ MoveOperation.className ] = MoveOperation;
 operations[ NoOperation.className ] = NoOperation;
 operations[ NoOperation.className ] = NoOperation;
 operations[ Operation.className ] = Operation;
 operations[ Operation.className ] = Operation;
-operations[ ReinsertOperation.className ] = ReinsertOperation;
-operations[ RemoveOperation.className ] = RemoveOperation;
 operations[ RenameOperation.className ] = RenameOperation;
 operations[ RenameOperation.className ] = RenameOperation;
 operations[ RootAttributeOperation.className ] = RootAttributeOperation;
 operations[ RootAttributeOperation.className ] = RootAttributeOperation;
 operations[ SplitOperation.className ] = SplitOperation;
 operations[ SplitOperation.className ] = SplitOperation;

+ 0 - 76
packages/ckeditor5-engine/src/model/operation/reinsertoperation.js

@@ -1,76 +0,0 @@
-/**
- * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/**
- * @module engine/model/operation/reinsertoperation
- */
-
-import MoveOperation from './moveoperation';
-import RemoveOperation from './removeoperation';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
-
-/**
- * Operation to reinsert previously removed nodes back to the non-graveyard root. This operation acts like
- * {@link module:engine/model/operation/moveoperation~MoveOperation} but it returns
- * {@link module:engine/model/operation/removeoperation~RemoveOperation} when reversed
- * and fires different change event.
- */
-export default class ReinsertOperation extends MoveOperation {
-	/**
-	 * Position where nodes will be re-inserted.
-	 *
-	 * @type {module:engine/model/position~Position}
-	 */
-	get position() {
-		return this.targetPosition;
-	}
-
-	/**
-	 * @param {module:engine/model/position~Position} pos
-	 */
-	set position( pos ) {
-		this.targetPosition = pos;
-	}
-
-	/**
-	 * @inheritDoc
-	 */
-	get type() {
-		return 'reinsert';
-	}
-
-	/**
-	 * See {@link module:engine/model/operation/operation~Operation#getReversed `Operation#getReversed()`}.
-	 *
-	 * @returns {module:engine/model/operation/removeoperation~RemoveOperation}
-	 */
-	getReversed() {
-		const newTargetPosition = this.sourcePosition._getTransformedByInsertion( this.targetPosition, this.howMany );
-
-		return new RemoveOperation( this.getMovedRangeStart(), this.howMany, newTargetPosition, this.baseVersion + 1 );
-	}
-
-	/**
-	 * @inheritDoc
-	 */
-	_validate() {
-		super._validate();
-
-		if ( !this.sourcePosition.root.document ) {
-			throw new CKEditorError( 'reinsert-operation-on-detached-item: Cannot reinsert detached item.' );
-		}
-
-		if ( !this.targetPosition.root.document ) {
-			throw new CKEditorError( 'reinsert-operation-to-detached-parent: Cannot reinsert item to detached parent.' );
-		}
-	}
-
-	/**
-	 * @inheritDoc
-	 */
-	static get className() {
-		return 'engine.model.operation.ReinsertOperation';
-	}
-}

+ 0 - 60
packages/ckeditor5-engine/src/model/operation/removeoperation.js

@@ -1,60 +0,0 @@
-/**
- * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/**
- * @module engine/model/operation/removeoperation
- */
-
-import MoveOperation from './moveoperation';
-import ReinsertOperation from './reinsertoperation';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
-
-/**
- * Operation to remove a range of nodes.
- */
-export default class RemoveOperation extends MoveOperation {
-	/**
-	 * @inheritDoc
-	 */
-	get type() {
-		return 'remove';
-	}
-
-	/**
-	 * See {@link module:engine/model/operation/operation~Operation#getReversed `Operation#getReversed()`}.
-	 *
-	 * @returns {module:engine/model/operation/reinsertoperation~ReinsertOperation|module:engine/model/operation/nooperation~NoOperation}
-	 */
-	getReversed() {
-		const newTargetPosition = this.sourcePosition._getTransformedByInsertion( this.targetPosition, this.howMany );
-
-		return new ReinsertOperation( this.getMovedRangeStart(), this.howMany, newTargetPosition, this.baseVersion + 1 );
-	}
-
-	/**
-	 * @inheritDoc
-	 */
-	_validate() {
-		super._validate();
-
-		if ( !this.sourcePosition.root.document ) {
-			/**
-			 * Item that is going to be removed needs to be a {@link module:engine/model/document~Document document} child.
-			 * To remove Item from detached document fragment use
-			 * {@link module:engine/model/operation/detachoperation~DetachOperation DetachOperation}.
-			 *
-			 * @error remove-operation-on-detached-item
-			 */
-			throw new CKEditorError( 'remove-operation-on-detached-item: Cannot remove detached item.' );
-		}
-	}
-
-	/**
-	 * @inheritDoc
-	 */
-	static get className() {
-		return 'engine.model.operation.RemoveOperation';
-	}
-}

+ 11 - 0
packages/ckeditor5-engine/src/model/operation/renameoperation.js

@@ -116,6 +116,17 @@ export default class RenameOperation extends Operation {
 		element.name = this.newName;
 		element.name = this.newName;
 	}
 	}
 
 
+	/**
+	 * @inheritDoc
+	 */
+	toJSON() {
+		const json = super.toJSON();
+
+		json.position = this.position.toJSON();
+
+		return json;
+	}
+
 	/**
 	/**
 	 * @inheritDoc
 	 * @inheritDoc
 	 */
 	 */

+ 12 - 1
packages/ckeditor5-engine/src/model/operation/rootattributeoperation.js

@@ -162,6 +162,17 @@ export default class RootAttributeOperation extends Operation {
 		}
 		}
 	}
 	}
 
 
+	/**
+	 * @inheritDoc
+	 */
+	toJSON() {
+		const json = super.toJSON();
+
+		json.root = this.root.toJSON();
+
+		return json;
+	}
+
 	/**
 	/**
 	 * @inheritDoc
 	 * @inheritDoc
 	 */
 	 */
@@ -186,7 +197,7 @@ export default class RootAttributeOperation extends Operation {
 			 */
 			 */
 			throw new CKEditorError(
 			throw new CKEditorError(
 				'rootattribute-operation-fromjson-no-root: Cannot create RootAttributeOperation. Root with specified name does not exist.',
 				'rootattribute-operation-fromjson-no-root: Cannot create RootAttributeOperation. Root with specified name does not exist.',
-				{ rootName: json }
+				{ rootName: json.root }
 			);
 			);
 		}
 		}
 
 

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

@@ -26,12 +26,13 @@ export default class SplitOperation extends Operation {
 	 * Creates a split operation.
 	 * Creates a split operation.
 	 *
 	 *
 	 * @param {module:engine/model/position~Position} position Position at which an element should be split.
 	 * @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
 	 * @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.
 	 * 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
 	 * @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.
 	 * 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 );
 		super( baseVersion );
 
 
 		/**
 		/**
@@ -43,6 +44,12 @@ export default class SplitOperation extends Operation {
 		this.position.stickiness = 'toNext';
 		this.position.stickiness = 'toNext';
 
 
 		this.graveyardPosition = graveyardPosition ? Position.createFromPosition( graveyardPosition ) : null;
 		this.graveyardPosition = graveyardPosition ? Position.createFromPosition( graveyardPosition ) : null;
+
+		if ( this.graveyardPosition ) {
+			this.graveyardPosition.stickiness = 'toNext';
+		}
+
+		this.howMany = howMany;
 	}
 	}
 
 
 	/**
 	/**
@@ -100,7 +107,7 @@ export default class SplitOperation extends Operation {
 	 * @returns {module:engine/model/operation/splitoperation~SplitOperation} Clone of this operation.
 	 * @returns {module:engine/model/operation/splitoperation~SplitOperation} Clone of this operation.
 	 */
 	 */
 	clone() {
 	clone() {
-		return new this.constructor( this.position, this.graveyardPosition, this.baseVersion );
+		return new this.constructor( this.position, this.howMany, this.graveyardPosition, this.baseVersion );
 	}
 	}
 
 
 	/**
 	/**
@@ -112,7 +119,7 @@ export default class SplitOperation extends Operation {
 		const graveyard = this.position.root.document.graveyard;
 		const graveyard = this.position.root.document.graveyard;
 		const graveyardPosition = new Position( graveyard, [ 0 ] );
 		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 );
 	}
 	}
 
 
 	/**
 	/**
@@ -130,6 +137,13 @@ export default class SplitOperation extends Operation {
 			 * @error split-operation-position-invalid
 			 * @error split-operation-position-invalid
 			 */
 			 */
 			throw new CKEditorError( 'split-operation-position-invalid: Split position is 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.' );
 		}
 		}
 	}
 	}
 
 
@@ -152,6 +166,21 @@ export default class SplitOperation extends Operation {
 		_move( sourceRange, this.moveTargetPosition );
 		_move( sourceRange, this.moveTargetPosition );
 	}
 	}
 
 
+	/**
+	 * @inheritDoc
+	 */
+	toJSON() {
+		const json = super.toJSON();
+
+		json.position = this.position.toJSON();
+
+		if ( this.graveyardPosition ) {
+			json.graveyardPosition = this.graveyardPosition.toJSON();
+		}
+
+		return json;
+	}
+
 	/**
 	/**
 	 * @inheritDoc
 	 * @inheritDoc
 	 */
 	 */
@@ -170,6 +199,6 @@ export default class SplitOperation extends Operation {
 		const position = Position.fromJSON( json.position, document );
 		const position = Position.fromJSON( json.position, document );
 		const graveyardPosition = json.graveyardPosition ? Position.fromJSON( json.graveyardPosition, document ) : null;
 		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 );
 	}
 	}
 }
 }

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 463 - 163
packages/ckeditor5-engine/src/model/operation/transform.js


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

@@ -130,6 +130,18 @@ export default class UnwrapOperation extends Operation {
 		_move( Range.createOn( elementToUnwrap ), this.graveyardPosition );
 		_move( Range.createOn( elementToUnwrap ), this.graveyardPosition );
 	}
 	}
 
 
+	/**
+	 * @inheritDoc
+	 */
+	toJSON() {
+		const json = super.toJSON();
+
+		json.position = this.position.toJSON();
+		json.graveyardPosition = this.graveyardPosition.toJSON();
+
+		return json;
+	}
+
 	/**
 	/**
 	 * @inheritDoc
 	 * @inheritDoc
 	 */
 	 */

+ 29 - 3
packages/ckeditor5-engine/src/model/operation/wrapoperation.js

@@ -30,8 +30,8 @@ export default class WrapOperation extends Operation {
 	 * the first {@link module:engine/model/item~Item model item} to wrap.
 	 * the first {@link module:engine/model/item~Item model item} to wrap.
 	 * @param {Number} howMany Offset size of wrapped range. Wrapped range will start at `position.offset` and end at
 	 * @param {Number} howMany Offset size of wrapped range. Wrapped range will start at `position.offset` and end at
 	 * `position.offset + howMany`.
 	 * `position.offset + howMany`.
-	 * @param {module:engine/model/element~Element|module:engine/model/position~Position} elementOrPosition Element to
-	 * wrap with or position in graveyard before the element which should be used as a wrapper.
+	 * @param {module:engine/model/element~Element|module:engine/model/position~Position} elementOrPosition Wrapper
+	 * element or position in graveyard before the element which should be used as a wrapper.
 	 * @param {Number|null} baseVersion Document {@link module:engine/model/document~Document#version} on which operation
 	 * @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.
 	 * can be applied or `null` if the operation operates on detached (non-document) tree.
 	 */
 	 */
@@ -62,6 +62,10 @@ export default class WrapOperation extends Operation {
 		this.element = elementOrPosition instanceof Element ? elementOrPosition : null;
 		this.element = elementOrPosition instanceof Element ? elementOrPosition : null;
 
 
 		this.graveyardPosition = elementOrPosition instanceof Element ? null : Position.createFromPosition( elementOrPosition );
 		this.graveyardPosition = elementOrPosition instanceof Element ? null : Position.createFromPosition( elementOrPosition );
+
+		if ( this.graveyardPosition ) {
+			this.graveyardPosition.stickiness = 'toNext';
+		}
 	}
 	}
 
 
 	/**
 	/**
@@ -148,7 +152,10 @@ export default class WrapOperation extends Operation {
 		const targetPosition = new Position( this.position.root, targetPath );
 		const targetPosition = new Position( this.position.root, targetPath );
 
 
 		if ( this.element ) {
 		if ( this.element ) {
-			_insert( insertPosition, this.element._clone() );
+			const originalElement = this.element;
+			this.element = this.element._clone();
+
+			_insert( insertPosition, originalElement );
 		} else {
 		} else {
 			_move( Range.createFromPositionAndShift( this.graveyardPosition, 1 ), insertPosition );
 			_move( Range.createFromPositionAndShift( this.graveyardPosition, 1 ), insertPosition );
 		}
 		}
@@ -156,6 +163,25 @@ export default class WrapOperation extends Operation {
 		_move( wrappedRange, targetPosition );
 		_move( wrappedRange, targetPosition );
 	}
 	}
 
 
+	/**
+	 * @inheritDoc
+	 */
+	toJSON() {
+		const json = super.toJSON();
+
+		json.position = this.position.toJSON();
+
+		if ( this.element ) {
+			json.element = this.element.toJSON();
+			delete json.graveyardPosition;
+		} else {
+			json.graveyardPosition = this.graveyardPosition.toJSON();
+			delete json.element;
+		}
+
+		return json;
+	}
+
 	/**
 	/**
 	 * @inheritDoc
 	 * @inheritDoc
 	 */
 	 */

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

@@ -563,6 +563,8 @@ export default class Position {
 				// Above happens during OT when the merged element is moved before the merged-to element.
 				// Above happens during OT when the merged element is moved before the merged-to element.
 				pos = pos._getTransformedByDeletion( operation.deletionPosition, 1 );
 				pos = pos._getTransformedByDeletion( operation.deletionPosition, 1 );
 			}
 			}
+		} else if ( this.isEqual( operation.deletionPosition ) ) {
+			pos = Position.createFromPosition( operation.deletionPosition );
 		} else {
 		} else {
 			pos = this._getTransformedByMove( operation.deletionPosition, operation.graveyardPosition, 1 );
 			pos = this._getTransformedByMove( operation.deletionPosition, operation.graveyardPosition, 1 );
 		}
 		}
@@ -599,15 +601,24 @@ export default class Position {
 			unwrappedRange.start.isEqual( this ) ||
 			unwrappedRange.start.isEqual( this ) ||
 			unwrappedRange.end.isEqual( this );
 			unwrappedRange.end.isEqual( this );
 
 
+		let pos;
+
 		if ( isContained ) {
 		if ( isContained ) {
-			return this._getCombined( operation.position, operation.targetPosition );
+			pos = this._getCombined( operation.position, operation.targetPosition );
 		} else if ( this.isEqual( operation.targetPosition ) ) {
 		} else if ( this.isEqual( operation.targetPosition ) ) {
-			return Position.createFromPosition( this );
+			pos = Position.createFromPosition( this );
 		} else {
 		} else {
-			const pos = this._getTransformedByInsertion( operation.targetPosition, operation.howMany - 1 );
+			pos = this._getTransformedByInsertion( operation.targetPosition, operation.howMany );
+		}
+
+		const targetPosition = operation.targetPosition.getShiftedBy( operation.howMany );
 
 
-			return pos._getTransformedByInsertion( operation.graveyardPosition, 1 );
+		if ( !targetPosition.isEqual( operation.graveyardPosition ) ) {
+			pos = pos._getTransformedByDeletion( targetPosition, 1 );
+			pos = pos._getTransformedByInsertion( operation.graveyardPosition, 1 );
 		}
 		}
+
+		return pos;
 	}
 	}
 
 
 	/**
 	/**
@@ -706,12 +717,17 @@ export default class Position {
 	 * @returns {module:engine/model/position~Position} Transformed position.
 	 * @returns {module:engine/model/position~Position} Transformed position.
 	 */
 	 */
 	_getTransformedByMove( sourcePosition, targetPosition, howMany ) {
 	_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 );
 		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 ||
 		const isMoved = transformed === null ||
 			( sourcePosition.isEqual( this ) && this.stickiness == 'toNext' ) ||
 			( sourcePosition.isEqual( this ) && this.stickiness == 'toNext' ) ||
 			( sourcePosition.getShiftedBy( howMany ).isEqual( this ) && this.stickiness == 'toPrevious' );
 			( sourcePosition.getShiftedBy( howMany ).isEqual( this ) && this.stickiness == 'toPrevious' );
@@ -719,14 +735,13 @@ export default class Position {
 		if ( isMoved ) {
 		if ( isMoved ) {
 			// This position is inside moved range (or sticks to it).
 			// 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.
 			// 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 {
 		} else {
 			// This position is not inside a removed range.
 			// This position is not inside a removed range.
+			//
 			// In next step, we simply reflect inserting `howMany` nodes, which might further affect the position.
 			// 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;
 	}
 	}
 
 
 	/**
 	/**
@@ -761,6 +776,7 @@ export default class Position {
 
 
 		// The first part of a path to combined position is a path to the place where nodes were moved.
 		// The first part of a path to combined position is a path to the place where nodes were moved.
 		const combined = Position.createFromPosition( target );
 		const combined = Position.createFromPosition( target );
+		combined.stickiness = this.stickiness;
 
 
 		// Then we have to update the rest of the path.
 		// Then we have to update the rest of the path.
 
 
@@ -774,6 +790,17 @@ export default class Position {
 		return combined;
 		return combined;
 	}
 	}
 
 
+	/**
+	 * @inheritDoc
+	 */
+	toJSON() {
+		return {
+			root: this.root.toJSON(),
+			path: Array.from( this.path ),
+			stickiness: this.stickiness
+		};
+	}
+
 	/**
 	/**
 	 * Creates position at the given location. The location can be specified as:
 	 * Creates position at the given location. The location can be specified as:
 	 *
 	 *

+ 51 - 6
packages/ckeditor5-engine/src/model/range.js

@@ -469,6 +469,18 @@ export default class Range {
 		return this.start.getCommonAncestor( this.end );
 		return this.start.getCommonAncestor( this.end );
 	}
 	}
 
 
+	/**
+	 * Converts `Range` to plain object and returns it.
+	 *
+	 * @returns {Object} `Node` converted to plain object.
+	 */
+	toJSON() {
+		return {
+			start: this.start.toJSON(),
+			end: this.end.toJSON()
+		};
+	}
+
 	_getTransformedByInsertOperation( operation, spread = false ) {
 	_getTransformedByInsertOperation( operation, spread = false ) {
 		return this._getTransformedByInsertion( operation.position, operation.howMany, spread );
 		return this._getTransformedByInsertion( operation.position, operation.howMany, spread );
 	}
 	}
@@ -493,16 +505,49 @@ export default class Range {
 	}
 	}
 
 
 	_getTransformedByMergeOperation( operation ) {
 	_getTransformedByMergeOperation( operation ) {
-		const start = this.start._getTransformedByMergeOperation( operation );
-		const end = this.end._getTransformedByMergeOperation( operation );
+		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 ) ) {
 		if ( start.isAfter( end ) ) {
-			// This happens in the following case:
+			// This happens in the following two, similar cases:
 			//
 			//
-			// <p>abc</p>{<p>x}yz</p> -> <p>abcx}yz</p>{
-			//                           <p>abcx{yz</p>}
+			// Case 1: Range start is directly before merged node.
+			//         Resulting range should include only nodes from the merged element:
 			//
 			//
-			return new Range( end, start );
+			// Before: <p>aa</p>{<p>b}b</p><p>cc</p>
+			// Merge:  <p>aab}b</p>{<p>cc</p>
+			// Fix:    <p>aa{b}b</p><p>cc</p>
+			//
+			// Case 2: Range start is not directly before merged node.
+			//         Result should include all nodes that were in the original range.
+			//
+			// Before: <p>aa</p>{<p>cc</p><p>b}b</p>
+			// Merge:  <p>aab}b</p>{<p>cc</p>
+			// Fix:    <p>aa{bb</p><p>cc</p>}
+			//
+			//         The range is expanded by an additional `b` letter but it is better than dropping the whole `cc` paragraph.
+			//
+			if ( !operation.deletionPosition.isEqual( start ) ) {
+				// Case 2.
+				end = operation.deletionPosition;
+			}
+
+			// In both cases start is at the end of the merge-to element.
+			start = operation.targetPosition;
+
+			return new Range( start, end );
 		}
 		}
 
 
 		return new Range( start, end );
 		return new Range( start, end );

+ 46 - 19
packages/ckeditor5-engine/src/model/schema.js

@@ -665,38 +665,54 @@ export default class Schema {
 	 *
 	 *
 	 * @param {Array.<module:engine/model/range~Range>} ranges Ranges to be validated.
 	 * @param {Array.<module:engine/model/range~Range>} ranges Ranges to be validated.
 	 * @param {String} attribute The name of the attribute to check.
 	 * @param {String} attribute The name of the attribute to check.
-	 * @returns {Array.<module:engine/model/range~Range>} Ranges in which the attribute is allowed.
+	 * @returns {Iterator.<module:engine/model/range~Range>} Ranges in which the attribute is allowed.
 	 */
 	 */
-	getValidRanges( ranges, attribute ) {
-		const validRanges = [];
+	* getValidRanges( ranges, attribute ) {
+		ranges = convertToMinimalFlatRanges( ranges );
 
 
 		for ( const range of ranges ) {
 		for ( const range of ranges ) {
-			let last = range.start;
-			let from = range.start;
-			const to = range.end;
-
-			for ( const value of range.getWalker() ) {
-				if ( !this.checkAttribute( value.item, attribute ) ) {
-					if ( !from.isEqual( last ) ) {
-						validRanges.push( new Range( from, last ) );
-					}
+			yield* this._getValidRangesForRange( range, attribute );
+		}
+	}
 
 
-					from = value.nextPosition;
-				}
+	/**
+	 * Takes a flat range and an attribute name. Traverses the range recursively and deeply to find and return all ranges
+	 * inside the given range on which the attribute can be applied.
+	 *
+	 * This is a helper function for {@link ~Schema#getValidRanges}.
+	 *
+	 * @private
+	 * @param {module:engine/model/range~Range} range Range to process.
+	 * @param {String} attribute The name of the attribute to check.
+	 * @returns {Iterator.<module:engine/model/range~Range>} Ranges in which the attribute is allowed.
+	 */
+	* _getValidRangesForRange( range, attribute ) {
+		let start = range.start;
+		let end = range.start;
 
 
-				last = value.nextPosition;
+		for ( const item of range.getItems( { shallow: true } ) ) {
+			if ( item.is( 'element' ) ) {
+				yield* this._getValidRangesForRange( Range.createIn( item ), attribute );
 			}
 			}
 
 
-			if ( from && !from.isEqual( to ) ) {
-				validRanges.push( new Range( from, to ) );
+			if ( !this.checkAttribute( item, attribute ) ) {
+				if ( !start.isEqual( end ) ) {
+					yield new Range( start, end );
+				}
+
+				start = Position.createAfter( item );
 			}
 			}
+
+			end = Position.createAfter( item );
 		}
 		}
 
 
-		return validRanges;
+		if ( !start.isEqual( end ) ) {
+			yield new Range( start, end );
+		}
 	}
 	}
 
 
 	/**
 	/**
-	 * Basing on given the `position`, finds and returns a {@link module:engine/model/range~Range range} which is
+	 * Basing on given `position`, finds and returns a {@link module:engine/model/range~Range range} which is
 	 * nearest to that `position` and is a correct range for selection.
 	 * nearest to that `position` and is a correct range for selection.
 	 *
 	 *
 	 * The correct selection range might be collapsed when it is located in a position where the text node can be placed.
 	 * The correct selection range might be collapsed when it is located in a position where the text node can be placed.
@@ -1572,3 +1588,14 @@ function* combineWalkers( backward, forward ) {
 		}
 		}
 	}
 	}
 }
 }
+
+// Takes an array of non-intersecting ranges. For each of them gets minimal flat ranges covering that range and returns
+// all those minimal flat ranges.
+//
+// @param {Array.<module:engine/model/range~Range>} ranges Ranges to process.
+// @returns {Iterator.<module:engine/model/range~Range>} Minimal flat ranges of given `ranges`.
+function* convertToMinimalFlatRanges( ranges ) {
+	for ( const range of ranges ) {
+		yield* range.getMinimalFlatRanges();
+	}
+}

+ 15 - 1
packages/ckeditor5-engine/src/model/selection.js

@@ -431,7 +431,21 @@ export default class Selection {
 		// Check whether there is any range in new ranges set that is different than all already added ranges.
 		// Check whether there is any range in new ranges set that is different than all already added ranges.
 		const anyNewRange = newRanges.some( newRange => {
 		const anyNewRange = newRanges.some( newRange => {
 			if ( !( newRange instanceof Range ) ) {
 			if ( !( newRange instanceof Range ) ) {
-				throw new CKEditorError( 'model-selection-added-not-range: Trying to add an object that is not an instance of Range.' );
+				/**
+				 * Selection range set to an object that is not an instance of {@link module:engine/model/range~Range}.
+				 *
+				 * Only {@link module:engine/model/range~Range} instances can be used to set a selection.
+				 * Common mistakes leading to this error are:
+				 *
+				 * * using DOM `Range` object,
+				 * * incorrect CKEditor 5 installation with multiple `ckeditor5-engine` packages having different versions.
+				 *
+				 * @error model-selection-set-ranges-not-range
+				 */
+				throw new CKEditorError(
+					'model-selection-set-ranges-not-range: ' +
+					'Selection range set to an object that is not an instance of model.Range.'
+				);
 			}
 			}
 
 
 			return this._ranges.every( oldRange => {
 			return this._ranges.every( oldRange => {

+ 5 - 5
packages/ckeditor5-engine/src/model/writer.js

@@ -12,7 +12,6 @@ import DetachOperation from './operation/detachoperation';
 import InsertOperation from './operation/insertoperation';
 import InsertOperation from './operation/insertoperation';
 import MarkerOperation from './operation/markeroperation';
 import MarkerOperation from './operation/markeroperation';
 import MoveOperation from './operation/moveoperation';
 import MoveOperation from './operation/moveoperation';
-import RemoveOperation from './operation/removeoperation';
 import RenameOperation from './operation/renameoperation';
 import RenameOperation from './operation/renameoperation';
 import RootAttributeOperation from './operation/rootattributeoperation';
 import RootAttributeOperation from './operation/rootattributeoperation';
 import SplitOperation from './operation/splitoperation';
 import SplitOperation from './operation/splitoperation';
@@ -568,7 +567,7 @@ export default class Writer {
 
 
 		const version = position.root.document.version;
 		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.batch.addOperation( merge );
 		this.model.applyOperation( merge );
 		this.model.applyOperation( merge );
@@ -645,7 +644,8 @@ export default class Writer {
 
 
 		do {
 		do {
 			const version = splitElement.root.document ? splitElement.root.document.version : null;
 			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.batch.addOperation( split );
 			this.model.applyOperation( split );
 			this.model.applyOperation( split );
@@ -1330,7 +1330,7 @@ function applyMarkerOperation( writer, name, oldRange, newRange, affectsData ) {
 	model.applyOperation( operation );
 	model.applyOperation( operation );
 }
 }
 
 
-// Creates `RemoveOperation` or `DetachOperation` that removes `howMany` nodes starting from `position`.
+// Creates `MoveOperation` or `DetachOperation` that removes `howMany` nodes starting from `position`.
 // The operation will be applied on given model instance and added to given operation instance.
 // The operation will be applied on given model instance and added to given operation instance.
 //
 //
 // @private
 // @private
@@ -1345,7 +1345,7 @@ function applyRemoveOperation( position, howMany, batch, model ) {
 		const doc = model.document;
 		const doc = model.document;
 		const graveyardPosition = new Position( doc.graveyard, [ 0 ] );
 		const graveyardPosition = new Position( doc.graveyard, [ 0 ] );
 
 
-		operation = new RemoveOperation( position, howMany, graveyardPosition, doc.version );
+		operation = new MoveOperation( position, howMany, graveyardPosition, doc.version );
 	} else {
 	} else {
 		operation = new DetachOperation( position, howMany );
 		operation = new DetachOperation( position, howMany );
 	}
 	}

+ 9 - 1
packages/ckeditor5-engine/src/view/selection.js

@@ -643,7 +643,15 @@ export default class Selection {
 	 */
 	 */
 	_addRange( range, isBackward = false ) {
 	_addRange( range, isBackward = false ) {
 		if ( !( range instanceof Range ) ) {
 		if ( !( range instanceof Range ) ) {
-			throw new CKEditorError( 'view-selection-invalid-range: Invalid Range.' );
+			/**
+			 * Selection range set to an object that is not an instance of {@link module:engine/view/range~Range}.
+			 *
+			 * @error view-selection-add-range-not-range
+			 */
+			throw new CKEditorError(
+				'view-selection-add-range-not-range: ' +
+				'Selection range set to an object that is not an instance of view.Range'
+			);
 		}
 		}
 
 
 		this._pushRange( range );
 		this._pushRange( range );

+ 3 - 38
packages/ckeditor5-engine/tests/dev-utils/enableenginedebug.js

@@ -21,8 +21,6 @@ import MoveOperation from '../../src/model/operation/moveoperation';
 import NoOperation from '../../src/model/operation/nooperation';
 import NoOperation from '../../src/model/operation/nooperation';
 import RenameOperation from '../../src/model/operation/renameoperation';
 import RenameOperation from '../../src/model/operation/renameoperation';
 import RootAttributeOperation from '../../src/model/operation/rootattributeoperation';
 import RootAttributeOperation from '../../src/model/operation/rootattributeoperation';
-import RemoveOperation from '../../src/model/operation/removeoperation';
-import transform from '../../src/model/operation/transform';
 import Model from '../../src/model/model';
 import Model from '../../src/model/model';
 import ModelDocumentFragment from '../../src/model/documentfragment';
 import ModelDocumentFragment from '../../src/model/documentfragment';
 
 
@@ -532,8 +530,8 @@ describe( 'debug tools', () => {
 				model.applyOperation( insert );
 				model.applyOperation( insert );
 
 
 				const graveyard = modelDoc.graveyard;
 				const graveyard = modelDoc.graveyard;
-				const remove = new RemoveOperation( ModelPosition.createAt( modelRoot, 1 ), 2, ModelPosition.createAt( graveyard, 0 ), 1 );
-				model.applyOperation( remove );
+				const move = new MoveOperation( ModelPosition.createAt( modelRoot, 1 ), 2, ModelPosition.createAt( graveyard, 0 ), 1 );
+				model.applyOperation( move );
 			} );
 			} );
 
 
 			log.resetHistory();
 			log.resetHistory();
@@ -646,7 +644,7 @@ describe( 'debug tools', () => {
 
 
 			const stringifiedOperations = model.getAppliedOperations();
 			const stringifiedOperations = model.getAppliedOperations();
 
 
-			expect( stringifiedOperations ).to.equal( JSON.stringify( insert.toJSON() ) );
+			expect( stringifiedOperations ).to.equal( JSON.stringify( insert ) );
 		} );
 		} );
 
 
 		it( 'createReplayer()', () => {
 		it( 'createReplayer()', () => {
@@ -668,39 +666,6 @@ describe( 'debug tools', () => {
 		} );
 		} );
 	} );
 	} );
 
 
-	describe( 'should provide error logging for transformation', () => {
-		let model, document, root, otherRoot;
-
-		beforeEach( () => {
-			model = new Model();
-			document = model.document;
-			root = document.createRoot();
-			otherRoot = document.createRoot( 'other', 'other' );
-		} );
-
-		it.skip( 'with more important operation A', () => {
-			const opA = new MoveOperation( ModelPosition.createAt( root, 4 ), 4, ModelPosition.createAt( otherRoot, 4 ), 0 );
-			const opB = new InsertOperation( ModelPosition.createAt( root, 0 ), new ModelText( 'a' ), 0 );
-
-			expect( () => {
-				transform.transform( opA, opB, { isStrong: true } );
-			} ).to.throw( Error );
-			expect( error.calledWith( opA.toString() + ' (important)' ) ).to.be.true;
-			expect( error.calledWith( opB.toString() ) ).to.be.true;
-		} );
-
-		it.skip( 'with more important operation B', () => {
-			const opA = new MoveOperation( ModelPosition.createAt( root, 4 ), 4, ModelPosition.createAt( otherRoot, 4 ), 0 );
-			const opB = new InsertOperation( ModelPosition.createAt( root, 0 ), new ModelText( 'a' ), 0 );
-
-			testUtils.sinon.stub( transform, 'transform' ).throws( new Error() );
-
-			expect( () => transform.transform( opA, opB, { isStrong: true } ) ).to.throw( Error );
-			expect( error.calledWith( opA.toString() ) ).to.be.true;
-			expect( error.calledWith( opB.toString() + ' (important)' ) ).to.be.true;
-		} );
-	} );
-
 	function expectLog( expectedLogMsg ) {
 	function expectLog( expectedLogMsg ) {
 		expect( log.calledWithExactly( expectedLogMsg ) ).to.be.true;
 		expect( log.calledWithExactly( expectedLogMsg ) ).to.be.true;
 		log.resetHistory();
 		log.resetHistory();

+ 0 - 9
packages/ckeditor5-engine/tests/model/_utils/utils.js

@@ -36,15 +36,6 @@ export function getNodesAndText( range ) {
 	return txt;
 	return txt;
 }
 }
 
 
-/**
- * Returns object JSON representation. It passes an object by JSON.stringify and JSON.parse functions.
- *
- * @param {Object|Array} object
- */
-export function jsonParseStringify( object ) {
-	return JSON.parse( JSON.stringify( object ) );
-}
-
 /**
 /**
  * Returns a {@link engine.model.Node} or if it starts at given offset, or {@link engine.model.TextProxy} with one
  * Returns a {@link engine.model.Node} or if it starts at given offset, or {@link engine.model.TextProxy} with one
  * character, if given offset is occupied by a {@link engine.model.Text}.
  * character, if given offset is occupied by a {@link engine.model.Text}.

+ 1 - 2
packages/ckeditor5-engine/tests/model/differ.js

@@ -10,7 +10,6 @@ import Position from '../../src/model/position';
 import Range from '../../src/model/range';
 import Range from '../../src/model/range';
 
 
 import InsertOperation from '../../src/model/operation/insertoperation';
 import InsertOperation from '../../src/model/operation/insertoperation';
-import RemoveOperation from '../../src/model/operation/removeoperation';
 import MoveOperation from '../../src/model/operation/moveoperation';
 import MoveOperation from '../../src/model/operation/moveoperation';
 import RenameOperation from '../../src/model/operation/renameoperation';
 import RenameOperation from '../../src/model/operation/renameoperation';
 import AttributeOperation from '../../src/model/operation/attributeoperation';
 import AttributeOperation from '../../src/model/operation/attributeoperation';
@@ -1617,7 +1616,7 @@ describe( 'Differ', () => {
 
 
 	function remove( sourcePosition, howMany ) {
 	function remove( sourcePosition, howMany ) {
 		const targetPosition = Position.createAt( doc.graveyard, doc.graveyard.maxOffset );
 		const targetPosition = Position.createAt( doc.graveyard, doc.graveyard.maxOffset );
-		const operation = new RemoveOperation( sourcePosition, howMany, targetPosition, doc.version );
+		const operation = new MoveOperation( sourcePosition, howMany, targetPosition, doc.version );
 
 
 		model.applyOperation( operation );
 		model.applyOperation( operation );
 	}
 	}

+ 1 - 2
packages/ckeditor5-engine/tests/model/document.js

@@ -12,7 +12,6 @@ import Range from '../../src/model/range';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import count from '@ckeditor/ckeditor5-utils/src/count';
 import count from '@ckeditor/ckeditor5-utils/src/count';
-import { jsonParseStringify } from './_utils/utils';
 
 
 describe( 'Document', () => {
 describe( 'Document', () => {
 	let model, doc;
 	let model, doc;
@@ -500,7 +499,7 @@ describe( 'Document', () => {
 	} );
 	} );
 
 
 	it( 'should be correctly converted to json', () => {
 	it( 'should be correctly converted to json', () => {
-		const serialized = jsonParseStringify( doc );
+		const serialized = doc.toJSON();
 
 
 		expect( serialized.selection ).to.equal( '[engine.model.DocumentSelection]' );
 		expect( serialized.selection ).to.equal( '[engine.model.DocumentSelection]' );
 		expect( serialized.model ).to.equal( '[engine.model.Model]' );
 		expect( serialized.model ).to.equal( '[engine.model.Model]' );

+ 4 - 5
packages/ckeditor5-engine/tests/model/documentfragment.js

@@ -7,7 +7,6 @@ import Element from '../../src/model/element';
 import Text from '../../src/model/text';
 import Text from '../../src/model/text';
 import TextProxy from '../../src/model/textproxy';
 import TextProxy from '../../src/model/textproxy';
 import DocumentFragment from '../../src/model/documentfragment';
 import DocumentFragment from '../../src/model/documentfragment';
-import { jsonParseStringify } from '../../tests/model/_utils/utils';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 
 describe( 'DocumentFragment', () => {
 describe( 'DocumentFragment', () => {
@@ -263,7 +262,7 @@ describe( 'DocumentFragment', () => {
 		it( 'should serialize empty document fragment', () => {
 		it( 'should serialize empty document fragment', () => {
 			const frag = new DocumentFragment();
 			const frag = new DocumentFragment();
 
 
-			expect( jsonParseStringify( frag ) ).to.deep.equal( [] );
+			expect( frag.toJSON() ).to.deep.equal( [] );
 		} );
 		} );
 
 
 		it( 'should serialize document fragment with children', () => {
 		it( 'should serialize document fragment with children', () => {
@@ -274,7 +273,7 @@ describe( 'DocumentFragment', () => {
 
 
 			const frag = new DocumentFragment( [ one, two, three ] );
 			const frag = new DocumentFragment( [ one, two, three ] );
 
 
-			expect( jsonParseStringify( frag ) ).to.deep.equal( [
+			expect( frag.toJSON() ).to.deep.equal( [
 				{ name: 'one' },
 				{ name: 'one' },
 				{
 				{
 					name: 'two',
 					name: 'two',
@@ -293,7 +292,7 @@ describe( 'DocumentFragment', () => {
 		it( 'should create document fragment without children', () => {
 		it( 'should create document fragment without children', () => {
 			const frag = new DocumentFragment();
 			const frag = new DocumentFragment();
 
 
-			const serialized = jsonParseStringify( frag );
+			const serialized = frag.toJSON();
 			const deserialized = DocumentFragment.fromJSON( serialized );
 			const deserialized = DocumentFragment.fromJSON( serialized );
 
 
 			expect( deserialized.isEmpty ).to.be.true;
 			expect( deserialized.isEmpty ).to.be.true;
@@ -304,7 +303,7 @@ describe( 'DocumentFragment', () => {
 			const foo = new Text( 'foo' );
 			const foo = new Text( 'foo' );
 			const frag = new DocumentFragment( [ p, foo ] );
 			const frag = new DocumentFragment( [ p, foo ] );
 
 
-			const serialized = jsonParseStringify( frag );
+			const serialized = frag.toJSON();
 			const deserialized = DocumentFragment.fromJSON( serialized );
 			const deserialized = DocumentFragment.fromJSON( serialized );
 
 
 			expect( deserialized.childCount ).to.equal( 2 );
 			expect( deserialized.childCount ).to.equal( 2 );

+ 7 - 8
packages/ckeditor5-engine/tests/model/documentselection.js

@@ -13,7 +13,6 @@ import LiveRange from '../../src/model/liverange';
 import DocumentSelection from '../../src/model/documentselection';
 import DocumentSelection from '../../src/model/documentselection';
 import InsertOperation from '../../src/model/operation/insertoperation';
 import InsertOperation from '../../src/model/operation/insertoperation';
 import MoveOperation from '../../src/model/operation/moveoperation';
 import MoveOperation from '../../src/model/operation/moveoperation';
-import RemoveOperation from '../../src/model/operation/removeoperation';
 import AttributeOperation from '../../src/model/operation/attributeoperation';
 import AttributeOperation from '../../src/model/operation/attributeoperation';
 import SplitOperation from '../../src/model/operation/splitoperation';
 import SplitOperation from '../../src/model/operation/splitoperation';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
@@ -334,7 +333,7 @@ describe( 'DocumentSelection', () => {
 		it( 'should throw an error when range is invalid', () => {
 		it( 'should throw an error when range is invalid', () => {
 			expect( () => {
 			expect( () => {
 				selection._setTo( [ { invalid: 'range' } ] );
 				selection._setTo( [ { invalid: 'range' } ] );
-			} ).to.throw( CKEditorError, /model-selection-added-not-range/ );
+			} ).to.throw( CKEditorError, /model-selection-set-ranges-not-range/ );
 		} );
 		} );
 
 
 		it( 'should log a warning when trying to set selection to the graveyard', () => {
 		it( 'should log a warning when trying to set selection to the graveyard', () => {
@@ -1029,7 +1028,7 @@ describe( 'DocumentSelection', () => {
 				} );
 				} );
 
 
 				const batch = new Batch();
 				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 );
 				batch.addOperation( splitOperation );
 				model.applyOperation( splitOperation );
 				model.applyOperation( splitOperation );
@@ -1131,12 +1130,12 @@ describe( 'DocumentSelection', () => {
 			} );
 			} );
 		} );
 		} );
 
 
-		describe( 'RemoveOperation', () => {
+		describe( 'MoveOperation to graveyard', () => {
 			it( 'fix selection range if it ends up in graveyard #1', () => {
 			it( 'fix selection range if it ends up in graveyard #1', () => {
 				selection._setTo( new Position( root, [ 1, 3 ] ) );
 				selection._setTo( new Position( root, [ 1, 3 ] ) );
 
 
 				model.applyOperation(
 				model.applyOperation(
-					new RemoveOperation(
+					new MoveOperation(
 						new Position( root, [ 1, 2 ] ),
 						new Position( root, [ 1, 2 ] ),
 						2,
 						2,
 						new Position( doc.graveyard, [ 0 ] ),
 						new Position( doc.graveyard, [ 0 ] ),
@@ -1151,7 +1150,7 @@ describe( 'DocumentSelection', () => {
 				selection._setTo( [ new Range( new Position( root, [ 1, 2 ] ), new Position( root, [ 1, 4 ] ) ) ] );
 				selection._setTo( [ new Range( new Position( root, [ 1, 2 ] ), new Position( root, [ 1, 4 ] ) ) ] );
 
 
 				model.applyOperation(
 				model.applyOperation(
-					new RemoveOperation(
+					new MoveOperation(
 						new Position( root, [ 1, 2 ] ),
 						new Position( root, [ 1, 2 ] ),
 						2,
 						2,
 						new Position( doc.graveyard, [ 0 ] ),
 						new Position( doc.graveyard, [ 0 ] ),
@@ -1166,7 +1165,7 @@ describe( 'DocumentSelection', () => {
 				selection._setTo( [ new Range( new Position( root, [ 1, 1 ] ), new Position( root, [ 1, 2 ] ) ) ] );
 				selection._setTo( [ new Range( new Position( root, [ 1, 1 ] ), new Position( root, [ 1, 2 ] ) ) ] );
 
 
 				model.applyOperation(
 				model.applyOperation(
-					new RemoveOperation(
+					new MoveOperation(
 						new Position( root, [ 1 ] ),
 						new Position( root, [ 1 ] ),
 						2,
 						2,
 						new Position( doc.graveyard, [ 0 ] ),
 						new Position( doc.graveyard, [ 0 ] ),
@@ -1179,7 +1178,7 @@ describe( 'DocumentSelection', () => {
 
 
 			it( 'fix selection range if it ends up in graveyard #4 - whole content removed', () => {
 			it( 'fix selection range if it ends up in graveyard #4 - whole content removed', () => {
 				model.applyOperation(
 				model.applyOperation(
-					new RemoveOperation(
+					new MoveOperation(
 						new Position( root, [ 0 ] ),
 						new Position( root, [ 0 ] ),
 						3,
 						3,
 						new Position( doc.graveyard, [ 0 ] ),
 						new Position( doc.graveyard, [ 0 ] ),

+ 10 - 8
packages/ckeditor5-engine/tests/model/element.js

@@ -7,7 +7,6 @@ import Node from '../../src/model/node';
 import Element from '../../src/model/element';
 import Element from '../../src/model/element';
 import Text from '../../src/model/text';
 import Text from '../../src/model/text';
 import TextProxy from '../../src/model/textproxy';
 import TextProxy from '../../src/model/textproxy';
-import { jsonParseStringify } from '../../tests/model/_utils/utils';
 import count from '@ckeditor/ckeditor5-utils/src/count';
 import count from '@ckeditor/ckeditor5-utils/src/count';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 
@@ -320,14 +319,17 @@ describe( 'Element', () => {
 		it( 'should serialize empty element', () => {
 		it( 'should serialize empty element', () => {
 			const element = new Element( 'one' );
 			const element = new Element( 'one' );
 
 
-			expect( jsonParseStringify( element ) ).to.deep.equal( { name: 'one' } );
+			expect( element.toJSON() ).to.deep.equal( { name: 'one' } );
 		} );
 		} );
 
 
 		it( 'should serialize element with attributes', () => {
 		it( 'should serialize element with attributes', () => {
 			const element = new Element( 'one', { foo: true, bar: false } );
 			const element = new Element( 'one', { foo: true, bar: false } );
 
 
-			expect( jsonParseStringify( element ) ).to.deep.equal( {
-				attributes: [ [ 'foo', true ], [ 'bar', false ] ],
+			expect( element.toJSON() ).to.deep.equal( {
+				attributes: {
+					foo: true,
+					bar: false
+				},
 				name: 'one'
 				name: 'one'
 			} );
 			} );
 		} );
 		} );
@@ -340,7 +342,7 @@ describe( 'Element', () => {
 
 
 			const node = new Element( null, null, [ one, two, three ] );
 			const node = new Element( null, null, [ one, two, three ] );
 
 
-			expect( jsonParseStringify( node ) ).to.deep.equal( {
+			expect( node.toJSON() ).to.deep.equal( {
 				children: [
 				children: [
 					{ name: 'one' },
 					{ name: 'one' },
 					{
 					{
@@ -362,7 +364,7 @@ describe( 'Element', () => {
 		it( 'should create element without attributes', () => {
 		it( 'should create element without attributes', () => {
 			const el = new Element( 'el' );
 			const el = new Element( 'el' );
 
 
-			const serialized = jsonParseStringify( el );
+			const serialized = el.toJSON();
 
 
 			const deserialized = Element.fromJSON( serialized );
 			const deserialized = Element.fromJSON( serialized );
 
 
@@ -374,7 +376,7 @@ describe( 'Element', () => {
 		it( 'should create element with attributes', () => {
 		it( 'should create element with attributes', () => {
 			const el = new Element( 'el', { foo: true } );
 			const el = new Element( 'el', { foo: true } );
 
 
-			const serialized = jsonParseStringify( el );
+			const serialized = el.toJSON();
 
 
 			const deserialized = Element.fromJSON( serialized );
 			const deserialized = Element.fromJSON( serialized );
 
 
@@ -390,7 +392,7 @@ describe( 'Element', () => {
 			const text = new Text( 'foo' );
 			const text = new Text( 'foo' );
 			const el = new Element( 'el', null, [ p, text ] );
 			const el = new Element( 'el', null, [ p, text ] );
 
 
-			const serialized = jsonParseStringify( el );
+			const serialized = el.toJSON();
 
 
 			const deserialized = Element.fromJSON( serialized );
 			const deserialized = Element.fromJSON( serialized );
 
 

+ 65 - 17
packages/ckeditor5-engine/tests/model/liverange.js

@@ -3,13 +3,14 @@
  * For licensing, see LICENSE.md.
  * For licensing, see LICENSE.md.
  */
  */
 
 
-import Batch from '../../src/model/batch';
 import Model from '../../src/model/model';
 import Model from '../../src/model/model';
 import Element from '../../src/model/element';
 import Element from '../../src/model/element';
 import Position from '../../src/model/position';
 import Position from '../../src/model/position';
 import LiveRange from '../../src/model/liverange';
 import LiveRange from '../../src/model/liverange';
 import Range from '../../src/model/range';
 import Range from '../../src/model/range';
 import Text from '../../src/model/text';
 import Text from '../../src/model/text';
+import MoveOperation from '../../src/model/operation/moveoperation';
+import MergeOperation from '../../src/model/operation/mergeoperation';
 import { stringify, setData } from '../../src/dev-utils/model';
 import { stringify, setData } from '../../src/dev-utils/model';
 
 
 describe( 'LiveRange', () => {
 describe( 'LiveRange', () => {
@@ -90,7 +91,7 @@ describe( 'LiveRange', () => {
 		range.detach();
 		range.detach();
 	} );
 	} );
 
 
-	it( 'should fire change:range event with proper data when its boundaries are changed', () => {
+	it( 'should fire change:range event with when its boundaries are changed', () => {
 		const live = new LiveRange( new Position( root, [ 0, 1, 4 ] ), new Position( root, [ 0, 2, 2 ] ) );
 		const live = new LiveRange( new Position( root, [ 0, 1, 4 ] ), new Position( root, [ 0, 2, 2 ] ) );
 		const copy = Range.createFromRange( live );
 		const copy = Range.createFromRange( live );
 
 
@@ -99,15 +100,11 @@ describe( 'LiveRange', () => {
 
 
 		const sourcePosition = new Position( root, [ 2 ] );
 		const sourcePosition = new Position( root, [ 2 ] );
 		const targetPosition = new Position( root, [ 0 ] );
 		const targetPosition = new Position( root, [ 0 ] );
-		const batch = new Batch();
-		let op = null;
 
 
-		model.enqueueChange( batch, writer => {
+		model.change( writer => {
 			const sourceRange = Range.createFromPositionAndShift( sourcePosition, 1 );
 			const sourceRange = Range.createFromPositionAndShift( sourcePosition, 1 );
 
 
 			writer.move( sourceRange, targetPosition );
 			writer.move( sourceRange, targetPosition );
-
-			op = batch.operations[ 0 ];
 		} );
 		} );
 
 
 		expect( spy.calledOnce ).to.be.true;
 		expect( spy.calledOnce ).to.be.true;
@@ -115,11 +112,11 @@ describe( 'LiveRange', () => {
 		// First parameter available in event should be a range that is equal to the live range before the live range changed.
 		// First parameter available in event should be a range that is equal to the live range before the live range changed.
 		expect( spy.args[ 0 ][ 1 ].isEqual( copy ) ).to.be.true;
 		expect( spy.args[ 0 ][ 1 ].isEqual( copy ) ).to.be.true;
 
 
-		// Second parameter is the operation that changed the range.
-		expect( spy.args[ 0 ][ 2 ] ).to.equal( op );
+		// Second parameter is null for operations that did not move the range into graveyard.
+		expect( spy.args[ 0 ][ 2 ].deletionPosition ).to.be.null;
 	} );
 	} );
 
 
-	it( 'should fire change:content event with proper data when content inside the range has changed', () => {
+	it( 'should fire change:content event when content inside the range has changed', () => {
 		const live = new LiveRange( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 3 ] ) );
 		const live = new LiveRange( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 3 ] ) );
 
 
 		const spy = sinon.spy();
 		const spy = sinon.spy();
@@ -127,15 +124,11 @@ describe( 'LiveRange', () => {
 
 
 		const sourcePosition = new Position( root, [ 0, 2, 0 ] );
 		const sourcePosition = new Position( root, [ 0, 2, 0 ] );
 		const targetPosition = new Position( root, [ 0, 4, 0 ] );
 		const targetPosition = new Position( root, [ 0, 4, 0 ] );
-		const batch = new Batch();
-		let op = null;
 
 
-		model.enqueueChange( batch, writer => {
+		model.change( writer => {
 			const sourceRange = Range.createFromPositionAndShift( sourcePosition, 2 );
 			const sourceRange = Range.createFromPositionAndShift( sourcePosition, 2 );
 
 
 			writer.move( sourceRange, targetPosition );
 			writer.move( sourceRange, targetPosition );
-
-			op = batch.operations[ 0 ];
 		} );
 		} );
 
 
 		expect( spy.calledOnce ).to.be.true;
 		expect( spy.calledOnce ).to.be.true;
@@ -143,8 +136,63 @@ describe( 'LiveRange', () => {
 		// First parameter available in event should be a range that is equal to the live range before the live range changed.
 		// First parameter available in event should be a range that is equal to the live range before the live range changed.
 		expect( spy.args[ 0 ][ 1 ].isEqual( live ) ).to.be.true;
 		expect( spy.args[ 0 ][ 1 ].isEqual( live ) ).to.be.true;
 
 
-		// Second parameter is the operation that changed the range.
-		expect( spy.args[ 0 ][ 2 ] ).to.equal( op );
+		// Second parameter is null for operations that did not move the range into graveyard.
+		expect( spy.args[ 0 ][ 2 ].deletionPosition ).to.be.null;
+	} );
+
+	it( 'should pass deletion position if range was removed (remove)', () => {
+		const live = new LiveRange( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 4 ] ) );
+
+		const spy = sinon.spy();
+		live.on( 'change:range', spy );
+
+		const sourcePosition = new Position( root, [ 0, 0 ] );
+
+		model.change( writer => {
+			writer.remove( Range.createFromPositionAndShift( sourcePosition, 6 ) );
+		} );
+
+		// Second parameter is deletion position.
+		expect( spy.args[ 0 ][ 2 ].deletionPosition.isEqual( sourcePosition ) ).to.be.true;
+	} );
+
+	// This scenario is hypothetically possible during OT if the element to merge-into was removed.
+	// In that case a live range inside the merged element will be merged into an element which is in graveyard.
+	// Because it may happen only in OT, in the test below we will generate operations by hand.
+	it( 'should pass deletion position if range was removed (merge)', () => {
+		const live = new LiveRange( new Position( root, [ 1, 0 ] ), new Position( root, [ 1, 1 ] ) );
+
+		const spy = sinon.spy();
+		live.on( 'change:range', spy );
+
+		model.change( writer => {
+			const batch = writer.batch;
+			const gy = model.document.graveyard;
+
+			const remove = new MoveOperation(
+				new Position( root, [ 0 ] ),
+				1,
+				new Position( gy, [ 0 ] ),
+				model.document.version
+			);
+
+			const merge = new MergeOperation(
+				new Position( root, [ 0, 0 ] ),
+				10,
+				new Position( gy, [ 0, 0 ] ),
+				new Position( gy, [ 0 ] ),
+				model.document.version + 1
+			);
+
+			batch.addOperation( remove );
+			model.applyOperation( remove );
+
+			batch.addOperation( merge );
+			model.applyOperation( merge );
+		} );
+
+		// Second parameter is deletion position.
+		expect( spy.args[ 1 ][ 2 ].deletionPosition.isEqual( new Position( root, [ 0 ] ) ) ).to.be.true;
 	} );
 	} );
 
 
 	describe( 'should get transformed and fire change:range if', () => {
 	describe( 'should get transformed and fire change:range if', () => {

+ 2 - 3
packages/ckeditor5-engine/tests/model/nodelist.js

@@ -6,7 +6,6 @@
 import NodeList from '../../src/model/nodelist';
 import NodeList from '../../src/model/nodelist';
 import Element from '../../src/model/element';
 import Element from '../../src/model/element';
 import Text from '../../src/model/text';
 import Text from '../../src/model/text';
-import { jsonParseStringify } from '../../tests/model/_utils/utils';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 
 describe( 'NodeList', () => {
 describe( 'NodeList', () => {
@@ -195,11 +194,11 @@ describe( 'NodeList', () => {
 
 
 	describe( 'toJSON', () => {
 	describe( 'toJSON', () => {
 		it( 'should serialize empty node list', () => {
 		it( 'should serialize empty node list', () => {
-			expect( jsonParseStringify( new NodeList() ) ).to.deep.equal( [] );
+			expect( ( new NodeList() ).toJSON() ).to.deep.equal( [] );
 		} );
 		} );
 
 
 		it( 'should serialize node list with nodes', () => {
 		it( 'should serialize node list with nodes', () => {
-			expect( jsonParseStringify( nodes ) ).to.deep.equal( [
+			expect( nodes.toJSON() ).to.deep.equal( [
 				{ name: 'p' },
 				{ name: 'p' },
 				{ data: 'foo' },
 				{ data: 'foo' },
 				{ name: 'image' }
 				{ name: 'image' }

+ 4 - 4
packages/ckeditor5-engine/tests/model/operation/attributeoperation.js

@@ -10,7 +10,6 @@ import Position from '../../../src/model/position';
 import Range from '../../../src/model/range';
 import Range from '../../../src/model/range';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import count from '@ckeditor/ckeditor5-utils/src/count';
 import count from '@ckeditor/ckeditor5-utils/src/count';
-import { jsonParseStringify } from '../../../tests/model/_utils/utils';
 
 
 describe( 'AttributeOperation', () => {
 describe( 'AttributeOperation', () => {
 	let model, doc, root;
 	let model, doc, root;
@@ -377,16 +376,17 @@ describe( 'AttributeOperation', () => {
 				doc.version
 				doc.version
 			);
 			);
 
 
-			const serialized = jsonParseStringify( op );
+			const serialized = op.toJSON();
 
 
 			expect( serialized.__className ).to.equal( 'engine.model.operation.AttributeOperation' );
 			expect( serialized.__className ).to.equal( 'engine.model.operation.AttributeOperation' );
+
 			expect( serialized ).to.deep.equal( {
 			expect( serialized ).to.deep.equal( {
 				__className: 'engine.model.operation.AttributeOperation',
 				__className: 'engine.model.operation.AttributeOperation',
 				baseVersion: 0,
 				baseVersion: 0,
 				key: 'key',
 				key: 'key',
 				newValue: 'newValue',
 				newValue: 'newValue',
 				oldValue: null,
 				oldValue: null,
-				range: jsonParseStringify( range )
+				range: range.toJSON()
 			} );
 			} );
 		} );
 		} );
 	} );
 	} );
@@ -402,7 +402,7 @@ describe( 'AttributeOperation', () => {
 				doc.version
 				doc.version
 			);
 			);
 
 
-			const serialized = jsonParseStringify( op );
+			const serialized = op.toJSON();
 			const deserialized = AttributeOperation.fromJSON( serialized, doc );
 			const deserialized = AttributeOperation.fromJSON( serialized, doc );
 
 
 			expect( deserialized ).to.deep.equal( op );
 			expect( deserialized ).to.deep.equal( op );

+ 2 - 3
packages/ckeditor5-engine/tests/model/operation/detachoperation.js

@@ -5,7 +5,6 @@
 
 
 import Model from '../../../src/model/model';
 import Model from '../../../src/model/model';
 import DetachOperation from '../../../src/model/operation/detachoperation';
 import DetachOperation from '../../../src/model/operation/detachoperation';
-import { jsonParseStringify } from '../../../tests/model/_utils/utils';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import Position from '../../../src/model/position';
 import Position from '../../../src/model/position';
 import DocumentFragment from '../../../src/model/documentfragment';
 import DocumentFragment from '../../../src/model/documentfragment';
@@ -61,12 +60,12 @@ describe( 'DetachOperation', () => {
 			const position = Position.createBefore( element );
 			const position = Position.createBefore( element );
 			const op = new DetachOperation( position, 1 );
 			const op = new DetachOperation( position, 1 );
 
 
-			const serialized = jsonParseStringify( op );
+			const serialized = op.toJSON();
 
 
 			expect( serialized ).to.deep.equal( {
 			expect( serialized ).to.deep.equal( {
 				__className: 'engine.model.operation.DetachOperation',
 				__className: 'engine.model.operation.DetachOperation',
 				baseVersion: null,
 				baseVersion: null,
-				sourcePosition: jsonParseStringify( position ),
+				sourcePosition: position.toJSON(),
 				howMany: 1
 				howMany: 1
 			} );
 			} );
 		} );
 		} );

+ 7 - 8
packages/ckeditor5-engine/tests/model/operation/insertoperation.js

@@ -7,11 +7,10 @@ import Model from '../../../src/model/model';
 import NodeList from '../../../src/model/nodelist';
 import NodeList from '../../../src/model/nodelist';
 import Element from '../../../src/model/element';
 import Element from '../../../src/model/element';
 import InsertOperation from '../../../src/model/operation/insertoperation';
 import InsertOperation from '../../../src/model/operation/insertoperation';
-import RemoveOperation from '../../../src/model/operation/removeoperation';
+import MoveOperation from '../../../src/model/operation/moveoperation';
 import Position from '../../../src/model/position';
 import Position from '../../../src/model/position';
 import Text from '../../../src/model/text';
 import Text from '../../../src/model/text';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
-import { jsonParseStringify } from '../../../tests/model/_utils/utils';
 
 
 describe( 'InsertOperation', () => {
 describe( 'InsertOperation', () => {
 	let model, doc, root;
 	let model, doc, root;
@@ -107,7 +106,7 @@ describe( 'InsertOperation', () => {
 		expect( root.getChild( 0 ).data ).to.equal( 'fooxbar' );
 		expect( root.getChild( 0 ).data ).to.equal( 'fooxbar' );
 	} );
 	} );
 
 
-	it( 'should create a RemoveOperation as a reverse', () => {
+	it( 'should create a MoveOperation as a reverse', () => {
 		const position = new Position( root, [ 0 ] );
 		const position = new Position( root, [ 0 ] );
 		const operation = new InsertOperation(
 		const operation = new InsertOperation(
 			position,
 			position,
@@ -117,7 +116,7 @@ describe( 'InsertOperation', () => {
 
 
 		const reverse = operation.getReversed();
 		const reverse = operation.getReversed();
 
 
-		expect( reverse ).to.be.an.instanceof( RemoveOperation );
+		expect( reverse ).to.be.an.instanceof( MoveOperation );
 		expect( reverse.baseVersion ).to.equal( 1 );
 		expect( reverse.baseVersion ).to.equal( 1 );
 		expect( reverse.sourcePosition.isEqual( position ) ).to.be.true;
 		expect( reverse.sourcePosition.isEqual( position ) ).to.be.true;
 		expect( reverse.howMany ).to.equal( 7 );
 		expect( reverse.howMany ).to.equal( 7 );
@@ -223,13 +222,13 @@ describe( 'InsertOperation', () => {
 			const op = new InsertOperation( position, new Text( 'x' ), doc.version );
 			const op = new InsertOperation( position, new Text( 'x' ), doc.version );
 			op.shouldReceiveAttributes = true;
 			op.shouldReceiveAttributes = true;
 
 
-			const serialized = jsonParseStringify( op );
+			const serialized = op.toJSON();
 
 
 			expect( serialized ).to.deep.equal( {
 			expect( serialized ).to.deep.equal( {
 				__className: 'engine.model.operation.InsertOperation',
 				__className: 'engine.model.operation.InsertOperation',
 				baseVersion: 0,
 				baseVersion: 0,
-				nodes: jsonParseStringify( new NodeList( [ new Text( 'x' ) ] ) ),
-				position: jsonParseStringify( position ),
+				nodes: ( new NodeList( [ new Text( 'x' ) ] ) ).toJSON(),
+				position: position.toJSON(),
 				shouldReceiveAttributes: true
 				shouldReceiveAttributes: true
 			} );
 			} );
 		} );
 		} );
@@ -244,7 +243,7 @@ describe( 'InsertOperation', () => {
 				doc.version
 				doc.version
 			);
 			);
 
 
-			const serialized = jsonParseStringify( op );
+			const serialized = op.toJSON();
 			const deserialized = InsertOperation.fromJSON( serialized, doc );
 			const deserialized = InsertOperation.fromJSON( serialized, doc );
 
 
 			expect( deserialized ).to.deep.equal( op );
 			expect( deserialized ).to.deep.equal( op );

+ 4 - 5
packages/ckeditor5-engine/tests/model/operation/markeroperation.js

@@ -7,7 +7,6 @@ import Model from '../../../src/model/model';
 import Text from '../../../src/model/text';
 import Text from '../../../src/model/text';
 import Range from '../../../src/model/range';
 import Range from '../../../src/model/range';
 import MarkerOperation from '../../../src/model/operation/markeroperation';
 import MarkerOperation from '../../../src/model/operation/markeroperation';
-import { jsonParseStringify } from '../../model/_utils/utils';
 
 
 function matchRange( range ) {
 function matchRange( range ) {
 	return sinon.match( rangeToMatch => rangeToMatch.isEqual( range ) );
 	return sinon.match( rangeToMatch => rangeToMatch.isEqual( range ) );
@@ -135,14 +134,14 @@ describe( 'MarkerOperation', () => {
 	describe( 'toJSON', () => {
 	describe( 'toJSON', () => {
 		it( 'should create proper serialized object', () => {
 		it( 'should create proper serialized object', () => {
 			const op = new MarkerOperation( 'name', null, range, model.markers, doc.version, true );
 			const op = new MarkerOperation( 'name', null, range, model.markers, doc.version, true );
-			const serialized = jsonParseStringify( op );
+			const serialized = op.toJSON();
 
 
 			expect( serialized ).to.deep.equal( {
 			expect( serialized ).to.deep.equal( {
 				__className: 'engine.model.operation.MarkerOperation',
 				__className: 'engine.model.operation.MarkerOperation',
 				baseVersion: 0,
 				baseVersion: 0,
 				name: 'name',
 				name: 'name',
 				oldRange: null,
 				oldRange: null,
-				newRange: jsonParseStringify( range ),
+				newRange: range.toJSON(),
 				affectsData: true
 				affectsData: true
 			} );
 			} );
 		} );
 		} );
@@ -152,7 +151,7 @@ describe( 'MarkerOperation', () => {
 		it( 'should create proper MarkerOperation from json object #1', () => {
 		it( 'should create proper MarkerOperation from json object #1', () => {
 			const op = new MarkerOperation( 'name', null, range, model.markers, doc.version, true );
 			const op = new MarkerOperation( 'name', null, range, model.markers, doc.version, true );
 
 
-			const serialized = jsonParseStringify( op );
+			const serialized = op.toJSON();
 			const deserialized = MarkerOperation.fromJSON( serialized, doc );
 			const deserialized = MarkerOperation.fromJSON( serialized, doc );
 
 
 			expect( deserialized ).to.deep.equal( op );
 			expect( deserialized ).to.deep.equal( op );
@@ -162,7 +161,7 @@ describe( 'MarkerOperation', () => {
 			// Gotta love 100% CC.
 			// Gotta love 100% CC.
 			const op = new MarkerOperation( 'name', range, null, model.markers, doc.version, true );
 			const op = new MarkerOperation( 'name', range, null, model.markers, doc.version, true );
 
 
-			const serialized = jsonParseStringify( op );
+			const serialized = op.toJSON();
 			const deserialized = MarkerOperation.fromJSON( serialized, doc );
 			const deserialized = MarkerOperation.fromJSON( serialized, doc );
 
 
 			expect( deserialized ).to.deep.equal( op );
 			expect( deserialized ).to.deep.equal( op );

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

@@ -9,26 +9,29 @@ import Position from '../../../src/model/position';
 import Element from '../../../src/model/element';
 import Element from '../../../src/model/element';
 import Text from '../../../src/model/text';
 import Text from '../../../src/model/text';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
-import { jsonParseStringify } from '../../../tests/model/_utils/utils';
 
 
 describe( 'MoveOperation', () => {
 describe( 'MoveOperation', () => {
-	let model, doc, root;
+	let model, doc, root, gy;
 
 
 	beforeEach( () => {
 	beforeEach( () => {
 		model = new Model();
 		model = new Model();
 		doc = model.document;
 		doc = model.document;
 		root = doc.createRoot();
 		root = doc.createRoot();
+		gy = doc.graveyard;
 	} );
 	} );
 
 
 	it( 'should have proper type', () => {
 	it( 'should have proper type', () => {
-		const op = new MoveOperation(
-			new Position( root, [ 0, 0 ] ),
-			1,
-			new Position( root, [ 1, 0 ] ),
-			doc.version
-		);
+		const move = new MoveOperation( new Position( root, [ 0, 0 ] ), 1, new Position( root, [ 1, 0 ] ), 0 );
+		expect( move.type ).to.equal( 'move' );
+
+		const remove1 = new MoveOperation( new Position( root, [ 0, 0 ] ), 1, new Position( gy, [ 0 ] ), 0 );
+		expect( remove1.type ).to.equal( 'remove' );
+
+		const remove2 = new MoveOperation( new Position( gy, [ 0 ] ), 1, new Position( gy, [ 1 ] ), 0 );
+		expect( remove2.type ).to.equal( 'remove' );
 
 
-		expect( op.type ).to.equal( 'move' );
+		const reinsert = new MoveOperation( new Position( gy, [ 0 ] ), 1, new Position( root, [ 0, 0 ] ), 0 );
+		expect( reinsert.type ).to.equal( 'reinsert' );
 	} );
 	} );
 
 
 	it( 'should move from one node to another', () => {
 	it( 'should move from one node to another', () => {
@@ -266,14 +269,14 @@ describe( 'MoveOperation', () => {
 			const targetPosition = new Position( root, [ 1, 0 ] );
 			const targetPosition = new Position( root, [ 1, 0 ] );
 			const op = new MoveOperation( sourcePosition, 1, targetPosition, doc.version );
 			const op = new MoveOperation( sourcePosition, 1, targetPosition, doc.version );
 
 
-			const serialized = jsonParseStringify( op );
+			const serialized = op.toJSON();
 
 
 			expect( serialized ).to.deep.equal( {
 			expect( serialized ).to.deep.equal( {
 				__className: 'engine.model.operation.MoveOperation',
 				__className: 'engine.model.operation.MoveOperation',
 				baseVersion: 0,
 				baseVersion: 0,
 				howMany: 1,
 				howMany: 1,
-				sourcePosition: jsonParseStringify( op.sourcePosition ),
-				targetPosition: jsonParseStringify( op.targetPosition )
+				sourcePosition: op.sourcePosition.toJSON(),
+				targetPosition: op.targetPosition.toJSON()
 			} );
 			} );
 		} );
 		} );
 	} );
 	} );
@@ -284,7 +287,7 @@ describe( 'MoveOperation', () => {
 			const targetPosition = new Position( root, [ 1, 0 ] );
 			const targetPosition = new Position( root, [ 1, 0 ] );
 			const op = new MoveOperation( sourcePosition, 1, targetPosition, doc.version );
 			const op = new MoveOperation( sourcePosition, 1, targetPosition, doc.version );
 
 
-			const serialized = jsonParseStringify( op );
+			const serialized = op.toJSON();
 			const deserialized = MoveOperation.fromJSON( serialized, doc );
 			const deserialized = MoveOperation.fromJSON( serialized, doc );
 
 
 			expect( deserialized ).to.deep.equal( op );
 			expect( deserialized ).to.deep.equal( op );

+ 2 - 3
packages/ckeditor5-engine/tests/model/operation/nooperation.js

@@ -5,7 +5,6 @@
 
 
 import Model from '../../../src/model/model';
 import Model from '../../../src/model/model';
 import NoOperation from '../../../src/model/operation/nooperation';
 import NoOperation from '../../../src/model/operation/nooperation';
-import { jsonParseStringify } from '../../../tests/model/_utils/utils';
 
 
 describe( 'NoOperation', () => {
 describe( 'NoOperation', () => {
 	let model, noop, doc;
 	let model, noop, doc;
@@ -36,7 +35,7 @@ describe( 'NoOperation', () => {
 
 
 	describe( 'toJSON', () => {
 	describe( 'toJSON', () => {
 		it( 'should create proper json object', () => {
 		it( 'should create proper json object', () => {
-			const serialized = jsonParseStringify( noop );
+			const serialized = noop.toJSON();
 
 
 			expect( serialized ).to.deep.equal( {
 			expect( serialized ).to.deep.equal( {
 				__className: 'engine.model.operation.NoOperation',
 				__className: 'engine.model.operation.NoOperation',
@@ -47,7 +46,7 @@ describe( 'NoOperation', () => {
 
 
 	describe( 'fromJSON', () => {
 	describe( 'fromJSON', () => {
 		it( 'should create proper NoOperation from json object', () => {
 		it( 'should create proper NoOperation from json object', () => {
-			const serialized = jsonParseStringify( noop );
+			const serialized = noop.toJSON();
 			const deserialized = NoOperation.fromJSON( serialized, doc );
 			const deserialized = NoOperation.fromJSON( serialized, doc );
 
 
 			expect( deserialized ).to.deep.equal( noop );
 			expect( deserialized ).to.deep.equal( noop );

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

@@ -5,7 +5,6 @@
 
 
 import Batch from '../../../src/model/batch';
 import Batch from '../../../src/model/batch';
 import Operation from '../../../src/model/operation/operation';
 import Operation from '../../../src/model/operation/operation';
-import { jsonParseStringify } from '../../../tests/model/_utils/utils';
 
 
 describe( 'Operation', () => {
 describe( 'Operation', () => {
 	it( 'should save its base version', () => {
 	it( 'should save its base version', () => {
@@ -32,7 +31,7 @@ describe( 'Operation', () => {
 		it( 'should create proper json object #1', () => {
 		it( 'should create proper json object #1', () => {
 			const op = new Operation( 4 );
 			const op = new Operation( 4 );
 
 
-			const serialized = jsonParseStringify( op );
+			const serialized = op.toJSON();
 
 
 			expect( serialized ).to.deep.equal( {
 			expect( serialized ).to.deep.equal( {
 				__className: 'engine.model.operation.Operation',
 				__className: 'engine.model.operation.Operation',
@@ -45,7 +44,7 @@ describe( 'Operation', () => {
 			const batch = new Batch();
 			const batch = new Batch();
 			batch.addOperation( op );
 			batch.addOperation( op );
 
 
-			const serialized = jsonParseStringify( op );
+			const serialized = op.toJSON();
 
 
 			expect( serialized ).to.deep.equal( {
 			expect( serialized ).to.deep.equal( {
 				__className: 'engine.model.operation.Operation',
 				__className: 'engine.model.operation.Operation',
@@ -58,7 +57,7 @@ describe( 'Operation', () => {
 		it( 'should create proper Operation from json object', () => {
 		it( 'should create proper Operation from json object', () => {
 			const op = new Operation( 4 );
 			const op = new Operation( 4 );
 
 
-			const serialized = jsonParseStringify( op );
+			const serialized = op.toJSON();
 			const deserialized = Operation.fromJSON( serialized );
 			const deserialized = Operation.fromJSON( serialized );
 
 
 			expect( deserialized ).to.deep.equal( op );
 			expect( deserialized ).to.deep.equal( op );

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

@@ -1,157 +0,0 @@
-/**
- * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import Model from '../../../src/model/model';
-import ReinsertOperation from '../../../src/model/operation/reinsertoperation';
-import RemoveOperation from '../../../src/model/operation/removeoperation';
-import MoveOperation from '../../../src/model/operation/moveoperation';
-import Position from '../../../src/model/position';
-import DocumentFragment from '../../../src/model/documentfragment';
-import Element from '../../../src/model/element';
-import Text from '../../../src/model/text';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
-import { jsonParseStringify } from '../../../tests/model/_utils/utils';
-
-describe( 'ReinsertOperation', () => {
-	let model, doc, root, graveyard, operation, graveyardPosition, rootPosition;
-
-	beforeEach( () => {
-		model = new Model();
-		doc = model.document;
-		root = doc.createRoot();
-		graveyard = doc.graveyard;
-
-		graveyardPosition = new Position( graveyard, [ 0 ] );
-		rootPosition = new Position( root, [ 0 ] );
-
-		operation = new ReinsertOperation(
-			graveyardPosition,
-			2,
-			rootPosition,
-			doc.version
-		);
-	} );
-
-	it( 'should have position property equal to the position where node will be reinserted', () => {
-		expect( operation.position.isEqual( rootPosition ) ).to.be.true;
-
-		// Setting also works:
-		operation.position = new Position( root, [ 1 ] );
-		expect( operation.position.isEqual( new Position( root, [ 1 ] ) ) ).to.be.true;
-	} );
-
-	it( 'should have proper type', () => {
-		expect( operation.type ).to.equal( 'reinsert' );
-	} );
-
-	it( 'should extend MoveOperation class', () => {
-		expect( operation ).to.be.instanceof( MoveOperation );
-	} );
-
-	it( 'should create ReinsertOperation with same parameters when cloned', () => {
-		const clone = operation.clone();
-
-		expect( clone ).to.be.instanceof( ReinsertOperation );
-		expect( clone.sourcePosition.isEqual( operation.sourcePosition ) ).to.be.true;
-		expect( clone.targetPosition.isEqual( operation.targetPosition ) ).to.be.true;
-		expect( clone.howMany ).to.equal( operation.howMany );
-		expect( clone.baseVersion ).to.equal( operation.baseVersion );
-	} );
-
-	it( 'should create RemoveOperation as a reverse', () => {
-		graveyard._appendChild( new Element( 'x' ) );
-
-		const reverse = operation.getReversed();
-
-		expect( reverse ).to.be.an.instanceof( RemoveOperation );
-		expect( reverse.baseVersion ).to.equal( 1 );
-		expect( reverse.howMany ).to.equal( 2 );
-		expect( reverse.sourcePosition.isEqual( rootPosition ) ).to.be.true;
-		expect( reverse.targetPosition.isEqual( graveyardPosition ) ).to.be.true;
-	} );
-
-	it( 'should create correct RemoveOperation when reversed if target position was in graveyard', () => {
-		const operation = new ReinsertOperation( new Position( doc.graveyard, [ 0 ] ), 1, new Position( doc.graveyard, [ 3 ] ), 0 );
-		const reverse = operation.getReversed();
-
-		expect( reverse.sourcePosition.path ).to.deep.equal( [ 2 ] );
-		expect( reverse.targetPosition.path ).to.deep.equal( [ 0 ] );
-	} );
-
-	it( 'should undo reinsert set of nodes by applying reverse operation', () => {
-		const reverse = operation.getReversed();
-
-		graveyard._insertChild( 0, new Text( 'xx' ) );
-
-		model.applyOperation( operation );
-
-		expect( doc.version ).to.equal( 1 );
-		expect( root.maxOffset ).to.equal( 2 );
-		expect( graveyard.maxOffset ).to.equal( 0 );
-
-		model.applyOperation( reverse );
-
-		expect( doc.version ).to.equal( 2 );
-		expect( root.maxOffset ).to.equal( 0 );
-		expect( graveyard.maxOffset ).to.equal( 2 );
-	} );
-
-	describe( '_validate()', () => {
-		it( 'should throw when target position is not in the document', () => {
-			const docFrag = new DocumentFragment();
-
-			graveyard._insertChild( 0, new Text( 'xx' ) );
-
-			operation = new ReinsertOperation(
-				graveyardPosition,
-				1,
-				Position.createAt( docFrag ),
-				doc.version
-			);
-
-			expect( () => {
-				operation._validate();
-			} ).to.throw( CKEditorError, /^reinsert-operation-to-detached-parent/ );
-		} );
-
-		it( 'should throw when source position is not in the document', () => {
-			const docFrag = new DocumentFragment( new Text( 'xx' ) );
-
-			operation = new ReinsertOperation(
-				Position.createAt( docFrag ),
-				1,
-				rootPosition,
-				doc.version
-			);
-
-			expect( () => {
-				operation._validate();
-			} ).to.throw( CKEditorError, /^reinsert-operation-on-detached-item/ );
-		} );
-	} );
-
-	describe( 'toJSON', () => {
-		it( 'should create proper json object', () => {
-			const serialized = jsonParseStringify( operation );
-
-			expect( serialized ).to.deep.equal( {
-				__className: 'engine.model.operation.ReinsertOperation',
-				baseVersion: 0,
-				howMany: 2,
-				sourcePosition: jsonParseStringify( operation.sourcePosition ),
-				targetPosition: jsonParseStringify( operation.targetPosition )
-			} );
-		} );
-	} );
-
-	describe( 'fromJSON', () => {
-		it( 'should create proper ReinsertOperation from json object', () => {
-			const serialized = jsonParseStringify( operation );
-			const deserialized = ReinsertOperation.fromJSON( serialized, doc );
-
-			expect( deserialized ).to.deep.equal( operation );
-		} );
-	} );
-} );

+ 0 - 191
packages/ckeditor5-engine/tests/model/operation/removeoperation.js

@@ -1,191 +0,0 @@
-/**
- * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import Model from '../../../src/model/model';
-import ReinsertOperation from '../../../src/model/operation/reinsertoperation';
-import RemoveOperation from '../../../src/model/operation/removeoperation';
-import MoveOperation from '../../../src/model/operation/moveoperation';
-import Position from '../../../src/model/position';
-import DocumentFragment from '../../../src/model/documentfragment';
-import Element from '../../../src/model/element';
-import Text from '../../../src/model/text';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
-import { jsonParseStringify } from '../../../tests/model/_utils/utils';
-
-describe( 'RemoveOperation', () => {
-	let model, doc, root, graveyard;
-
-	beforeEach( () => {
-		model = new Model();
-		doc = model.document;
-		root = doc.createRoot();
-		graveyard = doc.graveyard;
-	} );
-
-	it( 'should have proper type', () => {
-		const op = new RemoveOperation(
-			new Position( root, [ 2 ] ),
-			2,
-			new Position( doc.graveyard, [ 0 ] ),
-			doc.version
-		);
-
-		expect( op.type ).to.equal( 'remove' );
-	} );
-
-	it( 'should extend MoveOperation class', () => {
-		const operation = new RemoveOperation(
-			new Position( root, [ 2 ] ),
-			2,
-			new Position( doc.graveyard, [ 0 ] ),
-			doc.version
-		);
-
-		expect( operation ).to.be.instanceof( MoveOperation );
-	} );
-
-	it( 'should be able to remove set of nodes and append them to graveyard root', () => {
-		root._insertChild( 0, new Text( 'fozbar' ) );
-
-		model.applyOperation(
-			new RemoveOperation(
-				new Position( root, [ 2 ] ),
-				2,
-				new Position( doc.graveyard, [ 0 ] ),
-				doc.version
-			)
-		);
-
-		expect( doc.version ).to.equal( 1 );
-		expect( root.maxOffset ).to.equal( 4 );
-		expect( root.getChild( 0 ).data ).to.equal( 'foar' );
-
-		expect( graveyard.maxOffset ).to.equal( 2 );
-		expect( graveyard.getChild( 0 ).data ).to.equal( 'zb' );
-	} );
-
-	it( 'should create RemoveOperation with same parameters when cloned', () => {
-		const pos = new Position( root, [ 2 ] );
-
-		const operation = new RemoveOperation( pos, 2, new Position( doc.graveyard, [ 0 ] ), doc.version );
-		const clone = operation.clone();
-
-		expect( clone ).to.be.instanceof( RemoveOperation );
-		expect( clone.sourcePosition.isEqual( pos ) ).to.be.true;
-		expect( clone.targetPosition.isEqual( operation.targetPosition ) ).to.be.true;
-		expect( clone.howMany ).to.equal( operation.howMany );
-		expect( clone.baseVersion ).to.equal( operation.baseVersion );
-	} );
-
-	it( 'should create ReinsertOperation when reversed', () => {
-		const position = new Position( root, [ 0 ] );
-		const operation = new RemoveOperation( position, 2, new Position( doc.graveyard, [ 0 ] ), 0 );
-		const reverse = operation.getReversed();
-
-		expect( reverse ).to.be.an.instanceof( ReinsertOperation );
-		expect( reverse.baseVersion ).to.equal( 1 );
-		expect( reverse.howMany ).to.equal( 2 );
-		expect( reverse.sourcePosition.isEqual( operation.targetPosition ) ).to.be.true;
-		expect( reverse.targetPosition.isEqual( position ) ).to.be.true;
-	} );
-
-	it( 'should create correct ReinsertOperation when reversed if source range was in graveyard', () => {
-		const operation = new RemoveOperation( new Position( doc.graveyard, [ 2 ] ), 1, new Position( doc.graveyard, [ 0 ] ), 0 );
-		const reverse = operation.getReversed();
-
-		expect( reverse.sourcePosition.path ).to.deep.equal( [ 0 ] );
-		expect( reverse.targetPosition.path ).to.deep.equal( [ 3 ] );
-	} );
-
-	it( 'should undo remove set of nodes by applying reverse operation', () => {
-		const position = new Position( root, [ 0 ] );
-		const operation = new RemoveOperation( position, 3, new Position( doc.graveyard, [ 0 ] ), 0 );
-		const reverse = operation.getReversed();
-
-		root._insertChild( 0, new Text( 'bar' ) );
-
-		model.applyOperation( operation );
-
-		expect( doc.version ).to.equal( 1 );
-		expect( root.maxOffset ).to.equal( 0 );
-
-		model.applyOperation( reverse );
-
-		expect( doc.version ).to.equal( 2 );
-		expect( root.maxOffset ).to.equal( 3 );
-		expect( root.getChild( 0 ).data ).to.equal( 'bar' );
-	} );
-
-	it( 'should properly remove a node that is already in a graveyard', () => {
-		doc.graveyard._appendChild( [ new Element( 'x' ), new Element( 'y' ), new Element( 'z' ) ] );
-
-		const position = new Position( doc.graveyard, [ 2 ] );
-		const operation = new RemoveOperation( position, 1, new Position( doc.graveyard, [ 0 ] ), 0 );
-
-		model.applyOperation( operation );
-
-		expect( doc.graveyard.childCount ).to.equal( 3 );
-		expect( doc.graveyard.getChild( 0 ).name ).to.equal( 'z' );
-		expect( doc.graveyard.getChild( 1 ).name ).to.equal( 'x' );
-		expect( doc.graveyard.getChild( 2 ).name ).to.equal( 'y' );
-	} );
-
-	describe( '_validate()', () => {
-		it( 'should throw when is executed on detached item', () => {
-			const docFrag = new DocumentFragment();
-			const item = new Element( 'foo' );
-
-			docFrag._appendChild( [ item ] );
-
-			const op = new RemoveOperation(
-				new Position( docFrag, [ 0 ] ),
-				1,
-				new Position( doc.graveyard, [ 0 ] ),
-				doc.version
-			);
-
-			expect( () => {
-				op._validate();
-			} ).to.throw( CKEditorError, /^remove-operation-on-detached-item/ );
-		} );
-	} );
-
-	describe( 'toJSON', () => {
-		it( 'should create proper json object', () => {
-			const op = new RemoveOperation(
-				new Position( root, [ 2 ] ),
-				2,
-				new Position( doc.graveyard, [ 0 ] ),
-				doc.version
-			);
-
-			const serialized = jsonParseStringify( op );
-
-			expect( serialized ).to.deep.equal( {
-				__className: 'engine.model.operation.RemoveOperation',
-				baseVersion: 0,
-				howMany: 2,
-				sourcePosition: jsonParseStringify( op.sourcePosition ),
-				targetPosition: jsonParseStringify( op.targetPosition )
-			} );
-		} );
-	} );
-
-	describe( 'fromJSON', () => {
-		it( 'should create proper RemoveOperation from json object', () => {
-			const op = new RemoveOperation(
-				new Position( root, [ 2 ] ),
-				2,
-				new Position( doc.graveyard, [ 0 ] ),
-				doc.version
-			);
-
-			const serialized = jsonParseStringify( op );
-			const deserialized = RemoveOperation.fromJSON( serialized, doc );
-
-			expect( deserialized ).to.deep.equal( op );
-		} );
-	} );
-} );

+ 3 - 4
packages/ckeditor5-engine/tests/model/operation/renameoperation.js

@@ -8,7 +8,6 @@ import Element from '../../../src/model/element';
 import RenameOperation from '../../../src/model/operation/renameoperation';
 import RenameOperation from '../../../src/model/operation/renameoperation';
 import Position from '../../../src/model/position';
 import Position from '../../../src/model/position';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
-import { jsonParseStringify } from '../../../tests/model/_utils/utils';
 
 
 describe( 'RenameOperation', () => {
 describe( 'RenameOperation', () => {
 	const oldName = 'oldName';
 	const oldName = 'oldName';
@@ -106,12 +105,12 @@ describe( 'RenameOperation', () => {
 	describe( 'toJSON', () => {
 	describe( 'toJSON', () => {
 		it( 'should create proper serialized object', () => {
 		it( 'should create proper serialized object', () => {
 			const op = new RenameOperation( Position.createAt( root, 'end' ), oldName, newName, doc.version );
 			const op = new RenameOperation( Position.createAt( root, 'end' ), oldName, newName, doc.version );
-			const serialized = jsonParseStringify( op );
+			const serialized = op.toJSON();
 
 
 			expect( serialized ).to.deep.equal( {
 			expect( serialized ).to.deep.equal( {
 				__className: 'engine.model.operation.RenameOperation',
 				__className: 'engine.model.operation.RenameOperation',
 				baseVersion: 0,
 				baseVersion: 0,
-				position: jsonParseStringify( op.position ),
+				position: op.position.toJSON(),
 				newName: 'newName',
 				newName: 'newName',
 				oldName: 'oldName'
 				oldName: 'oldName'
 			} );
 			} );
@@ -122,7 +121,7 @@ describe( 'RenameOperation', () => {
 		it( 'should create proper AttributeOperation from json object', () => {
 		it( 'should create proper AttributeOperation from json object', () => {
 			const op = new RenameOperation( Position.createAt( root, 'end' ), oldName, newName, doc.version );
 			const op = new RenameOperation( Position.createAt( root, 'end' ), oldName, newName, doc.version );
 
 
-			const serialized = jsonParseStringify( op );
+			const serialized = op.toJSON();
 			const deserialized = RenameOperation.fromJSON( serialized, doc );
 			const deserialized = RenameOperation.fromJSON( serialized, doc );
 
 
 			expect( deserialized ).to.deep.equal( op );
 			expect( deserialized ).to.deep.equal( op );

+ 4 - 4
packages/ckeditor5-engine/tests/model/operation/rootattributeoperation.js

@@ -8,7 +8,6 @@ import DocumentFragment from '../../../src/model/documentfragment';
 import Element from '../../../src/model/element';
 import Element from '../../../src/model/element';
 import RootAttributeOperation from '../../../src/model/operation/rootattributeoperation';
 import RootAttributeOperation from '../../../src/model/operation/rootattributeoperation';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
-import { jsonParseStringify } from '../../../tests/model/_utils/utils';
 
 
 describe( 'RootAttributeOperation', () => {
 describe( 'RootAttributeOperation', () => {
 	let model, doc, root;
 	let model, doc, root;
@@ -268,7 +267,7 @@ describe( 'RootAttributeOperation', () => {
 				doc.version
 				doc.version
 			);
 			);
 
 
-			const serialized = jsonParseStringify( op );
+			const serialized = op.toJSON();
 
 
 			expect( serialized.__className ).to.equal( 'engine.model.operation.RootAttributeOperation' );
 			expect( serialized.__className ).to.equal( 'engine.model.operation.RootAttributeOperation' );
 			expect( serialized ).to.deep.equal( {
 			expect( serialized ).to.deep.equal( {
@@ -286,7 +285,7 @@ describe( 'RootAttributeOperation', () => {
 		it( 'should create proper RootAttributeOperation from json object', () => {
 		it( 'should create proper RootAttributeOperation from json object', () => {
 			const op = new RootAttributeOperation( root, 'key', null, 'newValue', doc.version );
 			const op = new RootAttributeOperation( root, 'key', null, 'newValue', doc.version );
 
 
-			const serialized = jsonParseStringify( op );
+			const serialized = op.toJSON();
 			const deserialized = RootAttributeOperation.fromJSON( serialized, doc );
 			const deserialized = RootAttributeOperation.fromJSON( serialized, doc );
 
 
 			expect( deserialized ).to.deep.equal( op );
 			expect( deserialized ).to.deep.equal( op );
@@ -301,7 +300,8 @@ describe( 'RootAttributeOperation', () => {
 				doc.version
 				doc.version
 			);
 			);
 
 
-			const serialized = jsonParseStringify( op );
+			const serialized = op.toJSON();
+
 			serialized.root = 'no-root';
 			serialized.root = 'no-root';
 
 
 			expect( () => {
 			expect( () => {

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 135 - 151
packages/ckeditor5-engine/tests/model/operation/transform.js


+ 723 - 25
packages/ckeditor5-engine/tests/model/operation/transform/attribute.js

@@ -232,6 +232,21 @@ describe( 'transform', () => {
 				);
 				);
 			} );
 			} );
 
 
+			it( 'text between changed nodes', () => {
+				john.setData( '<paragraph>[Foo</paragraph><paragraph>Bar]</paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph><paragraph>B[]ar</paragraph>' );
+
+				john.setAttribute( 'bold', true );
+				kate.type( 'Abc' );
+
+				syncClients();
+
+				expectClients(
+					'<paragraph><$text bold="true">Foo</$text></paragraph>' +
+					'<paragraph><$text bold="true">BAbcar</$text></paragraph>'
+				);
+			} );
+
 			it( 'element in different path', () => {
 			it( 'element in different path', () => {
 				john.setData( '<paragraph>[Foo]</paragraph><paragraph>Bar</paragraph>' );
 				john.setData( '<paragraph>[Foo]</paragraph><paragraph>Bar</paragraph>' );
 				kate.setData( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>[]' );
 				kate.setData( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>[]' );
@@ -263,6 +278,96 @@ describe( 'transform', () => {
 					'<paragraph>Abc</paragraph>'
 					'<paragraph>Abc</paragraph>'
 				);
 				);
 			} );
 			} );
+
+			it( 'remove attribute from element in different path', () => {
+				john.setData( '<paragraph>[]Foo</paragraph><paragraph bold="true">Bar</paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph>[<paragraph bold="true">Bar</paragraph>]' );
+
+				john.type( 'Abc' );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<paragraph>AbcFoo</paragraph><paragraph>Bar</paragraph>' );
+			} );
+
+			it( 'remove attribute from text in different path', () => {
+				john.setData( '<paragraph>[]Foo</paragraph><paragraph><$text bold="true">Bar</$text></paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph><paragraph><$text bold="true">[Bar]</$text></paragraph>' );
+
+				john.type( 'Abc' );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<paragraph>AbcFoo</paragraph><paragraph>Bar</paragraph>' );
+			} );
+
+			it( 'remove attribute from text while typing in client\'s range', () => {
+				john.setData( '<paragraph>F<$text bold="true">o[]o</$text></paragraph>' );
+				kate.setData( '<paragraph>F<$text bold="true">[oo]</$text></paragraph>' );
+
+				john.type( 'Bar' );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<paragraph>FoBaro</paragraph>' );
+			} );
+
+			it( 'remove attribute from element in same path', () => {
+				john.setData( '<paragraph bold="true">[]Foo</paragraph>' );
+				kate.setData( '[<paragraph bold="true">Foo</paragraph>]' );
+
+				john.type( 'Bar' );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<paragraph>BarFoo</paragraph>' );
+			} );
+
+			it( 'remove attribute from text with 2 attributes while typing in client\'s range', () => {
+				john.setData( '<paragraph>F<$text bold="true" italic="true">o[]o</$text></paragraph>' );
+				kate.setData( '<paragraph>F<$text bold="true" italic="true">[oo]</$text></paragraph>' );
+
+				john.type( 'Bar', john.document.selection.getAttributes() );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<paragraph>F<$text italic="true">oBaro</$text></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', () => {
 		describe( 'by move', () => {
@@ -325,7 +430,7 @@ describe( 'transform', () => {
 				kate.setData( '<paragraph>F[oo] Bar</paragraph>' );
 				kate.setData( '<paragraph>F[oo] Bar</paragraph>' );
 
 
 				john.setAttribute( 'bold', true );
 				john.setAttribute( 'bold', true );
-				kate.move( [ 0, 7 ], [ 0, 1 ], [ 0, 3 ] );
+				kate.move( [ 0, 7 ] );
 
 
 				syncClients();
 				syncClients();
 
 
@@ -343,6 +448,87 @@ describe( 'transform', () => {
 
 
 				expectClients( '<paragraph bold="true">F Baroo</paragraph>' );
 				expectClients( '<paragraph bold="true">F Baroo</paragraph>' );
 			} );
 			} );
+
+			it( 'remove attribute from element in different path', () => {
+				john.setData( '<paragraph>F[oo]</paragraph><paragraph bold="true">Bar</paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph>[<paragraph bold="true">Bar</paragraph>]' );
+
+				john.move( [ 1, 0 ] );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients(
+					'<paragraph>F</paragraph>' +
+					'<paragraph>ooBar</paragraph>'
+				);
+			} );
+
+			it( 'remove attribute from text in different path', () => {
+				john.setData( '<paragraph>F[oo]</paragraph><paragraph><$text bold="true">Bar</$text></paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph><paragraph><$text bold="true">[Bar]</$text></paragraph>' );
+
+				john.move( [ 1, 0 ] );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients(
+					'<paragraph>F</paragraph>' +
+					'<paragraph>ooBar</paragraph>'
+				);
+			} );
+
+			it( 'remove attribute from text in same path', () => {
+				john.setData( '<paragraph>[Fo]<$text bold="true">o</$text></paragraph>' );
+				kate.setData( '<paragraph>Fo<$text bold="true">[o]</$text></paragraph>' );
+
+				john.move( [ 0, 3 ] );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<paragraph>oFo</paragraph>' );
+			} );
+
+			it( 'remove attribute from element in same path', () => {
+				john.setData( '<paragraph bold="true">[Fo]o</paragraph>' );
+				kate.setData( '[<paragraph bold="true">Foo</paragraph>]' );
+
+				john.move( [ 0, 3 ] );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<paragraph>oFo</paragraph>' );
+			} );
+
+			it( 'remove attribute from text with 2 attributes in same path', () => {
+				john.setData( '<paragraph>[Fo]<$text bold="true" italic="true">o</$text></paragraph>' );
+				kate.setData( '<paragraph>Fo<$text bold="true" italic="true">[o]</$text></paragraph>' );
+
+				john.move( [ 0, 3 ] );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<paragraph><$text italic="true">o</$text>Fo</paragraph>' );
+			} );
+
+			it( 'remove attribute from text in other user\'s selection', () => {
+				john.setData( '<paragraph><$text bold="true">[Foo]</$text></paragraph><paragraph></paragraph>' );
+				kate.setData( '<paragraph><$text bold="true">[Foo]</$text></paragraph><paragraph></paragraph>' );
+
+				john.move( [ 1, 0 ] );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients(
+					'<paragraph></paragraph>' +
+					'<paragraph>Foo</paragraph>'
+				);
+			} );
 		} );
 		} );
 
 
 		describe( 'by wrap', () => {
 		describe( 'by wrap', () => {
@@ -407,6 +593,80 @@ describe( 'transform', () => {
 					'</blockQuote>'
 					'</blockQuote>'
 				);
 				);
 			} );
 			} );
+
+			it( 'remove attribute from element in different path', () => {
+				john.setData( '[<paragraph>Foo</paragraph>]<paragraph bold="true">Bar</paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph>[<paragraph bold="true">Bar</paragraph>]' );
+
+				john.wrap( 'blockQuote' );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients(
+					'<blockQuote>' +
+						'<paragraph>Foo</paragraph>' +
+					'</blockQuote>' +
+					'<paragraph>Bar</paragraph>'
+				);
+			} );
+
+			it( 'remove attribute from text in different path', () => {
+				john.setData( '[<paragraph>Foo</paragraph>]<paragraph><$text bold="true">Bar</$text></paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph><paragraph><$text bold="true">[Bar]</$text></paragraph>' );
+
+				john.wrap( 'blockQuote' );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients(
+					'<blockQuote>' +
+						'<paragraph>Foo</paragraph>' +
+					'</blockQuote>' +
+					'<paragraph>Bar</paragraph>'
+				);
+			} );
+
+			it( 'remove attribute from text in same path', () => {
+				john.setData( '[<paragraph>Fo<$text bold="true">o</$text></paragraph>]' );
+				kate.setData( '<paragraph>Fo<$text bold="true">[o]</$text></paragraph>' );
+
+				john.wrap( 'blockQuote' );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<blockQuote><paragraph>Foo</paragraph></blockQuote>' );
+			} );
+
+			it( 'remove attribute from element in same path', () => {
+				john.setData( '[<paragraph bold="true">Foo</paragraph>]' );
+				kate.setData( '[<paragraph bold="true">Foo</paragraph>]' );
+
+				john.wrap( 'blockQuote' );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<blockQuote><paragraph>Foo</paragraph></blockQuote>' );
+			} );
+
+			it( 'remove attribute from text with 2 attributes in same path', () => {
+				john.setData( '[<paragraph>Fo<$text bold="true" italic="true">o</$text></paragraph>]' );
+				kate.setData( '<paragraph>Fo<$text bold="true" italic="true">[o]</$text></paragraph>' );
+
+				john.wrap( 'blockQuote' );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients(
+					'<blockQuote>' +
+						'<paragraph>Fo<$text italic="true">o</$text></paragraph>' +
+					'</blockQuote>'
+				);
+			} );
 		} );
 		} );
 
 
 		describe( 'by unwrap', () => {
 		describe( 'by unwrap', () => {
@@ -440,6 +700,21 @@ describe( 'transform', () => {
 				);
 				);
 			} );
 			} );
 
 
+			it( 'text in different path', () => {
+				john.setData( '<paragraph>[Foo]</paragraph><blockQuote><paragraph>Bar</paragraph></blockQuote>' );
+				kate.setData( '<paragraph>Foo</paragraph><blockQuote><paragraph>[]Bar</paragraph></blockQuote>' );
+
+				john.setAttribute( 'bold', true );
+				kate.unwrap();
+
+				syncClients();
+
+				expectClients(
+					'<paragraph><$text bold="true">Foo</$text></paragraph>' +
+					'<blockQuote>Bar</blockQuote>'
+				);
+			} );
+
 			it( 'element in same path #1', () => {
 			it( 'element in same path #1', () => {
 				john.setData( '<blockQuote><paragraph>[Foo]</paragraph></blockQuote>' );
 				john.setData( '<blockQuote><paragraph>[Foo]</paragraph></blockQuote>' );
 				kate.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
 				kate.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
@@ -449,9 +724,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph><$text bold="true">Foo</$text></paragraph>'
-				);
+				expectClients( '<paragraph><$text bold="true">Foo</$text></paragraph>' );
 			} );
 			} );
 
 
 			it( 'element in same path #2', () => {
 			it( 'element in same path #2', () => {
@@ -465,6 +738,92 @@ describe( 'transform', () => {
 
 
 				expectClients( '<paragraph bold="true">Foo</paragraph>' );
 				expectClients( '<paragraph bold="true">Foo</paragraph>' );
 			} );
 			} );
+
+			it( 'text in same path', () => {
+				john.setData( '<blockQuote><paragraph>[Foo]</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
+
+				john.setAttribute( 'bold', true );
+				kate.unwrap();
+
+				syncClients();
+
+				expectClients( '<blockQuote><$text bold="true">Foo</$text></blockQuote>' );
+			} );
+
+			it( 'remove attribute from element in different path', () => {
+				john.setData(
+					'<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' +
+					'<paragraph bold="true">Bar</paragraph>' );
+				kate.setData(
+					'<blockQuote><paragraph>Foo</paragraph></blockQuote>' +
+					'[<paragraph bold="true">Bar</paragraph>]' );
+
+				john.unwrap();
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients(
+					'<paragraph>Foo</paragraph>' +
+					'<paragraph>Bar</paragraph>'
+				);
+			} );
+
+			it( 'remove attribute from text in different path', () => {
+				john.setData(
+					'<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' +
+					'<paragraph><$text bold="true">Bar</$text></paragraph>' );
+				kate.setData(
+					'<blockQuote><paragraph>Foo</paragraph></blockQuote>' +
+					'<paragraph><$text bold="true">[Bar]</$text></paragraph>' );
+
+				john.unwrap();
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients(
+					'<blockQuote>Foo</blockQuote>' +
+					'<paragraph>Bar</paragraph>'
+				);
+			} );
+
+			it( 'remove attribute from text in same path', () => {
+				john.setData( '<blockQuote><paragraph>[]Fo<$text bold="true">o</$text></paragraph></blockQuote>' );
+				kate.setData( '<blockQuote><paragraph>Fo<$text bold="true">[o]</$text></paragraph></blockQuote>' );
+
+				john.unwrap();
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<blockQuote>Foo</blockQuote>' );
+			} );
+
+			it( 'remove attribute from element in same path', () => {
+				john.setData( '<blockQuote><paragraph bold="true">[]Foo</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote>[<paragraph bold="true">Foo</paragraph>]</blockQuote>' );
+
+				john.unwrap();
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<blockQuote>Foo</blockQuote>' );
+			} );
+
+			it( 'remove attribute from text with 2 attributes in same path', () => {
+				john.setData( '<blockQuote><paragraph>[]Fo<$text bold="true" italic="true">o</$text></paragraph></blockQuote>' );
+				kate.setData( '<blockQuote><paragraph>Fo<$text bold="true" italic="true">[o]</$text></paragraph></blockQuote>' );
+
+				john.unwrap();
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<blockQuote>Fo<$text italic="true">o</$text></blockQuote>' );
+			} );
 		} );
 		} );
 
 
 		describe( 'by split', () => {
 		describe( 'by split', () => {
@@ -559,6 +918,95 @@ describe( 'transform', () => {
 					'<paragraph><$text attr="bar">ar</$text></paragraph>'
 					'<paragraph><$text attr="bar">ar</$text></paragraph>'
 				);
 				);
 			} );
 			} );
+
+			it( 'remove attribute from element in different path', () => {
+				john.setData( '<paragraph>F[]oo</paragraph><paragraph bold="true">Bar</paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph>[<paragraph bold="true">Bar</paragraph>]' );
+
+				john.split();
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients(
+					'<paragraph>F</paragraph>' +
+					'<paragraph>oo</paragraph>' +
+					'<paragraph>Bar</paragraph>'
+				);
+			} );
+
+			it( 'remove attribute from text in different path', () => {
+				john.setData( '<paragraph>F[]oo</paragraph><paragraph><$text bold="true">Bar</$text></paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph><paragraph><$text bold="true">[Bar]</$text></paragraph>' );
+
+				john.split();
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients(
+					'<paragraph>F</paragraph>' +
+					'<paragraph>oo</paragraph>' +
+					'<paragraph>Bar</paragraph>'
+				);
+			} );
+
+			it( 'remove attribute from text in same path #1', () => {
+				john.setData( '<paragraph>F[]o<$text bold="true">o</$text></paragraph>' );
+				kate.setData( '<paragraph>Fo<$text bold="true">[o]</$text></paragraph>' );
+
+				john.split();
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients(
+					'<paragraph>F</paragraph>' +
+					'<paragraph>oo</paragraph>' );
+			} );
+
+			it( 'remove attribute from text in same path #2', () => {
+				john.setData( '<paragraph>F<$text bold="true">o[]o</$text></paragraph>' );
+				kate.setData( '<paragraph>F<$text bold="true">[oo]</$text></paragraph>' );
+
+				john.split();
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients(
+					'<paragraph>Fo</paragraph>' +
+					'<paragraph>o</paragraph>' );
+			} );
+
+			it( 'remove attribute from element in same path', () => {
+				john.setData( '<paragraph bold="true">F[]oo</paragraph>' );
+				kate.setData( '[<paragraph bold="true">Foo</paragraph>]' );
+
+				john.split();
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients(
+					'<paragraph>F</paragraph>' +
+					'<paragraph>oo</paragraph>' );
+			} );
+
+			it( 'remove attribute from text with 2 attributes in same path', () => {
+				john.setData( '<paragraph>F<$text bold="true" italic="true">o[]o</$text></paragraph>' );
+				kate.setData( '<paragraph>F<$text bold="true" italic="true">[oo]</$text></paragraph>' );
+
+				john.split();
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients(
+					'<paragraph>F<$text italic="true">o</$text></paragraph>' +
+					'<paragraph><$text italic="true">o</$text></paragraph>'
+				);
+			} );
 		} );
 		} );
 
 
 		describe( 'by remove', () => {
 		describe( 'by remove', () => {
@@ -586,9 +1034,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph><$text bold="true">Fo</$text></paragraph>'
-				);
+				expectClients( '<paragraph><$text bold="true">Fo</$text></paragraph>' );
 			} );
 			} );
 
 
 			it( 'text in other user\'s selection', () => {
 			it( 'text in other user\'s selection', () => {
@@ -600,10 +1046,114 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
+				expectClients( '<paragraph><$text bold="true">F</$text></paragraph>' );
+			} );
+
+			it( 'remove attribute from element in different path', () => {
+				john.setData( '<paragraph>F[oo]</paragraph><paragraph bold="true">Bar</paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph>[<paragraph bold="true">Bar</paragraph>]' );
+
+				john.remove();
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients(
+					'<paragraph>F</paragraph>' +
+					'<paragraph>Bar</paragraph>'
+				);
+			} );
+
+			it( 'remove attribute from text in different path', () => {
+				john.setData( '<paragraph>F[oo]</paragraph><paragraph><$text bold="true">Bar</$text></paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph><paragraph><$text bold="true">[Bar]</$text></paragraph>' );
+
+				john.remove();
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
 				expectClients(
 				expectClients(
-					'<paragraph><$text bold="true">F</$text></paragraph>'
+					'<paragraph>F</paragraph>' +
+					'<paragraph>Bar</paragraph>'
 				);
 				);
 			} );
 			} );
+
+			it( 'remove attribute from text in same path #1', () => {
+				john.setData( '<paragraph>[Fo]<$text bold="true">o</$text></paragraph>' );
+				kate.setData( '<paragraph>Fo<$text bold="true">[o]</$text></paragraph>' );
+
+				john.remove();
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<paragraph>o</paragraph>' );
+			} );
+
+			it( 'remove attribute from text in same path #2', () => {
+				john.setData( '<paragraph>F[o<$text bold="true">o]z</$text>Bar</paragraph>' );
+				kate.setData( '<paragraph>Fo<$text bold="true">[oz]</$text>Bar</paragraph>' );
+
+				john.remove();
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients(
+					'<paragraph>FzBar</paragraph>'
+				);
+			} );
+
+			it( 'remove attribute from element in same path', () => {
+				john.setData( '<paragraph bold="true">[Fo]o</paragraph>' );
+				kate.setData( '[<paragraph bold="true">Foo</paragraph>]' );
+
+				john.remove();
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<paragraph>o</paragraph>' );
+			} );
+
+			it( 'remove attribute from text with 2 attributes in same path #1', () => {
+				john.setData( '<paragraph>[Fo]<$text bold="true" italic="true">o</$text></paragraph>' );
+				kate.setData( '<paragraph>Fo<$text bold="true" italic="true">[o]</$text></paragraph>' );
+
+				john.remove();
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<paragraph><$text italic="true">o</$text></paragraph>' );
+			} );
+
+			it( 'remove attribute from text with 2 attributes in same path #2', () => {
+				john.setData( '<paragraph>F[o<$text bold="true" italic="true">o]z</$text>Bar</paragraph>' );
+				kate.setData( '<paragraph>Fo<$text bold="true" italic="true">[oz]</$text>Bar</paragraph>' );
+
+				john.remove();
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients(
+					'<paragraph>F<$text italic="true">z</$text>Bar</paragraph>'
+				);
+			} );
+
+			it( 'remove attribute from text in other user\'s selection', () => {
+				john.setData( '<paragraph><$text bold="true">[Foo]</$text></paragraph>' );
+				kate.setData( '<paragraph><$text bold="true">[Foo]</$text></paragraph>' );
+
+				john.remove();
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<paragraph></paragraph>' );
+			} );
 		} );
 		} );
 
 
 		describe( 'by remove attribute', () => {
 		describe( 'by remove attribute', () => {
@@ -646,9 +1196,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph><$text bold="true">Fo</$text>o</paragraph>'
-				);
+				expectClients( '<paragraph><$text bold="true">Fo</$text>o</paragraph>' );
 			} );
 			} );
 
 
 			it( 'from element in same path', () => {
 			it( 'from element in same path', () => {
@@ -672,9 +1220,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph><$text bold="true">Fo</$text><$text italic="true">o</$text></paragraph>'
-				);
+				expectClients( '<paragraph><$text bold="true">Fo</$text><$text italic="true">o</$text></paragraph>' );
 			} );
 			} );
 
 
 			it( 'from text in other user\'s selection', () => {
 			it( 'from text in other user\'s selection', () => {
@@ -686,9 +1232,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph><$text italic="true">Foo</$text></paragraph>'
-				);
+				expectClients( '<paragraph><$text italic="true">Foo</$text></paragraph>' );
 			} );
 			} );
 		} );
 		} );
 
 
@@ -780,6 +1324,88 @@ describe( 'transform', () => {
 
 
 				expectClients( '<paragraph><m1:start></m1:start><$text bold="true">Foo Bar</$text><m1:end></m1:end></paragraph>' );
 				expectClients( '<paragraph><m1:start></m1:start><$text bold="true">Foo Bar</$text><m1:end></m1:end></paragraph>' );
 			} );
 			} );
+
+			it( 'remove attribute from element in different path', () => {
+				john.setData( '<paragraph>[Foo]</paragraph><paragraph bold="true">Bar</paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph>[<paragraph bold="true">Bar</paragraph>]' );
+
+				john.setMarker( 'm1' );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients(
+					'<paragraph><m1:start></m1:start>Foo<m1:end></m1:end></paragraph>' +
+					'<paragraph>Bar</paragraph>'
+				);
+			} );
+
+			it( 'remove attribute from text in different path', () => {
+				john.setData( '<paragraph>[Foo]</paragraph><paragraph><$text bold="true">Bar</$text></paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph><paragraph><$text bold="true">[Bar]</$text></paragraph>' );
+
+				john.setMarker( 'm1' );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients(
+					'<paragraph><m1:start></m1:start>Foo<m1:end></m1:end></paragraph>' +
+					'<paragraph>Bar</paragraph>'
+				);
+			} );
+
+			it( 'remove attribute from text in same path', () => {
+				john.setData( '<paragraph>[Fo]<$text bold="true">o</$text></paragraph>' );
+				kate.setData( '<paragraph>Fo<$text bold="true">[o]</$text></paragraph>' );
+
+				john.setMarker( 'm1' );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<paragraph><m1:start></m1:start>Fo<m1:end></m1:end>o</paragraph>' );
+			} );
+
+			it( 'remove attribute from text in same path, then undo', () => {
+				john.setData( '<paragraph>[Fo]<$text bold="true">o</$text></paragraph>' );
+				kate.setData( '<paragraph>Fo<$text bold="true">[o]</$text></paragraph>' );
+
+				john.setMarker( 'm1' );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				kate.undo();
+
+				syncClients();
+
+				expectClients( '<paragraph><m1:start></m1:start>Fo<m1:end></m1:end><$text bold="true">o</$text></paragraph>' );
+			} );
+
+			it( 'remove attribute from text with 2 attributes in same path', () => {
+				john.setData( '<paragraph>[Fo]<$text bold="true" italic="true">o</$text></paragraph>' );
+				kate.setData( '<paragraph>Fo<$text bold="true" italic="true">[o]</$text></paragraph>' );
+
+				john.setMarker( 'm1' );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<paragraph><m1:start></m1:start>Fo<m1:end></m1:end><$text italic="true">o</$text></paragraph>' );
+			} );
+
+			it( 'remove attribute from text in other user\'s selection', () => {
+				john.setData( '<paragraph><$text bold="true">[Foo]</$text></paragraph>' );
+				kate.setData( '<paragraph><$text bold="true">[Foo]</$text></paragraph>' );
+
+				john.setMarker( 'm1' );
+				kate.removeAttribute( 'bold' );
+
+				syncClients();
+
+				expectClients( '<paragraph><m1:start></m1:start>Foo<m1:end></m1:end></paragraph>' );
+			} );
 		} );
 		} );
 
 
 		describe( 'by merge', () => {
 		describe( 'by merge', () => {
@@ -795,7 +1421,7 @@ describe( 'transform', () => {
 				expectClients( '<paragraph>FooB<$text bold="true">ar</$text></paragraph>' );
 				expectClients( '<paragraph>FooB<$text bold="true">ar</$text></paragraph>' );
 			} );
 			} );
 
 
-			it.skip( 'element into paragraph #2, then undo', () => {
+			it( 'element into paragraph #2, then undo', () => {
 				john.setData( '<paragraph>Foo</paragraph>[<paragraph>Bar</paragraph>]' );
 				john.setData( '<paragraph>Foo</paragraph>[<paragraph>Bar</paragraph>]' );
 				kate.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );
 				kate.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );
 
 
@@ -838,9 +1464,7 @@ describe( 'transform', () => {
 
 
 			syncClients();
 			syncClients();
 
 
-			expectClients(
-				'<heading1><$text bold="true">Foo Bar</$text></heading1>'
-			);
+			expectClients( '<heading1><$text bold="true">Foo Bar</$text></heading1>' );
 		} );
 		} );
 
 
 		it( 'element in user\'s selection', () => {
 		it( 'element in user\'s selection', () => {
@@ -852,9 +1476,7 @@ describe( 'transform', () => {
 
 
 			syncClients();
 			syncClients();
 
 
-			expectClients(
-				'<heading1><$text bold="true">Foo</$text></heading1>'
-			);
+			expectClients( '<heading1><$text bold="true">Foo</$text></heading1>' );
 		} );
 		} );
 
 
 		it( 'element in user\'s selection, then undo', () => {
 		it( 'element in user\'s selection, then undo', () => {
@@ -871,9 +1493,85 @@ describe( 'transform', () => {
 
 
 			syncClients();
 			syncClients();
 
 
+			expectClients( '<paragraph>Foo</paragraph>' );
+		} );
+
+		it( 'remove attribute from element in different path', () => {
+			john.setData( '<paragraph>F[]oo</paragraph><paragraph bold="true">Bar</paragraph>' );
+			kate.setData( '<paragraph>Foo</paragraph>[<paragraph bold="true">Bar</paragraph>]' );
+
+			john.rename( 'heading1' );
+			kate.removeAttribute( 'bold' );
+
+			syncClients();
+
 			expectClients(
 			expectClients(
-				'<paragraph>Foo</paragraph>'
+				'<heading1>Foo</heading1>' +
+				'<paragraph>Bar</paragraph>'
 			);
 			);
 		} );
 		} );
+
+		it( 'remove attribute from text in different path', () => {
+			john.setData( '<paragraph>F[]oo</paragraph><paragraph><$text bold="true">Bar</$text></paragraph>' );
+			kate.setData( '<paragraph>Foo</paragraph><paragraph><$text bold="true">[Bar]</$text></paragraph>' );
+
+			john.rename( 'heading1' );
+			kate.removeAttribute( 'bold' );
+
+			syncClients();
+
+			expectClients(
+				'<heading1>Foo</heading1>' +
+				'<paragraph>Bar</paragraph>'
+			);
+		} );
+
+		it( 'remove attribute from text in same path', () => {
+			john.setData( '<paragraph>F[]o<$text bold="true">o</$text></paragraph>' );
+			kate.setData( '<paragraph>Fo<$text bold="true">[o]</$text></paragraph>' );
+
+			john.rename( 'heading1' );
+			kate.removeAttribute( 'bold' );
+
+			syncClients();
+
+			expectClients( '<heading1>Foo</heading1>' );
+		} );
+
+		it( 'remove attribute from element in same path', () => {
+			john.setData( '<paragraph bold="true">F[]oo</paragraph>' );
+			kate.setData( '[<paragraph bold="true">Foo</paragraph>]' );
+
+			john.rename( 'heading1' );
+			kate.removeAttribute( 'bold' );
+
+			syncClients();
+
+			expectClients( '<heading1>Foo</heading1>' );
+		} );
+
+		it( 'remove attribute from text with 2 attributes in same path', () => {
+			john.setData( '<paragraph>F[o]<$text bold="true" italic="true">o</$text></paragraph>' );
+			kate.setData( '<paragraph>Fo<$text bold="true" italic="true">[o]</$text></paragraph>' );
+
+			john.rename( 'heading1' );
+			kate.removeAttribute( 'bold' );
+
+			syncClients();
+
+			expectClients( '<heading1>Fo<$text italic="true">o</$text></heading1>' );
+		} );
+
+		it( 'remove attribute from text in other user\'s selection', () => {
+			john.setData( '<paragraph><$text bold="true">[Foo]</$text></paragraph>' );
+			kate.setData( '<paragraph><$text bold="true">[Foo]</$text></paragraph>' );
+
+			john.rename( 'heading1' );
+			kate.removeAttribute( 'bold' );
+
+			syncClients();
+
+			expectClients( '<heading1>Foo</heading1>' );
+		} );
 	} );
 	} );
 } );
 } );

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

@@ -39,6 +39,24 @@ describe( 'transform', () => {
 
 
 				expectClients( '<paragraph>Fobc</paragraph>' );
 				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>' );
+			} );
 		} );
 		} );
 	} );
 	} );
 } );
 } );

+ 51 - 93
packages/ckeditor5-engine/tests/model/operation/transform/insert.js

@@ -175,9 +175,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph></paragraph>'
-				);
+				expectClients( '<paragraph></paragraph>' );
 			} );
 			} );
 		} );
 		} );
 
 
@@ -216,6 +214,21 @@ describe( 'transform', () => {
 				);
 				);
 			} );
 			} );
 
 
+			it( 'text at different paths', () => {
+				john.setData( '<paragraph>[]Foo</paragraph><paragraph>Bar</paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph><paragraph>B[ar]</paragraph>' );
+
+				john.type( 'Abc' );
+				kate.move( [ 1, 0 ] );
+
+				syncClients();
+
+				expectClients(
+					'<paragraph>AbcFoo</paragraph>' +
+					'<paragraph>arB</paragraph>'
+				);
+			} );
+
 			it( 'text at same path', () => {
 			it( 'text at same path', () => {
 				john.setData( '<paragraph>F[]oo Bar</paragraph>' );
 				john.setData( '<paragraph>F[]oo Bar</paragraph>' );
 				kate.setData( '<paragraph>Foo B[ar]</paragraph>' );
 				kate.setData( '<paragraph>Foo B[ar]</paragraph>' );
@@ -254,7 +267,7 @@ describe( 'transform', () => {
 		} );
 		} );
 
 
 		describe( 'by wrap', () => {
 		describe( 'by wrap', () => {
-			it( 'element in same path', () => {
+			it( 'element in same path #1', () => {
 				john.setData( '<paragraph>Foo Bar</paragraph>[]' );
 				john.setData( '<paragraph>Foo Bar</paragraph>[]' );
 				kate.setData( '[<paragraph>Foo Bar</paragraph>]' );
 				kate.setData( '[<paragraph>Foo Bar</paragraph>]' );
 
 
@@ -271,7 +284,7 @@ describe( 'transform', () => {
 				);
 				);
 			} );
 			} );
 
 
-			it( 'element in same path', () => {
+			it( 'element in same path #2', () => {
 				john.setData( '<paragraph>Foo[]</paragraph>' );
 				john.setData( '<paragraph>Foo[]</paragraph>' );
 				kate.setData( '[<paragraph>Foo</paragraph>]' );
 				kate.setData( '[<paragraph>Foo</paragraph>]' );
 
 
@@ -287,7 +300,7 @@ describe( 'transform', () => {
 				);
 				);
 			} );
 			} );
 
 
-			it( 'element in different paths', () => {
+			it( 'element in different paths #1', () => {
 				john.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );
 				john.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );
 				kate.setData( '<paragraph>Foo</paragraph>[<paragraph>Bar</paragraph>]' );
 				kate.setData( '<paragraph>Foo</paragraph>[<paragraph>Bar</paragraph>]' );
 
 
@@ -305,7 +318,7 @@ describe( 'transform', () => {
 				);
 				);
 			} );
 			} );
 
 
-			it( 'element in different paths', () => {
+			it( 'element in different paths #2', () => {
 				john.setData( '<paragraph>Foo</paragraph><paragraph>Bar[]</paragraph>' );
 				john.setData( '<paragraph>Foo</paragraph><paragraph>Bar[]</paragraph>' );
 				kate.setData( '[<paragraph>Foo</paragraph>]<paragraph>Bar</paragraph>' );
 				kate.setData( '[<paragraph>Foo</paragraph>]<paragraph>Bar</paragraph>' );
 
 
@@ -444,9 +457,19 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>FooAbc</paragraph><paragraph>Bar</paragraph>'
-				);
+				expectClients( '<paragraph>FooAbc</paragraph><paragraph>Bar</paragraph>' );
+			} );
+
+			it( 'text in different path', () => {
+				john.setData( '<paragraph>Foo[]</paragraph><blockQuote><paragraph>Bar</paragraph></blockQuote>' );
+				kate.setData( '<paragraph>Foo</paragraph><blockQuote><paragraph>[]Bar</paragraph></blockQuote>' );
+
+				john.type( 'Abc' );
+				kate.unwrap();
+
+				syncClients();
+
+				expectClients( '<paragraph>FooAbc</paragraph><blockQuote>Bar</blockQuote>' );
 			} );
 			} );
 
 
 			it( 'element in same path #1', () => {
 			it( 'element in same path #1', () => {
@@ -458,9 +481,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>Foo Bar</paragraph>'
-				);
+				expectClients( '<paragraph>Foo Bar</paragraph>' );
 			} );
 			} );
 
 
 			it( 'element in same path #2', () => {
 			it( 'element in same path #2', () => {
@@ -478,6 +499,18 @@ describe( 'transform', () => {
 				);
 				);
 			} );
 			} );
 
 
+			it( 'text in same path', () => {
+				john.setData( '<blockQuote><paragraph>Foo[]</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
+
+				john.type( ' Bar' );
+				kate.unwrap();
+
+				syncClients();
+
+				expectClients( '<blockQuote>Foo Bar</blockQuote>' );
+			} );
+
 			it( 'element, then insert text and move', () => {
 			it( 'element, then insert text and move', () => {
 				john.setData( '<blockQuote>[]<paragraph>Foo</paragraph></blockQuote>' );
 				john.setData( '<blockQuote>[]<paragraph>Foo</paragraph></blockQuote>' );
 				kate.setData( '<blockQuote>[]<paragraph>Foo</paragraph></blockQuote>' );
 				kate.setData( '<blockQuote>[]<paragraph>Foo</paragraph></blockQuote>' );
@@ -700,9 +733,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>FooAbc</paragraph><paragraph></paragraph>'
-				);
+				expectClients( '<paragraph>FooAbc</paragraph><paragraph></paragraph>' );
 			} );
 			} );
 
 
 			it( 'text in same path', () => {
 			it( 'text in same path', () => {
@@ -714,9 +745,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>Bar</paragraph>'
-				);
+				expectClients( '<paragraph>Bar</paragraph>' );
 			} );
 			} );
 
 
 			it( 'element in different path', () => {
 			it( 'element in different path', () => {
@@ -811,7 +840,7 @@ describe( 'transform', () => {
 				expectClients( '<paragraph>Foo</paragraph><paragraph>BarAbc</paragraph>' );
 				expectClients( '<paragraph>Foo</paragraph><paragraph>BarAbc</paragraph>' );
 			} );
 			} );
 
 
-			it.skip( 'element, then add marker, split and undo with type #2', () => {
+			it( 'element, then add marker, split and undo with type #2', () => {
 				john.setData( '<paragraph>Foo</paragraph>[]' );
 				john.setData( '<paragraph>Foo</paragraph>[]' );
 				kate.setData( '[<paragraph>Foo</paragraph>]' );
 				kate.setData( '[<paragraph>Foo</paragraph>]' );
 
 
@@ -845,8 +874,6 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				// Actual content:
-				// <paragraph></paragraph><paragraph>Ba</paragraph><paragraph></paragraph>
 				expectClients( '<paragraph>Foo</paragraph><paragraph>BarAbc</paragraph>' );
 				expectClients( '<paragraph>Foo</paragraph><paragraph>BarAbc</paragraph>' );
 			} );
 			} );
 		} );
 		} );
@@ -861,9 +888,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>Foo Bar</paragraph>'
-				);
+				expectClients( '<paragraph>Foo Bar</paragraph>' );
 			} );
 			} );
 		} );
 		} );
 
 
@@ -908,74 +933,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>BarFo<m1:start></m1:start>o<m1:end></m1:end></paragraph>'
-				);
-			} );
-		} );
-
-		// This should be moved to attribute.js.
-		describe( 'by remove attribute', () => {
-			it( 'from element in different path', () => {
-				john.setData( '<paragraph>[]Foo</paragraph><paragraph bold="true">Bar</paragraph>' );
-				kate.setData( '<paragraph>Foo</paragraph>[<paragraph bold="true">Bar</paragraph>]' );
-
-				john.type( 'Abc' );
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients( '<paragraph>AbcFoo</paragraph><paragraph>Bar</paragraph>' );
-			} );
-
-			it( 'from text in different path', () => {
-				john.setData( '<paragraph>[]Foo</paragraph><paragraph><$text bold="true">Bar</$text></paragraph>' );
-				kate.setData( '<paragraph>Foo</paragraph><paragraph><$text bold="true">[Bar]</$text></paragraph>' );
-
-				john.type( 'Abc' );
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients( '<paragraph>AbcFoo</paragraph><paragraph>Bar</paragraph>' );
-			} );
-
-			it( 'from text in same path', () => {
-				john.setData( '<paragraph>[]Fo<$text bold="true">o</$text></paragraph>' );
-				kate.setData( '<paragraph>Fo<$text bold="true">[o]</$text></paragraph>' );
-
-				john.type( 'Bar' );
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients( '<paragraph>BarFoo</paragraph>' );
-			} );
-
-			it( 'from element in same path', () => {
-				john.setData( '<paragraph bold="true">[]Foo</paragraph>' );
-				kate.setData( '[<paragraph bold="true">Foo</paragraph>]' );
-
-				john.type( 'Bar' );
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients( '<paragraph>BarFoo</paragraph>' );
-			} );
-
-			it( 'from text with 2 attributes in same path', () => {
-				john.setData( '<paragraph>[]Fo<$text bold="true" italic="true">o</$text></paragraph>' );
-				kate.setData( '<paragraph>Fo<$text bold="true" italic="true">[o]</$text></paragraph>' );
-
-				john.type( 'Bar' );
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients(
-					'<paragraph>BarFo<$text italic="true">o</$text></paragraph>'
-				);
+				expectClients( '<paragraph>BarFo<m1:start></m1:start>o<m1:end></m1:end></paragraph>' );
 			} );
 			} );
 		} );
 		} );
 
 

+ 93 - 134
packages/ckeditor5-engine/tests/model/operation/transform/marker.js

@@ -159,9 +159,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph></paragraph>'
-				);
+				expectClients( '<paragraph></paragraph>' );
 			} );
 			} );
 
 
 			it.skip( 'then remove text and undo', () => {
 			it.skip( 'then remove text and undo', () => {
@@ -183,7 +181,6 @@ describe( 'transform', () => {
 				kate.remove();
 				kate.remove();
 
 
 				syncClients();
 				syncClients();
-
 				expectClients( '<paragraph></paragraph>' );
 				expectClients( '<paragraph></paragraph>' );
 
 
 				john.undo();
 				john.undo();
@@ -191,8 +188,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				// Actual result for Kate:
-				// <paragraph><m1:start></m1:start>Fo<m1:end></m1:end><m2:start></m2:start>o<m2:end></m2:end> Bar</paragraph>
+				// Wrong markers transforming.
 				expectClients(
 				expectClients(
 					'<paragraph>' +
 					'<paragraph>' +
 						'<m1:start></m1:start>Foo<m1:end></m1:end><m2:start></m2:start> Bar<m2:end></m2:end>' +
 						'<m1:start></m1:start>Foo<m1:end></m1:end><m2:start></m2:start> Bar<m2:end></m2:end>' +
@@ -210,7 +206,9 @@ describe( 'transform', () => {
 				syncClients();
 				syncClients();
 
 
 				expectClients(
 				expectClients(
-					'<paragraph><m1:start></m1:start>Foo<m1:end></m1:end> <m2:start></m2:start>Bar<m2:end></m2:end></paragraph>'
+					'<paragraph>' +
+						'<m1:start></m1:start>Foo<m1:end></m1:end> <m2:start></m2:start>Bar<m2:end></m2:end>' +
+					'</paragraph>'
 				);
 				);
 
 
 				john.setSelection( [ 0, 1 ], [ 0, 3 ] );
 				john.setSelection( [ 0, 1 ], [ 0, 3 ] );
@@ -221,9 +219,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph><m1:start></m1:start>F<m1:end></m1:end> oo</paragraph>'
-				);
+				expectClients( '<paragraph><m1:start></m1:start>F<m1:end></m1:end> oo</paragraph>' );
 			} );
 			} );
 
 
 			it( 'then unwrap and merge', () => {
 			it( 'then unwrap and merge', () => {
@@ -314,9 +310,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>Foo Bar</paragraph>'
-				);
+				expectClients( '<paragraph>Foo Bar</paragraph>' );
 			} );
 			} );
 
 
 			it( 'with the same name', () => {
 			it( 'with the same name', () => {
@@ -357,9 +351,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>o<m1:start></m1:start>Fo<m1:end></m1:end></paragraph>'
-				);
+				expectClients( '<paragraph>o<m1:start></m1:start>Fo<m1:end></m1:end></paragraph>' );
 			} );
 			} );
 
 
 			it( 'text from other user\'s range #1', () => {
 			it( 'text from other user\'s range #1', () => {
@@ -373,9 +365,7 @@ describe( 'transform', () => {
 
 
 				// Actual result for Kate:
 				// Actual result for Kate:
 				// <paragraph>Ba<m1:start></m1:start>Foo r<m1:end></m1:end></paragraph>
 				// <paragraph>Ba<m1:start></m1:start>Foo r<m1:end></m1:end></paragraph>
-				expectClients(
-					'<paragraph><m1:start></m1:start>BaFoo r<m1:end></m1:end></paragraph>'
-				);
+				expectClients( '<paragraph><m1:start></m1:start>BaFoo r<m1:end></m1:end></paragraph>' );
 			} );
 			} );
 
 
 			it( 'text from other user\'s range #2', () => {
 			it( 'text from other user\'s range #2', () => {
@@ -436,9 +426,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph><m1:start></m1:start>Foo<m1:end></m1:end> </paragraph>'
-				);
+				expectClients( '<paragraph><m1:start></m1:start>Foo<m1:end></m1:end> </paragraph>' );
 			} );
 			} );
 
 
 			it( 'text in other user\'s selection #1', () => {
 			it( 'text in other user\'s selection #1', () => {
@@ -450,9 +438,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph><m1:start></m1:start>F<m1:end></m1:end></paragraph>'
-				);
+				expectClients( '<paragraph><m1:start></m1:start>F<m1:end></m1:end></paragraph>' );
 			} );
 			} );
 
 
 			it( 'text in other user\'s selection #2', () => {
 			it( 'text in other user\'s selection #2', () => {
@@ -464,9 +450,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph></paragraph>'
-				);
+				expectClients( '<paragraph></paragraph>' );
 			} );
 			} );
 		} );
 		} );
 
 
@@ -541,6 +525,21 @@ describe( 'transform', () => {
 				);
 				);
 			} );
 			} );
 
 
+			it( 'text in different path', () => {
+				john.setData( '<paragraph>[Foo]</paragraph><blockQuote><paragraph>Bar</paragraph></blockQuote>' );
+				kate.setData( '<paragraph>Foo</paragraph><blockQuote><paragraph>[]Bar</paragraph></blockQuote>' );
+
+				john.setMarker( 'm1' );
+				kate.unwrap();
+
+				syncClients();
+
+				expectClients(
+					'<paragraph><m1:start></m1:start>Foo<m1:end></m1:end></paragraph>' +
+					'<blockQuote>Bar</blockQuote>'
+				);
+			} );
+
 			it( 'element in same path', () => {
 			it( 'element in same path', () => {
 				john.setData( '<blockQuote><paragraph>[Foo]</paragraph></blockQuote>' );
 				john.setData( '<blockQuote><paragraph>[Foo]</paragraph></blockQuote>' );
 				kate.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
 				kate.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
@@ -550,9 +549,19 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph><m1:start></m1:start>Foo<m1:end></m1:end></paragraph>'
-				);
+				expectClients( '<paragraph><m1:start></m1:start>Foo<m1:end></m1:end></paragraph>' );
+			} );
+
+			it( 'text in same path', () => {
+				john.setData( '<blockQuote><paragraph>[Foo]</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
+
+				john.setMarker( 'm1' );
+				kate.unwrap();
+
+				syncClients();
+
+				expectClients( '<blockQuote><m1:start></m1:start>Foo<m1:end></m1:end></blockQuote>' );
 			} );
 			} );
 
 
 			it( 'element in same path, then undo', () => {
 			it( 'element in same path, then undo', () => {
@@ -568,9 +577,23 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>Foo</paragraph>'
-				);
+				expectClients( '<paragraph>Foo</paragraph>' );
+			} );
+
+			it( 'text in same path, then undo', () => {
+				john.setData( '<blockQuote><paragraph>[Foo]</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
+
+				john.setMarker( 'm1' );
+				kate.unwrap();
+
+				syncClients();
+
+				john.undo();
+
+				syncClients();
+
+				expectClients( '<blockQuote>Foo</blockQuote>' );
 			} );
 			} );
 		} );
 		} );
 
 
@@ -646,148 +669,86 @@ describe( 'transform', () => {
 			} );
 			} );
 		} );
 		} );
 
 
-		// Should be in attribute.js.
-		describe( 'by remove attribute', () => {
-			it( 'from element in different path', () => {
-				john.setData( '<paragraph>[Foo]</paragraph><paragraph bold="true">Bar</paragraph>' );
-				kate.setData( '<paragraph>Foo</paragraph>[<paragraph bold="true">Bar</paragraph>]' );
-
-				john.setMarker( 'm1' );
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients(
-					'<paragraph><m1:start></m1:start>Foo<m1:end></m1:end></paragraph>' +
-					'<paragraph>Bar</paragraph>'
-				);
-			} );
-
-			it( 'from text in different path', () => {
-				john.setData( '<paragraph>[Foo]</paragraph><paragraph><$text bold="true">Bar</$text></paragraph>' );
-				kate.setData( '<paragraph>Foo</paragraph><paragraph><$text bold="true">[Bar]</$text></paragraph>' );
-
-				john.setMarker( 'm1' );
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients(
-					'<paragraph><m1:start></m1:start>Foo<m1:end></m1:end></paragraph>' +
-					'<paragraph>Bar</paragraph>'
-				);
-			} );
-
-			it( 'from text in same path', () => {
-				john.setData( '<paragraph>[Fo]<$text bold="true">o</$text></paragraph>' );
-				kate.setData( '<paragraph>Fo<$text bold="true">[o]</$text></paragraph>' );
+		describe( 'by merge', () => {
+			it( 'element into paragraph', () => {
+				john.setData( '<paragraph>[Foo]</paragraph><paragraph> Bar</paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph>[]<paragraph> Bar</paragraph>' );
 
 
 				john.setMarker( 'm1' );
 				john.setMarker( 'm1' );
-				kate.removeAttribute( 'bold' );
+				kate.merge();
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph><m1:start></m1:start>Fo<m1:end></m1:end>o</paragraph>'
-				);
+				expectClients( '<paragraph><m1:start></m1:start>Foo<m1:end></m1:end> Bar</paragraph>' );
 			} );
 			} );
 
 
-			it( 'from text in same path, then undo', () => {
-				john.setData( '<paragraph>[Fo]<$text bold="true">o</$text></paragraph>' );
-				kate.setData( '<paragraph>Fo<$text bold="true">[o]</$text></paragraph>' );
+			it( 'elements into paragraph', () => {
+				john.setData( '<paragraph>[Foo]</paragraph><paragraph> Bar</paragraph><paragraph> Abc</paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph><paragraph> Bar</paragraph>[]<paragraph> Abc</paragraph>' );
 
 
 				john.setMarker( 'm1' );
 				john.setMarker( 'm1' );
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				kate.undo();
+				kate.merge();
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph><m1:start></m1:start>Fo<m1:end></m1:end><$text bold="true">o</$text></paragraph>'
-				);
-			} );
-
-			it( 'from text with 2 attributes in same path', () => {
-				john.setData( '<paragraph>[Fo]<$text bold="true" italic="true">o</$text></paragraph>' );
-				kate.setData( '<paragraph>Fo<$text bold="true" italic="true">[o]</$text></paragraph>' );
+				kate.setSelection( [ 1 ] );
 
 
-				john.setMarker( 'm1' );
-				kate.removeAttribute( 'bold' );
+				kate.merge();
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph><m1:start></m1:start>Fo<m1:end></m1:end><$text italic="true">o</$text></paragraph>'
-				);
+				expectClients( '<paragraph><m1:start></m1:start>Foo<m1:end></m1:end> Bar Abc</paragraph>' );
 			} );
 			} );
 
 
-			it( 'from text in other user\'s selection', () => {
-				john.setData( '<paragraph><$text bold="true">[Foo]</$text></paragraph>' );
-				kate.setData( '<paragraph><$text bold="true">[Foo]</$text></paragraph>' );
+			it( 'wrapped element into wrapped paragraph', () => {
+				john.setData( '<blockQuote><paragraph>[Foo]</paragraph><paragraph> Bar</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote><paragraph>Foo</paragraph>[]<paragraph> Bar</paragraph></blockQuote>' );
 
 
 				john.setMarker( 'm1' );
 				john.setMarker( 'm1' );
-				kate.removeAttribute( 'bold' );
+				kate.merge();
 
 
 				syncClients();
 				syncClients();
 
 
 				expectClients(
 				expectClients(
-					'<paragraph><m1:start></m1:start>Foo<m1:end></m1:end></paragraph>'
+					'<blockQuote>' +
+						'<paragraph><m1:start></m1:start>Foo<m1:end></m1:end> Bar</paragraph>' +
+					'</blockQuote>'
 				);
 				);
 			} );
 			} );
-		} );
 
 
-		describe( 'by merge', () => {
-			it( 'element into paragraph', () => {
-				john.setData( '<paragraph>[Foo]</paragraph><paragraph> Bar</paragraph>' );
-				kate.setData( '<paragraph>Foo</paragraph>[]<paragraph> Bar</paragraph>' );
+			it( 'only marker end is inside merged element #1', () => {
+				john.setData( '<paragraph>Foo</paragraph>[<paragraph>B]ar</paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );
 
 
 				john.setMarker( 'm1' );
 				john.setMarker( 'm1' );
 				kate.merge();
 				kate.merge();
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph><m1:start></m1:start>Foo<m1:end></m1:end> Bar</paragraph>'
-				);
+				expectClients( '<paragraph>Foo<m1:start></m1:start>B<m1:end></m1:end>ar</paragraph>' );
 			} );
 			} );
 
 
-			it( 'elements into paragraph', () => {
-				john.setData( '<paragraph>[Foo]</paragraph><paragraph> Bar</paragraph><paragraph> Abc</paragraph>' );
-				kate.setData( '<paragraph>Foo</paragraph><paragraph> Bar</paragraph>[]<paragraph> Abc</paragraph>' );
+			it( 'only marker end is inside merged element #2', () => {
+				john.setData( '<paragraph>Foo[]Bar</paragraph>' );
+				kate.setData( '<paragraph>Foo[]Bar</paragraph>' );
 
 
-				john.setMarker( 'm1' );
-				kate.merge();
+				kate.split();
 
 
 				syncClients();
 				syncClients();
+				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
 
 
-				kate.setSelection( [ 1 ] );
-
-				kate.merge();
+				john.setSelection( [ 1 ] );
+				john.insert( '<paragraph>Xyz</paragraph>' );
 
 
 				syncClients();
 				syncClients();
+				expectClients( '<paragraph>Foo</paragraph><paragraph>Xyz</paragraph><paragraph>Bar</paragraph>' );
 
 
-				expectClients(
-					'<paragraph><m1:start></m1:start>Foo<m1:end></m1:end> Bar Abc</paragraph>'
-				);
-			} );
-
-			it( 'wrapped element into wrapped paragraph', () => {
-				john.setData( '<blockQuote><paragraph>[Foo]</paragraph><paragraph> Bar</paragraph></blockQuote>' );
-				kate.setData( '<blockQuote><paragraph>Foo</paragraph>[]<paragraph> Bar</paragraph></blockQuote>' );
-
+				john.setSelection( [ 1 ], [ 2, 1 ] );
 				john.setMarker( 'm1' );
 				john.setMarker( 'm1' );
-				kate.merge();
+				kate.undo();
 
 
 				syncClients();
 				syncClients();
-
-				expectClients(
-					'<blockQuote>' +
-						'<paragraph><m1:start></m1:start>Foo<m1:end></m1:end> Bar</paragraph>' +
-					'</blockQuote>'
-				);
+				expectClients( '<paragraph>Foo<m1:start></m1:start>Bar</paragraph><paragraph>Xyz</paragraph><m1:end></m1:end>' );
 			} );
 			} );
 		} );
 		} );
 
 
@@ -816,9 +777,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<heading1>Fo<m1:start></m1:start>o<m1:end></m1:end></heading1>'
-				);
+				expectClients( '<heading1>Fo<m1:start></m1:start>o<m1:end></m1:end></heading1>' );
 			} );
 			} );
 		} );
 		} );
 	} );
 	} );

+ 50 - 119
packages/ckeditor5-engine/tests/model/operation/transform/move.js

@@ -224,21 +224,6 @@ describe( 'transform', () => {
 					'</blockQuote>'
 					'</blockQuote>'
 				);
 				);
 			} );
 			} );
-
-			it( 'intersecting wrap', () => {
-				john.setData( '<paragraph>F[oo]</paragraph><paragraph>Bar</paragraph>' );
-				kate.setData( '<paragraph>[Foo]</paragraph><paragraph>Bar</paragraph>' );
-
-				john.move( [ 1, 0 ] );
-				kate.wrap( 'div' );
-
-				syncClients();
-
-				expectClients(
-					'<paragraph><div>F</div></paragraph>' +
-					'<paragraph>ooBar</paragraph>'
-				);
-			} );
 		} );
 		} );
 
 
 		describe( 'by unwrap', () => {
 		describe( 'by unwrap', () => {
@@ -272,6 +257,21 @@ describe( 'transform', () => {
 				);
 				);
 			} );
 			} );
 
 
+			it( 'text in different path', () => {
+				john.setData( '<paragraph>F[oo]</paragraph><blockQuote><paragraph>Bar</paragraph></blockQuote>' );
+				kate.setData( '<paragraph>Foo</paragraph><blockQuote><paragraph>[]Bar</paragraph></blockQuote>' );
+
+				john.move( [ 0, 0 ] );
+				kate.unwrap();
+
+				syncClients();
+
+				expectClients(
+					'<paragraph>ooF</paragraph>' +
+					'<blockQuote>Bar</blockQuote>'
+				);
+			} );
+
 			it( 'element in same path', () => {
 			it( 'element in same path', () => {
 				john.setData( '<blockQuote><paragraph>F[oo]</paragraph></blockQuote>' );
 				john.setData( '<blockQuote><paragraph>F[oo]</paragraph></blockQuote>' );
 				kate.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
 				kate.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
@@ -281,9 +281,19 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>ooF</paragraph>'
-				);
+				expectClients( '<paragraph>ooF</paragraph>' );
+			} );
+
+			it( 'text in same path', () => {
+				john.setData( '<blockQuote><paragraph>F[oo]</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
+
+				john.move( [ 0, 0, 0 ] );
+				kate.unwrap();
+
+				syncClients();
+
+				expectClients( '<blockQuote>ooF</blockQuote>' );
 			} );
 			} );
 		} );
 		} );
 
 
@@ -335,7 +345,7 @@ describe( 'transform', () => {
 				);
 				);
 			} );
 			} );
 
 
-			it.skip( 'text in other user\'s selection', () => {
+			it( 'text in other user\'s selection', () => {
 				john.setData( '<paragraph>[Foo] Bar</paragraph>' );
 				john.setData( '<paragraph>[Foo] Bar</paragraph>' );
 				kate.setData( '<paragraph>F[]oo Bar</paragraph>' );
 				kate.setData( '<paragraph>F[]oo Bar</paragraph>' );
 
 
@@ -366,7 +376,7 @@ describe( 'transform', () => {
 				);
 				);
 			} );
 			} );
 
 
-			it.skip( 'inside moved text', () => {
+			it( 'inside moved text', () => {
 				john.setData( '<paragraph>F[oo Ba]r</paragraph><paragraph>Abc</paragraph>' );
 				john.setData( '<paragraph>F[oo Ba]r</paragraph><paragraph>Abc</paragraph>' );
 				kate.setData( '<paragraph>Foo[] Bar</paragraph><paragraph>Abc</paragraph>' );
 				kate.setData( '<paragraph>Foo[] Bar</paragraph><paragraph>Abc</paragraph>' );
 
 
@@ -375,10 +385,6 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				// Actual result for Kate:
-				// <paragraph>F</paragraph><paragraph>r</paragraph><paragraph> BaooAbc</paragraph>
-				// Move operations have wrong targets when they target at same place.
-
 				expectClients(
 				expectClients(
 					'<paragraph>F</paragraph>' +
 					'<paragraph>F</paragraph>' +
 					'<paragraph>r</paragraph>' +
 					'<paragraph>r</paragraph>' +
@@ -479,9 +485,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>Foo Bar</paragraph>'
-				);
+				expectClients( '<paragraph>Foo Bar</paragraph>' );
 			} );
 			} );
 		} );
 		} );
 
 
@@ -495,9 +499,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>ooFBar</paragraph>'
-				);
+				expectClients( '<paragraph>ooFBar</paragraph>' );
 			} );
 			} );
 
 
 			it( 'element into paragraph #2', () => {
 			it( 'element into paragraph #2', () => {
@@ -509,9 +511,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>arFooB</paragraph>'
-				);
+				expectClients( '<paragraph>arFooB</paragraph>' );
 			} );
 			} );
 
 
 			it( 'element into paragraph #3', () => {
 			it( 'element into paragraph #3', () => {
@@ -523,9 +523,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>FooBar</paragraph>'
-				);
+				expectClients( '<paragraph>FooBar</paragraph>' );
 			} );
 			} );
 
 
 			it( 'wrapped element into wrapped paragraph #1', () => {
 			it( 'wrapped element into wrapped paragraph #1', () => {
@@ -537,9 +535,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<blockQuote><paragraph>ooFBar</paragraph></blockQuote>'
-				);
+				expectClients( '<blockQuote><paragraph>ooFBar</paragraph></blockQuote>' );
 			} );
 			} );
 
 
 			it( 'wrapped element into wrapped paragraph #2', () => {
 			it( 'wrapped element into wrapped paragraph #2', () => {
@@ -551,9 +547,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<blockQuote><paragraph>arFooB</paragraph></blockQuote>'
-				);
+				expectClients( '<blockQuote><paragraph>arFooB</paragraph></blockQuote>' );
 			} );
 			} );
 
 
 			it( 'wrapped element into wrapped paragraph #3', () => {
 			it( 'wrapped element into wrapped paragraph #3', () => {
@@ -565,9 +559,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<blockQuote><paragraph>FooBar</paragraph></blockQuote>'
-				);
+				expectClients( '<blockQuote><paragraph>FooBar</paragraph></blockQuote>' );
 			} );
 			} );
 
 
 			it( 'moved element', () => {
 			it( 'moved element', () => {
@@ -598,94 +590,33 @@ describe( 'transform', () => {
 
 
 				expectClients( '<paragraph>FooBar</paragraph>' );
 				expectClients( '<paragraph>FooBar</paragraph>' );
 			} );
 			} );
-		} );
 
 
-		describe( 'by remove attribute', () => {
-			it( 'from element in different path', () => {
-				john.setData( '<paragraph>F[oo]</paragraph><paragraph bold="true">Bar</paragraph>' );
-				kate.setData( '<paragraph>Foo</paragraph>[<paragraph bold="true">Bar</paragraph>]' );
-
-				john.move( [ 1, 0 ] );
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients(
-					'<paragraph>F</paragraph>' +
-					'<paragraph>ooBar</paragraph>'
-				);
-			} );
-
-			it( 'from text in different path', () => {
-				john.setData( '<paragraph>F[oo]</paragraph><paragraph>Bar</paragraph>' );
-				kate.setData( '<paragraph>Foo</paragraph><paragraph>[Bar]</paragraph>' );
-
-				john.move( [ 1, 0 ] );
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients(
-					'<paragraph>F</paragraph>' +
-					'<paragraph>ooBar</paragraph>'
-				);
-			} );
-
-			it( 'from text in same path', () => {
-				john.setData( '<paragraph>[Fo]<$text bold="true">o</$text></paragraph>' );
-				kate.setData( '<paragraph>Fo<$text bold="true">[o]</$text></paragraph>' );
-
-				john.move( [ 0, 3 ] );
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients(
-					'<paragraph>oFo</paragraph>'
-				);
-			} );
-
-			it( 'from element in same path', () => {
-				john.setData( '<paragraph bold="true">[Fo]o</paragraph>' );
-				kate.setData( '[<paragraph bold="true">Foo</paragraph>]' );
+			it( 'moved text', () => {
+				john.setData( '<paragraph>Foo</paragraph><paragraph>B[ar]</paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );
 
 
-				john.move( [ 0, 3 ] );
-				kate.removeAttribute( 'bold' );
+				john.move( [ 0, 0 ] );
+				kate.merge();
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>oFo</paragraph>'
-				);
+				expectClients( '<paragraph>arFooB</paragraph>' );
 			} );
 			} );
 
 
-			it( 'from text with 2 attributes in same path', () => {
-				john.setData( '<paragraph>[Fo]<$text bold="true" italic="true">o</$text></paragraph>' );
-				kate.setData( '<paragraph>Fo<$text bold="true" italic="true">[o]</$text></paragraph>' );
+			it( 'moved text, then undo', () => {
+				john.setData( '<paragraph>Foo</paragraph><paragraph>B[ar]</paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );
 
 
-				john.move( [ 0, 3 ] );
-				kate.removeAttribute( 'bold' );
+				john.move( [ 0, 0 ] );
+				kate.merge();
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph><$text italic="true">o</$text>Fo</paragraph>'
-				);
-			} );
-
-			it( 'from text in other user\'s selection', () => {
-				john.setData( '<paragraph><$text bold="true">[Foo]</$text></paragraph><paragraph></paragraph>' );
-				kate.setData( '<paragraph><$text bold="true">[Foo]</$text></paragraph><paragraph></paragraph>' );
-
-				john.move( [ 1, 0 ] );
-				kate.removeAttribute( 'bold' );
+				john.undo();
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph></paragraph>' +
-					'<paragraph>Foo</paragraph>'
-				);
+				expectClients( '<paragraph>FooBar</paragraph>' );
 			} );
 			} );
 		} );
 		} );
 
 

+ 79 - 145
packages/ckeditor5-engine/tests/model/operation/transform/remove.js

@@ -25,9 +25,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>F</paragraph><paragraph>B</paragraph>'
-				);
+				expectClients( '<paragraph>F</paragraph><paragraph>B</paragraph>' );
 			} );
 			} );
 
 
 			it( 'text in same path', () => {
 			it( 'text in same path', () => {
@@ -39,9 +37,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>F B</paragraph>'
-				);
+				expectClients( '<paragraph>F B</paragraph>' );
 			} );
 			} );
 
 
 			it( 'text in other user\'s selection #1', () => {
 			it( 'text in other user\'s selection #1', () => {
@@ -53,9 +49,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph></paragraph>'
-				);
+				expectClients( '<paragraph></paragraph>' );
 			} );
 			} );
 
 
 			it( 'text in other user\'s selection #2', () => {
 			it( 'text in other user\'s selection #2', () => {
@@ -67,9 +61,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph></paragraph>'
-				);
+				expectClients( '<paragraph></paragraph>' );
 			} );
 			} );
 
 
 			it( 'element in different path', () => {
 			it( 'element in different path', () => {
@@ -81,9 +73,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph></paragraph>'
-				);
+				expectClients( '<paragraph></paragraph>' );
 			} );
 			} );
 
 
 			it( 'text in other user\'s selection, then undo', () => {
 			it( 'text in other user\'s selection, then undo', () => {
@@ -100,9 +90,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>Foo Bar</paragraph>'
-				);
+				expectClients( '<paragraph>Foo Bar</paragraph>' );
 			} );
 			} );
 		} );
 		} );
 
 
@@ -133,9 +121,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<blockQuote><paragraph></paragraph></blockQuote>'
-				);
+				expectClients( '<blockQuote><paragraph></paragraph></blockQuote>' );
 			} );
 			} );
 
 
 			it( 'element while removing', () => {
 			it( 'element while removing', () => {
@@ -158,13 +144,30 @@ describe( 'transform', () => {
 				kate.wrap( 'blockQuote' );
 				kate.wrap( 'blockQuote' );
 
 
 				syncClients();
 				syncClients();
+				expectClients( '<paragraph>Foo</paragraph>' );
 
 
 				john.undo();
 				john.undo();
 
 
 				syncClients();
 				syncClients();
 
 
-				// Actual content:
-				// <paragraph>Foo</paragraph><paragraph>Bar</paragraph><blockQuote></blockQuote>
+				expectClients(
+					'<paragraph>Foo</paragraph>' +
+					'<blockQuote>' +
+						'<paragraph>Bar</paragraph>' +
+					'</blockQuote>'
+				);
+			} );
+
+			it( 'element while removing, then undo #2', () => {
+				john.setData( '<paragraph>Foo</paragraph>[<paragraph>Bar</paragraph>]' );
+				kate.setData( '<paragraph>Foo</paragraph>[<paragraph>Bar</paragraph>]' );
+
+				john.remove();
+				john.undo();
+				kate.wrap( 'blockQuote' );
+
+				syncClients();
+
 				expectClients(
 				expectClients(
 					'<paragraph>Foo</paragraph>' +
 					'<paragraph>Foo</paragraph>' +
 					'<blockQuote>' +
 					'<blockQuote>' +
@@ -190,6 +193,21 @@ describe( 'transform', () => {
 				);
 				);
 			} );
 			} );
 
 
+			it( 'text in different path', () => {
+				john.setData( '<paragraph>[Foo]</paragraph><blockQuote><paragraph>Bar</paragraph></blockQuote>' );
+				kate.setData( '<paragraph>Foo</paragraph><blockQuote><paragraph>[]Bar</paragraph></blockQuote>' );
+
+				john.remove();
+				kate.unwrap();
+
+				syncClients();
+
+				expectClients(
+					'<paragraph></paragraph>' +
+					'<blockQuote>Bar</blockQuote>'
+				);
+			} );
+
 			it( 'element in same path', () => {
 			it( 'element in same path', () => {
 				john.setData( '<blockQuote><paragraph>[Foo]</paragraph></blockQuote>' );
 				john.setData( '<blockQuote><paragraph>[Foo]</paragraph></blockQuote>' );
 				kate.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
 				kate.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
@@ -199,9 +217,35 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph></paragraph>'
-				);
+				expectClients( '<paragraph></paragraph>' );
+			} );
+
+			it( 'text in same path', () => {
+				john.setData( '<blockQuote><paragraph>[Foo]</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
+
+				john.remove();
+				kate.unwrap();
+
+				syncClients();
+
+				expectClients( '<blockQuote></blockQuote>' );
+			} );
+
+			it( 'text in same path, then undo', () => {
+				john.setData( '<blockQuote><paragraph>[Foo]</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
+
+				john.remove();
+				kate.unwrap();
+
+				syncClients();
+				expectClients( '<blockQuote></blockQuote>' );
+
+				john.undo();
+
+				syncClients();
+				expectClients( '<blockQuote>Foo</blockQuote>' );
 			} );
 			} );
 
 
 			it( 'element while removing', () => {
 			it( 'element while removing', () => {
@@ -213,12 +257,10 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>Foo</paragraph>'
-				);
+				expectClients( '<paragraph>Foo</paragraph>' );
 			} );
 			} );
 
 
-			it.skip( 'element while removing, then undo', () => {
+			it( 'element while removing, then undo', () => {
 				john.setData( '<paragraph>Foo</paragraph><blockQuote>[<paragraph>Bar</paragraph>]</blockQuote>' );
 				john.setData( '<paragraph>Foo</paragraph><blockQuote>[<paragraph>Bar</paragraph>]</blockQuote>' );
 				kate.setData( '<paragraph>Foo</paragraph><blockQuote>[]<paragraph>Bar</paragraph></blockQuote>' );
 				kate.setData( '<paragraph>Foo</paragraph><blockQuote>[]<paragraph>Bar</paragraph></blockQuote>' );
 
 
@@ -226,15 +268,11 @@ describe( 'transform', () => {
 				kate.unwrap();
 				kate.unwrap();
 
 
 				syncClients();
 				syncClients();
-
 				expectClients( '<paragraph>Foo</paragraph>' );
 				expectClients( '<paragraph>Foo</paragraph>' );
 
 
 				john.undo();
 				john.undo();
 
 
 				syncClients();
 				syncClients();
-
-				// Actual result:
-				// <paragraph>Foo</paragraph><blockQuote></blockQuote>
 				expectClients(
 				expectClients(
 					'<paragraph>Foo</paragraph>' +
 					'<paragraph>Foo</paragraph>' +
 					'<paragraph>Bar</paragraph>'
 					'<paragraph>Bar</paragraph>'
@@ -320,97 +358,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph></paragraph>'
-				);
-			} );
-		} );
-
-		describe( 'by remove attribute', () => {
-			it( 'from element in different path', () => {
-				john.setData( '<paragraph>F[oo]</paragraph><paragraph bold="true">Bar</paragraph>' );
-				kate.setData( '<paragraph>Foo</paragraph>[<paragraph bold="true">Bar</paragraph>]' );
-
-				john.remove();
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients(
-					'<paragraph>F</paragraph>' +
-					'<paragraph>Bar</paragraph>'
-				);
-			} );
-
-			it( 'from text in different path', () => {
-				john.setData( '<paragraph>F[oo]</paragraph><paragraph><$text bold="true">Bar</$text></paragraph>' );
-				kate.setData( '<paragraph>Foo</paragraph><paragraph><$text bold="true">[Bar]</$text></paragraph>' );
-
-				john.remove();
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients(
-					'<paragraph>F</paragraph>' +
-					'<paragraph>Bar</paragraph>'
-				);
-			} );
-
-			it( 'from text in same path', () => {
-				john.setData( '<paragraph>[Fo]<$text bold="true">o</$text></paragraph>' );
-				kate.setData( '<paragraph>Fo<$text bold="true">[o]</$text></paragraph>' );
-
-				john.remove();
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients(
-					'<paragraph>o</paragraph>'
-				);
-			} );
-
-			it( 'from element in same path', () => {
-				john.setData( '<paragraph bold="true">[Fo]o</paragraph>' );
-				kate.setData( '[<paragraph bold="true">Foo</paragraph>]' );
-
-				john.remove();
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients(
-					'<paragraph>o</paragraph>'
-				);
-			} );
-
-			it( 'from text with 2 attributes in same path', () => {
-				john.setData( '<paragraph>[Fo]<$text bold="true" italic="true">o</$text></paragraph>' );
-				kate.setData( '<paragraph>Fo<$text bold="true" italic="true">[o]</$text></paragraph>' );
-
-				john.remove();
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients(
-					'<paragraph><$text italic="true">o</$text></paragraph>'
-				);
-			} );
-
-			it( 'from text in other user\'s selection', () => {
-				john.setData( '<paragraph><$text bold="true">[Foo]</$text></paragraph>' );
-				kate.setData( '<paragraph><$text bold="true">[Foo]</$text></paragraph>' );
-
-				john.remove();
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients(
-					'<paragraph></paragraph>'
-				);
+				expectClients( '<paragraph></paragraph>' );
 			} );
 			} );
 		} );
 		} );
 
 
@@ -424,9 +372,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>FBar</paragraph>'
-				);
+				expectClients( '<paragraph>FBar</paragraph>' );
 			} );
 			} );
 
 
 			it( 'element into paragraph #2', () => {
 			it( 'element into paragraph #2', () => {
@@ -438,12 +384,10 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>FooB</paragraph>'
-				);
+				expectClients( '<paragraph>FooB</paragraph>' );
 			} );
 			} );
 
 
-			it.skip( 'element into paragraph, then undo', () => {
+			it( 'element into paragraph, then undo', () => {
 				john.setData( '<paragraph>F[oo]</paragraph><paragraph>Bar</paragraph>' );
 				john.setData( '<paragraph>F[oo]</paragraph><paragraph>Bar</paragraph>' );
 				kate.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );
 				kate.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );
 
 
@@ -451,15 +395,11 @@ describe( 'transform', () => {
 				kate.merge();
 				kate.merge();
 
 
 				syncClients();
 				syncClients();
-
 				expectClients( '<paragraph>FBar</paragraph>' );
 				expectClients( '<paragraph>FBar</paragraph>' );
 
 
 				john.undo();
 				john.undo();
 
 
 				syncClients();
 				syncClients();
-
-				// Actual result:
-				// <paragraph>F<paragraph></paragraph>oBar</paragraph>
 				expectClients( '<paragraph>FooBar</paragraph>' );
 				expectClients( '<paragraph>FooBar</paragraph>' );
 			} );
 			} );
 
 
@@ -472,9 +412,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<blockQuote><paragraph>FBar</paragraph></blockQuote>'
-				);
+				expectClients( '<blockQuote><paragraph>FBar</paragraph></blockQuote>' );
 			} );
 			} );
 
 
 			it( 'wrapped element into wrapped paragraph #2', () => {
 			it( 'wrapped element into wrapped paragraph #2', () => {
@@ -486,9 +424,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<blockQuote><paragraph>FooB</paragraph></blockQuote>'
-				);
+				expectClients( '<blockQuote><paragraph>FooB</paragraph></blockQuote>' );
 			} );
 			} );
 
 
 			it( 'removed element, then undo', () => {
 			it( 'removed element, then undo', () => {
@@ -546,9 +482,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<heading1></heading1>'
-				);
+				expectClients( '<heading1></heading1>' );
 			} );
 			} );
 		} );
 		} );
 	} );
 	} );

+ 37 - 108
packages/ckeditor5-engine/tests/model/operation/transform/rename.js

@@ -15,94 +15,6 @@ describe( 'transform', () => {
 	} );
 	} );
 
 
 	describe( 'rename', () => {
 	describe( 'rename', () => {
-		describe( 'by remove attribute', () => {
-			it( 'from element in different path', () => {
-				john.setData( '<paragraph>F[]oo</paragraph><paragraph bold="true">Bar</paragraph>' );
-				kate.setData( '<paragraph>Foo</paragraph>[<paragraph bold="true">Bar</paragraph>]' );
-
-				john.rename( 'heading1' );
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients(
-					'<heading1>Foo</heading1>' +
-					'<paragraph>Bar</paragraph>'
-				);
-			} );
-
-			it( 'from text in different path', () => {
-				john.setData( '<paragraph>F[]oo</paragraph><paragraph>Bar</paragraph>' );
-				kate.setData( '<paragraph>Foo</paragraph><paragraph>[Bar]</paragraph>' );
-
-				john.rename( 'heading1' );
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients(
-					'<heading1>Foo</heading1>' +
-					'<paragraph>Bar</paragraph>'
-				);
-			} );
-
-			it( 'from text in same path', () => {
-				john.setData( '<paragraph>F[]o<$text bold="true">o</$text></paragraph>' );
-				kate.setData( '<paragraph>Fo<$text bold="true">[o]</$text></paragraph>' );
-
-				john.rename( 'heading1' );
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients(
-					'<heading1>Foo</heading1>'
-				);
-			} );
-
-			it( 'from element in same path', () => {
-				john.setData( '<paragraph bold="true">F[]oo</paragraph>' );
-				kate.setData( '[<paragraph bold="true">Foo</paragraph>]' );
-
-				john.rename( 'heading1' );
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients(
-					'<heading1>Foo</heading1>'
-				);
-			} );
-
-			it( 'from text with 2 attributes in same path', () => {
-				john.setData( '<paragraph>F[o]<$text bold="true" italic="true">o</$text></paragraph>' );
-				kate.setData( '<paragraph>Fo<$text bold="true" italic="true">[o]</$text></paragraph>' );
-
-				john.rename( 'heading1' );
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients(
-					'<heading1>Fo<$text italic="true">o</$text></heading1>'
-				);
-			} );
-
-			it( 'from text in other user\'s selection', () => {
-				john.setData( '<paragraph><$text bold="true">[Foo]</$text></paragraph>' );
-				kate.setData( '<paragraph><$text bold="true">[Foo]</$text></paragraph>' );
-
-				john.rename( 'heading1' );
-				kate.removeAttribute( 'bold' );
-
-				syncClients();
-
-				expectClients(
-					'<heading1>Foo</heading1>'
-				);
-			} );
-		} );
-
 		describe( 'by rename', () => {
 		describe( 'by rename', () => {
 			it( 'elements in different paths #1', () => {
 			it( 'elements in different paths #1', () => {
 				john.setData( '<paragraph>[]Foo</paragraph><paragraph>Bar</paragraph>' );
 				john.setData( '<paragraph>[]Foo</paragraph><paragraph>Bar</paragraph>' );
@@ -129,15 +41,15 @@ describe( 'transform', () => {
 			} );
 			} );
 
 
 			it( 'the same element', () => {
 			it( 'the same element', () => {
-				john.setData( '<blockQuote>[]<paragraph>Foo Bar</paragraph></blockQuote>' );
-				kate.setData( '<blockQuote>[]<paragraph>Foo Bar</paragraph></blockQuote>' );
+				john.setData( '<blockQuote><paragraph>[]Foo Bar</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote><paragraph>[]Foo Bar</paragraph></blockQuote>' );
 
 
-				john.rename( 'blockQuote2' );
-				kate.rename( 'blockQuote3' );
+				john.rename( 'heading1' );
+				kate.rename( 'heading2' );
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients( '<blockQuote2><paragraph>Foo Bar</paragraph></blockQuote2>' );
+				expectClients( '<blockQuote><heading1>Foo Bar</heading1></blockQuote>' );
 			} );
 			} );
 		} );
 		} );
 
 
@@ -247,6 +159,21 @@ describe( 'transform', () => {
 				);
 				);
 			} );
 			} );
 
 
+			it( 'text in different path', () => {
+				john.setData( '<paragraph>F[]oo</paragraph><blockQuote><paragraph>Bar</paragraph></blockQuote>' );
+				kate.setData( '<paragraph>Foo</paragraph><blockQuote><paragraph>[]Bar</paragraph></blockQuote>' );
+
+				john.rename( 'heading1' );
+				kate.unwrap();
+
+				syncClients();
+
+				expectClients(
+					'<heading1>Foo</heading1>' +
+					'<blockQuote>Bar</blockQuote>'
+				);
+			} );
+
 			it( 'element in same path', () => {
 			it( 'element in same path', () => {
 				john.setData( '<blockQuote><paragraph>F[]oo</paragraph></blockQuote>' );
 				john.setData( '<blockQuote><paragraph>F[]oo</paragraph></blockQuote>' );
 				kate.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
 				kate.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
@@ -256,9 +183,19 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<heading1>Foo</heading1>'
-				);
+				expectClients( '<heading1>Foo</heading1>' );
+			} );
+
+			it( 'text in same path', () => {
+				john.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
+
+				john.rename( 'heading1' );
+				kate.unwrap();
+
+				syncClients();
+
+				expectClients( '<blockQuote>Foo</blockQuote>' );
 			} );
 			} );
 		} );
 		} );
 
 
@@ -272,9 +209,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<heading1>FooBar</heading1>'
-				);
+				expectClients( '<heading1>FooBar</heading1>' );
 			} );
 			} );
 
 
 			it( 'element into paragraph #2', () => {
 			it( 'element into paragraph #2', () => {
@@ -286,9 +221,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>FooBar</paragraph>'
-				);
+				expectClients( '<paragraph>FooBar</paragraph>' );
 			} );
 			} );
 
 
 			it( 'wrapped element into wrapped paragraph #1', () => {
 			it( 'wrapped element into wrapped paragraph #1', () => {
@@ -300,9 +233,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<blockQuote><heading1>FooBar</heading1></blockQuote>'
-				);
+				expectClients( '<blockQuote><heading1>FooBar</heading1></blockQuote>' );
 			} );
 			} );
 
 
 			it( 'wrapped element into wrapped paragraph #2', () => {
 			it( 'wrapped element into wrapped paragraph #2', () => {
@@ -314,9 +245,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<blockQuote><paragraph>FooBar</paragraph></blockQuote>'
-				);
+				expectClients( '<blockQuote><paragraph>FooBar</paragraph></blockQuote>' );
 			} );
 			} );
 		} );
 		} );
 	} );
 	} );

+ 42 - 21
packages/ckeditor5-engine/tests/model/operation/transform/split.js

@@ -90,7 +90,7 @@ describe( 'transform', () => {
 				);
 				);
 			} );
 			} );
 
 
-			it.skip( 'intersecting wrap', () => {
+			it( 'intersecting wrap', () => {
 				john.setData( '<paragraph>Fo[]o</paragraph>' );
 				john.setData( '<paragraph>Fo[]o</paragraph>' );
 				kate.setData( '<paragraph>F[oo]</paragraph>' );
 				kate.setData( '<paragraph>F[oo]</paragraph>' );
 
 
@@ -98,10 +98,9 @@ describe( 'transform', () => {
 				kate.wrap( 'div' );
 				kate.wrap( 'div' );
 
 
 				syncClients();
 				syncClients();
-
 				expectClients(
 				expectClients(
-					'<paragraph>F</paragraph>' +
-					'<paragraph><div>oo</div></paragraph>'
+					'<paragraph>Fo</paragraph>' +
+					'<paragraph>o</paragraph>'
 				);
 				);
 			} );
 			} );
 		} );
 		} );
@@ -122,8 +121,8 @@ describe( 'transform', () => {
 				);
 				);
 			} );
 			} );
 
 
-			it.skip( 'element in same position', () => {
-				john.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
+			it( 'text in same path', () => {
+				john.setData( '<blockQuote><paragraph>F[]oo</paragraph></blockQuote>' );
 				kate.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
 				kate.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
 
 
 				john.split();
 				john.split();
@@ -134,6 +133,17 @@ describe( 'transform', () => {
 				expectClients( '<blockQuote>Foo</blockQuote>' );
 				expectClients( '<blockQuote>Foo</blockQuote>' );
 			} );
 			} );
 
 
+			it( 'element in same position', () => {
+				john.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
+
+				john.split();
+				kate.unwrap();
+
+				syncClients();
+				expectClients( '<blockQuote>Foo</blockQuote>' );
+			} );
+
 			it( 'element in same path, then undo', () => {
 			it( 'element in same path, then undo', () => {
 				john.setData( '<blockQuote><paragraph>F[]oo</paragraph></blockQuote>' );
 				john.setData( '<blockQuote><paragraph>F[]oo</paragraph></blockQuote>' );
 				kate.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
 				kate.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
@@ -147,9 +157,23 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>Foo</paragraph>'
-				);
+				expectClients( '<paragraph>Foo</paragraph>' );
+			} );
+
+			it( 'text in same path, then undo', () => {
+				john.setData( '<blockQuote><paragraph>F[]oo</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
+
+				john.split();
+				kate.unwrap();
+
+				syncClients();
+
+				john.undo();
+
+				syncClients();
+
+				expectClients( '<blockQuote>Foo</blockQuote>' );
 			} );
 			} );
 
 
 			it( 'multiple elements', () => {
 			it( 'multiple elements', () => {
@@ -230,9 +254,7 @@ describe( 'transform', () => {
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients(
-					'<paragraph>Foo</paragraph>'
-				);
+				expectClients( '<paragraph>Foo</paragraph>' );
 			} );
 			} );
 
 
 			it( 'text in different path', () => {
 			it( 'text in different path', () => {
@@ -285,7 +307,7 @@ describe( 'transform', () => {
 				expectClients( '<paragraph>FooBar</paragraph>' );
 				expectClients( '<paragraph>FooBar</paragraph>' );
 			} );
 			} );
 
 
-			it.skip( 'element into paragraph #3', () => {
+			it( 'element into paragraph #3', () => {
 				john.setData( '<paragraph>Foo[]</paragraph><paragraph>Bar</paragraph>' );
 				john.setData( '<paragraph>Foo[]</paragraph><paragraph>Bar</paragraph>' );
 				kate.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );
 				kate.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );
 
 
@@ -299,13 +321,10 @@ describe( 'transform', () => {
 				kate.undo();
 				kate.undo();
 
 
 				syncClients();
 				syncClients();
-				// Actual content for Kate:
-				// <paragraph>FooBar</paragraph><paragraph></paragraph>
-				// There is a problem in Merge x Split transform in undo case.
 				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
 				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
 			} );
 			} );
 
 
-			it.skip( 'element into paragraph #4', () => {
+			it( 'element into paragraph #4', () => {
 				john.setData( '<paragraph>Foo</paragraph><paragraph>B[]ar</paragraph>' );
 				john.setData( '<paragraph>Foo</paragraph><paragraph>B[]ar</paragraph>' );
 				kate.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );
 				kate.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );
 
 
@@ -313,14 +332,16 @@ describe( 'transform', () => {
 				kate.merge();
 				kate.merge();
 
 
 				syncClients();
 				syncClients();
+				expectClients(
+					'<paragraph>FooB</paragraph>' +
+					'<paragraph>ar</paragraph>'
+				);
 
 
 				john.undo();
 				john.undo();
 				kate.undo();
 				kate.undo();
 
 
-				expectClients(
-					'<paragraph>Fo</paragraph>' +
-					'<paragraph>oBar</paragraph>'
-				);
+				syncClients();
+				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
 			} );
 			} );
 		} );
 		} );
 
 

+ 67 - 5
packages/ckeditor5-engine/tests/model/operation/transform/unwrap.js

@@ -38,6 +38,28 @@ describe( 'transform', () => {
 				);
 				);
 			} );
 			} );
 
 
+			it( 'text in different path', () => {
+				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.unwrap();
+
+				syncClients();
+
+				expectClients(
+					'<blockQuote>Foo</blockQuote>' +
+					'<blockQuote>Bar</blockQuote>'
+				);
+			} );
+
 			it( 'the same element', () => {
 			it( 'the same element', () => {
 				john.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
 				john.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
 				kate.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
 				kate.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
@@ -50,7 +72,7 @@ describe( 'transform', () => {
 				expectClients( '<paragraph>Foo</paragraph>' );
 				expectClients( '<paragraph>Foo</paragraph>' );
 			} );
 			} );
 
 
-			it.skip( 'the same element, then undo', () => {
+			it( 'the same element, then undo', () => {
 				john.setData( '<blockQuote>[]<paragraph>Foo</paragraph></blockQuote>' );
 				john.setData( '<blockQuote>[]<paragraph>Foo</paragraph></blockQuote>' );
 				kate.setData( '<blockQuote>[]<paragraph>Foo</paragraph></blockQuote>' );
 				kate.setData( '<blockQuote>[]<paragraph>Foo</paragraph></blockQuote>' );
 
 
@@ -63,15 +85,40 @@ describe( 'transform', () => {
 				john.undo();
 				john.undo();
 
 
 				syncClients();
 				syncClients();
-				// Actual content for Kate:
-				// <paragraph><paragraph>Foo</paragraph></paragraph>
-				// Kate has a different order of nodes in graveyard after syncing.
+				expectClients( '<blockQuote><paragraph>Foo</paragraph></blockQuote>' );
+			} );
+
+			it( 'the same text', () => {
+				john.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
+
+				john.unwrap();
+				kate.unwrap();
+
+				syncClients();
+
+				expectClients( '<blockQuote>Foo</blockQuote>' );
+			} );
+
+			it( 'the same text, then undo', () => {
+				john.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
+
+				john.unwrap();
+				kate.unwrap();
+
+				syncClients();
+
+				john.undo();
+
+				syncClients();
+
 				expectClients( '<blockQuote><paragraph>Foo</paragraph></blockQuote>' );
 				expectClients( '<blockQuote><paragraph>Foo</paragraph></blockQuote>' );
 			} );
 			} );
 		} );
 		} );
 
 
 		describe( 'by delete', () => {
 		describe( 'by delete', () => {
-			it( 'text from two elements', () => {
+			it( 'text from two elements #1', () => {
 				john.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote><paragraph>Bar</paragraph>' );
 				john.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote><paragraph>Bar</paragraph>' );
 				kate.setData( '<blockQuote><paragraph>Fo[o</paragraph></blockQuote><paragraph>Ba]r</paragraph>' );
 				kate.setData( '<blockQuote><paragraph>Fo[o</paragraph></blockQuote><paragraph>Ba]r</paragraph>' );
 
 
@@ -135,6 +182,21 @@ describe( 'transform', () => {
 					'<paragraph>Bar</paragraph>'
 					'<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>'
+				);
+			} );
 		} );
 		} );
 	} );
 	} );
 } );
 } );

+ 31 - 13
packages/ckeditor5-engine/tests/model/operation/transform/utils.js

@@ -7,7 +7,7 @@ import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
 import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting';
 import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting';
 
 
 import { getData, parse } from '../../../../src/dev-utils/model';
 import { getData, parse } from '../../../../src/dev-utils/model';
-import transform from '../../../../src/model/operation/transform';
+import { transformSets } from '../../../../src/model/operation/transform';
 import Position from '../../../../src/model/position';
 import Position from '../../../../src/model/position';
 import Range from '../../../../src/model/range';
 import Range from '../../../../src/model/range';
 import OperationFactory from '../../../../src/model/operation/operationfactory';
 import OperationFactory from '../../../../src/model/operation/operationfactory';
@@ -253,16 +253,34 @@ function bufferOperations( operations, client ) {
 }
 }
 
 
 export function syncClients() {
 export function syncClients() {
-	for ( const client of clients ) {
-		for ( const item of bufferedOperations ) {
-			const remoteOperations = item.operations.map( op => OperationFactory.fromJSON( JSON.parse( op ), client.document ) );
-			const remoteClient = item.client;
+	const clientsOperations = {};
+
+	// For each client, flatten all buffered operations into one set.
+	for ( const item of bufferedOperations ) {
+		const name = item.client.name;
+
+		if ( !clientsOperations[ name ] ) {
+			clientsOperations[ name ] = [];
+		}
+
+		clientsOperations[ name ].push( ...item.operations );
+	}
 
 
-			if ( remoteClient == client ) {
+	for ( const localClient of clients ) {
+		for ( const remoteClient of clients ) {
+			if ( remoteClient == localClient ) {
 				continue;
 				continue;
 			}
 			}
 
 
-			const clientOperations = Array.from( client.document.history.getOperations( client.syncedVersion ) );
+			const remoteOperationsJson = clientsOperations[ remoteClient.name ];
+
+			if ( !remoteOperationsJson ) {
+				continue;
+			}
+
+			const remoteOperations = remoteOperationsJson.map( op => OperationFactory.fromJSON( JSON.parse( op ), localClient.document ) );
+
+			const localOperations = Array.from( localClient.document.history.getOperations( localClient.syncedVersion ) );
 
 
 			let remoteOperationsTransformed = null;
 			let remoteOperationsTransformed = null;
 
 
@@ -271,21 +289,21 @@ export function syncClients() {
 				padWithNoOps: true
 				padWithNoOps: true
 			};
 			};
 
 
-			if ( client.orderNumber < remoteClient.orderNumber ) {
-				remoteOperationsTransformed = transform.transformSets( clientOperations, remoteOperations, options ).operationsB;
+			if ( localClient.orderNumber < remoteClient.orderNumber ) {
+				remoteOperationsTransformed = transformSets( localOperations, remoteOperations, options ).operationsB;
 			} else {
 			} else {
-				remoteOperationsTransformed = transform.transformSets( remoteOperations, clientOperations, options ).operationsA;
+				remoteOperationsTransformed = transformSets( remoteOperations, localOperations, options ).operationsA;
 			}
 			}
 
 
-			client.editor.model.enqueueChange( 'transparent', writer => {
+			localClient.editor.model.enqueueChange( 'transparent', writer => {
 				for ( const operation of remoteOperationsTransformed ) {
 				for ( const operation of remoteOperationsTransformed ) {
 					writer.batch.addOperation( operation );
 					writer.batch.addOperation( operation );
-					client.editor.model.applyOperation( operation );
+					localClient.editor.model.applyOperation( operation );
 				}
 				}
 			} );
 			} );
 		}
 		}
 
 
-		client.syncedVersion = client.document.version;
+		localClient.syncedVersion = localClient.document.version;
 	}
 	}
 
 
 	bufferedOperations.clear();
 	bufferedOperations.clear();

+ 272 - 24
packages/ckeditor5-engine/tests/model/operation/transform/wrap.js

@@ -63,19 +63,41 @@ describe( 'transform', () => {
 				);
 				);
 			} );
 			} );
 
 
-			it.skip( 'intersecting wrap #2', () => {
-				john.setData( '<paragraph>[Foo]</paragraph>' );
-				kate.setData( '<paragraph>F[o]o</paragraph>' );
+			it( 'intersecting wrap #2', () => {
+				john.setData( '[<paragraph>Foo</paragraph><paragraph>Bar</paragraph><paragraph>Abc</paragraph>]' );
+				kate.setData( '<paragraph>Foo</paragraph>[<paragraph>Bar</paragraph>]<paragraph>Abc</paragraph>' );
 
 
-				john.wrap( 'div' );
+				john.wrap( 'blockQuote' );
 				kate.wrap( 'div' );
 				kate.wrap( 'div' );
 
 
 				syncClients();
 				syncClients();
+				expectClients(
+					'<blockQuote>' +
+						'<paragraph>Foo</paragraph>' +
+						'<paragraph>Bar</paragraph>' +
+						'<paragraph>Abc</paragraph>' +
+					'</blockQuote>'
+				);
+			} );
+
+			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>' );
 
 
-				expectClients( '<paragraph><div>Foo</div></pragraph>' );
+				john.wrap( 'blockQuote' );
+				kate.wrap( 'div' );
+
+				syncClients();
+				expectClients(
+					'<blockQuote>' +
+						'<paragraph>Foo</paragraph>' +
+						'<paragraph>Bar</paragraph>' +
+					'</blockQuote>' +
+					'<paragraph>Abc</paragraph>'
+				);
 			} );
 			} );
 
 
-			it.skip( 'intersecting wrap, then undo #1', () => {
+			it( 'intersecting wrap, then undo #1', () => {
 				john.setData( '[<paragraph>Foo</paragraph><paragraph>Bar</paragraph>]<paragraph>Abc</paragraph>' );
 				john.setData( '[<paragraph>Foo</paragraph><paragraph>Bar</paragraph>]<paragraph>Abc</paragraph>' );
 				kate.setData( '<paragraph>Foo</paragraph>[<paragraph>Bar</paragraph><paragraph>Abc</paragraph>]' );
 				kate.setData( '<paragraph>Foo</paragraph>[<paragraph>Bar</paragraph><paragraph>Abc</paragraph>]' );
 
 
@@ -83,12 +105,20 @@ describe( 'transform', () => {
 				kate.wrap( 'div' );
 				kate.wrap( 'div' );
 
 
 				syncClients();
 				syncClients();
+				expectClients(
+					'<blockQuote>' +
+						'<paragraph>Foo</paragraph>' +
+						'<paragraph>Bar</paragraph>' +
+					'</blockQuote>' +
+					'<div>' +
+						'<paragraph>Abc</paragraph>' +
+					'</div>'
+				);
 
 
 				john.undo();
 				john.undo();
 				kate.undo();
 				kate.undo();
 
 
 				syncClients();
 				syncClients();
-
 				expectClients(
 				expectClients(
 					'<paragraph>Foo</paragraph>' +
 					'<paragraph>Foo</paragraph>' +
 					'<paragraph>Bar</paragraph>' +
 					'<paragraph>Bar</paragraph>' +
@@ -96,21 +126,50 @@ describe( 'transform', () => {
 				);
 				);
 			} );
 			} );
 
 
-			it.skip( 'intersecting wrap, then undo #2', () => {
-				john.setData( '<paragraph>[Foo]</paragraph>' );
-				kate.setData( '<paragraph>F[o]o</paragraph>' );
+			it( 'intersecting wrap, then undo #2', () => {
+				john.setData( '[<paragraph>Foo</paragraph><paragraph>Bar</paragraph>]<paragraph>Abc</paragraph>' );
+				kate.setData( '<paragraph>Foo</paragraph>[<paragraph>Bar</paragraph><paragraph>Abc</paragraph>]' );
 
 
-				john.wrap( 'div' );
+				john.wrap( 'blockQuote' );
 				kate.wrap( 'div' );
 				kate.wrap( 'div' );
 
 
 				syncClients();
 				syncClients();
+				expectClients(
+					'<blockQuote>' +
+						'<paragraph>Foo</paragraph>' +
+						'<paragraph>Bar</paragraph>' +
+					'</blockQuote>' +
+					'<div>' +
+						'<paragraph>Abc</paragraph>' +
+					'</div>'
+				);
 
 
 				john.undo();
 				john.undo();
 				kate.undo();
 				kate.undo();
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients( '<paragraph><div>Foo</div></pragraph>' );
+				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', () => {
 			it( 'element and text', () => {
@@ -142,37 +201,87 @@ describe( 'transform', () => {
 				);
 				);
 			} );
 			} );
 
 
+			it( 'text in different path', () => {
+				john.setData( '<paragraph>[Foo]</paragraph><blockQuote><paragraph>Bar</paragraph></blockQuote>' );
+				kate.setData( '<paragraph>Foo</paragraph><blockQuote><paragraph>[]Bar</paragraph></blockQuote>' );
+
+				john.wrap( 'div' );
+				kate.unwrap();
+
+				syncClients();
+
+				expectClients(
+					'<paragraph><div>Foo</div></paragraph>' +
+					'<blockQuote>Bar</blockQuote>'
+				);
+			} );
+
 			it( 'the same element', () => {
 			it( 'the same element', () => {
 				john.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
 				john.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
-				kate.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
+				kate.setData( '<blockQuote>[]<paragraph>Foo</paragraph></blockQuote>' );
 
 
-				john.wrap( 'blockQuote2' );
+				john.wrap( 'div' );
 				kate.unwrap();
 				kate.unwrap();
 
 
 				syncClients();
 				syncClients();
 
 
-				expectClients( '<blockQuote2><paragraph>Foo</paragraph></blockQuote2>' );
+				expectClients( '<div><paragraph>Foo</paragraph></div>' );
 			} );
 			} );
 
 
-			it( 'the same element, then undo', () => {
+			it.skip( 'the same element, then undo', () => {
+				// This is interesting scenario actually. Normally in wrap x wrap situation the stronger wrap just wins
+				// so we won't get incorrect model. But John was actually to make a wrap like this then he had
+				// <bq><div><p> structure for a while. So it should be possible to revert to it.
 				john.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
 				john.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
-				kate.setData( '<blockQuote>[<paragraph>Foo</paragraph>]</blockQuote>' );
+				kate.setData( '<blockQuote>[]<paragraph>Foo</paragraph></blockQuote>' );
 
 
-				john.wrap( 'blockQuote2' );
+				john.wrap( 'div' );
 				kate.unwrap();
 				kate.unwrap();
 
 
 				syncClients();
 				syncClients();
+				expectClients( '<div><paragraph>Foo</paragraph></div>' );
 
 
 				kate.undo();
 				kate.undo();
 
 
 				syncClients();
 				syncClients();
+				expectClients( '<blockQuote><div><paragraph>Foo</paragraph></div></blockQuote>' );
+			} );
 
 
-				expectClients( '<blockQuote><paragraph>Foo</paragraph></blockQuote>' );
+			it( 'the same text', () => {
+				john.setData( '<blockQuote><paragraph>[Foo]</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
+
+				john.wrap( 'div' );
+				kate.unwrap();
+
+				syncClients();
+
+				expectClients( '<blockQuote><div>Foo</div></blockQuote>' );
+			} );
+
+			it.skip( 'the same text, then undo', () => {
+				// This is interesting scenario actually. Normally in wrap x wrap situation the stronger wrap just wins
+				// so we won't get incorrect model. But John was actually to make a wrap like this then he had
+				// <bq><p><div> structure for a while. So it should be possible to revert to it.
+				john.setData( '<blockQuote><paragraph>[Foo]</paragraph></blockQuote>' );
+				kate.setData( '<blockQuote><paragraph>[]Foo</paragraph></blockQuote>' );
+
+				john.wrap( 'div' );
+				kate.unwrap();
+
+				syncClients();
+				expectClients( '<blockQuote><div>Foo</div></blockQuote>' );
+
+				kate.undo();
+
+				syncClients();
+
+				expectClients( '<blockQuote><paragraph><div>Foo</div></paragraph></blockQuote>' );
 			} );
 			} );
 		} );
 		} );
 
 
 		describe( 'by delete', () => {
 		describe( 'by delete', () => {
-			it( 'text in two elements', () => {
+			it( 'text in two elements #1', () => {
 				john.setData( '[<paragraph>Foo</paragraph>]<paragraph>Bar</paragraph>' );
 				john.setData( '[<paragraph>Foo</paragraph>]<paragraph>Bar</paragraph>' );
 				kate.setData( '<paragraph>Fo[o</paragraph><paragraph>Ba]r</paragraph>' );
 				kate.setData( '<paragraph>Fo[o</paragraph><paragraph>Ba]r</paragraph>' );
 
 
@@ -187,6 +296,147 @@ describe( 'transform', () => {
 					'</blockQuote>'
 					'</blockQuote>'
 				);
 				);
 			} );
 			} );
+
+			it( 'text in two elements #2', () => {
+				john.setData( '<paragraph>[Foo]</paragraph><paragraph>Bar</paragraph>' );
+				kate.setData( '<paragraph>Fo[o</paragraph><paragraph>Ba]r</paragraph>' );
+
+				john.wrap( 'div' );
+				kate.delete();
+
+				syncClients();
+
+				expectClients( '<paragraph><div>Fo</div>r</paragraph>' );
+			} );
+
+			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', () => {
+			it( 'remove the only wrapped element', () => {
+				john.setData( '[<paragraph>Foo</paragraph>]<paragraph>Bar</paragraph>' );
+				kate.setData( '[<paragraph>Foo</paragraph>]<paragraph>Bar</paragraph>' );
+
+				john.wrap( 'blockQuote' );
+				kate.remove();
+
+				syncClients();
+
+				expectClients( '<paragraph>Bar</paragraph>' );
+			} );
+
+			it( 'remove one of two wrapped elements', () => {
+				john.setData( '[<paragraph>Foo</paragraph><paragraph>Bar</paragraph>]' );
+				kate.setData( '[<paragraph>Foo</paragraph>]<paragraph>Bar</paragraph>' );
+
+				john.wrap( 'blockQuote' );
+				kate.remove();
+
+				syncClients();
+
+				expectClients( '<blockQuote><paragraph>Bar</paragraph></blockQuote>' );
+			} );
+
+			it( 'remove all wrapped elements', () => {
+				john.setData( '[<paragraph>Foo</paragraph><paragraph>Bar</paragraph>]<paragraph>Xyz</paragraph>' );
+				kate.setData( '[<paragraph>Foo</paragraph>]<paragraph>Bar</paragraph><paragraph>Xyz</paragraph>' );
+
+				john.wrap( 'blockQuote' );
+
+				kate.remove();
+				kate.setSelection( [ 0 ], [ 1 ] );
+				kate.remove();
+
+				syncClients();
+
+				expectClients( '<paragraph>Xyz</paragraph>' );
+			} );
+
+			it( 'remove the only wrapped element with undo', () => {
+				john.setData( '[<paragraph>Foo</paragraph>]<paragraph>Bar</paragraph>' );
+				kate.setData( '[<paragraph>Foo</paragraph>]<paragraph>Bar</paragraph>' );
+
+				john.wrap( 'blockQuote' );
+				kate.remove();
+
+				syncClients();
+				expectClients( '<paragraph>Bar</paragraph>' );
+
+				john.undo();
+				kate.undo();
+
+				syncClients();
+				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+			} );
+
+			it( 'remove one of two wrapped elements with undo', () => {
+				john.setData( '[<paragraph>Foo</paragraph><paragraph>Bar</paragraph>]' );
+				kate.setData( '[<paragraph>Foo</paragraph>]<paragraph>Bar</paragraph>' );
+
+				john.wrap( 'blockQuote' );
+				kate.remove();
+
+				syncClients();
+				expectClients( '<blockQuote><paragraph>Bar</paragraph></blockQuote>' );
+
+				john.undo();
+				kate.undo();
+
+				syncClients();
+				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+			} );
+
+			it( 'remove all wrapped elements with undo', () => {
+				john.setData( '[<paragraph>Foo</paragraph><paragraph>Bar</paragraph>]<paragraph>Xyz</paragraph>' );
+				kate.setData( '[<paragraph>Foo</paragraph>]<paragraph>Bar</paragraph><paragraph>Xyz</paragraph>' );
+
+				john.wrap( 'blockQuote' );
+
+				kate.remove();
+				kate.setSelection( [ 0 ], [ 1 ] );
+				kate.remove();
+
+				syncClients();
+				expectClients( '<paragraph>Xyz</paragraph>' );
+
+				john.undo();
+				kate.undo();
+				kate.undo();
+
+				syncClients();
+				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph><paragraph>Xyz</paragraph>' );
+			} );
 		} );
 		} );
 
 
 		describe( 'by merge', () => {
 		describe( 'by merge', () => {
@@ -219,7 +469,7 @@ describe( 'transform', () => {
 				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
 				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
 			} );
 			} );
 
 
-			it.skip( 'element into paragraph, then undo', () => {
+			it( 'element into paragraph, then undo', () => {
 				john.setData( '<paragraph>Foo</paragraph>[<paragraph>Bar</paragraph>]' );
 				john.setData( '<paragraph>Foo</paragraph>[<paragraph>Bar</paragraph>]' );
 				kate.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );
 				kate.setData( '<paragraph>Foo</paragraph>[]<paragraph>Bar</paragraph>' );
 
 
@@ -227,14 +477,12 @@ describe( 'transform', () => {
 				kate.merge();
 				kate.merge();
 
 
 				syncClients();
 				syncClients();
-
-				expectClients( '<blockQuote><paragraph>FooBar</paragraph></blockQuote>' );
+				expectClients( '<paragraph>FooBar</paragraph>' );
 
 
 				john.undo();
 				john.undo();
 				kate.undo();
 				kate.undo();
 
 
 				syncClients();
 				syncClients();
-
 				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
 				expectClients( '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
 			} );
 			} );
 		} );
 		} );

+ 88 - 0
packages/ckeditor5-engine/tests/model/operation/wrapoperation.js

@@ -0,0 +1,88 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Model from '../../../src/model/model';
+import Element from '../../../src/model/element';
+import WrapOperation from '../../../src/model/operation/wrapoperation';
+import Position from '../../../src/model/position';
+
+describe( 'WrapOperation', () => {
+	let model, doc, root;
+
+	beforeEach( () => {
+		model = new Model();
+		doc = model.document;
+		root = doc.createRoot();
+	} );
+
+	describe( 'type', () => {
+		it( 'should be wrap', () => {
+			const op = new WrapOperation(
+				new Position( root, [ 0 ] ),
+				1,
+				new Position( doc.graveyard, [ 0 ] ),
+				doc.version
+			);
+
+			expect( op.type ).to.equal( 'wrap' );
+		} );
+	} );
+
+	describe( 'toJSON', () => {
+		it( 'should create proper serialized object #1', () => {
+			const op = new WrapOperation(
+				new Position( root, [ 0 ] ),
+				1,
+				new Position( doc.graveyard, [ 0 ] ),
+				doc.version
+			);
+
+			const serialized = op.toJSON();
+
+			expect( serialized ).to.deep.equal( {
+				__className: 'engine.model.operation.WrapOperation',
+				baseVersion: 0,
+				position: op.position.toJSON(),
+				graveyardPosition: op.graveyardPosition.toJSON(),
+				howMany: 1
+			} );
+		} );
+
+		it( 'should create proper serialized object #2', () => {
+			const op = new WrapOperation(
+				new Position( root, [ 0 ] ),
+				1,
+				new Element( 'paragraph' ),
+				doc.version
+			);
+
+			const serialized = op.toJSON();
+
+			expect( serialized ).to.deep.equal( {
+				__className: 'engine.model.operation.WrapOperation',
+				baseVersion: 0,
+				position: op.position.toJSON(),
+				element: op.element.toJSON(),
+				howMany: 1
+			} );
+		} );
+	} );
+
+	describe( 'fromJSON', () => {
+		it( 'should create proper WrapOperation from json object', () => {
+			const op = new WrapOperation(
+				new Position( root, [ 0 ] ),
+				1,
+				new Position( doc.graveyard, [ 0 ] ),
+				doc.version
+			);
+
+			const serialized = op.toJSON();
+			const deserialized = WrapOperation.fromJSON( serialized, doc );
+
+			expect( deserialized ).to.deep.equal( op );
+		} );
+	} );
+} );

+ 2 - 3
packages/ckeditor5-engine/tests/model/position.js

@@ -11,7 +11,6 @@ import TextProxy from '../../src/model/textproxy';
 import Position from '../../src/model/position';
 import Position from '../../src/model/position';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
-import { jsonParseStringify } from '../../tests/model/_utils/utils';
 
 
 describe( 'Position', () => {
 describe( 'Position', () => {
 	let doc, root, otherRoot, p, ul, li1, li2, f, o, z, b, a, r, foz, bar;
 	let doc, root, otherRoot, p, ul, li1, li2, f, o, z, b, a, r, foz, bar;
@@ -805,7 +804,7 @@ describe( 'Position', () => {
 		it( 'should serialize position', () => {
 		it( 'should serialize position', () => {
 			const position = new Position( root, [ 0 ] );
 			const position = new Position( root, [ 0 ] );
 
 
-			const serialized = jsonParseStringify( position );
+			const serialized = position.toJSON();
 
 
 			expect( serialized ).to.deep.equal( { root: 'main', path: [ 0 ], stickiness: 'toNone' } );
 			expect( serialized ).to.deep.equal( { root: 'main', path: [ 0 ], stickiness: 'toNone' } );
 		} );
 		} );
@@ -814,7 +813,7 @@ describe( 'Position', () => {
 			const position = new Position( doc.graveyard, [ 0 ] );
 			const position = new Position( doc.graveyard, [ 0 ] );
 			position.stickiness = 'toPrevious';
 			position.stickiness = 'toPrevious';
 
 
-			const serialized = jsonParseStringify( position );
+			const serialized = position.toJSON();
 
 
 			expect( serialized ).to.deep.equal( { root: '$graveyard', path: [ 0 ], stickiness: 'toPrevious' } );
 			expect( serialized ).to.deep.equal( { root: '$graveyard', path: [ 0 ], stickiness: 'toPrevious' } );
 		} );
 		} );

+ 26 - 12
packages/ckeditor5-engine/tests/model/range.js

@@ -12,14 +12,12 @@ import TreeWalker from '../../src/model/treewalker';
 import AttributeOperation from '../../src/model/operation/attributeoperation';
 import AttributeOperation from '../../src/model/operation/attributeoperation';
 import InsertOperation from '../../src/model/operation/insertoperation';
 import InsertOperation from '../../src/model/operation/insertoperation';
 import MoveOperation from '../../src/model/operation/moveoperation';
 import MoveOperation from '../../src/model/operation/moveoperation';
-import RemoveOperation from '../../src/model/operation/removeoperation';
 import RenameOperation from '../../src/model/operation/renameoperation';
 import RenameOperation from '../../src/model/operation/renameoperation';
 import MergeOperation from '../../src/model/operation/mergeoperation';
 import MergeOperation from '../../src/model/operation/mergeoperation';
 import SplitOperation from '../../src/model/operation/splitoperation';
 import SplitOperation from '../../src/model/operation/splitoperation';
 import WrapOperation from '../../src/model/operation/wrapoperation';
 import WrapOperation from '../../src/model/operation/wrapoperation';
 import UnwrapOperation from '../../src/model/operation/unwrapoperation';
 import UnwrapOperation from '../../src/model/operation/unwrapoperation';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
-import { jsonParseStringify } from '../../tests/model/_utils/utils';
 
 
 describe( 'Range', () => {
 describe( 'Range', () => {
 	let doc, range, start, end, root, otherRoot, gy;
 	let doc, range, start, end, root, otherRoot, gy;
@@ -898,10 +896,10 @@ describe( 'Range', () => {
 			} );
 			} );
 		} );
 		} );
 
 
-		describe( 'by RemoveOperation', () => {
+		describe( 'by MoveOperation to graveyard', () => {
 			it( 'remove before range', () => {
 			it( 'remove before range', () => {
 				const start = new Position( root, [ 0 ] );
 				const start = new Position( root, [ 0 ] );
-				const op = new RemoveOperation( start, 2, gyPos, 1 );
+				const op = new MoveOperation( start, 2, gyPos, 1 );
 
 
 				const transformed = range.getTransformedByOperation( op );
 				const transformed = range.getTransformedByOperation( op );
 
 
@@ -910,7 +908,7 @@ describe( 'Range', () => {
 
 
 			it( 'remove intersecting with range', () => {
 			it( 'remove intersecting with range', () => {
 				const start = new Position( root, [ 4 ] );
 				const start = new Position( root, [ 4 ] );
-				const op = new RemoveOperation( start, 2, gyPos, 1 );
+				const op = new MoveOperation( start, 2, gyPos, 1 );
 
 
 				const transformed = range.getTransformedByOperation( op );
 				const transformed = range.getTransformedByOperation( op );
 
 
@@ -922,7 +920,7 @@ describe( 'Range', () => {
 			it( 'remove inside the range', () => {
 			it( 'remove inside the range', () => {
 				const start = new Position( root, [ 3 ] );
 				const start = new Position( root, [ 3 ] );
 
 
-				const op = new RemoveOperation( start, 2, gyPos, 1 );
+				const op = new MoveOperation( start, 2, gyPos, 1 );
 
 
 				const transformed = range.getTransformedByOperation( op );
 				const transformed = range.getTransformedByOperation( op );
 
 
@@ -945,7 +943,7 @@ describe( 'Range', () => {
 			it( 'split inside range', () => {
 			it( 'split inside range', () => {
 				range = new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 4 ] ) );
 				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 );
 				const transformed = range.getTransformedByOperation( op );
 
 
 				expect( transformed.length ).to.equal( 1 );
 				expect( transformed.length ).to.equal( 1 );
@@ -956,7 +954,7 @@ describe( 'Range', () => {
 			it( 'split at the beginning of multi-element range', () => {
 			it( 'split at the beginning of multi-element range', () => {
 				range = new Range( new Position( root, [ 0, 4 ] ), new Position( root, [ 1, 2 ] ) );
 				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 );
 				const transformed = range.getTransformedByOperation( op );
 
 
 				expect( transformed.length ).to.equal( 1 );
 				expect( transformed.length ).to.equal( 1 );
@@ -967,7 +965,7 @@ describe( 'Range', () => {
 			it( 'split inside range which starts at the beginning of split element', () => {
 			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 ] ) );
 				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 );
 				const transformed = range.getTransformedByOperation( op );
 
 
 				expect( transformed.length ).to.equal( 1 );
 				expect( transformed.length ).to.equal( 1 );
@@ -978,7 +976,7 @@ describe( 'Range', () => {
 			it( 'split inside range which end is at the end of split element', () => {
 			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 ] ) );
 				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 );
 				const transformed = range.getTransformedByOperation( op );
 
 
 				expect( transformed.length ).to.equal( 1 );
 				expect( transformed.length ).to.equal( 1 );
@@ -989,7 +987,7 @@ describe( 'Range', () => {
 			it( 'split element which has collapsed range at the end', () => {
 			it( 'split element which has collapsed range at the end', () => {
 				range = new Range( new Position( root, [ 0, 6 ] ), new Position( root, [ 0, 6 ] ) );
 				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 );
 				const transformed = range.getTransformedByOperation( op );
 
 
 				expect( transformed.length ).to.equal( 1 );
 				expect( transformed.length ).to.equal( 1 );
@@ -1005,6 +1003,7 @@ describe( 'Range', () => {
 
 
 				const op = new MergeOperation(
 				const op = new MergeOperation(
 					new Position( root, [ 1, 0 ] ),
 					new Position( root, [ 1, 0 ] ),
+					4,
 					new Position( root, [ 0, 3 ] ),
 					new Position( root, [ 0, 3 ] ),
 					gyPos,
 					gyPos,
 					1
 					1
@@ -1023,6 +1022,7 @@ describe( 'Range', () => {
 
 
 				const op = new MergeOperation(
 				const op = new MergeOperation(
 					new Position( root, [ 0, 0 ] ),
 					new Position( root, [ 0, 0 ] ),
+					4,
 					new Position( root, [ 1, 3 ] ),
 					new Position( root, [ 1, 3 ] ),
 					gyPos,
 					gyPos,
 					1
 					1
@@ -1046,6 +1046,7 @@ describe( 'Range', () => {
 
 
 				const op = new MergeOperation(
 				const op = new MergeOperation(
 					new Position( root, [ 1, 0 ] ),
 					new Position( root, [ 1, 0 ] ),
+					4,
 					new Position( root, [ 0, 1 ] ),
 					new Position( root, [ 0, 1 ] ),
 					gyPos,
 					gyPos,
 					1
 					1
@@ -1266,9 +1267,22 @@ describe( 'Range', () => {
 		} );
 		} );
 	} );
 	} );
 
 
+	describe( 'toJSON()', () => {
+		it( 'should serialize range', () => {
+			const range = new Range( new Position( root, [ 1 ] ), new Position( root, [ 3 ] ) );
+
+			const serialized = range.toJSON();
+
+			expect( serialized ).to.deep.equal( {
+				start: { root: 'main', path: [ 1 ], stickiness: 'toNext' },
+				end: { root: 'main', path: [ 3 ], stickiness: 'toPrevious' }
+			} );
+		} );
+	} );
+
 	describe( 'fromJSON()', () => {
 	describe( 'fromJSON()', () => {
 		it( 'should create range from given JSON object', () => {
 		it( 'should create range from given JSON object', () => {
-			const serialized = jsonParseStringify( range );
+			const serialized = range.toJSON();
 			const deserialized = Range.fromJSON( serialized, doc );
 			const deserialized = Range.fromJSON( serialized, doc );
 
 
 			expect( deserialized ).to.deep.equal( range );
 			expect( deserialized ).to.deep.equal( range );

+ 102 - 70
packages/ckeditor5-engine/tests/model/schema.js

@@ -1106,8 +1106,7 @@ describe( 'Schema', () => {
 	} );
 	} );
 
 
 	describe( 'getValidRanges()', () => {
 	describe( 'getValidRanges()', () => {
-		const attribute = 'bold';
-		let model, doc, root, schema, ranges;
+		let model, doc, root, schema;
 
 
 		beforeEach( () => {
 		beforeEach( () => {
 			model = new Model();
 			model = new Model();
@@ -1116,112 +1115,145 @@ describe( 'Schema', () => {
 			root = doc.createRoot();
 			root = doc.createRoot();
 
 
 			schema.register( 'p', { inheritAllFrom: '$block' } );
 			schema.register( 'p', { inheritAllFrom: '$block' } );
-			schema.register( 'h1', { inheritAllFrom: '$block' } );
-			schema.register( 'img', {
-				allowWhere: '$text'
-			} );
+			schema.register( 'img', { allowWhere: '$text' } );
 
 
-			schema.addAttributeCheck( ( ctx, attributeName ) => {
-				// Allow 'bold' on p>$text.
-				if ( ctx.endsWith( 'p $text' ) && attributeName == 'bold' ) {
-					return true;
-				}
+			// This is a "hack" to allow setting any ranges in `setData` util.
+			schema.extend( '$text', { allowIn: '$root' } );
+		} );
 
 
-				// Allow 'bold' on $root>p.
-				if ( ctx.endsWith( '$root p' ) && attributeName == 'bold' ) {
-					return true;
-				}
-			} );
+		function testValidRangesForAttribute( input, attribute, output ) {
+			setData( model, input );
 
 
-			// Parse data string to model.
-			const parsedModel = parse( '<p>foo<img />bar</p>', model.schema, { context: [ root.name ] } );
+			const validRanges = schema.getValidRanges( doc.selection.getRanges(), attribute );
+			const sel = new Selection( validRanges );
 
 
-			// Set parsed model data to prevent selection post-fixer from running.
-			model.change( writer => {
-				writer.insert( parsedModel, root );
-			} );
+			expect( stringify( root, sel ) ).to.equal( output );
+		}
+
+		it( 'should return a range with p for an attribute allowed only on p', () => {
+			schema.extend( 'p', { allowAttributes: 'foo' } );
 
 
-			ranges = [ Range.createOn( root.getChild( 0 ) ) ];
+			testValidRangesForAttribute(
+				'[<p>foo<img></img>bar</p>]',
+				'foo',
+				'[<p>foo<img></img>bar</p>]'
+			);
 		} );
 		} );
 
 
-		it( 'should return unmodified ranges when attribute is allowed on each item (text is not allowed in img)', () => {
-			schema.extend( 'img', { allowAttributes: 'bold' } );
+		it( 'should return ranges on text nodes for an attribute allowed only on text', () => {
+			schema.extend( '$text', { allowAttributes: 'bold' } );
 
 
-			expect( schema.getValidRanges( ranges, attribute ) ).to.deep.equal( ranges );
+			testValidRangesForAttribute(
+				'[<p>foo<img></img>bar</p>]',
+				'bold',
+				'<p>[foo]<img></img>[bar]</p>'
+			);
 		} );
 		} );
 
 
-		it( 'should return unmodified ranges when attribute is allowed on each item (text is allowed in img)', () => {
-			schema.extend( 'img', { allowAttributes: 'bold' } );
-			schema.extend( '$text', { allowIn: 'img' } );
+		it( 'should return a range on img for an attribute allowed only on img', () => {
+			schema.extend( 'img', { allowAttributes: 'src' } );
 
 
-			expect( schema.getValidRanges( ranges, attribute ) ).to.deep.equal( ranges );
+			testValidRangesForAttribute(
+				'[<p>foo<img></img>bar</p>]',
+				'src',
+				'<p>foo[<img></img>]bar</p>'
+			);
 		} );
 		} );
 
 
-		it( 'should return two ranges when attribute is not allowed on one item', () => {
+		it( 'should return a range containing all children for an attribute allowed on all children', () => {
+			schema.extend( '$text', { allowAttributes: 'bold' } );
 			schema.extend( 'img', { allowAttributes: 'bold' } );
 			schema.extend( 'img', { allowAttributes: 'bold' } );
-			schema.extend( '$text', { allowIn: 'img' } );
 
 
-			setData( model, '<p>[foo<img>xxx</img>bar]</p>' );
+			testValidRangesForAttribute(
+				'[<p>foo<img></img>bar</p>]',
+				'bold',
+				'<p>[foo<img></img>bar]</p>'
+			);
+		} );
 
 
-			const validRanges = schema.getValidRanges( doc.selection.getRanges(), attribute );
-			const sel = new Selection( validRanges );
+		it( 'should return a range with p and a range on all children for an attribute allowed on p and its children', () => {
+			schema.extend( 'p', { allowAttributes: 'foo' } );
+			schema.extend( '$text', { allowAttributes: 'foo' } );
+			schema.extend( 'img', { allowAttributes: 'foo' } );
 
 
-			expect( stringify( root, sel ) ).to.equal( '<p>[foo<img>]xxx[</img>bar]</p>' );
-		} );
+			setData( model, '[<p>foo<img></img>bar</p>]' );
 
 
-		it( 'should return three ranges when attribute is not allowed on one element but is allowed on its child', () => {
-			schema.extend( '$text', { allowIn: 'img' } );
+			const validRanges = Array.from( schema.getValidRanges( doc.selection.getRanges(), 'foo' ) );
 
 
-			schema.addAttributeCheck( ( ctx, attributeName ) => {
-				// Allow 'bold' on img>$text.
-				if ( ctx.endsWith( 'img $text' ) && attributeName == 'bold' ) {
-					return true;
-				}
-			} );
+			expect( validRanges.length ).to.equal( 2 );
 
 
-			setData( model, '<p>[foo<img>xxx</img>bar]</p>' );
+			expect( validRanges[ 0 ].start.path ).to.deep.equal( [ 0, 0 ] );
+			expect( validRanges[ 0 ].end.path ).to.deep.equal( [ 0, 7 ] );
 
 
-			const validRanges = schema.getValidRanges( doc.selection.getRanges(), attribute );
-			const sel = new Selection( validRanges );
+			expect( validRanges[ 1 ].start.path ).to.deep.equal( [ 0 ] );
+			expect( validRanges[ 1 ].end.path ).to.deep.equal( [ 1 ] );
+		} );
+
+		it( 'should not break a range if children are not allowed to have the attribute', () => {
+			schema.extend( 'p', { allowAttributes: 'foo' } );
 
 
-			expect( stringify( root, sel ) ).to.equal( '<p>[foo]<img>[xxx]</img>[bar]</p>' );
+			testValidRangesForAttribute(
+				'[<p>foo</p><p>bar</p>]',
+				'foo',
+				'[<p>foo</p><p>bar</p>]'
+			);
 		} );
 		} );
 
 
-		it( 'should not leak beyond the given ranges', () => {
-			setData( model, '<p>[foo<img></img>bar]x[bar<img></img>foo]</p>' );
+		it( 'should search deeply', () => {
+			schema.extend( '$text', { allowAttributes: 'bold', allowIn: 'img' } );
 
 
-			const validRanges = schema.getValidRanges( doc.selection.getRanges(), attribute );
-			const sel = new Selection( validRanges );
+			testValidRangesForAttribute(
+				'[<p>foo<img>xxx</img>bar</p>]',
+				'bold',
+				'<p>[foo]<img>[xxx]</img>[bar]</p>'
+			);
+		} );
+
+		it( 'should work with multiple ranges', () => {
+			schema.extend( '$text', { allowAttributes: 'bold' } );
 
 
-			expect( stringify( root, sel ) ).to.equal( '<p>[foo]<img></img>[bar]x[bar]<img></img>[foo]</p>' );
+			testValidRangesForAttribute(
+				'[<p>a</p><p>b</p>]<p>c</p><p>[d]</p>',
+				'bold',
+				'<p>[a]</p><p>[b]</p><p>c</p><p>[d]</p>'
+			);
 		} );
 		} );
 
 
-		it( 'should correctly handle a range which ends in a disallowed position', () => {
-			schema.extend( '$text', { allowIn: 'img' } );
+		it( 'should work with non-flat ranges', () => {
+			schema.extend( '$text', { allowAttributes: 'bold' } );
 
 
-			setData( model, '<p>[foo<img>bar]</img>bom</p>' );
+			testValidRangesForAttribute(
+				'[<p>a</p><p>b</p><p>c]</p><p>d</p>',
+				'bold',
+				'<p>[a]</p><p>[b]</p><p>[c]</p><p>d</p>'
+			);
+		} );
 
 
-			const validRanges = schema.getValidRanges( doc.selection.getRanges(), attribute );
-			const sel = new Selection( validRanges );
+		it( 'should not leak beyond the given ranges', () => {
+			schema.extend( '$text', { allowAttributes: 'bold' } );
 
 
-			expect( stringify( root, sel ) ).to.equal( '<p>[foo]<img>bar</img>bom</p>' );
+			testValidRangesForAttribute(
+				'[<p>foo</p><p>b]a[r</p><p>x]yz</p>',
+				'bold',
+				'<p>[foo]</p><p>[b]a[r]</p><p>[x]yz</p>'
+			);
 		} );
 		} );
 
 
-		it( 'should split range into two ranges and omit disallowed element', () => {
+		it( 'should correctly handle a range which ends in a disallowed position', () => {
+			schema.extend( '$text', { allowAttributes: 'bold', allowIn: 'img' } );
+
+			// Disallow bold on text inside image.
 			schema.addAttributeCheck( ( ctx, attributeName ) => {
 			schema.addAttributeCheck( ( ctx, attributeName ) => {
-				// Disallow 'bold' on p>img.
-				if ( ctx.endsWith( 'p img' ) && attributeName == 'bold' ) {
+				if ( ctx.endsWith( 'img $text' ) && attributeName == 'bold' ) {
 					return false;
 					return false;
 				}
 				}
 			} );
 			} );
 
 
-			const result = schema.getValidRanges( ranges, attribute );
-
-			expect( result ).to.length( 2 );
-			expect( result[ 0 ].start.path ).to.members( [ 0 ] );
-			expect( result[ 0 ].end.path ).to.members( [ 0, 3 ] );
-			expect( result[ 1 ].start.path ).to.members( [ 0, 4 ] );
-			expect( result[ 1 ].end.path ).to.members( [ 1 ] );
+			testValidRangesForAttribute(
+				'[<p>foo<img>xx]x</img>bar</p>',
+				'bold',
+				'<p>[foo]<img>xxx</img>bar</p>'
+			);
 		} );
 		} );
 	} );
 	} );
 
 

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

@@ -633,7 +633,7 @@ describe( 'Selection', () => {
 		it( 'should throw an error when range is invalid', () => {
 		it( 'should throw an error when range is invalid', () => {
 			expect( () => {
 			expect( () => {
 				selection._setRanges( [ { invalid: 'range' } ] );
 				selection._setRanges( [ { invalid: 'range' } ] );
-			} ).to.throw( CKEditorError, /model-selection-added-not-range/ );
+			} ).to.throw( CKEditorError, /model-selection-set-ranges-not-range/ );
 		} );
 		} );
 
 
 		it( 'should remove all ranges and add given ranges', () => {
 		it( 'should remove all ranges and add given ranges', () => {

+ 4 - 5
packages/ckeditor5-engine/tests/model/text.js

@@ -5,7 +5,6 @@
 
 
 import Text from '../../src/model/text';
 import Text from '../../src/model/text';
 import Node from '../../src/model/node';
 import Node from '../../src/model/node';
-import { jsonParseStringify } from '../../tests/model/_utils/utils';
 
 
 describe( 'Text', () => {
 describe( 'Text', () => {
 	describe( 'constructor()', () => {
 	describe( 'constructor()', () => {
@@ -67,8 +66,8 @@ describe( 'Text', () => {
 		it( 'should serialize text node', () => {
 		it( 'should serialize text node', () => {
 			const text = new Text( 'foo', { bold: true } );
 			const text = new Text( 'foo', { bold: true } );
 
 
-			expect( jsonParseStringify( text ) ).to.deep.equal( {
-				attributes: [ [ 'bold', true ] ],
+			expect( text.toJSON() ).to.deep.equal( {
+				attributes: { bold: true },
 				data: 'foo'
 				data: 'foo'
 			} );
 			} );
 		} );
 		} );
@@ -78,7 +77,7 @@ describe( 'Text', () => {
 		it( 'should create text node', () => {
 		it( 'should create text node', () => {
 			const text = new Text( 'foo', { bold: true } );
 			const text = new Text( 'foo', { bold: true } );
 
 
-			const serialized = jsonParseStringify( text );
+			const serialized = text.toJSON();
 			const deserialized = Text.fromJSON( serialized );
 			const deserialized = Text.fromJSON( serialized );
 
 
 			expect( deserialized.data ).to.equal( 'foo' );
 			expect( deserialized.data ).to.equal( 'foo' );
@@ -87,7 +86,7 @@ describe( 'Text', () => {
 
 
 		it( 'should support unicode', () => {
 		it( 'should support unicode', () => {
 			const textQ = new Text( 'நி' );
 			const textQ = new Text( 'நி' );
-			const json = jsonParseStringify( textQ );
+			const json = textQ.toJSON();
 
 
 			expect( json ).to.deep.equal( {
 			expect( json ).to.deep.equal( {
 				data: 'நி'
 				data: 'நி'

+ 0 - 17
packages/ckeditor5-engine/tests/model/utils-tests/utils.js

@@ -5,7 +5,6 @@
 
 
 import {
 import {
 	getNodesAndText,
 	getNodesAndText,
-	jsonParseStringify,
 	itemAt,
 	itemAt,
 	getText,
 	getText,
 	createRangeOnElementOnly
 	createRangeOnElementOnly
@@ -37,22 +36,6 @@ describe( 'getNodesAndText', () => {
 	} );
 	} );
 } );
 } );
 
 
-describe( 'jsonParseStringify', () => {
-	class Foo {
-		constructor( ra ) {
-			this.ra = ra;
-		}
-	}
-
-	it( 'should return cleaned object', () => {
-		const foo = new Foo( { bar: 'bar' } );
-
-		const fooJsoned = jsonParseStringify( foo );
-		expect( fooJsoned ).to.not.be.instanceOf( Foo );
-		expect( fooJsoned ).to.deep.equal( { ra: { bar: 'bar' } } );
-	} );
-} );
-
 describe( 'itemAt', () => {
 describe( 'itemAt', () => {
 	let foo, img, bar, element;
 	let foo, img, bar, element;
 
 

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

@@ -1545,7 +1545,7 @@ describe( 'Writer', () => {
 				expect( batch.operations.length ).to.equal( 2 );
 				expect( batch.operations.length ).to.equal( 2 );
 			} );
 			} );
 
 
-			it( 'should use RemoveOperation', () => {
+			it( 'should use MoveOperation to graveyard', () => {
 				batch = new Batch();
 				batch = new Batch();
 				remove( div );
 				remove( div );
 
 

+ 2 - 2
packages/ckeditor5-engine/tests/view/documentselection.js

@@ -113,7 +113,7 @@ describe( 'DocumentSelection', () => {
 			expect( () => {
 			expect( () => {
 				// eslint-disable-next-line no-new
 				// eslint-disable-next-line no-new
 				new DocumentSelection( [ { invalid: 'range' } ] );
 				new DocumentSelection( [ { invalid: 'range' } ] );
-			} ).to.throw( CKEditorError, 'view-selection-invalid-range: Invalid Range.' );
+			} ).to.throw( CKEditorError, /view-selection-add-range-not-range/ );
 		} );
 		} );
 
 
 		it( 'should throw an error when ranges intersects', () => {
 		it( 'should throw an error when ranges intersects', () => {
@@ -1004,7 +1004,7 @@ describe( 'DocumentSelection', () => {
 			it( 'should throw an error when range is invalid', () => {
 			it( 'should throw an error when range is invalid', () => {
 				expect( () => {
 				expect( () => {
 					documentSelection._setTo( [ { invalid: 'range' } ] );
 					documentSelection._setTo( [ { invalid: 'range' } ] );
-				} ).to.throw( CKEditorError, 'view-selection-invalid-range: Invalid Range.' );
+				} ).to.throw( CKEditorError, /view-selection-add-range-not-range/ );
 			} );
 			} );
 
 
 			it( 'should throw when range is intersecting with already added range', () => {
 			it( 'should throw when range is intersecting with already added range', () => {

+ 2 - 2
packages/ckeditor5-engine/tests/view/selection.js

@@ -113,7 +113,7 @@ describe( 'Selection', () => {
 			expect( () => {
 			expect( () => {
 				// eslint-disable-next-line no-new
 				// eslint-disable-next-line no-new
 				new Selection( [ { invalid: 'range' } ] );
 				new Selection( [ { invalid: 'range' } ] );
-			} ).to.throw( CKEditorError, 'view-selection-invalid-range: Invalid Range.' );
+			} ).to.throw( CKEditorError, /view-selection-add-range-not-range/ );
 		} );
 		} );
 
 
 		it( 'should throw an error when ranges intersects', () => {
 		it( 'should throw an error when ranges intersects', () => {
@@ -878,7 +878,7 @@ describe( 'Selection', () => {
 			it( 'should throw an error when range is invalid', () => {
 			it( 'should throw an error when range is invalid', () => {
 				expect( () => {
 				expect( () => {
 					selection.setTo( [ { invalid: 'range' } ] );
 					selection.setTo( [ { invalid: 'range' } ] );
-				} ).to.throw( CKEditorError, 'view-selection-invalid-range: Invalid Range.' );
+				} ).to.throw( CKEditorError, /view-selection-add-range-not-range/ );
 			} );
 			} );
 
 
 			it( 'should throw when range is intersecting with already added range', () => {
 			it( 'should throw when range is intersecting with already added range', () => {

Vissa filer visades inte eftersom för många filer har ändrats