8
0
Quellcode durchsuchen

Merge branch 'master' into t/120

Piotrek Koszuliński vor 10 Jahren
Ursprung
Commit
0620aba269
47 geänderte Dateien mit 3756 neuen und 471 gelöschten Zeilen
  1. 2 3
      packages/ckeditor5-engine/.jscsrc
  2. 0 4
      packages/ckeditor5-engine/src/config.js
  3. 19 5
      packages/ckeditor5-engine/src/emittermixin.js
  4. 120 0
      packages/ckeditor5-engine/src/treemodel/attributelist.js
  5. 5 1
      packages/ckeditor5-engine/src/treemodel/batch.js
  6. 63 0
      packages/ckeditor5-engine/src/treemodel/delta/movedelta.js
  7. 23 13
      packages/ckeditor5-engine/src/treemodel/delta/removedelta.js
  8. 65 0
      packages/ckeditor5-engine/src/treemodel/delta/unwrapdelta.js
  9. 59 0
      packages/ckeditor5-engine/src/treemodel/delta/weakinsertdelta.js
  10. 82 0
      packages/ckeditor5-engine/src/treemodel/delta/wrapdelta.js
  11. 13 4
      packages/ckeditor5-engine/src/treemodel/document.js
  12. 188 0
      packages/ckeditor5-engine/src/treemodel/liveposition.js
  13. 141 0
      packages/ckeditor5-engine/src/treemodel/liverange.js
  14. 26 64
      packages/ckeditor5-engine/src/treemodel/node.js
  15. 6 5
      packages/ckeditor5-engine/src/treemodel/operation/attributeoperation.js
  16. 3 2
      packages/ckeditor5-engine/src/treemodel/operation/insertoperation.js
  17. 8 7
      packages/ckeditor5-engine/src/treemodel/operation/moveoperation.js
  18. 4 0
      packages/ckeditor5-engine/src/treemodel/operation/removeoperation.js
  19. 3 2
      packages/ckeditor5-engine/src/treemodel/operation/transform.js
  20. 84 16
      packages/ckeditor5-engine/src/treemodel/position.js
  21. 154 42
      packages/ckeditor5-engine/src/treemodel/range.js
  22. 248 0
      packages/ckeditor5-engine/src/treemodel/selection.js
  23. 0 82
      packages/ckeditor5-engine/src/treemodel/smartrange.js
  24. 28 0
      packages/ckeditor5-engine/tests/_tools/tools.js
  25. 12 0
      packages/ckeditor5-engine/tests/emittermixin.js
  26. 161 0
      packages/ckeditor5-engine/tests/treemodel/attributelist.js
  27. 2 2
      packages/ckeditor5-engine/tests/treemodel/delta/attributedelta.js
  28. 24 17
      packages/ckeditor5-engine/tests/treemodel/delta/insertdelta.js
  29. 80 0
      packages/ckeditor5-engine/tests/treemodel/delta/movedelta.js
  30. 42 24
      packages/ckeditor5-engine/tests/treemodel/delta/removedelta.js
  31. 67 0
      packages/ckeditor5-engine/tests/treemodel/delta/unwrapdelta.js
  32. 63 0
      packages/ckeditor5-engine/tests/treemodel/delta/weakinsertdelta.js
  33. 103 0
      packages/ckeditor5-engine/tests/treemodel/delta/wrapdelta.js
  34. 2 1
      packages/ckeditor5-engine/tests/treemodel/document/document.js
  35. 377 0
      packages/ckeditor5-engine/tests/treemodel/liveposition.js
  36. 515 0
      packages/ckeditor5-engine/tests/treemodel/liverange.js
  37. 116 114
      packages/ckeditor5-engine/tests/treemodel/node.js
  38. 1 1
      packages/ckeditor5-engine/tests/treemodel/operation/attributeoperation.js
  39. 4 4
      packages/ckeditor5-engine/tests/treemodel/operation/insertoperation.js
  40. 5 5
      packages/ckeditor5-engine/tests/treemodel/operation/moveoperation.js
  41. 1 1
      packages/ckeditor5-engine/tests/treemodel/operation/nooperation.js
  42. 11 1
      packages/ckeditor5-engine/tests/treemodel/operation/reinsertoperation.js
  43. 17 5
      packages/ckeditor5-engine/tests/treemodel/operation/removeoperation.js
  44. 11 15
      packages/ckeditor5-engine/tests/treemodel/operation/transform.js
  45. 91 9
      packages/ckeditor5-engine/tests/treemodel/position.js
  46. 179 22
      packages/ckeditor5-engine/tests/treemodel/range.js
  47. 528 0
      packages/ckeditor5-engine/tests/treemodel/selection.js

+ 2 - 3
packages/ckeditor5-engine/.jscsrc

@@ -58,6 +58,5 @@
 	"disallowNewlineBeforeBlockStatements": true,
 	"validateLineBreaks": "LF",
 	"validateQuoteMarks": "'",
-	"validateIndentation": "\t",
-	"safeContextKeyword": [ "that" ]
-}
+	"validateIndentation": "\t"
+}

+ 0 - 4
packages/ckeditor5-engine/src/config.js

@@ -77,9 +77,7 @@ export default class Config extends Model {
 		}
 
 		// The target for this configuration is, for now, this object.
-		//jscs:disable safeContextKeyword
 		let target = this;
-		//jscs:enable
 
 		// The configuration name should be split into parts if it has dots. E.g: `resize.width`.
 		const parts = name.toLowerCase().split( '.' );
@@ -133,9 +131,7 @@ export default class Config extends Model {
 	 */
 	get( name ) {
 		// The target for this configuration is, for now, this object.
-		//jscs:disable safeContextKeyword
 		let source = this;
-		//jscs:enable
 
 		// The configuration name should be split into parts if it has dots. E.g. `resize.width` -> [`resize`, `width`]
 		const parts = name.toLowerCase().split( '.' );

+ 19 - 5
packages/ckeditor5-engine/src/emittermixin.js

@@ -5,6 +5,13 @@
 
 'use strict';
 
+import EventInfo from './eventinfo.js';
+import utils from './utils.js';
+
+// Saves how many callbacks has been already added. Does not decrement when callback is removed.
+// Used internally as a unique id for a callback.
+let eventsCounter = 0;
+
 /**
  * Mixin that injects the events API into its host.
  *
@@ -12,9 +19,6 @@
  * @singleton
  */
 
-import EventInfo from './eventinfo.js';
-import utils from './utils.js';
-
 const EmitterMixin = {
 	/**
 	 * Registers a callback function to be executed when an event is fired.
@@ -37,7 +41,9 @@ const EmitterMixin = {
 		callback = {
 			callback: callback,
 			ctx: ctx || this,
-			priority: priority
+			priority: priority,
+			// Save counter value as unique id.
+			counter: ++eventsCounter
 		};
 
 		// Add the callback to the list in the right priority position.
@@ -228,7 +234,15 @@ const EmitterMixin = {
 		args = Array.prototype.slice.call( arguments, 1 );
 		args.unshift( eventInfo );
 
+		// Save how many callbacks were added at the moment when the event has been fired.
+		const counter = eventsCounter;
+
 		for ( let i = 0; i < callbacks.length; i++ ) {
+			// Filter out callbacks that have been added after event has been fired.
+			if ( callbacks[ i ].counter > counter ) {
+				continue;
+			}
+
 			callbacks[ i ].callback.apply( callbacks[ i ].ctx, args );
 
 			// Remove the callback from future requests if off() has been called.
@@ -282,4 +296,4 @@ function getCallbacksIfAny( source, event ) {
 	}
 
 	return callbacks;
-}
+}

+ 120 - 0
packages/ckeditor5-engine/src/treemodel/attributelist.js

@@ -0,0 +1,120 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+CKEDITOR.define( [ 'treemodel/attribute' ], ( Attribute ) => {
+	/**
+	 * List of attributes. Used to manage a set of attributes added to and removed from an object containing
+	 * AttributeList.
+	 *
+	 * @class treeModel.AttributeList
+	 */
+	class AttributeList {
+		/**
+		 * Creates a list of attributes.
+		 *
+		 * @param {Iterable.<treeModel.Attribute>} [attrs] Attributes to initialize this list with.
+		 * @constructor
+		 */
+		constructor( attrs ) {
+			/**
+			 * Internal set containing the attributes stored by this list.
+			 *
+			 * @private
+			 * @property {Set.<treeModel.Attribute>} _attrs
+			 */
+
+			this.setAttrsTo( attrs );
+		}
+
+		/**
+		 * Returns value of an attribute with given key or null if there are no attributes with given key.
+		 *
+		 * @param {String} key The attribute key.
+		 * @returns {*|null} Value of found attribute or null if attribute with given key has not been found.
+		 */
+		getAttr( key ) {
+			for ( let attr of this._attrs ) {
+				if ( attr.key == key ) {
+					return attr.value;
+				}
+			}
+
+			return null;
+		}
+
+		/**
+		 * Returns attribute iterator.
+		 *
+		 * @returns {Iterable.<treeModel.Attribute>} Attribute iterator.
+		 */
+		getAttrs() {
+			return this._attrs[ Symbol.iterator ]();
+		}
+
+		/**
+		 * Returns `true` if the object contains given {@link treeModel.Attribute attribute} or
+		 * an attribute with the same key if passed parameter was a string.
+		 *
+		 * @param {treeModel.Attribute|String} attrOrKey An attribute or a key to look for.
+		 * @returns {Boolean} True if object contains given attribute or an attribute with the given key.
+		 */
+		hasAttr( attrOrKey ) {
+			if ( attrOrKey instanceof Attribute ) {
+				for ( let attr of this._attrs ) {
+					if ( attr.isEqual( attrOrKey ) ) {
+						return true;
+					}
+				}
+			} else {
+				for ( let attr of this._attrs ) {
+					if ( attr.key == attrOrKey ) {
+						return true;
+					}
+				}
+			}
+
+			return false;
+		}
+
+		/**
+		 * Removes attribute from the list of attributes.
+		 *
+		 * @param {String} key The attribute key.
+		 */
+		removeAttr( key ) {
+			for ( let attr of this._attrs ) {
+				if ( attr.key == key ) {
+					this._attrs.delete( attr );
+
+					return;
+				}
+			}
+		}
+
+		/**
+		 * Sets a given attribute. If the attribute with the same key already exists it will be removed.
+		 *
+		 * @param {treeModel.Attribute} attr Attribute to set.
+		 */
+		setAttr( attr ) {
+			this.removeAttr( attr.key );
+
+			this._attrs.add( attr );
+		}
+
+		/**
+		 * Removes all attributes and sets passed attributes.
+		 *
+		 * @param {Iterable.<treeModel.Attribute>} attrs Array of attributes to set.
+		 */
+		setAttrsTo( attrs ) {
+			this._attrs = new Set( attrs );
+		}
+	}
+
+	return AttributeList;
+} );

+ 5 - 1
packages/ckeditor5-engine/src/treemodel/batch.js

@@ -12,10 +12,14 @@
 CKEDITOR.define( [
 	'treemodel/delta/batch-base',
 	'treemodel/delta/insertdelta',
+	'treemodel/delta/weakinsertdelta',
+	'treemodel/delta/movedelta',
 	'treemodel/delta/removedelta',
 	'treemodel/delta/attributedelta',
 	'treemodel/delta/splitdelta',
-	'treemodel/delta/mergedelta'
+	'treemodel/delta/mergedelta',
+	'treemodel/delta/wrapdelta',
+	'treemodel/delta/unwrapdelta'
 ], ( Batch ) => {
 	return Batch;
 } );

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

@@ -0,0 +1,63 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+CKEDITOR.define( [
+	'treemodel/delta/delta',
+	'treemodel/delta/register',
+	'treemodel/operation/moveoperation',
+	'treemodel/position',
+	'treemodel/range',
+	'ckeditorerror'
+], ( Delta, register, MoveOperation, Position, Range, CKEditorError ) => {
+	/**
+	 * To provide specific OT behavior and better collisions solving, {@link treeModel.Batch#move} method
+	 * uses the `MoveDelta` class which inherits from the `Delta` class and may overwrite some methods.
+	 *
+	 * @class treeModel.delta.MoveDelta
+	 */
+	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 );
+	}
+
+	/**
+	 * Moves given node or given range of nodes to target position.
+	 *
+	 * @chainable
+	 * @method move
+	 * @memberOf treeModel.Batch
+	 * @param {treeModel.Node|treeModel.Range} nodeOrRange Node or range of nodes to move.
+	 * @param {treeModel.Position} targetPosition Position where moved nodes will be inserted.
+	 */
+	register( 'move', function( nodeOrRange, targetPosition ) {
+		const delta = new MoveDelta();
+
+		if ( nodeOrRange instanceof Range ) {
+			if ( !nodeOrRange.isFlat ) {
+				/**
+				 * Range to move is not flat.
+				 *
+				 * @error batch-move-range-not-flat
+				 */
+				throw new CKEditorError( 'batch-move-range-not-flat: Range to move is not flat.' );
+			}
+
+			addMoveOperation( this, delta, nodeOrRange.start, nodeOrRange.end.offset - nodeOrRange.start.offset, targetPosition );
+		} else {
+			addMoveOperation( this, delta, Position.createBefore( nodeOrRange ), 1, targetPosition );
+		}
+
+		this.addDelta( delta );
+
+		return this;
+	} );
+
+	return MoveDelta;
+} );

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

