Kaynağa Gözat

Merge pull request #245 from ckeditor/t/164

TreeView writer
Piotr Jasiun 9 yıl önce
ebeveyn
işleme
63d007dc18
25 değiştirilmiş dosya ile 4170 ekleme ve 35 silme
  1. 5 1
      packages/ckeditor5-engine/src/treemodel/element.js
  2. 76 4
      packages/ckeditor5-engine/src/treeview/element.js
  3. 21 0
      packages/ckeditor5-engine/src/treeview/node.js
  4. 37 1
      packages/ckeditor5-engine/src/treeview/position.js
  5. 75 0
      packages/ckeditor5-engine/src/treeview/range.js
  6. 29 0
      packages/ckeditor5-engine/src/treeview/text.js
  7. 8 0
      packages/ckeditor5-engine/src/treeview/treeview.js
  8. 635 0
      packages/ckeditor5-engine/src/treeview/writer.js
  9. 3 5
      packages/ckeditor5-engine/tests/treemodel/delta/attributedelta.js
  10. 2 4
      packages/ckeditor5-engine/tests/treemodel/delta/delta.js
  11. 4 6
      packages/ckeditor5-engine/tests/treeview/domconverter.js
  12. 101 12
      packages/ckeditor5-engine/tests/treeview/element.js
  13. 21 0
      packages/ckeditor5-engine/tests/treeview/node.js
  14. 63 2
      packages/ckeditor5-engine/tests/treeview/position.js
  15. 76 0
      packages/ckeditor5-engine/tests/treeview/range.js
  16. 36 0
      packages/ckeditor5-engine/tests/treeview/text.js
  17. 161 0
      packages/ckeditor5-engine/tests/treeview/writer/_utils/utils.js
  18. 301 0
      packages/ckeditor5-engine/tests/treeview/writer/breakattributes.js
  19. 230 0
      packages/ckeditor5-engine/tests/treeview/writer/breakrange.js
  20. 372 0
      packages/ckeditor5-engine/tests/treeview/writer/insert.js
  21. 277 0
      packages/ckeditor5-engine/tests/treeview/writer/mergeattributes.js
  22. 284 0
      packages/ckeditor5-engine/tests/treeview/writer/remove.js
  23. 696 0
      packages/ckeditor5-engine/tests/treeview/writer/unwrap.js
  24. 536 0
      packages/ckeditor5-engine/tests/treeview/writer/wrap.js
  25. 121 0
      packages/ckeditor5-engine/tests/treeview/writer/writer.js

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

@@ -122,10 +122,14 @@ export default class Element extends Node {
 	 *
 	 * @method core.treeModel.Element#removeChildren
 	 * @param {Number} index Position of the first node to remove.
-	 * @param {Number} number Number of nodes to remove.
+	 * @param {Number} [number] Number of nodes to remove.
 	 * @returns {core.treeModel.NodeList} The list of removed nodes.
 	 */
 	removeChildren( index, number ) {
+		if ( typeof number === 'undefined' ) {
+			number = 1;
+		}
+
 		let nodeList = this._children.remove( index, number );
 
 		for ( let node of nodeList._nodes ) {

+ 76 - 4
packages/ckeditor5-engine/src/treeview/element.js

@@ -65,15 +65,36 @@ export default class Element extends Node {
 		}
 	}
 
+	/**
+	 * Clones provided element.
+	 *
+	 * @param {Boolean} deep If set to `true` clones element and all its children recursively. When set to `false`,
+	 * element will be cloned without any children.
+	 * @returns {Element} Clone of this element.
+	 */
+	clone( deep ) {
+		const childrenClone = [];
+
+		if ( deep ) {
+			for ( let child of this.getChildren() ) {
+				childrenClone.push( child.clone( deep ) );
+			}
+		}
+
+		return new Element( this.name, this._attrs, childrenClone );
+	}
+
 	/**
 	 * {@link core.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.
 	 *
-	 * @param {core.treeView.Node|Iterable.<core.treeView.Node>} nodes Node or the list of nodes to be inserted.
 	 * @fires core.treeView.Node#change
+	 * @param {core.treeView.Node|Iterable.<core.treeView.Node>} nodes Node or the list of nodes to be inserted.
+	 * @returns {Number} Number of appended nodes.
+
 	 */
 	appendChildren( nodes ) {
-		this.insertChildren( this.getChildCount(), nodes );
+		return this.insertChildren( this.getChildCount(), nodes );
 	}
 
 	/**
@@ -96,7 +117,7 @@ export default class Element extends Node {
 	}
 
 	/**
-	 * Gets index of the given child node.
+	 * Gets index of the given child node. Returns `-1` if child node is not found.
 	 *
 	 * @param {core.treeView.Node} node Child node.
 	 * @returns {Number} Index of the child node.
@@ -163,9 +184,11 @@ export default class Element extends Node {
 	 * @param {Number} index Position where nodes should be inserted.
 	 * @param {core.treeView.Node|Iterable.<core.treeView.Node>} nodes Node or the list of nodes to be inserted.
 	 * @fires core.treeView.Node#change
+	 * @returns {Number} Number of inserted nodes.
 	 */
 	insertChildren( index, nodes ) {
 		this._fireChange( 'CHILDREN', this );
+		let count = 0;
 
 		if ( !utils.isIterable( nodes ) ) {
 			nodes = [ nodes ];
@@ -176,7 +199,10 @@ export default class Element extends Node {
 
 			this._children.splice( index, 0, node );
 			index++;
+			count++;
 		}
+
+		return count;
 	}
 
 	/**
@@ -196,17 +222,63 @@ export default class Element extends Node {
 	 * Removes number of child nodes starting at the given index and set the parent of these nodes to `null`.
 	 *
 	 * @param {Number} index Number of the first node to remove.
-	 * @param {Number} number Number of nodes to remove.
+	 * @param {Number} [number] Number of nodes to remove.
 	 * @returns {Array.<core.treeView.Node>} The array of removed nodes.
 	 * @fires core.treeView.Node#change
 	 */
 	removeChildren( index, number ) {
 		this._fireChange( 'CHILDREN', this );
 
+		if ( typeof number === 'undefined' ) {
+			number = 1;
+		}
+
 		for ( let i = index; i < index + number; i++ ) {
 			this._children[ i ].parent = null;
 		}
 
 		return this._children.splice( index, number );
 	}
+
+	/**
+	 * Checks if this element is similar to other element.
+	 * Both elements should have the same name and attributes to be considered as similar. Two similar elements
+	 * can contain different set of children nodes.
+	 *
+	 * @param {Element} otherElement
+	 * @returns {Boolean}
+	 */
+	isSimilar( otherElement ) {
+		if ( !( otherElement instanceof Element ) ) {
+			return false;
+		}
+
+		// If exactly the same Element is provided - return true immediately.
+		if ( this === otherElement ) {
+			return true;
+		}
+
+		// Check name and attributes.
+		if ( this.name != otherElement.name ) {
+			return false;
+		}
+
+		const thisNodeAttrKeys = this.getAttributeKeys();
+		const otherNodeAttrKeys = otherElement.getAttributeKeys();
+		let count = 0;
+
+		for ( let key of thisNodeAttrKeys ) {
+			if ( this.getAttribute( key ) !== otherElement.getAttribute( key ) ) {
+				return false;
+			}
+
+			count++;
+		}
+
+		if ( count != utils.count( otherNodeAttrKeys ) ) {
+			return false;
+		}
+
+		return true;
+	}
 }

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

@@ -115,6 +115,13 @@ export default class Node {
 		this._treeView = treeView;
 	}
 
+	/**
+	 * Removes node from parent.
+	 */
+	remove() {
+		this.parent.removeChildren( this.getIndex() );
+	}
+
 	/**
 	 * @param {core.treeView.ChangeType} type Type of the change.
 	 * @param {core.treeView.Node} node Changed node.
@@ -128,6 +135,20 @@ export default class Node {
 		}
 	}
 
+	/**
+	 * Clones this node.
+	 *
+	 * @method treeView.Node#clone
+	 * @returns {treeView.Node} Clone of this node.
+	 */
+
+	/**
+	 * Checks if provided node is similar to this node.
+	 *
+	 * @method treeView.Node#isSimilar
+	 * @returns {Boolean} True if nodes are similar.
+	 */
+
 	/**
 	 * Fired when a node changes.
 	 *

+ 37 - 1
packages/ckeditor5-engine/src/treeview/position.js

@@ -5,7 +5,8 @@
 
 'use strict';
 
-/**Position in the tree. Position is always located before or after a node.
+/**
+ * Position in the tree. Position is always located before or after a node.
  *
  * @memberOf core.treeView
  */
@@ -33,4 +34,39 @@ export default class Position {
 		 */
 		this.offset = offset;
 	}
+
+	/**
+	 * Returns a new instance of Position with offset incremented by `shift` value.
+	 *
+	 * @param {Number} shift How position offset should get changed. Accepts negative values.
+	 * @returns {core.treeView.Position} Shifted position.
+	 */
+	getShiftedBy( shift ) {
+		let shifted = Position.createFromPosition( this );
+
+		let offset = shifted.offset + shift;
+		shifted.offset = offset < 0 ? 0 : offset;
+
+		return shifted;
+	}
+
+	/**
+	 * Checks whether this position equals given position.
+	 *
+	 * @param {core.treeView.Position} otherPosition Position to compare with.
+	 * @returns {Boolean} True if positions are same.
+	 */
+	isEqual( otherPosition ) {
+		return this == otherPosition || ( this.parent == otherPosition.parent && this.offset == otherPosition.offset );
+	}
+
+	/**
+	 * Creates and returns a new instance of Position, which is equal to passed position.
+	 *
+	 * @param {core.treeView.Position} position Position to be cloned.
+	 * @returns {core.treeView.Position}
+	 */
+	static createFromPosition( position ) {
+		return new this( position.parent, position.offset );
+	}
 }

+ 75 - 0
packages/ckeditor5-engine/src/treeview/range.js

@@ -0,0 +1,75 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Position from './position.js';
+
+/**
+ * Tree view range.
+ *
+ * @memberOf core.treeView
+ */
+export default class Range {
+	/**
+	 * Creates a range spanning from `start` position to `end` position.
+	 * **Note:** Constructor creates it's own {@link core.treeView.Position} instances basing on passed values.
+	 *
+	 * @param {core.treeView.Position} start Start position.
+	 * @param {core.treeView.Position} end End position.
+	 */
+	constructor( start, end ) {
+		/**
+		 * Start position.
+		 *
+		 * @member core.treeView.Range#start
+		 * @type {core.treeView.Position}
+		 */
+		this.start = Position.createFromPosition( start );
+
+		/**
+		 * End position.
+		 *
+		 * @member core.treeView.Range#end
+		 * @type {core.treeView.Position}
+		 */
+		this.end = Position.createFromPosition( end );
+	}
+
+	/**
+	 * Returns whether the range is collapsed, that is it start and end positions are equal.
+	 *
+	 * @type {Boolean}
+	 */
+	get isCollapsed() {
+		return this.start.isEqual( this.end );
+	}
+
+	/**
+	 * Two ranges equal if their start and end positions equal.
+	 *
+	 * @param {core.treeView.Range} otherRange Range to compare with.
+	 * @returns {Boolean} True if ranges equal.
+	 */
+	isEqual( otherRange ) {
+		return this == otherRange || ( this.start.isEqual( otherRange.start ) && this.end.isEqual( otherRange.end ) );
+	}
+
+	/**
+	 * Creates a range from given parents and offsets.
+	 *
+	 * @param {core.treeView.Element} startElement Start position parent element.
+	 * @param {Number} startOffset Start position offset.
+	 * @param {core.treeView.Element} endElement End position parent element.
+	 * @param {Number} endOffset End position offset.
+	 * @returns {core.treeView.Range} Created range.
+	 */
+	static createFromParentsAndOffsets( startElement, startOffset, endElement, endOffset ) {
+		return new this(
+			new Position( startElement, startOffset ),
+			new Position( endElement, endOffset )
+		);
+	}
+}

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

@@ -33,6 +33,20 @@ export default class Text extends Node {
 		this._data = data;
 	}
 
+	/**
+	 * Clones this node.
+	 *
+	 * @returns {core.treeView.Text} Text node that is a clone of this node.
+	 */
+	clone() {
+		return new Text( this.data );
+	}
+
+	/**
+	 * The text content.
+	 *
+	 * Setting the data fires the {@link treeView.Node#change change event}.
+	 */
 	get data() {
 		return this._data;
 	}
@@ -42,4 +56,19 @@ export default class Text extends Node {
 
 		this._data = data;
 	}
+
+	/**
+	 * Checks if this text node is similar to other text node.
+	 * Both nodes should have the same data to be considered as similar.
+	 *
+	 * @param {core.treeView.Text} otherNode Node to check if it is same as this node.
+	 * @returns {Boolean}
+	 */
+	isSimilar( otherNode ) {
+		if ( !( otherNode instanceof Text ) ) {
+			return false;
+		}
+
+		return this === otherNode || this.data === otherNode.data;
+	}
 }

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

@@ -8,6 +8,7 @@
 import EmitterMixin from '../../utils/emittermixin.js';
 import Renderer from './renderer.js';
 import DomConverter from './domconverter.js';
+import Writer from './writer.js';
 
 import utils from '../../utils/utils.js';
 
