瀏覽代碼

Merge pull request #166 from ckeditor/t/112-c

T/112 c: TreeView classes structure.
Piotrek Koszuliński 10 年之前
父節點
當前提交
ae84c2e3be
共有 30 個文件被更改,包括 2994 次插入0 次删除
  1. 302 0
      packages/ckeditor5-engine/src/treeview/converter.js
  2. 217 0
      packages/ckeditor5-engine/src/treeview/element.js
  3. 148 0
      packages/ckeditor5-engine/src/treeview/node.js
  4. 215 0
      packages/ckeditor5-engine/src/treeview/observer/mutationobserver.js
  5. 42 0
      packages/ckeditor5-engine/src/treeview/observer/observer.js
  6. 36 0
      packages/ckeditor5-engine/src/treeview/position.js
  7. 189 0
      packages/ckeditor5-engine/src/treeview/renderer.js
  8. 50 0
      packages/ckeditor5-engine/src/treeview/text.js
  9. 122 0
      packages/ckeditor5-engine/src/treeview/treeview.js
  10. 117 0
      packages/ckeditor5-engine/src/utils-diff.js
  11. 435 0
      packages/ckeditor5-engine/tests/treeview/converter.js
  12. 198 0
      packages/ckeditor5-engine/tests/treeview/element.js
  13. 37 0
      packages/ckeditor5-engine/tests/treeview/integration.js
  14. 4 0
      packages/ckeditor5-engine/tests/treeview/manual/init.html
  15. 23 0
      packages/ckeditor5-engine/tests/treeview/manual/init.js
  16. 13 0
      packages/ckeditor5-engine/tests/treeview/manual/init.md
  17. 1 0
      packages/ckeditor5-engine/tests/treeview/manual/mutationobserver.html
  18. 27 0
      packages/ckeditor5-engine/tests/treeview/manual/mutationobserver.js
  19. 15 0
      packages/ckeditor5-engine/tests/treeview/manual/mutationobserver.md
  20. 1 0
      packages/ckeditor5-engine/tests/treeview/manual/typing.html
  21. 34 0
      packages/ckeditor5-engine/tests/treeview/manual/typing.js
  22. 8 0
      packages/ckeditor5-engine/tests/treeview/manual/typing.md
  23. 215 0
      packages/ckeditor5-engine/tests/treeview/node.js
  24. 1 0
      packages/ckeditor5-engine/tests/treeview/observer/mutationobserver.html
  25. 119 0
      packages/ckeditor5-engine/tests/treeview/observer/mutationobserver.js
  26. 22 0
      packages/ckeditor5-engine/tests/treeview/position.js
  27. 270 0
      packages/ckeditor5-engine/tests/treeview/renderer.js
  28. 32 0
      packages/ckeditor5-engine/tests/treeview/text.js
  29. 70 0
      packages/ckeditor5-engine/tests/treeview/treeview.js
  30. 31 0
      packages/ckeditor5-engine/tests/utils-diff.js

+ 302 - 0
packages/ckeditor5-engine/src/treeview/converter.js

@@ -0,0 +1,302 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import ViewText from './text.js';
+import ViewElement from './element.js';
+
+/**
+ * Converter is a set of tools to do transformations between DOM nodes and view nodes. It also handles
+ * {@link #bindElements binding} these nodes.
+ *
+ * Converter does not check which nodes should be rendered (use {@link treeView.Renderer}), does not keep a state of
+ * a tree nor keeps synchronization between tree view and DOM tree (use {@treeView.TreeView}).
+ *
+ * Converter keeps DOM elements to View element bindings, so when the converter will be destroyed, the binding will be
+ * lost. Two converters will keep separate binding maps, so one tree view can be bound with two DOM trees.
+ *
+ * @class treeView.Converter
+ */
+export default class Converter {
+	/**
+	 * Creates converter.
+	 *
+	 * @constructor
+	 */
+	constructor() {
+		// Using WeakMap prevent memory leaks: when the converter will be destroyed all referenced between View and DOM
+		// will be removed. Also because it is a *Weak*Map when both view and DOM elements will be removed referenced
+		// will be also removed, isn't it brilliant?
+		//
+		// Yes, PJ. It is.
+
+		/**
+		 * DOM to View mapping.
+		 *
+		 * @private
+		 * @type {WeakMap}
+		 */
+		this._domToViewMapping = new WeakMap();
+
+		/**
+		 * View to DOM mapping.
+		 *
+		 * @private
+		 * @type {WeakMap}
+		 */
+		this._viewToDomMapping = new WeakMap();
+	}
+
+	/**
+	 * Binds DOM and View elements, so it will be possible to get corresponding elements using
+	 * {@link treeView.Converter#getCorrespondingViewElement} and {@link treeView.Converter#getCorespondingDOMElement}.
+	 *
+	 * @param {HTMLElement} domElement DOM element to bind.
+	 * @param {treeView.Element} viewElement View element to bind.
+	 */
+	bindElements( domElement, viewElement ) {
+		this._domToViewMapping.set( domElement, viewElement );
+		this._viewToDomMapping.set( viewElement, domElement );
+	}
+
+	/**
+	 * Compares DOM and View nodes. Elements are same when they are bound. Text nodes are same when they have the same
+	 * text data. Nodes need to have corresponding types. In all other cases nodes are different.
+	 *
+	 * @param {Node} domNode DOM node to compare.
+	 * @param {treeView.Node} viewNode View node to compare.
+	 * @returns {Boolean} True if nodes are same.
+	 */
+	compareNodes( domNode, viewNode ) {
+		// Elements.
+		if ( domNode instanceof HTMLElement && viewNode instanceof ViewElement ) {
+			return domNode === this.getCorrespondingDomElement( viewNode );
+		}
+		// Texts.
+		else if ( domNode instanceof Text && viewNode instanceof ViewText ) {
+			return domNode.data === viewNode.data;
+		}
+
+		// Not matching types.
+		return false;
+	}
+
+	/**
+	 * Converts view to DOM. For all text nodes and not bound elements new elements will be created. For bound
+	 * elements function will return corresponding elements.
+	 *
+	 * @param {treeView.Node} viewNode View node to transform.
+	 * @param {document} domDocument Document which will be used to create DOM nodes.
+	 * @param {Object} [options] Conversion options.
+	 * @param {Boolean} [options.bind=false] Determines whether new elements will be bound.
+	 * @param {Boolean} [options.withChildren=true] If true node's children will be converter too.
+	 * @returns {Node} Converted node.
+	 */
+	viewToDom( viewNode, domDocument, options ) {
+		if ( !options ) {
+			options = {};
+		}
+
+		if ( viewNode instanceof ViewText ) {
+			return domDocument.createTextNode( viewNode.data );
+		} else {
+			if ( this.getCorrespondingDom( viewNode ) ) {
+				return this.getCorrespondingDom( viewNode );
+			}
+
+			const domElement = domDocument.createElement( viewNode.name );
+
+			if ( options.bind ) {
+				this.bindElements( domElement, viewNode );
+			}
+
+			for ( let key of viewNode.getAttributeKeys() ) {
+				domElement.setAttribute( key, viewNode.getAttribute( key ) );
+			}
+
+			if ( options.withChildren || options.withChildren === undefined ) {
+				for ( let childView of viewNode.getChildren() ) {
+					domElement.appendChild( this.viewToDom( childView, domDocument, options ) );
+				}
+			}
+
+			return domElement;
+		}
+	}
+
+	/**
+	 * Converts DOM to view. For all text nodes and not bound elements new elements will be created. For bound
+	 * elements function will return corresponding elements.
+	 *
+	 * @param {Node} domNode DOM node to transform.
+	 * @param {Object} [options] Conversion options.
+	 * @param {Boolean} [options.bind=false] Determines whether new elements will be bound.
+	 * @param {Boolean} [options.withChildren=true] It true node's children will be converter too.
+	 * @returns {treeView.Node} Converted node.
+	 */
+	domToView( domNode, options ) {
+		if ( !options ) {
+			options = {};
+		}
+
+		if ( domNode instanceof Text ) {
+			return new ViewText( domNode.data );
+		} else {
+			if ( this.getCorrespondingView( domNode ) ) {
+				return this.getCorrespondingView( domNode );
+			}
+
+			const viewElement = new ViewElement( domNode.tagName.toLowerCase() );
+
+			if ( options.bind ) {
+				this.bindElements( domNode, viewElement );
+			}
+
+			const attrs = domNode.attributes;
+
+			for ( let i = attrs.length - 1; i >= 0; i-- ) {
+				viewElement.setAttribute( attrs[ i ].name, attrs[ i ].value );
+			}
+
+			if ( options.withChildren || options.withChildren === undefined ) {
+				for ( let i = 0, len = domNode.childNodes.length; i < len; i++ ) {
+					let domChild = domNode.childNodes[ i ];
+
+					viewElement.appendChildren( this.domToView( domChild, options ) );
+				}
+			}
+
+			return viewElement;
+		}
+	}
+
+	/**
+	 * Gets corresponding view node. This function use {@link #getCorrespondingViewElement} for elements and
+	 * {@link getCorrespondingViewText} for text nodes.
+	 *
+	 * @param {Node} domNode DOM node.
+	 * @returns {treeView.Node|null} Corresponding node.
+	 */
+	getCorrespondingView( domNode ) {
+		if ( domNode instanceof HTMLElement ) {
+			return this.getCorrespondingViewElement( domNode );
+		} else {
+			return this.getCorrespondingViewText( domNode );
+		}
+	}
+
+	/**
+	 * Gets corresponding view element. Returns element if an view element was {@link #bindElements bound} to the given
+	 * DOM element or null otherwise.
+	 *
+	 * @param {HTMLElement} domElement DOM element.
+	 * @returns {treeView.Element|null} Corresponding element or null if none element was bound.
+	 */
+	getCorrespondingViewElement( domElement ) {
+		return this._domToViewMapping.get( domElement );
+	}
+
+	/**
+	 * Gets corresponding text node. Text nodes are not {@link #bindElements bound}, corresponding text node is
+	 * returned based on the sibling or parent.
+	 *
+	 * If the directly previous sibling is a {@link #bindElements bound} element, it is used to find the corresponding
+	 * text node.
+	 *
+	 * If this is a first child in the parent and the parent is a {@link #bindElements bound} element, it is used to
+	 * find the corresponding text node.
+	 *
+	 * Otherwise `null` is returned.
+	 *
+	 * @param {Text} domText DOM text node.
+	 * @returns {treeView.Text|null} Corresponding view text node or null, if it was not possible to find a
+	 * corresponding node.
+	 */
+	getCorrespondingViewText( domText ) {
+		const previousSibling = domText.previousSibling;
+
+		// Try to use previous sibling to find the corresponding text node.
+		if ( previousSibling ) {
+			if ( !( previousSibling instanceof HTMLElement ) ) {
+				// The previous is text or comment.
+				return null;
+			}
+
+			const viewElement = this.getCorrespondingViewElement( previousSibling );
+
+			if ( viewElement ) {
+				return viewElement.getNextSibling();
+			}
+		}
+		// Try to use parent to find the corresponding text node.
+		else {
+			const viewElement = this.getCorrespondingViewElement( domText.parentElement );
+
+			if ( viewElement ) {
+				return viewElement.getChild( 0 );
+			}
+		}
+
+		return null;
+	}
+
+	/**
+	 * Gets corresponding DOM node. This function uses {@link #getCorrespondingDomElement} for elements and
+	 * {@link #getCorrespondingDomText} for text nodes.
+	 *
+	 * @param {treeView.Node} viewNode View node.
+	 * @returns {Node|null} Corresponding DOM node.
+	 */
+	getCorrespondingDom( viewNode ) {
+		if ( viewNode instanceof ViewElement ) {
+			return this.getCorrespondingDomElement( viewNode );
+		} else {
+			return this.getCorrespondingDomText( viewNode );
+		}
+	}
+
+	/**
+	 * Gets corresponding DOM element. Returns element if an DOM element was {@link #bindElements bound} to the given
+	 * view element or null otherwise.
+	 *
+	 * @param {treeView.Element} viewElement View element.
+	 * @returns {HTMLElement|null} Corresponding element or null if none element was bound.
+	 */
+	getCorrespondingDomElement( viewElement ) {
+		return this._viewToDomMapping.get( viewElement );
+	}
+
+	/**
+	 * Gets corresponding text node. Text nodes are not {@link #bindElements bound}, corresponding text node is
+	 * returned based on the sibling or parent.
+	 *
+	 * If the directly previous sibling is a {@link #bindElements bound} element, it is used to find the corresponding
+	 * text node.
+	 *
+	 * If this is a first child in the parent and the parent is a {@link #bindElements bound} element, it is used to
+	 * find the corresponding text node.
+	 *
+	 * Otherwise null is returned.
+	 *
+	 * @param {treeView.Text} viewText View text node.
+	 * @returns {Text|null} Corresponding DOM text node or null, if it was not possible to find a corresponding node.
+	 */
+	getCorrespondingDomText( viewText ) {
+		const previousSibling = viewText.getPreviousSibling();
+
+		// Try to use previous sibling to find the corresponding text node.
+		if ( previousSibling && this.getCorrespondingDom( previousSibling ) ) {
+			return this.getCorrespondingDom( previousSibling ).nextSibling;
+		}
+
+		// Try to use parent to find the corresponding text node.
+		if ( !previousSibling && this.getCorrespondingDom( viewText.parent ) ) {
+			return this.getCorrespondingDom( viewText.parent ).childNodes[ 0 ];
+		}
+
+		return null;
+	}
+}

