Browse Source

Merge pull request #676 from ckeditor/t/675

OT: Stabilize operational transformation.
Piotrek Koszuliński 9 years ago
parent
commit
62afba3843
36 changed files with 1076 additions and 169 deletions
  1. 8 1
      packages/ckeditor5-engine/src/controller/deletecontent.js
  2. 3 11
      packages/ckeditor5-engine/src/controller/insertcontent.js
  3. 9 0
      packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js
  4. 14 5
      packages/ckeditor5-engine/src/model/delta/attributedelta.js
  5. 99 3
      packages/ckeditor5-engine/src/model/delta/basic-transformations.js
  6. 51 7
      packages/ckeditor5-engine/src/model/document.js
  7. 2 1
      packages/ckeditor5-engine/src/model/liveselection.js
  8. 9 0
      packages/ckeditor5-engine/src/model/operation/attributeoperation.js
  9. 3 1
      packages/ckeditor5-engine/src/model/operation/insertoperation.js
  10. 26 18
      packages/ckeditor5-engine/src/model/operation/moveoperation.js
  11. 1 1
      packages/ckeditor5-engine/src/model/operation/operation.js
  12. 2 0
      packages/ckeditor5-engine/src/model/operation/operationfactory.js
  13. 0 1
      packages/ckeditor5-engine/src/model/operation/reinsertoperation.js
  14. 16 1
      packages/ckeditor5-engine/src/model/operation/removeoperation.js
  15. 8 0
      packages/ckeditor5-engine/src/model/operation/renameoperation.js
  16. 38 24
      packages/ckeditor5-engine/src/model/operation/transform.js
  17. 21 4
      packages/ckeditor5-engine/src/view/domconverter.js
  18. 2 10
      packages/ckeditor5-engine/src/view/renderer.js
  19. 15 0
      packages/ckeditor5-engine/tests/controller/deletecontent.js
  20. 0 25
      packages/ckeditor5-engine/tests/controller/insertcontent.js
  21. 25 1
      packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js
  22. 3 6
      packages/ckeditor5-engine/tests/model/delta/attributedelta.js
  23. 124 7
      packages/ckeditor5-engine/tests/model/delta/transform/attributedelta.js
  24. 112 0
      packages/ckeditor5-engine/tests/model/delta/transform/renamedelta.js
  25. 229 0
      packages/ckeditor5-engine/tests/model/delta/transform/splitdelta.js
  26. 47 0
      packages/ckeditor5-engine/tests/model/document/document.js
  27. 5 5
      packages/ckeditor5-engine/tests/model/liveselection.js
  28. 16 0
      packages/ckeditor5-engine/tests/model/operation/attributeoperation.js
  29. 7 2
      packages/ckeditor5-engine/tests/model/operation/insertoperation.js
  30. 25 1
      packages/ckeditor5-engine/tests/model/operation/moveoperation.js
  31. 0 1
      packages/ckeditor5-engine/tests/model/operation/reinsertoperation.js
  32. 3 1
      packages/ckeditor5-engine/tests/model/operation/removeoperation.js
  33. 7 0
      packages/ckeditor5-engine/tests/model/operation/renameoperation.js
  34. 87 32
      packages/ckeditor5-engine/tests/model/operation/transform.js
  35. 29 0
      packages/ckeditor5-engine/tests/view/domconverter/view-to-dom.js
  36. 30 0
      packages/ckeditor5-engine/tests/view/renderer.js

+ 8 - 1
packages/ckeditor5-engine/src/controller/deletecontent.js

@@ -52,7 +52,14 @@ export default function deleteContent( selection, batch, options = {} ) {
 				const mergePath = startPos.path.slice( 0, mergeDepth );
 				mergePath.push( startPos.path[ mergeDepth ] + 1 );
 
-				batch.merge( new Position( endPos.root, mergePath ) );
+				const mergePos = new Position( endPos.root, mergePath );
+				const nextNode = mergePos.nodeAfter;
+
+				if ( nextNode.childCount > 0 ) {
+					batch.merge( mergePos );
+				} else {
+					batch.remove( nextNode );
+				}
 			}
 		}
 	}

+ 3 - 11
packages/ckeditor5-engine/src/controller/insertcontent.js

@@ -128,18 +128,10 @@ class Insertion {
 		if ( this.nodeToSelect ) {
 			return [ Range.createOn( this.nodeToSelect ) ];
 		} else {
-			const searchRange = new Range( Position.createAt( this.position.root ), this.position );
+			const document = this.dataController.model;
+			const selectionPosition = document.getNearestSelectionPosition( this.position );
 
-			for ( const position of searchRange.getPositions( { direction: 'backward' } ) ) {
-				if ( this.schema.check( { name: '$text', inside: position } ) ) {
-					return [ new Range( position ) ];
-				}
-			}
-
-			// As a last resort, simply return the current position.
-			// See the "should not break when autoparagraphing of text is not possible" test for
-			// a case which triggers this condition.
-			return [ new Range( this.position ) ];
+			return [ new Range( selectionPosition ) ];
 		}
 	}
 

+ 9 - 0
packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js

@@ -139,6 +139,15 @@ export default class ModelConversionDispatcher {
 			return;
 		}
 