@@ -48,6 +49,13 @@ export default class TreeView {
 		 */
 		this.observers = new Set();
 
+		/**
+		 * Tree View writer.
+		 *
+		 * @member {core.treeView.Writer} core.treeView.TreeView#writer
+		 */
+		this.writer = new Writer();
+
 		/**
 		 * Instance of the {@link core.treeView.DomConverter domConverter} use by
 		 * {@link core.treeView.TreeView#renderer renderer} and {@link core.treeView.TreeView#observers observers}.

+ 635 - 0
packages/ckeditor5-engine/src/treeview/writer.js

@@ -0,0 +1,635 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Position from './position.js';
+import Element from './element.js';
+import Text from './text.js';
+import Range from './range.js';
+import CKEditorError from '../../utils/ckeditorerror.js';
+
+/**
+ * Tree View Writer class.
+ * Writer defines a high-level API for TreeView manipulations.
+ *
+ * @memberOf core.treeView
+ */
+ export default class Writer {
+	constructor() {
+		/**
+		 * Priorities map. Maps node to priority.
+		 * Nodes with priorities are considered as attributes.
+		 *
+		 * @member {WeakMap} core.treeView.Writer#_priorities
+		 * @protected
+		 */
+		this._priorities = new WeakMap();
+	}
+
+	/**
+	 * Returns `true` if provided node is a `container` node.
+	 *
+	 * `Container` nodes are mostly elements like `<p>` or `<div>`. Break and merge operations are performed only in a
+	 * bounds of a container nodes. Containers will not be broken or merged by
+	 * {@link core.treeView.Writer#breakAttributes breakAttributes} and
+	 * {@link core.treeView.Writer#mergeAttributes mergeAttributes}.
+	 *
+	 * `Attribute` nodes are mostly elements like `<b>` or `<span>`. Attributes can be broken and merged. Merging requires
+	 * that attribute nodes are {@link core.treeView.Element#isSimilar similar} and have same priority. Setting different
+	 * priorities on similar nodes may prevent merging, eg. two `<abbr>` nodes next ot each other shouldn't be merged.
+	 * There might be a need to mark `<span>` element as a container node, for example in situation when it will be a
+	 * container of an inline widget:
+	 *
+	 * 		<span color="red">foobar</span>  // attribute
+	 * 		<span data-widget>foobar</span>  // container
+	 *
+	 * @param {core.treeView.Element} node Node to check.
+	 * @returns {Boolean} `True` if provided node is a container.
+     */
+	isContainer( node ) {
+		const isElement = node instanceof Element;
+
+		return isElement && !this._priorities.has( node );
+	}
+
+	/**
+	 * Returns `true` if provided node is an `attribute` node.
+	 * For more information about attribute and container nodes see {@link core.treeView.Writer#isContainer isContainer}
+	 * method description.
+	 *
+	 * @see core.treeView.Writer#isContainer
+	 * @param {core.treeView.Element} node Node to check.
+	 * @returns {Boolean} `True` if provided node is an attribute.
+	 */
+	isAttribute( node ) {
+		const isElement = node instanceof Element;
+
+		return isElement && this._priorities.has( node );
+	}
+
+	/**
+	 * Sets node priority. When priority is defined, node is considered as `attribute`.
+	 *
+	 * @see core.treeView.Writer#isContainer
+	 * @param {core.treeView.Node} node
+	 * @param {Number} priority
+     */
+	setPriority( node, priority ) {
+		this._priorities.set( node, priority );
+	}
+
+	/**
+	 * Returns node's priority.
+	 *
+	 * @param {core.treeView.Node} node Node to check its priority.
+	 * @returns {Number|undefined} Priority or `undefined` if there is no priority defined.
+     */
+	getPriority( node ) {
+		return this._priorities.get( node );
+	}
+
+	/**
+	 * Returns first parent container of specified {@link core.treeView.Position Position}.
+	 * Position's parent node is checked as first, then next parents are checked.
+	 *
+	 * @param {core.treeView.Position} position Position used as a start point to locate parent container.
+	 * @returns {core.treeView.Element|undefined} Parent container element or `undefined` if container is not found.
+	 */
+	getParentContainer( position ) {
+		let parent = position.parent;
+
+		while ( !this.isContainer( parent ) ) {
+			if ( !parent ) {
+				return undefined;
+			}
+			parent = parent.parent;
+		}
+
+		return parent;
+	}
+
+	/**
+	 * Breaks attribute nodes at provided position. It breaks `attribute` nodes inside `container` node.
+	 *
+	 * In following examples `<p>` is a container, `<b>` and `<u>` are attribute nodes:
+	 *
+	 *		<p>{foo}<b><u>{bar}|</u></b></p> -> <p>{foo}<b><u>{bar}</u></b>|</p>
+	 *		<p>{foo}<b><u>|{bar}</u></b></p> -> <p>{foo}|<b><u>{bar}</u></b></p>
+	 *		<p>{foo}<b><u>{b|ar}</u></b></p> -> <p>{foo}<b><u>{b}</u></b>|<b><u>{ar}</u></b></p>
+	 *
+	 * @see core.treeView.Writer#isContainer
+	 * @see core.treeView.Writer#isAttribute
+	 *
+	 * @param {core.treeView.Position} position Position where to break attributes.
+	 * @returns {core.treeView.Position} New position after breaking the attributes.
+	 */
+	breakAttributes( position ) {
+		const positionOffset = position.offset;
+		const positionParent = position.parent;
+
+		// Position's parent is container, so no attributes to break.
+		if ( this.isContainer( positionParent ) ) {
+			return Position.createFromPosition( position );
+		}
+
+		const parentIsText = positionParent instanceof Text;
+		const length = parentIsText ? positionParent.data.length : positionParent.getChildCount();
+
+		// <p>foo<b><u>bar|</u></b></p>
+		// <p>foo<b><u>bar</u>|</b></p>
+		// <p>foo<b><u>bar</u></b>|</p>
+		if ( positionOffset == length ) {
+			const newPosition = new Position( positionParent.parent, positionParent.getIndex() + 1 );
+
+			return this.breakAttributes( newPosition );
+		} else
+		// <p>foo<b><u>|bar</u></b></p>
+		// <p>foo<b>|<u>bar</u></b></p>
+		// <p>foo|<b><u>bar</u></b></p>
+		if ( positionOffset === 0 ) {
+			const newPosition = new Position( positionParent.parent, positionParent.getIndex() );
+
+			return this.breakAttributes( newPosition );
+		}
+		// <p>foo<b><u>"b|ar"</u></b></p>
+		// <p>foo<b><u>"b"|"ar"</u></b></p>
+		// <p>foo<b><u>b</u>|<u>ar</u></b></p>
+		// <p>foo<b><u>b</u></b>|<b><u>ar</u></b></p>
+		else {
+			const offsetAfter = positionParent.getIndex() + 1;
+
+			if ( parentIsText ) {
+				// Break text.
+				// Get part of the text that need to be moved.
+				const textToMove = positionParent.data.slice( positionOffset );
+
+				// Leave rest of the text in position's parent.
+				positionParent.data = positionParent.data.slice( 0, positionOffset );
+
+				// Insert new text node after position's parent text node.
+				positionParent.parent.insertChildren( offsetAfter, new Text( textToMove ) );
+
+				// Create new position to work on.
+				const newPosition = new Position( positionParent.parent, offsetAfter );
+
+				return this.breakAttributes( newPosition );
+			} else {
+				// Break element.
+				const clonedNode = positionParent.clone();
+
+				// Clone priority.
+				this.setPriority( clonedNode, this.getPriority( positionParent ) );
+
+				// Insert cloned node to position's parent node.
+				positionParent.parent.insertChildren( offsetAfter, clonedNode );
+
+				// Get nodes to move.
+				const count = positionParent.getChildCount() - positionOffset;
+				const nodesToMove = positionParent.removeChildren( positionOffset, count );
+
+				// Move nodes to cloned node.
+				clonedNode.appendChildren( nodesToMove );
+
+				// Create new position to work on.
+				const newPosition = new Position( positionParent.parent, offsetAfter );
+
+				return this.breakAttributes( newPosition );
+			}
+		}
+	}
+
+	/**
+	 * Uses {@link core.treeView.Writer#breakAttributes breakAttribute} method to break attributes on
+	 * {@link core.treeView.Range#start start} and {@link core.treeView.Range#end end} positions of
+	 * provided {@link core.treeView.Range Range}.
+	 *
+	 * Throws {@link core.CKEditorError CKEditorError} `treeview-writer-invalid-range-container` when
+	 * {@link core.treeView.Range#start start} and {@link core.treeView.Range#end end} positions are not placed inside
+	 * same parent container.
+	 *
+	 * @see core.treeView.Writer#breakAttribute
+	 * @param {core.treeView.Range} range Range which `start` and `end` positions will be used to break attributes.
+	 * @returns {core.treeView.Range} New range with located at break positions.
+	 */
+	breakRange( range ) {
+		const rangeStart = range.start;
+		const rangeEnd = range.end;
+
+		// Range should be placed inside one container.
+		if ( this.getParentContainer( rangeStart ) !== this.getParentContainer( rangeEnd ) ) {
+			/**
+			 * Range is not placed inside same container.
+			 *
+			 * @error treeview-writer-invalid-range-container
+			 */
+			throw new CKEditorError( 'treeview-writer-invalid-range-container' );
+		}
+
+		// Break at the collapsed position. Return new collapsed range.
+		if ( range.isCollapsed ) {
+			const position = this.breakAttributes( range.start );
+
+			return new Range( position, position );
+		}
+
+		const breakEnd = this.breakAttributes( rangeEnd );
+		const count = breakEnd.parent.getChildCount();
+		const breakStart = this.breakAttributes( rangeStart );
+
+		// Calculate new break end offset.
+		breakEnd.offset += breakEnd.parent.getChildCount() - count;
+
+		return new Range( breakStart, breakEnd );
+	}
+
+	/**
+	 * Merges attribute nodes. It also merges text nodes if needed.
+	 * Only {@link core.treeView.Element#isSimilar similar} `attribute` nodes, with same priority can be merged.
+	 *
+	 * In following examples `<p>` is a container and `<b>` is an attribute node:
+	 *
+	 *		<p>{foo}|{bar}</p> -> <p>{foo|bar}</p>
+	 *		<p><b>{foo}</b>|<b>{bar}</b> -> <p><b>{foo|bar}</b></b>
+	 *		<p><b foo="bar">{a}</b>|<b foo="baz">{b}</b> -> <p><b foo="bar">{a}</b>|<b foo="baz">{b}</b>
+	 *
+	 * @see core.treeView.Writer#isContainer
+	 * @see core.treeView.Writer#isAttribute
+	 * @param {core.treeView.Position} position Merge position.
+	 * @returns {core.treeView.Position} Position after merge.
+	 */
+	mergeAttributes( position ) {
+		const positionOffset = position.offset;
+		const positionParent = position.parent;
+
+		// When inside text node - nothing to merge.
+		if ( positionParent instanceof Text ) {
+			return position;
+		}
+
+		const nodeBefore = positionParent.getChild( positionOffset - 1 );
+		const nodeAfter = positionParent.getChild( positionOffset );
+
+		// Position should be placed between two nodes.
+		if ( !nodeBefore || !nodeAfter ) {
+			return position;
+		}
+
+		// When one or both nodes are containers - no attributes to merge.
+		if ( this.isContainer( nodeBefore ) || this.isContainer( nodeAfter ) ) {
+			return position;
+		}
+
+		if ( nodeBefore instanceof Text && nodeAfter instanceof Text ) {
+			// When selection is between two text nodes.
+			// Merge text data into first text node and remove second one.
+			const nodeBeforeLength = nodeBefore.data.length;
+			nodeBefore.data += nodeAfter.data;
+			positionParent.removeChildren( positionOffset );
+
+			return new Position( nodeBefore, nodeBeforeLength );
+		} else if ( nodeBefore.isSimilar( nodeAfter ) ) {
+			// When selection is between same nodes.
+			const nodeBeforePriority = this._priorities.get( nodeBefore );
+			const nodeAfterPriority = this._priorities.get( nodeAfter );
+
+			// Do not merge same nodes with different priorities.
+			if ( nodeBeforePriority === undefined || nodeBeforePriority !== nodeAfterPriority ) {
+				return Position.createFromPosition( position );
+			}
+
+			// Move all children nodes from node placed after selection and remove that node.
+			const count = nodeBefore.getChildCount();
+			nodeBefore.appendChildren( nodeAfter.getChildren() );
+			nodeAfter.remove();
+
+			// New position is located inside the first node, before new nodes.
+			// Call this method recursively to merge again if needed.
+			return this.mergeAttributes( new Position( nodeBefore, count ) );
+		}
+
+		return position;
+	}
+
+	/**
+	 * Insert node or nodes at specified position. Takes care about breaking attributes before insertion
+	 * and merging them afterwards.
+	 *
+	 * @param {core.treeView.Position} position Insertion position.
+	 * @param {core.treeView.Node|Iterable.<core.treeView.Node>} nodes Node or nodes to insert.
+	 * @returns {core.treeView.Range} Range around inserted nodes.
+	 */
+	insert( position, nodes ) {
+		const container = this.getParentContainer( position );
+		const insertionPosition = this.breakAttributes( position );
+
+		const length = container.insertChildren( insertionPosition.offset, nodes );
+		const endPosition = insertionPosition.getShiftedBy( length );
+		const start = this.mergeAttributes( insertionPosition );
+
+		// When no nodes were inserted - return collapsed range.
+		if ( length === 0 ) {
+			return new Range( start, start );
+		} else {
+			// If start position was merged - move end position.
+			if ( !start.isEqual( insertionPosition ) ) {
+				endPosition.offset--;
+			}
+			const end = this.mergeAttributes( endPosition );
+
+			return new Range( start, end );
+		}
+	}
+
+	/**
+	 * Removes provided range from the container.
+	 *
+	 * Throws {@link core.CKEditorError CKEditorError} `treeview-writer-invalid-range-container` when
+	 * {@link core.treeView.Range#start start} and {@link core.treeView.Range#end end} positions are not placed inside
+	 * same parent container.
+	 *
+	 * @param {core.treeView.Range} range Range to remove from container. After removing, it will be updated
+	 * to a collapsed range showing the new position.
+	 * @returns {Array.<core.treeView.Node>} The array of removed nodes.
+	 */
+	remove( range ) {
+		// Range should be placed inside one container.
+		if ( this.getParentContainer( range.start ) !== this.getParentContainer( range.end ) ) {
+			/**
+			 * Range is not placed inside same container.
+			 *
+			 * @error treeview-writer-invalid-range-container
+			 */
+			throw new CKEditorError( 'treeview-writer-invalid-range-container' );
+		}
+
+		// If range is collapsed - nothing to remove.
+		if ( range.isCollapsed ) {
+			return [];
+		}
+
+		// Break attributes at range start and end.
+		const { start: breakStart, end: breakEnd } = this.breakRange( range );
+		const parentContainer = breakStart.parent;
+
+		const count = breakEnd.offset - breakStart.offset;
+
+		// Remove nodes in range.
+		const removed = parentContainer.removeChildren( breakStart.offset, count );
+
+		// Merge after removing.
+		const mergePosition = this.mergeAttributes( breakStart );
+		range.start = mergePosition;
+		range.end = Position.createFromPosition( mergePosition );
+
+		// Return removed nodes.
+		return removed;
+	}
+
+	/**
+	 * Moves nodes from provided range to target position.
+	 *
+	 * Throws {@link core.CKEditorError CKEditorError} `treeview-writer-invalid-range-container` when
+	 * {@link core.treeView.Range#start start} and {@link core.treeView.Range#end end} positions are not placed inside
+	 * same parent container.
+	 *
+	 * @param {core.treeView.Range} sourceRange Range containing nodes to move.
+	 * @param {core.treeView.Position} targetPosition Position to insert.
+	 * @returns {core.treeView.Range} Range in target container. Inserted nodes are placed between
+	 * {@link core.treeView.Range#start start} and {@link core.treeView.Range#end end} positions.
+	 */
+	move( sourceRange, targetPosition ) {
+		const nodes = this.remove( sourceRange );
+
+		return this.insert( targetPosition, nodes );
+	}
+
+	/**
+	 * Wraps elements within range with provided attribute element.
+	 *
+	 * Throws {@link core.CKEditorError} `treeview-writer-invalid-range-container` when {@link core.treeView.Range#start}
+	 * and {@link core.treeView.Range#end} positions are not placed inside same parent container.
+	 *
+	 * @param {core.treeView.Range} range Range to wrap.
+	 * @param {core.treeView.Element} attribute Attribute element to use as wrapper.
+	 * @param {Number} priority Priority to set.
+	 */
+	wrap( range, attribute, priority ) {
+		// Range should be placed inside one container.
+		if ( this.getParentContainer( range.start ) !== this.getParentContainer( range.end ) ) {
+			/**
+			 * Range is not placed inside same container.
+			 *
+			 * @error treeview-writer-invalid-range-container
+			 */
+			throw new CKEditorError( 'treeview-writer-invalid-range-container' );
+		}
+
+		// If range is collapsed - nothing to wrap.
+		if ( range.isCollapsed ) {
+			return range;
+		}
+
+		// Sets wrapper element priority.
+		this.setPriority( attribute, priority );
+
+		// Break attributes at range start and end.
+		const { start: breakStart, end: breakEnd } = this.breakRange( range );
+		const parentContainer = breakStart.parent;
+
+		// Unwrap children located between break points.
+		const unwrappedRange = unwrapChildren( this, parentContainer, breakStart.offset, breakEnd.offset, attribute );
+
+		// Wrap all children with attribute.
+		const newRange = wrapChildren( this, parentContainer, unwrappedRange.start.offset, unwrappedRange.end.offset, attribute );
+
+		// Merge attributes at the both ends and return a new range.
+		const start = this.mergeAttributes( newRange.start );
+
+		// If start position was merged - move end position back.
+		if ( !start.isEqual( newRange.start ) ) {
+			newRange.end.offset--;
+		}
+		const end = this.mergeAttributes( newRange.end );
+
+		return new Range( start, end );
+	}
+
+	/**
+	 * Unwraps nodes within provided range from attribute element.
+	 *
+	 * Throws {@link core.CKEditorError CKEditorError} `treeview-writer-invalid-range-container` when
+	 * {@link core.treeView.Range#start start} and {@link core.treeView.Range#end end} positions are not placed inside
+	 * same parent container.
+	 *
+	 * @param {core.treeView.Range} range
+	 * @param {core.treeView.Element} element
+	 */
+	unwrap( range, attribute ) {
+		// Range should be placed inside one container.
+		if ( this.getParentContainer( range.start ) !== this.getParentContainer( range.end ) ) {
+			/**
+			 * Range is not placed inside same container.
+			 *
+			 * @error treeview-writer-invalid-range-container
+			 */
+			throw new CKEditorError( 'treeview-writer-invalid-range-container' );
+		}
+
+		// If range is collapsed - nothing to unwrap.
+		if ( range.isCollapsed ) {
+			return range;
+		}
+
+		// Break attributes at range start and end.
+		const { start: breakStart, end: breakEnd } = this.breakRange( range );
+		const parentContainer = breakStart.parent;
+
+		// Unwrap children located between break points.
+		const newRange = unwrapChildren( this, parentContainer, breakStart.offset, breakEnd.offset, attribute );
+
+		// Merge attributes at the both ends and return a new range.
+		const start = this.mergeAttributes( newRange.start );
+
+		// If start position was merged - move end position back.
+		if ( !start.isEqual( newRange.start ) ) {
+			newRange.end.offset--;
+		}
+		const end = this.mergeAttributes( newRange.end );
+
+		return new Range( start, end );
+	}
+}
+
+// Unwraps children from provided `attribute`. Only children contained in `parent` element between
+// `startOffset` and `endOffset` will be unwrapped.
+//
+// @private
+// @param {core.treeView.Writer} writer
+// @param {core.treeView.Element} parent
+// @param {Number} startOffset
+// @param {Number} endOffset
+// @param {core.treeView.Element} attribute
+function unwrapChildren( writer, parent, startOffset, endOffset, attribute ) {
+	let i = startOffset;
+	const unwrapPositions = [];
+
+	// Iterate over each element between provided offsets inside parent.
+	while ( i < endOffset ) {
+		const child = parent.getChild( i );
+
+		// If attributes are the similar and have same priority, then unwrap.
+		if (  child.isSimilar( attribute ) && writer.getPriority( child ) == writer.getPriority( attribute ) ) {
+			const unwrapped = child.getChildren();
+			const count = child.getChildCount();
+
+			// Replace wrapper element with its children
+			child.remove();
+			parent.insertChildren( i, unwrapped );
+
+			// Save start and end position of moved items.
+			unwrapPositions.push(
+				new Position( parent, i ),
+				new Position( parent, i + count )
+			);
+
+			// Skip elements that were unwrapped. Assuming that there won't be another element to unwrap in child
+			// elements.
+			i += count;
+			endOffset += count - 1;
+		} else {
+			// If other nested attribute is found start unwrapping there.
+			if ( writer.isAttribute( child ) ) {
+				unwrapChildren( writer, child, 0, child.getChildCount(), attribute );
+			}
+
+			i++;
+		}
+	}
+
+	// Merge at each unwrap.
+	let offsetChange = 0;
+
+	for ( let position of unwrapPositions ) {
+		position.offset -= offsetChange;
+
+		// Do not merge with elements outside selected children.
+		if ( position.offset == startOffset || position.offset == endOffset ) {
+			continue;
+		}
+
+		const newPosition = writer.mergeAttributes( position );
+
+		// If nodes were merged - other merge offsets will change.
+		if ( !newPosition.isEqual( position ) ) {
+			offsetChange++;
+			endOffset--;
+		}
+	}
+
+	return Range.createFromParentsAndOffsets( parent, startOffset, parent, endOffset );
+}
+
+// Wraps children with provided `attribute`. Only children contained in `parent` element between
+// `startOffset` and `endOffset` will be wrapped.
+
+// @private
+// @param {core.treeView.Writer} writer
+// @param {core.treeView.Element} parent
+// @param {Number} startOffset
+// @param {Number} endOffset
+// @param {core.treeView.Element} attribute
+function wrapChildren( writer, parent, startOffset, endOffset, attribute ) {
+	let i = startOffset;
+	const wrapPositions = [];
+
+	while ( i < endOffset ) {
+		const child = parent.getChild( i );
+		const isText = child instanceof Text;
+		const isAttribute = writer.isAttribute( child );
+		const priority = writer.getPriority( attribute );
+
+		// Wrap text or attributes with higher or equal priority.
+		if ( isText || ( isAttribute && priority <= writer.getPriority( child ) ) ) {
+			// Clone attribute.
+			const newAttribute = attribute.clone();
+			writer.setPriority( newAttribute, priority );
+
+			// Wrap current node with new attribute;
+			child.remove();
+			newAttribute.appendChildren( child );
+			parent.insertChildren( i, newAttribute );
+
+			wrapPositions.push(	new Position( parent, i ) );
+		} else {
+			// If other nested attribute is found start wrapping there.
+			if ( writer.isAttribute( child ) ) {
+				wrapChildren( writer, child, 0, child.getChildCount(), attribute );
+			}
+		}
+
+		i++;
+	}
+
+	// Merge at each wrap.
+	let offsetChange = 0;
+
+	for ( let position of wrapPositions ) {
+		// Do not merge with elements outside selected children.
+		if ( position.offset == startOffset ) {
+			continue;
+		}
+
+		const newPosition = writer.mergeAttributes( position );
+
+		// If nodes were merged - other merge offsets will change.
+		if ( !newPosition.isEqual( position ) ) {
+			offsetChange++;
+			endOffset--;
+		}
+	}
+
+	return Range.createFromParentsAndOffsets( parent, startOffset, parent, endOffset );
+}

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

@@ -7,7 +7,7 @@
 
 'use strict';
 
-import testUtils from '/tests/utils/_utils/utils.js';
+import utils from '/ckeditor5/utils/utils.js';
 import Document from '/ckeditor5/core/treemodel/document.js';
 import Text from '/ckeditor5/core/treemodel/text.js';
 import Range from '/ckeditor5/core/treemodel/range.js';
@@ -17,8 +17,6 @@ import Element from '/ckeditor5/core/treemodel/element.js';
 import AttributeDelta from '/ckeditor5/core/treemodel/delta/attributedelta.js';
 import AttributeOperation from '/ckeditor5/core/treemodel/operation/attributeoperation.js';
 
-const getIteratorCount = testUtils.getIteratorCount;
-
 let doc, root;
 
 beforeEach( () => {
@@ -37,7 +35,7 @@ describe( 'Batch', () => {
 		let count = 0;
 
 		for ( let delta of batch.deltas ) {
-			count += getIteratorCount( delta.operations );
+			count += utils.count( delta.operations );
 		}
 
 		return count;
@@ -143,7 +141,7 @@ describe( 'Batch', () => {
 
 			for ( let delta of batch.deltas ) {
 				for ( let operation of delta.operations ) {
-					count += getIteratorCount( operation.range.getItems( { singleCharacters: true } ) );
+					count += utils.count( operation.range.getItems( { singleCharacters: true } ) );
 				}
 			}
 

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

@@ -7,7 +7,7 @@
 
 'use strict';
 
-import testUtils from '/tests/utils/_utils/utils.js';
+import utils from '/ckeditor5/utils/utils.js';
 import Delta from '/ckeditor5/core/treemodel/delta/delta.js';
 import Operation from '/ckeditor5/core/treemodel/operation/operation.js';
 
@@ -31,8 +31,6 @@ class BarOperation extends FooOperation {
 	}
 }
 
-const getIteratorCount = testUtils.getIteratorCount;
-
 describe( 'Delta', () => {
 	describe( 'constructor', () => {
 		it( 'should create an delta with empty properties', () => {
@@ -72,7 +70,7 @@ describe( 'Delta', () => {
 			delta.addOperation( {} );
 			delta.addOperation( {} );
 
-			const count = getIteratorCount( delta.operations );
+			const count = utils.count( delta.operations );
 
 			expect( count ).to.equal( 3 );
 		} );

+ 4 - 6
packages/ckeditor5-engine/tests/treeview/domconverter.js

@@ -7,13 +7,11 @@
 
 'use strict';
 
-import testUtils from '/tests/utils/_utils/utils.js';
+import utils from '/ckeditor5/utils/utils.js';
 import ViewElement from '/ckeditor5/core/treeview/element.js';
 import ViewText from '/ckeditor5/core/treeview/text.js';
 import DomConverter from '/ckeditor5/core/treeview/domconverter.js';
 
-const getIteratorCount = testUtils.getIteratorCount;
-
 describe( 'DomConverter', () => {
 	let converter;
 
@@ -79,7 +77,7 @@ describe( 'DomConverter', () => {
 			expect( viewP.name ).to.equal( 'p' );
 
 			expect( viewP.getAttribute( 'class' ) ).to.equal( 'foo' );
-			expect( getIteratorCount( viewP.getAttributeKeys() ) ).to.equal( 1 );
+			expect( utils.count( viewP.getAttributeKeys() ) ).to.equal( 1 );
 
 			expect( viewP.getChildCount() ).to.equal( 2 );
 			expect( viewP.getChild( 0 ).name ).to.equal( 'img' );
@@ -105,7 +103,7 @@ describe( 'DomConverter', () => {
 			expect( viewP.name ).to.equal( 'p' );
 
 			expect( viewP.getAttribute( 'class' ) ).to.equal( 'foo' );
-			expect( getIteratorCount( viewP.getAttributeKeys() ) ).to.equal( 1 );
+			expect( utils.count( viewP.getAttributeKeys() ) ).to.equal( 1 );
 
 			expect( viewP.getChildCount() ).to.equal( 2 );
 			expect( viewP.getChild( 0 ).name ).to.equal( 'img' );
@@ -135,7 +133,7 @@ describe( 'DomConverter', () => {
 			expect( viewP.name ).to.equal( 'p' );
 
 			expect( viewP.getAttribute( 'class' ) ).to.equal( 'foo' );
-			expect( getIteratorCount( viewP.getAttributeKeys() ) ).to.equal( 1 );
+			expect( utils.count( viewP.getAttributeKeys() ) ).to.equal( 1 );
 
 			expect( viewP.getChildCount() ).to.equal( 0 );
 			expect( converter.getCorrespondingDom( viewP ) ).to.not.equal( domP );

+ 101 - 12
packages/ckeditor5-engine/tests/treeview/element.js

@@ -7,12 +7,10 @@
 
 'use strict';
 
-import testUtils from '/tests/utils/_utils/utils.js';
+import utils from '/ckeditor5/utils/utils.js';
 import Node from '/ckeditor5/core/treeview/node.js';
 import ViewElement from '/ckeditor5/core/treeview/element.js';
 
-const getIteratorCount = testUtils.getIteratorCount;
-
 describe( 'Element', () => {
 	describe( 'constructor', () => {
 		it( 'should create element without attributes', () => {
@@ -21,14 +19,14 @@ describe( 'Element', () => {
 			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 );
+			expect( utils.count( 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( utils.count( el.getAttributeKeys() ) ).to.equal( 1 );
 			expect( el.getAttribute( 'foo' ) ).to.equal( 'bar' );
 		} );
 
@@ -39,7 +37,7 @@ describe( 'Element', () => {
 			const el = new ViewElement( 'p', attrs );
 
 			expect( el ).to.have.property( 'name' ).that.equals( 'p' );
-			expect( getIteratorCount( el.getAttributeKeys() ) ).to.equal( 1 );
+			expect( utils.count( el.getAttributeKeys() ) ).to.equal( 1 );
 			expect( el.getAttribute( 'foo' ) ).to.equal( 'bar' );
 		} );
 
@@ -53,6 +51,92 @@ describe( 'Element', () => {
 		} );
 	} );
 
+	describe( 'clone', () => {
+		it( 'should clone element', () => {
+			const el = new ViewElement( 'p', { attr1: 'foo', attr2: 'bar' } );
+			const clone = el.clone();
+
+			expect( clone ).to.not.equal( el );
+			expect( clone.name ).to.equal( el.name );
+			expect( clone.getAttribute( 'attr1' ) ).to.equal( 'foo' );
+			expect( clone.getAttribute( 'attr2' ) ).to.equal( 'bar' );
+		} );
+
+		it( 'should deeply clone element', () => {
+			const el = new ViewElement( 'p', { attr1: 'foo', attr2: 'bar' }, [
+				new ViewElement( 'b', { attr: 'baz' } ),
+				new ViewElement( 'span', { attr: 'qux' } )
+			] );
+			const count = el.getChildCount();
+			const clone = el.clone( true );
+
+			expect( clone ).to.not.equal( el );
+			expect( clone.name ).to.equal( el.name );
+			expect( clone.getAttribute( 'attr1' ) ).to.equal( 'foo' );
+			expect( clone.getAttribute( 'attr2' ) ).to.equal( 'bar' );
+			expect( clone.getChildCount() ).to.equal( count );
+
+			for ( let i = 0; i < count; i++ ) {
+				const child = el.getChild( i );
+				const clonedChild = clone.getChild( i );
+
+				expect( clonedChild ).to.not.equal( child );
+				expect( clonedChild.name ).to.equal( child.name );
+				expect( clonedChild.getAttribute( 'attr' ) ).to.equal( child.getAttribute( 'attr' ) );
+			}
+		} );
+
+		it( 'shouldn\'t clone any children when deep copy is not performed', () => {
+			const el = new ViewElement( 'p', { attr1: 'foo', attr2: 'bar' }, [
+				new ViewElement( 'b', { attr: 'baz' } ),
+				new ViewElement( 'span', { attr: 'qux' } )
+			] );
+			const clone = el.clone( false );
+
+			expect( clone ).to.not.equal( el );
+			expect( clone.name ).to.equal( el.name );
+			expect( clone.getAttribute( 'attr1' ) ).to.equal( 'foo' );
+			expect( clone.getAttribute( 'attr2' ) ).to.equal( 'bar' );
+			expect( clone.getChildCount() ).to.equal( 0 );
+		} );
+	} );
+
+	describe( 'isSimilar', () => {
+		const el = new ViewElement( 'p', { foo: 'bar' } );
+		it( 'should return false when comparing to non-element', () => {
+			expect( el.isSimilar( null ) ).to.be.false;
+			expect( el.isSimilar( {} ) ).to.be.false;
+		} );
+
+		it( 'should return true when the same node is provided', () => {
+			expect( el.isSimilar( el ) ).to.be.true;
+		} );
+
+		it( 'should return true for element with same attributes and name', () => {
+			const other = new ViewElement( 'p', { foo: 'bar' } );
+			expect( el.isSimilar( other ) ).to.be.true;
+		} );
+
+		it( 'sould return false when name is not the same', () => {
+			const other = el.clone();
+			other.name = 'div';
+
+			expect( el.isSimilar( other ) ).to.be.false;
+		} );
+
+		it( 'should return false when attributes are not the same', () => {
+			const other1 = el.clone();
+			const other2 = el.clone();
+			const other3 = el.clone();
+			other1.setAttribute( 'baz', 'qux' );
+			other2.setAttribute( 'foo', 'not-bar' );
+			other3.removeAttribute( 'foo' );
+			expect( el.isSimilar( other1 ) ).to.be.false;
+			expect( el.isSimilar( other2 ) ).to.be.false;
+			expect( el.isSimilar( other3 ) ).to.be.false;
+		} );
+	} );
+
 	describe( 'children manipulation methods', () => {
 		let parent, el1, el2, el3, el4;
 
@@ -66,24 +150,29 @@ describe( 'Element', () => {
 
 		describe( 'insertion', () => {
 			it( 'should insert children', () => {
-				parent.insertChildren( 0, [ el1, el3 ] );
-				parent.insertChildren( 1, el2 );
+				const count1 = parent.insertChildren( 0, [ el1, el3 ] );
+				const count2 = 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' );
+				expect( count1 ).to.equal( 2 );
+				expect( count2 ).to.equal( 1 );
 			} );
 
 			it( 'should append children', () => {
-				parent.insertChildren( 0, el1 );
-				parent.appendChildren( el2 );
-				parent.appendChildren( el3 );
+				const count1 = parent.insertChildren( 0, el1 );
+				const count2 = parent.appendChildren( el2 );
+				const count3 = 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' );
+				expect( count1 ).to.equal( 1 );
+				expect( count2 ).to.equal( 1 );
+				expect( count3 ).to.equal( 1 );
 			} );
 		} );
 
@@ -191,7 +280,7 @@ describe( 'Element', () => {
 
 				expect( el.hasAttribute( 'foo' ) ).to.be.false;
 
-				expect( getIteratorCount( el.getAttributeKeys() ) ).to.equal( 0 );
+				expect( utils.count( el.getAttributeKeys() ) ).to.equal( 0 );
 			} );
 		} );
 	} );

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

@@ -115,6 +115,27 @@ describe( 'Node', () => {
 		} );
 	} );
 
+	describe( 'remove', () => {
+		it( 'should remove node from its parent', () => {
+			const char = new Text( 'a' );
+			const parent = new Element( 'p', null, [ char ] );
+			char.remove();
+
+			expect( parent.getChildIndex( char ) ).to.equal( -1 );
+		} );
+
+		it( 'uses parent.removeChildren method', () => {
+			const char = new Text( 'a' );
+			const parent = new Element( 'p', null, [ char ] );
+			const removeChildrenSpy = sinon.spy( parent, 'removeChildren' );
+			const index = char.getIndex();
+			char.remove();
+			removeChildrenSpy.restore();
+			sinon.assert.calledOnce( removeChildrenSpy );
+			sinon.assert.calledWithExactly( removeChildrenSpy, index );
+		} );
+	} );
+
 	describe( 'change event', () => {
 		let root, text, img;
 		let rootChangeSpy;

+ 63 - 2
packages/ckeditor5-engine/tests/treeview/position.js

@@ -10,13 +10,74 @@
 import Position from '/ckeditor5/core/treeview/position.js';
 
 describe( 'Position', () => {
+	const parentMock = {};
+
 	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 );
 		} );
 	} );
-} );
+
+	describe( 'getShiftedBy', () => {
+		it( 'returns new instance with shifted offset', () => {
+			const position = new Position( parentMock, 10 );
+			const shifted = position.getShiftedBy( 12 );
+			expect( shifted.offset ).to.equal( 22 );
+		} );
+
+		it( 'accepts negative values', () => {
+			const position = new Position( parentMock, 10 );
+			const shifted = position.getShiftedBy( -5 );
+			expect( shifted.offset ).to.equal( 5 );
+		} );
+
+		it( 'prevents offset to be a negative value', () => {
+			const position = new Position( parentMock, 10 );
+			const shifted = position.getShiftedBy( -20 );
+
+			expect( shifted.offset ).to.equal( 0 );
+		} );
+	} );
+
+	describe( 'createFromPosition', () => {
+		it( 'creates new Position with same parent and offset', () => {
+			const offset = 50;
+			const position = new Position( parentMock, offset );
+			const newPosition = Position.createFromPosition( position );
+
+			expect( position ).to.not.equal( newPosition );
+			expect( position.offset ).to.equal( offset );
+			expect( position.parent ).to.equal( parentMock );
+		} );
+	} );
+
+	describe( 'isEqual', () => {
+		it( 'should return true for same object', () => {
+			const position = new Position( {}, 12 );
+			expect( position.isEqual( position ) ).to.be.true;
+		} );
+
+		it( 'should return true for positions with same parent and offset', () => {
+			const parentMock = {};
+			const position1 = new Position( parentMock, 12 );
+			const position2 = new Position( parentMock, 12 );
+			expect( position1.isEqual( position2 ) ).to.be.true;
+		} );
+
+		it( 'should return false for positions with different parents', () => {
+			const position1 = new Position( {}, 12 );
+			const position2 = new Position( {}, 12 );
+			expect( position1.isEqual( position2 ) ).to.be.false;
+		} );
+
+		it( 'should return false for positions with different positions', () => {
+			const parentMock = {};
+			const position1 = new Position( parentMock, 12 );
+			const position2 = new Position( parentMock, 2 );
+			expect( position1.isEqual( position2 ) ).to.be.false;
+		} );
+	} );
+} );

+ 76 - 0
packages/ckeditor5-engine/tests/treeview/range.js

@@ -0,0 +1,76 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import Range from '/ckeditor5/core/treeview/range.js';
+import Position from '/ckeditor5/core/treeview/position.js';
+
+describe( 'Range', () => {
+	describe( 'constructor', () => {
+		it( 'creates range from provided positions', () => {
+			const start = new Position( {}, 1 );
+			const end = new Position( {}, 2 );
+			const range = new Range( start, end );
+
+			expect( range ).to.be.an.instanceof( Range );
+			expect( range ).to.have.property( 'start' ).that.not.equals( start );
+			expect( range ).to.have.property( 'end' ).that.not.equals( end );
+			expect( range.start.parent ).to.equal( start.parent );
+			expect( range.end.parent ).to.equal( end.parent );
+			expect( range.start.offset ).to.equal( start.offset );
+			expect( range.end.offset ).to.equal( end.offset );
+		} );
+	} );
+
+	describe( 'isEqual', () => {
+		it( 'should return true for the same range', () => {
+			const start = new Position( {}, 1 );
+			const end = new Position( {}, 2 );
+			const range = new Range( start, end );
+
+			expect( range.isEqual( range ) ).to.be.true;
+		} );
+
+		it( 'should return true for ranges with same start and end positions', () => {
+			const start = new Position( {}, 1 );
+			const end = new Position( {}, 2 );
+			const range1 = new Range( start, end );
+			const range2 = new Range( start, end );
+
+			expect( range1.isEqual( range2 ) ).to.be.true;
+		} );
+
+		it( 'should return false if start position is different', () => {
+			const start1 = new Position( {}, 1 );
+			const start2 = new Position( {}, 1 );
+			const end = new Position( {}, 2 );
+			const range1 = new Range( start1, end );
+			const range2 = new Range( start2, end );
+
+			expect( range1.isEqual( range2 ) ).to.be.false;
+		} );
+
+		it( 'should return false if end position is different', () => {
+			const start = new Position( {}, 1 );
+			const end1 = new Position( {}, 2 );
+			const end2 = new Position( {}, 2 );
+			const range1 = new Range( start, end1 );
+			const range2 = new Range( start, end2 );
+
+			expect( range1.isEqual( range2 ) ).to.be.false;
+		} );
+
+		it( 'should return false for ranges with same root and different offsets', () => {
+			const mockObject = {};
+			const range1 = new Range( new Position( mockObject, 0 ), new Position( mockObject, 10 )  );
+			const range2 = new Range( new Position( mockObject, 2 ), new Position( mockObject, 10 )  );
+
+			expect( range1.isEqual( range2 ) ).to.be.false;
+		} );
+	} );
+} );

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

@@ -21,6 +21,42 @@ describe( 'Element', () => {
 		} );
 	} );
 
+	describe( 'clone', () => {
+		it( 'should return new text with same data', () => {
+			const text = new ViewText( 'foo bar' );
+			const clone = text.clone();
+
+			expect( clone ).to.not.equal( text );
+			expect( clone.data ).to.equal( text.data );
+		} );
+	} );
+
+	describe( 'isSimilar', () => {
+		const text = new ViewText( 'foo' );
+
+		it( 'should return false when comparing to non-text', () => {
+			expect( text.isSimilar( null ) ).to.be.false;
+			expect( text.isSimilar( {} ) ).to.be.false;
+		} );
+
+		it( 'should return true when the same text node is provided', () => {
+			expect( text.isSimilar( text ) ).to.be.true;
+		} );
+
+		it( 'sould return true when data is the same', () => {
+			const other = new ViewText( 'foo' );
+
+			expect( text.isSimilar( other ) ).to.be.true;
+		} );
+
+		it( 'sould return false when data is not the same', () => {
+			const other = text.clone();
+			other.data = 'not-foo';
+
+			expect( text.isSimilar( other ) ).to.be.false;
+		} );
+	} );
+
 	describe( 'setText', () => {
 		it( 'should change the text', () => {
 			const text = new ViewText( 'foo' );

+ 161 - 0
packages/ckeditor5-engine/tests/treeview/writer/_utils/utils.js

@@ -0,0 +1,161 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+import Range from '/ckeditor5/core/treeview/range.js';
+import Position from '/ckeditor5/core/treeview/position.js';
+
+const utils = {
+	/**
+	 * Helper function that is used to create treeView elements from description object.
+	 *
+	 * @param {core.treeView.Writer} writer Writer instance. Used to set priorities.
+	 * @param {Object} description Description object.
+	 * @param {core.treeView.Range} [range] Optional parameter, used in recurrent calls.
+	 * @param {core.treeView.Position} [position] Optional parameter, used in recurrent calls.
+	 * @returns {Object} Returns object with `node`, `range`, `position` fields, containing created node and, optionally
+	 * range and position if description object contain information about them.
+	 */
+	create( writer, description, range, position ) {
+		const node = new description.instanceOf();
+
+		if ( !range ) {
+			range = Range.createFromParentsAndOffsets( node, 0, node, 0 );
+		}
+
+		if ( !position ) {
+			position = new Position( node, 0 );
+		}
+
+		if ( description.name ) {
+			node.name = description.name;
+		}
+
+		if ( description.data ) {
+			node.data = description.data;
+		}
+
+		if ( description.attributes ) {
+			Object.keys( description.attributes ).forEach( ( key ) => {
+				node.setAttribute( key, description.attributes[ key ] );
+			} );
+		}
+
+		if ( description.priority !== undefined ) {
+			writer.setPriority( node, description.priority );
+		}
+
+		if ( description.rangeStart !== undefined ) {
+			range.start.parent = node;
+			range.start.offset = description.rangeStart;
+		}
+
+		if ( description.rangeEnd !== undefined ) {
+			range.end.parent = node;
+			range.end.offset = description.rangeEnd;
+		}
+
+		if ( description.position !== undefined ) {
+			position.parent = node;
+			position.offset = description.position;
+		}
+
+		if ( description.children ) {
+			description.children.forEach( ( desc, index ) => {
+				const created = utils.create( writer, desc, range, position );
+				node.insertChildren( index, created.node );
+			} );
+		}
+
+		return { node, range, position };
+	},
+
+	/**
+	 * Helper function that is used to test output of writer methods by providing declarative description of the
+	 * expected output.
+	 * Examples:
+	 * 		test element: `<p>fo[o<b>ba]r</b></p>`
+	 * 		description: {
+	 * 			name: 'p',
+	 * 	    	instanceOf: Element,
+	 * 	    	children:[
+	 * 	    		{
+	 * 	    			instanceOf: Text,
+	 * 	    			data: 'foo',
+	 * 	    			rangeStart: 2
+ 	 * 	    		},
+	 *				{
+	 *					name: 'b'
+	 *					instanceOf: Element
+	 *					priority: 1,
+	 *					children: [
+	 *						{ instanceOf: Text, data: 'bar', rangeEnd: 2 }
+	 *					]
+	 *				}
+	 * 	    	]
+	 * 		}
+	 *
+	 *
+	 * @param {core.treeView.Writer} writer Writer instance. Used to test priority.
+	 * @param {core.treeView.Range|core.treeView.Position } location Range instance or Position instance.
+	 * Treated as Range when when `rangeStart`, `rangeEnd` is used, treated as Position when `position` is used.
+	 * @param {core.treeView.Node} node Element to check.
+	 * @param {Object} description Object describing expected element and its children.
+	 */
+	test( writer, location, node, description ) {
+		// If no root node provided - iterate over node array.
+		if ( description instanceof Array && node instanceof Array ) {
+			node.forEach( ( n, i ) => {
+				utils.test( writer, location, n, description[ i ] );
+			} );
+		}
+
+		if ( description.instanceOf ) {
+			expect( node ).to.be.instanceof( description.instanceOf );
+		}
+
+		if ( description.name ) {
+			expect( description.name ).to.equal( node.name  );
+		}
+
+		if ( description.data ) {
+			expect( description.data ).to.equal( node.data );
+		}
+
+		if ( description.priority !== undefined ) {
+			expect( description.priority ).to.equal( writer.getPriority( node ) );
+		}
+
+		if ( description.rangeStart !== undefined ) {
+			expect( node ).to.equal( location.start.parent );
+			expect( description.rangeStart ).to.equal( location.start.offset );
+		}
+
+		if ( description.rangeEnd !== undefined ) {
+			expect( node ).to.equal( location.end.parent );
+			expect( description.rangeEnd ).to.equal( location.end.offset );
+		}
+
+		if ( description.attributes ) {
+			Object.keys( description.attributes ).forEach( ( key ) => {
+				expect( description.attributes[ key ] ).to.equal( node.getAttribute( key ) );
+			} );
+		}
+
+		if ( description.position !== undefined ) {
+			expect( node ).to.equal( location.parent );
+			expect( description.position ).to.equal( location.offset );
+		}
+
+		if ( description.children ) {
+			expect( description.children.length ).to.equal( node.getChildCount() );
+			description.children.forEach( ( desc, index ) => {
+				utils.test( writer, location, node.getChild( index ), desc );
+			} );
+		}
+	}
+};
+
+export default utils;

+ 301 - 0
packages/ckeditor5-engine/tests/treeview/writer/breakattributes.js

@@ -0,0 +1,301 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import Writer from '/ckeditor5/core/treeview/writer.js';
+import Element from '/ckeditor5/core/treeview/element.js';
+import Text from '/ckeditor5/core/treeview/text.js';
+import utils from '/tests/core/treeview/writer/_utils/utils.js';
+
+describe( 'Writer', () => {
+	const create = utils.create;
+	const test = utils.test;
+	let writer;
+
+	beforeEach( () => {
+		writer = new Writer();
+	} );
+
+	describe( 'breakAttributes', () => {
+		// <p>{|foobar}</p> -> <p>|{foobar}</p>
+		it( '<p>{|foobar}</p>', () => {
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobar', position: 0 }
+				]
+			} );
+
+			const newPosition = writer.breakAttributes( created.position );
+
+			test( writer, newPosition, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				position: 0,
+				children: [
+					{ instanceOf: Text, data: 'foobar' }
+				]
+			} );
+		} );
+
+		it( '<p>foo|bar</p>', () => {
+			// <p>{foo|bar}</p> -> <p>{foo}|{bar}</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobar', position: 3 }
+				]
+			} );
+
+			const newPosition = writer.breakAttributes( created.position );
+
+			test( writer, newPosition, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				position: 1,
+				children: [
+					{ instanceOf: Text, data: 'foo' },
+					{ instanceOf: Text, data: 'bar' }
+				]
+			} );
+		} );
+
+		it( '<p>{foobar|}</p>', () => {
+			// <p>{foobar|}</p> -> <p>{foobar}|</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobar', position: 6 }
+				]
+			} );
+
+			const newPosition = writer.breakAttributes( created.position );
+
+			test( writer, newPosition, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				position: 1,
+				children: [
+					{ instanceOf: Text, data: 'foobar' }
+				]
+			} );
+		} );
+
+		it( '<p><b>{foo|bar}</b></p>', () => {
+			// <p><b>{foo|bar}</b></p> -> <p><b>{foo}</b>|<b>{bar}</b></p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foobar', position: 3 }
+						]
+					}
+				]
+			} );
+
+			const newPosition = writer.breakAttributes( created.position );
+
+			test( writer, newPosition, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				position: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foo' }
+						]
+					},
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'bar' }
+						]
+					}
+				]
+			} );
+		} );
+
+		it( '<p><b><u>{|foobar}</u></b></p>', () => {
+			// <p><b><u>{|foobar}</u></b></p> -> <p>|<b><u>{foobar}</u></b></p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{
+								instanceOf: Element,
+								name: 'u',
+								priority: 1,
+								children: [
+									{ instanceOf: Text, data: 'foobar', position: 0 }
+								]
+							}
+						]
+					}
+				]
+			} );
+
+			const newPosition = writer.breakAttributes( created.position );
+
+			test( writer, newPosition, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				position: 0,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{
+								instanceOf: Element,
+								name: 'u',
+								priority: 1,
+								children: [
+									{ instanceOf: Text, data: 'foobar' }
+								]
+							}
+						]
+					}
+				]
+			} );
+		} );
+
+		// <p><b><u>{foo|ba}r</u></b></p> -> <p><b><u>{foo}</u></b>|<b></u>{bar}</u></b></p>
+		it( '<p><b><u>{foo|bar}</u></b></p>', () => {
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{
+								instanceOf: Element,
+								name: 'u',
+								priority: 1,
+								children: [
+									{ instanceOf: Text, data: 'foobar', position: 3 }
+								]
+							}
+						]
+					}
+				]
+			} );
+
+			const newPosition = writer.breakAttributes( created.position );
+
+			test( writer, newPosition, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				position: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{
+								instanceOf: Element,
+								name: 'u',
+								priority: 1,
+								children: [
+									{ instanceOf: Text, data: 'foo' }
+								]
+							}
+						]
+					},
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{
+								instanceOf: Element,
+								name: 'u',
+								priority: 1,
+								children: [
+									{ instanceOf: Text, data: 'bar' }
+								]
+							}
+						]
+					}
+				]
+			} );
+		} );
+
+		it( '<p><b><u>{foobar|}</u></b></p>', () => {
+			// <p><b><u>{foobar|}</u></b></p> -> <p><b><u>{foobar}</u></b>|</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{
+								instanceOf: Element,
+								name: 'u',
+								priority: 1,
+								children: [
+									{ instanceOf: Text, data: 'foobar', position: 6 }
+								]
+							}
+						]
+					}
+				]
+			} );
+
+			const newPosition = writer.breakAttributes( created.position );
+
+			test( writer, newPosition, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				position: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{
+								instanceOf: Element,
+								name: 'u',
+								priority: 1,
+								children: [
+									{ instanceOf: Text, data: 'foobar' }
+								]
+							}
+						]
+					}
+				]
+			} );
+		} );
+	} );
+} );

