瀏覽代碼

Merge branch 'master' into t/439

Szymon Kupś 9 年之前
父節點
當前提交
9fa24abd01
共有 100 個文件被更改,包括 3447 次插入3147 次删除
  1. 0 0
      packages/ckeditor5-engine/src/conversion/conversion.jsdoc
  2. 10 10
      packages/ckeditor5-engine/src/conversion/mapper.js
  3. 7 7
      packages/ckeditor5-engine/src/conversion/model-converter-builder.js
  4. 14 13
      packages/ckeditor5-engine/src/conversion/model-selection-to-view-converters.js
  5. 2 2
      packages/ckeditor5-engine/src/conversion/model-to-view-converters.js
  6. 18 24
      packages/ckeditor5-engine/src/conversion/modelconsumable.js
  7. 11 11
      packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js
  8. 9 6
      packages/ckeditor5-engine/src/conversion/view-converter-builder.js
  9. 9 10
      packages/ckeditor5-engine/src/conversion/view-selection-to-model-converters.js
  10. 6 5
      packages/ckeditor5-engine/src/conversion/view-to-model-converters.js
  11. 1 20
      packages/ckeditor5-engine/src/editingcontroller.js
  12. 7 8
      packages/ckeditor5-engine/src/model/batch.js
  13. 0 132
      packages/ckeditor5-engine/src/model/characterproxy.js
  14. 2 2
      packages/ckeditor5-engine/src/model/composer/modifyselection.js
  15. 33 27
      packages/ckeditor5-engine/src/model/delta/attributedelta.js
  16. 22 16
      packages/ckeditor5-engine/src/model/delta/basic-transformations.js
  17. 0 1
      packages/ckeditor5-engine/src/model/delta/delta.js
  18. 11 2
      packages/ckeditor5-engine/src/model/delta/insertdelta.js
  19. 12 6
      packages/ckeditor5-engine/src/model/delta/mergedelta.js
  20. 16 9
      packages/ckeditor5-engine/src/model/delta/movedelta.js
  21. 6 6
      packages/ckeditor5-engine/src/model/delta/removedelta.js
  22. 17 3
      packages/ckeditor5-engine/src/model/delta/renamedelta.js
  23. 20 10
      packages/ckeditor5-engine/src/model/delta/splitdelta.js
  24. 1 1
      packages/ckeditor5-engine/src/model/delta/transform.js
  25. 8 2
      packages/ckeditor5-engine/src/model/delta/unwrapdelta.js
  26. 10 10
      packages/ckeditor5-engine/src/model/delta/weakinsertdelta.js
  27. 12 3
      packages/ckeditor5-engine/src/model/delta/wrapdelta.js
  28. 6 5
      packages/ckeditor5-engine/src/model/document.js
  29. 145 42
      packages/ckeditor5-engine/src/model/documentfragment.js
  30. 128 112
      packages/ckeditor5-engine/src/model/element.js
  31. 3 3
      packages/ckeditor5-engine/src/model/history.js
  32. 17 14
      packages/ckeditor5-engine/src/model/liveposition.js
  33. 13 14
      packages/ckeditor5-engine/src/model/liverange.js
  34. 20 17
      packages/ckeditor5-engine/src/model/liveselection.js
  35. 199 48
      packages/ckeditor5-engine/src/model/node.js
  36. 106 398
      packages/ckeditor5-engine/src/model/nodelist.js
  37. 30 22
      packages/ckeditor5-engine/src/model/operation/attributeoperation.js
  38. 40 13
      packages/ckeditor5-engine/src/model/operation/insertoperation.js
  39. 23 23
      packages/ckeditor5-engine/src/model/operation/moveoperation.js
  40. 7 3
      packages/ckeditor5-engine/src/model/operation/nooperation.js
  41. 1 1
      packages/ckeditor5-engine/src/model/operation/operationfactory.js
  42. 11 9
      packages/ckeditor5-engine/src/model/operation/reinsertoperation.js
  43. 14 8
      packages/ckeditor5-engine/src/model/operation/removeoperation.js
  44. 17 17
      packages/ckeditor5-engine/src/model/operation/transform.js
  45. 289 243
      packages/ckeditor5-engine/src/model/position.js
  46. 109 98
      packages/ckeditor5-engine/src/model/range.js
  47. 22 11
      packages/ckeditor5-engine/src/model/rootelement.js
  48. 246 139
      packages/ckeditor5-engine/src/model/selection.js
  49. 35 82
      packages/ckeditor5-engine/src/model/text.js
  50. 106 89
      packages/ckeditor5-engine/src/model/textproxy.js
  51. 33 31
      packages/ckeditor5-engine/src/model/treewalker.js
  52. 316 0
      packages/ckeditor5-engine/src/model/writer.js
  53. 17 1
      packages/ckeditor5-engine/src/view/document.js
  54. 1 1
      packages/ckeditor5-engine/src/view/documentfragment.js
  55. 3 3
      packages/ckeditor5-engine/src/view/observer/focusobserver.js
  56. 2 2
      packages/ckeditor5-engine/src/view/observer/keyobserver.js
  57. 2 2
      packages/ckeditor5-engine/src/view/observer/mutationobserver.js
  58. 2 2
      packages/ckeditor5-engine/src/view/observer/selectionobserver.js
  59. 27 13
      packages/ckeditor5-engine/tests/_utils-tests/model.js
  60. 9 8
      packages/ckeditor5-engine/tests/_utils/model.js
  61. 17 20
      packages/ckeditor5-engine/tests/conversion/advanced-converters.js
  62. 6 6
      packages/ckeditor5-engine/tests/conversion/mapper.js
  63. 13 20
      packages/ckeditor5-engine/tests/conversion/model-converter-builder.js
  64. 38 30
      packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js
  65. 14 13
      packages/ckeditor5-engine/tests/conversion/modelconsumable.js
  66. 12 10
      packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js
  67. 1 1
      packages/ckeditor5-engine/tests/conversion/view-converter-builder.js
  68. 7 14
      packages/ckeditor5-engine/tests/conversion/view-to-model-converters.js
  69. 0 11
      packages/ckeditor5-engine/tests/editingcontroller.js
  70. 44 1
      packages/ckeditor5-engine/tests/model/_utils/utils.js
  71. 0 133
      packages/ckeditor5-engine/tests/model/characterproxy.js
  72. 8 10
      packages/ckeditor5-engine/tests/model/delta/attributedelta.js
  73. 11 9
      packages/ckeditor5-engine/tests/model/delta/insertdelta.js
  74. 9 11
      packages/ckeditor5-engine/tests/model/delta/mergedelta.js
  75. 9 8
      packages/ckeditor5-engine/tests/model/delta/movedelta.js
  76. 9 7
      packages/ckeditor5-engine/tests/model/delta/removedelta.js
  77. 13 6
      packages/ckeditor5-engine/tests/model/delta/renamedelta.js
  78. 18 24
      packages/ckeditor5-engine/tests/model/delta/splitdelta.js
  79. 6 5
      packages/ckeditor5-engine/tests/model/delta/transform/_utils/utils.js
  80. 3 2
      packages/ckeditor5-engine/tests/model/delta/transform/weakinsertdelta.js
  81. 5 8
      packages/ckeditor5-engine/tests/model/delta/unwrapdelta.js
  82. 7 8
      packages/ckeditor5-engine/tests/model/delta/weakinsertdelta.js
  83. 18 23
      packages/ckeditor5-engine/tests/model/delta/wrapdelta.js
  84. 4 3
      packages/ckeditor5-engine/tests/model/document/change-event.js
  85. 3 3
      packages/ckeditor5-engine/tests/model/document/document.js
  86. 172 71
      packages/ckeditor5-engine/tests/model/documentfragment.js
  87. 140 171
      packages/ckeditor5-engine/tests/model/element.js
  88. 4 3
      packages/ckeditor5-engine/tests/model/liveposition.js
  89. 11 10
      packages/ckeditor5-engine/tests/model/liverange.js
  90. 13 10
      packages/ckeditor5-engine/tests/model/liveselection.js
  91. 176 48
      packages/ckeditor5-engine/tests/model/node.js
  92. 123 406
      packages/ckeditor5-engine/tests/model/nodelist.js
  93. 33 43
      packages/ckeditor5-engine/tests/model/operation/attributeoperation.js
  94. 62 33
      packages/ckeditor5-engine/tests/model/operation/insertoperation.js
  95. 28 35
      packages/ckeditor5-engine/tests/model/operation/moveoperation.js
  96. 5 4
      packages/ckeditor5-engine/tests/model/operation/reinsertoperation.js
  97. 17 20
      packages/ckeditor5-engine/tests/model/operation/removeoperation.js
  98. 86 42
      packages/ckeditor5-engine/tests/model/position.js
  99. 39 45
      packages/ckeditor5-engine/tests/model/range.js
  100. 0 0
      packages/ckeditor5-engine/tests/model/rootelement.js

packages/ckeditor5-engine/src/conversion/treecontroller.jsdoc → packages/ckeditor5-engine/src/conversion/conversion.jsdoc


+ 10 - 10
packages/ckeditor5-engine/src/conversion/mapper.js

@@ -12,8 +12,8 @@ import ViewText from '../view/text.js';
 /**
  * Maps elements and positions between {@link engine.view.Document view} and {@link engine.model model}.
  *
- * Mapper use binded elements to find corresponding elements and positions, so, to get proper results,
- * all Tree Model elements should be {@link engine.conversion.Mapper#bindElements binded}.
+ * Mapper use bound elements to find corresponding elements and positions, so, to get proper results,
+ * all model elements should be {@link engine.conversion.Mapper#bindElements bound}.
  *
  * @memberOf engine.conversion
  */
@@ -23,7 +23,7 @@ export default class Mapper {
 	 */
 	constructor() {
 		/**
-		 * Model element to View element mapping.
+		 * Model element to view element mapping.
 		 *
 		 * @private
 		 * @member {WeakMap} engine.conversion.Mapper#_modelToViewMapping
@@ -31,7 +31,7 @@ export default class Mapper {
 		this._modelToViewMapping = new WeakMap();
 
 		/**
-		 * View element to Model element mapping.
+		 * View element to model element mapping.
 		 *
 		 * @private
 		 * @member {WeakMap} engine.conversion.Mapper#_viewToModelMapping
@@ -65,7 +65,7 @@ export default class Mapper {
 	 * Gets the corresponding model element.
 	 *
 	 * @param {engine.view.Element} viewElement View element.
-	 * @returns {engine.model.Element|null} Corresponding model element or `null` if not found.
+	 * @returns {engine.model.Element|undefined} Corresponding model element or `undefined` if not found.
 	 */
 	toModelElement( viewElement ) {
 		return this._viewToModelMapping.get( viewElement );
@@ -75,7 +75,7 @@ export default class Mapper {
 	 * Gets the corresponding view element.
 	 *
 	 * @param {engine.model.Element} modelElement Model element.
-	 * @returns {engine.view.Element|null} Corresponding view element or `null` if not found.
+	 * @returns {engine.view.Element|undefined} Corresponding view element or `undefined` if not found.
 	 */
 	toViewElement( modelElement ) {
 		return this._modelToViewMapping.get( modelElement );
@@ -154,10 +154,10 @@ export default class Mapper {
 	_toModelOffset( viewParent, viewOffset, viewBlock ) {
 		if ( viewBlock != viewParent ) {
 			// See example.
-			const offsetToParentBegging = this._toModelOffset( viewParent.parent, viewParent.getIndex(), viewBlock );
+			const offsetToParentStart = this._toModelOffset( viewParent.parent, viewParent.getIndex(), viewBlock );
 			const offsetInParent = this._toModelOffset( viewParent, viewOffset, viewParent );
 
-			return offsetToParentBegging + offsetInParent;
+			return offsetToParentStart + offsetInParent;
 		}
 
 		// viewBlock == viewParent, so we need to calculate the offset in the parent element.
@@ -183,8 +183,8 @@ export default class Mapper {
 	 * Examples:
 	 *
 	 *		foo          -> 3 // Length of the text is the length of data.
-	 *		<b>foo</b>   -> 3 // Length the element which has no corresponding model element is a length of its children.
-	 *		<p>foo</p>   -> 1 // Length the element which has corresponding model element is always 1.
+	 *		<b>foo</b>   -> 3 // Length of the element which has no corresponding model element is a length of its children.
+	 *		<p>foo</p>   -> 1 // Length of the element which has corresponding model element is always 1.
 	 *
 	 * @private
 	 * @param {engine.view.Element} viewNode View node.

+ 7 - 7
packages/ckeditor5-engine/src/conversion/model-converter-builder.js

@@ -40,9 +40,9 @@ import ViewContainerElement from '../view/containerelement.js';
  *		BuildModelConverterFor( dispatcher ).fromAttribute( 'src' ).toAttribute();
  *
  * 3. Model attribute to view element converter. This is a converter that takes model attributes and represents them
- * as view elements. Those view elements are wrapping view elements are node that correspond to model elements and
- * nodes which had converter attribute. It is suitable for attributes like `bold`, where `bold` attribute set on model
- * text nodes is converter to `strong` view element.
+ * as view elements. Elements created by this kind of converter are wrapping other view elements. Wrapped view nodes
+ * correspond to model nodes had converter attribute. It is suitable for attributes like `bold`, where `bold` attribute
+ * set on model text nodes is converter to `strong` view element.
  *
  *		BuildModelConverterFor( dispatcher ).fromAttribute( 'bold' ).toElement( 'strong' );
  *
@@ -50,8 +50,8 @@ import ViewContainerElement from '../view/containerelement.js';
  * and {@link engine.conversion.ModelConverterBuilder#toAttribute} methods. See their descriptions to learn more.
  *
  * It is also possible to {@link engine.conversion.ModelConverterBuilder#withPriority change default priority}
- * of created converters to decide which converter should be fired earlier and which later. This is useful if you provide
- * a general converter but want to provide different converter for a specific-case (i.e. given model element is converted
+ * of created converters to decide which converter should be fired earlier and which later. This is useful if you have
+ * a general converter but also want to provide different special-case converters (i.e. given model element is converted
  * always to given view element, but if it has given attribute it is converter to other view element). For this,
  * use {@link engine.conversion.ModelConverterBuilder#withPriority withPriority} right after `from...` method.
  *
@@ -125,8 +125,8 @@ class ModelConverterBuilder {
 	 * Default priority is `10`.
 	 *
 	 * **Note:** Keep in mind that event priority, that is set by this modifier, is used for attribute priority
-	 * when {@link engine.view.writer} is used. This changes how model attributes converter to view elements are
-	 * ordered, i.e.: `<strong><em>foo</em></strong>` vs `<em><strong>foo</strong></em>`. Using priority you can also
+	 * when {@link engine.view.writer} is used. This changes how view elements are ordered,
+	 * i.e.: `<strong><em>foo</em></strong>` vs `<em><strong>foo</strong></em>`. Using priority you can also
 	 * prevent node merging, i.e.: `<span class="bold"><span class="theme">foo</span><span>` vs `<span class="bold theme">foo</span>`.
 	 * If you want to prevent merging, just set different priority for both converters.
 	 *

+ 14 - 13
packages/ckeditor5-engine/src/conversion/model-selection-to-view-converters.js

@@ -56,15 +56,15 @@ export function convertRangeSelection() {
  *
  * Example of view state before and after converting collapsed selection:
  *
- *		<p><strong>f^oo<strong>bar</p>
+ *		   <p><strong>f^oo<strong>bar</p>
  *		-> <p><strong>f</strong>^<strong>oo</strong>bar</p>
  *
- * By breaking attribute elements like `<strong>` selection is in correct elements. See also complementary
+ * By breaking attribute elements like `<strong>`, selection is in correct element. See also complementary
  * {@link engine.conversion.modelSelectionToView.convertSelectionAttribute attribute converter} for selection attributes,
  * which wraps collapsed selection into view elements. Those converters together ensure, that selection ends up in
- * appropriate elements.
+ * appropriate attribute elements.
  *
- * See also {@link engine.conversion.modelSelectionToView.clearAttributes} which do the clean-up by merging attributes.
+ * See also {@link engine.conversion.modelSelectionToView.clearAttributes} which does a clean-up by merging attributes.
  *
  * @external engine.conversion.modelSelectionToView
  * @function engine.conversion.modelSelectionToView.convertCollapsedSelection
@@ -116,20 +116,21 @@ export function convertCollapsedSelection() {
  * **Note:** You can use the same `elementCreator` function for this converter factory and {@link engine.conversion.modelToView.wrap}
  * model to view converter, as long as the `elementCreator` function uses only the first parameter (attribute value).
  *
- * Example of view state after converting collapsed selection. The scenario is: selection is inside bold text (`<strong>` element)
- * but it does not have bold attribute itself, but has italic attribute instead (let's assume that user turned off bold and turned
- * on italic with selection collapsed):
- *
  *		modelDispatcher.on( 'selection', convertCollapsedSelection() );
  *		modelDispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
+ *		modelDispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'strong' ) ) );
  *
  * Example of view states before and after converting collapsed selection:
  *
- *		<p><em>f^oo</em>bar</p>
+ *		   <p><em>f^oo</em>bar</p>
  *		-> <p><em>f</em>^<em>oo</em>bar</p>
  *		-> <p><em>f^oo</em>bar</p>
  *
- *		<p><strong>f^oo<strong>bar</p>
+ * Example of view state after converting collapsed selection. The scenario is: selection is inside bold text (`<strong>` element)
+ * but it does not have bold attribute itself and has italic attribute instead (let's assume that user turned off bold and turned
+ * on italic with selection collapsed):
+ *
+ *		   <p><strong>f^oo<strong>bar</p>
  *		-> <p><strong>f</strong>^<strong>oo<strong>bar</p>
  *		-> <p><strong>f</strong><em>^</em><strong>oo</strong>bar</p>
  *
@@ -174,13 +175,13 @@ export function convertSelectionAttribute( elementCreator ) {
  * {@link engine.view.AttributeElement view attribute elements} and merge sibling attributes at all start and end
  * positions of all ranges.
  *
- *		<p><strong>^</strong></p>
+ *		   <p><strong>^</strong></p>
  *		-> <p>^</p>
  *
- *		<p><strong>foo</strong>^<strong>bar</strong>bar</p>
+ *		   <p><strong>foo</strong>^<strong>bar</strong>bar</p>
  *		-> <p><strong>foo^bar<strong>bar</p>
  *
- *		<p><strong>foo</strong><em>^</em><strong>bar</strong>bar</p>
+ *		   <p><strong>foo</strong><em>^</em><strong>bar</strong>bar</p>
  *		-> <p><strong>foo^bar<strong>bar</p>
  *
  * This listener should be assigned before any converter for the new selection:

+ 2 - 2
packages/ckeditor5-engine/src/conversion/model-to-view-converters.js

@@ -79,7 +79,7 @@ export function insertText() {
 		consumable.consume( data.item, 'insert' );
 
 		const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
-		const viewText = new ViewText( data.item.text );
+		const viewText = new ViewText( data.item.data );
 
 		viewWriter.insert( viewPosition, viewText );
 
@@ -183,7 +183,7 @@ export function removeAttribute( attributeCreator ) {
 /**
  * Function factory, creates a converter that converts set/change attribute changes from the model to the view. In this case,
  * model attributes are converted to a view element that will be wrapping view nodes which corresponding model nodes had
- * the attribute set. This is useful for attributes like `bold`, which may be set on a text nodes in model but are
+ * the attribute set. This is useful for attributes like `bold`, which may be set on text nodes in model but are
  * represented as an element in the view:
  *
  *		[paragraph]              MODEL ====> VIEW        <p>

+ 18 - 24
packages/ckeditor5-engine/src/conversion/modelconsumable.js

@@ -39,9 +39,7 @@ import TextProxy from '../model/textproxy.js';
  *		//
  *		// [image]
  *		//   └─ [caption]
- *		//       ├─ f
- *		//       ├─ o
- *		//       └─ o
+ *		//       └─ foo
  *		//
  *		// View:
  *		//
@@ -241,22 +239,18 @@ export default class ModelConsumable {
 	_getSymbolForTextProxy( textProxy ) {
 		let symbol = null;
 
-		const startIndex = textProxy.first.getIndex();
-		const endIndex = startIndex + textProxy.text.length;
-		const parent = textProxy.commonParent;
-
-		const startIndexMap = this._textProxyRegistry.get( startIndex );
+		const startMap = this._textProxyRegistry.get( textProxy.startOffset );
 
-		if ( startIndexMap ) {
-			const endIndexMap = startIndexMap.get( endIndex );
+		if ( startMap ) {
+			const endMap = startMap.get( textProxy.endOffset );
 
-			if ( endIndexMap ) {
-				symbol = endIndexMap.get( parent );
+			if ( endMap ) {
+				symbol = endMap.get( textProxy.parent );
 			}
 		}
 
 		if ( !symbol ) {
-			symbol = this._addSymbolForTextProxy( startIndex, endIndex, parent );
+			symbol = this._addSymbolForTextProxy( textProxy.startOffset, textProxy.endOffset, textProxy.parent );
 		}
 
 		return symbol;
@@ -273,25 +267,25 @@ export default class ModelConsumable {
 	 * @param {engine.model.Element} parent Text proxy parent.
 	 * @returns {Symbol} Symbol generated for given properties.
 	 */
-	_addSymbolForTextProxy( startIndex, endIndex, parent ) {
+	_addSymbolForTextProxy( start, end, parent ) {
 		const symbol = Symbol();
-		let startIndexMap, endIndexMap;
+		let startMap, endMap;
 
-		startIndexMap = this._textProxyRegistry.get( startIndex );
+		startMap = this._textProxyRegistry.get( start );
 
-		if ( !startIndexMap ) {
-			startIndexMap = new Map();
-			this._textProxyRegistry.set( startIndex, startIndexMap );
+		if ( !startMap ) {
+			startMap = new Map();
+			this._textProxyRegistry.set( start, startMap );
 		}
 
-		endIndexMap = startIndexMap.get( endIndex );
+		endMap = startMap.get( end );
 
-		if ( !endIndexMap ) {
-			endIndexMap = new Map();
-			startIndexMap.set( endIndex, endIndexMap );
+		if ( !endMap ) {
+			endMap = new Map();
+			startMap.set( end, endMap );
 		}
 
-		endIndexMap.set( parent, symbol );
+		endMap.set( parent, symbol );
 
 		return symbol;
 	}

+ 11 - 11
packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js

@@ -177,12 +177,12 @@ export default class ModelConversionDispatcher {
 			// Fire a separate addAttribute event for each attribute that was set on inserted items.
 			// This is important because most attributes converters will listen only to add/change/removeAttribute events.
 			// If we would not add this part, attributes on inserted nodes would not be converted.
-			for ( let attr of item.getAttributes() ) {
-				data.attributeKey = attr[ 0 ];
+			for ( let key of item.getAttributeKeys() ) {
+				data.attributeKey = key;
 				data.attributeOldValue = null;
-				data.attributeNewValue = attr[ 1 ];
+				data.attributeNewValue = item.getAttribute( key );
 
-				this._testAndFire( 'addAttribute:' + attr[ 0 ], data, consumable );
+				this._testAndFire( 'addAttribute:' + key, data, consumable );
 			}
 		}
 	}
@@ -266,9 +266,9 @@ export default class ModelConversionDispatcher {
 
 		this.fire( 'selection', data, consumable, this.conversionApi );
 
-		for ( let attr of selection.getAttributes() ) {
-			data.key = attr[ 0 ];
-			data.value = attr[ 1 ];
+		for ( let key of selection.getAttributeKeys() ) {
+			data.key = key;
+			data.value = selection.getAttribute( key );
 
 			// Do not fire event if the attribute has been consumed.
 			if ( consumable.test( selection, 'selectionAttribute:' + data.key ) ) {
@@ -293,8 +293,8 @@ export default class ModelConversionDispatcher {
 
 			consumable.add( item, 'insert' );
 
-			for ( let attr of item.getAttributes() ) {
-				consumable.add( item, 'addAttribute:' + attr[ 0 ] );
+			for ( let key of item.getAttributeKeys() ) {
+				consumable.add( item, 'addAttribute:' + key );
 			}
 		}
 
@@ -335,8 +335,8 @@ export default class ModelConversionDispatcher {
 
 		consumable.add( selection, 'selection' );
 
-		for ( let attr of selection.getAttributes() ) {
-			consumable.add( selection, 'selectionAttribute:' + attr[ 0 ] );
+		for ( let key of selection.getAttributeKeys() ) {
+			consumable.add( selection, 'selectionAttribute:' + key );
 		}
 
 		return consumable;

+ 9 - 6
packages/ckeditor5-engine/src/conversion/view-converter-builder.js

@@ -5,6 +5,8 @@
 
 import Matcher from '../view/matcher.js';
 import ModelElement from '../model/element.js';
+import ModelPosition from '../model/position.js';
+import modelWriter from '../model/writer.js';
 import isIterable from '../../utils/isiterable.js';
 
 /**
@@ -253,8 +255,7 @@ class ViewConverterBuilder {
 					const modelElement = element instanceof Function ? element( data.input ) : new ModelElement( element );
 
 					// Check whether generated structure is okay with `Schema`.
-					// TODO: Make it more sane after .getAttributeKeys() is available for ModelElement.
-					const keys = Array.from( modelElement.getAttributes() ).map( ( attribute ) => attribute[ 0 ] );
+					const keys = Array.from( modelElement.getAttributeKeys() );
 
 					if ( !conversionApi.schema.check( { name: modelElement.name, attributes: keys, inside: data.context } ) ) {
 						continue;
@@ -270,7 +271,9 @@ class ViewConverterBuilder {
 					data.context.push( modelElement );
 
 					// Convert children of converted view element and append them to `modelElement`.
-					modelElement.appendChildren( conversionApi.convertChildren( data.input, consumable, data ) );
+					const modelChildren = conversionApi.convertChildren( data.input, consumable, data );
+					const insertPosition = ModelPosition.createAt( modelElement, 'end' );
+					modelWriter.insert( insertPosition, modelChildren );
 
 					// Remove created `modelElement` from the parents stack.
 					data.context.pop();
@@ -368,7 +371,7 @@ class ViewConverterBuilder {
 	}
 }
 
-// Helper function that sets given attributes on given `engine.model.Item` or `engine.model.DocumentFragment`.
+// Helper function that sets given attributes on given `engine.model.Node` or `engine.model.DocumentFragment`.
 function setAttributeOn( toChange, attribute, data, conversionApi ) {
 	if ( isIterable( toChange ) ) {
 		for ( let node of toChange ) {
@@ -378,8 +381,8 @@ function setAttributeOn( toChange, attribute, data, conversionApi ) {
 		return;
 	}
 
-	// TODO: Make it more sane after .getAttributeKeys() is available for ModelElement.
-	const keys = Array.from( toChange.getAttributes() ).map( ( attribute ) => attribute[ 0 ] ).concat( attribute.key );
+	const keys = Array.from( toChange.getAttributeKeys() );
+	keys.push( attribute.key );
 
 	const schemaQuery = {
 		name: toChange.name || '$text',

+ 9 - 10
packages/ckeditor5-engine/src/conversion/view-selection-to-model-converters.js

@@ -12,29 +12,28 @@
 
 /**
  * Function factory, creates a callback function which converts a {@link engine.view.Selection view selection} taken
- * from the {@link engine.view.Document#selectionChange} event and set in on the
- * {@link engine.model.Document#selection model}.
+ * from the {@link engine.view.Document#selectionChange} event and sets in on the {@link engine.model.Document#selection model}.
  *
- * Note that because there is not view selection change dispatcher nor any other advance view selection to model
- * conversion mechanism, this method is simple event listener.
+ * **Note**: because there is no view selection change dispatcher nor any other advanced view selection to model
+ * conversion mechanism, the callback should be set directly on view document.
  *
- *		view.document.on( 'selectionChange', convertSelectionChange( model, mapper ) );
+ *		view.document.on( 'selectionChange', convertSelectionChange( modelDocument, mapper ) );
  *
  * @function engine.conversion.viewSelectionToModel.convertSelectionChange
- * @param {engine.model.Document} model Document model on which selection should be updated.
+ * @param {engine.model.Document} modelDocument Model document on which selection should be updated.
  * @param {engine.conversion.Mapper} mapper Conversion mapper.
  * @returns {Function} {@link engine.view.Document#selectionChange} callback function.
  */
-export function convertSelectionChange( model, mapper ) {
+export function convertSelectionChange( modelDocument, mapper ) {
 	return ( evt, data ) => {
-		model.enqueueChanges( () => {
+		modelDocument.enqueueChanges( () => {
 			const viewSelection = data.newSelection;
 
-			model.selection.removeAllRanges();
+			modelDocument.selection.removeAllRanges();
 
 			for ( let viewRange of viewSelection.getRanges() ) {
 				const modelRange = mapper.toModelRange( viewRange );
-				model.selection.addRange( modelRange, viewSelection.isBackward );
+				modelDocument.selection.addRange( modelRange, viewSelection.isBackward );
 			}
 		} );
 	};

+ 6 - 5
packages/ckeditor5-engine/src/conversion/view-to-model-converters.js

@@ -5,6 +5,7 @@
 
 import ModelDocumentFragment from '../model/documentfragment.js';
 import ModelText from '../model/text.js';
+import { normalizeNodes } from '../model/writer.js';
 
 /**
  * Contains {@link engine.view view} to {@link engine.model model} converters for
@@ -16,12 +17,12 @@ import ModelText from '../model/text.js';
 /**
  * Function factory, creates a converter that converts {@link engine.view.DocumentFragment view document fragment} or
  * all children of {@link engine.view.Element} into {@link engine.model.DocumentFragment model document fragment}.
- * This is the "entry-point" converter for view to model conversion. This converter starts the conversion of all "children"
- * of passed view document fragment. Those "children" {@link engine.view.Node view nodes} are then handled by other converters.
+ * This is the "entry-point" converter for view to model conversion. This converter starts the conversion of all children
+ * of passed view document fragment. Those children {@link engine.view.Node view nodes} are then handled by other converters.
  *
  * This also a "default", last resort converter for all view elements that has not been converted by other converters.
- * When a view element is converted to the model and it does not have it's converter specified, all of that elements
- * children will be converted to {@link engine.model.DocumentFragment} and returned.
+ * When a view element is being converted to the model but it does not have converter specified, that view element
+ * will be converted to {@link engine.model.DocumentFragment model document fragment} and returned.
  *
  * @external engine.conversion.viewToModel
  * @function engine.conversion.viewToModel.convertToModelFragment
@@ -35,7 +36,7 @@ export function convertToModelFragment() {
 		if ( !data.output && consumable.test( data.input, { name: true } ) ) {
 			const convertedChildren = conversionApi.convertChildren( data.input, consumable, data );
 
-			data.output = new ModelDocumentFragment( convertedChildren );
+			data.output = new ModelDocumentFragment( normalizeNodes( convertedChildren ) );
 		}
 	};
 }

+ 1 - 20
packages/ckeditor5-engine/src/editingcontroller.js

@@ -4,11 +4,6 @@
  */
 
 import ViewDocument from './view/document.js';
-import MutationObserver from './view/observer/mutationobserver.js';
-import SelectionObserver from './view/observer/selectionobserver.js';
-import FocusObserver from './view/observer/focusobserver.js';
-import KeyObserver from './view/observer/keyobserver.js';
-
 import Mapper from './conversion/mapper.js';
 import ModelConversionDispatcher from './conversion/modelconversiondispatcher.js';
 import { insertText, remove, move } from './conversion/model-to-view-converters.js';
@@ -24,15 +19,7 @@ import EmitterMixin from '../utils/emittermixin.js';
 /**
  * Controller for the editing pipeline. The editing pipeline controls {@link engine.EditingController#model model} rendering,
  * including selection handling. It also creates {@link engine.EditingController#view view document} which build a
- * browser-independent virtualization over the DOM elements. Editing controller also attach default converters and
- * observers.
- *
- * Note that the following observers are attached by the controller and are always available:
- *
- * * {@link view.observer.MutationObserver},
- * * {@link view.observer.SelectionObserver},
- * * {@link view.observer.FocusObserver},
- * * {@link view.observer.KeyObserver}.
+ * browser-independent virtualization over the DOM elements. Editing controller also attach default converters.
  *
  * @memberOf engine
  */
@@ -59,12 +46,6 @@ export default class EditingController {
 		 */
 		this.view = new ViewDocument();
 
-		// Attach default observers.
-		this.view.addObserver( MutationObserver );
-		this.view.addObserver( SelectionObserver );
-		this.view.addObserver( FocusObserver );
-		this.view.addObserver( KeyObserver );
-
 		/**
 		 * Mapper which describes model-view binding.
 		 *

+ 7 - 8
packages/ckeditor5-engine/src/model/batch.js

@@ -6,10 +6,9 @@
 import CKEditorError from '../../utils/ckeditorerror.js';
 
 /**
- * The Batch class groups document changes (deltas). All deltas grouped in a single Batch can be
- * reverted together, so you can think about the Batch as a single undo step. If you want to extend one
- * undo step you can call another method on the same Batch object. If you want to create a separate undo step
- * you can create a new Batch.
+ * `Batch` instance groups document changes ({@link engine.model.Delta deltas}). All deltas grouped in a single `Batch` can be
+ * reverted together, so you can think about `Batch` as of a single undo step. If you want to extend given undo step you
+ * can call another method on the same `Batch` object. If you want to create a separate undo step you can create a new `Batch`.
  *
  * For example to create two separate undo steps you can call:
  *
@@ -30,7 +29,7 @@ import CKEditorError from '../../utils/ckeditorerror.js';
  */
 export default class Batch {
 	/**
-	 * Creates Batch instance. Not recommended to use directly, use {@link engine.model.Document#batch} instead.
+	 * Creates `Batch` instance. Not recommended to use directly, use {@link engine.model.Document#batch} instead.
 	 *
 	 * @param {engine.model.Document} document Document which this Batch changes.
 	 * @param {'transparent'|'default'} [type='default'] Type of the batch.
@@ -103,7 +102,7 @@ export default class Batch {
 }
 
 /**
- * Function to register batch methods. To make code scalable Batch do not have modification
+ * Function to register batch methods. To make code scalable `Batch` do not have modification
  * methods built in. They can be registered using this method.
  *
  * This method checks if there is no naming collision and throws `batch-register-taken` if the method name
@@ -114,10 +113,10 @@ export default class Batch {
  * For example:
  *
  *		Batch.register( 'insert', function( position, nodes ) {
- *			// You can use a class inherit from Delta if that class should handle OT in the special way.
+ *			// You can use a class inheriting from `Delta` if that class should handle OT in a special way.
  *			const delta = new Delta();
  *
- *			// Add delta to the Batch instance. It is important to add delta to batch before applying any operation.
+ *			// Add delta to the Batch instance. It is important to add a delta to the batch before applying any operation.
  *			this.addDelta( delta );
  *
  *			// Create operations which should be components of this delta.

+ 0 - 132
packages/ckeditor5-engine/src/model/characterproxy.js

@@ -1,132 +0,0 @@
-/**
- * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import Node from './node.js';
-import toMap from '../../utils/tomap.js';
-
-/**
- * A proxy object representing one character stored in the tree data model. It looks and behaves like
- * normal node, but is a read-only structure. This is a representation of the data. Manipulating it won't affect
- * the actual nodes in tree model.
- *
- * Keep in mind that CharacterProxy is static, meaning that it won't change when tree model changes. For example, if you
- * have a {engine.model.Element element} `myEl` containing text `foobar` and then assign `let b = myEl.getChild( 3 )`
- * and then remove all nodes from `myEl`, `b` will still have character `b`, parent `myEl` and offset `3`.
- *
- * CharacterProxy is created on the fly basing on tree model. It is not an explicit node in a tree model but
- * rather represents it. Because of this, it is not advised to store or compare instances of CharacterProxy class.
- * If you want to keep live reference to a point in a text, you should use {@link engine.model.LivePosition}.
- *
- * You should never create an instance of this class by your own. When passing parameters to constructors,
- * use string literals or {@link engine.model.Text} instead.
- *
- * @memberOf engine.model
- * @extends engine.model.Node
- */
-export default class CharacterProxy extends Node {
-	/**
-	 * Creates character node proxy.
-	 *
-	 * @protected
-	 * @param {engine.model.NodeListText} nodeListText Reference to a text object in a node list containing this character.
-	 * @param {Number} index Index of the character in `nodeListText`.
-	 */
-	constructor( nodeListText, index ) {
-		super( nodeListText._attrs );
-
-		/**
-		 * Character represented by this proxy.
-		 *
-		 * @readonly
-		 * @member {String} engine.model.CharacterProxy#character
-		 */
-		this.character = nodeListText.text.substr( index, 1 );
-
-		/**
-		 * @inheritdoc
-		 */
-		this.parent = nodeListText.parent;
-
-		/**
-		 * Reference to a text object in a node list containing this character.
-		 *
-		 * @protected
-		 * @readonly
-		 * @member {engine.model.NodeListText} engine.model.CharacterProxy#_nodeListText
-		 */
-		this._nodeListText = nodeListText;
-
-		/**
-		 * Index of the character in `nodeListText`.
-		 *
-		 * @protected
-		 * @readonly
-		 * @member {Number} engine.model.CharacterProxy#_index
-		 */
-		this._index = index;
-	}
-
-	/**
-	 * 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 TextProxy instance properties
-	 * will be refreshed, but other may get invalidated. It is highly unrecommended to store references to TextProxy instances.
-	 *
-	 * @param {String} key Key of attribute to set.
-	 * @param {*} value Attribute value.
-	 */
-	setAttribute( key, value ) {
-		let index = this.getIndex();
-
-		this.parent._children.setAttribute( index, 1, key, value );
-		this._attrs.set( key, value );
-	}
-
-	/**
-	 * Removes all attributes from the character proxy and sets given attributes.
-	 *
-	 * **Note:** Changing attributes of character proxy affects document state. This `CharacterProxy` instance properties
-	 * will be refreshed, but other instances of `CharacterProxy` and `TextProxy` may get invalidated.
-	 * It is highly unrecommended to store references to `CharacterProxy` instances.
-	 *
-	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set. See
-	 * {@link engine.model.CharacterProxy#getAttributes}.
-	 */
-	setAttributesTo( attrs ) {
-		let attrsMap = toMap( attrs );
-
-		this.clearAttributes();
-
-		for ( let attr of attrsMap ) {
-			this.setAttribute( attr[ 0 ], attr[ 1 ] );
-		}
-	}
-
-	/**
-	 * Removes an attribute with given key from the character proxy.
-	 *
-	 * **Note:** Changing attributes of character proxy affects document state. This `CharacterProxy` instance properties
-	 * will be refreshed, but other instances of `CharacterProxy` and `TextProxy` may get invalidated.
-	 * It is highly unrecommended to store references to `CharacterProxy` instances.
-	 *
-	 * @param {String} key Key of attribute to remove.
-	 */
-	removeAttribute( key ) {
-		this.setAttribute( key, null );
-	}
-
-	/**
-	 * Removes all attributes from the character proxy.
-	 *
-	 * **Note:** Changing attributes of character proxy affects document state. This `CharacterProxy` instance properties
-	 * will be refreshed, but other instances of `CharacterProxy` and `TextProxy` may get invalidated.
-	 * It is highly unrecommended to store references to `CharacterProxy` instances.
-	 */
-	clearAttributes() {
-		for ( let attr of this.getAttributes() ) {
-			this.removeAttribute( attr[ 0 ] );
-		}
-	}
-}

+ 2 - 2
packages/ckeditor5-engine/src/model/composer/modifyselection.js

@@ -39,7 +39,7 @@ export default function modifySelection( selection, options = {} ) {
 	let value = next.value;
 
 	// 2. Consume next character.
-	if ( value.type == 'character' ) {
+	if ( value.type == 'text' ) {
 		selection.setFocus( value.nextPosition );
 
 		return;
@@ -65,7 +65,7 @@ export default function modifySelection( selection, options = {} ) {
 
 	// 4.2. Character found after element end. Not really a valid case in our data model, but let's
 	// do something sensible and put the selection focus before that character.
-	if ( value.type == 'character' ) {
+	if ( value.type == 'text' ) {
 		selection.setFocus( value.previousPosition );
 	}
 	// 4.3. OK, we're entering a new element. So let's place there the focus.

+ 33 - 27
packages/ckeditor5-engine/src/model/delta/attributedelta.js

@@ -14,7 +14,7 @@ import RootElement from '../rootelement.js';
 import Element from '../element.js';
 
 /**
- * To provide specific OT behavior and better collisions solving, change methods ({@link engine.model.Batch#setAttr}
+ * To provide specific OT behavior and better collisions solving, methods to change attributes ({@link engine.model.Batch#setAttr}
  * and {@link engine.model.Batch#removeAttr}) use `AttributeDelta` class which inherits from the `Delta` class and may
  * overwrite some methods.
  *
@@ -25,6 +25,7 @@ export default class AttributeDelta extends Delta {
 	/**
 	 * The attribute key that is changed by the delta or `null` if the delta has no operations.
 	 *
+	 * @readonly
 	 * @type {String|null}
 	 */
 	get key() {
@@ -34,6 +35,7 @@ export default class AttributeDelta extends Delta {
 	/**
 	 * The attribute value that is set by the delta or `null` if the delta has no operations.
 	 *
+	 * @readonly
 	 * @type {*|null}
 	 */
 	get value() {
@@ -43,6 +45,7 @@ export default class AttributeDelta extends Delta {
 	/**
 	 * The range on which delta operates or `null` if the delta has no operations.
 	 *
+	 * @readonly
 	 * @type {engine.model.Range|null}
 	 */
 	get range() {
@@ -75,13 +78,16 @@ export default class AttributeDelta extends Delta {
 		return 'engine.model.delta.AttributeDelta';
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	static get _priority() {
 		return 20;
 	}
 }
 
 /**
- * To provide specific OT behavior and better collisions solving, change methods ({@link engine.model.Batch#setAttr}
+ * To provide specific OT behavior and better collisions solving, methods to change attributes ({@link engine.model.Batch#setAttr}
  * and {@link engine.model.Batch#removeAttr}) use `RootAttributeDelta` class which inherits from the `Delta` class and may
  * overwrite some methods.
  *
@@ -98,62 +104,63 @@ export class RootAttributeDelta extends Delta {
 }
 
 /**
- * Sets the value of the attribute of the node or on the range.
+ * Sets value of the attribute with given key on a {@link engine.model.Item model item} or on a {@link engine.model.Range range}.
  *
  * @chainable
  * @method engine.model.Batch#setAttr
  * @param {String} key Attribute key.
  * @param {*} value Attribute new value.
- * @param {engine.model.Node|engine.model.Range} nodeOrRange Node or range on which the attribute will be set.
+ * @param {engine.model.Item|engine.model.Range} itemOrRange Model item or range on which the attribute will be set.
  */
-register( 'setAttr', function( key, value, nodeOrRange ) {
-	attribute( this, key, value, nodeOrRange );
+register( 'setAttr', function( key, value, itemOrRange ) {
+	attribute( this, key, value, itemOrRange );
 
 	return this;
 } );
 
 /**
- * Removes an attribute from the range.
+ * Removes an attribute with given key from a {@link engine model.Item model item} or from a {@link engine.model.Range range}.
  *
  * @chainable
  * @method engine.model.Batch#removeAttr
  * @param {String} key Attribute key.
- * @param {engine.model.Node|engine.model.Range} nodeOrRange Node or range on which the attribute will be removed.
+ * @param {engine.model.Item|engine.model.Range} itemOrRange Model item or range from which the attribute will be removed.
  */
-register( 'removeAttr', function( key, nodeOrRange ) {
-	attribute( this, key, null, nodeOrRange );
+register( 'removeAttr', function( key, itemOrRange ) {
+	attribute( this, key, null, itemOrRange );
 
 	return this;
 } );
 
-function attribute( batch, key, value, nodeOrRange ) {
-	if ( nodeOrRange instanceof Range ) {
-		changeRange( batch, batch.document, key, value, nodeOrRange );
+function attribute( batch, key, value, itemOrRange ) {
+	if ( itemOrRange instanceof Range ) {
+		changeRange( batch, batch.document, key, value, itemOrRange );
 	} else {
-		changeNode( batch, batch.document, key, value, nodeOrRange );
+		changeItem( batch, batch.document, key, value, itemOrRange );
 	}
 }
 
-function changeNode( batch, doc, key, value, node ) {
-	const previousValue = node.getAttribute( key );
+function changeItem( batch, doc, key, value, item ) {
+	const previousValue = item.getAttribute( key );
 	let range, operation;
 
-	const delta = node instanceof RootElement ? new RootAttributeDelta() : new AttributeDelta();
+	const delta = item instanceof RootElement ? new RootAttributeDelta() : new AttributeDelta();
 	batch.addDelta( delta );
 
 	if ( previousValue != value ) {
-		if ( node instanceof RootElement ) {
+		if ( item instanceof RootElement ) {
 			// If we change attributes of root element, we have to use `RootAttributeOperation`.
-			operation = new RootAttributeOperation( node, key, previousValue, value, doc.version );
+			operation = new RootAttributeOperation( item, key, previousValue, value, doc.version );
 		} else {
-			if ( node instanceof Element ) {
+			if ( item instanceof Element ) {
 				// If we change the attribute of the element, we do not want to change attributes of its children, so
-				// the end on the range can not be put after the closing tag, it should be inside that element with the
-				// offset 0, so the range will contains only the opening tag...
-				range = new Range( Position.createBefore( node ), Position.createFromParentAndOffset( node, 0 ) );
+				// the end of the range cannot be after the closing tag, it should be inside that element, before any of
+				// it's children, so the range will contain only the opening tag.
+				range = new Range( Position.createBefore( item ), Position.createFromParentAndOffset( item, 0 ) );
 			} else {
-				// ...but for characters we can not put the range inside it, so we end the range after that character.
-				range = new Range( Position.createBefore( node ), Position.createAfter( node ) );
+				// If `item` is text proxy, we create a range from the beginning to the end of that text proxy, to change
+				// all characters represented by it.
+				range = new Range( Position.createBefore( item ), Position.createAfter( item ) );
 			}
 
 			operation = new AttributeOperation( range, key, previousValue || null, value || null, doc.version );
@@ -164,7 +171,7 @@ function changeNode( batch, doc, key, value, node ) {
 	}
 }
 
-// Because attribute operation needs to have the same attribute value on the whole range, this function split the range
+// Because attribute operation needs to have the same attribute value on the whole range, this function splits the range
 // into smaller parts.
 function changeRange( batch, doc, attributeKey, attributeValue, range ) {
 	const delta = new AttributeDelta();
@@ -215,5 +222,4 @@ function changeRange( batch, doc, attributeKey, attributeValue, range ) {
 }
 
 DeltaFactory.register( AttributeDelta );
-
 DeltaFactory.register( RootAttributeDelta );

+ 22 - 16
packages/ckeditor5-engine/src/model/delta/basic-transformations.js

@@ -294,16 +294,19 @@ addTransformationCase( WrapDelta, SplitDelta, ( a, b, isStrong ) => {
 // Creates an attribute delta that sets attribute from given `attributeDelta` on nodes from given `weakInsertDelta`.
 function _getComplementaryAttrDelta( weakInsertDelta, attributeDelta ) {
 	const complementaryAttrDelta = new AttributeDelta();
+	const nodes = weakInsertDelta.nodes;
 
 	// At the beginning we store the attribute value from the first node on `weakInsertDelta` node list.
-	let val = weakInsertDelta.nodeList.get( 0 ).getAttribute( attributeDelta.key );
+	let val = nodes.getNode( 0 ).getAttribute( attributeDelta.key );
 
 	// This stores the last index of `weakInsertDelta` node list where the attribute value was different
 	// than in the previous node. We need it to create separate `AttributeOperation`s for nodes with different attributes.
-	let lastIndex = 0;
+	let lastOffset = 0;
+	// Sum of offsets of already processed nodes.
+	let offsetSum = nodes.getNode( 0 ).offsetSize;
 
-	for ( let i = 0; i < weakInsertDelta.nodeList.length; i++ ) {
-		const node = weakInsertDelta.nodeList.get( i );
+	for ( let i = 1; i < nodes.length; i++ ) {
+		const node = nodes.getNode( i );
 		const nodeAttrVal = node.getAttribute( attributeDelta.key );
 
 		// If previous node has different attribute value, we will create an operation to the point before current node.
@@ -312,28 +315,31 @@ function _getComplementaryAttrDelta( weakInsertDelta, attributeDelta ) {
 			// New operation is created only when it is needed. If given node already has proper value for this
 			// attribute we simply skip it without adding a new operation.
 			if ( val != attributeDelta.value ) {
-				const range = new Range( weakInsertDelta.position.getShiftedBy( lastIndex ), weakInsertDelta.position.getShiftedBy( i ) );
-
-				// We don't care about base version because it will be updated after transformations anyway.
-				const attrOperation = new AttributeOperation( range, attributeDelta.key, val, attributeDelta.value, 0 );
-				complementaryAttrDelta.addOperation( attrOperation );
+				addOperation();
 			}
 
 			val = nodeAttrVal;
-			lastIndex = i;
+			lastOffset = offsetSum;
 		}
+
+		offsetSum = offsetSum + node.offsetSize;
 	}
 
 	// At the end we have to add additional `AttributeOperation` for the last part of node list. If all nodes on the
 	// node list had same attributes, this will be the only operation added to the delta.
-	const range = new Range(
-		weakInsertDelta.position.getShiftedBy( lastIndex ),
-		weakInsertDelta.position.getShiftedBy( weakInsertDelta.nodeList.length )
-	);
-
-	complementaryAttrDelta.addOperation( new AttributeOperation( range, attributeDelta.key, val, attributeDelta.value, 0 ) );
+	addOperation();
 
 	return complementaryAttrDelta;
+
+	function addOperation() {
+		const range = new Range(
+			weakInsertDelta.position.getShiftedBy( lastOffset ),
+			weakInsertDelta.position.getShiftedBy( offsetSum )
+		);
+
+		const attrOperation = new AttributeOperation( range, attributeDelta.key, val, attributeDelta.value, 0 );
+		complementaryAttrDelta.addOperation( attrOperation );
+	}
 }
 
 // This is "no-op" delta, it has no type and only no-operation, it basically does nothing.

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

@@ -55,7 +55,6 @@ export default class Delta {
 	}
 
 	/**
-	 * @protected
 	 * @param {Number} baseVersion
 	 */
 	set baseVersion( baseVersion ) {

+ 11 - 2
packages/ckeditor5-engine/src/model/delta/insertdelta.js

@@ -20,6 +20,7 @@ export default class InsertDelta extends Delta {
 	/**
 	 * Position where the delta inserts nodes or `null` if there are no operations in the delta.
 	 *
+	 * @readonly
 	 * @type {engine.model.Position|null}
 	 */
 	get position() {
@@ -29,15 +30,17 @@ export default class InsertDelta extends Delta {
 	/**
 	 * Node list containing all the nodes inserted by the delta or `null` if there are no operations in the delta.
 	 *
+	 * @readonly
 	 * @type {engine.model.NodeList|null}
 	 */
-	get nodeList() {
-		return this._insertOperation ? this._insertOperation.nodeList : null;
+	get nodes() {
+		return this._insertOperation ? this._insertOperation.nodes : null;
 	}
 
 	/**
 	 * Insert operation that is saved in this delta or `null` if there are no operations in the delta.
 	 *
+	 * @readonly
 	 * @protected
 	 * @type {engine.model.operation.InsertOperation|null}
 	 */
@@ -45,6 +48,9 @@ export default class InsertDelta extends Delta {
 		return this.operations[ 0 ] || null;
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	get _reverseDeltaClass() {
 		return RemoveDelta;
 	}
@@ -56,6 +62,9 @@ export default class InsertDelta extends Delta {
 		return 'engine.model.delta.InsertDelta';
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	static get _priority() {
 		return 20;
 	}

+ 12 - 6
packages/ckeditor5-engine/src/model/delta/mergedelta.js

@@ -24,12 +24,16 @@ export default class MergeDelta extends Delta {
 	/**
 	 * Position between to merged nodes or `null` if the delta has no operations.
 	 *
+	 * @readonly
 	 * @type {engine.model.Position|null}
 	 */
 	get position() {
 		return this._removeOperation ? this._removeOperation.sourcePosition : null;
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	getReversed() {
 		let delta = super.getReversed();
 
@@ -46,6 +50,7 @@ export default class MergeDelta extends Delta {
 	 * this might be an instance of {@link engine.model.operation.MoveOperation} instead of
 	 * {@link engine.model.operation.RemoveOperation}.
 	 *
+	 * @readonly
 	 * @protected
 	 * @type {engine.model.operation.MoveOperation|null}
 	 */
@@ -53,6 +58,9 @@ export default class MergeDelta extends Delta {
 		return this.operations[ 1 ] || null;
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	get _reverseDeltaClass() {
 		return SplitDelta;
 	}
@@ -88,8 +96,7 @@ register( 'merge', function( position ) {
 		 *
 		 * @error batch-merge-no-element-before
 		 */
-		throw new CKEditorError(
-			'batch-merge-no-element-before: Node before merge position must be an element.' );
+		throw new CKEditorError( 'batch-merge-no-element-before: Node before merge position must be an element.' );
 	}
 
 	if ( !( nodeAfter instanceof Element ) ) {
@@ -98,14 +105,13 @@ register( 'merge', function( position ) {
 		 *
 		 * @error batch-merge-no-element-after
 		 */
-		throw new CKEditorError(
-			'batch-merge-no-element-after: Node after merge position must be an element.' );
+		throw new CKEditorError( 'batch-merge-no-element-after: Node after merge position must be an element.' );
 	}
 
 	const positionAfter = Position.createFromParentAndOffset( nodeAfter, 0 );
-	const positionBefore = Position.createFromParentAndOffset( nodeBefore, nodeBefore.getChildCount() );
+	const positionBefore = Position.createFromParentAndOffset( nodeBefore, nodeBefore.getMaxOffset() );
 
-	const move = new MoveOperation( positionAfter, nodeAfter.getChildCount(), positionBefore, this.document.version );
+	const move = new MoveOperation( positionAfter, nodeAfter.getMaxOffset(), positionBefore, this.document.version );
 	move.isSticky = true;
 	delta.addOperation( move );
 	this.document.applyOperation( move );

+ 16 - 9
packages/ckeditor5-engine/src/model/delta/movedelta.js

@@ -20,7 +20,7 @@ import CKEditorError from '../../../utils/ckeditorerror.js';
  */
 export default class MoveDelta extends Delta {
 	/**
-	 * How many nodes are moved by the delta or `null` if there are no operations in the delta.
+	 * Offset size of moved range or `null` if there are no operations in the delta.
 	 *
 	 * @type {Number|null}
 	 */
@@ -51,7 +51,8 @@ export default class MoveDelta extends Delta {
 	}
 
 	/**
-	 * Move operation that is saved in this delta or `null` if there are no operations in the delta.
+	 * {@link engine.model.delta.MoveDelta#_moveOperation Move operation} that is saved in this delta or `null`
+	 * if there are no operations in the delta.
 	 *
 	 * @protected
 	 * @type {engine.model.operation.MoveOperation|null}
@@ -60,6 +61,9 @@ export default class MoveDelta extends Delta {
 		return this.operations[ 0 ] || null;
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	get _reverseDeltaClass() {
 		return MoveDelta;
 	}
@@ -71,6 +75,9 @@ export default class MoveDelta extends Delta {
 		return 'engine.model.delta.MoveDelta';
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	static get _priority() {
 		return 20;
 	}
@@ -83,19 +90,19 @@ function addMoveOperation( batch, delta, sourcePosition, howMany, targetPosition
 }
 
 /**
- * Moves given node or given range of nodes to target position.
+ * Moves given {@link engine.model.Item model item} or given range to target position.
  *
  * @chainable
  * @method engine.model.Batch#move
- * @param {engine.model.Node|engine.model.Range} nodeOrRange Node or range of nodes to move.
+ * @param {engine.model.Item|engine.model.Range} itemOrRange Model item or range of nodes to move.
  * @param {engine.model.Position} targetPosition Position where moved nodes will be inserted.
  */
-register( 'move', function( nodeOrRange, targetPosition ) {
+register( 'move', function( itemOrRange, targetPosition ) {
 	const delta = new MoveDelta();
 	this.addDelta( delta );
 
-	if ( nodeOrRange instanceof Range ) {
-		if ( !nodeOrRange.isFlat ) {
+	if ( itemOrRange instanceof Range ) {
+		if ( !itemOrRange.isFlat ) {
 			/**
 			 * Range to move is not flat.
 			 *
@@ -104,9 +111,9 @@ register( 'move', function( nodeOrRange, targetPosition ) {
 			throw new CKEditorError( 'batch-move-range-not-flat: Range to move is not flat.' );
 		}
 
-		addMoveOperation( this, delta, nodeOrRange.start, nodeOrRange.end.offset - nodeOrRange.start.offset, targetPosition );
+		addMoveOperation( this, delta, itemOrRange.start, itemOrRange.end.offset - itemOrRange.start.offset, targetPosition );
 	} else {
-		addMoveOperation( this, delta, Position.createBefore( nodeOrRange ), 1, targetPosition );
+		addMoveOperation( this, delta, Position.createBefore( itemOrRange ), 1, targetPosition );
 	}
 
 	return this;

+ 6 - 6
packages/ckeditor5-engine/src/model/delta/removedelta.js

@@ -33,25 +33,25 @@ function addRemoveOperation( batch, delta, position, howMany ) {
 }
 
 /**
- * Removes given node or range of nodes.
+ * Removes given {@link engine.model.Item model item} or given range.
  *
  * @chainable
  * @method engine.model.Batch#remove
- * @param {engine.model.Node|engine.model.Range} nodeOrRange Node or range of nodes to remove.
+ * @param {engine.model.Item|engine.model.Range} itemOrRange Model item or range to remove.
  */
-register( 'remove', function( nodeOrRange ) {
+register( 'remove', function( itemOrRange ) {
 	const delta = new RemoveDelta();
 	this.addDelta( delta );
 
-	if ( nodeOrRange instanceof Range ) {
+	if ( itemOrRange instanceof Range ) {
 		// The array is reversed, so the ranges are correct and do not have to be updated.
-		let ranges = nodeOrRange.getMinimalFlatRanges().reverse();
+		let ranges = itemOrRange.getMinimalFlatRanges().reverse();
 
 		for ( let flat of ranges ) {
 			addRemoveOperation( this, delta, flat.start, flat.end.offset - flat.start.offset );
 		}
 	} else {
-		addRemoveOperation( this, delta, Position.createBefore( nodeOrRange ), 1 );
+		addRemoveOperation( this, delta, Position.createBefore( itemOrRange ), 1 );
 	}
 
 	return this;

+ 17 - 3
packages/ckeditor5-engine/src/model/delta/renamedelta.js

@@ -11,6 +11,7 @@ import RemoveOperation from '../operation/removeoperation.js';
 import MoveOperation from '../operation/moveoperation.js';
 import Element from '../element.js';
 import Position from '../position.js';
+import CKEditorError from '../../../utils/ckeditorerror.js';
 
 /**
  * To provide specific OT behavior and better collisions solving, the {@link engine.model.Batch#rename Batch#rename} method
@@ -19,6 +20,9 @@ import Position from '../position.js';
  * @memberOf engine.model.delta
  */
 export default class RenameDelta extends Delta {
+	/**
+	 * @inheritDoc
+	 */
 	get _reverseDeltaClass() {
 		return RenameDelta;
 	}
@@ -32,13 +36,12 @@ export default class RenameDelta extends Delta {
 }
 
 function apply( batch, delta, operation ) {
-	batch.addDelta( delta );
 	delta.addOperation( operation );
 	batch.document.applyOperation( operation );
 }
 
 /**
- * Renames the given element.
+ * Renames given element.
  *
  * @chainable
  * @method engine.model.Batch#rename
@@ -46,7 +49,18 @@ function apply( batch, delta, operation ) {
  * @param {engine.model.Element} element The element to rename.
  */
 register( 'rename', function( newName, element ) {
+	if ( !( element instanceof Element ) ) {
+		/**
+		 * Trying to rename an object which is not an instance of Element.
+		 *
+		 * @error batch-rename-not-element-instance
+		 */
+		throw new CKEditorError( 'batch-rename-not-element-instance: Trying to rename an object which is not an instance of Element.' );
+	}
+
 	const delta = new RenameDelta();
+	this.addDelta( delta );
+
 	const newElement = new Element( newName );
 
 	apply(
@@ -56,7 +70,7 @@ register( 'rename', function( newName, element ) {
 
 	apply(
 		this, delta,
-		new MoveOperation( Position.createAt( element ), element.getChildCount(), Position.createAt( newElement ), this.document.version )
+		new MoveOperation( Position.createAt( element ), element.getMaxOffset(), Position.createAt( newElement ), this.document.version )
 	);
 
 	apply(

+ 20 - 10
packages/ckeditor5-engine/src/model/delta/splitdelta.js

@@ -30,6 +30,9 @@ export default class SplitDelta extends Delta {
 		return this._moveOperation ? this._moveOperation.sourcePosition : null;
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	getReversed() {
 		let delta = super.getReversed();
 
@@ -41,12 +44,13 @@ export default class SplitDelta extends Delta {
 	}
 
 	/**
-	 * Operation in the delta that adds a node to the tree model where split elements will be moved to or `null` if
+	 * Operation in the delta that adds to model an element into which split nodes will be moved, or `null` if
 	 * there are no operations in the delta.
 	 *
-	 * Most commonly this will be insert operation, as `SplitDelta` has to create a new node. If `SplitDelta` was created
-	 * through {@link engine.model.delta.MergeDelta MergeDelta} {@link engine.model.delta.Delta#getReversed reversing},
-	 * this will be a reinsert operation, as we will want to "insert-back" the node that was removed by `MergeDelta`.
+	 * Most commonly this will be {@link engine.model.operation.InsertOperation an insert operation}, as `SplitDelta`
+	 * has to create a new node. If `SplitDelta` was created through {@link engine.model.delta.Delta#getReversed reversing}
+	 * a {@link engine.model.delta.MergeDelta merge delta}, this will be a {@link engine.model.operation.ReinsertOperation reinsert operation},
+	 * as we will want to re-insert the exact element that was removed by that merge delta.
 	 *
 	 * @protected
 	 * @type {engine.model.operation.InsertOpertaion|engine.model.operation.ReinsertOperation|null}
@@ -56,8 +60,8 @@ export default class SplitDelta extends Delta {
 	}
 
 	/**
-	 * Operation in the delta that moves nodes from after split position to their new parent
-	 * or `null` if there are no operations in the delta.
+	 * Operation in the delta that moves model items, that are after split position, to their new parent or `null`
+	 * if there are no operations in the delta.
 	 *
 	 * @protected
 	 * @type {engine.model.operation.MoveOperation|null}
@@ -66,6 +70,9 @@ export default class SplitDelta extends Delta {
 		return this.operations[ 1 ] || null;
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	get _reverseDeltaClass() {
 		return MergeDelta;
 	}
@@ -77,15 +84,18 @@ export default class SplitDelta extends Delta {
 		return 'engine.model.delta.SplitDelta';
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	static get _priority() {
 		return 5;
 	}
 }
 
 /**
- * Splits a node at the given position.
+ * Splits an element at the given position.
  *
- * This cannot be a position inside the root element. The `batch-split-root` error will be thrown if
+ * The element cannot be a root element, as root element cannot be split. The `batch-split-root` error will be thrown if
  * you try to split the root element.
  *
  * @chainable
@@ -107,7 +117,7 @@ register( 'split', function( position ) {
 		throw new CKEditorError( 'batch-split-root: Root element can not be split.' );
 	}
 
-	const copy = new Element( splitElement.name, splitElement._attrs );
+	const copy = new Element( splitElement.name, splitElement.getAttributes() );
 
 	const insert = new InsertOperation( Position.createAfter( splitElement ), copy, this.document.version );
 
@@ -116,7 +126,7 @@ register( 'split', function( position ) {
 
 	const move = new MoveOperation(
 		position,
-		splitElement.getChildCount() - position.offset,
+		splitElement.getMaxOffset() - position.offset,
 		Position.createFromParentAndOffset( copy, 0 ),
 		this.document.version
 	);

+ 1 - 1
packages/ckeditor5-engine/src/model/delta/transform.js

@@ -60,7 +60,7 @@ function updateBaseVersion( baseVersion, deltas ) {
 /**
  * The default delta transformation function. It is used for those deltas that are not in special case conflict.
  *
- * This algorithm is similar to popular `dOPT` algorithm used in operational transformation, as we are in fact
+ * This algorithm is similar to a popular `dOPT` algorithm used in operational transformation, as we are in fact
  * transforming two sets of operations by each other.
  *
  * @external engine.model.delta.transform

+ 8 - 2
packages/ckeditor5-engine/src/model/delta/unwrapdelta.js

@@ -39,6 +39,9 @@ export default class UnwrapDelta extends Delta {
 		return this.operations[ 0 ] || null;
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	get _reverseDeltaClass() {
 		return WrapDelta;
 	}
@@ -50,13 +53,16 @@ export default class UnwrapDelta extends Delta {
 		return 'engine.model.delta.UnwrapDelta';
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	static get _priority() {
 		return 10;
 	}
 }
 
 /**
- * Unwraps specified element, that is moves all it's children before it and then removes it. Throws
+ * Unwraps given element, that is moves all it's children before it and then removes it. Throws
  * error if you try to unwrap an element that does not have a parent.
  *
  * @chainable
@@ -78,7 +84,7 @@ register( 'unwrap', function( element ) {
 
 	let sourcePosition = Position.createFromParentAndOffset( element, 0 );
 
-	const move = new MoveOperation( sourcePosition, element.getChildCount(), Position.createBefore( element ), this.document.version );
+	const move = new MoveOperation( sourcePosition, element.getMaxOffset(), Position.createBefore( element ), this.document.version );
 	move.isSticky = true;
 	delta.addOperation( move );
 	this.document.applyOperation( move );

+ 10 - 10
packages/ckeditor5-engine/src/model/delta/weakinsertdelta.js

@@ -7,7 +7,7 @@ import InsertDelta from './insertdelta.js';
 import { register } from '../batch.js';
 import DeltaFactory from './deltafactory.js';
 import InsertOperation from '../operation/insertoperation.js';
-import NodeList from '../nodelist.js';
+import { normalizeNodes } from './../writer.js';
 
 /**
  * @classdesc
@@ -26,18 +26,18 @@ export default class WeakInsertDelta extends InsertDelta {
 }
 
 /**
- * Inserts a node or nodes at the given position. {@link engine.model.Batch#weakInsert weakInsert} is commonly used for actions
+ * Inserts a node or nodes at given position. {@link engine.model.Batch#weakInsert weakInsert} is commonly used for actions
  * like typing or plain-text paste (without formatting). There are two differences between
  * {@link engine.model.Batch#insert insert} and {@link engine.model.Batch#weakInsert weakInsert}:
  *
  * * When using `weakInsert`, inserted nodes will have same attributes as the current attributes of
  * {@link engine.model.Document#selection document selection}.
- * * Normal behavior is that inserting inside range changed by
- * {@link engine.model.operation.AttributeOperation AttributeOperation} splits
- * the operation into two operations, which "omit" the inserted nodes. The correct behavior for `WeakInsertDelta` is that
+ * * If {@link engine.model.operation.InsertOperation insert operation} position is inside a range changed by
+ * {@link engine.model.operation.AttributeOperation attribute operation}, the attribute operation is split into two operations.
+ * Thanks to this, attribute change "omits" the inserted nodes. The correct behavior for `WeakInsertDelta` is that
  * {@link engine.model.operation.AttributeOperation AttributeOperation} does not "break" and also
- * applies attributes for inserted nodes.
- * The above has to be reflected during {@link engine.model.operation.transform operational transformation}.
+ * applies attributes for inserted nodes. This behavior has to be reflected during
+ * {@link engine.model.delta.transform delta transformation}.
  *
  * @chainable
  * @method engine.model.Batch#weakInsert
@@ -48,10 +48,10 @@ register( 'weakInsert', function( position, nodes ) {
 	const delta = new WeakInsertDelta();
 	this.addDelta( delta );
 
-	nodes = new NodeList( nodes );
+	nodes = normalizeNodes( nodes );
 
-	for ( let node of nodes._nodes ) {
-		node._attrs = new Map( this.document.selection.getAttributes() );
+	for ( let node of nodes ) {
+		node.setAttributesTo( this.document.selection.getAttributes() );
 	}
 
 	const operation = new InsertOperation( position, nodes, this.document.version );

+ 12 - 3
packages/ckeditor5-engine/src/model/delta/wrapdelta.js

@@ -34,7 +34,7 @@ export default class WrapDelta extends Delta {
 	}
 
 	/**
-	 * How many nodes is wrapped by the delta or `null` if there are no operations in delta.
+	 * Offset size of range to wrap by the delta or `null` if there are no operations in delta.
 	 *
 	 * @type {Number}
 	 */
@@ -64,22 +64,31 @@ export default class WrapDelta extends Delta {
 		return this.operations[ 1 ] || null;
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	get _reverseDeltaClass() {
 		return UnwrapDelta;
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	static get className() {
 		return 'engine.model.delta.WrapDelta';
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	static get _priority() {
 		return 10;
 	}
 }
 
 /**
- * Wraps given range with given element or with a new element of specified name if string has been passed.
- * **Note:** given range should be a "flat range" (see {@link engine.model.Range#isFlat}). If not, error will be thrown.
+ * Wraps given range with given element or with a new element with specified name, if string has been passed.
+ * **Note:** range to wrap should be a "flat range" (see {@link engine.model.Range#isFlat}). If not, error will be thrown.
  *
  * @chainable
  * @method engine.model.Batch#wrap

+ 6 - 5
packages/ckeditor5-engine/src/model/document.js

@@ -11,12 +11,12 @@ import RootElement from './rootelement.js';
 import Batch from './batch.js';
 import History from './history.js';
 import LiveSelection from './liveselection.js';
-import EmitterMixin from '../../utils/emittermixin.js';
-import CKEditorError from '../../utils/ckeditorerror.js';
-import mix from '../../utils/mix.js';
 import Schema from './schema.js';
 import Composer from './composer/composer.js';
 import clone from '../../utils/lib/lodash/clone.js';
+import EmitterMixin from '../../utils/emittermixin.js';
+import CKEditorError from '../../utils/ckeditorerror.js';
+import mix from '../../utils/mix.js';
 
 const graveyardName = '$graveyard';
 
@@ -26,7 +26,7 @@ const graveyardName = '$graveyard';
  * represented by the separate root.
  *
  * All changes in the document are done by {@link engine.model.operation.Operation operations}. To create operations in
- * the simple way use use the {@link engine.model.Batch} API, for example:
+ * a simple way, use the {@link engine.model.Batch} API, for example:
  *
  *		doc.batch().insert( position, nodes ).split( otherPosition );
  *
@@ -36,7 +36,8 @@ const graveyardName = '$graveyard';
  */
 export default class Document {
 	/**
-	 * Creates an empty document instance with no {@link engine.model.Document#roots} (other than graveyard).
+	 * Creates an empty document instance with no {@link engine.model.Document#roots} (other than
+	 * a {@link engine.model.Document#graveyard graveyard root}).
 	 */
 	constructor() {
 		/**

+ 145 - 42
packages/ckeditor5-engine/src/model/documentfragment.js

@@ -4,25 +4,28 @@
  */
 
 import NodeList from './nodelist.js';
+import Element from './element.js';
+import Text from './text.js';
+import isIterable from '../../utils/isiterable.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.
+ * DocumentFragment represents a part of model which does not have a common root but it's top-level nodes
+ * can be seen as siblings. In other words, it is a detached part of model tree, without a root.
  *
  * @memberOf engine.model
  */
 export default class DocumentFragment {
 	/**
-	 * Creates empty DocumentFragment.
+	 * Creates an empty `DocumentFragment`.
 	 *
-	 * @param {engine.model.NodeSet} children List of nodes contained inside the DocumentFragment.
+	 * @param {engine.model.Node|Iterable.<engine.model.Node>} children Nodes to be contained inside the `DocumentFragment`.
 	 */
 	constructor( children ) {
 		/**
-		 * List of nodes contained inside the DocumentFragment.
+		 * List of nodes contained inside the document fragment.
 		 *
-		 * @protected
-		 * @member {engine.model.NodeSet} engine.model.DocumentFragment#_children
+		 * @private
+		 * @member {engine.model.NodeList} engine.model.DocumentFragment#_children
 		 */
 		this._children = new NodeList();
 
@@ -32,14 +35,16 @@ export default class DocumentFragment {
 	}
 
 	/**
-	 * `DocumentFragment` iterator. Returns {@link engine.model.Node nodes} that are added to the `DocumentFragment`.
+	 * Returns an iterator that iterates over all nodes contained inside this document fragment.
+	 *
+	 * @returns {Iterator.<engine.model.Node>}
 	 */
 	[ Symbol.iterator ]() {
-		return this._children[ Symbol.iterator ]();
+		return this.getChildren();
 	}
 
 	/**
-	 * The root of `DocumentFragment`. Returns itself. Added for compatibility reasons with {@link engine.model.Element}.
+	 * Artificial root of `DocumentFragment`. Returns itself. Added for compatibility reasons.
 	 *
 	 * @readonly
 	 * @type {engine.model.DocumentFragment}
@@ -49,88 +54,186 @@ export default class DocumentFragment {
 	}
 
 	/**
-	 * Returns path to the `DocumentFragment` This is always equal to empty array and is added for compatibility reasons
-	 * with {@link engine.model.Element}.
+	 * Returns path to a `DocumentFragment`, which is an empty array. Added for compatibility reasons.
 	 *
-	 * @returns {Array} The path.
+	 * @returns {Array}
 	 */
 	getPath() {
 		return [];
 	}
 
 	/**
-	 * Gets child at the given index.
+	 * Returns `true` if there are no nodes inside this document fragment, `false` otherwise.
+	 *
+	 * @returns {Boolean}
+	 */
+	isEmpty() {
+		return this.getChildCount() === 0;
+	}
+
+	/**
+	 * Gets the child at the given index. Returns `null` if incorrect index was passed.
 	 *
 	 * @param {Number} index Index of child.
-	 * @returns {engine.model.Node} Child node.
+	 * @returns {engine.model.Node|null} Child node.
 	 */
 	getChild( index ) {
-		return this._children.get( index );
+		return this._children.getNode( index );
+	}
+
+	/**
+	 * Returns an index of the given child node. Returns `null` if given node is not a child of this document fragment.
+	 *
+	 * @param {engine.model.Node} node Child node to look for.
+	 * @returns {Number|null} Child node's index.
+	 */
+	getChildIndex( node ) {
+		return this._children.getNodeIndex( node );
 	}
 
 	/**
-	 * Gets the number of top-level elements of DocumentFragment.
+	 * Returns an iterator that iterates over all of this document fragment's children.
 	 *
-	 * @returns {Number} The number of top-level elements.
+	 * @returns {Iterable.<engine.model.Node>}
+	 */
+	getChildren() {
+		return this._children[ Symbol.iterator ]();
+	}
+
+	/**
+	 * Returns the number of this document fragment's children.
+	 *
+	 * @returns {Number}
 	 */
 	getChildCount() {
 		return this._children.length;
 	}
 
 	/**
-	 * Gets index of the given child node.
+	 * Returns the starting offset of given child. Starting offset is equal to the sum of
+	 * {engine.model.Node#offsetSize offset sizes} of all node's siblings that are before it. Returns `null` if
+	 * given node is not a child of this document fragment.
 	 *
-	 * @param {engine.model.Node} node Child node.
-	 * @returns {Number} Index of the child node.
+	 * @param {engine.model.Node} node Child node to look for.
+	 * @returns {Number|null} Child node's starting offset.
 	 */
-	getChildIndex( node ) {
-		return this._children.indexOf( node );
+	getChildStartOffset( node ) {
+		return this._children.getNodeStartOffset( node );
+	}
+
+	/**
+	 * Returns the sum of {engine.model.Node#offsetSize offset sizes} of all of this document fragment's children.
+	 *
+	 * @returns {Number}
+	 */
+	getMaxOffset() {
+		return this._children.totalOffset;
 	}
 
 	/**
-	 * Inserts a child node or a list of child nodes at the end of this DocumentFragment.
+	 * Converts offset "position" to index "position".
 	 *
-	 * @param {engine.model.NodeSet} nodes The list of nodes to be inserted.
+	 * Returns index of a node that occupies given offset. If given offset is too low, returns `0`. If given offset is
+	 * too high, returns {@link engine.model.DocumentFragment#getChildCount index after last child}.
+	 *
+	 *		const textNode = new Text( 'foo' );
+	 *		const pElement = new Element( 'p' );
+	 *		const docFrag = new DocumentFragment( [ textNode, pElement ] );
+	 *		docFrag.offsetToIndex( -1 ); // Returns 0, because offset is too low.
+	 *		docFrag.offsetToIndex( 0 ); // Returns 0, because offset 0 is taken by `textNode` which is at index 0.
+	 *		docFrag.offsetToIndex( 1 ); // Returns 0, because `textNode` has `offsetSize` equal to 3, so it occupies offset 1 too.
+	 *		docFrag.offsetToIndex( 2 ); // Returns 0.
+	 *		docFrag.offsetToIndex( 3 ); // Returns 1.
+	 *		docFrag.offsetToIndex( 4 ); // Returns 2. There are no nodes at offset 4, so last available index is returned.
+	 *
+	 * @param {Number} offset Offset to look for.
+	 * @returns {Number} Index of a node that occupies given offset.
+	 */
+	offsetToIndex( offset ) {
+		return this._children.offsetToIndex( offset );
+	}
+
+	/**
+	 * {@link engine.model.DocumentFragment#insertChildren Inserts} one or more nodes at the end of this document fragment.
+	 *
+	 * @param {engine.model.Node|Iterable.<engine.model.Node>} nodes 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.
+	 * Inserts one or more nodes at the given index and sets {@link engine.model.Node#parent parent} of these nodes
+	 * to this document fragment.
 	 *
-	 * @param {Number} index Position where nodes should be inserted.
-	 * @param {engine.model.NodeSet} nodes The list of nodes to be inserted.
+	 * @param {Number} index Index at which nodes should be inserted.
+	 * @param {engine.model.Node|Iterable.<engine.model.Node>} nodes Nodes to be inserted.
 	 */
 	insertChildren( index, nodes ) {
-		let nodeList = new NodeList( nodes );
-
-		for ( let node of nodeList._nodes ) {
-			node.parent = this;
+		if ( !isIterable( nodes ) ) {
+			nodes = [ nodes ];
 		}
 
-		// Clean original DocumentFragment so it won't contain nodes that were added somewhere else.
-		if ( nodes instanceof DocumentFragment ) {
-			nodes._children = new NodeList();
+		for ( let node of nodes ) {
+			node.parent = this;
 		}
 
-		this._children.insert( index, nodeList );
+		this._children.insertNodes( index, nodes );
 	}
 
 	/**
-	 * Removes number of child nodes starting at the given index and set the parent of these nodes to `null`.
+	 * Removes one or more nodes starting at the given index and sets {@link engine.model.Node#parent parent} of these nodes to `null`.
 	 *
-	 * @param {Number} index Position of the first node to remove.
+	 * @param {Number} index Index of the first node to remove.
 	 * @param {Number} [howMany=1] Number of nodes to remove.
-	 * @returns {engine.model.NodeList} The list of removed nodes.
+	 * @returns {Array.<engine.model.Node>} Array containing removed nodes.
 	 */
 	removeChildren( index, howMany = 1 ) {
-		let nodeList = this._children.remove( index, howMany );
+		const nodes = this._children.removeNodes( index, howMany );
 
-		for ( let node of nodeList._nodes ) {
+		for ( let node of nodes ) {
 			node.parent = null;
 		}
 
-		return nodeList;
+		return nodes;
+	}
+
+	/**
+	 * Converts `DocumentFragment` instance to plain object and returns it.
+	 * Takes care of converting all of this document fragment's children.
+	 *
+	 * @returns {Object} `DocumentFragment` instance converted to plain object.
+	 */
+	toJSON() {
+		let json = [];
+
+		for ( let node of this._children ) {
+			json.push( node.toJSON() );
+		}
+
+		return json;
+	}
+
+	/**
+	 * Creates a `DocumentFragment` instance from given plain object (i.e. parsed JSON string).
+	 * Converts `DocumentFragment` children to proper nodes.
+	 *
+	 * @param {Object} json Plain object to be converted to `DocumentFragment`.
+	 * @returns {engine.model.DocumentFragment} `DocumentFragment` instance created using given plain object.
+	 */
+	static fromJSON( json ) {
+		let children = [];
+
+		for ( let child of json ) {
+			if ( child.name ) {
+				// If child has name property, it is an Element.
+				children.push( Element.fromJSON( child ) );
+			} else {
+				// Otherwise, it is a Text node.
+				children.push( Text.fromJSON( child ) );
+			}
+		}
+
+		return new DocumentFragment( children );
 	}
 }

+ 128 - 112
packages/ckeditor5-engine/src/model/element.js

@@ -5,23 +5,25 @@
 
 import Node from './node.js';
 import NodeList from './nodelist.js';
-import DocumentFragment from './documentfragment.js';
-import Range from './range.js';
-import toMap from '../../utils/tomap.js';
+import Text from './text.js';
+import isIterable from '../../utils/isiterable.js';
 
 /**
- * Tree data model element.
+ * Model element. Type of {@link engine.model.Node node} that has a {@link engine.model.Element#name name} and
+ * {@link engine.model.Element#getChildren child nodes}.
+ *
+ * **Important**: see {@link engine.model.Node} to read about restrictions using `Element` and `Node` API.
  *
  * @memberOf engine.model
  */
 export default class Element extends Node {
 	/**
-	 * Creates a tree data model element.
+	 * Creates a model element.
 	 *
-	 * @param {String} name Node name.
-	 * @param {Iterable} [attrs] Iterable collection of attributes.
-	 * @param {engine.model.NodeSet} [children] List of nodes to be inserted.
-	 * into created element. List of nodes can be of any type accepted by the {@link engine.model.NodeList} constructor.
+	 * @param {String} name Element's name.
+	 * @param {Object} [attrs] Element's attributes. See {@link utils.toMap} for a list of accepted values.
+	 * @param {engine.model.Node|Iterable.<engine.model.Node>} [children] One or more nodes to be inserted as children of
+	 * created element.
 	 */
 	constructor( name, attrs, children ) {
 		super( attrs );
@@ -37,7 +39,7 @@ export default class Element extends Node {
 		/**
 		 * List of children nodes.
 		 *
-		 * @protected
+		 * @private
 		 * @member {engine.model.NodeList} engine.model.Element#_children
 		 */
 		this._children = new NodeList();
@@ -48,185 +50,199 @@ export default class Element extends Node {
 	}
 
 	/**
-	 * Gets child at the given index.
+	 * Creates a copy of this element and returns it. Created element has same name and attributes as original element.
+	 * If clone is not deep, children of copied element are references to the same nodes as in original element.
+	 * If clone is deep, original element's children are also cloned.
 	 *
-	 * @param {Number} index Index of child.
-	 * @returns {engine.model.Node} Child node.
+	 * @param {Boolean} [deep=false] Decides whether children of this element should also be cloned (`true`) or not (`false`).
 	 */
-	getChild( index ) {
-		return this._children.get( index );
+	clone( deep = false ) {
+		const children = deep ?
+			Array.from( this._children ).map( ( node ) => node.clone() ) :
+			Array.from( this._children );
+
+		return new Element( this.name, this.getAttributes(), children );
 	}
 
 	/**
-	 * Gets the number of element's children.
+	 * Returns `true` if there are no nodes inside this element, `false` otherwise.
 	 *
-	 * @returns {Number} The number of element's children.
+	 * @returns {Boolean}
 	 */
-	getChildCount() {
-		return this._children.length;
+	isEmpty() {
+		return this.getChildCount() === 0;
 	}
 
 	/**
-	 * Gets index of the given child node.
+	 * Gets the child at the given index.
 	 *
-	 * @param {engine.model.Node} node Child node.
-	 * @returns {Number} Index of the child node.
+	 * @param {Number} index Index of child.
+	 * @returns {engine.model.Node} Child node.
 	 */
-	getChildIndex( node ) {
-		return this._children.indexOf( node );
+	getChild( index ) {
+		return this._children.getNode( index );
 	}
 
 	/**
-	 * {@link engine.model.Element#insert Inserts} 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.
+	 * Returns an index of the given child node. Returns `null` if given node is not a child of this element.
 	 *
-	 * Note that the list of children can be modified only in elements not yet attached to the document.
-	 * All attached nodes should be modified using the {@link engine.model.operation.InsertOperation}.
-	 *
-	 * @param {engine.model.NodeSet} nodes The list of nodes to be inserted.
+	 * @param {engine.model.Node} node Child node to look for.
+	 * @returns {Number} Child node's index in this element.
 	 */
-	appendChildren( nodes ) {
-		this.insertChildren( this.getChildCount(), nodes );
+	getChildIndex( node ) {
+		return this._children.getNodeIndex( node );
 	}
 
 	/**
-	 * Inserts a list of child nodes on the given index and sets the parent of these nodes to this element.
+	 * Returns an iterator that iterates over all of this element's children.
 	 *
-	 * Note that the list of children can be modified only in elements not yet attached to the document.
-	 * All attached nodes should be modified using the {@link engine.model.operation.InsertOperation}.
-	 *
-	 * @param {Number} index Position where nodes should be inserted.
-	 * @param {engine.model.NodeSet} nodes The list of nodes to be inserted.
+	 * @returns {Iterable.<engine.model.Node>}
 	 */
-	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 );
+	getChildren() {
+		return this._children[ Symbol.iterator ]();
 	}
 
 	/**
-	 * Removes number of child nodes starting at the given index and set the parent of these nodes to `null`.
+	 * Returns the number of this element's children.
 	 *
-	 * Note that the list of children can be modified only in elements not yet attached to the document.
-	 * All attached nodes should be modified using the {@link engine.model.operation.RemoveOperation}.
-	 *
-	 * @param {Number} index Position of the first node to remove.
-	 * @param {Number} [howMany=1] Number of nodes to remove.
-	 * @returns {engine.model.NodeList} The list of removed nodes.
+	 * @returns {Number}
 	 */
-	removeChildren( index, howMany = 1 ) {
-		let nodeList = this._children.remove( index, howMany );
-
-		for ( let node of nodeList._nodes ) {
-			node.parent = null;
-		}
-
-		return nodeList;
+	getChildCount() {
+		return this._children.length;
 	}
 
 	/**
-	 * Sets attribute on the element. If attribute with the same key already is set, it overwrites its value.
+	 * Returns the starting offset of given child. Starting offset is equal to the sum of
+	 * {engine.model.Node#offsetSize offset sizes} of all node's siblings that are before it. Returns `null` if
+	 * given node is not a child of this element.
 	 *
-	 * @param {String} key Key of attribute to set.
-	 * @param {*} value Attribute value.
+	 * @param {engine.model.Node} node Child node to look for.
+	 * @returns {Number} Child node's starting offset.
 	 */
-	setAttribute( key, value ) {
-		this._attrs.set( key, value );
+	getChildStartOffset( node ) {
+		return this._children.getNodeStartOffset( node );
 	}
 
 	/**
-	 * Removes all attributes from the element and sets given attributes.
+	 * Returns the sum of {engine.model.Node#offsetSize offset sizes} of all of this element's children.
 	 *
-	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set. See {@link engine.model.Node#getAttributes}.
+	 * @returns {Number}
 	 */
-	setAttributesTo( attrs ) {
-		this._attrs = toMap( attrs );
+	getMaxOffset() {
+		return this._children.totalOffset;
 	}
 
 	/**
-	 * Removes an attribute with given key from the element.
-	 *
-	 * @param {String} key Key of attribute to remove.
-	 * @returns {Boolean} `true` if the attribute was set on the element, `false` otherwise.
+	 * Returns index of a node that occupies given offset. If given offset is too low, returns `0`. If given offset is
+	 * too high, returns {@link engine.model.Element#getChildCount index after last child}.
+	 *
+	 *		const textNode = new Text( 'foo' );
+	 *		const pElement = new Element( 'p' );
+	 *		const divElement = new Element( [ textNode, pElement ] );
+	 *		divElement.offsetToIndex( -1 ); // Returns 0, because offset is too low.
+	 *		divElement.offsetToIndex( 0 ); // Returns 0, because offset 0 is taken by `textNode` which is at index 0.
+	 *		divElement.offsetToIndex( 1 ); // Returns 0, because `textNode` has `offsetSize` equal to 3, so it occupies offset 1 too.
+	 *		divElement.offsetToIndex( 2 ); // Returns 0.
+	 *		divElement.offsetToIndex( 3 ); // Returns 1.
+	 *		divElement.offsetToIndex( 4 ); // Returns 2. There are no nodes at offset 4, so last available index is returned.
+	 *
+	 * @param {Number} offset Offset to look for.
+	 * @returns {Number}
 	 */
-	removeAttribute( key ) {
-		return this._attrs.delete( key );
+	offsetToIndex( offset ) {
+		return this._children.offsetToIndex( offset );
 	}
 
 	/**
-	 * Removes all attributes from the element.
+	 * {@link engine.model.Element#insertChildren Inserts} one or more nodes at the end of this element.
+	 *
+	 * @param {engine.model.Node|Iterable.<engine.model.Node>} nodes Nodes to be inserted.
 	 */
-	clearAttributes() {
-		this._attrs.clear();
+	appendChildren( nodes ) {
+		this.insertChildren( this.getChildCount(), nodes );
 	}
 
 	/**
-	 * Checks whether element is empty (has no children).
+	 * Inserts one or more nodes at the given index and sets {@link engine.model.Node#parent parent} of these nodes
+	 * to this element.
 	 *
-	 * @returns {Boolean}
+	 * @param {Number} index Index at which nodes should be inserted.
+	 * @param {engine.model.Node|Iterable.<engine.model.Node>} nodes Nodes to be inserted.
 	 */
-	isEmpty() {
-		return this.getChildCount() === 0;
+	insertChildren( index, nodes ) {
+		if ( !isIterable( nodes ) ) {
+			nodes = [ nodes ];
+		}
+
+		for ( let node of nodes ) {
+			node.parent = this;
+		}
+
+		this._children.insertNodes( index, nodes );
 	}
 
 	/**
-	 * Gets the text content of the element. The return value is created by concatenating all
-	 * text nodes in this element and its descendants.
+	 * Removes one or more nodes starting at the given index and sets {@link engine.model.Node#parent parent} of these nodes to `null`.
 	 *
-	 * @returns {String}
+	 * @param {Number} index Index of the first node to remove.
+	 * @param {Number} [howMany=1] Number of nodes to remove.
+	 * @returns {Array.<engine.model.Node>} Array containing removed nodes.
 	 */
-	getText() {
-		let text = '';
+	removeChildren( index, howMany = 1 ) {
+		const nodes = this._children.removeNodes( index, howMany );
 
-		for ( let value of Range.createFromElement( this ) ) {
-			if ( value.type == 'text' ) {
-				text += value.item.text;
-			}
+		for ( let node of nodes ) {
+			node.parent = null;
 		}
 
-		return text;
+		return nodes;
 	}
 
 	/**
-	 * Custom toJSON method to solve child-parent circular dependencies.
+	 * Converts `Element` instance to plain object and returns it. Takes care of converting all of this element's children.
 	 *
-	 * @returns {Object} Clone of this object with the parent property replaced with its name.
+	 * @returns {Object} `Element` instance converted to plain object.
 	 */
 	toJSON() {
 		let json = super.toJSON();
 
-		if ( this._children.length ) {
-			json.children = this._children.toJSON();
-		}
-
 		json.name = this.name;
 
+		if ( this._children.length > 0 ) {
+			json.children = [];
+
+			for ( let node of this._children ) {
+				json.children.push( node.toJSON() );
+			}
+		}
+
 		return json;
 	}
 
 	/**
-	 * Creates Element object from deserilized object, ie. from parsed JSON string.
+	 * Creates an `Element` instance from given plain object (i.e. parsed JSON string).
+	 * Converts `Element` children to proper nodes.
 	 *
-	 *		let deserialized = JSON.parse( JSON.stringify( someElementObject ) );
-	 *		let element = NodeList.fromJSON( deserialized );
-	 *
-	 * @param {Object} json
-	 * @returns {engine.model.Element}
+	 * @param {Object} json Plain object to be converted to `Element`.
+	 * @returns {engine.model.Element} `Element` instance created using given plain object.
 	 */
 	static fromJSON( json ) {
+		let children = null;
+
 		if ( json.children ) {
-			return new Element( json.name, json.attributes, NodeList.fromJSON( json.children ) );
+			children = [];
+
+			for ( let child of json.children ) {
+				if ( child.name ) {
+					// If child has name property, it is an Element.
+					children.push( Element.fromJSON( child ) );
+				} else {
+					// Otherwise, it is a Text node.
+					children.push( Text.fromJSON( child ) );
+				}
+			}
 		}
 
-		return new Element( json.name, json.attributes );
+		return new Element( json.name, json.attributes, children );
 	}
 }

+ 3 - 3
packages/ckeditor5-engine/src/model/history.js

@@ -12,7 +12,7 @@ import CKEditorError from '../../utils/ckeditorerror.js';
  *
  * **Note:** deltas kept in `History` should be used only to transform deltas. It's not advised to use `History` to get
  * original delta basing on it's {@link engine.model.delta.Delta#baseVersion baseVersion}. Also, after transforming a
- * delta by deltas from `History`, fix it's base version accordingly (set to {@link engine.model.Document#version}.
+ * delta by deltas from `History`, fix it's base version accordingly (set it to {@link engine.model.Document#version document version}).
  *
  * @memberOf engine.model
  */
@@ -56,7 +56,7 @@ export default class History {
 	/**
 	 * Returns deltas added to the history.
 	 *
-	 * @param {Number} [from=0] Base version from which deltas should be returned (inclusive). Defaults to `0` which means
+	 * @param {Number} [from=0] Base version from which deltas should be returned (inclusive). Defaults to `0`, which means
 	 * that deltas from the first one will be returned.
 	 * @param {Number} [to=Number.POSITIVE_INFINITY] Base version up to which deltas should be returned (exclusive).
 	 * Defaults to `Number.POSITIVE_INFINITY` which means that deltas up to the last one will be returned.
@@ -143,7 +143,7 @@ export default class History {
 	 * **Note:** if delta with `baseVersion` was already updated by multiple deltas, all updated deltas will be removed
 	 * and new deltas will be inserted at their position.
 	 *
-	 * **Note:** delta marked as reversed won't get updated.
+	 * **Note:** removed delta won't get updated.
 	 *
 	 * @param {Number} baseVersion Base version of a delta to update.
 	 * @param {Iterable.<engine.model.delta.Delta>} updatedDeltas Deltas to be inserted in place of updated delta.

+ 17 - 14
packages/ckeditor5-engine/src/model/liveposition.js

@@ -11,12 +11,14 @@ import mix from '../../utils/mix.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
 
 /**
- * LivePosition is a position in {@link engine.model.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.model.Position}, LivePosition works only in roots that are
+ * `LivePosition` is a type of {@link engine.model.Position Position} that updates itself as {@link engine.model.Document document}
+ * is changed through operations. It may be used as a bookmark.
+ *
+ * **Note:** Contrary to {@link engine.model.Position}, `LivePosition` works only in roots that are
  * {@link engine.model.RootElement}. If {@link engine.model.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.model.LivePosition#detach} whenever you don't need LivePosition anymore.
+ *
+ * **Note:** Be very careful when dealing with `LivePosition`. Each `LivePosition` instance bind events that might
+ * have to be unbound. Use {@link engine.model.LivePosition#detach} whenever you don't need `LivePosition` anymore.
  *
  * @memberOf engine.model
  * @extends engine.model.Position
@@ -44,10 +46,10 @@ export default class LivePosition extends Position {
 		super( root, path );
 
 		/**
-		 * Flag representing LivePosition stickiness. LivePosition might be sticking to previous node or next node.
-		 * Whenever some nodes are inserted at the same position as LivePosition, `stickiness` is checked to decide if
+		 * Flag representing `LivePosition` stickiness. `LivePosition` might be sticking to previous node or next node.
+		 * Whenever some nodes are inserted at the same position as `LivePosition`, `stickiness` is checked to decide if
 		 * LivePosition should be moved. Similar applies when a range of nodes is moved and one of it's boundary
-		 * position is same as LivePosition.
+		 * position is same as `LivePosition`.
 		 *
 		 * Examples:
 		 *
@@ -69,7 +71,7 @@ export default class LivePosition extends Position {
 	}
 
 	/**
-	 * Unbinds all events previously bound by LivePosition. Use it whenever you don't need LivePosition instance
+	 * Unbinds all events previously bound by `LivePosition`. Use it whenever you don't need `LivePosition` instance
 	 * anymore (i.e. when leaving scope in which it was declared or before re-assigning variable that was
 	 * referring to it).
 	 */
@@ -112,7 +114,8 @@ export default class LivePosition extends Position {
 }
 
 /**
- * Binds this LivePosition to the {@link engine.model.Document} that owns this position {@link engine.model.RootElement root}.
+ * Binds this `LivePosition` to the {@link engine.model.Document document} that owns
+ * this position's {@link engine.model.Position#root root}.
  *
  * @ignore
  * @private
@@ -132,7 +135,7 @@ function bindWithDocument() {
 }
 
 /**
- * Updates this position accordingly to the updates applied to the Tree Model. Bases on change events.
+ * Updates this position accordingly to the updates applied to the model. Bases on change events.
  *
  * @ignore
  * @private
@@ -150,7 +153,7 @@ function transform( type, range, position ) {
 	switch ( type ) {
 		case 'insert':
 			let insertBefore = this.stickiness == 'STICKS_TO_NEXT';
-			transformed = this.getTransformedByInsertion( range.start, howMany, insertBefore );
+			transformed = this._getTransformedByInsertion( range.start, howMany, insertBefore );
 			break;
 
 		case 'move':
@@ -162,12 +165,12 @@ function transform( type, range, position ) {
 				( originalRange.start.isEqual( this ) && this.stickiness == 'STICKS_TO_NEXT' ) ||
 				( originalRange.end.isEqual( this ) && this.stickiness == 'STICKS_TO_PREVIOUS' );
 
-			// We can't use .getTransformedByMove() because we have a different if-condition.
+			// We can't use ._getTransformedByMove() because we have a different if-condition.
 			if ( gotMoved ) {
 				transformed = this._getCombined( position, range.start );
 			} else {
 				let insertBefore = this.stickiness == 'STICKS_TO_NEXT';
-				transformed = this.getTransformedByMove( position, range.start, howMany, insertBefore );
+				transformed = this._getTransformedByMove( position, range.start, howMany, insertBefore );
 			}
 			break;
 		default:

+ 13 - 14
packages/ckeditor5-engine/src/model/liverange.js

@@ -8,7 +8,8 @@ import EmitterMixin from '../../utils/emittermixin.js';
 import mix from '../../utils/mix.js';
 
 /**
- * LiveRange is a Range in the Tree Model that updates itself as the tree changes. It may be used as a bookmark.
+ * `LiveRange` is a type of {@link engine.model.Range Range} that updates itself as {@link engine.model.Document document}
+ * is changed through operations. It may be used as a bookmark.
  *
  * **Note:** Be very careful when dealing with `LiveRange`. Each `LiveRange` instance bind events that might
  * have to be unbound. Use {@link engine.model.LiveRange#detach detach} whenever you don't need `LiveRange` anymore.
@@ -28,7 +29,7 @@ export default class LiveRange extends Range {
 	}
 
 	/**
-	 * Unbinds all events previously bound by LiveRange. Use it whenever you don't need LiveRange instance
+	 * Unbinds all events previously bound by `LiveRange`. Use it whenever you don't need `LiveRange` instance
 	 * anymore (i.e. when leaving scope in which it was declared or before re-assigning variable that was
 	 * referring to it).
 	 */
@@ -74,7 +75,7 @@ export default class LiveRange extends Range {
 }
 
 /**
- * Binds this LiveRange to the {@link engine.model.Document} that owns this range.
+ * Binds this `LiveRange` to the {@link engine.model.Document document} that owns this range's {@link engine.model.Range#root root}.
  *
  * @ignore
  * @private
@@ -87,32 +88,30 @@ function bindWithDocument() {
 		this.root.document,
 		'change',
 		( event, type, changes ) => {
-			fixBoundaries.call( this, type, changes.range, changes.sourcePosition );
+			transform.call( this, type, changes.range, changes.sourcePosition );
 		},
 		this
 	);
 }
 
 /**
- * LiveRange boundaries are instances of {@link engine.model.LivePosition}, so it is updated thanks to them. This method
- * additionally fixes the results of updating live positions taking into account that those live positions
- * are boundaries of a range. An example case for fixing live positions is end boundary is moved before start boundary.
+ * Updates this range accordingly to the updates applied to the model. Bases on change events.
  *
  * @ignore
  * @private
- * @method fixBoundaries
+ * @method transform
  * @param {String} type Type of changes applied to the Tree Model.
  * @param {engine.model.Range} range Range containing the result of applied change.
  * @param {engine.model.Position} [position] Additional position parameter provided by some change events.
  */
-function fixBoundaries( type, range, position ) {
+function transform( type, range, position ) {
 	/* jshint validthis: true */
 	let updated;
 	const howMany = range.end.offset - range.start.offset;
 
 	switch ( type ) {
 		case 'insert':
-			updated = this.getTransformedByInsertion( range.start, howMany, false, true )[ 0 ];
+			updated = this._getTransformedByInsertion( range.start, howMany, false, true )[ 0 ];
 			break;
 
 		case 'move':
@@ -120,12 +119,12 @@ function fixBoundaries( type, range, position ) {
 		case 'reinsert':
 			const sourcePosition = position;
 
-			// Range.getTransformedByMove is expecting `targetPosition` to be "before" move
+			// Range._getTransformedByMove is expecting `targetPosition` to be "before" move
 			// (before transformation). `range.start` is already after the move happened.
 			// We have to revert `range.start` to the state before the move.
-			const targetPosition = range.start.getTransformedByInsertion( sourcePosition, howMany );
+			const targetPosition = range.start._getTransformedByInsertion( sourcePosition, howMany );
 
-			const result = this.getTransformedByMove( sourcePosition, targetPosition, howMany, false, true );
+			const result = this._getTransformedByMove( sourcePosition, targetPosition, howMany, false, true );
 
 			// First item in the array is the "difference" part, so a part of the range
 			// that did not get moved. We use it as reference range and expand if possible.
@@ -133,7 +132,7 @@ function fixBoundaries( type, range, position ) {
 
 			// We will check if there is other range and if it is touching the reference range.
 			// If it does, we will expand the reference range (at the beginning or at the end).
-			// Keep in mind that without settings `spread` flag, `getTransformedByMove` may
+			// Keep in mind that without settings `spread` flag, `_getTransformedByMove` may
 			// return maximum two ranges.
 			if ( result.length > 1 ) {
 				let otherRange = result[ 1 ];

+ 20 - 17
packages/ckeditor5-engine/src/model/liveselection.js

@@ -6,7 +6,8 @@
 import LiveRange from './liverange.js';
 import Range from './range.js';
 import Position from './position.js';
-import CharacterProxy from './characterproxy.js';
+import Text from './text.js';
+import TextProxy from './textproxy.js';
 import toMap from '../../utils/tomap.js';
 
 import Selection from './selection.js';
@@ -14,21 +15,24 @@ import Selection from './selection.js';
 const storePrefix = 'selection:';
 
 /**
- * `LiveSelection` is a special type of {@link engine.model.Selection selection} that listens to changes on a
+ * `LiveSelection` is a type of {@link engine.model.Selection selection} that listens to changes on a
  * {@link engine.model.Document document} and has it ranges updated accordingly. Internal implementation of this
  * mechanism bases on {@link engine.model.LiveRange live ranges}.
  *
- * Differences between {@link engine.model.Selection} and `LiveSelection` are three:
- * * there is always a range in `LiveSelection`, even if no ranges were added - in this case, there is a
- * "default range" in selection which is a collapsed range set at the beginning of the {@link engine.model.Document document},
- * * ranges added to this selection updates automatically when the document changes,
- * * live selection may have attributes.
+ * Differences between {@link engine.model.Selection} and `LiveSelection` are two:
+ * * there is always a range in `LiveSelection` - even if no ranges were added there is a
+ * {@link engine.model.LiveSelection#_getDefaultRange "default range"} present in the selection,
+ * * ranges added to this selection updates automatically when the document changes.
+ *
+ * Since `LiveSelection` uses {@link engine.model.LiveRange live ranges} and is updated when {@link engine.model.Document document}
+ * changes, it cannot be set on {@link engine.model.Node nodes} that are inside {@link engine.model.DocumentFragment document fragment}.
+ * If you need to represent a selection in document fragment, use {@link engine.model.Selection "normal" selection} instead.
  *
  * @memberOf engine.model
  */
 export default class LiveSelection extends Selection {
 	/**
-	 * Creates an empty document selection for given {@link engine.model.Document}.
+	 * Creates an empty live selection for given {@link engine.model.Document}.
 	 *
 	 * @param {engine.model.Document} document Document which owns this selection.
 	 */
@@ -166,7 +170,7 @@ export default class LiveSelection extends Selection {
 
 	/**
 	 * Returns a default range for this selection. The default range is a collapsed range that starts and ends
-	 * at the beginning of this selection's document {@link engine.model.Document#_getDefaultRoot default root}.
+	 * at the beginning of this selection's document's {@link engine.model.Document#_getDefaultRoot default root}.
 	 * This "artificial" range is important for algorithms that base on selection, so they won't break or need
 	 * special logic if there are no real ranges in the selection.
 	 *
@@ -197,11 +201,11 @@ export default class LiveSelection extends Selection {
 		const selectionParent = this.getFirstPosition().parent;
 
 		if ( this.isCollapsed && selectionParent.getChildCount() === 0 ) {
-			for ( let attr of selectionParent.getAttributes() ) {
-				if ( attr[ 0 ].indexOf( storePrefix ) === 0 ) {
-					const realKey = attr[ 0 ].substr( storePrefix.length );
+			for ( let key of selectionParent.getAttributeKeys() ) {
+				if ( key.indexOf( storePrefix ) === 0 ) {
+					const realKey = key.substr( storePrefix.length );
 
-					yield [ realKey, attr[ 1 ] ];
+					yield [ realKey, selectionParent.getAttribute( key ) ];
 				}
 			}
 		}
@@ -281,7 +285,6 @@ export default class LiveSelection extends Selection {
 	 */
 	_updateAttributes() {
 		const position = this.getFirstPosition();
-		const positionParent = position.parent;
 
 		let attrs = null;
 
@@ -300,8 +303,8 @@ export default class LiveSelection extends Selection {
 		} else {
 			// 2. If the selection is a caret or the range does not contain a character node...
 
-			const nodeBefore = positionParent.getChild( position.offset - 1 );
-			const nodeAfter = positionParent.getChild( position.offset );
+			const nodeBefore = position.textNode ? position.textNode : position.nodeBefore;
+			const nodeAfter = position.textNode ? position.textNode : position.nodeAfter;
 
 			// ...look at the node before caret and take attributes from it if it is a character node.
 			attrs = getAttrsIfCharacter( nodeBefore );
@@ -344,7 +347,7 @@ export default class LiveSelection extends Selection {
 		}
 
 		function getAttrsIfCharacter( node ) {
-			if ( node instanceof CharacterProxy ) {
+			if ( node instanceof TextProxy || node instanceof Text ) {
 				return node.getAttributes();
 			}
 

+ 199 - 48
packages/ckeditor5-engine/src/model/node.js

@@ -7,22 +7,47 @@ import toMap from '../../utils/tomap.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
 
 /**
- * Tree model node. This is an abstract class for other classes representing different nodes in Tree Model.
+ * Model node. Most basic structure of model tree.
+ *
+ * This is an abstract class that is a base for other classes representing different nodes in model.
+ *
+ * **Note:** If a node is detached from the model tree, you can manipulate it using it's API.
+ * However, it is **very important** that nodes already attached to model tree should be only changed through
+ * {@link engine.model.Document#batch Batch API}.
+ *
+ * Changes done by `Node` methods, like {@link engine.model.Node#insertChildren insertChildren} or
+ * {@link engine.model.Node#setAttribute setAttribute} do not generate {@link engine.model.operation.Operation operations}
+ * which are essential for correct editor work if you modify nodes in {@link engine.model.Document document} root.
+ *
+ * The flow of working on `Node` (and classes that inherits from it) is as such:
+ * 1. You can create a `Node` instance, modify it using it's API.
+ * 2. Add `Node` to the model using `Batch` API.
+ * 3. Change `Node` that was already added to the model using `Batch` API.
+ *
+ * Similarly, you cannot use `Batch` API on a node that has not been added to the model tree, with the exception
+ * of {@link engine.model.Batch#insert inserting} that node to the model tree.
+ *
+ * Be aware that using {@link engine.model.Batch#remove remove from Batch API} does not allow to use `Node` API because
+ * the information about `Node` is still kept in model document.
+ *
+ * In case of {@link engine.model.Element element node}, adding and removing children also counts as changing a node and
+ * follows same rules.
  *
  * @memberOf engine.model
  */
 export default class Node {
 	/**
-	 * Creates a tree node.
+	 * Creates a model node.
 	 *
 	 * This is an abstract class, so this constructor should not be used directly.
 	 *
 	 * @abstract
-	 * @param {Iterable|Object} [attrs] Iterable collection of attributes.
+	 * @param {Object} [attrs] Node's attributes. See {@link utils.toMap} for a list of accepted values.
 	 */
 	constructor( attrs ) {
 		/**
-		 * Element or DocumentFragment that is a parent of this node.
+		 * Parent of this node. It could be {@link engine.model.Element} or {@link engine.model.DocumentFragment}.
+		 * Equals to `null` if the node has no parent.
 		 *
 		 * @readonly
 		 * @member {engine.model.Element|engine.model.DocumentFragment|null} engine.model.Node#parent
@@ -30,39 +55,16 @@ export default class Node {
 		this.parent = null;
 
 		/**
-		 * List of attributes set on this node.
+		 * Attributes set on this node.
 		 *
-		 * **Note:** It is **important** that attributes of nodes already attached to the document must be changed
-		 * only by an {@link engine.model.operation.AttributeOperation}. Do not set attributes of such nodes
-		 * using {@link engine.model.Node} methods.
-		 *
-		 * @protected
+		 * @private
 		 * @member {Map} engine.model.Node#_attrs
 		 */
 		this._attrs = toMap( attrs );
 	}
 
 	/**
-	 * Depth of the node, which equals to total number of its parents.
-	 *
-	 * @readonly
-	 * @type {Number}
-	 */
-	get depth() {
-		let depth = 0;
-		let parent = this.parent;
-
-		while ( parent ) {
-			depth++;
-
-			parent = parent.parent;
-		}
-
-		return depth;
-	}
-
-	/**
-	 * Nodes next sibling or `null` if it is the last child.
+	 * Node's next sibling or `null` if the node is a last child of it's parent or if the node has no parent.
 	 *
 	 * @readonly
 	 * @type {engine.model.Node|null}
@@ -74,7 +76,7 @@ export default class Node {
 	}
 
 	/**
-	 * Nodes previous sibling or null if it is the last child.
+	 * Node's previous sibling or `null` if the node is a first child of it's parent or if the node has no parent.
 	 *
 	 * @readonly
 	 * @type {engine.model.Node|null}
@@ -86,10 +88,11 @@ export default class Node {
 	}
 
 	/**
-	 * The top parent for the node. If node has no parent it is the root itself.
+	 * The top-most ancestor of the node. If node has no parent it is the root itself. If the node is a part
+	 * of {@link engine.model.DocumentFragment}, it's `root` is equal to that `DocumentFragment`.
 	 *
 	 * @readonly
-	 * @type {engine.model.Element}
+	 * @type {engine.model.Node|engine.model.DocumentFragment}
 	 */
 	get root() {
 		let root = this;
@@ -102,11 +105,94 @@ export default class Node {
 	}
 
 	/**
-	 * Index of the node in the parent element or null if the node has no parent.
+	 * {@link engine.model.Document Document} that owns this node or `null` if the node has no parent or is inside
+	 * a {@link engine.model.DocumentFragment DocumentFragment}.
+	 *
+	 * @readonly
+	 * @type {engine.model.Document|null}
+	 */
+	get document() {
+		// This is a top element of a sub-tree.
+		if ( this.root == this ) {
+			return null;
+		}
+
+		// Root may be `DocumentFragment` which does not have document property.
+		return this.root.document || null;
+	}
+
+	/**
+	 * Offset at which this node starts in it's parent. It is equal to the sum of {@link engine.model.Node#offsetSize offsetSize}
+	 * of all it's previous siblings. Equals to `null` if node has no parent.
 	 *
-	 * Throws error if the parent element does not contain this node.
+	 * Throws error if the parent element does not contain this node. This means that model tree got broken.
+	 *
+	 * @readonly
+	 * @type {Number|Null}
+	 */
+	get startOffset() {
+		let pos;
+
+		if ( !this.parent ) {
+			return null;
+		}
+
+		if ( ( pos = this.parent.getChildStartOffset( this ) ) === null ) {
+			/**
+			 * The node's parent does not contain this node.
+			 *
+			 * @error node-not-found-in-parent
+			 */
+			throw new CKEditorError( 'node-not-found-in-parent: The node\'s parent does not contain this node.' );
+		}
+
+		return pos;
+	}
+
+	/**
+	 * Offset size of this node. Represents how much "offset space" is occupied by the node in it's parent.
+	 * It is important for {@link engine.model.Position position}. When node has `offsetSize` greater than `1`, position
+	 * can be placed between that node start and end. `offsetSize` greater than `1` is for nodes that represents more
+	 * than one entity, i.e. {@link engine.model.Text text node}.
 	 *
-	 * @returns {Number|Null} Index of the node in the parent element or null if the node has not parent.
+	 * @readonly
+	 * @type {Number}
+	 */
+	get offsetSize() {
+		return 1;
+	}
+
+	/**
+	 * Offset at which this node ends in it's parent. It is equal to the sum of this node's
+	 * {@link engine.model.Node#startOffset start offset} and {@link engine.model.Node#offsetSize offset size}. Equals
+	 * to `null` if the node has no parent.
+	 *
+	 * @readonly
+	 * @type {Number|null}
+	 */
+	get endOffset() {
+		if ( !this.parent ) {
+			return null;
+		}
+
+		return this.startOffset + this.offsetSize;
+	}
+
+	/**
+	 * Creates a copy of this node, that is a node with exactly same attributes, and returns it.
+	 *
+	 * @returns {engine.model.Node} Node with same attributes as this node.
+	 */
+	clone() {
+		return new Node( this._attrs );
+	}
+
+	/**
+	 * Index of this node in it's parent or `null` if the node has no parent.
+	 *
+	 * Throws error if the parent element does not contain this node. This means that model tree got broken.
+	 *
+	 * @returns {Number|null}
 	 */
 	getIndex() {
 		let pos;
@@ -115,7 +201,7 @@ export default class Node {
 			return null;
 		}
 
-		if ( ( pos = this.parent.getChildIndex( this ) ) == -1 ) {
+		if ( ( pos = this.parent.getChildIndex( this ) ) === null ) {
 			/**
 			 * The node's parent does not contain this node.
 			 *
@@ -128,17 +214,27 @@ export default class Node {
 	}
 
 	/**
-	 * Gets path to the node. For example if the node is the second child of the first child of the root then the path
-	 * will be `[ 1, 2 ]`. This path can be used as a parameter of {@link engine.model.Position}.
+	 * Gets path to the node. The path is an array containing starting offsets of consecutive ancestors of this node,
+	 * beginning from {@link engine.model.Node#root root}, down to this node's starting offset. The path can be used to
+	 * create {@link engine.model.Position Position} instance.
 	 *
-	 * @returns {Number[]} The path.
+	 *		const abc = new Text( 'abc' );
+	 *		const foo = new Text( 'foo' );
+	 *		const h1 = new Element( 'h1', null, new Text( 'header' ) );
+	 *		const p = new Element( 'p', null, [ abc, foo ] );
+	 *		const div = new Element( 'div', null, [ h1, p ] );
+	 *		foo.getPath(); // Returns [ 1, 3 ]. `foo` is in `p` which is in `div`. `p` starts at offset 1, while `foo` at 3.
+	 *		h1.getPath(); // Returns [ 0 ].
+	 *		div.getPath(); // Returns [].
+	 *
+	 * @returns {Array.<Number>} The path.
 	 */
 	getPath() {
 		const path = [];
 		let node = this;
 
 		while ( node.parent ) {
-			path.unshift( node.getIndex() );
+			path.unshift( node.startOffset );
 			node = node.parent;
 		}
 
@@ -146,7 +242,14 @@ export default class Node {
 	}
 
 	/**
-	 * Checks if the node has an attribute for given key.
+	 * Removes this node from it's parent.
+	 */
+	remove() {
+		this.parent.removeChildren( this.getIndex() );
+	}
+
+	/**
+	 * Checks if the node has an attribute with given key.
 	 *
 	 * @param {String} key Key of attribute to check.
 	 * @returns {Boolean} `true` if attribute with given key is set on node, `false` otherwise.
@@ -156,28 +259,76 @@ export default class Node {
 	}
 
 	/**
-	 * Gets an attribute value for given key or undefined if that attribute is not set on node.
+	 * Gets an attribute value for given key or `undefined` if that attribute is not set on node.
 	 *
 	 * @param {String} key Key of attribute to look for.
-	 * @returns {*} Attribute value or null.
+	 * @returns {*} Attribute value or `undefined`.
 	 */
 	getAttribute( key ) {
 		return this._attrs.get( key );
 	}
 
 	/**
-	 * Returns iterator that iterates over this node attributes.
+	 * Returns iterator that iterates over this node's attributes.
+	 *
+	 * Attributes are returned as arrays containing two items. First one is attribute key and second is attribute value.
+	 * This format is accepted by native `Map` object and also can be passed in `Node` constructor.
 	 *
 	 * @returns {Iterable.<*>}
 	 */
 	getAttributes() {
-		return this._attrs[ Symbol.iterator ]();
+		return this._attrs.entries();
+	}
+
+	/**
+	 * Returns iterator that iterates over this node's attribute keys.
+	 *
+	 * @returns {Iterator.<String>}
+	 */
+	getAttributeKeys() {
+		return this._attrs.keys();
+	}
+
+	/**
+	 * Sets attribute on the node. If attribute with the same key already is set, it's value is overwritten.
+	 *
+	 * @param {String} key Key of attribute to set.
+	 * @param {*} value Attribute value.
+	 */
+	setAttribute( key, value ) {
+		this._attrs.set( key, value );
+	}
+
+	/**
+	 * Removes all attributes from the node and sets given attributes.
+	 *
+	 * @param {Object} [attrs] Attributes to set. See {@link utils.toMap} for a list of accepted values.
+	 */
+	setAttributesTo( attrs ) {
+		this._attrs = toMap( attrs );
+	}
+
+	/**
+	 * Removes an attribute with given key from the node.
+	 *
+	 * @param {String} key Key of attribute to remove.
+	 * @returns {Boolean} `true` if the attribute was set on the element, `false` otherwise.
+	 */
+	removeAttribute( key ) {
+		return this._attrs.delete( key );
+	}
+
+	/**
+	 * Removes all attributes from the node.
+	 */
+	clearAttributes() {
+		this._attrs.clear();
 	}
 
 	/**
-	 * Custom toJSON method to solve child-parent circular dependencies.
+	 * Converts `Node` to plain object and returns it.
 	 *
-	 * @returns {Object} Clone of this object with the parent property replaced with its name.
+	 * @returns {Object} `Node` converted to plain object.
 	 */
 	toJSON() {
 		let json = {};

+ 106 - 398
packages/ckeditor5-engine/src/model/nodelist.js

@@ -3,496 +3,204 @@
  * For licensing, see LICENSE.md.
  */
 
-import CharacterProxy from './characterproxy.js';
-import Text from './text.js';
-import Element from './element.js';
-import DocumentFragment from './documentfragment.js';
-import mapsEqual from '../../utils/mapsequal.js';
-import isIterable from '../../utils/isiterable.js';
+import Node from './node.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
 
 /**
- * This is a private helper-class for {@link engine.model.NodeList} text compression utility.
- *
- * @protected
- * @memberOf engine.model
- * @extends engine.model.Text
- */
-class NodeListText extends Text {
-	/**
-	 * @see engine.model.Text#constructor
-	 * @protected
-	 * @constructor
-	 */
-	constructor( text, attrs ) {
-		super( text, attrs );
-
-		/**
-		 * Element that contains character nodes represented by this NodeListText.
-		 *
-		 * @type {engine.model.Element|engine.model.DocumentFragment|null}
-		 */
-		this.parent = null;
-	}
-
-	/**
-	 * Gets a character at given index and creates a {@link engine.model.CharacterProxy} out of it.
-	 *
-	 * @param {Number} index Character index.
-	 * @returns {engine.model.CharacterProxy}
-	 */
-	getCharAt( index ) {
-		index = index && index >= 0 ? index : 0;
-
-		return new CharacterProxy( this, index );
-	}
-}
-
-/**
- * List of nodes. It is used to represent multiple nodes with a given order, for example children of
- * {@link engine.model.Element} object or nodes inserted using {@link engine.model.operation.InsertOperation}.
- *
- * Thanks to the constructor, which accepts various arguments, this class lets you easily create desired list of nodes.
- *
- * Parameters passed to constructor are converted and internally kept as an array of {@link engine.model.Node}
- * and {@link engine.model.Text} instances.
- *
- * @memberOf engine.model
+ * Provides an interface to operate on a list of {@link engine.model.Node nodes}. `NodeList` is used internally
+ * in classes like {@link engine.model.Element Element} or {@link engine.model.DocumentFragment DocumentFragment}.
  */
 export default class NodeList {
 	/**
-	 * Constructor lets you create a list of nodes in many ways. See examples:
-	 *
-	 *		let nodeList = new NodeList( [ new Element( p1 ), new Element( p1 ) ] );
-	 *		nodeList.length; // 2
-	 *
-	 *		let nodeList = new NodeList( new Element( p ) );
-	 *		nodeList.length; // 1
+	 * Creates an empty node list.
 	 *
-	 *		let nodeList = new NodeList( [ 'foo', new Element( p ), 'bar' ] );
-	 *		nodeList.length; // 7
-	 *
-	 *		let nodeList = new NodeList( 'foo' );
-	 *		nodeList.length; // 3
-	 *
-	 *		let nodeList = new NodeList( new Text( 'foo', { bar: 'bom' } ) );
-	 *		nodeList.length; // 3
-	 *		nodeList.get( 0 ).getAttribute( 'bar' ); // 'bom'
-	 *		nodeList.get( 1 ).getAttribute( 'bar' ); // 'bom'
-	 *		nodeList.get( 2 ).getAttribute( 'bar' ); // 'bom'
-	 *
-	 *		let nodeListA = new NodeList( 'foo' );
-	 *		let nodeListB = new NodeList( nodeListA );
-	 *		nodeListA === nodeListB // true
-	 *		nodeListB.length // 3
-	 *
-	 * @see engine.model.NodeSet
-	 *
-	 * @param {engine.model.NodeSet} nodes List of nodes.
-	 * @constructor
+	 * @param {Iterable.<engine.model.Node>} nodes Nodes contained in this node list.
 	 */
 	constructor( nodes ) {
-		if ( nodes instanceof NodeList ) {
-			// We do not clone anything.
-			return nodes;
-		} else if ( nodes instanceof DocumentFragment ) {
-			return nodes._children;
-		}
-
 		/**
-		 * Internal array to store nodes.
+		 * Nodes contained in this node list.
 		 *
-		 * @protected
-		 * @member {Array} engine.model.NodeList#_nodes
+		 * @private
+		 * @member {Array.<engine.model.Node>} engine.model.NodeList#_nodes
 		 */
 		this._nodes = [];
 
 		/**
-		 * Internal array where each index is mapped to correct node from `_nodes` array. This is introduced
-		 * to easily refer `_nodes` by index, since some of elements in `_nodes` may contain multiple characters,
-		 * which occupy multiple slots in `_indexMap`.
+		 * Represents which node occupies given offset.
 		 *
 		 * @private
-		 * @member {Array} engine.model.NodeList#_indexMap
+		 * @member {Array.<engine.model.Node>} engine.model.NodeList#_nodeAtOffset
 		 */
-		this._indexMap = [];
+		this._nodeAtOffset = [];
 
 		if ( nodes ) {
-			if ( typeof nodes == 'string' || !isIterable( nodes ) ) {
-				nodes = [ nodes ];
-			}
-
-			for ( let node of getNodes( nodes ) ) {
-				let indexInNodes = this._nodes.length;
-				let mergedWithPrev = false;
-				let length = 1;
-
-				if ( node instanceof CharacterProxy ) {
-					node = new NodeListText( node.character, node._attrs );
-				} else if ( node instanceof Text ) {
-					node = new NodeListText( node.text, node._attrs );
-				} else if ( typeof node == 'string' ) {
-					node = new NodeListText( node, [] );
-				}
-
-				if ( node instanceof NodeListText ) {
-					length = node.text.length;
-
-					let prev = this._nodes[ this._nodes.length - 1 ];
-
-					if ( prev instanceof NodeListText && mapsEqual( prev._attrs, node._attrs ) ) {
-						// If previously added text has same attributes, merge this text with it.
-						prev.text += node.text;
-						mergedWithPrev = true;
-						indexInNodes--;
-					} else if ( node.text.length === 0 ) {
-						// If this is an empty text just omit it.
-						continue;
-					}
-				}
-
-				if ( !mergedWithPrev ) {
-					this._nodes.push( node );
-				}
-
-				for ( let i = 0; i < length; i++ ) {
-					this._indexMap.push( indexInNodes );
-				}
-			}
+			this.insertNodes( 0, nodes );
 		}
 	}
 
 	/**
-	 * Number of nodes in the node list.
+	 * Returns an iterator that iterates over all nodes contained inside this node list.
 	 *
-	 * @readonly
-	 * @type {Number}
+	 * @returns {Iterator.<engine.model.Node>}
 	 */
-	get length() {
-		return this._indexMap.length;
+	[ Symbol.iterator ]() {
+		return this._nodes[ Symbol.iterator ]();
 	}
 
 	/**
-	 * Node list iterator.
-	 */
-	*[ Symbol.iterator ]() {
-		for ( let i = 0; i < this.length; i++ ) {
-			yield this.get( i );
-		}
-	}
-
-	/**
-	 * Returns node at the given index.
+	 * Returns the number of nodes contained inside this node list.
 	 *
-	 * @param {Number} index Node index.
-	 * @returns {engine.model.Node} Node at given index.
+	 * @returns {Number}
 	 */
-	get( index ) {
-		let realIndex = this._indexMap[ index ];
-		let node = this._nodes[ realIndex ];
-
-		if ( node instanceof NodeListText ) {
-			return node.getCharAt( this._getCharIndex( index ) );
-		} else {
-			return node;
-		}
+	get length() {
+		return this._nodes.length;
 	}
 
 	/**
-	 * Search for the element in the node list.
+	 * Returns the sum of {engine.model.Node#offsetSize offset sizes} of all nodes contained inside this node list.
 	 *
-	 * @param {engine.model.Node} node Node to find.
-	 * @returns {Number} Position of the element in the list or -1 if not found.
+	 * @returns {Number}
 	 */
-	indexOf( node ) {
-		if ( node instanceof CharacterProxy ) {
-			let baseIndex = this.indexOf( node._nodeListText );
-
-			return baseIndex == -1 ? -1 : baseIndex + node._index;
-		}
-
-		let realIndex = this._nodes.indexOf( node );
-
-		return this._indexMap.indexOf( realIndex );
+	get totalOffset() {
+		return this._nodeAtOffset.length;
 	}
 
 	/**
-	 * Inserts nodes from the given node list into this node list at the given index.
+	 * Gets the node at the given index. Returns `null` if incorrect index was passed.
 	 *
-	 * @param {Number} index Position where nodes should be inserted.
-	 * @param {engine.model.NodeList} nodeList List of nodes to insert.
+	 * @param {Number} index Index of node.
+	 * @returns {engine.model.Node|null} Node at given index.
 	 */
-	insert( index, nodeList ) {
-		if ( this._nodes.length === 0 ) {
-			this._nodes = nodeList._nodes.slice();
-			this._indexMap = nodeList._indexMap.slice();
-
-			return;
-		}
-
-		// If we are inserting into a text, splitting may be needed.
-		this._splitNodeAt( index );
-
-		// If `index` is too high to be found in `_indexMap` it means that we insert at the end of node list.
-		let realIndex = index >= this._indexMap.length ? this._nodes.length : this._indexMap[ index ];
-
-		// Splice arrays from inserted nodeList into this nodeList.
-		this._indexMap.splice.apply( this._indexMap, [ index, 0 ].concat( nodeList._indexMap ) );
-		this._nodes.splice.apply( this._nodes, [ realIndex, 0 ].concat( nodeList._nodes ) );
-
-		// Fix indexes in index map.
-		// From the beginning of spliced-in array to the end of spliced-in array.
-		for ( let i = index; i < index + nodeList._indexMap.length; i++ ) {
-			this._indexMap[ i ] += realIndex;
-		}
-
-		// From the end of spliced-in array to the end of original array.
-		for ( let i = index + nodeList._indexMap.length; i < this._indexMap.length; i++ ) {
-			this._indexMap[ i ] += nodeList._nodes.length;
-		}
-
-		this._mergeNodeAt( index );
-		this._mergeNodeAt( index + nodeList.length );
+	getNode( index ) {
+		return this._nodes[ index ] || null;
 	}
 
 	/**
-	 * Removes number of nodes starting at the given index.
+	 * Returns an index of the given node. Returns `null` if given node is not inside this node list.
 	 *
-	 * @param {Number} index Position of the first node to remove.
-	 * @param {Number} number Number of nodes to remove.
-	 * @returns {engine.model.NodeList} List of removed nodes.
+	 * @param {engine.model.Node} node Child node to look for.
+	 * @returns {Number|null} Child node's index.
 	 */
-	remove( index, number ) {
-		if ( this._nodes.length === 0 ) {
-			return new NodeList();
-		}
-
-		// Removed "range" may start in NodeListText or end in NodeListText. Some splitting may be needed.
-		this._splitNodeAt( index );
-		this._splitNodeAt( index + number );
-
-		// If given index is too high to be found in `_indexMap` it means that we remove to the end of node list.
-		let realIndexEnd = ( index + number ) >= this._indexMap.length ? this._nodes.length : this._indexMap[ index + number ];
-		let realIndexStart = this._indexMap[ index ];
-		let removed = this._nodes.splice( realIndexStart, realIndexEnd - realIndexStart );
-
-		this._indexMap.splice( index, number );
+	getNodeIndex( node ) {
+		const index = this._nodes.indexOf( node );
 
-		for ( let i = index; i < this._indexMap.length; i++ ) {
-			this._indexMap[ i ] -= removed.length;
-		}
-
-		this._mergeNodeAt( index );
-
-		return new NodeList( removed );
+		return index == -1 ? null : index;
 	}
 
 	/**
-	 * Sets or removes given attribute on a range of nodes in the node list.
+	 * Returns the starting offset of given node. Starting offset is equal to the sum of
+	 * {engine.model.Node#offsetSize offset sizes} of all nodes that are before this node in this node list.
 	 *
-	 * @param {Number} index Position of the first node to change.
-	 * @param {Number} number Number of nodes to change.
-	 * @param {String} key Attribute key to change.
-	 * @param {*} [attribute] Attribute value or null if attribute with given key should be removed.
+	 * @param {engine.model.Node} node Node to look for.
+	 * @returns {Number|null} Node's starting offset.
 	 */
-	setAttribute( index, number, key, value ) {
-		if ( index < 0 || index + number > this.length ) {
-			/**
-			 * Trying to set attribute on non-existing node list items.
-			 *
-			 * @error nodelist-setattribute-out-of-bounds
-			 * @param root
-			 */
-			throw new CKEditorError( 'nodelist-setattribute-out-of-bounds: Trying to set attribute on non-existing node list items.' );
-		}
-
-		// "Range" of nodes to remove attributes may start in NodeListText or end in NodeListText. Some splitting may be needed.
-		this._splitNodeAt( index );
-		this._splitNodeAt( index + number );
-
-		let i = index;
-
-		while ( i < index + number ) {
-			let node = this._nodes[ this._indexMap[ i ] ];
-
-			if ( node instanceof NodeListText ) {
-				if ( value !== null ) {
-					node._attrs.set( key, value );
-				} else {
-					node._attrs.delete( key );
-				}
-
-				this._mergeNodeAt( i );
-				i += node.text.length;
-			} else {
-				if ( value !== null ) {
-					node.setAttribute( key, value );
-				} else {
-					node.removeAttribute( key );
-				}
-
-				i++;
-			}
-		}
+	getNodeStartOffset( node ) {
+		const offset = this._nodeAtOffset.indexOf( node );
 
-		this._mergeNodeAt( index + number );
+		return offset == -1 ? null : offset;
 	}
 
 	/**
-	 * Custom toJSON method to solve child-parent circular dependencies.
+	 * Converts index "position" to offset "position".
 	 *
-	 * @returns {Object} Clone of this object with the parent property replaced with its name.
+	 * 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.
+	 *
+	 * @param {Number} index Node's index.
+	 * @returns {Number} Node's starting offset.
 	 */
-	toJSON() {
-		if ( !this._nodes.length ) {
-			return {};
+	indexToOffset( index ) {
+		if ( index < 0 ) {
+			return 0;
+		} else if ( index >= this._nodes.length ) {
+			return this.totalOffset;
 		}
 
-		let json = { nodes: [] };
-
-		for ( let node of this._nodes ) {
-			json.nodes.push( node.toJSON() );
-		}
+		const node = this._nodes[ index ];
 
-		return json;
+		return this.getNodeStartOffset( node );
 	}
 
 	/**
-	 * Creates NodeList object from deserilized object, ie. from parsed JSON string.
+	 * Converts offset "position" to index "position".
 	 *
-	 *		let deserialized = JSON.parse( JSON.stringify( someNodeList ) );
-	 *		let nodeList = NodeList.fromJSON( deserialized );
+	 * 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.
 	 *
-	 * @param {Object} json Deserialized JSON object.
-	 * @returns {engine.model.NodeList}
+	 * @param {Number} offset Offset to look for.
+	 * @returns {Number} Index of a node that occupies given offset.
 	 */
-	static fromJSON( json ) {
-		let nodes = [];
-
-		if ( json.nodes ) {
-			for ( let node of json.nodes ) {
-				if ( node.text ) {
-					nodes.push( new Text( node.text, node.attributes ) );
-				} else {
-					nodes.push( Element.fromJSON( node ) );
-				}
-			}
+	offsetToIndex( offset ) {
+		if ( offset < 0 ) {
+			return 0;
+		} else if ( offset >= this._nodeAtOffset.length ) {
+			return this.length;
 		}
 
-		return new NodeList( nodes );
+		const node = this._nodeAtOffset[ offset ];
+
+		return this.getNodeIndex( node );
 	}
 
 	/**
-	 * Checks whether given index is inside a text and if so, splits that text node. This method should be used
-	 * to split text objects whenever there are some changes made on a part of text object (i.e. removing part of text,
-	 * inserting between text object, changing attributes of part of a text object).
+	 * Inserts given nodes at given index.
 	 *
-	 * @protected
-	 * @param {Number} index Index in the node list at which node should be broken.
+	 * @param {Number} index Index at which nodes should be inserted.
+	 * @param {Iterable.<engine.model.Node>} nodes Nodes to be inserted.
 	 */
-	_splitNodeAt( index ) {
-		if ( this._indexMap[ index ] != this._indexMap[ index - 1 ] || this._indexMap.length === 0 ) {
-			// Node before and node after splitting point are already different.
-			// Or the node list is empty.
-			// No splitting is needed.
-			return;
+	insertNodes( index, nodes ) {
+		// Validation.
+		for ( let node of nodes ) {
+			if ( !( node instanceof Node ) ) {
+				/**
+				 * Trying to insert an object which is not a Node instance.
+				 *
+				 * @error nodelist-insertNodes-not-node
+				 */
+				throw new CKEditorError( 'nodelist-insertNodes-not-node: Trying to insert an object which is not a Node instance.' );
+			}
 		}
 
-		let realIndex = this._indexMap[ index ];
-		let node = this._nodes[ realIndex ];
-
-		// Get position in the text node where the text should be split.
-		let charIndex = this._getCharIndex( index );
-
-		// Get text part before and after split point.
-		let textBefore = node.text.substr( 0, charIndex );
-		let textAfter = node.text.substr( charIndex );
+		const offset = this.indexToOffset( index );
 
-		// "Remove" part after split point from current text node.
-		node.text = textBefore;
+		this._nodes.splice( index, 0, ...nodes );
 
-		// Create a new text node with the "removed" part and splice it after original node.
-		let newText = new NodeListText( textAfter, node._attrs );
-		newText.parent = node.parent;
-		this._nodes.splice.call( this._nodes, realIndex + 1, 0, newText );
+		const offsetsArray = [];
 
-		// We added new element in the middle of _nodes what invalidated _indexMap. We have to fix it.
-		for ( let i = index; i < this._indexMap.length; i++ ) {
-			this._indexMap[ i ]++;
+		for ( let node of nodes ) {
+			for ( let i = 0; i < node.offsetSize; i++ ) {
+				offsetsArray.push( node );
+			}
 		}
+
+		this._nodeAtOffset.splice( offset, 0, ...offsetsArray );
 	}
 
 	/**
-	 * Checks whether given index is between two text nodes that have same attributes and if so, merges them
-	 * together into one node. Used to compress characters into large text objects and use less memory. This method
-	 * should be used whenever there are some changed done to the node list to check whether it is possible to merge
-	 * text objects.
+	 * Removes one or more nodes starting at the given index.
 	 *
-	 * @param {Number} index Index in the node list at which node should be merged.
-	 * @protected
+	 * @param {Number} indexStart Index of the first node to remove.
+	 * @param {Number} [howMany=1] Number of nodes to remove.
+	 * @returns {Array.<engine.model.Node>} Array containing removed nodes.
 	 */
-	_mergeNodeAt( index ) {
-		if ( this._indexMap[ index ] == this._indexMap[ index - 1 ] || this._indexMap.length === 0 ) {
-			// Node before and node after splitting point are already same.
-			// Or the node list is empty.
-			// No splitting is needed.
-			return;
-		}
+	removeNodes( indexStart, howMany = 1 ) {
+		const indexEnd = indexStart + howMany;
 
-		// Get the node before and after given index.
-		let realIndexBefore = this._indexMap[ index - 1 ];
-		let realIndexAfter = this._indexMap[ index ];
+		const offsetStart = this.indexToOffset( indexStart );
+		const offsetEnd = this.indexToOffset( indexEnd );
 
-		let nodeBefore = this._nodes[ realIndexBefore ];
-		let nodeAfter = this._nodes[ realIndexAfter ];
+		this._nodeAtOffset.splice( offsetStart, offsetEnd - offsetStart );
 
-		// Check if both of those nodes are text objects with same attributes.
-		if ( nodeBefore instanceof NodeListText && nodeAfter instanceof NodeListText && mapsEqual( nodeBefore._attrs, nodeAfter._attrs ) ) {
-			// Append text of text node after index to the before one.
-			nodeBefore.text += nodeAfter.text;
-
-			// Remove text node after index.
-			this._nodes.splice( realIndexAfter, 1 );
-
-			for ( let i = index; i < this._indexMap.length; i++ ) {
-				this._indexMap[ i ]--;
-			}
-		}
+		return this._nodes.splice( indexStart, howMany );
 	}
 
 	/**
-	 * Helper function that takes an index in a node list that is inside a text node and returns the offset of that
-	 * index from the beginning of that text node. If index
+	 * Converts `NodeList` instance to an array containing nodes that were inserted in the node list. Nodes
+	 * are also converted to their plain object representation.
 	 *
-	 * @param index
-	 * @returns {Number} Offset of given index from the beginning of the text node.
-	 * @private
+	 * @returns {Array.<engine.model.Node>} `NodeList` instance converted to `Array`.
 	 */
-	_getCharIndex( index ) {
-		return index - this._indexMap.indexOf( this._indexMap[ index ] );
-	}
-}
-
-/**
- * Value that is convertible to an item kept in {@link engine.model.NodeList} or an iterable collection of such items.
- * In other words, this is anything that {@link engine.model.NodeList#constructor} is able to take and convert to node:
- * * {@link engine.model.Node} will be left as is
- * * {@link engine.model.Text} and {String} will be converted to a set of {@link engine.model.CharacterProxy}
- * * {@link engine.model.NodeList} will clone a node list (but not the nodes inside, so the new and passed list will
- * 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.model.Node|engine.model.Text|String|engine.model.NodeList|engine.model.DocumentFragment|Iterable}
- * engine.model.NodeSet
- */
-
-// Helper function that "flattens" `engine.model.DocumentFragment`.
-function* getNodes( nodes ) {
-	for ( let node of nodes ) {
-		if ( node instanceof DocumentFragment ) {
-			yield* node;
-		} else {
-			yield node;
-		}
+	toJSON() {
+		return this._nodes.map( ( node ) => node.toJSON() );
 	}
 }

+ 30 - 22
packages/ckeditor5-engine/src/model/operation/attributeoperation.js

@@ -6,9 +6,12 @@
 import Operation from './operation.js';
 import Range from '../range.js';
 import CKEditorError from '../../../utils/ckeditorerror.js';
+import writer from '../writer.js';
 
 /**
- * Operation to change nodes' attribute. Using this class you can add, remove or change value of the attribute.
+ * Operation to change nodes' attribute.
+ *
+ * Using this class you can add, remove or change value of the attribute.
  *
  * @memberOf engine.model.operation
  * @extends engine.model.operation.Operation
@@ -17,19 +20,19 @@ export default class AttributeOperation extends Operation {
 	/**
 	 * Creates an operation that changes, removes or adds attributes.
 	 *
-	 * If only the new attribute is set, then it will be inserted. Note that in all nodes in ranges there must be
-	 * no attributes with the same key as the new attribute.
+	 * If only `newValue` is set, attribute will be added on a node. Note that all nodes in operation's range must not
+	 * have an attribute with the same key as the added attribute.
 	 *
-	 * If only the old attribute is set, then it will be removed. Note that this attribute must be present in all nodes in
-	 * ranges.
+	 * If only `oldValue` is set, then attribute with given key will be removed. Note that all nodes in operation's range
+	 * must have an attribute with that key added.
 	 *
-	 * If both new and old attributes are set, then the operation will change the attribute value. Note that both new and
-	 * old attributes have to have the same key and the old attribute must be present in all nodes in ranges.
+	 * If both `newValue` and `oldValue` are set, then the operation will change the attribute value. Note that all nodes in
+	 * operation's ranges must already have an attribute with given key and `oldValue` as value
 	 *
 	 * @param {engine.model.Range} range Range on which the operation should be applied.
 	 * @param {String} key Key of an attribute to change or remove.
-	 * @param {*} oldValue Old value of the attribute with given key or `null` if adding a new attribute.
-	 * @param {*} newValue New value to set for the attribute. If `null`, then the operation just removes the attribute.
+	 * @param {*} oldValue Old value of the attribute with given key or `null`, if attribute was not set before.
+	 * @param {*} newValue New value of the attribute with given key or `null`, if operation should remove attribute.
 	 * @param {Number} baseVersion {@link engine.model.Document#version} on which the operation can be applied.
 	 */
 	constructor( range, key, oldValue, newValue, baseVersion ) {
@@ -52,7 +55,7 @@ export default class AttributeOperation extends Operation {
 		this.key = key;
 
 		/**
-		 * Old value of the attribute with given key or `null` if adding a new attribute.
+		 * Old value of the attribute with given key or `null`, if attribute was not set before.
 		 *
 		 * @readonly
 		 * @member {*} engine.model.operation.AttributeOperation#oldValue
@@ -60,7 +63,7 @@ export default class AttributeOperation extends Operation {
 		this.oldValue = oldValue;
 
 		/**
-		 * New value to set for the attribute. If `null`, then the operation just removes the attribute.
+		 * New value of the attribute with given key or `null`, if operation should remove attribute.
 		 *
 		 * @readonly
 		 * @member {*} engine.model.operation.AttributeOperation#newValue
@@ -68,6 +71,9 @@ export default class AttributeOperation extends Operation {
 		this.newValue = newValue;
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	get type() {
 		if ( this.oldValue === null ) {
 			return 'addAttribute';
@@ -79,6 +85,7 @@ export default class AttributeOperation extends Operation {
 	}
 
 	/**
+	 * @inheritDoc
 	 * @returns {engine.model.operation.AttributeOperation}
 	 */
 	clone() {
@@ -86,25 +93,29 @@ export default class AttributeOperation extends Operation {
 	}
 
 	/**
+	 * @inheritDoc
 	 * @returns {engine.model.operation.AttributeOperation}
 	 */
 	getReversed() {
 		return new AttributeOperation( this.range, this.key, this.newValue, this.oldValue, this.baseVersion + 1 );
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	_execute() {
+		// Validation.
 		for ( let item of this.range.getItems() ) {
 			if ( this.oldValue !== null && item.getAttribute( this.key ) !== this.oldValue ) {
 				/**
-				 * The attribute which should be removed does not exists for the given node.
+				 * Changed node has different attribute value than operation's old attribute value.
 				 *
-				 * @error operation-attribute-no-attr-to-remove
+				 * @error operation-attribute-wrong-old-value
 				 * @param {engine.model.Item} item
 				 * @param {String} key
-				 * @param {*} value
 				 */
 				throw new CKEditorError(
-					'operation-attribute-no-attr-to-remove: The attribute which should be removed does not exists for given node.',
+					'operation-attribute-wrong-old-value: Changed node has different attribute value than operation\'s old attribute value.',
 					{ item: item, key: this.key }
 				);
 			}
@@ -122,14 +133,11 @@ export default class AttributeOperation extends Operation {
 					{ node: item, key: this.key }
 				);
 			}
-
-			if ( this.newValue !== null ) {
-				item.setAttribute( this.key, this.newValue );
-			} else {
-				item.removeAttribute( this.key );
-			}
 		}
 
+		// Execution.
+		writer.setAttribute( this.range, this.key, this.newValue );
+
 		return { range: this.range, key: this.key, oldValue: this.oldValue, newValue: this.newValue };
 	}
 
@@ -141,7 +149,7 @@ export default class AttributeOperation extends Operation {
 	}
 
 	/**
-	 * Creates AttributeOperation object from deserilized object, i.e. from parsed JSON string.
+	 * Creates `AttributeOperation` object from deserilized object, i.e. from parsed JSON string.
 	 *
 	 * @param {Object} json Deserialized JSON object.
 	 * @param {engine.model.Document} document Document on which this operation will be applied.

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

@@ -4,13 +4,16 @@
  */
 
 import Operation from './operation.js';
-import NodeList from '../nodelist.js';
 import Position from '../position.js';
-import Range from '../range.js';
+import NodeList from '../nodelist.js';
 import RemoveOperation from './removeoperation.js';
+import writer from './../writer.js';
+import { normalizeNodes } from './../writer.js';
+import Text from '../text.js';
+import Element from '../element.js';
 
 /**
- * Operation to insert list of nodes on the given position in the tree data model.
+ * Operation to insert one or more nodes at given position in the model.
  *
  * @memberOf engine.model.operation
  * @extends engine.model.operation.Operation
@@ -21,7 +24,6 @@ export default class InsertOperation extends Operation {
 	 *
 	 * @param {engine.model.Position} position Position of insertion.
 	 * @param {engine.model.NodeSet} nodes The list of nodes to be inserted.
-	 * List of nodes can be any type accepted by the {@link engine.model.NodeList} constructor.
 	 * @param {Number} baseVersion {@link engine.model.Document#version} on which operation can be applied.
 	 */
 	constructor( position, nodes, baseVersion ) {
@@ -41,33 +43,46 @@ export default class InsertOperation extends Operation {
 		 * @readonly
 		 * @member {engine.model.NodeList} engine.model.operation.InsertOperation#nodeList
 		 */
-		this.nodeList = new NodeList( nodes );
+		this.nodes = new NodeList( normalizeNodes( nodes ) );
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	get type() {
 		return 'insert';
 	}
 
 	/**
+	 * @inheritDoc
 	 * @returns {engine.model.operation.InsertOperation}
 	 */
 	clone() {
-		return new InsertOperation( this.position, this.nodeList, this.baseVersion );
+		return new InsertOperation( this.position, this.nodes, this.baseVersion );
 	}
 
 	/**
+	 * @inheritDoc
 	 * @returns {engine.model.operation.RemoveOperation}
 	 */
 	getReversed() {
-		return new RemoveOperation( this.position, this.nodeList.length, this.baseVersion + 1 );
+		return new RemoveOperation( this.position, this.nodes.totalOffset, this.baseVersion + 1 );
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	_execute() {
-		this.position.parent.insertChildren( this.position.offset, this.nodeList );
+		// What happens here is that we want original nodes be passed to writer because we want original nodes
+		// to be inserted to the model. But in InsertOperation, we want to keep those nodes as they were added
+		// to the operation, not modified. For example, text nodes can get merged or cropped while Elements can
+		// get children. It is important that InsertOperation has the copy of original nodes in intact state.
+		const originalNodes = this.nodes;
+		this.nodes = new NodeList( [ ...originalNodes ].map( ( node ) => node.clone( true ) ) );
 
-		return {
-			range: Range.createFromPositionAndShift( this.position, this.nodeList.length )
-		};
+		const range = writer.insert( this.position, originalNodes );
+
+		return { range };
 	}
 
 	/**
@@ -78,13 +93,25 @@ export default class InsertOperation extends Operation {
 	}
 
 	/**
-	 * Creates InsertOperation object from deserilized object, i.e. from parsed JSON string.
+	 * Creates `InsertOperation` object from deserilized object, i.e. from parsed JSON string.
 	 *
 	 * @param {Object} json Deserialized JSON object.
 	 * @param {engine.model.Document} document Document on which this operation will be applied.
 	 * @returns {engine.model.operation.InsertOperation}
 	 */
 	static fromJSON( json, document ) {
-		return new InsertOperation( Position.fromJSON( json.position, document ), NodeList.fromJSON( json.nodeList ), json.baseVersion );
+		let children = [];
+
+		for ( let child of json.nodes ) {
+			if ( child.name ) {
+				// If child has name property, it is an Element.
+				children.push( Element.fromJSON( child ) );
+			} else {
+				// Otherwise, it is a Text node.
+				children.push( Text.fromJSON( child ) );
+			}
+		}
+
+		return new InsertOperation( Position.fromJSON( json.position, document ), children, json.baseVersion );
 	}
 }

+ 23 - 23
packages/ckeditor5-engine/src/model/operation/moveoperation.js

@@ -8,41 +8,42 @@ import Position from '../position.js';
 import Range from '../range.js';
 import CKEditorError from '../../../utils/ckeditorerror.js';
 import compareArrays from '../../../utils/comparearrays.js';
+import writer from './../writer.js';
 
 /**
- * Operation to move list of subsequent nodes from one position in the document to another.
+ * Operation to move a range of {@link engine.model.Item model items} to given {@link engine.model.Position target position}.
  *
  * @memberOf engine.model.operation
- * @extends engine.model.operation.Operation
  */
 export default class MoveOperation extends Operation {
 	/**
 	 * Creates a move operation.
 	 *
-	 * @param {engine.model.Position} sourcePosition Position before the first node to move.
-	 * @param {Number} howMany How many consecutive nodes to move, starting from `sourcePosition`.
-	 * @param {engine.model.Position} targetPosition Position where moved nodes will be inserted.
+	 * @param {engine.model.Position} sourcePosition Position before the first {@link engine.model.Item model item} to move.
+	 * @param {Number} howMany Offset size of moved range. Moved range will start from `sourcePosition` and end at
+	 * `sourcePosition` with offset shifted by `howMany`.
+	 * @param {engine.model.Position} targetPosition Position at which moved nodes will be inserted.
 	 * @param {Number} baseVersion {@link engine.model.Document#version} on which operation can be applied.
 	 */
 	constructor( sourcePosition, howMany, targetPosition, baseVersion ) {
 		super( baseVersion );
 
 		/**
-		 * Source move position.
+		 * Position before the first {@link engine.model.Item model item} to move.
 		 *
 		 * @member {engine.model.Position} engine.model.operation.MoveOperation#sourcePosition
 		 */
 		this.sourcePosition = Position.createFromPosition( sourcePosition );
 
 		/**
-		 * How many nodes to move.
+		 * Offset size of moved range.
 		 *
 		 * @member {Number} engine.model.operation.MoveOperation#howMany
 		 */
 		this.howMany = howMany;
 
 		/**
-		 * Target move position.
+		 * Position at which moved nodes will be inserted.
 		 *
 		 * @member {engine.model.Position} engine.model.operation.MoveOperation#targetPosition
 		 */
@@ -62,7 +63,7 @@ export default class MoveOperation extends Operation {
 		 *
 		 * @member {engine.model.Position} engine.model.operation.MoveOperation#movedRangeStart
 		 */
-		this.movedRangeStart = this.targetPosition.getTransformedByDeletion( this.sourcePosition, this.howMany );
+		this.movedRangeStart = this.targetPosition._getTransformedByDeletion( this.sourcePosition, this.howMany );
 
 		/**
 		 * Defines whether `MoveOperation` is sticky. If `MoveOperation` is sticky, during
@@ -75,11 +76,15 @@ export default class MoveOperation extends Operation {
 		this.isSticky = false;
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	get type() {
 		return 'move';
 	}
 
 	/**
+	 * @inheritDoc
 	 * @returns {engine.model.operation.MoveOperation}
 	 */
 	clone() {
@@ -90,10 +95,11 @@ export default class MoveOperation extends Operation {
 	}
 
 	/**
+	 * @inheritDoc
 	 * @returns {engine.model.operation.MoveOperation}
 	 */
 	getReversed() {
-		let newTargetPosition = this.sourcePosition.getTransformedByInsertion( this.targetPosition, this.howMany );
+		let newTargetPosition = this.sourcePosition._getTransformedByInsertion( this.targetPosition, this.howMany );
 
 		const op = new this.constructor( this.movedRangeStart, this.howMany, newTargetPosition, this.baseVersion + 1 );
 		op.isSticky = this.isSticky;
@@ -101,6 +107,9 @@ export default class MoveOperation extends Operation {
 		return op;
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	_execute() {
 		let sourceElement = this.sourcePosition.parent;
 		let targetElement = this.targetPosition.parent;
@@ -119,7 +128,7 @@ export default class MoveOperation extends Operation {
 			throw new CKEditorError(
 				'operation-move-position-invalid: Source position or target position is invalid.'
 			);
-		} else if ( sourceOffset + this.howMany > sourceElement.getChildCount() ) {
+		} else if ( sourceOffset + this.howMany > sourceElement.getMaxOffset() ) {
 			/**
 			 * The nodes which should be moved do not exist.
 			 *
@@ -153,21 +162,12 @@ export default class MoveOperation extends Operation {
 				}
 			}
 		}
-		// End of validation.
-
-		// If we move children in the same element and we remove elements on the position before the target we
-		// need to update a target offset.
-		if ( sourceElement === targetElement && sourceOffset < targetOffset ) {
-			targetOffset -= this.howMany;
-		}
-
-		const removedNodes = sourceElement.removeChildren( sourceOffset, this.howMany );
 
-		targetElement.insertChildren( targetOffset, removedNodes );
+		const range = writer.move( Range.createFromPositionAndShift( this.sourcePosition, this.howMany ), this.targetPosition );
 
 		return {
 			sourcePosition: this.sourcePosition,
-			range: Range.createFromPositionAndShift( this.movedRangeStart, this.howMany )
+			range: range
 		};
 	}
 
@@ -179,7 +179,7 @@ export default class MoveOperation extends Operation {
 	}
 
 	/**
-	 * Creates MoveOperation object from deserilized object, i.e. from parsed JSON string.
+	 * Creates `MoveOperation` object from deserilized object, i.e. from parsed JSON string.
 	 *
 	 * @param {Object} json Deserialized JSON object.
 	 * @param {engine.model.Document} document Document on which this operation will be applied.

+ 7 - 3
packages/ckeditor5-engine/src/model/operation/nooperation.js

@@ -6,9 +6,8 @@
 import Operation from './operation.js';
 
 /**
- * Operation which is doing nothing ("empty operation", "do-nothing operation", "noop").
- * This is an operation, which when executed does not change the tree model.
- * It still has some parameters defined for transformation purposes.
+ * Operation which is doing nothing ("empty operation", "do-nothing operation", "noop"). This is an operation,
+ * which when executed does not change the tree model. It still has some parameters defined for transformation purposes.
  *
  * In most cases this operation is a result of transforming operations. When transformation returns
  * {@link engine.model.operation.NoOperation} it means that changes done by the transformed operation
@@ -19,6 +18,7 @@ import Operation from './operation.js';
  */
 export default class NoOperation extends Operation {
 	/**
+	 * @inheritDoc
 	 * @returns {engine.model.operation.NoOperation}
 	 */
 	clone() {
@@ -26,12 +26,16 @@ export default class NoOperation extends Operation {
 	}
 
 	/**
+	 * @inheritDoc
 	 * @returns {engine.model.operation.NoOperation}
 	 */
 	getReversed() {
 		return new NoOperation( this.baseVersion + 1 );
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	_execute() {
 		// Do nothing.
 	}

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

@@ -30,7 +30,7 @@ operations[ RootAttributeOperation.className ] = RootAttributeOperation;
  */
 export default class OperationFactory {
 	/**
-	 * Creates concrete Operation object from deserilized object, i.e. from parsed JSON string.
+	 * Creates concrete `Operation` object from deserilized object, i.e. from parsed JSON string.
 	 *
 	 * @param {Object} json Deserialized JSON object.
 	 * @param {engine.model.Document} document Document on which this operation will be applied.

+ 11 - 9
packages/ckeditor5-engine/src/model/operation/reinsertoperation.js

@@ -7,20 +7,15 @@ import MoveOperation from './moveoperation.js';
 import RemoveOperation from './removeoperation.js';
 
 /**
- * Operation to reinsert previously removed nodes back to the non-graveyard root.
- * This is basically {@link engine.model.operation.MoveOperation} but it returns
- * {@link engine.model.operation.RemoveOperation} when reversed.
- *
- * With this class, we achieve two goals: by having separate classes it's easier to distinguish whether move
- * operation is actually a remove/reinsert operation and fire proper events. Also it
- * will be easier to expand if we need to change operation's behavior if it is remove/reinsert.
+ * Operation to reinsert previously removed nodes back to the non-graveyard root. This operation acts like
+ * {@link engine.model.operation.MoveOperation} but it returns {@link engine.model.operation.RemoveOperation} when reversed
+ * and fires different change event.
  *
  * @memberOf engine.model.operation
- * @extends engine.model.operation.Operation
  */
 export default class ReinsertOperation extends MoveOperation {
 	/**
-	 * Position where re-inserted node will be inserted.
+	 * Position where nodes will be re-inserted.
 	 *
 	 * @type {engine.model.Position}
 	 */
@@ -28,15 +23,22 @@ export default class ReinsertOperation extends MoveOperation {
 		return this.targetPosition;
 	}
 
+	/**
+	 * @param {engine.model.Position} pos
+	 */
 	set position( pos ) {
 		this.targetPosition = pos;
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	get type() {
 		return 'reinsert';
 	}
 
 	/**
+	 * @inheritDoc
 	 * @returns {engine.model.operation.RemoveOperation}
 	 */
 	getReversed() {

+ 14 - 8
packages/ckeditor5-engine/src/model/operation/removeoperation.js

@@ -12,21 +12,21 @@ import ReinsertOperation from './reinsertoperation.js';
  * Operation to remove a range of nodes.
  *
  * @memberOf engine.model.operation
- * @extends engine.model.operation.Operation
  */
 export default class RemoveOperation extends MoveOperation {
 	/**
-	 *
 	 * Creates a remove operation.
 	 *
-	 * @param {engine.model.Position} position Position before the first node to remove.
-	 * @param {Number} howMany How many nodes to remove.
+	 * @param {engine.model.Position} position Position before the first {@link engine.model.Item model item} to remove.
+	 * @param {Number} howMany Offset size of removed range. {@link engine.model.Item Model items} will be removed starting
+	 * from `sourcePosition`, up to a `sourcePosition` with offset shifted by `howMany`.
 	 * @param {Number} baseVersion {@link engine.model.Document#version} on which operation can be applied.
 	 */
 	constructor( position, howMany, baseVersion ) {
 		const graveyard = position.root.document.graveyard;
+		const graveyardPosition = new Position( graveyard, [ graveyard.getMaxOffset(), 0 ] );
 
-		super( position, howMany, new Position( graveyard, [ graveyard.getChildCount(), 0 ] ), baseVersion );
+		super( position, howMany, graveyardPosition, baseVersion );
 	}
 
 	/**
@@ -57,9 +57,11 @@ export default class RemoveOperation extends MoveOperation {
 	}
 
 	/**
-	 * Flag informing whether this operation should insert "holder" element (`true`) or should remove nodes
-	 * into existing "holder" element (`false`). It is `true` for each `RemoveOperation` that is the first `RemoveOperation`
-	 * in it's delta which points to given holder element.
+	 * Flag informing whether this operation should insert "holder" element (`true`) or should move removed nodes
+	 * into existing "holder" element (`false`).
+	 *
+	 * It is `true` for each `RemoveOperation` that is the first `RemoveOperation` in it's delta that points to given holder element.
+	 * This way only one `RemoveOperation` in given delta will insert "holder" element.
 	 *
 	 * @protected
 	 * @type {Boolean}
@@ -88,6 +90,7 @@ export default class RemoveOperation extends MoveOperation {
 	}
 
 	/**
+	 * @inheritDoc
 	 * @returns {engine.model.operation.ReinsertOperation}
 	 */
 	getReversed() {
@@ -95,6 +98,7 @@ export default class RemoveOperation extends MoveOperation {
 	}
 
 	/**
+	 * @inheritDoc
 	 * @returns {engine.model.operation.RemoveOperation}
 	 */
 	clone() {
@@ -109,6 +113,7 @@ export default class RemoveOperation extends MoveOperation {
 	 * @inheritDoc
 	 */
 	_execute() {
+		// Insert "holder" element in graveyard root, if the operation needs it.
 		if ( this._needsHolderElement ) {
 			const graveyard = this.targetPosition.root;
 			const holderElement = new Element( '$graveyardHolder' );
@@ -116,6 +121,7 @@ export default class RemoveOperation extends MoveOperation {
 			graveyard.insertChildren( this.targetPosition.path[ 0 ], holderElement );
 		}
 
+		// Then, execute as a move operation.
 		return super._execute();
 	}
 

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

@@ -14,10 +14,8 @@ import isEqual from '../../../utils/lib/lodash/isEqual.js';
 import compareArrays from '../../../utils/comparearrays.js';
 
 /**
- * Transforms given {@link engine.model.operation.Operation operation} by another
- * {@link engine.model.operation.Operation operation} and
- * returns the result of that transformation as an array containing one or more
- * {@link engine.model.operation.Operation operation} elements.
+ * Transforms given {@link engine.model.operation.Operation operation} by another {@link engine.model.operation.Operation operation}
+ * and returns the result of that transformation as an array containing one or more {@link engine.model.operation.Operation operations}.
  *
  * Operations work on specified positions, passed to them when they are created. Whenever {@link engine.model.Document document}
  * changes, we have to reflect those modifications by updating or "transforming" operations which are not yet applied.
@@ -65,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.nodeList.length, !isStrong );
+			transformed.position = transformed.position._getTransformedByInsertion( b.position, b.nodes.totalOffset, !isStrong );
 
 			return [ transformed ];
 		},
@@ -80,7 +78,7 @@ const ot = {
 			const transformed = a.clone();
 
 			// Transform insert position by the other operation parameters.
-			transformed.position = a.position.getTransformedByMove( b.sourcePosition, b.targetPosition, b.howMany, !isStrong, b.isSticky );
+			transformed.position = a.position._getTransformedByMove( b.sourcePosition, b.targetPosition, b.howMany, !isStrong, b.isSticky );
 
 			return [ transformed ];
 		}
@@ -90,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.nodeList.length, true, false );
+			const ranges = a.range._getTransformedByInsertion( b.position, b.nodes.totalOffset, true, false );
 
 			// Map transformed range(s) to operations and return them.
 			return ranges.reverse().map( ( range ) => {
@@ -169,15 +167,15 @@ const ot = {
 				// Take the start and the end of the range and transform them by deletion of moved nodes.
 				// Note that if rangeB was inside AttributeOperation range, only difference.end will be transformed.
 				// This nicely covers the joining simplification we did in the previous step.
-				difference.start = difference.start.getTransformedByDeletion( b.sourcePosition, b.howMany );
-				difference.end = difference.end.getTransformedByDeletion( b.sourcePosition, b.howMany );
+				difference.start = difference.start._getTransformedByDeletion( b.sourcePosition, b.howMany );
+				difference.end = difference.end._getTransformedByDeletion( b.sourcePosition, b.howMany );
 
 				// MoveOperation pastes nodes into target position. We acknowledge this by proper transformation.
 				// Note that since we operate on transformed difference range, we should transform by
 				// previously transformed target position.
-				// Note that we do not use Position.getTransformedByMove on range boundaries because we need to
+				// Note that we do not use Position._getTransformedByMove on range boundaries because we need to
 				// transform by insertion a range as a whole, since newTargetPosition might be inside that range.
-				ranges = difference.getTransformedByInsertion( b.movedRangeStart, b.howMany, true, false ).reverse();
+				ranges = difference._getTransformedByInsertion( b.movedRangeStart, b.howMany, true, false ).reverse();
 			}
 
 			if ( common !== null ) {
@@ -223,12 +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.nodeList.length, false, a.isSticky )[ 0 ];
+			range = range._getTransformedByInsertion( b.position, b.nodes.totalOffset, 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.nodeList.length, !isStrong ),
+				a instanceof RemoveOperation ?
+					a.baseVersion :
+					a.targetPosition._getTransformedByInsertion( b.position, b.nodes.totalOffset, !isStrong ),
 				a instanceof RemoveOperation ? undefined : a.baseVersion
 			);
 
@@ -287,8 +287,8 @@ const ot = {
 			let difference = joinRanges( rangeA.getDifference( rangeB ) );
 
 			if ( difference ) {
-				difference.start = difference.start.getTransformedByMove( b.sourcePosition, b.targetPosition, b.howMany, !a.isSticky, false );
-				difference.end = difference.end.getTransformedByMove( b.sourcePosition, b.targetPosition, b.howMany, a.isSticky, false );
+				difference.start = difference.start._getTransformedByMove( b.sourcePosition, b.targetPosition, b.howMany, !a.isSticky, false );
+				difference.end = difference.end._getTransformedByMove( b.sourcePosition, b.targetPosition, b.howMany, a.isSticky, false );
 
 				ranges.push( difference );
 			}
@@ -342,7 +342,7 @@ const ot = {
 			}
 
 			// Target position also could be affected by the other MoveOperation. We will transform it.
-			let newTargetPosition = a.targetPosition.getTransformedByMove(
+			let newTargetPosition = a.targetPosition._getTransformedByMove(
 				b.sourcePosition,
 				b.targetPosition,
 				b.howMany,
@@ -422,7 +422,7 @@ function updateBaseVersions( baseVersion, operations ) {
 
 // Checks whether MoveOperation targetPosition is inside a node from the moved range of the other MoveOperation.
 function moveTargetIntoMovedRange( a, b ) {
-	return a.targetPosition.getTransformedByDeletion( b.sourcePosition, b.howMany ) === null;
+	return a.targetPosition._getTransformedByDeletion( b.sourcePosition, b.howMany ) === null;
 }
 
 // Gets an array of Ranges and produces one Range out of it. The root of a new range will be same as

+ 289 - 243
packages/ckeditor5-engine/src/model/position.js

@@ -8,10 +8,24 @@ import Element from './element.js';
 import last from '../../utils/lib/lodash/last.js';
 import compareArrays from '../../utils/comparearrays';
 import CKEditorError from '../../utils/ckeditorerror.js';
+import Text from './text.js';
 
 /**
- * Position in the tree. Position is always located before or after a node.
- * See {@link #path} property for more information.
+ * Represents a position in the model tree.
+ *
+ * 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}.
+ *
+ * Also, {@link engine.model.operation.Operation operations} kept in {@link engine.model.Document#history document history}
+ * are storing positions (and ranges) which were correct when those operations were applied, but may not be correct
+ * after document got changed.
+ *
+ * When changes are applied to model, it may also happen that {@link engine.model.Position#parent position parent} will change
+ * even if position path has not changed. Keep in mind, that if a position leads to non-existing element,
+ * {@link engine.model.Position#parent} and some other properties and methods will throw errors.
+ *
+ * In most cases, position with wrong path is caused by an error in code, but it is sometimes needed, as described above.
  *
  * @memberOf engine.model
  */
@@ -19,9 +33,8 @@ export default class Position {
 	/**
 	 * Creates a position.
 	 *
-	 * @param {engine.model.Element|engine.model.DocumentFragment} root
-	 * Root of the position path. Element (most often a {@link engine.model.RootElement}) or a document fragment.
-	 * @param {Array.<Number>} path Position path. See {@link engine.model.Position#path} property for more information.
+	 * @param {engine.model.Element|engine.model.DocumentFragment} root Root of the position.
+	 * @param {Array.<Number>} path Position path. See {@link engine.model.Position#path}.
 	 */
 	constructor( root, path ) {
 		if ( !( root instanceof Element ) && !( root instanceof DocumentFragment ) ) {
@@ -56,19 +69,31 @@ export default class Position {
 		this.root = root;
 
 		/**
-		 * Position of the node it the tree. Must contain at least one item. For example:
+		 * Position of the node it the tree.
+		 *
+		 * 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
+		 * {@link engine.model.Node#startOffset starting offsets} of position ancestors, starting from direct root children,
+		 * down to the position offset in it's parent.
 		 *
-		 *		 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 ]
+		 *		     |  |- foo    before: [ 1, 0, 0 ]   after: [ 1, 0, 3 ]
+		 *		     |- LI        before: [ 1, 1 ]      after: [ 1, 2 ]
+		 *		        |- bar    before: [ 1, 1, 0 ]   after: [ 1, 1, 3 ]
+		 *
+		 * `foo` and `bar` are representing {@link engine.model.Text text nodes}. Since text nodes has offset size
+		 * greater than `1` you can place position offset between their start and end:
+		 *
+		 *		 ROOT
+		 *		  |- P
+		 *		  |- UL
+		 *		     |- LI
+		 *		     |  |- f^o|o  ^ has path: [ 1, 0, 1 ]   | has path: [ 1, 0, 2 ]
+		 *		     |- LI
+		 *		        |- b^a|r  ^ has path: [ 1, 1, 1 ]   | has path: [ 1, 1, 2 ]
 		 *
 		 * @member {Array.<Number>} engine.model.Position#path
 		 */
@@ -76,29 +101,54 @@ export default class Position {
 	}
 
 	/**
-	 * Node directly after the position.
+	 * Position {@link engine.model.Position#offset offset} converted to an index in position's parent node. It is
+	 * equal to the {@link engine.model.Node#getIndex index} of a node after this position. If position is placed
+	 * in text node, position index is equal to the index of that text node.
+	 *
+	 * @readonly
+	 * @type {Number}
+	 */
+	get index() {
+		return this.parent.offsetToIndex( this.offset );
+	}
+
+	/**
+	 * Returns {@link engine.model.Text text node} instance in which this position is placed or `null` if this
+	 * position is not in a text node.
+	 *
+	 * @readonly
+	 * @type {engine.model.Text|null}
+	 */
+	get textNode() {
+		let node = this.parent.getChild( this.index );
+
+		return ( node instanceof Text && node.startOffset < this.offset ) ? node : null;
+	}
+
+	/**
+	 * Node directly after this position or `null` if this position is in text node.
 	 *
 	 * @readonly
-	 * @type {engine.model.Node}
+	 * @type {engine.model.Node|null}
 	 */
 	get nodeAfter() {
-		return this.parent.getChild( this.offset ) || null;
+		return this.textNode === null ? this.parent.getChild( this.index ) : null;
 	}
 
 	/**
-	 * Node directly before the position.
+	 * Node directly before this position or `null` if this position is in text node.
 	 *
 	 * @readonly
 	 * @type {Node}
 	 */
 	get nodeBefore() {
-		return this.parent.getChild( this.offset - 1 ) || null;
+		return this.textNode === null ? this.parent.getChild( this.index - 1 ) : null;
 	}
 
 	/**
-	 * Offset at which the position is located in the {@link #parent}.
+	 * Offset at which this position is located in it's {@link engine.model.Position#parent parent}. It is equal
+	 * to the last item in position {@link engine.model.Position#path path}.
 	 *
-	 * @readonly
 	 * @type {Number}
 	 */
 	get offset() {
@@ -106,8 +156,6 @@ export default class Position {
 	}
 
 	/**
-	 * Sets offset in the parent, which is the last element of the path.
-	 *
 	 * @param {Number} newOffset
 	 */
 	set offset( newOffset ) {
@@ -115,7 +163,12 @@ export default class Position {
 	}
 
 	/**
-	 * Parent element of the position. The position is located at {@link #offset} in this element.
+	 * Parent element of this position.
+	 *
+	 * Keep in mind that `parent` value is calculated when the property is accessed. If {@link engine.model.Position#path position path}
+	 * leads to a non-existing element, `parent` property will throw error.
+	 *
+	 * Also it is a good idea to cache `parent` property if it is used frequently in an algorithm (i.e. in a long loop).
 	 *
 	 * @readonly
 	 * @type {engine.model.Element}
@@ -123,10 +176,8 @@ export default class Position {
 	get parent() {
 		let parent = this.root;
 
-		let i, len;
-
-		for ( i = 0, len = this.path.length - 1; i < len; i++ ) {
-			parent = parent.getChild( this.path[ i ] );
+		for ( let i = 0; i < this.path.length - 1; i++ ) {
+			parent = parent.getChild( parent.offsetToIndex( this.path[ i ] ) );
 		}
 
 		return parent;
@@ -165,20 +216,22 @@ export default class Position {
 	}
 
 	/**
-	 * Returns the path to the parent, which is the {@link engine.model.Position#path} without the last element.
+	 * Returns a path to this position's parent. Parent path is equal to position {@link engine.model.Position#path path}
+	 * but without the last item.
 	 *
 	 * This method returns the parent path even if the parent does not exists.
 	 *
-	 * @returns {Number[]} Path to the parent.
+	 * @returns {Array.<Number>} Path to the parent.
 	 */
 	getParentPath() {
 		return this.path.slice( 0, -1 );
 	}
 
 	/**
-	 * Returns a new instance of Position with offset incremented by `shift` value.
+	 * Returns a new instance of `Position`, that has same {@link engine.model.Position#parent parent} but it's offset
+	 * is shifted by `shift` value (can be a negative value).
 	 *
-	 * @param {Number} shift How position offset should get changed. Accepts negative values.
+	 * @param {Number} shift Offset shift. Can be a negative value.
 	 * @returns {engine.model.Position} Shifted position.
 	 */
 	getShiftedBy( shift ) {
@@ -191,7 +244,139 @@ export default class Position {
 	}
 
 	/**
-	 * Returns this position after being updated by removing `howMany` nodes starting from `deletePosition`.
+	 * Checks whether this position is after given position.
+	 *
+	 * @see engine.model.Position#isBefore
+	 *
+	 * @param {engine.model.Position} otherPosition Position to compare with.
+	 * @returns {Boolean} True if this position is after given position.
+	 */
+	isAfter( otherPosition ) {
+		return this.compareWith( otherPosition ) == 'after';
+	}
+
+	/**
+	 * Checks whether this position is before given position.
+	 *
+	 * **Note:** watch out when using negation of the value returned by this method, because the negation will also
+	 * be `true` if positions are in different roots and you might not expect this. You should probably use
+	 * `a.isAfter( b ) || a.isEqual( b )` or `!a.isBefore( p ) && a.root == b.root` in most scenarios. If your
+	 * condition uses multiple `isAfter` and `isBefore` checks, build them so they do not use negated values, i.e.:
+	 *
+	 *		if ( a.isBefore( b ) && c.isAfter( d ) ) {
+	 *			// do A.
+	 *		} else {
+	 *			// do B.
+	 *		}
+	 *
+	 * or, if you have only one if-branch:
+	 *
+	 *		if ( !( a.isBefore( b ) && c.isAfter( d ) ) {
+	 *			// do B.
+	 *		}
+	 *
+	 * rather than:
+	 *
+	 *		if ( !a.isBefore( b ) || && !c.isAfter( d ) ) {
+	 *			// do B.
+	 *		} else {
+	 *			// do A.
+	 *		}
+	 *
+	 * @param {engine.model.Position} otherPosition Position to compare with.
+	 * @returns {Boolean} True if this position is before given position.
+	 */
+	isBefore( otherPosition ) {
+		return this.compareWith( otherPosition ) == 'before';
+	}
+
+	/**
+	 * Checks whether this position is equal to given position.
+	 *
+	 * @param {engine.model.Position} otherPosition Position to compare with.
+	 * @returns {Boolean} True if positions are same.
+	 */
+	isEqual( otherPosition ) {
+		return this.compareWith( otherPosition ) == 'same';
+	}
+
+	/**
+	 * Checks whether this position is touching given position. Positions touch when there are no text nodes
+	 * or empty nodes in a range between them. Technically, those positions are not equal but in many cases
+	 * they are very similar or even indistinguishable.
+	 *
+	 * @param {engine.model.Position} otherPosition Position to compare with.
+	 * @returns {Boolean} True if positions touch.
+	 */
+	isTouching( otherPosition ) {
+		let left = null;
+		let right = null;
+		let compare = this.compareWith( otherPosition );
+
+		switch ( compare ) {
+			case 'same':
+				return true;
+
+			case 'before':
+				left = Position.createFromPosition( this );
+				right = Position.createFromPosition( otherPosition );
+				break;
+
+			case 'after':
+				left = Position.createFromPosition( otherPosition );
+				right = Position.createFromPosition( this );
+				break;
+
+			default:
+				return false;
+		}
+
+		// Cached for optimization purposes.
+		let leftParent = left.parent;
+
+		while ( left.path.length + right.path.length ) {
+			if ( left.isEqual( right ) ) {
+				return true;
+			}
+
+			if ( left.path.length > right.path.length ) {
+				if ( left.offset !== leftParent.getMaxOffset() ) {
+					return false;
+				}
+
+				left.path = left.path.slice( 0, -1 );
+				leftParent = leftParent.parent;
+				left.offset++;
+			} else {
+				if ( right.offset !== 0 ) {
+					return false;
+				}
+
+				right.path = right.path.slice( 0, -1 );
+			}
+		}
+	}
+
+	/**
+	 * Returns `true` if position is at the beginning of its {@link engine.model.Position#parent parent}, `false` otherwise.
+	 *
+	 * @returns {Boolean}
+	 */
+	isAtStart() {
+		return this.offset === 0;
+	}
+
+	/**
+	 * Returns `true` if position is at the end of its {@link engine.model.Position#parent parent}, `false` otherwise.
+	 *
+	 * @returns {Boolean}
+	 */
+	isAtEnd() {
+		return this.offset == this.parent.getMaxOffset();
+	}
+
+	/**
+	 * Returns a copy of this position that is updated by removing `howMany` nodes starting from `deletePosition`.
 	 * It may happen that this position is in a removed node. If that is the case, `null` is returned instead.
 	 *
 	 * @protected
@@ -199,7 +384,7 @@ export default class Position {
 	 * @param {Number} howMany How many nodes are removed.
 	 * @returns {engine.model.Position|null} Transformed position or `null`.
 	 */
-	getTransformedByDeletion( deletePosition, howMany ) {
+	_getTransformedByDeletion( deletePosition, howMany ) {
 		let transformed = Position.createFromPosition( this );
 
 		// This position can't be affected if deletion was in a different root.
@@ -240,7 +425,7 @@ export default class Position {
 	}
 
 	/**
-	 * Returns this position after being updated by inserting `howMany` nodes at `insertPosition`.
+	 * Returns a copy of this position that is updated by inserting `howMany` nodes at `insertPosition`.
 	 *
 	 * @protected
 	 * @param {engine.model.Position} insertPosition Position where nodes are inserted.
@@ -250,7 +435,7 @@ export default class Position {
 	 * set to `true`, this position will get transformed. If the flag is set to `false`, it won't.
 	 * @returns {engine.model.Position} Transformed position.
 	 */
-	getTransformedByInsertion( insertPosition, howMany, insertBefore ) {
+	_getTransformedByInsertion( insertPosition, howMany, insertBefore ) {
 		let transformed = Position.createFromPosition( this );
 
 		// This position can't be affected if insertion was in a different root.
@@ -280,7 +465,7 @@ export default class Position {
 	}
 
 	/**
-	 * Returns this position after being updated by moving `howMany` nodes from `sourcePosition` to `targetPosition`.
+	 * Returns a copy of this position that is updated by moving `howMany` nodes from `sourcePosition` to `targetPosition`.
 	 *
 	 * @protected
 	 * @param {engine.model.Position} sourcePosition Position before the first element to move.
@@ -293,12 +478,12 @@ export default class Position {
 	 * with the moved range if it is equal to one of range's boundaries.
 	 * @returns {engine.model.Position} Transformed position.
 	 */
-	getTransformedByMove( sourcePosition, targetPosition, howMany, insertBefore, sticky ) {
+	_getTransformedByMove( sourcePosition, targetPosition, howMany, insertBefore, sticky ) {
 		// Moving a range removes nodes from their original position. We acknowledge this by proper transformation.
-		let transformed = this.getTransformedByDeletion( sourcePosition, howMany );
+		let transformed = this._getTransformedByDeletion( sourcePosition, howMany );
 
 		// Then we update target position, as it could be affected by nodes removal too.
-		targetPosition = targetPosition.getTransformedByDeletion( sourcePosition, howMany );
+		targetPosition = targetPosition._getTransformedByDeletion( sourcePosition, howMany );
 
 		if ( transformed === null || ( sticky && transformed.isEqual( sourcePosition ) ) ) {
 			// This position is inside moved range (or sticks to it).
@@ -307,138 +492,55 @@ export default class Position {
 		} else {
 			// This position is not inside a removed range.
 			// In next step, we simply reflect inserting `howMany` nodes, which might further affect the position.
-			transformed = transformed.getTransformedByInsertion( targetPosition, howMany, insertBefore );
+			transformed = transformed._getTransformedByInsertion( targetPosition, howMany, insertBefore );
 		}
 
 		return transformed;
 	}
 
 	/**
-	 * Checks whether this position is after given position.
-	 *
-	 * @see engine.model.Position#isBefore
+	 * Returns a new position that is a combination of this position and given positions.
 	 *
-	 * @param {engine.model.Position} otherPosition Position to compare with.
-	 * @returns {Boolean} True if this position is after given position.
-	 */
-	isAfter( otherPosition ) {
-		return this.compareWith( otherPosition ) == 'after';
-	}
-
-	/**
-	 * Checks whether this position is before given position.
+	 * The combined position is a copy of this position transformed by moving a range starting at `source` position
+	 * to the `target` position. It is expected that this position is inside the moved range.
 	 *
-	 * **Note:** watch out when using negation of the value returned by this method, because the negation will also
-	 * be `true` if positions are in different roots and you might not expect this. You should probably use
-	 * `a.isAfter( b ) || a.isEqual( b )` or `!a.isBefore( p ) && a.root == b.root` in most scenarios. If your
-	 * condition uses multiple `isAfter` and `isBefore` checks, build them so they do not use negated values, i.e.:
-	 *
-	 *		if ( a.isBefore( b ) && c.isAfter( d ) ) {
-	 *			// do A.
-	 *		} else {
-	 *			// do B.
-	 *		}
-	 *
-	 * or, if you have only one if-branch:
-	 *
-	 *		if ( !( a.isBefore( b ) && c.isAfter( d ) ) {
-	 *			// do B.
-	 *		}
-	 *
-	 * rather than:
+	 * Example:
 	 *
-	 *		if ( !a.isBefore( b ) || && !c.isAfter( d ) ) {
-	 *			// do B.
-	 *		} else {
-	 *			// do A.
-	 *		}
+	 *		let original = new Position( root, [ 2, 3, 1 ] );
+	 *		let source = new Position( root, [ 2, 2 ] );
+	 *		let target = new Position( otherRoot, [ 1, 1, 3 ] );
+	 *		original._getCombined( source, target ); // path is [ 1, 1, 4, 1 ], root is `otherRoot`
 	 *
-	 * @param {engine.model.Position} otherPosition Position to compare with.
-	 * @returns {Boolean} True if this position is before given position.
-	 */
-	isBefore( otherPosition ) {
-		return this.compareWith( otherPosition ) == 'before';
-	}
-
-	/**
-	 * Checks whether this position equals given position.
+	 * Explanation:
 	 *
-	 * @param {engine.model.Position} otherPosition Position to compare with.
-	 * @returns {Boolean} True if positions are same.
-	 */
-	isEqual( otherPosition ) {
-		return this.compareWith( otherPosition ) == 'same';
-	}
-
-	/**
-	 * Checks whether this position is touching given position. Positions touch when there are no characters
-	 * or empty nodes in a range between them. Technically, those positions are not equal but in many cases
-	 * they are very similar or even indistinguishable when they touch.
+	 * We have a position `[ 2, 3, 1 ]` and move some nodes from `[ 2, 2 ]` to `[ 1, 1, 3 ]`. The original position
+	 * was inside moved nodes and now should point to the new place. The moved nodes will be after
+	 * positions `[ 1, 1, 3 ]`, `[ 1, 1, 4 ]`, `[ 1, 1, 5 ]`. Since our position was in the second moved node,
+	 * the transformed position will be in a sub-tree of a node at `[ 1, 1, 4 ]`. Looking at original path, we
+	 * took care of `[ 2, 3 ]` part of it. Now we have to add the rest of the original path to the transformed path.
+	 * Finally, the transformed position will point to `[ 1, 1, 4, 1 ]`.
 	 *
-	 * @param {engine.model.Position} otherPosition Position to compare with.
-	 * @returns {Boolean} True if positions touch.
+	 * @protected
+	 * @param {engine.model.Position} source Beginning of the moved range.
+	 * @param {engine.model.Position} target Position where the range is moved.
+	 * @returns {engine.model.Position} Combined position.
 	 */
-	isTouching( otherPosition ) {
-		let left = null;
-		let right = null;
-		let compare = this.compareWith( otherPosition );
-
-		switch ( compare ) {
-			case 'same':
-				return true;
-
-			case 'before':
-				left = Position.createFromPosition( this );
-				right = Position.createFromPosition( otherPosition );
-				break;
-
-			case 'after':
-				left = Position.createFromPosition( otherPosition );
-				right = Position.createFromPosition( this );
-				break;
-
-			default:
-				return false;
-		}
-
-		while ( left.path.length + right.path.length ) {
-			if ( left.isEqual( right ) ) {
-				return true;
-			}
+	_getCombined( source, target ) {
+		const i = source.path.length - 1;
 
-			if ( left.path.length > right.path.length ) {
-				if ( left.nodeAfter !== null ) {
-					return false;
-				}
+		// The first part of a path to combined position is a path to the place where nodes were moved.
+		let combined = Position.createFromPosition( target );
 
-				left.path = left.path.slice( 0, -1 );
-				left.offset++;
-			} else {
-				if ( right.nodeBefore !== null ) {
-					return false;
-				}
+		// Then we have to update the rest of the path.
 
-				right.path = right.path.slice( 0, -1 );
-			}
-		}
-	}
+		// Fix the offset because this position might be after `from` position and we have to reflect that.
+		combined.offset = combined.offset + this.path[ i ] - source.offset;
 
-	/**
-	 * Whether position is at the beginning of its {@link engine.model.Position#parent}.
-	 *
-	 * @returns {Boolean}
-	 */
-	isAtStart() {
-		return this.offset === 0;
-	}
+		// Then, add the rest of the path.
+		// If this position is at the same level as `from` position nothing will get added.
+		combined.path = combined.path.concat( this.path.slice( i + 1 ) );
 
-	/**
-	 * Whether position is at the end of its {@link engine.model.Position#parent}.
-	 *
-	 * @returns {Boolean}
-	 */
-	isAtEnd() {
-		return this.offset == this.parent.getChildCount();
+		return combined;
 	}
 
 	/**
@@ -446,8 +548,8 @@ export default class Position {
 	 *
 	 * * a {@link engine.model.Position position},
 	 * * parent element and offset (offset defaults to `0`),
-	 * * parent element and `'end'` (sets selection at the end of that element),
-	 * * node and `'before'` or `'after'` (sets selection before or after the given node).
+	 * * parent element and `'end'` (sets position at the end of that element),
+	 * * {@link engine.model.Item model item} and `'before'` or `'after'` (sets position before or after given model item).
 	 *
 	 * This method is a shortcut to other constructors such as:
 	 *
@@ -456,20 +558,18 @@ export default class Position {
 	 * * {@link engine.model.Position.createFromParentAndOffset},
 	 * * {@link engine.model.Position.createFromPosition}.
 	 *
-	 * @param {engine.model.Node|engine.model.Position} nodeOrPosition
+	 * @param {engine.model.Item|engine.model.Position} itemOrPosition
 	 * @param {Number|'end'|'before'|'after'} [offset=0] Offset or one of the flags. Used only when
-	 * first parameter is a node.
+	 * first parameter is a {@link engine.model.Item model item}.
 	 */
-	static createAt( nodeOrPosition, offset ) {
-		let node;
-
-		if ( nodeOrPosition instanceof Position ) {
-			return this.createFromPosition( nodeOrPosition );
+	static createAt( itemOrPosition, offset ) {
+		if ( itemOrPosition instanceof Position ) {
+			return this.createFromPosition( itemOrPosition );
 		} else {
-			node = nodeOrPosition;
+			const node = itemOrPosition;
 
 			if ( offset == 'end' ) {
-				offset = node.getChildCount();
+				offset = node.getMaxOffset();
 			} else if ( offset == 'before' ) {
 				return this.createBefore( node );
 			} else if ( offset == 'after' ) {
@@ -483,54 +583,49 @@ export default class Position {
 	}
 
 	/**
-	 * Creates a new position after given node.
+	 * Creates a new position, after given {@link engine.model.Item model item}.
 	 *
-	 * @see {@link engine.model.TreeWalkerValue}
-	 *
-	 * @param {engine.model.Node} node Node the position should be directly after.
+	 * @param {engine.model.Item} item Item after which the position should be placed.
 	 * @returns {engine.model.Position}
 	 */
-	static createAfter( node ) {
-		if ( !node.parent ) {
+	static createAfter( item ) {
+		if ( !item.parent ) {
 			/**
 			 * You can not make position after root.
 			 *
 			 * @error position-after-root
-			 * @param {engine.model.Node} root
+			 * @param {engine.model.Item} root
 			 */
-			throw new CKEditorError( 'position-after-root: You can not make position after root.', { root: node } );
+			throw new CKEditorError( 'position-after-root: You can not make position after root.', { root: item } );
 		}
 
-		return this.createFromParentAndOffset( node.parent, node.getIndex() + 1 );
+		return this.createFromParentAndOffset( item.parent, item.endOffset );
 	}
 
 	/**
-	 * Creates a new position before the given node.
-	 *
-	 * @see {@link engine.model.TreeWalkerValue}
+	 * Creates a new position, before the given {@link engine.model.Item model item}.
 	 *
-	 * @param {engine.model.node} node Node the position should be directly before.
+	 * @param {engine.model.Item} item Item before which the position should be placed.
 	 * @returns {engine.model.Position}
 	 */
-	static createBefore( node ) {
-		if ( !node.parent ) {
+	static createBefore( item ) {
+		if ( !item.parent ) {
 			/**
 			 * You can not make position before root.
 			 *
 			 * @error position-before-root
-			 * @param {engine.model.Node} root
+			 * @param {engine.model.Item} root
 			 */
-			throw new CKEditorError( 'position-before-root: You can not make position before root.', { root: node } );
+			throw new CKEditorError( 'position-before-root: You can not make position before root.', { root: item } );
 		}
 
-		return this.createFromParentAndOffset( node.parent, node.getIndex() );
+		return this.createFromParentAndOffset( item.parent, item.startOffset );
 	}
 
 	/**
-	 * Creates a new position from the parent element and the offset in that element.
+	 * Creates a new position from the parent element and an offset in that element.
 	 *
-	 * @param {engine.model.Element|engine.model.DocumentFragment} parent Position's parent element or
-	 * document fragment.
+	 * @param {engine.model.Element|engine.model.DocumentFragment} parent Position's parent.
 	 * @param {Number} offset Position's offset.
 	 * @returns {engine.model.Position}
 	 */
@@ -541,7 +636,7 @@ export default class Position {
 			 *
 			 * @error position-parent-incorrect
 			 */
-			throw new CKEditorError( 'position-parent-incorrect: Position parent have to be a model element or model document fragment.' );
+			throw new CKEditorError( 'position-parent-incorrect: Position parent have to be a element or document fragment.' );
 		}
 
 		const path = parent.getPath();
@@ -552,7 +647,7 @@ export default class Position {
 	}
 
 	/**
-	 * Creates and returns a new instance of Position, which is equal to passed position.
+	 * Creates a new position, which is equal to passed position.
 	 *
 	 * @param {engine.model.Position} position Position to be cloned.
 	 * @returns {engine.model.Position}
@@ -562,11 +657,10 @@ export default class Position {
 	}
 
 	/**
-	 * Creates Element object from deserilized object, ie. from parsed JSON string.
+	 * Creates a `Position` instance from given plain object (i.e. parsed JSON string).
 	 *
-	 * @param {Object} json Deserialized JSON object.
-	 * @param {engine.model.Document} doc Document on which this operation will be applied.
-	 * @returns {engine.model.Position}
+	 * @param {Object} json Plain object to be converted to `Position`.
+	 * @returns {engine.model.Position} `Position` instance created using given plain object.
 	 */
 	static fromJSON( json, doc ) {
 		if ( json.root === '$graveyard' ) {
@@ -588,54 +682,6 @@ export default class Position {
 
 		return new Position( doc.getRoot( json.root ), json.path );
 	}
-
-	/**
-	 * Returns a new position that is a combination of this position and given positions. The combined
-	 * position is this position transformed by moving a range starting at `from` to `to` position.
-	 * It is expected that this position is inside the moved range.
-	 *
-	 * In other words, this method in a smart way "cuts out" `source` path from this position and
-	 * injects `target` path in it's place, while doing necessary fixes in order to get a correct path.
-	 *
-	 * Example:
-	 *
-	 * 	let original = new Position( root, [ 2, 3, 1 ] );
-	 * 	let source = new Position( root, [ 2, 2 ] );
-	 * 	let target = new Position( otherRoot, [ 1, 1, 3 ] );
-	 * 	let combined = original.getCombined( source, target );
-	 * 	// combined.path is [ 1, 1, 4, 1 ], combined.root is otherRoot
-	 *
-	 * Explanation:
-	 *
-	 * We have a position `[ 2, 3, 1 ]` and move some nodes from `[ 2, 2 ]` to `[ 1, 1, 3 ]`. The original position
-	 * was inside moved nodes and now should point to the new place. The moved nodes will be after
-	 * positions `[ 1, 1, 3 ]`, `[ 1, 1, 4 ]`, `[ 1, 1, 5 ]`. Since our position was in the second moved node,
-	 * the transformed position will be in a sub-tree of a node at `[ 1, 1, 4 ]`. Looking at original path, we
-	 * took care of `[ 2, 3 ]` part of it. Now we have to add the rest of the original path to the transformed path.
-	 * Finally, the transformed position will point to `[ 1, 1, 4, 1 ]`.
-	 *
-	 * @protected
-	 * @param {engine.model.Position} source Beginning of the moved range.
-	 * @param {engine.model.Position} target Position where the range is moved.
-	 * @returns {engine.model.Position} Combined position.
-	 */
-	_getCombined( source, target ) {
-		const i = source.path.length - 1;
-
-		// The first part of a path to combined position is a path to the place where nodes were moved.
-		let combined = Position.createFromPosition( target );
-
-		// Then we have to update the rest of the path.
-
-		// Fix the offset because this position might be after `from` position and we have to reflect that.
-		combined.offset = combined.offset + this.path[ i ] - source.offset;
-
-		// Then, add the rest of the path.
-		// If this position is at the same level as `from` position nothing will get added.
-		combined.path = combined.path.concat( this.path.slice( i + 1 ) );
-
-		return combined;
-	}
 }
 
 /**

+ 109 - 98
packages/ckeditor5-engine/src/model/range.js

@@ -15,7 +15,8 @@ import compareArrays from '../../utils/comparearrays.js';
 export default class Range {
 	/**
 	 * Creates a range spanning from `start` position to `end` position.
-	 * **Note:** Constructor creates it's own {@link engine.model.Position} instances basing on passed values.
+	 *
+	 * **Note:** Constructor creates it's own {@link engine.model.Position Position} instances basing on passed values.
 	 *
 	 * @param {engine.model.Position} start Start position.
 	 * @param {engine.model.Position} end End position.
@@ -41,20 +42,12 @@ export default class Range {
 	/**
 	 * Returns an iterator that iterates over all {@link engine.model.Item items} that are in this range and returns
 	 * them together with additional information like length or {@link engine.model.Position positions},
-	 * grouped as {@link engine.model.TreeWalkerValue}. It iterates over all {@link engine.model.TextProxy texts}
-	 * that are inside the range and all the {@link engine.model.Element}s we enter into when iterating over this
-	 * range.
-	 *
-	 * **Note:** iterator will not return a parent node of start position. This is in contrary to
-	 * {@link engine.model.TreeWalker} which will return that node with `'elementEnd'` type. Iterator also
-	 * returns each {@link engine.model.Element} once, while simply used {@link engine.model.TreeWalker} might
-	 * return it twice: for `'elementStart'` and `'elementEnd'`.
+	 * grouped as {@link engine.model.TreeWalkerValue}. It iterates over all {@link engine.model.TextProxy text contents}
+	 * that are inside the range and all the {@link engine.model.Element}s that are entered into when iterating over this range.
 	 *
-	 * **Note:** because iterator does not return {@link engine.model.TreeWalkerValue values} with the type of
-	 * `'elementEnd'`, you can use {@link engine.model.TreeWalkerValue.previousPosition} as a position before the
-	 * item.
+	 * This iterator uses {@link engine.model.TreeWalker} with `boundaries` set to this range and `ignoreElementEnd` option
+	 * set to `true`.
 	 *
-	 * @see engine.model.TreeWalker
 	 * @returns {Iterable.<engine.model.TreeWalkerValue>}
 	 */
 	*[ Symbol.iterator ]() {
@@ -62,7 +55,8 @@ export default class Range {
 	}
 
 	/**
-	 * Returns whether the range is collapsed, that is it start and end positions are equal.
+	 * Returns whether the range is collapsed, that is if {@link engine.model.Range#start start} and
+	 * {@link engine.model.Range#end end} positions are equal.
 	 *
 	 * @type {Boolean}
 	 */
@@ -71,7 +65,8 @@ export default class Range {
 	}
 
 	/**
-	 * Returns whether this range is flat, that is if start position and end position are in the same parent.
+	 * Returns whether this range is flat, that is if {@link engine.model.Range#start start} position and
+	 * {@link engine.model.Range#end end} position are in the same {@link engine.model.Position#parent parent}.
 	 *
 	 * @type {Boolean}
 	 */
@@ -80,7 +75,10 @@ export default class Range {
 	}
 
 	/**
-	 * Returns whether this range has any nodes in it.
+	 * Returns whether this range has no nodes in it, that is if {@link engine.model.Range#start start} position and
+	 * {@link engine.model.Range#end end} position are {@link engine.model.Position#isTouching touching}.
+	 *
+	 * **Note:** A range may be empty, but not {@link engine.model.Range#isCollapsed collapsed}.
 	 *
 	 * @type {Boolean}
 	 */
@@ -91,19 +89,17 @@ export default class Range {
 	/**
 	 * Range root element.
 	 *
-	 * Equals to the root of start position (which should be same as root of end position).
-	 *
-	 * @type {engine.model.RootElement|engine.model.DocumentFragment}
+	 * @type {engine.model.Element|engine.model.DocumentFragment}
 	 */
 	get root() {
 		return this.start.root;
 	}
 
 	/**
-	 * Checks whether this contains given {@link engine.model.Position position}.
+	 * Checks whether this range contains given {@link engine.model.Position position}.
 	 *
 	 * @param {engine.model.Position} position Position to check.
-	 * @returns {Boolean} True if given {@link engine.model.Position position} is contained.
+	 * @returns {Boolean} `true` if given {@link engine.model.Position position} is contained in this range, `false` otherwise.
 	 */
 	containsPosition( position ) {
 		return position.isAfter( this.start ) && position.isBefore( this.end );
@@ -113,15 +109,36 @@ export default class Range {
 	 * Checks whether this range contains given {@link engine.model.Range range}.
 	 *
 	 * @param {engine.model.Range} otherRange Range to check.
-	 * @returns {Boolean} True if given {@link engine.model.Range range} boundaries are contained by this range.
+	 * @returns {Boolean} `true` if given {@link engine.model.Range range} boundaries are contained by this range, `false` otherwise.
 	 */
 	containsRange( otherRange ) {
 		return this.containsPosition( otherRange.start ) && this.containsPosition( otherRange.end );
 	}
 
 	/**
-	 * Gets a part of this {@link engine.model.Range range} which is not a part of given {@link engine.model.Range range}. Returned
-	 * array contains zero, one or two {@link engine.model.Range ranges}.
+	 * Two ranges are equal if their {@link engine.model.Range#start start} and
+	 * {@link engine.model.Range#end end} positions are equal.
+	 *
+	 * @param {engine.model.Range} otherRange Range to compare with.
+	 * @returns {Boolean} `true` if ranges are equal, `false` otherwise.
+	 */
+	isEqual( otherRange ) {
+		return this.start.isEqual( otherRange.start ) && this.end.isEqual( otherRange.end );
+	}
+
+	/**
+	 * Checks and returns whether this range intersects with given range.
+	 *
+	 * @param {engine.model.Range} otherRange Range to compare with.
+	 * @returns {Boolean} `true` if ranges intersect, `false` otherwise.
+	 */
+	isIntersecting( otherRange ) {
+		return this.start.isBefore( otherRange.end ) && this.end.isAfter( otherRange.start );
+	}
+
+	/**
+	 * Computes which part(s) of this {@link engine.model.Range range} is not a part of given {@link engine.model.Range range}.
+	 * Returned array contains zero, one or two {@link engine.model.Range ranges}.
 	 *
 	 * Examples:
 	 *
@@ -167,8 +184,8 @@ export default class Range {
 	}
 
 	/**
-	 * Returns an intersection of this {@link engine.model.Range range} and given {@link engine.model.Range range}. Intersection
-	 * is a common part of both of those ranges. If ranges has no common part, returns `null`.
+	 * Returns an intersection of this {@link engine.model.Range range} and given {@link engine.model.Range range}.
+	 * Intersection is a common part of both of those ranges. If ranges has no common part, returns `null`.
 	 *
 	 * Examples:
 	 *
@@ -180,7 +197,7 @@ export default class Range {
 	 *		transformed = range.getIntersection( otherRange ); // range from [ 3 ] to [ 4, 0, 1 ]
 	 *
 	 * @param {engine.model.Range} otherRange Range to check for intersection.
-	 * @returns {engine.model.Range|null} A common part of given ranges or null if ranges have no common part.
+	 * @returns {engine.model.Range|null} A common part of given ranges or `null` if ranges have no common part.
 	 */
 	getIntersection( otherRange ) {
 		if ( this.isIntersecting( otherRange ) ) {
@@ -209,8 +226,9 @@ export default class Range {
 	}
 
 	/**
-	 * Computes and returns the smallest set of {@link #isFlat flat} ranges, that covers this range in whole.
-	 * Assuming that tree model model structure is ("[" and "]" are range boundaries):
+	 * Computes and returns the smallest set of {@link engine.model.Range#isFlat flat} ranges, that covers this range in whole.
+	 *
+	 * See an example of model structure (`[` and `]` are range boundaries):
 	 *
 	 *		root                                                            root
 	 *		 |- element DIV                         DIV             P2              P3             DIV
@@ -228,8 +246,8 @@ export default class Range {
 	 *		 |   |- element P4
 	 *		 |   |   |- "ipsum"
 	 *
-	 * As it can be seen, letters contained in the range are stloremfoobarse, spread across different parents.
-	 * We are looking for minimal set of {@link #isFlat flat} ranges that contains the same nodes.
+	 * As it can be seen, letters contained in the range are: `stloremfoobarse`, spread across different parents.
+	 * We are looking for minimal set of flat ranges that contains the same nodes.
 	 *
 	 * Minimal flat ranges for above range `( [ 0, 0, 3 ], [ 3, 0, 2 ] )` will be:
 	 *
@@ -238,10 +256,13 @@ export default class Range {
 	 *		( [ 1 ], [ 3 ] ) = element P2, element P3 ("foobar")
 	 *		( [ 3, 0, 0 ], [ 3, 0, 2 ] ) = "se"
 	 *
-	 * **Note:** this method is not returning flat ranges that contain no nodes. It may also happen that not-collapsed
-	 * range will return an empty array of flat ranges.
+	 * **Note:** if an {@link engine.model.Element element} is not contained wholly in this range, it won't be returned
+	 * in any of returned flat ranges. See in an example, how `H` elements at the beginning and at the end of the range
+	 * were omitted. Only it's parts that were wholly in the range were returned.
+	 *
+	 * **Note:** this method is not returning flat ranges that contain no nodes.
 	 *
-	 * @returns {Array.<engine.model.Range>} Array of flat ranges.
+	 * @returns {Array.<engine.model.Range>} Array of flat ranges covering this range.
 	 */
 	getMinimalFlatRanges() {
 		let ranges = [];
@@ -256,7 +277,7 @@ export default class Range {
 
 		// Go up.
 		while ( pos.path.length > diffAt + 1 ) {
-			let howMany = posParent.getChildCount() - pos.offset;
+			let howMany = posParent.getMaxOffset() - pos.offset;
 
 			if ( howMany !== 0 ) {
 				ranges.push( new Range( pos, pos.getShiftedBy( howMany ) ) );
@@ -284,7 +305,7 @@ export default class Range {
 	}
 
 	/**
-	 * Creates a {@link engine.model.TreeWalker} instance with this range as a boundary.
+	 * Creates a {@link engine.model.TreeWalker TreeWalker} instance with this range as a boundary.
 	 *
 	 * @param {Object} options Object with configuration options. See {@link engine.model.TreeWalker}.
 	 * @param {engine.model.Position} [options.startPosition]
@@ -300,15 +321,15 @@ export default class Range {
 
 	/**
 	 * Returns an iterator that iterates over all {@link engine.model.Item items} that are in this range and returns
-	 * them. It iterates over all {@link engine.model.CharacterProxy characters} or
-	 * {@link engine.model.TextProxy texts} that are inside the range and all the {@link engine.model.Element}s
-	 * we enter into when iterating over this range. Note that it use {@link engine.model.TreeWalker} with the
-	 * {@link engine.model.TreeWalker#ignoreElementEnd ignoreElementEnd} option set to true.
+	 * them.
+	 *
+	 * This method uses {@link engine.model.TreeWalker} with `boundaries` set to this range and `ignoreElementEnd` option
+	 * set to `true`. However it returns only {@link engine.model.Item model items}, not {@link engine.model.TreeWalkerValue}.
+	 *
+	 * You may specify additional options for the tree walker. See {@link engine.model.TreeWalker} for
+	 * a full list of available options.
 	 *
 	 * @param {Object} options Object with configuration options. See {@link engine.model.TreeWalker}.
-	 * @param {engine.model.Position} [options.startPosition]
-	 * @param {Boolean} [options.singleCharacters=false]
-	 * @param {Boolean} [options.shallow=false]
 	 * @returns {Iterable.<engine.model.Item>}
 	 */
 	*getItems( options = {} ) {
@@ -326,9 +347,13 @@ export default class Range {
 	 * Returns an iterator that iterates over all {@link engine.model.Position positions} that are boundaries or
 	 * contained in this range.
 	 *
+	 * This method uses {@link engine.model.TreeWalker} with `boundaries` set to this range. However it returns only
+	 * {@link engine.model.Position positions}, not {@link engine.model.TreeWalkerValue}.
+	 *
+	 * You may specify additional options for the tree walker. See {@link engine.model.TreeWalker} for
+	 * a full list of available options.
+	 *
 	 * @param {Object} options Object with configuration options. See {@link engine.model.TreeWalker}.
-	 * @param {Boolean} [options.singleCharacters=false]
-	 * @param {Boolean} [options.shallow=false]
 	 * @returns {Iterable.<engine.model.Position>}
 	 */
 	*getPositions( options = {} ) {
@@ -344,29 +369,29 @@ export default class Range {
 	}
 
 	/**
-	 * Returns an array containing one or two {engine.model.Range ranges} that are a result of transforming this
+	 * Returns an array containing one or two {@link engine.model.Range ranges} that are a result of transforming this
 	 * {@link engine.model.Range range} by inserting `howMany` nodes at `insertPosition`. Two {@link engine.model.Range ranges} are
 	 * returned if the insertion was inside this {@link engine.model.Range range} and `spread` is set to `true`.
 	 *
 	 * Examples:
 	 *
 	 *		let range = new Range( new Position( root, [ 2, 7 ] ), new Position( root, [ 4, 0, 1 ] ) );
-	 *		let transformed = range.getTransformedByInsertion( new Position( root, [ 1 ] ), 2 );
+	 *		let transformed = range._getTransformedByInsertion( new Position( root, [ 1 ] ), 2 );
 	 *		// transformed array has one range from [ 4, 7 ] to [ 6, 0, 1 ]
 	 *
-	 *		transformed = range.getTransformedByInsertion( new Position( root, [ 4, 0, 0 ] ), 4 );
+	 *		transformed = range._getTransformedByInsertion( new Position( root, [ 4, 0, 0 ] ), 4 );
 	 *		// transformed array has one range from [ 2, 7 ] to [ 4, 0, 5 ]
 	 *
-	 *		transformed = range.getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 4 );
+	 *		transformed = range._getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 4 );
 	 *		// transformed array has one range, which is equal to original range
 	 *
-	 *		transformed = range.getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 4, true );
+	 *		transformed = range._getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 4, true );
 	 *		// transformed array has two ranges: from [ 2, 7 ] to [ 3, 2 ] and from [ 3, 6 ] to [ 4, 0, 1 ]
 	 *
-	 *		transformed = range.getTransformedByInsertion( new Position( root, [ 4, 0, 1 ] ), 4, false, false );
+	 *		transformed = range._getTransformedByInsertion( new Position( root, [ 4, 0, 1 ] ), 4, false, false );
 	 *		// transformed array has one range which is equal to original range because insertion is after the range boundary
 	 *
-	 *		transformed = range.getTransformedByInsertion( new Position( root, [ 4, 0, 1 ] ), 4, false, true );
+	 *		transformed = range._getTransformedByInsertion( new Position( root, [ 4, 0, 1 ] ), 4, false, true );
 	 *		// transformed array has one range: from [ 2, 7 ] to [ 4, 0, 5 ] because range was expanded
 	 *
 	 * @protected
@@ -378,7 +403,7 @@ export default class Range {
 	 * range boundary. Defaults to `false`.
 	 * @returns {Array.<engine.model.Range>} Result of the transformation.
 	 */
-	getTransformedByInsertion( insertPosition, howMany, spread = false, isSticky = false ) {
+	_getTransformedByInsertion( insertPosition, howMany, spread = false, isSticky = false ) {
 		if ( spread && this.containsPosition( insertPosition ) ) {
 			// Range has to be spread. The first part is from original start to the spread point.
 			// The other part is from spread point to the original end, but transformed by
@@ -387,8 +412,8 @@ export default class Range {
 			return [
 				new Range( this.start, insertPosition ),
 				new Range(
-					insertPosition.getTransformedByInsertion( insertPosition, howMany, true ),
-					this.end.getTransformedByInsertion( insertPosition, howMany, this.isCollapsed )
+					insertPosition._getTransformedByInsertion( insertPosition, howMany, true ),
+					this.end._getTransformedByInsertion( insertPosition, howMany, this.isCollapsed )
 				)
 			];
 		} else {
@@ -397,17 +422,18 @@ export default class Range {
 			let insertBeforeStart = range.isCollapsed ? isSticky : !isSticky;
 			let insertBeforeEnd = isSticky;
 
-			range.start = range.start.getTransformedByInsertion( insertPosition, howMany, insertBeforeStart );
-			range.end = range.end.getTransformedByInsertion( insertPosition, howMany, insertBeforeEnd );
+			range.start = range.start._getTransformedByInsertion( insertPosition, howMany, insertBeforeStart );
+			range.end = range.end._getTransformedByInsertion( insertPosition, howMany, insertBeforeEnd );
 
 			return [ range ];
 		}
 	}
 
 	/**
-	 * Returns an array containing {engine.model.Range ranges} that are a result of transforming this
+	 * Returns an array containing {@link engine.model.Range ranges} that are a result of transforming this
 	 * {@link engine.model.Range range} by moving `howMany` nodes from `sourcePosition` to `targetPosition`.
 	 *
+	 * @protected
 	 * @param {engine.model.Position} sourcePosition Position from which nodes are moved.
 	 * @param {engine.model.Position} targetPosition Position to where nodes are moved.
 	 * @param {Number} howMany How many nodes are moved.
@@ -415,7 +441,7 @@ export default class Range {
 	 * was inside the range. Defaults to `false`.
 	 * @returns {Array.<engine.model.Range>} Result of the transformation.
 	 */
-	getTransformedByMove( sourcePosition, targetPosition, howMany, spread, isSticky = false ) {
+	_getTransformedByMove( sourcePosition, targetPosition, howMany, spread, isSticky = false ) {
 		let result;
 
 		const moveRange = new Range( sourcePosition, sourcePosition.getShiftedBy( howMany ) );
@@ -425,25 +451,25 @@ export default class Range {
 
 		if ( differenceSet.length == 1 ) {
 			difference = new Range(
-				differenceSet[ 0 ].start.getTransformedByDeletion( sourcePosition, howMany ),
-				differenceSet[ 0 ].end.getTransformedByDeletion( sourcePosition, howMany )
+				differenceSet[ 0 ].start._getTransformedByDeletion( sourcePosition, howMany ),
+				differenceSet[ 0 ].end._getTransformedByDeletion( sourcePosition, howMany )
 			);
 		} else if ( differenceSet.length == 2 ) {
 			// This means that ranges were moved from the inside of this range.
 			// So we can operate on this range positions and we don't have to transform starting position.
 			difference = new Range(
 				this.start,
-				this.end.getTransformedByDeletion( sourcePosition, howMany )
+				this.end._getTransformedByDeletion( sourcePosition, howMany )
 			);
 		} else {
 			// 0.
 			difference = null;
 		}
 
-		const insertPosition = targetPosition.getTransformedByDeletion( sourcePosition, howMany );
+		const insertPosition = targetPosition._getTransformedByDeletion( sourcePosition, howMany );
 
 		if ( difference ) {
-			result = difference.getTransformedByInsertion( insertPosition, howMany, spread, isSticky );
+			result = difference._getTransformedByInsertion( insertPosition, howMany, spread, isSticky );
 		} else {
 			result = [];
 		}
@@ -463,54 +489,40 @@ export default class Range {
 	}
 
 	/**
-	 * Two ranges equal if their start and end positions equal.
-	 *
-	 * @param {engine.model.Range} otherRange Range to compare with.
-	 * @returns {Boolean} True if ranges equal.
-	 */
-	isEqual( otherRange ) {
-		return this.start.isEqual( otherRange.start ) && this.end.isEqual( otherRange.end );
-	}
-
-	/**
-	 * Checks and returns whether this range intersects with given range.
-	 *
-	 * @param {engine.model.Range} otherRange Range to compare with.
-	 * @returns {Boolean} True if ranges intersect.
-	 */
-	isIntersecting( otherRange ) {
-		return this.start.isBefore( otherRange.end ) && this.end.isAfter( otherRange.start );
-	}
-
-	/**
-	 * Creates a range inside an element which starts before the first child and ends after the last child.
+	 * Creates a range inside an {@link engine.model.Element element} which starts before the first child of
+	 * that element and ends after the last child of that element.
 	 *
 	 * @param {engine.model.Element} element Element which is a parent for the range.
-	 * @returns {engine.model.Range} Created range.
+	 * @returns {engine.model.Range}
 	 */
 	static createFromElement( element ) {
-		return this.createFromParentsAndOffsets( element, 0, element, element.getChildCount() );
+		return this.createFromParentsAndOffsets( element, 0, element, element.getMaxOffset() );
 	}
 
 	/**
-	 * Creates a range on given element only. The range starts just before the element and ends before the first child of the element.
+	 * Creates a range on given {@link engine.model.Element element} only. The range starts directly before that element
+	 * and ends before the first child of that element.
 	 *
 	 * @param {engine.model.Element} element Element on which range should be created.
-	 * @returns {engine.model.Range} Created range.
+	 * @returns {engine.model.Range}
 	 */
 	static createOnElement( element ) {
-		return this.createFromParentsAndOffsets( element.parent, element.getIndex(), element, 0 );
+		return this.createFromParentsAndOffsets( element.parent, element.startOffset, element, 0 );
 	}
 
 	/**
-	 * Creates a new range spreading from specified position to the same position moved by given shift.
+	 * Creates a new range, spreading from specified {@link engine.model.Position position} to a position moved by
+	 * given `shift`. If `shift` is a negative value, shifted position is treated as the beginning of the range.
 	 *
 	 * @param {engine.model.Position} position Beginning of the range.
 	 * @param {Number} shift How long the range should be.
 	 * @returns {engine.model.Range}
 	 */
 	static createFromPositionAndShift( position, shift ) {
-		return new this( position, position.getShiftedBy( shift ) );
+		const start = position;
+		const end = position.getShiftedBy( shift );
+
+		return shift > 0 ? new this( start, end ) : new this( end, start );
 	}
 
 	/**
@@ -520,7 +532,7 @@ export default class Range {
 	 * @param {Number} startOffset Start position offset.
 	 * @param {engine.model.Element} endElement End position parent element.
 	 * @param {Number} endOffset End position offset.
-	 * @returns {engine.model.Range} Created range.
+	 * @returns {engine.model.Range}
 	 */
 	static createFromParentsAndOffsets( startElement, startOffset, endElement, endOffset ) {
 		return new this(
@@ -530,7 +542,7 @@ export default class Range {
 	}
 
 	/**
-	 * Creates and returns a new instance of Range which is equal to passed range.
+	 * Creates a new instance of `Range` which is equal to passed range.
 	 *
 	 * @param {engine.model.Range} range Range to clone.
 	 * @returns {engine.model.Range}
@@ -540,11 +552,10 @@ export default class Range {
 	}
 
 	/**
-	 * Creates Range from deserilized object, ie. from parsed JSON string.
+	 * Creates a `Range` instance from given plain object (i.e. parsed JSON string).
 	 *
-	 * @param {Object} json Deserialized JSON object.
-	 * @param {engine.model.Document} doc Document on which this operation will be applied.
-	 * @returns {engine.model.Range}
+	 * @param {Object} json Plain object to be converted to `Range`.
+	 * @returns {engine.model.Element} `Range` instance created using given plain object.
 	 */
 	static fromJSON( json, doc ) {
 		return new this( Position.fromJSON( json.start, doc ), Position.fromJSON( json.end, doc ) );

+ 22 - 11
packages/ckeditor5-engine/src/model/rootelement.js

@@ -6,7 +6,7 @@
 import Element from './element.js';
 
 /**
- * Class for nodes that are roots of trees in data model.
+ * Type of {@link engine.model.Element} that is a root of a model tree.
  *
  * @memberOf engine.model
  * @extends engine.model.Element
@@ -15,23 +15,23 @@ export default class RootElement extends Element {
 	/**
 	 * Creates root element.
 	 *
-	 * @param {engine.model.Document} doc {@link engine.model.Document} that is an owner of the root.
+	 * @param {engine.model.Document} doc Document that is an owner of this root.
 	 * @param {String} name Node name.
-	 * @param {String} [rootName='main'] Root name inside parent {@link engine.model.Document}.
+	 * @param {String} [rootName='main'] Unique root name used to identify this root element by {@link engine.model.Document}.
 	 */
 	constructor( doc, name, rootName = 'main' ) {
 		super( name );
 
 		/**
-		 * {@link engine.model.Document} that is an owner of this root.
+		 * Document that is an owner of this root.
 		 *
-		 * @readonly
-		 * @member {engine.model.Document} engine.model.RootElement#document
+		 * @private
+		 * @member {engine.model.Document} engine.model.RootElement#_doc
 		 */
-		this.document = doc;
+		this._doc = doc;
 
 		/**
-		 * Name of this root inside {@link engine.model.Document} that is an owner of this root.
+		 * Unique root name used to identify this root element by {@link engine.model.Document}.
 		 *
 		 * @readonly
 		 * @member {String} engine.model.RootElement#rootName
@@ -40,10 +40,21 @@ export default class RootElement extends Element {
 	}
 
 	/**
-	 * Custom toJSON method to solve child-parent circular dependencies.
+	 * {@link engine.model.Document Document} that owns this root element.
+	 *
+	 * In contrary, to {@link engine.model.Node node}, root element always have a `document`.
+	 *
+	 * @readonly
+	 * @type {engine.model.Document|null}
+	 */
+	get document() {
+		return this._doc;
+	}
+
+	/**
+	 * Converts `RootElement` instance to `String` containing it's name.
 	 *
-	 * @method engine.model.RootElement#toJSON
-	 * @returns {String} Name of this root inside {@link engine.model.Document} that is an owner of this root.
+	 * @returns {String} `RootElement` instance converted to `String`.
 	 */
 	toJSON() {
 		return this.rootName;

+ 246 - 139
packages/ckeditor5-engine/src/model/selection.js

@@ -12,7 +12,8 @@ import toMap from '../../utils/tomap.js';
 
 /**
  * `Selection` is a group of {@link engine.model.Range ranges} which has a direction specified by
- * {@link engine.model.Selection#anchor anchor} and {@link engine.model.Selection#focus focus}.
+ * {@link engine.model.Selection#anchor anchor} and {@link engine.model.Selection#focus focus}. Additionally,
+ * `Selection` may have it's own attributes.
  *
  * @memberOf engine.model
  */
@@ -30,7 +31,7 @@ export default class Selection {
 		this._lastRangeBackward = false;
 
 		/**
-		 * Stores all ranges that are selected.
+		 * Stores selection ranges.
 		 *
 		 * @protected
 		 * @member {Array.<engine.model.Range>} engine.model.Selection#_ranges
@@ -47,90 +48,15 @@ export default class Selection {
 	}
 
 	/**
-	 * Gets an attribute value for given key or `undefined` if that attribute is not set on the selection.
-	 *
-	 * @param {String} key Key of attribute to look for.
-	 * @returns {*} Attribute value or `undefined`.
-	 */
-	getAttribute( key ) {
-		return this._attrs.get( key );
-	}
-
-	/**
-	 * Returns iterator that iterates over this selection attributes.
-	 *
-	 * @returns {Iterable.<*>}
-	 */
-	getAttributes() {
-		return this._attrs[ Symbol.iterator ]();
-	}
-
-	/**
-	 * Checks if the selection has an attribute for given key.
-	 *
-	 * @param {String} key Key of attribute to check.
-	 * @returns {Boolean} `true` if attribute with given key is set on selection, `false` otherwise.
-	 */
-	hasAttribute( key ) {
-		return this._attrs.has( key );
-	}
-
-	/**
-	 * Removes all attributes from the selection.
-	 *
-	 * @fires engine.model.Selection#change:attribute
-	 */
-	clearAttributes() {
-		this._attrs.clear();
-
-		this.fire( 'change:attribute' );
-	}
-
-	/**
-	 * Removes an attribute with given key from the selection.
-	 *
-	 * @fires engine.model.Selection#change:attribute
-	 * @param {String} key Key of attribute to remove.
-	 */
-	removeAttribute( key ) {
-		this._attrs.delete( key );
-
-		this.fire( 'change:attribute' );
-	}
-
-	/**
-	 * Sets attribute on the selection. If attribute with the same key already is set, it overwrites its values.
-	 *
-	 * @fires engine.model.Selection#change:attribute
-	 * @param {String} key Key of attribute to set.
-	 * @param {*} value Attribute value.
-	 */
-	setAttribute( key, value ) {
-		this._attrs.set( key, value );
-
-		this.fire( 'change:attribute' );
-	}
-
-	/**
-	 * Removes all attributes from the selection and sets given attributes.
-	 *
-	 * @fires engine.model.Selection#change:attribute
-	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set.
-	 */
-	setAttributesTo( attrs ) {
-		this._attrs = toMap( attrs );
-
-		this.fire( 'change:attribute' );
-	}
-
-	/**
-	 * Selection anchor. Anchor may be described as a position where the selection starts. Together with
-	 * {@link engine.model.Selection#focus} they define the direction of selection, which is important
-	 * when expanding/shrinking selection. Anchor is always the start or end of the most recent added range.
+	 * Selection anchor. Anchor may be described as a position where the most recent part of the selection starts.
+	 * Together with {@link engine.model.Selection#focus} they define the direction of selection, which is important
+	 * when expanding/shrinking selection. Anchor is always {@link engine.model.Range#start start} or
+	 * {@link engine.model.Range#end end} position of the most recently added range.
 	 *
 	 * Is set to `null` if there are no ranges in selection.
 	 *
 	 * @see engine.model.Selection#focus
+	 * @readonly
 	 * @type {engine.model.Position|null}
 	 */
 	get anchor() {
@@ -149,6 +75,7 @@ export default class Selection {
 	 * Is set to `null` if there are no ranges in selection.
 	 *
 	 * @see engine.model.Selection#anchor
+	 * @readonly
 	 * @type {engine.model.Position|null}
 	 */
 	get focus() {
@@ -165,6 +92,7 @@ export default class Selection {
 	 * Returns whether the selection is collapsed. Selection is collapsed when there is exactly one range which is
 	 * collapsed.
 	 *
+	 * @readonly
 	 * @type {Boolean}
 	 */
 	get isCollapsed() {
@@ -181,7 +109,7 @@ export default class Selection {
 	 * Returns number of ranges in selection.
 	 *
 	 * @type {Number}
-     */
+	 */
 	get rangeCount() {
 		return this._ranges.length;
 	}
@@ -196,28 +124,25 @@ export default class Selection {
 	}
 
 	/**
-	 * Adds a range to the selection. Added range is copied. This means that passed range is not saved in `Selection`
-	 * instance and operating on it will not change `Selection` state.
-	 *
-	 * Accepts a flag describing in which way the selection is made - passed range might be selected from
-	 * {@link engine.model.Range#start start} to {@link engine.model.Range#end end} or from {@link engine.model.Range#end end}
-	 * to {@link engine.model.Range#start start}. The flag is used to set {@link engine.model.Selection#anchor} and
-	 * {@link engine.model.Selection#focus} properties.
+	 * Checks whether, this selection is equal to given selection. Selections equal if they have the same ranges and directions.
 	 *
-	 * @fires engine.model.Selection#change:range
-	 * @param {engine.model.Range} range Range to add.
-	 * @param {Boolean} [isBackward] Flag describing if added range was selected forward - from start to end (`false`)
-	 * or backward - from end to start (`true`). Defaults to `false`.
+	 * @param {engine.model.Selection} otherSelection Selection to compare with.
+	 * @returns {Boolean} `true` if selections are equal, `false` otherwise.
 	 */
-	addRange( range, isBackward ) {
-		if ( !( range instanceof Range ) ) {
-			throw new CKEditorError( 'selection-invalid-range: Invalid Range.' );
+	isEqual( otherSelection ) {
+		const rangeCount = this.rangeCount;
+
+		if ( rangeCount != otherSelection.rangeCount ) {
+			return false;
 		}
 
-		this._pushRange( range );
-		this._lastRangeBackward = !!isBackward;
+		for ( let i = 0; i < this.rangeCount; i++ ) {
+			if ( !this._ranges[ i ].isEqual( otherSelection._ranges[ i ] ) ) {
+				return false;
+			}
+		}
 
-		this.fire( 'change:range' );
+		return this.isBackward === otherSelection.isBackward;
 	}
 
 	/**
@@ -243,9 +168,7 @@ export default class Selection {
 	getFirstRange() {
 		let first = null;
 
-		for ( let i = 0; i < this._ranges.length; i++ ) {
-			let range = this._ranges[ i ];
-
+		for ( let range of this._ranges ) {
 			if ( !first || range.start.isBefore( first.start ) ) {
 				first = range;
 			}
@@ -255,8 +178,29 @@ export default class Selection {
 	}
 
 	/**
+	 * Returns a copy of the last range in the selection. Last range is the one which {@link engine.model.Range#end end} position
+	 * {@link engine.model.Position#isAfter is after} end position of all other ranges (not to confuse with the range most
+	 * recently added to the selection).
+	 *
+	 * Returns `null` if there are no ranges in selection.
+	 *
+	 * @returns {engine.model.Range|null}
+	 */
+	getLastRange() {
+		let last = null;
+
+		for ( let range of this._ranges ) {
+			if ( !last || range.end.isAfter( last.end ) ) {
+				last = range;
+			}
+		}
+
+		return last ? Range.createFromRange( last ) : null;
+	}
+
+	/**
 	 * Returns the first position in the selection. First position is the position that {@link engine.model.Position#isBefore is before}
-	 * any other position in the selection ranges.
+	 * any other position in the selection.
 	 *
 	 * Returns `null` if there are no ranges in selection.
 	 *
@@ -269,7 +213,42 @@ export default class Selection {
 	}
 
 	/**
-	 * Removes all ranges that were added to the selection. Fires update event.
+	 * Returns the last position in the selection. Last position is the position that {@link engine.model.Position#isAfter is after}
+	 * any other position in the selection.
+	 *
+	 * Returns `null` if there are no ranges in selection.
+	 *
+	 * @returns {engine.model.Position|null}
+	 */
+	getLastPosition() {
+		const lastRange = this.getLastRange();
+
+		return lastRange ? Position.createFromPosition( lastRange.end ) : null;
+	}
+
+	/**
+	 * Adds a range to this selection. Added range is copied. This means that passed range is not saved in `Selection`
+	 * instance and operating on it will not change `Selection` state.
+	 *
+	 * Accepts a flag describing in which way the selection is made - passed range might be selected from
+	 * {@link engine.model.Range#start start} to {@link engine.model.Range#end end} or from {@link engine.model.Range#end end}
+	 * to {@link engine.model.Range#start start}. The flag is used to set {@link engine.model.Selection#anchor} and
+	 * {@link engine.model.Selection#focus} properties.
+	 *
+	 * @fires engine.model.Selection#change:range
+	 * @param {engine.model.Range} range Range to add.
+	 * @param {Boolean} [isBackward=false] Flag describing if added range was selected forward - from start to end (`false`)
+	 * or backward - from end to start (`true`).
+	 */
+	addRange( range, isBackward = false ) {
+		this._pushRange( range );
+		this._lastRangeBackward = !!isBackward;
+
+		this.fire( 'change:range' );
+	}
+
+	/**
+	 * Removes all ranges that were added to the selection.
 	 *
 	 * @fires engine.model.Selection#change:range
 	 */
@@ -281,22 +260,19 @@ export default class Selection {
 
 	/**
 	 * Replaces all ranges that were added to the selection with given array of ranges. Last range of the array
-	 * is treated like the last added range and is used to set {@link #anchor} and {@link #focus}. Accepts a flag
-	 * describing in which way the selection is made (see {@link #addRange}).
+	 * is treated like the last added range and is used to set {@link engine.model.Selection#anchor} and
+	 * {@link engine.model.Selection#focus}. Accepts a flag describing in which direction the selection is made
+	 * (see {@link engine.model.Selection#addRange}).
 	 *
 	 * @fires engine.model.Selection#change:range
-	 * @param {Iterable.<engine.model.Range>} newRanges Iterable set of ranges that should be set.
-	 * @param {Boolean} [isLastBackward] Flag describing if last added range was selected forward - from start to end (`false`)
-	 * or backward - from end to start (`true`). Defaults to `false`.
+	 * @param {Iterable.<engine.model.Range>} newRanges Ranges to set.
+	 * @param {Boolean} [isLastBackward=false] Flag describing if last added range was selected forward - from start to end (`false`)
+	 * or backward - from end to start (`true`).
 	 */
 	setRanges( newRanges, isLastBackward ) {
 		this._ranges = [];
 
 		for ( let range of newRanges ) {
-			if ( !( range instanceof Range ) ) {
-				throw new CKEditorError( 'selection-invalid-range: Invalid Range.' );
-			}
-
 			this._pushRange( range );
 		}
 
@@ -306,33 +282,161 @@ export default class Selection {
 	}
 
 	/**
+	 * Sets this selection's ranges and direction to the ranges and direction of the given selection.
+	 *
+	 * @param {engine.model.Selection} otherSelection
+	 */
+	setTo( otherSelection ) {
+		this.setRanges( otherSelection.getRanges(), otherSelection.isBackward );
+	}
+
+	/**
 	 * Sets collapsed selection in the specified location.
 	 *
 	 * The location can be specified in the same form as {@link engine.model.Position.createAt} parameters.
 	 *
 	 * @fires engine.model.Selection#change:range
-	 * @param {engine.model.Node|engine.model.Position} nodeOrPosition
+	 * @param {engine.model.Item|engine.model.Position} itemOrPosition
 	 * @param {Number|'end'|'before'|'after'} [offset=0] Offset or one of the flags. Used only when
-	 * first parameter is a node.
+	 * first parameter is a {@link engine.model.Item model item}.
 	 */
-	collapse( nodeOrPosition, offset ) {
-		const pos = Position.createAt( nodeOrPosition, offset );
+	collapse( itemOrPosition, offset ) {
+		const pos = Position.createAt( itemOrPosition, offset );
 		const range = new Range( pos, pos );
 
 		this.setRanges( [ range ] );
 	}
 
 	/**
-	 * Sets {@link engine.model.Selection#focus} in the specified location.
+	 * Collapses selection to the selection's {@link engine.model.Selection#getFirstPosition first position}.
+	 * All ranges, besides the collapsed one, will be removed. Nothing will change if there are no ranges stored
+	 * inside selection.
+	 *
+	 * @fires engine.view.Selection#change
+	 */
+	collapseToStart() {
+		const startPosition = this.getFirstPosition();
+
+		if ( startPosition !== null ) {
+			this.setRanges( [ new Range( startPosition, startPosition ) ] );
+		}
+	}
+
+	/**
+	 * Collapses selection to the selection's {@link engine.model.Selection#getLastPosition last position}.
+	 * All ranges, besides the collapsed one, will be removed. Nothing will change if there are no ranges stored
+	 * inside selection.
+	 *
+	 * @fires engine.view.Selection#change
+	 */
+	collapseToEnd() {
+		const endPosition = this.getLastPosition();
+
+		if ( endPosition !== null ) {
+			this.setRanges( [ new Range( endPosition, endPosition ) ] );
+		}
+	}
+
+	/**
+	 * Gets an attribute value for given key or `undefined` if that attribute is not set on the selection.
+	 *
+	 * @param {String} key Key of attribute to look for.
+	 * @returns {*} Attribute value or `undefined`.
+	 */
+	getAttribute( key ) {
+		return this._attrs.get( key );
+	}
+
+	/**
+	 * Returns iterator that iterates over this selection's attributes.
+	 *
+	 * Attributes are returned as arrays containing two items. First one is attribute key and second is attribute value.
+	 * This format is accepted by native `Map` object and also can be passed in `Node` constructor.
+	 *
+	 * @returns {Iterable.<*>}
+	 */
+	getAttributes() {
+		return this._attrs.entries();
+	}
+
+	/**
+	 * Returns iterator that iterates over this selection's attribute keys.
+	 *
+	 * @returns {Iterator.<String>}
+	 */
+	getAttributeKeys() {
+		return this._attrs.keys();
+	}
+
+	/**
+	 * Checks if the selection has an attribute for given key.
+	 *
+	 * @param {String} key Key of attribute to check.
+	 * @returns {Boolean} `true` if attribute with given key is set on selection, `false` otherwise.
+	 */
+	hasAttribute( key ) {
+		return this._attrs.has( key );
+	}
+
+	/**
+	 * Removes all attributes from the selection.
+	 *
+	 * @fires engine.model.Selection#change:attribute
+	 */
+	clearAttributes() {
+		this._attrs.clear();
+
+		this.fire( 'change:attribute' );
+	}
+
+	/**
+	 * Removes an attribute with given key from the selection.
+	 *
+	 * @fires engine.model.Selection#change:attribute
+	 * @param {String} key Key of attribute to remove.
+	 */
+	removeAttribute( key ) {
+		this._attrs.delete( key );
+
+		this.fire( 'change:attribute' );
+	}
+
+	/**
+	 * Sets attribute on the selection. If attribute with the same key already is set, it's value is overwritten.
+	 *
+	 * @fires engine.model.Selection#change:attribute
+	 * @param {String} key Key of attribute to set.
+	 * @param {*} value Attribute value.
+	 */
+	setAttribute( key, value ) {
+		this._attrs.set( key, value );
+
+		this.fire( 'change:attribute' );
+	}
+
+	/**
+	 * Removes all attributes from the selection and sets given attributes.
+	 *
+	 * @fires engine.model.Selection#change:attribute
+	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set.
+	 */
+	setAttributesTo( attrs ) {
+		this._attrs = toMap( attrs );
+
+		this.fire( 'change:attribute' );
+	}
+
+	/**
+	 * Sets {@link engine.model.Selection#focus} to the specified location.
 	 *
 	 * The location can be specified in the same form as {@link engine.model.Position.createAt} parameters.
 	 *
 	 * @fires engine.model.Selection#change:range
-	 * @param {engine.model.Node|engine.model.Position} nodeOrPosition
+	 * @param {engine.model.Item|engine.model.Position} itemOrPosition
 	 * @param {Number|'end'|'before'|'after'} [offset=0] Offset or one of the flags. Used only when
-	 * first parameter is a node.
+	 * first parameter is a {@link engine.model.Item model item}.
 	 */
-	setFocus( nodeOrPosition, offset ) {
+	setFocus( itemOrPosition, offset ) {
 		if ( this.anchor === null ) {
 			/**
 			 * Cannot set selection focus if there are no ranges in selection.
@@ -342,7 +446,7 @@ export default class Selection {
 			throw new CKEditorError( 'selection-setFocus-no-ranges: Cannot set selection focus if there are no ranges in selection.' );
 		}
 
-		const newFocus = Position.createAt( nodeOrPosition, offset );
+		const newFocus = Position.createAt( itemOrPosition, offset );
 
 		if ( newFocus.compareWith( this.focus ) == 'same' ) {
 			return;
@@ -362,22 +466,37 @@ export default class Selection {
 	}
 
 	/**
-	 * Creates and returns an instance of {@link engine.model.Selection} that is a clone of given selection,
-	 * meaning that it has same ranges and same direction as it.
+	 * Creates and returns an instance of `Selection` that is a clone of given selection, meaning that it has same
+	 * ranges and same direction as this selection.
 	 *
 	 * @params {engine.model.Selection} otherSelection Selection to be cloned.
 	 * @returns {engine.model.Selection} `Selection` instance that is a clone of given selection.
 	 */
 	static createFromSelection( otherSelection ) {
 		const selection = new this();
-		selection.setRanges( otherSelection.getRanges(), otherSelection.isBackward );
+		selection.setTo( otherSelection );
 
 		return selection;
 	}
 
 	/**
+	 * Adds given range to internal {@link engine.model.Selection#_ranges ranges array}. Throws an error
+	 * if given range is intersecting with any range that is already stored in this selection.
+	 *
+	 * @protected
+	 * @param {engine.model.Range} range Range to add.
+	 */
+	_pushRange( range ) {
+		if ( !( range instanceof Range ) ) {
+			throw new CKEditorError( 'selection-added-not-range: Trying to add an object that is not an instance of Range.' );
+		}
+
+		this._checkRange( range );
+		this._ranges.push( Range.createFromRange( range ) );
+	}
+
+	/**
 	 * Checks if given range intersects with ranges that are already in the selection. Throws an error if it does.
-	 * This method is extracted from {@link engine.model.Selection#_pushRange } so it is easier to override it.
 	 *
 	 * @param {engine.model.Range} range Range to check.
 	 * @protected
@@ -408,18 +527,6 @@ export default class Selection {
 	_popRange() {
 		this._ranges.pop();
 	}
-
-	/**
-	 * Adds given range to internal {@link engine.model.Selection#_ranges ranges array}. Throws an error
-	 * if given range is intersecting with any range that is already stored in this selection.
-	 *
-	 * @protected
-	 * @param {engine.model.Range} range Range to add.
-	 */
-	_pushRange( range ) {
-		this._checkRange( range );
-		this._ranges.push( Range.createFromRange( range ) );
-	}
 }
 
 mix( Selection, EmitterMixin );

+ 35 - 82
packages/ckeditor5-engine/src/model/text.js

@@ -3,120 +3,73 @@
  * For licensing, see LICENSE.md.
  */
 
-import toMap from '../../utils/tomap.js';
+import Node from './node.js';
 
 /**
- * Data structure for text with attributes. Note that `Text` is not a {@link engine.model.Node}. This class is used
- * as an aggregator for multiple characters that have same attributes. Example usage:
+ * Model text node. Type of {@link engine.model.Node node} that contains {@link engine.model.Text#data text data}.
  *
- *        let myElem = new Element( 'li', [], new Text( 'text with attributes', { foo: true, bar: true } ) );
+ * **Important:** see {@link engine.model.Node} to read about restrictions using `Text` and `Node` API.
+ *
+ * **Note:** keep in mind that `Text` instances might indirectly got removed from model tree when model is changed.
+ * This happens when {@link engine.model.writer model writer} is used to change model and the text node is merged with
+ * another text node. Then, both text nodes are removed and a new text node is inserted into the model. Because of
+ * this behavior, keeping references to `Text` is not recommended. Instead, consider creating
+ * {@link engine.model.LivePosition live position} placed before the text node.
  *
  * @memberOf engine.model
  */
-export default class Text {
+export default class Text extends Node {
 	/**
-	 * Creates a text with attributes.
+	 * Creates a text node.
 	 *
-	 * @param {String} text Described text.
-	 * @param {Iterable|Object} [attrs] Iterable collection of attributes.
+	 * @param {String} data Node's text.
+	 * @param {Object} [attrs] Node's attributes. See {@link utils.toMap} for a list of accepted values.
 	 */
-	constructor( text, attrs ) {
-		/**
-		 * Text.
-		 *
-		 * @readonly
-		 * @member {String} engine.model.Text#text
-		 */
-		this.text = text || '';
+	constructor( data, attrs ) {
+		super( attrs );
 
 		/**
-		 * List of attributes bound with the text.
+		 * Text data contained in this text node.
 		 *
-		 * @protected
-		 * @member {Map} engine.model.Text#_attrs
+		 * @type {String}
 		 */
-		this._attrs = toMap( attrs );
+		this.data = data || '';
 	}
 
 	/**
-	 * Checks if the text has an attribute for given key.
-	 *
-	 * @param {String} key Key of attribute to check.
-	 * @returns {Boolean} `true` if attribute with given key is set on text, `false` otherwise.
+	 * @inheritDoc
 	 */
-	hasAttribute( key ) {
-		return this._attrs.has( key );
+	get offsetSize() {
+		return this.data.length;
 	}
 
 	/**
-	 * Gets an attribute value for given key or undefined if that attribute is not set on text.
-	 *
-	 * @param {String} key Key of attribute to look for.
-	 * @returns {*} Attribute value or null.
+	 * Creates a copy of this text node and returns it. Created text node has same text data and attributes as original text node.
 	 */
-	getAttribute( key ) {
-		return this._attrs.get( key );
+	clone() {
+		return new Text( this.data, this.getAttributes() );
 	}
 
 	/**
-	 * Returns iterator that iterates over this text attributes.
+	 * Converts `Text` instance to plain object and returns it.
 	 *
-	 * @returns {Iterable.<*>}
+	 * @returns {Object} `Text` instance converted to plain object.
 	 */
-	getAttributes() {
-		return this._attrs[ Symbol.iterator ]();
-	}
-
-	/**
-	 * Sets attribute on text. If attribute with the same key already is set, it overwrites its value.
-	 *
-	 * @param {String} key Key of attribute to set.
-	 * @param {*} value Attribute value.
-	 */
-	setAttribute( key, value ) {
-		this._attrs.set( key, value );
-	}
-
-	/**
-	 * Removes all attributes from text and sets given attributes.
-	 *
-	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set. See {@link engine.model.Text#getAttributes}.
-	 */
-	setAttributesTo( attrs ) {
-		this._attrs = toMap( attrs );
-	}
+	toJSON() {
+		let json = super.toJSON();
 
-	/**
-	 * Removes an attribute with given key from text.
-	 *
-	 * @param {String} key Key of attribute to remove.
-	 * @returns {Boolean} `true` if the attribute was set on text, `false` otherwise.
-	 */
-	removeAttribute( key ) {
-		return this._attrs.delete( key );
-	}
+		json.data = this.data;
 
-	/**
-	 * Removes all attributes from text.
-	 */
-	clearAttributes() {
-		this._attrs.clear();
+		return json;
 	}
 
 	/**
-	 * Custom toJSON method to solve child-parent circular dependencies.
+	 * Creates a `Text` instance from given plain object (i.e. parsed JSON string).
 	 *
-	 * @returns {Object} Clone of this object with the parent property replaced with its name.
+	 * @param {Object} json Plain object to be converted to `Text`.
+	 * @returns {engine.model.Text} `Text` instance created using given plain object.
 	 */
-	toJSON() {
-		let json = {
-			text: this.text
-		};
-
-		if ( this._attrs.size ) {
-			json.attributes = [ ...this._attrs ];
-		}
-
-		return json;
+	static fromJSON( json ) {
+		return new Text( json.data, json.attributes );
 	}
 }

+ 106 - 89
packages/ckeditor5-engine/src/model/textproxy.js

@@ -3,171 +3,188 @@
  * For licensing, see LICENSE.md.
  */
 
-import CharacterProxy from './characterproxy.js';
-import toMap from '../../utils/tomap.js';
-
 /**
- * TextProxy 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.model.TreeWalker}, {@link engine.model.Range}).
+ * `TextProxy` represents a part of {@link engine.model.Text text node}.
+ *
+ * Since {@link engine.model.Position positions} can be placed between characters of a text node,
+ * {@link engine.model.Range ranges} may contain only parts of text nodes. When {@link engine.model.Range#getItems getting items}
+ * contained in such range, we need to represent a part of that text node, since returning the whole text node would be incorrect.
+ * `TextProxy` solves this issue.
+ *
+ * `TextProxy` has an API similar to {@link engine.model.Text Text} and allows to do most of the common tasks performed
+ * on model nodes.
+ *
+ * **Note:** Some `TextProxy` instances may represent whole text node, not just a part of it.
  *
- * **Note:** TextProxy 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 TextProxy instances
- * because they might get invalidated due to operations on Document. This is especially true when you change
- * attributes of TextProxy.
+ * **Note:** `TextProxy` is not an instance of {@link engine.model.Node node}. Keep this in mind when using it as a
+ * parameter of methods.
  *
- * Difference between {@link engine.model.TextProxy} and {@link engine.model.Text} is that the former is a set of
- * nodes taken from tree model, while {@link engine.model.Text} is simply a string with attributes set.
+ * **Note:** `TextProxy` is readonly interface. If you want to perform changes on model data represented by a `TextProxy`
+ * use {@link engine.model.writer model writer API}.
  *
- * You should never create an instance of this class by your own. Instead, use string literals or {@link engine.model.Text}.
+ * **Note:** `TextProxy` instances are created on the fly, basing on the current state of model. Because of this, it is
+ * highly unrecommended to store references to `TextProxy` instances. `TextProxy` instances are not refreshed when
+ * model changes, so they might get invalidated. Instead, consider creating {@link engine.model.LivePosition live position}.
+ *
+ * `TextProxy` instances are created by {@link engine.model.TreeWalker model tree walker}. You should not need to create
+ * an instance of this class by your own.
  *
  * @memberOf engine.model
  */
 export default class TextProxy {
 	/**
-	 * Creates a text fragment.
+	 * Creates a text proxy.
 	 *
 	 * @protected
-	 * @param {engine.model.CharacterProxy} firstCharacter First character node contained in {@link engine.model.TextProxy}.
-	 * @param {Number} length Whole text contained in {@link engine.model.TextProxy}.
+	 * @param {engine.model.Text} textNode Text node which part is represented by this text proxy.
+	 * @param {Number} offsetInText Offset in {@link engine.model.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( firstCharacter, length ) {
+	constructor( textNode, offsetInText, length ) {
 		/**
-		 * First character node contained in {@link engine.model.TextProxy}.
+		 * Text node which part is represented by this text proxy.
 		 *
 		 * @readonly
-		 * @member {engine.model.CharacterProxy} engine.model.TextProxy#first
+		 * @member {engine.model.Text} engine.model.TextProxy#textNode
 		 */
-		this.first = firstCharacter;
+		this.textNode = textNode;
 
 		/**
-		 * Characters contained in {@link engine.model.TextProxy}.
+		 * Text data represented by this text proxy.
 		 *
 		 * @readonly
-		 * @member {String} engine.model.TextProxy#text
+		 * @member {String} engine.model.TextProxy#data
 		 */
-		this.text = firstCharacter._nodeListText.text.substr( this.first._index, length );
+		this.data = textNode.data.substring( offsetInText, offsetInText + length );
 
 		/**
-		 * Last {@link engine.model.CharacterProxy character node} contained in {@link engine.model.TextProxy}.
+		 * Offset in {@link engine.model.TextProxy#textNode text node} from which the text proxy starts.
 		 *
 		 * @readonly
-		 * @member {engine.model.CharacterProxy} engine.model.TextProxy#last
+		 * @member {Number} engine.model.TextProxy#offsetInText
 		 */
-		this.last = this.getCharAt( this.text.length - 1 );
+		this.offsetInText = offsetInText;
 	}
 
 	/**
-	 * A common parent of all character nodes contained in {@link engine.model.TextProxy}.
+	 * Parent of this text proxy, which is same as parent of text node represented by this text proxy.
 	 *
-	 * @type {engine.model.Element}
+	 * @readonly
+	 * @type {engine.model.Element|engine.model.DocumentFragment|null}
 	 */
-	get commonParent() {
-		return this.first.parent;
+	get parent() {
+		return this.textNode.parent;
 	}
 
 	/**
-	 * Gets a character at given index and creates a {@link engine.model.CharacterProxy} out of it.
+	 * Root of this text proxy, which is same as root of text node represented by this text proxy.
 	 *
-	 * @param {Number} index Character index.
-	 * @returns {engine.model.CharacterProxy}
+	 * @readonly
+	 * @type {engine.model.Element|engine.model.DocumentFragment}
 	 */
-	getCharAt( index ) {
-		if ( index < 0 || index >= this.text.length ) {
-			return null;
-		}
-
-		return new CharacterProxy( this.first._nodeListText, this.first._index + index );
+	get root() {
+		return this.textNode.root;
 	}
 
 	/**
-	 * Checks if the text fragment has an attribute for given key.
+	 * {@link engine.model.Document Document} that owns text node represented by this text proxy or `null` if the text node
+	 * has no parent or is inside a {@link engine.model.DocumentFragment DocumentFragment}.
 	 *
-	 * @param {String} key Key of attribute to check.
-	 * @returns {Boolean} `true` if attribute with given key is set on text fragment, `false` otherwise.
+	 * @returns {engine.model.Document|null}
 	 */
-	hasAttribute( key ) {
-		return this.first.hasAttribute( key );
+	get document() {
+		return this.textNode.document;
 	}
 
 	/**
-	 * Gets an attribute value for given key or undefined it that attribute is not set on text fragment.
+	 * Offset at which this text proxy starts in it's parent.
 	 *
-	 * @param {String} key Key of attribute to look for.
-	 * @returns {*} Attribute value or null.
+	 * @see engine.model.Node#startOffset
+	 * @readonly
+	 * @type {Number}
 	 */
-	getAttribute( key ) {
-		return this.first.getAttribute( key );
+	get startOffset() {
+		return this.textNode.startOffset !== null ? this.textNode.startOffset + this.offsetInText : null;
 	}
 
 	/**
-	 * Returns iterator that iterates over this text fragment attributes.
+	 * Offset size of this text proxy. Equal to the number of characters represented by the text proxy.
 	 *
-	 * @returns {Iterable.<*>}
+	 * @see engine.model.Node#offsetSize
+	 * @readonly
+	 * @type {Number}
 	 */
-	getAttributes() {
-		return this.first.getAttributes();
+	get offsetSize() {
+		return this.data.length;
 	}
 
 	/**
-	 * Sets attribute on the text fragment. If attribute with the same key already is set, it overwrites its values.
+	 * Offset at which this text proxy ends in it's parent.
 	 *
-	 * **Note:** Changing attributes of text fragment affects document state. This TextProxy instance properties
-	 * will be refreshed, but other may get invalidated. It is highly unrecommended to store references to TextProxy instances.
+	 * @see engine.model.Node#endOffset
+	 * @readonly
+	 * @type {Number}
+	 */
+	get endOffset() {
+		return this.startOffset !== null ? this.startOffset + this.offsetSize : null;
+	}
+
+	/**
+	 * Gets path to this text proxy.
 	 *
-	 * @param {String} key Key of attribute to set.
-	 * @param {*} value Attribute value.
+	 * @see engine.model.Node#getPath
+	 * @readonly
+	 * @type {Array.<Number>}
 	 */
-	setAttribute( key, value ) {
-		let index = this.first.getIndex();
+	getPath() {
+		const path = this.textNode.getPath();
 
-		this.commonParent._children.setAttribute( this.first.getIndex(), this.text.length, key, value );
+		if ( path.length > 0 ) {
+			path[ path.length - 1 ] += this.offsetInText;
+		}
 
-		this.first = this.commonParent.getChild( index );
-		this.last = this.getCharAt( this.text.length - 1 );
+		return path;
 	}
 
 	/**
-	 * Removes all attributes from the text fragment and sets given attributes.
+	 * Checks if this text proxy has an attribute for given key.
 	 *
-	 * **Note:** Changing attributes of text fragment affects document state. This `TextProxy` instance properties
-	 * will be refreshed, but other may get invalidated. It is highly unrecommended to store references to TextProxy instances.
-	 *
-	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set.
-	 * See {@link engine.model.TextProxy#getAttributes}.
+	 * @param {String} key Key of attribute to check.
+	 * @returns {Boolean} `true` if attribute with given key is set on text proxy, `false` otherwise.
 	 */
-	setAttributesTo( attrs ) {
-		let attrsMap = toMap( attrs );
-
-		this.clearAttributes();
+	hasAttribute( key ) {
+		return this.textNode.hasAttribute( key );
+	}
 
-		for ( let attr of attrsMap ) {
-			this.setAttribute( attr[ 0 ], attr[ 1 ] );
-		}
+	/**
+	 * Gets an attribute value for given key or `undefined` if that attribute is not set on text proxy.
+	 *
+	 * @param {String} key Key of attribute to look for.
+	 * @returns {*} Attribute value or `undefined`.
+	 */
+	getAttribute( key ) {
+		return this.textNode.getAttribute( key );
 	}
 
 	/**
-	 * Removes an attribute with given key from the text fragment.
+	 * Returns iterator that iterates over this node's attributes. Attributes are returned as arrays containing two
+	 * items. First one is attribute key and second is attribute value.
 	 *
-	 * **Note:** Changing attributes of text fragment affects document state. This `TextProxy` instance properties
-	 * will be refreshed, but other may get invalidated. It is highly unrecommended to store references to TextProxy instances.
+	 * This format is accepted by native `Map` object and also can be passed in `Node` constructor.
 	 *
-	 * @param {String} key Key of attribute to remove.
+	 * @returns {Iterable.<*>}
 	 */
-	removeAttribute( key ) {
-		this.setAttribute( key, null );
+	getAttributes() {
+		return this.textNode.getAttributes();
 	}
 
 	/**
-	 * Removes all attributes from the text fragment.
+	 * Returns iterator that iterates over this node's attribute keys.
 	 *
-	 * **Note:** Changing attributes of text fragment affects document state. This `TextProxy` instance properties
-	 * will be refreshed, but other may get invalidated. It is highly unrecommended to store references to TextProxy instances.
+	 * @returns {Iterator.<String>}
 	 */
-	clearAttributes() {
-		for ( let attr of this.getAttributes() ) {
-			this.removeAttribute( attr[ 0 ] );
-		}
+	getAttributeKeys() {
+		return this.textNode.getAttributeKeys();
 	}
 }

+ 33 - 31
packages/ckeditor5-engine/src/model/treewalker.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md.
  */
 
-import CharacterProxy from './characterproxy.js';
+import Text from './text.js';
 import TextProxy from './textproxy.js';
 import Element from './element.js';
 import Position from './position.js';
@@ -177,7 +177,7 @@ export default class TreeWalker {
 		const parent = this._visitedParent;
 
 		// We are at the end of the root.
-		if ( parent.parent === null && position.offset === parent.getChildCount() ) {
+		if ( parent.parent === null && position.offset === parent.getMaxOffset() ) {
 			return { done: true };
 		}
 
@@ -186,7 +186,7 @@ export default class TreeWalker {
 			return { done: true };
 		}
 
-		const node = parent.getChild( position.offset );
+		const node = position.textNode ? position.textNode : position.nodeAfter;
 
 		if ( node instanceof Element ) {
 			if ( !this.shallow ) {
@@ -200,28 +200,29 @@ export default class TreeWalker {
 			this.position = position;
 
 			return formatReturnValue( 'elementStart', node, previousPosition, position, 1 );
-		} else if ( node instanceof CharacterProxy ) {
-			if ( this.singleCharacters ) {
-				position.offset++;
-				this.position = position;
+		} else if ( node instanceof Text ) {
+			let charactersCount, offsetInTextNode;
 
-				return formatReturnValue( 'character', node, previousPosition, position, 1 );
+			if ( this.singleCharacters ) {
+				charactersCount = 1;
 			} else {
-				let charactersCount = node._nodeListText.text.length - node._index;
-				let offset = position.offset + charactersCount;
+				let offset = node.endOffset;
 
 				if ( this._boundaryEndParent == parent && this.boundaries.end.offset < offset ) {
 					offset = this.boundaries.end.offset;
-					charactersCount = offset - position.offset;
 				}
 
-				let textProxy = new TextProxy( node, charactersCount );
+				charactersCount = offset - position.offset;
+			}
+
+			offsetInTextNode = position.offset - node.startOffset;
 
-				position.offset = offset;
-				this.position = position;
+			const item = new TextProxy( node, offsetInTextNode, charactersCount );
 
-				return formatReturnValue( 'text', textProxy, previousPosition, position, charactersCount );
-			}
+			position.offset += charactersCount;
+			this.position = position;
+
+			return formatReturnValue( 'text', item, previousPosition, position, charactersCount );
 		} else {
 			// `node` is not set, we reached the end of current `parent`.
 			position.path.pop();
@@ -261,13 +262,13 @@ export default class TreeWalker {
 		}
 
 		// Get node just before current position
-		const node = parent.getChild( position.offset - 1 );
+		const node = position.textNode ? position.textNode : position.nodeBefore;
 
 		if ( node instanceof Element ) {
 			position.offset--;
 
 			if ( !this.shallow ) {
-				position.path.push( node.getChildCount() );
+				position.path.push( node.getMaxOffset() );
 				this.position = position;
 				this._visitedParent = node;
 
@@ -281,28 +282,29 @@ export default class TreeWalker {
 
 				return formatReturnValue( 'elementStart', node, previousPosition, position, 1 );
 			}
-		} else if ( node instanceof CharacterProxy ) {
-			if ( this.singleCharacters ) {
-				position.offset--;
-				this.position = position;
+		} else if ( node instanceof Text ) {
+			let charactersCount, offsetInTextNode;
 
-				return formatReturnValue( 'character', node, previousPosition, position, 1 );
+			if ( this.singleCharacters ) {
+				charactersCount = 1;
 			} else {
-				let charactersCount = node._index + 1;
-				let offset = position.offset - charactersCount;
+				let offset = node.startOffset;
 
 				if ( this._boundaryStartParent == parent && this.boundaries.start.offset > offset ) {
 					offset = this.boundaries.start.offset;
-					charactersCount = position.offset - offset;
 				}
 
-				let textFragment = new TextProxy( parent.getChild( offset ), charactersCount );
+				charactersCount = position.offset - offset;
+			}
 
-				position.offset = offset;
-				this.position = position;
+			offsetInTextNode = position.offset - node.startOffset;
 
-				return formatReturnValue( 'text', textFragment, previousPosition, position, charactersCount );
-			}
+			const item = new TextProxy( node, offsetInTextNode - charactersCount, charactersCount );
+
+			position.offset -= charactersCount;
+			this.position = position;
+
+			return formatReturnValue( 'text', item, previousPosition, position, charactersCount );
 		} else {
 			// `node` is not set, we reached the beginning of current `parent`.
 			position.path.pop();

+ 316 - 0
packages/ckeditor5-engine/src/model/writer.js

@@ -0,0 +1,316 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Node from './node.js';
+import Text from './text.js';
+import TextProxy from './textproxy.js';
+import Range from './range.js';
+import DocumentFragment from './documentfragment.js';
+import NodeList from './nodelist.js';
+import CKEditorError from '../../utils/ckeditorerror.js';
+
+/**
+ * Contains functions used for composing model tree, grouped together under "model writer" name. Those functions
+ * are built on top of {@link engine.model.Node node}, and it's child classes', APIs.
+ *
+ * Model writer API has multiple advantages and it is highly recommended to use it when changing model tree and nodes:
+ * * model writer API {@link engine.model.writer.normalizeNodes normalizes inserted nodes}, which means that you can insert
+ * not only {@link engine.model.Node nodes}, but also `String`s, {@link engine.model.TextProxy text proxies} and
+ * {@link engine.model.DocumentFragment document fragments},
+ * * model writer API operates on {@link engine.model.Position positions}, which means that you have
+ * better control over manipulating model tree as positions operate on offsets rather than indexes,
+ * * model writer API automatically merges {@link engine.model.TextNode text nodes} with same attributes, which means
+ * lower memory usage and better efficiency.
+ *
+ * @namespace engine.model.writer
+ */
+
+export default {
+	insert,
+	remove,
+	move,
+	setAttribute,
+	removeAttribute,
+	normalizeNodes
+};
+
+/**
+ * Inserts given nodes at given position.
+ *
+ * @function engine.model.writer.insert
+ * @param {engine.model.Position} position Position at which nodes should be inserted.
+ * @param {engine.model.NodeSet} nodes Nodes to insert.
+ * @returns {engine.model.Range} Range spanning over inserted elements.
+ */
+export function insert( position, nodes ) {
+	nodes = normalizeNodes( nodes );
+
+	// We have to count offset before inserting nodes because they can get merged and we would get wrong offsets.
+	const offset = nodes.reduce( ( sum, node ) => sum + node.offsetSize, 0 );
+	const parent = position.parent;
+
+	// Insertion might be in a text node, we should split it if that's the case.
+	let index = _splitNodeAtPosition( position );
+
+	// Insert nodes at given index. After splitting we have a proper index and insertion is between nodes,
+	// using basic `Element` API.
+	parent.insertChildren( index, nodes );
+
+	// Merge text nodes, if possible. Merging is needed only at points where inserted nodes "touch" "old" nodes.
+	_mergeNodesAtIndex( parent, index + nodes.length );
+	_mergeNodesAtIndex( parent, index );
+
+	return new Range( position, position.getShiftedBy( offset ) );
+}
+
+/**
+ * Removed nodes in given range. Only {@link engine.model.Range#isFlat flat} ranges are accepted.
+ *
+ * @function engine.model.writer.remove
+ * @param {engine.model.Range} range Range containing nodes to remove.
+ * @returns {Array.<engine.model.Node>}
+ */
+export function remove( range ) {
+	if ( !range.isFlat ) {
+		/**
+		 * Trying to remove a range that starts and ends in different element.
+		 *
+		 * @error model-writer-remove-range-not-flat
+		 */
+		throw new CKEditorError( 'model-writer-remove-range-not-flat: ' +
+			'Trying to remove a range that starts and ends in different element.' );
+	}
+
+	const parent = range.start.parent;
+
+	// Range may be inside text nodes, we have to split them if that's the case.
+	const indexStart = _splitNodeAtPosition( range.start );
+	const indexEnd = _splitNodeAtPosition( range.end );
+
+	// Remove the text nodes using basic `Element` API.
+	const removed = parent.removeChildren( indexStart, indexEnd - indexStart );
+
+	// Merge text nodes, if possible. After some nodes were removed, node before and after removed range will be
+	// touching at the position equal to the removed range beginning. We check merging possibility there.
+	_mergeNodesAtIndex( parent, indexStart );
+
+	return removed;
+}
+
+/**
+ * Moves nodes in given range to given target position. Only {@link engine.model.Range#isFlat flat} ranges are accepted.
+ *
+ * @param {engine.model.Range} sourceRange Range containing nodes to move.
+ * @param {engine.model.Position} targetPosition Position to which nodes should be moved.
+ * @returns {engine.model.Range} Range containing moved nodes.
+ */
+export function move( sourceRange, targetPosition ) {
+	/* jshint validthis:true */
+	if ( !sourceRange.isFlat ) {
+		/**
+		 * Trying to move a range that starts and ends in different element.
+		 *
+		 * @error model-writer-move-range-not-flat
+		 */
+		throw new CKEditorError( 'model-writer-move-range-not-flat: ' +
+			'Trying to move a range that starts and ends in different element.' );
+	}
+
+	const nodes = this.remove( sourceRange );
+
+	// We have to fix `targetPosition` because model changed after nodes from `sourceRange` got removed and
+	// that change might have an impact on `targetPosition`.
+	targetPosition = targetPosition._getTransformedByDeletion( sourceRange.start, sourceRange.end.offset - sourceRange.start.offset );
+
+	return this.insert( targetPosition, nodes );
+}
+
+/**
+ * Sets given attribute on nodes in given range.
+ *
+ * @param {engine.model.Range} range Range containing nodes that should have the attribute set.
+ * @param {String} key Key of attribute to set.
+ * @param {*} value Attribute value.
+ */
+export function setAttribute( range, key, value ) {
+	// Range might start or end in text nodes, so we have to split them.
+	_splitNodeAtPosition( range.start );
+	const indexEnd = _splitNodeAtPosition( range.end );
+
+	// Iterate over all items in the range.
+	for ( let item of range.getItems() ) {
+		// Iterator will return `TextProxy` instances but we know that those text proxies will
+		// always represent full text nodes (this is guaranteed thanks to splitting we did before).
+		// So, we can operate on those text proxies' text nodes.
+		let node = item instanceof TextProxy ? item.textNode : item;
+
+		if ( value !== null ) {
+			node.setAttribute( key, value );
+		} else {
+			node.removeAttribute( key );
+		}
+
+		// After attributes changing it may happen that some text nodes can be merged. Try to merge with previous node.
+		_mergeNodesAtIndex( node.parent, node.getIndex() );
+	}
+
+	// Try to merge last changed node with it's previous sibling (not covered by the loop above).
+	_mergeNodesAtIndex( range.end.parent, indexEnd );
+}
+
+/**
+ * Removes given attribute from nodes in given range.
+ *
+ * @param {engine.model.Range} range Range containing nodes that should have the attribute removed.
+ * @param {String} key Key of attribute to remove.
+ */
+export function removeAttribute( range, key ) {
+	/* jshint validthis:true */
+	this.setAttribute( range, key, null );
+}
+
+/**
+ * Normalizes given object or an array of objects to an array of {@link engine.model.Node nodes}. See
+ * {@link engine.model.NodeSet NodeSet} for details on how normalization is performed.
+ *
+ * @param {engine.model.NodeSet} nodes Objects to normalize.
+ * @returns {Array.<engine.model.Node>} Normalized nodes.
+ */
+export function normalizeNodes( nodes ) {
+	const normalized = [];
+
+	if ( !( nodes instanceof Array ) ) {
+		nodes = [ nodes ];
+	}
+
+	// Convert instances of classes other than Node.
+	for ( let i = 0; i < nodes.length; i++ ) {
+		if ( typeof nodes[ i ] == 'string' ) {
+			normalized.push( new Text( nodes[ i ] ) );
+		} else if ( nodes[ i ] instanceof TextProxy ) {
+			normalized.push( new Text( nodes[ i ].data, nodes[ i ].getAttributes() ) );
+		} else if ( nodes[ i ] instanceof DocumentFragment || nodes[ i ] instanceof NodeList ) {
+			for ( let child of nodes[ i ] ) {
+				normalized.push( child );
+			}
+		} else if ( nodes[ i ] instanceof Node ) {
+			normalized.push( nodes[ i ] );
+		}
+		// Skip unrecognized type.
+	}
+
+	// Merge text nodes.
+	for ( let i = 1; i < normalized.length; i++ ) {
+		const node = normalized[ i ];
+		const prev = normalized[ i - 1 ];
+
+		if ( node instanceof Text && prev instanceof Text && _haveSameAttributes( node, prev ) ) {
+			// Doing this instead changing prev.data because .data is readonly.
+			normalized.splice( i - 1, 2, new Text( prev.data + node.data, prev.getAttributes() ) );
+			i--;
+		}
+	}
+
+	return normalized;
+}
+
+/**
+ * Checks if nodes before and after given index in given element are {@link engine.model.Text text nodes} and
+ * merges them into one node if they have same attributes.
+ *
+ * Merging is done by removing two text nodes and inserting a new text node containing data from both merged text nodes.
+ *
+ * @ignore
+ * @private
+ * @param {engine.model.Element} element Parent element of nodes to merge.
+ * @param {Number} index Index between nodes to merge.
+ */
+function _mergeNodesAtIndex( element, index ) {
+	const nodeBefore = element.getChild( index - 1 );
+	const nodeAfter = element.getChild( index );
+
+	// Check if both of those nodes are text objects with same attributes.
+	if ( nodeBefore instanceof Text && nodeAfter instanceof Text && _haveSameAttributes( nodeBefore, nodeAfter ) ) {
+		// Append text of text node after index to the before one.
+		const mergedNode = new Text( nodeBefore.data + nodeAfter.data, nodeBefore.getAttributes() );
+
+		// Remove separate text nodes.
+		element.removeChildren( index - 1, 2 );
+
+		// Insert merged text node.
+		element.insertChildren( index - 1, mergedNode );
+	}
+}
+
+/**
+ * Checks if given position is in a text node, and if so, splits the text node in two text nodes, each of them
+ * containing a part of original text node.
+ *
+ * @ignore
+ * @private
+ * @param {engine.model.Position} position Position at which node should be split.
+ * @returns {Number} Index, in position's parent element, between split nodes.
+ */
+function _splitNodeAtPosition( position ) {
+	const textNode = position.textNode;
+	const element = position.parent;
+
+	if ( textNode ) {
+		const offsetDiff = position.offset - textNode.startOffset;
+		const index = textNode.getIndex();
+
+		element.removeChildren( index, 1 );
+
+		const firstPart = new Text( textNode.data.substr( 0, offsetDiff ), textNode.getAttributes() );
+		const secondPart = new Text( textNode.data.substr( offsetDiff ), textNode.getAttributes() );
+
+		element.insertChildren( index, [ firstPart, secondPart ] );
+
+		return index + 1;
+	}
+
+	return element.offsetToIndex( position.offset );
+}
+
+/**
+ * Checks whether two given nodes have same attributes.
+ *
+ * @ignore
+ * @private
+ * @param {engine.model.Node} nodeA Node to check.
+ * @param {engine.model.Node} nodeB Node to check.
+ * @returns {Boolean} `true` if nodes have same attributes, `false` otherwise.
+ */
+function _haveSameAttributes( nodeA, nodeB ) {
+	const iteratorA = nodeA.getAttributes();
+	const iteratorB = nodeB.getAttributes();
+
+	for ( let attr of iteratorA ) {
+		if ( attr[ 1 ] !== nodeB.getAttribute( attr[ 0 ] ) ) {
+			return false;
+		}
+
+		iteratorB.next();
+	}
+
+	return iteratorB.next().done;
+}
+
+/**
+ * Value that can be normalized to an array of {@link engine.model.Node nodes}.
+ *
+ * Non-arrays are normalized as follows:
+ * * {@link engine.model.Node Node} is left as is,
+ * * {@link engine.model.TextProxy TextProxy} and `String` are normalized to {@link engine.model.Text Text},
+ * * {@link engine.model.NodeList NodeList} is normalized to an array containing all nodes that are in that node list,
+ * * {@link engine.model.DocumentFragment DocumentFragment} is normalized to an array containing all of it's children.
+ *
+ * Arrays are processed item by item like non-array values and flattened to one array. Normalization always results in
+ * a flat array of {@link engine.model.Node nodes}. Consecutive text nodes (or items normalized to text nodes) will be
+ * merged if they have same attributes.
+ *
+ * @typedef {engine.model.Node|engine.model.TextProxy|String|engine.model.NodeList|engine.model.DocumentFragment|Iterable}
+ * engine.model.NodeSet
+ */

+ 17 - 1
packages/ckeditor5-engine/src/view/document.js

@@ -9,7 +9,10 @@ import DomConverter from './domconverter.js';
 import RootEditableElement from './rooteditableelement.js';
 import { injectQuirksHandling } from './filler.js';
 import log from '../../utils/log.js';
-
+import MutationObserver from './observer/mutationobserver.js';
+import SelectionObserver from './observer/selectionobserver.js';
+import FocusObserver from './observer/focusobserver.js';
+import KeyObserver from './observer/keyobserver.js';
 import mix from '../../utils/mix.js';
 import ObservableMixin from '../../utils/observablemixin.js';
 
@@ -22,6 +25,13 @@ import ObservableMixin from '../../utils/observablemixin.js';
  * If you want to only transform the tree of view elements to the DOM elements you can use the
  * {@link engine.view.DomConverter DomConverter}.
  *
+ * Note that the following observers are added by the class constructor and are always available:
+ *
+ * * {@link view.observer.MutationObserver},
+ * * {@link view.observer.SelectionObserver},
+ * * {@link view.observer.FocusObserver},
+ * * {@link view.observer.KeyObserver}.
+ *
  * @memberOf engine.view
  * @mixes utils.EmitterMixin
  */
@@ -92,6 +102,12 @@ export default class Document {
 		 */
 		this._observers = new Map();
 
+		// Add default observers.
+		this.addObserver( MutationObserver );
+		this.addObserver( SelectionObserver );
+		this.addObserver( FocusObserver );
+		this.addObserver( KeyObserver );
+
 		injectQuirksHandling( this );
 
 		// Listens `render` event on default priority.

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

@@ -142,7 +142,7 @@ export default class DocumentFragment {
 	 * @fires engine.view.Node#change
 	 */
 	_fireChange( type, node ) {
-		this.fire( 'change', type, node );
+		this.fire( 'change:' + type, node );
 	}
 }
 

+ 3 - 3
packages/ckeditor5-engine/src/view/observer/focusobserver.js

@@ -10,7 +10,7 @@ import DomEventObserver from './domeventobserver.js';
  * Focus observer handle also {@link engine.view.RootEditableElement#isFocused isFocused} property of the
  * {@link engine.view.RootEditableElement root elements}.
  *
- * Note that this observer is attached by the {@link engine.EditingController} and is available by default.
+ * Note that this observer is attached by the {@link engine.view.Document} and is available by default.
  *
  * @memberOf engine.view.observer
  * @extends engine.view.observer.DomEventObserver
@@ -44,7 +44,7 @@ export default class FocusObserver extends DomEventObserver {
  *
  * Introduced by {@link engine.view.observer.FocusObserver}.
  *
- * Note that because {@link engine.view.observer.FocusObserver} is attached by the {@link engine.EditingController}
+ * Note that because {@link engine.view.observer.FocusObserver} is attached by the {@link engine.view.Document}
  * this event is available by default.
  *
  * @see engine.view.observer.FocusObserver
@@ -57,7 +57,7 @@ export default class FocusObserver extends DomEventObserver {
  *
  * Introduced by {@link engine.view.observer.FocusObserver}.
  *
- * Note that because {@link engine.view.observer.FocusObserver} is attached by the {@link engine.EditingController}
+ * Note that because {@link engine.view.observer.FocusObserver} is attached by the {@link engine.view.Document}
  * this event is available by default.
  *
  * @see engine.view.observer.FocusObserver

+ 2 - 2
packages/ckeditor5-engine/src/view/observer/keyobserver.js

@@ -9,7 +9,7 @@ import { getCode } from '../../../utils/keyboard.js';
 /**
  * {@link engine.view.Document#keydown Key down} event observer.
  *
- * Note that this observer is attached by the {@link engine.EditingController} and is available by default.
+ * Note that this observer is attached by the {@link engine.view.Document} and is available by default.
  *
  * @memberOf engine.view.observer
  * @extends engine.view.observer.DomEventObserver
@@ -41,7 +41,7 @@ export default class KeyObserver extends DomEventObserver {
  *
  * Introduced by {@link engine.view.observer.KeyObserver}.
  *
- * Note that because {@link engine.view.observer.KeyObserver} is attached by the {@link engine.EditingController}
+ * Note that because {@link engine.view.observer.KeyObserver} is attached by the {@link engine.view.Document}
  * this event is available by default.
  *
  * @see engine.view.observer.KeyObserver

+ 2 - 2
packages/ckeditor5-engine/src/view/observer/mutationobserver.js

@@ -17,7 +17,7 @@ import { startsWithFiller, getDataWithoutFiller } from '../filler.js';
  * mutations on elements which do not have corresponding view elements. Also
  * {@link engine.view.Document.MutatatedText text mutation} is fired only if parent element do not change child list.
  *
- * Note that this observer is attached by the {@link engine.EditingController} and is available by default.
+ * Note that this observer is attached by the {@link engine.view.Document} and is available by default.
  *
  * @memberOf engine.view.observer
  * @extends engine.view.observer.Observer
@@ -201,7 +201,7 @@ export default class MutationObserver extends Observer {
  *
  * Introduced by {@link engine.view.observer.MutationObserver}.
  *
- * Note that because {@link engine.view.observer.MutationObserver} is attached by the {@link engine.EditingController}
+ * Note that because {@link engine.view.observer.MutationObserver} is attached by the {@link engine.view.Document}
  * this event is available by default.
  *
  * @see engine.view.observer.MutationObserver

+ 2 - 2
packages/ckeditor5-engine/src/view/observer/selectionobserver.js

@@ -15,7 +15,7 @@ import log from '../../../utils/log.js';
  * {@link engine.view.Document#selectionChange} event only if selection change was the only change in the document
  * and DOM selection is different then the view selection.
  *
- * Note that this observer is attached by the {@link engine.EditingController} and is available by default.
+ * Note that this observer is attached by the {@link engine.view.Document} and is available by default.
  *
  * @see engine.view.MutationObserver
  * @memberOf engine.view.observer
@@ -192,7 +192,7 @@ export default class SelectionObserver extends Observer {
  *
  * Introduced by {@link engine.view.observer.SelectionObserver}.
  *
- * Note that because {@link engine.view.observer.SelectionObserver} is attached by the {@link engine.EditingController}
+ * Note that because {@link engine.view.observer.SelectionObserver} is attached by the {@link engine.view.Document}
  * this event is available by default.
  *
  * @see engine.view.observer.SelectionObserver

+ 27 - 13
packages/ckeditor5-engine/tests/_utils-tests/model.js

@@ -29,7 +29,7 @@ describe( 'model test utils', () => {
 	describe( 'getData', () => {
 		it( 'should use stringify method', () => {
 			const stringifySpy = sandbox.spy( getData, '_stringify' );
-			root.appendChildren( new Element( 'b', null, [ 'btext' ] ) );
+			root.appendChildren( new Element( 'b', null, new Text( 'btext' ) ) );
 
 			expect( getData( document, { withoutSelection: true } ) ).to.equal( '<b>btext</b>' );
 			sinon.assert.calledOnce( stringifySpy );
@@ -38,7 +38,7 @@ describe( 'model test utils', () => {
 
 		it( 'should use stringify method with selection', () => {
 			const stringifySpy = sandbox.spy( getData, '_stringify' );
-			root.appendChildren( new Element( 'b', null, [ 'btext' ] ) );
+			root.appendChildren( new Element( 'b', null, new Text( 'btext' ) ) );
 			document.selection.addRange( Range.createFromParentsAndOffsets( root, 0, root, 1 ) );
 
 			expect( getData( document ) ).to.equal( '<selection><b>btext</b></selection>' );
@@ -94,21 +94,29 @@ describe( 'model test utils', () => {
 		} );
 
 		it( 'should stringify element', () => {
-			const element = new Element( 'a', null, [ new Element( 'b', null, 'btext' ), 'atext' ] );
+			const element = new Element( 'a', null, [
+				new Element( 'b', null, new Text( 'btext' ) ),
+				new Text( 'atext' )
+			] );
+
 			expect( stringify( element ) ).to.equal( '<a><b>btext</b>atext</a>' );
 		} );
 
 		it( 'should stringify document fragment', () => {
-			const fragment = new DocumentFragment( [ new Element( 'b', null, 'btext' ), 'atext' ] );
+			const fragment = new DocumentFragment( [
+				new Element( 'b', null, new Text( 'btext' ) ),
+				new Text( 'atext' )
+			] );
+
 			expect( stringify( fragment ) ).to.equal( '<b>btext</b>atext' );
 		} );
 
 		it( 'writes elements and texts', () => {
 			root.appendChildren( [
-				new Element( 'a', null, 'atext' ),
+				new Element( 'a', null, new Text( 'atext' ) ),
 				new Element( 'b', null, [
 					new Element( 'c1' ),
-					'ctext',
+					new Text( 'ctext' ),
 					new Element( 'c2' )
 				] ),
 				new Element( 'd' )
@@ -136,7 +144,7 @@ describe( 'model test utils', () => {
 		it( 'writes text attributes', () => {
 			root.appendChildren( [
 				new Text( 'foo', { bold: true } ),
-				'bar',
+				new Text( 'bar' ),
 				new Text( 'bom', { bold: true, italic: true } ),
 				new Element( 'a', null, [
 					new Text( 'pom', { underline: true, bold: true } )
@@ -160,7 +168,7 @@ describe( 'model test utils', () => {
 
 				root.appendChildren( [
 					elA,
-					'foo',
+					new Text( 'foo' ),
 					new Text( 'bar', { bold: true } ),
 					elB
 				] );
@@ -361,9 +369,12 @@ describe( 'model test utils', () => {
 		test( 'sets text attributes', {
 			data: '<$text bold=true italic=true>foo</$text><$text bold=true>bar</$text>bom',
 			check( root ) {
-				expect( root.getChildCount() ).to.equal( 9 );
-				expect( root.getChild( 0 ) ).to.have.property( 'character', 'f' );
+				expect( root.getChildCount() ).to.equal( 3 );
+				expect( root.getMaxOffset() ).to.equal( 9 );
+				expect( root.getChild( 0 ) ).to.have.property( 'data', 'foo' );
 				expect( root.getChild( 0 ).getAttribute( 'italic' ) ).to.equal( true );
+				expect( root.getChild( 1 ) ).to.have.property( 'data', 'bar' );
+				expect( root.getChild( 1 ).getAttribute( 'bold' ) ).to.equal( true );
 			}
 		} );
 
@@ -439,8 +450,11 @@ describe( 'model test utils', () => {
 
 			test( 'sets collapsed selection within a text', {
 				data: 'foo<selection />bar',
-				check( root ) {
-					expect( root.getChildCount() ).to.equal( 6 );
+				check( text, selection ) {
+					expect( text.offsetSize ).to.equal( 6 );
+					expect( text.getPath() ).to.deep.equal( [ 0 ] );
+					expect( selection.getFirstRange().start.path ).to.deep.equal( [ 3 ] );
+					expect( selection.getFirstRange().end.path ).to.deep.equal( [ 3 ] );
 				}
 			} );
 
@@ -454,7 +468,7 @@ describe( 'model test utils', () => {
 			test( 'sets collapsed selection between text and text with attributes', {
 				data: 'foo<selection /><$text bold=true>bar</$text>',
 				check( root, selection ) {
-					expect( root.getChildCount() ).to.equal( 6 );
+					expect( root.getMaxOffset() ).to.equal( 6 );
 					expect( selection.getAttribute( 'bold' ) ).to.be.undefined;
 				}
 			} );

+ 9 - 8
packages/ckeditor5-engine/tests/_utils/model.js

@@ -12,6 +12,7 @@ import Element from '/ckeditor5/engine/model/element.js';
 import DocumentFragment from '/ckeditor5/engine/model/documentfragment.js';
 import Selection from '/ckeditor5/engine/model/selection.js';
 import Document from '/ckeditor5/engine/model/document.js';
+import writer from '/ckeditor5/engine/model/writer.js';
 
 /**
  * Writes the contents of the {@link engine.model.Document Document} to an HTML-like string.
@@ -146,7 +147,7 @@ export function parse( data, options = {} ) {
 	if ( options.document ) {
 		const document = options.document;
 		root = document.getRoot( rootName );
-		root.removeChildren( 0, root.getChildCount() );
+		root.removeChildren( 0, root.getMaxOffset() );
 		selection = document.selection;
 	} else {
 		root = new DocumentFragment();
@@ -158,7 +159,7 @@ export function parse( data, options = {} ) {
 
 	const handlers = {
 		text( token ) {
-			root.appendChildren( new Text( token.text, textAttributes ) );
+			writer.insert( Position.createFromParentAndOffset( root, root.getMaxOffset() ), new Text( token.data, textAttributes ) );
 		},
 
 		textStart( token ) {
@@ -176,7 +177,7 @@ export function parse( data, options = {} ) {
 
 		openingTag( token ) {
 			let el = new Element( token.name, token.attributes );
-			root.appendChildren( el );
+			writer.insert( Position.createFromParentAndOffset( root, root.getMaxOffset() ), el );
 
 			root = el;
 
@@ -198,7 +199,7 @@ export function parse( data, options = {} ) {
 		},
 
 		selectionStart( token ) {
-			selectionStart = Position.createFromParentAndOffset( root, root.getChildCount() );
+			selectionStart = Position.createFromParentAndOffset( root, root.getMaxOffset() );
 			selectionAttributes = token.attributes;
 		},
 
@@ -208,7 +209,7 @@ export function parse( data, options = {} ) {
 			}
 
 			withSelection = true;
-			selectionEnd = Position.createFromParentAndOffset( root, root.getChildCount() );
+			selectionEnd = Position.createFromParentAndOffset( root, root.getMaxOffset() );
 
 			selection.setRanges(
 				[ new Range( selectionStart, selectionEnd ) ],
@@ -274,7 +275,7 @@ function writeItem( walkerValue, selection, options ) {
 function writeText( walkerValue, selection, options ) {
 	const item = walkerValue.item;
 	const attrs = writeAttributes( item.getAttributes() );
-	let text = Array.from( item.text );
+	let text = Array.from( item.data );
 
 	if ( options.selection ) {
 		const startIndex = walkerValue.previousPosition.offset + 1;
@@ -285,7 +286,7 @@ function writeText( walkerValue, selection, options ) {
 			// Add the selection marker without changing any indexes, so if second marker must be added
 			// in the same loop it does not blow up.
 			text[ index - startIndex ] +=
-				writeSelection( Position.createFromParentAndOffset( item.commonParent, index ), selection );
+				writeSelection( Position.createFromParentAndOffset( item.parent, index ), selection );
 
 			index++;
 		}
@@ -406,7 +407,7 @@ const handlers = {
 	text( match ) {
 		return {
 			type: 'text',
-			text: match[ 0 ]
+			data: match[ 0 ]
 		};
 	}
 };

+ 17 - 20
packages/ckeditor5-engine/tests/conversion/advanced-converters.js

@@ -13,6 +13,7 @@ import ModelTextProxy from '/ckeditor5/engine/model/textproxy.js';
 import ModelRange from '/ckeditor5/engine/model/range.js';
 import ModelPosition from '/ckeditor5/engine/model/position.js';
 import ModelWalker from '/ckeditor5/engine/model/treewalker.js';
+import modelWriter from '/ckeditor5/engine/model/writer.js';
 
 import ViewElement from '/ckeditor5/engine/view/element.js';
 import ViewContainerElement from '/ckeditor5/engine/view/containerelement.js';
@@ -109,7 +110,7 @@ function modelToString( item ) {
 	if ( item instanceof ModelTextProxy ) {
 		let attributes = modelAttributesToString( item );
 
-		result = attributes ? '<$text' + attributes + '>' + item.text + '</$text>' : item.text;
+		result = attributes ? '<$text' + attributes + '>' + item.data + '</$text>' : item.data;
 	} else {
 		let walker = new ModelWalker( { boundaries: ModelRange.createFromElement( item ), shallow: true } );
 
@@ -264,7 +265,9 @@ describe( 'image with caption converters', () => {
 	} );
 
 	it( 'should convert model images changes with caption to view', () => {
-		let modelElement = new ModelElement( 'image', { src: 'foo.jpg', title: 'foo' }, new ModelElement( 'caption', {}, 'foobar' ) );
+		let modelElement = new ModelElement( 'image', { src: 'foo.jpg', title: 'foo' }, [
+			new ModelElement( 'caption', {}, new ModelText( 'foobar' ) )
+		] );
 		modelRoot.appendChildren( modelElement );
 		modelDispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
 
@@ -491,17 +494,14 @@ describe( 'custom attribute handling for given element', () => {
 		expect( viewToString( viewRoot ) ).to.equal( '<div><a href="foo.html" title="Foo title">foo</a></div>' );
 
 		// Let's change link's attributes.
-		for ( let value of range ) {
-			value.item.setAttribute( 'linkHref', 'bar.html' );
-			value.item.setAttribute( 'linkTitle', 'Bar title' );
-		}
+		modelWriter.setAttribute( range, 'linkHref', 'bar.html' );
+		modelWriter.setAttribute( range, 'linkTitle', 'Bar title' );
 		modelDispatcher.convertAttribute( 'changeAttribute', range, 'linkHref', 'foo.html', 'bar.html' );
 		modelDispatcher.convertAttribute( 'changeAttribute', range, 'linkTitle', 'Foo title', 'Bar title' );
 
 		expect( viewToString( viewRoot ) ).to.equal( '<div><a href="bar.html" title="Bar title">foo</a></div>' );
 
-		// Let's remove a letter from the link.
-		const removed = modelRoot.removeChildren( 0, 1 );
+		const removed = modelWriter.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ) );
 		modelDoc.graveyard.appendChildren( removed );
 		modelDispatcher.convertRemove(
 			ModelPosition.createFromParentAndOffset( modelRoot, 0 ),
@@ -513,17 +513,13 @@ describe( 'custom attribute handling for given element', () => {
 		range = ModelRange.createFromElement( modelRoot );
 
 		// Let's remove just one attribute.
-		for ( let value of range ) {
-			value.item.removeAttribute( 'linkTitle' );
-		}
+		modelWriter.removeAttribute( range, 'linkTitle' );
 		modelDispatcher.convertAttribute( 'removeAttribute', range, 'linkTitle', 'Bar title', null );
 
 		expect( viewToString( viewRoot ) ).to.equal( '<div><a href="bar.html">oo</a></div>' );
 
 		// Let's remove the other attribute.
-		for ( let value of range ) {
-			value.item.removeAttribute( 'linkHref' );
-		}
+		modelWriter.removeAttribute( range, 'linkHref' );
 		modelDispatcher.convertAttribute( 'removeAttribute', range, 'linkHref', 'bar.html', null );
 
 		expect( viewToString( viewRoot ) ).to.equal( '<div>oo</div>' );
@@ -535,13 +531,13 @@ describe( 'custom attribute handling for given element', () => {
 		let modelText = viewDispatcher.convert( viewElement )[ 0 ];
 
 		expect( modelText ).to.be.instanceof( ModelText );
-		expect( modelText.text ).to.equal( 'foo' );
+		expect( modelText.data ).to.equal( 'foo' );
 		expect( modelText.getAttribute( 'linkHref' ) ).to.equal( 'foo.html' );
 		expect( modelText.getAttribute( 'linkTitle' ) ).to.equal( 'Foo title' );
 	} );
 
 	it( 'should convert quote model element with linkHref and linkTitle attribute to view', () => {
-		let modelElement = new ModelElement( 'quote', { linkHref: 'foo.html', linkTitle: 'Foo source' }, 'foo' );
+		let modelElement = new ModelElement( 'quote', { linkHref: 'foo.html', linkTitle: 'Foo source' }, new ModelText( 'foo' ) );
 		modelRoot.appendChildren( modelElement );
 		modelDispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
 
@@ -620,7 +616,8 @@ it( 'default table view to model converter', () => {
 
 			for ( let i = 1; i < children.length; i++ ) {
 				if ( children[ i ] instanceof ModelText && children[ i - 1 ] instanceof ModelText ) {
-					children[ i - 1 ].text += ' ';
+					children.splice( i, 0, new ModelText( ' ' ) );
+					i++;
 				}
 			}
 
@@ -716,12 +713,12 @@ describe( 'universal converter', () => {
 		let modelElement = new ModelElement( 'table', { cellpadding: 5, cellspacing: 5 }, [
 			new ModelElement( 'tr', null, [
 				new ModelElement( 'td', null, [
-					'foo ',
+					new ModelText( 'foo ' ),
 					new ModelText( 'abc', { bold: true } ),
-					' bar'
+					new ModelText( ' bar' )
 				] ),
 				new ModelElement( 'td', null, [
-					new ModelElement( 'foo', { foo: 'bar' }, 'bar' )
+					new ModelElement( 'foo', { foo: 'bar' }, new ModelText( 'bar' ) )
 				] )
 			] )
 		] );

+ 6 - 6
packages/ckeditor5-engine/tests/conversion/mapper.js

@@ -102,9 +102,9 @@ describe( 'Mapper', () => {
 
 			modelImg = new ModelElement( 'img' );
 			modelP = new ModelElement( 'p', {}, [
-				'y',
+				new ModelText( 'y' ),
 				new ModelText( 'foo', { b: true, i: true } ),
-				'bar',
+				new ModelText( 'bar' ),
 				modelImg,
 				new ModelText( 'b', { u: true } ),
 				new ModelText( 'o', { u: true, sup: true } ),
@@ -113,9 +113,9 @@ describe( 'Mapper', () => {
 
 			modelDiv = new ModelRootElement();
 			modelDiv.appendChildren( [
-				'x',
+				new ModelText( 'x' ),
 				modelP,
-				'zz'
+				new ModelText( 'zz' )
 			] );
 
 			viewTextB = new ViewText( 'b' );
@@ -307,10 +307,10 @@ describe( 'Mapper', () => {
 			//   └─ zz                   ---> viewTextZZ
 
 			modelImg = new ModelElement( 'img' );
-			modelCaption = new ModelElement( 'caption', {}, 'foo' );
+			modelCaption = new ModelElement( 'caption', {}, new ModelText( 'foo' ) );
 			modelWidget = new ModelElement( 'widget', {}, [ modelImg, modelCaption ] );
 			modelDiv = new ModelRootElement();
-			modelDiv.appendChildren( [ 'x', modelWidget, 'zz' ] );
+			modelDiv.appendChildren( [ new ModelText( 'x' ), modelWidget, new ModelText( 'zz' ) ] );
 
 			viewTextX = new ViewText( 'y' );
 			viewTextZZ = new ViewText( 'zz' );

+ 13 - 20
packages/ckeditor5-engine/tests/conversion/model-converter-builder.js

@@ -12,6 +12,7 @@ import ModelElement from '/ckeditor5/engine/model/element.js';
 import ModelText from '/ckeditor5/engine/model/text.js';
 import ModelRange from '/ckeditor5/engine/model/range.js';
 import ModelPosition from '/ckeditor5/engine/model/position.js';
+import modelWriter from '/ckeditor5/engine/model/writer.js';
 
 import ViewDocument from '/ckeditor5/engine/view/document.js';
 import ViewElement from '/ckeditor5/engine/view/element.js';
@@ -93,7 +94,7 @@ describe( 'Model converter builder', () => {
 		it( 'using passed view element name', () => {
 			BuildModelConverterFor( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
 
-			let modelElement = new ModelElement( 'paragraph', null, 'foobar' );
+			let modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) );
 			modelRoot.appendChildren( modelElement );
 
 			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
@@ -117,7 +118,7 @@ describe( 'Model converter builder', () => {
 				.fromElement( 'header' )
 				.toElement( ( data ) => new ViewContainerElement( 'h' + data.item.getAttribute( 'level' ) ) );
 
-			let modelElement = new ModelElement( 'header', { level: 2 }, 'foobar' );
+			let modelElement = new ModelElement( 'header', { level: 2 }, new ModelText( 'foobar' ) );
 			modelRoot.appendChildren( modelElement );
 
 			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
@@ -141,9 +142,7 @@ describe( 'Model converter builder', () => {
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><strong>foo</strong></div>' );
 
-			for ( let value of ModelRange.createFromElement( modelRoot ) ) {
-				value.item.removeAttribute( 'bold' );
-			}
+			modelWriter.removeAttribute( ModelRange.createFromElement( modelRoot ), 'bold' );
 
 			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createFromElement( modelRoot ), 'bold', true, null );
 
@@ -160,9 +159,7 @@ describe( 'Model converter builder', () => {
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><strong>foo</strong></div>' );
 
-			for ( let value of ModelRange.createFromElement( modelRoot ) ) {
-				value.item.removeAttribute( 'bold' );
-			}
+			modelWriter.removeAttribute( ModelRange.createFromElement( modelRoot ), 'bold' );
 
 			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createFromElement( modelRoot ), 'bold', true, null );
 
@@ -179,17 +176,13 @@ describe( 'Model converter builder', () => {
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>foo</em></div>' );
 
-			for ( let value of ModelRange.createFromElement( modelRoot ) ) {
-				value.item.setAttribute( 'italic', 'i' );
-			}
+			modelWriter.setAttribute( ModelRange.createFromElement( modelRoot ), 'italic', 'i' );
 
 			dispatcher.convertAttribute( 'changeAttribute', ModelRange.createFromElement( modelRoot ), 'italic', 'em', 'i' );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><i>foo</i></div>' );
 
-			for ( let value of ModelRange.createFromElement( modelRoot ) ) {
-				value.item.removeAttribute();
-			}
+			modelWriter.removeAttribute( ModelRange.createFromElement( modelRoot ), 'italic' );
 
 			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createFromElement( modelRoot ), 'italic', 'i', null );
 
@@ -268,7 +261,7 @@ describe( 'Model converter builder', () => {
 		it( 'using default 1-to-1 conversion', () => {
 			BuildModelConverterFor( dispatcher ).fromAttribute( 'class' ).toAttribute();
 
-			let modelElement = new ModelElement( 'paragraph', { class: 'myClass' }, 'foobar' );
+			let modelElement = new ModelElement( 'paragraph', { class: 'myClass' }, new ModelText( 'foobar' ) );
 			modelRoot.appendChildren( modelElement );
 
 			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
@@ -289,7 +282,7 @@ describe( 'Model converter builder', () => {
 		it( 'using passed attribute key', () => {
 			BuildModelConverterFor( dispatcher ).fromAttribute( 'theme' ).toAttribute( 'class' );
 
-			let modelElement = new ModelElement( 'paragraph', { theme: 'abc' }, 'foobar' );
+			let modelElement = new ModelElement( 'paragraph', { theme: 'abc' }, new ModelText( 'foobar' ) );
 			modelRoot.appendChildren( modelElement );
 
 			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
@@ -310,7 +303,7 @@ describe( 'Model converter builder', () => {
 		it( 'using passed attribute key and value', () => {
 			BuildModelConverterFor( dispatcher ).fromAttribute( 'highlighted' ).toAttribute( 'style', 'background:yellow' );
 
-			let modelElement = new ModelElement( 'paragraph', { 'highlighted': true }, 'foobar' );
+			let modelElement = new ModelElement( 'paragraph', { 'highlighted': true }, new ModelText( 'foobar' ) );
 			modelRoot.appendChildren( modelElement );
 
 			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
@@ -328,7 +321,7 @@ describe( 'Model converter builder', () => {
 				.fromAttribute( 'theme' )
 				.toAttribute( ( value ) => ( { key: 'class', value: value + '-theme' } ) );
 
-			let modelElement = new ModelElement( 'paragraph', { theme: 'nice' }, 'foobar' );
+			let modelElement = new ModelElement( 'paragraph', { theme: 'nice' }, new ModelText( 'foobar' ) );
 			modelRoot.appendChildren( modelElement );
 
 			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
@@ -352,7 +345,7 @@ describe( 'Model converter builder', () => {
 			BuildModelConverterFor( dispatcher ).fromElement( 'custom' ).toElement( 'custom' );
 			BuildModelConverterFor( dispatcher ).fromElement( 'custom' ).withPriority( 0 ).toElement( 'other' );
 
-			let modelElement = new ModelElement( 'custom', null, 'foobar' );
+			let modelElement = new ModelElement( 'custom', null, new ModelText( 'foobar' ) );
 			modelRoot.appendChildren( modelElement );
 
 			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
@@ -368,7 +361,7 @@ describe( 'Model converter builder', () => {
 		// If above would do something this one would not be fired:
 		BuildModelConverterFor( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
 
-		let modelElement = new ModelElement( 'div', null, new ModelElement( 'paragraph', null, 'foobar' ) );
+		let modelElement = new ModelElement( 'div', null, new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) ) );
 		modelRoot.appendChildren( modelElement );
 
 		dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );

+ 38 - 30
packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js

@@ -10,6 +10,7 @@ import ModelElement from '/ckeditor5/engine/model/element.js';
 import ModelText from '/ckeditor5/engine/model/text.js';
 import ModelRange from '/ckeditor5/engine/model/range.js';
 import ModelPosition from '/ckeditor5/engine/model/position.js';
+import modelWriter from '/ckeditor5/engine/model/writer.js';
 
 import ViewElement from '/ckeditor5/engine/view/element.js';
 import ViewContainerElement from '/ckeditor5/engine/view/containerelement.js';
@@ -77,7 +78,7 @@ function viewToString( item ) {
 
 describe( 'insertText', () => {
 	it( 'should convert text insertion in model to view text', () => {
-		modelRoot.appendChildren( 'foobar' );
+		modelRoot.appendChildren( new ModelText( 'foobar' ) );
 		dispatcher.on( 'insert:$text', insertText() );
 
 		dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
@@ -88,7 +89,7 @@ describe( 'insertText', () => {
 
 describe( 'insertElement', () => {
 	it( 'should convert element insertion in model to and map positions for future converting', () => {
-		const modelElement = new ModelElement( 'paragraph', null, 'foobar' );
+		const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) );
 		const viewElement = new ViewContainerElement( 'p' );
 
 		modelRoot.appendChildren( modelElement );
@@ -108,8 +109,8 @@ describe( 'insertElement', () => {
 				return new ViewContainerElement( 'p' );
 			}
 		};
-		const niceP = new ModelElement( 'myParagraph', { nice: true }, 'foo' );
-		const badP = new ModelElement( 'myParagraph', null, 'bar' );
+		const niceP = new ModelElement( 'myParagraph', { nice: true }, new ModelText( 'foo' ) );
+		const badP = new ModelElement( 'myParagraph', null, new ModelText( 'bar' ) );
 
 		modelRoot.appendChildren( [ niceP, badP ] );
 
@@ -124,7 +125,7 @@ describe( 'insertElement', () => {
 
 describe( 'setAttribute/removeAttribute', () => {
 	it( 'should convert attribute insert/change/remove on a model node', () => {
-		const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, 'foobar' );
+		const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, new ModelText( 'foobar' ) );
 		const viewElement = new ViewContainerElement( 'p' );
 
 		modelRoot.appendChildren( modelElement );
@@ -150,7 +151,7 @@ describe( 'setAttribute/removeAttribute', () => {
 	} );
 
 	it( 'should convert insert/change/remove with attribute generating function as a parameter', () => {
-		const modelParagraph = new ModelElement( 'paragraph', { theme: 'nice' }, 'foobar' );
+		const modelParagraph = new ModelElement( 'paragraph', { theme: 'nice' }, new ModelText( 'foobar' ) );
 		const modelDiv = new ModelElement( 'div', { theme: 'nice' } );
 
 		const themeConverter = ( value, key, data ) => {
@@ -201,9 +202,7 @@ describe( 'wrap/unwrap', () => {
 
 		expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
 
-		for ( let value of ModelRange.createFromElement( modelElement ) ) {
-			value.item.removeAttribute( 'bold' );
-		}
+		modelWriter.removeAttribute( ModelRange.createFromElement( modelElement ), 'bold' );
 
 		dispatcher.convertAttribute( 'removeAttribute', ModelRange.createFromElement( modelElement ), 'bold', true, null );
 
@@ -230,9 +229,7 @@ describe( 'wrap/unwrap', () => {
 
 		expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
 
-		for ( let value of ModelRange.createFromElement( modelElement ) ) {
-			value.item.removeAttribute( 'style' );
-		}
+		modelWriter.removeAttribute( ModelRange.createFromElement( modelElement ), 'style' );
 
 		dispatcher.convertAttribute( 'removeAttribute', ModelRange.createFromElement( modelElement ), 'style', 'bold', null );
 
@@ -240,8 +237,12 @@ describe( 'wrap/unwrap', () => {
 	} );
 
 	it( 'should update range on re-wrapping attribute (#475)', () => {
-		const modelElement = new ModelElement( 'paragraph', null,
-			[ 'x', new ModelText( 'foo', { link: 'http://foo.com' } ), 'x' ] );
+		const modelElement = new ModelElement( 'paragraph', null, [
+			new ModelText( 'x' ),
+			new ModelText( 'foo', { link: 'http://foo.com' } ),
+			new ModelText( 'x' )
+		] );
+
 		const viewP = new ViewContainerElement( 'p' );
 
 		const elementGenerator = ( href ) => new ViewAttributeElement( 'a', { href } );
@@ -258,9 +259,7 @@ describe( 'wrap/unwrap', () => {
 
 		expect( viewToString( viewRoot ) ).to.equal( '<div><p>x<a href="http://foo.com">foo</a>x</p></div>' );
 
-		for ( let value of ModelRange.createFromElement( modelElement ) ) {
-			value.item.setAttribute( 'link', 'http://foobar.com' );
-		}
+		modelWriter.setAttribute( ModelRange.createFromElement( modelElement ), 'link', 'http://foobar.com' );
 
 		dispatcher.convertAttribute(
 			'changeAttribute',
@@ -276,8 +275,13 @@ describe( 'wrap/unwrap', () => {
 
 describe( 'move', () => {
 	it( 'should move items in view accordingly to changes in model', () => {
-		const modelDivA = new ModelElement( 'div', null, [ 'foo', new ModelElement( 'image' ) ] );
-		const modelDivB = new ModelElement( 'div', null, [ 'xxyy' ] );
+		const modelDivA = new ModelElement( 'div', null, [
+			new ModelText( 'foo' ),
+			new ModelElement( 'image' ),
+			new ModelText( 'bar' )
+		] );
+
+		const modelDivB = new ModelElement( 'div', null, new ModelText( 'xxyy' ) );
 
 		modelRoot.appendChildren( [ modelDivA, modelDivB ] );
 		dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
@@ -287,21 +291,25 @@ describe( 'move', () => {
 
 		dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
 
-		const removedNodes = modelDivA.removeChildren( 2, 2 );
-		modelDivB.insertChildren( 2, removedNodes );
+		const removedNodes = modelDivA.removeChildren( 0, 2 );
+		modelDivB.insertChildren( 0, removedNodes );
 
 		dispatcher.convertMove(
-			ModelPosition.createFromParentAndOffset( modelDivA, 2 ),
-			ModelRange.createFromParentsAndOffsets( modelDivB, 2, modelDivB, 4 )
+			ModelPosition.createFromParentAndOffset( modelDivA, 0 ),
+			ModelRange.createFromParentsAndOffsets( modelDivB, 0, modelDivB, 4 )
 		);
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><div>fo</div><div>xxo<img></img>yy</div></div>' );
+		expect( viewToString( viewRoot ) ).to.equal( '<div><div>bar</div><div>foo<img></img>xxyy</div></div>' );
 	} );
 } );
 
 describe( 'remove', () => {
 	it( 'should remove items from view accordingly to changes in model', () => {
-		const modelDiv = new ModelElement( 'div', null, [ 'foo', new ModelElement( 'image' ) ] );
+		const modelDiv = new ModelElement( 'div', null, [
+			new ModelText( 'foo' ),
+			new ModelElement( 'image' ),
+			new ModelText( 'bar' )
+		] );
 
 		modelRoot.appendChildren( modelDiv );
 		dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
@@ -311,14 +319,14 @@ describe( 'remove', () => {
 
 		dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
 
-		const removedNodes = modelDiv.removeChildren( 2, 2 );
-		modelDoc.graveyard.insertChildren( 2, removedNodes );
+		const removedNodes = modelDiv.removeChildren( 0, 2 );
+		modelDoc.graveyard.insertChildren( 0, removedNodes );
 
 		dispatcher.convertRemove(
-			ModelPosition.createFromParentAndOffset( modelDiv, 2 ),
-			ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 2 )
+			ModelPosition.createFromParentAndOffset( modelDiv, 0 ),
+			ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 4 )
 		);
 
-		expect( viewToString( viewRoot ) ).to.equal( '<div><div>fo</div></div>' );
+		expect( viewToString( viewRoot ) ).to.equal( '<div><div>bar</div></div>' );
 	} );
 } );

+ 14 - 13
packages/ckeditor5-engine/tests/conversion/modelconsumable.js

@@ -8,13 +8,14 @@
 import ModelConsumable from '/ckeditor5/engine/conversion/modelconsumable.js';
 import ModelElement from '/ckeditor5/engine/model/element.js';
 import ModelTextProxy from '/ckeditor5/engine/model/textproxy.js';
+import ModelText from '/ckeditor5/engine/model/text.js';
 
 describe( 'ModelConsumable', () => {
 	let modelConsumable, modelElement;
 
 	beforeEach( () => {
 		modelConsumable = new ModelConsumable();
-		modelElement = new ModelElement( 'paragraph', null, 'foobar' );
+		modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) );
 	} );
 
 	describe( 'add', () => {
@@ -35,7 +36,7 @@ describe( 'ModelConsumable', () => {
 		} );
 
 		it( 'should correctly add text proxy instances', () => {
-			const modelTextProxy = new ModelTextProxy( modelElement.getChild( 1 ), 3 );
+			const modelTextProxy = new ModelTextProxy( modelElement.getChild( 0 ), 1, 3 );
 
 			modelConsumable.add( modelTextProxy, 'type' );
 
@@ -60,16 +61,16 @@ describe( 'ModelConsumable', () => {
 		} );
 
 		it( 'should correctly consume text proxy instances', () => {
-			const proxy1To4 = new ModelTextProxy( modelElement.getChild( 1 ), 3 );
-			const proxy1To5 = new ModelTextProxy( modelElement.getChild( 1 ), 4 );
-			const proxyDiv1To4 = new ModelTextProxy( new ModelElement( 'div', null, 'abcdef' ).getChild( 1 ), 3 );
+			const proxy1To4 = new ModelTextProxy( modelElement.getChild( 0 ), 1, 3 );
+			const proxy1To5 = new ModelTextProxy( modelElement.getChild( 0 ), 1, 4 );
+			const proxyOther1To4 = new ModelTextProxy( new ModelText( 'abcdef' ), 1, 3 );
 
 			modelConsumable.add( proxy1To4, 'type' );
 
 			expect( modelConsumable.consume( proxy1To5, 'type' ) ).to.be.false;
-			expect( modelConsumable.consume( proxyDiv1To4, 'type' ) ).to.be.false;
+			expect( modelConsumable.consume( proxyOther1To4, 'type' ) ).to.be.false;
 
-			const equalProxy1To4 = new ModelTextProxy( modelElement.getChild( 1 ), 3 );
+			const equalProxy1To4 = new ModelTextProxy( modelElement.getChild( 0 ), 1, 3 );
 			const result = modelConsumable.consume( equalProxy1To4, 'type' );
 
 			expect( result ).to.be.true;
@@ -103,7 +104,7 @@ describe( 'ModelConsumable', () => {
 		} );
 
 		it( 'should correctly revert text proxy instances', () => {
-			const modelTextProxy = new ModelTextProxy( modelElement.getChild( 1 ), 3 );
+			const modelTextProxy = new ModelTextProxy( modelElement.getChild( 0 ), 1, 3 );
 
 			modelConsumable.add( modelTextProxy, 'type' );
 			modelConsumable.consume( modelTextProxy, 'type' );
@@ -125,17 +126,17 @@ describe( 'ModelConsumable', () => {
 		} );
 
 		it( 'should correctly test for text proxy instances', () => {
-			const proxy1To4 = new ModelTextProxy( modelElement.getChild( 1 ), 3 );
-			const proxy1To5 = new ModelTextProxy( modelElement.getChild( 1 ), 4 );
-			const proxyDiv1To4 = new ModelTextProxy( new ModelElement( 'div', null, 'abcdef' ).getChild( 1 ), 3 );
-			const equalProxy1To4 = new ModelTextProxy( modelElement.getChild( 1 ), 3 );
+			const proxy1To4 = new ModelTextProxy( modelElement.getChild( 0 ), 1, 3 );
+			const proxy1To5 = new ModelTextProxy( modelElement.getChild( 0 ), 1, 4 );
+			const proxyOther1To4 = new ModelTextProxy( new ModelText( 'abcdef' ), 1, 3 );
+			const equalProxy1To4 = new ModelTextProxy( modelElement.getChild( 0 ), 1, 3 );
 
 			modelConsumable.add( proxy1To4, 'type' );
 
 			expect( modelConsumable.test( proxy1To4, 'type' ) ).to.be.true;
 			expect( modelConsumable.test( proxy1To4, 'otherType' ) ).to.be.null;
 			expect( modelConsumable.test( proxy1To5, 'type' ) ).to.be.null;
-			expect( modelConsumable.test( proxyDiv1To4, 'type' ) ).to.be.null;
+			expect( modelConsumable.test( proxyOther1To4, 'type' ) ).to.be.null;
 			expect( modelConsumable.test( equalProxy1To4, 'type' ) ).to.be.true;
 		} );
 	} );

+ 12 - 10
packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js

@@ -40,7 +40,7 @@ describe( 'ModelConversionDispatcher', () => {
 
 		beforeEach( () => {
 			image = new ModelElement( 'image' );
-			root.appendChildren( [ image, 'foobar' ] );
+			root.appendChildren( [ image, new ModelText( 'foobar' ) ] );
 
 			imagePos = ModelPosition.createBefore( image );
 
@@ -198,7 +198,7 @@ describe( 'ModelConversionDispatcher', () => {
 			root.appendChildren( [
 				new ModelText( 'foo', { bold: true } ),
 				new ModelElement( 'image' ),
-				'bar',
+				new ModelText( 'bar' ),
 				new ModelElement( 'paragraph', { class: 'nice' }, new ModelText( 'xx', { italic: true } ) )
 			] );
 
@@ -208,7 +208,7 @@ describe( 'ModelConversionDispatcher', () => {
 			// We will check everything connected with insert event:
 			dispatcher.on( 'insert', ( evt, data, consumable ) => {
 				// Check if the item is correct.
-				const itemId = data.item.name ? data.item.name : '$text:' + data.item.text;
+				const itemId = data.item.name ? data.item.name : '$text:' + data.item.data;
 				// Check if the range is correct.
 				const log = 'insert:' + itemId + ':' + data.range.start.path + ':' + data.range.end.path;
 
@@ -222,7 +222,7 @@ describe( 'ModelConversionDispatcher', () => {
 
 			// Same here.
 			dispatcher.on( 'addAttribute', ( evt, data, consumable ) => {
-				const itemId = data.item.name ? data.item.name : '$text:' + data.item.text;
+				const itemId = data.item.name ? data.item.name : '$text:' + data.item.data;
 				const key = data.attributeKey;
 				const value = data.attributeNewValue;
 				const log = 'addAttribute:' + key + ':' + value + ':' + itemId + ':' + data.range.start.path + ':' + data.range.end.path;
@@ -250,7 +250,9 @@ describe( 'ModelConversionDispatcher', () => {
 
 		it( 'should not fire events for already consumed parts of model', () => {
 			root.appendChildren( [
-				new ModelElement( 'image', { src: 'foo.jpg', title: 'bar', bold: true }, new ModelElement( 'caption', {}, 'title' ) )
+				new ModelElement( 'image', { src: 'foo.jpg', title: 'bar', bold: true }, [
+					new ModelElement( 'caption', {}, new ModelText( 'title' ) )
+				] )
 			] );
 
 			sinon.spy( dispatcher, 'fire' );
@@ -276,7 +278,7 @@ describe( 'ModelConversionDispatcher', () => {
 
 	describe( 'convertMove', () => {
 		it( 'should fire single event for moved range', () => {
-			root.appendChildren( 'barfoo' );
+			root.appendChildren( new ModelText( 'barfoo' ) );
 
 			const range = ModelRange.createFromParentsAndOffsets( root, 0, root, 3 );
 			const loggedEvents = [];
@@ -294,8 +296,8 @@ describe( 'ModelConversionDispatcher', () => {
 
 	describe( 'convertRemove', () => {
 		it( 'should fire single event for removed range', () => {
-			root.appendChildren( 'foo' );
-			doc.graveyard.appendChildren( 'bar' );
+			root.appendChildren( new ModelText( 'foo' ) );
+			doc.graveyard.appendChildren( new ModelText( 'bar' ) );
 
 			const range = ModelRange.createFromParentsAndOffsets( doc.graveyard, 0, doc.graveyard, 3 );
 			const loggedEvents = [];
@@ -323,7 +325,7 @@ describe( 'ModelConversionDispatcher', () => {
 			const loggedEvents = [];
 
 			dispatcher.on( 'addAttribute', ( evt, data, consumable ) => {
-				const itemId = data.item.name ? data.item.name : '$text:' + data.item.text;
+				const itemId = data.item.name ? data.item.name : '$text:' + data.item.data;
 				const key = data.attributeKey;
 				const value = data.attributeNewValue;
 				const log = 'addAttribute:' + key + ':' + value + ':' + itemId + ':' + data.range.start.path + ':' + data.range.end.path;
@@ -368,7 +370,7 @@ describe( 'ModelConversionDispatcher', () => {
 		beforeEach( () => {
 			dispatcher.off( 'selection' );
 
-			root.appendChildren( 'foobar' );
+			root.appendChildren( new ModelText( 'foobar' ) );
 			doc.selection.setRanges( [
 				new ModelRange( new ModelPosition( root, [ 1 ] ), new ModelPosition( root, [ 3 ] ) ),
 				new ModelRange( new ModelPosition( root, [ 4 ] ), new ModelPosition( root, [ 5 ] ) )

+ 1 - 1
packages/ckeditor5-engine/tests/conversion/view-converter-builder.js

@@ -40,7 +40,7 @@ function modelToString( item ) {
 	if ( item instanceof ModelTextProxy ) {
 		let attributes = modelAttributesToString( item );
 
-		result = attributes ? '<$text' + attributes + '>' + item.text + '</$text>' : item.text;
+		result = attributes ? '<$text' + attributes + '>' + item.data + '</$text>' : item.data;
 	} else {
 		let walker = new ModelWalker( { boundaries: ModelRange.createFromElement( item ), shallow: true } );
 

+ 7 - 14
packages/ckeditor5-engine/tests/conversion/view-to-model-converters.js

@@ -36,7 +36,7 @@ describe( 'convertText', () => {
 		const result = dispatcher.convert( viewText, objWithContext );
 
 		expect( result ).to.be.instanceof( ModelText );
-		expect( result.text ).to.equal( 'foobar' );
+		expect( result.data ).to.equal( 'foobar' );
 	} );
 
 	it( 'should not convert already consumed texts', () => {
@@ -54,7 +54,7 @@ describe( 'convertText', () => {
 		const result = dispatcher.convert( viewText, objWithContext );
 
 		expect( result ).to.be.instanceof( ModelText );
-		expect( result.text ).to.equal( 'foo****ba****r' );
+		expect( result.data ).to.equal( 'foo****ba****r' );
 	} );
 
 	it( 'should not convert text if it is wrong with schema', () => {
@@ -69,7 +69,7 @@ describe( 'convertText', () => {
 
 		result = dispatcher.convert( viewText, { context: [ '$block' ] } );
 		expect( result ).to.be.instanceof( ModelText );
-		expect( result.text ).to.equal( 'foobar' );
+		expect( result.data ).to.equal( 'foobar' );
 	} );
 } );
 
@@ -89,13 +89,8 @@ describe( 'convertToModelFragment', () => {
 		const result = dispatcher.convert( viewFragment, objWithContext );
 
 		expect( result ).to.be.instanceof( ModelDocumentFragment );
-		expect( result.getChildCount() ).to.equal( 6 );
-		expect( result.getChild( 0 ).character ).to.equal( 'f' );
-		expect( result.getChild( 1 ).character ).to.equal( 'o' );
-		expect( result.getChild( 2 ).character ).to.equal( 'o' );
-		expect( result.getChild( 3 ).character ).to.equal( 'b' );
-		expect( result.getChild( 4 ).character ).to.equal( 'a' );
-		expect( result.getChild( 5 ).character ).to.equal( 'r' );
+		expect( result.getMaxOffset() ).to.equal( 6 );
+		expect( result.getChild( 0 ).data ).to.equal( 'foobar' );
 	} );
 
 	it( 'should not convert already consumed (converted) changes', () => {
@@ -120,9 +115,7 @@ describe( 'convertToModelFragment', () => {
 
 		expect( result ).to.be.instanceof( ModelElement );
 		expect( result.name ).to.equal( 'paragraph' );
-		expect( result.getChildCount() ).to.equal( 3 );
-		expect( result.getChild( 0 ).character ).to.equal( 'f' );
-		expect( result.getChild( 1 ).character ).to.equal( 'o' );
-		expect( result.getChild( 2 ).character ).to.equal( 'o' );
+		expect( result.getMaxOffset() ).to.equal( 3 );
+		expect( result.getChild( 0 ).data ).to.equal( 'foo' );
 	} );
 } );

+ 0 - 11
packages/ckeditor5-engine/tests/editingcontroller.js

@@ -8,10 +8,6 @@
 import EditingController from '/ckeditor5/engine/editingcontroller.js';
 
 import ViewDocument from '/ckeditor5/engine/view/document.js';
-import MutationObserver from '/ckeditor5/engine/view/observer/mutationobserver.js';
-import SelectionObserver from '/ckeditor5/engine/view/observer/selectionobserver.js';
-import FocusObserver from '/ckeditor5/engine/view/observer/focusobserver.js';
-import KeyObserver from '/ckeditor5/engine/view/observer/keyobserver.js';
 
 import Mapper from '/ckeditor5/engine/conversion/mapper.js';
 import ModelConversionDispatcher from '/ckeditor5/engine/conversion/modelconversiondispatcher.js';
@@ -42,13 +38,6 @@ describe( 'EditingController', () => {
 			expect( editing ).to.have.property( 'mapper' ).that.is.instanceof( Mapper );
 			expect( editing ).to.have.property( 'modelToView' ).that.is.instanceof( ModelConversionDispatcher );
 		} );
-
-		it( 'should add observers', () => {
-			expect( editing.view.getObserver( MutationObserver ) ).to.be.instanceof( MutationObserver );
-			expect( editing.view.getObserver( SelectionObserver ) ).to.be.instanceof( SelectionObserver );
-			expect( editing.view.getObserver( FocusObserver ) ).to.be.instanceof( FocusObserver );
-			expect( editing.view.getObserver( KeyObserver ) ).to.be.instanceof( KeyObserver );
-		} );
 	} );
 
 	describe( 'createRoot', () => {

+ 44 - 1
packages/ckeditor5-engine/tests/model/_utils/utils.js

@@ -4,6 +4,8 @@
  */
 
 import TreeWalker from '/ckeditor5/engine/model/treewalker.js';
+import Text from '/ckeditor5/engine/model/text.js';
+import TextProxy from '/ckeditor5/engine/model/textproxy.js';
 import Delta from '/ckeditor5/engine/model/delta/delta.js';
 
 /**
@@ -22,7 +24,7 @@ export function getNodesAndText( range ) {
 
 	for ( let value of treeWalker ) {
 		let node = value.item;
-		let nodeText = node.text || node.character;
+		let nodeText = node.data;
 
 		if ( nodeText ) {
 			txt += nodeText.toLowerCase();
@@ -57,3 +59,44 @@ export function wrapInDelta( operation ) {
 
 	return operation;
 }
+
+/**
+ * Returns a {@link engine.model.Node} or if it starts at given offset, or {@link engine.model.TextProxy} with one
+ * character, if given offset is occupied by a {@link engine.model.Text}.
+ *
+ * @param {engine.model.Element} parent Element from which item will be returned.
+ * @param {Number} offset Item's offset.
+ * @returns {engine.model.Node|engine.model.TextProxy}
+ */
+export function itemAt( parent, offset ) {
+	let index = parent.offsetToIndex( offset );
+	let node = parent.getChild( index );
+
+	if ( node instanceof Text ) {
+		let offsetInText = offset - node.startOffset;
+
+		return new TextProxy( node, offsetInText, 1 );
+	}
+
+	return node;
+}
+
+/**
+ * Returns all text contents that are inside given element and all it's children.
+ *
+ * @param {engine.model.Element} element Element from which text will be returned.
+ * @returns {String} Text contents of the element.
+ */
+export function getText( element ) {
+	let text = '';
+
+	for ( let child of element.getChildren() ) {
+		if ( child.data ) {
+			text += child.data;
+		} else if ( child.name ) {
+			text += getText( child );
+		}
+	}
+
+	return text;
+}

+ 0 - 133
packages/ckeditor5-engine/tests/model/characterproxy.js

@@ -1,133 +0,0 @@
-/**
- * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/* bender-tags: model */
-
-import Node from '/ckeditor5/engine/model/node.js';
-import Element from '/ckeditor5/engine/model/element.js';
-import Text from '/ckeditor5/engine/model/text.js';
-import mapsEqual from '/ckeditor5/utils/mapsequal.js';
-
-describe( 'CharacterProxy', () => {
-	let text, element, char;
-
-	beforeEach( () => {
-		text = new Text( 'abc', { foo: true } );
-		element = new Element( 'div', [], [ new Element( 'p' ), text, new Element( 'p' ) ] );
-		char = element.getChild( 2 );
-	} );
-
-	it( 'should extend Node class', () => {
-		expect( char ).to.be.instanceof( Node );
-	} );
-
-	it( 'should have correct character property', () => {
-		expect( char ).to.have.property( 'character' ).that.equals( 'b' );
-	} );
-
-	it( 'should have correct parent property', () => {
-		expect( char ).to.have.property( 'parent' ).that.equals( element );
-	} );
-
-	it( 'should have attributes list equal to passed to Text instance', () => {
-		expect( mapsEqual( char._attrs, text._attrs ) ).to.be.true;
-	} );
-
-	it( 'should return correct index in parent node', () => {
-		expect( char.getIndex() ).to.equal( 2 );
-	} );
-
-	describe( 'attributes interface', () => {
-		describe( 'hasAttribute', () => {
-			it( 'should return true if text fragment has attribute with given key', () => {
-				expect( char.hasAttribute( 'foo' ) ).to.be.true;
-			} );
-
-			it( 'should return false if text fragment does not have attribute with given key', () => {
-				expect( char.hasAttribute( 'abc' ) ).to.be.false;
-			} );
-		} );
-
-		describe( 'getAttribute', () => {
-			it( 'should return attribute with given key if text fragment has given attribute', () => {
-				expect( char.getAttribute( 'foo' ) ).to.be.true;
-			} );
-
-			it( 'should return undefined if text fragment does not have given attribute', () => {
-				expect( char.getAttribute( 'bar' ) ).to.be.undefined;
-			} );
-		} );
-
-		describe( 'getAttributes', () => {
-			it( 'should return an iterator that iterates over all attributes set on the text fragment', () => {
-				let attrs = Array.from( char.getAttributes() );
-
-				expect( attrs ).to.deep.equal( [ [ 'foo', true ] ] );
-			} );
-		} );
-
-		describe( 'setAttribute', () => {
-			it( 'should set attribute on given character', () => {
-				char.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.be.undefined;
-				expect( element.getChild( 4 ).getAttribute( 'abc' ) ).to.be.undefined;
-			} );
-
-			it( 'should remove attribute when passed attribute value is null', () => {
-				char.setAttribute( 'foo', null );
-
-				expect( element.getChild( 0 ).hasAttribute( 'foo' ) ).to.be.false;
-				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.true;
-				expect( element.getChild( 4 ).hasAttribute( 'foo' ) ).to.be.false;
-			} );
-
-			it( 'should correctly split and merge characters', () => {
-				char.setAttribute( 'abc', 'xyz' );
-				char.nextSibling.setAttribute( 'abc', 'xyz' );
-
-				expect( element._children._nodes.length ).to.equal( 4 );
-				expect( element._children._nodes[ 1 ].text ).to.equal( 'a' );
-				expect( element._children._nodes[ 2 ].text ).to.equal( 'bc' );
-			} );
-		} );
-
-		describe( 'setAttributesTo', () => {
-			it( 'should remove all attributes from character and set given ones', () => {
-				char.setAttributesTo( { abc: 'xyz' } );
-
-				expect( element.getChild( 2 ).hasAttribute( 'foo' ) ).to.be.false;
-				expect( element.getChild( 2 ).getAttribute( 'abc' ) ).to.equal( 'xyz' );
-			} );
-		} );
-
-		describe( 'removeAttribute', () => {
-			it( 'should remove given attribute from character', () => {
-				char.removeAttribute( 'foo' );
-
-				expect( element.getChild( 0 ).hasAttribute( 'foo' ) ).to.be.false;
-				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.true;
-				expect( element.getChild( 4 ).hasAttribute( 'foo' ) ).to.be.false;
-			} );
-		} );
-
-		describe( 'clearAttributes', () => {
-			it( 'should remove all attributes from text fragment', () => {
-				char.setAttribute( 'abc', 'xyz' );
-				char.clearAttributes();
-
-				expect( element.getChild( 2 ).hasAttribute( 'foo' ) ).to.be.false;
-				expect( element.getChild( 2 ).hasAttribute( 'abc' ) ).to.be.false;
-			} );
-		} );
-	} );
-} );

+ 8 - 10
packages/ckeditor5-engine/tests/model/delta/attributedelta.js

@@ -45,15 +45,13 @@ describe( 'Batch', () => {
 	}
 
 	describe( 'change attribute on node', () => {
-		let node, text, char;
+		let node, text;
 
 		beforeEach( () => {
 			node = new Element( 'p', { a: 1 } );
 			text = new Text( 'c', { a: 1 } );
 
 			root.insertChildren( 0, [ node, text ] );
-
-			char = root.getChild( 1 );
 		} );
 
 		describe( 'setAttr', () => {
@@ -70,13 +68,13 @@ describe( 'Batch', () => {
 			} );
 
 			it( 'should create the attribute on text node', () => {
-				batch.setAttr( 'b', 2, char );
+				batch.setAttr( 'b', 2, text );
 				expect( getOperationsCount() ).to.equal( 1 );
 				expect( root.getChild( 1 ).getAttribute( 'b' ) ).to.equal( 2 );
 			} );
 
 			it( 'should change the attribute of text node', () => {
-				batch.setAttr( 'a', 2, char );
+				batch.setAttr( 'a', 2, text );
 				expect( getOperationsCount() ).to.equal( 1 );
 				expect( root.getChild( 1 ).getAttribute( 'a' ) ).to.equal( 2 );
 			} );
@@ -108,7 +106,7 @@ describe( 'Batch', () => {
 			} );
 
 			it( 'should remove the attribute from character', () => {
-				batch.removeAttr( 'a', char );
+				batch.removeAttr( 'a', text );
 				expect( getOperationsCount() ).to.equal( 1 );
 				expect( root.getChild( 1 ).getAttribute( 'a' ) ).to.be.undefined;
 			} );
@@ -136,13 +134,13 @@ describe( 'Batch', () => {
 		beforeEach( () => {
 			root.insertChildren( 0, [
 				new Text( 'xxx', { a: 1 } ),
-				'xxx',
+				new Text( 'xxx' ),
 				new Text( 'xxx', { a: 1 } ),
 				new Text( 'xxx', { a: 2 } ),
-				'xxx',
+				new Text( 'xxx' ),
 				new Text( 'xxx', { a: 1 } ),
-				new Element( 'e', { a: 2 }, 'xxx' ),
-				'xxx'
+				new Element( 'e', { a: 2 }, new Text( 'xxx' ) ),
+				new Text( 'xxx' )
 			] );
 		} );
 

+ 11 - 9
packages/ckeditor5-engine/tests/model/delta/insertdelta.js

@@ -8,6 +8,7 @@
 import Document from '/ckeditor5/engine/model/document.js';
 import Element from '/ckeditor5/engine/model/element.js';
 import Position from '/ckeditor5/engine/model/position.js';
+import Text from '/ckeditor5/engine/model/text.js';
 
 import InsertOperation from '/ckeditor5/engine/model/operation/insertoperation.js';
 import InsertDelta from '/ckeditor5/engine/model/delta/insertdelta.js';
@@ -21,7 +22,7 @@ describe( 'Batch', () => {
 	beforeEach( () => {
 		doc = new Document();
 		root = doc.createRoot();
-		root.insertChildren( 0, 'abc' );
+		root.insertChildren( 0, new Text( 'abc' ) );
 
 		batch = doc.batch();
 
@@ -33,9 +34,10 @@ describe( 'Batch', () => {
 
 	describe( 'insert', () => {
 		it( 'should insert given nodes at given position', () => {
-			expect( root.getChildCount() ).to.equal( 5 );
-			expect( root.getChild( 2 ) ).to.equal( p );
-			expect( root.getChild( 3 ) ).to.equal( ul );
+			expect( root.getChildCount() ).to.equal( 4 );
+			expect( root.getMaxOffset() ).to.equal( 5 );
+			expect( root.getChild( 1 ) ).to.equal( p );
+			expect( root.getChild( 2 ) ).to.equal( ul );
 		} );
 
 		it( 'should be chainable', () => {
@@ -83,17 +85,17 @@ describe( 'InsertDelta', () => {
 		} );
 	} );
 
-	describe( 'nodeList', () => {
+	describe( 'nodes', () => {
 		it( 'should be null if there are no operations in delta', () => {
-			expect( insertDelta.nodeList ).to.be.null;
+			expect( insertDelta.nodes ).to.be.null;
 		} );
 
-		it( 'should be equal to the node list inserted by the delta', () => {
+		it( 'should be equal to the nodes inserted by the delta', () => {
 			let elementX = new Element( 'x' );
 			insertDelta.operations.push( new InsertOperation( new Position( root, [ 1, 2, 3 ] ), elementX, 0 ) );
 
-			expect( insertDelta.nodeList.length ).to.equal( 1 );
-			expect( insertDelta.nodeList.get( 0 ) ).to.equal( elementX );
+			expect( insertDelta.nodes.length ).to.equal( 1 );
+			expect( insertDelta.nodes.getNode( 0 ) ).to.equal( elementX );
 		} );
 	} );
 

+ 9 - 11
packages/ckeditor5-engine/tests/model/delta/mergedelta.js

@@ -8,6 +8,7 @@
 import Document from '/ckeditor5/engine/model/document.js';
 import Position from '/ckeditor5/engine/model/position.js';
 import Element from '/ckeditor5/engine/model/element.js';
+import Text from '/ckeditor5/engine/model/text.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 
 import MergeDelta from '/ckeditor5/engine/model/delta/mergedelta.js';
@@ -17,6 +18,8 @@ import MoveOperation from '/ckeditor5/engine/model/operation/moveoperation.js';
 import RemoveOperation from '/ckeditor5/engine/model/operation/removeoperation.js';
 import ReinsertOperation from '/ckeditor5/engine/model/operation/reinsertoperation.js';
 
+import count from '/ckeditor5/utils/count.js';
+
 describe( 'Batch', () => {
 	let doc, root, p1, p2, batch;
 
@@ -24,8 +27,8 @@ describe( 'Batch', () => {
 		doc = new Document();
 		root = doc.createRoot();
 
-		p1 = new Element( 'p', { key1: 'value1' }, 'foo' );
-		p2 = new Element( 'p', { key2: 'value2' }, 'bar' );
+		p1 = new Element( 'p', { key1: 'value1' }, new Text( 'foo' ) );
+		p2 = new Element( 'p', { key2: 'value2' }, new Text( 'bar' ) );
 
 		root.insertChildren( 0, [ p1, p2 ] );
 	} );
@@ -34,17 +37,12 @@ describe( 'Batch', () => {
 		it( 'should merge foo and bar into foobar', () => {
 			doc.batch().merge( new Position( root, [ 1 ] ) );
 
-			expect( root.getChildCount() ).to.equal( 1 );
+			expect( root.getMaxOffset() ).to.equal( 1 );
 			expect( root.getChild( 0 ).name ).to.equal( 'p' );
-			expect( root.getChild( 0 ).getChildCount() ).to.equal( 6 );
-			expect( root.getChild( 0 )._attrs.size ).to.equal( 1 );
+			expect( root.getChild( 0 ).getMaxOffset() ).to.equal( 6 );
+			expect( count( root.getChild( 0 ).getAttributes() ) ).to.equal( 1 );
 			expect( root.getChild( 0 ).getAttribute( 'key1' ) ).to.equal( 'value1' );
-			expect( root.getChild( 0 ).getChild( 0 ).character ).to.equal( 'f' );
-			expect( root.getChild( 0 ).getChild( 1 ).character ).to.equal( 'o' );
-			expect( root.getChild( 0 ).getChild( 2 ).character ).to.equal( 'o' );
-			expect( root.getChild( 0 ).getChild( 3 ).character ).to.equal( 'b' );
-			expect( root.getChild( 0 ).getChild( 4 ).character ).to.equal( 'a' );
-			expect( root.getChild( 0 ).getChild( 5 ).character ).to.equal( 'r' );
+			expect( root.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foobar' );
 		} );
 
 		it( 'should throw if there is no element after', () => {

+ 9 - 8
packages/ckeditor5-engine/tests/model/delta/movedelta.js

@@ -10,6 +10,7 @@ import Document from '/ckeditor5/engine/model/document.js';
 import Position from '/ckeditor5/engine/model/position.js';
 import Range from '/ckeditor5/engine/model/range.js';
 import Element from '/ckeditor5/engine/model/element.js';
+import Text from '/ckeditor5/engine/model/text.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 
 import MoveDelta from '/ckeditor5/engine/model/delta/movedelta.js';
@@ -22,11 +23,11 @@ describe( 'Batch', () => {
 		doc = new Document();
 		root = doc.createRoot();
 
-		div = new Element( 'div', [], 'foobar' );
-		p = new Element( 'p', [], 'abcxyz' );
+		div = new Element( 'div', [], new Text( 'foobar' ) );
+		p = new Element( 'p', [], new Text( 'abcxyz' ) );
 
-		div.insertChildren( 4, [ new Element( 'p', [], 'gggg' ) ] );
-		div.insertChildren( 2, [ new Element( 'p', [], 'hhhh' ) ] );
+		div.insertChildren( 0, [ new Element( 'p', [], new Text( 'gggg' ) ) ] );
+		div.insertChildren( 2, [ new Element( 'p', [], new Text( 'hhhh' ) ) ] );
 
 		root.insertChildren( 0, [ div, p ] );
 
@@ -37,17 +38,17 @@ describe( 'Batch', () => {
 		it( 'should move specified node', () => {
 			batch.move( div, new Position( root, [ 2 ] ) );
 
-			expect( root.getChildCount() ).to.equal( 2 );
+			expect( root.getMaxOffset() ).to.equal( 2 );
 			expect( getNodesAndText( Range.createFromElement( root.getChild( 0 ) ) ) ).to.equal( 'abcxyz' );
-			expect( getNodesAndText( Range.createFromElement( root.getChild( 1 ) ) ) ).to.equal( 'foPhhhhPobPggggPar' );
+			expect( getNodesAndText( Range.createFromElement( root.getChild( 1 ) ) ) ).to.equal( 'PggggPfoobarPhhhhP' );
 		} );
 
 		it( 'should move flat range of nodes', () => {
 			let range = new Range( new Position( root, [ 0, 3 ] ), new Position( root, [ 0, 7 ] ) );
 			batch.move( range, new Position( root, [ 1, 3 ] ) );
 
-			expect( getNodesAndText( Range.createFromElement( root.getChild( 0 ) ) ) ).to.equal( 'foPhhhhPr' );
-			expect( getNodesAndText( Range.createFromElement( root.getChild( 1 ) ) ) ).to.equal( 'abcobPggggPaxyz' );
+			expect( getNodesAndText( Range.createFromElement( root.getChild( 0 ) ) ) ).to.equal( 'PggggPfoPhhhhP' );
+			expect( getNodesAndText( Range.createFromElement( root.getChild( 1 ) ) ) ).to.equal( 'abcobarxyz' );
 		} );
 
 		it( 'should throw if given range is not flat', () => {

+ 9 - 7
packages/ckeditor5-engine/tests/model/delta/removedelta.js

@@ -10,6 +10,7 @@ import Document from '/ckeditor5/engine/model/document.js';
 import Position from '/ckeditor5/engine/model/position.js';
 import Range from '/ckeditor5/engine/model/range.js';
 import Element from '/ckeditor5/engine/model/element.js';
+import Text from '/ckeditor5/engine/model/text.js';
 import RemoveDelta from '/ckeditor5/engine/model/delta/removedelta.js';
 
 describe( 'Batch', () => {
@@ -19,11 +20,11 @@ describe( 'Batch', () => {
 		doc = new Document();
 		root = doc.createRoot();
 
-		div = new Element( 'div', [], 'foobar' );
-		p = new Element( 'p', [], 'abcxyz' );
+		div = new Element( 'div', [], new Text( 'foobar' ) );
+		p = new Element( 'p', [], new Text( 'abcxyz' ) );
 
-		div.insertChildren( 4, [ new Element( 'p', [], 'gggg' ) ] );
-		div.insertChildren( 2, [ new Element( 'p', [], 'hhhh' ) ] );
+		div.insertChildren( 0, [ new Element( 'p', [], new Text( 'gggg' ) ) ] );
+		div.insertChildren( 2, [ new Element( 'p', [], new Text( 'hhhh' ) ) ] );
 
 		root.insertChildren( 0, [ div, p ] );
 
@@ -31,21 +32,22 @@ describe( 'Batch', () => {
 
 		// Range starts in ROOT > DIV > P > gg|gg.
 		// Range ends in ROOT > DIV > ...|ar.
-		range = new Range( new Position( root, [ 0, 2, 2 ] ), new Position( root, [ 0, 6 ] ) );
+		range = new Range( new Position( root, [ 0, 0, 2 ] ), new Position( root, [ 0, 5 ] ) );
 	} );
 
 	describe( 'remove', () => {
 		it( 'should remove specified node', () => {
 			batch.remove( div );
 
+			expect( root.getMaxOffset() ).to.equal( 1 );
 			expect( root.getChildCount() ).to.equal( 1 );
 			expect( getNodesAndText( Range.createFromElement( root.getChild( 0 ) ) ) ).to.equal( 'abcxyz' );
 		} );
 
-		it( 'should move any range of nodes', () => {
+		it( 'should remove any range of nodes', () => {
 			batch.remove( range );
 
-			expect( getNodesAndText( Range.createFromElement( root.getChild( 0 ) ) ) ).to.equal( 'foPhhPar' );
+			expect( getNodesAndText( Range.createFromElement( root.getChild( 0 ) ) ) ).to.equal( 'PggParPhhhhP' );
 			expect( getNodesAndText( Range.createFromElement( root.getChild( 1 ) ) ) ).to.equal( 'abcxyz' );
 		} );
 

+ 13 - 6
packages/ckeditor5-engine/tests/model/delta/renamedelta.js

@@ -7,6 +7,8 @@
 
 import Document from '/ckeditor5/engine/model/document.js';
 import Element from '/ckeditor5/engine/model/element.js';
+import Text from '/ckeditor5/engine/model/text.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 
 import RenameDelta from '/ckeditor5/engine/model/delta/renamedelta.js';
 
@@ -17,7 +19,7 @@ describe( 'Batch', () => {
 		doc = new Document();
 		root = doc.createRoot();
 
-		const p = new Element( 'p', null, 'abc' );
+		const p = new Element( 'p', null, new Text( 'abc' ) );
 		root.appendChildren( p );
 
 		batch = doc.batch();
@@ -27,9 +29,15 @@ describe( 'Batch', () => {
 
 	describe( 'rename', () => {
 		it( 'should rename given element', () => {
-			expect( root.getChildCount() ).to.equal( 1 );
+			expect( root.getMaxOffset() ).to.equal( 1 );
 			expect( root.getChild( 0 ) ).to.have.property( 'name', 'h' );
-			expect( root.getChild( 0 ).getText() ).to.equal( 'abc' );
+			expect( root.getChild( 0 ) ).to.have.property( 'name', 'h' );
+		} );
+
+		it( 'should throw if not an Element instance is passed', () => {
+			expect( () => {
+				batch.rename( 'h', new Text( 'abc' ) );
+			} ).to.throw( CKEditorError, /^batch-rename-not-element-instance/ );
 		} );
 
 		it( 'should be chainable', () => {
@@ -72,7 +80,7 @@ describe( 'RenameDelta', () => {
 		} );
 
 		it( 'should return correct RenameDelta', () => {
-			root.appendChildren( new Element( 'p', null, 'abc' ) );
+			root.appendChildren( new Element( 'p', null, new Text( 'abc' ) ) );
 
 			const batch = doc.batch();
 
@@ -84,9 +92,8 @@ describe( 'RenameDelta', () => {
 				doc.applyOperation( operation );
 			} );
 
-			expect( root.getChildCount() ).to.equal( 1 );
+			expect( root.getMaxOffset() ).to.equal( 1 );
 			expect( root.getChild( 0 ) ).to.have.property( 'name', 'p' );
-			expect( root.getChild( 0 ).getText() ).to.equal( 'abc' );
 		} );
 	} );
 

+ 18 - 24
packages/ckeditor5-engine/tests/model/delta/splitdelta.js

@@ -8,6 +8,7 @@
 import Document from '/ckeditor5/engine/model/document.js';
 import Position from '/ckeditor5/engine/model/position.js';
 import Element from '/ckeditor5/engine/model/element.js';
+import Text from '/ckeditor5/engine/model/text.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 
 import MergeDelta from '/ckeditor5/engine/model/delta/mergedelta.js';
@@ -17,6 +18,8 @@ import InsertOperation from '/ckeditor5/engine/model/operation/insertoperation.j
 import MoveOperation from '/ckeditor5/engine/model/operation/moveoperation.js';
 import RemoveOperation from '/ckeditor5/engine/model/operation/removeoperation.js';
 
+import count from '/ckeditor5/utils/count.js';
+
 describe( 'Batch', () => {
 	let doc, root, p;
 
@@ -24,7 +27,7 @@ describe( 'Batch', () => {
 		doc = new Document();
 		root = doc.createRoot();
 
-		p = new Element( 'p', { key: 'value' }, 'foobar' );
+		p = new Element( 'p', { key: 'value' }, new Text( 'foobar' ) );
 
 		root.insertChildren( 0, p );
 	} );
@@ -33,44 +36,35 @@ describe( 'Batch', () => {
 		it( 'should split foobar to foo and bar', () => {
 			doc.batch().split( new Position( root, [ 0, 3 ] ) );
 
-			expect( root.getChildCount() ).to.equal( 2 );
+			expect( root.getMaxOffset() ).to.equal( 2 );
 
 			expect( root.getChild( 0 ).name ).to.equal( 'p' );
-			expect( root.getChild( 0 ).getChildCount() ).to.equal( 3 );
-			expect( root.getChild( 0 )._attrs.size ).to.equal( 1 );
+			expect( root.getChild( 0 ).getMaxOffset() ).to.equal( 3 );
+			expect( count( root.getChild( 0 ).getAttributes() ) ).to.equal( 1 );
 			expect( root.getChild( 0 ).getAttribute( 'key' ) ).to.equal( 'value' );
-			expect( root.getChild( 0 ).getChild( 0 ).character ).to.equal( 'f' );
-			expect( root.getChild( 0 ).getChild( 1 ).character ).to.equal( 'o' );
-			expect( root.getChild( 0 ).getChild( 2 ).character ).to.equal( 'o' );
+			expect( root.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foo' );
 
 			expect( root.getChild( 1 ).name ).to.equal( 'p' );
-			expect( root.getChild( 1 ).getChildCount() ).to.equal( 3 );
-			expect( root.getChild( 1 )._attrs.size ).to.equal( 1 );
+			expect( root.getChild( 1 ).getMaxOffset() ).to.equal( 3 );
+			expect( count( root.getChild( 1 ).getAttributes() ) ).to.equal( 1 );
 			expect( root.getChild( 1 ).getAttribute( 'key' ) ).to.equal( 'value' );
-			expect( root.getChild( 1 ).getChild( 0 ).character ).to.equal( 'b' );
-			expect( root.getChild( 1 ).getChild( 1 ).character ).to.equal( 'a' );
-			expect( root.getChild( 1 ).getChild( 2 ).character ).to.equal( 'r' );
+			expect( root.getChild( 1 ).getChild( 0 ).data ).to.equal( 'bar' );
 		} );
 
 		it( 'should create an empty paragraph if we split at the end', () => {
 			doc.batch().split( new Position( root, [ 0, 6 ] ) );
 
-			expect( root.getChildCount() ).to.equal( 2 );
+			expect( root.getMaxOffset() ).to.equal( 2 );
 
 			expect( root.getChild( 0 ).name ).to.equal( 'p' );
-			expect( root.getChild( 0 ).getChildCount() ).to.equal( 6 );
-			expect( root.getChild( 0 )._attrs.size ).to.equal( 1 );
+			expect( root.getChild( 0 ).getMaxOffset() ).to.equal( 6 );
+			expect( count( root.getChild( 0 ).getAttributes() ) ).to.equal( 1 );
 			expect( root.getChild( 0 ).getAttribute( 'key' ) ).to.equal( 'value' );
-			expect( root.getChild( 0 ).getChild( 0 ).character ).to.equal( 'f' );
-			expect( root.getChild( 0 ).getChild( 1 ).character ).to.equal( 'o' );
-			expect( root.getChild( 0 ).getChild( 2 ).character ).to.equal( 'o' );
-			expect( root.getChild( 0 ).getChild( 3 ).character ).to.equal( 'b' );
-			expect( root.getChild( 0 ).getChild( 4 ).character ).to.equal( 'a' );
-			expect( root.getChild( 0 ).getChild( 5 ).character ).to.equal( 'r' );
+			expect( root.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foobar' );
 
 			expect( root.getChild( 1 ).name ).to.equal( 'p' );
-			expect( root.getChild( 1 ).getChildCount() ).to.equal( 0 );
-			expect( root.getChild( 1 )._attrs.size ).to.equal( 1 );
+			expect( root.getChild( 1 ).getMaxOffset() ).to.equal( 0 );
+			expect( count( root.getChild( 1 ).getAttributes() ) ).to.equal( 1 );
 			expect( root.getChild( 1 ).getAttribute( 'key' ) ).to.equal( 'value' );
 		} );
 
@@ -168,7 +162,7 @@ describe( 'SplitDelta', () => {
 			splitDelta.operations.push( new MoveOperation( new Position( root, [ 1, 1, 4 ] ), 4, new Position( root, [ 1, 2, 0 ] ), 1 ) );
 
 			expect( splitDelta._cloneOperation ).to.be.instanceof( InsertOperation );
-			expect( splitDelta._cloneOperation.nodeList.get( 0 ) ).to.equal( p );
+			expect( splitDelta._cloneOperation.nodes.getNode( 0 ) ).to.equal( p );
 			expect( splitDelta._cloneOperation.position.path ).to.deep.equal( [ 1, 2 ] );
 		} );
 	} );

+ 6 - 5
packages/ckeditor5-engine/tests/model/delta/transform/_utils/utils.js

@@ -7,6 +7,7 @@
 
 import Document from '/ckeditor5/engine/model/document.js';
 import Element from '/ckeditor5/engine/model/element.js';
+import Text from '/ckeditor5/engine/model/text.js';
 
 import Position from '/ckeditor5/engine/model/position.js';
 import Range from '/ckeditor5/engine/model/range.js';
@@ -154,7 +155,7 @@ export function expectOperation( op, params ) {
 			expect( op ).to.be.instanceof( params[ i ] );
 		}
 		else if ( i == 'nodes' ) {
-			expect( op.nodeList._nodes ).to.deep.equal( params[ i ] );
+			expect( Array.from( op.nodes ) ).to.deep.equal( params[ i ] );
 		} else if ( params[ i ] instanceof Position || params[ i ] instanceof Range ) {
 			expect( op[ i ].isEqual( params[ i ] ) ).to.be.true;
 		} else {
@@ -176,16 +177,16 @@ export function getFilledDocument() {
 	root.insertChildren( 0, [
 		new Element( 'x' ),
 		new Element( 'x' ),
-		new Element( 'x', [], 'a' ),
+		new Element( 'x', [], new Text( 'a' ) ),
 		new Element( 'div', [], [
 			new Element( 'x' ),
 			new Element( 'x' ),
-			new Element( 'x', [], 'a' ),
+			new Element( 'x', [], new Text( 'a' ) ),
 			new Element( 'div', [], [
 				new Element( 'x' ),
 				new Element( 'x' ),
-				new Element( 'x', [], 'abcd' ),
-				new Element( 'p', [], 'abcfoobarxyz' )
+				new Element( 'x', [], new Text( 'abcd' ) ),
+				new Element( 'p', [], new Text( 'abcfoobarxyz' ) )
 			] )
 		] )
 	] );

+ 3 - 2
packages/ckeditor5-engine/tests/model/delta/transform/weakinsertdelta.js

@@ -47,6 +47,7 @@ describe( 'transform', () => {
 					'a',
 					new Text( 'b', { key: 'new' } ),
 					new Text( 'c', { key: 'different' } ),
+					new Text( 'c', { key: 'different', key2: true } ),
 					'de'
 				],
 				baseVersion
@@ -88,7 +89,7 @@ describe( 'transform', () => {
 						},
 						{
 							type: AttributeOperation,
-							range: new Range( new Position( root, [ 3, 3, 2 ] ), new Position( root, [ 3, 3, 3 ] ) ),
+							range: new Range( new Position( root, [ 3, 3, 2 ] ), new Position( root, [ 3, 3, 4 ] ) ),
 							key: 'key',
 							oldValue: 'different',
 							newValue: 'new',
@@ -96,7 +97,7 @@ describe( 'transform', () => {
 						},
 						{
 							type: AttributeOperation,
-							range: new Range( new Position( root, [ 3, 3, 3 ] ), new Position( root, [ 3, 3, 5 ] ) ),
+							range: new Range( new Position( root, [ 3, 3, 4 ] ), new Position( root, [ 3, 3, 6 ] ) ),
 							key: 'key',
 							oldValue: undefined,
 							newValue: 'new',

+ 5 - 8
packages/ckeditor5-engine/tests/model/delta/unwrapdelta.js

@@ -7,6 +7,7 @@
 
 import Document from '/ckeditor5/engine/model/document.js';
 import Element from '/ckeditor5/engine/model/element.js';
+import Text from '/ckeditor5/engine/model/text.js';
 import Position from '/ckeditor5/engine/model/position.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 
@@ -24,20 +25,16 @@ describe( 'Batch', () => {
 		doc = new Document();
 		root = doc.createRoot();
 
-		p = new Element( 'p', [], 'xyz' );
-		root.insertChildren( 0, [ 'a', p, 'b' ] );
+		p = new Element( 'p', [], new Text( 'xyz' ) );
+		root.insertChildren( 0, [ new Text( 'a' ), p, new Text( 'b' ) ] );
 	} );
 
 	describe( 'unwrap', () => {
 		it( 'should unwrap given element', () => {
 			doc.batch().unwrap( p );
 
-			expect( root.getChildCount() ).to.equal( 5 );
-			expect( root.getChild( 0 ).character ).to.equal( 'a' );
-			expect( root.getChild( 1 ).character ).to.equal( 'x' );
-			expect( root.getChild( 2 ).character ).to.equal( 'y' );
-			expect( root.getChild( 3 ).character ).to.equal( 'z' );
-			expect( root.getChild( 4 ).character ).to.equal( 'b' );
+			expect( root.getMaxOffset() ).to.equal( 5 );
+			expect( root.getChild( 0 ).data ).to.equal( 'axyzb' );
 		} );
 
 		it( 'should throw if element to unwrap has no parent', () => {

+ 7 - 8
packages/ckeditor5-engine/tests/model/delta/weakinsertdelta.js

@@ -7,6 +7,7 @@
 
 import Document from '/ckeditor5/engine/model/document.js';
 import Position from '/ckeditor5/engine/model/position.js';
+import Text from '/ckeditor5/engine/model/text.js';
 import WeakInsertDelta from '/ckeditor5/engine/model/delta/weakinsertdelta.js';
 
 describe( 'Batch', () => {
@@ -16,7 +17,7 @@ describe( 'Batch', () => {
 		doc = new Document();
 		root = doc.createRoot();
 
-		root.insertChildren( 0, 'abc' );
+		root.insertChildren( 0, new Text( 'abc' ) );
 
 		batch = doc.batch();
 
@@ -29,16 +30,14 @@ describe( 'Batch', () => {
 
 	describe( 'weakInsert', () => {
 		it( 'should insert given nodes at given position', () => {
-			expect( root.getChildCount() ).to.equal( 6 );
-			expect( root.getChild( 2 ).character ).to.equal( 'x' );
-			expect( root.getChild( 3 ).character ).to.equal( 'y' );
-			expect( root.getChild( 4 ).character ).to.equal( 'z' );
+			expect( root.getMaxOffset() ).to.equal( 6 );
+			expect( root.getChild( 0 ).data ).to.equal( 'ab' );
+			expect( root.getChild( 1 ).data ).to.equal( 'xyz' );
+			expect( root.getChild( 2 ).data ).to.equal( 'c' );
 		} );
 
 		it( 'should set inserted nodes attributes to same as current selection attributes', () => {
-			expect( Array.from( root.getChild( 2 )._attrs ) ).to.deep.equal( attrs );
-			expect( Array.from( root.getChild( 3 )._attrs ) ).to.deep.equal( attrs );
-			expect( Array.from( root.getChild( 4 )._attrs ) ).to.deep.equal( attrs );
+			expect( Array.from( root.getChild( 1 ).getAttributes() ) ).to.deep.equal( attrs );
 		} );
 
 		it( 'should be chainable', () => {

+ 18 - 23
packages/ckeditor5-engine/tests/model/delta/wrapdelta.js

@@ -9,6 +9,7 @@ import Document from '/ckeditor5/engine/model/document.js';
 import Position from '/ckeditor5/engine/model/position.js';
 import Range from '/ckeditor5/engine/model/range.js';
 import Element from '/ckeditor5/engine/model/element.js';
+import Text from '/ckeditor5/engine/model/text.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 
 import WrapDelta from '/ckeditor5/engine/model/delta/wrapdelta.js';
@@ -25,7 +26,7 @@ describe( 'Batch', () => {
 		doc = new Document();
 		root = doc.createRoot();
 
-		root.insertChildren( 0, 'foobar' );
+		root.insertChildren( 0, new Text( 'foobar' ) );
 
 		range = new Range( new Position( root, [ 2 ] ), new Position( root, [ 4 ] ) );
 	} );
@@ -35,31 +36,25 @@ describe( 'Batch', () => {
 			let p = new Element( 'p' );
 			doc.batch().wrap( range, p );
 
-			expect( root.getChildCount() ).to.equal( 5 );
-			expect( root.getChild( 0 ).character ).to.equal( 'f' );
-			expect( root.getChild( 1 ).character ).to.equal( 'o' );
-			expect( root.getChild( 2 ) ).to.equal( p );
-			expect( p.getChild( 0 ).character ).to.equal( 'o' );
-			expect( p.getChild( 1 ).character ).to.equal( 'b' );
-			expect( root.getChild( 3 ).character ).to.equal( 'a' );
-			expect( root.getChild( 4 ).character ).to.equal( 'r' );
+			expect( root.getMaxOffset() ).to.equal( 5 );
+			expect( root.getChild( 0 ).data ).to.equal( 'fo' );
+			expect( root.getChild( 1 ) ).to.equal( p );
+			expect( p.getChild( 0 ).data ).to.equal( 'ob' );
+			expect( root.getChild( 2 ).data ).to.equal( 'ar' );
 		} );
 
 		it( 'should wrap flat range with an element of given name', () => {
 			doc.batch().wrap( range, 'p' );
 
-			expect( root.getChildCount() ).to.equal( 5 );
-			expect( root.getChild( 0 ).character ).to.equal( 'f' );
-			expect( root.getChild( 1 ).character ).to.equal( 'o' );
-			expect( root.getChild( 2 ).name ).to.equal( 'p' );
-			expect( root.getChild( 2 ).getChild( 0 ).character ).to.equal( 'o' );
-			expect( root.getChild( 2 ).getChild( 1 ).character ).to.equal( 'b' );
-			expect( root.getChild( 3 ).character ).to.equal( 'a' );
-			expect( root.getChild( 4 ).character ).to.equal( 'r' );
+			expect( root.getMaxOffset() ).to.equal( 5 );
+			expect( root.getChild( 0 ).data ).to.equal( 'fo' );
+			expect( root.getChild( 1 ).name ).to.equal( 'p' );
+			expect( root.getChild( 1 ).getChild( 0 ).data ).to.equal( 'ob' );
+			expect( root.getChild( 2 ).data ).to.equal( 'ar' );
 		} );
 
 		it( 'should throw if range to wrap is not flat', () => {
-			root.insertChildren( 6, [ new Element( 'p', [], 'xyz' ) ] );
+			root.insertChildren( 1, [ new Element( 'p', [], new Text( 'xyz' ) ) ] );
 			let notFlatRange = new Range( new Position( root, [ 3 ] ), new Position( root, [ 6, 2 ] ) );
 
 			expect( () => {
@@ -68,7 +63,7 @@ describe( 'Batch', () => {
 		} );
 
 		it( 'should throw if element to wrap with has children', () => {
-			let p = new Element( 'p', [], 'a' );
+			let p = new Element( 'p', [], new Text( 'a' ) );
 
 			expect( () => {
 				doc.batch().wrap( range, p );
@@ -125,7 +120,7 @@ describe( 'WrapDelta', () => {
 		} );
 
 		it( 'should be equal to wrapped range', () => {
-			wrapDelta.operations.push( new InsertOperation( new Position( root, [ 1, 6 ] ), 1 ) );
+			wrapDelta.operations.push( new InsertOperation( new Position( root, [ 1, 6 ] ), [], 1 ) );
 			wrapDelta.operations.push( new MoveOperation( new Position( root, [ 1, 1 ] ), 5, new Position( root, [ 1, 6, 0 ] ) ) );
 
 			expect( wrapDelta.range.start.isEqual( new Position( root, [ 1, 1 ] ) ) ).to.be.true;
@@ -141,7 +136,7 @@ describe( 'WrapDelta', () => {
 		it( 'should be equal to the number of wrapped elements', () => {
 			let howMany = 5;
 
-			wrapDelta.operations.push( new InsertOperation( new Position( root, [ 1, 6 ] ), 1 ) );
+			wrapDelta.operations.push( new InsertOperation( new Position( root, [ 1, 6 ] ), [], 1 ) );
 			wrapDelta.operations.push( new MoveOperation( new Position( root, [ 1, 1 ] ), howMany, new Position( root, [ 1, 6, 0 ] ) ) );
 
 			expect( wrapDelta.howMany ).to.equal( 5 );
@@ -157,7 +152,7 @@ describe( 'WrapDelta', () => {
 		} );
 
 		it( 'should return correct UnwrapDelta', () => {
-			wrapDelta.operations.push( new InsertOperation( new Position( root, [ 1, 6 ] ), 1 ) );
+			wrapDelta.operations.push( new InsertOperation( new Position( root, [ 1, 6 ] ), new Element( 'p' ), 1 ) );
 			wrapDelta.operations.push( new MoveOperation( new Position( root, [ 1, 1 ] ), 5, new Position( root, [ 1, 6, 0 ] ) ) );
 
 			let reversed = wrapDelta.getReversed();
@@ -182,7 +177,7 @@ describe( 'WrapDelta', () => {
 		} );
 
 		it( 'should be equal to the first operation in the delta', () => {
-			let insertOperation = new InsertOperation( new Position( root, [ 1, 6 ] ), 1 );
+			let insertOperation = new InsertOperation( new Position( root, [ 1, 6 ] ), [], 1 );
 
 			wrapDelta.operations.push( insertOperation );
 			wrapDelta.operations.push( new MoveOperation( new Position( root, [ 1, 1 ] ), 5, new Position( root, [ 1, 6, 0 ] ) ) );

+ 4 - 3
packages/ckeditor5-engine/tests/model/document/change-event.js

@@ -7,6 +7,7 @@
 
 import Document from '/ckeditor5/engine/model/document.js';
 import Element from '/ckeditor5/engine/model/element.js';
+import Text from '/ckeditor5/engine/model/text.js';
 import Position from '/ckeditor5/engine/model/position.js';
 import Range from '/ckeditor5/engine/model/range.js';
 import AttributeOperation from '/ckeditor5/engine/model/operation/attributeoperation.js';
@@ -60,7 +61,7 @@ describe( 'Document change event', () => {
 
 	it( 'should be fired when nodes are moved', () => {
 		const p1 = new Element( 'p' );
-		p1.insertChildren( 0, [ new Element( 'p' ), 'foo' ] );
+		p1.insertChildren( 0, [ new Element( 'p' ), new Text( 'foo' ) ] );
 
 		const p2 = new Element( 'p' );
 
@@ -82,7 +83,7 @@ describe( 'Document change event', () => {
 	} );
 
 	it( 'should be fired when multiple nodes are removed and reinserted', () => {
-		root.insertChildren( 0, 'foo' );
+		root.insertChildren( 0, new Text( 'foo' ) );
 
 		const removeOperation = new RemoveOperation( new Position( root, [ 0 ] ), 3, doc.version );
 		doc.applyOperation( wrapInDelta( removeOperation ) );
@@ -104,7 +105,7 @@ describe( 'Document change event', () => {
 	} );
 
 	it( 'should be fired when attribute is inserted', () => {
-		root.insertChildren( 0, 'foo' );
+		root.insertChildren( 0, new Text( 'foo' ) );
 
 		doc.applyOperation( wrapInDelta(
 			new AttributeOperation(

+ 3 - 3
packages/ckeditor5-engine/tests/model/document/document.js

@@ -27,7 +27,7 @@ describe( 'Document', () => {
 			expect( doc ).to.have.property( '_roots' ).that.is.instanceof( Map );
 			expect( doc._roots.size ).to.equal( 1 );
 			expect( doc.graveyard ).to.be.instanceof( RootElement );
-			expect( doc.graveyard.getChildCount() ).to.equal( 0 );
+			expect( doc.graveyard.getMaxOffset() ).to.equal( 0 );
 			expect( count( doc.selection.getRanges() ) ).to.equal( 1 );
 
 			expect( doc.composer ).to.be.instanceof( Composer );
@@ -54,7 +54,7 @@ describe( 'Document', () => {
 
 			expect( doc._roots.size ).to.equal( 2 );
 			expect( root ).to.be.instanceof( RootElement );
-			expect( root.getChildCount() ).to.equal( 0 );
+			expect( root.getMaxOffset() ).to.equal( 0 );
 			expect( root ).to.have.property( 'name', '$root' );
 			expect( root ).to.have.property( 'rootName', 'main' );
 		} );
@@ -64,7 +64,7 @@ describe( 'Document', () => {
 
 			expect( doc._roots.size ).to.equal( 2 );
 			expect( root ).to.be.instanceof( RootElement );
-			expect( root.getChildCount() ).to.equal( 0 );
+			expect( root.getMaxOffset() ).to.equal( 0 );
 			expect( root ).to.have.property( 'name', 'customElementName' );
 			expect( root ).to.have.property( 'rootName', 'customRootName' );
 		} );

+ 172 - 71
packages/ckeditor5-engine/tests/model/documentfragment.js

@@ -5,9 +5,10 @@
 
 /* bender-tags: model */
 
-import NodeList from '/ckeditor5/engine/model/nodelist.js';
 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';
 
 describe( 'DocumentFragment', () => {
 	describe( 'constructor', () => {
@@ -15,15 +16,18 @@ describe( 'DocumentFragment', () => {
 			let frag = new DocumentFragment();
 
 			expect( frag.getChildCount() ).to.equal( 0 );
+			expect( frag.getMaxOffset() ).to.equal( 0 );
 		} );
 
 		it( 'should create document fragment with children', () => {
-			let frag = new DocumentFragment( [ 'x', new Element( 'p' ), 'y' ] );
+			let frag = new DocumentFragment( [ new Text( 'xx' ), new Element( 'p' ), new Text( 'yy' ) ] );
 
 			expect( frag.getChildCount() ).to.equal( 3 );
-			expect( frag.getChild( 0 ) ).to.have.property( 'character' ).that.equals( 'x' );
+			expect( frag.getMaxOffset() ).to.equal( 5 );
+
+			expect( frag.getChild( 0 ) ).to.have.property( 'data' ).that.equals( 'xx' );
 			expect( frag.getChild( 1 ) ).to.have.property( 'name' ).that.equals( 'p' );
-			expect( frag.getChild( 2 ) ).to.have.property( 'character' ).that.equals( 'y' );
+			expect( frag.getChild( 2 ) ).to.have.property( 'data' ).that.equals( 'yy' );
 		} );
 
 		it( 'should have root property, equal to itself', () => {
@@ -34,123 +38,220 @@ describe( 'DocumentFragment', () => {
 	} );
 
 	describe( 'iterator', () => {
-		it( 'should iterate over all nodes added to document fragment', () => {
-			let frag = new DocumentFragment( [ 'x', new Element( 'p' ), 'y' ] );
-			let arr = Array.from( frag );
+		it( 'should iterate over document fragment\'s children', () => {
+			let xx = new Text( 'xx' );
+			let p = new Element( 'p' );
+			let yy = new Text( 'yy' );
+			let frag = new DocumentFragment( [ xx, p, yy ] );
 
-			expect( arr.length ).to.equal( 3 );
-			expect( arr[ 0 ] ).to.have.property( 'character' ).that.equals( 'x' );
-			expect( arr[ 1 ] ).to.have.property( 'name' ).that.equals( 'p' );
-			expect( arr[ 2 ] ).to.have.property( 'character' ).that.equals( 'y' );
+			expect( Array.from( frag ) ).to.deep.equal( [ xx, p, yy ] );
 		} );
 	} );
 
 	describe( 'getPath', () => {
 		it( 'should return empty array', () => {
-			let frag = new DocumentFragment( [ 'x', new Element( 'p' ), 'y' ] );
+			let frag = new DocumentFragment( [ new Text( 'x' ), new Element( 'p' ), new Text( 'y' ) ] );
 
 			expect( frag.getPath() ).to.deep.equal( [] );
 		} );
 	} );
 
-	describe( 'insertChildren', () => {
-		it( 'should add children to the document fragment', () => {
-			let frag = new DocumentFragment( 'xy' );
-			frag.insertChildren( 1, 'foo' );
+	describe( 'isEmpty', () => {
+		it( 'should return true if document fragment has no children', () => {
+			let frag = new DocumentFragment();
 
-			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' );
+			expect( frag.isEmpty() ).to.be.true;
 		} );
 
-		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 ] );
+		it( 'should return false if document fragment has children', () => {
+			let frag = new DocumentFragment( new Text( 'a' ) );
 
-			let frag = new DocumentFragment();
+			expect( frag.isEmpty() ).to.be.false;
+		} );
+	} );
 
-			frag.insertChildren( 0, otherFrag );
+	describe( 'offsetToIndex', () => {
+		let frag;
+
+		beforeEach( () => {
+			frag = new Element( 'elem', [], [ new Element( 'p' ), new Text( 'bar' ), new Element( 'h' ) ] );
+		} );
+
+		it( 'should return index of a node that occupies given offset in this element', () => {
+			expect( frag.offsetToIndex( 0 ) ).to.equal( 0 );
+			expect( frag.offsetToIndex( 1 ) ).to.equal( 1 );
+			expect( frag.offsetToIndex( 2 ) ).to.equal( 1 );
+			expect( frag.offsetToIndex( 3 ) ).to.equal( 1 );
+			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 return document fragment\'s child count if offset is too high', () => {
+			expect( frag.offsetToIndex( 5 ) ).to.equal( 3 );
+			expect( frag.offsetToIndex( 33 ) ).to.equal( 3 );
+		} );
+	} );
+
+	describe( 'insertChildren', () => {
+		it( 'should add children to the document fragment', () => {
+			let frag = new DocumentFragment( new Text( 'xy' ) );
+			frag.insertChildren( 1, new Text( 'foo' ) );
 
 			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 );
+			expect( frag.getMaxOffset() ).to.equal( 5 );
+			expect( frag.getChild( 0 ) ).to.have.property( 'data' ).that.equals( 'xy' );
+			expect( frag.getChild( 1 ) ).to.have.property( 'data' ).that.equals( 'foo' );
 		} );
 	} );
 
 	describe( 'appendChildren', () => {
 		it( 'should add children to the end of the element', () => {
-			let frag = new DocumentFragment( 'xy' );
-			frag.appendChildren( 'foo' );
+			let frag = new DocumentFragment( new Text( 'xy' ) );
+			frag.appendChildren( new Text( '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' );
+			expect( frag.getChildCount() ).to.equal( 2 );
+			expect( frag.getMaxOffset() ).to.equal( 5 );
+			expect( frag.getChild( 0 ) ).to.have.property( 'data' ).that.equals( 'xy' );
+			expect( frag.getChild( 1 ) ).to.have.property( 'data' ).that.equals( 'foo' );
 		} );
 	} );
 
 	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 );
+		it( 'should remove children from the element and return them as an array', () => {
+			let frag = new DocumentFragment( [ new Text( 'foobar' ), new Element( 'image' ) ] );
+			let removed = frag.removeChildren( 1, 1 );
 
-			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( frag.getChildCount() ).to.equal( 1 );
+			expect( frag.getMaxOffset() ).to.equal( 6 );
 
-			expect( removed ).to.be.instanceof( NodeList );
-			expect( removed.length ).to.equal( 3 );
+			expect( frag.getChild( 0 ) ).to.have.property( 'data' ).that.equals( 'foobar' );
 
-			expect( removed.get( 0 ).character ).to.equal( 'o' );
-			expect( removed.get( 1 ).character ).to.equal( 'b' );
-			expect( removed.get( 2 ).character ).to.equal( 'a' );
+			expect( removed.length ).to.equal( 1 );
+			expect( removed[ 0 ].name ).to.equal( 'image' );
 		} );
 
 		it( 'should remove one child when second parameter is not specified', () => {
-			let frag = new DocumentFragment( 'foo' );
-			let removed = frag.removeChildren( 2 );
+			let frag = new DocumentFragment( [ new Text( 'foo' ), new Element( 'image' ) ] );
+			let removed = frag.removeChildren( 0 );
 
-			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( frag.getChildCount() ).to.equal( 1 );
+			expect( frag.getMaxOffset() ).to.equal( 1 );
+			expect( frag.getChild( 0 ).name ).to.equal( 'image' );
 
-			expect( removed ).to.be.instanceof( NodeList );
 			expect( removed.length ).to.equal( 1 );
-
-			expect( removed.get( 0 ).character ).to.equal( 'o' );
+			expect( removed[ 0 ].data ).to.equal( 'foo' );
 		} );
 	} );
 
 	describe( 'getChildIndex', () => {
 		it( 'should return child index', () => {
-			let frag = new DocumentFragment( [ new Element( 'p' ), 'bar', new Element( 'h' ) ] );
+			let frag = new DocumentFragment( [ new Element( 'p' ), new Text( '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 );
+			let textBAR = frag.getChild( 1 );
+			let h = frag.getChild( 2 );
 
 			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 );
+			expect( frag.getChildIndex( textBAR ) ).to.equal( 1 );
+			expect( frag.getChildIndex( h ) ).to.equal( 2 );
+		} );
+	} );
+
+	describe( 'getChildStartOffset', () => {
+		it( 'should return child start offset', () => {
+			let frag = new DocumentFragment( [ new Element( 'p' ), new Text( 'bar' ), new Element( 'h' ) ] );
+
+			let p = frag.getChild( 0 );
+			let textBAR = frag.getChild( 1 );
+			let h = frag.getChild( 2 );
+
+			expect( frag.getChildStartOffset( p ) ).to.equal( 0 );
+			expect( frag.getChildStartOffset( textBAR ) ).to.equal( 1 );
+			expect( frag.getChildStartOffset( h ) ).to.equal( 4 );
+		} );
+
+		it( 'should return null if node is not a child of that document fragment', () => {
+			let frag = new DocumentFragment( [ new Element( 'p' ), new Text( 'bar' ), new Element( 'h' ) ] );
+
+			let p = new Element( 'p' );
+
+			expect( frag.getChildStartOffset( p ) ).to.equal( null );
 		} );
 	} );
 
 	describe( 'getChildCount', () => {
-		it( 'should return number of children', () => {
-			let frag = new DocumentFragment( 'bar' );
+		it( 'should return number of children nodes', () => {
+			let frag = new DocumentFragment( new Text( 'bar' ) );
 
-			expect( frag.getChildCount() ).to.equal( 3 );
+			expect( frag.getChildCount() ).to.equal( 1 );
+		} );
+	} );
+
+	describe( 'getMaxOffset', () => {
+		it( 'should return offset after the last children', () => {
+			let frag = new DocumentFragment( new Text( 'bar' ) );
+
+			expect( frag.getMaxOffset() ).to.equal( 3 );
+		} );
+	} );
+
+	describe( 'toJSON', () => {
+		it( 'should serialize empty document fragment', () => {
+			let frag = new DocumentFragment();
+
+			expect( jsonParseStringify( frag ) ).to.deep.equal( [] );
+		} );
+
+		it( 'should serialize document fragment with children', () => {
+			let img = new Element( 'img' );
+			let one = new Element( 'one' );
+			let two = new Element( 'two', null, [ new Text( 'ba' ), img, new Text( 'r' ) ] );
+			let three = new Element( 'three' );
+
+			let frag = new DocumentFragment( [ one, two, three ] );
+
+			expect( jsonParseStringify( frag ) ).to.deep.equal( [
+				{ name: 'one' },
+				{
+					name: 'two',
+					children: [
+						{ data: 'ba' },
+						{ name: 'img' },
+						{ data: 'r' }
+					]
+				},
+				{ name: 'three' }
+			] );
+		} );
+	} );
+
+	describe( 'fromJSON', () => {
+		it( 'should create document fragment without children', () => {
+			const frag = new DocumentFragment();
+
+			let serialized = jsonParseStringify( frag );
+			let deserialized = DocumentFragment.fromJSON( serialized );
+
+			expect( deserialized.isEmpty() ).to.be.true;
+		} );
+
+		it( 'should create element with children', () => {
+			const p = new Element( 'p' );
+			const foo = new Text( 'foo' );
+			const frag = new DocumentFragment( [ p, foo ] );
+
+			let serialized = jsonParseStringify( frag );
+			let deserialized = DocumentFragment.fromJSON( serialized );
+
+			expect( deserialized.getChildCount() ).to.equal( 2 );
+
+			expect( deserialized.getChild( 0 ).name ).to.equal( 'p' );
+			expect( deserialized.getChild( 0 ).parent ).to.equal( deserialized );
+
+			expect( deserialized.getChild( 1 ).data ).to.equal( 'foo' );
+			expect( deserialized.getChild( 1 ).parent ).to.equal( deserialized );
 		} );
 	} );
 } );

+ 140 - 171
packages/ckeditor5-engine/tests/model/element.js

@@ -6,236 +6,205 @@
 /* bender-tags: model */
 
 import Node from '/ckeditor5/engine/model/node.js';
-import NodeList from '/ckeditor5/engine/model/nodelist.js';
 import Element from '/ckeditor5/engine/model/element.js';
-import DocumentFragment from '/ckeditor5/engine/model/documentfragment.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';
 
 describe( 'Element', () => {
 	describe( 'constructor', () => {
-		it( 'should create element without attributes', () => {
+		it( 'should create empty element', () => {
 			let element = new Element( 'elem' );
-			let parent = new Element( 'parent', [], [ element ] );
 
 			expect( element ).to.be.an.instanceof( Node );
 			expect( element ).to.have.property( 'name' ).that.equals( 'elem' );
-			expect( element ).to.have.property( 'parent' ).that.equals( parent );
-			expect( element._attrs.size ).to.equal( 0 );
+
+			expect( count( element.getAttributes() ) ).to.equal( 0 );
+			expect( count( element.getChildren() ) ).to.equal( 0 );
 		} );
 
 		it( 'should create element with attributes', () => {
-			let attr = { foo: 'bar' };
+			let element = new Element( 'elem', { foo: 'bar' } );
 
-			let element = new Element( 'elem', [ attr ] );
-			let parent = new Element( 'parent', [], [ element ] );
-
-			expect( element ).to.be.an.instanceof( Node );
-			expect( element ).to.have.property( 'name' ).that.equals( 'elem' );
-			expect( element ).to.have.property( 'parent' ).that.equals( parent );
-			expect( element._attrs.size ).to.equal( 1 );
-			expect( element.getAttribute( attr.key ) ).to.equal( attr.value );
+			expect( count( element.getAttributes() ) ).to.equal( 1 );
+			expect( element.getAttribute( 'foo' ) ).to.equal( 'bar' );
 		} );
 
 		it( 'should create element with children', () => {
-			let element = new Element( 'elem', [], 'foo' );
+			let element = new Element( 'elem', [], new Text( 'foo' ) );
 
-			expect( element ).to.have.property( 'name' ).that.equals( 'elem' );
-			expect( element.getChildCount() ).to.equal( 3 );
-			expect( element.getChild( 0 ) ).to.have.property( 'character' ).that.equals( 'f' );
-			expect( element.getChild( 1 ) ).to.have.property( 'character' ).that.equals( 'o' );
-			expect( element.getChild( 2 ) ).to.have.property( 'character' ).that.equals( 'o' );
+			expect( element.getChildCount() ).to.equal( 1 );
+			expect( element.getMaxOffset() ).to.equal( 3 );
+			expect( element.getChild( 0 ).data ).to.equal( 'foo' );
 		} );
 	} );
 
-	describe( 'insertChildren', () => {
-		it( 'should add children to the element', () => {
-			let element = new Element( 'elem', [], [ 'xy' ] );
-			element.insertChildren( 1, 'foo' );
+	describe( 'clone', () => {
+		it( 'should return an element with same name, attributes and same instances of children if clone was not deep', () => {
+			let p = new Element( 'p' );
+			let foo = new Text( 'foo' );
 
-			expect( element ).to.have.property( 'name' ).that.equals( 'elem' );
-			expect( element.getChildCount() ).to.equal( 5 );
-			expect( element.getChild( 0 ) ).to.have.property( 'character' ).that.equals( 'x' );
-			expect( element.getChild( 1 ) ).to.have.property( 'character' ).that.equals( 'f' );
-			expect( element.getChild( 2 ) ).to.have.property( 'character' ).that.equals( 'o' );
-			expect( element.getChild( 3 ) ).to.have.property( 'character' ).that.equals( 'o' );
-			expect( element.getChild( 4 ) ).to.have.property( 'character' ).that.equals( 'y' );
+			let element = new Element( 'elem', { bold: true, italic: true }, [ p, foo ] );
+			let copy = element.clone();
+
+			expect( copy.name ).to.equal( 'elem' );
+			expect( Array.from( copy.getAttributes() ) ).to.deep.equal( [ [ 'bold', true ], [ 'italic', true ] ] );
+			expect( Array.from( copy.getChildren() ) ).to.deep.equal( [ p, foo ] );
 		} );
 
-		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 ] );
+		it( 'should clone children, if clone is deep', () => {
+			let p = new Element( 'p' );
+			let foo = new Text( 'foo' );
 
-			let element = new Element( 'div' );
+			let element = new Element( 'elem', { bold: true, italic: true }, [ p, foo ] );
+			let copy = element.clone( true );
 
-			element.insertChildren( 0, frag );
+			expect( copy.name ).to.equal( 'elem' );
+			expect( Array.from( copy.getAttributes() ) ).to.deep.equal( [ [ 'bold', true ], [ 'italic', true ] ] );
+			expect( copy.getChildCount() ).to.equal( 2 );
 
-			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 );
+			expect( copy.getChild( 0 ) ).not.to.equal( p );
+			expect( copy.getChild( 1 ) ).not.to.equal( foo );
+
+			expect( copy.getChild( 0 ).name ).to.equal( 'p' );
+			expect( copy.getChild( 1 ).data ).to.equal( 'foo' );
 		} );
 	} );
 
-	describe( 'appendChildren', () => {
-		it( 'should add children to the end of the element', () => {
-			let element = new Element( 'elem', [], [ 'xy' ] );
-			element.appendChildren( 'foo' );
+	describe( 'insertChildren', () => {
+		it( 'should add a child to the element', () => {
+			let element = new Element( 'elem', [], new Text( 'xy' ) );
+			element.insertChildren( 1, new Text( 'foo' ) );
 
-			expect( element ).to.have.property( 'name' ).that.equals( 'elem' );
-			expect( element.getChildCount() ).to.equal( 5 );
-			expect( element.getChild( 0 ) ).to.have.property( 'character' ).that.equals( 'x' );
-			expect( element.getChild( 1 ) ).to.have.property( 'character' ).that.equals( 'y' );
-			expect( element.getChild( 2 ) ).to.have.property( 'character' ).that.equals( 'f' );
-			expect( element.getChild( 3 ) ).to.have.property( 'character' ).that.equals( 'o' );
-			expect( element.getChild( 4 ) ).to.have.property( 'character' ).that.equals( 'o' );
+			expect( element.getChildCount() ).to.equal( 2 );
+			expect( element.getMaxOffset() ).to.equal( 5 );
+			expect( element.getChild( 0 ).data ).to.equal( 'xy' );
+			expect( element.getChild( 1 ).data ).to.equal( 'foo' );
 		} );
 
-		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 ] );
+		it( 'should add children to the element', () => {
+			let element = new Element( 'elem' );
+			element.insertChildren( 0, [ new Element( 'image' ), new Text( 'xy' ), new Element( 'list' ) ] );
 
-			let element = new Element( 'div' );
+			expect( element.getChildCount() ).to.equal( 3 );
+			expect( element.getMaxOffset() ).to.equal( 4 );
+			expect( element.getChild( 0 ).name ).to.equal( 'image' );
+			expect( element.getChild( 1 ).data ).to.equal( 'xy' );
+			expect( element.getChild( 2 ).name ).to.equal( 'list' );
+		} );
+	} );
 
-			element.appendChildren( frag );
+	describe( 'appendChildren', () => {
+		it( 'should use insertChildren to add children at the end of the element', () => {
+			let element = new Element( 'elem', [], new Text( 'xy' ) );
 
-			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 );
+			sinon.spy( element, 'insertChildren' );
+
+			let text = new Text( 'foo' );
+			element.appendChildren( text );
+
+			expect( element.insertChildren.calledWithExactly( 0, text ) );
 		} );
 	} );
 
 	describe( 'removeChildren', () => {
-		it( 'should remove children from the element and return them as a NodeList', () => {
-			let element = new Element( 'elem', [], [ 'foobar' ] );
-			let removed = element.removeChildren( 2, 3 );
+		it( 'should remove children from the element and return them as an array', () => {
+			let element = new Element( 'elem', [], [ new Text( 'foobar' ), new Element( 'image' ) ] );
+			let removed = element.removeChildren( 1, 1 );
 
-			expect( element.getChildCount() ).to.equal( 3 );
-			expect( element.getChild( 0 ) ).to.have.property( 'character' ).that.equals( 'f' );
-			expect( element.getChild( 1 ) ).to.have.property( 'character' ).that.equals( 'o' );
-			expect( element.getChild( 2 ) ).to.have.property( 'character' ).that.equals( 'r' );
+			expect( element.getChildCount() ).to.equal( 1 );
+			expect( element.getMaxOffset() ).to.equal( 6 );
 
-			expect( removed ).to.be.instanceof( NodeList );
-			expect( removed.length ).to.equal( 3 );
+			expect( element.getChild( 0 ).data ).to.equal( 'foobar' );
 
-			expect( removed.get( 0 ).character ).to.equal( 'o' );
-			expect( removed.get( 1 ).character ).to.equal( 'b' );
-			expect( removed.get( 2 ).character ).to.equal( 'a' );
+			expect( removed.length ).to.equal( 1 );
+			expect( removed[ 0 ].name ).to.equal( 'image' );
 		} );
 
 		it( 'should remove one child when second parameter is not specified', () => {
-			let element = new Element( 'elem', [], [ 'foo' ] );
-			let removed = element.removeChildren( 2 );
+			let element = new Element( 'element', [], [ new Text( 'foo' ), new Element( 'image' ) ] );
+			let removed = element.removeChildren( 0 );
 
-			expect( element.getChildCount() ).to.equal( 2 );
-			expect( element.getChild( 0 ) ).to.have.property( 'character' ).that.equals( 'f' );
-			expect( element.getChild( 1 ) ).to.have.property( 'character' ).that.equals( 'o' );
+			expect( element.getChildCount() ).to.equal( 1 );
+			expect( element.getMaxOffset() ).to.equal( 1 );
+			expect( element.getChild( 0 ).name ).to.equal( 'image' );
 
-			expect( removed ).to.be.instanceof( NodeList );
 			expect( removed.length ).to.equal( 1 );
-
-			expect( removed.get( 0 ).character ).to.equal( 'o' );
+			expect( removed[ 0 ].data ).to.equal( 'foo' );
 		} );
 	} );
 
 	describe( 'getChildIndex', () => {
 		it( 'should return child index', () => {
-			let element = new Element( 'elem', [], [ new Element( 'p' ), 'bar', new Element( 'h' ) ] );
+			let element = new Element( 'elem', [], [ new Element( 'p' ), new Text( 'bar' ), new Element( 'h' ) ] );
 			let p = element.getChild( 0 );
-			let b = element.getChild( 1 );
-			let a = element.getChild( 2 );
-			let r = element.getChild( 3 );
-			let h = element.getChild( 4 );
+			let bar = element.getChild( 1 );
+			let h = element.getChild( 2 );
 
 			expect( element.getChildIndex( p ) ).to.equal( 0 );
-			expect( element.getChildIndex( b ) ).to.equal( 1 );
-			expect( element.getChildIndex( a ) ).to.equal( 2 );
-			expect( element.getChildIndex( r ) ).to.equal( 3 );
-			expect( element.getChildIndex( h ) ).to.equal( 4 );
+			expect( element.getChildIndex( bar ) ).to.equal( 1 );
+			expect( element.getChildIndex( h ) ).to.equal( 2 );
 		} );
 	} );
 
-	describe( 'getChildCount', () => {
-		it( 'should return number of children', () => {
-			let element = new Element( 'elem', [], [ 'bar' ] );
+	describe( 'getChildStartOffset', () => {
+		it( 'should return child offset', () => {
+			let element = new Element( 'elem', [], [ new Element( 'p' ), new Text( 'bar' ), new Element( 'h' ) ] );
+			let p = element.getChild( 0 );
+			let bar = element.getChild( 1 );
+			let h = element.getChild( 2 );
 
-			expect( element.getChildCount() ).to.equal( 3 );
+			expect( element.getChildStartOffset( p ) ).to.equal( 0 );
+			expect( element.getChildStartOffset( bar ) ).to.equal( 1 );
+			expect( element.getChildStartOffset( h ) ).to.equal( 4 );
 		} );
 	} );
 
-	describe( 'attributes interface', () => {
-		let node;
+	describe( 'getChildCount', () => {
+		it( 'should return number of children', () => {
+			let element = new Element( 'elem', [], new Text( 'bar' ) );
 
-		beforeEach( () => {
-			node = new Element();
+			expect( element.getChildCount() ).to.equal( 1 );
 		} );
+	} );
 
-		describe( 'setAttribute', () => {
-			it( 'should set given attribute on the element', () => {
-				node.setAttribute( 'foo', 'bar' );
+	describe( 'getMaxOffset', () => {
+		it( 'should return offset number after the last child', () => {
+			let element = new Element( 'elem', [], [ new Element( 'p' ), new Text( 'bar' ), new Element( 'h' ) ] );
 
-				expect( node.getAttribute( 'foo' ) ).to.equal( 'bar' );
-			} );
+			expect( element.getMaxOffset() ).to.equal( 5 );
 		} );
+	} );
 
-		describe( 'setAttributesTo', () => {
-			it( 'should remove all attributes set on element and set the given ones', () => {
-				node.setAttribute( 'abc', 'xyz' );
-				node.setAttributesTo( { foo: 'bar' } );
-
-				expect( node.getAttribute( 'foo' ) ).to.equal( 'bar' );
-				expect( node.getAttribute( 'abc' ) ).to.be.undefined;
-			} );
+	describe( 'isEmpty', () => {
+		it( 'checks whether element has no children', () => {
+			expect( new Element( 'a' ).isEmpty() ).to.be.true;
+			expect( new Element( 'a', null, new Text( 'x' ) ).isEmpty() ).to.be.false;
 		} );
+	} );
 
-		describe( 'removeAttribute', () => {
-			it( 'should remove attribute set on the element and return true', () => {
-				node.setAttribute( 'foo', 'bar' );
-				let result = node.removeAttribute( 'foo' );
-
-				expect( node.getAttribute( 'foo' ) ).to.be.undefined;
-				expect( result ).to.be.true;
-			} );
-
-			it( 'should return false if element does not contain given attribute', () => {
-				let result = node.removeAttribute( 'foo' );
+	describe( 'offsetToIndex', () => {
+		let element;
 
-				expect( result ).to.be.false;
-			} );
+		beforeEach( () => {
+			element = new Element( 'elem', [], [ new Element( 'p' ), new Text( 'bar' ), new Element( 'h' ) ] );
 		} );
 
-		describe( 'clearAttributes', () => {
-			it( 'should remove all attributes from the element', () => {
-				node.setAttribute( 'foo', 'bar' );
-				node.setAttribute( 'abc', 'xyz' );
-
-				node.clearAttributes();
-
-				expect( node.getAttribute( 'foo' ) ).to.be.undefined;
-				expect( node.getAttribute( 'abc' ) ).to.be.undefined;
-			} );
+		it( 'should return index of a node that occupies given offset in this element', () => {
+			expect( element.offsetToIndex( 0 ) ).to.equal( 0 );
+			expect( element.offsetToIndex( 1 ) ).to.equal( 1 );
+			expect( element.offsetToIndex( 2 ) ).to.equal( 1 );
+			expect( element.offsetToIndex( 3 ) ).to.equal( 1 );
+			expect( element.offsetToIndex( 4 ) ).to.equal( 2 );
 		} );
-	} );
 
-	describe( 'isEmpty', () => {
-		it( 'checks whether element has no children', () => {
-			expect( new Element( 'a' ).isEmpty() ).to.be.true;
-			expect( new Element( 'a', null, 'x' ).isEmpty() ).to.be.false;
+		it( 'should return 0 if offset is too low', () => {
+			expect( element.offsetToIndex( -1 ) ).to.equal( 0 );
 		} );
-	} );
-
-	describe( 'getText()', () => {
-		it( 'returns all text nodes', () => {
-			const el = new Element( 'p', null, [
-				new Element( 'p', null, 'abc' ),
-				'def',
-				new Element( 'p', null, 'ghi' ),
-				'jkl'
-			] );
 
-			expect( el.getText() ).to.equal( 'abcdefghijkl' );
+		it( 'should return element\'s child count if offset is too high', () => {
+			expect( element.offsetToIndex( 5 ) ).to.equal( 3 );
+			expect( element.offsetToIndex( 33 ) ).to.equal( 3 );
 		} );
 	} );
 
@@ -258,28 +227,24 @@ describe( 'Element', () => {
 		it( 'should serialize node with children', () => {
 			let img = new Element( 'img' );
 			let one = new Element( 'one' );
-			let two = new Element( 'two', null, [ 'b', 'a', img, 'r' ] );
+			let two = new Element( 'two', null, [ new Text( 'ba' ), img, new Text( 'r' ) ] );
 			let three = new Element( 'three' );
 
 			let node = new Element( null, null, [ one, two, three ] );
 
 			expect( jsonParseStringify( node ) ).to.deep.equal( {
-				children: {
-					nodes: [
-						{ name: 'one' },
-						{
-							children: {
-								nodes: [
-									{ text: 'ba' },
-									{ name: 'img' },
-									{ text: 'r' }
-								]
-							},
-							name: 'two'
-						},
-						{ name: 'three' }
-					]
-				},
+				children: [
+					{ name: 'one' },
+					{
+						children: [
+							{ data: 'ba' },
+							{ name: 'img' },
+							{ data: 'r' }
+						],
+						name: 'two'
+					},
+					{ name: 'three' }
+				],
 				name: null
 			} );
 		} );
@@ -314,7 +279,8 @@ describe( 'Element', () => {
 
 		it( 'should create element with children', () => {
 			const p = new Element( 'p' );
-			const el = new Element( 'el', null, p );
+			const text = new Text( 'foo' );
+			const el = new Element( 'el', null, [ p, text ] );
 
 			let serialized = jsonParseStringify( el );
 
@@ -322,10 +288,13 @@ describe( 'Element', () => {
 
 			expect( deserialized.parent ).to.be.null;
 			expect( deserialized.name ).to.equal( 'el' );
-			expect( deserialized.getChildCount() ).to.equal( 1 );
+			expect( deserialized.getChildCount() ).to.equal( 2 );
 
 			expect( deserialized.getChild( 0 ).name ).to.equal( 'p' );
 			expect( deserialized.getChild( 0 ).parent ).to.equal( deserialized );
+
+			expect( deserialized.getChild( 1 ).data ).to.equal( 'foo' );
+			expect( deserialized.getChild( 1 ).parent ).to.equal( deserialized );
 		} );
 	} );
 } );

+ 4 - 3
packages/ckeditor5-engine/tests/model/liveposition.js

@@ -8,6 +8,7 @@
 import Document from '/ckeditor5/engine/model/document.js';
 import DocumentFragment from '/ckeditor5/engine/model/documentfragment.js';
 import Element from '/ckeditor5/engine/model/element.js';
+import Text from '/ckeditor5/engine/model/text.js';
 import Position from '/ckeditor5/engine/model/position.js';
 import LivePosition from '/ckeditor5/engine/model/liveposition.js';
 import Range from '/ckeditor5/engine/model/range.js';
@@ -20,10 +21,10 @@ describe( 'LivePosition', () => {
 		doc = new Document();
 		root = doc.createRoot();
 
-		li1 = new Element( 'li', [], 'abcdef' );
-		li2 = new Element( 'li', [], 'foobar' );
+		li1 = new Element( 'li', [], new Text( 'abcdef' ) );
+		li2 = new Element( 'li', [], new Text( 'foobar' ) );
 		ul = new Element( 'ul', [], [ li1, li2 ] );
-		p = new Element( 'p', [], 'qwerty' );
+		p = new Element( 'p', [], new Text( 'qwerty' ) );
 
 		root.insertChildren( 0, [ p, ul ] );
 	} );

+ 11 - 10
packages/ckeditor5-engine/tests/model/liverange.js

@@ -10,6 +10,7 @@ import Element from '/ckeditor5/engine/model/element.js';
 import Position from '/ckeditor5/engine/model/position.js';
 import LiveRange from '/ckeditor5/engine/model/liverange.js';
 import Range from '/ckeditor5/engine/model/range.js';
+import Text from '/ckeditor5/engine/model/text.js';
 
 describe( 'LiveRange', () => {
 	let doc, root, ul, p;
@@ -19,20 +20,20 @@ describe( 'LiveRange', () => {
 		root = doc.createRoot();
 
 		let lis = [
-			new Element( 'li', [], 'aaaaaaaaaa' ),
-			new Element( 'li', [], 'bbbbbbbbbb' ),
-			new Element( 'li', [], 'cccccccccc' ),
-			new Element( 'li', [], 'dddddddddd' ),
-			new Element( 'li', [], 'eeeeeeeeee' ),
-			new Element( 'li', [], 'ffffffffff' ),
-			new Element( 'li', [], 'gggggggggg' ),
-			new Element( 'li', [], 'hhhhhhhhhh' )
+			new Element( 'li', [], new Text( 'aaaaaaaaaa' ) ),
+			new Element( 'li', [], new Text( 'bbbbbbbbbb' ) ),
+			new Element( 'li', [], new Text( 'cccccccccc' ) ),
+			new Element( 'li', [], new Text( 'dddddddddd' ) ),
+			new Element( 'li', [], new Text( 'eeeeeeeeee' ) ),
+			new Element( 'li', [], new Text( 'ffffffffff' ) ),
+			new Element( 'li', [], new Text( 'gggggggggg' ) ),
+			new Element( 'li', [], new Text( 'hhhhhhhhhh' ) )
 		];
 
 		ul = new Element( 'ul', [], lis );
-		p = new Element( 'p', [], 'qwertyuiop' );
+		p = new Element( 'p', [], new Text( 'qwertyuiop' ) );
 
-		root.insertChildren( 0, [ ul, p, 'xyzxyz' ] );
+		root.insertChildren( 0, [ ul, p, new Text( 'xyzxyz' ) ] );
 	} );
 
 	it( 'should be an instance of Range', () => {

+ 13 - 10
packages/ckeditor5-engine/tests/model/liveselection.js

@@ -14,6 +14,7 @@ import LiveRange from '/ckeditor5/engine/model/liverange.js';
 import LiveSelection from '/ckeditor5/engine/model/liveselection.js';
 import InsertOperation from '/ckeditor5/engine/model/operation/insertoperation.js';
 import MoveOperation from '/ckeditor5/engine/model/operation/moveoperation.js';
+import count from '/ckeditor5/utils/count.js';
 import testUtils from '/tests/ckeditor5/_utils/utils.js';
 import { wrapInDelta } from '/tests/engine/model/_utils/utils.js';
 
@@ -34,11 +35,11 @@ describe( 'LiveSelection', () => {
 		root.appendChildren( [
 			new Element( 'p' ),
 			new Element( 'p' ),
-			new Element( 'p', [], 'foobar' ),
+			new Element( 'p', [], new Text( 'foobar' ) ),
 			new Element( 'p' ),
 			new Element( 'p' ),
 			new Element( 'p' ),
-			new Element( 'p', [], 'foobar' )
+			new Element( 'p', [], new Text( 'foobar' ) )
 		] );
 		selection = doc.selection;
 		doc.schema.registerItem( 'p', '$block' );
@@ -65,7 +66,7 @@ describe( 'LiveSelection', () => {
 		it( 'should be set to the beginning of the doc if there is no editable element', () => {
 			doc = new Document();
 			root = doc.createRoot();
-			root.insertChildren( 0, 'foobar' );
+			root.insertChildren( 0, new Text( 'foobar' ) );
 			selection = doc.selection;
 
 			const ranges = Array.from( selection.getRanges() );
@@ -74,8 +75,7 @@ describe( 'LiveSelection', () => {
 			expect( selection.anchor.isEqual( new Position( root, [ 0 ] ) ) ).to.be.true;
 			expect( selection.focus.isEqual( new Position( root, [ 0 ] ) ) ).to.be.true;
 			expect( selection ).to.have.property( 'isBackward', false );
-			expect( selection._attrs ).to.be.instanceof( Map );
-			expect( selection._attrs.size ).to.equal( 0 );
+			expect( count( selection.getAttributes() ) ).to.equal( 0 );
 		} );
 
 		it( 'should skip element when you can not put selection', () => {
@@ -83,7 +83,7 @@ describe( 'LiveSelection', () => {
 			root = doc.createRoot();
 			root.insertChildren( 0, [
 				new Element( 'img' ),
-				new Element( 'p', [], 'foobar' )
+				new Element( 'p', [], new Text( 'foobar' ) )
 			] );
 			doc.schema.registerItem( 'img' );
 			doc.schema.registerItem( 'p', '$block' );
@@ -95,8 +95,7 @@ describe( 'LiveSelection', () => {
 			expect( selection.anchor.isEqual( new Position( root, [ 1, 0 ] ) ) ).to.be.true;
 			expect( selection.focus.isEqual( new Position( root, [ 1, 0 ] ) ) ).to.be.true;
 			expect( selection ).to.have.property( 'isBackward', false );
-			expect( selection._attrs ).to.be.instanceof( Map );
-			expect( selection._attrs.size ).to.equal( 0 );
+			expect( count( selection.getAttributes() ) ).to.equal( 0 );
 		} );
 	} );
 
@@ -288,7 +287,11 @@ describe( 'LiveSelection', () => {
 		let spy;
 
 		beforeEach( () => {
-			root.insertChildren( 0, [ new Element( 'ul', [], 'abcdef' ), new Element( 'p', [], 'foobar' ), 'xyz' ] );
+			root.insertChildren( 0, [
+				new Element( 'ul', [], new Text( 'abcdef' ) ),
+				new Element( 'p', [], new Text( 'foobar' ) ),
+				new Text( 'xyz' )
+			] );
 
 			selection.addRange( new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 1, 4 ] ) ) );
 
@@ -431,7 +434,7 @@ describe( 'LiveSelection', () => {
 
 		beforeEach( () => {
 			root.insertChildren( 0, [
-				new Element( 'p', [], 'foobar' ),
+				new Element( 'p', [], new Text( 'foobar' ) ),
 				new Element( 'p', [], [] )
 			] );
 

+ 176 - 48
packages/ckeditor5-engine/tests/model/node.js

@@ -5,41 +5,35 @@
 
 /* bender-tags: model */
 
+import Document from '/ckeditor5/engine/model/document.js';
+import DocumentFragment from '/ckeditor5/engine/model/documentfragment.js';
+import Node from '/ckeditor5/engine/model/node.js';
 import Element from '/ckeditor5/engine/model/element.js';
+import Text from '/ckeditor5/engine/model/text.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+import count from '/ckeditor5/utils/count.js';
 
 describe( 'Node', () => {
-	let root;
+	let doc, root, node;
 	let one, two, three;
-	let charB, charA, charR, img;
+	let textBA, textR, img;
 
 	before( () => {
-		img = new Element( 'img' );
+		node = new Node();
 
 		one = new Element( 'one' );
-		two = new Element( 'two', null, [ 'b', 'a', img, 'r' ] );
-		charB = two.getChild( 0 );
-		charA = two.getChild( 1 );
-		charR = two.getChild( 3 );
+		two = new Element( 'two', null, [ new Text( 'ba' ), new Element( 'img' ), new Text( 'r' ) ] );
+		textBA = two.getChild( 0 );
+		img = two.getChild( 1 );
+		textR = two.getChild( 2 );
 		three = new Element( 'three' );
 
-		root = new Element( null, null, [ one, two, three ] );
+		doc = new Document();
+		root = doc.createRoot();
+		root.appendChildren( [ one, two, three ] );
 	} );
 
 	describe( 'should have a correct property', () => {
-		it( 'depth', () => {
-			expect( root ).to.have.property( 'depth' ).that.equals( 0 );
-
-			expect( one ).to.have.property( 'depth' ).that.equals( 1 );
-			expect( two ).to.have.property( 'depth' ).that.equals( 1 );
-			expect( three ).to.have.property( 'depth' ).that.equals( 1 );
-
-			expect( charB ).to.have.property( 'depth' ).that.equals( 2 );
-			expect( charA ).to.have.property( 'depth' ).that.equals( 2 );
-			expect( img ).to.have.property( 'depth' ).that.equals( 2 );
-			expect( charR ).to.have.property( 'depth' ).that.equals( 2 );
-		} );
-
 		it( 'root', () => {
 			expect( root ).to.have.property( 'root' ).that.equals( root );
 
@@ -47,7 +41,11 @@ describe( 'Node', () => {
 			expect( two ).to.have.property( 'root' ).that.equals( root );
 			expect( three ).to.have.property( 'root' ).that.equals( root );
 
+			expect( textBA ).to.have.property( 'root' ).that.equals( root );
 			expect( img ).to.have.property( 'root' ).that.equals( root );
+			expect( textR ).to.have.property( 'root' ).that.equals( root );
+
+			expect( node ).to.have.property( 'root' ).that.equals( node );
 		} );
 
 		it( 'nextSibling', () => {
@@ -57,39 +55,56 @@ describe( 'Node', () => {
 			expect( two ).to.have.property( 'nextSibling' ).that.equals( three );
 			expect( three ).to.have.property( 'nextSibling' ).that.is.null;
 
-			expect( charB ).to.have.property( 'nextSibling' ).that.deep.equals( charA );
-			expect( charA ).to.have.property( 'nextSibling' ).that.deep.equals( img );
-			expect( img ).to.have.property( 'nextSibling' ).that.deep.equals( charR );
-			expect( charR ).to.have.property( 'nextSibling' ).that.is.null;
+			expect( textBA ).to.have.property( 'nextSibling' ).that.deep.equals( img );
+			expect( img ).to.have.property( 'nextSibling' ).that.deep.equals( textR );
+			expect( textR ).to.have.property( 'nextSibling' ).that.is.null;
+
+			expect( node ).to.have.property( 'nextSibling' ).that.is.null;
 		} );
 
 		it( 'previousSibling', () => {
-			expect( root ).to.have.property( 'previousSibling' ).that.is.expect;
+			expect( root ).to.have.property( 'previousSibling' ).that.is.null;
 
 			expect( one ).to.have.property( 'previousSibling' ).that.is.null;
 			expect( two ).to.have.property( 'previousSibling' ).that.equals( one );
 			expect( three ).to.have.property( 'previousSibling' ).that.equals( two );
 
-			expect( charB ).to.have.property( 'previousSibling' ).that.is.null;
-			expect( charA ).to.have.property( 'previousSibling' ).that.deep.equals( charB );
-			expect( img ).to.have.property( 'previousSibling' ).that.deep.equals( charA );
-			expect( charR ).to.have.property( 'previousSibling' ).that.deep.equals( img );
+			expect( textBA ).to.have.property( 'previousSibling' ).that.is.null;
+			expect( img ).to.have.property( 'previousSibling' ).that.deep.equals( textBA );
+			expect( textR ).to.have.property( 'previousSibling' ).that.deep.equals( img );
+
+			expect( node ).to.have.property( 'previousSibling' ).that.is.null;
+		} );
+
+		it( 'document', () => {
+			expect( root ).to.have.property( 'document' ).that.equals( doc );
+
+			expect( one ).to.have.property( 'document' ).that.equals( doc );
+			expect( two ).to.have.property( 'document' ).that.equals( doc );
+			expect( three ).to.have.property( 'document' ).that.equals( doc );
+
+			expect( textBA ).to.have.property( 'document' ).that.equals( doc );
+			expect( img ).to.have.property( 'document' ).that.equals( doc );
+			expect( textR ).to.have.property( 'document' ).that.equals( doc );
+
+			expect( node ).to.have.property( 'document' ).that.is.null;
+
+			// DocumentFragment does not have document property, so node's document property should be null.
+			let docFrag = new DocumentFragment();
+			docFrag.appendChildren( node );
+			expect( node ).to.have.property( 'document' ).that.is.null;
 		} );
 	} );
 
 	describe( 'constructor', () => {
 		it( 'should create empty attribute list if no parameters were passed', () => {
-			let foo = new Element( 'foo' );
-
-			expect( foo._attrs ).to.be.instanceof( Map );
-			expect( foo._attrs.size ).to.equal( 0 );
+			expect( count( node.getAttributes() ) ).to.equal( 0 );
 		} );
 
 		it( 'should initialize attribute list with passed attributes', () => {
-			let attrs = { foo: true, bar: false };
-			let foo = new Element( 'foo', attrs );
+			let foo = new Node( { foo: true, bar: false } );
 
-			expect( foo._attrs.size ).to.equal( 2 );
+			expect( count( foo.getAttributes() ) ).to.equal( 2 );
 			expect( foo.getAttribute( 'foo' ) ).to.equal( true );
 			expect( foo.getAttribute( 'bar' ) ).to.equal( false );
 		} );
@@ -105,25 +120,92 @@ describe( 'Node', () => {
 			expect( two.getIndex() ).to.equal( 1 );
 			expect( three.getIndex() ).to.equal( 2 );
 
-			expect( charB.getIndex() ).to.equal( 0 );
-			expect( img.getIndex() ).to.equal( 2 );
-			expect( charR.getIndex() ).to.equal( 3 );
+			expect( textBA.getIndex() ).to.equal( 0 );
+			expect( img.getIndex() ).to.equal( 1 );
+			expect( textR.getIndex() ).to.equal( 2 );
+		} );
+
+		it( 'should throw an error if parent does not contain element', () => {
+			node.parent = new Element( 'parent' );
+
+			expect(
+				() => {
+					node.getIndex();
+				}
+			).to.throw( CKEditorError, /node-not-found-in-parent/ );
 		} );
+	} );
+
+	describe( 'clone', () => {
+		it( 'should return a copy of cloned node', () => {
+			let node = new Node( { foo: 'bar' } );
+			let copy = node.clone();
+
+			expect( copy ).not.to.equal( node );
+			expect( Array.from( copy.getAttributes() ) ).to.deep.equal( Array.from( node.getAttributes() ) );
+		} );
+	} );
 
-		it( 'should throw an error if parent does not contains element', () => {
-			let e = new Element( 'e' );
-			let bar = new Element( 'bar', [], [] );
+	describe( 'remove', () => {
+		it( 'should remove node from it\'s parent', () => {
+			let element = new Element( 'p' );
+			element.appendChildren( node );
 
-			e.parent = bar;
+			node.remove();
+
+			expect( element.getChildCount() ).to.equal( 0 );
+			expect( node.parent ).to.be.null;
+		} );
+
+		it( 'should throw if node does not have a parent', () => {
+			expect( () => {
+				node.remove();
+			} ).to.throw;
+		} );
+	} );
+
+	describe( 'startOffset', () => {
+		it( 'should return null if the parent is null', () => {
+			expect( root.startOffset ).to.be.null;
+		} );
+
+		it( 'should return offset in the parent', () => {
+			expect( one.startOffset ).to.equal( 0 );
+			expect( two.startOffset ).to.equal( 1 );
+			expect( three.startOffset ).to.equal( 2 );
+
+			expect( textBA.startOffset ).to.equal( 0 );
+			expect( img.startOffset ).to.equal( 2 );
+			expect( textR.startOffset ).to.equal( 3 );
+		} );
+
+		it( 'should throw an error if parent does not contain element', () => {
+			node.parent = new Element( 'parent' );
 
 			expect(
 				() => {
-					e.getIndex();
+					node.startOffset;
 				}
 			).to.throw( CKEditorError, /node-not-found-in-parent/ );
 		} );
 	} );
 
+	describe( 'endOffset', () => {
+		it( 'should return null if the parent is null', () => {
+			expect( root.endOffset ).to.be.null;
+		} );
+
+		it( 'should return offset at which the node ends', () => {
+			expect( one.endOffset ).to.equal( 1 );
+			expect( two.endOffset ).to.equal( 2 );
+			expect( three.endOffset ).to.equal( 3 );
+
+			expect( textBA.endOffset ).to.equal( 2 );
+			expect( img.endOffset ).to.equal( 3 );
+			expect( textR.endOffset ).to.equal( 4 );
+		} );
+	} );
+
 	describe( 'getPath', () => {
 		it( 'should return proper path', () => {
 			expect( root.getPath() ).to.deep.equal( [] );
@@ -132,14 +214,14 @@ describe( 'Node', () => {
 			expect( two.getPath() ).to.deep.equal( [ 1 ] );
 			expect( three.getPath() ).to.deep.equal( [ 2 ] );
 
-			expect( charB.getPath() ).to.deep.equal( [ 1, 0 ] );
+			expect( textBA.getPath() ).to.deep.equal( [ 1, 0 ] );
 			expect( img.getPath() ).to.deep.equal( [ 1, 2 ] );
-			expect( charR.getPath() ).to.deep.equal( [ 1, 3 ] );
+			expect( textR.getPath() ).to.deep.equal( [ 1, 3 ] );
 		} );
 	} );
 
 	describe( 'attributes interface', () => {
-		let node = new Element( 'p', { foo: 'bar' } );
+		let node = new Node( { foo: 'bar' } );
 
 		describe( 'hasAttribute', () => {
 			it( 'should return true if element contains attribute with given key', () => {
@@ -166,5 +248,51 @@ describe( 'Node', () => {
 				expect( Array.from( node.getAttributes() ) ).to.deep.equal( [ [ 'foo', 'bar' ] ] );
 			} );
 		} );
+
+		describe( 'setAttribute', () => {
+			it( 'should set given attribute on the element', () => {
+				node.setAttribute( 'foo', 'bar' );
+
+				expect( node.getAttribute( 'foo' ) ).to.equal( 'bar' );
+			} );
+		} );
+
+		describe( 'setAttributesTo', () => {
+			it( 'should remove all attributes set on element and set the given ones', () => {
+				node.setAttribute( 'abc', 'xyz' );
+				node.setAttributesTo( { foo: 'bar' } );
+
+				expect( node.getAttribute( 'foo' ) ).to.equal( 'bar' );
+				expect( node.getAttribute( 'abc' ) ).to.be.undefined;
+			} );
+		} );
+
+		describe( 'removeAttribute', () => {
+			it( 'should remove attribute set on the element and return true', () => {
+				node.setAttribute( 'foo', 'bar' );
+				let result = node.removeAttribute( 'foo' );
+
+				expect( node.getAttribute( 'foo' ) ).to.be.undefined;
+				expect( result ).to.be.true;
+			} );
+
+			it( 'should return false if element does not contain given attribute', () => {
+				let result = node.removeAttribute( 'foo' );
+
+				expect( result ).to.be.false;
+			} );
+		} );
+
+		describe( 'clearAttributes', () => {
+			it( 'should remove all attributes from the element', () => {
+				node.setAttribute( 'foo', 'bar' );
+				node.setAttribute( 'abc', 'xyz' );
+
+				node.clearAttributes();
+
+				expect( node.getAttribute( 'foo' ) ).to.be.undefined;
+				expect( node.getAttribute( 'abc' ) ).to.be.undefined;
+			} );
+		} );
 	} );
 } );

+ 123 - 406
packages/ckeditor5-engine/tests/model/nodelist.js

@@ -6,479 +6,196 @@
 /* bender-tags: model */
 
 import NodeList from '/ckeditor5/engine/model/nodelist.js';
-import DocumentFragment from '/ckeditor5/engine/model/documentfragment.js';
 import Element from '/ckeditor5/engine/model/element.js';
 import Text from '/ckeditor5/engine/model/text.js';
-import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 import { jsonParseStringify } from '/tests/engine/model/_utils/utils.js';
+import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 
 describe( 'NodeList', () => {
-	describe( 'constructor', () => {
-		it( 'should add elements to the node list', () => {
-			let p = new Element( 'p' );
-			let nodeList = new NodeList( p );
-
-			expect( nodeList.length ).to.equal( 1 );
-			expect( nodeList.get( 0 ) ).to.equal( p );
-		} );
-
-		it( 'should change string into a set of nodes', () => {
-			let nodeList = new NodeList( 'foo' );
-
-			expect( nodeList.length ).to.equal( 3 );
-			expect( nodeList.get( 0 ).character ).to.equal( 'f' );
-			expect( nodeList.get( 1 ).character ).to.equal( 'o' );
-			expect( nodeList.get( 2 ).character ).to.equal( 'o' );
-		} );
-
-		it( 'should change node into a set of nodes', () => {
-			let nodeList = new NodeList( new Text( 'xy' ) );
-
-			expect( nodeList.length ).to.equal( 2 );
-			expect( nodeList.get( 0 ).character ).to.equal( 'x' );
-			expect( nodeList.get( 1 ).character ).to.equal( 'y' );
-		} );
-
-		it( 'should change text with attribute into a set of nodes', () => {
-			let attr = { bold: true };
-			let nodeList = new NodeList( new Text( 'foo', [ attr ] ) );
-
-			expect( nodeList.length ).to.equal( 3 );
-			expect( nodeList.get( 0 ).character ).to.equal( 'f' );
-			expect( nodeList.get( 0 ).getAttribute( attr.key ) ).to.equal( attr.value );
-			expect( nodeList.get( 1 ).character ).to.equal( 'o' );
-			expect( nodeList.get( 1 ).getAttribute( attr.key ) ).to.equal( attr.value );
-			expect( nodeList.get( 2 ).character ).to.equal( 'o' );
-			expect( nodeList.get( 2 ).getAttribute( attr.key ) ).to.equal( attr.value );
-		} );
+	let nodes, p, foo, img;
 
-		it( 'should change array of characters into a set of nodes', () => {
-			let char = new Element( 'p', [], 'y' ).getChild( 0 );
-			let nodeList = new NodeList( [ 'foo', new Text( 'x' ), char, 'bar' ] );
-
-			expect( nodeList.length ).to.equal( 8 );
-			expect( nodeList.get( 0 ).character ).to.equal( 'f' );
-			expect( nodeList.get( 1 ).character ).to.equal( 'o' );
-			expect( nodeList.get( 2 ).character ).to.equal( 'o' );
-			expect( nodeList.get( 3 ).character ).to.equal( 'x' );
-			expect( nodeList.get( 4 ).character ).to.equal( 'y' );
-			expect( nodeList.get( 5 ).character ).to.equal( 'b' );
-			expect( nodeList.get( 6 ).character ).to.equal( 'a' );
-			expect( nodeList.get( 7 ).character ).to.equal( 'r' );
-		} );
-
-		it( 'should omit empty strings / texts', () => {
-			let nodeList = new NodeList( [ 'fo', '', 'ob', new Text( '', { foo: true } ), 'ar' ] );
-
-			expect( nodeList.length ).to.equal( 6 );
-			expect( nodeList.get( 0 ).character ).to.equal( 'f' );
-			expect( nodeList.get( 1 ).character ).to.equal( 'o' );
-			expect( nodeList.get( 2 ).character ).to.equal( 'o' );
-			expect( nodeList.get( 3 ).character ).to.equal( 'b' );
-			expect( nodeList.get( 4 ).character ).to.equal( 'a' );
-			expect( nodeList.get( 5 ).character ).to.equal( 'r' );
-
-			expect( nodeList.get( 0 )._attrs.size ).to.equal( 0 );
-			expect( nodeList.get( 1 )._attrs.size ).to.equal( 0 );
-			expect( nodeList.get( 2 )._attrs.size ).to.equal( 0 );
-			expect( nodeList.get( 3 )._attrs.size ).to.equal( 0 );
-			expect( nodeList.get( 4 )._attrs.size ).to.equal( 0 );
-			expect( nodeList.get( 5 )._attrs.size ).to.equal( 0 );
-		} );
-
-		it( 'should merge strings and text objects if possible', () => {
-			let attr = { foo: 'bar' };
-			let nodeList = new NodeList( [ 'fo', new Text( 'o' ), new Text( 'x', [ attr ] ), new Text( 'y', [ attr ] ), 'bar' ] );
-
-			expect( nodeList.length ).to.equal( 8 );
-			expect( nodeList.get( 0 ).character ).to.equal( 'f' );
-			expect( nodeList.get( 1 ).character ).to.equal( 'o' );
-			expect( nodeList.get( 2 ).character ).to.equal( 'o' );
-			expect( nodeList.get( 3 ).character ).to.equal( 'x' );
-			expect( nodeList.get( 4 ).character ).to.equal( 'y' );
-			expect( nodeList.get( 5 ).character ).to.equal( 'b' );
-			expect( nodeList.get( 6 ).character ).to.equal( 'a' );
-			expect( nodeList.get( 7 ).character ).to.equal( 'r' );
-
-			expect( nodeList._nodes.length ).to.equal( 3 );
-			expect( nodeList._nodes[ 0 ].text ).to.equal( 'foo' );
-			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 );
-		} );
-
-		it( 'should accept DocumentFragment as one of items in input array', () => {
-			let p1 = new Element( 'p' );
-			let p2 = new Element( 'p' );
-			let p3 = new Element( 'p' );
-			let frag = new DocumentFragment( [ p1, p2 ] );
-
-			let nodeList = new NodeList( [ frag, p3 ] );
-
-			expect( nodeList.length ).to.equal( 3 );
-			expect( nodeList.get( 0 ) ).to.equal( p1 );
-			expect( nodeList.get( 1 ) ).to.equal( p2 );
-			expect( nodeList.get( 2 ) ).to.equal( p3 );
-		} );
+	beforeEach( () => {
+		p = new Element( 'p' );
+		foo = new Text( 'foo' );
+		img = new Element( 'image' );
+		nodes = new NodeList( [ p, foo, img ] );
 	} );
 
-	describe( 'insert', () => {
-		it( 'should insert one node list into another', () => {
-			let outerList = new NodeList( 'foo' );
-			let innerList = new NodeList( 'xxx' );
-
-			outerList.insert( 2, innerList );
-
-			expect( outerList.length ).to.equal( 6 );
-			expect( outerList.get( 0 ).character ).to.equal( 'f' );
-			expect( outerList.get( 1 ).character ).to.equal( 'o' );
-			expect( outerList.get( 2 ).character ).to.equal( 'x' );
-			expect( outerList.get( 3 ).character ).to.equal( 'x' );
-			expect( outerList.get( 4 ).character ).to.equal( 'x' );
-			expect( outerList.get( 5 ).character ).to.equal( 'o' );
+	describe( 'constructor', () => {
+		it( 'should create empty node list', () => {
+			expect( new NodeList().length ).to.equal( 0 );
 		} );
 
-		it( 'should merge inserted text objects if possible', () => {
-			let attr = { foo: 'bar' };
-			let outerList = new NodeList( [ 'foo', new Text( 'bar', [ attr ] ) ] );
-			let innerList = new NodeList( [ 'x', new Text( 'y', [ attr ] ) ] );
-
-			outerList.insert( 3, innerList );
-
-			expect( outerList._nodes.length ).to.equal( 2 );
-			expect( outerList._nodes[ 0 ].text ).to.equal( 'foox' );
-			expect( outerList._nodes[ 1 ].text ).to.equal( 'ybar' );
+		it( 'should create node list with passed nodes', () => {
+			expect( nodes.length ).to.equal( 3 );
+			expect( nodes.getNode( 0 ) ).to.equal( p );
+			expect( nodes.getNode( 1 ) ).to.equal( foo );
+			expect( nodes.getNode( 2 ) ).to.equal( img );
 		} );
 	} );
 
-	describe( 'remove', () => {
-		it( 'should remove part of the node list and return removed nodes as another node list', () => {
-			let nodeList = new NodeList( 'foobar' );
-
-			let removed = nodeList.remove( 2, 3 );
-
-			expect( nodeList.length ).to.equal( 3 );
-			expect( nodeList.get( 0 ).character ).to.equal( 'f' );
-			expect( nodeList.get( 1 ).character ).to.equal( 'o' );
-			expect( nodeList.get( 2 ).character ).to.equal( '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 merge text objects left in node list possible', () => {
-			let attr = { foo: 'bar' };
-			let nodeList = new NodeList( [ 'foo', new Text( 'xxx', [ attr ] ), 'bar' ] );
-
-			nodeList.remove( 2, 5 );
-
-			expect( nodeList._nodes.length ).to.equal( 1 );
-			expect( nodeList._nodes[ 0 ].text ).to.equal( 'foar' );
-		} );
-
-		it( 'should return empty node list and do nothing if node list removed from is also empty', () => {
-			let nodeList = new NodeList();
-			let result = nodeList.remove( 2, 3 );
-
-			expect( result.length ).to.equal( 0 );
+	describe( 'iterator', () => {
+		it( 'should iterate over all nodes from node list', () => {
+			expect( Array.from( nodes ) ).to.deep.equal( [ p, foo, img ] );
 		} );
 	} );
 
-	describe( 'indexOf', () => {
-		let nodeList, p;
-
-		beforeEach( () => {
-			p = new Element( 'p' );
-			nodeList = new NodeList( [ 'abc', p, 'def' ] );
-		} );
-
-		it( 'should return index of specified element', () => {
-			let index = nodeList.indexOf( p );
-
-			expect( index ).to.equal( 3 );
-		} );
-
-		it( 'should return index of specified character', () => {
-			let char = nodeList.get( 5 );
-			let index = nodeList.indexOf( char );
-
-			expect( index ).to.equal( 5 );
-		} );
-
-		it( 'should return -1 if specified element is not a part of a node list', () => {
-			expect( nodeList.indexOf( new Element( 'p' ) ) ).to.equal( -1 );
-		} );
-
-		it( 'should return -1 if specified character is not a part of a node list', () => {
-			let div = new Element( 'div', [], 'a' );
-			let char = div.getChild( 0 );
-
-			expect( nodeList.indexOf( char ) ).to.equal( -1 );
+	describe( 'totalOffset', () => {
+		it( 'should be equal to the sum of offsetSize of all nodes in node list', () => {
+			expect( nodes.totalOffset ).to.equal( 5 );
 		} );
 	} );
 
-	describe( 'iterator', () => {
-		it( 'should iterate over all elements in the collection', () => {
-			let characters = 'foo';
-			let nodeList = new NodeList( characters );
-			let i = 0;
-
-			for ( let node of nodeList ) {
-				expect( node.character ).to.equal( characters[ i ] );
-				i++;
-			}
-
-			expect( i ).to.equal( 3 );
+	describe( 'getNode', () => {
+		it( 'should return null for wrong index', () => {
+			expect( nodes.getNode( -1 ) ).to.be.null;
+			expect( nodes.getNode( 3 ) ).to.be.null;
 		} );
 	} );
 
-	describe( 'setAttribute', () => {
-		it( 'should change attribute for multiple items in node list but not for their children', () => {
-			let p = new Element( 'p', [], 'x' );
-			let div = new Element( 'div' );
-
-			let nodeList = new NodeList( [ p, 'foo', div, 'bar' ] );
-
-			nodeList.setAttribute( 0, 6, 'a', 'true' );
-
-			// Attribute set.
-			expect( p.hasAttribute( 'a' ) ).to.be.true;
-			expect( nodeList.get( 1 ).hasAttribute( 'a' ) ).to.be.true;
-			expect( nodeList.get( 2 ).hasAttribute( 'a' ) ).to.be.true;
-			expect( nodeList.get( 3 ).hasAttribute( 'a' ) ).to.be.true;
-			expect( div.hasAttribute( 'a' ) ).to.be.true;
-			expect( nodeList.get( 5 ).hasAttribute( 'a' ) ).to.be.true;
-			expect( nodeList.get( 6 ).hasAttribute( 'a' ) ).to.be.false;
-			expect( nodeList.get( 7 ).hasAttribute( 'a' ) ).to.be.false;
-
-			// Attribute not set for children.
-			expect( p.getChild( 0 ).hasAttribute( 'a' ) ).to.be.false;
+	describe( 'getNodeIndex', () => {
+		it( 'should return an index at which given node is stored', () => {
+			expect( nodes.getNodeIndex( p ) ).to.equal( 0 );
+			expect( nodes.getNodeIndex( foo ) ).to.equal( 1 );
+			expect( nodes.getNodeIndex( img ) ).to.equal( 2 );
 		} );
 
-		it( 'should remove attribute if no new attribute has been passed', () => {
-			let p = new Element( 'p', { a: true } );
-			let text = new Text( 'foobar', { a: true } );
-			let nodeList = new NodeList( [ p, text ] );
-
-			nodeList.setAttribute( 0, 4, 'a', null );
-
-			expect( p.hasAttribute( 'a' ) ).to.be.false;
-			expect( nodeList.get( 1 ).hasAttribute( 'a' ) ).to.be.false;
-			expect( nodeList.get( 2 ).hasAttribute( 'a' ) ).to.be.false;
-			expect( nodeList.get( 3 ).hasAttribute( 'a' ) ).to.be.false;
-			expect( nodeList.get( 4 ).hasAttribute( 'a' ) ).to.be.true;
-			expect( nodeList.get( 5 ).hasAttribute( 'a' ) ).to.be.true;
-
-			expect( nodeList._nodes.length ).to.equal( 3 );
-		} );
-
-		it( 'should throw if wrong index or number is passed', () => {
-			let attr = { a: true };
-			let text = new Text( 'foo', [ attr ] );
-			let nodeList = new NodeList( text );
-
-			expect( () => {
-				nodeList.setAttribute( -1, 2, attr.key, null );
-			} ).to.throw( CKEditorError, /nodelist-setattribute-out-of-bounds/ );
-
-			expect( () => {
-				nodeList.setAttribute( 2, 2, attr.key, null );
-			} ).to.throw( CKEditorError, /nodelist-setattribute-out-of-bounds/ );
+		it( 'should return null if node is not in the node list', () => {
+			expect( nodes.getNodeIndex( new Element( 'p' ) ) ).to.be.null;
 		} );
 	} );
 
-	describe( '_splitNodeAt', () => {
-		it( 'should split text object into two text objects', () => {
-			let nodeList = new NodeList( 'abcd' );
-			nodeList._splitNodeAt( 2 );
-
-			expect( nodeList._nodes.length ).to.equal( 2 );
-			expect( nodeList._nodes[ 0 ].text ).to.equal( 'ab' );
-			expect( nodeList._nodes[ 1 ].text ).to.equal( 'cd' );
+	describe( 'getNodeStartOffset', () => {
+		it( 'should return offset at which given node starts', () => {
+			expect( nodes.getNodeStartOffset( p ) ).to.equal( 0 );
+			expect( nodes.getNodeStartOffset( foo ) ).to.equal( 1 );
+			expect( nodes.getNodeStartOffset( img ) ).to.equal( 4 );
 		} );
 
-		it( 'should do nothing if node before and after index are different', () => {
-			let nodeList = new NodeList( [ new Text( 'ab', { foo: true } ), 'cd' ] );
-			nodeList._splitNodeAt( 2 );
-
-			expect( nodeList._nodes.length ).to.equal( 2 );
-			expect( nodeList._nodes[ 0 ].text ).to.equal( 'ab' );
-			expect( nodeList._nodes[ 1 ].text ).to.equal( 'cd' );
+		it( 'should return null if node is not in the node list', () => {
+			expect( nodes.getNodeStartOffset( new Element( 'p' ) ) ).to.be.null;
 		} );
 	} );
 
-	describe( '_mergeNodeAt', () => {
-		it( 'should merge two text object if they have same attributes', () => {
-			let attr = { foo: true };
-			let nodeList = new NodeList( [ 'ab', new Text( 'cd', [ attr ] ) ] );
-
-			expect( nodeList._nodes.length ).to.equal( 2 );
-
-			nodeList._nodes[ 1 ]._attrs.delete( attr.key );
-			nodeList._mergeNodeAt( 2 );
-
-			expect( nodeList._nodes.length ).to.equal( 1 );
-			expect( nodeList._nodes[ 0 ].text ).to.equal( 'abcd' );
+	describe( 'indexToOffset', () => {
+		it( 'should return starting offset of a node stored at given index', () => {
+			expect( nodes.indexToOffset( 0 ) ).to.equal( 0 );
+			expect( nodes.indexToOffset( 1 ) ).to.equal( 1 );
+			expect( nodes.indexToOffset( 2 ) ).to.equal( 4 );
 		} );
 
-		it( 'should do nothing if text objects has different attributes', () => {
-			let nodeList = new NodeList( [ new Text( 'ab', { foo: true } ), 'cd' ] );
-
-			nodeList._mergeNodeAt( 2 );
-
-			expect( nodeList._nodes.length ).to.equal( 2 );
-			expect( nodeList._nodes[ 0 ].text ).to.equal( 'ab' );
-			expect( nodeList._nodes[ 1 ].text ).to.equal( 'cd' );
+		it( 'should return 0 if given index was lower than 0', () => {
+			expect( nodes.indexToOffset( -1 ) ).to.equal( 0 );
 		} );
-	} );
 
-	describe( '_getCharIndex', () => {
-		it( 'should return offset of character at given index from the beginning of the NodeListText containing that character', () => {
-			let nodeList = new NodeList( [ new Text( 'ab', { foo: true } ), 'cd' ] );
-			let charIndexC = nodeList._getCharIndex( 2 );
-			let charIndexD = nodeList._getCharIndex( 3 );
-
-			expect( charIndexC ).to.equal( 0 );
-			expect( charIndexD ).to.equal( 1 );
+		it( 'should return totalOffset if given index was too high', () => {
+			expect( nodes.indexToOffset( 3 ) ).to.equal( 5 );
+			expect( nodes.indexToOffset( 99 ) ).to.equal( 5 );
 		} );
 	} );
 
-	describe( 'toJSON', () => {
-		it( 'should return serialized empty object', () => {
-			let nodeList = new NodeList();
-
-			expect( jsonParseStringify( nodeList ) ).to.deep.equal( {} );
+	describe( 'offsetToIndex', () => {
+		it( 'should return index of a node that occupies given offset', () => {
+			expect( nodes.offsetToIndex( 0 ) ).to.equal( 0 );
+			expect( nodes.offsetToIndex( 1 ) ).to.equal( 1 );
+			expect( nodes.offsetToIndex( 2 ) ).to.equal( 1 );
+			expect( nodes.offsetToIndex( 3 ) ).to.equal( 1 );
+			expect( nodes.offsetToIndex( 4 ) ).to.equal( 2 );
 		} );
 
-		it( 'should return serialized object', () => {
-			let p = new Element( 'p' );
-			let nodeList = new NodeList( p );
+		it( 'should return 0 if given offset was lower than 0', () => {
+			expect( nodes.offsetToIndex( -1 ) ).to.equal( 0 );
+		} );
 
-			expect( jsonParseStringify( nodeList ) ).to.deep.equal( { nodes: [ jsonParseStringify( p ) ] } );
+		it( 'should return length if given offset was too high', () => {
+			expect( nodes.offsetToIndex( 5 ) ).to.equal( 3 );
+			expect( nodes.offsetToIndex( 99 ) ).to.equal( 3 );
 		} );
+	} );
 
-		it( 'should return serialized object with child text', () => {
-			let p = new Element( 'p', null, 'bar' );
-			let nodeList = new NodeList( p );
+	describe( 'insertNodes', () => {
+		it( 'should insert nodes at given index', () => {
+			let newImg = new Element( 'image' );
+			nodes.insertNodes( 1, [ newImg ]  );
 
-			let newVar = {
-				nodes: [ {
-					children: { nodes: [ { text: 'bar' } ] },
-					name: 'p'
-				} ]
-			};
+			let bar = new Text( 'bar', { bold: true } );
+			let xyz = new Text( 'xyz' );
+			nodes.insertNodes( 4, [ bar, xyz ] );
 
-			expect( jsonParseStringify( nodeList ) ).to.deep.equal( newVar );
-		} );
+			expect( nodes.length ).to.equal( 6 );
+			expect( nodes.totalOffset ).to.equal( 12 );
 
-		it( 'should return serialized object for text', () => {
-			let text = new Text( 'bar' );
-			let nodeList = new NodeList( text );
+			expect( Array.from( nodes ) ).to.deep.equal( [ p, newImg, foo, img, bar, xyz ] );
 
-			expect( jsonParseStringify( nodeList ) ).to.deep.equal( { nodes: [ { text: 'bar' } ] } );
-		} );
+			expect( nodes.getNode( 0 ) ).to.equal( p );
+			expect( nodes.getNode( 1 ) ).to.equal( newImg );
+			expect( nodes.getNode( 2 ) ).to.equal( foo );
+			expect( nodes.getNode( 3 ) ).to.equal( img );
+			expect( nodes.getNode( 4 ) ).to.equal( bar );
+			expect( nodes.getNode( 5 ) ).to.equal( xyz );
 
-		it( 'should return serialized object for text with attributes', () => {
-			let text = new Text( 'bar', { bold: true } );
-			let nodeList = new NodeList( text );
+			expect( nodes.getNodeIndex( p ) ).to.equal( 0 );
+			expect( nodes.getNodeIndex( newImg ) ).to.equal( 1 );
+			expect( nodes.getNodeIndex( foo ) ).to.equal( 2 );
+			expect( nodes.getNodeIndex( img ) ).to.equal( 3 );
+			expect( nodes.getNodeIndex( bar ) ).to.equal( 4 );
+			expect( nodes.getNodeIndex( xyz ) ).to.equal( 5 );
 
-			expect( jsonParseStringify( nodeList ) ).to.deep.equal( { nodes: [ { attributes: [ [ 'bold', true ] ], text: 'bar' } ] } );
+			expect( nodes.getNodeStartOffset( p ) ).to.equal( 0 );
+			expect( nodes.getNodeStartOffset( newImg ) ).to.equal( 1 );
+			expect( nodes.getNodeStartOffset( foo ) ).to.equal( 2 );
+			expect( nodes.getNodeStartOffset( img ) ).to.equal( 5 );
+			expect( nodes.getNodeStartOffset( bar ) ).to.equal( 6 );
+			expect( nodes.getNodeStartOffset( xyz ) ).to.equal( 9 );
 		} );
 
-		it( 'should return serialized object for text with attributes', () => {
-			let text = new Text( 'bar', { bold: true } );
-			let nodeList = new NodeList( text );
-
-			expect( jsonParseStringify( nodeList ) ).to.deep.equal( {
-				nodes: [ { attributes: [ [ 'bold', true ] ], text: 'bar' } ]
-			} );
+		it( 'should throw if not a Node is inserted', () => {
+			expect( () => {
+				nodes.insertNodes( 0, [ 'foo' ] );
+			} ).to.throw( CKEditorError, /nodelist-insertNodes-not-node/ );
 		} );
 	} );
 
-	describe( 'fromJSON', () => {
-		it( 'should create instance from empty serialized element', () => {
-			let nodeList = new NodeList();
-
-			let serialized = jsonParseStringify( nodeList );
+	describe( 'removeNodes', () => {
+		it( 'should remove one or more nodes from given index', () => {
+			nodes.removeNodes( 0, 2 );
 
-			let deserialized = NodeList.fromJSON( serialized );
+			expect( nodes.length ).to.equal( 1 );
+			expect( nodes.totalOffset ).to.equal( 1 );
 
-			expect( deserialized.length ).to.equal( nodeList.length );
+			expect( nodes.getNode( 0 ) ).to.equal( img );
+			expect( nodes.getNodeIndex( img ) ).to.equal( 0 );
+			expect( nodes.getNodeStartOffset( img ) ).to.equal( 0 );
 		} );
 
-		it( 'should create instance from serialized text with attributes', () => {
-			let text = new Text( 'bar', { bold: true } );
-			let nodeList = new NodeList( text );
+		it( 'should remove one node if howMany parameter was not specified', () => {
+			nodes.removeNodes( 1 );
 
-			let serialized = jsonParseStringify( nodeList );
+			expect( nodes.length ).to.equal( 2 );
+			expect( nodes.totalOffset ).to.equal( 2 );
 
-			let deserialized = NodeList.fromJSON( serialized );
+			expect( nodes.getNode( 0 ) ).to.equal( p );
+			expect( nodes.getNode( 1 ) ).to.equal( img );
 
-			expect( deserialized.length ).to.equal( nodeList.length );
+			expect( nodes.getNodeIndex( p ) ).to.equal( 0 );
+			expect( nodes.getNodeIndex( img ) ).to.equal( 1 );
 
-			for ( let i = 0; i < 3; i++ ) {
-				expect( deserialized.get( i ).character ).to.equal( nodeList.get( i ).character );
-				expect( deserialized.get( i ).hasAttribute( 'bold' ) ).to.equal( nodeList.get( i ).hasAttribute( 'bold' ) );
-				expect( deserialized.get( i ).getAttribute( 'bold' ) ).to.equal( nodeList.get( i ).getAttribute( 'bold' ) );
-			}
+			expect( nodes.getNodeStartOffset( p ) ).to.equal( 0 );
+			expect( nodes.getNodeStartOffset( img ) ).to.equal( 1 );
 		} );
+	} );
 
-		it( 'should create instance from serialized element', () => {
-			let p = new Element( 'p' );
-			let nodeList = new NodeList( p );
-
-			let serialized = jsonParseStringify( nodeList );
-
-			let deserialized = NodeList.fromJSON( serialized );
-
-			expect( deserialized.length ).to.equal( nodeList.length );
-			expect( deserialized.get( 0 ).name ).to.deep.equal( nodeList.get( 0 ).name );
-		} );
-
-		it( 'should create instance from serialized element with attributes', () => {
-			let p = new Element( 'p', { bold: true } );
-			let nodeList = new NodeList( p );
-
-			let serialized = jsonParseStringify( nodeList );
-
-			let deserialized = NodeList.fromJSON( serialized );
-
-			expect( deserialized.length ).to.equal( nodeList.length );
-			expect( deserialized.get( 0 ).name ).to.deep.equal( nodeList.get( 0 ).name );
-			expect( deserialized.get( 0 ).hasAttribute( 'bold' ) ).to.equal( nodeList.get( 0 ).hasAttribute( 'bold' ) );
-			expect( deserialized.get( 0 ).getAttribute( 'bold' ) ).to.equal( nodeList.get( 0 ).getAttribute( 'bold' ) );
+	describe( 'toJSON', () => {
+		it( 'should serialize empty node list', () => {
+			expect( jsonParseStringify( new NodeList() ) ).to.deep.equal( [] );
 		} );
 
-		it( 'should create instance from serialized element with parent', () => {
-			let p = new Element( 'p', null, 'bar' );
-			let nodeList = new NodeList( p );
-
-			let serialized = jsonParseStringify( nodeList );
-			let deserialized = NodeList.fromJSON( serialized );
-
-			expect( deserialized.length ).to.equal( nodeList.length );
-			expect( deserialized.get( 0 ).name ).to.equal( nodeList.get( 0 ).name );
-			expect( deserialized.get( 0 ).getChildCount() ).to.equal( nodeList.get( 0 ).getChildCount() );
-
-			for ( let i = 0; i < 3; i++ ) {
-				expect( deserialized.get( 0 ).getChild( i ).character ).to.equal( nodeList.get( 0 ).getChild( i ).character );
-				expect( deserialized.get( 0 ).getChild( i ).hasAttribute( 'bold' ) ).to.equal( nodeList.get( 0 ).getChild( i ).hasAttribute( 'bold' ) );
-				expect( deserialized.get( 0 ).getChild( i ).getAttribute( 'bold' ) ).to.equal( nodeList.get( 0 ).getChild( i ).getAttribute( 'bold' ) );
-			}
+		it( 'should serialize node list with nodes', () => {
+			expect( jsonParseStringify( nodes ) ).to.deep.equal( [
+				{ name: 'p' },
+				{ data: 'foo' },
+				{ name: 'image' }
+			] );
 		} );
 	} );
 } );

+ 33 - 43
packages/ckeditor5-engine/tests/model/operation/attributeoperation.js

@@ -7,11 +7,12 @@
 
 import Document from '/ckeditor5/engine/model/document.js';
 import Element from '/ckeditor5/engine/model/element.js';
+import Text from '/ckeditor5/engine/model/text.js';
 import AttributeOperation from '/ckeditor5/engine/model/operation/attributeoperation.js';
 import Position from '/ckeditor5/engine/model/position.js';
 import Range from '/ckeditor5/engine/model/range.js';
-import Text from '/ckeditor5/engine/model/text.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
+import count from '/ckeditor5/utils/count.js';
 import { jsonParseStringify, wrapInDelta } from '/tests/engine/model/_utils/utils.js';
 
 describe( 'AttributeOperation', () => {
@@ -61,7 +62,7 @@ describe( 'AttributeOperation', () => {
 	} );
 
 	it( 'should insert attribute to the set of nodes', () => {
-		root.insertChildren( 0, 'bar' );
+		root.insertChildren( 0, new Text( 'bar' ) );
 
 		doc.applyOperation( wrapInDelta(
 			new AttributeOperation(
@@ -74,10 +75,11 @@ describe( 'AttributeOperation', () => {
 		) );
 
 		expect( doc.version ).to.equal( 1 );
-		expect( root.getChildCount() ).to.equal( 3 );
+		expect( root.getMaxOffset() ).to.equal( 3 );
 		expect( root.getChild( 0 ).hasAttribute( 'isNew' ) ).to.be.true;
-		expect( root.getChild( 1 ).hasAttribute( 'isNew' ) ).to.be.true;
-		expect( root.getChild( 2 )._attrs.size ).to.equal( 0 );
+		expect( root.getChild( 0 ).data ).to.equal( 'ba' );
+		expect( root.getChild( 1 ).hasAttribute( 'isNew' ) ).to.be.false;
+		expect( root.getChild( 1 ).data ).to.equal( 'r' );
 	} );
 
 	it( 'should add attribute to the existing attributes', () => {
@@ -94,8 +96,8 @@ describe( 'AttributeOperation', () => {
 		) );
 
 		expect( doc.version ).to.equal( 1 );
-		expect( root.getChildCount() ).to.equal( 1 );
-		expect( root.getChild( 0 )._attrs.size ).to.equal( 3 );
+		expect( root.getMaxOffset() ).to.equal( 1 );
+		expect( count( root.getChild( 0 ).getAttributes() ) ).to.equal( 3 );
 		expect( root.getChild( 0 ).hasAttribute( 'isNew' ) ).to.be.true;
 		expect( root.getChild( 0 ).hasAttribute( 'foo' ) ).to.be.true;
 		expect( root.getChild( 0 ).hasAttribute( 'bar' ) ).to.be.true;
@@ -115,13 +117,11 @@ describe( 'AttributeOperation', () => {
 		) );
 
 		expect( doc.version ).to.equal( 1 );
-		expect( root.getChildCount() ).to.equal( 3 );
-		expect( root.getChild( 0 )._attrs.size ).to.equal( 1 );
+		expect( root.getMaxOffset() ).to.equal( 3 );
+		expect( count( root.getChild( 0 ).getAttributes() ) ).to.equal( 1 );
 		expect( root.getChild( 0 ).getAttribute( 'isNew' ) ).to.be.true;
-		expect( root.getChild( 1 )._attrs.size ).to.equal( 1 );
-		expect( root.getChild( 1 ).getAttribute( 'isNew' ) ).to.be.true;
-		expect( root.getChild( 2 )._attrs.size ).to.equal( 1 );
-		expect( root.getChild( 2 ).getAttribute( 'isNew' ) ).to.be.false;
+		expect( count( root.getChild( 1 ).getAttributes() ) ).to.equal( 1 );
+		expect( root.getChild( 1 ).getAttribute( 'isNew' ) ).to.be.false;
 	} );
 
 	it( 'should change attribute in the middle of existing attributes', () => {
@@ -138,8 +138,8 @@ describe( 'AttributeOperation', () => {
 		) );
 
 		expect( doc.version ).to.equal( 1 );
-		expect( root.getChildCount() ).to.equal( 1 );
-		expect( root.getChild( 0 )._attrs.size ).to.equal( 3 );
+		expect( root.getMaxOffset() ).to.equal( 1 );
+		expect( count( root.getChild( 0 ).getAttributes() ) ).to.equal( 3 );
 		expect( root.getChild( 0 ).getAttribute( 'foo' ) ).to.be.true;
 		expect( root.getChild( 0 ).getAttribute( 'x' ) ).to.equal( 2 );
 		expect( root.getChild( 0 ).getAttribute( 'bar' ) ).to.be.true;
@@ -159,8 +159,8 @@ describe( 'AttributeOperation', () => {
 		) );
 
 		expect( doc.version ).to.equal( 1 );
-		expect( root.getChildCount() ).to.equal( 1 );
-		expect( root.getChild( 0 )._attrs.size ).to.equal( 2 );
+		expect( root.getMaxOffset() ).to.equal( 1 );
+		expect( count( root.getChild( 0 ).getAttributes() ) ).to.equal( 2 );
 		expect( root.getChild( 0 ).hasAttribute( 'foo' ) ).to.be.true;
 		expect( root.getChild( 0 ).hasAttribute( 'bar' ) ).to.be.true;
 	} );
@@ -179,7 +179,7 @@ describe( 'AttributeOperation', () => {
 	} );
 
 	it( 'should undo adding attribute by applying reverse operation', () => {
-		root.insertChildren( 0, 'bar' );
+		root.insertChildren( 0, new Text( 'bar' ) );
 
 		let operation = new AttributeOperation(
 			new Range( new Position( root, [ 0 ] ), new Position( root, [ 3 ] ) ),
@@ -195,15 +195,13 @@ describe( 'AttributeOperation', () => {
 		doc.applyOperation( wrapInDelta( reverse ) );
 
 		expect( doc.version ).to.equal( 2 );
-		expect( root.getChildCount() ).to.equal( 3 );
-		expect( root.getChild( 0 )._attrs.size ).to.equal( 0 );
-		expect( root.getChild( 1 )._attrs.size ).to.equal( 0 );
-		expect( root.getChild( 2 )._attrs.size ).to.equal( 0 );
+		expect( root.getMaxOffset() ).to.equal( 3 );
+		expect( count( root.getChild( 0 ).getAttributes() ) ).to.equal( 0 );
 	} );
 
 	it( 'should not set attribute of element if change range starts in the middle of that element', () => {
-		let eleA = new Element( 'a', [], 'abc' );
-		let eleB = new Element( 'b', [], 'xyz' );
+		let eleA = new Element( 'a', [], new Text( 'abc' ) );
+		let eleB = new Element( 'b', [], new Text( 'xyz' ) );
 
 		root.insertChildren( 0, [ eleA, eleB ] );
 
@@ -223,8 +221,8 @@ describe( 'AttributeOperation', () => {
 	it( 'should not remove attribute of element if change range starts in the middle of that element', () => {
 		let fooAttr = { foo: true };
 
-		let eleA = new Element( 'a', fooAttr, 'abc' );
-		let eleB = new Element( 'b', fooAttr, 'xyz' );
+		let eleA = new Element( 'a', fooAttr, new Text( 'abc' ) );
+		let eleB = new Element( 'b', fooAttr, new Text( 'xyz' ) );
 
 		root.insertChildren( 0, [ eleA, eleB ] );
 
@@ -258,13 +256,9 @@ describe( 'AttributeOperation', () => {
 		doc.applyOperation( wrapInDelta( reverse ) );
 
 		expect( doc.version ).to.equal( 2 );
-		expect( root.getChildCount() ).to.equal( 3 );
-		expect( root.getChild( 0 )._attrs.size ).to.equal( 1 );
+		expect( root.getMaxOffset() ).to.equal( 3 );
+		expect( count( root.getChild( 0 ).getAttributes() ) ).to.equal( 1 );
 		expect( root.getChild( 0 ).getAttribute( 'isNew' ) ).to.be.false;
-		expect( root.getChild( 1 )._attrs.size ).to.equal( 1 );
-		expect( root.getChild( 1 ).getAttribute( 'isNew' ) ).to.be.false;
-		expect( root.getChild( 2 )._attrs.size ).to.equal( 1 );
-		expect( root.getChild( 2 ).getAttribute( 'isNew' ) ).to.be.false;
 	} );
 
 	it( 'should undo remove attribute by applying reverse operation', () => {
@@ -284,17 +278,13 @@ describe( 'AttributeOperation', () => {
 		doc.applyOperation( wrapInDelta( reverse ) );
 
 		expect( doc.version ).to.equal( 2 );
-		expect( root.getChildCount() ).to.equal( 3 );
-		expect( root.getChild( 0 )._attrs.size ).to.equal( 1 );
+		expect( root.getMaxOffset() ).to.equal( 3 );
+		expect( count( root.getChild( 0 ).getAttributes() ) ).to.equal( 1 );
 		expect( root.getChild( 0 ).getAttribute( 'foo' ) ).to.be.true;
-		expect( root.getChild( 1 )._attrs.size ).to.equal( 1 );
-		expect( root.getChild( 1 ).getAttribute( 'foo' ) ).to.be.true;
-		expect( root.getChild( 2 )._attrs.size ).to.equal( 1 );
-		expect( root.getChild( 2 ).getAttribute( 'foo' ) ).to.be.true;
 	} );
 
 	it( 'should throw an error when one try to remove and the attribute does not exists', () => {
-		root.insertChildren( 0, 'x' );
+		root.insertChildren( 0, new Text( 'x' ) );
 
 		expect( () => {
 			doc.applyOperation( wrapInDelta(
@@ -306,7 +296,7 @@ describe( 'AttributeOperation', () => {
 					doc.version
 				)
 			) );
-		} ).to.throw( CKEditorError, /operation-attribute-no-attr-to-remove/ );
+		} ).to.throw( CKEditorError, /operation-attribute-wrong-old-value/ );
 	} );
 
 	it( 'should throw an error when one try to insert and the attribute already exists', () => {
@@ -349,7 +339,7 @@ describe( 'AttributeOperation', () => {
 		let attrB = { foo: 'b' };
 
 		root.insertChildren( 0, new Text( 'abc', attrA ) );
-		root.insertChildren( 3, new Text( 'xyz', attrB ) );
+		root.insertChildren( 1, new Text( 'xyz', attrB ) );
 
 		doc.applyOperation( wrapInDelta(
 			new AttributeOperation(
@@ -361,8 +351,8 @@ describe( 'AttributeOperation', () => {
 			) )
 		);
 
-		expect( root._children._nodes[ 0 ].text ).to.equal( 'a' );
-		expect( root._children._nodes[ 1 ].text ).to.equal( 'bcxyz' );
+		expect( root.getChild( 0 ).data ).to.equal( 'a' );
+		expect( root.getChild( 1 ).data ).to.equal( 'bcxyz' );
 	} );
 
 	describe( 'toJSON', () => {

+ 62 - 33
packages/ckeditor5-engine/tests/model/operation/insertoperation.js

@@ -6,8 +6,8 @@
 /* bender-tags: model, operation */
 
 import Document from '/ckeditor5/engine/model/document.js';
-import Node from '/ckeditor5/engine/model/node.js';
 import NodeList from '/ckeditor5/engine/model/nodelist.js';
+import Element from '/ckeditor5/engine/model/element.js';
 import InsertOperation from '/ckeditor5/engine/model/operation/insertoperation.js';
 import RemoveOperation from '/ckeditor5/engine/model/operation/removeoperation.js';
 import Position from '/ckeditor5/engine/model/position.js';
@@ -32,7 +32,7 @@ describe( 'InsertOperation', () => {
 		expect( op.type ).to.equal( 'insert' );
 	} );
 
-	it( 'should insert node', () => {
+	it( 'should insert text node', () => {
 		doc.applyOperation( wrapInDelta(
 			new InsertOperation(
 				new Position( root, [ 0 ] ),
@@ -42,28 +42,43 @@ describe( 'InsertOperation', () => {
 		) );
 
 		expect( doc.version ).to.equal( 1 );
-		expect( root.getChildCount() ).to.equal( 1 );
-		expect( root.getChild( 0 ).character ).to.equal( 'x' );
+		expect( root.getMaxOffset() ).to.equal( 1 );
+		expect( root.getChild( 0 ).data ).to.equal( 'x' );
+	} );
+
+	it( 'should insert element', () => {
+		doc.applyOperation( wrapInDelta(
+			new InsertOperation(
+				new Position( root, [ 0 ] ),
+				new Element( 'p' ),
+				doc.version
+			)
+		) );
+
+		expect( doc.version ).to.equal( 1 );
+		expect( root.getMaxOffset() ).to.equal( 1 );
+		expect( root.getChild( 0 ).name ).to.equal( 'p' );
 	} );
 
 	it( 'should insert set of nodes', () => {
 		doc.applyOperation( wrapInDelta(
 			new InsertOperation(
 				new Position( root, [ 0 ] ),
-				'bar',
+				[ 'bar', new Element( 'p' ), 'foo' ],
 				doc.version
 			)
 		) );
 
 		expect( doc.version ).to.equal( 1 );
+		expect( root.getMaxOffset() ).to.equal( 7 );
 		expect( root.getChildCount() ).to.equal( 3 );
-		expect( root.getChild( 0 ).character ).to.equal( 'b' );
-		expect( root.getChild( 1 ).character ).to.equal( 'a' );
-		expect( root.getChild( 2 ).character ).to.equal( 'r' );
+		expect( root.getChild( 0 ).data ).to.equal( 'bar' );
+		expect( root.getChild( 1 ).name ).to.equal( 'p' );
+		expect( root.getChild( 2 ).data ).to.equal( 'foo' );
 	} );
 
 	it( 'should insert between existing nodes', () => {
-		root.insertChildren( 0, 'xy' );
+		root.insertChildren( 0, new Text( 'xy' ) );
 
 		doc.applyOperation( wrapInDelta(
 			new InsertOperation(
@@ -74,12 +89,8 @@ describe( 'InsertOperation', () => {
 		) );
 
 		expect( doc.version ).to.equal( 1 );
-		expect( root.getChildCount() ).to.equal( 5 );
-		expect( root.getChild( 0 ).character ).to.equal( 'x' );
-		expect( root.getChild( 1 ).character ).to.equal( 'b' );
-		expect( root.getChild( 2 ).character ).to.equal( 'a' );
-		expect( root.getChild( 3 ).character ).to.equal( 'r' );
-		expect( root.getChild( 4 ).character ).to.equal( 'y' );
+		expect( root.getMaxOffset() ).to.equal( 5 );
+		expect( root.getChild( 0 ).data ).to.equal( 'xbary' );
 	} );
 
 	it( 'should insert text', () => {
@@ -92,14 +103,8 @@ describe( 'InsertOperation', () => {
 		) );
 
 		expect( doc.version ).to.equal( 1 );
-		expect( root.getChildCount() ).to.equal( 7 );
-		expect( root.getChild( 0 ).character ).to.equal( 'f' );
-		expect( root.getChild( 1 ).character ).to.equal( 'o' );
-		expect( root.getChild( 2 ).character ).to.equal( 'o' );
-		expect( root.getChild( 3 ).character ).to.equal( 'x' );
-		expect( root.getChild( 4 ).character ).to.equal( 'b' );
-		expect( root.getChild( 5 ).character ).to.equal( 'a' );
-		expect( root.getChild( 6 ).character ).to.equal( 'r' );
+		expect( root.getMaxOffset() ).to.equal( 7 );
+		expect( root.getChild( 0 ).data ).to.equal( 'fooxbar' );
 	} );
 
 	it( 'should create a RemoveOperation as a reverse', () => {
@@ -134,7 +139,7 @@ describe( 'InsertOperation', () => {
 		doc.applyOperation( wrapInDelta( reverse ) );
 
 		expect( doc.version ).to.equal( 2 );
-		expect( root.getChildCount() ).to.equal( 0 );
+		expect( root.getMaxOffset() ).to.equal( 0 );
 	} );
 
 	it( 'should undo insert set of nodes by applying reverse operation', () => {
@@ -153,14 +158,14 @@ describe( 'InsertOperation', () => {
 		doc.applyOperation( wrapInDelta( reverse ) );
 
 		expect( doc.version ).to.equal( 2 );
-		expect( root.getChildCount() ).to.equal( 0 );
+		expect( root.getMaxOffset() ).to.equal( 0 );
 	} );
 
 	it( 'should create operation with the same parameters when cloned', () => {
 		let position = new Position( root, [ 0 ] );
-		let nodeA = new Node();
-		let nodeB = new Node();
-		let nodes = new NodeList( [ nodeA, nodeB ] );
+		let nodeA = new Element( 'a' );
+		let nodeB = new Element( 'b' );
+		let nodes = [ nodeA, nodeB ];
 		let baseVersion = doc.version;
 
 		let op = new InsertOperation( position, nodes, baseVersion );
@@ -172,12 +177,32 @@ describe( 'InsertOperation', () => {
 
 		expect( clone ).to.be.instanceof( InsertOperation );
 		expect( clone.position.isEqual( position ) ).to.be.true;
-		expect( clone.nodeList.get( 0 ) ).to.equal( nodeA );
-		expect( clone.nodeList.get( 1 ) ).to.equal( nodeB );
-		expect( clone.nodeList.length ).to.equal( 2 );
+		expect( clone.nodes.getNode( 0 ) ).to.equal( nodeA );
+		expect( clone.nodes.getNode( 1 ) ).to.equal( nodeB );
+		expect( clone.nodes.length ).to.equal( 2 );
 		expect( clone.baseVersion ).to.equal( baseVersion );
 	} );
 
+	it( 'should save copies of inserted nodes after it is executed', () => {
+		let element = new Element( 'p', { key: 'value' } );
+
+		let op = new InsertOperation( new Position( root, [ 0 ] ), element, doc.version );
+		doc.applyOperation( wrapInDelta( op ) );
+
+		let text = new Text( 'text' );
+		let op2 = new InsertOperation( new Position( root, [ 0, 0 ] ), text, doc.version );
+		doc.applyOperation( wrapInDelta( op2 ) );
+
+		expect( op.nodes.getNode( 0 ) ).not.to.equal( element );
+		expect( op.nodes.getNode( 0 ).name ).to.equal( 'p' );
+		expect( Array.from( op.nodes.getNode( 0 ).getAttributes() ) ).to.deep.equal( [ [ 'key', 'value' ] ] );
+
+		expect( op.nodes.getNode( 0 ).getChildCount() ).to.equal( 0 );
+		expect( element.getChildCount() ).to.equal( 1 );
+
+		expect( op2.nodes.getNode( 0 ) ).not.to.equal( text );
+	} );
+
 	describe( 'toJSON', () => {
 		it( 'should create proper json object', () => {
 			const position = new Position( root, [ 0 ] );
@@ -188,7 +213,7 @@ describe( 'InsertOperation', () => {
 			expect( serialized ).to.deep.equal( {
 				__className: 'engine.model.operation.InsertOperation',
 				baseVersion: 0,
-				nodeList: jsonParseStringify( new NodeList( 'x' ) ),
+				nodes: jsonParseStringify( new NodeList( [ new Text( 'x' ) ] ) ),
 				position: jsonParseStringify( position )
 			} );
 		} );
@@ -197,7 +222,11 @@ describe( 'InsertOperation', () => {
 	describe( 'fromJSON', () => {
 		it( 'should create proper InsertOperation from json object', () => {
 			const position = new Position( root, [ 0 ] );
-			const op = new InsertOperation( position, new Text( 'x' ), doc.version );
+			const op = new InsertOperation(
+				position,
+				[ new Text( 'x' ), new Element( 'p', [], new Text( 'foo' ) ), 'y' ],
+				doc.version
+			);
 
 			const serialized = jsonParseStringify( op );
 			const deserialized = InsertOperation.fromJSON( serialized, doc );

+ 28 - 35
packages/ckeditor5-engine/tests/model/operation/moveoperation.js

@@ -9,6 +9,7 @@ import Document from '/ckeditor5/engine/model/document.js';
 import MoveOperation from '/ckeditor5/engine/model/operation/moveoperation.js';
 import Position from '/ckeditor5/engine/model/position.js';
 import Element from '/ckeditor5/engine/model/element.js';
+import Text from '/ckeditor5/engine/model/text.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 import { jsonParseStringify, wrapInDelta } from '/tests/engine/model/_utils/utils.js';
 
@@ -58,16 +59,16 @@ describe( 'MoveOperation', () => {
 		) );
 
 		expect( doc.version ).to.equal( 1 );
-		expect( root.getChildCount() ).to.equal( 2 );
+		expect( root.getMaxOffset() ).to.equal( 2 );
 		expect( root.getChild( 0 ).name ).to.equal( 'p1' );
 		expect( root.getChild( 1 ).name ).to.equal( 'p2' );
-		expect( p1.getChildCount() ).to.equal( 0 );
-		expect( p2.getChildCount() ).to.equal( 1 );
+		expect( p1.getMaxOffset() ).to.equal( 0 );
+		expect( p2.getMaxOffset() ).to.equal( 1 );
 		expect( p2.getChild( 0 ).name ).to.equal( 'x' );
 	} );
 
 	it( 'should move position of children in one node backward', () => {
-		root.insertChildren( 0, 'xbarx' );
+		root.insertChildren( 0, new Text( 'xbarx' ) );
 
 		doc.applyOperation( wrapInDelta(
 			new MoveOperation(
@@ -79,16 +80,12 @@ describe( 'MoveOperation', () => {
 		) );
 
 		expect( doc.version ).to.equal( 1 );
-		expect( root.getChildCount() ).to.equal( 5 );
-		expect( root.getChild( 0 ).character ).to.equal( 'x' );
-		expect( root.getChild( 1 ).character ).to.equal( 'a' );
-		expect( root.getChild( 2 ).character ).to.equal( 'r' );
-		expect( root.getChild( 3 ).character ).to.equal( 'b' );
-		expect( root.getChild( 4 ).character ).to.equal( 'x' );
+		expect( root.getMaxOffset() ).to.equal( 5 );
+		expect( root.getChild( 0 ).data ).to.equal( 'xarbx' );
 	} );
 
 	it( 'should move position of children in one node forward', () => {
-		root.insertChildren( 0, 'xbarx' );
+		root.insertChildren( 0, new Text( 'xbarx' ) );
 
 		doc.applyOperation( wrapInDelta(
 			new MoveOperation(
@@ -100,12 +97,8 @@ describe( 'MoveOperation', () => {
 		) );
 
 		expect( doc.version ).to.equal( 1 );
-		expect( root.getChildCount() ).to.equal( 5 );
-		expect( root.getChild( 0 ).character ).to.equal( 'x' );
-		expect( root.getChild( 1 ).character ).to.equal( 'r' );
-		expect( root.getChild( 2 ).character ).to.equal( 'b' );
-		expect( root.getChild( 3 ).character ).to.equal( 'a' );
-		expect( root.getChild( 4 ).character ).to.equal( 'x' );
+		expect( root.getMaxOffset() ).to.equal( 5 );
+		expect( root.getChild( 0 ).data ).to.equal( 'xrbax' );
 	} );
 
 	it( 'should create a proper MoveOperation as a reverse', () => {
@@ -144,22 +137,22 @@ describe( 'MoveOperation', () => {
 		doc.applyOperation( wrapInDelta( operation ) );
 
 		expect( doc.version ).to.equal( 1 );
-		expect( root.getChildCount() ).to.equal( 2 );
-		expect( p1.getChildCount() ).to.equal( 0 );
-		expect( p2.getChildCount() ).to.equal( 1 );
+		expect( root.getMaxOffset() ).to.equal( 2 );
+		expect( p1.getMaxOffset() ).to.equal( 0 );
+		expect( p2.getMaxOffset() ).to.equal( 1 );
 		expect( p2.getChild( 0 ).name ).to.equal( 'x' );
 
 		doc.applyOperation( wrapInDelta( operation.getReversed() ) );
 
 		expect( doc.version ).to.equal( 2 );
-		expect( root.getChildCount() ).to.equal( 2 );
-		expect( p1.getChildCount() ).to.equal( 1 );
+		expect( root.getMaxOffset() ).to.equal( 2 );
+		expect( p1.getMaxOffset() ).to.equal( 1 );
 		expect( p1.getChild( 0 ).name ).to.equal( 'x' );
-		expect( p2.getChildCount() ).to.equal( 0 );
+		expect( p2.getMaxOffset() ).to.equal( 0 );
 	} );
 
 	it( 'should throw an error if number of nodes to move exceeds the number of existing nodes in given element', () => {
-		root.insertChildren( 0, 'xbarx' );
+		root.insertChildren( 0, new Text( 'xbarx' ) );
 
 		let operation = new MoveOperation(
 			new Position( root, [ 3 ] ),
@@ -173,8 +166,8 @@ describe( 'MoveOperation', () => {
 
 	it( 'should throw an error if target or source parent-element specified by position does not exist', () => {
 		let p = new Element( 'p' );
-		p.insertChildren( 0, 'foo' );
-		root.insertChildren( 0, [ 'ab', p ] );
+		p.insertChildren( 0, new Text( 'foo' ) );
+		root.insertChildren( 0, [ new Text( 'ab' ), p ] );
 
 		let operation = new MoveOperation(
 			new Position( root, [ 2, 0 ] ),
@@ -183,13 +176,13 @@ describe( 'MoveOperation', () => {
 			doc.version
 		);
 
-		root.removeChildren( 2, 1 );
+		root.removeChildren( 1 );
 
 		expect( () => doc.applyOperation( wrapInDelta( operation ) ) ).to.throw( CKEditorError, /operation-move-position-invalid/ );
 	} );
 
 	it( 'should throw an error if operation tries to move a range between the beginning and the end of that range', () => {
-		root.insertChildren( 0, 'xbarx' );
+		root.insertChildren( 0, new Text( 'xbarx' ) );
 
 		let operation = new MoveOperation(
 			new Position( root, [ 1 ] ),
@@ -203,7 +196,7 @@ describe( 'MoveOperation', () => {
 
 	it( 'should throw an error if operation tries to move a range into a sub-tree of a node that is in that range', () => {
 		let p = new Element( 'p', [], [ new Element( 'p' ) ] );
-		root.insertChildren( 0, [ 'ab', p, 'xy' ] );
+		root.insertChildren( 0, [ new Text( 'ab' ), p, new Text( 'xy' ) ] );
 
 		let operation = new MoveOperation(
 			new Position( root, [ 1 ] ),
@@ -217,7 +210,7 @@ describe( 'MoveOperation', () => {
 
 	it( 'should not throw an error if operation move a range into a sibling', () => {
 		let p = new Element( 'p' );
-		root.insertChildren( 0, [ 'ab', p, 'xy' ] );
+		root.insertChildren( 0, [ new Text( 'ab' ), p, new Text( 'xy' ) ] );
 
 		let operation = new MoveOperation(
 			new Position( root, [ 1 ] ),
@@ -232,15 +225,15 @@ describe( 'MoveOperation', () => {
 			}
 		).not.to.throw();
 
-		expect( root.getChildCount() ).to.equal( 4 );
-		expect( p.getChildCount() ).to.equal( 1 );
-		expect( p.getChild( 0 ).character ).to.equal( 'b' );
+		expect( root.getMaxOffset() ).to.equal( 4 );
+		expect( p.getMaxOffset() ).to.equal( 1 );
+		expect( p.getChild( 0 ).data ).to.equal( 'b' );
 	} );
 
 	it( 'should not throw when operation paths looks like incorrect but move is between different roots', () => {
 		let p = new Element( 'p' );
-		root.insertChildren( 0, [ 'a', p, 'b' ] );
-		doc.graveyard.insertChildren( 0, [ 'abc' ] );
+		root.insertChildren( 0, [ new Text( 'a' ), p, new Text( 'b' ) ] );
+		doc.graveyard.insertChildren( 0, new Text( 'abc' ) );
 
 		let operation = new MoveOperation(
 			new Position( doc.graveyard, [ 0 ] ),

+ 5 - 4
packages/ckeditor5-engine/tests/model/operation/reinsertoperation.js

@@ -11,6 +11,7 @@ import RemoveOperation from '/ckeditor5/engine/model/operation/removeoperation.j
 import MoveOperation from '/ckeditor5/engine/model/operation/moveoperation.js';
 import Position from '/ckeditor5/engine/model/position.js';
 import Element from '/ckeditor5/engine/model/element.js';
+import Text from '/ckeditor5/engine/model/text.js';
 import { jsonParseStringify, wrapInDelta } from '/tests/engine/model/_utils/utils.js';
 
 describe( 'ReinsertOperation', () => {
@@ -76,19 +77,19 @@ describe( 'ReinsertOperation', () => {
 		let reverse = operation.getReversed();
 
 		const element = new Element();
-		element.insertChildren( 0, 'xx' );
+		element.insertChildren( 0, new Text( 'xx' ) );
 		graveyard.insertChildren( 0, element );
 
 		doc.applyOperation( wrapInDelta( operation ) );
 
 		expect( doc.version ).to.equal( 1 );
-		expect( root.getChildCount() ).to.equal( 2 );
-		expect( element.getChildCount() ).to.equal( 0 );
+		expect( root.getMaxOffset() ).to.equal( 2 );
+		expect( element.getMaxOffset() ).to.equal( 0 );
 
 		doc.applyOperation( wrapInDelta( reverse ) );
 
 		expect( doc.version ).to.equal( 2 );
-		expect( root.getChildCount() ).to.equal( 0 );
+		expect( root.getMaxOffset() ).to.equal( 0 );
 		// Don't check `element` - nodes are moved to new holder element.
 	} );
 

+ 17 - 20
packages/ckeditor5-engine/tests/model/operation/removeoperation.js

@@ -10,6 +10,7 @@ import ReinsertOperation from '/ckeditor5/engine/model/operation/reinsertoperati
 import RemoveOperation from '/ckeditor5/engine/model/operation/removeoperation.js';
 import MoveOperation from '/ckeditor5/engine/model/operation/moveoperation.js';
 import Position from '/ckeditor5/engine/model/position.js';
+import Text from '/ckeditor5/engine/model/text.js';
 import Delta from '/ckeditor5/engine/model/delta/delta.js';
 import { jsonParseStringify, wrapInDelta } from '/tests/engine/model/_utils/utils.js';
 
@@ -53,7 +54,7 @@ describe( 'RemoveOperation', () => {
 	} );
 
 	it( 'should remove set of nodes and append them to holder element in graveyard root', () => {
-		root.insertChildren( 0, 'fozbar' );
+		root.insertChildren( 0, new Text( 'fozbar' ) );
 
 		doc.applyOperation( wrapInDelta(
 			new RemoveOperation(
@@ -64,16 +65,15 @@ describe( 'RemoveOperation', () => {
 		) );
 
 		expect( doc.version ).to.equal( 1 );
-		expect( root.getChildCount() ).to.equal( 4 );
-		expect( root.getChild( 2 ).character ).to.equal( 'a' );
+		expect( root.getMaxOffset() ).to.equal( 4 );
+		expect( root.getChild( 0 ).data ).to.equal( 'foar' );
 
-		expect( graveyard.getChildCount() ).to.equal( 1 );
-		expect( graveyard.getChild( 0 ).getChild( 0 ).character ).to.equal( 'z' );
-		expect( graveyard.getChild( 0 ).getChild( 1 ).character ).to.equal( 'b' );
+		expect( graveyard.getMaxOffset() ).to.equal( 1 );
+		expect( graveyard.getChild( 0 ).getChild( 0 ).data ).to.equal( 'zb' );
 	} );
 
 	it( 'should create new holder element for remove operations in different deltas', () => {
-		root.insertChildren( 0, 'fozbar' );
+		root.insertChildren( 0, new Text( 'fozbar' ) );
 
 		doc.applyOperation( wrapInDelta(
 			new RemoveOperation(
@@ -99,14 +99,14 @@ describe( 'RemoveOperation', () => {
 			)
 		) );
 
-		expect( graveyard.getChildCount() ).to.equal( 3 );
-		expect( graveyard.getChild( 0 ).getChild( 0 ).character ).to.equal( 'f' );
-		expect( graveyard.getChild( 1 ).getChild( 0 ).character ).to.equal( 'o' );
-		expect( graveyard.getChild( 2 ).getChild( 0 ).character ).to.equal( 'z' );
+		expect( graveyard.getMaxOffset() ).to.equal( 3 );
+		expect( graveyard.getChild( 0 ).getChild( 0 ).data ).to.equal( 'f' );
+		expect( graveyard.getChild( 1 ).getChild( 0 ).data ).to.equal( 'o' );
+		expect( graveyard.getChild( 2 ).getChild( 0 ).data ).to.equal( 'z' );
 	} );
 
 	it( 'should not create new holder element for remove operation if it was already created for given delta', () => {
-		root.insertChildren( 0, 'fozbar' );
+		root.insertChildren( 0, new Text( 'fozbar' ) );
 
 		let delta = new Delta();
 
@@ -129,8 +129,7 @@ describe( 'RemoveOperation', () => {
 		doc.applyOperation( removeOpB );
 
 		expect( graveyard.getChildCount() ).to.equal( 1 );
-		expect( graveyard.getChild( 0 ).getChild( 0 ).character ).to.equal( 'f' );
-		expect( graveyard.getChild( 0 ).getChild( 1 ).character ).to.equal( 'o' );
+		expect( graveyard.getChild( 0 ).getChild( 0 ).data ).to.equal( 'fo' );
 	} );
 
 	it( 'should create RemoveOperation with same parameters when cloned', () => {
@@ -162,20 +161,18 @@ describe( 'RemoveOperation', () => {
 		let operation = new RemoveOperation( position, 3, 0 );
 		let reverse = operation.getReversed();
 
-		root.insertChildren( 0, 'bar' );
+		root.insertChildren( 0, new Text( 'bar' ) );
 
 		doc.applyOperation( wrapInDelta( operation ) );
 
 		expect( doc.version ).to.equal( 1 );
-		expect( root.getChildCount() ).to.equal( 0 );
+		expect( root.getMaxOffset() ).to.equal( 0 );
 
 		doc.applyOperation( wrapInDelta( reverse ) );
 
 		expect( doc.version ).to.equal( 2 );
-		expect( root.getChildCount() ).to.equal( 3 );
-		expect( root.getChild( 0 ).character ).to.equal( 'b' );
-		expect( root.getChild( 1 ).character ).to.equal( 'a' );
-		expect( root.getChild( 2 ).character ).to.equal( 'r' );
+		expect( root.getMaxOffset() ).to.equal( 3 );
+		expect( root.getChild( 0 ).data ).to.equal( 'bar' );
 	} );
 
 	describe( 'toJSON', () => {

+ 86 - 42
packages/ckeditor5-engine/tests/model/position.js

@@ -9,6 +9,7 @@ import Document from '/ckeditor5/engine/model/document.js';
 import DocumentFragment from '/ckeditor5/engine/model/documentfragment.js';
 import Element from '/ckeditor5/engine/model/element.js';
 import Text from '/ckeditor5/engine/model/text.js';
+import TextProxy from '/ckeditor5/engine/model/textproxy.js';
 import Position from '/ckeditor5/engine/model/position.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 import testUtils from '/tests/ckeditor5/_utils/utils.js';
@@ -17,7 +18,7 @@ import { jsonParseStringify } from '/tests/engine/model/_utils/utils.js';
 testUtils.createSinonSandbox();
 
 describe( 'position', () => {
-	let doc, root, otherRoot, p, ul, li1, li2, f, o, z, b, a, r;
+	let doc, root, otherRoot, p, ul, li1, li2, f, o, z, b, a, r, foz, bar;
 
 	// root
 	//  |- p         Before: [ 0 ]       After: [ 1 ]
@@ -36,17 +37,21 @@ describe( 'position', () => {
 		root = doc.createRoot();
 		otherRoot = doc.createRoot( '$root', 'otherRoot' );
 
-		li1 = new Element( 'li', [], 'foz' );
+		foz = new Text( 'foz' );
 
-		f = li1.getChild( 0 );
-		o = li1.getChild( 1 );
-		z = li1.getChild( 2 );
+		li1 = new Element( 'li', [], foz );
 
-		li2 = new Element( 'li', [], 'bar' );
+		f = new TextProxy( foz, 0, 1 );
+		o = new TextProxy( foz, 1, 1 );
+		z = new TextProxy( foz, 2, 1 );
 
-		b = li2.getChild( 0 );
-		a = li2.getChild( 1 );
-		r = li2.getChild( 2 );
+		bar = new Text( 'bar' );
+
+		li2 = new Element( 'li', [], bar );
+
+		b = new TextProxy( bar, 0, 1 );
+		a = new TextProxy( bar, 1, 1 );
+		r = new TextProxy( bar, 2, 1 );
 
 		ul = new Element( 'ul', [], [ li1, li2 ] );
 
@@ -271,6 +276,23 @@ describe( 'position', () => {
 		expect( new Position( root, [ 1, 0, 3 ] ) ).to.have.property( 'offset' ).that.equals( 3 );
 	} );
 
+	it( 'should have index', () => {
+		expect( new Position( root, [ 0 ] ) ).to.have.property( 'index' ).that.equals( 0 );
+		expect( new Position( root, [ 1 ] ) ).to.have.property( 'index' ).that.equals( 1 );
+		expect( new Position( root, [ 2 ] ) ).to.have.property( 'index' ).that.equals( 2 );
+
+		expect( new Position( root, [ 0, 0 ] ) ).to.have.property( 'index' ).that.equals( 0 );
+
+		expect( new Position( root, [ 1, 0 ] ) ).to.have.property( 'index' ).that.equals( 0 );
+		expect( new Position( root, [ 1, 1 ] ) ).to.have.property( 'index' ).that.equals( 1 );
+		expect( new Position( root, [ 1, 2 ] ) ).to.have.property( 'index' ).that.equals( 2 );
+
+		expect( new Position( root, [ 1, 0, 0 ] ) ).to.have.property( 'index' ).that.equals( 0 );
+		expect( new Position( root, [ 1, 0, 1 ] ) ).to.have.property( 'index' ).that.equals( 0 );
+		expect( new Position( root, [ 1, 0, 2 ] ) ).to.have.property( 'index' ).that.equals( 0 );
+		expect( new Position( root, [ 1, 0, 3 ] ) ).to.have.property( 'index' ).that.equals( 1 );
+	} );
+
 	it( 'should be able to set offset', () => {
 		let position = new Position( root, [ 1, 0, 2 ] );
 		position.offset = 4;
@@ -279,7 +301,7 @@ describe( 'position', () => {
 		expect( position.path ).to.deep.equal( [ 1, 0, 4 ] );
 	} );
 
-	it( 'should have nodeBefore', () => {
+	it( 'should have nodeBefore if it is not inside a text node', () => {
 		expect( new Position( root, [ 0 ] ).nodeBefore ).to.be.null;
 		expect( new Position( root, [ 1 ] ).nodeBefore ).to.equal( p );
 		expect( new Position( root, [ 2 ] ).nodeBefore ).to.equal( ul );
@@ -291,12 +313,12 @@ describe( 'position', () => {
 		expect( new Position( root, [ 1, 2 ] ).nodeBefore ).to.equal( li2 );
 
 		expect( new Position( root, [ 1, 0, 0 ] ).nodeBefore ).to.be.null;
-		expect( new Position( root, [ 1, 0, 1 ] ).nodeBefore.character ).to.equal( 'f' );
-		expect( new Position( root, [ 1, 0, 2 ] ).nodeBefore.character ).to.equal( 'o' );
-		expect( new Position( root, [ 1, 0, 3 ] ).nodeBefore.character ).to.equal( 'z' );
+		expect( new Position( root, [ 1, 0, 1 ] ).nodeBefore ).to.be.null;
+		expect( new Position( root, [ 1, 0, 2 ] ).nodeBefore ).to.be.null;
+		expect( new Position( root, [ 1, 0, 3 ] ).nodeBefore.data ).to.equal( 'foz' );
 	} );
 
-	it( 'should have nodeAfter', () => {
+	it( 'should have nodeAfter if it is not inside a text node', () => {
 		expect( new Position( root, [ 0 ] ).nodeAfter ).to.equal( p );
 		expect( new Position( root, [ 1 ] ).nodeAfter ).to.equal( ul );
 		expect( new Position( root, [ 2 ] ).nodeAfter ).to.be.null;
@@ -307,12 +329,34 @@ describe( 'position', () => {
 		expect( new Position( root, [ 1, 1 ] ).nodeAfter ).to.equal( li2 );
 		expect( new Position( root, [ 1, 2 ] ).nodeAfter ).to.be.null;
 
-		expect( new Position( root, [ 1, 0, 0 ] ).nodeAfter.character ).to.equal( 'f' );
-		expect( new Position( root, [ 1, 0, 1 ] ).nodeAfter.character ).to.equal( 'o' );
-		expect( new Position( root, [ 1, 0, 2 ] ).nodeAfter.character ).to.equal( 'z' );
+		expect( new Position( root, [ 1, 0, 0 ] ).nodeAfter.data ).to.equal( 'foz' );
+		expect( new Position( root, [ 1, 0, 1 ] ).nodeAfter ).to.be.null;
+		expect( new Position( root, [ 1, 0, 2 ] ).nodeAfter ).to.be.null;
 		expect( new Position( root, [ 1, 0, 3 ] ).nodeAfter ).to.be.null;
 	} );
 
+	it( 'should have a text node property if it is in text node', () => {
+		expect( new Position( root, [ 0 ] ).textNode ).to.be.null;
+		expect( new Position( root, [ 1 ] ).textNode ).to.be.null;
+		expect( new Position( root, [ 2 ] ).textNode ).to.be.null;
+
+		expect( new Position( root, [ 0, 0 ] ).textNode ).to.be.null;
+
+		expect( new Position( root, [ 1, 0 ] ).textNode ).to.be.null;
+		expect( new Position( root, [ 1, 1 ] ).textNode ).to.be.null;
+		expect( new Position( root, [ 1, 2 ] ).textNode ).to.be.null;
+
+		expect( new Position( root, [ 1, 0, 0 ] ).textNode ).to.be.null;
+		expect( new Position( root, [ 1, 0, 1 ] ).textNode ).to.equal( foz );
+		expect( new Position( root, [ 1, 0, 2 ] ).textNode ).to.equal( foz );
+		expect( new Position( root, [ 1, 0, 3 ] ).textNode ).to.be.null;
+
+		expect( new Position( root, [ 1, 1, 0 ] ).textNode ).to.be.null;
+		expect( new Position( root, [ 1, 1, 1 ] ).textNode ).to.equal( bar );
+		expect( new Position( root, [ 1, 1, 2 ] ).textNode ).to.equal( bar );
+		expect( new Position( root, [ 1, 1, 3 ] ).textNode ).to.be.null;
+	} );
+
 	it( 'should have proper parent path', () => {
 		let position = new Position( root, [ 1, 2, 3 ] );
 
@@ -454,7 +498,7 @@ describe( 'position', () => {
 
 	describe( 'isAtEnd', () => {
 		it( 'should return true if position is at the end of its parent', () => {
-			expect( new Position( root, [ root.getChildCount() ] ).isAtEnd() ).to.be.true;
+			expect( new Position( root, [ root.getMaxOffset() ] ).isAtEnd() ).to.be.true;
 			expect( new Position( root, [ 0 ] ).isAtEnd() ).to.be.false;
 		} );
 	} );
@@ -489,10 +533,10 @@ describe( 'position', () => {
 		} );
 	} );
 
-	describe( 'getTransformedByInsertion', () => {
+	describe( '_getTransformedByInsertion', () => {
 		it( 'should return a new Position instance', () => {
 			const position = new Position( root, [ 0 ] );
-			const transformed = position.getTransformedByInsertion( new Position( root, [ 2 ] ), 4, false );
+			const transformed = position._getTransformedByInsertion( new Position( root, [ 2 ] ), 4, false );
 
 			expect( transformed ).not.to.equal( position );
 			expect( transformed ).to.be.instanceof( Position );
@@ -500,58 +544,58 @@ describe( 'position', () => {
 
 		it( 'should increment offset if insertion is in the same parent and closer offset', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );
-			const transformed = position.getTransformedByInsertion( new Position( root, [ 1, 2, 2 ] ), 2, false );
+			const transformed = position._getTransformedByInsertion( new Position( root, [ 1, 2, 2 ] ), 2, false );
 
 			expect( transformed.offset ).to.equal( 5 );
 		} );
 
 		it( 'should not increment offset if insertion position is in different root', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );
-			const transformed = position.getTransformedByInsertion( new Position( otherRoot, [ 1, 2, 2 ] ), 2, false );
+			const transformed = position._getTransformedByInsertion( new Position( otherRoot, [ 1, 2, 2 ] ), 2, false );
 
 			expect( transformed.offset ).to.equal( 3 );
 		} );
 
 		it( 'should not increment offset if insertion is in the same parent and the same offset', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );
-			const transformed = position.getTransformedByInsertion( new Position( root, [ 1, 2, 3 ] ), 2, false );
+			const transformed = position._getTransformedByInsertion( new Position( root, [ 1, 2, 3 ] ), 2, false );
 
 			expect( transformed.offset ).to.equal( 3 );
 		} );
 
 		it( 'should increment offset if insertion is in the same parent and the same offset and it is inserted before', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );
-			const transformed = position.getTransformedByInsertion( new Position( root, [ 1, 2, 3 ] ), 2, true );
+			const transformed = position._getTransformedByInsertion( new Position( root, [ 1, 2, 3 ] ), 2, true );
 
 			expect( transformed.offset ).to.equal( 5 );
 		} );
 
 		it( 'should not increment offset if insertion is in the same parent and further offset', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );
-			const transformed = position.getTransformedByInsertion( new Position( root, [ 1, 2, 4 ] ), 2, false );
+			const transformed = position._getTransformedByInsertion( new Position( root, [ 1, 2, 4 ] ), 2, false );
 
 			expect( transformed.offset ).to.equal( 3 );
 		} );
 
 		it( 'should update path if insertion position parent is a node from that path and offset is before next node on that path', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );
-			const transformed = position.getTransformedByInsertion( new Position( root, [ 1, 2 ] ), 2, false );
+			const transformed = position._getTransformedByInsertion( new Position( root, [ 1, 2 ] ), 2, false );
 
 			expect( transformed.path ).to.deep.equal( [ 1, 4, 3 ] );
 		} );
 
 		it( 'should not update path if insertion position parent is a node from that path and offset is after next node on that path', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );
-			const transformed = position.getTransformedByInsertion( new Position( root, [ 1, 3 ] ), 2, false );
+			const transformed = position._getTransformedByInsertion( new Position( root, [ 1, 3 ] ), 2, false );
 
 			expect( transformed.path ).to.deep.equal( [ 1, 2, 3 ] );
 		} );
 	} );
 
-	describe( 'getTransformedByDeletion', () => {
+	describe( '_getTransformedByDeletion', () => {
 		it( 'should return a new Position instance', () => {
 			const position = new Position( root, [ 0 ] );
-			const transformed = position.getTransformedByDeletion( new Position( root, [ 2 ] ), 4 );
+			const transformed = position._getTransformedByDeletion( new Position( root, [ 2 ] ), 4 );
 
 			expect( transformed ).not.to.equal( position );
 			expect( transformed ).to.be.instanceof( Position );
@@ -559,86 +603,86 @@ describe( 'position', () => {
 
 		it( 'should return null if original position is inside one of removed nodes', () => {
 			const position = new Position( root, [ 1, 2 ] );
-			const transformed = position.getTransformedByDeletion( new Position( root, [ 0 ] ), 2 );
+			const transformed = position._getTransformedByDeletion( new Position( root, [ 0 ] ), 2 );
 
 			expect( transformed ).to.be.null;
 		} );
 
 		it( 'should decrement offset if deletion is in the same parent and closer offset', () => {
 			const position = new Position( root, [ 1, 2, 7 ] );
-			const transformed = position.getTransformedByDeletion( new Position( root, [ 1, 2, 2 ] ), 2 );
+			const transformed = position._getTransformedByDeletion( new Position( root, [ 1, 2, 2 ] ), 2 );
 
 			expect( transformed.offset ).to.equal( 5 );
 		} );
 
 		it( 'should return null if original position is between removed nodes', () => {
 			const position = new Position( root, [ 1, 2, 4 ] );
-			const transformed = position.getTransformedByDeletion( new Position( root, [ 1, 2, 3 ] ), 5 );
+			const transformed = position._getTransformedByDeletion( new Position( root, [ 1, 2, 3 ] ), 5 );
 
 			expect( transformed ).to.be.null;
 		} );
 
 		it( 'should not decrement offset if deletion position is in different root', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );
-			const transformed = position.getTransformedByDeletion( new Position( otherRoot, [ 1, 2, 1 ] ), 2 );
+			const transformed = position._getTransformedByDeletion( new Position( otherRoot, [ 1, 2, 1 ] ), 2 );
 
 			expect( transformed.offset ).to.equal( 3 );
 		} );
 
 		it( 'should not decrement offset if deletion is in the same parent and further offset', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );
-			const transformed = position.getTransformedByDeletion( new Position( root, [ 1, 2, 4 ] ), 2 );
+			const transformed = position._getTransformedByDeletion( new Position( root, [ 1, 2, 4 ] ), 2 );
 
 			expect( transformed.offset ).to.equal( 3 );
 		} );
 
 		it( 'should update path if deletion position parent is a node from that path and offset is before next node on that path', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );
-			const transformed = position.getTransformedByDeletion( new Position( root, [ 1, 0 ] ), 2 );
+			const transformed = position._getTransformedByDeletion( new Position( root, [ 1, 0 ] ), 2 );
 
 			expect( transformed.path ).to.deep.equal( [ 1, 0, 3 ] );
 		} );
 
 		it( 'should not update path if deletion position parent is a node from that path and offset is after next node on that path', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );
-			const transformed = position.getTransformedByDeletion( new Position( root, [ 1, 3 ] ), 2 );
+			const transformed = position._getTransformedByDeletion( new Position( root, [ 1, 3 ] ), 2 );
 
 			expect( transformed.path ).to.deep.equal( [ 1, 2, 3 ] );
 		} );
 	} );
 
-	describe( 'getTransformedByMove', () => {
+	describe( '_getTransformedByMove', () => {
 		it( 'should increment offset if a range was moved to the same parent and closer offset', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );
-			const transformed = position.getTransformedByMove( new Position( root, [ 2 ] ), new Position( root, [ 1, 2, 0 ] ), 3, false );
+			const transformed = position._getTransformedByMove( new Position( root, [ 2 ] ), new Position( root, [ 1, 2, 0 ] ), 3, false );
 
 			expect( transformed.path ).to.deep.equal( [ 1, 2, 6 ] );
 		} );
 
 		it( 'should decrement offset if a range was moved from the same parent and closer offset', () => {
 			const position = new Position( root, [ 1, 2, 6 ] );
-			const transformed = position.getTransformedByMove( new Position( root, [ 1, 2, 0 ] ), new Position( root, [ 2 ] ), 3, false );
+			const transformed = position._getTransformedByMove( new Position( root, [ 1, 2, 0 ] ), new Position( root, [ 2 ] ), 3, false );
 
 			expect( transformed.path ).to.deep.equal( [ 1, 2, 3 ] );
 		} );
 
 		it( 'should decrement offset if position was at the end of a range and move was not sticky', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );
-			const transformed = position.getTransformedByMove( new Position( root, [ 1, 2, 0 ] ), new Position( root, [ 2 ] ), 3, false );
+			const transformed = position._getTransformedByMove( new Position( root, [ 1, 2, 0 ] ), new Position( root, [ 2 ] ), 3, false );
 
 			expect( transformed.path ).to.deep.equal( [ 1, 2, 0 ] );
 		} );
 
 		it( 'should update path if position was at the end of a range and move was sticky', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );
-			const transformed = position.getTransformedByMove( new Position( root, [ 1, 2, 0 ] ), new Position( root, [ 2 ] ), 3, false, true );
+			const transformed = position._getTransformedByMove( new Position( root, [ 1, 2, 0 ] ), new Position( root, [ 2 ] ), 3, false, true );
 
 			expect( transformed.path ).to.deep.equal( [ 5 ] );
 		} );
 
 		it( 'should update path if a range contained this position', () => {
 			const position = new Position( root, [ 1, 2, 3 ] );
-			const transformed = position.getTransformedByMove( new Position( root, [ 1, 1 ] ), new Position( root, [ 2, 1 ] ), 3, false );
+			const transformed = position._getTransformedByMove( new Position( root, [ 1, 1 ] ), new Position( root, [ 2, 1 ] ), 3, false );
 
 			expect( transformed.path ).to.deep.equal( [ 2, 2, 3 ] );
 		} );

+ 39 - 45
packages/ckeditor5-engine/tests/model/range.js

@@ -129,19 +129,13 @@ describe( 'Range', () => {
 	} );
 
 	describe( 'static constructors', () => {
-		let p, f, o, z;
+		let p;
 
 		// root
 		//  |- p
-		//     |- f
-		//     |- o
-		//     |- z
+		//     |- foz
 		beforeEach( () => {
-			f = new Text( 'f' );
-			o = new Text( 'o' );
-			z = new Text( 'z' );
-
-			p = new Element( 'p', [], [ f, o, z ] );
+			p = new Element( 'p', [], new Text( 'foz' ) );
 
 			root.insertChildren( 0, [ p ] );
 		} );
@@ -273,9 +267,9 @@ describe( 'Range', () => {
 			let items = Array.from( range.getItems( { singleCharacters: true } ) );
 
 			expect( items.length ).to.equal( 3 );
-			expect( items[ 0 ].character ).to.equal( 'b' );
+			expect( items[ 0 ].data ).to.equal( 'b' );
 			expect( items[ 1 ] ).to.equal( e2 );
-			expect( items[ 2 ].character ).to.equal( 'x' );
+			expect( items[ 2 ].data ).to.equal( 'x' );
 		} );
 	} );
 
@@ -343,10 +337,10 @@ describe( 'Range', () => {
 		} );
 	} );
 
-	describe( 'getTransformedByInsertion', () => {
+	describe( '_getTransformedByInsertion', () => {
 		it( 'should return an array of Range objects', () => {
 			const range = new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) );
-			const transformed = range.getTransformedByInsertion( new Position( root, [ 2 ] ), 2 );
+			const transformed = range._getTransformedByInsertion( new Position( root, [ 2 ] ), 2 );
 
 			expect( transformed ).to.be.instanceof( Array );
 			expect( transformed[ 0 ] ).to.be.instanceof( Range );
@@ -354,7 +348,7 @@ describe( 'Range', () => {
 
 		it( 'should update it\'s positions offsets if insertion is before range and they are affected', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 4 ] ) );
-			const transformed = range.getTransformedByInsertion( new Position( root, [ 3, 1 ] ), 2 );
+			const transformed = range._getTransformedByInsertion( new Position( root, [ 3, 1 ] ), 2 );
 
 			expect( transformed[ 0 ].start.offset ).to.equal( 4 );
 			expect( transformed[ 0 ].end.offset ).to.equal( 6 );
@@ -362,7 +356,7 @@ describe( 'Range', () => {
 
 		it( 'should update it\'s positions paths if insertion is before range and they are affected', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 4, 4 ] ) );
-			const transformed = range.getTransformedByInsertion( new Position( root, [ 0 ] ), 2 );
+			const transformed = range._getTransformedByInsertion( new Position( root, [ 0 ] ), 2 );
 
 			expect( transformed[ 0 ].start.path[ 0 ] ).to.equal( 5 );
 			expect( transformed[ 0 ].end.path[ 0 ] ).to.equal( 6 );
@@ -370,7 +364,7 @@ describe( 'Range', () => {
 
 		it( 'should expand range if insertion was in the middle of range', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 5, 4 ] ) );
-			const transformed = range.getTransformedByInsertion( new Position( root, [ 5, 0 ] ), 4 );
+			const transformed = range._getTransformedByInsertion( new Position( root, [ 5, 0 ] ), 4 );
 
 			expect( transformed.length ).to.equal( 1 );
 
@@ -380,7 +374,7 @@ describe( 'Range', () => {
 
 		it( 'should return array with two ranges if insertion was in the middle of range and spread flag was set', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 5, 4 ] ) );
-			const transformed = range.getTransformedByInsertion( new Position( root, [ 4, 1, 6 ] ), 4, true );
+			const transformed = range._getTransformedByInsertion( new Position( root, [ 4, 1, 6 ] ), 4, true );
 
 			expect( transformed.length ).to.equal( 2 );
 
@@ -393,7 +387,7 @@ describe( 'Range', () => {
 
 		it( 'should not expand range if insertion is equal to start boundary of the range', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 8 ] ) );
-			const transformed = range.getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 3 );
+			const transformed = range._getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 3 );
 
 			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 5 ] );
 			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 3, 11 ] );
@@ -401,7 +395,7 @@ describe( 'Range', () => {
 
 		it( 'should expand range if insertion is equal to start boundary of the range and sticky flag is set', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 8 ] ) );
-			const transformed = range.getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 3, false, true );
+			const transformed = range._getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 3, false, true );
 
 			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
 			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 3, 11 ] );
@@ -409,7 +403,7 @@ describe( 'Range', () => {
 
 		it( 'should not update positions if insertion is before range (but not equal to the start boundary)', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 8 ] ) );
-			const transformed = range.getTransformedByInsertion( new Position( root, [ 3, 1 ] ), 3 );
+			const transformed = range._getTransformedByInsertion( new Position( root, [ 3, 1 ] ), 3 );
 
 			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 5 ] );
 			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 3, 11 ] );
@@ -417,7 +411,7 @@ describe( 'Range', () => {
 
 		it( 'should not expand range if insertion is equal to end boundary of the range', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 4, 4 ] ) );
-			const transformed = range.getTransformedByInsertion( new Position( root, [ 4, 4 ] ), 3 );
+			const transformed = range._getTransformedByInsertion( new Position( root, [ 4, 4 ] ), 3 );
 
 			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
 			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 4, 4 ] );
@@ -425,7 +419,7 @@ describe( 'Range', () => {
 
 		it( 'should expand range if insertion is equal to end boundary of the range and sticky flag is set', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 4, 4 ] ) );
-			const transformed = range.getTransformedByInsertion( new Position( root, [ 4, 4 ] ), 3, false, true );
+			const transformed = range._getTransformedByInsertion( new Position( root, [ 4, 4 ] ), 3, false, true );
 
 			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
 			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 4, 7 ] );
@@ -433,7 +427,7 @@ describe( 'Range', () => {
 
 		it( 'should not update positions if insertion is after range (but not equal to the end boundary)', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 4, 4 ] ) );
-			const transformed = range.getTransformedByInsertion( new Position( root, [ 4, 5 ] ), 3 );
+			const transformed = range._getTransformedByInsertion( new Position( root, [ 4, 5 ] ), 3 );
 
 			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
 			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 4, 4 ] );
@@ -441,7 +435,7 @@ describe( 'Range', () => {
 
 		it( 'should not change if the range is collapsed and isSticky is false', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 2 ] ) );
-			const transformed = range.getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 3, false, false );
+			const transformed = range._getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 3, false, false );
 
 			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
 			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 3, 2 ] );
@@ -449,17 +443,17 @@ describe( 'Range', () => {
 
 		it( 'should move after inserted nodes if the range is collapsed and isSticky is true', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 2 ] ) );
-			const transformed = range.getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 3, false, true );
+			const transformed = range._getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 3, false, true );
 
 			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 5 ] );
 			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 3, 5 ] );
 		} );
 	} );
 
-	describe( 'getTransformedByMove', () => {
+	describe( '_getTransformedByMove', () => {
 		it( 'should return an array of Range objects', () => {
 			const range = new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) );
-			const transformed = range.getTransformedByMove( new Position( root, [ 2 ] ), new Position( root, [ 5 ] ), 2 );
+			const transformed = range._getTransformedByMove( new Position( root, [ 2 ] ), new Position( root, [ 5 ] ), 2 );
 
 			expect( transformed ).to.be.instanceof( Array );
 			expect( transformed[ 0 ] ).to.be.instanceof( Range );
@@ -467,7 +461,7 @@ describe( 'Range', () => {
 
 		it( 'should update it\'s positions offsets if target is before range and they are affected', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 4 ] ) );
-			const transformed = range.getTransformedByMove( new Position( root, [ 8, 1 ] ), new Position( root, [ 3, 1 ] ), 2 );
+			const transformed = range._getTransformedByMove( new Position( root, [ 8, 1 ] ), new Position( root, [ 3, 1 ] ), 2 );
 
 			expect( transformed[ 0 ].start.offset ).to.equal( 4 );
 			expect( transformed[ 0 ].end.offset ).to.equal( 6 );
@@ -475,7 +469,7 @@ describe( 'Range', () => {
 
 		it( 'should update it\'s positions paths if target is before range and they are affected', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 4, 4 ] ) );
-			const transformed = range.getTransformedByMove( new Position( root, [ 8 ] ), new Position( root, [ 0 ] ), 2 );
+			const transformed = range._getTransformedByMove( new Position( root, [ 8 ] ), new Position( root, [ 0 ] ), 2 );
 
 			expect( transformed[ 0 ].start.path[ 0 ] ).to.equal( 5 );
 			expect( transformed[ 0 ].end.path[ 0 ] ).to.equal( 6 );
@@ -483,7 +477,7 @@ describe( 'Range', () => {
 
 		it( 'should expand range if target was in the middle of range', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 5, 4 ] ) );
-			const transformed = range.getTransformedByMove( new Position( root, [ 8 ] ), new Position( root, [ 5, 0 ] ), 4 );
+			const transformed = range._getTransformedByMove( new Position( root, [ 8 ] ), new Position( root, [ 5, 0 ] ), 4 );
 
 			expect( transformed.length ).to.equal( 1 );
 
@@ -493,7 +487,7 @@ describe( 'Range', () => {
 
 		it( 'should not expand range if insertion is equal to start boundary of the range', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 8 ] ) );
-			const transformed = range.getTransformedByMove( new Position( root, [ 8, 2 ] ), new Position( root, [ 3, 2 ] ), 3 );
+			const transformed = range._getTransformedByMove( new Position( root, [ 8, 2 ] ), new Position( root, [ 3, 2 ] ), 3 );
 
 			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 5 ] );
 			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 3, 11 ] );
@@ -501,7 +495,7 @@ describe( 'Range', () => {
 
 		it( 'should not expand range if insertion is equal to end boundary of the range', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 4, 4 ] ) );
-			const transformed = range.getTransformedByMove( new Position( root, [ 8, 4 ] ), new Position( root, [ 4, 4 ] ), 3 );
+			const transformed = range._getTransformedByMove( new Position( root, [ 8, 4 ] ), new Position( root, [ 4, 4 ] ), 3 );
 
 			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
 			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 4, 4 ] );
@@ -509,7 +503,7 @@ describe( 'Range', () => {
 
 		it( 'should update it\'s positions offsets if source is before range and they are affected', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 4 ] ) );
-			const transformed = range.getTransformedByMove( new Position( root, [ 3, 0 ] ), new Position( root, [ 8, 1 ] ), 2 );
+			const transformed = range._getTransformedByMove( new Position( root, [ 3, 0 ] ), new Position( root, [ 8, 1 ] ), 2 );
 
 			expect( transformed[ 0 ].start.offset ).to.equal( 0 );
 			expect( transformed[ 0 ].end.offset ).to.equal( 2 );
@@ -517,7 +511,7 @@ describe( 'Range', () => {
 
 		it( 'should update it\'s positions paths if source is before range and they are affected', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 4, 4 ] ) );
-			const transformed = range.getTransformedByMove( new Position( root, [ 0 ] ), new Position( root, [ 8 ] ), 2 );
+			const transformed = range._getTransformedByMove( new Position( root, [ 0 ] ), new Position( root, [ 8 ] ), 2 );
 
 			expect( transformed[ 0 ].start.path[ 0 ] ).to.equal( 1 );
 			expect( transformed[ 0 ].end.path[ 0 ] ).to.equal( 2 );
@@ -525,7 +519,7 @@ describe( 'Range', () => {
 
 		it( 'should shrink range if source was in the middle of range', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 5, 4 ] ) );
-			const transformed = range.getTransformedByMove( new Position( root, [ 5, 0 ] ), new Position( root, [ 8 ] ), 4 );
+			const transformed = range._getTransformedByMove( new Position( root, [ 5, 0 ] ), new Position( root, [ 8 ] ), 4 );
 
 			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
 			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 5, 0 ] );
@@ -533,7 +527,7 @@ describe( 'Range', () => {
 
 		it( 'should shrink range if source contained range start position', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 5, 4 ] ) );
-			const transformed = range.getTransformedByMove( new Position( root, [ 3, 1 ] ), new Position( root, [ 8 ] ), 2 );
+			const transformed = range._getTransformedByMove( new Position( root, [ 3, 1 ] ), new Position( root, [ 8 ] ), 2 );
 
 			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 1 ] );
 			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 5, 4 ] );
@@ -541,7 +535,7 @@ describe( 'Range', () => {
 
 		it( 'should shrink range if source contained range end position', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 5, 4 ] ) );
-			const transformed = range.getTransformedByMove( new Position( root, [ 5, 3 ] ), new Position( root, [ 8 ] ), 2 );
+			const transformed = range._getTransformedByMove( new Position( root, [ 5, 3 ] ), new Position( root, [ 8 ] ), 2 );
 
 			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
 			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 5, 3 ] );
@@ -549,7 +543,7 @@ describe( 'Range', () => {
 
 		it( 'should move range if it was contained in moved range', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 7 ] ) );
-			const transformed = range.getTransformedByMove( new Position( root, [ 3 ] ), new Position( root, [ 6 ] ), 2 );
+			const transformed = range._getTransformedByMove( new Position( root, [ 3 ] ), new Position( root, [ 6 ] ), 2 );
 
 			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 4, 2 ] );
 			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 4, 7 ] );
@@ -734,21 +728,21 @@ describe( 'Range', () => {
 
 	function mapNodesToNames( nodes ) {
 		return nodes.map( ( node ) => {
-			return ( node instanceof Element ) ? 'E:' + node.name : 'T:' + ( node.text || node.character );
+			return ( node instanceof Element ) ? 'E:' + node.name : 'T:' + node.data;
 		} );
 	}
 
 	function prepareRichRoot() {
 		root.insertChildren( 0, [
 			new Element( 'div', [], [
-				new Element( 'h', [], 'first' ),
-				new Element( 'p', [], 'lorem ipsum' )
+				new Element( 'h', [], new Text( 'first' ) ),
+				new Element( 'p', [], new Text( 'lorem ipsum' ) )
 			] ),
-			new Element( 'p', [], 'foo' ),
-			new Element( 'p', [], 'bar' ),
+			new Element( 'p', [], new Text( 'foo' ) ),
+			new Element( 'p', [], new Text( 'bar' ) ),
 			new Element( 'div', [], [
-				new Element( 'h', [], 'second' ),
-				new Element( 'p', [], 'lorem' )
+				new Element( 'h', [], new Text( 'second' ) ),
+				new Element( 'p', [], new Text( 'lorem' ) )
 			] )
 		] );
 	}

+ 0 - 0
packages/ckeditor5-engine/tests/model/rootelement.js


Some files were not shown because too many files changed in this diff