+		if ( type == 'remove' && data.sourcePosition.root.rootName == '$graveyard' ) {
+			return;
+		}
+
+		if ( type == 'rename' && data.element.root.rootName == '$graveyard' ) {
+			return;
+		}
+
+		// We can safely dispatch changes.
 		if ( type == 'insert' || type == 'reinsert' ) {
 			this.convertInsertion( data.range );
 		} else if ( type == 'move' ) {

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

@@ -54,12 +54,21 @@ export default class AttributeDelta extends Delta {
 			return this._range;
 		}
 
-		// If it is not cached we will evaluate it and cache it.
-		let firstOperation = this.operations[ 0 ];
-		let lastOperation = this.operations[ this.operations.length - 1 ];
+		let start = null;
+		let end = null;
 
-		if ( firstOperation ) {
-			this._range = new Range( firstOperation.range.start, lastOperation.range.end );
+		for ( let operation of this.operations ) {
+			if ( start === null || start.isAfter( operation.range.start ) ) {
+				start = operation.range.start;
+			}
+
+			if ( end === null || end.isBefore( operation.range.end ) ) {
+				end = operation.range.end;
+			}
+		}
+
+		if ( start && end ) {
+			this._range = new Range( start, end );
 
 			return this._range;
 		}

+ 99 - 3
packages/ckeditor5-engine/src/model/delta/basic-transformations.js

@@ -10,6 +10,7 @@ import Position from '../position.js';
 
 import NoOperation from '../operation/nooperation.js';
 import AttributeOperation from '../operation/attributeoperation.js';
+import InsertOperation from '../operation/insertoperation.js';
 import ReinsertOperation from '../operation/reinsertoperation.js';
 
 import Delta from './delta.js';
@@ -21,6 +22,7 @@ import SplitDelta from './splitdelta.js';
 import WeakInsertDelta from './weakinsertdelta.js';
 import WrapDelta from './wrapdelta.js';
 import UnwrapDelta from './unwrapdelta.js';
+import RenameDelta from './renamedelta.js';
 
 import compareArrays from '../../../utils/comparearrays.js';
 
@@ -39,6 +41,41 @@ addTransformationCase( AttributeDelta, WeakInsertDelta, ( a, b, isStrong ) => {
 	return deltas;
 } );
 
+// Add special case for AttributeDelta x SplitDelta transformation.
+addTransformationCase( AttributeDelta, SplitDelta, ( a, b, isStrong ) => {
+	const splitPosition = new Position( b.position.root, b.position.path.slice( 0, -1 ) );
+
+	const deltas = defaultTransform( a, b, isStrong );
+
+	for ( let operation of a.operations ) {
+		// If a node that has been split has it's attribute updated, we should also update attribute of
+		// the node created during splitting.
+		if ( operation.range.containsPosition( splitPosition ) || operation.range.start.isEqual( splitPosition ) ) {
+			const additionalAttributeDelta = new AttributeDelta();
+
+			const rangeStart = splitPosition.getShiftedBy( 1 );
+			const rangeEnd = Position.createFromPosition( rangeStart );
+			rangeEnd.path.push( 0 );
+
+			const oldValue = b._cloneOperation.nodes.getNode( 0 ).getAttribute( operation.key );
+
+			additionalAttributeDelta.addOperation( new AttributeOperation(
+				new Range( rangeStart, rangeEnd ),
+				operation.key,
+				oldValue === undefined ? null : oldValue,
+				operation.newValue,
+				0
+			) );
+
+			deltas.push( additionalAttributeDelta );
+
+			break;
+		}
+	}
+
+	return deltas;
+} );
+
 // Add special case for InsertDelta x MergeDelta transformation.
 addTransformationCase( InsertDelta, MergeDelta, ( a, b, isStrong ) => {
 	// If insert is applied at the same position where merge happened, we reverse the merge (we treat it like it
@@ -223,6 +260,31 @@ addTransformationCase( SplitDelta, WrapDelta, ( a, b, isStrong ) => {
 	return defaultTransform( a, b, isStrong );
 } );
 
+// Add special case for SplitDelta x WrapDelta transformation.
+addTransformationCase( SplitDelta, AttributeDelta, ( a, b ) => {
+	a = a.clone();
+
+	const splitPosition = new Position( a.position.root, a.position.path.slice( 0, -1 ) );
+
+	if ( a._cloneOperation instanceof InsertOperation ) {
+		// If element to split had it's attribute changed, we have to reflect this change in an element
+		// that is in SplitDelta's InsertOperation.
+		for ( let operation of b.operations ) {
+			if ( operation.range.containsPosition( splitPosition ) || operation.range.start.isEqual( splitPosition ) ) {
+				if ( operation.newValue !== null ) {
+					a._cloneOperation.nodes.getNode( 0 ).setAttribute( operation.key, operation.newValue );
+				} else {
+					a._cloneOperation.nodes.getNode( 0 ).removeAttribute( operation.key );
+				}
+
+				break;
+			}
+		}
+	}
+
+	return [ a ];
+} );
+
 // Add special case for UnwrapDelta x SplitDelta transformation.
 addTransformationCase( UnwrapDelta, SplitDelta, ( a, b, isStrong ) => {
 	// If incoming unwrap delta tries to unwrap node that got split we should unwrap the original node and the split copy.
@@ -247,10 +309,9 @@ addTransformationCase( UnwrapDelta, SplitDelta, ( a, b, isStrong ) => {
 } );
 
 // Add special case for WeakInsertDelta x AttributeDelta transformation.
-addTransformationCase( WeakInsertDelta, AttributeDelta, ( a, b, isStrong ) => {
+addTransformationCase( WeakInsertDelta, AttributeDelta, ( a, b ) => {
 	// If nodes are weak-inserted into attribute delta range, we need to apply changes from attribute delta on them.
-	// So first we do the normal transformation and if this special cases happens, we will add an extra delta.
-	const deltas = defaultTransform( a, b, isStrong );
+	const deltas = [ a.clone() ];
 
 	if ( b.range.containsPosition( a.position ) ) {
 		deltas.push( _getComplementaryAttrDelta( a, b ) );
@@ -290,6 +351,41 @@ addTransformationCase( WrapDelta, SplitDelta, ( a, b, isStrong ) => {
 	return defaultTransform( a, b, isStrong );
 } );
 
+// Add special case for RenameDelta x SplitDelta transformation.
+addTransformationCase( RenameDelta, SplitDelta, ( a, b, isStrong ) => {
+	const splitPosition = new Position( b.position.root, b.position.path.slice( 0, -1 ) );
+
+	const deltas = defaultTransform( a, b, isStrong );
+
+	if ( a.operations[ 0 ].position.isEqual( splitPosition ) ) {
+		// If a node that has been split has it's name changed, we should also change name of
+		// the node created during splitting.
+		const additionalRenameDelta = a.clone();
+		additionalRenameDelta.operations[ 0 ].position = a.operations[ 0 ].position.getShiftedBy( 1 );
+
+		deltas.push( additionalRenameDelta );
+	}
+
+	return deltas;
+} );
+
+// Add special case for SplitDelta x RenameDelta transformation.
+addTransformationCase( SplitDelta, RenameDelta, ( a, b ) => {
+	a = a.clone();
+
+	const splitPosition = new Position( a.position.root, a.position.path.slice( 0, -1 ) );
+
+	if ( a._cloneOperation instanceof InsertOperation ) {
+		// If element to split had it's name changed, we have to reflect this change in an element
+		// that is in SplitDelta's InsertOperation.
+		if ( b.operations[ 0 ].position.isEqual( splitPosition ) ) {
+			a._cloneOperation.nodes.getNode( 0 ).name = b.operations[ 0 ].newName;
+		}
+	}
+
+	return [ a ];
+} );
+
 // Helper function for `AttributeDelta` class transformations.
 // Creates an attribute delta that sets attribute from given `attributeDelta` on nodes from given `weakInsertDelta`.
 function _getComplementaryAttrDelta( weakInsertDelta, attributeDelta ) {

+ 51 - 7
packages/ckeditor5-engine/src/model/document.js

@@ -14,6 +14,7 @@ import Batch from './batch.js';
 import History from './history.js';
 import LiveSelection from './liveselection.js';
 import Schema from './schema.js';
+import TreeWalker from './treewalker.js';
 import clone from '../../utils/lib/lodash/clone.js';
 import EmitterMixin from '../../utils/emittermixin.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
@@ -275,6 +276,54 @@ export default class Document {
 	}
 
 	/**
+	 * Basing on given `position`, finds and returns a {@link engine.model.Position Position} instance that is nearest
+	 * to that `position` and is a correct position for selection. A position is correct for selection if
+	 * text node can be placed at that position.
+	 *
+	 * If no correct position is found, the first position in given `position` root is returned. This can happen if no node
+	 * has been added to the root or it may mean incorrect model document state.
+	 *
+	 * @param {engine.model.Position} position Reference position where selection position should be looked for.
+	 * @returns {engine.model.Position|null} Nearest selection position.
+	 */
+	getNearestSelectionPosition( position ) {
+		if ( this.schema.check( { name: '$text', inside: position } ) ) {
+			return Position.createFromPosition( position );
+		}
+
+		const backwardWalker = new TreeWalker( { startPosition: position, direction: 'backward' } );
+		const forwardWalker = new TreeWalker( { startPosition: position } );
+
+		let done = false;
+
+		while ( !done ) {
+			done = true;
+
+			let step = backwardWalker.next();
+
+			if ( !step.done ) {
+				if ( this.schema.check( { name: '$text', inside: step.value.nextPosition } ) ) {
+					return step.value.nextPosition;
+				}
+
+				done = false;
+			}
+
+			step = forwardWalker.next();
+
+			if ( !step.done ) {
+				if ( this.schema.check( { name: '$text', inside: step.value.nextPosition } ) ) {
+					return step.value.nextPosition;
+				}
+
+				done = false;
+			}
+		}
+
+		return new Position( position.root, [ 0 ] );
+	}
+
+	/**
 	 * Custom toJSON method to solve child-parent circular dependencies.
 	 *
 	 * @returns {Object} Clone of this object with the document property changed to string.
@@ -317,15 +366,10 @@ export default class Document {
 		const defaultRoot = this._getDefaultRoot();
 
 		// Find the first position where the selection can be put.
-		for ( let position of Range.createIn( defaultRoot ).getPositions() ) {
-			if ( this.schema.check( { name: '$text', inside: position } ) ) {
-				return new Range( position, position );
-			}
-		}
-
 		const position = new Position( defaultRoot, [ 0 ] );
+		const selectionPosition = this.getNearestSelectionPosition( position );
 
-		return new Range( position, position );
+		return new Range( selectionPosition );
 	}
 
 	/**

+ 2 - 1
packages/ckeditor5-engine/src/model/liveselection.js

@@ -621,7 +621,8 @@ export default class LiveSelection extends Selection {
 		newPath[ newPath.length - 1 ] -= gyPath[ 1 ];
 
 		const newPosition = new Position( oldRange.root, newPath );
-		const newRange = this._prepareRange( new Range( newPosition, newPosition ) );
+		const selectionPosition = this._document.getNearestSelectionPosition( newPosition );
+		const newRange = this._prepareRange( new Range( selectionPosition ) );
 
 		const index = this._ranges.indexOf( gyRange );
 		this._ranges.splice( index, 1, newRange );

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

@@ -7,6 +7,7 @@ import Operation from './operation.js';
 import Range from '../range.js';
 import CKEditorError from '../../../utils/ckeditorerror.js';
 import writer from '../writer.js';
+import isEqual from '../../../utils/lib/lodash/isEqual.js';
 
 /**
  * Operation to change nodes' attribute.
@@ -133,6 +134,14 @@ export default class AttributeOperation extends Operation {
 					{ node: item, key: this.key }
 				);
 			}
+
+			// If value to set is same as old value, don't do anything.
+			// By returning `undefined`, this operation will be seen as `NoOperation` - that means
+			// that it won't generate any events, etc. `AttributeOperation` with such parameters may be
+			// a result of operational transformation.
+			if ( isEqual( this.oldValue, this.newValue ) ) {
+				return;
+			}
 		}
 
 		// Execution.

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

@@ -58,7 +58,9 @@ export default class InsertOperation extends Operation {
 	 * @returns {engine.model.operation.InsertOperation}
 	 */
 	clone() {
-		return new InsertOperation( this.position, this.nodes, this.baseVersion );
+		const nodes = new NodeList( [ ...this.nodes ].map( ( node ) => node.clone( true ) ) );
+
+		return new InsertOperation( this.position, nodes, this.baseVersion );
 	}
 
 	/**

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

@@ -50,22 +50,6 @@ export default class MoveOperation extends Operation {
 		this.targetPosition = Position.createFromPosition( targetPosition );
 
 		/**
-		 * Position of the start of the moved range after it got moved. This may be different than
-		 * {@link engine.model.operation.MoveOperation#targetPosition} in some cases, i.e. when a range is moved
-		 * inside the same parent but {@link engine.model.operation.MoveOperation#targetPosition targetPosition}
-		 * is after {@link engine.model.operation.MoveOperation#sourcePosition sourcePosition}.
-		 *
-		 *		 vv              vv
-		 *		abcdefg ===> adefbcg
-		 *		     ^          ^
-		 *		     targetPos	movedRangeStart
-		 *		     offset 6	offset 4
-		 *
-		 * @member {engine.model.Position} engine.model.operation.MoveOperation#movedRangeStart
-		 */
-		this.movedRangeStart = this.targetPosition._getTransformedByDeletion( this.sourcePosition, this.howMany );
-
-		/**
 		 * Defines whether `MoveOperation` is sticky. If `MoveOperation` is sticky, during
 		 * {@link engine.model.operation.transform operational transformation} if there will be an operation that
 		 * inserts some nodes at the position equal to the boundary of this `MoveOperation`, that operation will
@@ -95,13 +79,31 @@ export default class MoveOperation extends Operation {
 	}
 
 	/**
+	 * Returns the start position of the moved range after it got moved. This may be different than
+	 * {@link engine.model.operation.MoveOperation#targetPosition} in some cases, i.e. when a range is moved
+	 * inside the same parent but {@link engine.model.operation.MoveOperation#targetPosition targetPosition}
+	 * is after {@link engine.model.operation.MoveOperation#sourcePosition sourcePosition}.
+	 *
+	 *		 vv              vv
+	 *		abcdefg ===> adefbcg
+	 *		     ^          ^
+	 *		     targetPos	movedRangeStart
+	 *		     offset 6	offset 4
+	 *
+	 * @returns {engine.model.Position}
+	 */
+	getMovedRangeStart() {
+		return this.targetPosition._getTransformedByDeletion( this.sourcePosition, this.howMany );
+	}
+
+	/**
 	 * @inheritDoc
 	 * @returns {engine.model.operation.MoveOperation}
 	 */
 	getReversed() {
 		let newTargetPosition = this.sourcePosition._getTransformedByInsertion( this.targetPosition, this.howMany );
 
-		const op = new this.constructor( this.movedRangeStart, this.howMany, newTargetPosition, this.baseVersion + 1 );
+		const op = new this.constructor( this.getMovedRangeStart(), this.howMany, newTargetPosition, this.baseVersion + 1 );
 		op.isSticky = this.isSticky;
 
 		return op;
@@ -189,6 +191,12 @@ export default class MoveOperation extends Operation {
 		let sourcePosition = Position.fromJSON( json.sourcePosition, document );
 		let targetPosition = Position.fromJSON( json.targetPosition, document );
 
-		return new MoveOperation( sourcePosition, json.howMany, targetPosition, json.baseVersion );
+		const move = new this( sourcePosition, json.howMany, targetPosition, json.baseVersion );
+
+		if ( json.isSticky ) {
+			move.isSticky = true;
+		}
+
+		return move;
 	}
 }

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

@@ -106,6 +106,6 @@ export default class Operation {
 	 * @returns {engine.model.operation.Operation}
 	 */
 	static fromJSON( json ) {
-		return new Operation( json.baseVersion );
+		return new this( json.baseVersion );
 	}
 }

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

@@ -10,6 +10,7 @@ import NoOperation from '../operation/nooperation.js';
 import Operation from '../operation/operation.js';
 import ReinsertOperation from '../operation/reinsertoperation.js';
 import RemoveOperation from '../operation/removeoperation.js';
+import RenameOperation from '../operation/renameoperation.js';
 import RootAttributeOperation from '../operation/rootattributeoperation.js';
 
 const operations = {};
@@ -20,6 +21,7 @@ operations[ NoOperation.className ] = NoOperation;
 operations[ Operation.className ] = Operation;
 operations[ ReinsertOperation.className ] = ReinsertOperation;
 operations[ RemoveOperation.className ] = RemoveOperation;
+operations[ RenameOperation.className ] = RenameOperation;
 operations[ RootAttributeOperation.className ] = RootAttributeOperation;
 
 /**

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

@@ -51,5 +51,4 @@ export default class ReinsertOperation extends MoveOperation {
 	static get className() {
 		return 'engine.model.operation.ReinsertOperation';
 	}
-
 }

+ 16 - 1
packages/ckeditor5-engine/src/model/operation/removeoperation.js

@@ -104,7 +104,6 @@ export default class RemoveOperation extends MoveOperation {
 	clone() {
 		let removeOperation = new RemoveOperation( this.sourcePosition, this.howMany, this.baseVersion );
 		removeOperation.targetPosition = Position.createFromPosition( this.targetPosition );
-		removeOperation.movedRangeStart = Position.createFromPosition( this.movedRangeStart );
 
 		return removeOperation;
 	}
@@ -131,4 +130,20 @@ export default class RemoveOperation extends MoveOperation {
 	static get className() {
 		return 'engine.model.operation.RemoveOperation';
 	}
+
+	/**
+	 * Creates `RemoveOperation` object from deserilized object, i.e. from parsed JSON string.
+	 *
+	 * @param {Object} json Deserialized JSON object.
+	 * @param {engine.model.Document} document Document on which this operation will be applied.
+	 * @returns {engine.model.operation.RemoveOperation}
+	 */
+	static fromJSON( json, document ) {
+		let sourcePosition = Position.fromJSON( json.sourcePosition, document );
+
+		const removeOp = new RemoveOperation( sourcePosition, json.howMany, json.baseVersion );
+		removeOp.targetPosition = Position.fromJSON( json.targetPosition, document );
+
+		return removeOp;
+	}
 }

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

@@ -100,6 +100,14 @@ export default class RenameOperation extends Operation {
 			);
 		}
 
+		// If value to set is same as old value, don't do anything.
+		// By not returning `undefined`, this operation will be seen as `NoOperation` - that means
+		// that it won't generate any events, etc. `RenameOperation` with such parameters may be
+		// a result of Operational Transformation.
+		if ( this.oldName == this.newName ) {
+			return;
+		}
+
 		element.name = this.newName;
 
 		return { element, oldName: this.oldName };

+ 38 - 24
packages/ckeditor5-engine/src/model/operation/transform.js

@@ -11,7 +11,6 @@ import MoveOperation from './moveoperation.js';
 import RemoveOperation from './removeoperation.js';
 import NoOperation from './nooperation.js';
 import Range from '../range.js';
-import isEqual from '../../../utils/lib/lodash/isEqual.js';
 import compareArrays from '../../../utils/comparearrays.js';
 
 /**
@@ -110,24 +109,20 @@ const ot = {
 					return new AttributeOperation( range, a.key, a.oldValue, a.newValue, a.baseVersion );
 				} );
 
-				// Then we take care of the common part of ranges, but only if operations has different `newValue`.
-				if ( isStrong && !isEqual( a.newValue, b.newValue ) ) {
+				// Then we take care of the common part of ranges.
+				const common = a.range.getIntersection( b.range );
+
+				if ( common ) {
 					// If this operation is more important, we also want to apply change to the part of the
 					// original range that has already been changed by the other operation. Since that range
 					// got changed we also have to update `oldValue`.
-					const common = a.range.getIntersection( b.range );
-
-					if ( common !== null ) {
-						operations.push( new AttributeOperation( common, b.key, b.oldValue, a.newValue, a.baseVersion ) );
+					if ( isStrong ) {
+						operations.push( new AttributeOperation( common, b.key, b.newValue, a.newValue, a.baseVersion ) );
+					} else if ( operations.length === 0 ) {
+						operations.push( new NoOperation( 0 ) );
 					}
 				}
 
-				// If no operations has been added nothing should get updated, but since we need to return
-				// an instance of Operation we add NoOperation to the array.
-				if ( operations.length === 0 ) {
-					operations.push( new NoOperation( a.baseVersion ) );
-				}
-
 				return operations;
 			} else {
 				// If operations don't conflict, simply return an array containing just a clone of this operation.
@@ -180,15 +175,15 @@ const ot = {
 				// previously transformed target position.
 				// Note that we do not use Position._getTransformedByMove on range boundaries because we need to
 				// transform by insertion a range as a whole, since newTargetPosition might be inside that range.
-				ranges = difference._getTransformedByInsertion( b.movedRangeStart, b.howMany, true, false ).reverse();
+				ranges = difference._getTransformedByInsertion( b.getMovedRangeStart(), b.howMany, true, false ).reverse();
 			}
 
 			if ( common !== null ) {
 				// Here we do not need to worry that newTargetPosition is inside moved range, because that
 				// would mean that the MoveOperation targets into itself, and that is incorrect operation.
 				// Instead, we calculate the new position of that part of original range.
-				common.start = common.start._getCombined( b.sourcePosition, b.movedRangeStart );
-				common.end = common.end._getCombined( b.sourcePosition, b.movedRangeStart );
+				common.start = common.start._getCombined( b.sourcePosition, b.getMovedRangeStart() );
+				common.end = common.end._getCombined( b.sourcePosition, b.getMovedRangeStart() );
 
 				ranges.push( common );
 			}
@@ -244,8 +239,12 @@ const ot = {
 			// Clone the operation, we don't want to alter the original operation.
 			const clone = a.clone();
 
-			if ( a.position.isEqual( b.position ) && !isStrong ) {
-				return [ new NoOperation( a.baseVersion ) ];
+			if ( a.position.isEqual( b.position ) ) {
+				if ( isStrong ) {
+					clone.oldName = b.newName;
+				} else {
+					return [ new NoOperation( a.baseVersion ) ];
+				}
 			}
 
 			return [ clone ];
@@ -280,6 +279,7 @@ const ot = {
 			);
 
 			result.isSticky = a.isSticky;
+			result._holderElementOffset = a._holderElementOffset;
 
 			return [ result ];
 		},
@@ -306,11 +306,11 @@ const ot = {
 			// (usually) creates a "holder" element for them in graveyard. Each RemoveOperation should move nodes to different
 			// "holder" element. If `a` operation points after `b` operation, we move `a` offset to acknowledge
 			// "holder" element insertion.
-			if ( a instanceof RemoveOperation && b instanceof RemoveOperation && b._needsHolderElement ) {
+			if ( a instanceof RemoveOperation && b instanceof RemoveOperation ) {
 				const aTarget = a.targetPosition.path[ 0 ];
 				const bTarget = b.targetPosition.path[ 0 ];
 
-				if ( aTarget > bTarget || ( aTarget == bTarget && isStrong ) ) {
+				if ( aTarget >= bTarget && isStrong ) {
 					// Do not change original operation!
 					a = a.clone();
 					a.targetPosition.path[ 0 ]++;
@@ -373,11 +373,11 @@ const ot = {
 				// Here we do not need to worry that newTargetPosition is inside moved range, because that
 				// would mean that the MoveOperation targets into itself, and that is incorrect operation.
 				// Instead, we calculate the new position of that part of original range.
-				common.start = common.start._getCombined( b.sourcePosition, b.movedRangeStart );
-				common.end = common.end._getCombined( b.sourcePosition, b.movedRangeStart );
+				common.start = common.start._getCombined( b.sourcePosition, b.getMovedRangeStart() );
+				common.end = common.end._getCombined( b.sourcePosition, b.getMovedRangeStart() );
 
 				// We have to take care of proper range order.
-				if ( difference && difference.start.isBefore( common.start ) ) {
+				if ( difference && rangeA.start.isBefore( rangeB.start ) ) {
 					ranges.push( common );
 				} else {
 					ranges.unshift( common );
@@ -387,7 +387,21 @@ const ot = {
 			// At this point we transformed this operation's source ranges it means that nothing should be changed.
 			// But since we need to return an instance of Operation we return an array with NoOperation.
 			if ( ranges.length === 0 ) {
-				return [ new NoOperation( a.baseVersion ) ];
+				if ( a instanceof RemoveOperation ) {
+					// If `a` operation was RemoveOperation, we cannot convert it to NoOperation.
+					// This is because RemoveOperation creates a holder in graveyard.
+					// Even if we "remove nothing" we need a RemoveOperation to create holder element
+					// so that the tree structure is synchronised between clients.
+					// Note that this can happen only if both operations are remove operations, because in
+					// other case RemoveOperation would be forced to be stronger and there would be a common range to move.
+					a = a.clone();
+					a.howMany = 0;
+					a.sourcePosition = b.targetPosition;
+
+					return [ a ];
+				} else {
+					return [ new NoOperation( a.baseVersion ) ];
+				}
 			}
 
 			// Target position also could be affected by the other MoveOperation. We will transform it.

+ 21 - 4
packages/ckeditor5-engine/src/view/domconverter.js

@@ -252,7 +252,7 @@ export default class DomConverter {
 	 * If the converted position is directly before inline filler it is moved inside the filler.
 	 *
 	 * @param {engine.view.position} viewPosition View position.
-	 * @returns {Object} position
+	 * @returns {Object|null} position DOM position or `null` if view position could not be converted to DOM.
 	 * @returns {Node} position.parent DOM position parent.
 	 * @returns {Number} position.offset DOM position offset.
 	 */
@@ -261,6 +261,12 @@ export default class DomConverter {
 
 		if ( viewParent instanceof ViewText ) {
 			const domParent = this.getCorrespondingDomText( viewParent );
+
+			if ( !domParent ) {
+				// Position is in a view text node that has not been rendered to DOM yet.
+				return null;
+			}
+
 			let offset = viewPosition.offset;
 
 			if ( startsWithFiller( domParent ) ) {
@@ -268,16 +274,27 @@ export default class DomConverter {
 			}
 
 			return { parent: domParent, offset: offset };
-		}
-		// viewParent instance of ViewElement.
-		else {
+		} else {
+			// viewParent is instance of ViewElement.
 			let domParent, domBefore, domAfter;
 
 			if ( viewPosition.offset === 0 ) {
 				domParent = this.getCorrespondingDom( viewPosition.parent );
+
+				if ( !domParent ) {
+					// Position is in a view element that has not been rendered to DOM yet.
+					return null;
+				}
+
 				domAfter = domParent.childNodes[ 0 ];
 			} else {
 				domBefore = this.getCorrespondingDom( viewPosition.nodeBefore );
+
+				if ( !domBefore ) {
+					// Position is after a view element that has not been rendered to DOM yet.
+					return null;
+				}
+
 				domParent = domBefore.parentNode;
 				domAfter = domBefore.nextSibling;
 			}

+ 2 - 10
packages/ckeditor5-engine/src/view/renderer.js

@@ -295,18 +295,10 @@ export default class Renderer {
 		// Possible options are:
 		// "FILLER{}"
 		// "FILLERadded-text{}"
-
 		const selectionPosition = this.selection.getFirstPosition();
+		const position = this.domConverter.viewPositionToDom( selectionPosition );
 
-		// If we cannot convert this position's parent it means that selection is in not yet rendered
-		// node, which means that the filler can't be there.
-		if ( !this.domConverter.getCorrespondingDom( selectionPosition.parent ) ) {
-			return false;
-		}
-
-		const { parent: domParent } = this.domConverter.viewPositionToDom( selectionPosition );
-
-		if ( this.domConverter.isText( domParent ) && startsWithFiller( domParent ) ) {
+		if ( position && this.domConverter.isText( position.parent ) && startsWithFiller( position.parent ) ) {
 			return true;
 		}
 

+ 15 - 0
packages/ckeditor5-engine/tests/controller/deletecontent.js

@@ -264,6 +264,21 @@ describe( 'Delete utils', () => {
 				'<heading1>[]</heading1>',
 				{ merge: true }
 			);
+
+			it( 'uses remove delta instead of merge delta if merged element is empty', () => {
+				setData( doc, '<paragraph>ab[cd</paragraph><paragraph>efgh]</paragraph>' );
+
+				const batch = doc.batch();
+				const spyMerge = sinon.spy( batch, 'merge' );
+				const spyRemove = sinon.spy( batch, 'remove' );
+
+				deleteContent( doc.selection, batch, { merge: true } );
+
+				expect( getData( doc ) ).to.equal( '<paragraph>ab[]</paragraph>' );
+
+				expect( spyMerge.called ).to.be.false;
+				expect( spyRemove.called ).to.be.true;
+			} );
 		} );
 
 		describe( 'in element selections scenarios', () => {

+ 0 - 25
packages/ckeditor5-engine/tests/controller/insertcontent.js

@@ -501,31 +501,6 @@ describe( 'DataController', () => {
 				expect( getData( doc ) ).to.equal( '<paragraph>f[]oo</paragraph>' );
 			} );
 		} );
-
-		describe( 'special schema configurations', () => {
-			beforeEach( () => {
-				doc = new Document();
-				doc.createRoot();
-
-				dataController = new DataController( doc );
-			} );
-
-			it( 'should not break when autoparagraphing of text is not possible', () => {
-				const schema = doc.schema;
-
-				schema.registerItem( 'noTextAllowed' );
-				schema.registerItem( 'object' );
-
-				schema.allow( { name: 'noTextAllowed', inside: '$root' } );
-				schema.allow( { name: 'object', inside: 'noTextAllowed' } );
-
-				schema.objects.add( 'object' );
-
-				setData( doc, '<noTextAllowed>[<object></object>]</noTextAllowed>' );
-				insertHelper( 'foo' );
-				expect( getData( doc ) ).to.equal( '<noTextAllowed>[]</noTextAllowed>' );
-			} );
-		} );
 	} );
 
 	// @param {engine.model.Item|String} content

+ 25 - 1
packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js

@@ -191,7 +191,7 @@ describe( 'ModelConversionDispatcher', () => {
 			expect( dispatcher.fire.called ).to.be.false;
 		} );
 
-		it( 'should not fire any event if change was in graveyard root and change type is different than remove', () => {
+		it( 'should not fire any event if changed range in graveyard root and change type is different than remove', () => {
 			sinon.spy( dispatcher, 'fire' );
 
 			let gyNode = new ModelElement( 'image' );
@@ -201,6 +201,30 @@ describe( 'ModelConversionDispatcher', () => {
 
 			expect( dispatcher.fire.called ).to.be.false;
 		} );
+
+		it( 'should not fire any event if remove operation moves nodes between graveyard holders', () => {
+			// This may happen during OT.
+			sinon.spy( dispatcher, 'fire' );
+
+			let gyNode = new ModelElement( 'image' );
+			doc.graveyard.appendChildren( gyNode );
+
+			doc.batch().remove( gyNode );
+
+			expect( dispatcher.fire.called ).to.be.false;
+		} );
+
+		it( 'should not fire any event if element in graveyard was removed', () => {
+			// This may happen during OT.
+			sinon.spy( dispatcher, 'fire' );
+
+			let gyNode = new ModelElement( 'image' );
+			doc.graveyard.appendChildren( gyNode );
+
+			doc.batch().rename( gyNode, 'p' );
+
+			expect( dispatcher.fire.called ).to.be.false;
+		} );
 	} );
 
 	describe( 'convertInsert', () => {

+ 3 - 6
packages/ckeditor5-engine/tests/model/delta/attributedelta.js

@@ -440,12 +440,9 @@ describe( 'AttributeDelta', () => {
 			expect( delta.range ).to.be.null;
 		} );
 
-		it( 'should be equal to the range on which delta operates', () => {
-			// Delta operates on range [ 1 ] to [ 6 ] but omits [ 4 ] - [ 5 ] for "a reason".
-			// Still the range should be from [ 1 ] to [ 6 ]. Delta may not apply anything on [ 4 ] - [ 5 ]
-			// because it already has proper attribute.
-			let rangeA = new Range( new Position( root, [ 1 ] ), new Position( root, [ 2 ] ) );
-			let rangeB = new Range( new Position( root, [ 2 ] ), new Position( root, [ 4 ] ) );
+		it( 'start and end should be equal to first and last changed position', () => {
+			let rangeA = new Range( new Position( root, [ 2 ] ), new Position( root, [ 4 ] ) );
+			let rangeB = new Range( new Position( root, [ 1 ] ), new Position( root, [ 2 ] ) );
 			let rangeC = new Range( new Position( root, [ 5 ] ), new Position( root, [ 6 ] ) );
 
 			delta.addOperation( new AttributeOperation( rangeA, 'key', 'oldA', 'new', 0 ) );

+ 124 - 7
packages/ckeditor5-engine/tests/model/delta/transform/attributedelta.js

@@ -10,6 +10,7 @@ import transformations from 'ckeditor5/engine/model/delta/basic-transformations.
 
 import transform from 'ckeditor5/engine/model/delta/transform.js';
 
+import Element from 'ckeditor5/engine/model/element.js';
 import Text from 'ckeditor5/engine/model/text.js';
 import Position from 'ckeditor5/engine/model/position.js';
 import Range from 'ckeditor5/engine/model/range.js';
@@ -22,6 +23,7 @@ import {
 	getFilledDocument,
 	getAttributeDelta,
 	getWeakInsertDelta,
+	getSplitDelta
 } from 'tests/engine/model/delta/transform/_utils/utils.js';
 
 describe( 'transform', () => {
@@ -34,15 +36,11 @@ describe( 'transform', () => {
 	} );
 
 	describe( 'AttributeDelta by', () => {
-		let attrDelta;
-
-		beforeEach( () => {
-			let attrRange = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 3, 3, 9 ] ) );
-			attrDelta = getAttributeDelta( attrRange, 'key', 'old', 'new', baseVersion );
-		} );
-
 		describe( 'WeakInsertDelta', () => {
 			it( 'weak insert inside attribute range should "fix" splitting the range', () => {
+				let attrRange = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 3, 3, 9 ] ) );
+				let attrDelta = getAttributeDelta( attrRange, 'key', 'old', 'new', baseVersion );
+
 				let insertPosition = new Position( root, [ 3, 3, 0 ] );
 				let insertDelta = getWeakInsertDelta(
 					insertPosition,
@@ -115,6 +113,9 @@ describe( 'transform', () => {
 			} );
 
 			it( 'should be normally transformed if weak insert is not in the attribute range', () => {
+				let attrRange = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 3, 3, 9 ] ) );
+				let attrDelta = getAttributeDelta( attrRange, 'key', 'old', 'new', baseVersion );
+
 				let insertPosition = new Position( root, [ 5 ] );
 				let insertDelta = getWeakInsertDelta( insertPosition, 'abc', baseVersion );
 
@@ -139,5 +140,121 @@ describe( 'transform', () => {
 				} );
 			} );
 		} );
+
+		describe( 'SplitDelta', () => {
+			it( 'change attribute of split element', () => {
+				let attrRange1 = Range.createFromParentsAndOffsets( root, 0, root, 1 );
+				let attrRange2 = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 4 ] ) );
+
+				let attrDelta = new AttributeDelta();
+				attrDelta.addOperation( new AttributeOperation( attrRange1, 'key', 'old', 'new', baseVersion ) );
+				attrDelta.addOperation( new AttributeOperation( attrRange2, 'key', 'old', 'new', baseVersion ) );
+
+				let splitPosition = new Position( root, [ 3, 3, 3, 3 ] );
+				let splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
+
+				let transformed = transform( attrDelta, splitDelta );
+
+				expect( transformed.length ).to.equal( 2 );
+
+				baseVersion = splitDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: AttributeDelta,
+					operations: [
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
+							key: 'key',
+							oldValue: 'old',
+							newValue: 'new',
+							baseVersion: baseVersion
+						},
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 3, 5 ] ), new Position( root, [ 3, 4 ] ) ),
+							key: 'key',
+							oldValue: 'old',
+							newValue: 'new',
+							baseVersion: baseVersion + 1
+						},
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 3, 4 ] ) ),
+							key: 'key',
+							oldValue: 'old',
+							newValue: 'new',
+							baseVersion: baseVersion + 2
+						},
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 3, 4, 0 ] ), new Position( root, [ 3, 3, 4, 9 ] ) ),
+							key: 'key',
+							oldValue: 'old',
+							newValue: 'new',
+							baseVersion: baseVersion + 3
+						}
+					]
+				} );
+
+				expectDelta( transformed[ 1 ], {
+					type: AttributeDelta,
+					operations: [
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 3, 4 ] ), new Position( root, [ 3, 3, 4, 0 ] ) ),
+							key: 'key',
+							oldValue: null,
+							newValue: 'new',
+							baseVersion: baseVersion + 4
+						}
+					]
+				} );
+			} );
+
+			// A different case.
+			it( 'change attribute of split element #2', () => {
+				let attrRange = new Range( new Position( root, [ 3, 3, 3 ] ), new Position( root, [ 3, 3, 3, 0 ] ) );
+				let attrDelta = new AttributeDelta();
+				attrDelta.addOperation( new AttributeOperation( attrRange, 'foo', null, 'bar', baseVersion ) );
+
+				let splitPosition = new Position( root, [ 3, 3, 3, 3 ] );
+				let splitDelta = getSplitDelta( splitPosition, new Element( 'p', { foo: 'old' } ), 9, baseVersion );
+
+				let transformed = transform( attrDelta, splitDelta );
+
+				expect( transformed.length ).to.equal( 2 );
+
+				baseVersion = splitDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: AttributeDelta,
+					operations: [
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 3, 3 ] ), new Position( root, [ 3, 3, 3, 0 ] ) ),
+							key: 'foo',
+							oldValue: null,
+							newValue: 'bar',
+							baseVersion: baseVersion
+						}
+					]
+				} );
+
+				expectDelta( transformed[ 1 ], {
+					type: AttributeDelta,
+					operations: [
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 3, 3, 4 ] ), new Position( root, [ 3, 3, 4, 0 ] ) ),
+							key: 'foo',
+							oldValue: 'old',
+							newValue: 'bar',
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+			} );
+		} );
 	} );
 } );