+ 230 - 0
packages/ckeditor5-engine/tests/treeview/writer/breakrange.js

@@ -0,0 +1,230 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import Writer from '/ckeditor5/core/treeview/writer.js';
+import Element from '/ckeditor5/core/treeview/element.js';
+import Range from '/ckeditor5/core/treeview/range.js';
+import Text from '/ckeditor5/core/treeview/text.js';
+import utils from '/tests/core/treeview/writer/_utils/utils.js';
+
+describe( 'Writer', () => {
+	const create = utils.create;
+	const test = utils.test;
+	let writer;
+
+	beforeEach( () => {
+		writer = new Writer();
+	} );
+
+	describe( 'breakRange', () => {
+		it( 'should throw when range placed in two containers', () => {
+			const p1 = new Element( 'p' );
+			const p2 = new Element( 'p' );
+
+			expect( () => {
+				writer.breakRange( Range.createFromParentsAndOffsets( p1, 0, p2, 0 ) );
+			} ).to.throw( 'treeview-writer-invalid-range-container' );
+		} );
+
+		it( 'should break at collapsed range and return collapsed one', () => {
+			// <p>{foo[]bar}</p> -> <p>{foo}[]{bar}</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobar', rangeStart: 3, rangeEnd: 3 }
+				]
+			} );
+
+			const newRange = writer.breakRange( created.range );
+
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 1,
+				rangeEnd: 1,
+				children: [
+					{ instanceOf: Text, data: 'foo' },
+					{ instanceOf: Text, data: 'bar' }
+				]
+			} );
+		} );
+
+		it( 'should break inside text node #1', () => {
+			// <p>{foo[bar]baz}</p> -> <p>{foo}[{bar}]{baz}</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobarbaz', rangeStart: 3, rangeEnd: 6 }
+				]
+			} );
+
+			const newRange = writer.breakRange( created.range );
+
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 1,
+				rangeEnd: 2,
+				children: [
+					{ instanceOf: Text, data: 'foo' },
+					{ instanceOf: Text, data: 'bar' },
+					{ instanceOf: Text, data: 'baz' }
+				]
+			} );
+		} );
+
+		it( 'should break inside text node #2', () => {
+			// <p>{foo[barbaz]}</p> -> <p>{foo}[{barbaz}]</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobarbaz', rangeStart: 3, rangeEnd: 9 }
+				]
+			} );
+
+			const newRange = writer.breakRange( created.range );
+
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 1,
+				rangeEnd: 2,
+				children: [
+					{ instanceOf: Text, data: 'foo' },
+					{ instanceOf: Text, data: 'barbaz' }
+				]
+			} );
+		} );
+
+		it( 'should break inside text node #3', () => {
+			// <p>{foo[barbaz}]</p> -> <p>{foo}[{barbaz}]</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeEnd: 1,
+				children: [
+					{ instanceOf: Text, data: 'foobarbaz', rangeStart: 3 }
+				]
+			} );
+
+			const newRange = writer.breakRange( created.range );
+
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 1,
+				rangeEnd: 2,
+				children: [
+					{ instanceOf: Text, data: 'foo' },
+					{ instanceOf: Text, data: 'barbaz' }
+				]
+			} );
+		} );
+
+		it( 'should break inside text node #4', () => {
+			// <p>{[foo]barbaz}</p> -> <p>[{foo}]{barbaz]</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobarbaz', rangeStart: 0, rangeEnd: 3 }
+				]
+			} );
+
+			const newRange = writer.breakRange( created.range );
+
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{ instanceOf: Text, data: 'foo' },
+					{ instanceOf: Text, data: 'barbaz' }
+				]
+			} );
+		} );
+
+		it( 'should break inside text node #5', () => {
+			// <p>[{foo]barbaz}</p> -> <p>[{foo}]{barbaz]</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				children: [
+					{ instanceOf: Text, data: 'foobarbaz', rangeEnd: 3 }
+				]
+			} );
+
+			const newRange = writer.breakRange( created.range );
+
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{ instanceOf: Text, data: 'foo' },
+					{ instanceOf: Text, data: 'barbaz' }
+				]
+			} );
+		} );
+
+		it( 'should break placed inside different nodes', () => {
+			// <p>{foo[bar}<b>{baz]qux}</b></p>
+			// <p>{foo}[{bar}<b>{baz}</b>]<b>qux</b></p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobar', rangeStart: 3 },
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'bazqux', rangeEnd: 3 }
+						]
+					}
+				]
+			} );
+
+			const newRange = writer.breakRange( created.range );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 1,
+				rangeEnd: 3,
+				children: [
+					{ instanceOf: Text, data: 'foo' },
+					{ instanceOf: Text, data: 'bar' },
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'baz' }
+						]
+					},
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'qux' }
+						]
+					}
+				]
+			} );
+		} );
+	} );
+} );

