Procházet zdrojové kódy

Merge pull request #116 from ckeditor/t/113-b

T/113 b LivePosition, LiveRange and Selection
Piotr Jasiun před 10 roky
rodič
revize
b11910d427
33 změnil soubory, kde provedl 2805 přidání a 399 odebrání
  1. 2 3
      packages/ckeditor5-engine/.jscsrc
  2. 0 4
      packages/ckeditor5-engine/src/config.js
  3. 15 1
      packages/ckeditor5-engine/src/emittermixin.js
  4. 120 0
      packages/ckeditor5-engine/src/treemodel/attributelist.js
  5. 13 4
      packages/ckeditor5-engine/src/treemodel/document.js
  6. 188 0
      packages/ckeditor5-engine/src/treemodel/liveposition.js
  7. 141 0
      packages/ckeditor5-engine/src/treemodel/liverange.js
  8. 26 64
      packages/ckeditor5-engine/src/treemodel/node.js
  9. 4 3
      packages/ckeditor5-engine/src/treemodel/operation/attributeoperation.js
  10. 3 2
      packages/ckeditor5-engine/src/treemodel/operation/insertoperation.js
  11. 6 5
      packages/ckeditor5-engine/src/treemodel/operation/moveoperation.js
  12. 4 0
      packages/ckeditor5-engine/src/treemodel/operation/removeoperation.js
  13. 3 2
      packages/ckeditor5-engine/src/treemodel/operation/transform.js
  14. 68 16
      packages/ckeditor5-engine/src/treemodel/position.js
  15. 53 37
      packages/ckeditor5-engine/src/treemodel/range.js
  16. 248 0
      packages/ckeditor5-engine/src/treemodel/selection.js
  17. 0 82
      packages/ckeditor5-engine/src/treemodel/smartrange.js
  18. 12 0
      packages/ckeditor5-engine/tests/emittermixin/emittermixin.js
  19. 161 0
      packages/ckeditor5-engine/tests/treemodel/attributelist.js
  20. 2 1
      packages/ckeditor5-engine/tests/treemodel/document/document.js
  21. 377 0
      packages/ckeditor5-engine/tests/treemodel/liveposition.js
  22. 515 0
      packages/ckeditor5-engine/tests/treemodel/liverange.js
  23. 116 114
      packages/ckeditor5-engine/tests/treemodel/node.js
  24. 1 1
      packages/ckeditor5-engine/tests/treemodel/operation/attributeoperation.js
  25. 4 4
      packages/ckeditor5-engine/tests/treemodel/operation/insertoperation.js
  26. 5 5
      packages/ckeditor5-engine/tests/treemodel/operation/moveoperation.js
  27. 1 1
      packages/ckeditor5-engine/tests/treemodel/operation/nooperation.js
  28. 11 1
      packages/ckeditor5-engine/tests/treemodel/operation/reinsertoperation.js
  29. 17 5
      packages/ckeditor5-engine/tests/treemodel/operation/removeoperation.js
  30. 11 15
      packages/ckeditor5-engine/tests/treemodel/operation/transform.js
  31. 66 9
      packages/ckeditor5-engine/tests/treemodel/position.js
  32. 84 20
      packages/ckeditor5-engine/tests/treemodel/range.js
  33. 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

@@ -75,9 +75,7 @@ CKEDITOR.define( [ 'model', 'utils' ], ( Model, utils ) => {
 			}
 
 			// 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( '.' );
@@ -131,9 +129,7 @@ CKEDITOR.define( [ 'model', 'utils' ], ( Model, utils ) => {
 		 */
 		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( '.' );

+ 15 - 1
packages/ckeditor5-engine/src/emittermixin.js

@@ -13,6 +13,10 @@
  */
 
 CKEDITOR.define( [ 'eventinfo', 'utils' ], ( EventInfo, utils ) => {
+	// 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;
+
 	const EmitterMixin = {
 		/**
 		 * Registers a callback function to be executed when an event is fired.
@@ -35,7 +39,9 @@ CKEDITOR.define( [ 'eventinfo', 'utils' ], ( EventInfo, utils ) => {
 			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.
@@ -226,7 +232,15 @@ CKEDITOR.define( [ 'eventinfo', 'utils' ], ( EventInfo, utils ) => {
 			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.

+ 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;
+} );

+ 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 );
 		}
 
 		/**

+ 4 - 3
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() {

+ 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.

+ 6 - 5
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.
 	 *
@@ -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
 					);
 				} );

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

@@ -128,16 +128,6 @@ CKEDITOR.define( [ 'treemodel/rootelement', 'utils', 'ckeditorerror' ], ( RootEl
 			return parent;
 		}
 
-		/**
-		 * 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.
 		 *
@@ -192,7 +182,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 +231,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 ) {
@@ -354,6 +344,58 @@ CKEDITOR.define( [ 'treemodel/rootelement', 'utils', 'ckeditorerror' ], ( RootEl
 			return this.compareWith( otherPosition ) == SAME;
 		}
 
+		/**
+		 * 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 = Position.createAfter( left.parent );
+				} else {
+					if ( right.nodeBefore !== null ) {
+						return false;
+					}
+
+					right = Position.createBefore( right.parent );
+				}
+			}
+		}
+
 		/**
 		 * Creates a new position after given node.
 		 *
@@ -371,7 +413,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 +433,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 +448,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 +495,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.
 

+ 53 - 37
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,41 @@ 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 );
 		}
 
 		/**
-		 * Creates and returns a new instance of {@link treeModel.Range}
-		 * which is equal to this {@link treeModel.Range range}.
+		 * Range root element. Equals to the root of start position (which should be same as root of end position).
 		 *
-		 * @returns {treeModel.Position} Cloned {@link treeModel.Range range}.
+		 * @property {treeModel.RootElement}
 		 */
-		clone() {
-			return new Range( this.start.clone(), this.end.clone() );
+		get root() {
+			return this.start.root;
+		}
+
+		/**
+		 * Range iterator.
+		 *
+		 * @see treeModel.PositionIterator
+		 */
+		[ Symbol.iterator ]() {
+			return new PositionIterator( this );
 		}
 
 		/**
@@ -99,33 +108,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 +147,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 +165,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.
@@ -229,10 +228,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 +238,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 );
@@ -261,6 +257,16 @@ CKEDITOR.define( [ 'treemodel/position', 'treemodel/positioniterator', 'utils' ]
 			return this.start.isEqual( otherRange.start ) && this.end.isEqual( otherRange.end );
 		}
 
+		/**
+		 * 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.
 		 *
@@ -268,7 +274,7 @@ CKEDITOR.define( [ 'treemodel/position', 'treemodel/positioniterator', 'utils' ]
 		 * @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 +285,10 @@ CKEDITOR.define( [ 'treemodel/position', 'treemodel/positioniterator', 'utils' ]
 		 * @returns {treeModel.Range}
 		 */
 		static createFromPositionAndShift( position, shift ) {
-			let endPosition = position.clone();
+			let endPosition = Position.createFromPosition( position );
 			endPosition.offset += shift;
 
-			return new Range( position, endPosition );
+			return new this( position, endPosition );
 		}
 
 		/**
@@ -295,11 +301,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;
-} );

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

@@ -118,6 +118,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 - 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

+ 66 - 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 ] );

+ 84 - 20
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,6 +187,15 @@ 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', () => {
@@ -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 ] ) );

+ 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() ) );
+		} );
+	} );
+} );