+ 217 - 0
packages/ckeditor5-engine/src/treeview/element.js

@@ -0,0 +1,217 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Node from './node.js';
+import utils from '../utils.js';
+import langUtils from '../lib/lodash/lang.js';
+
+/**
+ * Tree view element.
+ *
+ * @class treeView.Element
+ */
+export default class Element extends Node {
+	/**
+	 * Creates a tree view element.
+	 *
+	 * Attributes can be passes in various formats:
+	 *
+	 *		new Element( 'div', { 'class': 'editor', 'contentEditable': 'true' } ); // object
+	 *		new Element( 'div', [ [ 'class', 'editor' ], [ 'contentEditable', 'true' ] ] ); // map-like iterator
+	 *		new Element( 'div', mapOfAttributes ); // map
+	 *
+	 * @param {String} name Node name.
+	 * @param {Object|Interable} [attrs] Collection of attributes.
+	 * @param {treeView.Node|Iterable.<treeView.Node>} [children] List of nodes to be inserted into created element.
+	 * @constructor
+	 */
+	constructor( name, attrs, children ) {
+		super();
+
+		/**
+		 * Name of the element.
+		 *
+		 * @readonly
+		 * @property {String}
+		 */
+		this.name = name;
+
+		/**
+		 * Map of attributes, where attributes names are keys and attributes values are values.
+		 *
+		 * @protected
+		 * @property {Map} _attrs
+		 */
+		if ( langUtils.isPlainObject( attrs ) ) {
+			this._attrs = utils.objectToMap( attrs );
+		} else {
+			this._attrs = new Map( attrs );
+		}
+
+		/**
+		 * Array of child nodes.
+		 *
+		 * @protected
+		 * @property {Array.<treeView.Node>}
+		 */
+		this._children = [];
+
+		if ( children ) {
+			this.insertChildren( 0, children );
+		}
+	}
+
+	/**
+	 * {@link treeView.Element#insert Insert} a child node or a list of child nodes at the end of this node and sets
+	 * the parent of these nodes to this element.
+	 *
+	 * Fires the {@link treeView.Node#change change event}.
+	 *
+	 * @param {treeView.Node|Iterable.<treeView.Node>} nodes Node or the list of nodes to be inserted.
+	 */
+	appendChildren( nodes ) {
+		this.insertChildren( this.getChildCount(), nodes );
+	}
+
+	/**
+	 * Gets child at the given index.
+	 *
+	 * @param {Number} index Index of child.
+	 * @returns {treeView.Node} Child node.
+	 */
+	getChild( index ) {
+		return this._children[ index ];
+	}
+
+	/**
+	 * Gets the number of element's children.
+	 *
+	 * @returns {Number} The number of element's children.
+	 */
+	getChildCount() {
+		return this._children.length;
+	}
+
+	/**
+	 * Gets index of the given child node.
+	 *
+	 * @param {treeView.Node} node Child node.
+	 * @returns {Number} Index of the child node.
+	 */
+	getChildIndex( node ) {
+		return this._children.indexOf( node );
+	}
+
+	/**
+	 * Gets child nodes iterator.
+	 *
+	 * @returns {Iterable.<treeView.Node>} Child nodes iterator.
+	 */
+	getChildren() {
+		return this._children[ Symbol.iterator ]();
+	}
+
+	/**
+	 * Returns an iterator that contains the keys for attributes.
+	 *
+	 * @returns {Iterator.<String>} Keys for attributes.
+	 */
+	getAttributeKeys() {
+		return this._attrs.keys();
+	}
+
+	/**
+	 * Gets attribute by key.
+	 *
+	 * @param {String} key Attribute key.
+	 * @returns {String} Attribute value.
+	 */
+	getAttribute( key ) {
+		return this._attrs.get( key );
+	}
+
+	/**
+	 * Returns a boolean indicating whether an attribute with the specified key exists in the element.
+	 *
+	 * @param {String} key Attribute key.
+	 * @returns {Boolean} `true` if attribute with the specified key exists in the element, false otherwise.
+	 */
+	hasAttribute( key ) {
+		return this._attrs.has( key );
+	}
+
+	/**
+	 * Adds or overwrite attribute with a specified key and value.
+	 *
+	 * Fires the {@link treeView.Node#change change event}.
+	 *
+	 * @param {String} key Attribute key.
+	 * @param {String} value Attribute value.
+	 */
+	setAttribute( key, value ) {
+		this._fireChange( 'ATTRIBUTES', this );
+
+		this._attrs.set( key, value );
+	}
+
+	/**
+	 * Inserts a child node or a list of child nodes on the given index and sets the parent of these nodes to
+	 * this element.
+	 *
+	 * Fires the {@link treeView.Node#change change event}.
+	 *
+	 * @param {Number} index Position where nodes should be inserted.
+	 * @param {treeView.Node|Iterable.<treeView.Node>} nodes Node or the list of nodes to be inserted.
+	 */
+	insertChildren( index, nodes ) {
+		this._fireChange( 'CHILDREN', this );
+
+		if ( !utils.isIterable( nodes ) ) {
+			nodes = [ nodes ];
+		}
+
+		for ( let node of nodes ) {
+			node.parent = this;
+
+			this._children.splice( index, 0, node );
+			index++;
+		}
+	}
+
+	/**
+	 * Removes attribute from the element.
+	 *
+	 * Fires the {@link treeView.Node#change change event}.
+	 *
+	 * @param {String} key Attribute key.
+	 * @returns {Boolead} Returns true if an attribute existed and has been removed.
+	 */
+	removeAttribute( key ) {
+		this._fireChange( 'ATTRIBUTES', this );
+
+		return this._attrs.delete( key );
+	}
+
+	/**
+	 * Removes number of child nodes starting at the given index and set the parent of these nodes to `null`.
+	 *
+	 * Fires the {@link treeView.Node#change change event}.
+	 *
+	 * @param {Number} index Number of the first node to remove.
+	 * @param {Number} number Number of nodes to remove.
+	 * @returns {Array.<treeView.Node>} The array of removed nodes.
+	 */
+	removeChildren( index, number ) {
+		this._fireChange( 'CHILDREN', this );
+
+		for ( let i = index; i < index + number; i++ ) {
+			this._children[ i ].parent = null;
+		}
+
+		return this._children.splice( index, number );
+	}
+}

+ 148 - 0
packages/ckeditor5-engine/src/treeview/node.js

@@ -0,0 +1,148 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import CKEditorError from '../ckeditorerror.js';
+import EmitterMixin from '../emittermixin.js';
+import utils from '../utils.js';
+
+/**
+ * Abstract tree view node class.
+ *
+ * @abstract
+ * @class treeView.Node
+ */
+export default class Node {
+	/**
+	 * Creates a tree view node.
+	 *
+	 * This is an abstract class, so this constructor should not be used directly.
+	 *
+	 * @constructor
+	 */
+	constructor() {
+		/**
+		 * Parent element. Null by default. Set by {@link treeView.Element#insertChildren}.
+		 *
+		 * @readonly
+		 * @property {treeView.Element|null} parent
+		 */
+		this.parent = null;
+
+		/**
+		 * {@link treeView.TreeView} reference.
+		 *
+		 * @protected
+		 * @type {treeView.TreeView}
+		 */
+		this._treeView = null;
+	}
+
+	/**
+	 * Returns index of the node in the parent element or null if the node has no parent.
+	 *
+	 * Throws error if the parent element does not contain this node.
+	 *
+	 * @returns {Number|null} Index of the node in the parent element or null if the node has not parent.
+	 */
+	getIndex() {
+		let pos;
+
+		if ( !this.parent ) {
+			return null;
+		}
+
+		// No parent or child doesn't exist in parent's children.
+		if ( ( pos = this.parent.getChildIndex( this ) ) == -1 ) {
+			/**
+			 * The node's parent does not contain this node. It means that the document tree is corrupted.
+			 *
+			 * @error treeview-node-not-found-in-parent
+			 */
+			throw new CKEditorError( 'treeview-node-not-found-in-parent: The node\'s parent does not contain this node.' );
+		}
+
+		return pos;
+	}
+
+	/**
+	 * Returns nodes next sibling or `null` if it is the last child.
+	 *
+	 * @returns {treeView.Node|null} Nodes next sibling or `null` if it is the last child.
+	 */
+	getNextSibling() {
+		const index = this.getIndex();
+
+		return ( index !== null && this.parent.getChild( index + 1 ) ) || null;
+	}
+
+	/**
+	 * Returns nodes previous sibling or `null` if it is the first child.
+	 *
+	 * @returns {treeView.Node|null} Nodes previous sibling or `null` if it is the first child.
+	 */
+	getPreviousSibling() {
+		const index = this.getIndex();
+
+		return ( index !== null && this.parent.getChild( index - 1 ) ) || null;
+	}
+
+	/**
+	 * Gets {@link treeView.TreeView} reference. If the node has {@link treeView.TreeView}, assign by
+	 * {@link treeView.Node#setTreeView} it will be returned. Otherwise {@link treeView.TreeView} of the parents node
+	 * will be returned. If node has no parent, `null` will be returned.
+	 *
+	 * @returns {treeView.TreeView|null} Tree view of the node, tree view of the parent or null.
+	 */
+	getTreeView() {
+		if ( this._treeView ) {
+			return this._treeView;
+		} else if ( this.parent ) {
+			return this.parent.getTreeView();
+		} else {
+			return null;
+		}
+	}
+
+	/**
+	 * Sets the {@link treeView.TreeView} of the node. Note that not all of nodes need to have {@link treeView.TreeView}
+	 * assigned, see {@link treeView.Node#getTreeView}.
+	 *
+	 * @param {treeView.TreeView} treeView Tree view.
+	 */
+	setTreeView( treeView ) {
+		this._treeView = treeView;
+	}
+
+	/**
+	 * Fires the {@link treeView.Node#change change event}.
+	 *
+	 * @param {treeView.ChangeType} type Type of the change.
+	 * @param {treeView.Node} node Changed node.
+	 */
+	_fireChange( type, node ) {
+		this.fire( 'change', type, node );
+
+		if ( this.parent ) {
+			this.parent._fireChange( type, node );
+		}
+	}
+
+	/**
+	 * Fired when a node changes.
+	 *
+	 * * In case of {@link treeView.Text text nodes} it will be a change of the text data.
+	 * * In case of {@link treeView.Element elements} it will be a change of child nodes or attributes.
+	 *
+	 * Change event is bubbling, it is fired on the ancestors chain.
+	 *
+	 * @event change
+	 * @param {treeView.ChangeType} Type of the change.
+	 * @param {treeView.Node} Changed node.
+	 */
+}
+
+utils.mix( Node, EmitterMixin );

+ 215 - 0
packages/ckeditor5-engine/src/treeview/observer/mutationobserver.js