+ 372 - 0
packages/ckeditor5-engine/tests/treeview/writer/insert.js

@@ -0,0 +1,372 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import Writer from '/ckeditor5/core/treeview/writer.js';
+import Element from '/ckeditor5/core/treeview/element.js';
+import Position from '/ckeditor5/core/treeview/position.js';
+import Text from '/ckeditor5/core/treeview/text.js';
+import utils from '/tests/core/treeview/writer/_utils/utils.js';
+
+describe( 'Writer', () => {
+	const create = utils.create;
+	const test = utils.test;
+	let writer;
+
+	beforeEach( () => {
+		writer = new Writer();
+	} );
+
+	describe( 'insert', () => {
+		it( 'should return collapsed range in insertion position when using empty array', () => {
+			// <p>{foo|bar}</p> -> <p>{foo[]bar}</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobar', position: 3 }
+				]
+			} );
+
+			const newRange = writer.insert( created.position, [] );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobar', rangeStart: 3, rangeEnd: 3 }
+				]
+			} );
+		} );
+
+		it( 'should insert text into another text node #1', () => {
+			// <p>{foo|bar}</p> insert {baz}
+			// <p>{foo[baz]bar}</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobar', position: 3 }
+				]
+			} );
+
+			const newRange = writer.insert( created.position, new Text( 'baz' ) );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobazbar', rangeStart: 3, rangeEnd: 6 }
+				]
+			} );
+		} );
+
+		it( 'should insert text into another text node #2', () => {
+			// <p>{foobar|}</p> insert {baz}
+			// <p>{foobar[baz}]</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobar', position: 6 }
+				]
+			} );
+
+			const newRange = writer.insert( created.position, new Text( 'baz' ) );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeEnd: 1,
+				children: [
+					{ instanceOf: Text, data: 'foobarbaz', rangeStart: 6 }
+				]
+			} );
+		} );
+
+		it( 'should insert text into another text node #3', () => {
+			// <p>{|foobar}</p> insert {baz}
+			// <p>[{baz]foobar}</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobar', position: 0 }
+				]
+			} );
+
+			const newRange = writer.insert( created.position, new Text( 'baz' ) );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				children: [
+					{ instanceOf: Text, data: 'bazfoobar', rangeEnd: 3 }
+				]
+			} );
+		} );
+
+		it( 'should break attributes when inserting into text node', () => {
+			// <p>{foo|bar}</p> insert <b>{baz}</b>
+			// <p>{foo}[<b>baz</b>]{bar}</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobar', position: 3 }
+				]
+			} );
+			const toInsert = create( writer, {
+				instanceOf: Element,
+				name: 'b',
+				priority: 1,
+				children: [
+					{ instanceOf: Text, data: 'baz' }
+				]
+			} );
+
+			const newRange = writer.insert( created.position, toInsert.node );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 1,
+				rangeEnd: 2,
+				children: [
+					{ instanceOf: Text, data: 'foo' },
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'baz' }
+						]
+					},
+					{ instanceOf: Text, data: 'bar' }
+				]
+			} );
+		} );
+
+		it( 'should merge text ndoes', () => {
+			// <p>|{foobar}</p> insert {baz}
+			// <p>[{baz]foobar}</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				position: 0,
+				children: [
+					{ instanceOf: Text, data: 'foobar' }
+				]
+			} );
+
+			const newRange = writer.insert( created.position, new Text( 'baz' ) );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				children: [
+					{ instanceOf: Text, data: 'bazfoobar', rangeEnd: 3 }
+				]
+			} );
+		} );
+
+		it( 'should merge same attribute nodes', () => {
+			// <p><b>{foo|bar}</b></p> insert <b>{baz}</b>
+			// <p><b>{foo[baz]bar}</b></p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foobar', position: 3 }
+						]
+					}
+				]
+			} );
+			const toInsert = create( writer, {
+				instanceOf: Element,
+				name: 'b',
+				priority: 1,
+				children: [
+					{ instanceOf: Text, data: 'baz' }
+				]
+			} );
+
+			const newRange = writer.insert( created.position, toInsert.node );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foobazbar', rangeStart: 3, rangeEnd: 6 }
+						]
+					}
+				]
+			} );
+		} );
+
+		it( 'should not merge different attributes', () => {
+			// <p><b>{foo|bar}</b></p> insert <b>{baz}</b> ( different priority )
+			// <p><b>{foo}</b>[<b>{baz}</b>]<b>{bar}</b></p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foobar', position: 3 }
+						]
+					}
+				]
+			} );
+			const toInsert = create( writer, {
+				instanceOf: Element,
+				name: 'b',
+				priority: 2,
+				children: [
+					{ instanceOf: Text, data: 'baz' }
+				]
+			} );
+
+			const newRange = writer.insert( created.position, toInsert.node );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 1,
+				rangeEnd: 2,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foo' }
+						]
+					},
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 2,
+						children: [
+							{ instanceOf: Text, data: 'baz' }
+						]
+					},
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'bar' }
+						]
+					}
+				]
+			} );
+		} );
+
+		it( 'should allow to insert multiple nodes', () => {
+			// <p>|</p> insert <b>{foo}</b>{bar}
+			// <p>[<b>{foo}</b>{bar}]</p>
+			const root = new Element( 'p' );
+			const toInsert = create( writer, {
+				instanceOf: Element,
+				name: 'fake',
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foo' }
+						]
+					},
+					{ instanceOf: Text, data: 'bar' }
+				]
+			} ).node.getChildren();
+			const position = new Position( root, 0 );
+
+			const newRange = writer.insert( position, toInsert );
+			test( writer, newRange, root, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 2,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foo' }
+						]
+					},
+					{ instanceOf: Text, data: 'bar' }
+				]
+			} );
+		} );
+
+		it( 'should merge after inserting multiple nodes', () => {
+			// <p><b>{qux}</b>|{baz}</p> insert <b>{foo}</b>{bar}
+			// <p><b>{qux[foo}</b>{bar]baz}</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				position: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'qux' }
+						]
+					},
+					{ instanceOf: Text, data: 'baz' }
+				]
+			} );
+			const toInsert = create( writer, {
+				instanceOf: Element,
+				name: 'fake',
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foo' }
+						]
+					},
+					{ instanceOf: Text, data: 'bar' }
+				]
+			} ).node.getChildren();
+
+			const newRange = writer.insert( created.position, toInsert );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'quxfoo', rangeStart: 3 }
+						]
+					},
+					{ instanceOf: Text, data: 'barbaz', rangeEnd: 3 }
+				]
+			} );
+		} );
+	} );
+} );