+ 112 - 0
packages/ckeditor5-engine/tests/model/delta/transform/renamedelta.js

@@ -0,0 +1,112 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: model, operation */
+
+import transformations from 'ckeditor5/engine/model/delta/basic-transformations.js';
+/*jshint unused: false*/
+
+import transform from 'ckeditor5/engine/model/delta/transform.js';
+
+import Element from 'ckeditor5/engine/model/element.js';
+import Position from 'ckeditor5/engine/model/position.js';
+import Range from 'ckeditor5/engine/model/range.js';
+
+import RenameDelta from 'ckeditor5/engine/model/delta/renamedelta.js';
+import RenameOperation from 'ckeditor5/engine/model/operation/renameoperation.js';
+
+import {
+	getFilledDocument,
+	expectDelta,
+	getSplitDelta
+} from 'tests/engine/model/delta/transform/_utils/utils.js';
+
+describe( 'transform', () => {
+	let doc, root, baseVersion;
+
+	beforeEach( () => {
+		doc = getFilledDocument();
+		root = doc.getRoot();
+		baseVersion = doc.version;
+	} );
+
+	describe( 'RenameDelta by', () => {
+		describe( 'SplitDelta', () => {
+			it( 'split element is renamed', () => {
+				let renameDelta = new RenameDelta();
+				renameDelta.addOperation( new RenameOperation(
+					new Position( root, [ 3, 3 ] ),
+					'p',
+					'li',
+					baseVersion
+				) );
+
+				let splitPosition = new Position( root, [ 3, 3, 3 ] );
+				let splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
+
+				let transformed = transform( renameDelta, splitDelta );
+
+				baseVersion = splitDelta.length;
+
+				expect( transformed.length ).to.equal( 2 );
+
+				expectDelta( transformed[ 0 ], {
+					type: RenameDelta,
+					operations: [
+						{
+							type: RenameOperation,
+							oldName: 'p',
+							newName: 'li',
+							position: new Position( root, [ 3, 3 ] )
+						}
+					]
+				} );
+
+				expectDelta( transformed[ 1 ], {
+					type: RenameDelta,
+					operations: [
+						{
+							type: RenameOperation,
+							oldName: 'p',
+							newName: 'li',
+							position: new Position( root, [ 3, 4 ] )
+						}
+					]
+				} );
+			} );
+
+			it( 'split element is different than renamed element', () => {
+				let renameDelta = new RenameDelta();
+				renameDelta.addOperation( new RenameOperation(
+					new Position( root, [ 3, 3 ] ),
+					'p',
+					'li',
+					baseVersion
+				) );
+
+				let splitPosition = new Position( root, [ 3, 2, 1 ] );
+				let splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
+
+				let transformed = transform( renameDelta, splitDelta );
+
+				baseVersion = splitDelta.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: RenameDelta,
+					operations: [
+						{
+							type: RenameOperation,
+							oldName: 'p',
+							newName: 'li',
+							position: new Position( root, [ 3, 4 ] )
+						}
+					]
+				} );
+			} );
+		} );
+	} );
+} );

