浏览代码

Merge pull request #295 from ckeditor/t/284

T/284 DocumentFragment, TextFragment.setAttribute
Piotr Jasiun 9 年之前
父节点
当前提交
99b0debcb4

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

@@ -67,7 +67,6 @@ export default class InsertDelta extends Delta {
  * @method engine.treeModel.Batch#insert
  * @param {engine.treeModel.Position} position Position of insertion.
  * @param {engine.treeModel.NodeSet} nodes The list of nodes to be inserted.
- * List of nodes can be of any type accepted by the {@link engine.treeModel.NodeList} constructor.
  */
 register( 'insert', function( position, nodes ) {
 	const delta = new InsertDelta();

+ 111 - 0
packages/ckeditor5-engine/src/treemodel/documentfragment.js

@@ -0,0 +1,111 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import NodeList from './nodelist.js';
+
+/**
+ * DocumentFragment represents a part of Tree Model which does not have a common root but it's top level nodes
+ * can be seen as siblings.
+ *
+ * @memberOf engine.treeModel
+ */
+export default class DocumentFragment {
+	/**
+	 * Creates empty DocumentFragment.
+	 *
+	 * @param {engine.treeModel.NodeSet} children List of nodes contained inside the DocumentFragment.
+	 */
+	constructor( children ) {
+		/**
+		 * List of nodes contained inside the DocumentFragment.
+		 *
+		 * @protected
+		 * @member {engine.treeModel.NodeSet} engine.treeModel.DocumentFragment#_children
+		 */
+		this._children = new NodeList();
+
+		if ( children ) {
+			this.insertChildren( 0, children );
+		}
+	}
+
+	/**
+	 * Gets child at the given index.
+	 *
+	 * @param {Number} index Index of child.
+	 * @returns {engine.treeModel.Node} Child node.
+	 */
+	getChild( index ) {
+		return this._children.get( index );
+	}
+
+	/**
+	 * Gets the number of top-level elements of DocumentFragment.
+	 *
+	 * @returns {Number} The number of top-level elements.
+	 */
+	getChildCount() {
+		return this._children.length;
+	}
+
+	/**
+	 * Gets index of the given child node.
+	 *
+	 * @param {engine.treeModel.Node} node Child node.
+	 * @returns {Number} Index of the child node.
+	 */
+	getChildIndex( node ) {
+		return this._children.indexOf( node );
+	}
+
+	/**
+	 * Inserts a child node or a list of child nodes at the end of this DocumentFragment.
+	 *
+	 * @param {engine.treeModel.NodeSet} nodes The list of nodes to be inserted.
+	 */
+	appendChildren( nodes ) {
+		this.insertChildren( this.getChildCount(), nodes );
+	}
+
+	/**
+	 * Inserts a list of child nodes on the given index and sets the parent of these nodes to this DocumentFragment.
+	 *
+	 * @param {Number} index Position where nodes should be inserted.
+	 * @param {engine.treeModel.NodeSet} nodes The list of nodes to be inserted.
+	 */
+	insertChildren( index, nodes ) {
+		let nodeList = new NodeList( nodes );
+
+		for ( let node of nodeList._nodes ) {
+			node.parent = this;
+		}
+
+		// Clean original DocumentFragment so it won't contain nodes that were added somewhere else.
+		if ( nodes instanceof DocumentFragment ) {
+			nodes._children = new NodeList();
+		}
+
+		this._children.insert( index, nodeList );
+	}
+
+	/**
+	 * Removes number of child nodes starting at the given index and set the parent of these nodes to `null`.
+	 *
+	 * @param {Number} index Position of the first node to remove.
+	 * @param {Number} [howMany=1] Number of nodes to remove.
+	 * @returns {engine.treeModel.NodeList} The list of removed nodes.
+	 */
+	removeChildren( index, howMany = 1 ) {
+		let nodeList = this._children.remove( index, howMany );
+
+		for ( let node of nodeList._nodes ) {
+			node.parent = null;
+		}
+
+		return nodeList;
+	}
+}

+ 7 - 2
packages/ckeditor5-engine/src/treemodel/element.js

@@ -7,6 +7,7 @@
 
 import Node from './node.js';
 import NodeList from './nodelist.js';
+import DocumentFragment from './documentfragment.js';
 import utils from '../../utils/utils.js';
 
 /**
@@ -20,7 +21,7 @@ export default class Element extends Node {
 	 *
 	 * @param {String} name Node name.
 	 * @param {Iterable} attrs Iterable collection of attributes.
-	 * @param {engine.treeModel.NodeSet} children List of nodes to be inserted
+	 * @param {engine.treeModel.NodeSet} children List of nodes to be inserted.
 	 * into created element. List of nodes can be of any type accepted by the {@link engine.treeModel.NodeList} constructor.
 	 */
 	constructor( name, attrs, children ) {
@@ -97,7 +98,6 @@ export default class Element extends Node {
 	 *
 	 * @param {Number} index Position where nodes should be inserted.
 	 * @param {engine.treeModel.NodeSet} nodes The list of nodes to be inserted.
-	 * The list of nodes can be of any type accepted by the {@link engine.treeModel.NodeList} constructor.
 	 */
 	insertChildren( index, nodes ) {
 		let nodeList = new NodeList( nodes );
@@ -106,6 +106,11 @@ export default class Element extends Node {
 			node.parent = this;
 		}
 
+		// Clean original DocumentFragment so it won't contain nodes that were added somewhere else.
+		if ( nodes instanceof DocumentFragment ) {
+			nodes._children = new NodeList();
+		}
+
 		this._children.insert( index, nodeList );
 	}
 

+ 26 - 11
packages/ckeditor5-engine/src/treemodel/liveposition.js

@@ -5,13 +5,18 @@
 
 'use strict';
 
+import RootElement from './rootelement.js';
 import Position from './position.js';
 import Range from './range.js';
 import EmitterMixin from '../../utils/emittermixin.js';
 import utils from '../../utils/utils.js';
+import CKEditorError from '../../utils/ckeditorerror.js';
 
 /**
- * LivePosition is a position in the Tree Model that updates itself as the tree changes. It may be used as a bookmark.
+ * LivePosition is a position in {@link engine.treeModel.Document Document} that updates itself as Document is changed
+ * through operations. It may be used as a bookmark in the Document.
+ * **Note:** Contrary to {@link engine.treeModel.Position}, LivePosition works only in roots that are
+ * {@link engine.treeModel.RootElement}. If {@link engine.treeModel.DocumentFragment} is passed, error will be thrown.
  * **Note:** Be very careful when dealing with LivePosition. Each LivePosition instance bind events that might
  * have to be unbound. Use {@link engine.treeModel.LivePosition#detach} whenever you don't need LivePosition anymore.
  *
@@ -25,10 +30,19 @@ export default class LivePosition extends Position {
 	 * @see engine.treeModel.Position
 	 * @param {engine.treeModel.RootElement} root
 	 * @param {Array.<Number>} path
-	 * @param {engine.treeModel.PositionStickiness} [stickiness] Defaults to `'STICKS_TO_NEXT'`. See
-	 *  {@link engine.treeModel.LivePosition#stickiness}.
+	 * @param {engine.treeModel.PositionStickiness} [stickiness] Defaults to `'STICKS_TO_NEXT'`.
+	 * See {@link engine.treeModel.LivePosition#stickiness}.
 	 */
 	constructor( root, path, stickiness ) {
+		if ( !( root instanceof RootElement ) ) {
+			/**
+			 * LivePosition root has to be an instance of RootElement.
+			 *
+			 * @error liveposition-root-not-rootelement
+			 */
+			throw new CKEditorError( 'liveposition-root-not-rootelement: LivePosition root has to be an instance of RootElement.' );
+		}
+
 		super( root, path );
 
 		/**
@@ -38,15 +52,16 @@ export default class LivePosition extends Position {
 		 * position is same as LivePosition.
 		 *
 		 * Examples:
-		 * Insert:
-		 * Position is at | and we insert at the same position, marked as ^:
-		 * - | sticks to previous node: `<p>f|^oo</p>` => `<p>f|baroo</p>`
-		 * - | sticks to next node: `<p>f^|oo</p>` => `<p>fbar|oo</p>`
 		 *
-		 * Move:
-		 * Position is at | and range [ ] is moved to position ^:
-		 * - | sticks to previous node: `<p>f|[oo]</p><p>b^ar</p>` => `<p>f|</p><p>booar</p>`
-		 * - | sticks to next node: `<p>f|[oo]</p><p>b^ar</p>` => `<p>f</p><p>b|ooar</p>`
+		 *		Insert:
+		 *		Position is at | and we insert at the same position, marked as ^:
+		 *		- | sticks to previous node: `<p>f|^oo</p>` => `<p>f|baroo</p>`
+		 *		- | sticks to next node: `<p>f^|oo</p>` => `<p>fbar|oo</p>`
+		 *
+		 *		Move:
+		 *		Position is at | and range [ ] is moved to position ^:
+		 *		- | sticks to previous node: `<p>f|[oo]</p><p>b^ar</p>` => `<p>f|</p><p>booar</p>`
+		 *		- | sticks to next node: `<p>f|[oo]</p><p>b^ar</p>` => `<p>f</p><p>b|ooar</p>`
 		 *
 		 * @member {engine.treeModel.PositionStickiness} engine.treeModel.LivePosition#stickiness
 		 */

+ 2 - 2
packages/ckeditor5-engine/src/treemodel/node.js

@@ -25,10 +25,10 @@ export default class Node {
 	 */
 	constructor( attrs ) {
 		/**
-		 * Parent element. Null by default. Set by {@link engine.treeModel.Element#insertChildren}.
+		 * Element or DocumentFragment that is a parent of this node.
 		 *
 		 * @readonly
-		 * @member {engine.treeModel.Element|null} engine.treeModel.Node#parent
+		 * @member {engine.treeModel.Element|engine.treeModel.DocumentFragment|null} engine.treeModel.Node#parent
 		 */
 		this.parent = null;
 

+ 10 - 1
packages/ckeditor5-engine/src/treemodel/nodelist.js

@@ -7,6 +7,7 @@
 
 import CharacterProxy from './characterproxy.js';
 import Text from './text.js';
+import DocumentFragment from './documentfragment.js';
 import utils from '../../utils/utils.js';
 import clone from '../../utils/lib/lodash/clone.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
@@ -27,6 +28,11 @@ class NodeListText extends Text {
 	constructor( text, attrs ) {
 		super( text, attrs );
 
+		/**
+		 * Element that contains character nodes represented by this NodeListText.
+		 *
+		 * @type {engine.treeModel.Element|engine.treeModel.DocumentFragment|null}
+		 */
 		this.parent = null;
 	}
 
@@ -103,6 +109,8 @@ export default class NodeList {
 		if ( nodes instanceof NodeList ) {
 			// We do not clone anything.
 			return nodes;
+		} else if ( nodes instanceof DocumentFragment ) {
+			return nodes._children;
 		}
 
 		/**
@@ -449,5 +457,6 @@ export default class NodeList {
  * point to the same nodes.
  * * Iterable collection of above items will be iterated over and all items will be added to the node list.
  *
- * @typedef {engine.treeModel.Node|engine.treeModel.Text|String|engine.treeModel.NodeList|Iterable} engine.treeModel.NodeSet
+ * @typedef {engine.treeModel.Node|engine.treeModel.Text|String|engine.treeModel.NodeList|engine.treeModel.DocumentFragment|Iterable}
+ * engine.treeModel.NodeSet
  */

+ 4 - 9
packages/ckeditor5-engine/src/treemodel/operation/attributeoperation.js

@@ -120,15 +120,10 @@ export default class AttributeOperation extends Operation {
 				);
 			}
 
-			if ( item instanceof TextFragment ) {
-				item.commonParent._children.setAttribute( item.first.getIndex(), item.text.length, this.key, this.newValue );
-			}
-			else {
-				if ( this.newValue !== null ) {
-					item.setAttribute( this.key, this.newValue );
-				} else {
-					item.removeAttribute( this.key );
-				}
+			if ( this.newValue !== null ) {
+				item.setAttribute( this.key, this.newValue );
+			} else {
+				item.removeAttribute( this.key );
 			}
 		}
 

+ 21 - 23
packages/ckeditor5-engine/src/treemodel/position.js

@@ -6,9 +6,9 @@
 'use strict';
 
 import RootElement from './rootelement.js';
-import CKEditorError from '../../utils/ckeditorerror.js';
 import last from '../../utils/lib/lodash/last.js';
 import utils from '../../utils/utils.js';
+import CKEditorError from '../../utils/ckeditorerror.js';
 
 /**
  * Position in the tree. Position is always located before or after a node.
@@ -20,24 +20,22 @@ export default class Position {
 	/**
 	 * Creates a position.
 	 *
-	 * @param {engine.treeModel.RootElement} root Root element for the path. Note that this element can not have a parent.
-	 * @param {Array.<Number>} path Position path. Must contain at least one item. See {@link #path} property for more information.
+	 * @param {engine.treeModel.RootElement|engine.treeModel.DocumentFragment} root Root element for the position path.
+	 * @param {Array.<Number>} path Position path. See {@link engine.treeModel.Position#path} property for more information.
 	 */
 	constructor( root, path ) {
-		if ( !( root instanceof RootElement ) ) {
+		if ( !( root instanceof RootElement ) && !( root instanceof DocumentFragment ) ) {
 			/**
-			 * Position root has to be an instance of RootElement.
+			 * Position root invalid.
 			 *
-			 * @error position-root-not-rootelement
-			 * @param root
+			 * @error position-root-invalid.
 			 */
-			throw new CKEditorError( 'position-root-not-rootelement: Position root has to be an instance of RootElement.', { root: root } );
+			throw new CKEditorError( 'position-root-invalid: Position root invalid.' );
 		}
-
 		/**
-		 * Root element for the path. Note that this element can not have a parent.
+		 * Root element for the position path.
 		 *
-		 * @member {engine.treeModel.RootElement} engine.treeModel.Position#root
+		 * @member {engine.treeModel.RootElement|engine.treeModel.DocumentFragment} engine.treeModel.Position#root
 		 */
 		this.root = root;
 
@@ -52,19 +50,19 @@ export default class Position {
 		}
 
 		/**
-		 * Position of the node it the tree. For example:
+		 * Position of the node it the tree. Must contain at least one item. For example:
 		 *
-		 * root
-		 *  |- p         Before: [ 0 ]       After: [ 1 ]
-		 *  |- ul        Before: [ 1 ]       After: [ 2 ]
-		 *     |- li     Before: [ 1, 0 ]    After: [ 1, 1 ]
-		 *     |  |- f   Before: [ 1, 0, 0 ] After: [ 1, 0, 1 ]
-		 *     |  |- o   Before: [ 1, 0, 1 ] After: [ 1, 0, 2 ]
-		 *     |  |- o   Before: [ 1, 0, 2 ] After: [ 1, 0, 3 ]
-		 *     |- li     Before: [ 1, 1 ]    After: [ 1, 2 ]
-		 *        |- b   Before: [ 1, 1, 0 ] After: [ 1, 1, 1 ]
-		 *        |- a   Before: [ 1, 1, 1 ] After: [ 1, 1, 2 ]
-		 *        |- r   Before: [ 1, 1, 2 ] After: [ 1, 1, 3 ]
+		 *		 root
+		 *		  |- p         Before: [ 0 ]       After: [ 1 ]
+		 *		  |- ul        Before: [ 1 ]       After: [ 2 ]
+		 *		     |- li     Before: [ 1, 0 ]    After: [ 1, 1 ]
+		 *		     |  |- f   Before: [ 1, 0, 0 ] After: [ 1, 0, 1 ]
+		 *		     |  |- o   Before: [ 1, 0, 1 ] After: [ 1, 0, 2 ]
+		 *		     |  |- o   Before: [ 1, 0, 2 ] After: [ 1, 0, 3 ]
+		 *		     |- li     Before: [ 1, 1 ]    After: [ 1, 2 ]
+		 *		        |- b   Before: [ 1, 1, 0 ] After: [ 1, 1, 1 ]
+		 *		        |- a   Before: [ 1, 1, 1 ] After: [ 1, 1, 2 ]
+		 *		        |- r   Before: [ 1, 1, 2 ] After: [ 1, 1, 3 ]
 		 *
 		 * @member {Array.<Number>} engine.treeModel.Position#path
 		 */

+ 4 - 2
packages/ckeditor5-engine/src/treemodel/range.js

@@ -59,9 +59,11 @@ export default class Range {
 	}
 
 	/**
-	 * Range root element. Equals to the root of start position (which should be same as root of end position).
+	 * Range root element.
 	 *
-	 * @type {engine.treeModel.RootElement}
+	 * Equals to the root of start position (which should be same as root of end position).
+	 *
+	 * @type {engine.treeModel.RootElement|engine.treeModel.DocumentFragment}
 	 */
 	get root() {
 		return this.start.root;

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

@@ -6,12 +6,18 @@
 'use strict';
 
 import CharacterProxy from './characterproxy.js';
+import utils from '../../utils/utils.js';
 
 /**
  * TextFragment is an aggregator for multiple CharacterProxy instances that are placed next to each other in
  * tree model, in the same parent, and all have same attributes set. Instances of this class are created and returned
  * in various algorithms that "merge characters" (see {@link engine.treeModel.TreeWalker}, {@link engine.treeModel.Range}).
  *
+ * **Note:** TextFragment instances are created on the fly basing on the current state of tree model and attributes
+ * set on characters. Because of this it is highly unrecommended to store references to TextFragment instances
+ * because they might get invalidated due to operations on Document. This is especially true when you change
+ * attributes of TextFragment.
+ *
  * Difference between {@link engine.treeModel.TextFragment} and {@link engine.treeModel.Text} is that the former is a set of
  * nodes taken from tree model, while {@link engine.treeModel.Text} is simply a string with attributes set.
  *
@@ -105,4 +111,65 @@ export default class TextFragment {
 	getAttributes() {
 		return this.first.getAttributes();
 	}
+
+	/**
+	 * Sets attribute on the text fragment. If attribute with the same key already is set, it overwrites its values.
+	 *
+	 * **Note:** Changing attributes of text fragment affects document state. This TextFragment instance properties
+	 * will be refreshed, but other may get invalidated. It is highly unrecommended to store references to TextFragment instances.
+	 *
+	 * @param {String} key Key of attribute to set.
+	 * @param {*} value Attribute value.
+	 */
+	setAttribute( key, value ) {
+		let index = this.first.getIndex();
+
+		this.commonParent._children.setAttribute( this.first.getIndex(), this.text.length, key, value );
+
+		this.first = this.commonParent.getChild( index );
+		this.last = this.getCharAt( this.text.length - 1 );
+	}
+
+	/**
+	 * Removes all attributes from the text fragment and sets given attributes.
+	 *
+	 * **Note:** Changing attributes of text fragment affects document state. This TextFragment instance properties
+	 * will be refreshed, but other may get invalidated. It is highly unrecommended to store references to TextFragment instances.
+	 *
+	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set.
+	 * See {@link engine.treeModel.TextFragment#getAttributes}.
+	 */
+	setAttributesTo( attrs ) {
+		let attrsMap = utils.toMap( attrs );
+
+		this.clearAttributes();
+
+		for ( let attr of attrsMap ) {
+			this.setAttribute( attr[ 0 ], attr[ 1 ] );
+		}
+	}
+
+	/**
+	 * Removes an attribute with given key from the text fragment.
+	 *
+	 * **Note:** Changing attributes of text fragment affects document state. This TextFragment instance properties
+	 * will be refreshed, but other may get invalidated. It is highly unrecommended to store references to TextFragment instances.
+	 *
+	 * @param {String} key Key of attribute to remove.
+	 */
+	removeAttribute( key ) {
+		this.setAttribute( key, null );
+	}
+
+	/**
+	 * Removes all attributes from the text fragment.
+	 *
+	 * **Note:** Changing attributes of text fragment affects document state. This TextFragment instance properties
+	 * will be refreshed, but other may get invalidated. It is highly unrecommended to store references to TextFragment instances.
+	 */
+	clearAttributes() {
+		for ( let attr of this.getAttributes() ) {
+			this.removeAttribute( attr[ 0 ] );
+		}
+	}
 }

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

@@ -104,7 +104,7 @@ export default class TreeWalker {
 		 * Parent of the most recently visited node. Cached for optimization purposes.
 		 *
 		 * @private
-		 * @member {engine.treeModel.Element} engine.treeModel.TreeWalker#_visitedParent
+		 * @member {engine.treeModel.Element|engine.treeModel.DocumentFragment} engine.treeModel.TreeWalker#_visitedParent
 		 */
 		this._visitedParent = this.position.parent;
 	}

+ 132 - 0
packages/ckeditor5-engine/tests/treemodel/documentfragment.js

@@ -0,0 +1,132 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treemodel */
+
+'use strict';
+
+import NodeList from '/ckeditor5/engine/treemodel/nodelist.js';
+import Element from '/ckeditor5/engine/treemodel/element.js';
+import DocumentFragment from '/ckeditor5/engine/treemodel/documentfragment.js';
+
+describe( 'DocumentFragment', () => {
+	describe( 'constructor', () => {
+		it( 'should create empty document fragment', () => {
+			let frag = new DocumentFragment();
+
+			expect( frag.getChildCount() ).to.equal( 0 );
+		} );
+
+		it( 'should create document fragment with children', () => {
+			let frag = new DocumentFragment( [ 'x', new Element( 'p' ), 'y' ] );
+
+			expect( frag.getChildCount() ).to.equal( 3 );
+			expect( frag.getChild( 0 ) ).to.have.property( 'character' ).that.equals( 'x' );
+			expect( frag.getChild( 1 ) ).to.have.property( 'name' ).that.equals( 'p' );
+			expect( frag.getChild( 2 ) ).to.have.property( 'character' ).that.equals( 'y' );
+		} );
+	} );
+
+	describe( 'insertChildren', () => {
+		it( 'should add children to the document fragment', () => {
+			let frag = new DocumentFragment( 'xy' );
+			frag.insertChildren( 1, 'foo' );
+
+			expect( frag.getChildCount() ).to.equal( 5 );
+			expect( frag.getChild( 0 ) ).to.have.property( 'character' ).that.equals( 'x' );
+			expect( frag.getChild( 1 ) ).to.have.property( 'character' ).that.equals( 'f' );
+			expect( frag.getChild( 2 ) ).to.have.property( 'character' ).that.equals( 'o' );
+			expect( frag.getChild( 3 ) ).to.have.property( 'character' ).that.equals( 'o' );
+			expect( frag.getChild( 4 ) ).to.have.property( 'character' ).that.equals( 'y' );
+		} );
+
+		it( 'should accept DocumentFragment as a parameter and clean it after it is added', () => {
+			let p1 = new Element( 'p' );
+			let p2 = new Element( 'p' );
+			let otherFrag = new DocumentFragment( [ p1, p2 ] );
+
+			let frag = new DocumentFragment();
+
+			frag.insertChildren( 0, otherFrag );
+
+			expect( frag.getChildCount() ).to.equal( 2 );
+			expect( frag.getChild( 0 ) ).to.equal( p1 );
+			expect( frag.getChild( 1 ) ).to.equal( p2 );
+			expect( otherFrag.getChildCount() ).to.equal( 0 );
+		} );
+	} );
+
+	describe( 'appendChildren', () => {
+		it( 'should add children to the end of the element', () => {
+			let frag = new DocumentFragment( 'xy' );
+			frag.appendChildren( 'foo' );
+
+			expect( frag.getChildCount() ).to.equal( 5 );
+			expect( frag.getChild( 0 ) ).to.have.property( 'character' ).that.equals( 'x' );
+			expect( frag.getChild( 1 ) ).to.have.property( 'character' ).that.equals( 'y' );
+			expect( frag.getChild( 2 ) ).to.have.property( 'character' ).that.equals( 'f' );
+			expect( frag.getChild( 3 ) ).to.have.property( 'character' ).that.equals( 'o' );
+			expect( frag.getChild( 4 ) ).to.have.property( 'character' ).that.equals( 'o' );
+		} );
+	} );
+
+	describe( 'removeChildren', () => {
+		it( 'should remove children from the element and return them as a NodeList', () => {
+			let frag = new DocumentFragment( 'foobar' );
+			let removed = frag.removeChildren( 2, 3 );
+
+			expect( frag.getChildCount() ).to.equal( 3 );
+			expect( frag.getChild( 0 ) ).to.have.property( 'character' ).that.equals( 'f' );
+			expect( frag.getChild( 1 ) ).to.have.property( 'character' ).that.equals( 'o' );
+			expect( frag.getChild( 2 ) ).to.have.property( 'character' ).that.equals( 'r' );
+
+			expect( removed ).to.be.instanceof( NodeList );
+			expect( removed.length ).to.equal( 3 );
+
+			expect( removed.get( 0 ).character ).to.equal( 'o' );
+			expect( removed.get( 1 ).character ).to.equal( 'b' );
+			expect( removed.get( 2 ).character ).to.equal( 'a' );
+		} );
+
+		it( 'should remove one child when second parameter is not specified', () => {
+			let frag = new DocumentFragment( 'foo' );
+			let removed = frag.removeChildren( 2 );
+
+			expect( frag.getChildCount() ).to.equal( 2 );
+			expect( frag.getChild( 0 ) ).to.have.property( 'character' ).that.equals( 'f' );
+			expect( frag.getChild( 1 ) ).to.have.property( 'character' ).that.equals( 'o' );
+
+			expect( removed ).to.be.instanceof( NodeList );
+			expect( removed.length ).to.equal( 1 );
+
+			expect( removed.get( 0 ).character ).to.equal( 'o' );
+		} );
+	} );
+
+	describe( 'getChildIndex', () => {
+		it( 'should return child index', () => {
+			let frag = new DocumentFragment( [ new Element( 'p' ), 'bar', new Element( 'h' ) ] );
+			let p = frag.getChild( 0 );
+			let b = frag.getChild( 1 );
+			let a = frag.getChild( 2 );
+			let r = frag.getChild( 3 );
+			let h = frag.getChild( 4 );
+
+			expect( frag.getChildIndex( p ) ).to.equal( 0 );
+			expect( frag.getChildIndex( b ) ).to.equal( 1 );
+			expect( frag.getChildIndex( a ) ).to.equal( 2 );
+			expect( frag.getChildIndex( r ) ).to.equal( 3 );
+			expect( frag.getChildIndex( h ) ).to.equal( 4 );
+		} );
+	} );
+
+	describe( 'getChildCount', () => {
+		it( 'should return number of children', () => {
+			let frag = new DocumentFragment( 'bar' );
+
+			expect( frag.getChildCount() ).to.equal( 3 );
+		} );
+	} );
+} );

+ 31 - 0
packages/ckeditor5-engine/tests/treemodel/element.js

@@ -10,6 +10,7 @@
 import Node from '/ckeditor5/engine/treemodel/node.js';
 import NodeList from '/ckeditor5/engine/treemodel/nodelist.js';
 import Element from '/ckeditor5/engine/treemodel/element.js';
+import DocumentFragment from '/ckeditor5/engine/treemodel/documentfragment.js';
 
 describe( 'Element', () => {
 	describe( 'constructor', () => {
@@ -60,6 +61,21 @@ describe( 'Element', () => {
 			expect( element.getChild( 3 ) ).to.have.property( 'character' ).that.equals( 'o' );
 			expect( element.getChild( 4 ) ).to.have.property( 'character' ).that.equals( 'y' );
 		} );
+
+		it( 'should accept DocumentFragment as a parameter and clean it after it is added', () => {
+			let p1 = new Element( 'p' );
+			let p2 = new Element( 'p' );
+			let frag = new DocumentFragment( [ p1, p2 ] );
+
+			let element = new Element( 'div' );
+
+			element.insertChildren( 0, frag );
+
+			expect( element.getChildCount() ).to.equal( 2 );
+			expect( element.getChild( 0 ) ).to.equal( p1 );
+			expect( element.getChild( 1 ) ).to.equal( p2 );
+			expect( frag.getChildCount() ).to.equal( 0 );
+		} );
 	} );
 
 	describe( 'appendChildren', () => {
@@ -75,6 +91,21 @@ describe( 'Element', () => {
 			expect( element.getChild( 3 ) ).to.have.property( 'character' ).that.equals( 'o' );
 			expect( element.getChild( 4 ) ).to.have.property( 'character' ).that.equals( 'o' );
 		} );
+
+		it( 'should accept DocumentFragment as a parameter and clean it after it is added', () => {
+			let p1 = new Element( 'p' );
+			let p2 = new Element( 'p' );
+			let frag = new DocumentFragment( [ p1, p2 ] );
+
+			let element = new Element( 'div' );
+
+			element.appendChildren( frag );
+
+			expect( element.getChildCount() ).to.equal( 2 );
+			expect( element.getChild( 0 ) ).to.equal( p1 );
+			expect( element.getChild( 1 ) ).to.equal( p2 );
+			expect( frag.getChildCount() ).to.equal( 0 );
+		} );
 	} );
 
 	describe( 'removeChildren', () => {

+ 8 - 0
packages/ckeditor5-engine/tests/treemodel/liveposition.js

@@ -8,10 +8,12 @@
 'use strict';
 
 import Document from '/ckeditor5/engine/treemodel/document.js';
+import DocumentFragment from '/ckeditor5/engine/treemodel/documentfragment.js';
 import Element from '/ckeditor5/engine/treemodel/element.js';
 import Position from '/ckeditor5/engine/treemodel/position.js';
 import LivePosition from '/ckeditor5/engine/treemodel/liveposition.js';
 import Range from '/ckeditor5/engine/treemodel/range.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 
 describe( 'LivePosition', () => {
 	let doc, root, ul, p, li1, li2;
@@ -35,6 +37,12 @@ describe( 'LivePosition', () => {
 		expect( live ).to.be.instanceof( Position );
 	} );
 
+	it( 'should throw if given root is not a RootElement', () => {
+		expect( () => {
+			new LivePosition( new DocumentFragment(), [ 1 ] );
+		} ).to.throw( CKEditorError, /liveposition-root-not-rootelement/ );
+	} );
+
 	it( 'should listen to a change event of the document that owns this position root', () => {
 		sinon.spy( LivePosition.prototype, 'listenTo' );
 

+ 13 - 0
packages/ckeditor5-engine/tests/treemodel/nodelist.js

@@ -8,6 +8,7 @@
 'use strict';
 
 import NodeList from '/ckeditor5/engine/treemodel/nodelist.js';
+import DocumentFragment from '/ckeditor5/engine/treemodel/documentfragment.js';
 import Element from '/ckeditor5/engine/treemodel/element.js';
 import Text from '/ckeditor5/engine/treemodel/text.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
@@ -105,6 +106,18 @@ describe( 'NodeList', () => {
 			expect( nodeList._nodes[ 1 ].text ).to.equal( 'xy' );
 			expect( nodeList._nodes[ 2 ].text ).to.equal( 'bar' );
 		} );
+
+		it( 'should accept DocumentFragment as a parameter', () => {
+			let p1 = new Element( 'p' );
+			let p2 = new Element( 'p' );
+			let frag = new DocumentFragment( [ p1, p2 ] );
+
+			let nodeList = new NodeList( frag );
+
+			expect( nodeList.length ).to.equal( 2 );
+			expect( nodeList.get( 0 ) ).to.equal( p1 );
+			expect( nodeList.get( 1 ) ).to.equal( p2 );
+		} );
 	} );
 
 	describe( 'insert', () => {

+ 10 - 3
packages/ckeditor5-engine/tests/treemodel/position.js

@@ -8,6 +8,7 @@
 'use strict';
 
 import Document from '/ckeditor5/engine/treemodel/document.js';
+import DocumentFragment from '/ckeditor5/engine/treemodel/documentfragment.js';
 import Element from '/ckeditor5/engine/treemodel/element.js';
 import Position from '/ckeditor5/engine/treemodel/position.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
@@ -58,6 +59,12 @@ describe( 'position', () => {
 		expect( position ).to.have.property( 'root' ).that.equals( root );
 	} );
 
+	it( 'should accept DocumentFragment as a root', () => {
+		expect( () => {
+			new Position( new DocumentFragment(), [ 0 ] );
+		} ).not.to.throw;
+	} );
+
 	it( 'should throw error if given path is incorrect', () => {
 		expect( () => {
 			new Position( root, {} );
@@ -68,14 +75,14 @@ describe( 'position', () => {
 		} ).to.throw( CKEditorError, /position-path-incorrect/ );
 	} );
 
-	it( 'should throw error if given root is not a RootElement instance', () => {
+	it( 'should throw error if given root is invalid', () => {
 		expect( () => {
 			new Position();
-		} ).to.throw( CKEditorError, /position-root-not-rootelement/ );
+		} ).to.throw( CKEditorError, /position-root-invalid/ );
 
 		expect( () => {
 			new Position( new Element( 'p' ), [ 0 ] );
-		} ).to.throw( CKEditorError, /position-root-not-rootelement/ );
+		} ).to.throw( CKEditorError, /position-root-invalid/ );
 	} );
 
 	it( 'should create positions form node and offset', () => {

+ 76 - 3
packages/ckeditor5-engine/tests/treemodel/textfragment.js

@@ -16,14 +16,12 @@ import CharacterProxy from '/ckeditor5/engine/treemodel/characterproxy.js';
 describe( 'TextFragment', () => {
 	let doc, text, element, textFragment, root;
 
-	before( () => {
+	beforeEach( () => {
 		doc = new Document();
 		root = doc.createRoot( 'root' );
 		element = new Element( 'div' );
 		root.insertChildren( 0, element );
-	} );
 
-	beforeEach( () => {
 		text = new Text( 'foobar', { foo: 'bar' } );
 		element.insertChildren( 0, text );
 		textFragment = new TextFragment( element.getChild( 2 ), 3 );
@@ -95,5 +93,80 @@ describe( 'TextFragment', () => {
 				expect( attrs ).to.deep.equal( [ [ 'foo', 'bar' ] ] );
 			} );
 		} );
+
+		describe( 'setAttribute', () => {
+			it( 'should set attribute on characters contained in text fragment', () => {
+				textFragment.setAttribute( 'abc', 'xyz' );
+
+				expect( element.getChild( 0 ).getAttribute( 'abc' ) ).to.be.undefined;
+				expect( element.getChild( 1 ).getAttribute( 'abc' ) ).to.be.undefined;
+				expect( element.getChild( 2 ).getAttribute( 'abc' ) ).to.equal( 'xyz' );
+				expect( element.getChild( 3 ).getAttribute( 'abc' ) ).to.equal( 'xyz' );
+				expect( element.getChild( 4 ).getAttribute( 'abc' ) ).to.equal( 'xyz' );
+				expect( element.getChild( 5 ).getAttribute( 'abc' ) ).to.be.undefined;
+			} );
+
+			it( 'should remove attribute when passed attribute value is null', () => {
+				textFragment.setAttribute( 'foo', null );
+
+				expect( element.getChild( 0 ).hasAttribute( 'foo' ) ).to.be.true;
+				expect( element.getChild( 1 ).hasAttribute( 'foo' ) ).to.be.true;
+				expect( element.getChild( 2 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 3 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 4 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 5 ).hasAttribute( 'foo' ) ).to.be.true;
+			} );
+
+			it( 'should correctly split and merge text fragments and refresh this text fragment properties', () => {
+				let otherTextFragment = new TextFragment( element.getChild( 5 ), 1 );
+				otherTextFragment.setAttribute( 'foo', null );
+				textFragment.setAttribute( 'foo', null );
+
+				expect( element._children._nodes.length ).to.equal( 2 );
+				expect( textFragment.first._nodeListText ).to.equal( element._children._nodes[ 1 ] );
+			} );
+		} );
+
+		describe( 'setAttributesTo', () => {
+			it( 'should remove all attributes from text fragment and set given ones', () => {
+				textFragment.setAttributesTo( { abc: 'xyz' } );
+
+				expect( element.getChild( 2 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 3 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 4 ).hasAttribute( 'foo' ) ).to.be.false;
+
+				expect( element.getChild( 2 ).getAttribute( 'abc' ) ).to.equal( 'xyz' );
+				expect( element.getChild( 3 ).getAttribute( 'abc' ) ).to.equal( 'xyz' );
+				expect( element.getChild( 4 ).getAttribute( 'abc' ) ).to.equal( 'xyz' );
+			} );
+		} );
+
+		describe( 'removeAttribute', () => {
+			it( 'should remove given attribute from text fragment', () => {
+				textFragment.removeAttribute( 'foo' );
+
+				expect( element.getChild( 0 ).hasAttribute( 'foo' ) ).to.be.true;
+				expect( element.getChild( 1 ).hasAttribute( 'foo' ) ).to.be.true;
+				expect( element.getChild( 2 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 3 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 4 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 5 ).hasAttribute( 'foo' ) ).to.be.true;
+			} );
+		} );
+
+		describe( 'clearAttributes', () => {
+			it( 'should remove all attributes from text fragment', () => {
+				textFragment.setAttribute( 'abc', 'xyz' );
+				textFragment.clearAttributes();
+
+				expect( element.getChild( 2 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 3 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 4 ).hasAttribute( 'foo' ) ).to.be.false;
+
+				expect( element.getChild( 2 ).hasAttribute( 'abc' ) ).to.be.false;
+				expect( element.getChild( 3 ).hasAttribute( 'abc' ) ).to.be.false;
+				expect( element.getChild( 4 ).hasAttribute( 'abc' ) ).to.be.false;
+			} );
+		} );
 	} );
 } );