@@ -0,0 +1,215 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Observer from './observer.js';
+
+/**
+ * Mutation observer class observes changes in the DOM, fires {@link treeView.TreeView#mutations} event, mark view elements
+ * as changed and call {@link treeView.render}. Because all mutated nodes are marked as "to be rendered" and the
+ * {@link treeView.render} is called, all changes will be reverted, unless the mutation will be handled by the
+ * {@link treeView.TreeView#mutations} event listener. It means user will see only handled changes, and the editor will
+ * block all changes which are not handled.
+ *
+ * Mutation Observer also take care of reducing number of mutations which are fired. It removes duplicates and
+ * mutations on elements which do not have corresponding view elements. Also
+ * {@link treeView.TreeView.MutatatedText text mutation} is fired only if parent element do not change child list.
+ *
+ * @class treeView.observer.MutationObserver
+ */
+export default class MutationObserver extends Observer {
+	/**
+	 * Mutation observer constructor. Note that most of the initialization is done in
+	 * {@link treeView.observer.MutationObserver#init} method.
+	 *
+	 * @constructor
+	 */
+	constructor() {
+		super();
+
+		/**
+		 * Native mutation observer config.
+		 *
+		 * @private
+		 * @property {Object}
+		 */
+		this._config = {
+			childList: true,
+			characterData: true,
+			characterDataOldValue: true,
+			subtree: true
+		};
+	}
+
+	// Docs in the base class.
+	init( treeView ) {
+		/**
+		 * Reference to the {@link treeView.TreeView} object.
+		 *
+		 * @property {treeView.TreeView}
+		 */
+		this.treeView = treeView;
+
+		/**
+		 * Reference to the {@link treeView.TreeView#domRoot}.
+		 *
+		 * @property {HTMLElement}
+		 */
+		this.domRoot = treeView.domRoot;
+
+		/**
+		 * Reference to the {@link treeView.TreeView#converter}.
+		 *
+		 * @property {treeView.Converter}
+		 */
+		this.converter = treeView.converter;
+
+		/**
+		 * Reference to the {@link treeView.TreeView#renderer}.
+		 *
+		 * @property {treeView.Renderer}
+		 */
+		this.renderer = treeView.renderer;
+
+		/**
+		 * Native mutation observer.
+		 *
+		 * @private
+		 * @property {window.MutationObserver}
+		 */
+		this._mutationObserver = new window.MutationObserver( this._onMutations.bind( this ) );
+	}
+
+	// Docs in the base class.
+	attach() {
+		this._mutationObserver.observe( this.domRoot, this._config );
+	}
+
+	// Docs in the base class.
+	detach() {
+		this._mutationObserver.disconnect();
+	}
+
+	/**
+	 * Handles mutations. Deduplicates, mark view elements to sync, fire event and call render.
+	 *
+	 * @protected
+	 * @param {Array.<Object>} domMutations Array of native mutations.
+	 */
+	_onMutations( domMutations ) {
+		// Use map and set for deduplication.
+		const mutatedTexts = new Map();
+		const mutatedElements = new Set();
+
+		// Handle `childList` mutations first, so we will be able to check if the `characterData` mutation is in the
+		// element with changed structure anyway.
+		for ( let mutation of domMutations ) {
+			if ( mutation.type === 'childList' ) {
+				const element = this.converter.getCorrespondingViewElement( mutation.target );
+
+				if ( element ) {
+					mutatedElements.add( element );
+				}
+			}
+		}
+
+		// Handle `characterData` mutations later, when we have the full list of nodes which changed structure.
+		for ( let mutation of domMutations ) {
+			if ( mutation.type === 'characterData' ) {
+				const text = this.converter.getCorrespondingViewText( mutation.target );
+
+				if ( text && !mutatedElements.has( text.parent ) ) {
+					// Use text as a key, for deduplication. If there will be another mutation on the same text element
+					// we will have only one in the map.
+					mutatedTexts.set( text, {
+						type: 'text',
+						oldText: text.data,
+						newText: mutation.target.data,
+						node: text
+					} );
+				}
+			}
+		}
+
+		// Now we build the list of mutations to fire and mark elements. We did not do it earlier to avoid marking the
+		// same node multiple times in case of duplication.
+
+		// List of mutations we will fire.
+		const viewMutations = [];
+
+		for ( let mutatedText of mutatedTexts.values() ) {
+			this.renderer.markToSync( 'TEXT', mutatedText.node );
+
+			viewMutations.push( mutatedText );
+		}
+
+		for ( let viewElement of mutatedElements ) {
+			const domElement = this.converter.getCorrespondingDomElement( viewElement );
+			const domChildren = domElement.childNodes;
+			const viewChildren = viewElement.getChildren();
+			const newViewChildren = [];
+
+			// We want to have a list of View elements, not DOM elements.
+			for ( let i = 0; i < domChildren.length; i++ ) {
+				newViewChildren.push( this.converter.domToView( domChildren[ i ] ) );
+			}
+
+			this.renderer.markToSync( 'CHILDREN', viewElement );
+
+			viewMutations.push( {
+				type: 'children',
+				oldChildren: Array.from( viewChildren ),
+				newChildren: newViewChildren,
+				node: viewElement
+			} );
+		}
+
+		this.treeView.fire( 'mutations', viewMutations );
+
+		this.treeView.render();
+	}
+}
+
+/**
+ * Fired when mutation occurred. If tree view is not changed on this event, DOM will be reverter to the state before
+ * mutation, so all changes which should be applied, should be handled on this event.
+ *
+ * @event mutations
+ * @memberOf treeView.TreeView
+ *
+ * @param {Array.<treeView.MutatatedText|treeView.MutatatedChildren>} viewMutations
+ * Array of mutations.
+ * For mutated texts it will be {@link treeView.MutatatedText} and for mutated elements it will be
+ * {@link treeView.MutatatedElement}. You can recognize the type based on the `type` property.
+ */
+
+/**
+ * Mutation item for text.
+ *
+ * @see treeView.TreeView#mutations
+ * @see treeView.MutatatedChildren
+ *
+ * @typedef {Object} treeView.MutatatedText
+ *
+ * @property {String} type For text mutations it is always 'text'.
+ * @property {treeView.Text} node Mutated text node.
+ * @property {String} oldText Old text.
+ * @property {String} newText New text.
+ */
+
+/**
+ * Mutation item for child nodes.
+ *
+ * @see treeView.TreeView#mutations
+ * @see treeView.MutatatedText
+ *
+ * @typedef {Object} treeView.MutatatedChildren
+ *
+ * @property {String} type For child nodes mutations it is always 'children'.
+ * @property {treeView.Element} node Parent of the mutated children.
+ * @property {Array.<treeView.Node>} oldChildren Old child nodes.
+ * @property {Array.<treeView.Node>} newChildren New child nodes.
+ */

+ 42 - 0
packages/ckeditor5-engine/src/treeview/observer/observer.js

@@ -0,0 +1,42 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+/**
+ * Abstract base observer class. Observers are classes which observe changes on DOM elements, do the preliminary
+ * processing and fire events on the {@link treeView.TreeView} objects.
+ *
+ * @abstract
+ * @class treeView.observer.Observer
+ */
+export default class Observer {
+	/**
+	 * Inits the observer class, caches all needed {@link treeView.TreeView} properties, create objects.
+	 * This method do not {@link treeView.observer.Observer#attach attach} listeners to the DOM.
+	 *
+	 * @method init
+	 * @param {treeView.TreeView}
+	 */
+
+	/**
+	 * Attaches observers and listeners to DOM elements. This method is called when then observer is added to the
+	 * {@link treeView.TreeView} and after {@link treeView.TreeView#render rendering} to reattach observers and listeners.
+	 *
+	 * @see treeView.observer.Observer#detach
+	 *
+	 * @method attach
+	 */
+
+	/**
+	 * Detaches observers and listeners from the DOM elements. This method is called before
+	 * {@link treeView.TreeView#render rendering} to prevent firing events during rendering and when the editor is
+	 * destroyed.
+	 *
+	 * @see treeView.observer.Observer#attach
+	 *
+	 * @method detach
+	 */
+}

+ 36 - 0
packages/ckeditor5-engine/src/treeview/position.js

@@ -0,0 +1,36 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+/**
+ * Position in the tree. Position is always located before or after a node.
+ *
+ * @class treeView.Position
+ */
+ export default class Position {
+	/**
+	 * Creates a position.
+	 *
+	 * @param {treeView.Element} parent Position parent element.
+	 * @param {Number} offset Position offset.
+	 * @constructor
+	 */
+	constructor( parent, offset ) {
+		/**
+		 * Position parent element.
+		 *
+		 * @property {treeView.Element}
+		 */
+		this.parent = parent;
+
+		/**
+		 * Position offset.
+		 *
+		 * @property {Number}
+		 */
+		this.offset = offset;
+	}
+}

+ 189 - 0
packages/ckeditor5-engine/src/treeview/renderer.js

@@ -0,0 +1,189 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import diff from '../utils-diff.js';
+import CKEditorError from '../ckeditorerror.js';
+
+/**
+ * Renderer updates DOM tree, to make it a reflection of the view tree. Changed nodes need to be
+ * {@link treeView.Renderer#markToSync marked} to be rendered. Then, on {@link treeView.Renderer#render}, renderer
+ * ensure they need to be refreshed and creates DOM nodes from view nodes,
+ * {@link treeView.Converter#bindElements bind} them and insert into DOM tree. Renderer use {@link treeView.Converter}
+ * to transform and bind nodes.
+ *
+ * @class treeView.Renderer
+ */
+export default class Renderer {
+	/**
+	 * Creates a renderer instance.
+	 *
+	 * @param {treeView.Converter} converter Converter instance.
+	 * @constructor
+	 */
+	constructor( converter ) {
+		/**
+		 * Converter instance.
+		 *
+		 * @readonly
+		 * @property {treeView.Converter}
+		 */
+		this.converter = converter;
+
+		/**
+		 * Set of nodes which attributes changed and may need to be rendered.
+		 *
+		 * @readonly
+		 * @property {Set.<treeView.Node>}
+		 */
+		this.markedAttributes = new Set();
+
+		/**
+		 * Set of elements which child lists changed and may need to be rendered.
+		 *
+		 * @readonly
+		 * @property {Set.<treeView.Node>}
+		 */
+		this.markedChildren = new Set();
+
+		/**
+		 * Set of text nodes which text data changed and may need to be rendered.
+		 *
+		 * @readonly
+		 * @property {Set.<treeView.Node>}
+		 */
+		this.markedTexts = new Set();
+	}
+
+	/**
+	 * Mark node to be synchronized.
+	 *
+	 * Note that only view nodes which parents have corresponding DOM elements need to be marked to be synchronized.
+	 *
+	 * @see treeView.Renderer#markedAttributes
+	 * @see treeView.Renderer#markedChildren
+	 * @see treeView.Renderer#markedTexts
+	 *
+	 * @param {treeView.ChangeType} type Type of the change.
+	 * @param {treeView.Node} node Node to be marked.
+	 */
+	markToSync( type, node ) {
+		if ( type === 'TEXT' ) {
+			if ( this.converter.getCorrespondingDom( node.parent ) ) {
+				this.markedTexts.add( node );
+			}
+		} else {
+			// If the node has no DOM element it is not rendered yet,
+			// its children/attributes do not need to be marked to be sync.
+			if ( !this.converter.getCorrespondingDom( node ) ) {
+				return;
+			}
+
+			if ( type === 'ATTRIBUTES' ) {
+				this.markedAttributes.add( node );
+			} else if ( type === 'CHILDREN' ) {
+				this.markedChildren.add( node );
+			} else {
+				/**
+				 * Unknown type passed to Renderer.markToSync.
+				 *
+				 * @error renderer-unknown-type
+				 */
+				throw new CKEditorError( 'renderer-unknown-type: Unknown type passed to Renderer.markToSync.' );
+			}
+		}
+	}
+
+	/**
+	 * Render method check {@link treeView.Renderer#markedAttributes}, {@link treeView.Renderer#markedChildren} and
+	 * {@link treeView.Renderer#markedTexts} and updated all nodes which needs to be updated. Then it clear all three
+	 * sets.
+	 *
+	 * Renderer try not to bread IME, so it do as little as it is possible to update DOM.
+	 *
+	 * For attributes it adds new attributes to DOM elements, update attributes with different values and remove
+	 * attributes which does not exists in the view element.
+	 *
+	 * For text nodes it update the text string if it is different. Note that if parent element is marked as an element
+	 * which changed child list, text node update will not be done, because it may not be possible do find a
+	 * {@link @treeView.Converter#getCorrespondingDomText corresponding DOM text}. The change will be handled in the
+	 * parent element.
+	 *
+	 * For nodes which changed child list it calculates a {@link diff} using {@link @treeView.Converter#compareNodes}
+	 * and add or removed nodes which changed.
+	 */
+	render() {
+		const converter = this.converter;
+
+		for ( let node of this.markedTexts ) {
+			if ( !this.markedChildren.has( node.parent ) && converter.getCorrespondingDom( node.parent ) ) {
+				updateText( node );
+			}
+		}
+
+		for ( let element of this.markedAttributes ) {
+			updateAttrs( element );
+		}
+
+		for ( let element of this.markedChildren ) {
+			updateChildren( element );
+		}
+
+		this.markedTexts.clear();
+		this.markedAttributes.clear();
+		this.markedChildren.clear();
+
+		function updateText( viewText ) {
+			const domText = converter.getCorrespondingDom( viewText );
+
+			if ( domText.data != viewText.data ) {
+				domText.data = viewText.data;
+			}
+		}
+
+		function updateAttrs( viewElement ) {
+			const domElement = converter.getCorrespondingDom( viewElement );
+			const domAttrKeys = Array.from( domElement.attributes ).map( attr => attr.name );
+			const viewAttrKeys = viewElement.getAttributeKeys();
+
+			// Add or overwrite attributes.
+			for ( let key of viewAttrKeys ) {
+				domElement.setAttribute( key, viewElement.getAttribute( key ) );
+			}
+
+			// Remove from DOM attributes which do not exists in the view.
+			for ( let key of domAttrKeys ) {
+				if ( !viewElement.hasAttribute( key ) ) {
+					domElement.removeAttribute( key );
+				}
+			}
+		}
+
+		function updateChildren( viewElement ) {
+			const domElement = converter.getCorrespondingDom( viewElement );
+			const domChildren = domElement.childNodes;
+			const viewChildren = Array.from( viewElement.getChildren() );
+			const domDocument = domElement.ownerDocument;
+
+			const actions = diff( domChildren, viewChildren,
+				( domNode, viewNode ) => converter.compareNodes( domNode, viewNode ) );
+
+			let i = 0;
+
+			for ( let action of actions ) {
+				if ( action === 'INSERT' ) {
+					let domChildToInsert = converter.viewToDom( viewChildren[ i ], domDocument, { bind: true } );
+					domElement.insertBefore( domChildToInsert, domChildren[ i ] || null  );
+					i++;
+				} else if ( action === 'DELETE' ) {
+					domElement.removeChild( domChildren[ i ] );
+				} else { // 'EQUAL'
+					i++;
+				}
+			}
+		}
+	}
+}