+ 277 - 0
packages/ckeditor5-engine/tests/treeview/writer/mergeattributes.js

@@ -0,0 +1,277 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import Writer from '/ckeditor5/core/treeview/writer.js';
+import Element from '/ckeditor5/core/treeview/element.js';
+import Text from '/ckeditor5/core/treeview/text.js';
+import utils from '/tests/core/treeview/writer/_utils/utils.js';
+
+describe( 'Writer', () => {
+	const create = utils.create;
+	const test = utils.test;
+	let writer;
+
+	beforeEach( () => {
+		writer = new Writer();
+	} );
+
+	describe( 'mergeAttributes', () => {
+		it( 'should not merge if inside text node', () => {
+			// <p>{fo|obar}</p>
+			const description = {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobar', position: 2 }
+				]
+			};
+			const created = create( writer, description );
+			const newPosition = writer.mergeAttributes( created.position );
+			test( writer, newPosition, created.node, description );
+		} );
+
+		it( 'should not merge if between containers', () => {
+			// <div><p>{foo}</p>|<p>{bar}</p></div>
+			const description = {
+				instanceOf: Element,
+				name: 'div',
+				position: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'p',
+						children: [
+							{ instanceOf: Text, data: 'foo' }
+						]
+					},
+					{
+						instanceOf: Element,
+						name: 'p',
+						children: [
+							{ instanceOf: Text, data: 'bar' }
+						]
+					}
+				]
+			};
+			const created = create( writer, description );
+			const newPosition = writer.mergeAttributes( created.position );
+			test( writer, newPosition, created.node, description );
+		} );
+
+		it( 'should return same position when inside empty container', () => {
+			// <p>|</p>
+			const description = { instanceOf: Element, name: 'p', position: 0 };
+			const created = create( writer, description );
+			const newPosition = writer.mergeAttributes( created.position );
+			test( writer, newPosition, created.node, description );
+		} );
+
+		it( 'should not merge when position is placed at the beginning of the container', () => {
+			// <p>|<b></b></p>
+			const description = {
+				instanceOf: Element,
+				name: 'p',
+				position: 0,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1
+					}
+				]
+			};
+			const created = create( writer, description );
+			const newPosition = writer.mergeAttributes( created.position );
+			test( writer, newPosition, created.node, description );
+		} );
+
+		it( 'should not merge when position is placed at the end of the container', () => {
+			// <p><b></b>|</p>
+			const description = {
+				instanceOf: Element,
+				name: 'p',
+				position: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1
+					}
+				]
+			};
+			const created = create( writer, description );
+			const newPosition = writer.mergeAttributes( created.position );
+			test( writer, newPosition, created.node, description );
+		} );
+
+		it( 'should merge when placed between two text nodes', () => {
+			// <p>{foo}|{bar}</p> -> <p>{foo|bar}</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				position: 1,
+				children: [
+					{ instanceOf: Text, data: 'foo' },
+					{ instanceOf: Text, data: 'bar' }
+				]
+			} );
+
+			const newPosition = writer.mergeAttributes( created.position );
+
+			test( writer, newPosition, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobar', position: 3 }
+				]
+			} );
+		} );
+
+		it( 'should merge when placed between similar attribute nodes', () => {
+			// <p><b foo="bar"></b>|<b foo="bar"></b></p> -> <p><b foo="bar">|</b></p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				position: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						attributes: { foo: 'bar' }
+					},
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						attributes: { foo: 'bar' }
+					}
+				]
+			} );
+
+			const newPosition = writer.mergeAttributes( created.position );
+
+			test( writer, newPosition, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						position: 0,
+						attributes: { foo: 'bar' }
+					}
+				]
+			} );
+		} );
+
+		it( 'should not merge when placed between non-similar attribute nodes', () => {
+			// <p><b foo="bar"></b>|<b foo="baz"></b></p> ->
+			// <p><b foo="bar"></b>|<b foo="baz"></b></p>
+			const description = {
+				instanceOf: Element,
+				name: 'p',
+				position: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						attributes: { foo: 'bar' }
+					},
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						attributes: { foo: 'baz' }
+					}
+				]
+			};
+			const created = create( writer, description );
+			const newPosition = writer.mergeAttributes( created.position );
+			test( writer, newPosition, created.node, description );
+		} );
+
+		it( 'should not merge when placed between similar attribute nodes with different priority', () => {
+			// <p><b foo="bar"></b>|<b foo="bar"></b></p> -> <p><b foo="bar"></b>|<b foo="bar"></b></p>
+			const description =  {
+				instanceOf: Element,
+				name: 'p',
+				position: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						attributes: { foo: 'bar' }
+					},
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 2,
+						attributes: { foo: 'bar' }
+					}
+				]
+			};
+			const created = create( writer,description );
+			const newPosition = writer.mergeAttributes( created.position );
+			test( writer, newPosition, created.node, description );
+		} );
+
+		it( 'should merge attribute nodes and their contents if possible', () => {
+			// <p><b foo="bar">{foo}</b>|<b foo="bar">{bar}</b></p>
+			// <p><b foo="bar">{foo}|{bar}</b></p>
+			// <p><b foo="bar">{foo|bar}</b></p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				position: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						attributes: { foo: 'bar' },
+						children: [
+							{ instanceOf: Text, data: 'foo' }
+						]
+					},
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						attributes: { foo: 'bar' },
+						children: [
+							{ instanceOf: Text, data: 'bar' }
+						]
+					}
+				]
+			} );
+
+			const newPosition = writer.mergeAttributes( created.position );
+
+			test( writer, newPosition, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						attributes: { foo: 'bar' },
+						children: [
+							{ instanceOf: Text, data: 'foobar', position: 3 }
+						]
+					}
+				]
+			} );
+		} );
+	} );
+} );

