浏览代码

Merge pull request #469 from ckeditor/t/468

T/468 Fixes for Undo
Piotr Jasiun 9 年之前
父节点
当前提交
b6e781643f

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

@@ -134,6 +134,12 @@ export default class ModelConversionDispatcher {
 	 * @param {Object} data Additional information about the change.
 	 */
 	convertChange( type, data ) {
+		// Do not convert changes if they happen in graveyard.
+		// Graveyard is a special root that has no view / no other representation and changes done in it should not be converted.
+		if ( type !== 'remove' && data.range && data.range.root.rootName == '$graveyard' ) {
+			return;
+		}
+
 		if ( type == 'insert' || type == 'reinsert' ) {
 			this.convertInsert( data.range );
 		} else if ( type == 'move' ) {

+ 8 - 4
packages/ckeditor5-engine/src/model/composer/deletecontents.js

@@ -25,11 +25,15 @@ export default function deleteContents( batch, selection, options = {} ) {
 		return;
 	}
 
-	const startPos = selection.getFirstRange().start;
-	const endPos = LivePosition.createFromPosition( selection.getFirstRange().end );
+	const selRange = selection.getFirstRange();
 
-	// 1. Remove the contents.
-	batch.remove( selection.getFirstRange() );
+	const startPos = selRange.start;
+	const endPos = LivePosition.createFromPosition( selRange.end );
+
+	// 1. Remove the contents if there are any.
+	if ( !selRange.isEmpty ) {
+		batch.remove( selRange );
+	}
 
 	// 2. Merge elements in the right branch to the elements in the left branch.
 	// The only reasonable (in terms of data and selection correctness) case in which we need to do that is:

+ 44 - 15
packages/ckeditor5-engine/src/model/delta/basic-transformations.js

@@ -10,7 +10,9 @@ import { addTransformationCase, defaultTransform } from './transform.js';
 import Range from '../range.js';
 import Position from '../position.js';
 
+import NoOperation from '../operation/nooperation.js';
 import AttributeOperation from '../operation/attributeoperation.js';
+import ReinsertOperation from '../operation/reinsertoperation.js';
 
 import Delta from './delta.js';
 import AttributeDelta from './attributedelta.js';
@@ -60,7 +62,10 @@ addTransformationCase( MoveDelta, MergeDelta, ( a, b, isStrong ) => {
 	// didn't happen) and then apply the original move operation. This is "mirrored" in MergeDelta x MoveDelta
 	// transformation below, where we simply do not apply MergeDelta.
 
-	const operateInSameParent = compareArrays( a.sourcePosition.getParentPath(), b.position.getParentPath() ) === 'SAME';
+	const operateInSameParent =
+		a.sourcePosition.root == b.position.root &&
+		compareArrays( a.sourcePosition.getParentPath(), b.position.getParentPath() ) === 'SAME';
+
 	const mergeInsideMoveRange = a.sourcePosition.offset <= b.position.offset && a.sourcePosition.offset + a.howMany > b.position.offset;
 
 	if ( operateInSameParent && mergeInsideMoveRange ) {
@@ -78,9 +83,7 @@ addTransformationCase( MergeDelta, InsertDelta, ( a, b, isStrong ) => {
 	// If merge is applied at the same position where we inserted a range of nodes we cancel the merge as it's results
 	// may be unexpected and very weird. Even if we do some "magic" we don't know what really are users' expectations.
 	if ( a.position.isEqual( b.position ) ) {
-		// This is "no-op" delta, it has no type and no operations, it basically does nothing.
-		// It is used when we don't want to apply changes but still we need to return a delta.
-		return [ new Delta() ];
+		return [ noDelta() ];
 	}
 
 	return defaultTransform( a, b, isStrong );
@@ -91,13 +94,14 @@ addTransformationCase( MergeDelta, MoveDelta, ( a, b, isStrong ) => {
 	// If merge is applied at the position between moved nodes we cancel the merge as it's results may be unexpected and
 	// very weird. Even if we do some "magic" we don't know what really are users' expectations.
 
-	const operateInSameParent = compareArrays( a.position.getParentPath(), b.sourcePosition.getParentPath() ) === 'SAME';
+	const operateInSameParent =
+		a.position.root == b.sourcePosition.root &&
+		compareArrays( a.position.getParentPath(), b.sourcePosition.getParentPath() ) === 'SAME';
+
 	const mergeInsideMoveRange = b.sourcePosition.offset <= a.position.offset && b.sourcePosition.offset + b.howMany > a.position.offset;
 
 	if ( operateInSameParent && mergeInsideMoveRange ) {
-		// This is "no-op" delta, it has no type and no operations, it basically does nothing.
-		// It is used when we don't want to apply changes but still we need to return a delta.
-		return [ new Delta() ];
+		return [ noDelta() ];
 	}
 
 	return defaultTransform( a, b, isStrong );
@@ -112,7 +116,7 @@ addTransformationCase( SplitDelta, SplitDelta, ( a, b, isStrong ) => {
 	if ( compareArrays( pathA, pathB ) == 'SAME' ) {
 		if ( a.position.offset == b.position.offset ) {
 			// We are applying split at the position where split already happened. Additional split is not needed.
-			return [ new Delta() ];
+			return [ noDelta() ];
 		} else if ( a.position.offset < b.position.offset ) {
 			// Incoming split delta splits at closer offset. So we simply have to once again split the same node,
 			// but since it was already split (at further offset) there are less child nodes in the split node.
@@ -121,6 +125,15 @@ addTransformationCase( SplitDelta, SplitDelta, ( a, b, isStrong ) => {
 			const delta = a.clone();
 			delta._moveOperation.howMany = b.position.offset - a.position.offset;
 
+			// If both SplitDeltas are taking their nodes from graveyard, we have to transform their ReinsertOperations.
+			if (
+				a._cloneOperation instanceof ReinsertOperation &&
+				b._cloneOperation instanceof ReinsertOperation &&
+				a._cloneOperation.sourcePosition.offset > b._cloneOperation.sourcePosition.offset
+			) {
+				delta._cloneOperation.sourcePosition.offset--;
+			}
+
 			return [ delta ];
 		} else {
 			// Incoming split delta splits at further offset. We have to simulate that we are not splitting the
@@ -134,6 +147,15 @@ addTransformationCase( SplitDelta, SplitDelta, ( a, b, isStrong ) => {
 			delta._moveOperation.targetPosition.path[ delta._moveOperation.targetPosition.path.length - 2 ]++;
 			delta._moveOperation.sourcePosition.offset = a.position.offset - b.position.offset;
 
+			// If both SplitDeltas are taking their nodes from graveyard, we have to transform their ReinsertOperations.
+			if (
+				a._cloneOperation instanceof ReinsertOperation &&
+				b._cloneOperation instanceof ReinsertOperation &&
+				a._cloneOperation.sourcePosition.offset > b._cloneOperation.sourcePosition.offset
+			) {
+				delta._cloneOperation.sourcePosition.offset--;
+			}
+
 			return [ delta ];
 		}
 	}
@@ -146,9 +168,7 @@ addTransformationCase( SplitDelta, UnwrapDelta, ( a, b, isStrong ) => {
 	// If incoming split delta tries to split a node that just got unwrapped, there is actually nothing to split,
 	// so we discard that delta.
 	if ( compareArrays( b.position.path, a.position.getParentPath() ) === 'SAME' ) {
-		// This is "no-op" delta, it has no type and no operations, it basically does nothing.
-		// It is used when we don't want to apply changes but still we need to return a delta.
-		return [ new Delta() ];
+		return [ noDelta() ];
 	}
 
 	return defaultTransform( a, b, isStrong );
@@ -163,9 +183,7 @@ addTransformationCase( SplitDelta, WrapDelta, ( a, b, isStrong ) => {
 	const splitInsideWrapRange = b.range.start.offset < a.position.offset && b.range.end.offset >= a.position.offset;
 
 	if ( operateInSameParent && splitInsideWrapRange ) {
-		// This is "no-op" delta, it has no type and no operations, it basically does nothing.
-		// It is used when we don't want to apply changes but still we need to return a delta.
-		return [ new Delta() ];
+		return [ noDelta() ];
 	} else if ( compareArrays( a.position.getParentPath(), b.range.end.getShiftedBy( -1 ).path ) === 'SAME' ) {
 		// Split position is directly inside the last node from wrap range.
 		// If that's the case, we manually change split delta so it will "target" inside the wrapping element.
@@ -310,3 +328,14 @@ function _getComplementaryAttrDelta( weakInsertDelta, attributeDelta ) {
 
 	return complementaryAttrDelta;
 }
+
+// This is "no-op" delta, it has no type and only no-operation, it basically does nothing.
+// It is used when we don't want to apply changes but still we need to return a delta.
+function noDelta() {
+	let noDelta = new Delta();
+
+	// BaseVersion will be fixed later anyway.
+	noDelta.addOperation( new NoOperation( 0 ) );
+
+	return noDelta;
+}

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

@@ -57,6 +57,16 @@ export default class Delta {
 	}
 
 	/**
+	 * @protected
+	 * @param {Number} baseVersion
+	 */
+	set baseVersion( baseVersion ) {
+		for ( let operation of this.operations ) {
+			operation.baseVersion = baseVersion++;
+		}
+	}
+
+	/**
 	 * A class that will be used when creating reversed delta.
 	 *
 	 * @private

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

@@ -32,6 +32,16 @@ export default class MergeDelta extends Delta {
 		return this._removeOperation ? this._removeOperation.sourcePosition : null;
 	}
 
+	getReversed() {
+		let delta = super.getReversed();
+
+		if ( delta.operations.length > 0 ) {
+			delta.operations[ 1 ].isSticky = false;
+		}
+
+		return delta;
+	}
+
 	/**
 	 * Operation in this delta that removes the node after merge position (which will be empty at that point) or
 	 * `null` if the delta has no operations. Note, that after {@link engine.model.delta.transform transformation}

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

@@ -32,6 +32,16 @@ export default class SplitDelta extends Delta {
 		return this._moveOperation ? this._moveOperation.sourcePosition : null;
 	}
 
+	getReversed() {
+		let delta = super.getReversed();
+
+		if ( delta.operations.length > 0 ) {
+			delta.operations[ 0 ].isSticky = true;
+		}
+
+		return delta;
+	}
+
 	/**
 	 * Operation in the delta that adds a node to the tree model where split elements will be moved to or `null` if
 	 * there are no operations in the delta.

+ 9 - 1
packages/ckeditor5-engine/src/model/history.js

@@ -101,6 +101,14 @@ export default class History {
 			transformed = allResults;
 		}
 
+		// Fix base versions.
+		let baseVersion = transformed[ 0 ].operations[ 0 ].baseVersion;
+
+		for ( let i = 0; i < transformed.length; i++ ) {
+			transformed[ i ].baseVersion = baseVersion;
+			baseVersion += transformed[ i ].operations.length;
+		}
+
 		return transformed;
 	}
 
@@ -131,6 +139,6 @@ export default class History {
 	 * @returns {Array.<engine.model.delta.Delta>} Result of the transformation.
 	 */
 	static _transform( toTransform, transformBy ) {
-		return transform( toTransform, transformBy, false );
+		return transform( toTransform, transformBy, true );
 	}
 }

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

@@ -114,7 +114,7 @@ function fixBoundaries( type, range, position ) {
 
 	switch ( type ) {
 		case 'insert':
-			updated = this.getTransformedByInsertion( range.start, howMany )[ 0 ];
+			updated = this.getTransformedByInsertion( range.start, howMany, false, true )[ 0 ];
 			break;
 
 		case 'move':
@@ -127,7 +127,7 @@ function fixBoundaries( type, range, position ) {
 			// We have to revert `range.start` to the state before the move.
 			const targetPosition = range.start.getTransformedByInsertion( sourcePosition, howMany );
 
-			const result = this.getTransformedByMove( sourcePosition, targetPosition, howMany );
+			const result = this.getTransformedByMove( sourcePosition, targetPosition, howMany, false, true );
 
 			// First item in the array is the "difference" part, so a part of the range
 			// that did not get moved. We use it as reference range and expand if possible.

+ 15 - 9
packages/ckeditor5-engine/src/model/operation/transform.js

@@ -215,14 +215,16 @@ const ot = {
 			let range = Range.createFromPositionAndShift( a.sourcePosition, a.howMany );
 			range = range.getTransformedByInsertion( b.position, b.nodeList.length, false, a.isSticky )[ 0 ];
 
-			return [
-				new a.constructor(
-					range.start,
-					range.end.offset - range.start.offset,
-					a instanceof RemoveOperation ? a.baseVersion : a.targetPosition.getTransformedByInsertion( b.position, b.nodeList.length, !isStrong ),
-					a instanceof RemoveOperation ? undefined : a.baseVersion
-				)
-			];
+			let result = new a.constructor(
+				range.start,
+				range.end.offset - range.start.offset,
+				a instanceof RemoveOperation ? a.baseVersion : a.targetPosition.getTransformedByInsertion( b.position, b.nodeList.length, !isStrong ),
+				a instanceof RemoveOperation ? undefined : a.baseVersion
+			);
+
+			result.isSticky = a.isSticky;
+
+			return [ result ];
 		},
 
 		AttributeOperation: doNotUpdate,
@@ -317,12 +319,16 @@ const ot = {
 			// Map transformed range(s) to operations and return them.
 			return ranges.reverse().map( ( range ) => {
 				// We want to keep correct operation class.
-				return new a.constructor(
+				let result = new a.constructor(
 					range.start,
 					range.end.offset - range.start.offset,
 					a instanceof RemoveOperation ? a.baseVersion : newTargetPosition,
 					a instanceof RemoveOperation ? undefined : a.baseVersion
 				);
+
+				result.isSticky = a.isSticky;
+
+				return result;
 			} );
 		}
 	}

+ 37 - 30
packages/ckeditor5-engine/src/model/range.js

@@ -41,6 +41,29 @@ export default class Range {
 	}
 
 	/**
+	 * Returns an iterator that iterates over all {@link engine.model.Item items} that are in this range and returns
+	 * them together with additional information like length or {@link engine.model.Position positions},
+	 * grouped as {@link engine.model.TreeWalkerValue}. It iterates over all {@link engine.model.TextProxy texts}
+	 * that are inside the range and all the {@link engine.model.Element}s we enter into when iterating over this
+	 * range.
+	 *
+	 * **Note:** iterator will not return a parent node of start position. This is in contrary to
+	 * {@link engine.model.TreeWalker} which will return that node with `'ELEMENT_END'` type. Iterator also
+	 * returns each {@link engine.model.Element} once, while simply used {@link engine.model.TreeWalker} might
+	 * return it twice: for `'ELEMENT_START'` and `'ELEMENT_END'`.
+	 *
+	 * **Note:** because iterator does not return {@link engine.model.TreeWalkerValue values} with the type of
+	 * `'ELEMENT_END'`, you can use {@link engine.model.TreeWalkerValue.previousPosition} as a position before the
+	 * item.
+	 *
+	 * @see engine.model.TreeWalker
+	 * @returns {Iterable.<engine.model.TreeWalkerValue>}
+	 */
+	*[ Symbol.iterator ]() {
+		yield* new TreeWalker( { boundaries: this, ignoreElementEnd: true } );
+	}
+
+	/**
 	 * Returns whether the range is collapsed, that is it start and end positions are equal.
 	 *
 	 * @type {Boolean}
@@ -59,6 +82,15 @@ export default class Range {
 	}
 
 	/**
+	 * Returns whether this range has any nodes in it.
+	 *
+	 * @type {Boolean}
+	 */
+	get isEmpty() {
+		return this.start.isTouching( this.end );
+	}
+
+	/**
 	 * Range root element.
 	 *
 	 * Equals to the root of start position (which should be same as root of end position).
@@ -254,29 +286,6 @@ export default class Range {
 	}
 
 	/**
-	 * Returns an iterator that iterates over all {@link engine.model.Item items} that are in this range and returns
-	 * them together with additional information like length or {@link engine.model.Position positions},
-	 * grouped as {@link engine.model.TreeWalkerValue}. It iterates over all {@link engine.model.TextProxy texts}
-	 * that are inside the range and all the {@link engine.model.Element}s we enter into when iterating over this
-	 * range.
-	 *
-	 * **Note:** iterator will not return a parent node of start position. This is in contrary to
-	 * {@link engine.model.TreeWalker} which will return that node with `'ELEMENT_END'` type. Iterator also
-	 * returns each {@link engine.model.Element} once, while simply used {@link engine.model.TreeWalker} might
-	 * return it twice: for `'ELEMENT_START'` and `'ELEMENT_END'`.
-	 *
-	 * **Note:** because iterator does not return {@link engine.model.TreeWalkerValue values} with the type of
-	 * `'ELEMENT_END'`, you can use {@link engine.model.TreeWalkerValue.previousPosition} as a position before the
-	 * item.
-	 *
-	 * @see engine.model.TreeWalker
-	 * @returns {Iterable.<engine.model.TreeWalkerValue>}
-	 */
-	*[ Symbol.iterator ]() {
-		yield* new TreeWalker( { boundaries: this, ignoreElementEnd: true } );
-	}
-
-	/**
 	 * Creates a {@link engine.model.TreeWalker} instance with this range as a boundary.
 	 *
 	 * @param {Object} options Object with configuration options. See {@link engine.model.TreeWalker}.
@@ -371,9 +380,7 @@ export default class Range {
 	 * range boundary. Defaults to `false`.
 	 * @returns {Array.<engine.model.Range>} Result of the transformation.
 	 */
-	getTransformedByInsertion( insertPosition, howMany, spread, isSticky ) {
-		isSticky = !!isSticky;
-
+	getTransformedByInsertion( insertPosition, howMany, spread = false, isSticky = false ) {
 		if ( spread && this.containsPosition( insertPosition ) ) {
 			// Range has to be spread. The first part is from original start to the spread point.
 			// The other part is from spread point to the original end, but transformed by
@@ -389,8 +396,8 @@ export default class Range {
 		} else {
 			const range = Range.createFromRange( this );
 
-			let insertBeforeStart = range.isCollapsed ? true : !isSticky;
-			let insertBeforeEnd = range.isCollapsed ? true : isSticky;
+			let insertBeforeStart = range.isCollapsed ? isSticky : !isSticky;
+			let insertBeforeEnd = isSticky;
 
 			range.start = range.start.getTransformedByInsertion( insertPosition, howMany, insertBeforeStart );
 			range.end = range.end.getTransformedByInsertion( insertPosition, howMany, insertBeforeEnd );
@@ -410,7 +417,7 @@ export default class Range {
 	 * was inside the range. Defaults to `false`.
 	 * @returns {Array.<engine.model.Range>} Result of the transformation.
 	 */
-	getTransformedByMove( sourcePosition, targetPosition, howMany, spread ) {
+	getTransformedByMove( sourcePosition, targetPosition, howMany, spread, isSticky = false ) {
 		let result;
 
 		const moveRange = new Range( sourcePosition, sourcePosition.getShiftedBy( howMany ) );
@@ -438,7 +445,7 @@ export default class Range {
 		const insertPosition = targetPosition.getTransformedByDeletion( sourcePosition, howMany );
 
 		if ( difference ) {
-			result = difference.getTransformedByInsertion( insertPosition, howMany, spread );
+			result = difference.getTransformedByInsertion( insertPosition, howMany, spread, isSticky );
 		} else {
 			result = [];
 		}

+ 5 - 3
packages/ckeditor5-engine/src/view/domconverter.js

@@ -547,9 +547,11 @@ export default class DomConverter {
 			return this.getCorrespondingDomElement( viewNode );
 		} else if ( viewNode instanceof ViewDocumentFragment ) {
 			return this.getCorrespondingDomDocumentFragment( viewNode );
-		} else {
+		} else if ( viewNode instanceof ViewText ) {
 			return this.getCorrespondingDomText( viewNode );
 		}
+
+		return null;
 	}
 
 	/**
@@ -597,8 +599,8 @@ export default class DomConverter {
 			return this.getCorrespondingDom( previousSibling ).nextSibling;
 		}
 
-		// Try to use parent to find the corresponding text node.
-		if ( !previousSibling && this.getCorrespondingDom( viewText.parent ) ) {
+		// If this is a first node, try to use parent to find the corresponding text node.
+		if ( !previousSibling && viewText.parent && this.getCorrespondingDom( viewText.parent ) ) {
 			return this.getCorrespondingDom( viewText.parent ).childNodes[ 0 ];
 		}
 

+ 11 - 0
packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js

@@ -181,6 +181,17 @@ 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', () => {
+			sinon.spy( dispatcher, 'fire' );
+
+			let gyNode = new ModelElement( 'image' );
+			doc.graveyard.appendChildren( gyNode );
+
+			doc.batch().setAttr( 'key', 'value', gyNode );
+
+			expect( dispatcher.fire.called ).to.be.false;
+		} );
 	} );
 
 	describe( 'convertInsert', () => {

+ 8 - 0
packages/ckeditor5-engine/tests/model/composer/deletecontents.js

@@ -182,6 +182,14 @@ describe( 'Delete utils', () => {
 				{ merge: true }
 			);
 
+			// For code coverage reasons.
+			test(
+				'merges element when selection is in two consecutive nodes even when it is empty',
+				'<p>foo<selection></p><p></selection>bar</p>',
+				'<p>foo<selection />bar</p>',
+				{ merge: true }
+			);
+
 			// If you disagree with this case please read the notes before this section.
 			test(
 				'merges elements when left end deep nested',

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

@@ -59,6 +59,32 @@ describe( 'Delta', () => {
 		} );
 	} );
 
+	describe( 'baseVersion', () => {
+		it( 'should return baseVersion of first operation in the delta', () => {
+			const delta = new Delta();
+
+			delta.addOperation( { baseVersion: 0 } );
+			delta.addOperation( { baseVersion: 1 } );
+			delta.addOperation( { baseVersion: 2 } );
+
+			expect( delta.baseVersion ).to.equal( 0 );
+		} );
+
+		it( 'should change baseVersion of it\'s operations', () => {
+			const delta = new Delta();
+
+			delta.addOperation( { baseVersion: 0 } );
+			delta.addOperation( { baseVersion: 1 } );
+			delta.addOperation( { baseVersion: 2 } );
+
+			delta.baseVersion = 10;
+
+			expect( delta.operations[ 0 ].baseVersion ).to.equal( 10 );
+			expect( delta.operations[ 1 ].baseVersion ).to.equal( 11 );
+			expect( delta.operations[ 2 ].baseVersion ).to.equal( 12 );
+		} );
+	} );
+
 	describe( 'addOperation', () => {
 		it( 'should add operation to the delta', () => {
 			const delta = new Delta();

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

@@ -21,6 +21,7 @@ import MergeDelta from '/ckeditor5/engine/model/delta/mergedelta.js';
 
 import MoveOperation from '/ckeditor5/engine/model/operation/moveoperation.js';
 import RemoveOperation from '/ckeditor5/engine/model/operation/removeoperation.js';
+import NoOperation from '/ckeditor5/engine/model/operation/nooperation.js';
 
 import { getNodesAndText, jsonParseStringify } from '/tests/engine/model/_utils/utils.js';
 
@@ -65,11 +66,18 @@ describe( 'transform', () => {
 
 				// Expected: MergeDelta gets ignored and is not applied.
 
+				baseVersion = insertDelta.operations.length;
+
 				expect( transformed.length ).to.equal( 1 );
 
 				expectDelta( transformed[ 0 ], {
 					type: Delta,
-					operations: []
+					operations: [
+						{
+							type: NoOperation,
+							baseVersion: baseVersion
+						}
+					]
 				} );
 
 				// Test if deltas do what they should after applying transformed delta.
@@ -134,11 +142,18 @@ describe( 'transform', () => {
 				let moveDelta = getMoveDelta( new Position( root, [ 3, 3, 3 ] ), 1, new Position( root, [ 3, 3, 0 ] ), baseVersion );
 				let transformed = transform( mergeDelta, moveDelta );
 
+				baseVersion = moveDelta.operations.length;
+
 				expect( transformed.length ).to.equal( 1 );
 
 				expectDelta( transformed[ 0 ], {
 					type: Delta,
-					operations: []
+					operations: [
+						{
+							type: NoOperation,
+							baseVersion: baseVersion
+						}
+					]
 				} );
 
 				// Test if deltas do what they should after applying transformed delta.

+ 116 - 3
packages/ckeditor5-engine/tests/model/delta/transform/splitdelta.js

@@ -20,7 +20,9 @@ import Delta from '/ckeditor5/engine/model/delta/delta.js';
 import SplitDelta from '/ckeditor5/engine/model/delta/splitdelta.js';
 
 import InsertOperation from '/ckeditor5/engine/model/operation/insertoperation.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 { getNodesAndText, jsonParseStringify } from '/tests/engine/model/_utils/utils.js';
 
@@ -56,11 +58,18 @@ describe( 'transform', () => {
 				let splitDeltaB = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
 				let transformed = transform( splitDelta, splitDeltaB );
 
+				baseVersion = splitDeltaB.operations.length;
+
 				expect( transformed.length ).to.equal( 1 );
 
 				expectDelta( transformed[ 0 ], {
 					type: Delta,
-					operations: []
+					operations: [
+						{
+							type: NoOperation,
+							baseVersion: baseVersion
+						}
+					]
 				} );
 
 				// Test if deltas do what they should after applying transformed delta.
@@ -112,6 +121,51 @@ describe( 'transform', () => {
 				expect( nodesAndText ).to.equal( 'XXXXXabcdXPabcPPfoPPobarxyzP' );
 			} );
 
+			it( 'split in same parent, incoming delta splits closer, split deltas have reinsert operations', () => {
+				let reOp = new ReinsertOperation(
+					new Position( gy, [ 1 ] ),
+					1,
+					Position.createFromPosition( splitDelta.operations[ 0 ].position ),
+					splitDelta.operations[ 0 ].baseVersion
+				);
+				splitDelta.operations[ 0 ] = reOp;
+
+				let splitDeltaB = getSplitDelta( new Position( root, [ 3, 3, 3, 5 ] ), new Element( 'p' ), 7, baseVersion );
+				reOp = new ReinsertOperation(
+					new Position( gy, [ 0 ] ),
+					1,
+					Position.createFromPosition( splitDeltaB.operations[ 0 ].position ),
+					splitDeltaB.operations[ 0 ].baseVersion
+				);
+				splitDeltaB.operations[ 0 ] = reOp;
+
+				let transformed = transform( splitDelta, splitDeltaB );
+
+				baseVersion = splitDeltaB.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: ReinsertOperation,
+							sourcePosition: new Position( gy, [ 0 ] ),
+							howMany: 1,
+							targetPosition: new Position( root, [ 3, 3, 4 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3, 3 ] ),
+							howMany: 2,
+							targetPosition: new Position( root, [ 3, 3, 4, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+			} );
+
 			it( 'split in same parent, incoming delta splits further', () => {
 				let splitDeltaB = getSplitDelta( new Position( root, [ 3, 3, 3, 1 ] ), new Element( 'p' ), 11, baseVersion );
 				let transformed = transform( splitDelta, splitDeltaB );
@@ -149,6 +203,51 @@ describe( 'transform', () => {
 				expect( nodesAndText ).to.equal( 'XXXXXabcdXPaPPbcPPfoobarxyzP' );
 			} );
 
+			it( 'split in same parent, incoming delta splits further, split deltas have reinsert operations', () => {
+				let reOp = new ReinsertOperation(
+					new Position( gy, [ 1 ] ),
+					1,
+					Position.createFromPosition( splitDelta.operations[ 0 ].position ),
+					splitDelta.operations[ 0 ].baseVersion
+				);
+				splitDelta.operations[ 0 ] = reOp;
+
+				let splitDeltaB = getSplitDelta( new Position( root, [ 3, 3, 3, 1 ] ), new Element( 'p' ), 11, baseVersion );
+				reOp = new ReinsertOperation(
+					new Position( gy, [ 0 ] ),
+					1,
+					Position.createFromPosition( splitDeltaB.operations[ 0 ].position ),
+					splitDeltaB.operations[ 0 ].baseVersion
+				);
+				splitDeltaB.operations[ 0 ] = reOp;
+
+				let transformed = transform( splitDelta, splitDeltaB );
+
+				baseVersion = splitDeltaB.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: ReinsertOperation,
+							sourcePosition: new Position( gy, [ 0 ] ),
+							howMany: 1,
+							targetPosition: new Position( root, [ 3, 3, 5 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 4, 2 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 3, 5, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+			} );
+
 			it( 'split in split parent', () => {
 				let splitDeltaB = getSplitDelta( new Position( root, [ 3, 3, 3 ] ), new Element( 'div' ), 1, baseVersion );
 				let transformed = transform( splitDelta, splitDeltaB );
@@ -192,11 +291,18 @@ describe( 'transform', () => {
 				let unwrapDelta = getUnwrapDelta( new Position( root, [ 3, 3, 3 ] ), 12, baseVersion );
 				let transformed = transform( splitDelta, unwrapDelta );
 
+				baseVersion = unwrapDelta.operations.length;
+
 				expect( transformed.length ).to.equal( 1 );
 
 				expectDelta( transformed[ 0 ], {
 					type: Delta,
-					operations: []
+					operations: [
+						{
+							type: NoOperation,
+							baseVersion: baseVersion
+						}
+					]
 				} );
 
 				// Test if deltas do what they should after applying transformed delta.
@@ -255,11 +361,18 @@ describe( 'transform', () => {
 
 				let transformed = transform( splitDelta, wrapDelta );
 
+				baseVersion = wrapDelta.operations.length;
+
 				expect( transformed.length ).to.equal( 1 );
 
 				expectDelta( transformed[ 0 ], {
 					type: Delta,
-					operations: []
+					operations: [
+						{
+							type: NoOperation,
+							baseVersion: baseVersion
+						}
+					]
 				} );
 
 				// Test if deltas do what they should after applying transformed delta.

+ 30 - 0
packages/ckeditor5-engine/tests/model/history.js

@@ -116,6 +116,36 @@ describe( 'History', () => {
 			expect( History._transform.calledWithExactly( sinon.match.instanceOf( Delta ), deltaD ) ).to.be.true;
 		} );
 
+		it( 'should correctly set base versions if multiple deltas are result of transformation', () => {
+			// Let's stub History._transform so it will always return two deltas with two operations each.
+			History._transform = function() {
+				let resultA = new Delta();
+				resultA.addOperation( new NoOperation( 1 ) );
+				resultA.addOperation( new NoOperation( 1 ) );
+
+				let resultB = new Delta();
+				resultB.addOperation( new NoOperation( 1 ) );
+				resultB.addOperation( new NoOperation( 1 ) );
+
+				return [ resultA, resultB ];
+			};
+
+			let deltaA = new Delta();
+			deltaA.addOperation( new NoOperation( 0 ) );
+
+			let deltaX = new Delta();
+			deltaX.addOperation( new NoOperation( 0 ) );
+
+			history.addOperation( deltaA.operations[ 0 ] );
+
+			let result = history.getTransformedDelta( deltaX );
+
+			expect( result[ 0 ].operations[ 0 ].baseVersion ).to.equal( 1 );
+			expect( result[ 0 ].operations[ 1 ].baseVersion ).to.equal( 2 );
+			expect( result[ 1 ].operations[ 0 ].baseVersion ).to.equal( 3 );
+			expect( result[ 1 ].operations[ 1 ].baseVersion ).to.equal( 4 );
+		} );
+
 		it( 'should not transform given delta if it bases on current version of history', () => {
 			let deltaA = new Delta();
 			deltaA.addOperation( new NoOperation( 0 ) );

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

@@ -441,9 +441,17 @@ describe( 'Range', () => {
 			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 4, 4 ] );
 		} );
 
-		it( 'should move after inserted nodes if the range is collapsed', () => {
+		it( 'should not change if the range is collapsed and isSticky is false', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 2 ] ) );
-			const transformed = range.getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 3 );
+			const transformed = range.getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 3, false, false );
+
+			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
+			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 3, 2 ] );
+		} );
+
+		it( 'should move after inserted nodes if the range is collapsed and isSticky is true', () => {
+			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 2 ] ) );
+			const transformed = range.getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 3, false, true );
 
 			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 5 ] );
 			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 3, 5 ] );
@@ -710,6 +718,22 @@ describe( 'Range', () => {
 		} );
 	} );
 
+	describe( 'isEmpty', () => {
+		beforeEach( () => {
+			prepareRichRoot( root );
+		} );
+
+		it( 'should be true if there are no nodes between range start and end', () => {
+			let range = new Range( new Position( root, [ 0, 0, 5 ] ), new Position( root, [ 0, 1, 0 ] ) );
+			expect( range.isEmpty ).to.be.true;
+		} );
+
+		it( 'should be false if there are nodes between range start and end', () => {
+			let range = new Range( new Position( root, [ 0, 0, 5 ] ), new Position( root, [ 0, 1, 1 ] ) );
+			expect( range.isEmpty ).to.be.false;
+		} );
+	} );
+
 	function mapNodesToNames( nodes ) {
 		return nodes.map( ( node ) => {
 			return ( node instanceof Element ) ? 'E:' + node.name : 'T:' + ( node.text || node.character );

+ 4 - 0
packages/ckeditor5-engine/tests/view/domconverter/binding.js

@@ -239,6 +239,10 @@ describe( 'DomConverter', () => {
 
 			expect( converter.getCorrespondingDom( viewFragment ) ).to.equal( domFragment );
 		} );
+
+		it( 'should return null if wrong parameter is passed', () => {
+			expect( converter.getCorrespondingDom( null ) ).to.be.null;
+		} );
 	} );
 
 	describe( 'getCorrespondingDomElement', () => {