+ 50 - 0
packages/ckeditor5-engine/src/treeview/text.js

@@ -0,0 +1,50 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Node from './node.js';
+
+/**
+ * Tree view text node.
+ *
+ * @class treeView.Text
+ */
+export default class Text extends Node {
+	/**
+	 * Creates a tree view text node.
+	 *
+	 * @param {String} data Text.
+	 * @constructor
+	 */
+	constructor( data ) {
+		super();
+
+		/**
+		 * The text content.
+		 *
+		 * @private
+		 * @property {String}
+		 */
+		this._data = data;
+	}
+
+	/**
+	 * The text content.
+	 *
+	 * Setting the data fires the {@link treeView.Node#change change event}.
+	 *
+	 * @property {String} Text data.
+	 */
+	get data() {
+		return this._data;
+	}
+
+	set data( data ) {
+		this._fireChange( 'TEXT', this );
+
+		this._data = data;
+	}
+}

+ 122 - 0
packages/ckeditor5-engine/src/treeview/treeview.js

@@ -0,0 +1,122 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import EmitterMixin from '../emittermixin.js';
+import Renderer from './renderer.js';
+import Converter from './converter.js';
+
+import utils from '../utils.js';
+
+/**
+ * TreeView class combines the actual tree of view elements, tree of DOM elements, {@link treeView.Converter converter},
+ * {@link treeView.Renderer renderer} and all {@link treeView.Observer observers}. It creates an abstract layer over the
+ * content editable area.
+ *
+ * If you want to only transform the tree of view elements to the DOM elements you can use the {@link treeView.Converter}.
+ *
+ * @mixins EmitterMixin
+ * @class treeView.TreeView
+ */
+export default class TreeView {
+	/**
+	 * Creates a TreeView based on the HTMLElement.
+	 *
+	 * The constructor copies the element name and attributes to create the
+	 * root of the view, but does not copy its children. This means that the while rendering, the whole content of this
+	 * root element will be removed when you call {@link treeView.TreeView#render} but the root name and attributes will
+	 * be preserved.
+	 *
+	 * @param {HTMLElement} domRoot DOM element in which the tree view should do change.
+	 * @constructor
+	 */
+	constructor( domRoot ) {
+		/**
+		 * Root of the DOM tree.
+		 *
+		 * @property {HTMLElement}
+		 */
+		this.domRoot = domRoot;
+
+		/**
+		 * Set of {@link treeView.Observer observers}.
+		 *
+		 * @property {Set.<treeView.Observer>}
+		 */
+		this.observers = new Set();
+
+		/**
+		 * Instance of the {@link treeView.Converter converter} use by {@link treeView.TreeView#renderer renderer} and
+		 * {@link treeView.TreeView#observers observers}.
+		 *
+		 * @property {treeView.Converter}
+		 */
+		this.converter = new Converter();
+
+		/**
+		 * Root of the view tree.
+		 *
+		 * @property {treeView.Element}
+		 */
+		this.viewRoot = this.converter.domToView( domRoot, { bind: true, withChildren: false } );
+		this.viewRoot.setTreeView( this );
+
+		/**
+		 * Instance of the {@link treeView.TreeView#renderer renderer}.
+		 *
+		 * @property {treeView.Renderer}
+		 */
+		this.renderer = new Renderer( this.converter );
+		this.renderer.markToSync( 'CHILDREN', this.viewRoot );
+
+		// Mark changed nodes in the renderer.
+		this.viewRoot.on( 'change', ( evt, type, node ) => {
+			this.renderer.markToSync( type, node );
+		} );
+	}
+
+	/**
+	 * Adds an observer to the set of observers. This method also {@link treeView.Observer#init initializes} and
+	 * {@link treeView.Observer#attach attaches} the observer.
+	 *
+	 * @param {treeView.Observer} observer Observer to add.
+	 */
+	addObserver( observer ) {
+		this.observers.add( observer );
+		observer.init( this );
+		observer.attach();
+	}
+
+	/**
+	 * Renders all changes. In order to avoid triggering the observers (e.g. mutations) all observers all detached
+	 * before rendering and reattached after that.
+	 */
+	render() {
+		for ( let observer of this.observers ) {
+			observer.detach();
+		}
+
+		this.renderer.render();
+
+		for ( let observer of this.observers ) {
+			observer.attach();
+		}
+	}
+}
+
+utils.mix( TreeView, EmitterMixin );
+
+/**
+ * Enum representing type of the change.
+ *
+ * Possible values:
+ *
+ * * `CHILDREN` - for child list changes,
+ * * `ATTRIBUTES` - for element attributes changes,
+ * * `TEXT` - for text nodes changes.
+ *
+ * @typedef {String} treeView.ChangeType
+ */

+ 117 - 0
packages/ckeditor5-engine/src/utils-diff.js

@@ -0,0 +1,117 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+// The following code is based on the "O(NP) Sequence Comparison Algorithm"
+// by Sun Wu, Udi Manber, Gene Myers, Webb Miller.
+
+/**
+ * Calculates the difference between two arrays producing an object containing a list of actions
+ * necessary to transform one array into another.
+ *
+ *		diff( 'aba', 'acca' ); // [ EQUAL, INSERT, INSERT, DELETE, EQUAL ]
+ *
+ * @param {Array} a Input array.
+ * @param {Array} b Output array.
+ * @param {Function} [cmp] Optional function used to compare array values, by default === is used.
+ * @return {Array} Array of actions.
+ */
+ export default function diff( a, b, cmp ) {
+	// Set the comparator function.
+	cmp = cmp || function( a, b ) {
+			return a === b;
+		};
+
+	// Temporary action type statics.
+	let _INSERT, _DELETE;
+
+	// Swapped the arrays to use the shorter one as the first one.
+	if ( b.length < a.length ) {
+		let tmp = a;
+
+		a = b;
+		b = tmp;
+
+		// We swap the action types as well.
+		_INSERT = 'DELETE';
+		_DELETE = 'INSERT';
+	} else {
+		_INSERT = 'INSERT';
+		_DELETE = 'DELETE';
+	}
+
+	const m = a.length;
+	const n = b.length;
+	const delta = n - m;
+
+	// Edit scripts, for each diagonal.
+	const es = {};
+	// Furthest points, the furthest y we can get on each diagonal.
+	const fp = {};
+
+	function snake( k ) {
+		// We use -1 as an alternative below to handle initial values ( instead of filling the fp with -1 first ).
+		// Furthest points (y) on the diagonal below k.
+		const y1 = ( fp[ k - 1 ] !== undefined ? fp[ k - 1 ] : -1 ) + 1;
+		// Furthest points (y) on the diagonal above k.
+		const y2 = fp[ k + 1 ] !== undefined ? fp[ k + 1 ] : -1;
+		// The way we should go to get further.
+		const dir = y1 > y2 ? -1 : 1;
+
+		// Clone previous actions array (if any).
+		if ( es[ k + dir ] ) {
+			es[ k ] = es[ k + dir ].slice( 0 );
+		}
+
+		// Create actions array.
+		if ( !es[ k ] ) {
+			es[ k ] = [];
+		}
+
+		// Push the action.
+		es[ k ].push( y1 > y2 ? _INSERT : _DELETE );
+
+		// Set the beginning coordinates.
+		let y = Math.max( y1, y2 );
+		let x = y - k;
+
+		// Traverse the diagonal as long as the values match.
+		while ( x < m && y < n && cmp( a[ x ], b[ y ] ) ) {
+			x++;
+			y++;
+			// Push no change action.
+			es[ k ].push( 'EQUAL' );
+		}
+
+		return y;
+	}
+
+	let p = 0;
+	let k;
+
+	// Traverse the graph until we reach the end of the longer string.
+	do {
+		// Updates furthest points and edit scripts for diagonals below delta.
+		for ( k = -p; k < delta; k++ ) {
+			fp[ k ] = snake( k );
+		}
+
+		// Updates furthest points and edit scripts for diagonals above delta.
+		for ( k = delta + p; k > delta; k-- ) {
+			fp[ k ] = snake( k );
+		}
+
+		// Updates furthest point and edit script for the delta diagonal.
+		// note that the delta diagonal is the one which goes through the sink (m, n).
+		fp[ delta ] = snake( delta );
+
+		p++;
+	} while ( fp[ delta ] !== n );
+
+	// Return the final list of edit actions.
+	// We remove the first item that represents the action for the injected nulls.
+	return es[ delta ].slice( 1 );
+}

+ 435 - 0
packages/ckeditor5-engine/tests/treeview/converter.js

