Explorar el Código

Merge pull request #287 from ckeditor/t/250

T/250 History and other improvements in core for undo
Piotr Jasiun hace 9 años
padre
commit
d429b3fdc6
Se han modificado 27 ficheros con 675 adiciones y 102 borrados
  1. 3 3
      packages/ckeditor5-engine/src/treemodel/batch.js
  2. 3 3
      packages/ckeditor5-engine/src/treemodel/delta/attributedelta.js
  3. 50 0
      packages/ckeditor5-engine/src/treemodel/delta/basic-transformations.js
  4. 20 5
      packages/ckeditor5-engine/src/treemodel/delta/delta.js
  5. 3 2
      packages/ckeditor5-engine/src/treemodel/delta/mergedelta.js
  6. 1 1
      packages/ckeditor5-engine/src/treemodel/delta/movedelta.js
  7. 1 1
      packages/ckeditor5-engine/src/treemodel/delta/removedelta.js
  8. 2 1
      packages/ckeditor5-engine/src/treemodel/delta/splitdelta.js
  9. 3 2
      packages/ckeditor5-engine/src/treemodel/delta/unwrapdelta.js
  10. 1 1
      packages/ckeditor5-engine/src/treemodel/delta/weakinsertdelta.js
  11. 23 2
      packages/ckeditor5-engine/src/treemodel/delta/wrapdelta.js
  12. 13 3
      packages/ckeditor5-engine/src/treemodel/document.js
  13. 126 0
      packages/ckeditor5-engine/src/treemodel/history.js
  14. 19 16
      packages/ckeditor5-engine/src/treemodel/operation/moveoperation.js
  15. 14 5
      packages/ckeditor5-engine/src/treemodel/operation/reinsertoperation.js
  16. 0 4
      packages/ckeditor5-engine/src/treemodel/operation/removeoperation.js
  17. 11 11
      packages/ckeditor5-engine/src/treemodel/operation/transform.js
  18. 27 32
      packages/ckeditor5-engine/tests/treemodel/batch.js
  19. 10 2
      packages/ckeditor5-engine/tests/treemodel/delta/transform/_utils/utils.js
  20. 14 2
      packages/ckeditor5-engine/tests/treemodel/delta/transform/delta.js
  21. 40 1
      packages/ckeditor5-engine/tests/treemodel/delta/transform/splitdelta.js
  22. 45 1
      packages/ckeditor5-engine/tests/treemodel/delta/transform/wrapdelta.js
  23. 30 0
      packages/ckeditor5-engine/tests/treemodel/delta/wrapdelta.js
  24. 154 0
      packages/ckeditor5-engine/tests/treemodel/history.js
  25. 1 1
      packages/ckeditor5-engine/tests/treemodel/operation/moveoperation.js
  26. 8 0
      packages/ckeditor5-engine/tests/treemodel/operation/reinsertoperation.js
  27. 53 3
      packages/ckeditor5-engine/tests/treemodel/operation/transform.js

+ 3 - 3
packages/ckeditor5-engine/src/treemodel/batch.js

@@ -87,12 +87,12 @@ export default class Batch {
  *			// Create operations which should be components of this delta.
  *			const operation = new InsertOperation( position, nodes, this.doc.version );
  *
+ *			// Add operation to the delta. It is important to add operation before applying it.
+ *			delta.addOperation( operation );
+ *
  *			// Remember to apply every operation, no magic, you need to do it manually.
  *			this.doc.applyOperation( operation );
  *
- *			// Add operation to the delta.
- *			delta.addOperation( operation );
- *
  *			// Add delta to the Batch instance.
  *			this.addDelta( delta );
  *

+ 3 - 3
packages/ckeditor5-engine/src/treemodel/delta/attributedelta.js

@@ -154,8 +154,8 @@ function changeNode( doc, key, value, node ) {
 			operation = new AttributeOperation( range, key, previousValue, value, doc.version );
 		}
 
-		doc.applyOperation( operation );
 		delta.addOperation( operation );
+		doc.applyOperation( operation );
 	}
 
 	// It is expected that this method returns a delta.
