8
0
Просмотр исходного кода

Merge pull request #139 from ckeditor/t/62-c

t/62-c - Node list compression. Fixed #62.
Piotr Jasiun 10 лет назад
Родитель
Сommit
c957de0476
42 измененных файлов с 1909 добавлено и 1245 удалено
  1. 0 43
      packages/ckeditor5-engine/src/treemodel/attribute.js
  2. 93 56
      packages/ckeditor5-engine/src/treemodel/attributelist.js
  3. 0 34
      packages/ckeditor5-engine/src/treemodel/character.js
  4. 72 0
      packages/ckeditor5-engine/src/treemodel/characterproxy.js
  5. 6 6
      packages/ckeditor5-engine/src/treemodel/delta/attributedelta.js
  6. 2 2
      packages/ckeditor5-engine/src/treemodel/delta/insertdelta.js
  7. 1 1
      packages/ckeditor5-engine/src/treemodel/delta/splitdelta.js
  8. 3 3
      packages/ckeditor5-engine/src/treemodel/delta/weakinsertdelta.js
  9. 11 8
      packages/ckeditor5-engine/src/treemodel/element.js
  10. 8 49
      packages/ckeditor5-engine/src/treemodel/node.js
  11. 283 34
      packages/ckeditor5-engine/src/treemodel/nodelist.js
  12. 33 12
      packages/ckeditor5-engine/src/treemodel/operation/attributeoperation.js
  13. 1 1
      packages/ckeditor5-engine/src/treemodel/operation/insertoperation.js
  14. 0 171
      packages/ckeditor5-engine/src/treemodel/positioniterator.js
  15. 79 31
      packages/ckeditor5-engine/src/treemodel/range.js
  16. 5 47
      packages/ckeditor5-engine/src/treemodel/selection.js
  17. 13 7
      packages/ckeditor5-engine/src/treemodel/text.js
  18. 85 0
      packages/ckeditor5-engine/src/treemodel/textfragment.js
  19. 282 0
      packages/ckeditor5-engine/src/treemodel/treewalker.js
  20. 5 4
      packages/ckeditor5-engine/tests/treemodel/_utils/utils.js
  21. 0 47
      packages/ckeditor5-engine/tests/treemodel/attribute.js
  22. 123 82
      packages/ckeditor5-engine/tests/treemodel/attributelist.js
  23. 0 42
      packages/ckeditor5-engine/tests/treemodel/character.js
  24. 46 0
      packages/ckeditor5-engine/tests/treemodel/characterproxy.js
  25. 19 17
      packages/ckeditor5-engine/tests/treemodel/delta/attributedelta.js
  26. 2 5
      packages/ckeditor5-engine/tests/treemodel/delta/mergedelta.js
  27. 8 11
      packages/ckeditor5-engine/tests/treemodel/delta/splitdelta.js
  28. 5 5
      packages/ckeditor5-engine/tests/treemodel/delta/weakinsertdelta.js
  29. 19 24
      packages/ckeditor5-engine/tests/treemodel/element.js
  30. 33 141
      packages/ckeditor5-engine/tests/treemodel/node.js
  31. 202 19
      packages/ckeditor5-engine/tests/treemodel/nodelist.js
  32. 61 45
      packages/ckeditor5-engine/tests/treemodel/operation/attributeoperation.js
  33. 6 6
      packages/ckeditor5-engine/tests/treemodel/operation/insertoperation.js
  34. 3 7
      packages/ckeditor5-engine/tests/treemodel/operation/removeoperation.js
  35. 30 31
      packages/ckeditor5-engine/tests/treemodel/position.js
  36. 0 127
      packages/ckeditor5-engine/tests/treemodel/positioniterator.js
  37. 79 18
      packages/ckeditor5-engine/tests/treemodel/range.js
  38. 1 4
      packages/ckeditor5-engine/tests/treemodel/rootelement.js
  39. 4 103
      packages/ckeditor5-engine/tests/treemodel/selection.js
  40. 11 2
      packages/ckeditor5-engine/tests/treemodel/text.js
  41. 62 0
      packages/ckeditor5-engine/tests/treemodel/textfragment.js
  42. 213 0
      packages/ckeditor5-engine/tests/treemodel/treewalker.js

+ 0 - 43
packages/ckeditor5-engine/src/treemodel/attribute.js