@@ -0,0 +1,435 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import coreTestUtils from '/tests/core/_utils/utils.js';
+import ViewElement from '/ckeditor5/core/treeview/element.js';
+import ViewText from '/ckeditor5/core/treeview/text.js';
+import Converter from '/ckeditor5/core/treeview/converter.js';
+
+const getIteratorCount = coreTestUtils.getIteratorCount;
+
+describe( 'converter', () => {
+	let converter;
+
+	before( () => {
+		converter = new Converter();
+	} );
+
+	describe( 'bindElements', () => {
+		it( 'should bind elements', () => {
+			const domElement = document.createElement( 'p' );
+			const viewElement = new ViewElement( 'p' );
+
+			converter.bindElements( domElement, viewElement );
+
+			expect( converter.getCorrespondingView( domElement ) ).to.equal( viewElement );
+			expect( converter.getCorrespondingDom( viewElement ) ).to.equal( domElement );
+		} );
+	} );
+
+	describe( 'compareNodes', () => {
+		it( 'should return false for nodes not matching types', () => {
+			const domElement = document.createElement( 'p' );
+			const viewText = new ViewText( 'foo' );
+
+			expect( converter.compareNodes( domElement, viewText ) ).to.be.false;
+		} );
+
+		it( 'should return true for bound elements', () => {
+			const domElement = document.createElement( 'p' );
+			const viewElement = new ViewElement( 'p' );
+
+			converter.bindElements( domElement, viewElement );
+
+			expect( converter.compareNodes( domElement, viewElement ) ).to.be.true;
+		} );
+
+		it( 'should return true for the same texts', () => {
+			const domText = document.createTextNode( 'foo' );
+			const viewText = new ViewText( 'foo' );
+
+			expect( converter.compareNodes( domText, viewText ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'domToView', () => {
+		it( 'should create tree of view elements from DOM elements', () => {
+			const domImg = document.createElement( 'img' );
+			const domText = document.createTextNode( 'foo' );
+			const domP = document.createElement( 'p' );
+
+			domP.setAttribute( 'class', 'foo' );
+
+			domP.appendChild( domImg );
+			domP.appendChild( domText );
+
+			const viewImg = new ViewElement( 'img' );
+
+			converter.bindElements( domImg, viewImg );
+
+			const viewP = converter.domToView( domP );
+
+			expect( viewP ).to.be.an.instanceof( ViewElement );
+			expect( viewP.name ).to.equal( 'p' );
+
+			expect( viewP.getAttribute( 'class' ) ).to.equal( 'foo' );
+			expect( getIteratorCount( viewP.getAttributeKeys() ) ).to.equal( 1 );
+
+			expect( viewP.getChildCount() ).to.equal( 2 );
+			expect( viewP.getChild( 0 ).name ).to.equal( 'img' );
+			expect( viewP.getChild( 1 ).data ).to.equal( 'foo' );
+
+			expect( converter.getCorrespondingDom( viewP ) ).to.not.equal( domP );
+			expect( converter.getCorrespondingDom( viewP.getChild( 0 ) ) ).to.equal( domImg );
+		} );
+
+		it( 'should create tree of view elements from DOM elements and bind elements', () => {
+			const domImg = document.createElement( 'img' );
+			const domText = document.createTextNode( 'foo' );
+			const domP = document.createElement( 'p' );
+
+			domP.setAttribute( 'class', 'foo' );
+
+			domP.appendChild( domImg );
+			domP.appendChild( domText );
+
+			const viewP = converter.domToView( domP, { bind: true } );
+
+			expect( viewP ).to.be.an.instanceof( ViewElement );
+			expect( viewP.name ).to.equal( 'p' );
+
+			expect( viewP.getAttribute( 'class' ) ).to.equal( 'foo' );
+			expect( getIteratorCount( viewP.getAttributeKeys() ) ).to.equal( 1 );
+
+			expect( viewP.getChildCount() ).to.equal( 2 );
+			expect( viewP.getChild( 0 ).name ).to.equal( 'img' );
+			expect( viewP.getChild( 1 ).data ).to.equal( 'foo' );
+
+			expect( converter.getCorrespondingDom( viewP ) ).to.equal( domP );
+			expect( converter.getCorrespondingDom( viewP.getChild( 0 ) ) ).to.equal( domP.childNodes[ 0 ] );
+		} );
+
+		it( 'should create tree of view elements from DOM element without children', () => {
+			const domImg = document.createElement( 'img' );
+			const domText = document.createTextNode( 'foo' );
+			const domP = document.createElement( 'p' );
+
+			domP.setAttribute( 'class', 'foo' );
+
+			domP.appendChild( domImg );
+			domP.appendChild( domText );
+
+			const viewImg = new ViewElement( 'img' );
+
+			converter.bindElements( domImg, viewImg );
+
+			const viewP = converter.domToView( domP, { withChildren: false } );
+
+			expect( viewP ).to.be.an.instanceof( ViewElement );
+			expect( viewP.name ).to.equal( 'p' );
+
+			expect( viewP.getAttribute( 'class' ) ).to.equal( 'foo' );
+			expect( getIteratorCount( viewP.getAttributeKeys() ) ).to.equal( 1 );
+
+			expect( viewP.getChildCount() ).to.equal( 0 );
+			expect( converter.getCorrespondingDom( viewP ) ).to.not.equal( domP );
+		} );
+	} );
+
+	describe( 'viewToDom', () => {
+		it( 'should create tree of DOM elements from view elements', () => {
+			const viewImg = new ViewElement( 'img' );
+			const viewText = new ViewText( 'foo' );
+			const viewP = new ViewElement( 'p' );
+
+			viewP.setAttribute( 'class', 'foo' );
+
+			viewP.appendChildren( viewImg );
+			viewP.appendChildren( viewText );
+
+			const domImg = document.createElement( 'img' );
+
+			converter.bindElements( domImg, viewImg );
+
+			const domP = converter.viewToDom( viewP, document );
+
+			expect( domP ).to.be.an.instanceof( HTMLElement );
+			expect( domP.tagName ).to.equal( 'P' );
+
+			expect( domP.getAttribute( 'class' ) ).to.equal( 'foo' );
+			expect( domP.attributes.length ).to.equal( 1 );
+
+			expect( domP.childNodes.length ).to.equal( 2 );
+			expect( domP.childNodes[ 0 ].tagName ).to.equal( 'IMG' );
+			expect( domP.childNodes[ 1 ].data ).to.equal( 'foo' );
+
+			expect( converter.getCorrespondingView( domP ) ).not.to.equal( viewP );
+			expect( converter.getCorrespondingView( domP.childNodes[ 0 ] ) ).to.equal( viewImg );
+		} );
+
+		it( 'should create tree of DOM elements from view elements and bind elements', () => {
+			const viewImg = new ViewElement( 'img' );
+			const viewText = new ViewText( 'foo' );
+			const viewP = new ViewElement( 'p' );
+
+			viewP.setAttribute( 'class', 'foo' );
+
+			viewP.appendChildren( viewImg );
+			viewP.appendChildren( viewText );
+
+			const domP = converter.viewToDom( viewP, document, { bind: true } );
+
+			expect( domP ).to.be.an.instanceof( HTMLElement );
+			expect( domP.tagName ).to.equal( 'P' );
+
+			expect( domP.getAttribute( 'class' ) ).to.equal( 'foo' );
+			expect( domP.attributes.length ).to.equal( 1 );
+
+			expect( domP.childNodes.length ).to.equal( 2 );
+			expect( domP.childNodes[ 0 ].tagName ).to.equal( 'IMG' );
+			expect( domP.childNodes[ 1 ].data ).to.equal( 'foo' );
+
+			expect( converter.getCorrespondingView( domP ) ).to.equal( viewP );
+			expect( converter.getCorrespondingView( domP.childNodes[ 0 ] ) ).to.equal( viewP.getChild( 0 ) );
+		} );
+
+		it( 'should create tree of DOM elements from view element without children', () => {
+			const viewImg = new ViewElement( 'img' );
+			const viewText = new ViewText( 'foo' );
+			const viewP = new ViewElement( 'p' );
+
+			viewP.setAttribute( 'class', 'foo' );
+
+			viewP.appendChildren( viewImg );
+			viewP.appendChildren( viewText );
+
+			const domImg = document.createElement( 'img' );
+
+			converter.bindElements( domImg, viewImg );
+
+			const domP = converter.viewToDom( viewP, document, { withChildren: false } );
+
+			expect( domP ).to.be.an.instanceof( HTMLElement );
+			expect( domP.tagName ).to.equal( 'P' );
+
+			expect( domP.getAttribute( 'class' ) ).to.equal( 'foo' );
+			expect( domP.attributes.length ).to.equal( 1 );
+
+			expect( domP.childNodes.length ).to.equal( 0 );
+			expect( converter.getCorrespondingView( domP ) ).not.to.equal( viewP );
+		} );
+	} );
+
+	describe( 'getCorrespondingView', () => {
+		it( 'should return corresponding view element if element is passed', () => {
+			const domElement = document.createElement( 'p' );
+			const viewElement = new ViewElement( 'p' );
+
+			converter.bindElements( domElement, viewElement );
+
+			expect( converter.getCorrespondingView( domElement ) ).to.equal( viewElement );
+		} );
+
+		it( 'should return corresponding view text if text is passed', () => {
+			const domText = document.createTextNode( 'foo' );
+			const domP = document.createElement( 'p' );
+
+			domP.appendChild( domText );
+
+			const viewP = converter.domToView( domP );
+			const viewText = viewP.getChild( 0 );
+
+			converter.bindElements( domP, viewP );
+
+			expect( converter.getCorrespondingView( domText ) ).to.equal( viewText );
+		} );
+	} );
+
+	describe( 'getCorrespondingViewElement', () => {
+		it( 'should return corresponding view element', () => {
+			const domElement = document.createElement( 'p' );
+			const viewElement = new ViewElement( 'p' );
+
+			converter.bindElements( domElement, viewElement );
+
+			expect( converter.getCorrespondingViewElement( domElement ) ).to.equal( viewElement );
+		} );
+	} );
+
+	describe( 'getCorrespondingViewText', () => {
+		it( 'should return corresponding view text based on sibling', () => {
+			const domImg = document.createElement( 'img' );
+			const domText = document.createTextNode( 'foo' );
+			const domP = document.createElement( 'p' );
+
+			domP.appendChild( domImg );
+			domP.appendChild( domText );
+
+			const viewImg = new ViewElement( 'img' );
+
+			converter.bindElements( domImg, viewImg );
+
+			const viewP = converter.domToView( domP );
+			const viewText = viewP.getChild( 1 );
+
+			expect( converter.getCorrespondingViewText( domText ) ).to.equal( viewText );
+		} );
+
+		it( 'should return corresponding view text based on parent', () => {
+			const domText = document.createTextNode( 'foo' );
+			const domP = document.createElement( 'p' );
+
+			domP.appendChild( domText );
+
+			const viewP = converter.domToView( domP );
+			const viewText = viewP.getChild( 0 );
+
+			converter.bindElements( domP, viewP );
+
+			expect( converter.getCorrespondingViewText( domText ) ).to.equal( viewText );
+		} );
+
+		it( 'should return null if sibling is not bound', () => {
+			const domImg = document.createElement( 'img' );
+			const domText = document.createTextNode( 'foo' );
+			const domP = document.createElement( 'p' );
+
+			domP.appendChild( domImg );
+			domP.appendChild( domText );
+
+			const viewP = converter.domToView( domP );
+
+			converter.bindElements( domP, viewP );
+
+			expect( converter.getCorrespondingViewText( domText ) ).to.be.null;
+		} );
+
+		it( 'should return null if sibling is not element', () => {
+			const domTextFoo = document.createTextNode( 'foo' );
+			const domTextBar = document.createTextNode( 'bar' );
+			const domP = document.createElement( 'p' );
+
+			domP.appendChild( domTextFoo );
+			domP.appendChild( domTextBar );
+
+			const viewP = converter.domToView( domP );
+
+			converter.bindElements( domP, viewP );
+
+			expect( converter.getCorrespondingViewText( domTextBar ) ).to.be.null;
+		} );
+
+		it( 'should return null if parent is not bound', () => {
+			const domText = document.createTextNode( 'foo' );
+			const domP = document.createElement( 'p' );
+
+			domP.appendChild( domText );
+
+			expect( converter.getCorrespondingViewText( domText ) ).to.be.null;
+		} );
+	} );
+
+	describe( 'getCorrespondingDom', () => {
+		it( 'should return corresponding DOM element if element was passed', () => {
+			const domElement = document.createElement( 'p' );
+			const viewElement = new ViewElement( 'p' );
+
+			converter.bindElements( domElement, viewElement );
+
+			expect( converter.getCorrespondingDom( viewElement ) ).to.equal( domElement );
+		} );
+
+		it( 'should return corresponding DOM text if text was passed', () => {
+			const domText = document.createTextNode( 'foo' );
+			const domP = document.createElement( 'p' );
+
+			domP.appendChild( domText );
+
+			const viewP = converter.domToView( domP );
+			const viewText = viewP.getChild( 0 );
+
+			converter.bindElements( domP, viewP );
+
+			expect( converter.getCorrespondingDom( viewText ) ).to.equal( domText );
+		} );
+	} );
+
+	describe( 'getCorrespondingDomElement', () => {
+		it( 'should return corresponding DOM element', () => {
+			const domElement = document.createElement( 'p' );
+			const viewElement = new ViewElement( 'p' );
+
+			converter.bindElements( domElement, viewElement );
+
+			expect( converter.getCorrespondingDomElement( viewElement ) ).to.equal( domElement );
+		} );
+	} );
+
+	describe( 'getCorrespondingDomText', () => {
+		it( 'should return corresponding DOM text based on sibling', () => {
+			const domImg = document.createElement( 'img' );
+			const domText = document.createTextNode( 'foo' );
+			const domP = document.createElement( 'p' );
+
+			domP.appendChild( domImg );
+			domP.appendChild( domText );
+
+			const viewImg = new ViewElement( 'img' );
+
+			converter.bindElements( domImg, viewImg );
+
+			const viewP = converter.domToView( domP );
+			const viewText = viewP.getChild( 1 );
+
+			expect( converter.getCorrespondingDomText( viewText ) ).to.equal( domText );
+		} );
+
+		it( 'should return corresponding DOM text based on parent', () => {
+			const domText = document.createTextNode( 'foo' );
+			const domP = document.createElement( 'p' );
+
+			domP.appendChild( domText );
+
+			const viewP = converter.domToView( domP );
+			const viewText = viewP.getChild( 0 );
+
+			converter.bindElements( domP, viewP );
+
+			expect( converter.getCorrespondingDomText( viewText ) ).to.equal( domText );
+		} );
+
+		it( 'should return null if sibling is not bound', () => {
+			const domImg = document.createElement( 'img' );
+			const domText = document.createTextNode( 'foo' );
+			const domP = document.createElement( 'p' );
+
+			domP.appendChild( domImg );
+			domP.appendChild( domText );
+
+			const viewP = converter.domToView( domP );
+			const viewText = viewP.getChild( 1 );
+
+			converter.bindElements( domP, viewP );
+
+			expect( converter.getCorrespondingDomText( viewText ) ).to.be.null;
+		} );
+
+		it( 'should return null if parent is not bound', () => {
+			const domText = document.createTextNode( 'foo' );
+			const domP = document.createElement( 'p' );
+
+			domP.appendChild( domText );
+
+			const viewP = converter.domToView( domP );
+			const viewText = viewP.getChild( 0 );
+
+			expect( converter.getCorrespondingDomText( viewText ) ).to.be.null;
+		} );
+	} );
+} );

+ 198 - 0
packages/ckeditor5-engine/tests/treeview/element.js

@@ -0,0 +1,198 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import coreTestUtils from '/tests/core/_utils/utils.js';
+import Node from '/ckeditor5/core/treeview/node.js';
+import ViewElement from '/ckeditor5/core/treeview/element.js';
+
+const getIteratorCount = coreTestUtils.getIteratorCount;
+
+describe( 'Element', () => {
+	describe( 'constructor', () => {
+		it( 'should create element without attributes', () => {
+			const el = new ViewElement( 'p' );
+
+			expect( el ).to.be.an.instanceof( Node );
+			expect( el ).to.have.property( 'name' ).that.equals( 'p' );
+			expect( el ).to.have.property( 'parent' ).that.is.null;
+			expect( getIteratorCount( el.getAttributeKeys() ) ).to.equal( 0 );
+		} );
+
+		it( 'should create element with attributes as plain object', () => {
+			const el = new ViewElement( 'p', { foo: 'bar' } );
+
+			expect( el ).to.have.property( 'name' ).that.equals( 'p' );
+			expect( getIteratorCount( el.getAttributeKeys() ) ).to.equal( 1 );
+			expect( el.getAttribute( 'foo' ) ).to.equal( 'bar' );
+		} );
+
+		it( 'should create element with attributes as map', () => {
+			const attrs = new Map();
+			attrs.set( 'foo', 'bar' );
+
+			const el = new ViewElement( 'p', attrs );
+
+			expect( el ).to.have.property( 'name' ).that.equals( 'p' );
+			expect( getIteratorCount( el.getAttributeKeys() ) ).to.equal( 1 );
+			expect( el.getAttribute( 'foo' ) ).to.equal( 'bar' );
+		} );
+
+		it( 'should create element with children', () => {
+			const child = new ViewElement( 'p', { foo: 'bar' } );
+			const parent = new ViewElement( 'div', [], [ child ] );
+
+			expect( parent ).to.have.property( 'name' ).that.equals( 'div' );
+			expect( parent.getChildCount() ).to.equal( 1 );
+			expect( parent.getChild( 0 ) ).to.have.property( 'name' ).that.equals( 'p' );
+		} );
+	} );
+
+	describe( 'children manipulation methods', () => {
+		let parent, el1, el2, el3, el4;
+
+		beforeEach( () => {
+			parent = new ViewElement( 'p' );
+			el1 = new ViewElement( 'el1' );
+			el2 = new ViewElement( 'el2' );
+			el3 = new ViewElement( 'el3' );
+			el4 = new ViewElement( 'el4' );
+		} );
+
+		describe( 'insertion', () => {
+			it( 'should insert children', () => {
+				parent.insertChildren( 0, [ el1, el3 ] );
+				parent.insertChildren( 1, el2 );
+
+				expect( parent.getChildCount() ).to.equal( 3 );
+				expect( parent.getChild( 0 ) ).to.have.property( 'name' ).that.equals( 'el1' );
+				expect( parent.getChild( 1 ) ).to.have.property( 'name' ).that.equals( 'el2' );
+				expect( parent.getChild( 2 ) ).to.have.property( 'name' ).that.equals( 'el3' );
+			} );
+
+			it( 'should append children', () => {
+				parent.insertChildren( 0, el1 );
+				parent.appendChildren( el2 );
+				parent.appendChildren( el3 );
+
+				expect( parent.getChildCount() ).to.equal( 3 );
+				expect( parent.getChild( 0 ) ).to.have.property( 'name' ).that.equals( 'el1' );
+				expect( parent.getChild( 1 ) ).to.have.property( 'name' ).that.equals( 'el2' );
+				expect( parent.getChild( 2 ) ).to.have.property( 'name' ).that.equals( 'el3' );
+			} );
+		} );
+
+		describe( 'getChildIndex', () => {
+			it( 'should return child index', () => {
+				parent.appendChildren( el1 );
+				parent.appendChildren( el2 );
+				parent.appendChildren( el3 );
+
+				expect( parent.getChildCount() ).to.equal( 3 );
+				expect( parent.getChildIndex( el1 ) ).to.equal( 0 );
+				expect( parent.getChildIndex( el2 ) ).to.equal( 1 );
+				expect( parent.getChildIndex( el3 ) ).to.equal( 2 );
+			} );
+		} );
+
+		describe( 'getChildren', () => {
+			it( 'should renturn children iterator', () => {
+				parent.appendChildren( el1 );
+				parent.appendChildren( el2 );
+				parent.appendChildren( el3 );
+
+				const expected = [ el1, el2, el3 ];
+				let i = 0;
+
+				for ( let child of parent.getChildren() ) {
+					expect( child ).to.equal( expected[ i ] );
+					i++;
+				}
+
+				expect( i ).to.equal( 3 );
+			} );
+		} );
+
+		describe( 'removeChildren', () => {
+			it( 'should remove children', () => {
+				parent.appendChildren( el1 );
+				parent.appendChildren( el2 );
+				parent.appendChildren( el3 );
+				parent.appendChildren( el4 );
+
+				parent.removeChildren( 1, 2 );
+
+				expect( parent.getChildCount() ).to.equal( 2 );
+				expect( parent.getChild( 0 ) ).to.have.property( 'name' ).that.equals( 'el1' );
+				expect( parent.getChild( 1 ) ).to.have.property( 'name' ).that.equals( 'el4' );
+
+				expect( el1.parent ).to.equal( parent );
+				expect( el2.parent ).to.be.null;
+				expect( el3.parent ).to.be.null;
+				expect( el4.parent ).equal( parent );
+			} );
+		} );
+	} );
+
+	describe( 'attributes manipulation methods', () => {
+		let el;
+
+		beforeEach( () => {
+			el = new ViewElement( 'p' );
+		} );
+
+		describe( 'getAttribute', () => {
+			it( 'should return attribute', () => {
+				el.setAttribute( 'foo', 'bar' );
+
+				expect( el.getAttribute( 'foo' ) ).to.equal( 'bar' );
+				expect( el.getAttribute( 'bom' ) ).to.not.be.ok;
+			} );
+		} );
+
+		describe( 'hasAttribute', () => {
+			it( 'should return true if element has attribute', () => {
+				el.setAttribute( 'foo', 'bar' );
+
+				expect( el.hasAttribute( 'foo' ) ).to.be.true;
+				expect( el.hasAttribute( 'bom' ) ).to.be.false;
+			} );
+		} );
+
+		describe( 'getAttributeKeys', () => {
+			it( 'should return keys', () => {
+				el.setAttribute( 'foo', true );
+				el.setAttribute( 'bar', true );
+
+				const expected = [ 'foo', 'bar' ];
+				let i = 0;
+
+				for ( let key of el.getAttributeKeys() ) {
+					expect( key ).to.equal( expected[ i ] );
+					i++;
+				}
+
+				expect( i ).to.equal( 2 );
+			} );
+		} );
+
+		describe( 'removeAttribute', () => {
+			it( 'should remove attributes', () => {
+				el.setAttribute( 'foo', true );
+
+				expect( el.hasAttribute( 'foo' ) ).to.be.true;
+
+				el.removeAttribute( 'foo' );
+
+				expect( el.hasAttribute( 'foo' ) ).to.be.false;
+
+				expect( getIteratorCount( el.getAttributeKeys() ) ).to.equal( 0 );
+			} );
+		} );
+	} );
+} );

+ 37 - 0
packages/ckeditor5-engine/tests/treeview/integration.js

@@ -0,0 +1,37 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import TreeView from '/ckeditor5/core/treeview/treeview.js';
+import TreeElement from '/ckeditor5/core/treeview/element.js';
+
+describe( 'TreeView integration', () => {
+	it( 'should remove content of the DOM', () => {
+		const domP = document.createElement( 'p' );
+		const domDiv = document.createElement( 'div' );
+		domDiv.setAttribute( 'id', 'editor' );
+		domDiv.appendChild( domP );
+
+		const treeView = new TreeView( domDiv );
+		treeView.render();
+
+		expect( domDiv.childNodes.length ).to.equal( 0 );
+		expect( domDiv.getAttribute( 'id' ) ).to.equal( 'editor' );
+	} );
+
+	it( 'should render changes in the TreeView', () => {
+		const domDiv = document.createElement( 'div' );
+
+		const treeView = new TreeView( domDiv );
+		treeView.viewRoot.appendChildren( new TreeElement( 'p' ) );
+		treeView.render();
+
+		expect( domDiv.childNodes.length ).to.equal( 1 );
+		expect( domDiv.childNodes[ 0 ].tagName ).to.equal( 'P' );
+	} );
+} );

+ 4 - 0
packages/ckeditor5-engine/tests/treeview/manual/init.html

@@ -0,0 +1,4 @@
+<div contenteditable="true" id="editor">
+	<p>Old</p>
+	<p>Content</p>
+</div>

+ 23 - 0
packages/ckeditor5-engine/tests/treeview/manual/init.js

@@ -0,0 +1,23 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global console:false */
+
+'use strict';
+
+import TreeView from '/ckeditor5/core/treeview/treeview.js';
+import Element from '/ckeditor5/core/treeview/element.js';
+import Text from '/ckeditor5/core/treeview/text.js';
+
+const treeView = new TreeView( document.getElementById( 'editor' ) );
+
+treeView.viewRoot.insertChildren( 0, [
+	new Element( 'p', [], [ new Text( 'New' ) ] ),
+	new Element( 'p', [], [ new Text( 'Content' ) ] )
+] );
+
+treeView.render();
+
+console.log( treeView );

+ 13 - 0
packages/ckeditor5-engine/tests/treeview/manual/init.md

@@ -0,0 +1,13 @@
+@bender-ui: collapsed
+@bender-tags: treeview
+
+## Init ##
+
+TreeView should be initialised and displayed in console.
+
+The content shoud be:
+
+```
+New
+Content
+```

+ 1 - 0
packages/ckeditor5-engine/tests/treeview/manual/mutationobserver.html

@@ -0,0 +1 @@
+<div contenteditable="true" id="editor"></div>

+ 27 - 0
packages/ckeditor5-engine/tests/treeview/manual/mutationobserver.js

@@ -0,0 +1,27 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global console:false */
+
+'use strict';
+
+import TreeView from '/ckeditor5/core/treeview/treeview.js';
+import Element from '/ckeditor5/core/treeview/element.js';
+import Text from '/ckeditor5/core/treeview/text.js';
+import MutationObserver from '/ckeditor5/core/treeview/observer/mutationobserver.js';
+
+const treeView = new TreeView( document.getElementById( 'editor' ) );
+const mutationObserver = new MutationObserver();
+
+treeView.on( 'mutations', ( evt, mutations ) => console.log( mutations ) );
+
+treeView.addObserver( mutationObserver );
+
+treeView.viewRoot.insertChildren( 0, [
+	new Element( 'p', [], [ new Text( 'foo' ) ] ),
+	new Element( 'p', [], [ new Text( 'bom' ) ] )
+] );
+
+treeView.render();

+ 15 - 0
packages/ckeditor5-engine/tests/treeview/manual/mutationobserver.md

@@ -0,0 +1,15 @@
+@bender-ui: collapsed
+@bender-tags: treeview
+
+Try to:
+
+* type,
+* break paragraph,
+* delete break,
+* bold,
+* move text/paragraph,
+* insert/delete paragraph.
+
+## Mutation Preventing ##
+
+Document should not change, all changes should be prevented before rendering. Selection may change.

+ 1 - 0
packages/ckeditor5-engine/tests/treeview/manual/typing.html

@@ -0,0 +1 @@
+<div contenteditable="true" id="editor"></div>

+ 34 - 0
packages/ckeditor5-engine/tests/treeview/manual/typing.js

@@ -0,0 +1,34 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global console:false */
+
+'use strict';
+
+import TreeView from '/ckeditor5/core/treeview/treeview.js';
+import Element from '/ckeditor5/core/treeview/element.js';
+import Text from '/ckeditor5/core/treeview/text.js';
+import MutationObserver from '/ckeditor5/core/treeview/observer/mutationobserver.js';
+
+const treeView = new TreeView( document.getElementById( 'editor' ) );
+
+treeView.on( 'mutations', ( evt, mutations ) => console.log( mutations ) );
+treeView.on( 'mutations', handleTyping );
+
+treeView.addObserver( new MutationObserver() );
+
+treeView.viewRoot.insertChildren( 0, [ new Element( 'p', [], [ new Text( 'foo' ) ] ) ] );
+
+treeView.render();
+
+function handleTyping( evt, mutations ) {
+	const mutation = mutations[ 0 ];
+
+	if ( mutations.length > 1 || mutation.type !== 'text' ) {
+		return;
+	}
+
+	mutation.node.data = mutation.newText;
+}

+ 8 - 0
packages/ckeditor5-engine/tests/treeview/manual/typing.md

@@ -0,0 +1,8 @@
+@bender-ui: collapsed
+@bender-tags: treeview
+
+## Typing ##
+
+* It should be possible to type (change the content of the text node).
+* All other operations should be permitted, it should not be possible to remove whole node.
+* Composition should work fine, not be broken.

+ 215 - 0
packages/ckeditor5-engine/tests/treeview/node.js

@@ -0,0 +1,215 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import Element from '/ckeditor5/core/treeview/element.js';
+import Text from '/ckeditor5/core/treeview/text.js';
+import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
+
+describe( 'Node', () => {
+	let root;
+	let one, two, three;
+	let charB, charA, charR, img;
+
+	before( () => {
+		charB = new Text( 'b' );
+		charA = new Text( 'a' );
+		img = new Element( 'img' );
+		charR = new Text( 'r' );
+
+		one = new Element( 'one' );
+		two = new Element( 'two', null, [ charB, charA, img, charR ] );
+		three = new Element( 'three' );
+
+		root = new Element( null, null, [ one, two, three ] );
+	} );
+
+	describe( 'getNextSibling/getPreviousSibling', () => {
+		it( 'should return next sibling', () => {
+			expect( root.getNextSibling() ).to.be.null;
+
+			expect( one.getNextSibling() ).to.equal( two );
+			expect( two.getNextSibling() ).to.equal( three );
+			expect( three.getNextSibling() ).to.be.null;
+
+			expect( charB.getNextSibling() ).to.equal( charA );
+			expect( charA.getNextSibling() ).to.equal( img );
+			expect( img.getNextSibling() ).to.equal( charR );
+			expect( charR.getNextSibling() ).to.be.null;
+		} );
+
+		it( 'should return previous sibling', () => {
+			expect( root.getPreviousSibling() ).to.be.null;
+
+			expect( one.getPreviousSibling() ).to.be.null;
+			expect( two.getPreviousSibling() ).to.equal( one );
+			expect( three.getPreviousSibling() ).to.equal( two );
+
+			expect( charB.getPreviousSibling() ).to.be.null;
+			expect( charA.getPreviousSibling() ).to.equal( charB );
+			expect( img.getPreviousSibling() ).to.equal( charA );
+			expect( charR.getPreviousSibling() ).to.equal( img );
+		} );
+	} );
+
+	describe( 'getIndex', () => {
+		it( 'should return null if the parent is null', () => {
+			expect( root.getIndex() ).to.be.null;
+		} );
+
+		it( 'should return index in the parent', () => {
+			expect( one.getIndex() ).to.equal( 0 );
+			expect( two.getIndex() ).to.equal( 1 );
+			expect( three.getIndex() ).to.equal( 2 );
+
+			expect( charB.getIndex() ).to.equal( 0 );
+			expect( charA.getIndex() ).to.equal( 1 );
+			expect( img.getIndex() ).to.equal( 2 );
+			expect( charR.getIndex() ).to.equal( 3 );
+		} );
+
+		it( 'should throw an error if parent does not contain element', () => {
+			let f = new Text( 'f' );
+			let bar = new Element( 'bar', [], [] );
+
+			f.parent = bar;
+
+			expect(
+				() => {
+					f.getIndex();
+				}
+			).to.throw( CKEditorError, /treeview-node-not-found-in-parent/ );
+		} );
+	} );
+
+	describe( 'getTreeView', () => {
+		it( 'should return null if any parent has not set treeview', () => {
+			expect( charA.getTreeView() ).to.be.null;
+		} );
+
+		it( 'should return TreeView attached to the element', () => {
+			const tvMock = {};
+			const element = new Element( 'p' );
+
+			element.setTreeView( tvMock );
+
+			expect( element.getTreeView() ).to.equal( tvMock );
+		} );
+
+		it( 'should return TreeView attached to the parent element', () => {
+			const tvMock = {};
+			const parent = new Element( 'div' );
+			const child = new Element( 'p' );
+
+			child.parent = parent;
+
+			parent.setTreeView( tvMock );
+
+			expect( parent.getTreeView() ).to.equal( tvMock );
+			expect( child.getTreeView() ).to.equal( tvMock );
+		} );
+	} );
+
+	describe( 'change event', () => {
+		let root, text, img;
+		let rootChangeSpy;
+
+		before( () => {
+			rootChangeSpy = sinon.spy();
+		} );
+
+		beforeEach( () => {
+			text = new Text( 'foo' );
+			img = new Element( 'img' );
+			img.setAttribute( 'src', 'img.png' );
+
+			root = new Element( 'p', { renderer: { markToSync: rootChangeSpy } } );
+			root.appendChildren( [ text, img ] );
+
+			root.on( 'change', ( evt, type, node ) => {
+				rootChangeSpy( type, node );
+			} );
+
+			rootChangeSpy.reset();
+		} );
+
+		it( 'should be fired on the node', () => {
+			const imgChangeSpy = sinon.spy();
+
+			img.on( 'change', ( evt, type, node ) => {
+				imgChangeSpy( type, node );
+			} );
+
+			img.setAttribute( 'width', 100 );
+
+			sinon.assert.calledOnce( imgChangeSpy );
+			sinon.assert.calledWith( imgChangeSpy, 'ATTRIBUTES', img );
+		} );
+
+		it( 'should be fired on the parent', () => {
+			img.setAttribute( 'width', 100 );
+
+			sinon.assert.calledOnce( rootChangeSpy );
+			sinon.assert.calledWith( rootChangeSpy, 'ATTRIBUTES', img );
+		} );
+
+		describe( 'setAttr', () => {
+			it( 'should fire change event', () => {
+				img.setAttribute( 'width', 100 );
+
+				sinon.assert.calledOnce( rootChangeSpy );
+				sinon.assert.calledWith( rootChangeSpy, 'ATTRIBUTES', img );
+			} );
+		} );
+
+		describe( 'removeAttr', () => {
+			it( 'should fire change event', () => {
+				img.removeAttribute( 'src' );
+
+				sinon.assert.calledOnce( rootChangeSpy );
+				sinon.assert.calledWith( rootChangeSpy, 'ATTRIBUTES', img );
+			} );
+		} );
+
+		describe( 'insertChildren', () => {
+			it( 'should fire change event', () => {
+				root.insertChildren( 1, new Element( 'img' ) );
+
+				sinon.assert.calledOnce( rootChangeSpy );
+				sinon.assert.calledWith( rootChangeSpy, 'CHILDREN', root );
+			} );
+		} );
+
+		describe( 'appendChildren', () => {
+			it( 'should fire change event', () => {
+				root.appendChildren( new Element( 'img' ) );
+
+				sinon.assert.calledOnce( rootChangeSpy );
+				sinon.assert.calledWith( rootChangeSpy, 'CHILDREN', root );
+			} );
+		} );
+
+		describe( 'removeChildren', () => {
+			it( 'should fire change event', () => {
+				root.removeChildren( 1, 1 );
+
+				sinon.assert.calledOnce( rootChangeSpy );
+				sinon.assert.calledWith( rootChangeSpy, 'CHILDREN', root );
+			} );
+		} );
+
+		describe( 'removeChildren', () => {
+			it( 'should fire change event', () => {
+				text.data = 'bar';
+
+				sinon.assert.calledOnce( rootChangeSpy );
+				sinon.assert.calledWith( rootChangeSpy, 'TEXT', text );
+			} );
+		} );
+	} );
+} );

+ 1 - 0
packages/ckeditor5-engine/tests/treeview/observer/mutationobserver.html

@@ -0,0 +1 @@
+<div contenteditable="true" id="editor"></div>

+ 119 - 0
packages/ckeditor5-engine/tests/treeview/observer/mutationobserver.js

@@ -0,0 +1,119 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import TreeView from '/ckeditor5/core/treeview/treeview.js';
+import Element from '/ckeditor5/core/treeview/element.js';
+import Text from '/ckeditor5/core/treeview/text.js';
+import MutationObserver from '/ckeditor5/core/treeview/observer/mutationobserver.js';
+
+describe( 'MutationObserver', () => {
+	let domEditor, treeView, mutationObserver, lastMutations;
+
+	beforeEach( () => {
+		domEditor = document.getElementById( 'editor' );
+		treeView = new TreeView( domEditor );
+		mutationObserver = new MutationObserver();
+		lastMutations = null;
+
+		treeView.addObserver( mutationObserver );
+		treeView.on( 'mutations', ( evt, mutations ) => lastMutations = mutations );
+
+		treeView.viewRoot.insertChildren( 0, [
+			new Element( 'p', [], [ new Text( 'foo' ) ] ),
+			new Element( 'p', [], [ new Text( 'bar' ) ] )
+			] );
+
+		treeView.render();
+	} );
+
+	afterEach( () => {
+		mutationObserver.detach();
+	} );
+
+	it( 'should handle typing', () => {
+		domEditor.childNodes[ 0 ].childNodes[ 0 ].data = 'foom';
+
+		handleMutation();
+
+		expectDomEditorNotToChange();
+		expect( lastMutations.length ).to.equal( 1 );
+		expect( lastMutations[ 0 ].type ).to.equal( 'text' );
+		expect( lastMutations[ 0 ].node ).to.equal( treeView.viewRoot.getChild( 0 ).getChild( 0 ) );
+		expect( lastMutations[ 0 ].newText ).to.equal( 'foom' );
+		expect( lastMutations[ 0 ].oldText ).to.equal( 'foo' );
+	} );
+
+	it( 'should handle bold', () => {
+		domEditor.childNodes[ 0 ].childNodes[ 0 ].data = 'f';
+		const domB = document.createElement( 'b' );
+		domB.appendChild( document.createTextNode( 'oo' ) );
+		domEditor.childNodes[ 0 ].appendChild( domB );
+
+		handleMutation();
+
+		expectDomEditorNotToChange();
+		expect( lastMutations.length ).to.equal( 1 );
+		expect( lastMutations[ 0 ].type ).to.equal( 'children' );
+		expect( lastMutations[ 0 ].node ).to.equal( treeView.viewRoot.getChild( 0 ) );
+
+		expect( lastMutations[ 0 ].newChildren.length ).to.equal( 2 );
+		expect( lastMutations[ 0 ].newChildren[ 0 ].data ).to.equal( 'f' );
+		expect( lastMutations[ 0 ].newChildren[ 1 ].name ).to.equal( 'b' );
+
+		expect( lastMutations[ 0 ].oldChildren.length ).to.equal( 1 );
+		expect( lastMutations[ 0 ].oldChildren[ 0 ].data ).to.equal( 'foo' );
+	} );
+
+	it( 'should deduplicate text changes', () => {
+		domEditor.childNodes[ 0 ].childNodes[ 0 ].data = 'foox';
+		domEditor.childNodes[ 0 ].childNodes[ 0 ].data = 'fooxy';
+
+		handleMutation();
+
+		expectDomEditorNotToChange();
+		expect( lastMutations.length ).to.equal( 1 );
+		expect( lastMutations[ 0 ].type ).to.equal( 'text' );
+		expect( lastMutations[ 0 ].node ).to.equal( treeView.viewRoot.getChild( 0 ).getChild( 0 ) );
+		expect( lastMutations[ 0 ].newText ).to.equal( 'fooxy' );
+		expect( lastMutations[ 0 ].oldText ).to.equal( 'foo' );
+	} );
+
+	it( 'should ignore changes in elements not attached to tree view', () => {
+		const domP = document.createElement( 'p' );
+		const domB = document.createElement( 'b' );
+		const domText = document.createTextNode( 'bom' );
+
+		domEditor.appendChild( domP );
+		domP.appendChild( domB );
+		domB.appendChild( domText );
+
+		handleMutation();
+
+		expectDomEditorNotToChange();
+		expect( lastMutations.length ).to.equal( 1 );
+		expect( lastMutations[ 0 ].type ).to.equal( 'children' );
+		expect( lastMutations[ 0 ].node ).to.equal( treeView.viewRoot );
+	} );
+
+	function handleMutation() {
+		mutationObserver._onMutations( mutationObserver._mutationObserver.takeRecords() );
+	}
+
+	function expectDomEditorNotToChange() {
+		expect( domEditor.childNodes.length ).to.equal( 2 );
+		expect( domEditor.childNodes[ 0 ].tagName ).to.equal( 'P' );
+		expect( domEditor.childNodes[ 1 ].tagName ).to.equal( 'P' );
+
+		expect( domEditor.childNodes[ 0 ].childNodes.length ).to.equal( 1 );
+		expect( domEditor.childNodes[ 0 ].childNodes[ 0 ].data ).to.equal( 'foo' );
+
+		expect( domEditor.childNodes[ 1 ].childNodes.length ).to.equal( 1 );
+		expect( domEditor.childNodes[ 1 ].childNodes[ 0 ].data ).to.equal( 'bar' );
+	}
+} );

+ 22 - 0
packages/ckeditor5-engine/tests/treeview/position.js

@@ -0,0 +1,22 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import Position from '/ckeditor5/core/treeview/position.js';
+
+describe( 'Position', () => {
+	describe( 'constructor', () => {
+		it( 'should create element without attributes', () => {
+			const parentMock = {};
+			const elem = new Position( parentMock, 5 );
+
+			expect( elem ).to.have.property( 'parent' ).that.equals( parentMock );
+			expect( elem ).to.have.property( 'offset' ).that.equals( 5 );
+		} );
+	} );
+} );

+ 270 - 0
packages/ckeditor5-engine/tests/treeview/renderer.js

@@ -0,0 +1,270 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import Renderer from '/ckeditor5/core/treeview/renderer.js';
+import ViewElement from '/ckeditor5/core/treeview/element.js';
+import ViewText from '/ckeditor5/core/treeview/text.js';
+import Converter from '/ckeditor5/core/treeview/converter.js';
+import CKEditorError from '/ckeditor5/core/ckeditorerror.js';
+
+describe( 'Renderer', () => {
+	let converter, renderer;
+
+	before( () => {
+		converter = new Converter();
+		renderer = new Renderer( converter );
+	} );
+
+	describe( 'markToSync', () => {
+		let viewNode;
+
+		beforeEach( () => {
+			viewNode = new ViewElement( 'p' );
+
+			const domNode = document.createElement( 'p' );
+			converter.bindElements( domNode, viewNode );
+			viewNode.appendChildren( new ViewText( 'foo' ) );
+
+			renderer.markedTexts.clear();
+			renderer.markedAttributes.clear();
+			renderer.markedChildren.clear();
+		} );
+
+		it( 'should mark attributes which need update', () => {
+			viewNode.setAttribute( 'class', 'foo' );
+
+			renderer.markToSync( 'ATTRIBUTES', viewNode );
+
+			expect( renderer.markedAttributes.has( viewNode ) ).to.be.true;
+		} );
+
+		it( 'should mark children which need update', () => {
+			viewNode.appendChildren( new ViewText( 'foo' ) );
+
+			renderer.markToSync( 'CHILDREN', viewNode );
+
+			expect( renderer.markedChildren.has( viewNode ) ).to.be.true;
+		} );
+
+		it( 'should not mark children if element has no corresponding node', () => {
+			// Overwrite viewNode with node without coresponding DOM node.
+			viewNode = new ViewElement( 'p' );
+
+			viewNode.appendChildren( new ViewText( 'foo' ) );
+
+			renderer.markToSync( 'CHILDREN', viewNode );
+
+			expect( renderer.markedTexts.has( viewNode ) ).to.be.false;
+		} );
+
+		it( 'should mark text which need update', () => {
+			const viewText = new ViewText( 'foo' );
+			viewNode.appendChildren( viewText );
+			viewText.data = 'bar';
+
+			renderer.markToSync( 'TEXT', viewText );
+
+			expect( renderer.markedTexts.has( viewText ) ).to.be.true;
+		} );
+
+		it( 'should not mark text if parent has no corresponding node', () => {
+			const viewText = new ViewText( 'foo' );
+			// Overwrite viewNode with node without coresponding DOM node.
+			viewNode = new ViewElement( 'p' );
+
+			viewNode.appendChildren( viewText );
+			viewText.data = 'bar';
+
+			renderer.markToSync( 'TEXT', viewText );
+
+			expect( renderer.markedTexts.has( viewText ) ).to.be.false;
+		} );
+
+		it( 'should throw if the type is unknown', () => {
+			expect( () => {
+				renderer.markToSync( 'UNKNOWN', viewNode );
+			} ).to.throw( CKEditorError, /^renderer-unknown-type/ );
+		} );
+	} );
+
+	describe( 'render', () => {
+		let viewNode, domNode;
+
+		beforeEach( () => {
+			viewNode = new ViewElement( 'p' );
+			domNode = document.createElement( 'p' );
+
+			converter.bindElements( domNode, viewNode );
+
+			renderer.markedTexts.clear();
+			renderer.markedAttributes.clear();
+			renderer.markedChildren.clear();
+		} );
+
+		it( 'should update attributes', () => {
+			viewNode.setAttribute( 'class', 'foo' );
+
+			renderer.markToSync( 'ATTRIBUTES', viewNode );
+			renderer.render();
+
+			expect( domNode.getAttribute( 'class' ) ).to.equal( 'foo' );
+
+			expect( renderer.markedAttributes.size ).to.equal( 0 );
+		} );
+
+		it( 'should remove attributes', () => {
+			viewNode.setAttribute( 'class', 'foo' );
+			domNode.setAttribute( 'id', 'bar' );
+			domNode.setAttribute( 'class', 'bar' );
+
+			renderer.markToSync( 'ATTRIBUTES', viewNode );
+			renderer.render();
+
+			expect( domNode.getAttribute( 'class' ) ).to.equal( 'foo' );
+			expect( domNode.getAttribute( 'id' ) ).to.be.not.ok;
+
+			expect( renderer.markedAttributes.size ).to.equal( 0 );
+		} );
+
+		it( 'should add children', () => {
+			viewNode.appendChildren( new ViewText( 'foo' ) );
+
+			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.render();
+
+			expect( domNode.childNodes.length ).to.equal( 1 );
+			expect( domNode.childNodes[ 0 ].data ).to.equal( 'foo' );
+
+			expect( renderer.markedChildren.size ).to.equal( 0 );
+		} );
+
+		it( 'should remove children', () => {
+			viewNode.appendChildren( new ViewText( 'foo' ) );
+
+			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.render();
+
+			expect( domNode.childNodes.length ).to.equal( 1 );
+			expect( domNode.childNodes[ 0 ].data ).to.equal( 'foo' );
+
+			viewNode.removeChildren( 0, 1 );
+
+			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.render();
+
+			expect( domNode.childNodes.length ).to.equal( 0 );
+
+			expect( renderer.markedChildren.size ).to.equal( 0 );
+		} );
+
+		it( 'should update text', () => {
+			const viewText = new ViewText( 'foo' );
+			viewNode.appendChildren( viewText );
+
+			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.render();
+
+			expect( domNode.childNodes.length ).to.equal( 1 );
+			expect( domNode.childNodes[ 0 ].data ).to.equal( 'foo' );
+
+			viewText.data = 'bar';
+
+			renderer.markToSync( 'TEXT', viewText );
+			renderer.render();
+
+			expect( domNode.childNodes.length ).to.equal( 1 );
+			expect( domNode.childNodes[ 0 ].data ).to.equal( 'bar' );
+
+			expect( renderer.markedTexts.size ).to.equal( 0 );
+		} );
+
+		it( 'should not update text parent child list changed', () => {
+			const viewImg = new ViewElement( 'img' );
+			const viewText = new ViewText( 'foo' );
+			viewNode.appendChildren( [ viewImg, viewText ] );
+
+			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.markToSync( 'TEXT', viewText );
+			renderer.render();
+
+			expect( domNode.childNodes.length ).to.equal( 2 );
+			expect( domNode.childNodes[ 0 ].tagName ).to.equal( 'IMG' );
+			expect( domNode.childNodes[ 1 ].data ).to.equal( 'foo' );
+		} );
+
+		it( 'should not change text if it is the same during text rendering', () => {
+			const viewText = new ViewText( 'foo' );
+			viewNode.appendChildren( viewText );
+
+			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.render();
+
+			// This should not be changed during the render.
+			const domText = domNode.childNodes[ 0 ];
+
+			renderer.markToSync( 'TEXT', viewText );
+			renderer.render();
+
+			expect( domNode.childNodes.length ).to.equal( 1 );
+			expect( domNode.childNodes[ 0 ] ).to.equal( domText );
+		} );
+
+		it( 'should not change text if it is the same during children rendering', () => {
+			const viewText = new ViewText( 'foo' );
+			viewNode.appendChildren( viewText );
+
+			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.render();
+
+			// This should not be changed during the render.
+			const domText = domNode.childNodes[ 0 ];
+
+			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.render();
+
+			expect( domNode.childNodes.length ).to.equal( 1 );
+			expect( domNode.childNodes[ 0 ] ).to.equal( domText );
+		} );
+
+		it( 'should not change element if it is the same', () => {
+			const viewImg = new ViewElement( 'img' );
+			viewNode.appendChildren( viewImg );
+
+			// This should not be changed during the render.
+			const domImg = document.createElement( 'img' );
+			domNode.appendChild( domImg );
+
+			converter.bindElements( domImg, viewImg );
+
+			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.render();
+
+			expect( domNode.childNodes.length ).to.equal( 1 );
+			expect( domNode.childNodes[ 0 ] ).to.equal( domImg );
+		} );
+
+		it( 'should change element if it is different', () => {
+			const viewImg = new ViewElement( 'img' );
+			viewNode.appendChildren( viewImg );
+
+			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.render();
+
+			const viewP = new ViewElement( 'p' );
+			viewNode.removeChildren( 0, 1 );
+			viewNode.appendChildren( viewP );
+
+			renderer.markToSync( 'CHILDREN', viewNode );
+			renderer.render();
+
+			expect( domNode.childNodes.length ).to.equal( 1 );
+			expect( domNode.childNodes[ 0 ].tagName ).to.equal( 'P' );
+		} );
+	} );
+} );

+ 32 - 0
packages/ckeditor5-engine/tests/treeview/text.js

@@ -0,0 +1,32 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import ViewNode from '/ckeditor5/core/treeview/node.js';
+import ViewText from '/ckeditor5/core/treeview/text.js';
+
+describe( 'Element', () => {
+	describe( 'constructor', () => {
+		it( 'should create element without attributes', () => {
+			const text = new ViewText( 'foo' );
+
+			expect( text ).to.be.an.instanceof( ViewNode );
+			expect( text.data ).to.equal( 'foo' );
+			expect( text ).to.have.property( 'parent' ).that.is.null;
+		} );
+	} );
+
+	describe( 'setText', () => {
+		it( 'should change the text', () => {
+			const text = new ViewText( 'foo' );
+			text.data = 'bar';
+
+			expect( text.data ).to.equal( 'bar' );
+		} );
+	} );
+} );

+ 70 - 0
packages/ckeditor5-engine/tests/treeview/treeview.js

@@ -0,0 +1,70 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import TreeView from '/ckeditor5/core/treeview/treeview.js';
+import Observer from '/ckeditor5/core/treeview/observer/observer.js';
+import Renderer from '/ckeditor5/core/treeview/renderer.js';
+import Converter from '/ckeditor5/core/treeview/converter.js';
+
+describe( 'TreeView', () => {
+	describe( 'constructor', () => {
+		it( 'should create TreeView with all properties', () => {
+			const domP = document.createElement( 'p' );
+			const domDiv = document.createElement( 'div' );
+			domDiv.setAttribute( 'id', 'editor' );
+			domDiv.appendChild( domP );
+
+			const treeView = new TreeView( domDiv );
+
+			expect( treeView ).to.have.property( 'domRoot' ).that.equals( domDiv );
+			expect( treeView ).to.have.property( 'observers' ).that.is.instanceOf( Set );
+			expect( treeView ).to.have.property( 'renderer' ).that.is.instanceOf( Renderer );
+			expect( treeView ).to.have.property( 'converter' ).that.is.instanceOf( Converter );
+			expect( treeView ).to.have.property( 'viewRoot' );
+
+			expect( treeView.converter.getCorrespondingDom( treeView.viewRoot ) ).to.equal( domDiv );
+			expect( treeView.viewRoot.name ).to.equal( 'div' );
+			expect( treeView.viewRoot.getAttribute( 'id' ) ).to.equal( 'editor' );
+			expect( treeView.renderer.markedChildren.has( treeView.viewRoot ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'observer', () => {
+		let observerMock, treeView;
+
+		beforeEach( () => {
+			observerMock = new Observer();
+			observerMock.attach = sinon.spy();
+			observerMock.detach = sinon.spy();
+			observerMock.init = sinon.spy();
+
+			treeView = new TreeView( document.createElement( 'div' ) );
+			treeView.renderer.render = sinon.spy();
+		} );
+
+		it( 'should be inited and attached on adding', () => {
+			treeView.addObserver( observerMock );
+
+			expect( treeView.observers.has( observerMock ) ).to.be.true;
+			sinon.assert.calledOnce( observerMock.init );
+			sinon.assert.calledWith( observerMock.init, treeView );
+			sinon.assert.calledOnce( observerMock.attach );
+		} );
+
+		it( 'should be detached and reattached on render', () => {
+			treeView.addObserver( observerMock );
+			treeView.render();
+
+			expect( treeView.observers.has( observerMock ) ).to.be.true;
+			sinon.assert.calledOnce( observerMock.detach );
+			sinon.assert.calledOnce( treeView.renderer.render );
+			sinon.assert.calledTwice( observerMock.attach );
+		} );
+	} );
+} );

+ 31 - 0
packages/ckeditor5-engine/tests/utils-diff.js

@@ -0,0 +1,31 @@
+/**
+ * @license Copyright (c) 2003-20'INSERT'6, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import diff from '/ckeditor5/core/utils-diff.js';
+
+describe( 'diff', () => {
+	it( 'should diff arrays', () => {
+		expect( diff( 'aba', 'acca' ) ).to.deep.equals( [ 'EQUAL', 'INSERT', 'INSERT', 'DELETE', 'EQUAL' ] );
+	} );
+
+	it( 'should reverse result if the second array is shorter', () => {
+		expect( diff( 'acca', 'aba' ) ).to.deep.equals( [ 'EQUAL', 'DELETE', 'DELETE', 'INSERT', 'EQUAL' ] );
+	} );
+
+	it( 'should diff if arrays are same', () => {
+		expect( diff( 'abc', 'abc' ) ).to.deep.equals( [ 'EQUAL', 'EQUAL', 'EQUAL' ] );
+	} );
+
+	it( 'should diff if one array is empty', () => {
+		expect( diff( '', 'abc' ) ).to.deep.equals( [ 'INSERT', 'INSERT', 'INSERT' ] );
+	} );
+
+	it( 'should use custom comparator', () => {
+		expect( diff( 'aBc', 'abc' ) ).to.deep.equals( [ 'EQUAL', 'INSERT', 'DELETE', 'EQUAL' ] );
+		expect( diff( 'aBc', 'abc', ( a, b ) => a.toLowerCase() == b.toLowerCase() ) ).to.deep.equals( [ 'EQUAL', 'EQUAL', 'EQUAL' ] );
+	} );
+} );