+ 229 - 0
packages/ckeditor5-engine/tests/model/delta/transform/splitdelta.js

@@ -16,11 +16,15 @@ import Range from 'ckeditor5/engine/model/range.js';
 
 import Delta from 'ckeditor5/engine/model/delta/delta.js';
 import SplitDelta from 'ckeditor5/engine/model/delta/splitdelta.js';
+import AttributeDelta from 'ckeditor5/engine/model/delta/attributedelta.js';
+import RenameDelta from 'ckeditor5/engine/model/delta/renamedelta.js';
 
 import InsertOperation from 'ckeditor5/engine/model/operation/insertoperation.js';
+import AttributeOperation from 'ckeditor5/engine/model/operation/attributeoperation.js';
 import ReinsertOperation from 'ckeditor5/engine/model/operation/reinsertoperation.js';
 import MoveOperation from 'ckeditor5/engine/model/operation/moveoperation.js';
 import NoOperation from 'ckeditor5/engine/model/operation/nooperation.js';
+import RenameOperation from 'ckeditor5/engine/model/operation/renameoperation.js';
 
 import { getNodesAndText, jsonParseStringify } from 'tests/engine/model/_utils/utils.js';
 
@@ -461,5 +465,230 @@ describe( 'transform', () => {
 				expect( nodesAndText ).to.equal( 'EXabcdXPabcPPfoobarxyzPE' );
 			} );
 		} );
