Sfoglia il codice sorgente

Merge pull request #545 from ckeditor/t/542

T/542 Follow-ups after refactor engine model text handling
Szymon Cofalik 9 anni fa
parent
commit
6fd2b988bf

+ 20 - 4
packages/ckeditor5-engine/src/model/documentfragment.js

@@ -127,7 +127,7 @@ export default class DocumentFragment {
 	 * @returns {Number}
 	 */
 	getMaxOffset() {
-		return this._children.totalOffset;
+		return this._children.getMaxOffset();
 	}
 
 	/**
@@ -170,9 +170,7 @@ export default class DocumentFragment {
 	 * @param {engine.model.Node|Iterable.<engine.model.Node>} nodes Nodes to be inserted.
 	 */
 	insertChildren( index, nodes ) {
-		if ( !isIterable( nodes ) ) {
-			nodes = [ nodes ];
-		}
+		nodes = normalize( nodes );
 
 		for ( let node of nodes ) {
 			node.parent = this;
@@ -237,3 +235,21 @@ export default class DocumentFragment {
 		return new DocumentFragment( children );
 	}
 }
+
+// Converts strings to Text and non-iterables to arrays.
+//
+// @param {String|engine.model.Node|Iterable.<String|engine.model.Node>}
+// @return {Iterable.<engine.model.Node>}
+function normalize( nodes ) {
+	// Separate condition because string is iterable.
+	if ( typeof nodes == 'string' ) {
+		return [ new Text( nodes ) ];
+	}
+
+	if ( !isIterable( nodes ) ) {
+		nodes = [ nodes ];
+	}
+
+	// Array.from to enable .map() on non-arrays.
+	return Array.from( nodes ).map( ( node ) => typeof node == 'string' ? new Text( node ) : node );
+}

+ 20 - 4
packages/ckeditor5-engine/src/model/element.js

@@ -129,7 +129,7 @@ export default class Element extends Node {
 	 * @returns {Number}
 	 */
 	getMaxOffset() {
-		return this._children.totalOffset;
+		return this._children.getMaxOffset();
 	}
 
 	/**
@@ -170,9 +170,7 @@ export default class Element extends Node {
 	 * @param {engine.model.Node|Iterable.<engine.model.Node>} nodes Nodes to be inserted.
 	 */
 	insertChildren( index, nodes ) {
-		if ( !isIterable( nodes ) ) {
-			nodes = [ nodes ];
-		}
+		nodes = normalize( nodes );
 
 		for ( let node of nodes ) {
 			node.parent = this;
@@ -246,3 +244,21 @@ export default class Element extends Node {
 		return new Element( json.name, json.attributes, children );
 	}
 }
+
+// Converts strings to Text and non-iterables to arrays.
+//
+// @param {String|engine.model.Node|Iterable.<String|engine.model.Node>}
+// @return {Iterable.<engine.model.Node>}
+function normalize( nodes ) {
+	// Separate condition because string is iterable.
+	if ( typeof nodes == 'string' ) {
+		return [ new Text( nodes ) ];
+	}
+
+	if ( !isIterable( nodes ) ) {
+		nodes = [ nodes ];
+	}
+
+	// Array.from to enable .map() on non-arrays.
+	return Array.from( nodes ).map( ( node ) => typeof node == 'string' ? new Text( node ) : node );
+}

+ 38 - 47
packages/ckeditor5-engine/src/model/nodelist.js

@@ -25,14 +25,6 @@ export default class NodeList {
 		 */
 		this._nodes = [];
 
-		/**
-		 * Represents which node occupies given offset.
-		 *
-		 * @private
-		 * @member {Array.<engine.model.Node>} engine.model.NodeList#_nodeAtOffset
-		 */
-		this._nodeAtOffset = [];
-
 		if ( nodes ) {
 			this.insertNodes( 0, nodes );
 		}
@@ -61,8 +53,8 @@ export default class NodeList {
 	 *
 	 * @returns {Number}
 	 */
-	get totalOffset() {
-		return this._nodeAtOffset.length;
+	getMaxOffset() {
+		return this._nodes.reduce( ( sum, node ) => sum + node.offsetSize, 0 );
 	}
 
 	/**
@@ -95,51 +87,69 @@ export default class NodeList {
 	 * @returns {Number|null} Node's starting offset.
 	 */
 	getNodeStartOffset( node ) {
-		const offset = this._nodeAtOffset.indexOf( node );
+		const index = this.getNodeIndex( node );
 
-		return offset == -1 ? null : offset;
+		return index === null ? null : this._nodes.slice( 0, index ).reduce( ( sum, node ) => sum + node.offsetSize, 0 );
 	}
 
 	/**
-	 * Converts index "position" to offset "position".
+	 * Converts index to offset in node list.
 	 *
-	 * Returns starting offset of a node that is at given index. If given index is too low, `0` is returned. If
-	 * given index is too high, {@link engine.model.NodeList#totalOffset last available offset} is returned.
+	 * Returns starting offset of a node that is at given index. Throws {@link utils.CKEditorError CKEditorError}
+	 * `nodelist-index-out-of-bounds` if given index is less than `0` or more than {@link engine.model.NodeList#length}.
 	 *
 	 * @param {Number} index Node's index.
 	 * @returns {Number} Node's starting offset.
 	 */
 	indexToOffset( index ) {
-		if ( index < 0 ) {
-			return 0;
-		} else if ( index >= this._nodes.length ) {
-			return this.totalOffset;
+		if ( index == this._nodes.length ) {
+			return this.getMaxOffset();
 		}
 
 		const node = this._nodes[ index ];
 
+		if ( !node ) {
+			/**
+			 * Given index cannot be found in the node list.
+			 *
+			 * @error nodelist-index-out-of-bounds
+			 */
+			throw new CKEditorError( 'nodelist-index-out-of-bounds: Given index cannot be found in the node list.' );
+		}
+
 		return this.getNodeStartOffset( node );
 	}
 
 	/**
-	 * Converts offset "position" to index "position".
+	 * Converts offset in node list to index.
 	 *
-	 * Returns index of a node that occupies given offset. If given offset is too low, `0` is returned. If
-	 * given offset is too high, {@link engine.model.NodeList#length last available index} is returned.
+	 * Returns index of a node that occupies given offset. Throws {@link utils.CKEditorError CKEditorError}
+	 * `nodelist-offset-out-of-bounds` if given offset is less than `0` or more than {@link engine.model.NodeList#getMaxOffset}.
 	 *
 	 * @param {Number} offset Offset to look for.
 	 * @returns {Number} Index of a node that occupies given offset.
 	 */
 	offsetToIndex( offset ) {
-		if ( offset < 0 ) {
-			return 0;
-		} else if ( offset >= this._nodeAtOffset.length ) {
-			return this.length;
+		let totalOffset = 0;
+
+		for ( let node of this._nodes ) {
+			if ( offset >= totalOffset && offset < totalOffset + node.offsetSize  ) {
+				return this.getNodeIndex( node );
+			}
+
+			totalOffset += node.offsetSize;
 		}
 
-		const node = this._nodeAtOffset[ offset ];
+		if ( totalOffset != offset ) {
+			/**
+			 * Given offset cannot be found in the node list.
+			 *
+			 * @error nodelist-offset-out-of-bounds
+			 */
+			throw new CKEditorError( 'nodelist-offset-out-of-bounds: Given offset cannot be found in the node list.' );
+		}
 
-		return this.getNodeIndex( node );
+		return this.length;
 	}
 
 	/**
@@ -161,19 +171,7 @@ export default class NodeList {
 			}
 		}
 
-		const offset = this.indexToOffset( index );
-
 		this._nodes.splice( index, 0, ...nodes );
-
-		const offsetsArray = [];
-
-		for ( let node of nodes ) {
-			for ( let i = 0; i < node.offsetSize; i++ ) {
-				offsetsArray.push( node );
-			}
-		}
-
-		this._nodeAtOffset.splice( offset, 0, ...offsetsArray );
 	}
 
 	/**
@@ -184,13 +182,6 @@ export default class NodeList {
 	 * @returns {Array.<engine.model.Node>} Array containing removed nodes.
 	 */
 	removeNodes( indexStart, howMany = 1 ) {
-		const indexEnd = indexStart + howMany;
-
-		const offsetStart = this.indexToOffset( indexStart );
-		const offsetEnd = this.indexToOffset( indexEnd );
-
-		this._nodeAtOffset.splice( offsetStart, offsetEnd - offsetStart );
-
 		return this._nodes.splice( indexStart, howMany );
 	}
 

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

@@ -66,7 +66,7 @@ export default class InsertOperation extends Operation {
 	 * @returns {engine.model.operation.RemoveOperation}
 	 */
 	getReversed() {
-		return new RemoveOperation( this.position, this.nodes.totalOffset, this.baseVersion + 1 );
+		return new RemoveOperation( this.position, this.nodes.getMaxOffset(), this.baseVersion + 1 );
 	}
 
 	/**

+ 4 - 4
packages/ckeditor5-engine/src/model/operation/transform.js

@@ -63,7 +63,7 @@ const ot = {
 			const transformed = a.clone();
 
 			// Transform insert position by the other operation position.
-			transformed.position = transformed.position._getTransformedByInsertion( b.position, b.nodes.totalOffset, !isStrong );
+			transformed.position = transformed.position._getTransformedByInsertion( b.position, b.nodes.getMaxOffset(), !isStrong );
 
 			return [ transformed ];
 		},
@@ -88,7 +88,7 @@ const ot = {
 		// Transforms AttributeOperation `a` by InsertOperation `b`. Returns results as an array of operations.
 		InsertOperation( a, b ) {
 			// Transform this operation's range.
-			const ranges = a.range._getTransformedByInsertion( b.position, b.nodes.totalOffset, true, false );
+			const ranges = a.range._getTransformedByInsertion( b.position, b.nodes.getMaxOffset(), true, false );
 
 			// Map transformed range(s) to operations and return them.
 			return ranges.reverse().map( ( range ) => {
@@ -221,14 +221,14 @@ const ot = {
 		InsertOperation( a, b, isStrong ) {
 			// Create range from MoveOperation properties and transform it by insertion.
 			let range = Range.createFromPositionAndShift( a.sourcePosition, a.howMany );
-			range = range._getTransformedByInsertion( b.position, b.nodes.totalOffset, false, a.isSticky )[ 0 ];
+			range = range._getTransformedByInsertion( b.position, b.nodes.getMaxOffset(), false, a.isSticky )[ 0 ];
 
 			let result = new a.constructor(
 				range.start,
 				range.end.offset - range.start.offset,
 				a instanceof RemoveOperation ?
 					a.baseVersion :
-					a.targetPosition._getTransformedByInsertion( b.position, b.nodes.totalOffset, !isStrong ),
+					a.targetPosition._getTransformedByInsertion( b.position, b.nodes.getMaxOffset(), !isStrong ),
 				a instanceof RemoveOperation ? undefined : a.baseVersion
 			);
 

+ 4 - 1
packages/ckeditor5-engine/src/model/position.js

@@ -13,6 +13,9 @@ import Text from './text.js';
 /**
  * Represents a position in the model tree.
  *
+ * **Note:** Position is based on offsets, not indexes. This means that position in element containing two text nodes
+ * with data `foo` and `bar`, position between them has offset `3`, not `1`. See {@link engine.model.Position#path} for more.
+ *
  * Since position in a model is represented by a {@link engine.model.Position#root position root} and
  * {@link engine.model.Position#path position path} it is possible to create positions placed in non-existing elements.
  * This requirement is important for {@link engine.model.operation.transfrom operational transformation}.
@@ -69,7 +72,7 @@ export default class Position {
 		this.root = root;
 
 		/**
-		 * Position of the node it the tree.
+		 * Position of the node it the tree. Path is described through offsets, not indexes.
 		 *
 		 * Position can be placed before, after or in a {@link engine.model.Node node} if that node has
 		 * {@link engine.model.Node#offsetSize} greater than `1`. Items in position path are

+ 36 - 0
packages/ckeditor5-engine/src/model/textproxy.js

@@ -130,6 +130,21 @@ export default class TextProxy {
 		return this.startOffset !== null ? this.startOffset + this.offsetSize : null;
 	}
 
+	/**
+	 * Flag indicating whether `TextProxy` instance covers only part of the original {@link engine.model.Text text node}
+	 * (`true`) or the whole text node (`false`).
+	 *
+	 * This is `false` when text proxy starts at the very beginning of {@link engine.model.TextProxy#textNode textNode}
+	 * ({@link engine.model.TextProxy#offsetInText offsetInText} equals `0`) and text proxy sizes is equal to
+	 * text node size.
+	 *
+	 * @readonly
+	 * @type {Boolean}
+	 */
+	get isPartial() {
+		return this.offsetInText !== 0 || this.offsetSize !== this.textNode.offsetSize;
+	}
+
 	/**
 	 * Gets path to this text proxy.
 	 *
@@ -147,6 +162,27 @@ export default class TextProxy {
 		return path;
 	}
 
+	/**
+	 * Returns ancestors array of this text proxy.
+	 *
+	 * @param {Object} options Options object.
+	 * @param {Boolean} [options.includeNode=false] When set to `true` this text proxy will be also included in parent's array.
+	 * @param {Boolean} [options.parentFirst=false] When set to `true`, array will be sorted from text proxy parent to root element,
+	 * otherwise root element will be the first item in the array.
+	 * @returns {Array} Array with ancestors.
+	 */
+	getAncestors( options = { includeNode: false, parentFirst: false } ) {
+		const ancestors = [];
+		let parent = options.includeNode ? this : this.parent;
+
+		while ( parent ) {
+			ancestors[ options.parentFirst ? 'push' : 'unshift' ]( parent );
+			parent = parent.parent;
+		}
+
+		return ancestors;
+	}
+
 	/**
 	 * Checks if this text proxy has an attribute for given key.
 	 *

+ 20 - 3
packages/ckeditor5-engine/src/view/documentfragment.js

@@ -3,6 +3,7 @@
  * For licensing, see LICENSE.md.
  */
 
+import Text from './text.js';
 import mix from '../../utils/mix.js';
 import isIterable from '../../utils/isiterable.js';
 import EmitterMixin from '../../utils/emittermixin.js';
@@ -120,9 +121,7 @@ export default class DocumentFragment {
 		this._fireChange( 'children', this );
 		let count = 0;
 
-		if ( !isIterable( nodes ) ) {
-			nodes = [ nodes ];
-		}
+		nodes = normalize( nodes );
 
 		for ( let node of nodes ) {
 			node.parent = this;
@@ -175,3 +174,21 @@ export default class DocumentFragment {
 }
 
 mix( DocumentFragment, EmitterMixin );
+
+// Converts strings to Text and non-iterables to arrays.
+//
+// @param {String|engine.view.Node|Iterable.<String|engine.view.Node>}
+// @return {Iterable.<engine.view.Node>}
+function normalize( nodes ) {
+	// Separate condition because string is iterable.
+	if ( typeof nodes == 'string' ) {
+		return [ new Text( nodes ) ];
+	}
+
+	if ( !isIterable( nodes ) ) {
+		nodes = [ nodes ];
+	}
+
+	// Array.from to enable .map() on non-arrays.
+	return Array.from( nodes ).map( ( node ) => typeof node == 'string' ? new Text( node ) : node );
+}

+ 20 - 3
packages/ckeditor5-engine/src/view/element.js

@@ -4,6 +4,7 @@
  */
 
 import Node from './node.js';
+import Text from './text.js';
 import objectToMap from '../../utils/objecttomap.js';
 import isIterable from '../../utils/isiterable.js';
 import isPlainObject from '../../utils/lib/lodash/isPlainObject.js';
@@ -301,9 +302,7 @@ export default class Element extends Node {
 		this._fireChange( 'children', this );
 		let count = 0;
 
-		if ( !isIterable( nodes ) ) {
-			nodes = [ nodes ];
-		}
+		nodes = normalize( nodes );
 
 		for ( let node of nodes ) {
 			node.parent = this;
@@ -594,3 +593,21 @@ function parseClasses( classesSet, classesString ) {
 	classesSet.clear();
 	classArray.forEach( name => classesSet.add( name ) );
 }
+
+// Converts strings to Text and non-iterables to arrays.
+//
+// @param {String|engine.view.Node|Iterable.<String|engine.view.Node>}
+// @return {Iterable.<engine.view.Node>}
+function normalize( nodes ) {
+	// Separate condition because string is iterable.
+	if ( typeof nodes == 'string' ) {
+		return [ new Text( nodes ) ];
+	}
+
+	if ( !isIterable( nodes ) ) {
+		nodes = [ nodes ];
+	}
+
+	// Array.from to enable .map() on non-arrays.
+	return Array.from( nodes ).map( ( node ) => typeof node == 'string' ? new Text( node ) : node );
+}

+ 38 - 22
packages/ckeditor5-engine/src/view/textproxy.js

@@ -18,21 +18,15 @@
  */
 export default class TextProxy {
 	/**
-	 * Creates a tree view text proxy.
+	 * Creates a text proxy.
 	 *
-	 * @param {engine.view.Text} textNode Text node which text proxy is a substring.
-	 * @param {Number} startOffset Offset from beginning of {#textNode} and first character of {#data}.
-	 * @param {Number} [length] Length of substring. If is not set then the end offset is at the end of {#textNode}.
+	 * @protected
+	 * @param {engine.view.Text} textNode Text node which part is represented by this text proxy.
+	 * @param {Number} offsetInText Offset in {@link engine.view.TextProxy#textNode text node} from which the text proxy starts.
+	 * @param {Number} length Text proxy length, that is how many text node's characters, starting from `offsetInText` it represents.
+	 * @constructor
 	 */
-	constructor( textNode, startOffset, length ) {
-		/**
-		 * Element that is a parent of this text proxy.
-		 *
-		 * @readonly
-		 * @member {engine.view.Element|engine.view.DocumentFragment|null} engine.view.Node#parent
-		 */
-		this.parent = textNode.parent;
-
+	constructor( textNode, offsetInText, length ) {
 		/**
 		 * Reference to the {@link engine.view.Text} element which TextProxy is a substring.
 		 *
@@ -42,23 +36,45 @@ export default class TextProxy {
 		this.textNode = textNode;
 
 		/**
-		 * Offset in the `textNode` where this `TextProxy` instance starts.
+		 * Text data represented by this text proxy.
 		 *
 		 * @readonly
-		 * @member {Number} engine.view.TextProxy#offsetInText
+		 * @member {String} engine.view.TextProxy#data
 		 */
-		this.offsetInText = startOffset;
+		this.data = textNode.data.substring( offsetInText, offsetInText + length );
 
 		/**
-		 * The text content.
+		 * Offset in the `textNode` where this `TextProxy` instance starts.
 		 *
 		 * @readonly
-		 * @member {String} engine.view.TextProxy#data
+		 * @member {Number} engine.view.TextProxy#offsetInText
 		 */
-		this.data = textNode.data.substring(
-			startOffset,
-			startOffset + ( length || textNode.data.length - startOffset )
-		);
+		this.offsetInText = offsetInText;
+	}
+
+	/**
+	 * Element that is a parent of this text proxy.
+	 *
+	 * @readonly
+	 * @type {engine.view.Element|engine.view.DocumentFragment|null}
+	 */
+	get parent() {
+		return this.textNode.parent;
+	}
+
+	/**
+	 * Flag indicating whether `TextProxy` instance covers only part of the original {@link engine.view.Text text node}
+	 * (`true`) or the whole text node (`false`).
+	 *
+	 * This is `false` when text proxy starts at the very beginning of {@link engine.view.TextProxy#textNode textNode}
+	 * ({@link engine.view.TextProxy#offsetInText offsetInText} equals `0`) and text proxy sizes is equal to
+	 * text node size.
+	 *
+	 * @readonly
+	 * @type {Boolean}
+	 */
+	get isPartial() {
+		return this.offsetInText !== 0 || this.data.length !== this.textNode.data.length;
 	}
 
 	/**

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

@@ -311,7 +311,7 @@ export default class TreeWalker {
 				if ( node == this._boundaryStartParent ) {
 					const offset = this.boundaries.start.offset;
 
-					item = new TextProxy( node, offset );
+					item = new TextProxy( node, offset, node.data.length - offset );
 					charactersCount = item.data.length;
 					position = Position.createBefore( item );
 				} else {

+ 20 - 4
packages/ckeditor5-engine/tests/model/documentfragment.js

@@ -9,6 +9,7 @@ import Element from '/ckeditor5/engine/model/element.js';
 import Text from '/ckeditor5/engine/model/text.js';
 import DocumentFragment from '/ckeditor5/engine/model/documentfragment.js';
 import { jsonParseStringify } from '/tests/engine/model/_utils/utils.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 
 describe( 'DocumentFragment', () => {
 	describe( 'constructor', () => {
@@ -85,13 +86,18 @@ describe( 'DocumentFragment', () => {
 			expect( frag.offsetToIndex( 4 ) ).to.equal( 2 );
 		} );
 
-		it( 'should return 0 if offset is too low', () => {
-			expect( frag.offsetToIndex( -1 ) ).to.equal( 0 );
+		it( 'should throw if given offset is too high or too low', () => {
+			expect( () => {
+				frag.offsetToIndex( -1 );
+			} ).to.throw( CKEditorError, /nodelist-offset-out-of-bounds/ );
+
+			expect( () => {
+				frag.offsetToIndex( 55 );
+			} ).to.throw( CKEditorError, /nodelist-offset-out-of-bounds/ );
 		} );
 
-		it( 'should return document fragment\'s child count if offset is too high', () => {
+		it( 'should return length if given offset is equal to getMaxOffset()', () => {
 			expect( frag.offsetToIndex( 5 ) ).to.equal( 3 );
-			expect( frag.offsetToIndex( 33 ) ).to.equal( 3 );
 		} );
 	} );
 
@@ -105,6 +111,16 @@ describe( 'DocumentFragment', () => {
 			expect( frag.getChild( 0 ) ).to.have.property( 'data' ).that.equals( 'xy' );
 			expect( frag.getChild( 1 ) ).to.have.property( 'data' ).that.equals( 'foo' );
 		} );
+
+		it( 'should accept strings and arrays', () => {
+			let frag = new DocumentFragment();
+			frag.insertChildren( 0, [ new Element( 'p' ), 'abc' ] );
+
+			expect( frag.getChildCount() ).to.equal( 2 );
+			expect( frag.getMaxOffset() ).to.equal( 4 );
+			expect( frag.getChild( 0 ) ).to.have.property( 'name' ).that.equals( 'p' );
+			expect( frag.getChild( 1 ) ).to.have.property( 'data' ).that.equals( 'abc' );
+		} );
 	} );
 
 	describe( 'appendChildren', () => {

+ 20 - 4
packages/ckeditor5-engine/tests/model/element.js

@@ -10,6 +10,7 @@ import Element from '/ckeditor5/engine/model/element.js';
 import Text from '/ckeditor5/engine/model/text.js';
 import { jsonParseStringify } from '/tests/engine/model/_utils/utils.js';
 import count from '/ckeditor5/utils/count.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 
 describe( 'Element', () => {
 	describe( 'constructor', () => {
@@ -92,6 +93,16 @@ describe( 'Element', () => {
 			expect( element.getChild( 1 ).data ).to.equal( 'xy' );
 			expect( element.getChild( 2 ).name ).to.equal( 'list' );
 		} );
+
+		it( 'should accept strings', () => {
+			let element = new Element( 'div' );
+			element.insertChildren( 0, [ new Element( 'p' ), 'abc' ] );
+
+			expect( element.getChildCount() ).to.equal( 2 );
+			expect( element.getMaxOffset() ).to.equal( 4 );
+			expect( element.getChild( 0 ) ).to.have.property( 'name' ).that.equals( 'p' );
+			expect( element.getChild( 1 ) ).to.have.property( 'data' ).that.equals( 'abc' );
+		} );
 	} );
 
 	describe( 'appendChildren', () => {
@@ -198,13 +209,18 @@ describe( 'Element', () => {
 			expect( element.offsetToIndex( 4 ) ).to.equal( 2 );
 		} );
 
-		it( 'should return 0 if offset is too low', () => {
-			expect( element.offsetToIndex( -1 ) ).to.equal( 0 );
+		it( 'should throw if given offset is too high or too low', () => {
+			expect( () => {
+				element.offsetToIndex( -1 );
+			} ).to.throw( CKEditorError, /nodelist-offset-out-of-bounds/ );
+
+			expect( () => {
+				element.offsetToIndex( 55 );
+			} ).to.throw( CKEditorError, /nodelist-offset-out-of-bounds/ );
 		} );
 
-		it( 'should return element\'s child count if offset is too high', () => {
+		it( 'should return length if given offset is equal to getMaxOffset()', () => {
 			expect( element.offsetToIndex( 5 ) ).to.equal( 3 );
-			expect( element.offsetToIndex( 33 ) ).to.equal( 3 );
 		} );
 	} );
 

+ 23 - 13
packages/ckeditor5-engine/tests/model/nodelist.js

@@ -40,9 +40,9 @@ describe( 'NodeList', () => {
 		} );
 	} );
 
-	describe( 'totalOffset', () => {
+	describe( 'getMaxOffset', () => {
 		it( 'should be equal to the sum of offsetSize of all nodes in node list', () => {
-			expect( nodes.totalOffset ).to.equal( 5 );
+			expect( nodes.getMaxOffset() ).to.equal( 5 );
 		} );
 	} );
 
@@ -84,13 +84,18 @@ describe( 'NodeList', () => {
 			expect( nodes.indexToOffset( 2 ) ).to.equal( 4 );
 		} );
 
-		it( 'should return 0 if given index was lower than 0', () => {
-			expect( nodes.indexToOffset( -1 ) ).to.equal( 0 );
+		it( 'should throw if given offset is too high or too low', () => {
+			expect( () => {
+				nodes.indexToOffset( -1 );
+			} ).to.throw( CKEditorError, /nodelist-index-out-of-bounds/ );
+
+			expect( () => {
+				nodes.indexToOffset( 99 );
+			} ).to.throw( CKEditorError, /nodelist-index-out-of-bounds/ );
 		} );
 
-		it( 'should return totalOffset if given index was too high', () => {
+		it( 'should return length if given offset is equal to getMaxOffset()', () => {
 			expect( nodes.indexToOffset( 3 ) ).to.equal( 5 );
-			expect( nodes.indexToOffset( 99 ) ).to.equal( 5 );
 		} );
 	} );
 
@@ -103,13 +108,18 @@ describe( 'NodeList', () => {
 			expect( nodes.offsetToIndex( 4 ) ).to.equal( 2 );
 		} );
 
-		it( 'should return 0 if given offset was lower than 0', () => {
-			expect( nodes.offsetToIndex( -1 ) ).to.equal( 0 );
+		it( 'should throw if given offset is too high or too low', () => {
+			expect( () => {
+				nodes.offsetToIndex( -1 );
+			} ).to.throw( CKEditorError, /nodelist-offset-out-of-bounds/ );
+
+			expect( () => {
+				nodes.offsetToIndex( 55 );
+			} ).to.throw( CKEditorError, /nodelist-offset-out-of-bounds/ );
 		} );
 
-		it( 'should return length if given offset was too high', () => {
+		it( 'should return length if given offset is equal to getMaxOffset()', () => {
 			expect( nodes.offsetToIndex( 5 ) ).to.equal( 3 );
-			expect( nodes.offsetToIndex( 99 ) ).to.equal( 3 );
 		} );
 	} );
 
@@ -123,7 +133,7 @@ describe( 'NodeList', () => {
 			nodes.insertNodes( 4, [ bar, xyz ] );
 
 			expect( nodes.length ).to.equal( 6 );
-			expect( nodes.totalOffset ).to.equal( 12 );
+			expect( nodes.getMaxOffset() ).to.equal( 12 );
 
 			expect( Array.from( nodes ) ).to.deep.equal( [ p, newImg, foo, img, bar, xyz ] );
 
@@ -161,7 +171,7 @@ describe( 'NodeList', () => {
 			nodes.removeNodes( 0, 2 );
 
 			expect( nodes.length ).to.equal( 1 );
-			expect( nodes.totalOffset ).to.equal( 1 );
+			expect( nodes.getMaxOffset() ).to.equal( 1 );
 
 			expect( nodes.getNode( 0 ) ).to.equal( img );
 			expect( nodes.getNodeIndex( img ) ).to.equal( 0 );
@@ -172,7 +182,7 @@ describe( 'NodeList', () => {
 			nodes.removeNodes( 1 );
 
 			expect( nodes.length ).to.equal( 2 );
-			expect( nodes.totalOffset ).to.equal( 2 );
+			expect( nodes.getMaxOffset() ).to.equal( 2 );
 
 			expect( nodes.getNode( 0 ) ).to.equal( p );
 			expect( nodes.getNode( 1 ) ).to.equal( img );

+ 23 - 0
packages/ckeditor5-engine/tests/model/textproxy.js

@@ -72,6 +72,15 @@ describe( 'TextProxy', () => {
 		expect( textProxyNoParent ).to.have.property( 'offsetInText' ).that.equals( 1 );
 	} );
 
+	it( 'should have isPartial property', () => {
+		let startTextProxy = new TextProxy( text, 0, 4 );
+		let fullTextProxy = new TextProxy( text, 0, 6 );
+
+		expect( textProxy.isPartial ).to.be.true;
+		expect( startTextProxy.isPartial ).to.be.true;
+		expect( fullTextProxy.isPartial ).to.be.false;
+	} );
+
 	describe( 'getPath', () => {
 		it( 'should return path to the text proxy', () => {
 			expect( textProxy.getPath() ).to.deep.equal( [ 0, 5 ] );
@@ -79,6 +88,20 @@ describe( 'TextProxy', () => {
 		} );
 	} );
 
+	describe( 'getAncestors', () => {
+		it( 'should return proper array of ancestor nodes', () => {
+			expect( textProxy.getAncestors() ).to.deep.equal( [ root, element ] );
+		} );
+
+		it( 'should include itself if includeNode option is set to true', () => {
+			expect( textProxy.getAncestors( { includeNode: true } ) ).to.deep.equal( [ root, element, textProxy ] );
+		} );
+
+		it( 'should reverse order if parentFirst option is set to true', () => {
+			expect( textProxy.getAncestors( { includeNode: true, parentFirst: true } ) ).to.deep.equal( [ textProxy, element, root ] );
+		} );
+	} );
+
 	describe( 'attributes interface', () => {
 		describe( 'hasAttribute', () => {
 			it( 'should return true if text proxy has attribute with given key', () => {

+ 7 - 0
packages/ckeditor5-engine/tests/view/documentfragment.js

@@ -103,6 +103,13 @@ describe( 'DocumentFragment', () => {
 				expect( count2 ).to.equal( 1 );
 			} );
 
+			it( 'should accept strings', () => {
+				fragment.insertChildren( 0, 'abc' );
+
+				expect( fragment.getChildCount() ).to.equal( 1 );
+				expect( fragment.getChild( 0 ) ).to.have.property( 'data' ).that.equals( 'abc' );
+			} );
+
 			it( 'should append children', () => {
 				const count1 = fragment.insertChildren( 0, el1 );
 				const count2 = fragment.appendChildren( el2 );

+ 7 - 0
packages/ckeditor5-engine/tests/view/element.js

@@ -255,6 +255,13 @@ describe( 'Element', () => {
 				expect( count2 ).to.equal( 1 );
 			} );
 
+			it( 'should accept strings', () => {
+				parent.insertChildren( 0, 'abc' );
+
+				expect( parent.getChildCount() ).to.equal( 1 );
+				expect( parent.getChild( 0 ) ).to.have.property( 'data' ).that.equals( 'abc' );
+			} );
+
 			it( 'should append children', () => {
 				const count1 = parent.insertChildren( 0, el1 );
 				const count2 = parent.appendChildren( el2 );

+ 6 - 4
packages/ckeditor5-engine/tests/view/textproxy.js

@@ -32,11 +32,13 @@ describe( 'TextProxy', () => {
 			expect( textProxy ).to.have.property( 'offsetInText' ).to.equal( 2 );
 		} );
 
-		it( 'should get text from specified offset to the end of textNode if length is not defined', () => {
-			textProxy = new TextProxy( text, 2 );
+		it( 'should have isPartial property', () => {
+			let startTextProxy = new TextProxy( text, 0, 4 );
+			let fullTextProxy = new TextProxy( text, 0, 8 );
 
-			expect( textProxy ).to.have.property( 'data' ).to.equal( 'cdefgh' );
-			expect( textProxy ).to.have.property( 'offsetInText' ).to.equal( 2 );
+			expect( textProxy.isPartial ).to.be.true;
+			expect( startTextProxy.isPartial ).to.be.true;
+			expect( fullTextProxy.isPartial ).to.be.false;
 		} );
 	} );