@@ -204,10 +204,10 @@ function changeRange( doc, attributeKey, attributeValue, range ) {
 
 	function addOperation() {
 		let range = new Range( lastSplitPosition, position );
-		const operation = new AttributeOperation( range, attributeKey, attributeValueBefore, attributeValue, doc.version );
+		const operation = new AttributeOperation( range, attributeKey, attributeValueBefore || null, attributeValue, doc.version );
 
-		doc.applyOperation( operation );
 		delta.addOperation( operation );
+		doc.applyOperation( operation );
 	}
 
 	return delta;

+ 50 - 0
packages/ckeditor5-engine/src/treemodel/delta/basic-transformations.js

@@ -8,6 +8,7 @@
 import { addTransformationCase, defaultTransform } from './transform.js';
 
 import Range from '../range.js';
+import Position from '../position.js';
 
 import AttributeOperation from '../operation/attributeoperation.js';
 
@@ -165,6 +166,42 @@ addTransformationCase( SplitDelta, WrapDelta, ( a, b, isStrong ) => {
 		// 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() ];
+	} else if ( utils.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.
+		// By doing so we will be inserting split node right to the original node which feels natural and is a good UX.
+		const delta = a.clone();
+
+		// 1. Fix insert operation position.
+		// Node to split is the last children of the wrapping element.
+		// Wrapping element is the element inserted by WrapDelta (re)insert operation.
+		// It is inserted after the wrapped range, but the wrapped range will be moved inside it.
+		// Having this in mind, it is correct to use wrapped range start position as the position before wrapping element.
+		const splitNodePos = Position.createFromPosition( b.range.start );
+		// Now, `splitNodePos` points before wrapping element.
+		// To get a position before last children of that element, we expand position's `path` member by proper offset.
+		splitNodePos.path.push( b.howMany - 1 );
+
+		// SplitDelta insert operation position should be right after the node we split.
+		const insertPos = splitNodePos.getShiftedBy( 1 );
+		delta._cloneOperation.position = insertPos;
+
+		// 2. Fix move operation source position.
+		// Nodes moved by SplitDelta will be moved from new position, modified by WrapDelta.
+		// To obtain that new position, `splitNodePos` will be used, as this is the node we are extracting children from.
+		const sourcePos = Position.createFromPosition( splitNodePos );
+		// Nothing changed inside split node so it is correct to use the original split position offset.
+		sourcePos.path.push( a.position.offset );
+		delta._moveOperation.sourcePosition = sourcePos;
+
+		// 3. Fix move operation target position.
+		// SplitDelta move operation target position should be inside the node inserted by operation above.
+		// Since the node is empty, we will insert at offset 0.
+		const targetPos = Position.createFromPosition( insertPos );
+		targetPos.path.push( 0 );
+		delta._moveOperation.targetPosition = targetPos;
+
+		return [ delta ];
 	}
 
 	return defaultTransform( a, b, isStrong );
@@ -210,6 +247,19 @@ addTransformationCase( WrapDelta, SplitDelta, ( a, b, isStrong ) => {
 			b.getReversed(),
 			a.clone()
 		];
+	} else if ( utils.compareArrays( b.position.getParentPath(), a.range.end.getShiftedBy( -1 ).path ) === 'SAME' ) {
+		const delta = a.clone();
+
+		// Move wrapping element insert position one node further so it is after the split node insertion.
+		delta._insertOperation.position.offset++;
+
+		// Include the split node copy.
+		delta._moveOperation.howMany++;
+
+		// Change the path to wrapping element in move operation.
+		delta._moveOperation.targetPosition.path[ delta._moveOperation.targetPosition.path.length - 2 ]++;
+
+		return [ delta ];
 	}
 
 	return defaultTransform( a, b, isStrong );

+ 20 - 5
packages/ckeditor5-engine/src/treemodel/delta/delta.js

@@ -38,6 +38,21 @@ export default class Delta {
 		this.operations = [];
 	}
 
+	/**
+	 * Returns delta base version which is equal to the base version of the first operation in delta. If there
+	 * are no operations in delta, returns `null`.
+	 *
+	 * @see core.treeModel.Document
+	 * @type {Number|null}
+	 */
+	get baseVersion() {
+		if ( this.operations.length > 0 ) {
+			return this.operations[ 0 ].baseVersion;
+		}
+
+		return null;
+	}
+
 	/**
 	 * A class that will be used when creating reversed delta.
 	 *
@@ -67,7 +82,6 @@ export default class Delta {
 	 */
 	clone() {
 		let delta = new this.constructor();
-		delta.batch = this.batch;
 
 		for ( let op of this.operations ) {
 			delta.addOperation( op.clone() );
@@ -91,14 +105,15 @@ export default class Delta {
 		let delta = new this._reverseDeltaClass();
 
 		for ( let op of this.operations ) {
-			let reversedOp = op.getReversed();
-			reversedOp.baseVersion += this.operations.length - 1;
-
-			delta.addOperation( reversedOp );
+			delta.addOperation( op.getReversed() );
 		}
 
 		delta.operations.reverse();
 
+		for ( let i = 0; i < delta.operations.length; i++ ) {
+			delta.operations[ i ].baseVersion = this.operations[ this.operations.length - 1 ].baseVersion + i + 1;
+		}
+
 		return delta;
 	}
 

+ 3 - 2
packages/ckeditor5-engine/src/treemodel/delta/mergedelta.js

@@ -93,12 +93,13 @@ register( 'merge', function( position ) {
 	const positionBefore = Position.createFromParentAndOffset( nodeBefore, nodeBefore.getChildCount() );
 
 	const move = new MoveOperation( positionAfter, nodeAfter.getChildCount(), positionBefore, this.doc.version );
-	this.doc.applyOperation( move );
+	move.isSticky = true;
 	delta.addOperation( move );
+	this.doc.applyOperation( move );
 
 	const remove = new RemoveOperation( position, 1, this.doc.version );
-	this.doc.applyOperation( remove );
 	delta.addOperation( remove );
+	this.doc.applyOperation( remove );
 
 	this.addDelta( delta );
 

+ 1 - 1
packages/ckeditor5-engine/src/treemodel/delta/movedelta.js

@@ -77,8 +77,8 @@ export default class MoveDelta extends Delta {
 
 function addMoveOperation( batch, delta, sourcePosition, howMany, targetPosition ) {
 	const operation = new MoveOperation( sourcePosition, howMany, targetPosition, batch.doc.version );
-	batch.doc.applyOperation( operation );
 	delta.addOperation( operation );
+	batch.doc.applyOperation( operation );
 }
 
 /**

+ 1 - 1
packages/ckeditor5-engine/src/treemodel/delta/removedelta.js

@@ -22,8 +22,8 @@ export default class RemoveDelta extends MoveDelta {}
 
 function addRemoveOperation( batch, delta, position, howMany ) {
 	const operation = new RemoveOperation( position, howMany, batch.doc.version );
-	batch.doc.applyOperation( operation );
 	delta.addOperation( operation );
+	batch.doc.applyOperation( operation );
 }
 
 /**

+ 2 - 1
packages/ckeditor5-engine/src/treemodel/delta/splitdelta.js

@@ -67,7 +67,7 @@ export default class SplitDelta extends Delta {
 	}
 
 	static get _priority() {
-		return 10;
+		return 5;
 	}
 }
 
@@ -108,6 +108,7 @@ register( 'split', function( position ) {
 		Position.createFromParentAndOffset( copy, 0 ),
 		this.doc.version
 	);
+	move.isSticky = true;
 
 	delta.addOperation( move );
 	this.doc.applyOperation( move );

+ 3 - 2
packages/ckeditor5-engine/src/treemodel/delta/unwrapdelta.js

@@ -77,14 +77,15 @@ register( 'unwrap', function( element ) {
 	let sourcePosition = Position.createFromParentAndOffset( element, 0 );
 
 	const move = new MoveOperation( sourcePosition, element.getChildCount(), Position.createBefore( element ), this.doc.version );
-	this.doc.applyOperation( move );
+	move.isSticky = true;
 	delta.addOperation( move );
+	this.doc.applyOperation( move );
 
 	// Computing new position because we moved some nodes before `element`.
 	// If we would cache `Position.createBefore( element )` we remove wrong node.
 	const remove = new RemoveOperation( Position.createBefore( element ), 1, this.doc.version );
-	this.doc.applyOperation( remove );
 	delta.addOperation( remove );
+	this.doc.applyOperation( remove );
 
 	this.addDelta( delta );
 

+ 1 - 1
packages/ckeditor5-engine/src/treemodel/delta/weakinsertdelta.js

@@ -45,8 +45,8 @@ register( 'weakInsert', function( position, nodes ) {
 	}
 
 	const operation = new InsertOperation( position, nodes, this.doc.version );
-	this.doc.applyOperation( operation );
 	delta.addOperation( operation );
+	this.doc.applyOperation( operation );
 
 	this.addDelta( delta );
 

+ 23 - 2
packages/ckeditor5-engine/src/treemodel/delta/wrapdelta.js

@@ -34,6 +34,27 @@ export default class WrapDelta extends Delta {
 		return moveOp ? Range.createFromPositionAndShift( moveOp.sourcePosition, moveOp.howMany ) : null;
 	}
 
+	/**
+	 * How many nodes is wrapped by the delta or `null` if there are no operations in delta.
+	 *
+	 * @type {Number}
+	 */
+	get howMany() {
+		let range = this.range;
+
+		return range ? range.end.offset - range.start.offset : 0;
+	}
+
+	/**
+	 * Operation that inserts wrapping element or `null` if there are no operations in the delta.
+	 *
+	 * @protected
+	 * @type {core.treeModel.operation.InsertOperation|core.treeModel.operation.ReinsertOperation}
+	 */
+	get _insertOperation() {
+		return this.operations[ 0 ] || null;
+	}
+
 	/**
 	 * Operation that moves wrapped nodes to their new parent or `null` if there are no operations in the delta.
 	 *
@@ -100,13 +121,13 @@ register( 'wrap', function( range, elementOrString ) {
 	const delta = new WrapDelta();
 
 	let insert = new InsertOperation( range.end, element, this.doc.version );
-	this.doc.applyOperation( insert );
 	delta.addOperation( insert );
+	this.doc.applyOperation( insert );
 
 	let targetPosition = Position.createFromParentAndOffset( element, 0 );
 	let move = new MoveOperation( range.start, range.end.offset - range.start.offset, targetPosition, this.doc.version );
-	this.doc.applyOperation( move );
 	delta.addOperation( move );
+	this.doc.applyOperation( move );
 
 	this.addDelta( delta );
 

+ 13 - 3
packages/ckeditor5-engine/src/treemodel/document.js

@@ -11,6 +11,7 @@ import transformations from './delta/basic-transformations.js'; // jshint ignore
 
 import RootElement from './rootelement.js';
 import Batch from './batch.js';
+import History from './history.js';
 import Selection from './selection.js';
 import EmitterMixin from '../../utils/emittermixin.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
@@ -93,6 +94,14 @@ export default class Document {
 
 		// Graveyard tree root. Document always have a graveyard root, which stores removed nodes.
 		this.createRoot( graveyardSymbol );
+
+		/**
+		 * Document's history.
+		 *
+		 * @readonly
+		 * @member {core.treeModel.History} core.treeModel.Document#history
+		 */
+		this.history = new History();
 	}
 
 	/**
@@ -120,8 +129,7 @@ export default class Document {
 	 * {@link core.treeModel.operation.Operation operations}. To create operations in the simple way use the
 	 * {@link core.treeModel.Batch} API available via {@link core.treeModel.Document#batch} method.
 	 *
-	 * This method calls {@link core.treeModel.Document#change} event.
-	 *
+	 * @fires @link core.treeModel.Document#change
 	 * @param {core.treeModel.operation.Operation} operation Operation to be applied.
 	 */
 	applyOperation( operation ) {
@@ -141,6 +149,8 @@ export default class Document {
 
 		this.version++;
 
+		this.history.addOperation( operation );
+
 		const batch = operation.delta && operation.delta.batch;
 		this.fire( 'change', operation.type, changes, batch );
 	}
@@ -198,7 +208,7 @@ export default class Document {
 	 *
 	 * When all queued changes are done {@link core.treeModel.Document#changesDone} event is fired.
 	 *
-	 * @fires {@link core.treeModel.Document#changesDone}
+	 * @fires @link core.treeModel.Document#changesDone
 	 * @param {Function} callback Callback to enqueue.
 	 */
 	enqueueChanges( callback ) {

+ 126 - 0
packages/ckeditor5-engine/src/treemodel/history.js

@@ -0,0 +1,126 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+// Load all basic deltas and transformations, they register themselves, but they need to be imported somewhere.
+import deltas from './delta/basic-deltas.js';
+import transformations from './delta/basic-transformations.js';
+/*jshint unused: false*/
+
+import transform from './delta/transform.js';
+import CKEditorError from '../../utils/ckeditorerror.js';
+
+/**
+ * History keeps the track of all the deltas applied to the {@link core.treeModel.Document document} and provides
+ * utility tools to operate on the history. Most of times history is needed to transform a delta that has wrong
+ * {@link core.treeModel.delta.Delta#baseVersion} to a state where it can be applied to the document.
+ *
+ * @memberOf core.treeModel
+ */
+export default class History {
+	/**
+	 * Creates an empty History instance.
+	 */
+	constructor() {
+		/**
+		 * Deltas added to the history.
+		 *
+		 * @private
+		 * @member {Array.<core.treeModel.delta.Delta>} core.treeModel.History#_deltas
+		 */
+		this._deltas = [];
+
+		/**
+		 * Helper structure that maps added delta's base version to the index in {@link core.treeModel.History#_deltas}
+		 * at which the delta was added.
+		 *
+		 * @private
+		 * @member {Map} core.treeModel.History#_historyPoints
+		 */
+		this._historyPoints = new Map();
+	}
+
+	/**
+	 * Gets the number of base version which an up-to-date operation should have.
+	 *
+	 * @private
+	 * @type {Number}
+	 */
+	get _nextHistoryPoint() {
+		const lastDelta = this._deltas[ this._deltas.length - 1 ];
+
+		return lastDelta.baseVersion + lastDelta.operations.length;
+	}
+
+	/**
+	 * Adds an operation to the history.
+	 *
+	 * @param {core.treeModel.operation.Operation} operation Operation to add.
+	 */
+	addOperation( operation ) {
+		const delta = operation.delta;
+
+		// History cares about deltas not singular operations.
+		// Operations from a delta are added one by one, from first to last.
+		// Operations from one delta cannot be mixed with operations from other deltas.
+		// This all leads us to the conclusion that we could just save deltas history.
+		// What is more, we need to check only the last position in history to check if delta is already in the history.
+		if ( delta && this._deltas[ this._deltas.length - 1 ] !== delta ) {
+			const index = this._deltas.length;
+
+			this._deltas[ index ] = delta;
+			this._historyPoints.set( delta.baseVersion, index );
+		}
+	}
+
+	/**
+	 * Transforms out-dated delta by all deltas that were added to the history since the given delta's base version. In other
+	 * words, it makes the delta up-to-date with the history. The transformed delta(s) is (are) ready to be applied
+	 * to the {@link core.treeModel.Document document}.
+	 *
+	 * @param {core.treeModel.delta.Delta} delta Delta to update.
+	 * @returns {Array.<core.treeModel.delta.Delta>} Result of transformation which is an array containing one or more deltas.
+	 */
+	getTransformedDelta( delta ) {
+		if ( delta.baseVersion === this._nextHistoryPoint ) {
+			return [ delta ];
+		}
+
+		let index = this._historyPoints.get( delta.baseVersion );
+
+		if ( index === undefined ) {
+			throw new CKEditorError( 'history-wrong-version: Cannot retrieve point in history that is a base for given delta.' );
+		}
+
+		let transformed = [ delta ];
+
+		while ( index < this._deltas.length ) {
+			const historyDelta = this._deltas[ index ];
+			let allResults = [];
+
+			for ( let deltaToTransform of transformed ) {
+				const transformedDelta = History._transform( deltaToTransform, historyDelta );
+				allResults = allResults.concat( transformedDelta );
+			}
+
+			transformed = allResults;
+			index++;
+		}
+
+		return transformed;
+	}
+
+	/**
+	 * Transforms given delta by another given delta. Exposed for testing purposes.
+	 *
+	 * @protected
+	 * @param {core.treeModel.delta.Delta} toTransform Delta to be transformed.
+	 * @param {core.treeModel.delta.Delta} transformBy Delta to transform by.
+	 */
+	static _transform( toTransform, transformBy ) {
+		return transform( toTransform, transformBy, false );
+	}
+}

+ 19 - 16
packages/ckeditor5-engine/src/treemodel/operation/moveoperation.js

@@ -49,30 +49,30 @@ export default class MoveOperation extends Operation {
 		 * @member {core.treeModel.Position} core.treeModel.operation.MoveOperation#targetPosition
 		 */
 		this.targetPosition = Position.createFromPosition( targetPosition );
+
+		/**
+		 * Defines whether `MoveOperation` is sticky. If `MoveOperation` is sticky, during
+		 * {@link core.treeModel.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
+		 * get their insertion path updated to the position where this `MoveOperation` moves the range.
+		 *
+		 * @type {Boolean}
+		 */
+		this.isSticky = false;
 	}
 
 	get type() {
 		return 'move';
 	}
 
-	/**
-	 * Defines whether `MoveOperation` is sticky. If `MoveOperation` is sticky, during
-	 * {@link core.treeModel.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
-	 * get their insertion path updated to the position where this `MoveOperation` moves the range.
-	 *
-	 * @protected
-	 * @type {Boolean}
-	 */
-	get isSticky() {
-		return true;
-	}
-
 	/**
 	 * @returns {core.treeModel.operation.MoveOperation}
 	 */
 	clone() {
-		return new this.constructor( this.sourcePosition, this.howMany, this.targetPosition, this.baseVersion );
+		const op = new this.constructor( this.sourcePosition, this.howMany, this.targetPosition, this.baseVersion );
+		op.isSticky = this.isSticky;
+
+		return op;
 	}
 
 	/**
@@ -82,7 +82,10 @@ export default class MoveOperation extends Operation {
 		let newSourcePosition = this.targetPosition.getTransformedByDeletion( this.sourcePosition, this.howMany );
 		let newTargetPosition = this.sourcePosition.getTransformedByInsertion( this.targetPosition, this.howMany );
 
-		return new this.constructor( newSourcePosition, this.howMany, newTargetPosition, this.baseVersion + 1 );
+		const op = new this.constructor( newSourcePosition, this.howMany, newTargetPosition, this.baseVersion + 1 );
+		op.isSticky = this.isSticky;
+
+		return op;
 	}
 
 	_execute() {
@@ -112,7 +115,7 @@ export default class MoveOperation extends Operation {
 			throw new CKEditorError(
 				'operation-move-nodes-do-not-exist: The nodes which should be moved do not exist.'
 			);
-		} else if ( sourceElement === targetElement && sourceOffset <= targetOffset && targetOffset < sourceOffset + this.howMany ) {
+		} else if ( sourceElement === targetElement && sourceOffset < targetOffset && targetOffset < sourceOffset + this.howMany ) {
 			/**
 			 * Trying to move a range of nodes into the middle of that range.
 			 *

+ 14 - 5
packages/ckeditor5-engine/src/treemodel/operation/reinsertoperation.js

@@ -22,17 +22,26 @@ import RemoveOperation from './removeoperation.js';
  */
 export default class ReinsertOperation extends MoveOperation {
 	/**
-	 * @returns {core.treeModel.operation.RemoveOperation}
+	 * Position where re-inserted node will be inserted.
+	 *
+	 * @type {core.treeModel.Position}
 	 */
-	getReversed() {
-		return new RemoveOperation( this.targetPosition, this.howMany, this.baseVersion + 1 );
+	get position() {
+		return this.targetPosition;
+	}
+
+	set position( pos ) {
+		this.targetPosition = pos;
 	}
 
 	get type() {
 		return 'reinsert';
 	}
 
-	get isSticky() {
-		return false;
+	/**
+	 * @returns {core.treeModel.operation.RemoveOperation}
+	 */
+	getReversed() {
+		return new RemoveOperation( this.targetPosition, this.howMany, this.baseVersion + 1 );
 	}
 }

+ 0 - 4
packages/ckeditor5-engine/src/treemodel/operation/removeoperation.js

@@ -35,10 +35,6 @@ export default class RemoveOperation extends MoveOperation {
 		return 'remove';
 	}
 
-	get isSticky() {
-		return false;
-	}
-
 	/**
 	 * @returns {core.treeModel.operation.ReinsertOperation}
 	 */

+ 11 - 11
packages/ckeditor5-engine/src/treemodel/operation/transform.js

@@ -252,8 +252,6 @@ const ot = {
 				isStrong = false;
 			}
 
-			let isSticky = a.isSticky && b.isSticky;
-
 			// Create ranges from MoveOperations properties.
 			const rangeA = Range.createFromPositionAndShift( a.sourcePosition, a.howMany );
 			const rangeB = Range.createFromPositionAndShift( b.sourcePosition, b.howMany );
@@ -268,8 +266,8 @@ const ot = {
 			let difference = joinRanges( rangeA.getDifference( rangeB ) );
 
 			if ( difference ) {
-				difference.start = difference.start.getTransformedByMove( b.sourcePosition, moveTargetPosition, b.howMany, !isSticky, false );
-				difference.end = difference.end.getTransformedByMove( b.sourcePosition, moveTargetPosition, b.howMany, isSticky, false );
+				difference.start = difference.start.getTransformedByMove( b.sourcePosition, moveTargetPosition, b.howMany, !a.isSticky, false );
+				difference.end = difference.end.getTransformedByMove( b.sourcePosition, moveTargetPosition, b.howMany, a.isSticky, false );
 
 				ranges.push( difference );
 			}
@@ -281,14 +279,16 @@ const ot = {
 			// * on the same tree level - it means that we move the same nodes into different places
 			// * on deeper tree level - it means that we move nodes that are inside moved nodes
 			// The operations are conflicting only if they try to move exactly same nodes, so only in the first case.
-			// So, we will handle common range if it is "deeper" or if transformed operation is more important.
-			let isDeeper = utils.compareArrays( b.sourcePosition.getParentPath(), a.sourcePosition.getParentPath() ) == 'PREFIX';
+			// That means that we transform common part in two cases:
+			// * `rangeA` is "deeper" than `rangeB` so it does not collide
+			// * `rangeA` is at the same level but is stronger than `rangeB`.
+			let aCompB = utils.compareArrays( a.sourcePosition.getParentPath(), b.sourcePosition.getParentPath() );
 
 			// If the `b` MoveOperation points inside the `a` MoveOperation range, the common part will be included in
 			// range(s) that (is) are results of processing `difference`. If that's the case, we cannot include it again.
-			let bIsIncluded = rangeA.containsPosition( b.targetPosition ) ||
-				( rangeA.start.isEqual( b.targetPosition ) && isSticky ) ||
-				( rangeA.end.isEqual( b.targetPosition ) && isSticky );
+			let bTargetsToA = rangeA.containsPosition( b.targetPosition ) ||
+				( rangeA.start.isEqual( b.targetPosition ) && a.isSticky ) ||
+				( rangeA.end.isEqual( b.targetPosition ) && a.isSticky );
 
 			// If the `b` MoveOperation range contains both whole `a` range and target position we do an exception and
 			// transform `a` operation. Normally, when same nodes are moved, we stick with stronger operation's target.
@@ -296,7 +296,7 @@ const ot = {
 			// smaller range will be moved to larger range target. The effect of this transformation feels natural.
 			let aIsInside = rangeB.containsRange( rangeA ) && rangeB.containsPosition( a.targetPosition );
 
-			if ( common !== null && ( isDeeper || isStrong || aIsInside ) && !bIsIncluded ) {
+			if ( common !== null && ( aCompB === 'EXTENSION' || ( aCompB === 'SAME' && isStrong ) || aIsInside ) && !bTargetsToA ) {
 				// 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.
@@ -318,7 +318,7 @@ const ot = {
 			}
 
 			// Target position also could be affected by the other MoveOperation. We will transform it.
-			let newTargetPosition = a.targetPosition.getTransformedByMove( b.sourcePosition, moveTargetPosition, b.howMany, !isStrong, isSticky );
+			let newTargetPosition = a.targetPosition.getTransformedByMove( b.sourcePosition, moveTargetPosition, b.howMany, !isStrong, b.isSticky );
 
 			// Map transformed range(s) to operations and return them.
 			return ranges.reverse().map( ( range ) => {

+ 27 - 32
packages/ckeditor5-engine/tests/treemodel/batch.js

@@ -10,60 +10,41 @@
 /* jshint unused: false */
 import deltas from '/ckeditor5/core/treemodel/delta/basic-deltas.js';
 
+import Document from '/ckeditor5/core/treemodel/document.js';
 import Batch from '/ckeditor5/core/treemodel/batch.js';
 import { register } from '/ckeditor5/core/treemodel/batch.js';
 import Delta from '/ckeditor5/core/treemodel/delta/delta.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 
+class TestDelta extends Delta {
+	constructor( batch ) {
+		super( batch, [] );
+	}
+}
+
 describe( 'Batch', () => {
 	it( 'should have registered basic methods', () => {
-		const batch = new Batch();
+		const batch = new Batch( new Document() );
 
 		expect( batch.setAttr ).to.be.a( 'function' );
 		expect( batch.removeAttr ).to.be.a( 'function' );
 	} );
 
 	describe( 'register', () => {
-		let TestDelta;
-
-		before( () => {
-			TestDelta = class extends Delta {
-				constructor( batch ) {
-					super( batch, [] );
-				}
-			};
-		} );
-
 		afterEach( () => {
 			delete Batch.prototype.foo;
 		} );
 
-		it( 'should register function which return an delta', () => {
-			register( 'foo', function() {
-				this.addDelta( new TestDelta() );
-			} );
-
-			const batch = new Batch();
-
-			batch.foo();
-
-			expect( batch.deltas.length ).to.equal( 1 );
-			expect( batch.deltas[ 0 ] ).to.be.instanceof( TestDelta );
-		} );
+		it( 'should register function to the batch prototype', () => {
+			const spy = sinon.spy();
 
-		it( 'should register function which return an multiple deltas', () => {
-			register( 'foo', function() {
-				this.addDelta( new TestDelta() );
-				this.addDelta( new TestDelta() );
-			} );
+			register( 'foo', spy );
 
-			const batch = new Batch();
+			const batch = new Batch( new Document() );
 
 			batch.foo();
 
-			expect( batch.deltas.length ).to.equal( 2 );
-			expect( batch.deltas[ 0 ] ).to.be.instanceof( TestDelta );
-			expect( batch.deltas[ 1 ] ).to.be.instanceof( TestDelta );
+			expect( spy.calledOnce ).to.be.true;
 		} );
 
 		it( 'should throw if one try to register the same batch twice', () => {
@@ -74,4 +55,18 @@ describe( 'Batch', () => {
 			} ).to.throw( CKEditorError, /^batch-register-taken/ );
 		} );
 	} );
+
+	describe( 'addDelta', () => {
+		it( 'should add delta to the batch', () => {
+			const batch = new Batch( new Document() );
+			const deltaA = new Delta();
+			const deltaB = new Delta();
+			batch.addDelta( deltaA );
+			batch.addDelta( deltaB );
+
+			expect( batch.deltas.length ).to.equal( 2 );
+			expect( batch.deltas[ 0 ] ).to.equal( deltaA );
+			expect( batch.deltas[ 1 ] ).to.equal( deltaB );
+		} );
+	} );
 } );

+ 10 - 2
packages/ckeditor5-engine/tests/treemodel/delta/transform/_utils/utils.js

@@ -59,7 +59,10 @@ export function getMergeDelta( position, howManyInPrev, howManyInNext, version )
 	targetPosition.offset--;
 	targetPosition.path.push( howManyInPrev );
 
-	delta.addOperation( new MoveOperation( sourcePosition, howManyInNext, targetPosition, version ) );
+	let move = new MoveOperation( sourcePosition, howManyInNext, targetPosition, version );
+	move.isSticky = true;
+
+	delta.addOperation( move );
 	delta.addOperation( new RemoveOperation( position, 1, version + 1 ) );
 
 	return delta;
@@ -94,7 +97,11 @@ export function getSplitDelta( position, nodeCopy, howManyMove, version ) {
 	targetPosition.path.push( 0 );
 
 	delta.addOperation( new InsertOperation( insertPosition, [ nodeCopy ], version ) );
-	delta.addOperation( new MoveOperation( position, howManyMove, targetPosition, version + 1 ) );
+
+	let move = new MoveOperation( position, howManyMove, targetPosition, version + 1 );
+	move.isSticky = true;
+
+	delta.addOperation( move );
 
 	return delta;
 }
@@ -121,6 +128,7 @@ export function getUnwrapDelta( positionBefore, howManyChildren, version ) {
 	sourcePosition.path.push( 0 );
 
 	let move = new MoveOperation( sourcePosition, howManyChildren, positionBefore, version );
+	move.isSticky = true;
 
 	let removePosition = Position.createFromPosition( positionBefore );
 	removePosition.offset += howManyChildren;

+ 14 - 2
packages/ckeditor5-engine/tests/treemodel/delta/transform/delta.js

@@ -21,7 +21,7 @@ import {
 	getFilledDocument,
 } from '/tests/core/treemodel/delta/transform/_utils/utils.js';
 
-describe( 'transform', () => {
+describe( 'Delta', () => {
 	let doc, root, baseVersion;
 
 	beforeEach( () => {
@@ -30,7 +30,19 @@ describe( 'transform', () => {
 		baseVersion = doc.version;
 	} );
 
-	it( 'should transform delta by transforming it\'s operations', () => {
+	it( 'should have baseVersion property, equal to the baseVersion of first operation in Delta or null', () => {
+		let deltaA = new Delta();
+
+		expect( deltaA.baseVersion ).to.be.null;
+
+		let version = 5;
+
+		deltaA.addOperation( new MoveOperation( new Position( root, [ 1, 2, 3 ] ), 4, new Position( root, [ 4, 0 ] ), version ) );
+
+		expect( deltaA.baseVersion ).to.equal( 5 );
+	} );
+
+	it( 'should be transformable by another Delta', () => {
 		let deltaA = new Delta();
 		let deltaB = new Delta();
 

+ 40 - 1
packages/ckeditor5-engine/tests/treemodel/delta/transform/splitdelta.js

@@ -273,12 +273,51 @@ describe( 'transform', () => {
 				expect( nodesAndText ).to.equal( 'PaEbcfoEobarxyzP' );
 			} );
 
+			it( 'split position is before wrapped nodes', () => {
+				let wrapRange = new Range( new Position( root, [ 3, 3, 3, 5 ] ), new Position( root, [ 3, 3, 3, 7 ] ) );
+				let wrapElement = new Element( 'E' );
+				let wrapDelta = getWrapDelta( wrapRange, wrapElement, baseVersion );
+
+				let transformed = transform( splitDelta, wrapDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = wrapDelta.operations.length;
+
+				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: 8,
+							targetPosition: new Position( root, [ 3, 3, 4, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( wrapDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 3 ] ), 2 ) );
+
+				// WrapDelta and SplitDelta are correctly applied.
+				expect( nodesAndText ).to.equal( 'PabcPPfoEobEarxyzP' );
+			} );
+
 			it( 'split position is inside wrapped node', () => {
 				let wrapRange = new Range( new Position( root, [ 3, 3, 2 ] ), new Position( root, [ 3, 3, 4 ] ) );
 				let wrapElement = new Element( 'E' );
 				let wrapDelta = getWrapDelta( wrapRange, wrapElement, baseVersion );
 
-				let transformed = transform( splitDelta, wrapDelta, true );
+				let transformed = transform( splitDelta, wrapDelta );
 
 				expect( transformed.length ).to.equal( 1 );
 

+ 45 - 1
packages/ckeditor5-engine/tests/treemodel/delta/transform/wrapdelta.js

@@ -117,7 +117,7 @@ describe( 'transform', () => {
 				let splitPosition = new Position( root, [ 3, 3, 3, 1 ] );
 				let splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 11, baseVersion );
 
-				let transformed = transform( wrapDelta, splitDelta, true );
+				let transformed = transform( wrapDelta, splitDelta );
 
 				expect( transformed.length ).to.equal( 1 );
 
@@ -150,6 +150,50 @@ describe( 'transform', () => {
 				// WrapDelta and SplitDelta are correctly applied.
 				expect( nodesAndText ).to.equal( 'PaPPEbcfoEobarxyzP' );
 			} );
+
+			it( 'split position is inside wrapped node', () => {
+				// For this case, we need different WrapDelta so it is overwritten.
+				let wrapRange = new Range( new Position( root, [ 3, 3, 2 ] ), new Position( root, [ 3, 3, 4 ] ) );
+				let wrapElement = new Element( 'E' );
+
+				wrapDelta = getWrapDelta( wrapRange, wrapElement, baseVersion );
+
+				let splitPosition = new Position( root, [ 3, 3, 3, 3 ] );
+				let splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 9, baseVersion );
+
+				let transformed = transform( wrapDelta, splitDelta );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = wrapDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: WrapDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 5 ] ),
+							baseVersion: baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 2 ] ),
+							howMany: 3,
+							targetPosition: new Position( root, [ 3, 3, 5, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+
+				// Test if deltas do what they should after applying transformed delta.
+				applyDelta( splitDelta, doc );
+				applyDelta( transformed[ 0 ], doc );
+
+				let nodesAndText = getNodesAndText( Range.createFromPositionAndShift( new Position( root, [ 3, 3, 2 ] ), 1 ) );
+
+				// WrapDelta and SplitDelta are correctly applied.
+				expect( nodesAndText ).to.equal( 'EXabcdXPabcPPfoobarxyzPE' );
+			} );
 		} );
 	} );
 } );

+ 30 - 0
packages/ckeditor5-engine/tests/treemodel/delta/wrapdelta.js

@@ -124,6 +124,21 @@ describe( 'WrapDelta', () => {
 		} );
 	} );
 
+	describe( 'howMany', () => {
+		it( 'should be equal to 0 if there are no operations in delta', () => {
+			expect( wrapDelta.howMany ).to.equal( 0 );
+		} );
+
+		it( 'should be equal to the number of wrapped elements', () => {
+			let howMany = 5;
+
+			wrapDelta.operations.push( new InsertOperation( new Position( root, [ 1, 6 ] ), 1 ) );
+			wrapDelta.operations.push( new MoveOperation( new Position( root, [ 1, 1 ] ), howMany, new Position( root, [ 1, 6, 0 ] ) ) );
+
+			expect( wrapDelta.howMany ).to.equal( 5 );
+		} );
+	} );
+
 	describe( 'getReversed', () => {
 		it( 'should return empty UnwrapDelta if there are no operations in delta', () => {
 			let reversed = wrapDelta.getReversed();
@@ -151,5 +166,20 @@ describe( 'WrapDelta', () => {
 			expect( reversed.operations[ 1 ].howMany ).to.equal( 1 );
 		} );
 	} );
+
+	describe( '_insertOperation', () => {
+		it( 'should be null if there are no operations in the delta', () => {
+			expect( wrapDelta._insertOperation ).to.be.null;
+		} );
+
+		it( 'should be equal to the first operation in the delta', () => {
+			let insertOperation = new InsertOperation( new Position( root, [ 1, 6 ] ), 1 );
+
+			wrapDelta.operations.push( insertOperation );
+			wrapDelta.operations.push( new MoveOperation( new Position( root, [ 1, 1 ] ), 5, new Position( root, [ 1, 6, 0 ] ) ) );
+
+			expect( wrapDelta._insertOperation ).to.equal( insertOperation );
+		} );
+	} );
 } );
 

+ 154 - 0
packages/ckeditor5-engine/tests/treemodel/history.js

@@ -0,0 +1,154 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import History from '/ckeditor5/core/treemodel/history.js';
+import Delta from '/ckeditor5/core/treemodel/delta/delta.js';
+import NoOperation from '/ckeditor5/core/treemodel/operation/nooperation.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+
+describe( 'History', () => {
+	let history;
+
+	beforeEach( () => {
+		history = new History();
+	} );
+
+	describe( 'constructor', () => {
+		it( 'should create an empty History instance', () => {
+			expect( history._deltas.length ).to.equal( 0 );
+			expect( history._historyPoints.size ).to.equal( 0 );
+		} );
+	} );
+
+	describe( 'addOperation', () => {
+		it( 'should save delta containing passed operation in the history', () => {
+			let delta = new Delta();
+			let operation = new NoOperation( 0 );
+
+			delta.addOperation( operation );
+			history.addOperation( operation );
+
+			expect( history._deltas.length ).to.equal( 1 );
+			expect( history._deltas[ 0 ] ).to.equal( delta );
+		} );
+
+		it( 'should save each delta only once', () => {
+			let delta = new Delta();
+
+			delta.addOperation( new NoOperation( 0 ) );
+			delta.addOperation( new NoOperation( 1 ) );
+			delta.addOperation( new NoOperation( 2 ) );
+
+			for ( let operation of delta.operations ) {
+				history.addOperation( operation );
+			}
+
+			expect( history._deltas.length ).to.equal( 1 );
+			expect( history._deltas[ 0 ] ).to.equal( delta );
+		} );
+
+		it( 'should save multiple deltas and keep their order', () => {
+			let deltaA = new Delta();
+			let deltaB = new Delta();
+			let deltaC = new Delta();
+
+			let deltas = [ deltaA, deltaB, deltaC ];
+
+			let i = 0;
+
+			for ( let delta of deltas ) {
+				delta.addOperation( new NoOperation( i++ ) );
+				delta.addOperation( new NoOperation( i++ ) );
+			}
+
+			for ( let delta of deltas ) {
+				for ( let operation of delta.operations ) {
+					history.addOperation( operation );
+				}
+			}
+
+			expect( history._deltas.length ).to.equal( 3 );
+			expect( history._deltas[ 0 ] ).to.equal( deltaA );
+			expect( history._deltas[ 1 ] ).to.equal( deltaB );
+			expect( history._deltas[ 2 ] ).to.equal( deltaC );
+		} );
+	} );
+
+	describe( 'getTransformedDelta', () => {
+		it( 'should transform given delta by deltas from history which were applied since the baseVersion of given delta', () => {
+			sinon.spy( History, '_transform' );
+
+			let deltaA = new Delta();
+			deltaA.addOperation( new NoOperation( 0 ) );
+
+			let deltaB = new Delta();
+			deltaB.addOperation( new NoOperation( 1 ) );
+
+			let deltaC = new Delta();
+			deltaC.addOperation( new NoOperation( 2 ) );
+
+			let deltaD = new Delta();
+			deltaD.addOperation( new NoOperation( 3 ) );
+
+			let deltaX = new Delta();
+			deltaX.addOperation( new NoOperation( 1 ) );
+
+			history.addOperation( deltaA.operations[ 0 ] );
+			history.addOperation( deltaB.operations[ 0 ] );
+			history.addOperation( deltaC.operations[ 0 ] );
+			history.addOperation( deltaD.operations[ 0 ] );
+
+			// `deltaX` bases on the same history point as `deltaB` -- so it already acknowledges `deltaA` existence.
+			// It should be transformed by `deltaB` and all following deltas (`deltaC` and `deltaD`).
+			history.getTransformedDelta( deltaX );
+
+			// `deltaX` was not transformed by `deltaA`.
+			expect( History._transform.calledWithExactly( deltaX, deltaA ) ).to.be.false;
+
+			expect( History._transform.calledWithExactly( deltaX, deltaB ) ).to.be.true;
+			// We can't do exact call matching because after first transformation, what we are further transforming
+			// is no longer `deltaX` but a result of transforming `deltaX` and `deltaB`.
+			expect( History._transform.calledWithExactly( sinon.match.instanceOf( Delta ), deltaC ) ).to.be.true;
+			expect( History._transform.calledWithExactly( sinon.match.instanceOf( Delta ), deltaD ) ).to.be.true;
+		} );
+
+		it( 'should not transform given delta if it bases on current version of history', () => {
+			let deltaA = new Delta();
+			deltaA.addOperation( new NoOperation( 0 ) );
+
+			let deltaB = new Delta();
+			let opB = new NoOperation( 1 );
+			deltaB.addOperation( opB );
+
+			history.addOperation( deltaA.operations[ 0 ] );
+
+			let result = history.getTransformedDelta( deltaB );
+
+			expect( result.length ).to.equal( 1 );
+			expect( result[ 0 ] ).to.equal( deltaB );
+			expect( result[ 0 ].operations[ 0 ] ).to.equal( opB );
+		} );
+
+		it( 'should throw if given delta bases on an incorrect version of history', () => {
+			let deltaA = new Delta();
+			deltaA.addOperation( new NoOperation( 0 ) );
+			deltaA.addOperation( new NoOperation( 1 ) );
+
+			history.addOperation( deltaA.operations[ 0 ] );
+			history.addOperation( deltaA.operations[ 1 ] );
+
+			let deltaB = new Delta();
+			// Wrong base version - should be either 0 or 2, operation can't be based on an operation that is
+			// in the middle of other delta, because deltas are atomic, not dividable structures.
+			deltaB.addOperation( new NoOperation( 1 ) );
+
+			expect( () => {
+				history.getTransformedDelta( deltaB );
+			} ).to.throw( CKEditorError, /history-wrong-version/ );
+		} );
+	} );
+} );

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

@@ -40,7 +40,7 @@ describe( 'MoveOperation', () => {
 			doc.version
 		);
 
-		expect( op.isSticky ).to.be.true;
+		expect( op.isSticky ).to.be.false;
 	} );
 
 	it( 'should move from one node to another', () => {

+ 8 - 0
packages/ckeditor5-engine/tests/treemodel/operation/reinsertoperation.js

@@ -32,6 +32,14 @@ describe( 'ReinsertOperation', () => {
 		);
 	} );
 
+	it( 'should have position property equal to the position where node will be reinserted', () => {
+		expect( operation.position.isEqual( rootPosition ) ).to.be.true;
+
+		// Setting also works:
+		operation.position = new Position( root, [ 1 ] );
+		expect( operation.position.isEqual( new Position( root, [ 1 ] ) ) ).to.be.true;
+	} );
+
 	it( 'should have proper type', () => {
 		expect( operation.type ).to.equal( 'reinsert' );
 	} );

+ 53 - 3
packages/ckeditor5-engine/tests/treemodel/operation/transform.js

@@ -1713,7 +1713,7 @@ describe( 'transform', () => {
 				expectOperation( transOp[ 0 ], expected );
 			} );
 
-			it( 'target at offset same as range end boundary: expand range', () => {
+			it( 'target at offset same as range end boundary: no operation update', () => {
 				let transformBy = new InsertOperation(
 					new Position( root, [ 2, 2, 6 ] ),
 					[ nodeA, nodeB ],
@@ -1722,6 +1722,21 @@ describe( 'transform', () => {
 
 				let transOp = transform( op, transformBy );
 
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target at offset same as range end boundary (sticky move): expand range', () => {
+				let transformBy = new InsertOperation(
+					new Position( root, [ 2, 2, 6 ] ),
+					[ nodeA, nodeB ],
+					baseVersion
+				);
+
+				op.isSticky = true;
+
+				let transOp = transform( op, transformBy );
+
 				expected.howMany = 4;
 
 				expect( transOp.length ).to.equal( 1 );
@@ -2035,7 +2050,7 @@ describe( 'transform', () => {
 				expectOperation( transOp[ 0 ], expected );
 			} );
 
-			it( 'target at start boundary of transforming move range: expand move range', () => {
+			it( 'target at start boundary of transforming move range: increment source offset', () => {
 				let transformBy = new MoveOperation(
 					new Position( root, [ 4, 1, 0 ] ),
 					2,
@@ -2047,12 +2062,31 @@ describe( 'transform', () => {
 
 				expect( transOp.length ).to.equal( 1 );
 
+				expected.sourcePosition.offset = 6;
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target at start boundary of transforming move range (sticky move): expand move range', () => {
+				let transformBy = new MoveOperation(
+					new Position( root, [ 4, 1, 0 ] ),
+					2,
+					new Position( root, [ 2, 2, 4 ] ),
+					baseVersion
+				);
+
+				op.isSticky = true;
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+
 				expected.howMany = 4;
 
 				expectOperation( transOp[ 0 ], expected );
 			} );
 
-			it( 'target at end boundary of transforming move range: expand move range', () => {
+			it( 'target at end boundary of transforming move range: no operation update', () => {
 				let transformBy = new MoveOperation(
 					new Position( root, [ 4, 1, 0 ] ),
 					2,
@@ -2062,6 +2096,22 @@ describe( 'transform', () => {
 
 				let transOp = transform( op, transformBy );
 
+				expect( transOp.length ).to.equal( 1 );
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'target at end boundary of transforming move range (sticky move): expand move range', () => {
+				let transformBy = new MoveOperation(
+					new Position( root, [ 4, 1, 0 ] ),
+					2,
+					new Position( root, [ 2, 2, 6 ] ),
+					baseVersion
+				);
+
+				op.isSticky = true;
+
+				let transOp = transform( op, transformBy );
+
 				expect( transOp.length ).to.equal( 1 );
 
 				expected.howMany = 4;