+
+		describe( 'AttributeDelta', () => {
+			it( 'attribute changed on split element', () => {
+				let attributeDelta = new AttributeDelta();
+
+				attributeDelta.addOperation( new AttributeOperation(
+					Range.createFromParentsAndOffsets( root, 0, root, 2 ), 'key', 'oldValue', 'newValue', baseVersion
+				) );
+
+				attributeDelta.addOperation( new AttributeOperation(
+					Range.createFromPositionAndShift( new Position( root, [ 3, 3, 2 ] ), 3 ), 'key', null, 'newValue', baseVersion + 1
+				) );
+
+				let transformed = transform( splitDelta, attributeDelta );
+
+				baseVersion = attributeDelta.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 4 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3, 3 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 3, 4, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				expect( transformed[ 0 ].operations[ 0 ].nodes.getNode( 0 ).getAttribute( 'key' ) ).to.equal( 'newValue' );
+			} );
+
+			it( 'attribute removed from split element', () => {
+				splitDelta.operations[ 0 ].nodes.getNode( 0 ).setAttribute( 'key', 'oldValue' );
+				let attributeDelta = new AttributeDelta();
+
+				attributeDelta.addOperation( new AttributeOperation(
+					Range.createFromParentsAndOffsets( root, 0, root, 2 ), 'key', 'otherValue', null, baseVersion
+				) );
+
+				attributeDelta.addOperation( new AttributeOperation(
+					Range.createFromPositionAndShift( new Position( root, [ 3, 3, 2 ] ), 3 ), 'key', 'oldValue', null, baseVersion + 1
+				) );
+
+				let transformed = transform( splitDelta, attributeDelta );
+
+				baseVersion = attributeDelta.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 4 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3, 3 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 3, 4, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				expect( transformed[ 0 ].operations[ 0 ].nodes.getNode( 0 ).hasAttribute( 'key' ) ).to.be.false;
+			} );
+
+			it( 'attribute changed on split element that is reinserted from graveyard', () => {
+				splitDelta.operations[ 0 ] = new ReinsertOperation(
+					new Position( gy, [ 1 ] ),
+					1,
+					new Position( root, [ 3, 3, 4 ] ),
+					baseVersion
+				);
+
+				let attributeDelta = new AttributeDelta();
+
+				attributeDelta.addOperation( new AttributeOperation(
+					Range.createFromParentsAndOffsets( root, 0, root, 4 ), 'key', 'oldValue', 'newValue', baseVersion
+				) );
+
+				let transformed = transform( splitDelta, attributeDelta );
+
+				baseVersion = attributeDelta.operations.length;
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: ReinsertOperation,
+							sourcePosition: new Position( gy, [ 1 ] ),
+							howMany: 1,
+							targetPosition: new Position( root, [ 3, 3, 4 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3, 3 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 3, 4, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+			} );
+		} );
+
+		describe( 'RenameDelta', () => {
+			it( 'renamed split element', () => {
+				const renameDelta = new RenameDelta();
+				renameDelta.addOperation( new RenameOperation(
+					new Position( root, [ 3, 3, 3 ] ), 'p', 'li', baseVersion
+				) );
+
+				let transformed = transform( splitDelta, renameDelta );
+
+				baseVersion = renameDelta.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 4 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3, 3 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 3, 4, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				expect( transformed[ 0 ].operations[ 0 ].nodes.getNode( 0 ).name ).to.equal( 'li' );
+			} );
+
+			it( 'split element is different than renamed element', () => {
+				const renameDelta = new RenameDelta();
+				renameDelta.addOperation( new RenameOperation(
+					new Position( root, [ 4 ] ), 'p', 'li', baseVersion
+				) );
+
+				let transformed = transform( splitDelta, renameDelta );
+
+				baseVersion = renameDelta.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 4 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3, 3 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 3, 4, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+			} );
+
+			it( 'renamed split element that is reinserted from graveyard', () => {
+				splitDelta.operations[ 0 ] = new ReinsertOperation(
+					new Position( gy, [ 1 ] ),
+					1,
+					new Position( root, [ 3, 3, 4 ] ),
+					baseVersion
+				);
+
+				const renameDelta = new RenameDelta();
+				renameDelta.addOperation( new RenameOperation(
+					new Position( root, [ 3, 3, 3 ] ), 'p', 'li', baseVersion
+				) );
+
+				let transformed = transform( splitDelta, renameDelta );
+
+				baseVersion = renameDelta.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: ReinsertOperation,
+							sourcePosition: new Position( gy, [ 1 ] ),
+							howMany: 1,
+							targetPosition: new Position( root, [ 3, 3, 4 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3, 3 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 3, 4, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+			} );
+		} );
 	} );
 } );

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