+ 284 - 0
packages/ckeditor5-engine/tests/treeview/writer/remove.js

@@ -0,0 +1,284 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import Writer from '/ckeditor5/core/treeview/writer.js';
+import Element from '/ckeditor5/core/treeview/element.js';
+import Range from '/ckeditor5/core/treeview/range.js';
+import Text from '/ckeditor5/core/treeview/text.js';
+import utils from '/tests/core/treeview/writer/_utils/utils.js';
+
+describe( 'Writer', () => {
+	const create = utils.create;
+	const test = utils.test;
+	let writer;
+
+	beforeEach( () => {
+		writer = new Writer();
+	} );
+
+	describe( 'remove', () => {
+		it( 'should throw when range placed in two containers', () => {
+			const p1 = new Element( 'p' );
+			const p2 = new Element( 'p' );
+
+			expect( () => {
+				writer.remove( Range.createFromParentsAndOffsets( p1, 0, p2, 0 ) );
+			} ).to.throw( 'treeview-writer-invalid-range-container' );
+		} );
+
+		it( 'should return empty array when range is collapsed', () => {
+			const p = new Element( 'p' );
+			const range = Range.createFromParentsAndOffsets( p, 0, p, 0 );
+			const nodes = writer.remove( range );
+
+			expect( nodes ).to.be.array;
+			expect( nodes.length ).to.equal( 0 );
+			expect( range.isCollapsed ).to.be.true;
+		} );
+
+		it( 'should remove single text node', () => {
+			// <p>[{foobar}]</p> -> <p>|</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{ instanceOf: Text, data: 'foobar' }
+				]
+			} );
+
+			const removed = writer.remove( created.range );
+			test( writer, created.range.start, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				position: 0,
+				children: []
+			} );
+
+			// Test removed nodes.
+			test( writer, null, removed, [
+				{ instanceOf: Text, data: 'foobar' }
+			] );
+		} );
+
+		it( 'should not leave empty text nodes', () => {
+			// <p>{[foobar]}</p> -> <p>|</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobar', rangeStart: 0, rangeEnd: 6 }
+				]
+			} );
+
+			const removed = writer.remove( created.range );
+			test( writer, created.range.start, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				position: 0,
+				children: []
+			} );
+
+			// Test removed nodes.
+			test( writer, null, removed, [
+				{ instanceOf: Text, data: 'foobar' }
+			] );
+		} );
+
+		it( 'should remove part of the text node', () => {
+			// <p>{f[oob]ar}</p> -> <p>{f|ar}</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobar', rangeStart: 1, rangeEnd: 4 }
+				]
+			} );
+
+			const removed = writer.remove( created.range );
+			test( writer, created.range.start, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'far', position: 1 }
+				]
+			} );
+
+			// Test removed nodes.
+			test( writer, null, removed, [
+				{ instanceOf: Text, data: 'oob' }
+			] );
+		} );
+
+		it( 'should remove parts of nodes', () => {
+			// <p>{f[oo}<b>{ba]r}</b></p> -> <p>{f}|<b>r</b></p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foo', rangeStart: 1 },
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'bar', rangeEnd: 2 }
+						]
+					}
+				]
+			} );
+
+			const removed = writer.remove( created.range );
+			test( writer, created.range.start, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				position: 1,
+				children: [
+					{ instanceOf: Text, data: 'f' },
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'r' }
+						]
+					}
+				]
+			} );
+
+			// Test removed nodes.
+			test( writer, null, removed, [
+				{ instanceOf: Text, data: 'oo' },
+				{
+					instanceOf: Element,
+					priority: 1,
+					name: 'b',
+					children: [
+						{ instanceOf: Text, data: 'ba' }
+					]
+				}
+			] );
+		} );
+
+		it( 'should merge after removing #1', () => {
+			// <p><b>foo</b>[{bar}]<b>bazqux</b></p> -> <p><b>foo|bazqux</b></p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 1,
+				rangeEnd: 2,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foo' }
+						]
+					},
+					{ instanceOf: Text, data: 'bar' },
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'bazqux' }
+						]
+					}
+				]
+			} );
+
+			const removed = writer.remove( created.range );
+			test( writer, created.range.start, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foobazqux', position: 3 }
+						]
+					}
+				]
+			} );
+
+			// Test removed nodes.
+			test( writer, null, removed, [
+				{ instanceOf: Text, data: 'bar' }
+			] );
+		} );
+
+		it( 'should merge after removing #2', () => {
+			// <p><b>fo[o</b>{bar}<b>ba]zqux</b></p> -> <p><b>fo|zqux</b></p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foo', rangeStart: 2 }
+						]
+					},
+					{ instanceOf: Text, data: 'bar' },
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'bazqux', rangeEnd: 2 }
+						]
+					}
+				]
+			} );
+
+			const removed = writer.remove( created.range );
+			test( writer, created.range.start, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'fozqux', position: 2 }
+						]
+					}
+				]
+			} );
+
+			// Test removed nodes.
+			test( writer, null, removed, [
+				{
+					instanceOf: Element,
+					priority: 1,
+					name: 'b',
+					children: [
+						{ instanceOf: Text, data: 'o' }
+					]
+				},
+				{ instanceOf: Text, data: 'bar' },
+				{
+					instanceOf: Element,
+					priority: 1,
+					name: 'b',
+					children: [
+						{ instanceOf: Text, data: 'ba' }
+					]
+				}
+			] );
+		} );
+	} );
+} );

