Переглянути джерело

Merge pull request #161 from ckeditor/t/63-b

T/63 b Attributes API
Piotr Jasiun 10 роки тому
батько
коміт
c54882d550
37 змінених файлів з 995 додано та 974 видалено
  1. 0 80
      packages/ckeditor5-engine/src/treemodel/attribute.js
  2. 0 155
      packages/ckeditor5-engine/src/treemodel/attributelist.js
  3. 1 1
      packages/ckeditor5-engine/src/treemodel/characterproxy.js
  4. 5 15
      packages/ckeditor5-engine/src/treemodel/delta/attributedelta.js
  5. 1 1
      packages/ckeditor5-engine/src/treemodel/delta/splitdelta.js
  6. 1 2
      packages/ckeditor5-engine/src/treemodel/delta/weakinsertdelta.js
  7. 5 4
      packages/ckeditor5-engine/src/treemodel/document.js
  8. 38 3
      packages/ckeditor5-engine/src/treemodel/element.js
  9. 35 6
      packages/ckeditor5-engine/src/treemodel/node.js
  10. 61 9
      packages/ckeditor5-engine/src/treemodel/nodelist.js
  11. 57 86
      packages/ckeditor5-engine/src/treemodel/operation/attributeoperation.js
  12. 8 43
      packages/ckeditor5-engine/src/treemodel/operation/transform.js
  13. 3 1
      packages/ckeditor5-engine/src/treemodel/range.js
  14. 68 4
      packages/ckeditor5-engine/src/treemodel/selection.js
  15. 7 8
      packages/ckeditor5-engine/src/treemodel/text.js
  16. 47 24
      packages/ckeditor5-engine/src/treemodel/textfragment.js
  17. 2 5
      packages/ckeditor5-engine/src/treemodel/treewalker.js
  18. 61 0
      packages/ckeditor5-engine/src/utils.js
  19. 0 28
      packages/ckeditor5-engine/tests/treemodel/attribute.js
  20. 0 190
      packages/ckeditor5-engine/tests/treemodel/attributelist.js
  21. 3 3
      packages/ckeditor5-engine/tests/treemodel/characterproxy.js
  22. 15 16
      packages/ckeditor5-engine/tests/treemodel/delta/attributedelta.js
  23. 4 5
      packages/ckeditor5-engine/tests/treemodel/delta/mergedelta.js
  24. 9 10
      packages/ckeditor5-engine/tests/treemodel/delta/splitdelta.js
  25. 5 9
      packages/ckeditor5-engine/tests/treemodel/delta/weakinsertdelta.js
  26. 18 13
      packages/ckeditor5-engine/tests/treemodel/document/change-event.js
  27. 58 5
      packages/ckeditor5-engine/tests/treemodel/element.js
  28. 46 11
      packages/ckeditor5-engine/tests/treemodel/node.js
  29. 76 20
      packages/ckeditor5-engine/tests/treemodel/nodelist.js
  30. 91 132
      packages/ckeditor5-engine/tests/treemodel/operation/attributeoperation.js
  31. 65 49
      packages/ckeditor5-engine/tests/treemodel/operation/transform.js
  32. 1 1
      packages/ckeditor5-engine/tests/treemodel/rootelement.js
  33. 88 5
      packages/ckeditor5-engine/tests/treemodel/selection.js
  34. 3 6
      packages/ckeditor5-engine/tests/treemodel/text.js
  35. 60 15
      packages/ckeditor5-engine/tests/treemodel/textfragment.js
  36. 7 9
      packages/ckeditor5-engine/tests/treemodel/treewalker.js
  37. 46 0
      packages/ckeditor5-engine/tests/utils.js

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

@@ -1,80 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-'use strict';
-
-import langUtils from '../lib/lodash/lang.js';
-
-/**
- * Attributes can store any additional information for nodes in the data model.
- *
- * @class treeModel.Attribute
- */
-export default class Attribute {
-	/**
-	 * Creates a new instance of the `Attribute` class. Once attribute is created it is immutable.
-	 *
-	 * @param {String} key Attribute key.
-	 * @param {Mixed} value Attribute value.
-	 * @constructor
-	 */
-	constructor( key, value ) {
-		/**
-		 * Attribute key.
-		 *
-		 * @readonly
-		 * @property {String} key
-		 */
-		this.key = key;
-
-		/**
-		 * Attribute value. Note that value may be any type, including objects.
-		 *
-		 * @readonly
-		 * @property {Mixed} value
-		 */
-		this.value = value;
-
-		/**
-		 * Attribute hash. Used to compare attributes. Two attributes with the same key and value will have the same hash.
-		 *
-		 * @readonly
-		 * @private
-		 * @property {String} _hash
-		 */
-		this._hash = this.key + ': ' + JSON.stringify( this.value, sort );
-
-		// 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 ) ) {
-				const sorted = {};
-
-				// Sort keys and fill up the sorted object.
-				Object.keys( value ).sort().forEach( ( key ) => {
-					sorted[ key ] = value[ key ];
-				} );
-
-				return sorted;
-			} else {
-				return value;
-			}
-		}
-	}
-
-	/**
-	 * Compares two attributes. Returns `true` if two attributes have the same key and value even if the order of keys
-	 * in the value object is different.
-	 *
-	 *		let attr1 = new Attribute( 'foo', { a: 1, b: 2 } );
-	 *		let attr2 = new Attribute( 'foo', { b: 2, a: 1 } );
-	 *		attr1.isEqual( attr2 ); // true
-	 *
-	 * @param {treeModel.Attribute} otherAttr Attribute to compare with.
-	 * @returns {Boolean} True if attributes are equal to each other.
-	 */
-	isEqual( otherAttr ) {
-		return this._hash === otherAttr._hash;
-	}
-}

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

@@ -1,155 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-'use strict';
-
-import Attribute from './attribute.js';
-
-/**
- * List of attributes.
- *
- * @class treeModel.AttributeList
- */
-export default class AttributeList extends Map {
-	/**
-	 * Creates AttributeList. If parameter is passed, initializes created list with passed {@link treeModel.Attribute}s.
-	 *
-	 * @constructor
-	 * @param {Iterable.<treeModel.Attribute>} attrs Attributes to initialize with.
-	 */
-	constructor( attrs ) {
-		super();
-
-		if ( attrs ) {
-			this.setTo( attrs );
-		}
-
-		/**
-		 * Amount of attributes added to the AttributeList.
-		 *
-		 * @property {Number} size
-		 */
-	}
-
-	/**
-	 * AttributeList iterator. Iterates over all attributes from the list.
-	 */
-	[ Symbol.iterator ]() {
-		let it = super[ Symbol.iterator ]();
-
-		return {
-			next: () => {
-				let step = it.next();
-
-				return {
-					value: step.value ? step.value[ 1 ] : undefined,
-					done: step.done
-				};
-			}
-		};
-	}
-
-	/**
-	 * Adds attribute to the attributes list. If attribute with the same key already is set, it overwrites its values.
-	 *
-	 * @chainable
-	 * @param {treeModel.Attribute} attr Attribute to add or overwrite.
-	 * @returns {treeModel.AttributeList} This AttributeList object.
-	 */
-	set( attr ) {
-		super.set( attr.key, attr );
-
-		return this;
-	}
-
-	/**
-	 * Removes all attributes from AttributeList and adds given attributes.
-	 *
-	 * @param {Iterable.<Attribute>} attrs Iterable object containing attributes to be set.
-	 */
-	setTo( attrs ) {
-		this.clear();
-
-		for ( let value of attrs ) {
-			this.set( value );
-		}
-	}
-
-	/**
-	 * 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 Attribute or key of attribute to check.
-	 * @returns {Boolean} `true` if given attribute or attribute with given key exists in AttributeList. `false` otherwise.
-	 */
-	has( attrOrKey ) {
-		if ( attrOrKey instanceof Attribute ) {
-			let attr = this.get( attrOrKey.key );
-
-			if ( attr ) {
-				return attr.isEqual( attrOrKey );
-			}
-		} else {
-			return super.has( attrOrKey );
-		}
-
-		return false;
-	}
-
-	/**
-	 * 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 {treeModel.AttributeList} attrs AttributeList to compare with.
-	 * @returns {Boolean} `true` if AttributeLists are equal, `false` otherwise.
-	 */
-	isEqual( attrs ) {
-		if ( this.size != attrs.size ) {
-			return false;
-		}
-
-		for ( let attr of attrs ) {
-			if ( !this.has( attr ) ) {
-				return false;
-			}
-		}
-
-		return true;
-	}
-
-	/**
-	 * Gets an attribute by its key.
-	 *
-	 * @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.
-	 */
-
-	/**
-	 * 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 from AttributeList.
-	 *
-	 * @method clear
-	 */
-}

+ 1 - 1
packages/ckeditor5-engine/src/treemodel/characterproxy.js