@@ -8,8 +8,10 @@
 CKEDITOR.define( [
 	'treemodel/delta/delta',
 	'treemodel/delta/register',
-	'treemodel/operation/removeoperation'
-], ( Delta, register, RemoveOperation ) => {
+	'treemodel/operation/removeoperation',
+	'treemodel/position',
+	'treemodel/range'
+], ( Delta, register, RemoveOperation, Position, Range ) => {
 	/**
 	 * To provide specific OT behavior and better collisions solving, {@link treeModel.Batch#remove} method
 	 * uses the `RemoveDelta` class which inherits from the `Delta` class and may overwrite some methods.
@@ -18,25 +20,33 @@ CKEDITOR.define( [
 	 */
 	class RemoveDelta extends Delta {}
 
+	function addRemoveOperation( batch, delta, position, howMany ) {
+		const operation = new RemoveOperation( position, howMany, batch.doc.version );
+		batch.doc.applyOperation( operation );
+		delta.addOperation( operation );
+	}
+
 	/**
-	 * Removes nodes starting from the given position.
+	 * Removes given node or range of nodes.
 	 *
 	 * @chainable
 	 * @method remove
 	 * @memberOf treeModel.Batch
-	 * @param {treeModel.Position} position Position before the first node to remove.
-	 * @param {Number} howMany How many nodes to remove.
+	 * @param {treeModel.Node|treeModel.Range} nodeOrRange Node or range of nodes to remove.
 	 */
-	register( 'remove', function( position, howMany ) {
-		if ( typeof howMany !== 'number' ) {
-			howMany = 1;
-		}
-
+	register( 'remove', function( nodeOrRange ) {
 		const delta = new RemoveDelta();
 
-		const operation = new RemoveOperation( position, howMany, this.doc.version );
-		this.doc.applyOperation( operation );
-		delta.addOperation( operation );
+		if ( nodeOrRange instanceof Range ) {
+			// The array is reversed, so the ranges are correct and do not have to be updated.
+			let ranges = nodeOrRange.getMinimalFlatRanges().reverse();
+
+			for ( let flat of ranges ) {
+				addRemoveOperation( this, delta, flat.start, flat.end.offset - flat.start.offset );
+			}
+		} else {
+			addRemoveOperation( this, delta, Position.createBefore( nodeOrRange ), 1 );
+		}
 
 		this.addDelta( delta );
 

+ 65 - 0
packages/ckeditor5-engine/src/treemodel/delta/unwrapdelta.js

@@ -0,0 +1,65 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+CKEDITOR.define( [
+	'treemodel/delta/delta',
+	'treemodel/delta/register',
+	'treemodel/position',
+	'treemodel/element',
+	'treemodel/operation/removeoperation',
+	'treemodel/operation/moveoperation',
+	'ckeditorerror'
+], ( Delta, register, Position, Element, RemoveOperation, MoveOperation, CKEditorError ) => {
+	/**
+	 * To provide specific OT behavior and better collisions solving, {@link treeModel.Batch#merge} method
+	 * uses the `UnwrapDelta` class which inherits from the `Delta` class and may overwrite some methods.
+	 *
+	 * @class treeModel.delta.UnwrapDelta
+	 */
+	class UnwrapDelta extends Delta {}
+
+	/**
+	 * Unwraps specified element, that is moves all it's children before it and then removes it. Throws
+	 * error if you try to unwrap an element that does not have a parent.
+	 *
+	 * @chainable
+	 * @method unwrap
+	 * @memberOf treeModel.Batch
+	 * @param {treeModel.Element} position Element to unwrap.
+	 */
+	register( 'unwrap', function( element ) {
+		if ( element.parent === null ) {
+			/**
+			 * Trying to unwrap an element that has no parent.
+			 *
+			 * @error batch-unwrap-element-no-parent
+			 */
+			throw new CKEditorError(
+				'batch-unwrap-element-no-parent: Trying to unwrap an element that has no parent.' );
+		}
+
+		const delta = new UnwrapDelta();
+
+		let sourcePosition = Position.createFromParentAndOffset( element, 0 );
+
+		const move = new MoveOperation( sourcePosition, element.getChildCount(), Position.createBefore( element ), this.doc.version );
+		this.doc.applyOperation( move );
+		delta.addOperation( 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.addDelta( delta );
+
+		return this;
+	} );
+
+	return UnwrapDelta;
+} );

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

@@ -0,0 +1,59 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+CKEDITOR.define( [
+	'treemodel/delta/delta',
+	'treemodel/delta/register',
+	'treemodel/operation/insertoperation',
+	'treemodel/nodelist'
+], ( Delta, register, InsertOperation, NodeList ) => {
+	/**
+	 * To provide specific OT behavior and better collisions solving, the {@link treeModel.Batch#insert} method
+	 * uses the `WeakInsertDelta` class which inherits from the `Delta` class and may overwrite some methods.
+	 *
+	 * @class treeModel.delta.WeakInsertDelta
+	 */
+	class WeakInsertDelta extends Delta {}
+
+	/**
+	 * Inserts a node or nodes at the given position. {@link treeModel.Batch#weakInsert} is commonly used for actions
+	 * like typing or plain-text paste (without formatting). There are two differences between
+	 * {@link treeModel.Batch#insert} and {@link treeModel.Batch#weakInsert}:
+	 * * When using `weakInsert`, inserted nodes will have same attributes as the current attributes of
+	 * {@link treeModel.Document#selection document selection}.
+	 * * The above has to be reflected during {@link treeModel.operation.transform operational transformation}. Normal
+	 * behavior is that inserting inside range changed by {@link treeModel.operation.AttributeOperation} splits the operation
+	 * into two operations, which "omit" the inserted nodes. The correct behavior for `WeakInsertDelta` is that
+	 * {@link treeModel.operation.AttributeOperation} does not "break" and also applies attributes for inserted nodes.
+	 *
+	 * @chainable
+	 * @memberOf treeModel.Batch
+	 * @method weakInsert
+	 * @param {treeModel.Position} position Position of insertion.
+	 * @param {treeModel.Node|treeModel.Text|treeModel.NodeList|String|Iterable} nodes The list of nodes to be inserted.
+	 * List of nodes can be of any type accepted by the {@link treeModel.NodeList} constructor.
+	 */
+	register( 'weakInsert', function( position, nodes ) {
+		const delta = new WeakInsertDelta();
+
+		nodes = new NodeList( nodes );
+
+		for ( let node of nodes ) {
+			node.setAttrsTo( this.doc.selection.getAttrs() );
+		}
+
+		const operation = new InsertOperation( position, nodes, this.doc.version );
+		this.doc.applyOperation( operation );
+		delta.addOperation( operation );
+
+		this.addDelta( delta );
+
+		return this;
+	} );
+
+	return WeakInsertDelta;
+} );

+ 82 - 0
packages/ckeditor5-engine/src/treemodel/delta/wrapdelta.js

@@ -0,0 +1,82 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+CKEDITOR.define( [
+	'treemodel/delta/delta',
+	'treemodel/delta/register',
+	'treemodel/position',
+	'treemodel/element',
+	'treemodel/operation/insertoperation',
+	'treemodel/operation/moveoperation',
+	'ckeditorerror'
+], ( Delta, register, Position, Element, InsertOperation, MoveOperation, CKEditorError ) => {
+	/**
+	 * To provide specific OT behavior and better collisions solving, {@link treeModel.Batch#merge} method
+	 * uses the `WrapDelta` class which inherits from the `Delta` class and may overwrite some methods.
+	 *
+	 * @class treeModel.delta.WrapDelta
+	 */
+	class WrapDelta extends Delta {}
+
+	/**
+	 * Wraps given range with given element or with a new element of specified name if string has been passed.
+	 * **Note:** given range should be a "flat range" (see {@link treeModel.Range#isFlat}). If not, error will be thrown.
+	 *
+	 * @chainable
+	 * @method wrap
+	 * @memberOf treeModel.Batch
+	 * @param {treeModel.Range} range Range to wrap.
+	 * @param {treeModel.Element|String} elementOrString Element or name of element to wrap the range with.
+	 */
+	register( 'wrap', function( range, elementOrString ) {
+		if ( !range.isFlat ) {
+			/**
+			 * Range to wrap is not flat.
+			 *
+			 * @error batch-wrap-range-not-flat
+			 */
+			throw new CKEditorError( 'batch-wrap-range-not-flat: Range to wrap is not flat.' );
+		}
+
+		let element = elementOrString instanceof Element ? elementOrString : new Element( elementOrString );
+
+		if ( element.getChildCount() > 0 ) {
+			/**
+			 * Element to wrap with is not empty.
+			 *
+			 * @error batch-wrap-element-not-empty
+			 */
+			throw new CKEditorError( 'batch-wrap-element-not-empty: Element to wrap with is not empty.' );
+		}
+
+		if ( element.parent !== null ) {
+			/**
+			 * Element to wrap with is already attached to a tree model.
+			 *
+			 * @error batch-wrap-element-attached
+			 */
+			throw new CKEditorError( 'batch-wrap-element-attached: Element to wrap with is already attached to tree model.' );
+		}
+
+		const delta = new WrapDelta();
+
+		let insert = new InsertOperation( range.end, element, this.doc.version );
+		this.doc.applyOperation( insert );
+		delta.addOperation( 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.addDelta( delta );
+
+		return this;
+	} );
+
+	return WrapDelta;
+} );

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

@@ -9,10 +9,11 @@ CKEDITOR.define( [
 	'treemodel/element',
 	'treemodel/rootelement',
 	'treemodel/batch',
+	'treemodel/selection',
 	'emittermixin',
 	'utils',
 	'ckeditorerror'
-], ( Element, RootElement, Batch, EmitterMixin, utils, CKEditorError ) => {
+], ( Element, RootElement, Batch, Selection, EmitterMixin, utils, CKEditorError ) => {
 	const graveyardSymbol = Symbol( 'graveyard' );
 
 	/**
@@ -43,9 +44,6 @@ CKEDITOR.define( [
 			 */
 			this.roots = new Map();
 
-			// Graveyard tree root. Document always have a graveyard root, which stores removed nodes.
-			this.createRoot( graveyardSymbol );
-
 			/**
 			 * Document version. It starts from `0` and every operation increases the version number. It is used to ensure that
 			 * operations are applied on the proper document version. If the {@link treeModel.operation.Operation#baseVersion} will
@@ -56,6 +54,9 @@ CKEDITOR.define( [
 			 */
 			this.version = 0;
 
+			// Graveyard tree root. Document always have a graveyard root, which stores removed nodes.
+			this.createRoot( graveyardSymbol );
+
 			/**
 			 * Array of pending changes. See: {@link #enqueueChanges}.
 			 *
@@ -63,6 +64,14 @@ CKEDITOR.define( [
 			 * @property {Array.<Function>}
 			 */
 			this._pendingChanges = [];
+
+			/**
+			 * Selection done on this document.
+			 *
+			 * @readonly
+			 * @property {treeModel.Selection}
+			 */
+			this.selection = new Selection();
 		}
 
 		/**

+ 188 - 0
packages/ckeditor5-engine/src/treemodel/liveposition.js

@@ -0,0 +1,188 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+CKEDITOR.define( [
+	'treemodel/position',
+	'treemodel/range',
+	'emittermixin',
+	'utils'
+], ( Position, Range, EmitterMixin, utils ) => {
+	const STICKS_TO_NEXT = 0;
+	const STICKS_TO_PREVIOUS = 1;
+
+	/**
+	 * LivePosition is a position in the Tree Model that updates itself as the tree changes. It may be used as a bookmark.
+	 * **Note:** Be very careful when dealing with LivePosition. Each LivePosition instance bind events that might
+	 * have to be unbound. Use {@link #detach} whenever you don't need LivePosition anymore.
+	 *
+	 * @class treeModel.LivePosition
+	 */
+
+	class LivePosition extends Position {
+		/**
+		 * Creates a live position.
+		 *
+		 * @see {@link treeModel.Position}
+		 * @param root
+		 * @param path
+		 * @param {Number} [stickiness] Flag representing how live position is "sticking" with their neighbour nodes.
+		 * Defaults to {@link #STICKS_TO_NEXT}. See {@link #stickiness}.
+		 * @constructor
+		 */
+		constructor( root, path, stickiness ) {
+			super( root, path );
+
+			/**
+			 * Flag representing LivePosition stickiness. LivePosition might be sticking to previous node or next node.
+			 * Whenever some nodes are inserted at the same position as LivePosition, `stickiness` is checked to decide if
+			 * LivePosition should be moved. Similar applies when a range of nodes is moved and one of it's boundary
+			 * position is same as LivePosition.
+			 *
+			 * Examples:
+			 * Insert:
+			 * Position is at | and we insert at the same position, marked as ^:
+			 * | sticks to previous node: `<p>f|^oo</p>` => `<p>f|baroo</p>`
+			 * | sticks to next node: `<p>f^|oo</p>` => `<p>fbar|oo</p>`
+			 *
+			 * Move:
+			 * Position is at | and range [ ] is moved to position ^:
+			 * | sticks to previous node: `<p>f|[oo]</p><p>b^ar</p>` => `<p>f|</p><p>booar</p>`
+			 * | sticks to next node: `<p>f|[oo]</p><p>b^ar</p>` => `<p>f</p><p>b|ooar</p>`
+			 *
+			 * Accepted values are {@link #STICKS_TO_PREVIOUS} and {@link #STICKS_TO_NEXT}.
+			 *
+			 * @type {Number}
+			 */
+			this.stickiness = stickiness || STICKS_TO_NEXT;
+
+			bindWithDocument.call( this );
+		}
+
+		/**
+		 * Unbinds all events previously bound by LivePosition. Use it whenever you don't need LivePosition instance
+		 * anymore (i.e. when leaving scope in which it was declared or before re-assigning variable that was
+		 * referring to it).
+		 */
+		detach() {
+			this.stopListening();
+		}
+
+		/**
+		 * @static
+		 * @method createAfter
+		 * @see {@link treeModel.Position#createAfter}
+		 * @param {treeModel.Node} node
+		 * @returns {treeModel.LivePosition}
+		 */
+
+		/**
+		 * @static
+		 * @method createBefore
+		 * @see {@link treeModel.Position#createBefore}
+		 * @param {treeModel.Node} node
+		 * @returns {treeModel.LivePosition}
+		 */
+
+		/**
+		 * @static
+		 * @method createFromParentAndOffset
+		 * @see {@link treeModel.Position#createFromParentAndOffset}
+		 * @param {treeModel.Element} parent
+		 * @param {Number} offset
+		 * @returns {treeModel.LivePosition}
+		 */
+
+		/**
+		 * @static
+		 * @method createFromPosition
+		 * @see {@link treeModel.Position#createFromPosition}
+		 * @param {treeModel.Position} position
+		 * @returns {treeModel.LivePosition}
+		 */
+	}
+
+	/**
+	 * Binds this LivePosition to the {@link treeModel.Document} that owns this position {@link treeModel.RootElement root}.
+	 *
+	 * @private
+	 * @method bindWithDocument
+	 */
+	function bindWithDocument() {
+		/*jshint validthis: true */
+
+		this.listenTo(
+			this.root.document,
+			'change',
+			( event, type, changes ) => {
+				transform.call( this, type, changes.range, changes.sourcePosition );
+			},
+			this
+		);
+	}
+
+	/**
+	 * Updates this position accordingly to the updates applied to the Tree Model. Bases on change events.
+	 *
+	 * @private
+	 * @method transform
+	 * @param {String} type Type of changes applied to the Tree Model.
+	 * @param {treeModel.Range} range Range containing the result of applied change.
+	 * @param {treeModel.Position} [position] Additional position parameter provided by some change events.
+	 */
+	function transform( type, range, position ) {
+		/*jshint validthis: true */
+
+		let howMany = range.end.offset - range.start.offset;
+		let transformed;
+
+		switch ( type ) {
+			case 'insert':
+				let insertBefore = this.stickiness == STICKS_TO_NEXT;
+				transformed = this.getTransformedByInsertion( range.start, howMany, insertBefore );
+				break;
+
+			case 'move':
+			case 'remove':
+			case 'reinsert':
+				let originalRange = Range.createFromPositionAndShift( position, howMany );
+
+				let gotMoved = originalRange.containsPosition( this ) ||
+					( originalRange.start.isEqual( this ) && this.stickiness == STICKS_TO_NEXT ) ||
+					( originalRange.end.isEqual( this ) && this.stickiness == STICKS_TO_PREVIOUS );
+
+				// We can't use .getTransformedByMove() because we have a different if-condition.
+				if ( gotMoved ) {
+					transformed = this._getCombined( position, range.start );
+				} else {
+					let insertBefore = this.stickiness == STICKS_TO_NEXT;
+					transformed = this.getTransformedByMove( position, range.start, howMany, insertBefore );
+				}
+				break;
+		}
+
+		this.path = transformed.path;
+		this.root = transformed.root;
+	}
+
+	/**
+	 * Flag representing that the position is sticking to the node before it or to the beginning of it's parent node.
+	 *
+	 * @type {Number}
+	 */
+	LivePosition.STICKS_TO_PREVIOUS = STICKS_TO_PREVIOUS;
+
+	/**
+	 * Flag representing that the position is sticking to the node after it or to the end of it's parent node.
+	 *
+	 * @type {number}
+	 */
+	LivePosition.STICKS_TO_NEXT = STICKS_TO_NEXT;
+
+	utils.extend( LivePosition.prototype, EmitterMixin );
+
+	return LivePosition;
+} );

+ 141 - 0
packages/ckeditor5-engine/src/treemodel/liverange.js

@@ -0,0 +1,141 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+CKEDITOR.define( [
+	'treemodel/range',
+	'treemodel/liveposition',
+	'emittermixin',
+	'utils'
+], ( Range, LivePosition, EmitterMixin, utils ) => {
+	/**
+	 * LiveRange is a Range in the Tree Model that updates itself as the tree changes. It may be used as a bookmark.
+	 * **Note:** Constructor creates it's own {@link treeModel.LivePosition} instances basing on passed values.
+	 * **Note:** Be very careful when dealing with LiveRange. Each LiveRange instance bind events that might
+	 * have to be unbound. Use {@link #detach} whenever you don't need LiveRange anymore.
+	 *
+	 * @class treeModel.LiveRange
+	 */
+	class LiveRange extends Range {
+		/**
+		 * Creates a live range.
+		 *
+		 * @see {treeModel.Range}
+		 * @constructor
+		 */
+		constructor( start, end ) {
+			super( start, end );
+
+			this.start = new LivePosition( this.start.root, this.start.path.slice(), LivePosition.STICKS_TO_NEXT );
+			this.end = new LivePosition( this.end.root, this.end.path.slice(), LivePosition.STICKS_TO_PREVIOUS );
+
+			bindWithDocument.call( this );
+		}
+
+		/**
+		 * Unbinds all events previously bound by LiveRange. Use it whenever you don't need LiveRange instance
+		 * anymore (i.e. when leaving scope in which it was declared or before re-assigning variable that was
+		 * referring to it).
+		 */
+		detach() {
+			this.start.detach();
+			this.end.detach();
+			this.stopListening();
+		}
+
+		/**
+		 * @see {@link treeModel.Range#createFromElement}
+		 * @static
+		 * @method createFromElement
+		 * @param {treeModel.Element} element
+		 * @returns {treeModel.LiveRange}
+		 */
+
+		/**
+		 * @see {@link treeModel.Range#createFromPositionAndShift}
+		 * @static
+		 * @method createFromPositionAndShift
+		 * @param {treeModel.Position} position
+		 * @param {Number} shift
+		 * @returns {treeModel.LiveRange}
+		 */
+
+		/**
+		 * @see {@link treeModel.Range#createFromParentsAndOffsets}
+		 * @static
+		 * @method createFromParentsAndOffsets
+		 * @param {treeModel.Element} startElement
+		 * @param {Number} startOffset
+		 * @param {treeModel.Element} endElement
+		 * @param {Number} endOffset
+		 * @returns {treeModel.LiveRange}
+		 */
+
+		/**
+		 * @see {@link treeModel.Range#createFromRange}
+		 * @static
+		 * @method createFromRange
+		 * @param {treeModel.Range} range
+		 * @returns {treeModel.LiveRange}
+		 */
+	}
+
+	/**
+	 * Binds this LiveRange to the {@link treeModel.Document} that owns this range.
+	 *
+	 * @private
+	 * @method bindWithDocument
+	 */
+	function bindWithDocument() {
+		/*jshint validthis: true */
+
+		this.listenTo(
+			this.root.document,
+			'change',
+			( event, type, changes ) => {
+				fixBoundaries.call( this, type, changes.range, changes.sourcePosition );
+			},
+			this
+		);
+	}
+
+	/**
+	 * LiveRange boundaries are instances of {@link treeModel.LivePosition}, so it is updated thanks to them. This method
+	 * additionally fixes the results of updating live positions taking into account that those live positions
+	 * are boundaries of a range. An example case for fixing live positions is end boundary is moved before start boundary.
+	 *
+	 * @private
+	 * @method fixBoundaries
+	 * @param {String} type Type of changes applied to the Tree Model.
+	 * @param {treeModel.Range} range Range containing the result of applied change.
+	 * @param {treeModel.Position} [position] Additional position parameter provided by some change events.
+	 */
+	function fixBoundaries( type, range, position ) {
+		/*jshint validthis: true */
+
+		if ( type == 'move' || type == 'remove' || type == 'reinsert' ) {
+			let containsStart = range.containsPosition( this.start ) || range.start.isEqual( this.start );
+			let containsEnd = range.containsPosition( this.end ) || range.end.isEqual( this.end );
+			position = position.getTransformedByInsertion( range.start, range.end.offset - range.start.offset, true );
+
+			// If the range contains both start and end, don't do anything - LivePositions that are boundaries of
+			// this LiveRange are in correct places, they got correctly transformed.
+			if ( containsStart && !containsEnd && !range.end.isTouching( position ) ) {
+				this.start.path = position.path.slice();
+				this.start.root = position.root;
+			}
+
+			if ( containsEnd && !containsStart && !range.start.isTouching( position ) ) {
+				this.end.path = position.path.slice();
+				this.end.root = position.root;
+			}
+		}
+	}
+
+	utils.extend( LiveRange.prototype, EmitterMixin );
+
+	return LiveRange;
+} );

+ 26 - 64
packages/ckeditor5-engine/src/treemodel/node.js

@@ -5,7 +5,11 @@
 
 'use strict';
 
-CKEDITOR.define( [ 'treemodel/attribute', 'utils', 'ckeditorerror' ], ( Attribute, utils, CKEditorError ) => {
+CKEDITOR.define( [
+	'treemodel/attributelist',
+	'utils',
+	'ckeditorerror'
+], ( AttributeList, utils, CKEditorError ) => {
 	/**
 	 * Abstract document tree node class.
 	 *
@@ -31,14 +35,13 @@ CKEDITOR.define( [ 'treemodel/attribute', 'utils', 'ckeditorerror' ], ( Attribut
 			this.parent = null;
 
 			/**
-			 * Attributes set.
-			 *
+			 * List of attributes set on this node.
 			 * Attributes of nodes attached to the document can be changed only be the {@link treeModel.operation.AttributeOperation}.
 			 *
 			 * @private
-			 * @property {Set} _attrs
+			 * @property {treeModel.AttributeList} _attrs
 			 */
-			this._attrs = new Set( attrs );
+			this._attrs = new AttributeList( attrs );
 		}
 
 		/**
@@ -91,7 +94,7 @@ CKEDITOR.define( [ 'treemodel/attribute', 'utils', 'ckeditorerror' ], ( Attribut
 		 * @property {Number} depth
 		 */
 		get root() {
-			let root = this; // jscs:ignore safeContextKeyword
+			let root = this;
 
 			while ( root.parent ) {
 				root = root.parent;
@@ -101,30 +104,17 @@ CKEDITOR.define( [ 'treemodel/attribute', 'utils', 'ckeditorerror' ], ( Attribut
 		}
 
 		/**
-		 * Finds an attribute by a key.
-		 *
-		 * @param {String} attr The attribute key.
-		 * @returns {treeModel.Attribute} The found attribute.
+		 * @see {@link treeModel.AttributeList#getAttr}
 		 */
 		getAttr( key ) {
-			for ( let attr of this._attrs ) {
-				if ( attr.key == key ) {
-					return attr.value;
-				}
-			}
-
-			return null;
+			return this._attrs.getAttr( key );
 		}
 
 		/**
-		 * Returns attribute iterator. It can be use to create a new element with the same attributes:
-		 *
-		 *		const copy = new Element( element.name, element.getAttrs() );
-		 *
-		 * @returns {Iterable.<treeModel.Attribute>} Attribute iterator.
+		 * @see {@link treeModel.AttributeList#getAttrs}
 		 */
 		getAttrs() {
-			return this._attrs[ Symbol.iterator ]();
+			return this._attrs.getAttrs();
 		}
 
 		/**
@@ -162,7 +152,7 @@ CKEDITOR.define( [ 'treemodel/attribute', 'utils', 'ckeditorerror' ], ( Attribut
 		 */
 		getPath() {
 			const path = [];
-			let node = this; // jscs:ignore safeContextKeyword
+			let node = this;
 
 			while ( node.parent ) {
 				path.unshift( node.getIndex() );
@@ -173,59 +163,31 @@ CKEDITOR.define( [ 'treemodel/attribute', 'utils', 'ckeditorerror' ], ( Attribut
 		}
 
 		/**
-		 * Returns `true` if the node contains an attribute with the same key and value as given or the same key if the
-		 * given parameter is a string.
-		 *
-		 * @param {treeModel.Attribute|String} key An attribute or a key to compare.
-		 * @returns {Boolean} True if node contains given attribute or an attribute with the given key.
+		 * @see {@link treeModel.AttributeList#hasAttr}
 		 */
 		hasAttr( key ) {
-			let attr;
-
-			// Attribute.
-			if ( key instanceof Attribute ) {
-				for ( attr of this._attrs ) {
-					if ( attr.isEqual( key ) ) {
-						return true;
-					}
-				}
-			}
-			// Key.
-			else {
-				for ( attr of this._attrs ) {
-					if ( attr.key == key ) {
-						return true;
-					}
-				}
-			}
-
-			return false;
+			return this._attrs.hasAttr( key );
 		}
 
 		/**
-		 * Removes attribute from the list of attributes.
-		 *
-		 * @param {String} key The attribute key.
+		 * @see {@link treeModel.AttributeList#removeAttr}
 		 */
 		removeAttr( key ) {
-			for ( let attr of this._attrs ) {
-				if ( attr.key == key ) {
-					this._attrs.delete( attr );
-
-					return;
-				}
-			}
+			this._attrs.removeAttr( key );
 		}
 
 		/**
-		 * Sets a given attribute. If the attribute with the same key already exists it will be removed.
-		 *
-		 * @param {treeModel.Attribute} attr Attribute to set.
+		 * @see {@link treeModel.AttributeList#setAttr}
 		 */
 		setAttr( attr ) {
-			this.removeAttr( attr.key );
+			this._attrs.setAttr( attr );
+		}
 
-			this._attrs.add( attr );
+		/**
+		 * @see {@link treeModel.AttributeList#setAttrsTo}
+		 */
+		setAttrsTo( attrs ) {
+			this._attrs.setAttrsTo( attrs );
 		}
 
 		/**

+ 6 - 5
packages/ckeditor5-engine/src/treemodel/operation/attributeoperation.js

@@ -7,8 +7,9 @@
 
 CKEDITOR.define( [
 	'treemodel/operation/operation',
+	'treemodel/range',
 	'ckeditorerror'
-], ( Operation, CKEditorError ) => {
+], ( Operation, Range, CKEditorError ) => {
 	/**
 	 * Operation to change nodes' attribute. Using this class you can add, remove or change value of the attribute.
 	 *
@@ -42,7 +43,7 @@ CKEDITOR.define( [
 			 * @readonly
 			 * @type {treeModel.Range}
 			 */
-			this.range = range;
+			this.range = Range.createFromRange( range );
 
 			/**
 			 * Old attribute to change. Set to `null` if operation inserts a new attribute.
@@ -66,7 +67,7 @@ CKEDITOR.define( [
 		}
 
 		clone() {
-			return new AttributeOperation( this.range.clone(), this.oldAttr, this.newAttr, this.baseVersion );
+			return new AttributeOperation( this.range, this.oldAttr, this.newAttr, this.baseVersion );
 		}
 
 		getReversed() {
@@ -92,7 +93,7 @@ CKEDITOR.define( [
 
 			// Remove or change.
 			if ( oldAttr !== null ) {
-				for ( let node of this.range.getNodes() ) {
+				for ( let node of this.range.getAllNodes() ) {
 					if ( !node.hasAttr( oldAttr ) ) {
 						/**
 						 * The attribute which should be removed does not exists for the given node.
@@ -116,7 +117,7 @@ CKEDITOR.define( [
 
 			// Insert or change.
 			if ( newAttr !== null ) {
-				for ( let node of this.range.getNodes() ) {
+				for ( let node of this.range.getAllNodes() ) {
 					if ( oldAttr === null && node.hasAttr( newAttr.key ) ) {
 						/**
 						 * The attribute with given key already exists for the given node.

+ 3 - 2
packages/ckeditor5-engine/src/treemodel/operation/insertoperation.js

@@ -8,9 +8,10 @@
 CKEDITOR.define( [
 	'treemodel/operation/operation',
 	'treemodel/nodelist',
+	'treemodel/position',
 	'treemodel/range',
 	'treemodel/operation/removeoperation'
-], ( Operation, NodeList, Range ) => {
+], ( Operation, NodeList, Position, Range ) => {
 	/**
 	 * Operation to insert list of nodes on the given position in the tree data model.
 	 *
@@ -35,7 +36,7 @@ CKEDITOR.define( [
 			 * @readonly
 			 * @type {treeModel.Position}
 			 */
-			this.position = position;
+			this.position = Position.createFromPosition( position );
 
 			/**
 			 * List of nodes to insert.

+ 8 - 7
packages/ckeditor5-engine/src/treemodel/operation/moveoperation.js

@@ -7,10 +7,11 @@
 
 CKEDITOR.define( [
 	'treemodel/operation/operation',
+	'treemodel/position',
 	'treemodel/range',
 	'ckeditorerror',
 	'utils'
-], ( Operation, Range, CKEditorError, utils ) => {
+], ( Operation, Position, Range, CKEditorError, utils ) => {
 	/**
 	 * Operation to move list of subsequent nodes from one position in the document to another.
 	 *
@@ -20,9 +21,9 @@ CKEDITOR.define( [
 		/**
 		 * Creates a move operation.
 		 *
-		 * @param {treeModel.Position} sourcePosition Position before the first element to move.
+		 * @param {treeModel.Position} sourcePosition Position before the first node to move.
 		 * @param {Number} howMany How many consecutive nodes to move, starting from `sourcePosition`.
-		 * @param {treeModel.Position} targetPosition Position where moved elements will be inserted.
+		 * @param {treeModel.Position} targetPosition Position where moved nodes will be inserted.
 		 * @param {Number} baseVersion {@link treeModel.Document#version} on which operation can be applied.
 		 * @constructor
 		 */
@@ -34,7 +35,7 @@ CKEDITOR.define( [
 			 *
 			 * @type {treeModel.Position}
 			 */
-			this.sourcePosition = sourcePosition;
+			this.sourcePosition = Position.createFromPosition( sourcePosition );
 
 			/**
 			 * How many nodes to move.
@@ -48,7 +49,7 @@ CKEDITOR.define( [
 			 *
 			 * @type {treeModel.Position}
 			 */
-			this.targetPosition = targetPosition;
+			this.targetPosition = Position.createFromPosition( targetPosition );
 		}
 
 		get type() {
@@ -56,11 +57,11 @@ CKEDITOR.define( [
 		}
 
 		clone() {
-			return new MoveOperation( this.sourcePosition.clone(), this.howMany, this.targetPosition.clone(), this.baseVersion );
+			return new this.constructor( this.sourcePosition, this.howMany, this.targetPosition, this.baseVersion );
 		}
 
 		getReversed() {
-			return new MoveOperation( this.targetPosition.clone(), this.howMany, this.sourcePosition.clone(), this.baseVersion + 1 );
+			return new this.constructor( this.targetPosition, this.howMany, this.sourcePosition, this.baseVersion + 1 );
 		}
 
 		_execute() {

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

@@ -41,6 +41,10 @@ CKEDITOR.define( [
 
 			return new ReinsertOperation( this.targetPosition, this.howMany, this.sourcePosition, this.baseVersion + 1 );
 		}
+
+		clone() {
+			return new RemoveOperation( this.sourcePosition, this.howMany, this.baseVersion );
+		}
 	}
 
 	return RemoveOperation;

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

@@ -48,9 +48,10 @@ CKEDITOR.define( [
 	'treemodel/operation/attributeoperation',
 	'treemodel/operation/moveoperation',
 	'treemodel/operation/nooperation',
+	'treemodel/position',
 	'treemodel/range',
 	'utils'
-], ( InsertOperation, AttributeOperation, MoveOperation, NoOperation, Range, utils ) => {
+], ( InsertOperation, AttributeOperation, MoveOperation, NoOperation, Position, Range, utils ) => {
 	const ot = {
 		InsertOperation: {
 			// Transforms InsertOperation `a` by InsertOperation `b`. Accepts a flag stating whether `a` is more important
@@ -208,7 +209,7 @@ CKEDITOR.define( [
 					return new MoveOperation(
 						range.start,
 						range.end.offset - range.start.offset,
-						newTargetPosition.clone(),
+						Position.createFromPosition( newTargetPosition ),
 						a.baseVersion
 					);
 				} );

+ 84 - 16
packages/ckeditor5-engine/src/treemodel/position.js

@@ -129,16 +129,6 @@ CKEDITOR.define( [ 'treemodel/rootelement', 'utils', 'ckeditorerror' ], ( RootEl
 		}
 
 		/**
-		 * Creates and returns a new instance of {@link treeModel.Position}
-		 * which is equal to this {@link treeModel.Position position}.
-		 *
-		 * @returns {treeModel.Position} Cloned {@link treeModel.Position position}.
-		 */
-		clone() {
-			return new Position( this.root, this.path.slice() );
-		}
-
-		/**
 		 * Checks whether this position is before or after given position.
 		 *
 		 * @param {treeModel.Position} otherPosition Position to compare with.
@@ -184,6 +174,21 @@ CKEDITOR.define( [ 'treemodel/rootelement', 'utils', 'ckeditorerror' ], ( RootEl
 		}
 
 		/**
+		 * Returns a new instance of Position with offset incremented by `shift` value.
+		 *
+		 * @param {Number} shift How position offset should get changed. Accepts negative values.
+		 * @returns {treeModel.Position} Shifted position.
+		 */
+		getShiftedBy( shift ) {
+			let shifted = Position.createFromPosition( this );
+
+			let offset = shifted.offset + shift;
+			shifted.offset = offset < 0 ? 0 : offset;
+
+			return shifted;
+		}
+
+		/**
 		 * Returns this position after being updated by removing `howMany` nodes starting from `deletePosition`.
 		 * It may happen that this position is in a removed node. If that is the case, `null` is returned instead.
 		 *
@@ -192,7 +197,7 @@ CKEDITOR.define( [ 'treemodel/rootelement', 'utils', 'ckeditorerror' ], ( RootEl
 		 * @returns {treeModel.Position|null} Transformed position or `null`.
 		 */
 		getTransformedByDeletion( deletePosition, howMany ) {
-			let transformed = this.clone();
+			let transformed = Position.createFromPosition( this );
 
 			// This position can't be affected if deletion was in a different root.
 			if ( this.root != deletePosition.root ) {
@@ -241,7 +246,7 @@ CKEDITOR.define( [ 'treemodel/rootelement', 'utils', 'ckeditorerror' ], ( RootEl
 		 * @returns {treeModel.Position} Transformed position.
 		 */
 		getTransformedByInsertion( insertPosition, howMany, insertBefore ) {
-			let transformed = this.clone();
+			let transformed = Position.createFromPosition( this );
 
 			// This position can't be affected if insertion was in a different root.
 			if ( this.root != insertPosition.root ) {
@@ -355,6 +360,59 @@ CKEDITOR.define( [ 'treemodel/rootelement', 'utils', 'ckeditorerror' ], ( RootEl
 		}
 
 		/**
+		 * Checks whether this position is touching given position. Positions touch when there are no characters
+		 * or empty nodes in a range between them. Technically, those positions are not equal but in many cases
+		 * they are very similar or even indistinguishable when they touch.
+		 *
+		 * @param {treeModel.Position} otherPosition Position to compare with.
+		 * @returns {Boolean} True if positions touch.
+		 */
+		isTouching( otherPosition ) {
+			let left = null;
+			let right = null;
+			let compare = this.compareWith( otherPosition );
+
+			switch ( compare ) {
+				case SAME:
+					return true;
+
+				case BEFORE:
+					left = this;
+					right = otherPosition;
+					break;
+
+				case AFTER:
+					left = otherPosition;
+					right = this;
+					break;
+
+				default:
+					return false;
+			}
+
+			while ( left.path.length + right.path.length ) {
+				if ( left.isEqual( right ) ) {
+					return true;
+				}
+
+				if ( left.path.length > right.path.length ) {
+					if ( left.nodeAfter !== null ) {
+						return false;
+					}
+
+					left.path = left.path.slice( 0, -1 );
+					left.offset++;
+				} else {
+					if ( right.nodeBefore !== null ) {
+						return false;
+					}
+
+					right.path = right.path.slice( 0, -1 );
+				}
+			}
+		}
+
+		/**
 		 * Creates a new position after given node.
 		 *
 		 * @param {treeModel.Node} node Node the position should be directly after.
@@ -371,7 +429,7 @@ CKEDITOR.define( [ 'treemodel/rootelement', 'utils', 'ckeditorerror' ], ( RootEl
 				throw new CKEditorError( 'position-after-root: You can not make position after root.', { root: node } );
 			}
 
-			return Position.createFromParentAndOffset( node.parent, node.getIndex() + 1 );
+			return this.createFromParentAndOffset( node.parent, node.getIndex() + 1 );
 		}
 
 		/**
@@ -391,7 +449,7 @@ CKEDITOR.define( [ 'treemodel/rootelement', 'utils', 'ckeditorerror' ], ( RootEl
 				throw new CKEditorError( 'position-before-root: You can not make position before root.', { root: node } );
 			}
 
-			return Position.createFromParentAndOffset( node.parent, node.getIndex() );
+			return this.createFromParentAndOffset( node.parent, node.getIndex() );
 		}
 
 		/**
@@ -406,7 +464,17 @@ CKEDITOR.define( [ 'treemodel/rootelement', 'utils', 'ckeditorerror' ], ( RootEl
 
 			path.push( offset );
 
-			return new Position( parent.root, path );
+			return new this( parent.root, path );
+		}
+
+		/**
+		 * Creates and returns a new instance of Position, which is equal to passed position.
+		 *
+		 * @param {treeModel.Position} position Position to be cloned.
+		 * @returns {treeModel.Position}
+		 */
+		static createFromPosition( position ) {
+			return new this( position.root, position.path.slice() );
 		}
 
 		/**
@@ -443,7 +511,7 @@ CKEDITOR.define( [ 'treemodel/rootelement', 'utils', 'ckeditorerror' ], ( RootEl
 			const i = source.path.length - 1;
 
 			// The first part of a path to combined position is a path to the place where nodes were moved.
-			let combined = target.clone();
+			let combined = Position.createFromPosition( target );
 
 			// Then we have to update the rest of the path.
 

+ 154 - 42
packages/ckeditor5-engine/src/treemodel/range.js

@@ -13,7 +13,8 @@ CKEDITOR.define( [ 'treemodel/position', 'treemodel/positioniterator', 'utils' ]
 	 */
 	class Range {
 		/**
-		 * Creates a range.
+		 * Creates a range spanning from `start` position to `end` position.
+		 * **Note:** Constructor creates it's own {@link treeModel.Position} instances basing on passed values.
 		 *
 		 * @param {treeModel.Position} start Start position.
 		 * @param {treeModel.Position} end End position.
@@ -25,33 +26,50 @@ CKEDITOR.define( [ 'treemodel/position', 'treemodel/positioniterator', 'utils' ]
 			 *
 			 * @property {treeModel.Position}
 			 */
-			this.start = start;
+			this.start = Position.createFromPosition( start );
 
 			/**
 			 * End position.
 			 *
 			 * @property {treeModel.Position}
 			 */
-			this.end = end;
+			this.end = Position.createFromPosition( end );
 		}
 
 		/**
-		 * Range iterator.
+		 * Returns whether the range is collapsed, that is it start and end positions are equal.
 		 *
-		 * @see treeModel.PositionIterator
+		 * @property {Boolean}
 		 */
-		[ Symbol.iterator ]() {
-			return new PositionIterator( this );
+		get isCollapsed() {
+			return this.start.isEqual( this.end );
+		}
+
+		/**
+		 * Returns whether this range is flat, that is if start position and end position are in the same parent.
+		 *
+		 * @returns {Boolean}
+		 */
+		get isFlat() {
+			return this.start.parent === this.end.parent;
+		}
+
+		/**
+		 * Range root element. Equals to the root of start position (which should be same as root of end position).
+		 *
+		 * @property {treeModel.RootElement}
+		 */
+		get root() {
+			return this.start.root;
 		}
 
 		/**
-		 * Creates and returns a new instance of {@link treeModel.Range}
-		 * which is equal to this {@link treeModel.Range range}.
+		 * Range iterator.
 		 *
-		 * @returns {treeModel.Position} Cloned {@link treeModel.Range range}.
+		 * @see treeModel.PositionIterator
 		 */
-		clone() {
-			return new Range( this.start.clone(), this.end.clone() );
+		[ Symbol.iterator ]() {
+			return new PositionIterator( this );
 		}
 
 		/**
@@ -99,33 +117,23 @@ CKEDITOR.define( [ 'treemodel/position', 'treemodel/positioniterator', 'utils' ]
 		getDifference( otherRange ) {
 			const ranges = [];
 
-			if ( this.start.isBefore( otherRange.end ) && this.end.isAfter( otherRange.start ) ) {
+			if ( this.isIntersecting( otherRange ) ) {
 				// Ranges intersect.
 
 				if ( this.containsPosition( otherRange.start ) ) {
 					// Given range start is inside this range. This means that we have to
 					// add shrunken range - from the start to the middle of this range.
-					ranges.push(
-						new Range(
-							this.start.clone(),
-							otherRange.start.clone()
-						)
-					);
+					ranges.push( new Range( this.start, otherRange.start ) );
 				}
 
 				if ( this.containsPosition( otherRange.end ) ) {
 					// Given range end is inside this range. This means that we have to
 					// add shrunken range - from the middle of this range to the end.
-					ranges.push(
-						new Range(
-							otherRange.end.clone(),
-							this.end.clone()
-						)
-					);
+					ranges.push( new Range( otherRange.end, this.end ) );
 				}
 			} else {
 				// Ranges do not intersect, return the original range.
-				ranges.push( this.clone() );
+				ranges.push( Range.createFromRange( this ) );
 			}
 
 			return ranges;
@@ -148,7 +156,7 @@ CKEDITOR.define( [ 'treemodel/position', 'treemodel/positioniterator', 'utils' ]
 		 * @returns {treeModel.Range|null} A common part of given ranges or null if ranges have no common part.
 		 */
 		getIntersection( otherRange ) {
-			if ( this.start.isBefore( otherRange.end ) && this.end.isAfter( otherRange.start ) ) {
+			if ( this.isIntersecting( otherRange ) ) {
 				// Ranges intersect, so a common range will be returned.
 				// At most, it will be same as this range.
 				let commonRangeStart = this.start;
@@ -166,7 +174,7 @@ CKEDITOR.define( [ 'treemodel/position', 'treemodel/positioniterator', 'utils' ]
 					commonRangeEnd = otherRange.end;
 				}
 
-				return new Range( commonRangeStart.clone(), commonRangeEnd.clone() );
+				return new Range( commonRangeStart, commonRangeEnd );
 			}
 
 			// Ranges do not intersect, so they do not have common part.
@@ -174,20 +182,89 @@ CKEDITOR.define( [ 'treemodel/position', 'treemodel/positioniterator', 'utils' ]
 		}
 
 		/**
+		 * Computes and returns the smallest set of {@link #isFlat flat} ranges, that covers this range in whole.
+		 * Assuming that tree model model structure is ("[" and "]" are range boundaries):
+		 *
+		 *		root                                                            root
+		 *		 |- element DIV                         DIV             P2              P3             DIV
+		 *		 |   |- element H                   H        P1        f o o           b a r       H         P4
+		 *		 |   |   |- "fir[st"             fir[st     lorem                               se]cond     ipsum
+		 *		 |   |- element P1
+		 *		 |   |   |- "lorem"                                              ||
+		 *		 |- element P2                                                   ||
+		 *		 |   |- "foo"                                                    VV
+		 *		 |- element P3
+		 *		 |   |- "bar"                                                   root
+		 *		 |- element DIV                         DIV             [P2             P3]             DIV
+		 *		 |   |- element H                   H       [P1]       f o o           b a r        H         P4
+		 *		 |   |   |- "se]cond"            fir[st]    lorem                               [se]cond     ipsum
+		 *		 |   |- element P4
+		 *		 |   |   |- "ipsum"
+		 *
+		 * Flat ranges for range `( [ 0, 0, 3 ], [ 3, 0, 2 ] )` would be:
+		 *
+		 *		( [ 0, 0, 3 ], [ 0, 0, 5 ] ) = "st"
+		 *		( [ 0, 1 ], [ 0, 2 ] ) = element P1
+		 *		( [ 1 ], [ 3 ] ) = element P2, element P3
+		 *		( [ 3, 0, 0 ], [ 3, 0, 2 ] ) = "se"
+		 *
+		 * **Note:** this method is not returning flat ranges that contain no nodes. It may also happen that not-collapsed
+		 * range will return an empty array of flat ranges.
+		 *
+		 * @returns {Array.<treeModel.Range>} Array of flat ranges.
+		 */
+		getMinimalFlatRanges() {
+			let ranges = [];
+
+			// We find on which tree-level start and end have the lowest common ancestor
+			let cmp = utils.compareArrays( this.start.path, this.end.path );
+			let diffAt = ( cmp < 0 ) ? Math.min( this.start.path.length, this.end.path.length ) : cmp;
+
+			let pos = Position.createFromPosition( this.start );
+
+			// go up
+			while ( pos.path.length > diffAt + 1 ) {
+				let howMany = pos.parent.getChildCount() - pos.offset;
+
+				if ( howMany !== 0 ) {
+					ranges.push( new Range( pos, pos.getShiftedBy( howMany ) ) );
+				}
+
+				pos.path = pos.path.slice( 0, -1 );
+				pos.offset++;
+			}
+
+			// go down
+			while ( pos.path.length <= this.end.path.length ) {
+				let offset = this.end.path[ pos.path.length - 1 ];
+				let howMany = offset - pos.offset;
+
+				if ( howMany !== 0 ) {
+					ranges.push( new Range( pos, pos.getShiftedBy( howMany ) ) );
+				}
+
+				pos.offset = offset;
+				pos.path.push( 0 );
+			}
+
+			return ranges;
+		}
+
+		/**
 		 * Returns an iterator that iterates over all {@link treeModel.Node nodes} that are in this range and returns them.
 		 * A node is in the range when it is after a {@link treeModel.Position position} contained in this range.
 		 * In other words, this iterates over all {@link treeModel.Character}s that are inside the range and
 		 * all the {@link treeModel.Element}s we enter into when iterating over this range.
 		 *
-		 * Note, that this method will not return a parent node of start position. This is in contrary to {@link treeModel.PositionIterator}
+		 * **Note:** this method will not return a parent node of start position. This is in contrary to {@link treeModel.PositionIterator}
 		 * which will return that node with {@link treeModel.PositionIterator#ELEMENT_LEAVE} type. This method, also, returns each
 		 * {@link treeModel.Element} once, while iterator return it twice: for {@link treeModel.PositionIterator#ELEMENT_ENTER} and
 		 * {@link treeModel.PositionIterator#ELEMENT_LEAVE}.
 		 *
 		 * @see {treeModel.PositionIterator}
-		 * @returns {treeModel.Node}
+		 * @returns {Iterable.<treeModel.Node>}
 		 */
-		*getNodes() {
+		*getAllNodes() {
 			for ( let value of this ) {
 				if ( value.type != PositionIterator.ELEMENT_LEAVE ) {
 					yield value.node;
@@ -196,6 +273,27 @@ CKEDITOR.define( [ 'treemodel/position', 'treemodel/positioniterator', 'utils' ]
 		}
 
 		/**
+		 * Returns an iterator that iterates over all {@link treeModel.Node nodes} that are top-level nodes in this range
+		 * and returns them. A node is a top-level node when it is in the range but it's parent is not. In other words,
+		 * this function splits the range into separate sub-trees and iterates over their roots.
+		 *
+		 * @returns {Iterable.<treeModel.Node>}
+		 */
+		*getTopLevelNodes() {
+			let flatRanges = this.getMinimalFlatRanges();
+
+			for ( let range of flatRanges ) {
+				let node = range.start.nodeAfter;
+				let offset = range.end.offset - range.start.offset;
+
+				for ( let i = 0; i < offset; i++ ) {
+					yield node;
+					node = node.nextSibling;
+				}
+			}
+		}
+
+		/**
 		 * Returns an array containing one or two {treeModel.Range ranges} that are a result of transforming this
 		 * {@link treeModel.Range range} by inserting `howMany` nodes at `insertPosition`. Two {@link treeModel.Range ranges} are
 		 * returned if the insertion was inside this {@link treeModel.Range range}.
@@ -229,10 +327,7 @@ CKEDITOR.define( [ 'treemodel/position', 'treemodel/positioniterator', 'utils' ]
 				// insertion to reflect insertion changes.
 
 				return [
-					new Range(
-						this.start.clone(),
-						insertPosition.clone()
-					),
+					new Range( this.start, insertPosition ),
 					new Range(
 						insertPosition.getTransformedByInsertion( insertPosition, howMany, true ),
 						this.end.getTransformedByInsertion( insertPosition, howMany, true )
@@ -242,7 +337,7 @@ CKEDITOR.define( [ 'treemodel/position', 'treemodel/positioniterator', 'utils' ]
 				// If insertion is not inside the range, simply transform range boundaries (positions) by the insertion.
 				// Both, one or none of them might be affected by the insertion.
 
-				const range = this.clone();
+				const range = Range.createFromRange( this );
 
 				range.start = range.start.getTransformedByInsertion( insertPosition, howMany, true );
 				range.end = range.end.getTransformedByInsertion( insertPosition, howMany, false );
@@ -262,13 +357,23 @@ CKEDITOR.define( [ 'treemodel/position', 'treemodel/positioniterator', 'utils' ]
 		}
 
 		/**
+		 * Checks and returns whether this range intersects with given range.
+		 *
+		 * @param {treeModel.Range} otherRange Range to compare with.
+		 * @returns {Boolean} True if ranges intersect.
+		 */
+		isIntersecting( otherRange ) {
+			return this.start.isBefore( otherRange.end ) && this.end.isAfter( otherRange.start );
+		}
+
+		/**
 		 * Creates a range inside an element which starts before the first child and ends after the last child.
 		 *
 		 * @param {treeModel.Element} element Element which is a parent for the range.
 		 * @returns {treeModel.Range} Created range.
 		 */
 		static createFromElement( element ) {
-			return Range.createFromParentsAndOffsets( element, 0, element, element.getChildCount() );
+			return this.createFromParentsAndOffsets( element, 0, element, element.getChildCount() );
 		}
 
 		/**
@@ -279,10 +384,7 @@ CKEDITOR.define( [ 'treemodel/position', 'treemodel/positioniterator', 'utils' ]
 		 * @returns {treeModel.Range}
 		 */
 		static createFromPositionAndShift( position, shift ) {
-			let endPosition = position.clone();
-			endPosition.offset += shift;
-
-			return new Range( position, endPosition );
+			return new this( position, position.getShiftedBy( shift ) );
 		}
 
 		/**
@@ -295,11 +397,21 @@ CKEDITOR.define( [ 'treemodel/position', 'treemodel/positioniterator', 'utils' ]
 		 * @returns {treeModel.Range} Created range.
 		 */
 		static createFromParentsAndOffsets( startElement, startOffset, endElement, endOffset ) {
-			return new Range(
+			return new this(
 				Position.createFromParentAndOffset( startElement, startOffset ),
 				Position.createFromParentAndOffset( endElement, endOffset )
 			);
 		}
+
+		/**
+		 * Creates and returns a new instance of Range which is equal to passed range.
+		 *
+		 * @param {treeModel.Range} range Range to clone.
+		 * @returns {treeModel.Range}
+		 */
+		static createFromRange( range ) {
+			return new this( range.start, range.end );
+		}
 	}
 
 	return Range;

+ 248 - 0
packages/ckeditor5-engine/src/treemodel/selection.js

@@ -0,0 +1,248 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+CKEDITOR.define( [
+	'treemodel/liverange',
+	'treemodel/attributelist',
+	'emittermixin',
+	'utils',
+	'ckeditorerror'
+], ( LiveRange, AttributeList, EmitterMixin, utils, CKEditorError ) => {
+	/**
+	 * Represents a selection that is made on nodes in {@link treeModel.Document}. Selection instance is
+	 * created by {@link treeModel.Document}. In most scenarios you should not need to create an instance of Selection.
+	 *
+	 * @class treeModel.Selection
+	 */
+	class Selection {
+		/**
+		 * Creates an empty selection.
+		 *
+		 * @constructor
+		 */
+		constructor() {
+			/**
+			 * List of attributes set on current selection.
+			 *
+			 * @private
+			 * @property {treeModel.AttributeList} _attrs
+			 */
+			this._attrs = new AttributeList();
+
+			/**
+			 * Stores all ranges that are selected.
+			 *
+			 * @private
+			 * @property {Array.<LiveRange>}
+			 */
+			this._ranges = [];
+
+			/**
+			 * Specifies whether the last added range was added as a backward or forward range.
+			 *
+			 * @private
+			 * @property {Boolean}
+			 */
+			this._lastRangeBackward = false;
+		}
+
+		/**
+		 * Selection anchor. Anchor may be described as a position where the selection starts.
+		 * Together with {@link #focus} they define the direction of selection, which is important
+		 * when expanding/shrinking selection. When there are no ranges in selection anchor is null.
+		 * Anchor is always a start or end of the most recent added range. It may be a bit unintuitive when
+		 * there are multiple ranges in selection.
+		 *
+		 * @property {treeModel.LivePosition|null}
+		 */
+		get anchor() {
+			if ( this._ranges.length > 0 ) {
+				let range = this._ranges[ this._ranges.length - 1 ];
+
+				return this._lastRangeBackward ? range.end : range.start;
+			}
+
+			return null;
+		}
+
+		/**
+		 * Selection focus. Focus is a position where the selection ends. When there are no ranges in selection,
+		 * focus is null.
+		 *
+		 * @see {#anchor}
+		 * @property {treeModel.LivePosition|null}
+		 */
+		get focus() {
+			if ( this._ranges.length > 0 ) {
+				let range = this._ranges[ this._ranges.length - 1 ];
+
+				return this._lastRangeBackward ? range.start : range.end;
+			}
+
+			return null;
+		}
+
+		/**
+		 * Returns whether the selection is collapsed. Selection is collapsed when all it's ranges are collapsed.
+		 *
+		 * @property {Boolean}
+		 */
+		get isCollapsed() {
+			for ( let i = 0; i < this._ranges.length; i++ ) {
+				if ( !this._ranges[ i ].isCollapsed ) {
+					return false;
+				}
+			}
+
+			return true;
+		}
+
+		/**
+		 * Adds a range to the selection. Added range is copied and converted to {@link treeModel.LiveRange}. This means
+		 * that passed range is not saved in the Selection instance and you can safely operate on it. Accepts a flag
+		 * describing in which way the selection is made - passed range might be selected from {@link treeModel.Range#start}
+		 * to {@link treeModel.Range#end} or from {@link treeModel.Range#start} to {@link treeModel.Range#end}. The flag
+		 * is used to set {@link #anchor} and {@link #focus} properties.
+		 *
+		 * @param {treeModel.Range} range Range to add.
+		 * @param {Boolean} [isBackward] Flag describing if added range was selected forward - from start to end (`false`)
+		 * or backward - from end to start (`true`). Defaults to `false`.
+		 */
+		addRange( range, isBackward ) {
+			pushRange.call( this, range );
+			this._lastRangeBackward = !!isBackward;
+
+			this.fire( 'update' );
+		}
+
+		/**
+		 * Unbinds all events previously bound by this selection and objects created by this selection.
+		 */
+		detach() {
+			for ( let i = 0; i < this._ranges.length; i++ ) {
+				this._ranges[ i ].detach();
+			}
+		}
+
+		/**
+		 * @see {@link treeModel.AttributeList#getAttr}
+		 */
+		getAttr( key ) {
+			return this._attrs.getAttr( key );
+		}
+
+		/**
+		 * @see {@link treeModel.AttributeList#getAttrs}
+		 */
+		getAttrs() {
+			return this._attrs.getAttrs();
+		}
+
+		/**
+		 * Returns an array of ranges added to the selection. The method returns a copy of internal array, so
+		 * it will not change when ranges get added or removed from selection.
+		 *
+		 * @returns {Array.<LiveRange>}
+		 */
+		getRanges() {
+			return this._ranges.slice();
+		}
+
+		/**
+		 * @see {@link treeModel.AttributeList#hasAttr}
+		 */
+		hasAttr( key ) {
+			return this._attrs.hasAttr( key );
+		}
+
+		/**
+		 * @see {@link treeModel.AttributeList#removeAttr}
+		 */
+		removeAttr( key ) {
+			this._attrs.removeAttr( key );
+		}
+
+		/**
+		 * Removes all ranges that were added to the selection. Fires update event.
+		 */
+		removeAllRanges() {
+			this.detach();
+			this._ranges = [];
+
+			this.fire( 'update' );
+		}
+
+		/**
+		 * @see {@link treeModel.AttributeList#setAttr}
+		 */
+		setAttr( attr ) {
+			this._attrs.setAttr( attr );
+		}
+
+		/**
+		 * @see {@link treeModel.AttributeList#setAttrsTo}
+		 */
+		setAttrsTo( attrs ) {
+			this._attrs.setAttrsTo( attrs );
+		}
+
+		/**
+		 * Replaces all ranges that were added to the selection with given array of ranges. Last range of the array
+		 * is treated like the last added range and is used to set {@link #anchor} and {@link #focus}. Accepts a flag
+		 * describing in which way the selection is made (see {@link #addRange}).
+		 *
+		 * @param {Array.<treeModel.Range>} newRanges Array of ranges to set.
+		 * @param {Boolean} [isLastBackward] Flag describing if last added range was selected forward - from start to end (`false`)
+		 * or backward - from end to start (`true`). Defaults to `false`.
+		 */
+		setRanges( newRanges, isLastBackward ) {
+			this.detach();
+			this._ranges = [];
+
+			for ( let i = 0; i < newRanges.length; i++ ) {
+				pushRange.call( this, newRanges[ i ] );
+			}
+
+			this._lastRangeBackward = !!isLastBackward;
+			this.fire( 'update' );
+		}
+	}
+
+	/**
+	 * Converts given range to {@link treeModel.LiveRange} and adds it to internal ranges array. Throws an error
+	 * if given range is intersecting with any range that is already stored in this selection.
+	 *
+	 * @private
+	 * @method pushRange
+	 * @memberOf {treeModel.Selection}
+	 * @param {treeModel.Range} range Range to add.
+	 */
+	function pushRange( range ) {
+		/* jshint validthis: true */
+		for ( let i = 0; i < this._ranges.length ; i++ ) {
+			if ( range.isIntersecting( this._ranges[ i ] ) ) {
+				/**
+				 * Trying to add a range that intersects with another range from selection.
+				 *
+				 * @error selection-range-intersects
+				 * @param {treeModel.Range} addedRange Range that was added to the selection.
+				 * @param {treeModel.Range} intersectingRange Range from selection that intersects with `addedRange`.
+				 */
+				throw new CKEditorError(
+					'selection-range-intersects: Trying to add a range that intersects with another range from selection.',
+					{ addedRange: range, intersectingRange: this._ranges[ i ] }
+				);
+			}
+		}
+
+		this._ranges.push( LiveRange.createFromRange( range ) );
+	}
+
+	utils.extend( Selection.prototype, EmitterMixin );
+
+	return Selection;
+} );

+ 0 - 82
packages/ckeditor5-engine/src/treemodel/smartrange.js

@@ -1,82 +0,0 @@
-/**
- * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-'use strict';
-
-CKEDITOR.define( [ 'treemodel/range', 'emittermixin', 'utils' ], ( Range, EmitterMixin, utils ) => {
-	/**
-	 * SmartRange is a Range in the Tree Model that updates itself as the tree changes. It may be used as a bookmark.
-	 * SmartRange object may fire 'update' event whenever it gets changed by internal mechanisms.
-	 *
-	 * @class treeModel.SmartRange
-	 */
-	class SmartRange extends Range {
-		/**
-		 * Creates a smart range.
-		 *
-		 * @see {treeModel.Range}
-		 * @constructor
-		 */
-		constructor( start, end ) {
-			super( start, end );
-
-			this.listenTo( this.root.document, 'update', transform, this );
-		}
-	}
-
-	/**
-	 * Updates this position accordingly to the updates applied to the Tree Model. Bases on change events.
-	 *
-	 * @method transform
-	 * @param {String} type Type of changes applied to the Tree Model.
-	 * @param {treeModel.Range} range Range containing the result of applied change.
-	 * @param {treeModel.Position} [position] Additional position parameter provided by some change events.
-	 * @private
-	 */
-	function transform( type, range, position ) {
-		/*jshint validthis: true */
-
-		let howMany = range.end.offset - range.start.offset;
-		let newStart, newEnd;
-
-		switch ( type ) {
-			case 'insert':
-				newStart = this.start.getTransformedByInsertion( range.start, howMany, true );
-				newEnd = this.end.getTransformedByInsertion( range.start, howMany, false );
-				break;
-
-			case 'move':
-			case 'remove':
-			case 'reinsert':
-				let differenceSet = this.getDifference( Range.createFromPositionAndShift( position, howMany ) );
-
-				if ( differenceSet.length > 0 ) {
-					let diff = differenceSet[ 0 ];
-
-					if ( differenceSet.length > 1 ) {
-						diff.end = differenceSet[ 1 ].end.clone();
-					}
-
-					newStart = diff.start.getTransformedByDeletion( position, howMany ).getTransformedByInsertion( range.start, howMany );
-					newEnd = diff.end.getTransformedByDeletion( position, howMany ).getTransformedByInsertion( range.start, howMany );
-				} else {
-					newStart = this.start._getCombined( position, range.start );
-					newEnd = this.end._getCombined( position, range.start );
-				}
-
-				break;
-		}
-
-		if ( !newStart.isEqual( this.start ) || !newEnd.isEqual( this.end ) ) {
-			this.start = newStart;
-			this.end = newEnd;
-			this.fire( 'update' );
-		}
-	}
-
-	utils.extend( SmartRange.prototype, EmitterMixin );
-
-	return SmartRange;
-} );

+ 28 - 0
packages/ckeditor5-engine/tests/_tools/tools.js

@@ -63,4 +63,32 @@
 			return count;
 		}
 	};
+
+	bender.tools.treemodel = {
+		/**
+		 * Returns tree structure as a simplified string. Elements are uppercase and characters are lowercase.
+		 * Start and end of an element is marked the same way, by the element's name (in uppercase).
+		 *
+		 *		let element = new Element( 'div', [], [ 'abc', new Element( 'p', [], 'foo' ), 'xyz' ] );
+		 *		bender.tools.treemodel.getNodesAndText( element ); // abcPfooPxyz
+		 *
+		 * @param {treeModel.Range} range Range to stringify.
+		 * @returns {String} String representing element inner structure.
+		 */
+		getNodesAndText: ( range ) => {
+			let txt = '';
+
+			for ( let step of range ) {
+				let node = step.node;
+
+				if ( node.character ) {
+					txt += node.character.toLowerCase();
+				} else if ( node.name ) {
+					txt += node.name.toUpperCase();
+				}
+			}
+
+			return txt;
+		}
+	};
 } )();

+ 12 - 0
packages/ckeditor5-engine/tests/emittermixin.js

@@ -122,6 +122,18 @@ describe( 'fire', () => {
 
 		sinon.assert.calledThrice( spy );
 	} );
+
+	it( 'should not fire callbacks for an event that were added while firing that event', () => {
+		let spy = sinon.spy();
+
+		emitter.on( 'test', () => {
+			emitter.on( 'test', spy );
+		} );
+
+		emitter.fire( 'test' );
+
+		sinon.assert.notCalled( spy );
+	} );
 } );
 
 describe( 'on', () => {

+ 161 - 0
packages/ckeditor5-engine/tests/treemodel/attributelist.js

@@ -0,0 +1,161 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel */
+
+/* bender-include: ../_tools/tools.js */
+
+'use strict';
+
+const getIteratorCount = bender.tools.core.getIteratorCount;
+
+const modules = bender.amd.require(
+	'treemodel/attributelist',
+	'treemodel/attribute',
+	'utils'
+);
+
+describe( 'AttributeList', () => {
+	let AttributeList, Attribute, utils;
+
+	before( () => {
+		AttributeList = modules[ 'treemodel/attributelist' ];
+		Attribute = modules[ 'treemodel/attribute' ];
+		utils = modules.utils;
+	} );
+
+	let list, attrFooBar;
+
+	beforeEach( () => {
+		list = new AttributeList();
+		attrFooBar = new Attribute( 'foo', 'bar' );
+	} );
+
+	describe( 'setAttr', () => {
+		it( 'should insert an attribute', () => {
+			list.setAttr( attrFooBar );
+
+			expect( getIteratorCount( list.getAttrs() ) ).to.equal( 1 );
+			expect( list.getAttr( attrFooBar.key ) ).to.equal( attrFooBar.value );
+		} );
+
+		it( 'should overwrite attribute with the same key', () => {
+			list.setAttr( attrFooBar );
+
+			expect( getIteratorCount( list.getAttrs() ) ).to.equal( 1 );
+			expect( list.getAttr( 'foo' ) ).to.equal( 'bar' );
+
+			let attrFooXyz = new Attribute( 'foo', 'xyz' );
+
+			list.setAttr( attrFooXyz );
+
+			expect( getIteratorCount( list.getAttrs() ) ).to.equal( 1 );
+			expect( list.getAttr( 'foo' ) ).to.equal( 'xyz' );
+		} );
+	} );
+
+	describe( 'setAttrsTo', () => {
+		it( 'should remove all attributes and set passed ones', () => {
+			list.setAttr( attrFooBar );
+
+			let attrs = [ new Attribute( 'abc', true ), new Attribute( 'xyz', false ) ];
+
+			list.setAttrsTo( attrs );
+
+			expect( getIteratorCount( list.getAttrs() ) ).to.equal( 2 );
+			expect( list.getAttr( 'foo' ) ).to.be.null;
+			expect( list.getAttr( 'abc' ) ).to.be.true;
+			expect( list.getAttr( 'xyz' ) ).to.be.false;
+		} );
+
+		it( 'should copy attributes array, not pass by reference', () => {
+			let attrs = [ new Attribute( 'attr', true ) ];
+
+			list.setAttrsTo( attrs );
+
+			attrs.pop();
+
+			expect( getIteratorCount( list.getAttrs() ) ).to.equal( 1 );
+		} );
+	} );
+
+	describe( 'getAttr', () => {
+		beforeEach( () => {
+			list.setAttr( attrFooBar );
+		} );
+
+		it( 'should return attribute value if key of previously set attribute has been passed', () => {
+			expect( list.getAttr( 'foo' ) ).to.equal( attrFooBar.value );
+		} );
+
+		it( 'should return null if attribute with given key has not been found', () => {
+			expect( list.getAttr( 'bar' ) ).to.be.null;
+		} );
+	} );
+
+	describe( 'removeAttr', () => {
+		it( 'should remove an attribute', () => {
+			let attrA = new Attribute( 'a', 'A' );
+			let attrB = new Attribute( 'b', 'B' );
+			let attrC = new Attribute( 'c', 'C' );
+
+			list.setAttr( attrA );
+			list.setAttr( attrB );
+			list.setAttr( attrC );
+
+			list.removeAttr( attrB.key );
+
+			expect( getIteratorCount( list.getAttrs() ) ).to.equal( 2 );
+			expect( list.getAttr( attrA.key ) ).to.equal( attrA.value );
+			expect( list.getAttr( attrC.key ) ).to.equal( attrC.value );
+			expect( list.getAttr( attrB.key ) ).to.be.null;
+		} );
+	} );
+
+	describe( 'hasAttr', () => {
+		it( 'should check attribute by key', () => {
+			list.setAttr( attrFooBar );
+			expect( list.hasAttr( 'foo' ) ).to.be.true;
+		} );
+
+		it( 'should return false if attribute was not found by key', () => {
+			expect( list.hasAttr( 'bar' ) ).to.be.false;
+		} );
+
+		it( 'should check attribute by object', () => {
+			list.setAttr( attrFooBar );
+			expect( list.hasAttr( attrFooBar ) ).to.be.true;
+		} );
+
+		it( 'should return false if attribute was not found by object', () => {
+			expect( list.hasAttr( attrFooBar ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'getAttrs', () => {
+		it( 'should return all set attributes', () => {
+			let attrA = new Attribute( 'a', 'A' );
+			let attrB = new Attribute( 'b', 'B' );
+			let attrC = new Attribute( 'c', 'C' );
+
+			list.setAttrsTo( [
+				attrA,
+				attrB,
+				attrC
+			] );
+
+			list.removeAttr( attrB.key );
+
+			let attrsIt = list.getAttrs();
+			let attrs = [];
+
+			for ( let attr of attrsIt ) {
+				attrs.push( attr );
+			}
+
+			expect( [ attrA, attrC ] ).to.deep.equal( attrs );
+		} );
+	} );
+} );

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

@@ -150,7 +150,7 @@ describe( 'Batch', () => {
 
 			for ( let delta of batch.deltas ) {
 				for ( let operation of delta.operations ) {
-					count += getIteratorCount( operation.range.getNodes() );
+					count += getIteratorCount( operation.range.getAllNodes() );
 				}
 			}
 
@@ -161,7 +161,7 @@ describe( 'Batch', () => {
 			// default: 111---111222---1112------
 			const range = Range.createFromElement( root );
 
-			return Array.from( range.getNodes() ).map( node => node.getAttr( 'a' ) || '-' ).join( '' );
+			return Array.from( range.getAllNodes() ).map( node => node.getAttr( 'a' ) || '-' ).join( '' );
 		}
 
 		describe( 'setAttr', () => {

+ 24 - 17
packages/ckeditor5-engine/tests/treemodel/delta/insertdelta.js

@@ -9,38 +9,45 @@
 
 const modules = bender.amd.require(
 	'treemodel/document',
-	'treemodel/position' );
+	'treemodel/element',
+	'treemodel/position',
+	'treemodel/delta/insertdelta'
+);
 
 describe( 'Batch', () => {
-	let Document, Position;
-
-	let doc, root;
+	let Document, Element, Position, InsertDelta;
 
 	before( () => {
 		Document = modules[ 'treemodel/document' ];
+		Element = modules[ 'treemodel/element' ];
 		Position = modules[ 'treemodel/position' ];
+		InsertDelta = modules[ 'treemodel/delta/insertdelta' ];
 	} );
 
+	let doc, root, batch, p, ul, chain;
+
 	beforeEach( () => {
 		doc = new Document();
 		root = doc.createRoot( 'root' );
-	} );
+		root.insertChildren( 0, 'abc' );
+
+		batch = doc.batch();
 
-	it( 'should insert text', () => {
-		const position = new Position( root, [ 0 ] );
-		doc.batch().insert( position, 'foo' );
+		p = new Element( 'p' );
+		ul = new Element( 'ul' );
 
-		expect( root.getChildCount() ).to.equal( 3 );
-		expect( root.getChild( 0 ).character ).to.equal( 'f' );
-		expect( root.getChild( 1 ).character ).to.equal( 'o' );
-		expect( root.getChild( 2 ).character ).to.equal( 'o' );
+		chain = batch.insert( new Position( root, [ 2 ] ), [ p, ul ] );
 	} );
 
-	it( 'should be chainable', () => {
-		const position = new Position( root, [ 0 ] );
-		const batch = doc.batch();
+	describe( 'insert', () => {
+		it( 'should insert given nodes at given position', () => {
+			expect( root.getChildCount() ).to.equal( 5 );
+			expect( root.getChild( 2 ) ).to.equal( p );
+			expect( root.getChild( 3 ) ).to.equal( ul );
+		} );
 
-		const chain = batch.insert( position, 'foo' );
-		expect( chain ).to.equal( batch );
+		it( 'should be chainable', () => {
+			expect( chain ).to.equal( batch );
+		} );
 	} );
 } );

+ 80 - 0
packages/ckeditor5-engine/tests/treemodel/delta/movedelta.js

@@ -0,0 +1,80 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, delta */
+/* bender-include: ../../_tools/tools.js */
+
+'use strict';
+
+const getNodesAndText = bender.tools.treemodel.getNodesAndText;
+
+const modules = bender.amd.require(
+	'treemodel/document',
+	'treemodel/position',
+	'treemodel/range',
+	'treemodel/element',
+	'ckeditorerror'
+);
+
+describe( 'Batch', () => {
+	let Document, Position, Range, Element, CKEditorError;
+
+	let doc, root, div, p, batch, chain;
+
+	before( () => {
+		Document = modules[ 'treemodel/document' ];
+		Position = modules[ 'treemodel/position' ];
+		Range = modules[ 'treemodel/range' ];
+		Element = modules[ 'treemodel/element' ];
+		CKEditorError = modules.ckeditorerror;
+	} );
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+
+		div = new Element( 'div', [], 'foobar' );
+		p = new Element( 'p', [], 'abcxyz' );
+
+		div.insertChildren( 4, [ new Element( 'p', [], 'gggg' ) ] );
+		div.insertChildren( 2, [ new Element( 'p', [], 'hhhh' ) ] );
+
+		root.insertChildren( 0, [ div, p ] );
+
+		batch = doc.batch();
+	} );
+
+	describe( 'move', () => {
+		it( 'should move specified node', () => {
+			batch.move( div, new Position( root, [ 2 ] ) );
+
+			expect( root.getChildCount() ).to.equal( 2 );
+			expect( getNodesAndText( Range.createFromElement( root.getChild( 0 ) ) ) ).to.equal( 'abcxyz' );
+			expect( getNodesAndText( Range.createFromElement( root.getChild( 1 ) ) ) ).to.equal( 'foPhhhhPobPggggPar' );
+		} );
+
+		it( 'should move flat range of nodes', () => {
+			let range = new Range( new Position( root, [ 0, 3 ] ), new Position( root, [ 0, 7 ] ) );
+			batch.move( range, new Position( root, [ 1, 3 ] ) );
+
+			expect( getNodesAndText( Range.createFromElement( root.getChild( 0 ) ) ) ).to.equal( 'foPhhhhPr' );
+			expect( getNodesAndText( Range.createFromElement( root.getChild( 1 ) ) ) ).to.equal( 'abcobPggggPaxyz' );
+		} );
+
+		it( 'should throw if given range is not flat', () => {
+			let notFlatRange = new Range( new Position( root, [ 0, 2, 2 ] ), new Position( root, [ 0, 6 ] ) );
+
+			expect( () => {
+				doc.batch().move( notFlatRange, new Position( root, [ 1, 3 ] ) );
+			} ).to.throw( CKEditorError, /^batch-move-range-not-flat/ );
+		} );
+
+		it( 'should be chainable', () => {
+			chain = batch.move( div, new Position( root, [ 1, 3 ] ) );
+
+			expect( chain ).to.equal( batch );
+		} );
+	} );
+} );

+ 42 - 24
packages/ckeditor5-engine/tests/treemodel/delta/removedelta.js

@@ -4,57 +4,75 @@
  */
 
 /* bender-tags: treemodel, delta */
+/* bender-include: ../../_tools/tools.js */
 
 'use strict';
 
+const getNodesAndText = bender.tools.treemodel.getNodesAndText;
+
 const modules = bender.amd.require(
 	'treemodel/document',
-	'treemodel/position' );
+	'treemodel/position',
+	'treemodel/range',
+	'treemodel/element'
+);
 
 describe( 'Batch', () => {
-	let Document, Position;
+	let Document, Position, Range, Element;
 
-	let doc, root;
+	let doc, root, div, p, batch, chain, range;
 
 	before( () => {
 		Document = modules[ 'treemodel/document' ];
 		Position = modules[ 'treemodel/position' ];
+		Range = modules[ 'treemodel/range' ];
+		Element = modules[ 'treemodel/element' ];
 	} );
 
 	beforeEach( () => {
 		doc = new Document();
 		root = doc.createRoot( 'root' );
-		root.insertChildren( 0, 'foobar' );
+
+		div = new Element( 'div', [], 'foobar' );
+		p = new Element( 'p', [], 'abcxyz' );
+
+		div.insertChildren( 4, [ new Element( 'p', [], 'gggg' ) ] );
+		div.insertChildren( 2, [ new Element( 'p', [], 'hhhh' ) ] );
+
+		root.insertChildren( 0, [ div, p ] );
+
+		batch = doc.batch();
+
+		// Range starts in ROOT > DIV > P > gg|gg.
+		// Range ends in ROOT > DIV > ...|ar.
+		range = new Range( new Position( root, [ 0, 2, 2 ] ), new Position( root, [ 0, 6 ] ) );
 	} );
 
 	describe( 'remove', () => {
-		it( 'should remove one element', () => {
-			const position = new Position( root, [ 1 ] );
-			doc.batch().remove( position );
-
-			expect( root.getChildCount() ).to.equal( 5 );
-			expect( root.getChild( 0 ).character ).to.equal( 'f' );
-			expect( root.getChild( 1 ).character ).to.equal( 'o' );
-			expect( root.getChild( 2 ).character ).to.equal( 'b' );
-			expect( root.getChild( 3 ).character ).to.equal( 'a' );
-			expect( root.getChild( 4 ).character ).to.equal( 'r' );
+		it( 'should remove specified node', () => {
+			batch.remove( div );
+
+			expect( root.getChildCount() ).to.equal( 1 );
+			expect( getNodesAndText( Range.createFromElement( root.getChild( 0 ) ) ) ).to.equal( 'abcxyz' );
+		} );
+
+		it( 'should move any range of nodes', () => {
+			batch.remove( range );
+
+			expect( getNodesAndText( Range.createFromElement( root.getChild( 0 ) ) ) ).to.equal( 'foPhhPar' );
+			expect( getNodesAndText( Range.createFromElement( root.getChild( 1 ) ) ) ).to.equal( 'abcxyz' );
 		} );
 
-		it( 'should remove 3 elements', () => {
-			const position = new Position( root, [ 1 ] );
-			doc.batch().remove( position, 3 );
+		it( 'should create minimal number of operations when removing a range', () => {
+			batch.remove( range );
 
-			expect( root.getChildCount() ).to.equal( 3 );
-			expect( root.getChild( 0 ).character ).to.equal( 'f' );
-			expect( root.getChild( 1 ).character ).to.equal( 'a' );
-			expect( root.getChild( 2 ).character ).to.equal( 'r' );
+			expect( batch.deltas.length ).to.equal( 1 );
+			expect( batch.deltas[ 0 ].operations.length ).to.equal( 2 );
 		} );
 
 		it( 'should be chainable', () => {
-			const position = new Position( root, [ 1 ] );
-			const batch = doc.batch();
+			chain = batch.remove( range );
 
-			const chain = batch.remove( position );
 			expect( chain ).to.equal( batch );
 		} );
 	} );

+ 67 - 0
packages/ckeditor5-engine/tests/treemodel/delta/unwrapdelta.js

@@ -0,0 +1,67 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, delta */
+
+/* bender-include: ../../_tools/tools.js */
+
+'use strict';
+
+const modules = bender.amd.require(
+	'treemodel/document',
+	'treemodel/position',
+	'treemodel/range',
+	'treemodel/element',
+	'ckeditorerror'
+);
+
+describe( 'Batch', () => {
+	let Document, Position, Element, CKEditorError;
+
+	let doc, root, p;
+
+	before( () => {
+		Document = modules[ 'treemodel/document' ];
+		Position = modules[ 'treemodel/position' ];
+		Element = modules[ 'treemodel/element' ];
+		CKEditorError = modules.ckeditorerror;
+	} );
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+
+		p = new Element( 'p', [], 'xyz' );
+		root.insertChildren( 0, [ 'a', p, 'b' ] );
+	} );
+
+	describe( 'unwrap', () => {
+		it( 'should unwrap given element', () => {
+			doc.batch().unwrap( p );
+
+			expect( root.getChildCount() ).to.equal( 5 );
+			expect( root.getChild( 0 ).character ).to.equal( 'a' );
+			expect( root.getChild( 1 ).character ).to.equal( 'x' );
+			expect( root.getChild( 2 ).character ).to.equal( 'y' );
+			expect( root.getChild( 3 ).character ).to.equal( 'z' );
+			expect( root.getChild( 4 ).character ).to.equal( 'b' );
+		} );
+
+		it( 'should throw if element to unwrap has no parent', () => {
+			let element = new Element( 'p' );
+
+			expect( () => {
+				doc.batch().unwrap( element );
+			} ).to.throw( CKEditorError, /^batch-unwrap-element-no-parent/ );
+		} );
+
+		it( 'should be chainable', () => {
+			const batch = doc.batch();
+
+			const chain = batch.unwrap( p );
+			expect( chain ).to.equal( batch );
+		} );
+	} );
+} );

+ 63 - 0
packages/ckeditor5-engine/tests/treemodel/delta/weakinsertdelta.js

@@ -0,0 +1,63 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, delta */
+
+'use strict';
+
+const modules = bender.amd.require(
+	'treemodel/document',
+	'treemodel/attribute',
+	'treemodel/position'
+);
+
+describe( 'Batch', () => {
+	let Document, Attribute, Position;
+
+	before( () => {
+		Document = modules[ 'treemodel/document' ];
+		Attribute = modules[ 'treemodel/attribute' ];
+		Position = modules[ 'treemodel/position' ];
+	} );
+
+	let doc, root, batch, chain, attrs;
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+
+		root.insertChildren( 0, 'abc' );
+
+		batch = doc.batch();
+
+		attrs = [
+			new Attribute( 'bold', true ),
+			new Attribute( 'foo', 'bar' )
+		];
+
+		doc.selection.setAttrsTo( attrs );
+
+		chain = batch.weakInsert( new Position( root, [ 2 ] ), 'xyz' );
+	} );
+
+	describe( 'insert', () => {
+		it( 'should insert given nodes at given position', () => {
+			expect( root.getChildCount() ).to.equal( 6 );
+			expect( root.getChild( 2 ).character ).to.equal( 'x' );
+			expect( root.getChild( 3 ).character ).to.equal( 'y' );
+			expect( root.getChild( 4 ).character ).to.equal( 'z' );
+		} );
+
+		it( 'should set inserted nodes attributes to same as current selection attributes', () => {
+			expect( Array.from( root.getChild( 2 ).getAttrs() ) ).to.deep.equal( attrs );
+			expect( Array.from( root.getChild( 3 ).getAttrs() ) ).to.deep.equal( attrs );
+			expect( Array.from( root.getChild( 4 ).getAttrs() ) ).to.deep.equal( attrs );
+		} );
+
+		it( 'should be chainable', () => {
+			expect( chain ).to.equal( batch );
+		} );
+	} );
+} );

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

@@ -0,0 +1,103 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel, delta */
+
+/* bender-include: ../../_tools/tools.js */
+
+'use strict';
+
+const modules = bender.amd.require(
+	'treemodel/document',
+	'treemodel/position',
+	'treemodel/range',
+	'treemodel/element',
+	'ckeditorerror'
+);
+
+describe( 'Batch', () => {
+	let Document, Position, Range, Element, CKEditorError;
+
+	let doc, root, range;
+
+	before( () => {
+		Document = modules[ 'treemodel/document' ];
+		Position = modules[ 'treemodel/position' ];
+		Range = modules[ 'treemodel/range' ];
+		Element = modules[ 'treemodel/element' ];
+		CKEditorError = modules.ckeditorerror;
+	} );
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+
+		root.insertChildren( 0, 'foobar' );
+
+		range = new Range( new Position( root, [ 2 ] ), new Position( root, [ 4 ] ) );
+	} );
+
+	describe( 'wrap', () => {
+		it( 'should wrap flat range with given element', () => {
+			let p = new Element( 'p' );
+			doc.batch().wrap( range, p );
+
+			expect( root.getChildCount() ).to.equal( 5 );
+			expect( root.getChild( 0 ).character ).to.equal( 'f' );
+			expect( root.getChild( 1 ).character ).to.equal( 'o' );
+			expect( root.getChild( 2 ) ).to.equal( p );
+			expect( p.getChild( 0 ).character ).to.equal( 'o' );
+			expect( p.getChild( 1 ).character ).to.equal( 'b' );
+			expect( root.getChild( 3 ).character ).to.equal( 'a' );
+			expect( root.getChild( 4 ).character ).to.equal( 'r' );
+		} );
+
+		it( 'should wrap flat range with an element of given name', () => {
+			doc.batch().wrap( range, 'p' );
+
+			expect( root.getChildCount() ).to.equal( 5 );
+			expect( root.getChild( 0 ).character ).to.equal( 'f' );
+			expect( root.getChild( 1 ).character ).to.equal( 'o' );
+			expect( root.getChild( 2 ).name ).to.equal( 'p' );
+			expect( root.getChild( 2 ).getChild( 0 ).character ).to.equal( 'o' );
+			expect( root.getChild( 2 ).getChild( 1 ).character ).to.equal( 'b' );
+			expect( root.getChild( 3 ).character ).to.equal( 'a' );
+			expect( root.getChild( 4 ).character ).to.equal( 'r' );
+		} );
+
+		it( 'should throw if range to wrap is not flat', () => {
+			root.insertChildren( 6, [ new Element( 'p', [], 'xyz' ) ] );
+			let notFlatRange = new Range( new Position( root, [ 3 ] ), new Position( root, [ 6, 2 ] ) );
+
+			expect( () => {
+				doc.batch().wrap( notFlatRange, 'p' );
+			} ).to.throw( CKEditorError, /^batch-wrap-range-not-flat/ );
+		} );
+
+		it( 'should throw if element to wrap with has children', () => {
+			let p = new Element( 'p', [], 'a' );
+
+			expect( () => {
+				doc.batch().wrap( range, p );
+			} ).to.throw( CKEditorError, /^batch-wrap-element-not-empty/ );
+		} );
+
+		it( 'should throw if element to wrap with has children', () => {
+			let p = new Element( 'p' );
+			root.insertChildren( 0, p );
+
+			expect( () => {
+				doc.batch().wrap( range, p );
+			} ).to.throw( CKEditorError, /^batch-wrap-element-attached/ );
+		} );
+
+		it( 'should be chainable', () => {
+			const batch = doc.batch();
+
+			const chain = batch.wrap( range, 'p' );
+			expect( chain ).to.equal( batch );
+		} );
+	} );
+} );

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

@@ -31,11 +31,12 @@ describe( 'Document', () => {
 	} );
 
 	describe( 'constructor', () => {
-		it( 'should create Document with no data and empty graveyard', () => {
+		it( 'should create Document with no data, empty graveyard and empty selection', () => {
 			expect( doc ).to.have.property( 'roots' ).that.is.instanceof( Map );
 			expect( doc.roots.size ).to.equal( 1 );
 			expect( doc.graveyard ).to.be.instanceof( RootElement );
 			expect( doc.graveyard.getChildCount() ).to.equal( 0 );
+			expect( doc.selection.getRanges().length ).to.equal( 0 );
 		} );
 	} );
 

+ 377 - 0
packages/ckeditor5-engine/tests/treemodel/liveposition.js

@@ -0,0 +1,377 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel */
+
+'use strict';
+
+const modules = bender.amd.require(
+	'treemodel/document',
+	'treemodel/element',
+	'treemodel/position',
+	'treemodel/liveposition',
+	'treemodel/range',
+	'emittermixin'
+);
+
+describe( 'LivePosition', () => {
+	let Document, Element, Position, LivePosition, Range, EmitterMixin;
+	let doc, root, ul, p, li1, li2;
+
+	before( () => {
+		Document = modules[ 'treemodel/document' ];
+		Element = modules[ 'treemodel/element' ];
+		Position = modules[ 'treemodel/position' ];
+		LivePosition = modules[ 'treemodel/liveposition' ];
+		Range = modules[ 'treemodel/range' ];
+		EmitterMixin = modules.emittermixin;
+
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+
+		li1 = new Element( 'li', [], 'abcdef' );
+		li2 = new Element( 'li', [], 'foobar' );
+		ul = new Element( 'ul', [], [ li1, li2 ] );
+		p = new Element( 'p', [], 'qwerty' );
+
+		root.insertChildren( 0, [ p, ul ] );
+	} );
+
+	it( 'should be an instance of Position', () => {
+		let live = new LivePosition( root, [ 0 ] );
+		live.detach();
+
+		expect( live ).to.be.instanceof( Position );
+	} );
+
+	it( 'should listen to a change event of the document that owns this position root', () => {
+		sinon.spy( LivePosition.prototype, 'listenTo' );
+
+		let live = new LivePosition( root, [ 0 ] );
+		live.detach();
+
+		expect( live.listenTo.calledWith( doc, 'change' ) ).to.be.true;
+
+		LivePosition.prototype.listenTo.restore();
+	} );
+
+	it( 'should stop listening when detached', () => {
+		sinon.spy( LivePosition.prototype, 'stopListening' );
+
+		let live = new LivePosition( root, [ 0 ] );
+		live.detach();
+
+		expect( live.stopListening.called ).to.be.true;
+
+		LivePosition.prototype.stopListening.restore();
+	} );
+
+	it( 'createFromPosition should return LivePosition', () => {
+		let position = LivePosition.createFromPosition( new Position( root, [ 0 ] ) );
+		expect( position ).to.be.instanceof( LivePosition );
+		position.detach();
+	} );
+
+	it( 'createFromParentAndOffset should return LivePosition', () => {
+		let position = LivePosition.createFromParentAndOffset( ul, 0 );
+		expect( position ).to.be.instanceof( LivePosition );
+		position.detach();
+	} );
+
+	it( 'createBefore should return LivePosition', () => {
+		let position = LivePosition.createBefore( ul );
+		expect( position ).to.be.instanceof( LivePosition );
+		position.detach();
+	} );
+
+	it( 'createAfter should return LivePosition', () => {
+		let position = LivePosition.createAfter( ul );
+		expect( position ).to.be.instanceof( LivePosition );
+		position.detach();
+	} );
+
+	describe( 'should get transformed if', () => {
+		let live;
+
+		beforeEach( () => {
+			live = new LivePosition( root, [ 1, 4, 6 ] );
+		} );
+
+		afterEach( () => {
+			live.detach();
+		} );
+
+		describe( 'insertion', () => {
+			it( 'is in the same parent and closer offset', () => {
+				let insertRange = new Range( new Position( root, [ 1, 4, 0 ] ), new Position( root, [ 1, 4, 3 ] ) );
+
+				doc.fire( 'change', 'insert', { range: insertRange }, null );
+
+				expect( live.path ).to.deep.equal( [ 1, 4, 9 ] );
+			} );
+
+			it( 'is at the same position and live position is sticking to right side', () => {
+				let insertRange = new Range( new Position( root, [ 1, 4, 6 ] ), new Position( root, [ 1, 4, 9 ] ) );
+
+				doc.fire( 'change', 'insert', { range: insertRange }, null );
+
+				expect( live.path ).to.deep.equal( [ 1, 4, 9 ] );
+			} );
+
+			it( 'is before a node from the live position path', () => {
+				let insertRange = new Range( new Position( root, [ 1, 0 ] ), new Position( root, [ 1, 2 ] ) );
+
+				doc.fire( 'change', 'insert', { range: insertRange }, null );
+
+				expect( live.path ).to.deep.equal( [ 1, 6, 6 ] );
+			} );
+		} );
+
+		describe( 'range move', () => {
+			it( 'is at the same parent and closer offset', () => {
+				let moveSource = new Position( root, [ 2 ] );
+				let moveRange = new Range( new Position( root, [ 1, 4, 0 ] ), new Position( root, [ 1, 4, 3 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.path ).to.deep.equal( [ 1, 4, 9 ] );
+			} );
+
+			it( 'is at the same position and live position is sticking to right side', () => {
+				let moveSource = new Position( root, [ 2 ] );
+				let moveRange = new Range( new Position( root, [ 1, 4, 6 ] ), new Position( root, [ 1, 4, 9 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.path ).to.deep.equal( [ 1, 4, 9 ] );
+			} );
+
+			it( 'is at a position before a node from the live position path', () => {
+				let moveSource = new Position( root, [ 2 ] );
+				let moveRange = new Range( new Position( root, [ 1, 0 ] ), new Position( root, [ 1, 2 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.path ).to.deep.equal( [ 1, 6, 6 ] );
+			} );
+
+			it( 'is from the same parent and closer offset', () => {
+				let moveSource = new Position( root, [ 1, 4, 0 ] );
+				let moveRange = new Range( new Position( root, [ 2, 0 ] ), new Position( root, [ 2, 4 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.path ).to.deep.equal( [ 1, 4, 2 ] );
+			} );
+
+			it( 'is from a position before a node from the live position path', () => {
+				let moveSource = new Position( root, [ 1, 0 ] );
+				let moveRange = new Range( new Position( root, [ 2, 0 ] ), new Position( root, [ 2, 4 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.path ).to.deep.equal( [ 1, 0, 6 ] );
+			} );
+
+			it( 'contains live position (same level)', () => {
+				let moveSource = new Position( root, [ 1, 4, 4 ] );
+				let moveRange = new Range( new Position( root, [ 2, 0 ] ), new Position( root, [ 2, 4 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.path ).to.deep.equal( [ 2, 2 ] );
+			} );
+
+			it( 'contains live position (deep)', () => {
+				let moveSource = new Position( root, [ 1, 3 ] );
+				let moveRange = new Range( new Position( root, [ 2, 0 ] ), new Position( root, [ 2, 4 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.path ).to.deep.equal( [ 2, 1, 6 ] );
+			} );
+		} );
+	} );
+
+	describe( 'should not get transformed if', () => {
+		let path, otherRoot;
+
+		before( () => {
+			path = [ 1, 4, 6 ];
+			otherRoot = doc.createRoot( 'otherRoot' );
+		} );
+
+		let live;
+
+		beforeEach( () => {
+			live = new LivePosition( root, path );
+		} );
+
+		afterEach( () => {
+			live.detach();
+		} );
+
+		describe( 'insertion', () => {
+			it( 'is in the same parent and further offset', () => {
+				let insertRange = new Range( new Position( root, [ 1, 4, 7 ] ), new Position( root, [ 1, 4, 9 ] ) );
+
+				doc.fire( 'change', 'insert', { range: insertRange }, null );
+
+				expect( live.path ).to.deep.equal( path );
+			} );
+
+			it( 'is at the same position and live position is sticking to left side', () => {
+				let live = new LivePosition( root, path, LivePosition.STICKS_TO_PREVIOUS );
+				let insertRange = new Range( new Position( root, [ 1, 4, 6 ] ), new Position( root, [ 1, 4, 9 ] ) );
+
+				doc.fire( 'change', 'insert', { range: insertRange }, null );
+
+				expect( live.path ).to.deep.equal( path );
+
+				live.detach();
+			} );
+
+			it( 'is after a node from the position path', () => {
+				let insertRange = new Range( new Position( root, [ 1, 5 ] ), new Position( root, [ 1, 7 ] ) );
+
+				doc.fire( 'change', 'insert', { range: insertRange }, null );
+
+				expect( live.path ).to.deep.equal( path );
+			} );
+
+			it( 'is in different root', () => {
+				let insertRange = new Range( new Position( otherRoot, [ 1, 4, 0 ] ), new Position( otherRoot, [ 1, 4, 4 ] ) );
+
+				doc.fire( 'change', 'insert', { range: insertRange }, null );
+
+				expect( live.path ).to.deep.equal( path );
+			} );
+		} );
+
+		describe( 'range move', () => {
+			it( 'is at the same parent and further offset', () => {
+				let moveSource = new Position( root, [ 2 ] );
+				let moveRange = new Range( new Position( root, [ 1, 4, 7 ] ), new Position( root, [ 1, 4, 9 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.path ).to.deep.equal( path );
+			} );
+
+			it( 'is at the same position and live position is sticking to left side', () => {
+				let live = new LivePosition( root, path, LivePosition.STICKS_TO_PREVIOUS );
+				let moveSource = new Position( root, [ 2 ] );
+				let moveRange = new Range( new Position( root, [ 1, 4, 6 ] ), new Position( root, [ 1, 4, 9 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.path ).to.deep.equal( path );
+
+				live.detach();
+			} );
+
+			it( 'is at a position after a node from the live position path', () => {
+				let moveSource = new Position( root, [ 2 ] );
+				let moveRange = new Range( new Position( root, [ 1, 5 ] ), new Position( root, [ 1, 7 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.path ).to.deep.equal( path );
+			} );
+
+			it( 'is from the same parent and further offset', () => {
+				let moveSource = new Position( root, [ 1, 4, 7 ] );
+				let moveRange = new Range( new Position( root, [ 2, 0 ] ), new Position( root, [ 2, 4 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.path ).to.deep.equal( path );
+			} );
+
+			it( 'is from a position after a node from the live position path', () => {
+				let moveSource = new Position( root, [ 1, 5 ] );
+				let moveRange = new Range( new Position( root, [ 2, 0 ] ), new Position( root, [ 2, 4 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.path ).to.deep.equal( path );
+			} );
+
+			it( 'is to different root', () => {
+				let moveSource = new Position( root, [ 2, 0 ] );
+				let moveRange = new Range( new Position( otherRoot, [ 1, 0 ] ), new Position( otherRoot, [ 1, 4 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.path ).to.deep.equal( path );
+			} );
+
+			it( 'is from different root', () => {
+				let moveSource = new Position( otherRoot, [ 1, 0 ] );
+				let moveRange = new Range( new Position( root, [ 2, 0 ] ), new Position( root, [ 2, 4 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.path ).to.deep.equal( path );
+			} );
+		} );
+	} );
+} );

+ 515 - 0
packages/ckeditor5-engine/tests/treemodel/liverange.js

@@ -0,0 +1,515 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel */
+
+'use strict';
+
+const modules = bender.amd.require(
+	'treemodel/document',
+	'treemodel/element',
+	'treemodel/position',
+	'treemodel/liverange',
+	'treemodel/range',
+	'emittermixin'
+);
+
+describe( 'LiveRange', () => {
+	let Document, Element, Position, LiveRange, Range, EmitterMixin;
+	let doc, root, ul, p;
+
+	before( () => {
+		Document = modules[ 'treemodel/document' ];
+		Element = modules[ 'treemodel/element' ];
+		Position = modules[ 'treemodel/position' ];
+		LiveRange = modules[ 'treemodel/liverange' ];
+		Range = modules[ 'treemodel/range' ];
+		EmitterMixin = modules.emittermixin;
+
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+
+		let lis = [
+			new Element( 'li', [], 'aaaaaaaaaa' ),
+			new Element( 'li', [], 'bbbbbbbbbb' ),
+			new Element( 'li', [], 'cccccccccc' ),
+			new Element( 'li', [], 'dddddddddd' ),
+			new Element( 'li', [], 'eeeeeeeeee' ),
+			new Element( 'li', [], 'ffffffffff' ),
+			new Element( 'li', [], 'gggggggggg' ),
+			new Element( 'li', [], 'hhhhhhhhhh' )
+		];
+
+		ul = new Element( 'ul', [], lis );
+		p = new Element( 'p', [], 'qwertyuiop' );
+
+		root.insertChildren( 0, [ ul, p, 'xyzxyz' ] );
+	} );
+
+	it( 'should be an instance of Range', () => {
+		let live = new LiveRange( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) );
+		live.detach();
+
+		expect( live ).to.be.instanceof( Range );
+	} );
+
+	it( 'should listen to a change event of the document that owns this range', () => {
+		sinon.spy( LiveRange.prototype, 'listenTo' );
+
+		let live = new LiveRange( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) );
+		live.detach();
+
+		expect( live.listenTo.calledWith( doc, 'change' ) ).to.be.true;
+
+		LiveRange.prototype.listenTo.restore();
+	} );
+
+	it( 'should stop listening when detached', () => {
+		sinon.spy( LiveRange.prototype, 'stopListening' );
+
+		let live = new LiveRange( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) );
+		live.detach();
+
+		expect( live.stopListening.called ).to.be.true;
+
+		LiveRange.prototype.stopListening.restore();
+	} );
+
+	it( 'createFromElement should return LiveRange', () => {
+		let range = LiveRange.createFromElement( p );
+		expect( range ).to.be.instanceof( LiveRange );
+		range.detach();
+	} );
+
+	it( 'createFromParentsAndOffsets should return LiveRange', () => {
+		let range = LiveRange.createFromParentsAndOffsets( root, 0, p, 2 );
+		expect( range ).to.be.instanceof( LiveRange );
+		range.detach();
+	} );
+
+	it( 'createFromPositionAndShift should return LiveRange', () => {
+		let range = LiveRange.createFromPositionAndShift( new Position( root, [ 0, 1 ] ), 4 );
+		expect( range ).to.be.instanceof( LiveRange );
+		range.detach();
+	} );
+
+	it( 'createFromRange should return LiveRange', () => {
+		let range = LiveRange.createFromRange( new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ) );
+		expect( range ).to.be.instanceof( LiveRange );
+		range.detach();
+	} );
+
+	// Examples may seem weird when you compare them with the tree structure generated at the beginning of tests.
+	// Since change event is fired _after_ operation is executed on tree model, you have to imagine that generated
+	// structure is representing what is _after_ operation is executed. So live LiveRange properties are describing
+	// virtual tree that is not existing anymore and event ranges are operating on the tree generated above.
+	describe( 'should get transformed if', () => {
+		let live;
+
+		beforeEach( () => {
+			live = new LiveRange( new Position( root, [ 0, 1, 4 ] ), new Position( root, [ 0, 2, 2 ] ) );
+		} );
+
+		afterEach( () => {
+			live.detach();
+		} );
+
+		describe( 'insertion', () => {
+			it( 'is in the same parent as range start and before it', () => {
+				let insertRange = new Range( new Position( root, [ 0, 1, 0 ] ), new Position( root, [ 0, 1, 4 ] ) );
+
+				doc.fire( 'change', 'insert', { range: insertRange }, null );
+
+				expect( live.start.path ).to.deep.equal( [ 0, 1, 8 ] );
+				expect( live.end.path ).to.deep.equal( [ 0, 2, 2 ] );
+			} );
+
+			it( 'is in the same parent as range end and before it', () => {
+				let insertRange = new Range( new Position( root, [ 0, 2, 0 ] ), new Position( root, [ 0, 2, 3 ] ) );
+
+				doc.fire( 'change', 'insert', { range: insertRange }, null );
+
+				expect( live.start.path ).to.deep.equal( [ 0, 1, 4 ] );
+				expect( live.end.path ).to.deep.equal( [ 0, 2, 5 ] );
+			} );
+
+			it( 'is at a position before a node from range start path', () => {
+				let insertRange = new Range( new Position( root, [ 0, 0 ] ), new Position( root, [ 0, 2 ] ) );
+
+				doc.fire( 'change', 'insert', { range: insertRange }, null );
+
+				expect( live.start.path ).to.deep.equal( [ 0, 3, 4 ] );
+				expect( live.end.path ).to.deep.equal( [ 0, 4, 2 ] );
+			} );
+
+			it( 'is at a position before a node from range end path', () => {
+				let insertRange = new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 3 ] ) );
+
+				doc.fire( 'change', 'insert', { range: insertRange }, null );
+
+				expect( live.start.path ).to.deep.equal( [ 0, 1, 4 ] );
+				expect( live.end.path ).to.deep.equal( [ 0, 3, 2 ] );
+			} );
+		} );
+
+		describe( 'range move', () => {
+			it( 'is to the same parent as range start and before it', () => {
+				let moveSource = new Position( root, [ 2 ] );
+				let moveRange = new Range( new Position( root, [ 0, 1, 0 ] ), new Position( root, [ 0, 1, 4 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.start.path ).to.deep.equal( [ 0, 1, 8 ] );
+				expect( live.end.path ).to.deep.equal( [ 0, 2, 2 ] );
+			} );
+
+			it( 'is to the same parent as range end and before it', () => {
+				let moveSource = new Position( root, [ 2 ] );
+				let moveRange = new Range( new Position( root, [ 0, 2, 0 ] ), new Position( root, [ 0, 2, 4 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.start.path ).to.deep.equal( [ 0, 1, 4 ] );
+				expect( live.end.path ).to.deep.equal( [ 0, 2, 6 ] );
+			} );
+
+			it( 'is to a position before a node from range start path', () => {
+				let moveSource = new Position( root, [ 2 ] );
+				let moveRange = new Range( new Position( root, [ 0, 0 ] ), new Position( root, [ 0, 2 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.start.path ).to.deep.equal( [ 0, 3, 4 ] );
+				expect( live.end.path ).to.deep.equal( [ 0, 4, 2 ] );
+			} );
+
+			it( 'is to a position before a node from range end path', () => {
+				let moveSource = new Position( root, [ 2 ] );
+				let moveRange = new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 0, 3 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.start.path ).to.deep.equal( [ 0, 1, 4 ] );
+				expect( live.end.path ).to.deep.equal( [ 0, 3, 2 ] );
+			} );
+
+			it( 'is from the same parent as range start and before it', () => {
+				let moveSource = new Position( root, [ 0, 1, 0 ] );
+				let moveRange = new Range( new Position( root, [ 2, 0 ] ), new Position( root, [ 2, 3 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.start.path ).to.deep.equal( [ 0, 1, 1 ] );
+				expect( live.end.path ).to.deep.equal( [ 0, 2, 2 ] );
+			} );
+
+			it( 'is from the same parent as range end and before it', () => {
+				let moveSource = new Position( root, [ 0, 2, 0 ] );
+				let moveRange = new Range( new Position( root, [ 2, 0 ] ), new Position( root, [ 2, 2 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.start.path ).to.deep.equal( [ 0, 1, 4 ] );
+				expect( live.end.path ).to.deep.equal( [ 0, 2, 0 ] );
+			} );
+
+			it( 'is from a position before a node from range start path', () => {
+				let moveSource = new Position( root, [ 0, 0 ] );
+				let moveRange = new Range( new Position( root, [ 2, 0 ] ), new Position( root, [ 2, 1 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.start.path ).to.deep.equal( [ 0, 0, 4 ] );
+				expect( live.end.path ).to.deep.equal( [ 0, 1, 2 ] );
+			} );
+
+			it( 'intersects on live range left side', () => {
+				let moveSource = new Position( root, [ 0, 1, 2 ] );
+				let moveRange = new Range( new Position( root, [ 2, 0 ] ), new Position( root, [ 2, 4 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.start.path ).to.deep.equal( [ 0, 1, 2 ] );
+				expect( live.end.path ).to.deep.equal( [ 0, 2, 2 ] );
+			} );
+
+			it( 'intersects on live range right side', () => {
+				let moveSource = new Position( root, [ 0, 2, 1 ] );
+				let moveRange = new Range( new Position( root, [ 2, 0 ] ), new Position( root, [ 2, 4 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.start.path ).to.deep.equal( [ 0, 1, 4 ] );
+				expect( live.end.path ).to.deep.equal( [ 0, 2, 1 ] );
+			} );
+
+			it( 'intersects on live range left side and live range new start is touching moved range end', () => {
+				let moveSource = new Position( root, [ 0, 1, 0 ] );
+				let moveRange = new Range( new Position( root, [ 0, 1 ] ), new Position( root, [ 0, 6 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.start.path ).to.deep.equal( [ 0, 5 ] );
+				expect( live.end.path ).to.deep.equal( [ 0, 7, 2 ] );
+			} );
+
+			it( 'intersects on live range right side and live range new end is touching moved range start', () => {
+				live.end.offset = 12;
+
+				let moveSource = new Position( root, [ 0, 2, 10 ] );
+				let moveRange = new Range( new Position( root, [ 0, 3, 0 ] ), new Position( root, [ 0, 3, 5 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.start.path ).to.deep.equal( [ 0, 1, 4 ] );
+				expect( live.end.path ).to.deep.equal( [ 0, 3, 2 ] );
+			} );
+
+			it( 'is equal to live range', () => {
+				live.end.path = [ 0, 1, 7 ];
+
+				let moveSource = new Position( root, [ 0, 1, 4 ] );
+				let moveRange = new Range( new Position( root, [ 0, 3, 0 ] ), new Position( root, [ 0, 3, 3 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.start.path ).to.deep.equal( [ 0, 3, 0 ] );
+				expect( live.end.path ).to.deep.equal( [ 0, 3, 3 ] );
+			} );
+
+			it( 'contains live range', () => {
+				live.end.path = [ 0, 1, 7 ];
+
+				let moveSource = new Position( root, [ 0, 1, 3 ] );
+				let moveRange = new Range( new Position( root, [ 0, 3, 0 ] ), new Position( root, [ 0, 3, 9 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.start.path ).to.deep.equal( [ 0, 3, 1 ] );
+				expect( live.end.path ).to.deep.equal( [ 0, 3, 4 ] );
+			} );
+		} );
+	} );
+
+	describe( 'should not get transformed if', () => {
+		let otherRoot;
+
+		before( () => {
+			otherRoot = doc.createRoot( 'otherRoot' );
+		} );
+
+		let live, clone;
+
+		beforeEach( () => {
+			live = new LiveRange( new Position( root, [ 0, 1, 4 ] ), new Position( root, [ 0, 2, 2 ] ) );
+			clone = Range.createFromRange( live );
+		} );
+
+		afterEach( () => {
+			live.detach();
+		} );
+
+		describe( 'insertion', () => {
+			// Technically range will be expanded but the boundaries properties will stay the same.
+			// Start won't change because insertion is after it.
+			// End won't change because it is in different node.
+			it( 'is in the same parent as range start and after it', () => {
+				let insertRange = new Range( new Position( root, [ 0, 1, 7 ] ), new Position( root, [ 0, 1, 9 ] ) );
+
+				doc.fire( 'change', 'insert', { range: insertRange }, null );
+
+				expect( live.isEqual( clone ) ).to.be.true;
+			} );
+
+			it( 'is in the same parent as range end and after it', () => {
+				let insertRange = new Range( new Position( root, [ 0, 2, 7 ] ), new Position( root, [ 0, 2, 9 ] ) );
+
+				doc.fire( 'change', 'insert', { range: insertRange }, null );
+
+				expect( live.isEqual( clone ) ).to.be.true;
+			} );
+
+			it( 'is to a position after a node from range end path', () => {
+				let insertRange = new Range( new Position( root, [ 3 ] ), new Position( root, [ 4 ] ) );
+
+				doc.fire( 'change', 'insert', { range: insertRange }, null );
+
+				expect( live.isEqual( clone ) ).to.be.true;
+			} );
+
+			it( 'is in different root', () => {
+				let insertRange = new Range( new Position( otherRoot, [ 0, 0 ] ), new Position( otherRoot, [ 0, 2 ] ) );
+
+				doc.fire( 'change', 'insert', { range: insertRange }, null );
+
+				expect( live.isEqual( clone ) ).to.be.true;
+			} );
+		} );
+
+		describe( 'range move', () => {
+			// Technically range will be expanded but the boundaries properties will stay the same.
+			// Start won't change because insertion is after it.
+			// End won't change because it is in different node.
+			it( 'is to the same parent as range start and after it', () => {
+				let moveSource = new Position( root, [ 4 ] );
+				let moveRange = new Range( new Position( root, [ 0, 1, 7 ] ), new Position( root, [ 0, 1, 9 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.isEqual( clone ) ).to.be.true;
+			} );
+
+			it( 'is to the same parent as range end and before it', () => {
+				let moveSource = new Position( root, [ 4 ] );
+				let moveRange = new Range( new Position( root, [ 0, 2, 3 ] ), new Position( root, [ 0, 2, 5 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.isEqual( clone ) ).to.be.true;
+			} );
+
+			it( 'is to a position after a node from range end path', () => {
+				let moveSource = new Position( root, [ 4 ] );
+				let moveRange = new Range( new Position( root, [ 0, 3 ] ), new Position( root, [ 0, 5 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.isEqual( clone ) ).to.be.true;
+			} );
+
+			// Technically range will be shrunk but the boundaries properties will stay the same.
+			// Start won't change because deletion is after it.
+			// End won't change because it is in different node.
+			it( 'is from the same parent as range start and after it', () => {
+				let moveSource = new Position( root, [ 0, 1, 6 ] );
+				let moveRange = new Range( new Position( root, [ 4, 0 ] ), new Position( root, [ 4, 3 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.isEqual( clone ) ).to.be.true;
+			} );
+
+			it( 'is from the same parent as range end and after it', () => {
+				let moveSource = new Position( root, [ 0, 2, 4 ] );
+				let moveRange = new Range( new Position( root, [ 4, 0 ] ), new Position( root, [ 4, 2 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.isEqual( clone ) ).to.be.true;
+			} );
+
+			it( 'is from a position after a node from range end path', () => {
+				let moveSource = new Position( root, [ 0, 3 ] );
+				let moveRange = new Range( new Position( root, [ 5, 0 ] ), new Position( root, [ 5, 1 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.isEqual( clone ) ).to.be.true;
+			} );
+
+			it( 'is to different root', () => {
+				let moveSource = new Position( root, [ 2 ] );
+				let moveRange = new Range( new Position( otherRoot, [ 0, 1, 0 ] ), new Position( otherRoot, [ 0, 1, 4 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.isEqual( clone ) ).to.be.true;
+			} );
+
+			it( 'is from different root', () => {
+				let moveSource = new Position( otherRoot, [ 0, 2, 0 ] );
+				let moveRange = new Range( new Position( root, [ 2, 0 ] ), new Position( root, [ 2, 2 ] ) );
+
+				let changes = {
+					range: moveRange,
+					sourcePosition: moveSource
+				};
+				doc.fire( 'change', 'move', changes, null );
+
+				expect( live.isEqual( clone ) ).to.be.true;
+			} );
+		} );
+	} );
+} );

+ 116 - 114
packages/ckeditor5-engine/tests/treemodel/node.js

@@ -15,21 +15,24 @@ const modules = bender.amd.require(
 	'treemodel/element',
 	'treemodel/character',
 	'treemodel/attribute',
+	'treemodel/attributelist',
 	'treemodel/nodelist',
 	'ckeditorerror'
 );
 
 describe( 'Node', () => {
-	let Element, Character, Attribute, NodeList, CKEditorError;
+	let Element, Character, Attribute, AttributeList, NodeList, CKEditorError;
 
 	let root;
 	let one, two, three;
-	let charB, charA, charR, img;
+	let charB, charA, charR, img, attrEle;
+	let attrFooBar;
 
 	before( () => {
 		Element = modules[ 'treemodel/element' ];
 		Character = modules[ 'treemodel/character' ];
 		Attribute = modules[ 'treemodel/attribute' ];
+		AttributeList = modules[ 'treemodel/attributelist' ];
 		NodeList = modules[ 'treemodel/nodelist' ];
 		CKEditorError = modules.ckeditorerror;
 
@@ -43,6 +46,12 @@ describe( 'Node', () => {
 		three = new Element( 'three' );
 
 		root = new Element( null, null, [ one, two, three ] );
+
+		attrFooBar = new Attribute( 'foo', 'bar' );
+	} );
+
+	beforeEach( () => {
+		attrEle = new Element( 'element' );
 	} );
 
 	describe( 'should have a correct property', () => {
@@ -100,7 +109,7 @@ describe( 'Node', () => {
 	} );
 
 	describe( 'constructor', () => {
-		it( 'should copy attributes, not pass by reference', () => {
+		it( 'should copy attributes list, not pass by reference', () => {
 			let attrs = [ new Attribute( 'attr', true ) ];
 			let foo = new Element( 'foo', attrs );
 			let bar = new Element( 'bar', attrs );
@@ -112,165 +121,158 @@ describe( 'Node', () => {
 		} );
 	} );
 
-	describe( 'getAttr', () => {
-		let fooAttr, element;
-
-		beforeEach( () => {
-			fooAttr = new Attribute( 'foo', true );
-			element = new Element( 'foo', [ fooAttr ] );
-		} );
+	it( 'should create proper JSON string using toJSON method', () => {
+		let b = new Character( 'b' );
+		let foo = new Element( 'foo', [], [ b ] );
 
-		it( 'should be possible to get attribute by key', () => {
-			expect( element.getAttr( 'foo' ) ).to.equal( fooAttr.value );
-		} );
+		let parsedFoo = JSON.parse( JSON.stringify( foo ) );
+		let parsedBar = JSON.parse( JSON.stringify( b ) );
 
-		it( 'should return null if attribute was not found by key', () => {
-			expect( element.getAttr( 'bar' ) ).to.be.null;
-		} );
+		expect( parsedFoo.parent ).to.equal( null );
+		expect( parsedBar.parent ).to.equal( 'foo' );
 	} );
 
-	describe( 'setAttr', () => {
-		it( 'should insert an attribute', () => {
-			let element = new Element( 'elem' );
-			let attr = new Attribute( 'foo', 'bar' );
+	describe( 'getIndex', () => {
+		it( 'should return null if the parent is null', () => {
+			expect( root.getIndex() ).to.be.null;
+		} );
 
-			element.setAttr( attr );
+		it( 'should return index in the parent', () => {
+			expect( one.getIndex() ).to.equal( 0 );
+			expect( two.getIndex() ).to.equal( 1 );
+			expect( three.getIndex() ).to.equal( 2 );
 
-			expect( getIteratorCount( element.getAttrs() ) ).to.equal( 1 );
-			expect( element.getAttr( attr.key ) ).to.equal( attr.value );
+			expect( charB.getIndex() ).to.equal( 0 );
+			expect( charA.getIndex() ).to.equal( 1 );
+			expect( img.getIndex() ).to.equal( 2 );
+			expect( charR.getIndex() ).to.equal( 3 );
 		} );
 
-		it( 'should overwrite attribute with the same key', () => {
-			let oldAttr = new Attribute( 'foo', 'bar' );
-			let newAttr = new Attribute( 'foo', 'bar' );
-			let element = new Element( 'elem', [ oldAttr ] );
+		it( 'should throw an error if parent does not contains element', () => {
+			let f = new Character( 'f' );
+			let bar = new Element( 'bar', [], [] );
 
-			element.setAttr( newAttr );
+			f.parent = bar;
 
-			expect( getIteratorCount( element.getAttrs() ) ).to.equal( 1 );
-			expect( element.getAttr( newAttr.key ) ).to.equal( newAttr.value );
+			expect(
+				() => {
+					f.getIndex();
+				}
+			).to.throw( CKEditorError, /node-not-found-in-parent/ );
 		} );
 	} );
 
-	describe( 'removeAttr', () => {
-		it( 'should remove an attribute', () => {
-			let attrA = new Attribute( 'a', 'A' );
-			let attrB = new Attribute( 'b', 'b' );
-			let attrC = new Attribute( 'c', 'C' );
-			let element = new Element( 'elem', [ attrA, attrB, attrC ] );
+	describe( 'getPath', () => {
+		it( 'should return proper path', () => {
+			expect( root.getPath() ).to.deep.equal( [] );
 
-			element.removeAttr( attrB.key );
+			expect( one.getPath() ).to.deep.equal( [ 0 ] );
+			expect( two.getPath() ).to.deep.equal( [ 1 ] );
+			expect( three.getPath() ).to.deep.equal( [ 2 ] );
 
-			expect( getIteratorCount( element.getAttrs() ) ).to.equal( 2 );
-			expect( element.getAttr( attrA.key ) ).to.equal( attrA.value );
-			expect( element.getAttr( attrC.key ) ).to.equal( attrC.value );
-			expect( element.getAttr( attrB.key ) ).to.be.null;
+			expect( charB.getPath() ).to.deep.equal( [ 1, 0 ] );
+			expect( charA.getPath() ).to.deep.equal( [ 1, 1 ] );
+			expect( img.getPath() ).to.deep.equal( [ 1, 2 ] );
+			expect( charR.getPath() ).to.deep.equal( [ 1, 3 ] );
 		} );
 	} );
 
-	describe( 'hasAttr', () => {
-		it( 'should check attribute by key', () => {
-			let fooAttr = new Attribute( 'foo', true );
-			let element = new Element( 'foo', [ fooAttr ] );
-
-			expect( element.hasAttr( 'foo' ) ).to.be.true;
-		} );
+	// Testing integration with attributes list.
+	// Tests copied from AttributeList tests.
+	// Some cases were omitted.
 
-		it( 'should return false if attribute was not found by key', () => {
-			let fooAttr = new Attribute( 'foo', true );
-			let element = new Element( 'foo', [ fooAttr ] );
+	describe( 'setAttr', () => {
+		it( 'should insert an attribute', () => {
+			attrEle.setAttr( attrFooBar );
 
-			expect( element.hasAttr( 'bar' ) ).to.be.false;
+			expect( getIteratorCount( attrEle.getAttrs() ) ).to.equal( 1 );
+			expect( attrEle.getAttr( attrFooBar.key ) ).to.equal( attrFooBar.value );
 		} );
+	} );
 
-		it( 'should check attribute by object', () => {
-			let fooAttr = new Attribute( 'foo', true );
-			let foo2Attr = new Attribute( 'foo', true );
-			let element = new Element( 'foo', [ fooAttr ] );
+	describe( 'setAttrsTo', () => {
+		it( 'should remove all attributes and set passed ones', () => {
+			attrEle.setAttr( attrFooBar );
 
-			expect( element.hasAttr( foo2Attr ) ).to.be.true;
-		} );
+			let attrs = [ new Attribute( 'abc', true ), new Attribute( 'xyz', false ) ];
 
-		it( 'should return false if attribute was not found by object', () => {
-			let fooAttr = new Attribute( 'foo', true );
-			let element = new Element( 'foo' );
+			attrEle.setAttrsTo( attrs );
 
-			expect( element.hasAttr( fooAttr ) ).to.be.false;
+			expect( getIteratorCount( attrEle.getAttrs() ) ).to.equal( 2 );
+			expect( attrEle.getAttr( 'foo' ) ).to.be.null;
+			expect( attrEle.getAttr( 'abc' ) ).to.be.true;
+			expect( attrEle.getAttr( 'xyz' ) ).to.be.false;
 		} );
+	} );
 
-		it( 'should create proper JSON string using toJSON method', () => {
-			let b = new Character( 'b' );
-			let foo = new Element( 'foo', [], [ b ] );
+	describe( 'getAttr', () => {
+		beforeEach( () => {
+			attrEle = new Element( 'e', [ attrFooBar ] );
+		} );
 
-			let parsedFoo = JSON.parse( JSON.stringify( foo ) );
-			let parsedBar = JSON.parse( JSON.stringify( b ) );
+		it( 'should return attribute value if key of previously set attribute has been passed', () => {
+			expect( attrEle.getAttr( 'foo' ) ).to.equal( attrFooBar.value );
+		} );
 
-			expect( parsedFoo.parent ).to.equal( null );
-			expect( parsedBar.parent ).to.equal( 'foo' );
+		it( 'should return null if attribute with given key has not been found', () => {
+			expect( attrEle.getAttr( 'bar' ) ).to.be.null;
 		} );
 	} );
 
-	describe( 'getAttrs', () => {
-		it( 'should allows to get attribute count', () => {
-			let element = new Element( 'foo', [
-				new Attribute( 1, true ),
-				new Attribute( 2, true ),
-				new Attribute( 3, true )
-			] );
+	describe( 'removeAttr', () => {
+		it( 'should remove an attribute', () => {
+			let attrA = new Attribute( 'a', 'A' );
+			let attrB = new Attribute( 'b', 'B' );
+			let attrC = new Attribute( 'c', 'C' );
 
-			expect( getIteratorCount( element.getAttrs() ) ).to.equal( 3 );
-		} );
+			attrEle.setAttr( attrA );
+			attrEle.setAttr( attrB );
+			attrEle.setAttr( attrC );
 
-		it( 'should allows to copy attributes', () => {
-			let element = new Element( 'foo', [ new Attribute( 'x', true ) ] );
-			let copy = new Element( 'bar', element.getAttrs() );
+			attrEle.removeAttr( attrB.key );
 
-			expect( copy.getAttr( 'x' ) ).to.be.true;
+			expect( getIteratorCount( attrEle.getAttrs() ) ).to.equal( 2 );
+			expect( attrEle.getAttr( attrA.key ) ).to.equal( attrA.value );
+			expect( attrEle.getAttr( attrC.key ) ).to.equal( attrC.value );
+			expect( attrEle.getAttr( attrB.key ) ).to.be.null;
 		} );
 	} );
 
-	describe( 'getIndex', () => {
-		it( 'should return null if the parent is null', () => {
-			expect( root.getIndex() ).to.be.null;
+	describe( 'hasAttr', () => {
+		it( 'should check attribute by key', () => {
+			attrEle.setAttr( attrFooBar );
+			expect( attrEle.hasAttr( 'foo' ) ).to.be.true;
 		} );
 
-		it( 'should return index in the parent', () => {
-			expect( one.getIndex() ).to.equal( 0 );
-			expect( two.getIndex() ).to.equal( 1 );
-			expect( three.getIndex() ).to.equal( 2 );
-
-			expect( charB.getIndex() ).to.equal( 0 );
-			expect( charA.getIndex() ).to.equal( 1 );
-			expect( img.getIndex() ).to.equal( 2 );
-			expect( charR.getIndex() ).to.equal( 3 );
+		it( 'should return false if attribute was not found by key', () => {
+			expect( attrEle.hasAttr( 'bar' ) ).to.be.false;
 		} );
 
-		it( 'should throw an error if parent does not contains element', () => {
-			let f = new Character( 'f' );
-			let bar = new Element( 'bar', [], [] );
-
-			f.parent = bar;
+		it( 'should check attribute by object', () => {
+			attrEle.setAttr( attrFooBar );
+			expect( attrEle.hasAttr( attrFooBar ) ).to.be.true;
+		} );
 
-			expect(
-				() => {
-					f.getIndex();
-				}
-			).to.throw( CKEditorError, /node-not-found-in-parent/ );
+		it( 'should return false if attribute was not found by object', () => {
+			expect( attrEle.hasAttr( attrFooBar ) ).to.be.false;
 		} );
 	} );
 
-	describe( 'getPath', () => {
-		it( 'should return proper path', () => {
-			expect( root.getPath() ).to.deep.equal( [] );
+	describe( 'getAttrs', () => {
+		it( 'should return all set attributes', () => {
+			let attrA = new Attribute( 'a', 'A' );
+			let attrB = new Attribute( 'b', 'B' );
+			let attrC = new Attribute( 'c', 'C' );
 
-			expect( one.getPath() ).to.deep.equal( [ 0 ] );
-			expect( two.getPath() ).to.deep.equal( [ 1 ] );
-			expect( three.getPath() ).to.deep.equal( [ 2 ] );
+			attrEle.setAttrsTo( [
+				attrA,
+				attrB,
+				attrC
+			] );
 
-			expect( charB.getPath() ).to.deep.equal( [ 1, 0 ] );
-			expect( charA.getPath() ).to.deep.equal( [ 1, 1 ] );
-			expect( img.getPath() ).to.deep.equal( [ 1, 2 ] );
-			expect( charR.getPath() ).to.deep.equal( [ 1, 3 ] );
+			attrEle.removeAttr( attrB.key );
+
+			expect( [ attrA, attrC ] ).to.deep.equal( Array.from( attrEle.getAttrs() ) );
 		} );
 	} );
 } );

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

@@ -184,7 +184,7 @@ describe( 'AttributeOperation', () => {
 
 		expect( reverse ).to.be.an.instanceof( AttributeOperation );
 		expect( reverse.baseVersion ).to.equal( 1 );
-		expect( reverse.range ).to.equal( range );
+		expect( reverse.range.isEqual( range ) ).to.be.true;
 		expect( reverse.oldAttr ).to.equal( newAttr );
 		expect( reverse.newAttr ).to.equal( oldAttr );
 	} );

+ 4 - 4
packages/ckeditor5-engine/tests/treemodel/operation/insertoperation.js

@@ -40,13 +40,13 @@ describe( 'InsertOperation', () => {
 	} );
 
 	it( 'should have proper type', () => {
-		const opp = new InsertOperation(
+		const op = new InsertOperation(
 			new Position( root, [ 0 ] ),
 			new Character( 'x' ),
 			doc.version
 		);
 
-		expect( opp.type ).to.equal( 'insert' );
+		expect( op.type ).to.equal( 'insert' );
 	} );
 
 	it( 'should insert node', () => {
@@ -119,7 +119,7 @@ describe( 'InsertOperation', () => {
 		expect( root.getChild( 6 ).character ).to.equal( 'r' );
 	} );
 
-	it( 'should create a remove operation as a reverse', () => {
+	it( 'should create a RemoveOperation as a reverse', () => {
 		let position = new Position( root, [ 0 ] );
 		let operation = new InsertOperation(
 			position,
@@ -131,7 +131,7 @@ describe( 'InsertOperation', () => {
 
 		expect( reverse ).to.be.an.instanceof( RemoveOperation );
 		expect( reverse.baseVersion ).to.equal( 1 );
-		expect( reverse.sourcePosition ).to.equal( position );
+		expect( reverse.sourcePosition.isEqual( position ) ).to.be.true;
 		expect( reverse.howMany ).to.equal( 7 );
 	} );
 

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

@@ -37,14 +37,14 @@ describe( 'MoveOperation', () => {
 	} );
 
 	it( 'should have proper type', () => {
-		const opp = new MoveOperation(
+		const op = new MoveOperation(
 			new Position( root, [ 0, 0 ] ),
-			new Position( root, [ 1, 0 ] ),
 			1,
+			new Position( root, [ 1, 0 ] ),
 			doc.version
 		);
 
-		expect( opp.type ).to.equal( 'move' );
+		expect( op.type ).to.equal( 'move' );
 	} );
 
 	it( 'should move from one node to another', () => {
@@ -113,7 +113,7 @@ describe( 'MoveOperation', () => {
 		expect( root.getChild( 4 ).character ).to.equal( 'x' );
 	} );
 
-	it( 'should create a move operation as a reverse', () => {
+	it( 'should create a MoveOperation as a reverse', () => {
 		let nodeList = new NodeList( 'bar' );
 
 		let sourcePosition = new Position( root, [ 0 ] );
@@ -239,7 +239,7 @@ describe( 'MoveOperation', () => {
 		expect( p.getChild( 0 ).character ).to.equal( 'b' );
 	} );
 
-	it( 'should create operation with the same parameters when cloned', () => {
+	it( 'should create MoveOperation with the same parameters when cloned', () => {
 		let sourcePosition = new Position( root, [ 0 ] );
 		let targetPosition = new Position( root, [ 1 ] );
 		let howMany = 4;

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

@@ -32,7 +32,7 @@ describe( 'NoOperation', () => {
 		expect( () => doc.applyOperation( noop ) ).to.not.throw( Error );
 	} );
 
-	it( 'should create a do-nothing operation as a reverse', () => {
+	it( 'should create a NoOperation as a reverse', () => {
 		const reverse = noop.getReversed();
 
 		expect( reverse ).to.be.an.instanceof( NoOperation );

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

@@ -52,7 +52,17 @@ describe( 'ReinsertOperation', () => {
 		expect( operation ).to.be.instanceof( MoveOperation );
 	} );
 
-	it( 'should create a remove operation as a reverse', () => {
+	it( 'should create ReinsertOperation with same parameters when cloned', () => {
+		let clone = operation.clone();
+
+		expect( clone ).to.be.instanceof( ReinsertOperation );
+		expect( clone.sourcePosition.isEqual( operation.sourcePosition ) ).to.be.true;
+		expect( clone.targetPosition.isEqual( operation.targetPosition ) ).to.be.true;
+		expect( clone.howMany ).to.equal( operation.howMany );
+		expect( clone.baseVersion ).to.equal( operation.baseVersion );
+	} );
+
+	it( 'should create a RemoveOperation as a reverse', () => {
 		let reverse = operation.getReversed();
 
 		expect( reverse ).to.be.an.instanceof( RemoveOperation );

+ 17 - 5
packages/ckeditor5-engine/tests/treemodel/operation/removeoperation.js

@@ -35,13 +35,13 @@ describe( 'RemoveOperation', () => {
 	} );
 
 	it( 'should have proper type', () => {
-		const opp = new RemoveOperation(
+		const op = new RemoveOperation(
 			new Position( root, [ 2 ] ),
 			2,
 			doc.version
 		);
 
-		expect( opp.type ).to.equal( 'remove' );
+		expect( op.type ).to.equal( 'remove' );
 	} );
 
 	it( 'should extend MoveOperation class', () => {
@@ -78,7 +78,19 @@ describe( 'RemoveOperation', () => {
 		expect( graveyard.getChild( 1 ) ).to.equal( b );
 	} );
 
-	it( 'should create a reinsert operation as a reverse', () => {
+	it( 'should create RemoveOperation with same parameters when cloned', () => {
+		let pos = new Position( root, [ 2 ] );
+
+		let operation = new RemoveOperation( pos, 2, doc.version );
+		let clone = operation.clone();
+
+		expect( clone ).to.be.instanceof( RemoveOperation );
+		expect( clone.sourcePosition.isEqual( pos ) ).to.be.true;
+		expect( clone.howMany ).to.equal( operation.howMany );
+		expect( clone.baseVersion ).to.equal( operation.baseVersion );
+	} );
+
+	it( 'should create a ReinsertOperation as a reverse', () => {
 		let position = new Position( root, [ 0 ] );
 		let operation = new RemoveOperation( position, 2, 0 );
 		let reverse = operation.getReversed();
@@ -86,8 +98,8 @@ describe( 'RemoveOperation', () => {
 		expect( reverse ).to.be.an.instanceof( ReinsertOperation );
 		expect( reverse.baseVersion ).to.equal( 1 );
 		expect( reverse.howMany ).to.equal( 2 );
-		expect( reverse.sourcePosition ).to.equal( operation.targetPosition );
-		expect( reverse.targetPosition ).to.equal( position );
+		expect( reverse.sourcePosition.isEqual( operation.targetPosition ) ).to.be.true;
+		expect( reverse.targetPosition.isEqual( position ) ).to.be.true;
 	} );
 
 	it( 'should undo remove set of nodes by applying reverse operation', () => {

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

@@ -86,7 +86,7 @@ describe( 'transform', () => {
 
 			expected = {
 				type: InsertOperation,
-				position: position.clone(),
+				position: Position.createFromPosition( position ),
 				baseVersion: baseVersion + 1
 			};
 		} );
@@ -189,12 +189,8 @@ describe( 'transform', () => {
 
 		describe( 'by AttributeOperation', () => {
 			it( 'no position update', () => {
-				let rangeStart = position.clone();
-				let rangeEnd = position.clone();
-				rangeEnd.offset += 2;
-
 				let transformBy = new AttributeOperation(
-					new Range( rangeStart, rangeEnd ),
+					Range.createFromPositionAndShift( position, 2 ),
 					null,
 					new Attribute( 'foo', 'bar' ),
 					baseVersion
@@ -437,7 +433,7 @@ describe( 'transform', () => {
 
 				op = new AttributeOperation( range, oldAttr, newAttr, baseVersion );
 
-				expected.range = new Range( start.clone(), end.clone() );
+				expected.range = new Range( start, end );
 			} );
 
 			describe( 'by InsertOperation', () => {
@@ -552,7 +548,7 @@ describe( 'transform', () => {
 			describe( 'by AttributeOperation', () => {
 				it( 'attributes have different key: no operation update', () => {
 					let transformBy = new AttributeOperation(
-						range.clone(),
+						Range.createFromRange( range ),
 						new Attribute( 'abc', true ),
 						new Attribute( 'abc', false ),
 						baseVersion
@@ -566,7 +562,7 @@ describe( 'transform', () => {
 
 				it( 'attributes set same value: no operation update', () => {
 					let transformBy = new AttributeOperation(
-						range.clone(),
+						Range.createFromRange( range ),
 						oldAttr,
 						newAttr,
 						baseVersion
@@ -1076,7 +1072,7 @@ describe( 'transform', () => {
 
 				op = new AttributeOperation( range, oldAttr, newAttr, baseVersion );
 
-				expected.range = new Range( start.clone(), end.clone() );
+				expected.range = new Range( start, end );
 			} );
 
 			describe( 'by InsertOperation', () => {
@@ -1317,15 +1313,15 @@ describe( 'transform', () => {
 			targetPosition = new Position( root, [ 3, 3, 3 ] );
 			howMany = 2;
 
-			rangeEnd = sourcePosition.clone();
+			rangeEnd = Position.createFromPosition( sourcePosition );
 			rangeEnd.offset += howMany;
 
 			op = new MoveOperation( sourcePosition, howMany, targetPosition, baseVersion );
 
 			expected = {
 				type: MoveOperation,
-				sourcePosition: sourcePosition.clone(),
-				targetPosition: targetPosition.clone(),
+				sourcePosition: Position.createFromPosition( sourcePosition ),
+				targetPosition: Position.createFromPosition( targetPosition ),
 				howMany: howMany,
 				baseVersion: baseVersion + 1
 			};
@@ -1898,7 +1894,7 @@ describe( 'transform', () => {
 
 			it( 'range is same as transforming range and is important: convert to NoOperation', () => {
 				let transformBy = new MoveOperation(
-					op.sourcePosition.clone(),
+					op.sourcePosition,
 					op.howMany,
 					new Position( root, [ 4, 1, 0 ] ),
 					baseVersion
@@ -1915,7 +1911,7 @@ describe( 'transform', () => {
 
 			it( 'range is same as transforming range and is less important: update range path', () => {
 				let transformBy = new MoveOperation(
-					op.sourcePosition.clone(),
+					op.sourcePosition,
 					op.howMany,
 					new Position( root, [ 4, 1, 0 ] ),
 					baseVersion

+ 91 - 9
packages/ckeditor5-engine/tests/treemodel/position.js

@@ -150,6 +150,15 @@ describe( 'position', () => {
 		expect( Position.createAfter( r ) ).to.have.property( 'path' ).that.deep.equals( [ 1, 1, 3 ] );
 	} );
 
+	it( 'should create a copy of given position', () => {
+		let original = new Position( root, [ 1, 2, 3 ] );
+		let position = Position.createFromPosition( original );
+
+		expect( position ).to.be.instanceof( Position );
+		expect( position.isEqual( original ) ).to.be.true;
+		expect( position ).not.to.be.equal( original );
+	} );
+
 	it( 'should throw error if one try to make positions after root', () => {
 		expect( () => {
 			Position.createAfter( root );
@@ -238,15 +247,6 @@ describe( 'position', () => {
 		expect( position.getParentPath() ).to.deep.equal( [ 1, 2 ] );
 	} );
 
-	it( 'should return a new, equal position when cloned', () => {
-		const position = new Position( root, [ 1, 2, 3 ] );
-		const clone = position.clone();
-
-		expect( clone ).not.to.be.equal( position ); // clone is not pointing to the same object as position
-		expect( clone.isEqual( position ) ).to.be.true; // but they are equal in the position-sense
-		expect( clone.path ).not.to.be.equal( position.path ); // make sure the paths are not the same array
-	} );
-
 	describe( 'isBefore', () => {
 		it( 'should return true if given position has same root and is before this position', () => {
 			let position = new Position( root, [ 1, 1, 2 ] );
@@ -316,6 +316,63 @@ describe( 'position', () => {
 		} );
 	} );
 
+	describe( 'isTouching', () => {
+		it( 'should return true if positions are same', () => {
+			let position = new Position( root, [ 1, 1, 1 ] );
+			let result = position.isTouching( new Position( root, [ 1, 1, 1 ] ) );
+
+			expect( result ).to.be.true;
+		} );
+
+		it( 'should return true if given position is in next node and there are no whole nodes before it', () => {
+			let positionA = new Position( root, [ 1 ] );
+			let positionB = new Position( root, [ 1, 0, 0 ] );
+
+			expect( positionA.isTouching( positionB ) ).to.be.true;
+			expect( positionB.isTouching( positionA ) ).to.be.true;
+		} );
+
+		it( 'should return true if given position is in previous node and there are no whole nodes after it', () => {
+			let positionA = new Position( root, [ 2 ] );
+			let positionB = new Position( root, [ 1, 1, 3 ] );
+
+			expect( positionA.isTouching( positionB ) ).to.be.true;
+			expect( positionB.isTouching( positionA ) ).to.be.true;
+		} );
+
+		it( 'should return true if positions are in different sub-trees but there are no whole nodes between them', () => {
+			let positionA = new Position( root, [ 1, 0, 3 ] );
+			let positionB = new Position( root, [ 1, 1, 0 ] );
+
+			expect( positionA.isTouching( positionB ) ).to.be.true;
+			expect( positionB.isTouching( positionA ) ).to.be.true;
+		} );
+
+		it( 'should return false if there are whole nodes between positions', () => {
+			let positionA = new Position( root, [ 2 ] );
+			let positionB = new Position( root, [ 1, 0, 3 ] );
+
+			expect( positionA.isTouching( positionB ) ).to.be.false;
+			expect( positionB.isTouching( positionA ) ).to.be.false;
+		} );
+
+		it( 'should return false if there are whole nodes between positions', () => {
+			let positionA = new Position( root, [ 1, 0, 3 ] );
+			let positionB = new Position( root, [ 1, 1, 1 ] );
+
+			expect( positionA.isTouching( positionB ) ).to.be.false;
+			expect( positionB.isTouching( positionA ) ).to.be.false;
+		} );
+
+		it( 'should return false if positions are in different roots', () => {
+			let positionA = new Position( root, [ 1, 0, 3 ] );
+			let positionB = new Position( otherRoot, [ 1, 1, 0 ] );
+
+			expect( positionA.isTouching( positionB ) ).to.be.false;
+			expect( positionB.isTouching( positionA ) ).to.be.false;
+		} );
+	} );
+
 	describe( 'compareWith', () => {
 		it( 'should return Position.SAME if positions are same', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );
@@ -498,4 +555,29 @@ describe( 'position', () => {
 			expect( combined.path ).to.deep.equal( [ 2, 7, 4, 2 ] );
 		} );
 	} );
+
+	describe( 'getShiftedBy', () => {
+		it( 'should return a new instance of Position with offset changed by shift value', () => {
+			let position = new Position( root, [ 1, 2, 3 ] );
+			let shifted = position.getShiftedBy( 2 );
+
+			expect( shifted ).to.be.instanceof( Position );
+			expect( shifted ).to.not.equal( position );
+			expect( shifted.path ).to.deep.equal( [ 1, 2, 5 ] );
+		} );
+
+		it( 'should accept negative values', () => {
+			let position = new Position( root, [ 1, 2, 3 ] );
+			let shifted = position.getShiftedBy( -2 );
+
+			expect( shifted.path ).to.deep.equal( [ 1, 2, 1 ] );
+		} );
+
+		it( 'should not let setting offset lower than zero', () => {
+			let position = new Position( root, [ 1, 2, 3 ] );
+			let shifted = position.getShiftedBy( -7 );
+
+			expect( shifted.path ).to.deep.equal( [ 1, 2, 0 ] );
+		} );
+	} );
 } );

+ 179 - 22
packages/ckeditor5-engine/tests/treemodel/range.js

@@ -26,29 +26,47 @@ describe( 'Range', () => {
 		Document = modules[ 'treemodel/document' ];
 	} );
 
-	let range, start, end, root;
+	let range, start, end, root, otherRoot;
 
 	beforeEach( () => {
 		let doc = new Document();
 		root = doc.createRoot( 'root' );
+		otherRoot = doc.createRoot( 'otherRoot' );
 
-		start = new Position( root, [ 0 ] );
-		end = new Position( root, [ 1 ] );
+		start = new Position( root, [ 1 ] );
+		end = new Position( root, [ 2 ] );
 
 		range = new Range( start, end );
 	} );
 
 	describe( 'constructor', () => {
 		it( 'should create a range with given positions', () => {
-			expect( range ).to.have.property( 'start' ).that.equal( start );
-			expect( range ).to.have.property( 'end' ).that.equal( end );
+			expect( range.start.isEqual( start ) ).to.be.true;
+			expect( range.end.isEqual( end ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'root', () => {
+		it( 'should be equal to start position root', () => {
+			expect( range.root ).to.equal( start.root );
+		} );
+	} );
+
+	describe( 'isCollapsed', () => {
+		it( 'should be true if range start and end positions are equal', () => {
+			let collapsedRange = new Range( start, start );
+			expect( collapsedRange.isCollapsed ).to.be.true;
+		} );
+
+		it( 'should be false if range start and end positions are not equal', () => {
+			expect( range.isCollapsed ).to.be.false;
 		} );
 	} );
 
 	describe( 'isEqual', () => {
 		it( 'should return true if the ranges are the same', () => {
-			let sameStart = new Position( root, [ 0 ] );
-			let sameEnd = new Position( root, [ 1 ] );
+			let sameStart = Position.createFromPosition( start );
+			let sameEnd = Position.createFromPosition( end );
 
 			let sameRange = new Range( sameStart, sameEnd );
 
@@ -58,12 +76,12 @@ describe( 'Range', () => {
 		it( 'should return false if the start position is different', () => {
 			let range = new Range( start, end );
 
-			let diffStart = new Position( root, [ 1 ] );
-			let sameEnd = new Position( root, [ 1 ] );
+			let diffStart = new Position( root, [ 0 ] );
+			let sameEnd = Position.createFromPosition( end );
 
 			let diffRange = new Range( diffStart, sameEnd );
 
-			expect( range.isEqual( diffRange ) ).to.not.be.true;
+			expect( range.isEqual( diffRange ) ).to.be.false;
 		} );
 
 		it( 'should return false if the end position is different', () => {
@@ -72,7 +90,53 @@ describe( 'Range', () => {
 
 			let diffRange = new Range( sameStart, diffEnd );
 
-			expect( range.isEqual( diffRange ) ).to.not.be.true;
+			expect( range.isEqual( diffRange ) ).to.be.false;
+		} );
+
+		it( 'should return false if ranges are in different roots', () => {
+			let otherRootStart = new Position( otherRoot, start.path.slice() );
+			let otherRootEnd = new Position( otherRoot, end.path.slice() );
+
+			let otherRootRange = new Range( otherRootStart, otherRootEnd );
+
+			expect( range.isEqual( otherRootRange ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'isIntersecting', () => {
+		it( 'should return true if given range is equal', () => {
+			let otherRange = Range.createFromRange( range );
+			expect( range.isIntersecting( otherRange ) ).to.be.true;
+		} );
+
+		it( 'should return true if given range contains this range', () => {
+			let otherRange = new Range( new Position( root, [ 0 ] ), new Position( root, [ 3 ] ) );
+			expect( range.isIntersecting( otherRange ) ).to.be.true;
+		} );
+
+		it( 'should return true if given range ends in this range', () => {
+			let otherRange = new Range( new Position( root, [ 0 ] ), new Position( root, [ 1, 4 ] ) );
+			expect( range.isIntersecting( otherRange ) ).to.be.true;
+		} );
+
+		it( 'should return true if given range starts in this range', () => {
+			let otherRange = new Range( new Position( root, [ 1, 4 ] ), new Position( root, [ 3 ] ) );
+			expect( range.isIntersecting( otherRange ) ).to.be.true;
+		} );
+
+		it( 'should return false if given range is fully before this range', () => {
+			let otherRange = new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) );
+			expect( range.isIntersecting( otherRange ) ).to.be.false;
+		} );
+
+		it( 'should return false if given range is fully after this range', () => {
+			let otherRange = new Range( new Position( root, [ 2 ] ), new Position( root, [ 2, 0 ] ) );
+			expect( range.isIntersecting( otherRange ) ).to.be.false;
+		} );
+
+		it( 'should return false if ranges are in different roots', () => {
+			let otherRange = new Range( new Position( otherRoot, [ 0 ] ), new Position( otherRoot, [ 1, 4 ] ) );
+			expect( range.isIntersecting( otherRange ) ).to.be.false;
 		} );
 	} );
 
@@ -123,9 +187,18 @@ describe( 'Range', () => {
 				expect( range.end.path ).to.deep.equal( [ 1, 2, 7 ] );
 			} );
 		} );
+
+		describe( 'createFromRange', () => {
+			it( 'should create a new instance of Range that is equal to passed range', () => {
+				const clone = Range.createFromRange( range );
+
+				expect( clone ).not.to.be.equal( range ); // clone is not pointing to the same object as position
+				expect( clone.isEqual( range ) ).to.be.true; // but they are equal in the position-sense
+			} );
+		} );
 	} );
 
-	describe( 'getNodes', () => {
+	describe( 'getAllNodes', () => {
 		it( 'should iterate over all nodes which "starts" in the range', () => {
 			let nodes = [];
 
@@ -146,7 +219,7 @@ describe( 'Range', () => {
 				new Position( root, [ 1, 1 ] )
 			);
 
-			for ( let node of range.getNodes() ) {
+			for ( let node of range.getAllNodes() ) {
 				nodes.push( node );
 			}
 
@@ -154,15 +227,6 @@ describe( 'Range', () => {
 		} );
 	} );
 
-	describe( 'clone', () => {
-		it( 'should return a new, equal position', () => {
-			const clone = range.clone();
-
-			expect( clone ).not.to.be.equal( range ); // clone is not pointing to the same object as position
-			expect( clone.isEqual( range ) ).to.be.true; // but they are equal in the position-sense
-		} );
-	} );
-
 	describe( 'containsPosition', () => {
 		beforeEach( () => {
 			range = new Range( new Position( root, [ 1 ] ), new Position( root, [ 3 ] ) );
@@ -322,4 +386,97 @@ describe( 'Range', () => {
 			expect( common.end.path ).to.deep.equal( [ 4, 7 ] );
 		} );
 	} );
+
+	describe( 'getMinimalFlatRanges', () => {
+		beforeEach( () => {
+			prepareRichRoot( root );
+		} );
+
+		it( 'should return empty array if range is collapsed', () => {
+			let range = new Range( new Position( root, [ 1, 3 ] ), new Position( root, [ 1, 3 ] ) );
+			let flat = range.getMinimalFlatRanges();
+
+			expect( flat.length ).to.equal( 0 );
+		} );
+
+		it( 'should return empty array if range does not contain any node', () => {
+			let range = new Range( new Position( root, [ 1, 3 ] ), new Position( root, [ 2, 0 ] ) );
+			let flat = range.getMinimalFlatRanges();
+
+			expect( flat.length ).to.equal( 0 );
+		} );
+
+		it( 'should return a minimal set of flat ranges that covers the range (start and end in different sub-trees)', () => {
+			let range = new Range( new Position( root, [ 0, 0, 3 ] ), new Position( root, [ 3, 0, 2 ] ) );
+			let flat = range.getMinimalFlatRanges();
+
+			expect( flat.length ).to.equal( 4 );
+			expect( flat[ 0 ].start.path ).to.deep.equal( [ 0, 0, 3 ] );
+			expect( flat[ 0 ].end.path ).to.deep.equal( [ 0, 0, 5 ] );
+			expect( flat[ 1 ].start.path ).to.deep.equal( [ 0, 1 ] );
+			expect( flat[ 1 ].end.path ).to.deep.equal( [ 0, 2 ] );
+			expect( flat[ 2 ].start.path ).to.deep.equal( [ 1 ] );
+			expect( flat[ 2 ].end.path ).to.deep.equal( [ 3 ] );
+			expect( flat[ 3 ].start.path ).to.deep.equal( [ 3, 0, 0 ] );
+			expect( flat[ 3 ].end.path ).to.deep.equal( [ 3, 0, 2 ] );
+		} );
+
+		it( 'should return a minimal set of flat ranges that covers the range (start.path is prefix of end.path)', () => {
+			let range = new Range( new Position( root, [ 0 ] ), new Position( root, [ 0, 1, 4 ] ) );
+			let flat = range.getMinimalFlatRanges();
+
+			expect( flat.length ).to.equal( 2 );
+			expect( flat[ 0 ].start.path ).to.deep.equal( [ 0, 0 ] );
+			expect( flat[ 0 ].end.path ).to.deep.equal( [ 0, 1 ] );
+			expect( flat[ 1 ].start.path ).to.deep.equal( [ 0, 1, 0 ] );
+			expect( flat[ 1 ].end.path ).to.deep.equal( [ 0, 1, 4 ] );
+		} );
+	} );
+
+	describe( 'getTopLevelNodes', () => {
+		beforeEach( () => {
+			prepareRichRoot( root );
+		} );
+
+		it( 'should iterate over all top-level nodes of this range', () => {
+			let range = new Range( new Position( root, [ 0, 0, 3 ] ), new Position( root, [ 3, 0, 2 ] ) );
+			let nodes = Array.from( range.getTopLevelNodes() );
+			let nodeNames = nodes.map( ( node ) => {
+				return ( node instanceof Element ) ? 'E:' + node.name : 'C:' + node.character;
+			} );
+
+			expect( nodeNames ).to.deep.equal( [ 'C:s', 'C:t', 'E:p', 'E:p', 'E:p', 'C:s', 'C:e' ] );
+		} );
+	} );
+
+	describe( 'isFlat', () => {
+		beforeEach( () => {
+			prepareRichRoot( root );
+		} );
+
+		it( 'should be true if start and end position are in the same parent', () => {
+			let range = new Range( new Position( root, [ 0, 0 ] ), new Position( root, [ 0, 2 ] ) );
+			expect( range.isFlat ).to.be.true;
+		} );
+
+		it( 'should be false if start and end position are in different parents', () => {
+			let range = new Range( new Position( root, [ 0, 0 ] ), new Position( root, [ 3, 0, 1 ] ) );
+			expect( range.isFlat ).to.be.false;
+		} );
+	} );
+
+	function prepareRichRoot() {
+		root.insertChildren( 0, [
+			new Element( 'div', [], [
+				new Element( 'h', [], 'first' ),
+				new Element( 'p', [], 'lorem ipsum' )
+			] ),
+			new Element( 'p', [], 'foo' ),
+			new Element( 'p', [], 'bar' ),
+			new Element( 'div', [], [
+				new Element( 'h', [], 'second' ),
+				new Element( 'p', [], 'lorem' )
+			] )
+		] );
+	}
 } );

+ 528 - 0
packages/ckeditor5-engine/tests/treemodel/selection.js

@@ -0,0 +1,528 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel */
+
+/* bender-include: ../_tools/tools.js */
+
+'use strict';
+
+const getIteratorCount = bender.tools.core.getIteratorCount;
+
+const modules = bender.amd.require(
+	'treemodel/document',
+	'treemodel/attribute',
+	'treemodel/element',
+	'treemodel/range',
+	'treemodel/position',
+	'treemodel/liverange',
+	'treemodel/selection',
+	'treemodel/operation/insertoperation',
+	'treemodel/operation/moveoperation',
+	'ckeditorerror'
+);
+
+describe( 'Selection', () => {
+	let Document, Attribute, Element, Range, Position, LiveRange, Selection, InsertOperation, MoveOperation, CKEditorError;
+	let attrFooBar;
+
+	before( () => {
+		Document = modules[ 'treemodel/document' ];
+		Attribute = modules[ 'treemodel/attribute' ];
+		Element = modules[ 'treemodel/element' ];
+		Range = modules[ 'treemodel/range' ];
+		Position = modules[ 'treemodel/position' ];
+		LiveRange = modules[ 'treemodel/liverange' ];
+		Selection = modules[ 'treemodel/selection' ];
+		InsertOperation = modules[ 'treemodel/operation/insertoperation' ];
+		MoveOperation = modules[ 'treemodel/operation/moveoperation' ];
+		CKEditorError = modules.ckeditorerror;
+
+		attrFooBar = new Attribute( 'foo', 'bar' );
+	} );
+
+	let doc, root, selection, liveRange, range;
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+		selection = new Selection();
+
+		liveRange = new LiveRange( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) );
+		range = new Range( new Position( root, [ 2 ] ), new Position( root, [ 2, 2 ] ) );
+	} );
+
+	afterEach( () => {
+		selection.detach();
+		liveRange.detach();
+	} );
+
+	it( 'should not have any range, anchor or focus position when just created', () => {
+		let ranges = selection.getRanges();
+
+		expect( ranges.length ).to.equal( 0 );
+		expect( selection.anchor ).to.be.null;
+		expect( selection.focus ).to.be.null;
+	} );
+
+	it( 'should be collapsed if it has no ranges or all ranges are collapsed', () => {
+		expect( selection.isCollapsed ).to.be.true;
+
+		selection.addRange( new Range( new Position( root, [ 0 ] ), new Position( root, [ 0 ] ) ) );
+
+		expect( selection.isCollapsed ).to.be.true;
+	} );
+
+	it( 'should not be collapsed when it has a range that is not collapsed', () => {
+		selection.addRange( liveRange );
+
+		expect( selection.isCollapsed ).to.be.false;
+
+		selection.addRange( new Range( new Position( root, [ 0 ] ), new Position( root, [ 0 ] ) ) );
+
+		expect( selection.isCollapsed ).to.be.false;
+	} );
+
+	it( 'should copy added ranges and store multiple ranges', () => {
+		selection.addRange( liveRange );
+		selection.addRange( range );
+
+		let ranges = selection.getRanges();
+
+		expect( ranges.length ).to.equal( 2 );
+		expect( ranges[ 0 ].isEqual( liveRange ) ).to.be.true;
+		expect( ranges[ 1 ].isEqual( range ) ).to.be.true;
+		expect( ranges[ 0 ] ).not.to.be.equal( liveRange );
+		expect( ranges[ 1 ] ).not.to.be.equal( range );
+	} );
+
+	it( 'should set anchor and focus to the start and end of the most recently added range', () => {
+		selection.addRange( liveRange );
+
+		expect( selection.anchor.path ).to.deep.equal( [ 0 ] );
+		expect( selection.focus.path ).to.deep.equal( [ 1 ] );
+
+		selection.addRange( range );
+
+		expect( selection.anchor.path ).to.deep.equal( [ 2 ] );
+		expect( selection.focus.path ).to.deep.equal( [ 2, 2 ] );
+	} );
+
+	it( 'should set anchor and focus to the end and start of the most recently added range if backward flag was used', () => {
+		selection.addRange( liveRange, true );
+
+		expect( selection.anchor.path ).to.deep.equal( [ 1 ] );
+		expect( selection.focus.path ).to.deep.equal( [ 0 ] );
+
+		selection.addRange( range, true );
+
+		expect( selection.anchor.path ).to.deep.equal( [ 2, 2 ] );
+		expect( selection.focus.path ).to.deep.equal( [ 2 ] );
+	} );
+
+	it( 'should return a copy of (not a reference to) array of stored ranges', () => {
+		selection.addRange( liveRange );
+
+		let ranges = selection.getRanges();
+
+		selection.addRange( range );
+
+		expect( ranges.length ).to.equal( 1 );
+		expect( ranges[ 0 ].isEqual( liveRange ) ).to.be.true;
+	} );
+
+	it( 'should convert added Range to LiveRange', () => {
+		selection.addRange( range );
+
+		let ranges = selection.getRanges();
+
+		expect( ranges[ 0 ] ).to.be.instanceof( LiveRange );
+	} );
+
+	it( 'should fire update event when adding a range', () => {
+		let spy = sinon.spy();
+		selection.on( 'update', spy );
+
+		selection.addRange( range );
+
+		expect( spy.called ).to.be.true;
+	} );
+
+	it( 'should unbind all events when detached', () => {
+		selection.addRange( liveRange );
+		selection.addRange( range );
+
+		let ranges = selection.getRanges();
+
+		sinon.spy( ranges[ 0 ], 'detach' );
+		sinon.spy( ranges[ 1 ], 'detach' );
+
+		selection.detach();
+
+		expect( ranges[ 0 ].detach.called ).to.be.true;
+		expect( ranges[ 1 ].detach.called ).to.be.true;
+
+		ranges[ 0 ].detach.restore();
+		ranges[ 1 ].detach.restore();
+	} );
+
+	it( 'should throw an error if added range intersects with already stored range', () => {
+		selection.addRange( liveRange );
+
+		expect( () => {
+			selection.addRange(
+				new Range(
+					new Position( root, [ 0, 4 ] ),
+					new Position( root, [ 1, 2 ] )
+				)
+			);
+		} ).to.throw( CKEditorError, /selection-range-intersects/ );
+	} );
+
+	describe( 'removeAllRanges', () => {
+		let spy, ranges;
+
+		beforeEach( () => {
+			selection.addRange( liveRange );
+			selection.addRange( range );
+
+			spy = sinon.spy();
+			selection.on( 'update', spy );
+
+			ranges = selection.getRanges();
+
+			sinon.spy( ranges[ 0 ], 'detach' );
+			sinon.spy( ranges[ 1 ], 'detach' );
+
+			selection.removeAllRanges();
+		} );
+
+		afterEach( () => {
+			ranges[ 0 ].detach.restore();
+			ranges[ 1 ].detach.restore();
+		} );
+
+		it( 'should remove all stored ranges', () => {
+			expect( selection.getRanges().length ).to.equal( 0 );
+			expect( selection.anchor ).to.be.null;
+			expect( selection.focus ).to.be.null;
+			expect( selection.isCollapsed ).to.be.true;
+		} );
+
+		it( 'should fire exactly one update event', () => {
+			expect( spy.calledOnce ).to.be.true;
+		} );
+
+		it( 'should detach removed ranges', () => {
+			expect( ranges[ 0 ].detach.called ).to.be.true;
+			expect( ranges[ 1 ].detach.called ).to.be.true;
+		} );
+	} );
+
+	describe( 'setRanges', () => {
+		let newRanges, spy, oldRanges;
+
+		before( () => {
+			newRanges = [
+				new Range( new Position( root, [ 4 ] ), new Position( root, [ 5 ] ) ),
+				new Range( new Position( root, [ 5, 0 ] ), new Position( root, [ 6, 0 ] ) )
+			];
+		} );
+
+		beforeEach( () => {
+			selection.addRange( liveRange );
+			selection.addRange( range );
+
+			spy = sinon.spy();
+			selection.on( 'update', spy );
+
+			oldRanges = selection.getRanges();
+
+			sinon.spy( oldRanges[ 0 ], 'detach' );
+			sinon.spy( oldRanges[ 1 ], 'detach' );
+		} );
+
+		afterEach( () => {
+			oldRanges[ 0 ].detach.restore();
+			oldRanges[ 1 ].detach.restore();
+		} );
+
+		it( 'should remove all ranges and add given ranges', () => {
+			selection.setRanges( newRanges );
+
+			let ranges = selection.getRanges();
+
+			expect( ranges.length ).to.equal( 2 );
+			expect( ranges[ 0 ].isEqual( newRanges[ 0 ] ) ).to.be.true;
+			expect( ranges[ 1 ].isEqual( newRanges[ 1 ] ) ).to.be.true;
+		} );
+
+		it( 'should use last range from given array to get anchor and focus position', () => {
+			selection.setRanges( newRanges );
+			expect( selection.anchor.path ).to.deep.equal( [ 5, 0 ] );
+			expect( selection.focus.path ).to.deep.equal( [ 6, 0 ] );
+		} );
+
+		it( 'should acknowledge backward flag when setting anchor and focus', () => {
+			selection.setRanges( newRanges, true );
+			expect( selection.anchor.path ).to.deep.equal( [ 6, 0 ] );
+			expect( selection.focus.path ).to.deep.equal( [ 5, 0 ] );
+		} );
+
+		it( 'should fire exactly one update event', () => {
+			selection.setRanges( newRanges );
+			expect( spy.calledOnce ).to.be.true;
+		} );
+
+		it( 'should detach removed LiveRanges', () => {
+			selection.setRanges( newRanges );
+			expect( oldRanges[ 0 ].detach.called ).to.be.true;
+			expect( oldRanges[ 1 ].detach.called ).to.be.true;
+		} );
+	} );
+
+	// Selection uses LiveRanges so here are only simple test to see if integration is
+	// working well, without getting into complicated corner cases.
+	describe( 'after applying an operation should get updated and not fire update event', () => {
+		let spy;
+
+		beforeEach( () => {
+			root.insertChildren( 0, [ new Element( 'ul', [], 'abcdef' ), new Element( 'p', [], 'foobar' ), 'xyz' ] );
+
+			selection.addRange( new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 1, 4 ] ) ) );
+
+			spy = sinon.spy();
+			selection.on( 'update', spy );
+		} );
+
+		describe( 'InsertOperation', () => {
+			it( 'before selection', () => {
+				doc.applyOperation(
+					new InsertOperation(
+						new Position( root, [ 0, 1 ] ),
+						'xyz',
+						doc.version
+					)
+				);
+
+				let range = selection.getRanges()[ 0 ];
+
+				expect( range.start.path ).to.deep.equal( [ 0, 5 ] );
+				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
+				expect( spy.called ).to.be.false;
+			} );
+
+			it( 'inside selection', () => {
+				doc.applyOperation(
+					new InsertOperation(
+						new Position( root, [ 1, 0 ] ),
+						'xyz',
+						doc.version
+					)
+				);
+
+				let range = selection.getRanges()[ 0 ];
+
+				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
+				expect( range.end.path ).to.deep.equal( [ 1, 7 ] );
+				expect( spy.called ).to.be.false;
+			} );
+		} );
+
+		describe( 'MoveOperation', () => {
+			it( 'move range from before a selection', () => {
+				doc.applyOperation(
+					new MoveOperation(
+						new Position( root, [ 0, 0 ] ),
+						2,
+						new Position( root, [ 2 ] ),
+						doc.version
+					)
+				);
+
+				let range = selection.getRanges()[ 0 ];
+
+				expect( range.start.path ).to.deep.equal( [ 0, 0 ] );
+				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
+				expect( spy.called ).to.be.false;
+			} );
+
+			it( 'moved into before a selection', () => {
+				doc.applyOperation(
+					new MoveOperation(
+						new Position( root, [ 2 ] ),
+						2,
+						new Position( root, [ 0, 0 ] ),
+						doc.version
+					)
+				);
+
+				let range = selection.getRanges()[ 0 ];
+
+				expect( range.start.path ).to.deep.equal( [ 0, 4 ] );
+				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
+				expect( spy.called ).to.be.false;
+			} );
+
+			it( 'move range from inside of selection', () => {
+				doc.applyOperation(
+					new MoveOperation(
+						new Position( root, [ 1, 0 ] ),
+						2,
+						new Position( root, [ 2 ] ),
+						doc.version
+					)
+				);
+
+				let range = selection.getRanges()[ 0 ];
+
+				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
+				expect( range.end.path ).to.deep.equal( [ 1, 2 ] );
+				expect( spy.called ).to.be.false;
+			} );
+
+			it( 'moved range intersects with selection', () => {
+				doc.applyOperation(
+					new MoveOperation(
+						new Position( root, [ 1, 3 ] ),
+						2,
+						new Position( root, [ 4 ] ),
+						doc.version
+					)
+				);
+
+				let range = selection.getRanges()[ 0 ];
+
+				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
+				expect( range.end.path ).to.deep.equal( [ 1, 3 ] );
+				expect( spy.called ).to.be.false;
+			} );
+
+			it( 'split inside selection (do not break selection)', () => {
+				doc.applyOperation(
+					new InsertOperation(
+						new Position( root, [ 2 ] ),
+						new Element( 'p' ),
+						doc.version
+					)
+				);
+
+				doc.applyOperation(
+					new MoveOperation(
+						new Position( root, [ 1, 2 ] ),
+						4,
+						new Position( root, [ 2, 0 ] ),
+						doc.version
+					)
+				);
+
+				let range = selection.getRanges()[ 0 ];
+
+				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
+				expect( range.end.path ).to.deep.equal( [ 2, 2 ] );
+				expect( spy.called ).to.be.false;
+			} );
+		} );
+	} );
+
+	// Testing integration with attributes list.
+	// Tests copied from AttributeList tests.
+	// Some cases were omitted.
+
+	describe( 'setAttr', () => {
+		it( 'should insert an attribute', () => {
+			selection.setAttr( attrFooBar );
+
+			expect( getIteratorCount( selection.getAttrs() ) ).to.equal( 1 );
+			expect( selection.getAttr( attrFooBar.key ) ).to.equal( attrFooBar.value );
+		} );
+	} );
+
+	describe( 'setAttrsTo', () => {
+		it( 'should remove all attributes and set passed ones', () => {
+			selection.setAttr( attrFooBar );
+
+			let attrs = [ new Attribute( 'abc', true ), new Attribute( 'xyz', false ) ];
+
+			selection.setAttrsTo( attrs );
+
+			expect( getIteratorCount( selection.getAttrs() ) ).to.equal( 2 );
+			expect( selection.getAttr( 'foo' ) ).to.be.null;
+			expect( selection.getAttr( 'abc' ) ).to.be.true;
+			expect( selection.getAttr( 'xyz' ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'getAttr', () => {
+		beforeEach( () => {
+			selection.setAttr( attrFooBar );
+		} );
+
+		it( 'should return attribute value if key of previously set attribute has been passed', () => {
+			expect( selection.getAttr( 'foo' ) ).to.equal( attrFooBar.value );
+		} );
+
+		it( 'should return null if attribute with given key has not been found', () => {
+			expect( selection.getAttr( 'bar' ) ).to.be.null;
+		} );
+	} );
+
+	describe( 'removeAttr', () => {
+		it( 'should remove an attribute', () => {
+			let attrA = new Attribute( 'a', 'A' );
+			let attrB = new Attribute( 'b', 'B' );
+			let attrC = new Attribute( 'c', 'C' );
+
+			selection.setAttr( attrA );
+			selection.setAttr( attrB );
+			selection.setAttr( attrC );
+
+			selection.removeAttr( attrB.key );
+
+			expect( getIteratorCount( selection.getAttrs() ) ).to.equal( 2 );
+			expect( selection.getAttr( attrA.key ) ).to.equal( attrA.value );
+			expect( selection.getAttr( attrC.key ) ).to.equal( attrC.value );
+			expect( selection.getAttr( attrB.key ) ).to.be.null;
+		} );
+	} );
+
+	describe( 'hasAttr', () => {
+		it( 'should check attribute by key', () => {
+			selection.setAttr( attrFooBar );
+			expect( selection.hasAttr( 'foo' ) ).to.be.true;
+		} );
+
+		it( 'should return false if attribute was not found by key', () => {
+			expect( selection.hasAttr( 'bar' ) ).to.be.false;
+		} );
+
+		it( 'should check attribute by object', () => {
+			selection.setAttr( attrFooBar );
+			expect( selection.hasAttr( attrFooBar ) ).to.be.true;
+		} );
+
+		it( 'should return false if attribute was not found by object', () => {
+			expect( selection.hasAttr( attrFooBar ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'getAttrs', () => {
+		it( 'should return all set attributes', () => {
+			let attrA = new Attribute( 'a', 'A' );
+			let attrB = new Attribute( 'b', 'B' );
+			let attrC = new Attribute( 'c', 'C' );
+
+			selection.setAttrsTo( [
+				attrA,
+				attrB,
+				attrC
+			] );
+
+			selection.removeAttr( attrB.key );
+
+			expect( [ attrA, attrC ] ).to.deep.equal( Array.from( selection.getAttrs() ) );
+		} );
+	} );
+} );