+ 696 - 0
packages/ckeditor5-engine/tests/treeview/writer/unwrap.js

@@ -0,0 +1,696 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import Writer from '/ckeditor5/core/treeview/writer.js';
+import Element from '/ckeditor5/core/treeview/element.js';
+import Position from '/ckeditor5/core/treeview/position.js';
+import Range from '/ckeditor5/core/treeview/range.js';
+import Text from '/ckeditor5/core/treeview/text.js';
+import utils from '/tests/core/treeview/writer/_utils/utils.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+
+describe( 'Writer', () => {
+	const create = utils.create;
+	const test = utils.test;
+	let writer;
+
+	beforeEach( () => {
+		writer = new Writer();
+	} );
+
+	describe( 'unwrap', () => {
+		it( 'should do nothing on collapsed ranges', () => {
+			const description = {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foo', rangeStart: 1, rangeEnd: 1 }
+				]
+			};
+			const created = create( writer, description );
+			const newRange = writer.unwrap( created.range, new Element( 'b' ), 1 );
+			test( writer, newRange, created.node, description );
+		} );
+
+		it( 'should do nothing on single text node', () => {
+			// <p>[{foobar}]</p>
+			const description = {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{ instanceOf: Text, data: 'foobar' }
+				]
+			};
+
+			const created = create( writer, description );
+			const b = new Element( 'b' );
+			writer.setPriority( b, 1 );
+			const newRange = writer.unwrap( created.range, b );
+
+			test( writer, newRange, created.node, description );
+		} );
+
+		it( 'should throw error when range placed in two containers', () => {
+			const container1 = new Element( 'p' );
+			const container2 = new Element( 'p' );
+			const range = new Range(
+				new Position( container1, 0 ),
+				new Position( container2, 1 )
+			);
+			const b = new Element( 'b' );
+
+			expect( () => {
+				writer.unwrap( range, b, 1 );
+			} ).to.throw( CKEditorError, 'treeview-writer-invalid-range-container' );
+		} );
+
+		it( 'should unwrap single node', () => {
+			// <p>[<b>{foobar}</b>]<p> -> <p>[{foobar}]</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceOf: Element,
+						priority: 1,
+						name: 'b',
+						children: [
+							{ instanceOf: Text, data: 'foobar' }
+						]
+					}
+				]
+			} );
+
+			const b = new Element( 'b' );
+			writer.setPriority( b, 1 );
+			const newRange = writer.unwrap( created.range, b );
+
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{ instanceOf: Text, data: 'foobar' }
+				]
+			} );
+		} );
+
+		it( 'should not unwrap attributes with different priorities #1', () => {
+			// <p>[<b>{foobar}</b>]<p> -> <p>[<b>{foobar}</b>]</p>
+			// Unwrapped with <b> but using different priority.
+			const description =  {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceOf: Element,
+						priority: 1,
+						name: 'b',
+						children: [
+							{ instanceOf: Text, data: 'foobar' }
+						]
+					}
+				]
+			};
+			const created = create( writer, description );
+
+			const b = new Element( 'b' );
+			writer.setPriority( b, 2 );
+			const newRange = writer.unwrap( created.range, b );
+
+			test( writer, newRange, created.node, description );
+		} );
+
+		it( 'should not unwrap attributes with different priorities #2', () => {
+			// <p>[<b>{foo}</b><b>{bar}</b><b>{baz}</b>]<p> -> <p>[{foo}<b>bar</b>{baz}]</p>
+			// <b> around `bar` has different priority than others.
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 3,
+				children: [
+					{
+						instanceOf: Element,
+						priority: 2,
+						name: 'b',
+						children: [
+							{ instanceOf: Text, data: 'foo' }
+						]
+					},
+					{
+						instanceOf: Element,
+						priority: 1,
+						name: 'b',
+						children: [
+							{ instanceOf: Text, data: 'bar' }
+						]
+					},
+					{
+						instanceOf: Element,
+						priority: 2,
+						name: 'b',
+						children: [
+							{ instanceOf: Text, data: 'baz' }
+						]
+					}
+				]
+			} );
+
+			const b = new Element( 'b' );
+			writer.setPriority( b, 2 );
+			const newRange = writer.unwrap( created.range, b );
+
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 3,
+				children: [
+					{ instanceOf: Text, data: 'foo' },
+					{
+						instanceOf: Element,
+						priority: 1,
+						name: 'b',
+						children: [
+							{ instanceOf: Text, data: 'bar' }
+						]
+					},
+					{ instanceOf: Text, data: 'baz' }
+				]
+			} );
+		} );
+
+		it( 'should unwrap part of the node', () => {
+			// <p>[{baz}<b>{foo]bar}</b><p> -> <p>[{bazfoo}]<b>{bar}</b></p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				children: [
+					{ instanceOf: Text, data: 'baz' },
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foobar', rangeEnd: 3 }
+						]
+
+					}
+				]
+			} );
+
+			const b = new Element( 'b' );
+			writer.setPriority( b, 1 );
+
+			const newRange = writer.unwrap( created.range, b );
+
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{ instanceOf: Text, data: 'bazfoo' },
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'bar' }
+						]
+
+					}
+				]
+			} );
+		} );
+
+		it( 'should unwrap nested attributes', () => {
+			// <p>[<u><b>{foobar}</b></u>]</p> -> <p>[<u>{foobar}</u>]</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'u',
+						priority: 1,
+						children: [
+							{
+								instanceOf: Element,
+								name: 'b',
+								priority: 1,
+								children: [
+									{ instanceOf: Text, data: 'foobar' }
+								]
+							}
+						]
+					}
+				]
+			} );
+			const b = new Element( 'b' );
+			writer.setPriority( b, 1 );
+
+			const newRange = writer.unwrap( created.range, b );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'u',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foobar' }
+						]
+					}
+				]
+			} );
+		} );
+
+		it( 'should merge unwrapped nodes #1', () => {
+			// <p>{foo}[<b>{bar}</b>]{bom}</p> -> <p>{foo[bar]bom}</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 1,
+				rangeEnd: 2,
+				children: [
+					{ instanceOf: Text, data: 'foo' },
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'bar' }
+						]
+					},
+					{ instanceOf: Text, data: 'bom' }
+				]
+			} );
+
+			const b =  new Element( 'b' );
+			writer.setPriority( b, 1 );
+			const newRange = writer.unwrap( created.range, b );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{
+						instanceOf: Text,
+						data: 'foobarbom',
+						rangeStart: 3,
+						rangeEnd: 6
+					}
+				]
+			} );
+		} );
+
+		it( 'should merge unwrapped nodes #2', () => {
+			// <p>{foo}<u>{bar}</u>[<b><u>{bazqux}</u></b>]</p> -> <p>{foo}<u>{bar[bazqux}</u>]</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 2,
+				rangeEnd: 3,
+				children: [
+					{ instanceOf: Text, data: 'foo' },
+					{
+						instanceOf: Element,
+						name: 'u',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'bar' }
+						]
+					},
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{
+								instanceOf: Element,
+								name: 'u',
+								priority: 1,
+								children: [
+									{ instanceOf: Text, data: 'bazqux' }
+								]
+							}
+						]
+					}
+				]
+			} );
+
+			const b = new Element( 'b' );
+			writer.setPriority( b, 1 );
+			const newRange = writer.unwrap( created.range, b );
+
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeEnd: 2,
+				children: [
+					{
+						instanceOf: Text,
+						data: 'foo'
+					},
+					{
+						instanceOf: Element,
+						name: 'u',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'barbazqux', rangeStart: 3 }
+						]
+					}
+				]
+			} );
+		} );
+
+		it( 'should merge unwrapped nodes #3', () => {
+			// <p>{foo}<u>{bar}</u>[<b><u>{baz]qux}</u></b></p> -> <p>{foo}<u>{bar[baz}</u>]<b><u>{qux}</u></b></p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 2,
+				children: [
+					{ instanceOf: Text, data: 'foo' },
+					{
+						instanceOf: Element,
+						name: 'u',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'bar' }
+						]
+					},
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{
+								instanceOf: Element,
+								name: 'u',
+								priority: 1,
+								children: [
+									{ instanceOf: Text, data: 'bazqux', rangeEnd: 3 }
+								]
+							}
+						]
+					}
+				]
+			} );
+
+			const b = new Element( 'b' );
+			writer.setPriority( b, 1 );
+			const newRange = writer.unwrap( created.range, b );
+
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeEnd: 2,
+				children: [
+					{
+						instanceOf: Text,
+						data: 'foo'
+					},
+					{
+						instanceOf: Element,
+						name: 'u',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'barbaz', rangeStart: 3 }
+						]
+					},
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{
+								instanceOf: Element,
+								name: 'u',
+								priority: 1,
+								children: [
+									{ instanceOf: Text, data: 'qux' }
+								]
+							}
+						]
+					}
+				]
+			} );
+		} );
+
+		it( 'should merge unwrapped nodes #4', () => {
+			// <p>{foo}<u>{bar}</u>[<b><u>{baz}</u></b>]<u>qux</u></p> -> <p>{foo}<u>{bar[baz]qux}</u></p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 2,
+				rangeEnd: 3,
+				children: [
+					{ instanceOf: Text, data: 'foo' },
+					{
+						instanceOf: Element,
+						name: 'u',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'bar' }
+						]
+					},
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{
+								instanceOf: Element,
+								name: 'u',
+								priority: 1,
+								children: [
+									{ instanceOf: Text, data: 'baz' }
+								]
+							}
+						]
+					},
+					{
+						instanceOf: Element,
+						name: 'u',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'qux' }
+						]
+					}
+				]
+			} );
+
+			const b = new Element( 'b' );
+			writer.setPriority( b, 1 );
+			const newRange = writer.unwrap( created.range, b );
+
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{
+						instanceOf: Text,
+						data: 'foo'
+					},
+					{
+						instanceOf: Element,
+						name: 'u',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'barbazqux', rangeStart: 3, rangeEnd: 6 }
+						]
+					}
+				]
+			} );
+		} );
+
+		it( 'should merge unwrapped nodes #5', () => {
+			// <p>[<b><u>{foo}</u></b><b><u>{bar}</u></b><b><u>{baz}</u></b>]</p> -> <p>[<u>{foobarbaz}</u>]</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 3,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{
+								instanceOf: Element,
+								name: 'u',
+								priority: 1,
+								children: [
+									{ instanceOf: Text, data: 'foo' }
+								]
+							}
+						]
+					},
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{
+								instanceOf: Element,
+								name: 'u',
+								priority: 1,
+								children: [
+									{ instanceOf: Text, data: 'bar' }
+								]
+							}
+						]
+					},
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{
+								instanceOf: Element,
+								name: 'u',
+								priority: 1,
+								children: [
+									{ instanceOf: Text, data: 'baz' }
+								]
+							}
+						]
+					}
+				]
+			} );
+
+			const b = new Element( 'b' );
+			writer.setPriority( b, 1 );
+			const newRange = writer.unwrap( created.range, b );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'u',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foobarbaz' }
+						]
+					}
+				]
+			} );
+		} );
+
+		it( 'should unwrap mixed ranges #1', () => {
+			// <p>[<u><b>{foo}]</b></u></p> -> <p>[<u>{foo}</u>]</p
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'u',
+						priority: 1,
+						children: [
+							{
+								instanceOf: Element,
+								name: 'b',
+								priority: 1,
+								rangeEnd: 1,
+								children: [
+									{ instanceOf: Text, data: 'foo' }
+								]
+							}
+						]
+					}
+				]
+			} );
+			const b = new Element( 'b' );
+			writer.setPriority( b, 1 );
+			const newRange = writer.unwrap( created.range, b );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'u',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foo' }
+						]
+					}
+				]
+			} );
+		} );
+
+		it( 'should unwrap mixed ranges #2', () => {
+			// <p>[<u><b>{foo]}</b></u></p> -> <p>[<u>{foo}</u>]</p
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'u',
+						priority: 1,
+						children: [
+							{
+								instanceOf: Element,
+								name: 'b',
+								priority: 1,
+								children: [
+									{ instanceOf: Text, data: 'foo', rangeEnd: 3 }
+								]
+							}
+						]
+					}
+				]
+			} );
+			const b = new Element( 'b' );
+			writer.setPriority( b, 1 );
+			const newRange = writer.unwrap( created.range, b );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'u',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foo' }
+						]
+					}
+				]
+			} );
+		} );
+	} );
+} );