@@ -35,7 +35,7 @@ export default class CharacterProxy extends Node {
 	 * @constructor
 	 */
 	constructor( nodeListText, index ) {
-		super( nodeListText.attrs );
+		super( nodeListText._attrs );
 
 		/**
 		 * Reference to a text object in a node list containing this character.

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

@@ -9,7 +9,6 @@ import { register } from '../batch-base.js';
 import AttributeOperation from '../operation/attributeoperation.js';
 import Position from '../position.js';
 import Range from '../range.js';
-import Attribute from '../attribute.js';
 import Element from '../element.js';
 
 /**
@@ -65,7 +64,7 @@ function attribute( batch, key, value, nodeOrRange ) {
 }
 
 function changeNode( doc, delta, key, value, node ) {
-	const previousValue = node.attrs.getValue( key );
+	const previousValue = node.getAttribute( key );
 	let range;
 
 	if ( previousValue != value ) {
@@ -79,12 +78,7 @@ function changeNode( doc, delta, key, value, node ) {
 			range = new Range( Position.createBefore( node ), Position.createAfter( node ) );
 		}
 
-		const operation = new AttributeOperation(
-				range,
-				previousValue ? new Attribute( key, previousValue ) : null,
-				value ? new Attribute( key, value ) : null,
-				doc.version
-			);
+		const operation = new AttributeOperation( range, key, previousValue, value, doc.version );
 
 		doc.applyOperation( operation );
 		delta.addOperation( operation );
@@ -114,7 +108,7 @@ function changeRange( doc, delta, key, value, range ) {
 		// 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_END position.
 		if ( next.value.type != 'ELEMENT_END' ) {
-			valueAfter = next.value.item.attrs.getValue( key );
+			valueAfter = next.value.item.getAttribute( 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 ).
@@ -141,12 +135,8 @@ function changeRange( doc, delta, key, value, range ) {
 	}
 
 	function addOperation() {
-		const operation = new AttributeOperation(
-				new Range( lastSplitPosition, position ),
-				valueBefore ? new Attribute( key, valueBefore ) : null,
-				value ? new Attribute( key, value ) : null,
-				doc.version
-			);
+		let range = new Range( lastSplitPosition, position );
+		const operation = new AttributeOperation( range, key, valueBefore, value, doc.version );
 
 		doc.applyOperation( operation );
 		delta.addOperation( operation );

+ 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.attrs );
+	const copy = new Element( splitElement.name, splitElement._attrs );
 	const insert = new InsertOperation( Position.createAfter( splitElement ), copy, this.doc.version );
 
 	this.doc.applyOperation( insert );

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

@@ -34,7 +34,6 @@ export default class WeakInsertDelta extends Delta {}
  * @method weakInsert
  * @param {treeModel.Position} position Position of insertion.
  * @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 ) {
 	const delta = new WeakInsertDelta();
@@ -42,7 +41,7 @@ register( 'weakInsert', function( position, nodes ) {
 	nodes = new NodeList( nodes );
 
 	for ( let node of nodes._nodes ) {
-		node.attrs.setTo( this.doc.selection.attrs );
+		node._attrs = new Map( this.doc.selection.getAttributes() );
 	}
 
 	const operation = new InsertOperation( position, nodes, this.doc.version );

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

@@ -215,10 +215,11 @@ export default class Document {
 	 * {@link #graveyard graveyard root}.
 	 * @param {treeModel.Position} [changeInfo.sourcePosition] Change source position. Exists for 'remove', 'reinsert' and 'move'.
 	 * Note that for 'reinsert' the source position will be in the {@link #graveyard graveyard root}.
-	 * @param {treeModel.Attribute} [changeInfo.oldAttr] Only for 'attr' type. If the type is 'attr' and `oldAttr`
-	 * is `undefined` it means that new attribute was inserted. Otherwise it contains changed or removed attribute.
-	 * @param {treeModel.Attribute} [changeInfo.newAttr] Only for 'attr' type. If the type is 'attr' and `newAttr`
-	 * is `undefined` it means that attribute was removed. Otherwise it contains changed or inserted attribute.
+	 * @param {String} [changeInfo.key] Only for 'attr' type. Key of changed / inserted / removed attribute.
+	 * @param {*} [changeInfo.oldValue] Only for 'attr' type. If the type is 'attr' and `oldValue`
+	 * is `undefined` it means that new attribute was inserted. Otherwise it contains changed or removed attribute value.
+	 * @param {*} [changeInfo.newValue] Only for 'attr' type. If the type is 'attr' and `newValue`
+	 * is `undefined` it means that attribute was removed. Otherwise it contains changed or inserted attribute value.
 	 * @param {treeModel.Batch} {@link treeModel.Batch} of changes which this change is a part of.
 	 */
 

+ 38 - 3
packages/ckeditor5-engine/src/treemodel/element.js

@@ -7,6 +7,7 @@
 
 import Node from './node.js';
 import NodeList from './nodelist.js';
+import utils from '../utils.js';
 
 /**
  * Tree data model element.
@@ -17,10 +18,8 @@ export default class Element extends Node {
 	/**
 	 * Creates a tree data model element.
 	 *
-	 * This constructor should be used only internally by the document.
-	 *
 	 * @param {String} name Node name.
-	 * @param {Iterable} attrs Iterable collection of {@link treeModel.Attribute attributes}.
+	 * @param {Iterable} attrs Iterable collection of attributes.
 	 * @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
@@ -117,4 +116,40 @@ export default class Element extends Node {
 
 		return nodeList;
 	}
+
+	/**
+	 * Sets attribute on the element. If attribute with the same key already is set, it overwrites its values.
+	 *
+	 * @param {String} key Key of attribute to set.
+	 * @param {*} value Attribute value.
+	 */
+	setAttribute( key, value ) {
+		this._attrs.set( key, value );
+	}
+
+	/**
+	 * Removes all attributes from the element and sets given attributes.
+	 *
+	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set. See {@link treeModel.Node#getAttributes}.
+	 */
+	setAttributesTo( attrs ) {
+		this._attrs = utils.toMap( attrs );
+	}
+
+	/**
+	 * Removes an attribute with given key from the element.
+	 *
+	 * @param {String} key Key of attribute to remove.
+	 * @returns {Boolean} `true` if the attribute was set on the element, `false` otherwise.
+	 */
+	removeAttribute( key ) {
+		return this._attrs.delete( key );
+	}
+
+	/**
+	 * Removes all attributes from the element.
+	 */
+	clearAttributes() {
+		this._attrs.clear();
+	}
 }

+ 35 - 6
packages/ckeditor5-engine/src/treemodel/node.js

@@ -5,8 +5,8 @@
 
 'use strict';
 
-import AttributeList from './attributelist.js';
 import langUtils from '../lib/lodash/lang.js';
+import utils from '../utils.js';
 import CKEditorError from '../ckeditorerror.js';
 
 /**
@@ -21,7 +21,7 @@ export default class Node {
 	 *
 	 * This is an abstract class, so this constructor should not be used directly.
 	 *
-	 * @param {Iterable} attrs Iterable collection of {@link treeModel.Attribute attributes}.
+	 * @param {Iterable|Object} attrs Iterable collection of attributes.
 	 * @constructor
 	 */
 	constructor( attrs ) {
@@ -37,12 +37,12 @@ export default class Node {
 		 * List of attributes set on this node.
 		 * **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.
+		 * using {@link treeModel.Node} methods.
 		 *
-		 * @readonly
-		 * @property {treeModel.AttributeList} attrs
+		 * @protected
+		 * @property {Map} _attrs
 		 */
-		this.attrs = new AttributeList( attrs );
+		this._attrs = utils.toMap( attrs );
 	}
 
 	/**
@@ -161,4 +161,33 @@ export default class Node {
 
 		return json;
 	}
+
+	/**
+	 * Checks if the node has an attribute for given key.
+	 *
+	 * @param {String} key Key of attribute to check.
+	 * @returns {Boolean} `true` if attribute with given key is set on node, `false` otherwise.
+	 */
+	hasAttribute( key ) {
+		return this._attrs.has( key );
+	}
+
+	/**
+	 * Gets an attribute value for given key or undefined if that attribute is not set on node.
+	 *
+	 * @param {String} key Key of attribute to look for.
+	 * @returns {*} Attribute value or null.
+	 */
+	getAttribute( key ) {
+		return this._attrs.get( key );
+	}
+
+	/**
+	 * Returns iterator that iterates over this node attributes.
+	 *
+	 * @returns {Iterable.<*>}
+	 */
+	getAttributes() {
+		return this._attrs[ Symbol.iterator ]();
+	}
 }

+ 61 - 9
packages/ckeditor5-engine/src/treemodel/nodelist.js

@@ -9,6 +9,7 @@ import CharacterProxy from './characterproxy.js';
 import Text from './text.js';
 import utils from '../utils.js';
 import langUtils from '../lib/lodash/lang.js';
+import CKEditorError from '../ckeditorerror.js';
 
 /**
  * Value that is convertible to an item kept in {@link treeModel.NodeList} or an iterable collection of such items.
@@ -94,11 +95,11 @@ export default class NodeList {
 	 *		let nodeList = new NodeList( 'foo' );
 	 *		nodeList.length; // 3
 	 *
-	 *		let nodeList = new NodeList( new Text( 'foo', [ new Attribute( 'bar', 'bom' ) ] ) );
+	 *		let nodeList = new NodeList( new Text( 'foo', { bar: 'bom' } ) );
 	 *		nodeList.length; // 3
-	 *		nodeList.get( 0 ).attrs.get( 'bar' ); // 'bom'
-	 *		nodeList.get( 1 ).attrs.get( 'bar' ); // 'bom'
-	 *		nodeList.get( 2 ).attrs.get( 'bar' ); // 'bom'
+	 *		nodeList.get( 0 ).getAttribute( 'bar' ); // 'bom'
+	 *		nodeList.get( 1 ).getAttribute( 'bar' ); // 'bom'
+	 *		nodeList.get( 2 ).getAttribute( 'bar' ); // 'bom'
 	 *
 	 *		let nodeListA = new NodeList( 'foo' );
 	 *		let nodeListB = new NodeList( nodeListA );
@@ -145,9 +146,9 @@ export default class NodeList {
 				let length = 1;
 
 				if ( node instanceof CharacterProxy ) {
-					node = new NodeListText( node.character, node.attrs );
+					node = new NodeListText( node.character, node._attrs );
 				} else if ( node instanceof Text ) {
-					node = new NodeListText( node.text, node.attrs );
+					node = new NodeListText( node.text, node._attrs );
 				} else if ( typeof node == 'string' ) {
 					node = new NodeListText( node, [] );
 				}
@@ -157,7 +158,7 @@ export default class NodeList {
 
 					let prev = this._nodes[ this._nodes.length - 1 ];
 
-					if ( prev instanceof NodeListText && prev.attrs.isEqual( node.attrs ) ) {
+					if ( prev instanceof NodeListText && utils.mapsEqual( prev._attrs, node._attrs ) ) {
 						// If previously added text has same attributes, merge this text with it.
 						prev.text += node.text;
 						mergedWithPrev = true;
@@ -309,6 +310,57 @@ export default class NodeList {
 		return new NodeList( removed );
 	}
 
+	/**
+	 * Sets or removes given attribute on a range of nodes in the node list.
+	 *
+	 * @param {Number} index Position of the first node to change.
+	 * @param {Number} number Number of nodes to change.
+	 * @param {String} key Attribute key to change.
+	 * @param {*} [attribute] Attribute value or null if attribute with given key should be removed.
+	 */
+	setAttribute( index, number, key, value ) {
+		if ( index < 0 || index + number > this.length ) {
+			/**
+			 * Trying to set attribute on non-existing node list items.
+			 *
+			 * @error nodelist-setattribute-out-of-bounds
+			 * @param root
+			 */
+			throw new CKEditorError( 'nodelist-setattribute-out-of-bounds: Trying to set attribute on non-existing node list items.' );
+		}
+
+		// "Range" of nodes to remove attributes may start in NodeListText or end in NodeListText. Some splitting may be needed.
+		this._splitNodeAt( index );
+		this._splitNodeAt( index + number );
+
+		let i = index;
+
+		while ( i < index + number ) {
+			let node = this._nodes[ this._indexMap[ i ] ];
+
+			if ( node instanceof NodeListText ) {
+				if ( value !== null ) {
+					node._attrs.set( key, value );
+				} else {
+					node._attrs.delete( key );
+				}
+
+				this._mergeNodeAt( i );
+				i += node.text.length;
+			} else {
+				if ( value !== null ) {
+					node.setAttribute( key, value );
+				} else {
+					node.removeAttribute( key );
+				}
+
+				i++;
+			}
+		}
+
+		this._mergeNodeAt( index + number );
+	}
+
 	/**
 	 * 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,
@@ -339,7 +391,7 @@ export default class NodeList {
 		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 );
+		let newText = new NodeListText( textAfter, node._attrs );
 		newText.parent = node.parent;
 		this._nodes.splice.call( this._nodes, realIndex + 1, 0, newText );
 
@@ -374,7 +426,7 @@ export default class NodeList {
 		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 ) ) {
+		if ( nodeBefore instanceof NodeListText && nodeAfter instanceof NodeListText && utils.mapsEqual( nodeBefore._attrs, nodeAfter._attrs ) ) {
 			// Append text of text node after index to the before one.
 			nodeBefore.text += nodeAfter.text;
 

+ 57 - 86
packages/ckeditor5-engine/src/treemodel/operation/attributeoperation.js

@@ -7,8 +7,8 @@
 
 import Operation from './operation.js';
 import Range from '../range.js';
-import TextFragment from '../textfragment.js';
 import CKEditorError from '../../ckeditorerror.js';
+import TextFragment from '../textfragment.js';
 
 /**
  * Operation to change nodes' attribute. Using this class you can add, remove or change value of the attribute.
@@ -29,12 +29,13 @@ export default class AttributeOperation extends Operation {
 	 * old attributes have to have the same key and the old attribute must be present in all nodes in ranges.
 	 *
 	 * @param {treeModel.Range} range Range on which the operation should be applied.
-	 * @param {treeModel.Attribute|null} oldAttr Attribute to be removed. If `null`, then the operation just inserts a new attribute.
-	 * @param {treeModel.Attribute|null} newAttr Attribute to be added. If `null`, then the operation just removes the attribute.
+	 * @param {String} key Key of an attribute to change or remove.
+	 * @param {*} oldValue Old value of the attribute with given key or `null` if adding a new attribute.
+	 * @param {*} newValue New value to set for the attribute. If `null`, then the operation just removes the attribute.
 	 * @param {Number} baseVersion {@link treeModel.Document#version} on which the operation can be applied.
 	 * @constructor
 	 */
-	constructor( range, oldAttr, newAttr, baseVersion ) {
+	constructor( range, key, oldValue, newValue, baseVersion ) {
 		super( baseVersion );
 
 		/**
@@ -46,20 +47,28 @@ export default class AttributeOperation extends Operation {
 		this.range = Range.createFromRange( range );
 
 		/**
-		 * Old attribute to change. Set to `null` if operation inserts a new attribute.
+		 * Key of an attribute to change or remove.
 		 *
 		 * @readonly
-		 * @type {treeModel.Attribute|null}
+		 * @property {String} key
 		 */
-		this.oldAttr = oldAttr;
+		this.key = key;
 
 		/**
-		 * New attribute. Set to `null` if operation removes the attribute.
+		 * Old value of the attribute with given key or `null` if adding a new attribute.
 		 *
 		 * @readonly
-		 * @type {treeModel.Attribute|null}
+		 * @property {*} oldValue
 		 */
-		this.newAttr = newAttr;
+		this.oldValue = oldValue;
+
+		/**
+		 * New value to set for the attribute. If `null`, then the operation just removes the attribute.
+		 *
+		 * @readonly
+		 * @property {*} newValue
+		 */
+		this.newValue = newValue;
 	}
 
 	get type() {
@@ -67,94 +76,56 @@ export default class AttributeOperation extends Operation {
 	}
 
 	clone() {
-		return new AttributeOperation( this.range, this.oldAttr, this.newAttr, this.baseVersion );
+		return new AttributeOperation( this.range, this.key, this.oldValue, this.newValue, this.baseVersion );
 	}
 
 	getReversed() {
-		return new AttributeOperation( this.range, this.newAttr, this.oldAttr, this.baseVersion + 1 );
+		return new AttributeOperation( this.range, this.key, this.newValue, this.oldValue, this.baseVersion + 1 );
 	}
 
 	_execute() {
-		const oldAttr = this.oldAttr;
-		const newAttr = this.newAttr;
-
-		if ( oldAttr !== null && newAttr !== null && oldAttr.key != newAttr.key ) {
-			/**
-			 * Old and new attributes should have the same keys.
-			 *
-			 * @error operation-attribute-different-keys
-			 * @param {treeModel.Attribute} oldAttr
-			 * @param {treeModel.Attribute} newAttr
-			 */
-			throw new CKEditorError(
-				'operation-attribute-different-keys: Old and new attributes should have the same keys.',
-				{ 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( 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.
-					 *
-					 * @error operation-attribute-no-attr-to-remove
-					 * @param {treeModel.Node} node
-					 * @param {treeModel.Attribute} attr
-					 */
-					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 }
-					);
-				}
-
-				if ( newAttr === null ) {
-					node.attrs.delete( oldAttr.key );
-				}
+		for ( let item of this.range.getAllNodes( true ) ) {
+			if ( this.oldValue !== null && item.getAttribute( this.key ) !== this.oldValue ) {
+				/**
+				 * The attribute which should be removed does not exists for the given node.
+				 *
+				 * @error operation-attribute-no-attr-to-remove
+				 * @param {treeModel.Node} node
+				 * @param {String} key
+				 * @param {*} value
+				 */
+				throw new CKEditorError(
+					'operation-attribute-no-attr-to-remove: The attribute which should be removed does not exists for given node.',
+					{ node: item, key: this.key, value: this.oldValue }
+				);
 			}
-		}
 
-		// Insert or change.
-		if ( newAttr !== null ) {
-			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 ( this.oldValue === null && this.newValue !== null && item.hasAttribute( this.key ) ) {
+				/**
+				 * The attribute with given key already exists for the given node.
+				 *
+				 * @error operation-attribute-attr-exists
+				 * @param {treeModel.Node} node
+				 * @param {String} key
+				 */
+				throw new CKEditorError(
+					'operation-attribute-attr-exists: The attribute with given key already exists.',
+					{ node: item, key: this.key }
+				);
+			}
 
-				if ( oldAttr === null && node.attrs.has( newAttr.key ) ) {
-					/**
-					 * The attribute with given key already exists for the given node.
-					 *
-					 * @error operation-attribute-attr-exists
-					 * @param {treeModel.Node} node
-					 * @param {treeModel.Attribute} attr
-					 */
-					throw new CKEditorError(
-						'operation-attribute-attr-exists: The attribute with given key already exists.',
-						{ node: node, attr: newAttr }
-					);
+			if ( item instanceof TextFragment ) {
+				item.commonParent._children.setAttribute( item.first.getIndex(), item.text.length, this.key, this.newValue );
+			}
+			else {
+				if ( this.newValue !== null ) {
+					item.setAttribute( this.key, this.newValue );
+				} else {
+					item.removeAttribute( this.key );
 				}
-
-				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 };
+		return { range: this.range, key: this.key, oldValue: this.oldValue, newValue: this.newValue };
 	}
 }

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

@@ -11,6 +11,7 @@ import MoveOperation from './moveoperation.js';
 import NoOperation from './nooperation.js';
 import Position from '../position.js';
 import Range from '../range.js';
+import langUtils from '../../lib/lodash/lang.js';
 import utils from '../../utils.js';
 
 /**
@@ -89,37 +90,32 @@ const ot = {
 
 			// Map transformed range(s) to operations and return them.
 			return ranges.reverse().map( ( range ) => {
-				return new AttributeOperation(
-					range,
-					a.oldAttr,
-					a.newAttr,
-					a.baseVersion
-				);
+				return new AttributeOperation( range, a.key, a.oldValue, a.newValue, a.baseVersion );
 			} );
 		},
 
 		// Transforms AttributeOperation `a` by AttributeOperation `b`. Accepts a flag stating whether `a` is more important
 		// than `b` when it comes to resolving conflicts. Returns results as an array of operations.
 		AttributeOperation( a, b, isStrong ) {
-			if ( haveConflictingAttributes( a, b ) ) {
+			if ( a.key === b.key ) {
 				// If operations attributes are in conflict, check if their ranges intersect and manage them properly.
 				let operations = [];
 
 				// First, we want to apply change to the part of a range that has not been changed by the other operation.
 				operations = operations.concat(
 					a.range.getDifference( b.range ).map( ( range ) => {
-						return new AttributeOperation( range, a.oldAttr, a.newAttr, a.baseVersion );
+						return new AttributeOperation( range, a.key, a.oldValue, a.newValue, a.baseVersion );
 					} )
 				);
 
-				if ( isStrong ) {
+				if ( isStrong && !langUtils.isEqual( a.newValue, b.newValue ) ) {
 					// If this operation is more important, we want also want to apply change to the part of the
 					// original range that has already been changed by the other operation. Since that range
 					// got changed we have to update oldAttr.
 					const common = a.range.getIntersection( b.range );
 
 					if ( common !== null ) {
-						operations.push( new AttributeOperation( common, b.oldAttr, a.newAttr, a.baseVersion ) );
+						operations.push( new AttributeOperation( common, b.key, b.oldValue, a.newValue, a.baseVersion ) );
 					}
 				}
 
@@ -131,7 +127,7 @@ const ot = {
 
 				return operations;
 			} else {
-				// If operations don't conflict simply, return an array containing just a clone of this operation.
+				// If operations don't conflict, simply return an array containing just a clone of this operation.
 				return [ a.clone() ];
 			}
 		},
@@ -184,12 +180,7 @@ const ot = {
 
 			// Map transformed range(s) to operations and return them.
 			return ranges.map( ( range ) => {
-				return new AttributeOperation(
-					range,
-					a.oldAttr,
-					a.newAttr,
-					a.baseVersion
-				);
+				return new AttributeOperation( range, a.key, a.oldValue, a.newValue, a.baseVersion );
 			} );
 		}
 	},
@@ -381,32 +372,6 @@ function moveTargetIntoMovedRange( a, b ) {
 	return a.targetPosition.getTransformedByDeletion( b.sourcePosition, b.howMany ) === null;
 }
 
-// Takes two AttributeOperations and checks whether their attributes are in conflict.
-// This happens when both operations changes an attribute with the same key and they either set different
-// values for this attribute or one of them removes it while the other one sets it.
-// Returns true if attributes are in conflict.
-function haveConflictingAttributes( a, b ) {
-	// Keeping in mind that newAttr or oldAttr might be null.
-	// We will retrieve the key from whichever parameter is set.
-	const keyA = ( a.newAttr || a.oldAttr ).key;
-	const keyB = ( b.newAttr || b.oldAttr ).key;
-
-	if ( keyA != keyB ) {
-		// Different keys - not conflicting.
-		return false;
-	}
-
-	if ( a.newAttr === null && b.newAttr === null ) {
-		// Both remove the attribute - not conflicting.
-		return false;
-	}
-
-	// Check if they set different value or one of them removes the attribute.
-	return ( a.newAttr === null && b.newAttr !== null ) ||
-		( a.newAttr !== null && b.newAttr === null ) ||
-		( !a.newAttr.isEqual( b.newAttr ) );
-}
-
 // Gets an array of Ranges and produces one Range out of it. The root of a new range will be same as
 // the root of the first range in the array. If any of given ranges has different root than the first range,
 // it will be discarded.

+ 3 - 1
packages/ckeditor5-engine/src/treemodel/range.js

@@ -228,10 +228,11 @@ export default class Range {
 		let diffAt = ( typeof cmp == 'string' ) ? Math.min( this.start.path.length, this.end.path.length ) : cmp;
 
 		let pos = Position.createFromPosition( this.start );
+		let posParent = pos.parent;
 
 		// Go up.
 		while ( pos.path.length > diffAt + 1 ) {
-			let howMany = pos.parent.getChildCount() - pos.offset;
+			let howMany = posParent.getChildCount() - pos.offset;
 
 			if ( howMany !== 0 ) {
 				ranges.push( new Range( pos, pos.getShiftedBy( howMany ) ) );
@@ -239,6 +240,7 @@ export default class Range {
 
 			pos.path = pos.path.slice( 0, -1 );
 			pos.offset++;
+			posParent = posParent.parent;
 		}
 
 		// Go down.

+ 68 - 4
packages/ckeditor5-engine/src/treemodel/selection.js

@@ -6,7 +6,6 @@
 'use strict';
 
 import LiveRange from './liverange.js';
-import AttributeList from './attributelist.js';
 import EmitterMixin from '../emittermixin.js';
 import CKEditorError from '../ckeditorerror.js';
 import utils from '../utils.js';
@@ -27,10 +26,10 @@ export default class Selection {
 		/**
 		 * List of attributes set on current selection.
 		 *
-		 * @readonly
-		 * @property {treeModel.AttributeList} attrs
+		 * @protected
+		 * @property {Map}
 		 */
-		this.attrs = new AttributeList();
+		this._attrs = new Map();
 
 		/**
 		 * Stores all ranges that are selected.
@@ -167,6 +166,71 @@ export default class Selection {
 		this._lastRangeBackward = !!isLastBackward;
 		this.fire( 'update' );
 	}
+
+	/**
+	 * Checks if the selection has an attribute for given key.
+	 *
+	 * @param {String} key Key of attribute to check.
+	 * @returns {Boolean} `true` if attribute with given key is set on selection, `false` otherwise.
+	 */
+	hasAttribute( key ) {
+		return this._attrs.has( key );
+	}
+
+	/**
+	 * Gets an attribute value for given key or undefined it that attribute is not set on selection.
+	 *
+	 * @param {String} key Key of attribute to look for.
+	 * @returns {*} Attribute value or null.
+	 */
+	getAttribute( key ) {
+		return this._attrs.get( key );
+	}
+
+	/**
+	 * Returns iterator that iterates over this selection attributes.
+	 *
+	 * @returns {Iterable.<*>}
+	 */
+	getAttributes() {
+		return this._attrs[ Symbol.iterator ]();
+	}
+
+	/**
+	 * Sets attribute on the selection. If attribute with the same key already is set, it overwrites its values.
+	 *
+	 * @param {String} key Key of attribute to set.
+	 * @param {*} value Attribute value.
+	 */
+	setAttribute( key, value ) {
+		this._attrs.set( key, value );
+	}
+
+	/**
+	 * Removes all attributes from the selection and sets given attributes.
+	 *
+	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set.
+	 */
+	setAttributesTo( attrs ) {
+		this._attrs = utils.toMap( attrs );
+	}
+
+	/**
+	 * Removes an attribute with given key from the selection.
+	 *
+	 * @param {String} key Key of attribute to remove.
+	 * @returns {Boolean} `true` if the attribute was set on the selection, `false` otherwise.
+	 */
+	removeAttribute( key ) {
+		return this._attrs.delete( key );
+	}
+
+	/**
+	 * Removes all attributes from the selection.
+	 */
+	clearAttributes() {
+		this._attrs.clear();
+	}
 }
 
 /**

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

@@ -5,15 +5,13 @@
 
 'use strict';
 
-import AttributeList from './attributelist.js';
+import utils from '../utils.js';
 
 /**
  * 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 ] ) );
+ *		let myElem = new Element( 'li', [], new Text( 'text with attributes', { foo: true, bar: true } ) );
  *
  * @class treeModel.Text
  */
@@ -22,7 +20,7 @@ export default class Text {
 	 * Creates a text with attributes.
 	 *
 	 * @param {String} text Described text.
-	 * @param {Iterable} attrs Iterable collection of {@link treeModel.Attribute attributes}.
+	 * @param {Iterable|Object} attrs Iterable collection of attributes.
 	 * @constructor
 	 */
 	constructor( text, attrs ) {
@@ -35,10 +33,11 @@ export default class Text {
 		this.text = text || '';
 
 		/**
-		 * {@link treeModel.Attribute AttributesList} bound with the text.
+		 * List of attributes bound with the text.
 		 *
-		 * @property {treeModel.AttributeList}
+		 * @protected
+		 * @property {Map}
 		 */
-		this.attrs = new AttributeList( attrs );
+		this._attrs = utils.toMap( attrs );
 	}
 }

+ 47 - 24
packages/ckeditor5-engine/src/treemodel/textfragment.js

@@ -5,19 +5,17 @@
 
 'use strict';
 
-import Position from './position.js';
-import Range from './range.js';
+import CharacterProxy from './characterproxy.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.
+ * Difference between {@link treeModel.TextFragment} and {@link treeModel.Text} is that the former is a set of
+ * nodes taken from 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.
+ * You should never create an instance of this class by your own. Instead, use string literals or {@link treeModel.Text}.
  *
  * @class treeModel.TextFragment
  */
@@ -25,19 +23,19 @@ 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}.
+	 * @param {treeModel.CharacterProxy} firstCharacter First character node contained in {@link treeModel.TextFragment}.
+	 * @param {Number} length Whole text contained in {@link treeModel.TextFragment}.
 	 * @protected
 	 * @constructor
 	 */
-	constructor( startPosition, text ) {
+	constructor( firstCharacter, length ) {
 		/**
-		 * First {@link treeModel.CharacterProxy character node} contained in {@link treeModel.TextFragment}.
+		 * First character node contained in {@link treeModel.TextFragment}.
 		 *
 		 * @readonly
 		 * @property {treeModel.CharacterProxy} first
 		 */
-		this.first = startPosition.nodeAfter;
+		this.first = firstCharacter;
 
 		/**
 		 * Characters contained in {@link treeModel.TextFragment}.
@@ -45,7 +43,7 @@ export default class TextFragment {
 		 * @readonly
 		 * @property {String} text
 		 */
-		this.text = text;
+		this.text = firstCharacter._nodeListText.text.substr( this.first._index, length );
 
 		/**
 		 * Last {@link treeModel.CharacterProxy character node} contained in {@link treeModel.TextFragment}.
@@ -54,14 +52,15 @@ export default class TextFragment {
 		 * @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;
+	/**
+	 * A common parent of all character nodes contained in {@link treeModel.TextFragment}.
+	 *
+	 * @property {treeModel.Element} commonParent
+	 */
+	get commonParent() {
+		return this.first.parent;
 	}
 
 	/**
@@ -71,15 +70,39 @@ export default class TextFragment {
 	 * @returns {treeModel.CharacterProxy}
 	 */
 	getCharAt( index ) {
-		return this.first.parent.getChild( this.first._index + index );
+		if ( index < 0 || index >= this.text.length ) {
+			return null;
+		}
+
+		return new CharacterProxy( this.first._nodeListText, this.first._index + index );
+	}
+
+	/**
+	 * Checks if the text fragment has an attribute for given key.
+	 *
+	 * @param {String} key Key of attribute to check.
+	 * @returns {Boolean} `true` if attribute with given key is set on text fragment, `false` otherwise.
+	 */
+	hasAttribute( key ) {
+		return this.first.hasAttribute( key );
+	}
+
+	/**
+	 * Gets an attribute value for given key or undefined it that attribute is not set on text fragment.
+	 *
+	 * @param {String} key Key of attribute to look for.
+	 * @returns {*} Attribute value or null.
+	 */
+	getAttribute( key ) {
+		return this.first.getAttribute( key );
 	}
 
 	/**
-	 * Creates and returns a range containing all characters from this {@link treeModel.TextFragment}.
+	 * Returns iterator that iterates over this text fragment attributes.
 	 *
-	 * @returns {Range}
+	 * @returns {Iterable.<*>}
 	 */
-	getRange() {
-		return new Range( Position.createBefore( this.first ), Position.createAfter( this.last ) );
+	getAttributes() {
+		return this.first.getAttributes();
 	}
 }

+ 2 - 5
packages/ckeditor5-engine/src/treemodel/treewalker.js

@@ -150,8 +150,7 @@ export default class TreeWalker {
 					charactersCount = offset - position.offset;
 				}
 
-				let text = node._nodeListText.text.substr( node._index, charactersCount );
-				let textFragment = new TextFragment( position, text );
+				let textFragment = new TextFragment( node, charactersCount );
 
 				position.offset = offset;
 				this.position = position;
@@ -216,13 +215,11 @@ export default class TreeWalker {
 					charactersCount = position.offset - offset;
 				}
 
-				let text = node._nodeListText.text.substr( node._index + 1 - charactersCount, charactersCount );
+				let textFragment = new TextFragment( parent.getChild( offset ), charactersCount );
 
 				position.offset = offset;
 				this.position = position;
 
-				let textFragment = new TextFragment( this.position, text );
-
 				return formatReturnValue( 'TEXT', textFragment );
 			} else {
 				position.offset--;

+ 61 - 0
packages/ckeditor5-engine/src/utils.js

@@ -5,6 +5,8 @@
 
 'use strict';
 
+import langUtils from './lib/lodash/lang.js';
+
 /**
  * An index at which arrays differ. If arrays are same at all indexes, it represents how arrays are related.
  * In this case, possible values are: 'SAME', 'PREFIX' or 'EXTENSION'.
@@ -91,6 +93,65 @@ const utils = {
 		}
 	},
 
+	/**
+	 * Transforms object to map.
+	 *
+	 *		const map = utils.objectToMap( { 'foo': 1, 'bar': 2 } );
+	 *		map.get( 'foo' ); // 1
+	 *
+	 * @param {Object} obj Object to transform.
+	 * @returns {Map} Map created from object.
+	 */
+	objectToMap( obj ) {
+		const map = new Map();
+
+		for ( let key in obj ) {
+			map.set( key, obj[ key ] );
+		}
+
+		return map;
+	},
+
+	/**
+	 * Transforms object or iterable to map. Iterable needs to be in the format acceptable by the `Map` constructor.
+	 *
+	 *		map = utils.toMap( { 'foo': 1, 'bar': 2 } );
+	 *		map = utils.toMap( [ [ 'foo', 1 ], [ 'bar', 2 ] ] );
+	 *		map = utils.toMap( anotherMap );
+	 *
+	 * @param {Object|Iterable} data Object or iterable to transform.
+	 * @returns {Map} Map created from data.
+	 */
+	toMap( data ) {
+		if ( langUtils.isPlainObject( data ) ) {
+			return utils.objectToMap( data );
+		} else {
+			return new Map( data );
+		}
+	},
+
+	/**
+	 * Checks whether given {Map}s are equal, that is has same size and same key-value pairs.
+	 *
+	 * @returns {Boolean} `true` if given maps are equal, `false` otherwise.
+	 */
+	mapsEqual( mapA, mapB ) {
+		if ( mapA.size != mapB.size ) {
+			return false;
+		}
+
+		for ( let attr of mapA.entries() ) {
+			let valA = JSON.stringify( attr[ 1 ] );
+			let valB = JSON.stringify( mapB.get( attr[ 0 ] ) );
+
+			if ( valA !== valB ) {
+				return false;
+			}
+		}
+
+		return true;
+	},
+
 	/**
 	 * Returns `nth` (starts from `0` of course) item of an `iterable`.
 	 *

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

@@ -1,28 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/* bender-tags: treemodel */
-
-'use strict';
-
-import Attribute from '/ckeditor5/core/treemodel/attribute.js';
-
-describe( 'Attribute', () => {
-	describe( 'constructor', () => {
-		it( 'should create attribute', () => {
-			let attr = new Attribute( 'foo', 'bar' );
-
-			expect( attr ).to.have.property( 'key' ).that.equals( 'foo' );
-			expect( attr ).to.have.property( 'value' ).that.equals( 'bar' );
-		} );
-
-		it( 'should create equal instance even if object has different order', () => {
-			let attr1 = new Attribute( 'foo', { a: 1, b: 2 } );
-			let attr2 = new Attribute( 'foo', { b: 2, a: 1 } );
-
-			expect( attr1.isEqual( attr2 ) ).to.be.true;
-		} );
-	} );
-} );

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

@@ -1,190 +0,0 @@
-/**
- * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/* bender-tags: treemodel */
-
-'use strict';
-
-import AttributeList from '/ckeditor5/core/treemodel/attributelist.js';
-import Attribute from '/ckeditor5/core/treemodel/attribute.js';
-
-describe( 'AttributeList', () => {
-	let list, attrFooBar;
-
-	beforeEach( () => {
-		list = new AttributeList();
-		attrFooBar = new Attribute( 'foo', 'bar' );
-	} );
-
-	it( 'should extend Map', () => {
-		expect( list ).to.be.instanceof( Map );
-	} );
-
-	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 copy passed AttributeList', () => {
-			list = new AttributeList( [ attrFooBar ] );
-			let copy = new AttributeList( list );
-
-			expect( copy.size ).to.equal( 1 );
-			expect( copy.has( 'foo' ) ).to.be.true;
-			expect( copy.get( 'foo' ).value ).to.equal( 'bar' );
-		} );
-	} );
-
-	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();
-
-			expect( step.done ).to.be.false;
-			expect( step.value ).to.equal( attrFooBar );
-
-			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( 'getValue', () => {
-		it( 'should return value of set attribute for given key', () => {
-			list.set( attrFooBar );
-			expect( list.getValue( 'foo' ) ).to.equal( 'bar' );
-		} );
-
-		it( 'should return null if attribute with given key is not set', () => {
-			expect( list.getValue( 'foo' ) ).to.be.null;
-		} );
-	} );
-
-	describe( 'set', () => {
-		it( 'should insert given attribute', () => {
-			list.set( attrFooBar );
-
-			expect( list.size ).to.equal( 1 );
-			expect( list.getValue( 'foo' ) ).to.equal( 'bar' );
-		} );
-
-		it( 'should overwrite attribute with the same key', () => {
-			list.set( attrFooBar );
-
-			expect( list.size ).to.equal( 1 );
-			expect( list.getValue( 'foo' ) ).to.equal( 'bar' );
-
-			let attrFooXyz = new Attribute( 'foo', 'xyz' );
-
-			list.set( attrFooXyz );
-
-			expect( list.size ).to.equal( 1 );
-			expect( list.getValue( 'foo' ) ).to.equal( 'xyz' );
-		} );
-	} );
-
-	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 ) ] );
-
-			expect( list.has( 'foo' ) ).to.be.false;
-			expect( list.getValue( 'abc' ) ).to.be.true;
-			expect( list.getValue( 'bar' ) ).to.be.false;
-		} );
-	} );
-
-	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;
-		} );
-
-		it( 'should return true if list contains an attribute with given key', () => {
-			list.set( attrFooBar );
-
-			expect( list.has( 'foo' ) ).to.be.true;
-		} );
-
-		it( 'should return false if list does not contain given attribute', () => {
-			list.set( attrFooBar );
-
-			expect( list.has( new Attribute( 'abc', true ) ) ).to.be.false;
-		} );
-
-		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 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 ] );
-
-			let other = new AttributeList( [ attrFooBar ] );
-
-			expect( list.isEqual( other ) ).to.be.false;
-			expect( other.isEqual( list ) ).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;
-		} );
-
-		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.setTo( [ attrFooBar, attrAbcXyz ] );
-
-			let other = new AttributeList( [ attrFooTrue, attrAbcXyz ] );
-
-			expect( list.isEqual( other ) ).to.be.false;
-			expect( other.isEqual( list ) ).to.be.false;
-		} );
-
-		it( 'should return true if lists have same attributes and same values for them', () => {
-			let attrAbcXyz = new Attribute( 'abc', 'xyz' );
-			list.setTo( [ attrFooBar, attrAbcXyz ] );
-
-			// Note different order of attributes.
-			let other = new AttributeList( [ attrAbcXyz, attrFooBar ] );
-
-			expect( list.isEqual( other ) ).to.be.true;
-			expect( other.isEqual( list ) ).to.be.true;
-		} );
-	} );
-} );