@@ -46,11 +46,6 @@ export default class Attribute {
 		 */
 		this._hash = this.key + ': ' + JSON.stringify( this.value, sort );
 
-		// If attribute is already registered the registered one should be returned.
-		if ( Attribute._register[ this._hash ] ) {
-			return Attribute._register[ this._hash ];
-		}
-
 		// We do not care about the order, so collections with the same elements should return the same hash.
 		function sort( key, value ) {
 			if ( !langUtils.isArray( value ) && langUtils.isObject( value ) ) {
@@ -82,42 +77,4 @@ export default class Attribute {
 	isEqual( otherAttr ) {
 		return this._hash === otherAttr._hash;
 	}
-
-	/**
-	 * To save memory, commonly used attributes may be registered. If an attribute is registered the constructor will
-	 * always return the same instance of this attribute.
-	 *
-	 * Note that attributes are registered globally.
-	 *
-	 *		let attr1 = Attribute.register( 'bold', true );
-	 *		let attr2 = Attribute.register( 'bold', true );
-	 *		let attr3 = new Attribute( 'bold', true );
-	 *		attr1 === attr2 // true
-	 *		attr1 === attr3 // true
-	 *
-	 * @static
-	 * @param {String} key Attribute key.
-	 * @param {Mixed} value Attribute value.
-	 * @returns {treeModel.Attribute} Registered attribute.
-	 */
-	static register( key, value ) {
-		const attr = new Attribute( key, value );
-
-		if ( this._register[ attr._hash ] ) {
-			return this._register[ attr._hash ];
-		} else {
-			this._register[ attr._hash ] = attr;
-
-			return attr;
-		}
-	}
 }
-
-/**
- * Register of attributes in which all registered attributes are stored.
- *
- * @static
- * @private
- * @property {String} _hash
- */
-Attribute._register = {};

+ 93 - 56
packages/ckeditor5-engine/src/treemodel/attributelist.js

@@ -8,111 +8,148 @@
 import Attribute from './attribute.js';
 
 /**
- * List of attributes. Used to manage a set of attributes added to and removed from an object containing
- * AttributeList.
+ * List of attributes.
  *
  * @class treeModel.AttributeList
  */
-export default class AttributeList {
+export default class AttributeList extends Map {
 	/**
-	 * Creates a list of attributes.
+	 * Creates AttributeList. If parameter is passed, initializes created list with passed {@link treeModel.Attribute}s.
 	 *
-	 * @param {Iterable.<treeModel.Attribute>} [attrs] Attributes to initialize this list with.
 	 * @constructor
+	 * @param {Iterable.<treeModel.Attribute>} attrs Attributes to initialize with.
 	 */
 	constructor( attrs ) {
+		super();
+
+		if ( attrs ) {
+			this.setTo( attrs );
+		}
+
 		/**
-		 * Internal set containing the attributes stored by this list.
+		 * Amount of attributes added to the AttributeList.
 		 *
-		 * @private
-		 * @property {Set.<treeModel.Attribute>} _attrs
+		 * @property {Number} size
 		 */
+	}
+
+	/**
+	 * AttributeList iterator. Iterates over all attributes from the list.
+	 */
+	[ Symbol.iterator ]() {
+		let it = super[ Symbol.iterator ]();
 
-		this.setAttrsTo( attrs );
+		return {
+			next: () => {
+				let step = it.next();
+
+				return {
+					value: step.value ? step.value[ 1 ] : undefined,
+					done: step.done
+				};
+			}
+		};
 	}
 
 	/**
-	 * Returns value of an attribute with given key or null if there are no attributes with given key.
+	 * Adds attribute to the attributes list. If attribute with the same key already is set, it overwrites its values.
 	 *
-	 * @param {String} key The attribute key.
-	 * @returns {*|null} Value of found attribute or null if attribute with given key has not been found.
+	 * @chainable
+	 * @param {treeModel.Attribute} attr Attribute to add or overwrite.
+	 * @returns {treeModel.AttributeList} This AttributeList object.
 	 */
-	getAttr( key ) {
-		for ( let attr of this._attrs ) {
-			if ( attr.key == key ) {
-				return attr.value;
-			}
-		}
+	set( attr ) {
+		super.set( attr.key, attr );
 
-		return null;
+		return this;
 	}
 
 	/**
-	 * Returns attribute iterator.
+	 * Removes all attributes from AttributeList and adds given attributes.
 	 *
-	 * @returns {Iterable.<treeModel.Attribute>} Attribute iterator.
+	 * @param {Iterable.<Attribute>} attrs Iterable object containing attributes to be set.
 	 */
-	getAttrs() {
-		return this._attrs[ Symbol.iterator ]();
+	setTo( attrs ) {
+		this.clear();
+
+		for ( let value of attrs ) {
+			this.set( value );
+		}
 	}
 
 	/**
-	 * Returns `true` if the object contains given {@link treeModel.Attribute attribute} or
-	 * an attribute with the same key if passed parameter was a string.
+	 * Checks if AttributeList contains attribute {@link treeModel.Attribute#isEqual equal} to given attribute or
+	 * attribute with given key if string was passed.
 	 *
-	 * @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.
+	 * @param {treeModel.Attribute|String} attrOrKey Attribute or key of attribute to check.
+	 * @returns {Boolean} `true` if given attribute or attribute with given key exists in AttributeList. `false` otherwise.
 	 */
-	hasAttr( attrOrKey ) {
+	has( attrOrKey ) {
 		if ( attrOrKey instanceof Attribute ) {
-			for ( let attr of this._attrs ) {
-				if ( attr.isEqual( attrOrKey ) ) {
-					return true;
-				}
+			let attr = this.get( attrOrKey.key );
+
+			if ( attr ) {
+				return attr.isEqual( attrOrKey );
 			}
 		} else {
-			for ( let attr of this._attrs ) {
-				if ( attr.key == attrOrKey ) {
-					return true;
-				}
-			}
+			return super.has( attrOrKey );
 		}
 
 		return false;
 	}
 
 	/**
-	 * Removes attribute from the list of attributes.
+	 * Gets an attribute value by attribute key.
+	 *
+	 * @param {String} key Key of attribute to look for.
+	 * @returns {*} Value of attribute with given key or null if the attribute has not been found in AttributeList
+	 */
+	getValue( key ) {
+		let attr = this.get( key );
+
+		return attr ? attr.value : null;
+	}
+
+	/**
+	 * Checks whether this AttributeList has exactly same attributes as given one.
 	 *
-	 * @param {String} key The attribute key.
+	 * @param {treeModel.AttributeList} attrs AttributeList to compare with.
+	 * @returns {Boolean} `true` if AttributeLists are equal, `false` otherwise.
 	 */
-	removeAttr( key ) {
-		for ( let attr of this._attrs ) {
-			if ( attr.key == key ) {
-				this._attrs.delete( attr );
+	isEqual( attrs ) {
+		if ( this.size != attrs.size ) {
+			return false;
+		}
 
-				return;
+		for ( let attr of attrs ) {
+			if ( !this.has( attr ) ) {
+				return false;
 			}
 		}
+
+		return true;
 	}
 
 	/**
-	 * Sets a given attribute. If the attribute with the same key already exists it will be removed.
+	 * Gets an attribute by its key.
 	 *
-	 * @param {treeModel.Attribute} attr Attribute to set.
+	 * @method get
+	 * @param {String} key Key of attribute to look for.
+	 * @returns {treeModel.Attribute|null} Attribute with given key or null if the attribute has not been found in
+	 * AttributeList.
 	 */
-	setAttr( attr ) {
-		this.removeAttr( attr.key );
 
-		this._attrs.add( attr );
-	}
+	/**
+	 * Removes an attribute with given key from AttributeList.
+	 *
+	 * @method delete
+	 * @param {String} key Key of attribute to remove.
+	 * @returns {Boolean} `true` if the attribute existed in the AttributeList. `false` otherwise.
+	 */
 
 	/**
-	 * Removes all attributes and sets passed attributes.
+	 * Removes all attributes from AttributeList.
 	 *
-	 * @param {Iterable.<treeModel.Attribute>} attrs Array of attributes to set.
+	 * @method clear
 	 */
-	setAttrsTo( attrs ) {
-		this._attrs = new Set( attrs );
-	}
-}
+}

+ 0 - 34
packages/ckeditor5-engine/src/treemodel/character.js

@@ -1,34 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-'use strict';
-
-import Node from './node.js';
-
-/**
- * Data structure for character stored in the tree data model.
- *
- * @class treeModel.Character
- */
-export default class Character extends Node {
-	/**
-	 * Creates character linear item.
-	 *
-	 * @param {String} character Described character.
-	 * @param {Iterable} attrs Iterable collection of {@link treeModel.Attribute attributes}.
-	 * @constructor
-	 */
-	constructor( character, attrs ) {
-		super( attrs );
-
-		/**
-		 * Described character.
-		 *
-		 * @readonly
-		 * @property {String} character
-		 */
-		this.character = character;
-	}
-}

+ 72 - 0
packages/ckeditor5-engine/src/treemodel/characterproxy.js

@@ -0,0 +1,72 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Node from './node.js';
+
+/**
+ * A proxy object representing one character stored in the tree data model. It looks and behaves like
+ * normal node, but is a read-only structure. This is a representation of the data. Manipulating it won't affect
+ * the actual nodes in tree model.
+ *
+ * Keep in mind that CharacterProxy is static, meaning that it won't change when tree model changes. For example, if you
+ * have a {treeModel.Element element} `myEl` containing text `foobar` and then assign `let b = myEl.getChild( 3 )` and
+ * then remove all nodes from `myEl`, `b` will still have character `b`, parent `myEl` and offset `3`.
+ *
+ * CharacterProxy is created on the fly basing on tree model. It is not an explicit node in a tree model but
+ * rather represents it. Because of this, it is not advised to store or compare instances of CharacterProxy class.
+ * If you want to keep live reference to a point in a text, you should use {@link treeModel.LivePosition}.
+ *
+ * You should never create an instance of this class by your own. When passing parameters to constructors,
+ * use string literals or {@link treeModel.Text} instead.
+ *
+ * @class treeModel.CharacterProxy
+ */
+export default class CharacterProxy extends Node {
+	/**
+	 * Creates character node proxy.
+	 *
+	 * @param {treeModel.NodeListText} nodeListText Reference to a text object in a node list containing this character.
+	 * @param {Number} index Index of the character in `nodeListText`.
+	 * @protected
+	 * @constructor
+	 */
+	constructor( nodeListText, index ) {
+		super( nodeListText.attrs );
+
+		/**
+		 * Reference to a text object in a node list containing this character.
+		 *
+		 * @protected
+		 * @readonly
+		 * @property {treeModel.NodeListText} nodeListText
+		 */
+		this._nodeListText = nodeListText;
+
+		/**
+		 * Index of the character in `nodeListText`.
+		 *
+		 * @protected
+		 * @readonly
+		 * @property {Number} index
+		 */
+		this._index = index;
+
+		/**
+		 * Character represented by this proxy.
+		 *
+		 * @protected
+		 * @readonly
+		 * @property {String} character
+		 */
+		this.character = nodeListText.text.substr( index, 1 );
+
+		/**
+		 * @see {@link treeModel.Node#parent}
+		 */
+		this.parent = this._nodeListText.parent;
+	}
+}

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

@@ -10,7 +10,7 @@ import register from './register.js';
 import AttributeOperation from '../operation/attributeoperation.js';
 import Position from '../position.js';
 import Range from '../range.js';
-import PositionIterator from '../positioniterator.js';
+import TreeWalker from '../treewalker.js';
 import Attribute from '../attribute.js';
 import Element from '../element.js';
 
@@ -67,7 +67,7 @@ function attribute( batch, key, value, nodeOrRange ) {
 }
 
 function changeNode( doc, delta, key, value, node ) {
-	const previousValue = node.getAttr( key );
+	const previousValue = node.attrs.getValue( key );
 	let range;
 
 	if ( previousValue != value ) {
@@ -114,9 +114,9 @@ function changeRange( doc, delta, key, value, range ) {
 
 	while ( !next.done ) {
 		// We check values only when the range contains given element, that is when the iterator "enters" the element.
-		// To prevent double-checking or not needed checking, we filter-out iterator values for ELEMENT_LEAVE position.
-		if ( next.value.type != PositionIterator.ELEMENT_LEAVE ) {
-			valueAfter = next.value.node.getAttr( key );
+		// To prevent double-checking or not needed checking, we filter-out iterator values for ELEMENT_END position.
+		if ( next.value.type != TreeWalker.ELEMENT_END ) {
+			valueAfter = next.value.item.attrs.getValue( key );
 
 			// At the first run of the iterator the position in undefined. We also do not have a valueBefore, but
 			// because valueAfter may be null, valueBefore may be equal valueAfter ( undefined == null ).
@@ -153,4 +153,4 @@ function changeRange( doc, delta, key, value, range ) {
 		doc.applyOperation( operation );
 		delta.addOperation( operation );
 	}
-}
+}

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

@@ -24,7 +24,7 @@ export default class InsertDelta extends Delta {}
  * @memberOf treeModel.Batch
  * @method insert
  * @param {treeModel.Position} position Position of insertion.
- * @param {treeModel.Node|treeModel.Text|treeModel.NodeList|String|Iterable} nodes The list of nodes to be inserted.
+ * @param {treeModel.NodesSet} nodes The list of nodes to be inserted.
  * List of nodes can be of any type accepted by the {@link treeModel.NodeList} constructor.
  */
 register( 'insert', function( position, nodes ) {
@@ -37,4 +37,4 @@ register( 'insert', function( position, nodes ) {
 	this.addDelta( delta );
 
 	return this;
-} );
+} );

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

@@ -45,7 +45,7 @@ register( 'split', function( position ) {
 		throw new CKEditorError( 'batch-split-root: Root element can not be split.' );
 	}
 
-	const copy = new Element( splitElement.name, splitElement.getAttrs() );
+	const copy = new Element( splitElement.name, splitElement.attrs );
 	const insert = new InsertOperation( Position.createAfter( splitElement ), copy, this.doc.version );
 
 	this.doc.applyOperation( insert );

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

@@ -33,7 +33,7 @@ export default class WeakInsertDelta extends Delta {}
  * @memberOf treeModel.Batch
  * @method weakInsert
  * @param {treeModel.Position} position Position of insertion.
- * @param {treeModel.Node|treeModel.Text|treeModel.NodeList|String|Iterable} nodes The list of nodes to be inserted.
+ * @param {treeModel.NodesSet} nodes The list of nodes to be inserted.
  * List of nodes can be of any type accepted by the {@link treeModel.NodeList} constructor.
  */
 register( 'weakInsert', function( position, nodes ) {
@@ -41,8 +41,8 @@ register( 'weakInsert', function( position, nodes ) {
 
 	nodes = new NodeList( nodes );
 
-	for ( let node of nodes ) {
-		node.setAttrsTo( this.doc.selection.getAttrs() );
+	for ( let node of nodes._nodes ) {
+		node.attrs.setTo( this.doc.selection.attrs );
 	}
 
 	const operation = new InsertOperation( position, nodes, this.doc.version );

+ 11 - 8
packages/ckeditor5-engine/src/treemodel/element.js

@@ -21,7 +21,7 @@ export default class Element extends Node {
 	 *
 	 * @param {String} name Node name.
 	 * @param {Iterable} attrs Iterable collection of {@link treeModel.Attribute attributes}.
-	 * @param {treeModel.Node|treeModel.Text|treeModel.NodeList|String|Iterable} children List of nodes to be inserted
+	 * @param {treeModel.NodesSet} children List of nodes to be inserted
 	 * into created element. List of nodes can be of any type accepted by the {@link treeModel.NodeList} constructor.
 	 * @constructor
 	 */
@@ -85,15 +85,17 @@ export default class Element extends Node {
 	 * All attached nodes should be modified using the {@link treeModel.operation.InsertOperation}.
 	 *
 	 * @param {Number} index Position where nodes should be inserted.
-	 * @param {treeModel.Node|treeModel.Text|treeModel.NodeList|String|Iterable} nodes The list of nodes to be inserted.
+	 * @param {treeModel.NodesSet} nodes The list of nodes to be inserted.
 	 * The list of nodes can be of any type accepted by the {@link treeModel.NodeList} constructor.
 	 */
 	insertChildren( index, nodes ) {
-		this._children.insert( index, new NodeList( nodes ) );
+		let nodeList = new NodeList( nodes );
 
-		for ( let node of this._children ) {
+		for ( let node of nodeList._nodes ) {
 			node.parent = this;
 		}
+
+		this._children.insert( index, nodeList );
 	}
 
 	/**
@@ -106,12 +108,13 @@ export default class Element extends Node {
 	 * @param {Number} number Number of nodes to remove.
 	 * @returns {treeModel.NodeList} The list of removed nodes.
 	 */
-
 	removeChildren( index, number ) {
-		for ( let i = index; i < index + number; i++ ) {
-			this._children.get( i ).parent = null;
+		let nodeList = this._children.remove( index, number );
+
+		for ( let node of nodeList._nodes ) {
+			node.parent = null;
 		}
 
-		return this._children.remove( index, number );
+		return nodeList;
 	}
 }

+ 8 - 49
packages/ckeditor5-engine/src/treemodel/node.js

@@ -35,12 +35,14 @@ export default class Node {
 
 		/**
 		 * List of attributes set on this node.
-		 * Attributes of nodes attached to the document can be changed only be the {@link treeModel.operation.AttributeOperation}.
+		 * **Note:** It is **important** that attributes of nodes already attached to the document must be changed
+		 * only by an {@link treeModel.operation.AttributeOperation}. Do not set attributes of such nodes
+		 * using {@link treeModel.AttributeList} API.
 		 *
-		 * @private
-		 * @property {treeModel.AttributeList} _attrs
+		 * @readonly
+		 * @property {treeModel.AttributeList} attrs
 		 */
-		this._attrs = new AttributeList( attrs );
+		this.attrs = new AttributeList( attrs );
 	}
 
 	/**
@@ -102,20 +104,6 @@ export default class Node {
 		return root;
 	}
 
-	/**
-	 * @see {@link treeModel.AttributeList#getAttr}
-	 */
-	getAttr( key ) {
-		return this._attrs.getAttr( key );
-	}
-
-	/**
-	 * @see {@link treeModel.AttributeList#getAttrs}
-	 */
-	getAttrs() {
-		return this._attrs.getAttrs();
-	}
-
 	/**
 	 * Index of the node in the parent element or null if the node has no parent.
 	 *
@@ -130,10 +118,9 @@ export default class Node {
 			return null;
 		}
 
-		// No parent or child doesn't exist in parent's children.
 		if ( ( pos = this.parent.getChildIndex( this ) ) == -1 ) {
 			/**
-			 * The node's parent does not contain this node. It means that the document tree is corrupted.
+			 * The node's parent does not contain this node.
 			 *
 			 * @error node-not-found-in-parent
 			 */
@@ -161,34 +148,6 @@ export default class Node {
 		return path;
 	}
 
-	/**
-	 * @see {@link treeModel.AttributeList#hasAttr}
-	 */
-	hasAttr( key ) {
-		return this._attrs.hasAttr( key );
-	}
-
-	/**
-	 * @see {@link treeModel.AttributeList#removeAttr}
-	 */
-	removeAttr( key ) {
-		this._attrs.removeAttr( key );
-	}
-
-	/**
-	 * @see {@link treeModel.AttributeList#setAttr}
-	 */
-	setAttr( attr ) {
-		this._attrs.setAttr( attr );
-	}
-
-	/**
-	 * @see {@link treeModel.AttributeList#setAttrsTo}
-	 */
-	setAttrsTo( attrs ) {
-		this._attrs.setAttrsTo( attrs );
-	}
-
 	/**
 	 * Custom toJSON method to solve child-parent circular dependencies.
 	 *
@@ -202,4 +161,4 @@ export default class Node {
 
 		return json;
 	}
-}
+}

+ 283 - 34
packages/ckeditor5-engine/src/treemodel/nodelist.js

@@ -5,10 +5,67 @@
 
 'use strict';
 
-import Character from './character.js';
+import CharacterProxy from './characterproxy.js';
 import Text from './text.js';
-import Node from './node.js';
 import utils from '../utils.js';
+import langUtils from '../lib/lodash/lang.js';
+
+/**
+ * Value that is convertible to an item kept in {@link treeModel.NodeList} or an iterable collection of such items.
+ * In other words, this is anything that {@link treeModel.NodeList#constructor} is able to take and convert to node:
+ * * {@link treeModel.Element} will be left as is
+ * * {@link treeModel.CharacterProxy} will be left as is
+ * * {@link treeModel.Text} and {String} will be converted to a set of {@link treeModel.CharacterProxy}
+ * * {@link treeModel.NodeList} will clone a node list (but not the nodes inside, so the new and passed list will
+ * point to the same nodes.
+ * * Iterable collection of above items will be iterated over and all items will be added to the node list.
+ *
+ * @typedef {treeModel.Element|treeModel.CharacterProxy|treeModel.Text|String|treeModel.NodeList|Iterable} treeModel.NodesSet
+ */
+
+/**
+ * This is a private helper-class for {@link treeModel.NodeList} text compression utility.
+ *
+ * @protected
+ * @class treeModel.NodeListText
+ */
+class NodeListText extends Text {
+	/**
+	 * @see {@link treeModel.Text#constructor}
+	 * @protected
+	 * @constructor
+	 */
+	constructor( text, attrs ) {
+		super( text, attrs );
+
+		this.parent = null;
+	}
+
+	/**
+	 * Gets a character at given index and creates a {@link treeModel.CharacterProxy} out of it.
+	 *
+	 * @param {Number} index Character index.
+	 * @returns {treeModel.CharacterProxy}
+	 */
+	getCharAt( index ) {
+		index = index && index >= 0 ? index : 0;
+
+		return new CharacterProxy( this, index );
+	}
+
+	/**
+	 * Custom toJSON method to solve child-parent circular dependencies.
+	 *
+	 * @returns {Object} Clone of this object with the parent property replaced with its name.
+	 */
+	toJSON() {
+		const json = langUtils.clone( this );
+
+		json.parent = json.parent ? this.parent.name : null;
+
+		return json;
+	}
+}
 
 /**
  * List of nodes. It is used to represent multiple nodes with a given order, for example children of
@@ -16,13 +73,14 @@ import utils from '../utils.js';
  *
  * Thanks to the constructor, which accepts various arguments, this class lets you easily create desired list of nodes.
  *
- * It also may internally compress nodes.
+ * Parameters passed to constructor are converted and internally kept as an array of {@link treeModel.Node}
+ * and {@link treeModel.Text} instances.
  *
  * @class treeModel.NodeList
  */
 export default class NodeList {
 	/**
-	 * Constructor let you create a list of nodes in many ways. See examples:
+	 * Constructor lets you create a list of nodes in many ways. See examples:
 	 *
 	 *		let nodeList = new NodeList( [ new Element( p1 ), new Element( p1 ) ] );
 	 *		nodeList.length; // 2
@@ -38,16 +96,18 @@ export default class NodeList {
 	 *
 	 *		let nodeList = new NodeList( new Text( 'foo', [ new Attribute( 'bar', 'bom' ) ] ) );
 	 *		nodeList.length; // 3
-	 *		nodeList.get( 0 ).getAttr( 'bar' ); // 'bom'
-	 *		nodeList.get( 1 ).getAttr( 'bar' ); // 'bom'
-	 *		nodeList.get( 2 ).getAttr( 'bar' ); // 'bom'
+	 *		nodeList.get( 0 ).attrs.get( 'bar' ); // 'bom'
+	 *		nodeList.get( 1 ).attrs.get( 'bar' ); // 'bom'
+	 *		nodeList.get( 2 ).attrs.get( 'bar' ); // 'bom'
 	 *
 	 *		let nodeListA = new NodeList( 'foo' );
 	 *		let nodeListB = new NodeList( nodeListA );
 	 *		nodeListA === nodeListB // true
 	 *		nodeListB.length // 3
 	 *
-	 * @param {treeModel.Node|treeModel.Text|treeModel.NodeList|String|Iterable} nodes List of nodes.
+	 * @see {@link treeModel.NodesSet} for more explanation.
+	 *
+	 * @param {treeModel.NodesSet} nodes List of nodes.
 	 * @constructor
 	 */
 	constructor( nodes ) {
@@ -59,34 +119,61 @@ export default class NodeList {
 		/**
 		 * Internal array to store nodes.
 		 *
-		 * @private
+		 * @protected
 		 * @property {Array}
 		 */
 		this._nodes = [];
 
-		if ( nodes ) {
-			let node, character;
+		/**
+		 * Internal array where each index is mapped to correct node from `_nodes` array. This is introduced
+		 * to easily refer `_nodes` by index, since some of elements in `_nodes` may contain multiple characters,
+		 * which occupy multiple slots in `_indexMap`.
+		 *
+		 * @private
+		 * @property {Array}
+		 */
+		this._indexMap = [];
 
-			if ( !utils.isIterable( nodes ) ) {
+		if ( nodes ) {
+			if ( typeof nodes == 'string' || !utils.isIterable( nodes ) ) {
 				nodes = [ nodes ];
 			}
 
-			for ( node of nodes ) {
-				// Node.
-				if ( node instanceof Node ) {
-					this._nodes.push( node );
+			for ( let node of nodes ) {
+				let indexInNodes = this._nodes.length;
+				let mergedWithPrev = false;
+				let length = 1;
+
+				if ( node instanceof CharacterProxy ) {
+					node = new NodeListText( node.character, node.attrs );
+				} else if ( node instanceof Text ) {
+					node = new NodeListText( node.text, node.attrs );
+				} else if ( typeof node == 'string' ) {
+					node = new NodeListText( node, [] );
 				}
-				// Text.
-				else if ( node instanceof Text ) {
-					for ( character of node.text ) {
-						this._nodes.push( new Character( character, node.attrs ) );
+
+				if ( node instanceof NodeListText ) {
+					length = node.text.length;
+
+					let prev = this._nodes[ this._nodes.length - 1 ];
+
+					if ( prev instanceof NodeListText && prev.attrs.isEqual( node.attrs ) ) {
+						// If previously added text has same attributes, merge this text with it.
+						prev.text += node.text;
+						mergedWithPrev = true;
+						indexInNodes--;
+					} else if ( node.text.length === 0 ) {
+						// If this is an empty text just omit it.
+						continue;
 					}
 				}
-				// String.
-				else {
-					for ( character of node ) {
-						this._nodes.push( new Character( character ) );
-					}
+
+				if ( !mergedWithPrev ) {
+					this._nodes.push( node );
+				}
+
+				for ( let i = 0; i < length; i++ ) {
+					this._indexMap.push( indexInNodes );
 				}
 			}
 		}
@@ -99,14 +186,21 @@ export default class NodeList {
 	 * @property {Number} length
 	 */
 	get length() {
-		return this._nodes.length;
+		return this._indexMap.length;
 	}
 
 	/**
 	 * Node list iterator.
 	 */
 	[ Symbol.iterator ]() {
-		return this._nodes[ Symbol.iterator ]();
+		let i = 0;
+
+		return {
+			next: () => ( {
+				done: i == this.length,
+				value: this.get( i++ )
+			} )
+		};
 	}
 
 	/**
@@ -116,17 +210,32 @@ export default class NodeList {
 	 * @returns {treeModel.Node} Node at given index.
 	 */
 	get( index ) {
-		return this._nodes[ index ];
+		let realIndex = this._indexMap[ index ];
+		let node = this._nodes[ realIndex ];
+
+		if ( node instanceof NodeListText ) {
+			return node.getCharAt( this._getCharIndex( index ) );
+		} else {
+			return node;
+		}
 	}
 
 	/**
-	 * Search for the node in the node list.
+	 * Search for the element in the node list.
 	 *
 	 * @param {treeModel.Node} node Node to find.
-	 * @returns {Number} Position of the node in the list.
+	 * @returns {Number} Position of the element in the list or -1 if not found.
 	 */
 	indexOf( node ) {
-		return this._nodes.indexOf( node );
+		if ( node instanceof CharacterProxy ) {
+			let baseIndex = this.indexOf( node._nodeListText );
+
+			return baseIndex == -1 ? -1 : baseIndex + node._index;
+		}
+
+		let realIndex = this._nodes.indexOf( node );
+
+		return this._indexMap.indexOf( realIndex );
 	}
 
 	/**
@@ -136,7 +245,36 @@ export default class NodeList {
 	 * @param {treeModel.NodeList} nodeList List of nodes to insert.
 	 */
 	insert( index, nodeList ) {
-		this._nodes.splice.apply( this._nodes, [ index, 0 ].concat( nodeList._nodes ) );
+		if ( this._nodes.length === 0 ) {
+			this._nodes = nodeList._nodes.slice();
+			this._indexMap = nodeList._indexMap.slice();
+
+			return;
+		}
+
+		// If we are inserting into a text, splitting may be needed.
+		this._splitNodeAt( index );
+
+		// If `index` is too high to be found in `_indexMap` it means that we insert at the end of node list.
+		let realIndex = index >= this._indexMap.length ? this._nodes.length : this._indexMap[ index ];
+
+		// Splice arrays from inserted nodeList into this nodeList.
+		this._indexMap.splice.apply( this._indexMap, [ index, 0 ].concat( nodeList._indexMap ) );
+		this._nodes.splice.apply( this._nodes, [ realIndex, 0 ].concat( nodeList._nodes ) );
+
+		// Fix indexes in index map.
+		// From the beginning of spliced-in array to the end of spliced-in array.
+		for ( let i = index; i < index + nodeList._indexMap.length; i++ ) {
+			this._indexMap[ i ] += realIndex;
+		}
+
+		// From the end of spliced-in array to the end of original array.
+		for ( let i = index + nodeList._indexMap.length; i < this._indexMap.length; i++ ) {
+			this._indexMap[ i ] += nodeList._nodes.length;
+		}
+
+		this._mergeNodeAt( index );
+		this._mergeNodeAt( index + nodeList.length );
 	}
 
 	/**
@@ -147,6 +285,117 @@ export default class NodeList {
 	 * @returns {treeModel.NodeList} List of removed nodes.
 	 */
 	remove( index, number ) {
-		return new NodeList( this._nodes.splice( index, number ) );
+		if ( this._nodes.length === 0 ) {
+			return new NodeList();
+		}
+
+		// Removed "range" may start in NodeListText or end in NodeListText. Some splitting may be needed.
+		this._splitNodeAt( index );
+		this._splitNodeAt( index + number );
+
+		// If given index is too high to be found in `_indexMap` it means that we remove to the end of node list.
+		let realIndexEnd = ( index + number ) >= this._indexMap.length ? this._nodes.length : this._indexMap[ index + number ];
+		let realIndexStart = this._indexMap[ index ];
+		let removed = this._nodes.splice( realIndexStart, realIndexEnd - realIndexStart );
+
+		this._indexMap.splice( index, number );
+
+		for ( let i = index; i < this._indexMap.length ; i++ ) {
+			this._indexMap[ i ] -= removed.length;
+		}
+
+		this._mergeNodeAt( index );
+
+		return new NodeList( removed );
+	}
+
+	/**
+	 * Checks whether given index is inside a text and if so, splits that text node. This method should be used
+	 * to split text objects whenever there are some changes made on a part of text object (i.e. removing part of text,
+	 * inserting between text object, changing attributes of part of a text object).
+	 *
+	 * @param {Number} index Index in the node list at which node should be broken.
+	 * @protected
+	 */
+	_splitNodeAt( index ) {
+		if ( this._indexMap[ index ] != this._indexMap[ index - 1 ] || this._indexMap.length === 0 ) {
+			// Node before and node after splitting point are already different.
+			// Or the node list is empty.
+			// No splitting is needed.
+			return;
+		}
+
+		let realIndex = this._indexMap[ index ];
+		let node = this._nodes[ realIndex ];
+
+		// Get position in the text node where the text should be split.
+		let charIndex = this._getCharIndex( index );
+
+		// Get text part before and after split point.
+		let textBefore = node.text.substr( 0, charIndex );
+		let textAfter = node.text.substr( charIndex );
+
+		// "Remove" part after split point from current text node.
+		node.text = textBefore;
+
+		// Create a new text node with the "removed" part and splice it after original node.
+		let newText = new NodeListText( textAfter, node.attrs );
+		newText.parent = node.parent;
+		this._nodes.splice.call( this._nodes, realIndex + 1, 0, newText );
+
+		// We added new element in the middle of _nodes what invalidated _indexMap. We have to fix it.
+		for ( let i = index; i < this._indexMap.length; i++ ) {
+			this._indexMap[ i ]++;
+		}
+	}
+
+	/**
+	 * Checks whether given index is between two text nodes that have same attributes and if so, merges them
+	 * together into one node. Used to compress characters into large text objects and use less memory. This method
+	 * should be used whenever there are some changed done to the node list to check whether it is possible to merge
+	 * text objects.
+	 *
+	 * @param {Number} index Index in the node list at which node should be merged.
+	 * @protected
+	 */
+	_mergeNodeAt( index ) {
+		if ( this._indexMap[ index ] == this._indexMap[ index - 1 ] || this._indexMap.length === 0 ) {
+			// Node before and node after splitting point are already same.
+			// Or the node list is empty.
+			// No splitting is needed.
+			return;
+		}
+
+		// Get the node before and after given index.
+		let realIndexBefore = this._indexMap[ index - 1 ];
+		let realIndexAfter = this._indexMap[ index ];
+
+		let nodeBefore = this._nodes[ realIndexBefore ];
+		let nodeAfter = this._nodes[ realIndexAfter ];
+
+		// Check if both of those nodes are text objects with same attributes.
+		if ( nodeBefore instanceof Text && nodeAfter instanceof Text && nodeBefore.attrs.isEqual( nodeAfter.attrs ) ) {
+			// Append text of text node after index to the before one.
+			nodeBefore.text += nodeAfter.text;
+
+			// Remove text node after index.
+			this._nodes.splice( realIndexAfter, 1 );
+
+			for ( let i = index; i < this._indexMap.length ; i++ ) {
+				this._indexMap[ i ]--;
+			}
+		}
+	}
+
+	/**
+	 * Helper function that takes an index in a node list that is inside a text node and returns the offset of that
+	 * index from the beginning of that text node. If index
+	 *
+	 * @param index
+	 * @returns {Number} Offset of given index from the beginning of the text node.
+	 * @private
+	 */
+	_getCharIndex( index ) {
+		return index - this._indexMap.indexOf( this._indexMap[ index ] );
 	}
-}
+}

+ 33 - 12
packages/ckeditor5-engine/src/treemodel/operation/attributeoperation.js

@@ -7,6 +7,7 @@
 
 import Operation from './operation.js';
 import Range from '../range.js';
+import TextFragment from '../textfragment.js';
 import CKEditorError from '../../ckeditorerror.js';
 
 /**
@@ -87,13 +88,24 @@ export default class AttributeOperation extends Operation {
 			 */
 			throw new CKEditorError(
 				'operation-attribute-different-keys: Old and new attributes should have the same keys.',
-				{ oldAttr: oldAttr, newAttr: newAttr } );
+				{ oldAttr: oldAttr, newAttr: newAttr }
+			);
 		}
 
+		// Split text nodes if needed, then get the nodes in the range and convert it to node list. It will be easier to operate.
+		this.range.start.parent._children._splitNodeAt( this.range.start.offset );
+		this.range.end.parent._children._splitNodeAt( this.range.end.offset );
+
 		// Remove or change.
 		if ( oldAttr !== null ) {
-			for ( let node of this.range.getAllNodes() ) {
-				if ( !node.hasAttr( oldAttr ) ) {
+			for ( let node of this.range.getAllNodes( true ) ) {
+				if ( node instanceof TextFragment ) {
+					// Because instance of TextFragment is kind-of a proxy, not a real, original item,
+					// we have to assign `node` a real item that is added to the node list.
+					node = node.first._nodeListText;
+				}
+
+				if ( !node.attrs.has( oldAttr ) ) {
 					/**
 					 * The attribute which should be removed does not exists for the given node.
 					 *
@@ -103,21 +115,26 @@ export default class AttributeOperation extends Operation {
 					 */
 					throw new CKEditorError(
 						'operation-attribute-no-attr-to-remove: The attribute which should be removed does not exists for given node.',
-						{ node: node, attr: oldAttr } );
+						{ node: node, attr: oldAttr }
+					);
 				}
 
-				// There is no use in removing attribute if we will overwrite it later.
-				// Still it is profitable to run through the loop to check if all nodes in the range has old attribute.
 				if ( newAttr === null ) {
-					node.removeAttr( oldAttr.key );
+					node.attrs.delete( oldAttr.key );
 				}
 			}
 		}
 
 		// Insert or change.
 		if ( newAttr !== null ) {
-			for ( let node of this.range.getAllNodes() ) {
-				if ( oldAttr === null && node.hasAttr( newAttr.key ) ) {
+			for ( let node of this.range.getAllNodes( true ) ) {
+				if ( node instanceof TextFragment ) {
+					// Because instance of TextFragment is kind-of a proxy, not a real, original item,
+					// we have to assign `node` a real item that is added to the node list.
+					node = node.first._nodeListText;
+				}
+
+				if ( oldAttr === null && node.attrs.has( newAttr.key ) ) {
 					/**
 					 * The attribute with given key already exists for the given node.
 					 *
@@ -127,13 +144,17 @@ export default class AttributeOperation extends Operation {
 					 */
 					throw new CKEditorError(
 						'operation-attribute-attr-exists: The attribute with given key already exists.',
-						{ node: node, attr: newAttr } );
+						{ node: node, attr: newAttr }
+					);
 				}
 
-				node.setAttr( newAttr );
+				node.attrs.set( newAttr );
 			}
 		}
 
+		this.range.start.parent._children._mergeNodeAt( this.range.start.offset );
+		this.range.end.parent._children._mergeNodeAt( this.range.end.offset );
+
 		return { range: this.range, oldAttr: oldAttr, newAttr: newAttr };
 	}
-}
+}

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

@@ -21,7 +21,7 @@ export default class InsertOperation extends Operation {
 	 * Creates an insert operation.
 	 *
 	 * @param {treeModel.Position} position Position of insertion.
-	 * @param {treeModel.Node|treeModel.Text|treeModel.NodeList|String|Iterable} nodes The list of nodes to be inserted.
+	 * @param {treeModel.NodesSet} nodes The list of nodes to be inserted.
 	 * List of nodes can be any type accepted by the {@link treeModel.NodeList} constructor.
 	 * @param {Number} baseVersion {@link treeModel.Document#version} on which operation can be applied.
 	 * @constructor

+ 0 - 171
packages/ckeditor5-engine/src/treemodel/positioniterator.js

@@ -1,171 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-'use strict';
-
-import Character from './character.js';
-import Element from './element.js';
-import Position from './position.js';
-
-const ELEMENT_ENTER = 0;
-const ELEMENT_LEAVE = 1;
-const CHARACTER = 2;
-
-/**
- * Position iterator class. It allows to iterate forward and backward over the tree document.
- *
- * @class treeModel.PositionIterator
- */
-export default class PositionIterator {
-	/**
-	 * Creates a range iterator.
-	 *
-	 * @param {treeModel.Range} [boundaries] Range to define boundaries of the iterator.
-	 * @param {treeModel.Position} [iteratorPosition] Starting position.
-	 * @constructor
-	 */
-	constructor( boundaries, iteratorPosition ) {
-		if ( boundaries instanceof Position ) {
-			this.position = boundaries;
-		} else {
-			this.boundaries =  boundaries;
-			this.position = iteratorPosition || boundaries.start;
-		}
-
-		/**
-		 * Iterator position.
-		 *
-		 * @property {treeModel.Position} position
-		 */
-
-		/**
-		 * Iterator boundaries.
-		 *
-		 * When the {@link #next} method is called on the end boundary or the {@link #previous} method
-		 * on the start boundary, then `{ done: true }` is returned.
-		 *
-		 * If boundaries are not defined they are set before first and after last child of the root node.
-		 *
-		 * @property {treeModel.Range} boundaries
-		 */
-	}
-
-	/**
-	 * Moves the {@link #position} to the next position and returns the enctountered value.
-	 *
-	 * @returns {Object} Value between the previous and the new {@link #position}.
-	 * @returns {Boolean} return.done True if iterator is done.
-	 * @returns {Object} return.value
-	 * @returns {Number} return.value.type Encountered value type, possible options: {@link PositionIterator#ELEMENT_ENTER},
-	 * {@link PositionIterator#ELEMENT_LEAVE} or {@link PositionIterator#CHARACTER}.
-	 * @returns {treeModel.Node} return.value.node Encountered node.
-	 */
-	next() {
-		const position = this.position;
-		const parent = position.parent;
-
-		// We are at the end of the root.
-		if ( parent.parent === null && position.offset === parent.getChildCount() ) {
-			return { done: true };
-		}
-
-		if ( this.boundaries && position.isEqual( this.boundaries.end ) ) {
-			return { done: true };
-		}
-
-		const nodeAfter = position.nodeAfter;
-
-		if ( nodeAfter instanceof Element ) {
-			this.position = Position.createFromParentAndOffset( nodeAfter, 0 );
-
-			return formatReturnValue( ELEMENT_ENTER, nodeAfter );
-		} else if ( nodeAfter instanceof Character ) {
-			this.position = Position.createFromParentAndOffset( parent, position.offset + 1 );
-
-			return formatReturnValue( CHARACTER, nodeAfter );
-		} else {
-			this.position = Position.createFromParentAndOffset( parent.parent, parent.getIndex() + 1 );
-
-			return formatReturnValue( ELEMENT_LEAVE, this.position.nodeBefore );
-		}
-	}
-
-	/**
-	 * Moves the {@link #position} to the previous position and returns the encountered value.
-	 *
-	 * @returns {Object} Value between the previous and the new {@link #position}.
-	 * @returns {Boolean} return.done True if iterator is done.
-	 * @returns {Object} return.value
-	 * @returns {Number} return.value.type Encountered value type, possible options: {@link PositionIterator#ELEMENT_ENTER},
-	 * {@link PositionIterator#ELEMENT_LEAVE} or {@link PositionIterator#CHARACTER}.
-	 * @returns {treeModel.Node} return.value.node Scanned node.
-	 */
-	previous() {
-		const position = this.position;
-		const parent = position.parent;
-
-		// We are at the beginning of the root.
-		if ( parent.parent === null && position.offset === 0 ) {
-			return { done: true };
-		}
-
-		if ( this.boundaries && position.isEqual( this.boundaries.start ) ) {
-			return { done: true };
-		}
-
-		const nodeBefore = position.nodeBefore;
-
-		if ( nodeBefore instanceof Element ) {
-			this.position = Position.createFromParentAndOffset( nodeBefore, nodeBefore.getChildCount() );
-
-			return formatReturnValue( ELEMENT_LEAVE, nodeBefore );
-		} else if ( nodeBefore instanceof Character ) {
-			this.position = Position.createFromParentAndOffset( parent, position.offset - 1 );
-
-			return formatReturnValue( CHARACTER, nodeBefore );
-		} else {
-			this.position = Position.createFromParentAndOffset( parent.parent, parent.getIndex() );
-
-			return formatReturnValue( ELEMENT_ENTER, this.position.nodeAfter );
-		}
-	}
-}
-
-function formatReturnValue( type, node ) {
-	return {
-		done: false,
-		value: {
-			type: type,
-			node: node
-		}
-	};
-}
-
-/**
- * Flag for character.
- *
- * @static
- * @readonly
- * @property {Number}
- */
-PositionIterator.CHARACTER = CHARACTER;
-
-/**
- * Flag for entering element.
- *
- * @static
- * @readonly
- * @property {Number}
- */
-PositionIterator.ELEMENT_ENTER = ELEMENT_ENTER;
-
-/**
- * Flag for leaving element.
- *
- * @static
- * @readonly
- * @property {Number}
- */
-PositionIterator.ELEMENT_LEAVE = ELEMENT_LEAVE;

+ 79 - 31
packages/ckeditor5-engine/src/treemodel/range.js

@@ -6,7 +6,7 @@
 'use strict';
 
 import Position from './position.js';
-import PositionIterator from './positioniterator.js';
+import TreeWalker from './treewalker.js';
 import utils from '../utils.js';
 
 /**
@@ -69,10 +69,10 @@ export default class Range {
 	/**
 	 * Range iterator.
 	 *
-	 * @see treeModel.PositionIterator
+	 * @see treeModel.TreeWalker
 	 */
 	[ Symbol.iterator ]() {
-		return new PositionIterator( this );
+		return new TreeWalker( { boundaries: this } );
 	}
 
 	/**
@@ -204,11 +204,14 @@ export default class Range {
 	 *		 |   |- element P4
 	 *		 |   |   |- "ipsum"
 	 *
-	 * Flat ranges for range `( [ 0, 0, 3 ], [ 3, 0, 2 ] )` would be:
+	 * As it can be seen, letters contained in the range are stloremfoobarse, spread across different parents.
+	 * We are looking for minimal set of {@link #isFlat flat} ranges that contains the same nodes.
+	 *
+	 * Minimal flat ranges for above range `( [ 0, 0, 3 ], [ 3, 0, 2 ] )` will be:
 	 *
 	 *		( [ 0, 0, 3 ], [ 0, 0, 5 ] ) = "st"
-	 *		( [ 0, 1 ], [ 0, 2 ] ) = element P1
-	 *		( [ 1 ], [ 3 ] ) = element P2, element P3
+	 *		( [ 0, 1 ], [ 0, 2 ] ) = element P1 ("lorem")
+	 *		( [ 1 ], [ 3 ] ) = element P2, element P3 ("foobar")
 	 *		( [ 3, 0, 0 ], [ 3, 0, 2 ] ) = "se"
 	 *
 	 * **Note:** this method is not returning flat ranges that contain no nodes. It may also happen that not-collapsed
@@ -256,23 +259,48 @@ export default class Range {
 	/**
 	 * Returns an iterator that iterates over all {@link treeModel.Node nodes} that are in this range and returns them.
 	 * A node is in the range when it is after a {@link treeModel.Position position} contained in this range.
-	 * In other words, this iterates over all {@link treeModel.Character}s that are inside the range and
-	 * all the {@link treeModel.Element}s we enter into when iterating over this range.
-	 *
-	 * **Note:** this method will not return a parent node of start position. This is in contrary to {@link treeModel.PositionIterator}
-	 * which will return that node with {@link treeModel.PositionIterator#ELEMENT_LEAVE} type. This method, also, returns each
-	 * {@link treeModel.Element} once, while iterator return it twice: for {@link treeModel.PositionIterator#ELEMENT_ENTER} and
-	 * {@link treeModel.PositionIterator#ELEMENT_LEAVE}.
-	 *
-	 * @see {treeModel.PositionIterator}
-	 * @returns {Iterable.<treeModel.Node>}
+	 * In other words, this iterates over all text characters that are inside the range and all the {@link treeModel.Element}s
+	 * we enter into when iterating over this range.
+	 *
+	 * **Note:** this method will not return a parent node of start position. This is in contrary to {@link treeModel.TreeWalker}
+	 * which will return that node with {@link treeModel.TreeWalker#ELEMENT_END} type. This method, also, returns each
+	 * {@link treeModel.Element} once, while iterator return it twice: for {@link treeModel.TreeWalker#ELEMENT_START} and
+	 * {@link treeModel.TreeWalker#ELEMENT_END}.
+	 *
+	 * @see {treeModel.TreeWalker}
+	 * @param {Boolean} [mergeCharacters] Flag indicating whether all consecutive characters with the same attributes
+	 * should be returned as one {@link treeModel.TextFragment} (`true`) or one by one as multiple {@link treeModel.CharacterProxy}
+	 * (`false`) objects. Defaults to `false`.
+	 * @returns {Iterable.<treeModel.Node|treeModel.TextFragment>}
 	 */
-	*getAllNodes() {
-		for ( let value of this ) {
-			if ( value.type != PositionIterator.ELEMENT_LEAVE ) {
-				yield value.node;
+	*getAllNodes( mergeCharacters ) {
+		let it = new TreeWalker( { boundaries: this, mergeCharacters: mergeCharacters } );
+		let step;
+
+		do {
+			step = it.next();
+
+			if ( step.value && step.value.type != TreeWalker.ELEMENT_END ) {
+				yield step.value.item;
 			}
-		}
+		} while ( !step.done );
+	}
+
+	/**
+	 * Returns an iterator that iterates over all {@link treeModel.Position positions} that are boundaries or
+	 * contained in this range.
+	 *
+	 * @param {Boolean} [mergeCharacters] Flag indicating whether all consecutive characters with the same attributes
+	 * should be returned as one {@link treeModel.TextFragment} (`true`) or one by one as multiple {@link treeModel.CharacterProxy}
+	 * (`false`) objects. Defaults to `false`.
+	 * @returns {Iterable.<treeModel.Position>}
+	 */
+	*getPositions( mergeCharacters ) {
+		let it = new TreeWalker( { boundaries: this, mergeCharacters: mergeCharacters } );
+
+		do {
+			yield it.position;
+		} while ( !it.next().done );
 	}
 
 	/**
@@ -280,19 +308,39 @@ export default class Range {
 	 * and returns them. A node is a top-level node when it is in the range but it's parent is not. In other words,
 	 * this function splits the range into separate sub-trees and iterates over their roots.
 	 *
-	 * @returns {Iterable.<treeModel.Node>}
+	 * @param {Boolean} [mergeCharacters] Flag indicating whether all consecutive characters with the same attributes
+	 * should be returned as one {@link treeModel.TextFragment} (`true`) or one by one as multiple {@link treeModel.CharacterProxy}
+	 * (`false`) objects. Defaults to `false`.
+	 * @returns {Iterable.<treeModel.Node|treeModel.TextFragment>}
 	 */
-	*getTopLevelNodes() {
+	*getTopLevelNodes( mergeCharacters ) {
 		let flatRanges = this.getMinimalFlatRanges();
 
 		for ( let range of flatRanges ) {
-			let node = range.start.nodeAfter;
-			let offset = range.end.offset - range.start.offset;
-
-			for ( let i = 0; i < offset; i++ ) {
-				yield node;
-				node = node.nextSibling;
-			}
+			// This loop could be much simpler as we could just iterate over siblings of node after the first
+			// position of each range. But then we would have to re-implement character merging strategy here.
+			let it = new TreeWalker( { boundaries: range, mergeCharacters: mergeCharacters } );
+			let step;
+
+			// We will only return nodes that are on same level as node after the range start. To do this,
+			// we keep "depth" counter.
+			let depth = 0;
+
+			do {
+				step = it.next();
+
+				if ( step.value ) {
+					if ( step.value.type == TreeWalker.ELEMENT_START ) {
+						depth++;
+					} else if ( step.value.type == TreeWalker.ELEMENT_END ) {
+						depth--;
+					}
+
+					if ( depth === 0 ) {
+						yield step.value.item;
+					}
+				}
+			} while ( !step.done );
 		}
 	}
 
@@ -415,4 +463,4 @@ export default class Range {
 	static createFromRange( range ) {
 		return new this( range.start, range.end );
 	}
-}
+}

+ 5 - 47
packages/ckeditor5-engine/src/treemodel/selection.js

@@ -27,10 +27,10 @@ export default class Selection {
 		/**
 		 * List of attributes set on current selection.
 		 *
-		 * @private
-		 * @property {treeModel.AttributeList} _attrs
+		 * @readonly
+		 * @property {treeModel.AttributeList} attrs
 		 */
-		this._attrs = new AttributeList();
+		this.attrs = new AttributeList();
 
 		/**
 		 * Stores all ranges that are selected.
@@ -119,7 +119,7 @@ export default class Selection {
 	}
 
 	/**
-	 * Unbinds all events previously bound by this selection and objects created by this selection.
+	 * Unbinds all events previously bound by this selection or objects created by this selection.
 	 */
 	detach() {
 		for ( let i = 0; i < this._ranges.length; i++ ) {
@@ -127,20 +127,6 @@ export default class Selection {
 		}
 	}
 
-	/**
-	 * @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.
@@ -151,20 +137,6 @@ export default class Selection {
 		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.
 	 */
@@ -175,20 +147,6 @@ export default class Selection {
 		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
@@ -241,4 +199,4 @@ function pushRange( range ) {
 	this._ranges.push( LiveRange.createFromRange( range ) );
 }
 
-objectUtils.extend( Selection.prototype, EmitterMixin );
+objectUtils.extend( Selection.prototype, EmitterMixin );

+ 13 - 7
packages/ckeditor5-engine/src/treemodel/text.js

@@ -5,9 +5,15 @@
 
 'use strict';
 
+import AttributeList from './attributelist.js';
+
 /**
- * Data structure for text with attributes. Note that the `Text` is not a {@link treeModel.Node},
- * because it will never be part of the document tree. {@link treeModel.Character is a node}.
+ * Data structure for text with attributes. Note that `Text` is not a {@link treeModel.Node}. This class is used
+ * as an aggregator for multiple characters that have same attributes. Example usage:
+ *
+ *		let attrFoo = new Attribute( 'foo', true );
+ *		let attrBar = new Attribute( 'bar', true );
+ *		let myElem = new Element( 'li', [], new Text( 'text with attributes', [ attrFoo, attrBar ] ) );
  *
  * @class treeModel.Text
  */
@@ -26,13 +32,13 @@ export default class Text {
 		 * @readonly
 		 * @property {String}
 		 */
-		this.text = text;
+		this.text = text || '';
 
 		/**
-		 * Iterable collection of {@link treeModel.Attribute attributes}.
+		 * {@link treeModel.Attribute AttributesList} bound with the text.
 		 *
-		 * @property {Iterable}
+		 * @property {treeModel.AttributeList}
 		 */
-		this.attrs = attrs;
+		this.attrs = new AttributeList( attrs );
 	}
-}
+}

+ 85 - 0
packages/ckeditor5-engine/src/treemodel/textfragment.js

@@ -0,0 +1,85 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Position from './position.js';
+import Range from './range.js';
+
+/**
+ * TextFragment is an aggregator for multiple CharacterProxy instances that are placed next to each other in
+ * tree model, in the same parent, and all have same attributes set. Instances of this class are created and returned
+ * in various algorithms that "merge characters" (see {@link treeModel.TreeWalker}, {@link treeModel.Range}).
+ *
+ * Difference between {@link treeModel.TextFragment} and {@link treeModel.Text} is that the former is bound to tree model,
+ * while {@link treeModel.Text} is simply a string with attributes set.
+ *
+ * You should never create an instance of this class by your own. When passing parameters to constructors,
+ * use string literals or {@link treeModel.Text} instead.
+ *
+ * @class treeModel.TextFragment
+ */
+export default class TextFragment {
+	/**
+	 * Creates a text fragment.
+	 *
+	 * @param {treeModel.Position} startPosition Position in the tree model where the {@link treeModel.TextFragment} starts.
+	 * @param {String} text Characters contained in {@link treeModel.TextFragment}.
+	 * @protected
+	 * @constructor
+	 */
+	constructor( startPosition, text ) {
+		/**
+		 * First {@link treeModel.CharacterProxy character node} contained in {@link treeModel.TextFragment}.
+		 *
+		 * @readonly
+		 * @property {treeModel.CharacterProxy} first
+		 */
+		this.first = startPosition.nodeAfter;
+
+		/**
+		 * Characters contained in {@link treeModel.TextFragment}.
+		 *
+		 * @readonly
+		 * @property {String} text
+		 */
+		this.text = text;
+
+		/**
+		 * Last {@link treeModel.CharacterProxy character node} contained in {@link treeModel.TextFragment}.
+		 *
+		 * @readonly
+		 * @property {treeModel.CharacterProxy} last
+		 */
+		this.last = this.getCharAt( this.text.length - 1 );
+
+		/**
+		 * List of attributes common for all characters in this {@link treeModel.TextFragment}.
+		 *
+		 * @readonly
+		 * @property {@link treeModel.AttributeList} attrs
+		 */
+		this.attrs = this.first.attrs;
+	}
+
+	/**
+	 * Gets a character at given index and creates a {@link treeModel.CharacterProxy} out of it.
+	 *
+	 * @param {Number} index Character index.
+	 * @returns {treeModel.CharacterProxy}
+	 */
+	getCharAt( index ) {
+		return this.first.parent.getChild( this.first._index + index );
+	}
+
+	/**
+	 * Creates and returns a range containing all characters from this {@link treeModel.TextFragment}.
+	 *
+	 * @returns {Range}
+	 */
+	getRange() {
+		return new Range( Position.createBefore( this.first ), Position.createAfter( this.last ) );
+	}
+}

+ 282 - 0
packages/ckeditor5-engine/src/treemodel/treewalker.js

@@ -0,0 +1,282 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import CharacterProxy from './characterproxy.js';
+import TextFragment from './textfragment.js';
+import Element from './element.js';
+import Position from './position.js';
+import CKEditorError from '../ckeditorerror.js';
+
+const ELEMENT_START = 0;
+const ELEMENT_END = 1;
+const TEXT = 2;
+const CHARACTER = 3;
+
+/**
+ * Position iterator class. It allows to iterate forward and backward over the tree document.
+ *
+ * @class treeModel.TreeWalker
+ */
+export default class TreeWalker {
+	/**
+	 * Creates a range iterator. All parameters are optional, but you have to specify either `boundaries` or `position`.
+	 *
+	 * @param {Object} options Object with configuration.
+	 * @param {treeModel.Range} [options.boundaries] Range to define boundaries of the iterator.
+	 * @param {treeModel.Position} [options.position] Starting position.
+	 * @param {Boolean} [options.mergeCharacters] Flag indicating whether all consecutive characters with the same attributes
+	 * should be returned as one {@link treeModel.TextFragment} (`true`) or one by one as multiple {@link treeModel.CharacterProxy}
+	 * (`false`) objects. Defaults to `false`.
+	 * @constructor
+	 */
+	constructor( options ) {
+		if ( !options || ( !options.boundaries && !options.position ) ) {
+			/**
+			 * Neither boundaries nor starting position have been defined.
+			 *
+			 * @error tree-walker-no-start-position
+			 */
+			throw new CKEditorError( 'tree-walker-no-start-position: Neither boundaries nor starting position have been defined.' );
+		}
+
+		/**
+		 * Iterator boundaries.
+		 *
+		 * When the {@link #next} method is called on the end boundary or the {@link #previous} method
+		 * on the start boundary, then `{ done: true }` is returned.
+		 *
+		 * If boundaries are not defined they are set before first and after last child of the root node.
+		 *
+		 * @property {treeModel.Range} boundaries
+		 */
+		this.boundaries = options.boundaries || null;
+
+		/**
+		 * Start boundary cached for optimization purposes.
+		 *
+		 * @private
+		 * @property {treeModel.Element} boundaryStartParent
+		 */
+		this._boundaryStartParent = this.boundaries ? this.boundaries.start.parent : null;
+
+		/**
+		 * End boundary cached for optimization purposes.
+		 *
+		 * @private
+		 * @property {treeModel.Element} boundaryEndParent
+		 */
+		this._boundaryEndParent = this.boundaries ? this.boundaries.end.parent : null;
+
+		/**
+		 * Iterator position.
+		 *
+		 * @property {treeModel.Position} position
+		 */
+		this.position = options.position ?
+			Position.createFromPosition( options.position ) :
+			Position.createFromPosition( options.boundaries.start );
+
+		/**
+		 * Flag indicating whether all consecutive characters with the same attributes should be
+		 * returned as one {@link treeModel.CharacterProxy} (`true`) or one by one (`false`).
+		 *
+		 * @property {Boolean} mergeCharacters
+		 */
+		this.mergeCharacters = !!options.mergeCharacters;
+
+		/**
+		 * Parent of the most recently visited node. Cached for optimization purposes.
+		 *
+		 * @private
+		 * @property {treeModel.Element} visitedParent
+		 */
+		this._visitedParent = this.position.parent;
+	}
+
+	/**
+	 * Moves the {@link #position} to the next position and returns the encountered value.
+	 *
+	 * @returns {Object} Value between the previous and the new {@link #position}.
+	 * @returns {Boolean} return.done True if iterator is done.
+	 * @returns {Object} return.value
+	 * @returns {Number} return.value.type Encountered value type, possible options: {@link TreeWalker#ELEMENT_START},
+	 * {@link TreeWalker#ELEMENT_END}, {@link TreeWalker#CHARACTER} or {@link TreeWalker#TEXT}.
+	 * @returns {treeModel.Node} return.value.item Encountered node.
+	 */
+	next() {
+		const position = Position.createFromPosition( this.position );
+		const parent = this._visitedParent;
+
+		// We are at the end of the root.
+		if ( parent.parent === null && position.offset === parent.getChildCount() ) {
+			return { done: true };
+		}
+
+		// Parent can't be null so by comparing with boundaryParent we check if boundaryParent is set at all.
+		if ( parent == this._boundaryEndParent && position.offset == this.boundaries.end.offset ) {
+			return { done: true };
+		}
+
+		const node = parent.getChild( position.offset );
+
+		if ( node instanceof Element ) {
+			// Manual operations on path internals for optimization purposes. Here and in the rest of the method.
+			position.path.push( 0 );
+			this.position = position;
+
+			this._visitedParent = node;
+
+			return formatReturnValue( ELEMENT_START, node );
+		} else if ( node instanceof CharacterProxy ) {
+			if ( this.mergeCharacters ) {
+				let charactersCount = node._nodeListText.text.length - node._index;
+				let offset = position.offset + charactersCount;
+
+				if ( this._boundaryEndParent == parent && this.boundaries.end.offset < offset ) {
+					offset = this.boundaries.end.offset;
+					charactersCount = offset - position.offset;
+				}
+
+				let text = node._nodeListText.text.substr( node._index, charactersCount );
+				let textFragment = new TextFragment( position, text );
+
+				position.offset = offset;
+				this.position = position;
+
+				return formatReturnValue( TEXT, textFragment );
+			} else {
+				position.offset++;
+				this.position = position;
+
+				return formatReturnValue( CHARACTER, node );
+			}
+		} else {
+			position.path.pop();
+			position.offset++;
+			this.position = position;
+
+			this._visitedParent = parent.parent;
+
+			return formatReturnValue( ELEMENT_END, parent );
+		}
+	}
+
+	/**
+	 * Moves the {@link #position} to the previous position and returns the encountered value.
+	 *
+	 * @returns {Object} Value between the previous and the new {@link #position}.
+	 * @returns {Boolean} return.done True if iterator is done.
+	 * @returns {Object} return.value
+	 * @returns {Number} return.value.type Encountered value type, possible options: {@link TreeWalker#ELEMENT_START},
+	 * {@link TreeWalker#ELEMENT_END}, {@link TreeWalker#CHARACTER} or {@link TreeWalker#TEXT}.
+	 * @returns {treeModel.Node} return.value.item Scanned node.
+	 */
+	previous() {
+		const position = Position.createFromPosition( this.position );
+		const parent = this._visitedParent;
+
+		// We are at the end of the root.
+		if ( parent.parent === null && position.offset === 0 ) {
+			return { done: true };
+		}
+
+		// Parent can't be null so by comparing with boundaryParent we check if boundaryParent is set at all.
+		if ( parent == this._boundaryStartParent && position.offset == this.boundaries.start.offset ) {
+			return { done: true };
+		}
+
+		const node = parent.getChild( position.offset - 1 );
+
+		if ( node instanceof Element ) {
+			// Manual operations on path internals for optimization purposes. Here and in the rest of the method.
+			position.offset--;
+			position.path.push( node.getChildCount() );
+			this.position = position;
+
+			this._visitedParent = node;
+
+			return formatReturnValue( ELEMENT_END, node );
+		} else if ( node instanceof CharacterProxy ) {
+			if ( this.mergeCharacters ) {
+				let charactersCount = node._index + 1;
+				let offset = position.offset - charactersCount;
+
+				if ( this._boundaryStartParent == parent && this.boundaries.start.offset > offset ) {
+					offset = this.boundaries.start.offset;
+					charactersCount = position.offset - offset;
+				}
+
+				let text = node._nodeListText.text.substr( node._index + 1 - charactersCount, charactersCount );
+
+				position.offset = offset;
+				this.position = position;
+
+				let textFragment = new TextFragment( this.position, text );
+
+				return formatReturnValue( TEXT, textFragment );
+			} else {
+				position.offset--;
+				this.position = position;
+
+				return formatReturnValue( CHARACTER, node );
+			}
+		} else {
+			position.path.pop();
+			this.position = position;
+
+			this._visitedParent = parent.parent;
+
+			return formatReturnValue( ELEMENT_START, parent );
+		}
+	}
+}
+
+function formatReturnValue( type, item ) {
+	return {
+		done: false,
+		value: {
+			type: type,
+			item: item
+		}
+	};
+}
+
+/**
+ * Flag for encountering start of an element.
+ *
+ * @static
+ * @readonly
+ * @property {Number}
+ */
+TreeWalker.ELEMENT_START = ELEMENT_START;
+
+/**
+ * Flag for encountering end of an element.
+ *
+ * @static
+ * @readonly
+ * @property {Number}
+ */
+TreeWalker.ELEMENT_END = ELEMENT_END;
+
+/**
+ * Flag for text.
+ *
+ * @static
+ * @readonly
+ * @property {Number}
+ */
+TreeWalker.TEXT = TEXT;
+
+/**
+ * Flag for character.
+ *
+ * @static
+ * @readonly
+ * @property {Number}
+ */
+TreeWalker.CHARACTER = CHARACTER;

+ 5 - 4
packages/ckeditor5-engine/tests/treemodel/_utils/utils.js

@@ -20,11 +20,12 @@ const utils = {
 		let txt = '';
 
 		for ( let step of range ) {
-			let node = step.node;
+			let node = step.item;
+			let nodeText = node.text || node.character;
 
-			if ( node.character ) {
-				txt += node.character.toLowerCase();
-			} else if ( node.name ) {
+			if ( nodeText ) {
+				txt += nodeText.toLowerCase();
+			} else {
 				txt += node.name.toUpperCase();
 			}
 		}

+ 0 - 47
packages/ckeditor5-engine/tests/treemodel/attribute.js

@@ -10,10 +10,6 @@
 import Attribute from '/ckeditor5/core/treemodel/attribute.js';
 
 describe( 'Attribute', () => {
-	beforeEach( () => {
-		Attribute._register = {};
-	} );
-
 	describe( 'constructor', () => {
 		it( 'should create attribute', () => {
 			let attr = new Attribute( 'foo', 'bar' );
@@ -28,48 +24,5 @@ describe( 'Attribute', () => {
 
 			expect( attr1.isEqual( attr2 ) ).to.be.true;
 		} );
-
-		it( 'should return the same object for registered objects', () => {
-			Attribute.register( 'register', true );
-
-			let attr1 = new Attribute( 'register', true );
-			let attr2 = new Attribute( 'register', true );
-
-			expect( attr1 ).to.equal( attr2 );
-			expect( attr1.isEqual( attr2 ) ).to.be.true;
-		} );
-
-		it( 'should return different objects for different values', () => {
-			Attribute.register( 'register', true );
-
-			let attr1 = new Attribute( 'register', true );
-			let attr2 = new Attribute( 'register', false );
-
-			expect( attr1 ).to.not.be.equals( attr2 );
-			expect( attr1.isEqual( attr2 ) ).to.not.be.true;
-		} );
-
-		it( 'should return different objects for not registered objects', () => {
-			Attribute.register( 'register', true );
-
-			let attr1 = new Attribute( 'register', false );
-			let attr2 = new Attribute( 'register', false );
-
-			expect( attr1 ).to.not.be.equals( attr2 );
-			expect( attr1.isEqual( attr2 ) ).to.be.true;
-		} );
-	} );
-
-	describe( 'register', () => {
-		it( 'Attribute.register should return registered attribute', () => {
-			let attr1 = new Attribute( 'register', true );
-			let attr2 = Attribute.register( 'register', true );
-			let attr3 = Attribute.register( 'register', true );
-			let attr4 = new Attribute( 'register', true );
-
-			expect( attr1 ).to.not.be.equals( attr2 );
-			expect( attr2 ).to.equal( attr3 );
-			expect( attr3 ).to.equal( attr4 );
-		} );
 	} );
 } );

+ 123 - 82
packages/ckeditor5-engine/tests/treemodel/attributelist.js

@@ -7,12 +7,9 @@
 
 'use strict';
 
-import coreTestUtils from '/tests/core/_utils/utils.js';
 import AttributeList from '/ckeditor5/core/treemodel/attributelist.js';
 import Attribute from '/ckeditor5/core/treemodel/attribute.js';
 
-const getIteratorCount = coreTestUtils.getIteratorCount;
-
 describe( 'AttributeList', () => {
 	let list, attrFooBar;
 
@@ -21,129 +18,173 @@ describe( 'AttributeList', () => {
 		attrFooBar = new Attribute( 'foo', 'bar' );
 	} );
 
-	describe( 'setAttr', () => {
-		it( 'should insert an attribute', () => {
-			list.setAttr( attrFooBar );
+	it( 'should extend Map', () => {
+		expect( list ).to.be.instanceof( Map );
+	} );
 
-			expect( getIteratorCount( list.getAttrs() ) ).to.equal( 1 );
-			expect( list.getAttr( attrFooBar.key ) ).to.equal( attrFooBar.value );
+	describe( 'constructor', () => {
+		it( 'should initialize list with passed attributes', () => {
+			list = new AttributeList( [ attrFooBar ] );
+			expect( list.size ).to.equal( 1 );
+			expect( list.has( 'foo' ) ).to.be.true;
+			expect( list.get( 'foo' ).value ).to.equal( 'bar' );
 		} );
 
-		it( 'should overwrite attribute with the same key', () => {
-			list.setAttr( attrFooBar );
+		it( 'should copy passed AttributeList', () => {
+			list = new AttributeList( [ attrFooBar ] );
+			let copy = new AttributeList( list );
 
-			expect( getIteratorCount( list.getAttrs() ) ).to.equal( 1 );
-			expect( list.getAttr( 'foo' ) ).to.equal( 'bar' );
+			expect( copy.size ).to.equal( 1 );
+			expect( copy.has( 'foo' ) ).to.be.true;
+			expect( copy.get( 'foo' ).value ).to.equal( 'bar' );
+		} );
+	} );
 
-			let attrFooXyz = new Attribute( 'foo', 'xyz' );
+	describe( 'iterator', () => {
+		it( 'should iterate over all added attributes', () => {
+			let attrAbcXyz = new Attribute( 'abc', 'xyz' );
+			let attrTestTrue = new Attribute( 'test', true );
+
+			list = new AttributeList( [ attrFooBar, attrAbcXyz, attrTestTrue ] );
+			list.delete( 'test' );
+
+			let it = list[ Symbol.iterator ]();
+
+			let step = it.next();
 
-			list.setAttr( attrFooXyz );
+			expect( step.done ).to.be.false;
+			expect( step.value ).to.equal( attrFooBar );
 
-			expect( getIteratorCount( list.getAttrs() ) ).to.equal( 1 );
-			expect( list.getAttr( 'foo' ) ).to.equal( 'xyz' );
+			step = it.next();
+
+			expect( step.done ).to.be.false;
+			expect( step.value ).to.equal( attrAbcXyz );
+
+			step = it.next();
+
+			expect( step.done ).to.be.true;
 		} );
 	} );
 
-	describe( 'setAttrsTo', () => {
-		it( 'should remove all attributes and set passed ones', () => {
-			list.setAttr( attrFooBar );
+	describe( 'getValue', () => {
+		it( 'should return value of set attribute for given key', () => {
+			list.set( attrFooBar );
+			expect( list.getValue( 'foo' ) ).to.equal( 'bar' );
+		} );
 
-			let attrs = [ new Attribute( 'abc', true ), new Attribute( 'xyz', false ) ];
+		it( 'should return null if attribute with given key is not set', () => {
+			expect( list.getValue( 'foo' ) ).to.be.null;
+		} );
+	} );
 
-			list.setAttrsTo( attrs );
+	describe( 'set', () => {
+		it( 'should insert given attribute', () => {
+			list.set( attrFooBar );
 
-			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;
+			expect( list.size ).to.equal( 1 );
+			expect( list.getValue( 'foo' ) ).to.equal( 'bar' );
 		} );
 
-		it( 'should copy attributes array, not pass by reference', () => {
-			let attrs = [ new Attribute( 'attr', true ) ];
+		it( 'should overwrite attribute with the same key', () => {
+			list.set( attrFooBar );
+
+			expect( list.size ).to.equal( 1 );
+			expect( list.getValue( 'foo' ) ).to.equal( 'bar' );
 
-			list.setAttrsTo( attrs );
+			let attrFooXyz = new Attribute( 'foo', 'xyz' );
 
-			attrs.pop();
+			list.set( attrFooXyz );
 
-			expect( getIteratorCount( list.getAttrs() ) ).to.equal( 1 );
+			expect( list.size ).to.equal( 1 );
+			expect( list.getValue( 'foo' ) ).to.equal( 'xyz' );
 		} );
 	} );
 
-	describe( 'getAttr', () => {
-		beforeEach( () => {
-			list.setAttr( attrFooBar );
-		} );
+	describe( 'setTo', () => {
+		it( 'should remove all attributes from the list and set given ones', () => {
+			list.set( attrFooBar );
+			list.setTo( [ new Attribute( 'abc', true ), new Attribute( 'bar', false ) ] );
 
-		it( 'should return attribute value if key of previously set attribute has been passed', () => {
-			expect( list.getAttr( 'foo' ) ).to.equal( attrFooBar.value );
+			expect( list.has( 'foo' ) ).to.be.false;
+			expect( list.getValue( 'abc' ) ).to.be.true;
+			expect( list.getValue( 'bar' ) ).to.be.false;
 		} );
+	} );
 
-		it( 'should return null if attribute with given key has not been found', () => {
-			expect( list.getAttr( 'bar' ) ).to.be.null;
+	describe( 'has', () => {
+		it( 'should return true if list contains given attribute (same key and value)', () => {
+			list.set( attrFooBar );
+
+			expect( list.has( attrFooBar ) ).to.be.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' );
+		it( 'should return true if list contains an attribute with given key', () => {
+			list.set( attrFooBar );
 
-			list.setAttr( attrA );
-			list.setAttr( attrB );
-			list.setAttr( attrC );
+			expect( list.has( 'foo' ) ).to.be.true;
+		} );
 
-			list.removeAttr( attrB.key );
+		it( 'should return false if list does not contain given attribute', () => {
+			list.set( attrFooBar );
 
-			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;
+			expect( list.has( new Attribute( 'abc', true ) ) ).to.be.false;
 		} );
-	} );
 
-	describe( 'hasAttr', () => {
-		it( 'should check attribute by key', () => {
-			list.setAttr( attrFooBar );
-			expect( list.hasAttr( 'foo' ) ).to.be.true;
+		it( 'should return false if list contains given attribute but value differs', () => {
+			list.set( attrFooBar );
+
+			expect( list.has( new Attribute( 'foo', 'foo' ) ) ).to.be.false;
 		} );
 
-		it( 'should return false if attribute was not found by key', () => {
-			expect( list.hasAttr( 'bar' ) ).to.be.false;
+		it( 'should return false if list does not contain an attribute with given key', () => {
+			list.set( attrFooBar );
+
+			expect( list.has( 'abc' ) ).to.be.false;
 		} );
+	} );
+
+	describe( 'isEqual', () => {
+		it( 'should return false if lists have different size', () => {
+			let attrAbcXyz = new Attribute( 'abc', 'xyz' );
+			list.setTo( [ attrFooBar, attrAbcXyz ] );
 
-		it( 'should check attribute by object', () => {
-			list.setAttr( attrFooBar );
-			expect( list.hasAttr( attrFooBar ) ).to.be.true;
+			let other = new AttributeList( [ attrFooBar ] );
+
+			expect( list.isEqual( other ) ).to.be.false;
+			expect( other.isEqual( list ) ).to.be.false;
 		} );
 
-		it( 'should return false if attribute was not found by object', () => {
-			expect( list.hasAttr( attrFooBar ) ).to.be.false;
+		it( 'should return false if lists have different attributes', () => {
+			let attrAbcXyz = new Attribute( 'abc', 'xyz' );
+			list.setTo( [ attrFooBar ] );
+
+			let other = new AttributeList( [ attrAbcXyz ] );
+
+			expect( list.isEqual( other ) ).to.be.false;
+			expect( other.isEqual( list ) ).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' );
+		it( 'should return false if lists have same attributes but different values for them', () => {
+			let attrAbcXyz = new Attribute( 'abc', 'xyz' );
+			let attrFooTrue = new Attribute( 'foo', true );
 
-			list.setAttrsTo( [
-				attrA,
-				attrB,
-				attrC
-			] );
+			list.setTo( [ attrFooBar, attrAbcXyz ] );
 
-			list.removeAttr( attrB.key );
+			let other = new AttributeList( [ attrFooTrue, attrAbcXyz ] );
+
+			expect( list.isEqual( other ) ).to.be.false;
+			expect( other.isEqual( list ) ).to.be.false;
+		} );
 
-			let attrsIt = list.getAttrs();
-			let attrs = [];
+		it( 'should return true if lists have same attributes and same values for them', () => {
+			let attrAbcXyz = new Attribute( 'abc', 'xyz' );
+			list.setTo( [ attrFooBar, attrAbcXyz ] );
 
-			for ( let attr of attrsIt ) {
-				attrs.push( attr );
-			}
+			// Note different order of attributes.
+			let other = new AttributeList( [ attrAbcXyz, attrFooBar ] );
 
-			expect( [ attrA, attrC ] ).to.deep.equal( attrs );
+			expect( list.isEqual( other ) ).to.be.true;
+			expect( other.isEqual( list ) ).to.be.true;
 		} );
 	} );
 } );

+ 0 - 42
packages/ckeditor5-engine/tests/treemodel/character.js

@@ -1,42 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/* bender-tags: treemodel */
-
-'use strict';
-
-import coreTestUtils from '/tests/core/_utils/utils.js';
-import Character from '/ckeditor5/core/treemodel/character.js';
-import Node from '/ckeditor5/core/treemodel/node.js';
-import Element from '/ckeditor5/core/treemodel/element.js';
-import Attribute from '/ckeditor5/core/treemodel/attribute.js';
-
-const getIteratorCount = coreTestUtils.getIteratorCount;
-
-describe( 'Character', () => {
-	describe( 'constructor', () => {
-		it( 'should create character without attributes', () => {
-			let character = new Character( 'f' );
-			let parent = new Element( 'parent', [], character );
-
-			expect( character ).to.be.an.instanceof( Node );
-			expect( character ).to.have.property( 'character' ).that.equals( 'f' );
-			expect( character ).to.have.property( 'parent' ).that.equals( parent );
-			expect( getIteratorCount( character.getAttrs() ) ).to.equal( 0 );
-		} );
-
-		it( 'should create character with attributes', () => {
-			let attr = new Attribute( 'foo', 'bar' );
-			let character = new Character( 'f', [ attr ] );
-			let parent = new Element( 'parent', [], character );
-
-			expect( character ).to.be.an.instanceof( Node );
-			expect( character ).to.have.property( 'character' ).that.equals( 'f' );
-			expect( character ).to.have.property( 'parent' ).that.equals( parent );
-			expect( getIteratorCount( character.getAttrs() ) ).to.equal( 1 );
-			expect( character.getAttr( attr.key ) ).to.equal( attr.value );
-		} );
-	} );
-} );

+ 46 - 0
packages/ckeditor5-engine/tests/treemodel/characterproxy.js

@@ -0,0 +1,46 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel */
+
+'use strict';
+
+import Node from '/ckeditor5/core/treemodel/node.js';
+import Element from '/ckeditor5/core/treemodel/element.js';
+import Text from '/ckeditor5/core/treemodel/text.js';
+import Attribute from '/ckeditor5/core/treemodel/attribute.js';
+
+describe( 'CharacterProxy', () => {
+	let text, element, char;
+
+	before( () => {
+		text = new Text( 'abc', [ new Attribute( 'foo', true ) ] );
+		element = new Element( 'div', [], [ new Element( 'p' ), text, new Element( 'p' ) ] );
+	} );
+
+	beforeEach( () => {
+		char = element.getChild( 2 );
+	} );
+
+	it( 'should extend Node class', () => {
+		expect( char ).to.be.instanceof( Node );
+	} );
+
+	it( 'should have correct character property', () => {
+		expect( char ).to.have.property( 'character' ).that.equals( 'b' );
+	} );
+
+	it( 'should have correct parent property', () => {
+		expect( char ).to.have.property( 'parent' ).that.equals( element );
+	} );
+
+	it( 'should have attributes list equal to passed to Text instance', () => {
+		expect( char.attrs.isEqual( text.attrs ) ).to.be.true;
+	} );
+
+	it( 'should return correct index in parent node', () => {
+		expect( char.getIndex() ).to.equal( 2 );
+	} );
+} );

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

@@ -14,7 +14,6 @@ import Attribute from '/ckeditor5/core/treemodel/attribute.js';
 import Range from '/ckeditor5/core/treemodel/range.js';
 import Position from '/ckeditor5/core/treemodel/position.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
-import Character from '/ckeditor5/core/treemodel/character.js';
 
 const getIteratorCount = coreTestUtils.getIteratorCount;
 
@@ -38,43 +37,46 @@ describe( 'Batch', () => {
 	}
 
 	describe( 'change attribute on node', () => {
-		let node, character;
+		let node, text, char;
 
 		beforeEach( () => {
 			node = new Element( 'p', [ new Attribute( 'a', 1 ) ] );
-			character = new Character( 'c', [ new Attribute( 'a', 1 ) ] );
-			root.insertChildren( 0, [ node, character ] );
+			text = new Text( 'c', [ new Attribute( 'a', 1 ) ] );
+
+			root.insertChildren( 0, [ node, text ] );
+
+			char = root.getChild( 1 );
 		} );
 
 		describe( 'setAttr', () => {
 			it( 'should create the attribute on element', () => {
 				batch.setAttr( 'b', 2, node );
 				expect( getOperationsCount() ).to.equal( 1 );
-				expect( node.getAttr( 'b' ) ).to.equal( 2 );
+				expect( node.attrs.getValue( 'b' ) ).to.equal( 2 );
 			} );
 
 			it( 'should change the attribute of element', () => {
 				batch.setAttr( 'a', 2, node );
 				expect( getOperationsCount() ).to.equal( 1 );
-				expect( node.getAttr( 'a' ) ).to.equal( 2 );
+				expect( node.attrs.getValue( 'a' ) ).to.equal( 2 );
 			} );
 
-			it( 'should create the attribute on character', () => {
-				batch.setAttr( 'b', 2, character );
+			it( 'should create the attribute on text node', () => {
+				batch.setAttr( 'b', 2, char );
 				expect( getOperationsCount() ).to.equal( 1 );
-				expect( character.getAttr( 'b' ) ).to.equal( 2 );
+				expect( root.getChild( 1 ).attrs.getValue( 'b' ) ).to.equal( 2 );
 			} );
 
-			it( 'should change the attribute of character', () => {
-				batch.setAttr( 'a', 2, character );
+			it( 'should change the attribute of text node', () => {
+				batch.setAttr( 'a', 2, char );
 				expect( getOperationsCount() ).to.equal( 1 );
-				expect( character.getAttr( 'a' ) ).to.equal( 2 );
+				expect( root.getChild( 1 ).attrs.getValue( 'a' ) ).to.equal( 2 );
 			} );
 
 			it( 'should do nothing if the attribute value is the same', () => {
 				batch.setAttr( 'a', 1, node );
 				expect( getOperationsCount() ).to.equal( 0 );
-				expect( node.getAttr( 'a' ) ).to.equal( 1 );
+				expect( node.attrs.getValue( 'a' ) ).to.equal( 1 );
 			} );
 
 			it( 'should be chainable', () => {
@@ -87,13 +89,13 @@ describe( 'Batch', () => {
 			it( 'should remove the attribute from element', () => {
 				batch.removeAttr( 'a', node );
 				expect( getOperationsCount() ).to.equal( 1 );
-				expect( node.getAttr( 'a' ) ).to.be.null;
+				expect( node.attrs.getValue( 'a' ) ).to.be.null;
 			} );
 
 			it( 'should remove the attribute from character', () => {
-				batch.removeAttr( 'a', character );
+				batch.removeAttr( 'a', char );
 				expect( getOperationsCount() ).to.equal( 1 );
-				expect( character.getAttr( 'a' ) ).to.be.null;
+				expect( root.getChild( 1 ).attrs.getValue( 'a' ) ).to.be.null;
 			} );
 
 			it( 'should do nothing if the attribute is not set', () => {
@@ -145,7 +147,7 @@ describe( 'Batch', () => {
 			// default: 111---111222---1112------
 			const range = Range.createFromElement( root );
 
-			return Array.from( range.getAllNodes() ).map( node => node.getAttr( 'a' ) || '-' ).join( '' );
+			return Array.from( range.getAllNodes() ).map( node => node.attrs.getValue( 'a' ) || '-' ).join( '' );
 		}
 
 		describe( 'setAttr', () => {

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

@@ -7,15 +7,12 @@
 
 'use strict';
 
-import coreTestUtils from '/tests/core/_utils/utils.js';
 import Document from '/ckeditor5/core/treemodel/document.js';
 import Position from '/ckeditor5/core/treemodel/position.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
 import Attribute from '/ckeditor5/core/treemodel/attribute.js';
 import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
 
-const getIteratorCount = coreTestUtils.getIteratorCount;
-
 describe( 'Batch', () => {
 	let doc, root, p1, p2;
 
@@ -36,8 +33,8 @@ describe( 'Batch', () => {
 			expect( root.getChildCount() ).to.equal( 1 );
 			expect( root.getChild( 0 ).name ).to.equal( 'p' );
 			expect( root.getChild( 0 ).getChildCount() ).to.equal( 6 );
-			expect( getIteratorCount( root.getChild( 0 ).getAttrs() ) ).to.equal( 1 );
-			expect( root.getChild( 0 ).getAttr( 'key1' ) ).to.equal( 'value1' );
+			expect( root.getChild( 0 ).attrs.size ).to.equal( 1 );
+			expect( root.getChild( 0 ).attrs.getValue( 'key1' ) ).to.equal( 'value1' );
 			expect( root.getChild( 0 ).getChild( 0 ).character ).to.equal( 'f' );
 			expect( root.getChild( 0 ).getChild( 1 ).character ).to.equal( 'o' );
 			expect( root.getChild( 0 ).getChild( 2 ).character ).to.equal( 'o' );

+ 8 - 11
packages/ckeditor5-engine/tests/treemodel/delta/splitdelta.js

@@ -7,15 +7,12 @@
 
 'use strict';
 
-import coreTestUtils from '/tests/core/_utils/utils.js';
 import Document from '/ckeditor5/core/treemodel/document.js';
 import Position from '/ckeditor5/core/treemodel/position.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
 import Attribute from '/ckeditor5/core/treemodel/attribute.js';
 import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
 
-const getIteratorCount = coreTestUtils.getIteratorCount;
-
 describe( 'Batch', () => {
 	let doc, root, p;
 
@@ -36,16 +33,16 @@ describe( 'Batch', () => {
 
 			expect( root.getChild( 0 ).name ).to.equal( 'p' );
 			expect( root.getChild( 0 ).getChildCount() ).to.equal( 3 );
-			expect( getIteratorCount( root.getChild( 0 ).getAttrs() ) ).to.equal( 1 );
-			expect( root.getChild( 0 ).getAttr( 'key' ) ).to.equal( 'value' );
+			expect( root.getChild( 0 ).attrs.size ).to.equal( 1 );
+			expect( root.getChild( 0 ).attrs.getValue( 'key' ) ).to.equal( 'value' );
 			expect( root.getChild( 0 ).getChild( 0 ).character ).to.equal( 'f' );
 			expect( root.getChild( 0 ).getChild( 1 ).character ).to.equal( 'o' );
 			expect( root.getChild( 0 ).getChild( 2 ).character ).to.equal( 'o' );
 
 			expect( root.getChild( 1 ).name ).to.equal( 'p' );
 			expect( root.getChild( 1 ).getChildCount() ).to.equal( 3 );
-			expect( getIteratorCount( root.getChild( 1 ).getAttrs() ) ).to.equal( 1 );
-			expect( root.getChild( 1 ).getAttr( 'key' ) ).to.equal( 'value' );
+			expect( root.getChild( 1 ).attrs.size ).to.equal( 1 );
+			expect( root.getChild( 1 ).attrs.getValue( 'key' ) ).to.equal( 'value' );
 			expect( root.getChild( 1 ).getChild( 0 ).character ).to.equal( 'b' );
 			expect( root.getChild( 1 ).getChild( 1 ).character ).to.equal( 'a' );
 			expect( root.getChild( 1 ).getChild( 2 ).character ).to.equal( 'r' );
@@ -58,8 +55,8 @@ describe( 'Batch', () => {
 
 			expect( root.getChild( 0 ).name ).to.equal( 'p' );
 			expect( root.getChild( 0 ).getChildCount() ).to.equal( 6 );
-			expect( getIteratorCount( root.getChild( 0 ).getAttrs() ) ).to.equal( 1 );
-			expect( root.getChild( 0 ).getAttr( 'key' ) ).to.equal( 'value' );
+			expect( root.getChild( 0 ).attrs.size ).to.equal( 1 );
+			expect( root.getChild( 0 ).attrs.getValue( 'key' ) ).to.equal( 'value' );
 			expect( root.getChild( 0 ).getChild( 0 ).character ).to.equal( 'f' );
 			expect( root.getChild( 0 ).getChild( 1 ).character ).to.equal( 'o' );
 			expect( root.getChild( 0 ).getChild( 2 ).character ).to.equal( 'o' );
@@ -69,8 +66,8 @@ describe( 'Batch', () => {
 
 			expect( root.getChild( 1 ).name ).to.equal( 'p' );
 			expect( root.getChild( 1 ).getChildCount() ).to.equal( 0 );
-			expect( getIteratorCount( root.getChild( 1 ).getAttrs() ) ).to.equal( 1 );
-			expect( root.getChild( 1 ).getAttr( 'key' ) ).to.equal( 'value' );
+			expect( root.getChild( 1 ).attrs.size ).to.equal( 1 );
+			expect( root.getChild( 1 ).attrs.getValue( 'key' ) ).to.equal( 'value' );
 		} );
 
 		it( 'should throw if we try to split a root', () => {

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

@@ -27,12 +27,12 @@ describe( 'Batch', () => {
 			new Attribute( 'foo', 'bar' )
 		];
 
-		doc.selection.setAttrsTo( attrs );
+		doc.selection.attrs.setTo( attrs );
 
 		chain = batch.weakInsert( new Position( root, [ 2 ] ), 'xyz' );
 	} );
 
-	describe( 'insert', () => {
+	describe( 'weakInsert', () => {
 		it( 'should insert given nodes at given position', () => {
 			expect( root.getChildCount() ).to.equal( 6 );
 			expect( root.getChild( 2 ).character ).to.equal( 'x' );
@@ -41,9 +41,9 @@ describe( 'Batch', () => {
 		} );
 
 		it( 'should set inserted nodes attributes to same as current selection attributes', () => {
-			expect( Array.from( root.getChild( 2 ).getAttrs() ) ).to.deep.equal( attrs );
-			expect( Array.from( root.getChild( 3 ).getAttrs() ) ).to.deep.equal( attrs );
-			expect( Array.from( root.getChild( 4 ).getAttrs() ) ).to.deep.equal( attrs );
+			expect( Array.from( root.getChild( 2 ).attrs ) ).to.deep.equal( attrs );
+			expect( Array.from( root.getChild( 3 ).attrs ) ).to.deep.equal( attrs );
+			expect( Array.from( root.getChild( 4 ).attrs ) ).to.deep.equal( attrs );
 		} );
 
 		it( 'should be chainable', () => {

+ 19 - 24
packages/ckeditor5-engine/tests/treemodel/element.js

@@ -7,14 +7,11 @@
 
 'use strict';
 
-import coreTestUtils from '/tests/core/_utils/utils.js';
 import Node from '/ckeditor5/core/treemodel/node.js';
 import NodeList from '/ckeditor5/core/treemodel/nodelist.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
 import Attribute from '/ckeditor5/core/treemodel/attribute.js';
 
-const getIteratorCount = coreTestUtils.getIteratorCount;
-
 describe( 'Element', () => {
 	describe( 'constructor', () => {
 		it( 'should create element without attributes', () => {
@@ -24,7 +21,7 @@ describe( 'Element', () => {
 			expect( element ).to.be.an.instanceof( Node );
 			expect( element ).to.have.property( 'name' ).that.equals( 'elem' );
 			expect( element ).to.have.property( 'parent' ).that.equals( parent );
-			expect( getIteratorCount( element.getAttrs() ) ).to.equal( 0 );
+			expect( element.attrs.size ).to.equal( 0 );
 		} );
 
 		it( 'should create element with attributes', () => {
@@ -36,8 +33,8 @@ describe( 'Element', () => {
 			expect( element ).to.be.an.instanceof( Node );
 			expect( element ).to.have.property( 'name' ).that.equals( 'elem' );
 			expect( element ).to.have.property( 'parent' ).that.equals( parent );
-			expect( getIteratorCount( element.getAttrs() ) ).to.equal( 1 );
-			expect( element.getAttr( attr.key ) ).to.equal( attr.value );
+			expect( element.attrs.size ).to.equal( 1 );
+			expect( element.attrs.getValue( attr.key ) ).to.equal( attr.value );
 		} );
 
 		it( 'should create element with children', () => {
@@ -69,9 +66,6 @@ describe( 'Element', () => {
 	describe( 'removeChildren', () => {
 		it( 'should remove children from the element and return them as a NodeList', () => {
 			let element = new Element( 'elem', [], [ 'foobar' ] );
-			let o = element.getChild( 2 );
-			let b = element.getChild( 3 );
-			let a = element.getChild( 4 );
 			let removed = element.removeChildren( 2, 3 );
 
 			expect( element.getChildCount() ).to.equal( 3 );
@@ -79,28 +73,29 @@ describe( 'Element', () => {
 			expect( element.getChild( 1 ) ).to.have.property( 'character' ).that.equals( 'o' );
 			expect( element.getChild( 2 ) ).to.have.property( 'character' ).that.equals( 'r' );
 
-			expect( o ).to.have.property( 'parent' ).that.is.null;
-			expect( b ).to.have.property( 'parent' ).that.is.null;
-			expect( a ).to.have.property( 'parent' ).that.is.null;
-
 			expect( removed ).to.be.instanceof( NodeList );
 			expect( removed.length ).to.equal( 3 );
-			expect( removed.get( 0 ) ).to.equal( o );
-			expect( removed.get( 1 ) ).to.equal( b );
-			expect( removed.get( 2 ) ).to.equal( a );
+
+			expect( removed.get( 0 ).character ).to.equal( 'o' );
+			expect( removed.get( 1 ).character ).to.equal( 'b' );
+			expect( removed.get( 2 ).character ).to.equal( 'a' );
 		} );
 	} );
 
 	describe( 'getChildIndex', () => {
 		it( 'should return child index', () => {
-			let element = new Element( 'elem', [], [ 'bar' ] );
-			let b = element.getChild( 0 );
-			let a = element.getChild( 1 );
-			let r = element.getChild( 2 );
-
-			expect( element.getChildIndex( b ) ).to.equal( 0 );
-			expect( element.getChildIndex( a ) ).to.equal( 1 );
-			expect( element.getChildIndex( r ) ).to.equal( 2 );
+			let element = new Element( 'elem', [], [ new Element( 'p' ), 'bar', new Element( 'h' ) ] );
+			let p = element.getChild( 0 );
+			let b = element.getChild( 1 );
+			let a = element.getChild( 2 );
+			let r = element.getChild( 3 );
+			let h = element.getChild( 4 );
+
+			expect( element.getChildIndex( p ) ).to.equal( 0 );
+			expect( element.getChildIndex( b ) ).to.equal( 1 );
+			expect( element.getChildIndex( a ) ).to.equal( 2 );
+			expect( element.getChildIndex( r ) ).to.equal( 3 );
+			expect( element.getChildIndex( h ) ).to.equal( 4 );
 		} );
 	} );
 

+ 33 - 141
packages/ckeditor5-engine/tests/treemodel/node.js

@@ -7,37 +7,27 @@
 
 'use strict';
 
-import coreTestUtils from '/tests/core/_utils/utils.js';
-import Attribute from '/ckeditor5/core/treemodel/attribute.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
+import Attribute from '/ckeditor5/core/treemodel/attribute.js';
+import AttributeList from '/ckeditor5/core/treemodel/attributelist.js';
 import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
-import Character from '/ckeditor5/core/treemodel/character.js';
-
-const getIteratorCount = coreTestUtils.getIteratorCount;
 
 describe( 'Node', () => {
 	let root;
 	let one, two, three;
-	let charB, charA, charR, img, attrEle;
-	let attrFooBar;
+	let charB, charA, charR, img;
 
 	before( () => {
-		charB = new Character( 'b' );
-		charA = new Character( 'a' );
 		img = new Element( 'img' );
-		charR = new Character( 'r' );
 
 		one = new Element( 'one' );
-		two = new Element( 'two', null, [ charB, charA, img, charR ] );
+		two = new Element( 'two', null, [ 'b', 'a', img, 'r' ] );
+		charB = two.getChild( 0 );
+		charA = two.getChild( 1 );
+		charR = two.getChild( 3 );
 		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', () => {
@@ -61,10 +51,7 @@ describe( 'Node', () => {
 			expect( two ).to.have.property( 'root' ).that.equals( root );
 			expect( three ).to.have.property( 'root' ).that.equals( root );
 
-			expect( charB ).to.have.property( 'root' ).that.equals( root );
-			expect( charA ).to.have.property( 'root' ).that.equals( root );
 			expect( img ).to.have.property( 'root' ).that.equals( root );
-			expect( charR ).to.have.property( 'root' ).that.equals( root );
 		} );
 
 		it( 'nextSibling', () => {
@@ -74,9 +61,9 @@ describe( 'Node', () => {
 			expect( two ).to.have.property( 'nextSibling' ).that.equals( three );
 			expect( three ).to.have.property( 'nextSibling' ).that.is.null;
 
-			expect( charB ).to.have.property( 'nextSibling' ).that.equals( charA );
-			expect( charA ).to.have.property( 'nextSibling' ).that.equals( img );
-			expect( img ).to.have.property( 'nextSibling' ).that.equals( charR );
+			expect( charB ).to.have.property( 'nextSibling' ).that.deep.equals( charA );
+			expect( charA ).to.have.property( 'nextSibling' ).that.deep.equals( img );
+			expect( img ).to.have.property( 'nextSibling' ).that.deep.equals( charR );
 			expect( charR ).to.have.property( 'nextSibling' ).that.is.null;
 		} );
 
@@ -88,34 +75,40 @@ describe( 'Node', () => {
 			expect( three ).to.have.property( 'previousSibling' ).that.equals( two );
 
 			expect( charB ).to.have.property( 'previousSibling' ).that.is.null;
-			expect( charA ).to.have.property( 'previousSibling' ).that.equals( charB );
-			expect( img ).to.have.property( 'previousSibling' ).that.equals( charA );
-			expect( charR ).to.have.property( 'previousSibling' ).that.equals( img );
+			expect( charA ).to.have.property( 'previousSibling' ).that.deep.equals( charB );
+			expect( img ).to.have.property( 'previousSibling' ).that.deep.equals( charA );
+			expect( charR ).to.have.property( 'previousSibling' ).that.deep.equals( img );
 		} );
 	} );
 
 	describe( 'constructor', () => {
-		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 );
+		it( 'should create empty attribute list if no parameters were passed', () => {
+			let foo = new Element( 'foo' );
 
-			foo.removeAttr( 'attr' );
+			expect( foo.attrs ).to.be.instanceof( AttributeList );
+			expect( foo.attrs.size ).to.equal( 0 );
+		} );
+
+		it( 'should initialize attribute list with passed attributes', () => {
+			let attrs = [
+				new Attribute( 'foo', true ),
+				new Attribute( 'bar', false )
+			];
+			let foo = new Element( 'foo', attrs );
 
-			expect( getIteratorCount( foo.getAttrs() ) ).to.equal( 0 );
-			expect( getIteratorCount( bar.getAttrs() ) ).to.equal( 1 );
+			expect( foo.attrs.size ).to.equal( 2 );
+			expect( foo.attrs.getValue( 'foo' ) ).to.equal( true );
+			expect( foo.attrs.getValue( 'bar' ) ).to.equal( false );
 		} );
 	} );
 
 	it( 'should create proper JSON string using toJSON method', () => {
-		let b = new Character( 'b' );
-		let foo = new Element( 'foo', [], [ b ] );
+		let foo = new Element( 'foo', [], [ 'b' ] );
 
 		let parsedFoo = JSON.parse( JSON.stringify( foo ) );
-		let parsedBar = JSON.parse( JSON.stringify( b ) );
 
 		expect( parsedFoo.parent ).to.equal( null );
-		expect( parsedBar.parent ).to.equal( 'foo' );
+		expect( parsedFoo._children._nodes[ 0 ].parent ).to.equal( 'foo' );
 	} );
 
 	describe( 'getIndex', () => {
@@ -129,20 +122,19 @@ describe( 'Node', () => {
 			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 throw an error if parent does not contains element', () => {
-			let f = new Character( 'f' );
+			let e = new Element( 'e' );
 			let bar = new Element( 'bar', [], [] );
 
-			f.parent = bar;
+			e.parent = bar;
 
 			expect(
 				() => {
-					f.getIndex();
+					e.getIndex();
 				}
 			).to.throw( CKEditorError, /node-not-found-in-parent/ );
 		} );
@@ -157,108 +149,8 @@ describe( 'Node', () => {
 			expect( three.getPath() ).to.deep.equal( [ 2 ] );
 
 			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 ] );
 		} );
 	} );
-
-	// Testing integration with attributes list.
-	// Tests copied from AttributeList tests.
-	// Some cases were omitted.
-
-	describe( 'setAttr', () => {
-		it( 'should insert an attribute', () => {
-			attrEle.setAttr( attrFooBar );
-
-			expect( getIteratorCount( attrEle.getAttrs() ) ).to.equal( 1 );
-			expect( attrEle.getAttr( attrFooBar.key ) ).to.equal( attrFooBar.value );
-		} );
-	} );
-
-	describe( 'setAttrsTo', () => {
-		it( 'should remove all attributes and set passed ones', () => {
-			attrEle.setAttr( attrFooBar );
-
-			let attrs = [ new Attribute( 'abc', true ), new Attribute( 'xyz', false ) ];
-
-			attrEle.setAttrsTo( attrs );
-
-			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;
-		} );
-	} );
-
-	describe( 'getAttr', () => {
-		beforeEach( () => {
-			attrEle = new Element( 'e', [ attrFooBar ] );
-		} );
-
-		it( 'should return attribute value if key of previously set attribute has been passed', () => {
-			expect( attrEle.getAttr( 'foo' ) ).to.equal( attrFooBar.value );
-		} );
-
-		it( 'should return null if attribute with given key has not been found', () => {
-			expect( attrEle.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' );
-
-			attrEle.setAttr( attrA );
-			attrEle.setAttr( attrB );
-			attrEle.setAttr( attrC );
-
-			attrEle.removeAttr( attrB.key );
-
-			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( 'hasAttr', () => {
-		it( 'should check attribute by key', () => {
-			attrEle.setAttr( attrFooBar );
-			expect( attrEle.hasAttr( 'foo' ) ).to.be.true;
-		} );
-
-		it( 'should return false if attribute was not found by key', () => {
-			expect( attrEle.hasAttr( 'bar' ) ).to.be.false;
-		} );
-
-		it( 'should check attribute by object', () => {
-			attrEle.setAttr( attrFooBar );
-			expect( attrEle.hasAttr( attrFooBar ) ).to.be.true;
-		} );
-
-		it( 'should return false if attribute was not found by object', () => {
-			expect( attrEle.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' );
-
-			attrEle.setAttrsTo( [
-				attrA,
-				attrB,
-				attrC
-			] );
-
-			attrEle.removeAttr( attrB.key );
-
-			expect( [ attrA, attrC ] ).to.deep.equal( Array.from( attrEle.getAttrs() ) );
-		} );
-	} );
 } );

+ 202 - 19
packages/ckeditor5-engine/tests/treemodel/nodelist.js

@@ -8,23 +8,18 @@
 'use strict';
 
 import NodeList from '/ckeditor5/core/treemodel/nodelist.js';
-import Character from '/ckeditor5/core/treemodel/character.js';
+import Element from '/ckeditor5/core/treemodel/element.js';
 import Text from '/ckeditor5/core/treemodel/text.js';
 import Attribute from '/ckeditor5/core/treemodel/attribute.js';
 
 describe( 'NodeList', () => {
 	describe( 'constructor', () => {
-		it( 'should change array of strings into a set of nodes', () => {
-			let nodeList = new NodeList( [ 'foo', new Character( 'x' ), 'bar' ] );
+		it( 'should add elements to the node list', () => {
+			let p = new Element( 'p' );
+			let nodeList = new NodeList( p );
 
-			expect( nodeList.length ).to.equal( 7 );
-			expect( nodeList.get( 0 ).character ).to.equal( 'f' );
-			expect( nodeList.get( 1 ).character ).to.equal( 'o' );
-			expect( nodeList.get( 2 ).character ).to.equal( 'o' );
-			expect( nodeList.get( 3 ).character ).to.equal( 'x' );
-			expect( nodeList.get( 4 ).character ).to.equal( 'b' );
-			expect( nodeList.get( 5 ).character ).to.equal( 'a' );
-			expect( nodeList.get( 6 ).character ).to.equal( 'r' );
+			expect( nodeList.length ).to.equal( 1 );
+			expect( nodeList.get( 0 ) ).to.equal( p );
 		} );
 
 		it( 'should change string into a set of nodes', () => {
@@ -37,10 +32,11 @@ describe( 'NodeList', () => {
 		} );
 
 		it( 'should change node into a set of nodes', () => {
-			let nodeList = new NodeList( new Character( 'x' ) );
+			let nodeList = new NodeList( new Text( 'xy' ) );
 
-			expect( nodeList.length ).to.equal( 1 );
+			expect( nodeList.length ).to.equal( 2 );
 			expect( nodeList.get( 0 ).character ).to.equal( 'x' );
+			expect( nodeList.get( 1 ).character ).to.equal( 'y' );
 		} );
 
 		it( 'should change text with attribute into a set of nodes', () => {
@@ -49,16 +45,70 @@ describe( 'NodeList', () => {
 
 			expect( nodeList.length ).to.equal( 3 );
 			expect( nodeList.get( 0 ).character ).to.equal( 'f' );
-			expect( nodeList.get( 0 ).getAttr( attr.key ) ).to.equal( attr.value );
+			expect( nodeList.get( 0 ).attrs.getValue( attr.key ) ).to.equal( attr.value );
+			expect( nodeList.get( 1 ).character ).to.equal( 'o' );
+			expect( nodeList.get( 1 ).attrs.getValue( attr.key ) ).to.equal( attr.value );
+			expect( nodeList.get( 2 ).character ).to.equal( 'o' );
+			expect( nodeList.get( 2 ).attrs.getValue( attr.key ) ).to.equal( attr.value );
+		} );
+
+		it( 'should change array of characters into a set of nodes', () => {
+			let char = new Element( 'p', [], 'y' ).getChild( 0 );
+			let nodeList = new NodeList( [ 'foo', new Text( 'x' ), char, 'bar' ] );
+
+			expect( nodeList.length ).to.equal( 8 );
+			expect( nodeList.get( 0 ).character ).to.equal( 'f' );
+			expect( nodeList.get( 1 ).character ).to.equal( 'o' );
+			expect( nodeList.get( 2 ).character ).to.equal( 'o' );
+			expect( nodeList.get( 3 ).character ).to.equal( 'x' );
+			expect( nodeList.get( 4 ).character ).to.equal( 'y' );
+			expect( nodeList.get( 5 ).character ).to.equal( 'b' );
+			expect( nodeList.get( 6 ).character ).to.equal( 'a' );
+			expect( nodeList.get( 7 ).character ).to.equal( 'r' );
+		} );
+
+		it( 'should omit empty strings / texts', () => {
+			let nodeList = new NodeList( [ 'fo', '', 'ob', new Text( '', [ new Attribute( 'foo', true ) ] ), 'ar' ] );
+
+			expect( nodeList.length ).to.equal( 6 );
+			expect( nodeList.get( 0 ).character ).to.equal( 'f' );
+			expect( nodeList.get( 1 ).character ).to.equal( 'o' );
+			expect( nodeList.get( 2 ).character ).to.equal( 'o' );
+			expect( nodeList.get( 3 ).character ).to.equal( 'b' );
+			expect( nodeList.get( 4 ).character ).to.equal( 'a' );
+			expect( nodeList.get( 5 ).character ).to.equal( 'r' );
+
+			expect( nodeList.get( 0 ).attrs.size ).to.equal( 0 );
+			expect( nodeList.get( 1 ).attrs.size ).to.equal( 0 );
+			expect( nodeList.get( 2 ).attrs.size ).to.equal( 0 );
+			expect( nodeList.get( 3 ).attrs.size ).to.equal( 0 );
+			expect( nodeList.get( 4 ).attrs.size ).to.equal( 0 );
+			expect( nodeList.get( 5 ).attrs.size ).to.equal( 0 );
+		} );
+
+		it( 'should merge strings and text objects if possible', () => {
+			let attr = new Attribute( 'foo', 'bar' );
+			let nodeList = new NodeList( [ 'fo', new Text( 'o' ), new Text( 'x', [ attr ] ), new Text( 'y', [ attr ] ), 'bar' ] );
+
+			expect( nodeList.length ).to.equal( 8 );
+			expect( nodeList.get( 0 ).character ).to.equal( 'f' );
 			expect( nodeList.get( 1 ).character ).to.equal( 'o' );
-			expect( nodeList.get( 1 ).getAttr( attr.key ) ).to.equal( attr.value );
 			expect( nodeList.get( 2 ).character ).to.equal( 'o' );
-			expect( nodeList.get( 2 ).getAttr( attr.key ) ).to.equal( attr.value );
+			expect( nodeList.get( 3 ).character ).to.equal( 'x' );
+			expect( nodeList.get( 4 ).character ).to.equal( 'y' );
+			expect( nodeList.get( 5 ).character ).to.equal( 'b' );
+			expect( nodeList.get( 6 ).character ).to.equal( 'a' );
+			expect( nodeList.get( 7 ).character ).to.equal( 'r' );
+
+			expect( nodeList._nodes.length ).to.equal( 3 );
+			expect( nodeList._nodes[ 0 ].text ).to.equal( 'foo' );
+			expect( nodeList._nodes[ 1 ].text ).to.equal( 'xy' );
+			expect( nodeList._nodes[ 2 ].text ).to.equal( 'bar' );
 		} );
 	} );
 
 	describe( 'insert', () => {
-		it( 'should insert one nodelist into another', () => {
+		it( 'should insert one node list into another', () => {
 			let outerList = new NodeList( 'foo' );
 			let innerList = new NodeList( 'xxx' );
 
@@ -72,18 +122,86 @@ describe( 'NodeList', () => {
 			expect( outerList.get( 4 ).character ).to.equal( 'x' );
 			expect( outerList.get( 5 ).character ).to.equal( 'o' );
 		} );
+
+		it( 'should merge inserted text objects if possible', () => {
+			let attr = new Attribute( 'foo', 'bar' );
+			let outerList = new NodeList( [ 'foo', new Text( 'bar', [ attr ] ) ] );
+			let innerList = new NodeList( [ 'x' , new Text( 'y', [ attr ] ) ] );
+
+			outerList.insert( 3, innerList );
+
+			expect( outerList._nodes.length ).to.equal( 2 );
+			expect( outerList._nodes[ 0 ].text ).to.equal( 'foox' );
+			expect( outerList._nodes[ 1 ].text ).to.equal( 'ybar' );
+		} );
 	} );
 
 	describe( 'remove', () => {
-		it( 'should remove part of the nodelist', () => {
+		it( 'should remove part of the node list and return removed nodes as another node list', () => {
 			let nodeList = new NodeList( 'foobar' );
 
-			nodeList.remove( 2, 3 );
+			let removed = nodeList.remove( 2, 3 );
 
 			expect( nodeList.length ).to.equal( 3 );
 			expect( nodeList.get( 0 ).character ).to.equal( 'f' );
 			expect( nodeList.get( 1 ).character ).to.equal( 'o' );
 			expect( nodeList.get( 2 ).character ).to.equal( 'r' );
+
+			expect( removed ).to.be.instanceof( NodeList );
+			expect( removed.length ).to.equal( 3 );
+			expect( removed.get( 0 ).character ).to.equal( 'o' );
+			expect( removed.get( 1 ).character ).to.equal( 'b' );
+			expect( removed.get( 2 ).character ).to.equal( 'a' );
+		} );
+
+		it( 'should merge text objects left in node list possible', () => {
+			let attr = new Attribute( 'foo', 'bar' );
+			let nodeList = new NodeList( [ 'foo', new Text( 'xxx', [ attr ] ), 'bar' ] );
+
+			nodeList.remove( 2, 5 );
+
+			expect( nodeList._nodes.length ).to.equal( 1 );
+			expect( nodeList._nodes[ 0 ].text ).to.equal( 'foar' );
+		} );
+
+		it( 'should return empty node list and do nothing if node list removed from is also empty', () => {
+			let nodeList = new NodeList();
+			let result = nodeList.remove( 2, 3 );
+
+			expect( result.length ).to.equal( 0 );
+		} );
+	} );
+
+	describe( 'indexOf', () => {
+		let nodeList, p;
+
+		beforeEach( () => {
+			p = new Element( 'p' );
+			nodeList = new NodeList( [ 'abc', p, 'def' ] );
+		} );
+
+		it( 'should return index of specified element', () => {
+			let index = nodeList.indexOf( p );
+
+			expect( index ).to.equal( 3 );
+		} );
+
+		it( 'should return index of specified character', () => {
+			let char = nodeList.get( 5 );
+			let index = nodeList.indexOf( char );
+
+			expect( index ).to.equal( 5 );
+		} );
+
+		it( 'should return -1 if specified element is not a part of a node list', () => {
+			expect( nodeList.indexOf( new Element( 'p' ) ) ).to.equal( -1 );
+		} );
+
+		it( 'should return -1 if specified character is not a part of a node list', () => {
+			let div = new Element( 'div', [], 'a' );
+			let char = div.getChild( 0 );
+
+			expect( nodeList.indexOf( char ) ).to.equal( -1 );
 		} );
 	} );
 
@@ -101,4 +219,69 @@ describe( 'NodeList', () => {
 			expect( i ).to.equal( 3 );
 		} );
 	} );
+
+	describe( '_splitNodeAt', () => {
+		it( 'should split text object into two text objects', () => {
+			let nodeList = new NodeList( 'abcd' );
+			nodeList._splitNodeAt( 2 );
+
+			expect( nodeList._nodes.length ).to.equal( 2 );
+			expect( nodeList._nodes[ 0 ].text ).to.equal( 'ab' );
+			expect( nodeList._nodes[ 1 ].text ).to.equal( 'cd' );
+		} );
+
+		it( 'should do nothing if node before and after index are different', () => {
+			let nodeList = new NodeList( [ new Text( 'ab', [ new Attribute( 'foo', true ) ] ), 'cd' ] );
+			nodeList._splitNodeAt( 2 );
+
+			expect( nodeList._nodes.length ).to.equal( 2 );
+			expect( nodeList._nodes[ 0 ].text ).to.equal( 'ab' );
+			expect( nodeList._nodes[ 1 ].text ).to.equal( 'cd' );
+		} );
+	} );
+
+	describe( '_mergeNodeAt', () => {
+		it( 'should merge two text object if they have same attributes', () => {
+			let attr = new Attribute( 'foo', true );
+			let nodeList = new NodeList( [ 'ab', new Text( 'cd', [ attr ] ) ] );
+			nodeList._nodes[ 1 ].attrs.delete( attr.key );
+
+			expect( nodeList._nodes.length ).to.equal( 2 );
+
+			nodeList._mergeNodeAt( 2 );
+
+			expect( nodeList._nodes.length ).to.equal( 1 );
+			expect( nodeList._nodes[ 0 ].text ).to.equal( 'abcd' );
+		} );
+
+		it( 'should do nothing if text objects has different attributes', () => {
+			let nodeList = new NodeList( [ new Text( 'ab', [ new Attribute( 'foo', true ) ] ), 'cd' ] );
+			nodeList._mergeNodeAt( 2 );
+
+			expect( nodeList._nodes.length ).to.equal( 2 );
+			expect( nodeList._nodes[ 0 ].text ).to.equal( 'ab' );
+			expect( nodeList._nodes[ 1 ].text ).to.equal( 'cd' );
+		} );
+	} );
+
+	describe( '_getCharIndex', () => {
+		it( 'should return offset of character at given index from the beginning of the NodeListText containing that character', () => {
+			let nodeList = new NodeList( [ new Text( 'ab', [ new Attribute( 'foo', true ) ] ), 'cd' ] );
+			let charIndexC = nodeList._getCharIndex( 2 );
+			let charIndexD = nodeList._getCharIndex( 3 );
+
+			expect( charIndexC ).to.equal( 0 );
+			expect( charIndexD ).to.equal( 1 );
+		} );
+	} );
+
+	// Additional test for code coverage.
+	describe( 'NodeListText', () => {
+		it( 'should create proper JSON string using toJSON method', () => {
+			let nodeList = new NodeList( 'foo' );
+			let parsed = JSON.parse( JSON.stringify( nodeList ) );
+
+			expect( parsed._nodes[ 0 ].parent ).to.equal( null );
+		} );
+	} );
 } );

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

@@ -7,19 +7,15 @@
 
 'use strict';
 
-import coreTestUtils from '/tests/core/_utils/utils.js';
 import Document from '/ckeditor5/core/treemodel/document.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
 import AttributeOperation from '/ckeditor5/core/treemodel/operation/attributeoperation.js';
 import Position from '/ckeditor5/core/treemodel/position.js';
 import Range from '/ckeditor5/core/treemodel/range.js';
-import Character from '/ckeditor5/core/treemodel/character.js';
 import Attribute from '/ckeditor5/core/treemodel/attribute.js';
 import Text from '/ckeditor5/core/treemodel/text.js';
 import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
 
-const getIteratorCount = coreTestUtils.getIteratorCount;
-
 describe( 'AttributeOperation', () => {
 	let doc, root;
 
@@ -55,9 +51,9 @@ describe( 'AttributeOperation', () => {
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 3 );
-		expect( root.getChild( 0 ).hasAttr( newAttr ) ).to.be.true;
-		expect( root.getChild( 1 ).hasAttr( newAttr ) ).to.be.true;
-		expect( getIteratorCount( root.getChild( 2 ).getAttrs() ) ).to.equal( 0 );
+		expect( root.getChild( 0 ).attrs.has( newAttr ) ).to.be.true;
+		expect( root.getChild( 1 ).attrs.has( newAttr ) ).to.be.true;
+		expect( root.getChild( 2 ).attrs.size ).to.equal( 0 );
 	} );
 
 	it( 'should add attribute to the existing attributes', () => {
@@ -65,7 +61,7 @@ describe( 'AttributeOperation', () => {
 		let fooAttr = new Attribute( 'foo', true );
 		let barAttr = new Attribute( 'bar', true );
 
-		root.insertChildren( 0, new Character( 'x', [ fooAttr, barAttr ] ) );
+		root.insertChildren( 0, new Text( 'x', [ fooAttr, barAttr ] ) );
 
 		doc.applyOperation(
 			new AttributeOperation(
@@ -78,10 +74,10 @@ describe( 'AttributeOperation', () => {
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 1 );
-		expect( getIteratorCount( root.getChild( 0 ).getAttrs() ) ).to.equal( 3 );
-		expect( root.getChild( 0 ).hasAttr( newAttr ) ).to.be.true;
-		expect( root.getChild( 0 ).hasAttr( fooAttr ) ).to.be.true;
-		expect( root.getChild( 0 ).hasAttr( barAttr ) ).to.be.true;
+		expect( root.getChild( 0 ).attrs.size ).to.equal( 3 );
+		expect( root.getChild( 0 ).attrs.has( newAttr ) ).to.be.true;
+		expect( root.getChild( 0 ).attrs.has( fooAttr ) ).to.be.true;
+		expect( root.getChild( 0 ).attrs.has( barAttr ) ).to.be.true;
 	} );
 
 	it( 'should change attribute to the set of nodes', () => {
@@ -101,12 +97,12 @@ describe( 'AttributeOperation', () => {
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 3 );
-		expect( getIteratorCount( root.getChild( 0 ).getAttrs() ) ).to.equal( 1 );
-		expect( root.getChild( 0 ).hasAttr( newAttr ) ).to.be.true;
-		expect( getIteratorCount( root.getChild( 1 ).getAttrs() ) ).to.equal( 1 );
-		expect( root.getChild( 1 ).hasAttr( newAttr ) ).to.be.true;
-		expect( getIteratorCount( root.getChild( 2 ).getAttrs() ) ).to.equal( 1 );
-		expect( root.getChild( 2 ).hasAttr( oldAttr ) ).to.be.true;
+		expect( root.getChild( 0 ).attrs.size ).to.equal( 1 );
+		expect( root.getChild( 0 ).attrs.has( newAttr ) ).to.be.true;
+		expect( root.getChild( 1 ).attrs.size ).to.equal( 1 );
+		expect( root.getChild( 1 ).attrs.has( newAttr ) ).to.be.true;
+		expect( root.getChild( 2 ).attrs.size ).to.equal( 1 );
+		expect( root.getChild( 2 ).attrs.has( oldAttr ) ).to.be.true;
 	} );
 
 	it( 'should change attribute in the middle of existing attributes', () => {
@@ -115,7 +111,7 @@ describe( 'AttributeOperation', () => {
 		let x2Attr = new Attribute( 'x', 2 );
 		let barAttr = new Attribute( 'bar', true );
 
-		root.insertChildren( 0, new Character( 'x', [ fooAttr, x1Attr, barAttr ] ) );
+		root.insertChildren( 0, new Text( 'x', [ fooAttr, x1Attr, barAttr ] ) );
 
 		doc.applyOperation(
 			new AttributeOperation(
@@ -128,10 +124,10 @@ describe( 'AttributeOperation', () => {
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 1 );
-		expect( getIteratorCount( root.getChild( 0 ).getAttrs() ) ).to.equal( 3 );
-		expect( root.getChild( 0 ).hasAttr( fooAttr ) ).to.be.true;
-		expect( root.getChild( 0 ).hasAttr( x2Attr ) ).to.be.true;
-		expect( root.getChild( 0 ).hasAttr( barAttr ) ).to.be.true;
+		expect( root.getChild( 0 ).attrs.size ).to.equal( 3 );
+		expect( root.getChild( 0 ).attrs.has( fooAttr ) ).to.be.true;
+		expect( root.getChild( 0 ).attrs.has( x2Attr ) ).to.be.true;
+		expect( root.getChild( 0 ).attrs.has( barAttr ) ).to.be.true;
 	} );
 
 	it( 'should remove attribute', () => {
@@ -139,7 +135,7 @@ describe( 'AttributeOperation', () => {
 		let xAttr = new Attribute( 'x', true );
 		let barAttr = new Attribute( 'bar', true );
 
-		root.insertChildren( 0, new Character( 'x', [ fooAttr, xAttr, barAttr ] ) );
+		root.insertChildren( 0, new Text( 'x', [ fooAttr, xAttr, barAttr ] ) );
 
 		doc.applyOperation(
 			new AttributeOperation(
@@ -152,9 +148,9 @@ describe( 'AttributeOperation', () => {
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 1 );
-		expect( getIteratorCount( root.getChild( 0 ).getAttrs() ) ).to.equal( 2 );
-		expect( root.getChild( 0 ).hasAttr( fooAttr ) ).to.be.true;
-		expect( root.getChild( 0 ).hasAttr( barAttr ) ).to.be.true;
+		expect( root.getChild( 0 ).attrs.size ).to.equal( 2 );
+		expect( root.getChild( 0 ).attrs.has( fooAttr ) ).to.be.true;
+		expect( root.getChild( 0 ).attrs.has( barAttr ) ).to.be.true;
 	} );
 
 	it( 'should create an AttributeOperation as a reverse', () => {
@@ -190,9 +186,9 @@ describe( 'AttributeOperation', () => {
 
 		expect( doc.version ).to.equal( 2 );
 		expect( root.getChildCount() ).to.equal( 3 );
-		expect( getIteratorCount( root.getChild( 0 ).getAttrs() ) ).to.equal( 0 );
-		expect( getIteratorCount( root.getChild( 1 ).getAttrs() ) ).to.equal( 0 );
-		expect( getIteratorCount( root.getChild( 2 ).getAttrs() ) ).to.equal( 0 );
+		expect( root.getChild( 0 ).attrs.size ).to.equal( 0 );
+		expect( root.getChild( 1 ).attrs.size ).to.equal( 0 );
+		expect( root.getChild( 2 ).attrs.size ).to.equal( 0 );
 	} );
 
 	it( 'should not set attribute of element if change range starts in the middle of that element', () => {
@@ -212,7 +208,7 @@ describe( 'AttributeOperation', () => {
 			)
 		);
 
-		expect( root.getChild( 0 ).hasAttr( fooAttr ) ).to.be.false;
+		expect( root.getChild( 0 ).attrs.has( fooAttr ) ).to.be.false;
 	} );
 
 	it( 'should not remove attribute of element if change range starts in the middle of that element', () => {
@@ -232,7 +228,7 @@ describe( 'AttributeOperation', () => {
 			)
 		);
 
-		expect( root.getChild( 0 ).hasAttr( fooAttr ) ).to.be.true;
+		expect( root.getChild( 0 ).attrs.has( fooAttr ) ).to.be.true;
 	} );
 
 	it( 'should undo changing attribute by applying reverse operation', () => {
@@ -256,12 +252,12 @@ describe( 'AttributeOperation', () => {
 
 		expect( doc.version ).to.equal( 2 );
 		expect( root.getChildCount() ).to.equal( 3 );
-		expect( getIteratorCount( root.getChild( 0 ).getAttrs() ) ).to.equal( 1 );
-		expect( root.getChild( 0 ).hasAttr( oldAttr ) ).to.be.true;
-		expect( getIteratorCount( root.getChild( 1 ).getAttrs() ) ).to.equal( 1 );
-		expect( root.getChild( 1 ).hasAttr( oldAttr ) ).to.be.true;
-		expect( getIteratorCount( root.getChild( 2 ).getAttrs() ) ).to.equal( 1 );
-		expect( root.getChild( 2 ).hasAttr( oldAttr ) ).to.be.true;
+		expect( root.getChild( 0 ).attrs.size ).to.equal( 1 );
+		expect( root.getChild( 0 ).attrs.has( oldAttr ) ).to.be.true;
+		expect( root.getChild( 1 ).attrs.size ).to.equal( 1 );
+		expect( root.getChild( 1 ).attrs.has( oldAttr ) ).to.be.true;
+		expect( root.getChild( 2 ).attrs.size ).to.equal( 1 );
+		expect( root.getChild( 2 ).attrs.has( oldAttr ) ).to.be.true;
 	} );
 
 	it( 'should undo remove attribute by applying reverse operation', () => {
@@ -284,12 +280,12 @@ describe( 'AttributeOperation', () => {
 
 		expect( doc.version ).to.equal( 2 );
 		expect( root.getChildCount() ).to.equal( 3 );
-		expect( getIteratorCount( root.getChild( 0 ).getAttrs() ) ).to.equal( 1 );
-		expect( root.getChild( 0 ).hasAttr( fooAttr ) ).to.be.true;
-		expect( getIteratorCount( root.getChild( 1 ).getAttrs() ) ).to.equal( 1 );
-		expect( root.getChild( 1 ).hasAttr( fooAttr ) ).to.be.true;
-		expect( getIteratorCount( root.getChild( 2 ).getAttrs() ) ).to.equal( 1 );
-		expect( root.getChild( 2 ).hasAttr( fooAttr ) ).to.be.true;
+		expect( root.getChild( 0 ).attrs.size ).to.equal( 1 );
+		expect( root.getChild( 0 ).attrs.has( fooAttr ) ).to.be.true;
+		expect( root.getChild( 1 ).attrs.size ).to.equal( 1 );
+		expect( root.getChild( 1 ).attrs.has( fooAttr ) ).to.be.true;
+		expect( root.getChild( 2 ).attrs.size ).to.equal( 1 );
+		expect( root.getChild( 2 ).attrs.has( fooAttr ) ).to.be.true;
 	} );
 
 	it( 'should throw an error when one try to remove and the attribute does not exists', () => {
@@ -313,7 +309,7 @@ describe( 'AttributeOperation', () => {
 		let x1Attr = new Attribute( 'x', 1 );
 		let x2Attr = new Attribute( 'x', 2 );
 
-		root.insertChildren( 0, new Character( 'x', [ x1Attr ] ) );
+		root.insertChildren( 0, new Text( 'x', [ x1Attr ] ) );
 
 		expect( () => {
 			doc.applyOperation(
@@ -364,4 +360,24 @@ describe( 'AttributeOperation', () => {
 		expect( clone.newAttr.isEqual( newAttr ) ).to.be.true;
 		expect( clone.baseVersion ).to.equal( baseVersion );
 	} );
+
+	it( 'should merge characters in node list', () => {
+		let attrA = new Attribute( 'foo', 'a' );
+		let attrB = new Attribute( 'foo', 'b' );
+
+		root.insertChildren( 0, new Text( 'abc', [ attrA ] ) );
+		root.insertChildren( 3, new Text( 'xyz', [ attrB ] ) );
+
+		doc.applyOperation(
+			new AttributeOperation(
+				new Range( new Position( root, [ 1 ] ), new Position( root, [ 3 ] ) ),
+				attrA,
+				attrB,
+				doc.version
+			)
+		);
+
+		expect( root._children._nodes[ 0 ].text ).to.equal( 'a' );
+		expect( root._children._nodes[ 1 ].text ).to.equal( 'bcxyz' );
+	} );
 } );

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

@@ -13,7 +13,7 @@ import NodeList from '/ckeditor5/core/treemodel/nodelist.js';
 import InsertOperation from '/ckeditor5/core/treemodel/operation/insertoperation.js';
 import RemoveOperation from '/ckeditor5/core/treemodel/operation/removeoperation.js';
 import Position from '/ckeditor5/core/treemodel/position.js';
-import Character from '/ckeditor5/core/treemodel/character.js';
+import Text from '/ckeditor5/core/treemodel/text.js';
 
 describe( 'InsertOperation', () => {
 	let doc, root;
@@ -26,7 +26,7 @@ describe( 'InsertOperation', () => {
 	it( 'should have proper type', () => {
 		const op = new InsertOperation(
 			new Position( root, [ 0 ] ),
-			new Character( 'x' ),
+			new Text( 'x' ),
 			doc.version
 		);
 
@@ -37,7 +37,7 @@ describe( 'InsertOperation', () => {
 		doc.applyOperation(
 			new InsertOperation(
 				new Position( root, [ 0 ] ),
-				new Character( 'x' ),
+				new Text( 'x' ),
 				doc.version
 			)
 		);
@@ -87,7 +87,7 @@ describe( 'InsertOperation', () => {
 		doc.applyOperation(
 			new InsertOperation(
 				new Position( root, [ 0 ] ),
-				[ 'foo', new Character( 'x' ), 'bar' ],
+				[ 'foo', new Text( 'x' ), 'bar' ],
 				doc.version
 			)
 		);
@@ -107,7 +107,7 @@ describe( 'InsertOperation', () => {
 		let position = new Position( root, [ 0 ] );
 		let operation = new InsertOperation(
 			position,
-			[ 'foo', new Character( 'x' ), 'bar' ],
+			[ 'foo', new Text( 'x' ), 'bar' ],
 			0
 		);
 
@@ -122,7 +122,7 @@ describe( 'InsertOperation', () => {
 	it( 'should undo insert node by applying reverse operation', () => {
 		let operation = new InsertOperation(
 			new Position( root, [ 0 ] ),
-			new Character( 'x' ),
+			new Text( 'x' ),
 			doc.version
 		);
 

+ 3 - 7
packages/ckeditor5-engine/tests/treemodel/operation/removeoperation.js

@@ -45,10 +45,6 @@ describe( 'RemoveOperation', () => {
 	it( 'should remove set of nodes and append them to graveyard root', () => {
 		root.insertChildren( 0, 'fozbar' );
 
-		let z = root.getChild( 2 );
-		let b = root.getChild( 3 );
-		let a = root.getChild( 4 );
-
 		doc.applyOperation(
 			new RemoveOperation(
 				new Position( root, [ 2 ] ),
@@ -59,11 +55,11 @@ describe( 'RemoveOperation', () => {
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 4 );
-		expect( root.getChild( 2 ) ).to.equal( a );
+		expect( root.getChild( 2 ).character ).to.equal( 'a' );
 
 		expect( graveyard.getChildCount() ).to.equal( 2 );
-		expect( graveyard.getChild( 0 ) ).to.equal( z );
-		expect( graveyard.getChild( 1 ) ).to.equal( b );
+		expect( graveyard.getChild( 0 ).character ).to.equal( 'z' );
+		expect( graveyard.getChild( 1 ).character ).to.equal( 'b' );
 	} );
 
 	it( 'should create RemoveOperation with same parameters when cloned', () => {

+ 30 - 31
packages/ckeditor5-engine/tests/treemodel/position.js

@@ -9,7 +9,6 @@
 
 import Document from '/ckeditor5/core/treemodel/document.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
-import Character from '/ckeditor5/core/treemodel/character.js';
 import Position from '/ckeditor5/core/treemodel/position.js';
 import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
 
@@ -33,17 +32,17 @@ describe( 'position', () => {
 		root = doc.createRoot( 'root' );
 		otherRoot = doc.createRoot( 'otherRoot' );
 
-		f = new Character( 'f' );
-		o = new Character( 'o' );
-		z = new Character( 'z' );
+		li1 = new Element( 'li', [], 'foz' );
 
-		li1 = new Element( 'li', [], [ f, o, z ] );
+		f = li1.getChild( 0 );
+		o = li1.getChild( 1 );
+		z = li1.getChild( 2 );
 
-		b = new Character( 'b' );
-		a = new Character( 'a' );
-		r = new Character( 'r' );
+		li2 = new Element( 'li', [], 'bar' );
 
-		li2 = new Element( 'li', [], [ b, a, r ] );
+		b = li2.getChild( 0 );
+		a = li2.getChild( 1 );
+		r = li2.getChild( 2 );
 
 		ul = new Element( 'ul', [], [ li1, li2 ] );
 
@@ -196,37 +195,37 @@ describe( 'position', () => {
 	} );
 
 	it( 'should have nodeBefore', () => {
-		expect( new Position( root, [ 0 ] ) ).to.have.property( 'nodeBefore' ).that.is.null;
-		expect( new Position( root, [ 1 ] ) ).to.have.property( 'nodeBefore' ).that.equals( p );
-		expect( new Position( root, [ 2 ] ) ).to.have.property( 'nodeBefore' ).that.equals( ul );
+		expect( new Position( root, [ 0 ] ).nodeBefore ).to.be.null;
+		expect( new Position( root, [ 1 ] ).nodeBefore ).to.equal( p );
+		expect( new Position( root, [ 2 ] ).nodeBefore ).to.equal( ul );
 
-		expect( new Position( root, [ 0, 0 ] ) ).to.have.property( 'nodeBefore' ).that.is.null;
+		expect( new Position( root, [ 0, 0 ] ).nodeBefore ).to.null;
 
-		expect( new Position( root, [ 1, 0 ] ) ).to.have.property( 'nodeBefore' ).that.is.null;
-		expect( new Position( root, [ 1, 1 ] ) ).to.have.property( 'nodeBefore' ).that.equals( li1 );
-		expect( new Position( root, [ 1, 2 ] ) ).to.have.property( 'nodeBefore' ).that.equals( li2 );
+		expect( new Position( root, [ 1, 0 ] ).nodeBefore ).to.be.null;
+		expect( new Position( root, [ 1, 1 ] ).nodeBefore ).to.equal( li1 );
+		expect( new Position( root, [ 1, 2 ] ).nodeBefore ).to.equal( li2 );
 
-		expect( new Position( root, [ 1, 0, 0 ] ) ).to.have.property( 'nodeBefore' ).that.is.null;
-		expect( new Position( root, [ 1, 0, 1 ] ) ).to.have.property( 'nodeBefore' ).that.equals( f );
-		expect( new Position( root, [ 1, 0, 2 ] ) ).to.have.property( 'nodeBefore' ).that.equals( o );
-		expect( new Position( root, [ 1, 0, 3 ] ) ).to.have.property( 'nodeBefore' ).that.equals( z );
+		expect( new Position( root, [ 1, 0, 0 ] ).nodeBefore ).to.be.null;
+		expect( new Position( root, [ 1, 0, 1 ] ).nodeBefore.character ).to.equal( 'f' );
+		expect( new Position( root, [ 1, 0, 2 ] ).nodeBefore.character ).to.equal( 'o' );
+		expect( new Position( root, [ 1, 0, 3 ] ).nodeBefore.character ).to.equal( 'z' );
 	} );
 
 	it( 'should have nodeAfter', () => {
-		expect( new Position( root, [ 0 ] ) ).to.have.property( 'nodeAfter' ).that.equals( p );
-		expect( new Position( root, [ 1 ] ) ).to.have.property( 'nodeAfter' ).that.equals( ul );
-		expect( new Position( root, [ 2 ] ) ).to.have.property( 'nodeAfter' ).that.is.null;
+		expect( new Position( root, [ 0 ] ).nodeAfter ).to.equal( p );
+		expect( new Position( root, [ 1 ] ).nodeAfter ).to.equal( ul );
+		expect( new Position( root, [ 2 ] ).nodeAfter ).to.be.null;
 
-		expect( new Position( root, [ 0, 0 ] ) ).to.have.property( 'nodeAfter' ).that.is.null;
+		expect( new Position( root, [ 0, 0 ] ).nodeAfter ).to.be.null;
 
-		expect( new Position( root, [ 1, 0 ] ) ).to.have.property( 'nodeAfter' ).that.equals( li1 );
-		expect( new Position( root, [ 1, 1 ] ) ).to.have.property( 'nodeAfter' ).that.equals( li2 );
-		expect( new Position( root, [ 1, 2 ] ) ).to.have.property( 'nodeAfter' ).that.is.null;
+		expect( new Position( root, [ 1, 0 ] ).nodeAfter ).to.equal( li1 );
+		expect( new Position( root, [ 1, 1 ] ).nodeAfter ).to.equal( li2 );
+		expect( new Position( root, [ 1, 2 ] ).nodeAfter ).to.be.null;
 
-		expect( new Position( root, [ 1, 0, 0 ] ) ).to.have.property( 'nodeAfter' ).that.equals( f );
-		expect( new Position( root, [ 1, 0, 1 ] ) ).to.have.property( 'nodeAfter' ).that.equals( o );
-		expect( new Position( root, [ 1, 0, 2 ] ) ).to.have.property( 'nodeAfter' ).that.equals( z );
-		expect( new Position( root, [ 1, 0, 3 ] ) ).to.have.property( 'nodeAfter' ).that.is.null;
+		expect( new Position( root, [ 1, 0, 0 ] ).nodeAfter.character ).to.equal( 'f' );
+		expect( new Position( root, [ 1, 0, 1 ] ).nodeAfter.character ).to.equal( 'o' );
+		expect( new Position( root, [ 1, 0, 2 ] ).nodeAfter.character ).to.equal( 'z' );
+		expect( new Position( root, [ 1, 0, 3 ] ).nodeAfter ).to.be.null;
 	} );
 
 	it( 'should have proper parent path', () => {

+ 0 - 127
packages/ckeditor5-engine/tests/treemodel/positioniterator.js

@@ -1,127 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/* bender-tags: treemodel */
-
-'use strict';
-
-import Document from '/ckeditor5/core/treemodel/document.js';
-import Element from '/ckeditor5/core/treemodel/element.js';
-import Character from '/ckeditor5/core/treemodel/character.js';
-import PositionIterator from '/ckeditor5/core/treemodel/positioniterator.js';
-import Position from '/ckeditor5/core/treemodel/position.js';
-import Range from '/ckeditor5/core/treemodel/range.js';
-
-describe( 'range iterator', () => {
-	let ELEMENT_ENTER, ELEMENT_LEAVE, CHARACTER;
-
-	let doc, expectedItems, root, img1, paragraph, b, a, r, img2, x;
-
-	before( () => {
-		ELEMENT_ENTER = PositionIterator.ELEMENT_ENTER;
-		ELEMENT_LEAVE = PositionIterator.ELEMENT_LEAVE;
-		CHARACTER = PositionIterator.CHARACTER;
-
-		doc = new Document();
-		root = doc.createRoot( 'root' );
-
-		// root
-		//  |- img1
-		//  |- p
-		//     |- B
-		//     |- A
-		//     |- R
-		//     |
-		//     |- img2
-		//     |
-		//     |- X
-
-		b = new Character( 'b' );
-		a = new Character( 'a' );
-		r = new Character( 'r' );
-		img2 = new Element( 'img2' );
-		x = new Character( 'x' );
-
-		paragraph = new Element( 'p', [], [ b, a, r, img2, x ] );
-		img1 = new Element( 'img1' );
-
-		root.insertChildren( 0, [ img1, paragraph ] );
-
-		expectedItems = [
-			{ type: ELEMENT_ENTER, node: img1 },
-			{ type: ELEMENT_LEAVE, node: img1 },
-			{ type: ELEMENT_ENTER, node: paragraph },
-			{ type: CHARACTER, node: b },
-			{ type: CHARACTER, node: a },
-			{ type: CHARACTER, node: r },
-			{ type: ELEMENT_ENTER, node: img2 },
-			{ type: ELEMENT_LEAVE, node: img2 },
-			{ type: CHARACTER, node: x },
-			{ type: ELEMENT_LEAVE, node: paragraph }
-		];
-	} );
-
-	it( 'should return next position', () => {
-		let iterator = new PositionIterator( new Position( root, [ 0 ] ) ); // beginning of root
-		let i, len;
-
-		for ( i = 0, len = expectedItems.length; i < len; i++ ) {
-			expect( iterator.next() ).to.deep.equal( { done: false, value: expectedItems[ i ] } );
-		}
-		expect( iterator.next() ).to.have.property( 'done' ).that.is.true;
-	} );
-
-	it( 'should return previous position', () => {
-		let iterator = new PositionIterator( new Position( root, [ 2 ] ) ); // ending of root
-
-		for ( let i = expectedItems.length - 1; i >= 0; i-- ) {
-			expect( iterator.previous() ).to.deep.equal( { done: false, value: expectedItems[ i ] } );
-		}
-		expect( iterator.previous() ).to.have.property( 'done' ).that.is.true;
-	} );
-
-	it( 'should return next position in the boundaries', () => {
-		let start = new Position( root, [ 1, 0 ] ); // p, 0
-		let end = new Position( root, [ 1, 3, 0 ] ); // img, 0
-
-		let iterator = new PositionIterator( new Range( start, end ) );
-
-		let i, len;
-
-		for ( i = 3, len = expectedItems.length; i < 7; i++ ) {
-			expect( iterator.next() ).to.deep.equal( { done: false, value: expectedItems[ i ] } );
-		}
-		expect( iterator.next() ).to.have.property( 'done' ).that.is.true;
-	} );
-
-	it( 'should return previous position in the boundaries', () => {
-		let start = new Position( root, [ 1, 0 ] ); // p, 0
-		let end = new Position( root, [ 1, 3, 0 ] ); // img, 0
-
-		let iterator = new PositionIterator( new Range( start, end ), end );
-
-		let i, len;
-
-		for ( i = 6, len = expectedItems.length; i > 2; i-- ) {
-			expect( iterator.previous() ).to.deep.equal( { done: false, value: expectedItems[ i ] } );
-		}
-		expect( iterator.previous() ).to.have.property( 'done' ).that.is.true;
-	} );
-
-	it( 'should return iterate over the range', () => {
-		let start = new Position( root, [ 0 ] ); // begging of root
-		let end = new Position( root, [ 2 ] ); // ending of root
-		let range = new Range( start, end );
-
-		let i = 0;
-		let value;
-
-		for ( value of range ) {
-			expect( value ).to.deep.equal( expectedItems[ i ] );
-			i++;
-		}
-		expect( i ).to.equal( expectedItems.length );
-	} );
-} );

+ 79 - 18
packages/ckeditor5-engine/tests/treemodel/range.js

@@ -10,7 +10,7 @@
 import Range from '/ckeditor5/core/treemodel/range.js';
 import Position from '/ckeditor5/core/treemodel/position.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
-import Character from '/ckeditor5/core/treemodel/character.js';
+import Text from '/ckeditor5/core/treemodel/text.js';
 import Document from '/ckeditor5/core/treemodel/document.js';
 
 describe( 'Range', () => {
@@ -137,9 +137,9 @@ describe( 'Range', () => {
 		//     |- o
 		//     |- z
 		before( () => {
-			f = new Character( 'f' );
-			o = new Character( 'o' );
-			z = new Character( 'z' );
+			f = new Text( 'f' );
+			o = new Text( 'o' );
+			z = new Text( 'z' );
 
 			p = new Element( 'p', [], [ f, o, z ] );
 
@@ -188,12 +188,10 @@ describe( 'Range', () => {
 
 	describe( 'getAllNodes', () => {
 		it( 'should iterate over all nodes which "starts" in the range', () => {
-			let nodes = [];
-
-			const a = new Character( 'a' );
-			const b = new Character( 'b' );
-			const x = new Character( 'x' );
-			const y = new Character( 'y' );
+			const a = new Text( 'a' );
+			const b = new Text( 'b' );
+			const x = new Text( 'x' );
+			const y = new Text( 'y' );
 
 			const e1 = new Element( 'e1' );
 			const e2 = new Element( 'e2' );
@@ -207,11 +205,22 @@ describe( 'Range', () => {
 				new Position( root, [ 1, 1 ] )
 			);
 
-			for ( let node of range.getAllNodes() ) {
-				nodes.push( node );
-			}
+			let nodes = Array.from( range.getAllNodes() );
+
+			expect( nodes.length ).to.equal( 3 );
+			expect( nodes[ 0 ].character ).to.equal( 'b' );
+			expect( nodes[ 1 ] ).to.equal( e2 );
+			expect( nodes[ 2 ].character ).to.equal( 'x' );
+		} );
+
+		it( 'should merge characters with same attributes', () => {
+			prepareRichRoot( root );
+
+			let range = new Range( new Position( root, [ 0, 0, 3 ] ), new Position( root, [ 3, 0, 2 ] ) );
+			let nodes = Array.from( range.getAllNodes( true ) );
+			let nodeNames = mapNodesToNames( nodes );
 
-			expect( nodes ).to.deep.equal( [ b, e2, x ] );
+			expect( nodeNames ).to.deep.equal( [ 'T:st', 'E:p', 'T:lorem ipsum', 'E:p', 'T:foo', 'E:p', 'T:bar', 'E:div', 'E:h', 'T:se' ] );
 		} );
 	} );
 
@@ -429,11 +438,57 @@ describe( 'Range', () => {
 		it( 'should iterate over all top-level nodes of this range', () => {
 			let range = new Range( new Position( root, [ 0, 0, 3 ] ), new Position( root, [ 3, 0, 2 ] ) );
 			let nodes = Array.from( range.getTopLevelNodes() );
-			let nodeNames = nodes.map( ( node ) => {
-				return ( node instanceof Element ) ? 'E:' + node.name : 'C:' + node.character;
-			} );
+			let nodeNames = mapNodesToNames( nodes );
+
+			expect( nodeNames ).to.deep.equal( [ 'T:s', 'T:t', 'E:p', 'E:p', 'E:p', 'T:s', 'T:e' ] );
+		} );
+
+		it( 'should merge characters with same attributes', () => {
+			let range = new Range( new Position( root, [ 0, 0, 3 ] ), new Position( root, [ 3, 0, 2 ] ) );
+			let nodes = Array.from( range.getTopLevelNodes( true ) );
+			let nodeNames = mapNodesToNames( nodes );
 
-			expect( nodeNames ).to.deep.equal( [ 'C:s', 'C:t', 'E:p', 'E:p', 'E:p', 'C:s', 'C:e' ] );
+			expect( nodeNames ).to.deep.equal( [ 'T:st', 'E:p', 'E:p', 'E:p', 'T:se' ] );
+		} );
+	} );
+
+	describe( 'getPositions', () => {
+		beforeEach( () => {
+			prepareRichRoot( root );
+		} );
+
+		it( 'should iterate over all positions in this range', () => {
+			let expectedPaths = [
+				[ 1, 2 ], [ 1, 3 ],
+				[ 2 ], [ 2, 0 ], [ 2, 1 ], [ 2, 2 ], [ 2, 3 ],
+				[ 3 ], [ 3, 0 ], [ 3, 0, 0 ], [ 3, 0, 1 ], [ 3, 0, 2 ]
+			];
+			let range = new Range( new Position( root, [ 1, 2 ] ), new Position( root, [ 3, 0, 2 ] ) );
+			let i = 0;
+
+			for ( let position of range.getPositions() ) {
+				expect( position.path ).to.deep.equal( expectedPaths[ i ] );
+				i++;
+			}
+
+			expect( i ).to.equal( expectedPaths.length );
+		} );
+
+		it( 'should merge characters with same attributes', () => {
+			let expectedPaths = [
+				[ 1, 2 ], [ 1, 3 ],
+				[ 2 ], [ 2, 0 ], [ 2, 3 ],
+				[ 3 ], [ 3, 0 ], [ 3, 0, 0 ], [ 3, 0, 2 ]
+			];
+			let range = new Range( new Position( root, [ 1, 2 ] ), new Position( root, [ 3, 0, 2 ] ) );
+			let i = 0;
+
+			for ( let position of range.getPositions( true ) ) {
+				expect( position.path ).to.deep.equal( expectedPaths[ i ] );
+				i++;
+			}
+
+			expect( i ).to.equal( expectedPaths.length );
 		} );
 	} );
 
@@ -453,6 +508,12 @@ describe( 'Range', () => {
 		} );
 	} );
 
+	function mapNodesToNames( nodes ) {
+		return nodes.map( ( node ) => {
+			return ( node instanceof Element ) ? 'E:' + node.name : 'T:' + ( node.text || node.character );
+		} );
+	}
+
 	function prepareRichRoot() {
 		root.insertChildren( 0, [
 			new Element( 'div', [], [

+ 1 - 4
packages/ckeditor5-engine/tests/treemodel/rootelement.js

@@ -7,13 +7,10 @@
 
 'use strict';
 
-import coreTestUtils from '/tests/core/_utils/utils.js';
 import Document from '/ckeditor5/core/treemodel/document.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
 import RootElement from '/ckeditor5/core/treemodel/rootelement.js';
 
-const getIteratorCount = coreTestUtils.getIteratorCount;
-
 describe( 'Element', () => {
 	describe( 'constructor', () => {
 		it( 'should create root element without attributes', () => {
@@ -22,7 +19,7 @@ describe( 'Element', () => {
 
 			expect( root ).to.be.an.instanceof( Element );
 			expect( root ).to.have.property( 'document' ).that.equals( doc );
-			expect( getIteratorCount( root.getAttrs() ) ).to.equal( 0 );
+			expect( root.attrs.size ).to.equal( 0 );
 			expect( root.getChildCount() ).to.equal( 0 );
 		} );
 	} );

+ 4 - 103
packages/ckeditor5-engine/tests/treemodel/selection.js

@@ -7,9 +7,9 @@
 
 'use strict';
 
-import coreTestUtils from '/tests/core/_utils/utils.js';
 import Document from '/ckeditor5/core/treemodel/document.js';
 import Attribute from '/ckeditor5/core/treemodel/attribute.js';
+import AttributeList from '/ckeditor5/core/treemodel/attributelist.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
 import Range from '/ckeditor5/core/treemodel/range.js';
 import Position from '/ckeditor5/core/treemodel/position.js';
@@ -19,8 +19,6 @@ import InsertOperation from '/ckeditor5/core/treemodel/operation/insertoperation
 import MoveOperation from '/ckeditor5/core/treemodel/operation/moveoperation.js';
 import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
 
-const getIteratorCount = coreTestUtils.getIteratorCount;
-
 describe( 'Selection', () => {
 	let attrFooBar;
 
@@ -44,12 +42,14 @@ describe( 'Selection', () => {
 		liveRange.detach();
 	} );
 
-	it( 'should not have any range, anchor or focus position when just created', () => {
+	it( 'should not have any range, attributes, 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;
+		expect( selection.attrs ).to.be.instanceof( AttributeList );
+		expect( selection.attrs.size ).to.equal( 0 );
 	} );
 
 	it( 'should be collapsed if it has no ranges or all ranges are collapsed', () => {
@@ -411,103 +411,4 @@ describe( 'Selection', () => {
 			} );
 		} );
 	} );
-
-	// 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() ) );
-		} );
-	} );
 } );

+ 11 - 2
packages/ckeditor5-engine/tests/treemodel/text.js

@@ -9,6 +9,7 @@
 
 import Text from '/ckeditor5/core/treemodel/text.js';
 import Attribute from '/ckeditor5/core/treemodel/attribute.js';
+import AttributeList from '/ckeditor5/core/treemodel/attributelist.js';
 
 describe( 'Text', () => {
 	describe( 'constructor', () => {
@@ -17,8 +18,16 @@ describe( 'Text', () => {
 			let text = new Text( 'bar', attrs );
 
 			expect( text ).to.have.property( 'text' ).that.equals( 'bar' );
-			expect( text ).to.have.property( 'attrs' ).that.is.an( 'array' );
-			expect( text.attrs ).to.be.deep.equals( attrs );
+			expect( text ).to.have.property( 'attrs' ).that.is.instanceof( AttributeList );
+			expect( Array.from( text.attrs ) ).to.deep.equal( attrs );
+		} );
+
+		it( 'should create empty text object', () => {
+			let empty1 = new Text();
+			let empty2 = new Text( '' );
+
+			expect( empty1.text ).to.equal( '' );
+			expect( empty2.text ).to.equal( '' );
 		} );
 	} );
 } );

+ 62 - 0
packages/ckeditor5-engine/tests/treemodel/textfragment.js

@@ -0,0 +1,62 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel */
+
+'use strict';
+
+import Element from '/ckeditor5/core/treemodel/element.js';
+import Text from '/ckeditor5/core/treemodel/text.js';
+import Attribute from '/ckeditor5/core/treemodel/attribute.js';
+import TextFragment from '/ckeditor5/core/treemodel/textfragment.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Document from '/ckeditor5/core/treemodel/document.js';
+
+describe( 'TextFragment', () => {
+	let doc, text, element, textFragment, root;
+
+	before( () => {
+		text = new Text( 'foobar', [ new Attribute( 'abc', 'xyz' ) ] );
+		element = new Element( 'div', [], [ text ] );
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+		root.insertChildren( 0, element );
+	} );
+
+	beforeEach( () => {
+		textFragment = new TextFragment( new Position( root, [ 0, 2 ] ), 'oba' );
+	} );
+
+	it( 'should have first property pointing to the first character node contained in TextFragment', () => {
+		let char = textFragment.first;
+
+		expect( char.getPath() ).to.deep.equal( [ 0, 2 ] );
+		expect( char.character ).to.equal( 'o' );
+	} );
+
+	it( 'should have last property pointing to the last character node contained in TextFragment', () => {
+		let char = textFragment.last;
+
+		expect( char.getPath() ).to.deep.equal( [ 0, 4 ] );
+		expect( char.character ).to.equal( 'a' );
+	} );
+
+	it( 'should have correct attributes property', () => {
+		expect( textFragment.attrs.size ).to.equal( 1 );
+		expect( textFragment.attrs.getValue( 'abc' ) ).to.equal( 'xyz' );
+	} );
+
+	it( 'should have text property', () => {
+		expect( textFragment ).to.have.property( 'text' ).that.equals( 'oba' );
+	} );
+
+	it( 'getRange should return range containing all characters from TextFragment', () => {
+		let range = textFragment.getRange();
+
+		expect( range.root ).to.equal( root );
+		expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
+		expect( range.end.path ).to.deep.equal( [ 0, 5 ] );
+	} );
+} );

+ 213 - 0
packages/ckeditor5-engine/tests/treemodel/treewalker.js

@@ -0,0 +1,213 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel */
+
+'use strict';
+
+import Document from '/ckeditor5/core/treemodel/document.js';
+import Attribute from '/ckeditor5/core/treemodel/attribute.js';
+import Element from '/ckeditor5/core/treemodel/element.js';
+import Text from '/ckeditor5/core/treemodel/text.js';
+import TreeWalker from '/ckeditor5/core/treemodel/treewalker.js';
+import Position from '/ckeditor5/core/treemodel/position.js';
+import Range from '/ckeditor5/core/treemodel/range.js';
+import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
+
+describe( 'range iterator', () => {
+	let ELEMENT_START, ELEMENT_END, CHARACTER, TEXT;
+
+	let doc, expectedItems, expectedItemsMerged, root, img1, paragraph, b, a, r, img2, x;
+
+	before( () => {
+		ELEMENT_START = TreeWalker.ELEMENT_START;
+		ELEMENT_END = TreeWalker.ELEMENT_END;
+		TEXT = TreeWalker.TEXT;
+		CHARACTER = TreeWalker.CHARACTER;
+
+		doc = new Document();
+		root = doc.createRoot( 'root' );
+
+		// root
+		//  |- img1
+		//  |- p
+		//     |- B
+		//     |- A
+		//     |- R
+		//     |
+		//     |- img2
+		//     |
+		//     |- X
+
+		let attrBoldTrue = new Attribute( 'bold', true );
+
+		b = new Text( 'b', [ attrBoldTrue ] );
+		a = new Text( 'a', [ attrBoldTrue ] );
+		r = new Text( 'r' );
+		img2 = new Element( 'img2' );
+		x = new Text( 'x' );
+
+		paragraph = new Element( 'p', [], [ b, a, r, img2, x ] );
+		img1 = new Element( 'img1' );
+
+		root.insertChildren( 0, [ img1, paragraph ] );
+
+		expectedItems = [
+			{ type: ELEMENT_START, item: img1 },
+			{ type: ELEMENT_END, item: img1 },
+			{ type: ELEMENT_START, item: paragraph },
+			{ type: CHARACTER, text: 'b', attrs: [ attrBoldTrue ] },
+			{ type: CHARACTER, text: 'a', attrs: [ attrBoldTrue ] },
+			{ type: CHARACTER, text: 'r', attrs: [] },
+			{ type: ELEMENT_START, item: img2 },
+			{ type: ELEMENT_END, item: img2 },
+			{ type: CHARACTER, text: 'x', attrs: [] },
+			{ type: ELEMENT_END, item: paragraph }
+		];
+
+		expectedItemsMerged = [
+			{ type: ELEMENT_START, item: img1 },
+			{ type: ELEMENT_END, item: img1 },
+			{ type: ELEMENT_START, item: paragraph },
+			{ type: TEXT, text: 'ba', attrs: [ attrBoldTrue ] },
+			{ type: TEXT, text: 'r', attrs: [] },
+			{ type: ELEMENT_START, item: img2 },
+			{ type: ELEMENT_END, item: img2 },
+			{ type: TEXT, text: 'x', attrs: [] },
+			{ type: ELEMENT_END, item: paragraph }
+		];
+	} );
+
+	function expectItem( item, expected ) {
+		expect( item.done ).to.be.false;
+
+		if ( item.value.type == TEXT || item.value.type == CHARACTER ) {
+			let text = item.value.item.text || item.value.item.character;
+
+			expect( text ).to.equal( expected.text );
+			expect( Array.from( item.value.item.attrs ) ).to.deep.equal( expected.attrs );
+		} else {
+			expect( item.value ).to.deep.equal( expected );
+		}
+	}
+
+	it( 'should return next position', () => {
+		let iterator = new TreeWalker( { position: new Position( root, [ 0 ] ) } ); // beginning of root
+		let i, len;
+
+		for ( i = 0, len = expectedItems.length; i < len; i++ ) {
+			expectItem( iterator.next(), expectedItems[ i ] );
+		}
+		expect( iterator.next() ).to.have.property( 'done' ).that.is.true;
+	} );
+
+	it( 'should return previous position', () => {
+		let iterator = new TreeWalker( { position: new Position( root, [ 2 ] ) } ); // ending of root
+
+		for ( let i = expectedItems.length - 1; i >= 0; i-- ) {
+			expectItem( iterator.previous(), expectedItems[ i ] );
+		}
+		expect( iterator.previous() ).to.have.property( 'done' ).that.is.true;
+	} );
+
+	it( 'should return next position in the boundaries', () => {
+		let start = new Position( root, [ 1, 0 ] ); // p, 0
+		let end = new Position( root, [ 1, 3, 0 ] ); // img, 0
+
+		let iterator = new TreeWalker( { boundaries: new Range( start, end ) } );
+
+		let i, len;
+
+		for ( i = 3, len = expectedItems.length; i < 7; i++ ) {
+			expectItem( iterator.next(), expectedItems[ i ] );
+		}
+		expect( iterator.next() ).to.have.property( 'done' ).that.is.true;
+	} );
+
+	it( 'should return previous position in the boundaries', () => {
+		let start = new Position( root, [ 1, 0 ] ); // p, 0
+		let end = new Position( root, [ 1, 3, 0 ] ); // img, 0
+
+		let iterator = new TreeWalker( { boundaries: new Range( start, end ), position: end } );
+
+		let i, len;
+
+		for ( i = 6, len = expectedItems.length; i > 2; i-- ) {
+			expectItem( iterator.previous(), expectedItems[ i ] );
+		}
+		expect( iterator.previous() ).to.have.property( 'done' ).that.is.true;
+	} );
+
+	it( 'should merge characters when iterating over the range using next', () => {
+		let start = new Position( root, [ 1 ] );
+		let end = new Position( root, [ 1, 4 ] );
+		let range = new Range( start, end );
+
+		let iterator = new TreeWalker( { boundaries: range, position: range.start, mergeCharacters: true } );
+		let i;
+
+		for ( i = 2; i <= 6; i++ ) {
+			expectItem( iterator.next(), expectedItemsMerged[ i ] );
+		}
+		expect( iterator.next() ).to.have.property( 'done' ).that.is.true;
+	} );
+
+	it( 'should merge characters when iterating over the range using previous', () => {
+		let start = new Position( root, [ 1 ] );
+		let end = new Position( root, [ 1, 4 ] );
+		let range = new Range( start, end );
+
+		let iterator = new TreeWalker( { boundaries: range, position: range.end, mergeCharacters: true } );
+
+		for ( let i = 6; i >= 2; i-- ) {
+			expectItem( iterator.previous(), expectedItemsMerged[ i ] );
+		}
+		expect( iterator.previous() ).to.have.property( 'done' ).that.is.true;
+	} );
+
+	it( 'should respect boundaries when iterating using next and merging characters', () => {
+		let start = new Position( root, [ 1, 0 ] );
+		let end = new Position( root, [ 1, 1 ] );
+		let range = new Range( start, end );
+
+		let iterator = new TreeWalker( { boundaries: range, position: range.start, mergeCharacters: true } );
+		let val = iterator.next();
+
+		expect( val.done ).to.be.false;
+		expect( val.value.item.text ).to.equal( 'b' );
+
+		val = iterator.next();
+		expect( val.done ).to.be.true;
+	} );
+
+	it( 'should respect boundaries when iterating using previous and merging characters', () => {
+		let start = new Position( root, [ 1, 1 ] );
+		let end = new Position( root, [ 1, 2 ] );
+		let range = new Range( start, end );
+
+		let iterator = new TreeWalker( { boundaries: range, position: range.end, mergeCharacters: true } );
+		let val = iterator.previous();
+
+		expect( val.done ).to.be.false;
+		expect( val.value.item.text ).to.equal( 'a' );
+
+		val = iterator.previous();
+		expect( val.done ).to.be.true;
+	} );
+
+	it( 'should throw if neither boundaries nor starting position is set', () => {
+		expect( () => {
+			new TreeWalker();
+		} ).to.throw( CKEditorError, /^tree-walker-no-start-position/ );
+
+		expect( () => {
+			new TreeWalker( {} );
+		} ).to.throw( CKEditorError, /^tree-walker-no-start-position/ );
+
+		expect( () => {
+			new TreeWalker( { mergeCharacters: true } );
+		} ).to.throw( CKEditorError, /^tree-walker-no-start-position/ );
+	} );
+} );