@@ -8,8 +8,10 @@
 import Document from 'ckeditor5/engine/model/document.js';
 import Schema from 'ckeditor5/engine/model/schema.js';
 import RootElement from 'ckeditor5/engine/model/rootelement.js';
+import Element from 'ckeditor5/engine/model/element.js';
 import Batch from 'ckeditor5/engine/model/batch.js';
 import Delta from 'ckeditor5/engine/model/delta/delta.js';
+import Position from 'ckeditor5/engine/model/position.js';
 import Range from 'ckeditor5/engine/model/range.js';
 import CKEditorError from 'ckeditor5/utils/ckeditorerror.js';
 import count from 'ckeditor5/utils/count.js';
@@ -274,6 +276,51 @@ describe( 'Document', () => {
 		} );
 	} );
 
+	describe( 'getNearestSelectionPosition', () => {
+		let root;
+
+		beforeEach( () => {
+			doc.schema.registerItem( 'paragraph', '$block' );
+			root = doc.createRoot();
+		} );
+
+		it( 'should return equal position if text node can be placed at that position', () => {
+			root.appendChildren( new Element( 'paragraph' ) );
+
+			const position = new Position( root, [ 0, 0 ] );
+			const selectionPosition = doc.getNearestSelectionPosition( position );
+
+			expect( position.isEqual( selectionPosition ) ).to.be.true;
+		} );
+
+		it( 'should return a position before if it is a closer position at which text node can be placed', () => {
+			root.appendChildren( [ new Element( 'paragraph' ), new Element( 'paragraph' ) ] );
+
+			const position = new Position( root, [ 1 ] );
+			const selectionPosition = doc.getNearestSelectionPosition( position );
+
+			expect( selectionPosition.path ).to.deep.equal( [ 0, 0 ] );
+		} );
+
+		it( 'should return a position after if it is a closer position at which text node can be placed', () => {
+			root.appendChildren( [ new Element( 'paragraph' ), new Element( 'image' ), new Element( 'paragraph' ) ] );
+
+			const position = new Position( root, [ 2 ] );
+			const selectionPosition = doc.getNearestSelectionPosition( position );
+
+			expect( selectionPosition.path ).to.deep.equal( [ 2, 0 ] );
+		} );
+
+		it( 'should return first position in root if there is no position where text node can be placed', () => {
+			root.appendChildren( new Element( 'table' ) );
+
+			const position = new Position( root, [ 0, 0 ] );
+			const selectionPosition = doc.getNearestSelectionPosition( position );
+
+			expect( selectionPosition.path ).to.deep.equal( [ 0 ] );
+		} );
+	} );
+
 	describe( '_getDefaultRoot', () => {
 		it( 'should return graveyard root if there are no other roots in the document', () => {
 			expect( doc._getDefaultRoot() ).to.equal( doc.graveyard );

+ 5 - 5
packages/ckeditor5-engine/tests/model/liveselection.js

@@ -320,7 +320,7 @@ describe( 'LiveSelection', () => {
 		beforeEach( () => {
 			root.removeChildren( 0, root.childCount );
 			root.insertChildren( 0, [
-				new Element( 'ul', [], new Text( 'abcdef' ) ),
+				new Element( 'p', [], new Text( 'abcdef' ) ),
 				new Element( 'p', [], new Text( 'foobar' ) ),
 				new Text( 'xyz' )
 			] );
@@ -584,17 +584,17 @@ describe( 'LiveSelection', () => {
 			} );
 
 			it( 'fix selection range if it ends up in graveyard #3', () => {
-				selection.setRanges( [ new Range( new Position( root, [ 1, 2 ] ), new Position( root, [ 1, 4 ] ) ) ] );
+				selection.setRanges( [ new Range( new Position( root, [ 1, 1 ] ), new Position( root, [ 1, 2 ] ) ) ] );
 
 				doc.applyOperation( wrapInDelta(
 					new RemoveOperation(
-						new Position( root, [ 0 ] ),
-						3,
+						new Position( root, [ 1 ] ),
+						2,
 						doc.version
 					)
 				) );
 
-				expect( selection.getFirstPosition().path ).to.deep.equal( [ 0 ] );
+				expect( selection.getFirstPosition().path ).to.deep.equal( [ 0, 6 ] );
 			} );
 		} );
 	} );

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

@@ -355,6 +355,22 @@ describe( 'AttributeOperation', () => {
 		expect( root.getChild( 1 ).data ).to.equal( 'bcxyz' );
 	} );
 
+	it( 'should return undefined upon execution if new value is same as old value', () => {
+		root.insertChildren( 0, new Text( 'bar', { foo: true } ) );
+
+		let operation = new AttributeOperation(
+			new Range( new Position( root, [ 0 ] ), new Position( root, [ 3 ] ) ),
+			'foo',
+			true,
+			true,
+			doc.version
+		);
+
+		const result = operation._execute();
+
+		expect( result ).to.be.undefined;
+	} );
+
 	describe( 'toJSON', () => {
 		it( 'should create proper serialized object', () => {
 			const range = new Range( new Position( root, [ 0 ] ), new Position( root, [ 2 ] ) );

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

@@ -177,8 +177,13 @@ describe( 'InsertOperation', () => {
 
 		expect( clone ).to.be.instanceof( InsertOperation );
 		expect( clone.position.isEqual( position ) ).to.be.true;
-		expect( clone.nodes.getNode( 0 ) ).to.equal( nodeA );
-		expect( clone.nodes.getNode( 1 ) ).to.equal( nodeB );
+
+		// New node, not pointer to the old instance.
+		expect( clone.nodes.getNode( 0 ) ).not.to.equal( nodeA );
+		expect( clone.nodes.getNode( 1 ) ).not.to.equal( nodeB );
+		expect( clone.nodes.getNode( 0 ) ).to.deep.equal( nodeA );
+		expect( clone.nodes.getNode( 1 ) ).to.deep.equal( nodeB );
+
 		expect( clone.nodes.length ).to.equal( 2 );
 		expect( clone.baseVersion ).to.equal( baseVersion );
 	} );

+ 25 - 1
packages/ckeditor5-engine/tests/model/operation/moveoperation.js

@@ -269,6 +269,19 @@ describe( 'MoveOperation', () => {
 		expect( clone.baseVersion ).to.equal( baseVersion );
 	} );
 
+	describe( 'getMovedRangeStart', () => {
+		it( 'should return move operation target position transformed by removing move operation source range', () => {
+			let sourcePosition = new Position( root, [ 0, 2 ] );
+			let targetPosition = new Position( root, [ 0, 6 ] );
+			let howMany = 3;
+			let baseVersion = doc.version;
+
+			let op = new MoveOperation( sourcePosition, howMany, targetPosition, baseVersion );
+
+			expect( op.getMovedRangeStart().path ).to.deep.equal( [ 0, 3 ] );
+		} );
+	} );
+
 	describe( 'toJSON', () => {
 		it( 'should create proper json object', () => {
 			const sourcePosition = new Position( root, [ 0, 0 ] );
@@ -282,7 +295,6 @@ describe( 'MoveOperation', () => {
 				baseVersion: 0,
 				howMany: 1,
 				isSticky: false,
-				movedRangeStart: jsonParseStringify( op.movedRangeStart ),
 				sourcePosition: jsonParseStringify( sourcePosition ),
 				targetPosition: jsonParseStringify( targetPosition )
 			} );
@@ -300,5 +312,17 @@ describe( 'MoveOperation', () => {
 
 			expect( deserialized ).to.deep.equal( op );
 		} );
+
+		it( 'should create proper MoveOperation from json object - sticky', () => {
+			const sourcePosition = new Position( root, [ 0, 0 ] );
+			const targetPosition = new Position( root, [ 1, 0 ] );
+			const op = new MoveOperation( sourcePosition, 1, targetPosition, doc.version );
+			op.isSticky = true;
+
+			const serialized = jsonParseStringify( op );
+			const deserialized = MoveOperation.fromJSON( serialized, doc );
+
+			expect( deserialized ).to.deep.equal( op );
+		} );
 	} );
 } );

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

@@ -102,7 +102,6 @@ describe( 'ReinsertOperation', () => {
 				baseVersion: 0,
 				howMany: 2,
 				isSticky: false,
-				movedRangeStart: jsonParseStringify( operation.movedRangeStart ),
 				sourcePosition: jsonParseStringify( operation.sourcePosition ),
 				targetPosition: jsonParseStringify( operation.targetPosition )
 			} );

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

@@ -11,6 +11,7 @@ import RemoveOperation from 'ckeditor5/engine/model/operation/removeoperation.js
 import MoveOperation from 'ckeditor5/engine/model/operation/moveoperation.js';
 import Position from 'ckeditor5/engine/model/position.js';
 import Text from 'ckeditor5/engine/model/text.js';
+import Element from 'ckeditor5/engine/model/element.js';
 import Delta from 'ckeditor5/engine/model/delta/delta.js';
 import { jsonParseStringify, wrapInDelta } from 'tests/engine/model/_utils/utils.js';
 
@@ -190,7 +191,6 @@ describe( 'RemoveOperation', () => {
 				baseVersion: 0,
 				howMany: 2,
 				isSticky: false,
-				movedRangeStart: jsonParseStringify( op.movedRangeStart ),
 				sourcePosition: jsonParseStringify( op.sourcePosition ),
 				targetPosition: jsonParseStringify( op.targetPosition )
 			} );
@@ -205,6 +205,8 @@ describe( 'RemoveOperation', () => {
 				doc.version
 			);
 
+			doc.graveyard.appendChildren( [ new Element( '$graveyardHolder' ), new Element( '$graveyardHolder' ) ] );
+
 			const serialized = jsonParseStringify( op );
 			const deserialized = RemoveOperation.fromJSON( serialized, doc );
 

+ 7 - 0
packages/ckeditor5-engine/tests/model/operation/renameoperation.js

@@ -94,6 +94,13 @@ describe( 'RenameOperation', () => {
 		expect( clone.newName ).to.equal( newName );
 	} );
 
+	it( 'should return undefined on execution if old name and new name is same', () => {
+		const op = new RenameOperation( Position.createAt( root, 0 ), oldName, oldName, doc.version );
+		const result = op._execute();
+
+		expect( result ).to.be.undefined;
+	} );
+
 	describe( 'toJSON', () => {
 		it( 'should create proper serialized object', () => {
 			const op = new RenameOperation( Position.createAt( root, 'end' ), oldName, newName, doc.version );

+ 87 - 32
packages/ckeditor5-engine/tests/model/operation/transform.js

@@ -555,7 +555,7 @@ describe( 'transform', () => {
 				it( 'attributes have different key: no operation update', () => {
 					let transformBy = new AttributeOperation(
 						Range.createFromRange( range ),
-						'abc',
+						'differentKey',
 						true,
 						false,
 						baseVersion
@@ -585,34 +585,31 @@ describe( 'transform', () => {
 					} );
 				} );
 
-				it( 'both operations removes same attribute: convert to NoOperation', () => {
-					op.newValue = null;
+				describe( 'that is less important and', () => {
+					it( 'both operations removes same attribute: change old value to null', () => {
+						op.newValue = null;
+						expected.newValue = null;
 
-					let transformBy = new AttributeOperation(
-						new Range( new Position( root, [ 1, 1, 4 ] ), new Position( root, [ 3 ] ) ),
-						'foo',
-						'another',
-						null,
-						baseVersion
-					);
+						let transformBy = new AttributeOperation(
+							new Range( new Position( root, [ 1, 1, 4 ] ), new Position( root, [ 3 ] ) ),
+							'foo',
+							'another',
+							null,
+							baseVersion
+						);
 
-					let transOp = transform( op, transformBy, true );
+						let transOp = transform( op, transformBy, true );
 
-					expected.newValue = null;
+						expected.oldValue = null;
 
-					expect( transOp.length ).to.equal( 1 );
-					expectOperation( transOp[ 0 ], {
-						type: NoOperation,
-						baseVersion: baseVersion + 1
+						expect( transOp.length ).to.equal( 1 );
 					} );
-				} );
 
-				describe( 'that is less important and', () => {
 					it( 'range does not intersect original range: no operation update', () => {
 						let transformBy = new AttributeOperation(
 							new Range( new Position( root, [ 3, 0 ] ), new Position( root, [ 4 ] ) ),
 							'foo',
-							'another',
+							'abc',
 							null,
 							baseVersion
 						);
@@ -627,14 +624,14 @@ describe( 'transform', () => {
 						let transformBy = new AttributeOperation(
 							new Range( new Position( root, [ 1, 1, 4 ] ), new Position( root, [ 3 ] ) ),
 							'foo',
-							'another',
+							'abc',
 							null,
 							baseVersion
 						);
 
 						let transOp = transform( op, transformBy, true );
 
-						expected.oldValue = 'another';
+						expected.oldValue = null;
 
 						expect( transOp.length ).to.equal( 1 );
 						expectOperation( transOp[ 0 ], expected );
@@ -644,13 +641,14 @@ describe( 'transform', () => {
 					it( 'range intersects on left: split into two operations, update oldValue', () => {
 						// Get more test cases and better code coverage
 						op.newValue = null;
+						expected.newValue = null;
 
 						let transformBy = new AttributeOperation(
 							new Range( new Position( root, [ 1, 4, 2 ] ), new Position( root, [ 3 ] ) ),
 							'foo',
-							'another',
+							null,
 							// Get more test cases and better code coverage
-							'anothernew',
+							'another',
 							baseVersion
 						);
 
@@ -658,8 +656,6 @@ describe( 'transform', () => {
 
 						expect( transOp.length ).to.equal( 2 );
 
-						expected.newValue = null;
-
 						expected.range.end.path = [ 1, 4, 2 ];
 
 						expectOperation( transOp[ 0 ], expected );
@@ -677,7 +673,7 @@ describe( 'transform', () => {
 						let transformBy = new AttributeOperation(
 							new Range( new Position( root, [ 1 ] ), new Position( root, [ 2, 1 ] ) ),
 							'foo',
-							'another',
+							'abc',
 							null,
 							baseVersion
 						);
@@ -692,7 +688,7 @@ describe( 'transform', () => {
 
 						expected.range.start = op.range.start;
 						expected.range.end.path = [ 2, 1 ];
-						expected.oldValue = 'another';
+						expected.oldValue = null;
 						expected.baseVersion++;
 
 						expectOperation( transOp[ 1 ], expected );
@@ -702,7 +698,7 @@ describe( 'transform', () => {
 						let transformBy = new AttributeOperation(
 							new Range( new Position( root, [ 1, 4, 1 ] ), new Position( root, [ 2, 1 ] ) ),
 							'foo',
-							'another',
+							'abc',
 							null,
 							baseVersion
 						);
@@ -723,7 +719,7 @@ describe( 'transform', () => {
 
 						expected.range.start.path = [ 1, 4, 1 ];
 						expected.range.end.path = [ 2, 1 ];
-						expected.oldValue = 'another';
+						expected.oldValue = null;
 						expected.baseVersion++;
 
 						expectOperation( transOp[ 2 ], expected );
@@ -731,6 +727,27 @@ describe( 'transform', () => {
 				} );
 
 				describe( 'that is more important and', () => {
+					it( 'both operations removes same attribute: convert to NoOperation', () => {
+						op.newValue = null;
+						expected.newValue = null;
+
+						let transformBy = new AttributeOperation(
+							new Range( new Position( root, [ 1, 1, 4 ] ), new Position( root, [ 3 ] ) ),
+							'foo',
+							'another',
+							null,
+							baseVersion
+						);
+
+						let transOp = transform( op, transformBy );
+
+						expect( transOp.length ).to.equal( 1 );
+						expectOperation( transOp[ 0 ], {
+							type: NoOperation,
+							baseVersion: baseVersion + 1
+						} );
+					} );
+
 					it( 'range does not intersect original range: no operation update', () => {
 						let transformBy = new AttributeOperation(
 							new Range( new Position( root, [ 3, 0 ] ), new Position( root, [ 4 ] ) ),
@@ -1377,7 +1394,6 @@ describe( 'transform', () => {
 				);
 
 				transformBy.targetPosition.path = [ 0, 0 ];
-				transformBy.movedRangeStart.path = [ 0, 0 ];
 
 				let transOp = transform( op, transformBy );
 
@@ -1397,7 +1413,6 @@ describe( 'transform', () => {
 				);
 
 				transformBy.targetPosition.path = [ 4, 0 ];
-				transformBy.movedRangeStart.path = [ 4, 0 ];
 
 				let transOp = transform( op, transformBy );
 
@@ -2694,6 +2709,44 @@ describe( 'transform', () => {
 		} );
 	} );
 
+	describe( 'RemoveOperation', () => {
+		describe( 'by RemoveOperation', () => {
+			it( 'removes same nodes and transformed is weak: change howMany to 0', () => {
+				let position = new Position( root, [ 2, 1 ] );
+				let op = new RemoveOperation( position, 3, baseVersion );
+				let transformBy = new RemoveOperation( position, 3, baseVersion );
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], {
+					type: RemoveOperation,
+					howMany: 0,
+					sourcePosition: new Position( doc.graveyard, [ 0, 0 ] ),
+					targetPosition: new Position( doc.graveyard, [ 0, 0 ] ),
+					baseVersion: baseVersion + 1
+				} );
+			} );
+
+			it( 'removes same nodes and transformed is strong: change source position', () => {
+				let position = new Position( root, [ 2, 1 ] );
+				let op = new RemoveOperation( position, 3, baseVersion );
+				let transformBy = new RemoveOperation( position, 3, baseVersion );
+
+				let transOp = transform( op, transformBy, true );
+
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], {
+					type: RemoveOperation,
+					howMany: 3,
+					sourcePosition: new Position( doc.graveyard, [ 0, 0 ] ),
+					targetPosition: new Position( doc.graveyard, [ 1, 0 ] ),
+					baseVersion: baseVersion + 1
+				} );
+			} );
+		} );
+	} );
+
 	describe( 'NoOperation', () => {
 		beforeEach( () => {
 			op = new NoOperation( baseVersion );
@@ -2938,7 +2991,7 @@ describe( 'transform', () => {
 				} );
 			} );
 
-			it( 'same element and is not important: no change', () => {
+			it( 'same element and is not important: change old name to new name', () => {
 				let transformBy = new RenameOperation(
 					new Position( root, [ 0, 2, 2 ] ),
 					'oldName',
@@ -2949,6 +3002,8 @@ describe( 'transform', () => {
 				let transOp = transform( op, transformBy, true );
 
 				expect( transOp.length ).to.equal( 1 );
+
+				expected.oldName = 'otherName';
 				expectOperation( transOp[ 0 ], expected );
 			} );
 		} );

+ 29 - 0
packages/ckeditor5-engine/tests/view/domconverter/view-to-dom.js

@@ -8,6 +8,7 @@
 
 import ViewText from 'ckeditor5/engine/view/text.js';
 import ViewElement from 'ckeditor5/engine/view/element.js';
+import ViewPosition from 'ckeditor5/engine/view/position.js';
 import ViewContainerElement from 'ckeditor5/engine/view/containerelement.js';
 import ViewAttributeElement from 'ckeditor5/engine/view/attributeelement.js';
 import DomConverter from 'ckeditor5/engine/view/domconverter.js';
@@ -508,6 +509,34 @@ describe( 'DomConverter', () => {
 			expect( domPosition.offset ).to.equal( INLINE_FILLER_LENGTH );
 			expect( domPosition.parent ).to.equal( domFiller );
 		} );
+
+		it( 'should return null if view position is after a view element that has not been rendered to DOM', () => {
+			const domP = createElement( document, 'p', null );
+			const { view: viewP, selection } = parse( '<container:p><attribute:b>foo</attribute:b>[]</container:p>' );
+
+			converter.bindElements( domP, viewP );
+
+			const viewPosition = selection.getFirstPosition();
+			const domPosition = converter.viewPositionToDom( viewPosition );
+
+			expect( domPosition ).to.equal( null );
+		} );
+
+		it( 'should return null if view position is in a view text node that has not been rendered to DOM', () => {
+			const viewText = new ViewText( 'foo' );
+			const viewPosition = new ViewPosition( viewText, 1 );
+			const domPosition = converter.viewPositionToDom( viewPosition );
+
+			expect( domPosition ).to.equal( null );
+		} );
+
+		it( 'should return null if view position is in a view element that has not been rendered to DOM', () => {
+			const viewElement = new ViewContainerElement( 'div' );
+			const viewPosition = new ViewPosition( viewElement, 0 );
+			const domPosition = converter.viewPositionToDom( viewPosition );
+
+			expect( domPosition ).to.equal( null );
+		} );
 	} );
 
 	describe( 'viewRangeToDom', () => {

+ 30 - 0
packages/ckeditor5-engine/tests/view/renderer.js

@@ -683,6 +683,36 @@ describe( 'Renderer', () => {
 			expect( domP.childNodes[ 1 ].childNodes[ 0 ].data ).to.equal( INLINE_FILLER );
 		} );
 
+		// Test for an edge case in the _isSelectionInInlineFiller, when selection is before a view element
+		// that has not been yet rendered/bound to DOM.
+		it( 'should remove inline filler if selection is before a view element not bound to dom', () => {
+			// Step 1: <p>bar<b>abc</b>"FILLER"{}</p>
+			const { view: viewP, selection: newSelection } = parse( '<container:p>bar<attribute:b>abc</attribute:b>[]</container:p>' );
+			viewRoot.appendChildren( viewP );
+			selection.setTo( newSelection );
+
+			renderer.markToSync( 'children', viewRoot );
+			renderer.render();
+
+			const domP = domRoot.childNodes[ 0 ];
+			expect( domP.childNodes.length ).to.equal( 3 );
+			expect( domP.childNodes[ 2 ].data ).to.equal( INLINE_FILLER );
+
+			// Step 2: Move selection to a new attribute element.
+			const viewAbc = parse( 'abc' );
+			viewP.appendChildren( viewAbc );
+
+			selection.removeAllRanges();
+			selection.addRange( ViewRange.createFromParentsAndOffsets( viewP, 3, viewP, 3 ) );
+
+			renderer.markToSync( 'children', viewP );
+			renderer.render();
+
+			// Step 3: Check whether old filler was removed.
+			expect( domP.childNodes.length ).to.equal( 3 );
+			expect( domP.textContent.indexOf( INLINE_FILLER ) ).to.equal( -1 );
+		} );
+
 		it( 'should handle typing in empty block, do nothing if changes are already applied', () => {
 			const domSelection = document.getSelection();