+ 3 - 3
packages/ckeditor5-engine/tests/treemodel/characterproxy.js

@@ -10,13 +10,13 @@
 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';
+import utils from '/ckeditor5/core/utils.js';
 
 describe( 'CharacterProxy', () => {
 	let text, element, char;
 
 	before( () => {
-		text = new Text( 'abc', [ new Attribute( 'foo', true ) ] );
+		text = new Text( 'abc', { foo: true } );
 		element = new Element( 'div', [], [ new Element( 'p' ), text, new Element( 'p' ) ] );
 	} );
 
@@ -37,7 +37,7 @@ describe( 'CharacterProxy', () => {
 	} );
 
 	it( 'should have attributes list equal to passed to Text instance', () => {
-		expect( char.attrs.isEqual( text.attrs ) ).to.be.true;
+		expect( utils.mapsEqual( char._attrs, text._attrs ) ).to.be.true;
 	} );
 
 	it( 'should return correct index in parent node', () => {

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

@@ -10,7 +10,6 @@
 import coreTestUtils from '/tests/core/_utils/utils.js';
 import Document from '/ckeditor5/core/treemodel/document.js';
 import Text from '/ckeditor5/core/treemodel/text.js';
-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';
@@ -40,8 +39,8 @@ describe( 'Batch', () => {
 		let node, text, char;
 
 		beforeEach( () => {
-			node = new Element( 'p', [ new Attribute( 'a', 1 ) ] );
-			text = new Text( 'c', [ new Attribute( 'a', 1 ) ] );
+			node = new Element( 'p', { a: 1 } );
+			text = new Text( 'c', { a: 1 } );
 
 			root.insertChildren( 0, [ node, text ] );
 
@@ -52,31 +51,31 @@ describe( 'Batch', () => {
 			it( 'should create the attribute on element', () => {
 				batch.setAttr( 'b', 2, node );
 				expect( getOperationsCount() ).to.equal( 1 );
-				expect( node.attrs.getValue( 'b' ) ).to.equal( 2 );
+				expect( node.getAttribute( 'b' ) ).to.equal( 2 );
 			} );
 
 			it( 'should change the attribute of element', () => {
 				batch.setAttr( 'a', 2, node );
 				expect( getOperationsCount() ).to.equal( 1 );
-				expect( node.attrs.getValue( 'a' ) ).to.equal( 2 );
+				expect( node.getAttribute( 'a' ) ).to.equal( 2 );
 			} );
 
 			it( 'should create the attribute on text node', () => {
 				batch.setAttr( 'b', 2, char );
 				expect( getOperationsCount() ).to.equal( 1 );
-				expect( root.getChild( 1 ).attrs.getValue( 'b' ) ).to.equal( 2 );
+				expect( root.getChild( 1 ).getAttribute( 'b' ) ).to.equal( 2 );
 			} );
 
 			it( 'should change the attribute of text node', () => {
 				batch.setAttr( 'a', 2, char );
 				expect( getOperationsCount() ).to.equal( 1 );
-				expect( root.getChild( 1 ).attrs.getValue( 'a' ) ).to.equal( 2 );
+				expect( root.getChild( 1 ).getAttribute( '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.attrs.getValue( 'a' ) ).to.equal( 1 );
+				expect( node.getAttribute( 'a' ) ).to.equal( 1 );
 			} );
 
 			it( 'should be chainable', () => {
@@ -89,13 +88,13 @@ describe( 'Batch', () => {
 			it( 'should remove the attribute from element', () => {
 				batch.removeAttr( 'a', node );
 				expect( getOperationsCount() ).to.equal( 1 );
-				expect( node.attrs.getValue( 'a' ) ).to.be.null;
+				expect( node.getAttribute( 'a' ) ).to.be.undefined;
 			} );
 
 			it( 'should remove the attribute from character', () => {
 				batch.removeAttr( 'a', char );
 				expect( getOperationsCount() ).to.equal( 1 );
-				expect( root.getChild( 1 ).attrs.getValue( 'a' ) ).to.be.null;
+				expect( root.getChild( 1 ).getAttribute( 'a' ) ).to.be.undefined;
 			} );
 
 			it( 'should do nothing if the attribute is not set', () => {
@@ -113,13 +112,13 @@ describe( 'Batch', () => {
 	describe( 'change attribute on range', () => {
 		beforeEach( () => {
 			root.insertChildren( 0, [
-				new Text( 'xxx', [ new Attribute( 'a', 1 ) ] ),
+				new Text( 'xxx', { a: 1 } ),
 				'xxx',
-				new Text( 'xxx', [ new Attribute( 'a', 1 ) ] ),
-				new Text( 'xxx', [ new Attribute( 'a', 2 ) ] ),
+				new Text( 'xxx', { a: 1 } ),
+				new Text( 'xxx', { a: 2 } ),
 				'xxx',
-				new Text( 'xxx', [ new Attribute( 'a', 1 ) ] ),
-				new Element( 'e', [ new Attribute( 'a', 2 ) ], 'xxx' ),
+				new Text( 'xxx', { a: 1 } ),
+				new Element( 'e', { a: 2 }, 'xxx' ),
 				'xxx'
 			] );
 		} );
@@ -147,7 +146,7 @@ describe( 'Batch', () => {
 			// default: 111---111222---1112------
 			const range = Range.createFromElement( root );
 
-			return Array.from( range.getAllNodes() ).map( node => node.attrs.getValue( 'a' ) || '-' ).join( '' );
+			return Array.from( range.getAllNodes() ).map( node => node.getAttribute( 'a' ) || '-' ).join( '' );
 		}
 
 		describe( 'setAttr', () => {

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

@@ -10,7 +10,6 @@
 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';
 
 describe( 'Batch', () => {
@@ -20,8 +19,8 @@ describe( 'Batch', () => {
 		doc = new Document();
 		root = doc.createRoot( 'root' );
 
-		p1 = new Element( 'p', [ new Attribute( 'key1', 'value1' ) ], 'foo' );
-		p2 = new Element( 'p', [ new Attribute( 'key2', 'value2' ) ], 'bar' );
+		p1 = new Element( 'p', { key1: 'value1' }, 'foo' );
+		p2 = new Element( 'p', { key2: 'value2' }, 'bar' );
 
 		root.insertChildren( 0, [ p1, p2 ] );
 	} );
@@ -33,8 +32,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( root.getChild( 0 ).attrs.size ).to.equal( 1 );
-			expect( root.getChild( 0 ).attrs.getValue( 'key1' ) ).to.equal( 'value1' );
+			expect( root.getChild( 0 )._attrs.size ).to.equal( 1 );
+			expect( root.getChild( 0 ).getAttribute( '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' );

+ 9 - 10
packages/ckeditor5-engine/tests/treemodel/delta/splitdelta.js

@@ -10,7 +10,6 @@
 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';
 
 describe( 'Batch', () => {
@@ -20,7 +19,7 @@ describe( 'Batch', () => {
 		doc = new Document();
 		root = doc.createRoot( 'root' );
 
-		p = new Element( 'p', [ new Attribute( 'key', 'value' ) ], 'foobar' );
+		p = new Element( 'p', { key: 'value' }, 'foobar' );
 
 		root.insertChildren( 0, p );
 	} );
@@ -33,16 +32,16 @@ describe( 'Batch', () => {
 
 			expect( root.getChild( 0 ).name ).to.equal( 'p' );
 			expect( root.getChild( 0 ).getChildCount() ).to.equal( 3 );
-			expect( root.getChild( 0 ).attrs.size ).to.equal( 1 );
-			expect( root.getChild( 0 ).attrs.getValue( 'key' ) ).to.equal( 'value' );
+			expect( root.getChild( 0 )._attrs.size ).to.equal( 1 );
+			expect( root.getChild( 0 ).getAttribute( '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( root.getChild( 1 ).attrs.size ).to.equal( 1 );
-			expect( root.getChild( 1 ).attrs.getValue( 'key' ) ).to.equal( 'value' );
+			expect( root.getChild( 1 )._attrs.size ).to.equal( 1 );
+			expect( root.getChild( 1 ).getAttribute( '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' );
@@ -55,8 +54,8 @@ describe( 'Batch', () => {
 
 			expect( root.getChild( 0 ).name ).to.equal( 'p' );
 			expect( root.getChild( 0 ).getChildCount() ).to.equal( 6 );
-			expect( root.getChild( 0 ).attrs.size ).to.equal( 1 );
-			expect( root.getChild( 0 ).attrs.getValue( 'key' ) ).to.equal( 'value' );
+			expect( root.getChild( 0 )._attrs.size ).to.equal( 1 );
+			expect( root.getChild( 0 ).getAttribute( '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' );
@@ -66,8 +65,8 @@ describe( 'Batch', () => {
 
 			expect( root.getChild( 1 ).name ).to.equal( 'p' );
 			expect( root.getChild( 1 ).getChildCount() ).to.equal( 0 );
-			expect( root.getChild( 1 ).attrs.size ).to.equal( 1 );
-			expect( root.getChild( 1 ).attrs.getValue( 'key' ) ).to.equal( 'value' );
+			expect( root.getChild( 1 )._attrs.size ).to.equal( 1 );
+			expect( root.getChild( 1 ).getAttribute( 'key' ) ).to.equal( 'value' );
 		} );
 
 		it( 'should throw if we try to split a root', () => {

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

@@ -8,7 +8,6 @@
 'use strict';
 
 import Document from '/ckeditor5/core/treemodel/document.js';
-import Attribute from '/ckeditor5/core/treemodel/attribute.js';
 import Position from '/ckeditor5/core/treemodel/position.js';
 
 describe( 'Batch', () => {
@@ -22,12 +21,9 @@ describe( 'Batch', () => {
 
 		batch = doc.batch();
 
-		attrs = [
-			new Attribute( 'bold', true ),
-			new Attribute( 'foo', 'bar' )
-		];
+		attrs = [ [ 'bold', true ], [ 'foo', 'bar' ] ];
 
-		doc.selection.attrs.setTo( attrs );
+		doc.selection.setAttributesTo( attrs );
 
 		chain = batch.weakInsert( new Position( root, [ 2 ] ), 'xyz' );
 	} );
@@ -41,9 +37,9 @@ describe( 'Batch', () => {
 		} );
 
 		it( 'should set inserted nodes attributes to same as current selection attributes', () => {
-			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 );
+			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', () => {

+ 18 - 13
packages/ckeditor5-engine/tests/treemodel/document/change-event.js

@@ -9,7 +9,6 @@
 
 import Document from '/ckeditor5/core/treemodel/document.js';
 import Element from '/ckeditor5/core/treemodel/element.js';
-import Attribute from '/ckeditor5/core/treemodel/attribute.js';
 import Position from '/ckeditor5/core/treemodel/position.js';
 import Range from '/ckeditor5/core/treemodel/range.js';
 import AttributeOperation from '/ckeditor5/core/treemodel/operation/attributeoperation.js';
@@ -109,8 +108,9 @@ describe( 'Document change event', () => {
 		doc.applyOperation(
 			new AttributeOperation(
 				Range.createFromParentsAndOffsets( root, 0, root, 3 ),
+				'key',
 				null,
-				new Attribute( 'key', 'new' ),
+				'new',
 				doc.version
 			)
 		);
@@ -118,18 +118,20 @@ describe( 'Document change event', () => {
 		expect( changes ).to.have.length( 1 );
 		expect( types[ 0 ] ).to.equal( 'attr' );
 		expect( changes[ 0 ].range ).to.deep.equal( Range.createFromParentsAndOffsets( root, 0, root, 3 ) );
-		expect( changes[ 0 ].oldAttr ).to.be.null;
-		expect( changes[ 0 ].newAttr ).to.deep.equal( new Attribute( 'key', 'new' ) );
+		expect( changes[ 0 ].key ).to.equal( 'key' );
+		expect( changes[ 0 ].oldValue ).to.be.null;
+		expect( changes[ 0 ].newValue ).to.equal( 'new' );
 	} );
 
 	it( 'should be fired when attribute is removed', () => {
-		const elem = new Element( 'p', [ new Attribute( 'key', 'old' ) ] );
+		const elem = new Element( 'p', { key: 'old' } );
 		root.insertChildren( 0, elem );
 
 		doc.applyOperation(
 			new AttributeOperation(
 				Range.createFromParentsAndOffsets( root, 0, elem, 0 ),
-				new Attribute( 'key', 'old' ),
+				'key',
+				'old',
 				null,
 				doc.version
 			)
@@ -138,19 +140,21 @@ describe( 'Document change event', () => {
 		expect( changes ).to.have.length( 1 );
 		expect( types[ 0 ] ).to.equal( 'attr' );
 		expect( changes[ 0 ].range ).to.deep.equal( Range.createFromParentsAndOffsets( root, 0, elem, 0 ) );
-		expect( changes[ 0 ].oldAttr ).to.deep.equal( new Attribute( 'key', 'old' ) );
-		expect( changes[ 0 ].newAttr ).to.be.null;
+		expect( changes[ 0 ].key ).to.equal( 'key' );
+		expect( changes[ 0 ].oldValue ).to.equal( 'old' );
+		expect( changes[ 0 ].newValue ).to.be.null;
 	}  );
 
 	it( 'should be fired when attribute changes', () => {
-		const elem = new Element( 'p', [ new Attribute( 'key', 'old' ) ] );
+		const elem = new Element( 'p', { key: 'old' } );
 		root.insertChildren( 0, elem );
 
 		doc.applyOperation(
 			new AttributeOperation(
 				Range.createFromParentsAndOffsets( root, 0, elem, 0 ),
-				new Attribute( 'key', 'old' ),
-				new Attribute( 'key', 'new' ),
+				'key',
+				'old',
+				'new',
 				doc.version
 			)
 		);
@@ -158,7 +162,8 @@ describe( 'Document change event', () => {
 		expect( changes ).to.have.length( 1 );
 		expect( types[ 0 ] ).to.equal( 'attr' );
 		expect( changes[ 0 ].range ).to.deep.equal( Range.createFromParentsAndOffsets( root, 0, elem, 0 ) );
-		expect( changes[ 0 ].oldAttr ).to.deep.equal( new Attribute( 'key', 'old' ) );
-		expect( changes[ 0 ].newAttr ).to.deep.equal( new Attribute( 'key', 'new' ) );
+		expect( changes[ 0 ].key ).to.equal( 'key' );
+		expect( changes[ 0 ].oldValue ).to.equal( 'old' );
+		expect( changes[ 0 ].newValue ).to.equal( 'new' );
 	}  );
 } );

+ 58 - 5
packages/ckeditor5-engine/tests/treemodel/element.js

@@ -10,7 +10,6 @@
 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';
 
 describe( 'Element', () => {
 	describe( 'constructor', () => {
@@ -21,11 +20,11 @@ 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( element.attrs.size ).to.equal( 0 );
+			expect( element._attrs.size ).to.equal( 0 );
 		} );
 
 		it( 'should create element with attributes', () => {
-			let attr = new Attribute( 'foo', 'bar' );
+			let attr = { foo: 'bar' };
 
 			let element = new Element( 'elem', [ attr ] );
 			let parent = new Element( 'parent', [], [ element ] );
@@ -33,8 +32,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( element.attrs.size ).to.equal( 1 );
-			expect( element.attrs.getValue( attr.key ) ).to.equal( attr.value );
+			expect( element._attrs.size ).to.equal( 1 );
+			expect( element.getAttribute( attr.key ) ).to.equal( attr.value );
 		} );
 
 		it( 'should create element with children', () => {
@@ -106,4 +105,58 @@ describe( 'Element', () => {
 			expect( element.getChildCount() ).to.equal( 3 );
 		} );
 	} );
+
+	describe( 'attributes interface', () => {
+		let node;
+
+		beforeEach( () => {
+			node = new Element();
+		} );
+
+		describe( 'setAttribute', () => {
+			it( 'should set given attribute on the element', () => {
+				node.setAttribute( 'foo', 'bar' );
+
+				expect( node.getAttribute( 'foo' ) ).to.equal( 'bar' );
+			} );
+		} );
+
+		describe( 'setAttributesTo', () => {
+			it( 'should remove all attributes set on element and set the given ones', () => {
+				node.setAttribute( 'abc', 'xyz' );
+				node.setAttributesTo( { foo: 'bar' } );
+
+				expect( node.getAttribute( 'foo' ) ).to.equal( 'bar' );
+				expect( node.getAttribute( 'abc' ) ).to.be.undefined;
+			} );
+		} );
+
+		describe( 'removeAttribute', () => {
+			it( 'should remove attribute set on the element and return true', () => {
+				node.setAttribute( 'foo', 'bar' );
+				let result = node.removeAttribute( 'foo' );
+
+				expect( node.getAttribute( 'foo' ) ).to.be.undefined;
+				expect( result ).to.be.true;
+			} );
+
+			it( 'should return false if element does not contain given attribute', () => {
+				let result = node.removeAttribute( 'foo' );
+
+				expect( result ).to.be.false;
+			} );
+		} );
+
+		describe( 'clearAttributes', () => {
+			it( 'should remove all attributes from the element', () => {
+				node.setAttribute( 'foo', 'bar' );
+				node.setAttribute( 'abc', 'xyz' );
+
+				node.clearAttributes();
+
+				expect( node.getAttribute( 'foo' ) ).to.be.undefined;
+				expect( node.getAttribute( 'abc' ) ).to.be.undefined;
+			} );
+		} );
+	} );
 } );

+ 46 - 11
packages/ckeditor5-engine/tests/treemodel/node.js

@@ -8,8 +8,6 @@
 'use strict';
 
 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';
 
 describe( 'Node', () => {
@@ -85,20 +83,17 @@ describe( 'Node', () => {
 		it( 'should create empty attribute list if no parameters were passed', () => {
 			let foo = new Element( 'foo' );
 
-			expect( foo.attrs ).to.be.instanceof( AttributeList );
-			expect( foo.attrs.size ).to.equal( 0 );
+			expect( foo._attrs ).to.be.instanceof( Map );
+			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 attrs = { foo: true, bar: false };
 			let foo = new Element( 'foo', attrs );
 
-			expect( foo.attrs.size ).to.equal( 2 );
-			expect( foo.attrs.getValue( 'foo' ) ).to.equal( true );
-			expect( foo.attrs.getValue( 'bar' ) ).to.equal( false );
+			expect( foo._attrs.size ).to.equal( 2 );
+			expect( foo.getAttribute( 'foo' ) ).to.equal( true );
+			expect( foo.getAttribute( 'bar' ) ).to.equal( false );
 		} );
 	} );
 
@@ -153,4 +148,44 @@ describe( 'Node', () => {
 			expect( charR.getPath() ).to.deep.equal( [ 1, 3 ] );
 		} );
 	} );
+
+	describe( 'attributes interface', () => {
+		let node = new Element( 'p', { foo: 'bar' } );
+
+		describe( 'hasAttribute', () => {
+			it( 'should return true if element contains attribute with given key', () => {
+				expect( node.hasAttribute( 'foo' ) ).to.be.true;
+			} );
+
+			it( 'should return false if element does not contain attribute with given key', () => {
+				expect( node.hasAttribute( 'bar' ) ).to.be.false;
+			} );
+		} );
+
+		describe( 'getAttribute', () => {
+			it( 'should return attribute value for given key if element contains given attribute', () => {
+				expect( node.getAttribute( 'foo' ) ).to.equal( 'bar' );
+			} );
+
+			it( 'should return undefined if element does not contain given attribute', () => {
+				expect( node.getAttribute( 'bar' ) ).to.be.undefined;
+			} );
+		} );
+
+		describe( 'getAttributes', () => {
+			it( 'should return an iterator that iterates over all attributes set on the element', () => {
+				let it = node.getAttributes();
+				let attrs = [];
+
+				let step = it.next();
+
+				while ( !step.done ) {
+					attrs.push( step.value );
+					step = it.next();
+				}
+
+				expect( attrs ).to.deep.equal( [ [ 'foo', 'bar' ] ] );
+			} );
+		} );
+	} );
 } );

+ 76 - 20
packages/ckeditor5-engine/tests/treemodel/nodelist.js

@@ -10,7 +10,7 @@
 import NodeList from '/ckeditor5/core/treemodel/nodelist.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';
+import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
 
 describe( 'NodeList', () => {
 	describe( 'constructor', () => {
@@ -40,16 +40,16 @@ describe( 'NodeList', () => {
 		} );
 
 		it( 'should change text with attribute into a set of nodes', () => {
-			let attr = new Attribute( 'bold', true );
+			let attr = { bold: true };
 			let nodeList = new NodeList( new Text( 'foo', [ attr ] ) );
 
 			expect( nodeList.length ).to.equal( 3 );
 			expect( nodeList.get( 0 ).character ).to.equal( 'f' );
-			expect( nodeList.get( 0 ).attrs.getValue( attr.key ) ).to.equal( attr.value );
+			expect( nodeList.get( 0 ).getAttribute( 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( 1 ).getAttribute( 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 );
+			expect( nodeList.get( 2 ).getAttribute( attr.key ) ).to.equal( attr.value );
 		} );
 
 		it( 'should change array of characters into a set of nodes', () => {
@@ -68,7 +68,7 @@ describe( 'NodeList', () => {
 		} );
 
 		it( 'should omit empty strings / texts', () => {
-			let nodeList = new NodeList( [ 'fo', '', 'ob', new Text( '', [ new Attribute( 'foo', true ) ] ), 'ar' ] );
+			let nodeList = new NodeList( [ 'fo', '', 'ob', new Text( '', { foo: true } ), 'ar' ] );
 
 			expect( nodeList.length ).to.equal( 6 );
 			expect( nodeList.get( 0 ).character ).to.equal( 'f' );
@@ -78,16 +78,16 @@ describe( 'NodeList', () => {
 			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 );
+			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 attr = { 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 );
@@ -124,7 +124,7 @@ describe( 'NodeList', () => {
 		} );
 
 		it( 'should merge inserted text objects if possible', () => {
-			let attr = new Attribute( 'foo', 'bar' );
+			let attr = { foo: 'bar' };
 			let outerList = new NodeList( [ 'foo', new Text( 'bar', [ attr ] ) ] );
 			let innerList = new NodeList( [ 'x' , new Text( 'y', [ attr ] ) ] );
 
@@ -155,7 +155,7 @@ describe( 'NodeList', () => {
 		} );
 
 		it( 'should merge text objects left in node list possible', () => {
-			let attr = new Attribute( 'foo', 'bar' );
+			let attr = { foo: 'bar' };
 			let nodeList = new NodeList( [ 'foo', new Text( 'xxx', [ attr ] ), 'bar' ] );
 
 			nodeList.remove( 2, 5 );
@@ -220,6 +220,61 @@ describe( 'NodeList', () => {
 		} );
 	} );
 
+	describe( 'setAttribute', () => {
+		it( 'should change attribute for multiple items in node list but not for their children', () => {
+			let p = new Element( 'p', [], 'x' );
+			let div = new Element( 'div' );
+
+			let nodeList = new NodeList( [ p, 'foo', div, 'bar' ] );
+
+			nodeList.setAttribute( 0, 6, 'a', 'true' );
+
+			// Attribute set.
+			expect( p.hasAttribute( 'a' ) ).to.be.true;
+			expect( nodeList.get( 1 ).hasAttribute( 'a' ) ).to.be.true;
+			expect( nodeList.get( 2 ).hasAttribute( 'a' ) ).to.be.true;
+			expect( nodeList.get( 3 ).hasAttribute( 'a' ) ).to.be.true;
+			expect( div.hasAttribute( 'a' ) ).to.be.true;
+			expect( nodeList.get( 5 ).hasAttribute( 'a' ) ).to.be.true;
+			expect( nodeList.get( 6 ).hasAttribute( 'a' ) ).to.be.false;
+			expect( nodeList.get( 7 ).hasAttribute( 'a' ) ).to.be.false;
+
+			// Attribute not set for children.
+			expect( p.getChild( 0 ).hasAttribute( 'a' ) ).to.be.false;
+		} );
+
+		it( 'should remove attribute if no new attribute has been passed', () => {
+			let p = new Element( 'p', { a: true } );
+			let text = new Text( 'foobar', { a: true } );
+			let nodeList = new NodeList( [ p, text ] );
+
+			nodeList.setAttribute( 0, 4, 'a', null );
+
+			expect( p.hasAttribute( 'a' ) ).to.be.false;
+			expect( nodeList.get( 1 ).hasAttribute( 'a' ) ).to.be.false;
+			expect( nodeList.get( 2 ).hasAttribute( 'a' ) ).to.be.false;
+			expect( nodeList.get( 3 ).hasAttribute( 'a' ) ).to.be.false;
+			expect( nodeList.get( 4 ).hasAttribute( 'a' ) ).to.be.true;
+			expect( nodeList.get( 5 ).hasAttribute( 'a' ) ).to.be.true;
+
+			expect( nodeList._nodes.length ).to.equal( 3 );
+		} );
+
+		it( 'should throw if wrong index or number is passed', () => {
+			let attr = { a: true };
+			let text = new Text( 'foo', [ attr ] );
+			let nodeList = new NodeList( text );
+
+			expect( () => {
+				nodeList.setAttribute( -1, 2, attr.key, null );
+			} ).to.throw( CKEditorError, /nodelist-setattribute-out-of-bounds/ );
+
+			expect( () => {
+				nodeList.setAttribute( 2, 2, attr.key, null );
+			} ).to.throw( CKEditorError, /nodelist-setattribute-out-of-bounds/ );
+		} );
+	} );
+
 	describe( '_splitNodeAt', () => {
 		it( 'should split text object into two text objects', () => {
 			let nodeList = new NodeList( 'abcd' );
@@ -231,7 +286,7 @@ describe( 'NodeList', () => {
 		} );
 
 		it( 'should do nothing if node before and after index are different', () => {
-			let nodeList = new NodeList( [ new Text( 'ab', [ new Attribute( 'foo', true ) ] ), 'cd' ] );
+			let nodeList = new NodeList( [ new Text( 'ab', { foo: true } ), 'cd' ] );
 			nodeList._splitNodeAt( 2 );
 
 			expect( nodeList._nodes.length ).to.equal( 2 );
@@ -242,12 +297,12 @@ describe( 'NodeList', () => {
 
 	describe( '_mergeNodeAt', () => {
 		it( 'should merge two text object if they have same attributes', () => {
-			let attr = new Attribute( 'foo', true );
+			let attr = { 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._nodes[ 1 ]._attrs.delete( attr.key );
 			nodeList._mergeNodeAt( 2 );
 
 			expect( nodeList._nodes.length ).to.equal( 1 );
@@ -255,7 +310,8 @@ describe( 'NodeList', () => {
 		} );
 
 		it( 'should do nothing if text objects has different attributes', () => {
-			let nodeList = new NodeList( [ new Text( 'ab', [ new Attribute( 'foo', true ) ] ), 'cd' ] );
+			let nodeList = new NodeList( [ new Text( 'ab', { foo: true } ), 'cd' ] );
+
 			nodeList._mergeNodeAt( 2 );
 
 			expect( nodeList._nodes.length ).to.equal( 2 );
@@ -266,7 +322,7 @@ describe( 'NodeList', () => {
 
 	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 nodeList = new NodeList( [ new Text( 'ab', { foo: true } ), 'cd' ] );
 			let charIndexC = nodeList._getCharIndex( 2 );
 			let charIndexD = nodeList._getCharIndex( 3 );
 

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

@@ -12,7 +12,6 @@ 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 Attribute from '/ckeditor5/core/treemodel/attribute.js';
 import Text from '/ckeditor5/core/treemodel/text.js';
 import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
 
@@ -27,8 +26,9 @@ describe( 'AttributeOperation', () => {
 	it( 'should have proper type', () => {
 		const op = new AttributeOperation(
 			new Range( new Position( root, [ 0 ] ), new Position( root, [ 2 ] ) ),
+			'isNew',
 			null,
-			new Attribute( 'isNew', true ),
+			true,
 			doc.version
 		);
 
@@ -36,111 +36,98 @@ describe( 'AttributeOperation', () => {
 	} );
 
 	it( 'should insert attribute to the set of nodes', () => {
-		let newAttr = new Attribute( 'isNew', true );
-
 		root.insertChildren( 0, 'bar' );
 
 		doc.applyOperation(
 			new AttributeOperation(
 				new Range( new Position( root, [ 0 ] ), new Position( root, [ 2 ] ) ),
+				'isNew',
 				null,
-				newAttr,
+				true,
 				doc.version
 			)
 		);
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 3 );
-		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 );
+		expect( root.getChild( 0 ).hasAttribute( 'isNew' ) ).to.be.true;
+		expect( root.getChild( 1 ).hasAttribute( 'isNew' ) ).to.be.true;
+		expect( root.getChild( 2 )._attrs.size ).to.equal( 0 );
 	} );
 
 	it( 'should add attribute to the existing attributes', () => {
-		let newAttr = new Attribute( 'isNew', true );
-		let fooAttr = new Attribute( 'foo', true );
-		let barAttr = new Attribute( 'bar', true );
-
-		root.insertChildren( 0, new Text( 'x', [ fooAttr, barAttr ] ) );
+		root.insertChildren( 0, new Text( 'x', { foo: true, bar: true } ) );
 
 		doc.applyOperation(
 			new AttributeOperation(
 				new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
+				'isNew',
 				null,
-				newAttr,
+				true,
 				doc.version
 			)
 		);
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 1 );
-		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;
+		expect( root.getChild( 0 )._attrs.size ).to.equal( 3 );
+		expect( root.getChild( 0 ).hasAttribute( 'isNew' ) ).to.be.true;
+		expect( root.getChild( 0 ).hasAttribute( 'foo' ) ).to.be.true;
+		expect( root.getChild( 0 ).hasAttribute( 'bar' ) ).to.be.true;
 	} );
 
 	it( 'should change attribute to the set of nodes', () => {
-		let oldAttr = new Attribute( 'isNew', false );
-		let newAttr = new Attribute( 'isNew', true );
-
-		root.insertChildren( 0, new Text( 'bar', [ oldAttr ] ) );
+		root.insertChildren( 0, new Text( 'bar', { isNew: false } ) );
 
 		doc.applyOperation(
 			new AttributeOperation(
 				new Range( new Position( root, [ 0 ] ), new Position( root, [ 2 ] ) ),
-				oldAttr,
-				newAttr,
+				'isNew',
+				false,
+				true,
 				doc.version
 			)
 		);
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 3 );
-		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;
+		expect( root.getChild( 0 )._attrs.size ).to.equal( 1 );
+		expect( root.getChild( 0 ).getAttribute( 'isNew' ) ).to.be.true;
+		expect( root.getChild( 1 )._attrs.size ).to.equal( 1 );
+		expect( root.getChild( 1 ).getAttribute( 'isNew' ) ).to.be.true;
+		expect( root.getChild( 2 )._attrs.size ).to.equal( 1 );
+		expect( root.getChild( 2 ).getAttribute( 'isNew' ) ).to.be.false;
 	} );
 
 	it( 'should change attribute in the middle of existing attributes', () => {
-		let fooAttr = new Attribute( 'foo', true );
-		let x1Attr = new Attribute( 'x', 1 );
-		let x2Attr = new Attribute( 'x', 2 );
-		let barAttr = new Attribute( 'bar', true );
-
-		root.insertChildren( 0, new Text( 'x', [ fooAttr, x1Attr, barAttr ] ) );
+		root.insertChildren( 0, new Text( 'x', { foo: true, x: 1, bar: true } ) );
 
 		doc.applyOperation(
 			new AttributeOperation(
 				new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
-				x1Attr,
-				x2Attr,
+				'x',
+				1,
+				2,
 				doc.version
 			)
 		);
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 1 );
-		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;
+		expect( root.getChild( 0 )._attrs.size ).to.equal( 3 );
+		expect( root.getChild( 0 ).getAttribute( 'foo' ) ).to.be.true;
+		expect( root.getChild( 0 ).getAttribute( 'x' ) ).to.equal( 2 );
+		expect( root.getChild( 0 ).getAttribute( 'bar' ) ).to.be.true;
 	} );
 
 	it( 'should remove attribute', () => {
-		let fooAttr = new Attribute( 'foo', true );
-		let xAttr = new Attribute( 'x', true );
-		let barAttr = new Attribute( 'bar', true );
-
-		root.insertChildren( 0, new Text( 'x', [ fooAttr, xAttr, barAttr ] ) );
+		root.insertChildren( 0, new Text( 'x', { foo: true, x: true, bar: true } ) );
 
 		doc.applyOperation(
 			new AttributeOperation(
 				new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
-				xAttr,
+				'x',
+				true,
 				null,
 				doc.version
 			)
@@ -148,34 +135,32 @@ describe( 'AttributeOperation', () => {
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 1 );
-		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;
+		expect( root.getChild( 0 )._attrs.size ).to.equal( 2 );
+		expect( root.getChild( 0 ).hasAttribute( 'foo' ) ).to.be.true;
+		expect( root.getChild( 0 ).hasAttribute( 'bar' ) ).to.be.true;
 	} );
 
 	it( 'should create an AttributeOperation as a reverse', () => {
-		let oldAttr = new Attribute( 'x', 'old' );
-		let newAttr = new Attribute( 'x', 'new' );
 		let range = new Range( new Position( root, [ 0 ] ), new Position( root, [ 3 ] ) );
-		let operation = new AttributeOperation( range, oldAttr, newAttr, doc.version );
+		let operation = new AttributeOperation( range, 'x', 'old', 'new', doc.version );
 		let reverse = operation.getReversed();
 
 		expect( reverse ).to.be.an.instanceof( AttributeOperation );
 		expect( reverse.baseVersion ).to.equal( 1 );
 		expect( reverse.range.isEqual( range ) ).to.be.true;
-		expect( reverse.oldAttr ).to.equal( newAttr );
-		expect( reverse.newAttr ).to.equal( oldAttr );
+		expect( reverse.key ).to.equal( 'x' );
+		expect( reverse.oldValue ).to.equal( 'new' );
+		expect( reverse.newValue ).to.equal( 'old' );
 	} );
 
 	it( 'should undo adding attribute by applying reverse operation', () => {
-		let newAttr = new Attribute( 'isNew', true );
-
 		root.insertChildren( 0, 'bar' );
 
 		let operation = new AttributeOperation(
 			new Range( new Position( root, [ 0 ] ), new Position( root, [ 3 ] ) ),
+			'isNew',
 			null,
-			newAttr,
+			true,
 			doc.version
 		);
 
@@ -186,14 +171,12 @@ describe( 'AttributeOperation', () => {
 
 		expect( doc.version ).to.equal( 2 );
 		expect( root.getChildCount() ).to.equal( 3 );
-		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 );
+		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', () => {
-		let fooAttr = new Attribute( 'foo', true );
-
 		let eleA = new Element( 'a', [], 'abc' );
 		let eleB = new Element( 'b', [], 'xyz' );
 
@@ -202,72 +185,70 @@ describe( 'AttributeOperation', () => {
 		doc.applyOperation(
 			new AttributeOperation(
 				new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 1, 2 ] ) ),
+				'foo',
 				null,
-				fooAttr,
+				true,
 				doc.version
 			)
 		);
 
-		expect( root.getChild( 0 ).attrs.has( fooAttr ) ).to.be.false;
+		expect( root.getChild( 0 ).hasAttribute( 'foo' ) ).to.be.false;
 	} );
 
 	it( 'should not remove attribute of element if change range starts in the middle of that element', () => {
-		let fooAttr = new Attribute( 'foo', true );
+		let fooAttr = { foo: true };
 
-		let eleA = new Element( 'a', [ fooAttr ], 'abc' );
-		let eleB = new Element( 'b', [ fooAttr ], 'xyz' );
+		let eleA = new Element( 'a', fooAttr, 'abc' );
+		let eleB = new Element( 'b', fooAttr, 'xyz' );
 
 		root.insertChildren( 0, [ eleA, eleB ] );
 
 		doc.applyOperation(
 			new AttributeOperation(
 				new Range( new Position( root, [ 0, 3 ] ), new Position( root, [ 1, 0 ] ) ),
-				fooAttr,
+				'foo',
+				true,
 				null,
 				doc.version
 			)
 		);
 
-		expect( root.getChild( 0 ).attrs.has( fooAttr ) ).to.be.true;
+		expect( root.getChild( 0 ).hasAttribute( 'foo' ) ).to.be.true;
 	} );
 
 	it( 'should undo changing attribute by applying reverse operation', () => {
-		let oldAttr = new Attribute( 'isNew', false );
-		let newAttr = new Attribute( 'isNew', true );
-
-		root.insertChildren( 0, new Text( 'bar', [ oldAttr ] ) );
+		root.insertChildren( 0, new Text( 'bar', { isNew: false } ) );
 
 		let operation = new AttributeOperation(
 			new Range( new Position( root, [ 0 ] ), new Position( root, [ 3 ] ) ),
-			oldAttr,
-			newAttr,
+			'isNew',
+			false,
+			true,
 			doc.version
 		);
 
 		let reverse = operation.getReversed();
 
 		doc.applyOperation( operation );
-
 		doc.applyOperation( reverse );
 
 		expect( doc.version ).to.equal( 2 );
 		expect( root.getChildCount() ).to.equal( 3 );
-		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;
+		expect( root.getChild( 0 )._attrs.size ).to.equal( 1 );
+		expect( root.getChild( 0 ).getAttribute( 'isNew' ) ).to.be.false;
+		expect( root.getChild( 1 )._attrs.size ).to.equal( 1 );
+		expect( root.getChild( 1 ).getAttribute( 'isNew' ) ).to.be.false;
+		expect( root.getChild( 2 )._attrs.size ).to.equal( 1 );
+		expect( root.getChild( 2 ).getAttribute( 'isNew' ) ).to.be.false;
 	} );
 
 	it( 'should undo remove attribute by applying reverse operation', () => {
-		let fooAttr = new Attribute( 'foo', false );
-
-		root.insertChildren( 0, new Text( 'bar', [ fooAttr ] ) );
+		root.insertChildren( 0, new Text( 'bar', { foo: true } ) );
 
 		let operation = new AttributeOperation(
 			new Range( new Position( root, [ 0 ] ), new Position( root, [ 3 ] ) ),
-			fooAttr,
+			'foo',
+			true,
 			null,
 			doc.version
 		);
@@ -275,29 +256,27 @@ describe( 'AttributeOperation', () => {
 		let reverse = operation.getReversed();
 
 		doc.applyOperation( operation );
-
 		doc.applyOperation( reverse );
 
 		expect( doc.version ).to.equal( 2 );
 		expect( root.getChildCount() ).to.equal( 3 );
-		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;
+		expect( root.getChild( 0 )._attrs.size ).to.equal( 1 );
+		expect( root.getChild( 0 ).getAttribute( 'foo' ) ).to.be.true;
+		expect( root.getChild( 1 )._attrs.size ).to.equal( 1 );
+		expect( root.getChild( 1 ).getAttribute( 'foo' ) ).to.be.true;
+		expect( root.getChild( 2 )._attrs.size ).to.equal( 1 );
+		expect( root.getChild( 2 ).getAttribute( 'foo' ) ).to.be.true;
 	} );
 
 	it( 'should throw an error when one try to remove and the attribute does not exists', () => {
-		let fooAttr = new Attribute( 'foo', true );
-
 		root.insertChildren( 0, 'x' );
 
 		expect( () => {
 			doc.applyOperation(
 				new AttributeOperation(
 					new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
-					fooAttr,
+					'foo',
+					true,
 					null,
 					doc.version
 				)
@@ -306,48 +285,26 @@ describe( 'AttributeOperation', () => {
 	} );
 
 	it( 'should throw an error when one try to insert and the attribute already exists', () => {
-		let x1Attr = new Attribute( 'x', 1 );
-		let x2Attr = new Attribute( 'x', 2 );
-
-		root.insertChildren( 0, new Text( 'x', [ x1Attr ] ) );
+		root.insertChildren( 0, new Text( 'x', { x: 1 } ) );
 
 		expect( () => {
 			doc.applyOperation(
 				new AttributeOperation(
 					new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
+					'x',
 					null,
-					x2Attr,
+					2,
 					doc.version
 				)
 			);
 		} ).to.throw( CKEditorError, /operation-attribute-attr-exists/ );
 	} );
 
-	it( 'should throw an error when one try to change and the new and old attributes have different keys', () => {
-		let fooAttr = new Attribute( 'foo', true );
-		let barAttr = new Attribute( 'bar', true );
-
-		root.insertChildren( 0, 'x' );
-
-		expect( () => {
-			doc.applyOperation(
-				new AttributeOperation(
-					new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
-					fooAttr,
-					barAttr,
-					doc.version
-				)
-			);
-		} ).to.throw( CKEditorError, /operation-attribute-different-keys/ );
-	} );
-
 	it( 'should create an AttributeOperation with the same parameters when cloned', () => {
 		let range = new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) );
-		let oldAttr = new Attribute( 'foo', 'old' );
-		let newAttr = new Attribute( 'foo', 'bar' );
 		let baseVersion = doc.version;
 
-		let op = new AttributeOperation( range, oldAttr, newAttr, baseVersion );
+		let op = new AttributeOperation( range, 'foo', 'old', 'new', baseVersion );
 
 		let clone = op.clone();
 
@@ -356,23 +313,25 @@ describe( 'AttributeOperation', () => {
 
 		expect( clone ).to.be.instanceof( AttributeOperation );
 		expect( clone.range.isEqual( range ) ).to.be.true;
-		expect( clone.oldAttr.isEqual( oldAttr ) ).to.be.true;
-		expect( clone.newAttr.isEqual( newAttr ) ).to.be.true;
+		expect( clone.key ).to.equal( 'foo' );
+		expect( clone.oldValue ).to.equal( 'old' );
+		expect( clone.newValue ).to.equal( 'new' );
 		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' );
+		let attrA = { foo: 'a' };
+		let attrB = { foo: 'b' };
 
-		root.insertChildren( 0, new Text( 'abc', [ attrA ] ) );
-		root.insertChildren( 3, new Text( 'xyz', [ attrB ] ) );
+		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,
+				'foo',
+				'a',
+				'b',
 				doc.version
 			)
 		);

+ 65 - 49
packages/ckeditor5-engine/tests/treemodel/operation/transform.js

@@ -11,7 +11,6 @@ import RootElement from '/ckeditor5/core/treemodel/rootelement.js';
 import Node from '/ckeditor5/core/treemodel/node.js';
 import Position from '/ckeditor5/core/treemodel/position.js';
 import Range from '/ckeditor5/core/treemodel/range.js';
-import Attribute from '/ckeditor5/core/treemodel/attribute.js';
 import transform from '/ckeditor5/core/treemodel/operation/transform.js';
 import InsertOperation from '/ckeditor5/core/treemodel/operation/insertoperation.js';
 import AttributeOperation from '/ckeditor5/core/treemodel/operation/attributeoperation.js';
@@ -169,8 +168,9 @@ describe( 'transform', () => {
 			it( 'no position update', () => {
 				let transformBy = new AttributeOperation(
 					Range.createFromPositionAndShift( position, 2 ),
+					'foo',
 					null,
-					new Attribute( 'foo', 'bar' ),
+					'bar',
 					baseVersion
 				);
 
@@ -385,19 +385,14 @@ describe( 'transform', () => {
 	} );
 
 	describe( 'AttributeOperation', () => {
-		let start, end, range, oldAttr, newAttr, anotherOldAttr, anotherNewAttr;
+		let start, end, range;
 
 		beforeEach( () => {
-			oldAttr = new Attribute( 'foo', 'abc' );
-			newAttr = new Attribute( 'foo', 'bar' );
-
-			anotherOldAttr = new Attribute( oldAttr.key, 'another' );
-			anotherNewAttr = new Attribute( oldAttr.key, 'anothernew' );
-
 			expected = {
 				type: AttributeOperation,
-				oldAttr: oldAttr,
-				newAttr: newAttr,
+				key: 'foo',
+				oldValue: 'abc',
+				newValue: 'bar',
 				baseVersion: baseVersion + 1
 			};
 		} );
@@ -409,7 +404,7 @@ describe( 'transform', () => {
 
 				range = new Range( start, end );
 
-				op = new AttributeOperation( range, oldAttr, newAttr, baseVersion );
+				op = new AttributeOperation( range, 'foo', 'abc', 'bar', baseVersion );
 
 				expected.range = new Range( start, end );
 			} );
@@ -527,8 +522,9 @@ describe( 'transform', () => {
 				it( 'attributes have different key: no operation update', () => {
 					let transformBy = new AttributeOperation(
 						Range.createFromRange( range ),
-						new Attribute( 'abc', true ),
-						new Attribute( 'abc', false ),
+						'abc',
+						true,
+						false,
 						baseVersion
 					);
 
@@ -538,43 +534,52 @@ describe( 'transform', () => {
 					expectOperation( transOp[ 0 ], expected );
 				} );
 
-				it( 'attributes set same value: no operation update', () => {
+				it( 'same key and value: convert to NoOperation', () => {
 					let transformBy = new AttributeOperation(
 						Range.createFromRange( range ),
-						oldAttr,
-						newAttr,
+						'foo',
+						'abc',
+						'bar',
 						baseVersion
 					);
 
 					let transOp = transform( op, transformBy );
 
 					expect( transOp.length ).to.equal( 1 );
-					expectOperation( transOp[ 0 ], expected );
+					expectOperation( transOp[ 0 ], {
+						type: NoOperation,
+						baseVersion: baseVersion + 1
+					} );
 				} );
 
-				it( 'both operations removes attribute: no operation update', () => {
-					op.newAttr = null;
+				it( 'both operations removes same attribute: convert to NoOperation', () => {
+					op.newValue = null;
 
 					let transformBy = new AttributeOperation(
 						new Range( new Position( root, [ 1, 1, 4 ] ), new Position( root, [ 3 ] ) ),
-						anotherOldAttr,
+						'foo',
+						'another',
 						null,
 						baseVersion
 					);
 
 					let transOp = transform( op, transformBy, true );
 
-					expected.newAttr = null;
+					expected.newValue = null;
 
 					expect( transOp.length ).to.equal( 1 );
-					expectOperation( transOp[ 0 ], expected );
+					expectOperation( transOp[ 0 ], {
+						type: NoOperation,
+						baseVersion: baseVersion + 1
+					} );
 				} );
 
 				describe( 'that is less important and', () => {
 					it( 'range does not intersect original range: no operation update', () => {
 						let transformBy = new AttributeOperation(
 							new Range( new Position( root, [ 3, 0 ] ), new Position( root, [ 4 ] ) ),
-							anotherOldAttr,
+							'foo',
+							'another',
 							null,
 							baseVersion
 						);
@@ -585,32 +590,34 @@ describe( 'transform', () => {
 						expectOperation( transOp[ 0 ], expected );
 					} );
 
-					it( 'range contains original range: update oldAttr', () => {
+					it( 'range contains original range: update oldValue', () => {
 						let transformBy = new AttributeOperation(
 							new Range( new Position( root, [ 1, 1, 4 ] ), new Position( root, [ 3 ] ) ),
-							anotherOldAttr,
+							'foo',
+							'another',
 							null,
 							baseVersion
 						);
 
 						let transOp = transform( op, transformBy, true );
 
-						expected.oldAttr = anotherOldAttr;
+						expected.oldValue = 'another';
 
 						expect( transOp.length ).to.equal( 1 );
 						expectOperation( transOp[ 0 ], expected );
 					} );
 
 					// [ original range   <   ]   range transformed by >
-					it( 'range intersects on left: split into two operations, update oldAttr', () => {
+					it( 'range intersects on left: split into two operations, update oldValue', () => {
 						// Get more test cases and better code coverage
-						op.newAttr = null;
+						op.newValue = null;
 
 						let transformBy = new AttributeOperation(
 							new Range( new Position( root, [ 1, 4, 2 ] ), new Position( root, [ 3 ] ) ),
-							anotherOldAttr,
+							'foo',
+							'another',
 							// Get more test cases and better code coverage
-							anotherNewAttr,
+							'anothernew',
 							baseVersion
 						);
 
@@ -618,7 +625,7 @@ describe( 'transform', () => {
 
 						expect( transOp.length ).to.equal( 2 );
 
-						expected.newAttr = null;
+						expected.newValue = null;
 
 						expected.range.end.path = [ 1, 4, 2 ];
 
@@ -626,17 +633,18 @@ describe( 'transform', () => {
 
 						expected.range.start.path = [ 1, 4, 2 ];
 						expected.range.end = op.range.end;
-						expected.oldAttr = anotherOldAttr;
+						expected.oldValue = 'another';
 						expected.baseVersion++;
 
 						expectOperation( transOp[ 1 ], expected );
 					} );
 
 					// [  range transformed by  <   ]  original range  >
-					it( 'range intersects on right: split into two operations, update oldAttr', () => {
+					it( 'range intersects on right: split into two operations, update oldValue', () => {
 						let transformBy = new AttributeOperation(
 							new Range( new Position( root, [ 1 ] ), new Position( root, [ 2, 1 ] ) ),
-							anotherOldAttr,
+							'foo',
+							'another',
 							null,
 							baseVersion
 						);
@@ -651,16 +659,17 @@ describe( 'transform', () => {
 
 						expected.range.start = op.range.start;
 						expected.range.end.path = [ 2, 1 ];
-						expected.oldAttr = anotherOldAttr;
+						expected.oldValue = 'another';
 						expected.baseVersion++;
 
 						expectOperation( transOp[ 1 ], expected );
 					} );
 
-					it( 'range is inside original range: split into three operations, update oldAttr', () => {
+					it( 'range is inside original range: split into three operations, update oldValue', () => {
 						let transformBy = new AttributeOperation(
 							new Range( new Position( root, [ 1, 4, 1 ] ), new Position( root, [ 2, 1 ] ) ),
-							anotherOldAttr,
+							'foo',
+							'another',
 							null,
 							baseVersion
 						);
@@ -681,7 +690,7 @@ describe( 'transform', () => {
 
 						expected.range.start.path = [ 1, 4, 1 ];
 						expected.range.end.path = [ 2, 1 ];
-						expected.oldAttr = anotherOldAttr;
+						expected.oldValue = 'another';
 						expected.baseVersion++;
 
 						expectOperation( transOp[ 2 ], expected );
@@ -692,7 +701,8 @@ describe( 'transform', () => {
 					it( 'range does not intersect original range: no operation update', () => {
 						let transformBy = new AttributeOperation(
 							new Range( new Position( root, [ 3, 0 ] ), new Position( root, [ 4 ] ) ),
-							oldAttr,
+							'foo',
+							'abc',
 							null,
 							baseVersion
 						);
@@ -706,7 +716,8 @@ describe( 'transform', () => {
 					it( 'range contains original range: convert to NoOperation', () => {
 						let transformBy = new AttributeOperation(
 							new Range( new Position( root, [ 1, 1, 4 ] ), new Position( root, [ 3 ] ) ),
-							oldAttr,
+							'foo',
+							'abc',
 							null,
 							baseVersion
 						);
@@ -724,7 +735,8 @@ describe( 'transform', () => {
 					it( 'range intersects on left: shrink original range', () => {
 						let transformBy = new AttributeOperation(
 							new Range( new Position( root, [ 1, 4, 2 ] ), new Position( root, [ 3 ] ) ),
-							oldAttr,
+							'foo',
+							'abc',
 							null,
 							baseVersion
 						);
@@ -741,7 +753,8 @@ describe( 'transform', () => {
 					it( 'range intersects on right: shrink original range', () => {
 						let transformBy = new AttributeOperation(
 							new Range( new Position( root, [ 1 ] ), new Position( root, [ 2, 1 ] ) ),
-							oldAttr,
+							'foo',
+							'abc',
 							null,
 							baseVersion
 						);
@@ -757,7 +770,8 @@ describe( 'transform', () => {
 					it( 'range is inside original range: split into two operations', () => {
 						let transformBy = new AttributeOperation(
 							new Range( new Position( root, [ 1, 4, 1 ] ), new Position( root, [ 2, 1 ] ) ),
-							oldAttr,
+							'foo',
+							'abc',
 							null,
 							baseVersion
 						);
@@ -1048,7 +1062,7 @@ describe( 'transform', () => {
 
 				range = new Range( start, end );
 
-				op = new AttributeOperation( range, oldAttr, newAttr, baseVersion );
+				op = new AttributeOperation( range, 'foo', 'abc', 'bar', baseVersion );
 
 				expected.range = new Range( start, end );
 			} );
@@ -1515,8 +1529,9 @@ describe( 'transform', () => {
 			it( 'no operation update', () => {
 				let transformBy = new AttributeOperation(
 					new Range( sourcePosition, rangeEnd ),
-					new Attribute( 'abc', true ),
-					new Attribute( 'abc', false ),
+					'abc',
+					true,
+					false,
 					baseVersion
 				);
 
@@ -2314,8 +2329,9 @@ describe( 'transform', () => {
 						new Position( root, [ 0 ] ),
 						new Position( root, [ 1 ] )
 					),
-					new Attribute( 'foo', 'bar' ),
-					new Attribute( 'foo', 'xyz' ),
+					'foo',
+					'bar',
+					'xyz',
 					baseVersion
 				);
 

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

@@ -19,7 +19,7 @@ describe( 'Element', () => {
 
 			expect( root ).to.be.an.instanceof( Element );
 			expect( root ).to.have.property( 'document' ).that.equals( doc );
-			expect( root.attrs.size ).to.equal( 0 );
+			expect( root._attrs.size ).to.equal( 0 );
 			expect( root.getChildCount() ).to.equal( 0 );
 		} );
 	} );

+ 88 - 5
packages/ckeditor5-engine/tests/treemodel/selection.js

@@ -8,8 +8,6 @@
 'use strict';
 
 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';
@@ -23,7 +21,7 @@ describe( 'Selection', () => {
 	let attrFooBar;
 
 	before( () => {
-		attrFooBar = new Attribute( 'foo', 'bar' );
+		attrFooBar = { foo: 'bar' };
 	} );
 
 	let doc, root, selection, liveRange, range;
@@ -48,8 +46,8 @@ describe( 'Selection', () => {
 		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 );
+		expect( selection._attrs ).to.be.instanceof( Map );
+		expect( selection._attrs.size ).to.equal( 0 );
 	} );
 
 	it( 'should be collapsed if it has no ranges or all ranges are collapsed', () => {
@@ -411,4 +409,89 @@ describe( 'Selection', () => {
 			} );
 		} );
 	} );
+
+	describe( 'attributes interface', () => {
+		describe( 'setAttribute', () => {
+			it( 'should set given attribute on the selection', () => {
+				selection.setAttribute( 'foo', 'bar' );
+
+				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
+			} );
+		} );
+
+		describe( 'hasAttribute', () => {
+			it( 'should return true if element contains attribute with given key', () => {
+				selection.setAttribute( 'foo', 'bar' );
+
+				expect( selection.hasAttribute( 'foo' ) ).to.be.true;
+			} );
+
+			it( 'should return false if element does not contain attribute with given key', () => {
+				expect( selection.hasAttribute( 'abc' ) ).to.be.false;
+			} );
+		} );
+
+		describe( 'getAttribute', () => {
+			it( 'should return undefined if element does not contain given attribute', () => {
+				expect( selection.getAttribute( 'abc' ) ).to.be.undefined;
+			} );
+		} );
+
+		describe( 'getAttributes', () => {
+			it( 'should return an iterator that iterates over all attributes set on the text fragment', () => {
+				selection.setAttribute( 'foo', 'bar' );
+				selection.setAttribute( 'abc', 'xyz' );
+
+				let it = selection.getAttributes();
+				let attrs = [];
+
+				let step = it.next();
+
+				while ( !step.done ) {
+					attrs.push( step.value );
+					step = it.next();
+				}
+
+				expect( attrs ).to.deep.equal( [ [ 'foo', 'bar' ], [ 'abc', 'xyz' ] ] );
+			} );
+		} );
+
+		describe( 'setAttributesTo', () => {
+			it( 'should remove all attributes set on element and set the given ones', () => {
+				selection.setAttribute( 'abc', 'xyz' );
+				selection.setAttributesTo( { foo: 'bar' } );
+
+				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
+				expect( selection.getAttribute( 'abc' ) ).to.be.undefined;
+			} );
+		} );
+
+		describe( 'removeAttribute', () => {
+			it( 'should remove attribute set on the text fragment and return true', () => {
+				selection.setAttribute( 'foo', 'bar' );
+				let result = selection.removeAttribute( 'foo' );
+
+				expect( selection.getAttribute( 'foo' ) ).to.be.undefined;
+				expect( result ).to.be.true;
+			} );
+
+			it( 'should return false if text fragment does not have given attribute', () => {
+				let result = selection.removeAttribute( 'abc' );
+
+				expect( result ).to.be.false;
+			} );
+		} );
+
+		describe( 'clearAttributes', () => {
+			it( 'should remove all attributes from the element', () => {
+				selection.setAttribute( 'foo', 'bar' );
+				selection.setAttribute( 'abc', 'xyz' );
+
+				selection.clearAttributes();
+
+				expect( selection.getAttribute( 'foo' ) ).to.be.undefined;
+				expect( selection.getAttribute( 'abc' ) ).to.be.undefined;
+			} );
+		} );
+	} );
 } );

+ 3 - 6
packages/ckeditor5-engine/tests/treemodel/text.js

@@ -8,18 +8,15 @@
 'use strict';
 
 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', () => {
 		it( 'should create character without attributes', () => {
-			let attrs = [ new Attribute( 'bold', true ) ];
-			let text = new Text( 'bar', attrs );
+			let text = new Text( 'bar', { bold: true } );
 
 			expect( text ).to.have.property( 'text' ).that.equals( 'bar' );
-			expect( text ).to.have.property( 'attrs' ).that.is.instanceof( AttributeList );
-			expect( Array.from( text.attrs ) ).to.deep.equal( attrs );
+			expect( text ).to.have.property( '_attrs' ).that.is.instanceof( Map );
+			expect( Array.from( text._attrs ) ).to.deep.equal( [ [ 'bold', true ] ] );
 		} );
 
 		it( 'should create empty text object', () => {

+ 60 - 15
packages/ckeditor5-engine/tests/treemodel/textfragment.js

@@ -9,24 +9,28 @@
 
 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';
+import CharacterProxy from '/ckeditor5/core/treemodel/characterproxy.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' );
+		element = new Element( 'div' );
 		root.insertChildren( 0, element );
 	} );
 
 	beforeEach( () => {
-		textFragment = new TextFragment( new Position( root, [ 0, 2 ] ), 'oba' );
+		text = new Text( 'foobar', { foo: 'bar' } );
+		element.insertChildren( 0, text );
+		textFragment = new TextFragment( element.getChild( 2 ), 3 );
+	} );
+
+	afterEach( () => {
+		element.removeChildren( 0, 1 );
 	} );
 
 	it( 'should have first property pointing to the first character node contained in TextFragment', () => {
@@ -43,20 +47,61 @@ describe( 'TextFragment', () => {
 		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();
+	describe( 'getCharAt', () => {
+		it( 'should return CharacterProxy element representing proper tree model character node', () => {
+			let char = textFragment.getCharAt( 1 );
+
+			expect( char ).to.be.instanceof( CharacterProxy );
+			expect( char.character ).to.equal( 'b' );
+			expect( char.getAttribute( 'foo' ) ).to.equal( 'bar' );
+			expect( char.parent ).to.equal( element );
+		} );
+
+		it( 'should return null for wrong index', () => {
+			expect( textFragment.getCharAt( -1 ) ).to.be.null;
+			expect( textFragment.getCharAt( 4 ) ).to.be.null;
+		} );
+	} );
+
+	describe( 'attributes interface', () => {
+		describe( 'hasAttribute', () => {
+			it( 'should return true if text fragment has attribute with given key', () => {
+				expect( textFragment.hasAttribute( 'foo' ) ).to.be.true;
+			} );
+
+			it( 'should return false if text fragment does not have attribute with given key', () => {
+				expect( textFragment.hasAttribute( 'abc' ) ).to.be.false;
+			} );
+		} );
+
+		describe( 'getAttribute', () => {
+			it( 'should return attribute with given key if text fragment has given attribute', () => {
+				expect( textFragment.getAttribute( 'foo' ) ).to.equal( 'bar' );
+			} );
+
+			it( 'should return null if text fragment does not have given attribute', () => {
+				expect( textFragment.getAttribute( 'bar' ) ).to.be.undefined;
+			} );
+		} );
+
+		describe( 'getAttributes', () => {
+			it( 'should return an iterator that iterates over all attributes set on the text fragment', () => {
+				let it = textFragment.getAttributes();
+				let attrs = [];
+
+				let step = it.next();
+
+				while ( !step.done ) {
+					attrs.push( step.value );
+					step = it.next();
+				}
 
-		expect( range.root ).to.equal( root );
-		expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
-		expect( range.end.path ).to.deep.equal( [ 0, 5 ] );
+				expect( attrs ).to.deep.equal( [ [ 'foo', 'bar' ] ] );
+			} );
+		} );
 	} );
 } );

+ 7 - 9
packages/ckeditor5-engine/tests/treemodel/treewalker.js

@@ -8,7 +8,6 @@
 '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';
@@ -34,10 +33,8 @@ describe( 'range iterator', () => {
 		//     |
 		//     |- X
 
-		let attrBoldTrue = new Attribute( 'bold', true );
-
-		b = new Text( 'b', [ attrBoldTrue ] );
-		a = new Text( 'a', [ attrBoldTrue ] );
+		b = new Text( 'b', { bold: true } );
+		a = new Text( 'a', { bold: true } );
 		r = new Text( 'r' );
 		img2 = new Element( 'img2' );
 		x = new Text( 'x' );
@@ -51,8 +48,8 @@ describe( 'range iterator', () => {
 			{ 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: 'b', attrs: [ [ 'bold', true ] ] },
+			{ type: 'CHARACTER', text: 'a', attrs: [ [ 'bold', true ] ] },
 			{ type: 'CHARACTER', text: 'r', attrs: [] },
 			{ type: 'ELEMENT_START', item: img2 },
 			{ type: 'ELEMENT_END', item: img2 },
@@ -64,7 +61,7 @@ describe( 'range iterator', () => {
 			{ type: 'ELEMENT_START', item: img1 },
 			{ type: 'ELEMENT_END', item: img1 },
 			{ type: 'ELEMENT_START', item: paragraph },
-			{ type: 'TEXT', text: 'ba', attrs: [ attrBoldTrue ] },
+			{ type: 'TEXT', text: 'ba', attrs: [ [ 'bold', true ] ] },
 			{ type: 'TEXT', text: 'r', attrs: [] },
 			{ type: 'ELEMENT_START', item: img2 },
 			{ type: 'ELEMENT_END', item: img2 },
@@ -78,9 +75,10 @@ describe( 'range iterator', () => {
 
 		if ( item.value.type == 'TEXT' || item.value.type == 'CHARACTER' ) {
 			let text = item.value.item.text || item.value.item.character;
+			let attrs = item.value.item._attrs || item.value.item.first._attrs;
 
 			expect( text ).to.equal( expected.text );
-			expect( Array.from( item.value.item.attrs ) ).to.deep.equal( expected.attrs );
+			expect( Array.from( attrs ) ).to.deep.equal( expected.attrs );
 		} else {
 			expect( item.value ).to.deep.equal( expected );
 		}

+ 46 - 0
packages/ckeditor5-engine/tests/utils.js

@@ -6,6 +6,9 @@
 'use strict';
 
 import utils from '/ckeditor5/core/utils.js';
+import coreTestUtils from '/tests/core/_utils/utils.js';
+
+const getIteratorCount = coreTestUtils.getIteratorCount;
 
 describe( 'utils', () => {
 	describe( 'spy', () => {
@@ -116,6 +119,49 @@ describe( 'utils', () => {
 		} );
 	} );
 
+	describe( 'toMap', () => {
+		it( 'should create map from object', () => {
+			const map = utils.toMap( { foo: 1, bar: 2 } );
+
+			expect( getIteratorCount( map ) ).to.equal( 2 );
+			expect( map.get( 'foo' ) ).to.equal( 1 );
+			expect( map.get( 'bar' ) ).to.equal( 2 );
+		} );
+
+		it( 'should create map from iterator', () => {
+			const map = utils.toMap( [ [ 'foo', 1 ], [ 'bar', 2 ] ] );
+
+			expect( getIteratorCount( map ) ).to.equal( 2 );
+			expect( map.get( 'foo' ) ).to.equal( 1 );
+			expect( map.get( 'bar' ) ).to.equal( 2 );
+		} );
+
+		it( 'should create map from another map', () => {
+			const data = new Map( [ [ 'foo', 1 ], [ 'bar', 2 ] ] );
+
+			const map = utils.toMap( data );
+
+			expect( getIteratorCount( map ) ).to.equal( 2 );
+			expect( map.get( 'foo' ) ).to.equal( 1 );
+			expect( map.get( 'bar' ) ).to.equal( 2 );
+		} );
+	} );
+
+	describe( 'mapsEqual', () => {
+		it( 'should return true if maps have exactly same entries (order of adding does not matter)', () => {
+			let mapA = new Map();
+			let mapB = new Map();
+
+			mapA.set( 'foo', 'bar' );
+			mapA.set( 'abc', 'xyz' );
+
+			mapB.set( 'abc', 'xyz' );
+			mapB.set( 'foo', 'bar' );
+
+			expect( utils.mapsEqual( mapA, mapB ) ).to.be.true;
+		} );
+	} );
+
 	describe( 'nth', () => {
 		it( 'should return 0th item', () => {
 			expect( utils.nth( 0, getIterator() ) ).to.equal( 11 );