+ 536 - 0
packages/ckeditor5-engine/tests/treeview/writer/wrap.js

@@ -0,0 +1,536 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import Writer from '/ckeditor5/core/treeview/writer.js';
+import Element from '/ckeditor5/core/treeview/element.js';
+import Position from '/ckeditor5/core/treeview/position.js';
+import Range from '/ckeditor5/core/treeview/range.js';
+import Text from '/ckeditor5/core/treeview/text.js';
+import utils from '/tests/core/treeview/writer/_utils/utils.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+
+describe( 'Writer', () => {
+	const create = utils.create;
+	const test = utils.test;
+	let writer;
+
+	beforeEach( () => {
+		writer = new Writer();
+	} );
+
+	describe( 'wrap', () => {
+		it( 'should do nothing on collapsed ranges', () => {
+			const description = {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foo', rangeStart: 1, rangeEnd: 1 }
+				]
+			};
+			const created = create( writer, description );
+			const newRange = writer.wrap( created.range, new Element( 'b' ), 1 );
+			test( writer, newRange, created.node, description );
+		} );
+
+		it( 'wraps single text node', () => {
+			// <p>[{foobar}]</p>
+			// wrap <b>
+			// <p>[<b>{foobar}<b>]</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{ instanceOf: Text, data: 'foobar' }
+				]
+			} );
+
+			const b = new Element( 'b' );
+			const newRange = writer.wrap( created.range, b, 1 );
+
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foobar' }
+						]
+					}
+				]
+			} );
+		} );
+
+		it( 'should throw error when range placed in two containers', () => {
+			const container1 = new Element( 'p' );
+			const container2 = new Element( 'p' );
+			const range = new Range(
+				new Position( container1, 0 ),
+				new Position( container2, 1 )
+			);
+			const b = new Element( 'b' );
+
+			expect( () => {
+				writer.wrap( range, b, 1 );
+			} ).to.throw( CKEditorError, 'treeview-writer-invalid-range-container' );
+		} );
+
+		it( 'wraps part of a single text node #1', () => {
+			// <p>[{foo]bar}</p>
+			// wrap with <b>
+			// <p>[<b>{foo}</b>]{bar}</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				children: [
+					{ instanceOf: Text, data: 'foobar', rangeEnd: 3 }
+				]
+			} );
+
+			const b = new Element( 'b' );
+			const newRange = writer.wrap( created.range, b, 2 );
+
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 2,
+						children: [
+							{ instanceOf: Text, data: 'foo' }
+						]
+					},
+					{ instanceOf: Text, data: 'bar' }
+				]
+			} );
+		} );
+
+		it( 'wraps part of a single text node #2', () => {
+			// <p>{[foo]bar}</p>
+			// wrap with <b>
+			// <p>[<b>{foo}</b>]{bar}</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobar', rangeStart: 0, rangeEnd: 3 }
+				]
+			} );
+
+			const b = new Element( 'b' );
+			const newRange = writer.wrap( created.range, b, 2 );
+
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 2,
+						children: [
+							{ instanceOf: Text, data: 'foo' }
+						]
+					},
+					{ instanceOf: Text, data: 'bar' }
+				]
+			} );
+		} );
+
+		it( 'wraps part of a single text node #3', () => {
+			// <p>{foo[bar]}</p>
+			// wrap with <b>
+			// <p>{foo}[<b>{bar}</b>]</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				children: [
+					{ instanceOf: Text, data: 'foobar', rangeStart: 3, rangeEnd: 6 }
+				]
+			} );
+
+			const b = new Element( 'b' );
+			const newRange = writer.wrap( created.range, b, 2 );
+
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 1,
+				rangeEnd: 2,
+				children: [
+					{ instanceOf: Text, data: 'foo' },
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 2,
+						children: [
+							{ instanceOf: Text, data: 'bar' }
+						]
+					}
+				]
+			} );
+		} );
+
+		it( 'should not wrap inside nested containers', () => {
+			// <div>[{foobar}<p>{baz}</p>]</div>
+			// wrap with <b>
+			// <div>[<b>{foobar}</b><p>{baz}</p>]</div>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'div',
+				rangeStart: 0,
+				rangeEnd: 2,
+				children: [
+					{ instanceOf: Text, data: 'foobar' },
+					{
+						instanceOf: Element,
+						name: 'p',
+						children: [
+							{ instanceOf: Text, data: 'baz' }
+						]
+					}
+				]
+			} );
+
+			const newRange = writer.wrap( created.range, new Element( 'b' ), 1 );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'div',
+				rangeStart: 0,
+				rangeEnd: 2,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foobar' }
+						]
+					},
+					{
+						instanceOf: Element,
+						name: 'p',
+						children: [
+							{ instanceOf: Text, data: 'baz' }
+						]
+					}
+				]
+			} );
+		} );
+
+		it( 'wraps according to priorities', () => {
+			// <p>[<u>{foobar}</u>]</p>
+			// wrap with <b> that has higher priority than <u>
+			// <p>[<u><b>{foobar}</b></u>]</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'u',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foobar' }
+						]
+					}
+				]
+			} );
+
+			const b = new Element( 'b' );
+			const newRange = writer.wrap( created.range, b, 2 );
+
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'u',
+						priority: 1,
+						children: [
+							{
+								instanceOf: Element,
+								name: 'b',
+								priority: 2,
+								children: [
+									{ instanceOf: Text, data: 'foobar' }
+								]
+							}
+						]
+					}
+				]
+			} );
+		} );
+
+		it( 'merges wrapped nodes #1', () => {
+			// <p>[<b>{foo}</b>{bar}<b>{baz}</b>]</p>
+			// wrap with <b>
+			// <p>[<b>{foobarbaz}</b>]</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 3,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foo' }
+						]
+					},
+					{ instanceOf: Text, data: 'bar' },
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'baz' }
+						]
+					}
+				]
+			} );
+
+			const b = new Element( 'b' );
+			const newRange = writer.wrap( created.range, b, 1 );
+
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foobarbaz' }
+						]
+					}
+				]
+			} );
+		} );
+
+		it( 'merges wrapped nodes #2', () => {
+			// <p><b>{foo}</b>[{bar]baz}</p>
+			// wrap with <b>
+			// <p><b>{foo[bar}</b>]{baz}</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foo' }
+						]
+					},
+					{ instanceOf: Text, data: 'barbaz', rangeEnd: 3 }
+				]
+			} );
+
+			const newRange = writer.wrap( created.range, new Element( 'b' ), 1 );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeEnd: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foobar', rangeStart: 3 }
+						]
+					},
+					{ instanceOf: Text, data: 'baz' }
+				]
+			} );
+		} );
+
+		it( 'merges wrapped nodes #3', () => {
+			// <p><b>{foobar}</b>[{baz}]</p>
+			// wrap with <b>
+			// <p><b>{foobar[baz}</b>]</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 1,
+				rangeEnd: 2,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foobar' }
+						]
+					},
+					{ instanceOf: Text, data: 'baz', rangeEnd: 3 }
+				]
+			} );
+
+			const newRange = writer.wrap( created.range, new Element( 'b' ), 1 );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeEnd: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foobarbaz', rangeStart: 6 }
+						]
+					}
+				]
+			} );
+		} );
+
+		it( 'merges wrapped nodes #4', () => {
+			// <p>[{foo}<i>{bar}</i>]{baz}</p>
+			// wrap with <b>
+			// <p>[<b>{foo}<i>{bar}</i></b>]{baz}</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 2,
+				children: [
+					{ instanceOf: Text, data: 'foo' },
+					{
+						instanceOf: Element,
+						name: 'i',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'bar' }
+						]
+					},
+					{ instanceOf: Text, data: 'baz' }
+				]
+			} );
+
+			const newRange = writer.wrap( created.range, new Element( 'b' ), 1 );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'foo' },
+							{
+								instanceOf: Element,
+								name: 'i',
+								priority: 1,
+								children: [
+									{ instanceOf: Text, data: 'bar' }
+								]
+							}
+						]
+					},
+					{ instanceOf: Text, data: 'baz' }
+				]
+			} );
+		} );
+
+		it( 'merges wrapped nodes #5', () => {
+			// <p>[{foo}<i>{bar}</i>{baz}]</p>
+			// wrap with <b>, that has higher priority than <i>
+			// <p>[<b>{foo}</b><i><b>{bar}</b></i><b>{baz}</b>]</p>
+			const created = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 3,
+				children: [
+					{ instanceOf: Text, data: 'foo' },
+					{
+						instanceOf: Element,
+						name: 'i',
+						priority: 1,
+						children: [
+							{ instanceOf: Text, data: 'bar' }
+						]
+					},
+					{ instanceOf: Text, data: 'baz' }
+				]
+			} );
+
+			const newRange = writer.wrap( created.range, new Element( 'b' ), 2 );
+			test( writer, newRange, created.node, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 3,
+				children: [
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 2,
+						children: [
+							{ instanceOf: Text, data: 'foo' }
+						]
+					},
+					{
+						instanceOf: Element,
+						name: 'i',
+						priority: 1,
+						children: [
+							{
+								instanceOf: Element,
+								name: 'b',
+								priority: 2,
+								children: [
+									{ instanceOf: Text, data: 'bar' }
+								]
+							}
+						]
+					},
+					{
+						instanceOf: Element,
+						name: 'b',
+						priority: 2,
+						children: [
+							{ instanceOf: Text, data: 'baz' }
+						]
+					}
+				]
+			} );
+		} );
+	} );
+} );

+ 121 - 0
packages/ckeditor5-engine/tests/treeview/writer/writer.js

@@ -0,0 +1,121 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treeview */
+
+'use strict';
+
+import Writer from '/ckeditor5/core/treeview/writer.js';
+import Element from '/ckeditor5/core/treeview/element.js';
+import Text from '/ckeditor5/core/treeview/text.js';
+import Position from '/ckeditor5/core/treeview/position.js';
+import utils from '/tests/core/treeview/writer/_utils/utils.js';
+
+describe( 'Writer', () => {
+	const create = utils.create;
+	let writer;
+
+	beforeEach( () => {
+		writer = new Writer();
+	} );
+
+	describe( 'isContainer', () => {
+		it( 'should return true for container elements', () => {
+			const containerElement = new Element( 'p' );
+			const attributeElement = new Element( 'b' );
+
+			writer._priorities.set( attributeElement, 1 );
+
+			expect( writer.isContainer( containerElement ) ).to.be.true;
+			expect( writer.isContainer( attributeElement ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'isAttribute', () => {
+		it( 'should return true for attribute elements', () => {
+			const containerElement = new Element( 'p' );
+			const attributeElement = new Element( 'b' );
+
+			writer._priorities.set( attributeElement, 1 );
+
+			expect( writer.isAttribute( containerElement ) ).to.be.false;
+			expect( writer.isAttribute( attributeElement ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'setPriority', () => {
+		it( 'sets node priority', () => {
+			const nodeMock = {};
+			writer.setPriority( nodeMock, 10 );
+
+			expect( writer._priorities.get( nodeMock ) ).to.equal( 10 );
+		} );
+	} );
+
+	describe( 'getPriority', () => {
+		it( 'gets node priority', () => {
+			const nodeMock = {};
+			writer._priorities.set( nodeMock, 12 );
+
+			expect( writer.getPriority( nodeMock ) ).to.equal( 12 );
+		} );
+	} );
+
+	describe( 'getParentContainer', () => {
+		it( 'should return parent container of the node', () => {
+			const text = new Text( 'foobar' );
+			const b = new Element( 'b', null, [ text ] );
+			const parent = new Element( 'p', null, [ b ] );
+
+			writer.setPriority( b, 1 );
+			const container = writer.getParentContainer( new Position( text, 0 ) );
+
+			expect( container ).to.equal( parent );
+		} );
+
+		it( 'should return undefined if no parent container', () => {
+			const text = new Text( 'foobar' );
+			const b = new Element( 'b', null, [ text ] );
+
+			writer.setPriority( b, 1 );
+			const container = writer.getParentContainer( new Position( text, 0 ) );
+
+			expect( container ).to.be.undefined;
+		} );
+	} );
+
+	describe( 'move', () => {
+		it( 'should move nodes using remove and insert methods', () => {
+			// <p>[{foobar}]</p>
+			// Move to <div>|</div>
+			// <div>[{foobar}]</div>
+			const source = create( writer, {
+				instanceOf: Element,
+				name: 'p',
+				rangeStart: 0,
+				rangeEnd: 1,
+				children: [
+					{ instanceOf: Text, data: 'foobar' }
+				]
+			} );
+			const target = create( writer, {
+				instanceOf: Element,
+				name: 'div',
+				position: 0
+			} );
+
+			const removeSpy = sinon.spy( writer, 'remove' );
+			const insertSpy = sinon.spy( writer, 'insert' );
+
+			const newRange = writer.move( source.range, target.position );
+
+			sinon.assert.calledOnce( removeSpy );
+			sinon.assert.calledWithExactly( removeSpy, source.range );
+			sinon.assert.calledOnce( insertSpy );
+			sinon.assert.calledWithExactly( insertSpy, target.position, removeSpy.firstCall.returnValue );
+			expect( newRange ).to.equal( insertSpy.firstCall.returnValue );
+		} );
+	} );
+} );