Selaa lähdekoodia

Merge branch 'master' into t/450

Kamil Piechaczek 9 vuotta sitten
vanhempi
sitoutus
8fa397fa99
100 muutettua tiedostoa jossa 3670 lisäystä ja 1971 poistoa
  1. 1 1
      packages/ckeditor5-engine/package.json
  2. 1 1
      packages/ckeditor5-engine/src/conversion/model-converter-builder.js
  3. 5 4
      packages/ckeditor5-engine/src/conversion/model-selection-to-view-converters.js
  4. 9 8
      packages/ckeditor5-engine/src/conversion/model-to-view-converters.js
  5. 2 2
      packages/ckeditor5-engine/src/conversion/modelconsumable.js
  6. 2 2
      packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js
  7. 2 11
      packages/ckeditor5-engine/src/datacontroller.js
  8. 0 1
      packages/ckeditor5-engine/src/editingcontroller.js
  9. 31 7
      packages/ckeditor5-engine/src/model/batch.js
  10. 10 1
      packages/ckeditor5-engine/src/model/delta/basic-transformations.js
  11. 15 12
      packages/ckeditor5-engine/src/model/document.js
  12. 135 69
      packages/ckeditor5-engine/src/model/history.js
  13. 439 0
      packages/ckeditor5-engine/src/model/liveselection.js
  14. 76 4
      packages/ckeditor5-engine/src/model/operation/removeoperation.js
  15. 40 3
      packages/ckeditor5-engine/src/model/operation/transform.js
  16. 81 354
      packages/ckeditor5-engine/src/model/selection.js
  17. 29 34
      packages/ckeditor5-engine/src/model/treewalker.js
  18. 1 1
      packages/ckeditor5-engine/src/view/attributeelement.js
  19. 3 3
      packages/ckeditor5-engine/src/view/containerelement.js
  20. 48 18
      packages/ckeditor5-engine/src/view/document.js
  21. 51 8
      packages/ckeditor5-engine/src/view/domconverter.js
  22. 14 3
      packages/ckeditor5-engine/src/view/editableelement.js
  23. 2 2
      packages/ckeditor5-engine/src/view/filler.js
  24. 6 5
      packages/ckeditor5-engine/src/view/observer/focusobserver.js
  25. 1 5
      packages/ckeditor5-engine/src/view/observer/selectionobserver.js
  26. 33 2
      packages/ckeditor5-engine/src/view/position.js
  27. 59 14
      packages/ckeditor5-engine/src/view/renderer.js
  28. 14 0
      packages/ckeditor5-engine/src/view/selection.js
  29. 105 0
      packages/ckeditor5-engine/src/view/textproxy.js
  30. 447 0
      packages/ckeditor5-engine/src/view/treewalker.js
  31. 477 492
      packages/ckeditor5-engine/src/view/writer.js
  32. 2 2
      packages/ckeditor5-engine/tests/_utils-tests/model.js
  33. 6 0
      packages/ckeditor5-engine/tests/_utils-tests/view.js
  34. 1 1
      packages/ckeditor5-engine/tests/_utils/model.js
  35. 1 1
      packages/ckeditor5-engine/tests/_utils/view.js
  36. 11 13
      packages/ckeditor5-engine/tests/conversion/advanced-converters.js
  37. 2 3
      packages/ckeditor5-engine/tests/conversion/model-converter-builder.js
  38. 5 5
      packages/ckeditor5-engine/tests/conversion/model-selection-to-view-converters.js
  39. 37 6
      packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js
  40. 4 3
      packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js
  41. 1 1
      packages/ckeditor5-engine/tests/conversion/view-converter-builder.js
  42. 2 2
      packages/ckeditor5-engine/tests/datacontroller.js
  43. 1 1
      packages/ckeditor5-engine/tests/dataprocessor/htmldataprocessor.js
  44. 2 2
      packages/ckeditor5-engine/tests/editingcontroller.js
  45. 16 0
      packages/ckeditor5-engine/tests/model/_utils/utils.js
  46. 32 0
      packages/ckeditor5-engine/tests/model/batch.js
  47. 1 1
      packages/ckeditor5-engine/tests/model/composer/composer.js
  48. 1 1
      packages/ckeditor5-engine/tests/model/composer/deletecontents.js
  49. 1 1
      packages/ckeditor5-engine/tests/model/composer/modifyselection.js
  50. 1 1
      packages/ckeditor5-engine/tests/model/delta/attributedelta.js
  51. 1 1
      packages/ckeditor5-engine/tests/model/delta/delta.js
  52. 1 1
      packages/ckeditor5-engine/tests/model/delta/deltafactory.js
  53. 2 2
      packages/ckeditor5-engine/tests/model/delta/insertdelta.js
  54. 2 2
      packages/ckeditor5-engine/tests/model/delta/mergedelta.js
  55. 2 2
      packages/ckeditor5-engine/tests/model/delta/movedelta.js
  56. 1 1
      packages/ckeditor5-engine/tests/model/delta/removedelta.js
  57. 2 2
      packages/ckeditor5-engine/tests/model/delta/renamedelta.js
  58. 2 2
      packages/ckeditor5-engine/tests/model/delta/splitdelta.js
  59. 1 1
      packages/ckeditor5-engine/tests/model/delta/transform/_utils/utils.js
  60. 1 1
      packages/ckeditor5-engine/tests/model/delta/transform/attributedelta.js
  61. 1 1
      packages/ckeditor5-engine/tests/model/delta/transform/delta.js
  62. 2 2
      packages/ckeditor5-engine/tests/model/delta/transform/insertdelta.js
  63. 3 7
      packages/ckeditor5-engine/tests/model/delta/transform/mergedelta.js
  64. 2 2
      packages/ckeditor5-engine/tests/model/delta/transform/movedelta.js
  65. 2 2
      packages/ckeditor5-engine/tests/model/delta/transform/removedelta.js
  66. 1 1
      packages/ckeditor5-engine/tests/model/delta/transform/splitdelta.js
  67. 4 5
      packages/ckeditor5-engine/tests/model/delta/transform/unwrapdelta.js
  68. 1 1
      packages/ckeditor5-engine/tests/model/delta/transform/weakinsertdelta.js
  69. 2 2
      packages/ckeditor5-engine/tests/model/delta/transform/wrapdelta.js
  70. 2 2
      packages/ckeditor5-engine/tests/model/delta/unwrapdelta.js
  71. 1 1
      packages/ckeditor5-engine/tests/model/delta/weakinsertdelta.js
  72. 2 2
      packages/ckeditor5-engine/tests/model/delta/wrapdelta.js
  73. 19 16
      packages/ckeditor5-engine/tests/model/document/change-event.js
  74. 38 20
      packages/ckeditor5-engine/tests/model/document/document.js
  75. 158 108
      packages/ckeditor5-engine/tests/model/history.js
  76. 2 2
      packages/ckeditor5-engine/tests/model/liveposition.js
  77. 2 2
      packages/ckeditor5-engine/tests/model/liverange.js
  78. 681 0
      packages/ckeditor5-engine/tests/model/liveselection.js
  79. 28 28
      packages/ckeditor5-engine/tests/model/operation/attributeoperation.js
  80. 14 14
      packages/ckeditor5-engine/tests/model/operation/insertoperation.js
  81. 16 16
      packages/ckeditor5-engine/tests/model/operation/moveoperation.js
  82. 3 3
      packages/ckeditor5-engine/tests/model/operation/nooperation.js
  83. 12 12
      packages/ckeditor5-engine/tests/model/operation/reinsertoperation.js
  84. 72 10
      packages/ckeditor5-engine/tests/model/operation/removeoperation.js
  85. 19 19
      packages/ckeditor5-engine/tests/model/operation/rootattributeoperation.js
  86. 58 3
      packages/ckeditor5-engine/tests/model/operation/transform.js
  87. 4 4
      packages/ckeditor5-engine/tests/model/position.js
  88. 2 2
      packages/ckeditor5-engine/tests/model/range.js
  89. 2 2
      packages/ckeditor5-engine/tests/model/schema/schema.js
  90. 30 537
      packages/ckeditor5-engine/tests/model/selection.js
  91. 1 1
      packages/ckeditor5-engine/tests/model/textproxy.js
  92. 1 1
      packages/ckeditor5-engine/tests/model/treewalker.js
  93. 14 2
      packages/ckeditor5-engine/tests/model/utils-tests/utils.js
  94. 8 0
      packages/ckeditor5-engine/tests/tickets/475/475.html
  95. 92 0
      packages/ckeditor5-engine/tests/tickets/475/475.js
  96. 11 0
      packages/ckeditor5-engine/tests/tickets/475/475.md
  97. 3 1
      packages/ckeditor5-engine/tests/view/_utils/createdocumentmock.js
  98. 67 12
      packages/ckeditor5-engine/tests/view/document/document.js
  99. 2 2
      packages/ckeditor5-engine/tests/view/document/jumpoverinlinefiller.js
  100. 7 0
      packages/ckeditor5-engine/tests/view/domconverter/binding.js

+ 1 - 1
packages/ckeditor5-engine/package.json

@@ -1,6 +1,6 @@
 {
   "name": "ckeditor5-engine",
-  "version": "0.0.1-0.dev",
+  "version": "0.1.0",
   "description": "CKEditor 5 Editing Engine",
   "keywords": [
     "CKEditor"

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

@@ -127,7 +127,7 @@ 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
+	 * 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
 	 * 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.

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

@@ -7,6 +7,7 @@
 
 import ViewElement from '../view/element.js';
 import ViewRange from '../view/range.js';
+import viewWriter from '../view/writer.js';
 
 /**
  * Contains {@link engine.model.Selection model selection} to {@link engine.view.Selection view selection} converters for
@@ -85,7 +86,7 @@ export function convertCollapsedSelection() {
 
 		const modelPosition = selection.getFirstPosition();
 		const viewPosition = conversionApi.mapper.toViewPosition( modelPosition );
-		const brokenPosition = conversionApi.writer.breakAttributes( viewPosition );
+		const brokenPosition = viewWriter.breakAt( viewPosition );
 
 		conversionApi.viewSelection.removeAllRanges();
 		conversionApi.viewSelection.addRange( new ViewRange( brokenPosition, brokenPosition ) );
@@ -163,7 +164,7 @@ export function convertSelectionAttribute( elementCreator ) {
 				elementCreator.clone( true ) :
 				elementCreator( data.value, selection, consumable, conversionApi );
 
-		viewPosition = conversionApi.writer.wrapPosition( viewPosition, viewElement );
+		viewPosition = viewWriter.wrapPosition( viewPosition, viewElement );
 
 		conversionApi.viewSelection.addRange( new ViewRange( viewPosition, viewPosition ) );
 	};
@@ -200,9 +201,9 @@ export function clearAttributes() {
 		for ( let range of conversionApi.viewSelection.getRanges() ) {
 			// Not collapsed selection should not have artifacts.
 			if ( range.isCollapsed ) {
-				// Position might be in the node removed by the Writer.
+				// Position might be in the node removed by the view writer.
 				if ( range.end.parent.getDocument() ) {
-					conversionApi.writer.mergeAttributes( range.start );
+					viewWriter.mergeAt( range.start );
 				}
 			}
 		}

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

@@ -10,6 +10,7 @@ import ModelRange from '../model/range.js';
 
 import ViewElement from '../view/element.js';
 import ViewText from '../view/text.js';
+import viewWriter from '../view/writer.js';
 
 /**
  * Contains {@link engine.model model} to {@link engine.view view} converters for
@@ -57,7 +58,7 @@ export function insertElement( elementCreator ) {
 			elementCreator( data, consumable, conversionApi );
 
 		conversionApi.mapper.bindElements( data.item, viewElement );
-		conversionApi.writer.insert( viewPosition, viewElement );
+		viewWriter.insert( viewPosition, viewElement );
 
 		evt.stop();
 	};
@@ -82,7 +83,7 @@ export function insertText() {
 		const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
 		const viewText = new ViewText( data.item.text );
 
-		conversionApi.writer.insert( viewPosition, viewText );
+		viewWriter.insert( viewPosition, viewText );
 
 		evt.stop();
 	};
@@ -212,7 +213,7 @@ export function wrap( elementCreator ) {
 	return ( evt, data, consumable, conversionApi ) => {
 		consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
 
-		const viewRange = conversionApi.mapper.toViewRange( data.range );
+		let viewRange = conversionApi.mapper.toViewRange( data.range );
 
 		const viewElement = ( elementCreator instanceof ViewElement ) ?
 			elementCreator.clone( true ) :
@@ -223,10 +224,10 @@ export function wrap( elementCreator ) {
 		// view element basing on old value and unwrap it before wrapping with a newly created view element.
 		if ( data.attributeOldValue !== null && !( elementCreator instanceof ViewElement ) ) {
 			const oldViewElement = elementCreator( data.attributeOldValue, data, consumable, conversionApi );
-			conversionApi.writer.unwrap( viewRange, oldViewElement, evt.priority );
+			viewRange = viewWriter.unwrap( viewRange, oldViewElement, evt.priority );
 		}
 
-		conversionApi.writer.wrap( viewRange, viewElement, evt.priority );
+		viewWriter.wrap( viewRange, viewElement, evt.priority );
 
 		evt.stop();
 	};
@@ -265,7 +266,7 @@ export function unwrap( elementCreator ) {
 			elementCreator.clone( true ) :
 			elementCreator( data.attributeOldValue, data, consumable, conversionApi );
 
-		conversionApi.writer.unwrap( viewRange, viewNode );
+		viewWriter.unwrap( viewRange, viewNode );
 
 		evt.stop();
 	};
@@ -295,7 +296,7 @@ export function move() {
 		const sourceViewRange = conversionApi.mapper.toViewRange( sourceModelRange );
 		const targetViewPosition = conversionApi.mapper.toViewPosition( data.range.start );
 
-		conversionApi.writer.move( sourceViewRange, targetViewPosition );
+		viewWriter.move( sourceViewRange, targetViewPosition );
 	};
 }
 
@@ -321,7 +322,7 @@ export function remove() {
 		const sourceModelRange = ModelRange.createFromPositionAndShift( data.sourcePosition, length );
 		const sourceViewRange = conversionApi.mapper.toViewRange( sourceModelRange );
 
-		conversionApi.writer.remove( sourceViewRange );
+		viewWriter.remove( sourceViewRange );
 	};
 }
 

+ 2 - 2
packages/ckeditor5-engine/src/conversion/modelconsumable.js

@@ -74,11 +74,11 @@ import TextProxy from '../model/textproxy.js';
  *
  *					conversionApi.mapper.bindElements( modelCaption, viewCaption );
  *					conversionApi.mapper.bindElements( data.item, viewImageHolder );
- *					conversionApi.writer.insert( insertPosition, viewImageHolder );
+ *					viewWriter.insert( insertPosition, viewImageHolder );
  *				}
  *			} else {
  *				conversionApi.mapper.bindElements( data.item, viewImage );
- *				conversionApi.writer.insert( insertPosition, viewImage );
+ *				viewWriter.insert( insertPosition, viewImage );
  *			}
  *
  *			evt.stop();

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

@@ -62,7 +62,7 @@ import extend from '../../utils/lib/lodash/extend.js';
  *			conversionApi.mapper.bindElements( data.item, viewNode );
  *
  *			// Add the newly created view element to the view.
- *			conversionApi.writer.insert( viewPosition, viewElement );
+ *			viewWriter.insert( viewPosition, viewElement );
  *
  *			// Remember to stop the event propagation if the data.item was consumed.
  *			evt.stop();
@@ -96,7 +96,7 @@ import extend from '../../utils/lib/lodash/extend.js';
  *			// different source, `data.item` already existed. This means we are safe to get `viewQuote` from mapper.
  *			const viewQuote = conversionApi.mapper.toViewElement( data.item );
  *			const position = new ViewPosition( viewQuote, viewQuote.getChildCount() );
- *			conversionApi.writer.insert( position, viewSourceBtn );
+ *			viewWriter.insert( position, viewSourceBtn );
  *
  *			evt.stop();
  *		}, 1 );

+ 2 - 11
packages/ckeditor5-engine/src/datacontroller.js

@@ -13,7 +13,6 @@ import { insertText } from './conversion/model-to-view-converters.js';
 import ViewConversionDispatcher from './conversion/viewconversiondispatcher.js';
 import { convertText, convertToModelFragment } from './conversion/view-to-model-converters.js';
 
-import Writer from './view/writer.js';
 import ViewDocumentFragment from './view/documentfragment.js';
 import DomConverter from './view/domconverter.js';
 import { NBSP_FILLER } from './view/filler.js';
@@ -67,14 +66,6 @@ export default class DataController {
 		 */
 		this._mapper = new Mapper();
 
-		/**
-		 * Writer used during the conversion.
-		 *
-		 * @private
-		 * @member {engine.view.Writer} engine.DataController#_writer
-		 */
-		this._writer = new Writer();
-
 		/**
 		 * DOM converter used during the conversion.
 		 *
@@ -97,7 +88,6 @@ export default class DataController {
 		 * @member {engine.conversion.ModelConversionDispatcher} engine.DataController#modelToView
 		 */
 		this.modelToView = new ModelConversionDispatcher( {
-			writer: this._writer,
 			mapper: this._mapper
 		} );
 		this.modelToView.on( 'insert:$text', insertText() );
@@ -171,7 +161,8 @@ export default class DataController {
 		const modelRoot = this.model.getRoot( rootName );
 
 		this.model.enqueueChanges( () => {
-			this.model.batch()
+			// Initial batch should be ignored by features like undo, etc.
+			this.model.batch( 'transparent' )
 				.remove( ModelRange.createFromElement( modelRoot ) )
 				.insert( ModelPosition.createAt( modelRoot, 0 ), this.parse( data ) );
 		} );

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

@@ -91,7 +91,6 @@ export default class EditingController {
 		 * @member {engine.conversion.ModelConversionDispatcher} engine.EditingController#modelToView
 		 */
 		this.modelToView = new ModelConversionDispatcher( {
-			writer: this.view.writer,
 			mapper: this.mapper,
 			viewSelection: this.view.selection
 		} );

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

@@ -35,27 +35,51 @@ export default class Batch {
 	 * Creates Batch instance. Not recommended to use directly, use {@link engine.model.Document#batch} instead.
 	 *
 	 * @param {engine.model.Document} doc Document which this Batch changes.
+	 * @param {'transparent'|'default'} [type='default'] Type of the batch.
 	 */
-	constructor( doc ) {
+	constructor( doc, type = 'default' ) {
 		/**
-		 * Document which this Batch changes.
+		 * Document which this batch changes.
 		 *
-		 * @member {engine.model.Document} engine.model.Batch#doc
 		 * @readonly
+		 * @member {engine.model.Document} engine.model.Batch#doc
 		 */
 		this.doc = doc;
 
 		/**
-		 * Array of deltas which compose Batch.
+		 * Array of deltas which compose this batch.
 		 *
-		 * @member {Array.<engine.model.delta.Delta>} engine.model.Batch#deltas
 		 * @readonly
+		 * @member {Array.<engine.model.delta.Delta>} engine.model.Batch#deltas
 		 */
 		this.deltas = [];
+
+		/**
+		 * Type of the batch.
+		 *
+		 * Can be one of the following values:
+		 * * `'default'` - all "normal" batches, most commonly used type.
+		 * * `'transparent'` - batch that should be ignored by other features, i.e. initial batch or collaborative editing changes.
+		 *
+		 * @readonly
+		 * @member {'transparent'|'default'} engine.model.Batch#type
+		 */
+		this.type = type;
+	}
+
+	/**
+	 * Returns this batch base version, which is equal to the base version of first delta in the batch.
+	 * If there are no deltas in the batch, it returns `null`.
+	 *
+	 * @readonly
+	 * @type {Number|null}
+	 */
+	get baseVersion() {
+		return this.deltas.length > 0 ? this.deltas[ 0 ].baseVersion : null;
 	}
 
 	/**
-	 * Adds delta to the Batch instance. All modification methods (insert, remove, split, etc.) use this method
+	 * Adds delta to the batch instance. All modification methods (insert, remove, split, etc.) use this method
 	 * to add created deltas.
 	 *
 	 * @param {engine.model.delta.Delta} delta Delta to add.
@@ -81,7 +105,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

+ 10 - 1
packages/ckeditor5-engine/src/model/delta/basic-transformations.js

@@ -230,10 +230,19 @@ addTransformationCase( UnwrapDelta, SplitDelta, ( a, b, isStrong ) => {
 	// If incoming unwrap delta tries to unwrap node that got split we should unwrap the original node and the split copy.
 	// This can be achieved either by reverting split and applying unwrap to singular node, or creating additional unwrap delta.
 	if ( compareArrays( a.position.path, b.position.getParentPath() ) === 'SAME' ) {
-		return [
+		const transformed = [
 			b.getReversed(),
 			a.clone()
 		];
+
+		// It's a kind of magic-magic-magic-maaaaagiiic!
+		transformed[ 1 ].operations[ 1 ].targetPosition.path[ 0 ]++;
+		// But seriously, we have to fix RemoveOperation in the second delta because reversed UnwrapDelta creates
+		// MergeDelta which also has RemoveOperation. Those two operations cannot point to the same "holder" element
+		// in the graveyard, so we fix it by hand. This is the only case where it happens in "special" transformation
+		// cases, and it won't happen for "default" transformation apart of RemoveDelta, where it is okay.
+
+		return transformed;
 	}
 
 	return defaultTransform( a, b, isStrong );

+ 15 - 12
packages/ckeditor5-engine/src/model/document.js

@@ -12,7 +12,7 @@ import transformations from './delta/basic-transformations.js'; // jshint ignore
 import RootElement from './rootelement.js';
 import Batch from './batch.js';
 import History from './history.js';
-import Selection from './selection.js';
+import LiveSelection from './liveselection.js';
 import EmitterMixin from '../../utils/emittermixin.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
 import mix from '../../utils/mix.js';
@@ -55,9 +55,9 @@ export default class Document {
 		 * Selection done on this document.
 		 *
 		 * @readonly
-		 * @member {engine.model.Selection} engine.model.Document#selection
+		 * @member {engine.model.LiveSelection} engine.model.Document#selection
 		 */
-		this.selection = new Selection( this );
+		this.selection = new LiveSelection( this );
 
 		/**
 		 * Schema for this document.
@@ -103,15 +103,17 @@ export default class Document {
 		} );
 
 		// Graveyard tree root. Document always have a graveyard root, which stores removed nodes.
-		this.createRoot( graveyardName );
+		this.createRoot( '$root', graveyardName );
 
 		/**
 		 * Document's history.
 		 *
+		 * **Note:** Be aware that deltas applied to the stored deltas might be removed or changed.
+		 *
 		 * @readonly
 		 * @member {engine.model.History} engine.model.Document#history
 		 */
-		this.history = new History();
+		this.history = new History( this );
 	}
 
 	/**
@@ -159,7 +161,7 @@ export default class Document {
 
 		this.version++;
 
-		this.history.addOperation( operation );
+		this.history.addDelta( operation.delta );
 
 		const batch = operation.delta && operation.delta.batch;
 
@@ -172,22 +174,23 @@ export default class Document {
 	/**
 	 * Creates a {@link engine.model.Batch} instance which allows to change the document.
 	 *
+	 * @param {String} [type] Batch type. See {@link engine.model.Batch#type}.
 	 * @returns {engine.model.Batch} Batch instance.
 	 */
-	batch() {
-		return new Batch( this );
+	batch( type ) {
+		return new Batch( this, type );
 	}
 
 	/**
 	 * Creates a new top-level root.
 	 *
-	 * @param {String} [rootName='main'] Unique root name.
 	 * @param {String} [elementName='$root'] Element name. Defaults to `'$root'` which also have
 	 * some basic schema defined (`$block`s are allowed inside the `$root`). Make sure to define a proper
 	 * schema if you use a different name.
+	 * @param {String} [rootName='main'] Unique root name.
 	 * @returns {engine.model.RootElement} Created root.
 	 */
-	createRoot( rootName = 'main', elementName = '$root' ) {
+	createRoot( elementName = '$root', rootName = 'main' ) {
 		if ( this._roots.has( rootName ) ) {
 			/**
 			 * Root with specified name already exists.
@@ -282,9 +285,9 @@ export default class Document {
 		const json = clone( this );
 
 		// Due to circular references we need to remove parent reference.
-		json.selection = '[engine.model.Selection]';
+		json.selection = '[engine.model.LiveSelection]';
 
-		return {};
+		return json;
 	}
 
 	/**

+ 135 - 69
packages/ckeditor5-engine/src/model/history.js

@@ -5,17 +5,16 @@
 
 'use strict';
 
-// Load all basic deltas and transformations, they register themselves, but they need to be imported somewhere.
-import deltas from './delta/basic-deltas.js'; // jshint ignore:line
-import transformations from './delta/basic-transformations.js'; // jshint ignore:line
-
-import transform from './delta/transform.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
 
 /**
- * History keeps the track of all the deltas applied to the {@link engine.model.Document document} and provides
- * utility tools to operate on the history. Most of times history is needed to transform a delta that has wrong
- * {@link engine.model.delta.Delta#baseVersion} to a state where it can be applied to the document.
+ * `History` keeps the track of all the deltas applied to the {@link engine.model.Document document}. Deltas stored in
+ * `History` might get updated, split into more deltas or even removed. This is used mostly to compress history, instead
+ * of keeping all deltas in a state in which they were applied.
+ *
+ * **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}.
  *
  * @memberOf engine.model
  */
@@ -27,7 +26,7 @@ export default class History {
 		/**
 		 * Deltas added to the history.
 		 *
-		 * @private
+		 * @protected
 		 * @member {Array.<engine.model.delta.Delta>} engine.model.History#_deltas
 		 */
 		this._deltas = [];
@@ -36,109 +35,176 @@ export default class History {
 		 * Helper structure that maps added delta's base version to the index in {@link engine.model.History#_deltas}
 		 * at which the delta was added.
 		 *
-		 * @private
+		 * @protected
 		 * @member {Map} engine.model.History#_historyPoints
 		 */
 		this._historyPoints = new Map();
 	}
 
 	/**
-	 * Gets the number of base version which an up-to-date operation should have.
+	 * Adds delta to the history.
 	 *
-	 * @private
-	 * @type {Number}
+	 * @param {engine.model.delta.Delta} delta Delta to add.
 	 */
-	get _nextHistoryPoint() {
-		const lastDelta = this._deltas[ this._deltas.length - 1 ];
+	addDelta( delta ) {
+		if ( delta.operations.length > 0 && !this._historyPoints.has( delta.baseVersion ) ) {
+			const index = this._deltas.length;
 
-		return lastDelta.baseVersion + lastDelta.operations.length;
+			this._deltas[ index ] = delta;
+			this._historyPoints.set( delta.baseVersion, index );
+		}
 	}
 
 	/**
-	 * Adds an operation to the history.
+	 * Returns deltas added to the history.
 	 *
-	 * @param {engine.model.operation.Operation} operation Operation to add.
+	 * @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.
+	 * @returns {Iterator.<engine.model.delta.Delta>} Deltas added to the history.
 	 */
-	addOperation( operation ) {
-		const delta = operation.delta;
-
-		// History cares about deltas not singular operations.
-		// Operations from a delta are added one by one, from first to last.
-		// Operations from one delta cannot be mixed with operations from other deltas.
-		// This all leads us to the conclusion that we could just save deltas history.
-		// What is more, we need to check only the last position in history to check if delta is already in the history.
-		if ( delta && this._deltas[ this._deltas.length - 1 ] !== delta ) {
-			const index = this._deltas.length;
+	*getDeltas( from = 0, to = Number.POSITIVE_INFINITY ) {
+		// No deltas added, nothing to yield.
+		if ( this._deltas.length === 0 ) {
+			return;
+		}
 
-			this._deltas[ index ] = delta;
-			this._historyPoints.set( delta.baseVersion, index );
+		// Will throw if base version is incorrect.
+		let fromIndex = this._getIndex( from );
+
+		// Base version is too low or too high and is not found in history.
+		if ( fromIndex == -1 ) {
+			return;
+		}
+
+		// We have correct `fromIndex` so let's iterate starting from it.
+		while ( fromIndex < this._deltas.length ) {
+			const delta = this._deltas[ fromIndex++ ];
+
+			if ( delta.baseVersion >= to ) {
+				break;
+			}
+
+			yield delta;
 		}
 	}
 
 	/**
-	 * Transforms out-dated delta by all deltas that were added to the history since the given delta's base version. In other
-	 * words, it makes the delta up-to-date with the history. The transformed delta(s) is (are) ready to be applied
-	 * to the {@link engine.model.Document document}.
+	 * Returns one or more deltas from history that bases on given `baseVersion`. Most often it will be just
+	 * one delta, but if that delta got updated by multiple deltas, all of those updated deltas will be returned.
 	 *
-	 * @param {engine.model.delta.Delta} delta Delta to update.
-	 * @returns {Array.<engine.model.delta.Delta>} Result of transformation which is an array containing one or more deltas.
+	 * @see engine.model.History#updateDelta
+	 * @param {Number} baseVersion Base version of the delta to retrieve.
+	 * @returns {Array.<engine.model.delta.Delta>|null} Delta with given base version or null if no such delta is in history.
 	 */
-	getTransformedDelta( delta ) {
-		if ( delta.baseVersion === this._nextHistoryPoint ) {
-			return [ delta ];
+	getDelta( baseVersion ) {
+		let index = this._historyPoints.get( baseVersion );
+
+		if ( index === undefined ) {
+			return null;
 		}
 
-		let transformed = [ delta ];
+		const deltas = [];
 
-		for ( let historyDelta of this.getDeltas( delta.baseVersion ) ) {
-			let allResults = [];
+		for ( index; index < this._deltas.length; index++ ) {
+			const delta = this._deltas[ index ];
 
-			for ( let deltaToTransform of transformed ) {
-				const transformedDelta = History._transform( deltaToTransform, historyDelta );
-				allResults = allResults.concat( transformedDelta );
+			if ( delta.baseVersion != baseVersion ) {
+				break;
 			}
 
-			transformed = allResults;
+			deltas.push( delta );
 		}
 
-		// Fix base versions.
-		let baseVersion = transformed[ 0 ].operations[ 0 ].baseVersion;
-
-		for ( let i = 0; i < transformed.length; i++ ) {
-			transformed[ i ].baseVersion = baseVersion;
-			baseVersion += transformed[ i ].operations.length;
-		}
+		return deltas.length === 0 ? null : deltas;
+	}
 
-		return transformed;
+	/**
+	 * Removes delta from the history. This happens i.e., when a delta is undone by another delta. Both undone delta and
+	 * undoing delta should be removed so they won't have an impact on transforming other deltas.
+	 *
+	 * **Note:** using this method does not change the state of {@link engine.model.Document model}. It just affects
+	 * the state of `History`.
+	 *
+	 * **Note:** when some deltas are removed, deltas between them should probably get updated. See
+	 * {@link engine.model.History#updateDelta}.
+	 *
+	 * **Note:** if delta with `baseVersion` got {@link engine.model.History#updateDelta updated} by multiple
+	 * deltas, all updated deltas will be removed.
+	 *
+	 * @param {Number} baseVersion Base version of a delta to be removed.
+	 */
+	removeDelta( baseVersion ) {
+		this.updateDelta( baseVersion, [] );
 	}
 
 	/**
-	 * Returns all deltas from history, starting from given history point (if passed).
+	 * Substitutes delta in history by one or more given deltas.
+	 *
+	 * **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.
 	 *
-	 * @param {Number} from History point.
-	 * @returns {Iterator.<engine.model.delta.Delta>} Deltas from given history point to the end of history.
+	 * **Note:** delta marked as reversed 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.
 	 */
-	*getDeltas( from = 0 ) {
-		let i = this._historyPoints.get( from );
+	updateDelta( baseVersion, updatedDeltas ) {
+		const deltas = this.getDelta( baseVersion );
 
-		if ( i === undefined ) {
-			throw new CKEditorError( 'history-wrong-version: Cannot retrieve given point in the history.' );
+		// If there are no deltas, stop executing function as there is nothing to update.
+		if ( deltas === null ) {
+			return;
 		}
 
-		for ( ; i < this._deltas.length; i++ ) {
-			yield this._deltas[ i ];
+		// Make sure that every updated delta has correct `baseVersion`.
+		// This is crucial for algorithms in `History` and algorithms using `History`.
+		for ( let delta of updatedDeltas ) {
+			delta.baseVersion = baseVersion;
+		}
+
+		// Put updated deltas in place of old deltas.
+		this._deltas.splice( this._getIndex( baseVersion ), deltas.length, ...updatedDeltas );
+
+		// Update history points.
+		const changeBy = updatedDeltas.length - deltas.length;
+
+		for ( let key of this._historyPoints.keys() ) {
+			if ( key > baseVersion ) {
+				this._historyPoints.set( key, this._historyPoints.get( key ) + changeBy );
+			}
 		}
 	}
 
 	/**
-	 * Transforms given delta by another given delta. Exposed for testing purposes.
+	 * Gets an index in {@link engine.model.History#_deltas} where delta with given `baseVersion` is added.
 	 *
-	 * @protected
-	 * @param {engine.model.delta.Delta} toTransform Delta to be transformed.
-	 * @param {engine.model.delta.Delta} transformBy Delta to transform by.
-	 * @returns {Array.<engine.model.delta.Delta>} Result of the transformation.
+	 * @private
+	 * @param {Number} baseVersion Base version of delta.
 	 */
-	static _transform( toTransform, transformBy ) {
-		return transform( toTransform, transformBy, true );
+	_getIndex( baseVersion ) {
+		let index = this._historyPoints.get( baseVersion );
+
+		// Base version not found - it is either too high or too low, or is in the middle of delta.
+		if ( index === undefined ) {
+			const lastDelta = this._deltas[ this._deltas.length - 1 ];
+			const nextBaseVersion = lastDelta.baseVersion + lastDelta.operations.length;
+
+			if ( baseVersion < 0 || baseVersion >= nextBaseVersion ) {
+				// Base version is too high or too low - it's acceptable situation.
+				// Return -1 because `baseVersion` was correct.
+				return -1;
+			}
+
+			/**
+			 * Given base version points to the middle of a delta.
+			 *
+			 * @error history-wrong-version
+			 */
+			throw new CKEditorError( 'history-wrong-version: Given base version points to the middle of a delta.' );
+		}
+
+		return index;
 	}
 }

+ 439 - 0
packages/ckeditor5-engine/src/model/liveselection.js

@@ -0,0 +1,439 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import LiveRange from './liverange.js';
+import Range from './range.js';
+import Position from './position.js';
+import CharacterProxy from './characterproxy.js';
+import toMap from '../../utils/tomap.js';
+
+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
+ * {@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.
+ *
+ * @memberOf engine.model
+ */
+export default class LiveSelection extends Selection {
+	/**
+	 * Creates an empty document selection for given {@link engine.model.Document}.
+	 *
+	 * @param {engine.model.Document} document Document which owns this selection.
+	 */
+	constructor( document ) {
+		super();
+
+		/**
+		 * Document which owns this selection.
+		 *
+		 * @private
+		 * @member {engine.model.Document} engine.model.Selection#_document
+		 */
+		this._document = document;
+
+		/**
+		 * List of attributes set on current selection.
+		 *
+		 * @protected
+		 * @member {Map} engine.model.LiveSelection#_attrs
+		 */
+		this._attrs = new Map();
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	get isCollapsed() {
+		const length = this._ranges.length;
+
+		return length === 0 ? true : super.isCollapsed;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	get anchor() {
+		return super.anchor || this._getDefaultRange().start;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	get focus() {
+		return super.focus || this._getDefaultRange().start;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	get rangeCount() {
+		return this._ranges.length ? this._ranges.length : 1;
+	}
+
+	/**
+	 * Unbinds all events previously bound by document selection.
+	 */
+	destroy() {
+		for ( let i = 0; i < this._ranges.length; i++ ) {
+			this._ranges[ i ].detach();
+		}
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	*getRanges() {
+		if ( this._ranges.length ) {
+			yield *super.getRanges();
+		} else {
+			yield this._getDefaultRange();
+		}
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	getFirstRange() {
+		return super.getFirstRange() || this._getDefaultRange();
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	removeAllRanges() {
+		this.destroy();
+		super.removeAllRanges();
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	setRanges( newRanges, isLastBackward ) {
+		this.destroy();
+		super.setRanges( newRanges, isLastBackward );
+	}
+
+	/**
+	 * Removes all attributes from the selection.
+	 *
+	 * @fires engine.model.LiveSelection#change:attribute
+	 */
+	clearAttributes() {
+		this._attrs.clear();
+		this._setStoredAttributesTo( new Map() );
+
+		this.fire( 'change:attribute' );
+	}
+
+	/**
+	 * 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 an attribute with given key from the selection.
+	 *
+	 * @fires engine.model.LiveSelection#change:attribute
+	 * @param {String} key Key of attribute to remove.
+	 */
+	removeAttribute( key ) {
+		this._attrs.delete( key );
+		this._removeStoredAttribute( 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.LiveSelection#change:attribute
+	 * @param {String} key Key of attribute to set.
+	 * @param {*} value Attribute value.
+	 */
+	setAttribute( key, value ) {
+		this._attrs.set( key, value );
+		this._storeAttribute( key, value );
+
+		this.fire( 'change:attribute' );
+	}
+
+	/**
+	 * Removes all attributes from the selection and sets given attributes.
+	 *
+	 * @fires engine.model.LiveSelection#change:attribute
+	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set.
+	 */
+	setAttributesTo( attrs ) {
+		this._attrs = toMap( attrs );
+		this._setStoredAttributesTo( this._attrs );
+
+		this.fire( 'change:attribute' );
+	}
+
+	/**
+	 * Creates and returns an instance of {@link engine.model.LiveSelection} that is a clone of given selection,
+	 * meaning that it has same ranges and same direction as it.
+	 *
+	 * @params {engine.model.Selection} otherSelection Selection to be cloned.
+	 * @returns {engine.model.LiveSelection} `LiveSelection` instance that is a clone of given selection.
+	 */
+
+	/**
+	 * @inheritDoc
+	 */
+	_popRange() {
+		this._ranges.pop().detach();
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_pushRange( range ) {
+		this._checkRange( range );
+		this._ranges.push( LiveRange.createFromRange( range ) );
+	}
+
+	/**
+	 * 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}.
+	 * 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.
+	 *
+	 * @private
+	 * @returns {engine.model.Range}
+	 */
+	_getDefaultRange() {
+		const defaultRoot = this._document._getDefaultRoot();
+
+		// Find the first position where the selection can be put.
+		for ( let position of Range.createFromElement( defaultRoot ).getPositions() ) {
+			if ( this._document.schema.check( { name: '$text', inside: position } ) ) {
+				return new Range( position, position );
+			}
+		}
+
+		const position = new Position( defaultRoot, [ 0 ] );
+
+		return new Range( position, position );
+	}
+
+	/**
+	 * Iterates through all attributes stored in current selection's parent.
+	 *
+	 * @returns {Iterable.<*>}
+	 */
+	*_getStoredAttributes() {
+		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 );
+
+					yield [ realKey, attr[ 1 ] ];
+				}
+			}
+		}
+	}
+
+	/**
+	 * Removes attribute with given key from attributes stored in current selection's parent node.
+	 *
+	 * @private
+	 * @param {String} key Key of attribute to remove.
+	 */
+	_removeStoredAttribute( key ) {
+		const selectionParent = this.getFirstPosition().parent;
+
+		if ( this.isCollapsed && selectionParent.getChildCount() === 0 ) {
+			const storeKey = LiveSelection._getStoreAttributeKey( key );
+
+			this._document.enqueueChanges( () => {
+				this._document.batch().removeAttr( storeKey, selectionParent );
+			} );
+		}
+	}
+
+	/**
+	 * Stores given attribute key and value in current selection's parent node if the selection is collapsed and
+	 * the parent node is empty.
+	 *
+	 * @private
+	 * @param {String} key Key of attribute to set.
+	 * @param {*} value Attribute value.
+	 */
+	_storeAttribute( key, value ) {
+		const selectionParent = this.getFirstPosition().parent;
+
+		if ( this.isCollapsed && selectionParent.getChildCount() === 0 ) {
+			const storeKey = LiveSelection._getStoreAttributeKey( key );
+
+			this._document.enqueueChanges( () => {
+				this._document.batch().setAttr( storeKey, value, selectionParent );
+			} );
+		}
+	}
+
+	/**
+	 * Sets selection attributes stored in current selection's parent node to given set of attributes.
+	 *
+	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set.
+	 * @private
+	 */
+	_setStoredAttributesTo( attrs ) {
+		const selectionParent = this.getFirstPosition().parent;
+
+		if ( this.isCollapsed && selectionParent.getChildCount() === 0 ) {
+			this._document.enqueueChanges( () => {
+				const batch = this._document.batch();
+
+				for ( let attr of this._getStoredAttributes() ) {
+					const storeKey = LiveSelection._getStoreAttributeKey( attr[ 0 ] );
+
+					batch.removeAttr( storeKey, selectionParent );
+				}
+
+				for ( let attr of attrs ) {
+					const storeKey = LiveSelection._getStoreAttributeKey( attr[ 0 ] );
+
+					batch.setAttr( storeKey, attr[ 1 ], selectionParent );
+				}
+			} );
+		}
+	}
+
+	/**
+	 * Updates this selection attributes according to it's ranges and the document.
+	 *
+	 * @fires engine.model.LiveSelection#change:attribute
+	 * @protected
+	 */
+	_updateAttributes() {
+		const position = this.getFirstPosition();
+		const positionParent = position.parent;
+
+		let attrs = null;
+
+		if ( !this.isCollapsed ) {
+			// 1. If selection is a range...
+			const range = this.getFirstRange();
+
+			// ...look for a first character node in that range and take attributes from it.
+			for ( let item of range ) {
+				// This is not an optimal solution because of https://github.com/ckeditor/ckeditor5-engine/issues/454.
+				// It can be done better by using `break;` instead of checking `attrs === null`.
+				if ( item.type == 'TEXT' && attrs === null ) {
+					attrs = item.item.getAttributes();
+				}
+			}
+		} 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 );
+
+			// ...look at the node before caret and take attributes from it if it is a character node.
+			attrs = getAttrsIfCharacter( nodeBefore );
+
+			// 3. If not, look at the node after caret...
+			if ( !attrs ) {
+				attrs = getAttrsIfCharacter( nodeAfter );
+			}
+
+			// 4. If not, try to find the first character on the left, that is in the same node.
+			if ( !attrs ) {
+				let node = nodeBefore;
+
+				while ( node && !attrs ) {
+					node = node.previousSibling;
+					attrs = getAttrsIfCharacter( node );
+				}
+			}
+
+			// 5. If not found, try to find the first character on the right, that is in the same node.
+			if ( !attrs ) {
+				let node = nodeAfter;
+
+				while ( node && !attrs ) {
+					node = node.nextSibling;
+					attrs = getAttrsIfCharacter( node );
+				}
+			}
+
+			// 6. If not found, selection should retrieve attributes from parent.
+			if ( !attrs ) {
+				attrs = this._getStoredAttributes();
+			}
+		}
+
+		if ( attrs ) {
+			this._attrs = new Map( attrs );
+		} else {
+			this.clearAttributes();
+		}
+
+		function getAttrsIfCharacter( node ) {
+			if ( node instanceof CharacterProxy ) {
+				return node.getAttributes();
+			}
+
+			return null;
+		}
+
+		this.fire( 'change:attribute' );
+	}
+
+	/**
+	 * Generates and returns an attribute key for selection attributes store, basing on original attribute key.
+	 *
+	 * @param {String} key Attribute key to convert.
+	 * @returns {String} Converted attribute key, applicable for selection store.
+	 */
+	static _getStoreAttributeKey( key ) {
+		return storePrefix + key;
+	}
+}
+
+/**
+ * Fired whenever selection attributes are changed.
+ *
+ * @event engine.model.LiveSelection#change:attribute
+ */

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

@@ -7,6 +7,7 @@
 
 import MoveOperation from './moveoperation.js';
 import Position from '../position.js';
+import Element from '../element.js';
 import ReinsertOperation from './reinsertoperation.js';
 
 /**
@@ -25,16 +26,69 @@ export default class RemoveOperation extends MoveOperation {
 	 * @param {Number} baseVersion {@link engine.model.Document#version} on which operation can be applied.
 	 */
 	constructor( position, howMany, baseVersion ) {
-		// Position in a graveyard where nodes were moved.
-		const graveyardPosition = Position.createFromParentAndOffset( position.root.document.graveyard, 0 );
+		const graveyard = position.root.document.graveyard;
 
-		super( position, howMany, graveyardPosition, baseVersion );
+		super( position, howMany, new Position( graveyard, [ graveyard.getChildCount(), 0 ] ), baseVersion );
 	}
 
+	/**
+	 * @inheritDoc
+	 */
 	get type() {
 		return 'remove';
 	}
 
+	/**
+	 * Offset of the graveyard "holder" element, in which nodes removed by this operation are stored.
+	 *
+	 * @protected
+	 * @type {Number}
+	 */
+	get _holderElementOffset() {
+		return this.targetPosition.path[ 0 ];
+	}
+
+	/**
+	 * Sets {@link engine.model.operation.RemoveOperation#_holderElementOffset}.
+	 *
+	 * @protected
+	 * @param {Number} offset
+	 */
+	set _holderElementOffset( offset ) {
+		this.targetPosition.path[ 0 ] = offset;
+	}
+
+	/**
+	 * 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.
+	 *
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _needsHolderElement() {
+		if ( this.delta ) {
+			// Let's look up all operations from this delta in the same order as they are in the delta.
+			for ( let operation of this.delta.operations ) {
+				// We are interested only in `RemoveOperation`s.
+				if ( operation instanceof RemoveOperation ) {
+					// If the first `RemoveOperation` in the delta is this operation, this operation
+					// needs to insert holder element in the graveyard.
+					if ( operation == this ) {
+						return true;
+					} else if ( operation._holderElementOffset == this._holderElementOffset ) {
+						// If there is a `RemoveOperation` in this delta that "points" to the same holder element offset,
+						// that operation will already insert holder element at that offset. We should not create another holder.
+						return false;
+					}
+				}
+			}
+		}
+
+		// By default `RemoveOperation` needs holder element, so set it so, if the operation does not have delta.
+		return true;
+	}
+
 	/**
 	 * @returns {engine.model.operation.ReinsertOperation}
 	 */
@@ -46,7 +100,25 @@ export default class RemoveOperation extends MoveOperation {
 	 * @returns {engine.model.operation.RemoveOperation}
 	 */
 	clone() {
-		return new RemoveOperation( this.sourcePosition, this.howMany, this.baseVersion );
+		let removeOperation = new RemoveOperation( this.sourcePosition, this.howMany, this.baseVersion );
+		removeOperation.targetPosition = Position.createFromPosition( this.targetPosition );
+		removeOperation.movedRangeStart = Position.createFromPosition( this.movedRangeStart );
+
+		return removeOperation;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_execute() {
+		if ( this._needsHolderElement ) {
+			const graveyard = this.targetPosition.root;
+			const holderElement = new Element( '$graveyardHolder' );
+
+			graveyard.insertChildren( this.targetPosition.path[ 0 ], holderElement );
+		}
+
+		return super._execute();
 	}
 
 	/**

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

@@ -146,6 +146,18 @@ const ot = {
 			// This will aggregate transformed ranges.
 			let ranges = [];
 
+			// Special case when MoveOperation is in fact a RemoveOperation. RemoveOperation not only moves nodes but also
+			// creates a "holder" element for them in graveyard. If there was a RemoveOperation pointing to an offset
+			// before this AttributeOperation, we have to increment AttributeOperation's offset.
+			if ( b instanceof RemoveOperation && b._needsHolderElement &&
+				a.range.root == b.targetPosition.root && a.range.start.path[ 0 ] >= b._holderElementOffset
+			) {
+				// Do not change original operation!
+				a = a.clone();
+				a.range.start.path[ 0 ]++;
+				a.range.end.path[ 0 ]++;
+			}
+
 			// Difference is a part of changed range that is modified by AttributeOperation but is not affected
 			// by MoveOperation. This can be zero, one or two ranges (if moved range is inside changed range).
 			// Right now we will make a simplification and join difference ranges and transform them as one. We will cover rangeB later.
@@ -243,7 +255,22 @@ const ot = {
 				return [ b.getReversed() ];
 			}
 
-			// If one of operations is actually a remove operation, we force remove operation to be the "stronger" one
+			// Special case when both operations are RemoveOperations. RemoveOperation not only moves nodes but also
+			// (usually) creates a "holder" element for them in graveyard. Each RemoveOperation should move nodes to different
+			// "holder" element. If `a` operation points after `b` operation, we move `a` offset to acknowledge
+			// "holder" element insertion.
+			if ( a instanceof RemoveOperation && b instanceof RemoveOperation && b._needsHolderElement ) {
+				const aTarget = a.targetPosition.path[ 0 ];
+				const bTarget = b.targetPosition.path[ 0 ];
+
+				if ( aTarget > bTarget || ( aTarget == bTarget && isStrong ) ) {
+					// Do not change original operation!
+					a = a.clone();
+					a.targetPosition.path[ 0 ]++;
+				}
+			}
+
+			// If only one of operations is a remove operation, we force remove operation to be the "stronger" one
 			// to provide more expected results.
 			if ( a instanceof RemoveOperation && !( b instanceof RemoveOperation ) ) {
 				isStrong = true;
@@ -290,7 +317,10 @@ const ot = {
 			// transform `a` operation. Normally, when same nodes are moved, we stick with stronger operation's target.
 			// Here it is a move inside larger range so there is no conflict because after all, all nodes from
 			// smaller range will be moved to larger range target. The effect of this transformation feels natural.
-			let aIsInside = rangeB.containsRange( rangeA ) && rangeB.containsPosition( a.targetPosition );
+			// Also if we wouldn't do that, we would get different results on both sides of transformation (i.e. in
+			// collaborative editing).
+			let aIsInside = rangeB.containsRange( rangeA ) &&
+				( rangeB.containsPosition( a.targetPosition ) || rangeB.start.isEqual( a.targetPosition ) || rangeB.end.isEqual( a.targetPosition ) );
 
 			if ( common !== null && ( aCompB === 'EXTENSION' || ( aCompB === 'SAME' && isStrong ) || aIsInside ) && !bTargetsToA ) {
 				// Here we do not need to worry that newTargetPosition is inside moved range, because that
@@ -314,7 +344,13 @@ const ot = {
 			}
 
 			// Target position also could be affected by the other MoveOperation. We will transform it.
-			let newTargetPosition = a.targetPosition.getTransformedByMove( b.sourcePosition, b.targetPosition, b.howMany, !isStrong, b.isSticky );
+			let newTargetPosition = a.targetPosition.getTransformedByMove(
+				b.sourcePosition,
+				b.targetPosition,
+				b.howMany,
+				!isStrong,
+				b.isSticky || aIsInside
+			);
 
 			// Map transformed range(s) to operations and return them.
 			return ranges.reverse().map( ( range ) => {
@@ -327,6 +363,7 @@ const ot = {
 				);
 
 				result.isSticky = a.isSticky;
+				result._holderElementOffset = a._holderElementOffset;
 
 				return result;
 			} );

+ 81 - 354
packages/ckeditor5-engine/src/model/selection.js

@@ -7,47 +7,21 @@
 
 import Position from './position.js';
 import Range from './range.js';
-import LiveRange from './liverange.js';
 import EmitterMixin from '../../utils/emittermixin.js';
-import CharacterProxy from './characterproxy.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
-import toMap from '../../utils/tomap.js';
 import mix from '../../utils/mix.js';
 
-const storePrefix = 'selection:';
-
 /**
- * Represents a selection that is made on nodes in {@link engine.model.Document}. `Selection` instance is
- * created by {@link engine.model.Document}. You should not need to create an instance of `Selection`.
- *
- * Keep in mind that selection always contains at least one range. If no ranges has been added to selection or all ranges
- * got removed from selection, the selection will be reset to contain {@link engine.model.Selection#_getDefaultRange the default range}.
+ * `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}.
  *
  * @memberOf engine.model
  */
 export default class Selection {
 	/**
 	 * Creates an empty selection.
-	 *
-	 * @param {engine.model.Document} document Document which owns this selection.
 	 */
-	constructor( document ) {
-		/**
-		 * List of attributes set on current selection.
-		 *
-		 * @protected
-		 * @member {Map} engine.model.Selection#_attrs
-		 */
-		this._attrs = new Map();
-
-		/**
-		 * Document which owns this selection.
-		 *
-		 * @private
-		 * @member {engine.model.Document} engine.model.Selection#_document
-		 */
-		this._document = document;
-
+	constructor() {
 		/**
 		 * Specifies whether the last added range was added as a backward or forward range.
 		 *
@@ -59,8 +33,8 @@ export default class Selection {
 		/**
 		 * Stores all ranges that are selected.
 		 *
-		 * @private
-		 * @member {Array.<engine.model.LiveRange>} engine.model.Selection#_ranges
+		 * @protected
+		 * @member {Array.<engine.model.Range>} engine.model.Selection#_ranges
 		 */
 		this._ranges = [];
 	}
@@ -69,27 +43,38 @@ export default class Selection {
 	 * 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.
-	 * It may be a bit unintuitive when there are multiple ranges in selection.
+	 *
+	 * Is set to `null` if there are no ranges in selection.
 	 *
 	 * @see engine.model.Selection#focus
-	 * @type {engine.model.LivePosition}
+	 * @type {engine.model.Position|null}
 	 */
 	get anchor() {
-		let range = this._ranges.length ? this._ranges[ this._ranges.length - 1 ] : this._getDefaultRange();
+		if ( this._ranges.length > 0 ) {
+			const range = this._ranges[ this._ranges.length - 1 ];
+
+			return this._lastRangeBackward ? range.end : range.start;
+		}
 
-		return this._lastRangeBackward ? range.end : range.start;
+		return null;
 	}
 
 	/**
 	 * Selection focus. Focus is a position where the selection ends.
 	 *
+	 * Is set to `null` if there are no ranges in selection.
+	 *
 	 * @see engine.model.Selection#anchor
-	 * @type {engine.model.LivePosition}
+	 * @type {engine.model.Position|null}
 	 */
 	get focus() {
-		let range = this._ranges.length ? this._ranges[ this._ranges.length - 1 ] : this._getDefaultRange();
+		if ( this._ranges.length > 0 ) {
+			const range = this._ranges[ this._ranges.length - 1 ];
 
-		return this._lastRangeBackward ? range.start : range.end;
+			return this._lastRangeBackward ? range.start : range.end;
+		}
+
+		return null;
 	}
 
 	/**
@@ -101,10 +86,7 @@ export default class Selection {
 	get isCollapsed() {
 		const length = this._ranges.length;
 
-		if ( length === 0 ) {
-			// Default range is collapsed.
-			return true;
-		} else if ( length === 1 ) {
+		if ( length === 1 ) {
 			return this._ranges[ 0 ].isCollapsed;
 		} else {
 			return false;
@@ -117,7 +99,7 @@ export default class Selection {
 	 * @type {Number}
      */
 	get rangeCount() {
-		return this._ranges.length ? this._ranges.length : 1;
+		return this._ranges.length;
 	}
 
 	/**
@@ -130,12 +112,12 @@ export default class Selection {
 	}
 
 	/**
-	 * Adds a range to the selection. Added range is copied and converted to {@link engine.model.LiveRange}. This means
-	 * that passed range is not saved in the Selection instance and you can safely operate on it.
+	 * 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} to {@link engine.model.Range#end} or from {@link engine.model.Range#end}
-	 * to {@link engine.model.Range#start}. The flag is used to set {@link engine.model.Selection#anchor} and
+	 * {@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
@@ -151,35 +133,24 @@ export default class Selection {
 	}
 
 	/**
-	 * Unbinds all events previously bound by this selection or objects created by this selection.
-	 */
-	destroy() {
-		for ( let i = 0; i < this._ranges.length; i++ ) {
-			this._ranges[ i ].detach();
-		}
-	}
-
-	/**
-	 * Returns an iterator that contains copies of all ranges added to the selection.
+	 * Returns an iterator that iterates over copies of selection ranges.
 	 *
 	 * @returns {Iterator.<engine.model.Range>}
 	 */
 	*getRanges() {
-		if ( this._ranges.length ) {
-			for ( let range of this._ranges ) {
-				yield Range.createFromRange( range );
-			}
-		} else {
-			yield this._getDefaultRange();
+		for ( let range of this._ranges ) {
+			yield Range.createFromRange( range );
 		}
 	}
 
 	/**
-	 * Returns the first range in the selection. First range is the one which {@link engine.model.Range#start start} position
+	 * Returns a copy of the first range in the selection. First range is the one which {@link engine.model.Range#start start} position
 	 * {@link engine.model.Position#isBefore is before} start position of all other ranges (not to confuse with the first range
 	 * added to the selection).
 	 *
-	 * @returns {engine.model.Range}
+	 * Returns `null` if there are no ranges in selection.
+	 *
+	 * @returns {engine.model.Range|null}
 	 */
 	getFirstRange() {
 		let first = null;
@@ -192,17 +163,21 @@ export default class Selection {
 			}
 		}
 
-		return first ? Range.createFromRange( first ) : this._getDefaultRange();
+		return first ? Range.createFromRange( first ) : 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.
 	 *
-	 * @returns {engine.model.Position}
+	 * Returns `null` if there are no ranges in selection.
+	 *
+	 * @returns {engine.model.Position|null}
 	 */
 	getFirstPosition() {
-		return Position.createFromPosition( this.getFirstRange().start );
+		const first = this.getFirstRange();
+
+		return first ? Position.createFromPosition( first.start ) : null;
 	}
 
 	/**
@@ -211,7 +186,6 @@ export default class Selection {
 	 * @fires engine.model.Selection#change:range
 	 */
 	removeAllRanges() {
-		this.destroy();
 		this._ranges = [];
 
 		this.fire( 'change:range' );
@@ -223,16 +197,15 @@ export default class Selection {
 	 * describing in which way the selection is made (see {@link #addRange}).
 	 *
 	 * @fires engine.model.Selection#change:range
-	 * @param {Array.<engine.model.Range>} newRanges Array of ranges to set.
+	 * @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`.
 	 */
 	setRanges( newRanges, isLastBackward ) {
-		this.destroy();
 		this._ranges = [];
 
-		for ( let i = 0; i < newRanges.length; i++ ) {
-			this._pushRange( newRanges[ i ] );
+		for ( let range of newRanges ) {
+			this._pushRange( range );
 		}
 
 		this._lastRangeBackward = !!isLastBackward;
@@ -268,6 +241,15 @@ export default class Selection {
 	 * first parameter is a node.
 	 */
 	setFocus( nodeOrPosition, offset ) {
+		if ( this.anchor === null ) {
+			/**
+			 * Cannot set selection focus if there are no ranges in selection.
+			 *
+			 * @error selection-setFocus-no-ranges
+			 */
+			throw new CKEditorError( 'selection-setFocus-no-ranges: Cannot set selection focus if there are no ranges in selection.' );
+		}
+
 		const newFocus = Position.createAt( nodeOrPosition, offset );
 
 		if ( newFocus.compareWith( this.focus ) == 'SAME' ) {
@@ -277,8 +259,7 @@ export default class Selection {
 		const anchor = this.anchor;
 
 		if ( this._ranges.length ) {
-			// TODO Replace with _popRange, so child classes can override this (needed for #329).
-			this._ranges.pop().detach();
+			this._popRange();
 		}
 
 		if ( newFocus.compareWith( anchor ) == 'BEFORE' ) {
@@ -289,95 +270,28 @@ export default class Selection {
 	}
 
 	/**
-	 * Removes all attributes from the selection.
-	 *
-	 * @fires engine.model.Selection#change:attribute
-	 */
-	clearAttributes() {
-		this._attrs.clear();
-		this._setStoredAttributesTo( new Map() );
-
-		this.fire( 'change:attribute' );
-	}
-
-	/**
-	 * Gets an attribute value for given key or undefined it that attribute is not set on selection.
-	 *
-	 * @param {String} key Key of attribute to look for.
-	 * @returns {*} Attribute value or null.
-	 */
-	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 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._removeStoredAttribute( key );
-
-		this.fire( 'change:attribute' );
-	}
-
-	/**
-	 * Sets attribute on the selection. If attribute with the same key already is set, it overwrites its values.
+	 * 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.
 	 *
-	 * @fires engine.model.Selection#change:attribute
-	 * @param {String} key Key of attribute to set.
-	 * @param {*} value Attribute value.
+	 * @params {engine.model.Selection} otherSelection Selection to be cloned.
+	 * @returns {engine.model.Selection} `Selection` instance that is a clone of given selection.
 	 */
-	setAttribute( key, value ) {
-		this._attrs.set( key, value );
-		this._storeAttribute( key, value );
+	static createFromSelection( otherSelection ) {
+		const selection = new this();
+		selection.setRanges( otherSelection.getRanges(), otherSelection.isBackward );
 
-		this.fire( 'change:attribute' );
+		return selection;
 	}
 
 	/**
-	 * Removes all attributes from the selection and sets given attributes.
+	 * 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.
 	 *
-	 * @fires engine.model.Selection#change:attribute
-	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set.
-	 */
-	setAttributesTo( attrs ) {
-		this._attrs = toMap( attrs );
-		this._setStoredAttributesTo( this._attrs );
-
-		this.fire( 'change:attribute' );
-	}
-
-	/**
-	 * Converts given range to {@link engine.model.LiveRange} and adds it to internal ranges array. Throws an error
-	 * if given range is intersecting with any range that is already stored in this selection.
-	 *
-	 * @private
-	 * @param {engine.model.Range} range Range to add.
+	 * @param {engine.model.Range} range Range to check.
+	 * @protected
 	 */
-	_pushRange( range ) {
-		for ( let i = 0; i < this._ranges.length ; i++ ) {
+	_checkRange( range ) {
+		for ( let i = 0; i < this._ranges.length; i++ ) {
 			if ( range.isIntersecting( this._ranges[ i ] ) ) {
 				/**
 				 * Trying to add a range that intersects with another range from selection.
@@ -392,221 +306,34 @@ export default class Selection {
 				);
 			}
 		}
-
-		this._ranges.push( LiveRange.createFromRange( range ) );
 	}
 
 	/**
-	 * Iterates through all attributes stored in current selection's parent.
-	 *
-	 * @returns {Iterable.<*>}
-	 */
-	*_getStoredAttributes() {
-		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 );
-
-					yield [ realKey, attr[ 1 ] ];
-				}
-			}
-		}
-	}
-
-	/**
-	 * Removes attribute with given key from attributes stored in current selection's parent node.
-	 *
-	 * @private
-	 * @param {String} key Key of attribute to remove.
-	 */
-	_removeStoredAttribute( key ) {
-		const selectionParent = this.getFirstPosition().parent;
-
-		if ( this.isCollapsed && selectionParent.getChildCount() === 0 ) {
-			const storeKey = Selection._getStoreAttributeKey( key );
-
-			this._document.enqueueChanges( () => {
-				this._document.batch().removeAttr( storeKey, selectionParent );
-			} );
-		}
-	}
-
-	/**
-	 * Stores given attribute key and value in current selection's parent node if the selection is collapsed and
-	 * the parent node is empty.
-	 *
-	 * @private
-	 * @param {String} key Key of attribute to set.
-	 * @param {*} value Attribute value.
-	 */
-	_storeAttribute( key, value ) {
-		const selectionParent = this.getFirstPosition().parent;
-
-		if ( this.isCollapsed && selectionParent.getChildCount() === 0 ) {
-			const storeKey = Selection._getStoreAttributeKey( key );
-
-			this._document.enqueueChanges( () => {
-				this._document.batch().setAttr( storeKey, value, selectionParent );
-			} );
-		}
-	}
-
-	/**
-	 * Sets selection attributes stored in current selection's parent node to given set of attributes.
-	 *
-	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set.
-	 * @private
-	 */
-	_setStoredAttributesTo( attrs ) {
-		const selectionParent = this.getFirstPosition().parent;
-
-		if ( this.isCollapsed && selectionParent.getChildCount() === 0 ) {
-			this._document.enqueueChanges( () => {
-				const batch = this._document.batch();
-
-				for ( let attr of this._getStoredAttributes() ) {
-					const storeKey = Selection._getStoreAttributeKey( attr[ 0 ] );
-
-					batch.removeAttr( storeKey, selectionParent );
-				}
-
-				for ( let attr of attrs ) {
-					const storeKey = Selection._getStoreAttributeKey( attr[ 0 ] );
-
-					batch.setAttr( storeKey, attr[ 1 ], selectionParent );
-				}
-			} );
-		}
-	}
-
-	/**
-	 * Updates this selection attributes based on it's position in the model.
+	 * Removes most recently added range from the selection.
 	 *
 	 * @protected
 	 */
-	_updateAttributes() {
-		const position = this.getFirstPosition();
-		const positionParent = position.parent;
-
-		let attrs = null;
-
-		if ( !this.isCollapsed ) {
-			// 1. If selection is a range...
-			const range = this.getFirstRange();
-
-			// ...look for a first character node in that range and take attributes from it.
-			for ( let item of range ) {
-				// This is not an optimal solution because of https://github.com/ckeditor/ckeditor5-engine/issues/454.
-				// It can be done better by using `break;` instead of checking `attrs === null`.
-				if ( item.type == 'TEXT' && attrs === null ) {
-					attrs = item.item.getAttributes();
-				}
-			}
-		} 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 );
-
-			// ...look at the node before caret and take attributes from it if it is a character node.
-			attrs = getAttrsIfCharacter( nodeBefore );
-
-			// 3. If not, look at the node after caret...
-			if ( !attrs ) {
-				attrs = getAttrsIfCharacter( nodeAfter );
-			}
-
-			// 4. If not, try to find the first character on the left, that is in the same node.
-			if ( !attrs ) {
-				let node = nodeBefore;
-
-				while ( node && !attrs ) {
-					node = node.previousSibling;
-					attrs = getAttrsIfCharacter( node );
-				}
-			}
-
-			// 5. If not found, try to find the first character on the right, that is in the same node.
-			if ( !attrs ) {
-				let node = nodeAfter;
-
-				while ( node && !attrs ) {
-					node = node.nextSibling;
-					attrs = getAttrsIfCharacter( node );
-				}
-			}
-
-			// 6. If not found, selection should retrieve attributes from parent.
-			if ( !attrs ) {
-				attrs = this._getStoredAttributes();
-			}
-		}
-
-		if ( attrs ) {
-			this._attrs = new Map( attrs );
-		} else {
-			this.clearAttributes();
-		}
-
-		function getAttrsIfCharacter( node ) {
-			if ( node instanceof CharacterProxy ) {
-				return node.getAttributes();
-			}
-
-			return null;
-		}
-
-		this.fire( 'change:attribute' );
+	_popRange() {
+		this._ranges.pop();
 	}
 
 	/**
-	 * 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}.
-	 * 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.
-	 *
-	 * @private
-	 * @returns {engine.model.Range}
-	 */
-	_getDefaultRange() {
-		const defaultRoot = this._document._getDefaultRoot();
-
-		// Find the first position where the selection can be put.
-		for ( let position of Range.createFromElement( defaultRoot ).getPositions() ) {
-			if ( this._document.schema.check( { name: '$text', inside: position } ) ) {
-				return new Range( position, position );
-			}
-		}
-
-		const position = new Position( defaultRoot, [ 0 ] );
-
-		return new Range( position, position );
-	}
-
-	/**
-	 * Generates and returns an attribute key for selection attributes store, basing on original attribute key.
+	 * 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.
 	 *
-	 * @param {String} key Attribute key to convert.
-	 * @returns {String} Converted attribute key, applicable for selection store.
+	 * @protected
+	 * @param {engine.model.Range} range Range to add.
 	 */
-	static _getStoreAttributeKey( key ) {
-		return storePrefix + key;
+	_pushRange( range ) {
+		this._checkRange( range );
+		this._ranges.push( Range.createFromRange( range ) );
 	}
 }
 
 mix( Selection, EmitterMixin );
 
 /**
- * Fired whenever selection ranges are changed through {@link engine.model.Selection Selection API}. Not fired when
- * {@link engine.model.LiveRange live ranges} inserted in selection change because of Tree Model changes.
+ * Fired whenever selection ranges are changed through {@link engine.model.Selection Selection API}.
  *
  * @event engine.model.Selection#change:range
  */
-
-/**
- * Fired whenever selection attributes are changed.
- *
- * @event engine.model.Selection#change:attribute
- */

+ 29 - 34
packages/ckeditor5-engine/src/model/treewalker.js

@@ -21,11 +21,10 @@ export default class TreeWalker {
 	 * Creates a range iterator. All parameters are optional, but you have to specify either `boundaries` or `startPosition`.
 	 *
 	 * @constructor
-	 * @param {Object} options Object with configuration.
-	 * @param {engine.model.Position} [options.startPosition] Starting position.
+	 * @param {Object} [options={}] Object with configuration.
+	 * @param {'FORWARD'|'BACKWARD'} [options.direction='FORWARD'] Walking direction.
 	 * @param {engine.model.Range} [options.boundaries=null] Range to define boundaries of the iterator.
 	 * @param {engine.model.Position} [options.startPosition] Starting position.
-	 * @param {'FORWARD'|'BACKWARD'} [options.direction='FORWARD'] Walking direction.
 	 * @param {Boolean} [options.singleCharacters=false] Flag indicating whether all consecutive characters with the same attributes
 	 * should be returned one by one as multiple {@link engine.model.CharacterProxy} (`true`) objects or as one
 	 * {@link engine.model.TextProxy} (`false`).
@@ -36,17 +35,8 @@ export default class TreeWalker {
 	 * each {@link engine.model.Element} will be returned once, while if the option is `false` they might be returned
 	 * twice: for `'ELEMENT_START'` and `'ELEMENT_END'`.
 	 */
-	constructor(
-		{
-			boundaries = null,
-			startPosition,
-			direction = 'FORWARD',
-			singleCharacters = false,
-			shallow = false,
-			ignoreElementEnd = false,
-		} = {}
-	) {
-		if ( !boundaries && !startPosition ) {
+	constructor( options = {} ) {
+		if ( !options.boundaries && !options.startPosition ) {
 			/**
 			 * Neither boundaries nor starting position have been defined.
 			 *
@@ -55,6 +45,8 @@ export default class TreeWalker {
 			throw new CKEditorError( 'tree-walker-no-start-position: Neither boundaries nor starting position have been defined.' );
 		}
 
+		const direction = options.direction || 'FORWARD';
+
 		if ( direction != 'FORWARD' && direction != 'BACKWARD' ) {
 			throw new CKEditorError(
 				'tree-walker-unknown-direction: Only `BACKWARD` and `FORWARD` direction allowed.',
@@ -62,40 +54,42 @@ export default class TreeWalker {
 			);
 		}
 
+		/**
+		 * Walking direction. Defaults `FORWARD`.
+		 *
+		 * @readonly
+		 * @member {'BACKWARD'|'FORWARD'} engine.model.TreeWalker#direction
+		 */
+		this.direction = direction;
+
 		/**
 		 * Iterator boundaries.
 		 *
-		 * When the {@link #next} method is called on the end boundary or the {@link #previous} method
-		 * on the start boundary, then `{ done: true }` is returned.
+		 * When the iterator is walking `FORWARD` on the end of boundary or is walking `BACKWARD`
+		 * on the start of boundary, then `{ done: true }` is returned.
 		 *
 		 * If boundaries are not defined they are set before first and after last child of the root node.
 		 *
 		 * @readonly
 		 * @member {engine.model.Range} engine.model.TreeWalker#boundaries
 		 */
-		this.boundaries = boundaries;
+		this.boundaries = options.boundaries || null;
 
 		/**
 		 * Iterator position. This is always static position, even if the initial position was a
-		 * {@link engine.model.LivePosition live position}.
+		 * {@link engine.model.LivePosition live position}. If start position is not defined then position depends
+		 * on {@link #direction}. If direction is `FORWARD` position starts form the beginning, when direction
+		 * is `BACKWARD` position starts from the end.
 		 *
 		 * @readonly
 		 * @member {engine.model.Position} engine.model.TreeWalker#position
 		 */
-		if ( startPosition ) {
-			this.position = Position.createFromPosition( startPosition );
+		if ( options.startPosition ) {
+			this.position = Position.createFromPosition( options.startPosition );
 		} else {
-			this.position = Position.createFromPosition( boundaries[ direction == 'BACKWARD' ? 'end' : 'start' ] );
+			this.position = Position.createFromPosition( this.boundaries[ this.direction == 'BACKWARD' ? 'end' : 'start' ] );
 		}
 
-		/**
-		 * Walking direction. Defaults `FORWARD`.
-		 *
-		 * @readonly
-		 * @member {'BACKWARD'|'FORWARD'} engine.model.TreeWalker#direction
-		 */
-		this.direction = direction;
-
 		/**
 		 * Flag indicating whether all consecutive characters with the same attributes should be
 		 * returned as one {@link engine.model.CharacterProxy} (`true`) or one by one (`false`).
@@ -103,7 +97,7 @@ export default class TreeWalker {
 		 * @readonly
 		 * @member {Boolean} engine.model.TreeWalker#singleCharacters
 		 */
-		this.singleCharacters = !!singleCharacters;
+		this.singleCharacters = !!options.singleCharacters;
 
 		/**
 		 * Flag indicating whether iterator should enter elements or not. If the iterator is shallow child nodes of any
@@ -112,7 +106,7 @@ export default class TreeWalker {
 		 * @readonly
 		 * @member {Boolean} engine.model.TreeWalker#shallow
 		 */
-		this.shallow = !!shallow;
+		this.shallow = !!options.shallow;
 
 		/**
 		 * Flag indicating whether iterator should ignore `ELEMENT_END` tags. If the option is true walker will not
@@ -123,7 +117,7 @@ export default class TreeWalker {
 		 * @readonly
 		 * @member {Boolean} engine.model.TreeWalker#ignoreElementEnd
 		 */
-		this.ignoreElementEnd = !!ignoreElementEnd;
+		this.ignoreElementEnd = !!options.ignoreElementEnd;
 
 		/**
 		 * Start boundary cached for optimization purposes.
@@ -251,7 +245,7 @@ export default class TreeWalker {
 	 * @private
 	 * @returns {Object}
 	 * @returns {Boolean} return.done True if iterator is done.
-	 * @returns {core.model.TreeWalkerValue} return.value Information about taken step.
+	 * @returns {engine.model.TreeWalkerValue} return.value Information about taken step.
 	 */
 	_previous() {
 		const previousPosition = this.position;
@@ -312,6 +306,7 @@ export default class TreeWalker {
 				return formatReturnValue( 'TEXT', textFragment, previousPosition, position, charactersCount );
 			}
 		} else {
+			// `node` is not set, we reached the beginning of current `parent`.
 			position.path.pop();
 			this.position = position;
 			this._visitedParent = parent.parent;
@@ -369,5 +364,5 @@ function formatReturnValue( type, item, previousPosition, nextPosition, length )
 /**
  * Tree walking directions.
  *
- * @typedef {'FORWARD'|'BACKWARD'} core.model.TreeWalkerDirection
+ * @typedef {'FORWARD'|'BACKWARD'} engine.view.TreeWalkerDirection
  */

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

@@ -12,7 +12,7 @@ const DEFAULT_PRIORITY = 10;
 
 /**
  * Attributes are elements which define document presentation. They are mostly elements like `<b>` or `<span>`.
- * Attributes can be broken and merged by the {@link engine.view.Writer}.
+ * Attributes can be broken and merged by the {@link engine.view.writer view writer}.
  *
  * Editing engine does not define fixed HTML DTD. This is why the type of the {@link engine.view.Element} need to
  * be defined by the feature developer. Creating an element you should use {@link engine.view.ContainerElement}

+ 3 - 3
packages/ckeditor5-engine/src/view/containerelement.js

@@ -21,15 +21,15 @@ import Element from './element.js';
  * DOM properly. {@link engine.view.DomConverter} will ensure that `ContainerElement` is editable and it is possible
  * to put caret inside it, even if the container is empty.
  *
- * Secondly, {@link engine.view.Writer} uses this information.
- * Nodes {@link engine.view.Writer#breakAttributes breaking} and {@link engine.view.Writer#mergeAttributes merging}
+ * Secondly, {@link engine.view.writer view writer} uses this information.
+ * Nodes {@link engine.view.writer.breakAt breaking} and {@link engine.view.writer.mergeAt merging}
  * is performed only in a bounds of a container nodes.
  *
  * For instance if `<p>` is an container and `<b>` is attribute:
  *
  *		<p><b>fo^o</b></p>
  *
- * {@link engine.view.Writer#breakAttributes breakAttributes} will create:
+ * {@link engine.view.writer.breakAt breakAt} will create:
  *
  *		<p><b>fo</b><b>o</b></p>
  *

+ 48 - 18
packages/ckeditor5-engine/src/view/document.js

@@ -7,10 +7,10 @@
 
 import Selection from './selection.js';
 import Renderer from './renderer.js';
-import Writer from './writer.js';
 import DomConverter from './domconverter.js';
 import RootEditableElement from './rooteditableelement.js';
 import { injectQuirksHandling } from './filler.js';
+import log from '../../utils/log.js';
 
 import mix from '../../utils/mix.js';
 import ObservableMixin from '../../utils/observablemixin.js';
@@ -48,14 +48,6 @@ export default class Document {
 		 */
 		this.selection = new Selection();
 
-		/**
-		 * Tree View writer.
-		 *
-		 * @readonly
-		 * @member {engine.view.Writer} engine.view.Document#writer
-		 */
-		this.writer = new Writer();
-
 		/**
 		 * Instance of the {@link engine.view.DomConverter domConverter} use by
 		 * {@link engine.view.Document#renderer renderer} and {@link engine.view.observer.Observer observers}.
@@ -74,16 +66,16 @@ export default class Document {
 		this.roots = new Map();
 
 		/**
-		 * {@link engine.view.EditableElement} which is currently focused or null if all of them are blurred.
+		 * True if document is focused.
 		 *
 		 * This property is updated by the {@link engine.view.obsever.FocusObserver}.
 		 * If the {@link engine.view.obsever.FocusObserver} is disabled this property will not change.
 		 *
 		 * @readonly
 		 * @observable
-		 * @member {engine.view.EditableElement|null} engine.view.Document#focusedEditable
+		 * @member {Boolean} engine.view.Document#isFocused
 		 */
-		this.set( 'focusedEditable', null );
+		this.set( 'isFocused', false );
 
 		/**
 		 * Instance of the {@link engine.view.Document#renderer renderer}.
@@ -92,7 +84,7 @@ export default class Document {
 		 * @member {engine.view.Renderer} engine.view.Document#renderer
 		 */
 		this.renderer = new Renderer( this.domConverter, this.selection );
-		this.renderer.bind( 'focusedEditable' ).to( this, 'focusedEditable' );
+		this.renderer.bind( 'isFocused' ).to( this, 'isFocused' );
 
 		/**
 		 * Map of registered {@link engine.view.Observer observers}.
@@ -103,6 +95,14 @@ export default class Document {
 		this._observers = new Map();
 
 		injectQuirksHandling( this );
+
+		// Listens `render` event on default priority.
+		// This way we can attach other listeners before or after rendering execution.
+		this.on( 'render', () => {
+			this.disableObservers();
+			this.renderer.render();
+			this.enableObservers();
+		} );
 	}
 
 	/**
@@ -181,7 +181,7 @@ export default class Document {
 		viewRoot.on( 'change:attributes', ( evt, node ) => this.renderer.markToSync( 'attributes', node ) );
 		viewRoot.on( 'change:text', ( evt, node ) => this.renderer.markToSync( 'text', node ) );
 
-		if ( domRoot instanceof HTMLElement ) {
+		if ( this.domConverter.isElement( domRoot ) ) {
 			this.attachDomRoot( domRoot, name );
 		}
 
@@ -207,6 +207,7 @@ export default class Document {
 		this.domConverter.bindElements( domRoot, viewRoot );
 
 		this.renderer.markToSync( 'children', viewRoot );
+		this.renderer.domDocuments.add( domRoot.ownerDocument );
 
 		for ( let observer of this._observers.values() ) {
 			observer.observe( domRoot, name );
@@ -237,13 +238,35 @@ export default class Document {
 	/**
 	 * Renders all changes. In order to avoid triggering the observers (e.g. mutations) all observers are disabled
 	 * before rendering and re-enabled after that.
+	 *
+	 * @fires engine.view.Document#render
 	 */
 	render() {
-		this.disableObservers();
-
-		this.renderer.render();
+		this.fire( 'render' );
+	}
 
-		this.enableObservers();
+	/**
+	 * Focuses document. It will focus {@link engine.view.EditableElement EditableElement} that is currently having
+	 * selection inside.
+	 */
+	focus() {
+		if ( !this.isFocused ) {
+			const editable = this.selection.getEditableElement();
+
+			if ( editable ) {
+				this.domConverter.focus( editable );
+				this.render();
+			} else {
+				/**
+				 * Before focusing view document, selection should be placed inside one of the view's editables.
+				 * Normally its selection will be converted from model document (which have default selection), but
+				 * when using view document on its own, we need to manually place selection before focusing it.
+				 *
+				 * @error view-focus-no-selection
+				 */
+				log.warn( 'view-focus-no-selection: There is no selection in any editable to focus.' );
+			}
+		}
 	}
 
 	/**
@@ -278,3 +301,10 @@ mix( Document, ObservableMixin );
  *
  * @typedef {String} engine.view.ChangeType
  */
+
+/**
+ * Fired when {@link engine.view.Document#render render} method is called. Actual rendering is executed as a listener to
+ * this event with default priority. This way other listeners can be used to run code before or after rendering.
+ *
+ * @event engine.view.Document.render
+ */

+ 51 - 8
packages/ckeditor5-engine/src/view/domconverter.js

@@ -234,7 +234,7 @@ export default class DomConverter {
 
 			// If there is an inline filler at position return position inside the filler. We should never return
 			// the position before the inline filler.
-			if ( domAfter instanceof Text && startsWithFiller( domAfter ) ) {
+			if ( this.isText( domAfter ) && startsWithFiller( domAfter ) ) {
 				return { parent: domAfter, offset: INLINE_FILLER_LENGTH };
 			}
 
@@ -261,7 +261,7 @@ export default class DomConverter {
 			return null;
 		}
 
-		if ( domNode instanceof Text ) {
+		if ( this.isText( domNode ) ) {
 			if ( isInlineFiller( domNode ) ) {
 				return null;
 			} else {
@@ -274,7 +274,7 @@ export default class DomConverter {
 
 			let viewElement;
 
-			if ( domNode instanceof DocumentFragment ) {
+			if ( this.isDocumentFragment( domNode ) ) {
 				// Create view document fragment.
 				viewElement = new ViewDocumentFragment();
 
@@ -383,7 +383,7 @@ export default class DomConverter {
 			return this.domPositionToView( domParent.parentNode, indexOf( domParent ) );
 		}
 
-		if ( domParent instanceof Text ) {
+		if ( this.isText( domParent ) ) {
 			if ( isInlineFiller( domParent ) ) {
 				return this.domPositionToView( domParent.parentNode, indexOf( domParent ) );
 			}
@@ -435,11 +435,11 @@ export default class DomConverter {
 	 * @returns {engine.view.Node|engine.view.DocumentFragment|null} Corresponding view item.
 	 */
 	getCorrespondingView( domNode ) {
-		if ( domNode instanceof HTMLElement ) {
+		if ( this.isElement( domNode ) ) {
 			return this.getCorrespondingViewElement( domNode );
-		} else if ( domNode instanceof DocumentFragment ) {
+		} else if ( this.isDocumentFragment( domNode ) ) {
 			return this.getCorrespondingViewDocumentFragment( domNode );
-		} else if ( domNode instanceof Text ) {
+		} else if ( this.isText( domNode ) ) {
 			return this.getCorrespondingViewText( domNode );
 		}
 
@@ -495,7 +495,7 @@ export default class DomConverter {
 
 		// Try to use previous sibling to find the corresponding text node.
 		if ( previousSibling ) {
-			if ( !( previousSibling instanceof HTMLElement ) ) {
+			if ( !( this.isElement( previousSibling ) ) ) {
 				// The previous is text or comment.
 				return null;
 			}
@@ -606,4 +606,47 @@ export default class DomConverter {
 
 		return null;
 	}
+
+	/**
+	 * Focuses DOM editable that is corresponding to provided {@link engine.view.EditableElement EditableElement}.
+	 *
+	 * @param {engine.view.EditableElement} viewEditable
+	 */
+	focus( viewEditable ) {
+		const domEditable = this.getCorrespondingDomElement( viewEditable );
+
+		if ( domEditable && domEditable.ownerDocument.activeElement !== domEditable ) {
+			domEditable.focus();
+		}
+	}
+
+	/**
+	 * Returns `true` when `node.nodeType` equals `Node.TEXT_NODE`.
+	 *
+	 * @param {Node} node Node to check.
+	 * @returns {Boolean}
+	 */
+	isText( node ) {
+		return node && node.nodeType == Node.TEXT_NODE;
+	}
+
+	/**
+	 * Returns `true` when `node.nodeType` equals `Node.ELEMENT_NODE`.
+	 *
+	 * @param {Node} node Node to check.
+	 * @returns {Boolean}
+	 */
+	isElement( node ) {
+		return node && node.nodeType == Node.ELEMENT_NODE;
+	}
+
+	/**
+	 * Returns `true` when `node.nodeType` equals `Node.DOCUMENT_FRAGMENT_NODE`.
+	 *
+	 * @param {Node} node Node to check.
+	 * @returns {Boolean}
+	 */
+	isDocumentFragment( node ) {
+		return node && node.nodeType == Node.DOCUMENT_FRAGMENT_NODE;
+	}
 }

+ 14 - 3
packages/ckeditor5-engine/src/view/editableelement.js

@@ -43,13 +43,24 @@ export default class EditableElement extends ContainerElement {
 		/**
 		 * Whether the editable is focused.
 		 *
-		 * This property updates when {@link engine.view.Document#focusedEditable} changes.
+		 * This property updates when {@link engine.view.Document#isFocused document.isFocused} is changed and after each
+		 * {@link engine.view.Document#render render} method call.
 		 *
 		 * @readonly
 		 * @observable
-		 * @member {Boolean} engine.view.RootEditableElement#isFocused
+		 * @member {Boolean} engine.view.EditableElement#isFocused
 		 */
-		this.bind( 'isFocused' ).to( document, 'focusedEditable', ( focusedEditable ) => focusedEditable == this );
+		this.bind( 'isFocused' ).to(
+			document,
+			'isFocused',
+			( isFocused ) => isFocused && document.selection.getEditableElement() == this
+		);
+
+		// Update focus state after each rendering. Selection might be moved to different editable before rendering,
+		// but this does not mean that editable has focus - it will be placed there after rendering.
+		this.listenTo( document, 'render', () => {
+			this.isFocused = document.isFocused && document.selection.getEditableElement() == this;
+		}, null, 11 );
 	}
 
 	/**

+ 2 - 2
packages/ckeditor5-engine/src/view/filler.js

@@ -10,7 +10,7 @@ import { keyCodes } from '../../utils/keyboard.js';
 /**
  * Set of utils related to block and inline fillers handling.
  *
- * Browsers do not allow to put caret in elements which does not have hight. Because of it, we need to fill all
+ * Browsers do not allow to put caret in elements which does not have height. Because of it, we need to fill all
  * empty elements which should be selectable with elements or characters called "fillers". Unfortunately there is no one
  * universal filler, this is why two types are uses:
  *
@@ -25,7 +25,7 @@ import { keyCodes } from '../../utils/keyboard.js';
  * `<b>` surrendered by text: `foo<b></b>bar`, if we want to put the caret there. CKEditor uses a sequence of the zero-width
  * spaces as an {@link engine.view.filler.INLINE_FILLER inline filler} having the predetermined
  * {@link engine.view.filler.INLINE_FILLER_LENGTH length}. A sequence is used, instead of a single character to
- * avoid threating random zero-width spaces as the inline filler. Disadvantage of the inline filler is that it is not
+ * avoid treating random zero-width spaces as the inline filler. Disadvantage of the inline filler is that it is not
  * transparent for the selection. The arrow key moves the caret between zero-width spaces characters, so the additional
  * code is needed to handle the caret.
  *

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

@@ -23,14 +23,15 @@ export default class FocusObserver extends DomEventObserver {
 
 		this.domEventType = [ 'focus', 'blur' ];
 
-		// Update `isFocus` property of root elements and `document#focusedEditable`.
-		document.on( 'focus', ( evt, data ) => {
-			document.focusedEditable = data.target;
+		document.on( 'focus', () => {
+			document.isFocused = true;
 		} );
 
 		document.on( 'blur', ( evt, data ) => {
-			if ( document.focusedEditable == data.target ) {
-				document.focusedEditable = null;
+			const selectedEditable = document.selection.getEditableElement();
+
+			if ( selectedEditable === null || selectedEditable === data.target ) {
+				document.isFocused = false;
 			}
 		} );
 	}

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

@@ -116,11 +116,7 @@ export default class SelectionObserver extends Observer {
 	 * @param {Document} domDocument DOM document.
 	 */
 	_handleSelectionChange( domDocument ) {
-		if ( !this.isEnabled ) {
-			return;
-		}
-
-		if ( !this.document.focusedEditable ) {
+		if ( !this.isEnabled || !this.document.isFocused ) {
 			return;
 		}
 

+ 33 - 2
packages/ckeditor5-engine/src/view/position.js

@@ -6,9 +6,11 @@
 'use strict';
 
 import Text from './text.js';
+import TextProxy from './textproxy.js';
 
 import compareArrays from '../../utils/comparearrays.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
+import EditableElement from './editableelement.js';
 
 /**
  * Position in the tree. Position is always located before or after a node.
@@ -186,13 +188,37 @@ export default class Position {
 		return index < 0 ? 'BEFORE' : 'AFTER';
 	}
 
+	/**
+	 * Returns {@link engine.view.EditableElement EditableElement} instance that contains this position.
+	 *
+	 * @returns {engine.view.EditableElement|null} Returns closest EditableElement or null if none is found.
+	 */
+	getEditableElement() {
+		let editable = this.parent;
+
+		while ( !( editable instanceof EditableElement ) ) {
+			if ( editable.parent ) {
+				editable = editable.parent;
+			} else {
+				return null;
+			}
+		}
+
+		return editable;
+	}
+
 	/**
 	 * Creates a new position after the given node.
 	 *
-	 * @param {engine.view.Node} node Node after which the position should be located.
+	 * @param {engine.view.Node|engine.view.TextProxy} node Node or text proxy after which the position should be located.
 	 * @returns {engine.view.Position}
 	 */
 	static createAfter( node ) {
+		// {@link engine.view.TextProxy} is not a instance of {@link engine.view.Node} so we need do handle it in specific way.
+		if ( node instanceof TextProxy ) {
+			return new Position( node.textNode, node.index + node.data.length );
+		}
+
 		if ( !node.parent ) {
 			/**
 			 * You can not make a position after a root.
@@ -209,10 +235,15 @@ export default class Position {
 	/**
 	 * Creates a new position before the given node.
 	 *
-	 * @param {engine.view.node} node Node before which the position should be located.
+	 * @param {engine.view.Node|engine.view.TextProxy} node Node or text proxy before which the position should be located.
 	 * @returns {engine.view.Position}
 	 */
 	static createBefore( node ) {
+		// {@link engine.view.TextProxy} is not a instance of {@link engine.view.Node} so we need do handle it in specific way.
+		if ( node instanceof TextProxy ) {
+			return new Position( node.textNode, node.index );
+		}
+
 		if ( !node.parent ) {
 			/**
 			 * You cannot make a position before a root.

+ 59 - 14
packages/ckeditor5-engine/src/view/renderer.js

@@ -40,6 +40,13 @@ export default class Renderer {
 	 * @param {engine.view.Selection} selection View selection.
 	 */
 	constructor( domConverter, selection ) {
+		/**
+		 * Set of DOM Documents instances.
+		 *
+		 * @member {Set.<Document>} engine.view.Renderer#domDocuments
+		 */
+		this.domDocuments = new Set();
+
 		/**
 		 * Converter instance.
 		 *
@@ -90,13 +97,12 @@ export default class Renderer {
 		this._inlineFillerPosition = null;
 
 		/**
-		 * {@link engine.view.EditableElement} in which selection is allowed to be rendered.
-		 * If it is null, then selection will not be rendered.
+		 * Indicates if view document is focused and selection can be rendered. Selection will not be rendered if
+		 * this is set to `false`.
 		 *
-		 * @readonly
-		 * @member {engine.view.EditableElement|null} engine.view.Renderer#focusedEditable
+		 * @member {Boolean} engine.view.Renderer#isFocused
 		 */
-		this.focusedEditable = null;
+		this.isFocused = false;
 	}
 
 	/**
@@ -140,7 +146,7 @@ export default class Renderer {
 
 	/**
 	 * Render method checks {@link engine.view.Renderer#markedAttributes},
-	 * {@link engine.view.Renderer#markedChildren} and {@link engine.view.Renderer#markedTexts} and updats all
+	 * {@link engine.view.Renderer#markedChildren} and {@link engine.view.Renderer#markedTexts} and updates all
 	 * nodes which need to be updated. Then it clears all three sets. Also, every time render is called it compares and
 	 * if needed updates the selection.
 	 *
@@ -155,7 +161,7 @@ export default class Renderer {
 	 * {@link engine.view.DomConverter#getCorrespondingDomText corresponding DOM text}. The change will be handled
 	 * in the parent element.
 	 *
-	 * For elements, which child lists have changed, it calculates a {@link diff} and adds or removs children which have changed.
+	 * For elements, which child lists have changed, it calculates a {@link diff} and adds or removes children which have changed.
 	 *
 	 * Rendering also handles {@link engine.view.filler fillers}. Especially, it checks if the inline filler is needed
 	 * at selection position and adds or removes it. To prevent breaking text composition inline filler will not be
@@ -189,6 +195,7 @@ export default class Renderer {
 		}
 
 		this._updateSelection();
+		this._updateFocus();
 
 		this.markedTexts.clear();
 		this.markedAttributes.clear();
@@ -243,7 +250,7 @@ export default class Renderer {
 		const domFillerNode = domFillerPosition.parent;
 
 		// If there is no filler viewPositionToDom will return parent node, so domFillerNode will be an element.
-		if ( !( domFillerNode instanceof Text ) || !startsWithFiller( domFillerNode ) ) {
+		if ( !( this.domConverter.isText( domFillerNode ) ) || !startsWithFiller( domFillerNode ) ) {
 			/**
 			 * No inline filler on expected position.
 			 *
@@ -364,7 +371,7 @@ export default class Renderer {
 		if ( filler && filler.parent == viewElement ) {
 			const expectedNodeAfterFiller = expectedDomChildren[ filler.offset ];
 
-			if ( expectedNodeAfterFiller instanceof Text ) {
+			if ( this.domConverter.isText( expectedNodeAfterFiller ) ) {
 				expectedNodeAfterFiller.data = INLINE_FILLER + expectedNodeAfterFiller.data;
 			} else {
 				expectedDomChildren.splice( filler.offset, 0, domDocument.createTextNode( INLINE_FILLER ) );
@@ -392,7 +399,7 @@ export default class Renderer {
 				return true;
 			}
 			// Texts.
-			else if ( actualDomChild instanceof Text && expectedDomChild instanceof Text ) {
+			else if ( domConverter.isText( actualDomChild ) && domConverter.isText( expectedDomChild ) ) {
 				return actualDomChild.data === expectedDomChild.data;
 			}
 			// Block fillers.
@@ -412,11 +419,19 @@ export default class Renderer {
 	 * @private
 	 */
 	_updateSelection() {
-		if ( !this.focusedEditable ) {
+		// If there is no selection - remove it from DOM elements that belongs to the editor.
+		if ( this.selection.rangeCount === 0 ) {
+			this._removeDomSelction();
+
 			return;
 		}
 
-		const domRoot = this.domConverter.getCorrespondingDomElement( this.focusedEditable );
+		if ( !this.isFocused ) {
+			return;
+		}
+
+		const selectedEditable = this.selection.getEditableElement();
+		const domRoot = this.domConverter.getCorrespondingDomElement( selectedEditable );
 
 		if ( !domRoot ) {
 			return;
@@ -432,8 +447,8 @@ export default class Renderer {
 		domSelection.removeAllRanges();
 
 		for ( let range of this.selection.getRanges() ) {
-			// Update ranges only in currently focused editable.
-			if ( range.start.parent.getRoot() == this.focusedEditable ) {
+			// Update ranges only in currently selected editable.
+			if ( range.start.parent.getRoot() == selectedEditable ) {
 				const domRangeStart = this.domConverter.viewPositionToDom( range.start );
 				const domRangeEnd = this.domConverter.viewPositionToDom( range.end );
 				const domRange = new Range();
@@ -443,6 +458,36 @@ export default class Renderer {
 			}
 		}
 	}
+
+	_removeDomSelction() {
+		for ( let doc of this.domDocuments ) {
+			const domSelection = doc.getSelection();
+
+			if ( domSelection.rangeCount ) {
+				const activeDomElement = doc.activeElement;
+				const viewElement = this.domConverter.getCorrespondingViewElement( activeDomElement );
+
+				if ( activeDomElement && viewElement ) {
+					doc.getSelection().removeAllRanges();
+				}
+			}
+		}
+	}
+
+	/**
+	 * Checks if focus needs to be updated and possibly updates it.
+	 *
+	 * @private
+	 */
+	_updateFocus() {
+		if ( this.isFocused ) {
+			const editable = this.selection.getEditableElement();
+
+			if ( editable ) {
+				this.domConverter.focus( editable );
+			}
+		}
+	}
 }
 
 mix( Renderer, ObservableMixin );

+ 14 - 0
packages/ckeditor5-engine/src/view/selection.js

@@ -279,6 +279,7 @@ export default class Selection {
 		}
 
 		this._lastRangeBackward = otherSelection._lastRangeBackward;
+		this.fire( 'change' );
 	}
 
 	/**
@@ -313,6 +314,19 @@ export default class Selection {
 		}
 	}
 
+	/**
+	 * Returns {@link engine.view.EditableElement EditableElement} instance that contains this selection.
+	 *
+	 * @returns {engine.view.EditableElement|null} Returns closest EditableElement or null if none is found.
+	 */
+	getEditableElement() {
+		if ( this.rangeCount ) {
+			return this.getFirstPosition().getEditableElement();
+		}
+
+		return null;
+	}
+
 	/**
 	 * Adds range to selection - creates copy of given range so it can be safely used and modified.
 	 *

+ 105 - 0
packages/ckeditor5-engine/src/view/textproxy.js

@@ -0,0 +1,105 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+/**
+ * TextProxy is a wrapper for substring of {@link engine.view.Text}. Instance of this class is created by
+ * {@link engine.view.TreeWalker} when only a part of {@link engine.view.Text} needs to be returned.
+ *
+ * **Note:** `TextProxy` instances are created on the fly basing on the current state of parent {@link engine.view.Text}.
+ * Because of this it is highly unrecommended to store references to `TextProxy` instances because they might get
+ * invalidated due to operations on the document. Also, `TextProxy` is not a {@link engine.view.Node} so it cannot be
+ * inserted as a child of {@link engine.view.Element}.
+ *
+ * You should never create an instance of this class by your own.
+ *
+ * @memberOf engine.view
+ */
+export default class TextProxy {
+	/**
+	 * Creates a tree view text proxy.
+	 *
+	 * @param {engine.view.Text} textNode Text node which text proxy is a substring.
+	 * @param {Number} startOffset Offset from beginning of {#textNode} and first character of {#data}.
+	 * @param {Number} [length] Length of substring. If is not set then the end offset is at the end of {#textNode}.
+	 */
+	constructor( textNode, startOffset, length ) {
+		/**
+		 * Element that is a parent of this text proxy.
+		 *
+		 * @readonly
+		 * @member {engine.view.Element|engine.view.DocumentFragment|null} engine.view.Node#parent
+		 */
+		this.parent = textNode.parent;
+
+		/**
+		 * Reference to the {@link engine.view.Text} element which TextProxy is a substring.
+		 *
+		 * @readonly
+		 * @member {engine.view.Text} engine.view.TextProxy#textNode
+		 */
+		this.textNode = textNode;
+
+		/**
+		 * Index of the substring in the `textParent`.
+		 *
+		 * @readonly
+		 * @member {Number} engine.view.TextProxy#index
+		 */
+		this.index = startOffset;
+
+		/**
+		 * The text content.
+		 *
+		 * @readonly
+		 * @member {String} engine.view.TextProxy#data
+		 */
+		this.data = textNode.data.substring(
+			startOffset,
+			startOffset + ( length || textNode.data.length - startOffset )
+		);
+	}
+
+	/**
+	 * Gets {@link engine.view.Document} reference, from the {@link engine.view.Node#getRoot root} of
+	 * {#textNode} or returns null if the root has no reference to the {@link engine.view.Document}.
+	 *
+	 * @returns {engine.view.Document|null} View Document of the text proxy or null.
+	 */
+	getDocument() {
+		return this.textNode.getDocument();
+	}
+
+	/**
+	 * Gets the top parent for the {#textNode}. If there is no parent {#textNode} is the root.
+	 *
+	 * @returns {engine.view.Node}
+	 */
+	getRoot() {
+		return this.textNode.getRoot();
+	}
+
+	/**
+	 * Returns ancestors array of this text proxy.
+	 *
+	 * @param {Object} options Options object.
+	 * @param {Boolean} [options.includeNode=false] When set to `true` {#textNode} will be also included in parent's array.
+	 * @param {Boolean} [options.parentFirst=false] When set to `true`, array will be sorted from text proxy parent to
+	 * root element, otherwise root element will be the first item in the array.
+	 * @returns {Array} Array with ancestors.
+	 */
+	getAncestors( options = { includeNode: false, parentFirst: false } ) {
+		const ancestors = [];
+		let parent = options.includeNode ? this.textNode : this.parent;
+
+		while ( parent !== null ) {
+			ancestors[ options.parentFirst ? 'push' : 'unshift' ]( parent );
+			parent = parent.parent;
+		}
+
+		return ancestors;
+	}
+}

+ 447 - 0
packages/ckeditor5-engine/src/view/treewalker.js

@@ -0,0 +1,447 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Element from './element.js';
+import Text from './text.js';
+import TextProxy from './textproxy.js';
+import Position from './position.js';
+import CKEditorError from '../../utils/ckeditorerror.js';
+
+/**
+ * Position iterator class. It allows to iterate forward and backward over the document.
+ *
+ * @memberOf engine.view
+ */
+export default class TreeWalker {
+	/**
+	 * Creates a range iterator. All parameters are optional, but you have to specify either `boundaries` or `startPosition`.
+	 *
+	 * @constructor
+	 * @param {Object} options Object with configuration.
+	 * @param {engine.view.Range} [options.boundaries=null] Range to define boundaries of the iterator.
+	 * @param {engine.view.Position} [options.startPosition] Starting position.
+	 * @param {'FORWARD'|'BACKWARD'} [options.direction='FORWARD'] Walking direction.
+	 * @param {Boolean} [options.singleCharacters=false] Flag indicating whether all characters from
+	 * {@link engine.view.Text} should be returned as one {@link engine.view.Text} (`false`) ore one by one as
+	 * {@link engine.view.TextProxy} (`true`).
+	 * @param {Boolean} [options.shallow=false] Flag indicating whether iterator should enter elements or not. If the
+	 * iterator is shallow child nodes of any iterated node will not be returned along with `ELEMENT_END` tag.
+	 * @param {Boolean} [options.ignoreElementEnd=false] Flag indicating whether iterator should ignore `ELEMENT_END`
+	 * tags. If the option is true walker will not return a parent node of start position. If this option is `true`
+	 * each {@link engine.view.Element} will be returned once, while if the option is `false` they might be returned
+	 * twice: for `'ELEMENT_START'` and `'ELEMENT_END'`.
+	 */
+	constructor( options = {} ) {
+		if ( !options.boundaries && !options.startPosition ) {
+			/**
+			 * Neither boundaries nor starting position have been defined.
+			 *
+			 * @error tree-walker-no-start-position
+			 */
+			throw new CKEditorError( 'tree-walker-no-start-position: Neither boundaries nor starting position have been defined.' );
+		}
+
+		if ( options.direction && options.direction != 'FORWARD' && options.direction != 'BACKWARD' ) {
+			throw new CKEditorError(
+				'tree-walker-unknown-direction: Only `BACKWARD` and `FORWARD` direction allowed.',
+				{ direction: options.direction }
+			);
+		}
+
+		/**
+		 * Iterator boundaries.
+		 *
+		 * When the iterator is walking `FORWARD` on the end of boundary or is walking `BACKWARD`
+		 * on the start of boundary, then `{ done: true }` is returned.
+		 *
+		 * If boundaries are not defined they are set before first and after last child of the root node.
+		 *
+		 * @readonly
+		 * @member {engine.view.Range} engine.view.TreeWalker#boundaries
+		 */
+		this.boundaries = options.boundaries || null;
+
+		/**
+		 * Iterator position. If start position is not defined then position depends on {@link #direction}. If direction is
+		 * `FORWARD` position starts form the beginning, when direction is `BACKWARD` position starts from the end.
+		 *
+		 * @readonly
+		 * @member {engine.view.Position} engine.view.TreeWalker#position
+		 */
+		if ( options.startPosition ) {
+			this.position = Position.createFromPosition( options.startPosition );
+		} else {
+			this.position = Position.createFromPosition( options.boundaries[ options.direction == 'BACKWARD' ? 'end' : 'start' ] );
+		}
+
+		/**
+		 * Walking direction. Defaults `FORWARD`.
+		 *
+		 * @readonly
+		 * @member {'BACKWARD'|'FORWARD'} engine.view.TreeWalker#direction
+		 */
+		this.direction = options.direction || 'FORWARD';
+
+		/**
+		 * Flag indicating whether all characters from {@link engine.view.Text} should be returned as one
+		 * {@link engine.view.Text} or one by one as {@link engine.view.TextProxy}.
+		 *
+		 * @readonly
+		 * @member {Boolean} engine.view.TreeWalker#singleCharacters
+		 */
+		this.singleCharacters = !!options.singleCharacters;
+
+		/**
+		 * Flag indicating whether iterator should enter elements or not. If the iterator is shallow child nodes of any
+		 * iterated node will not be returned along with `ELEMENT_END` tag.
+		 *
+		 * @readonly
+		 * @member {Boolean} engine.view.TreeWalker#shallow
+		 */
+		this.shallow = !!options.shallow;
+
+		/**
+		 * Flag indicating whether iterator should ignore `ELEMENT_END` tags. If set to `true`, walker will not
+		 * return a parent node of the start position. Each {@link engine.view.Element} will be returned once.
+		 * When set to `false` each element might be returned twice: for `'ELEMENT_START'` and `'ELEMENT_END'`.
+		 *
+		 * @readonly
+		 * @member {Boolean} engine.view.TreeWalker#ignoreElementEnd
+		 */
+		this.ignoreElementEnd = !!options.ignoreElementEnd;
+
+		/**
+		 * Start boundary parent.
+		 *
+		 * @private
+		 * @member {engine.view.Node} engine.view.TreeWalker#_boundaryStartParent
+		 */
+		this._boundaryStartParent = this.boundaries ? this.boundaries.start.parent : null;
+
+		/**
+		 * End boundary parent.
+		 *
+		 * @private
+		 * @member {engine.view.Node} engine.view.TreeWalker#_boundaryEndParent
+		 */
+		this._boundaryEndParent = this.boundaries ? this.boundaries.end.parent : null;
+	}
+
+	/**
+	 * Iterator interface.
+	 */
+	[ Symbol.iterator ]() {
+		return this;
+	}
+
+	/**
+	 * Iterator interface method.
+	 * Detects walking direction and makes step forward or backward.
+	 *
+	 * @returns {Object} Object implementing iterator interface, returning information about taken step.
+	 */
+	next() {
+		if ( this.direction == 'FORWARD' ) {
+			return this._next();
+		} else {
+			return this._previous();
+		}
+	}
+
+	/**
+	 * Makes a step forward in view. Moves the {@link #position} to the next position and returns the encountered value.
+	 *
+	 * @private
+	 * @returns {Object}
+	 * @returns {Boolean} return.done `true` if iterator is done, `false` otherwise.
+	 * @returns {engine.view.TreeWalkerValue} return.value Information about taken step.
+	 */
+	_next() {
+		let position = Position.createFromPosition( this.position );
+		const previousPosition = this.position;
+		const parent = position.parent;
+
+		// We are at the end of the root.
+		if ( parent.parent === null && position.offset === parent.getChildCount() ) {
+			return { done: true };
+		}
+
+		// We reached the walker boundary.
+		if ( parent === this._boundaryEndParent && position.offset == this.boundaries.end.offset ) {
+			return { done: true };
+		}
+
+		// Get node just after current position.
+		let node;
+
+		// Text is a specific parent because it contains string instead of child nodes.
+		if ( parent instanceof Text ) {
+			node = parent.data[ position.offset ];
+		} else {
+			node = parent.getChild( position.offset );
+		}
+
+		if ( node instanceof Element ) {
+			if ( !this.shallow ) {
+				position = new Position( node, 0 );
+			} else {
+				position.offset++;
+			}
+
+			this.position = position;
+
+			return this._formatReturnValue( 'ELEMENT_START', node, previousPosition, position, 1 );
+		} else if ( node instanceof Text ) {
+			if ( this.singleCharacters ) {
+				position = new Position( node, 0 );
+				this.position = position;
+
+				return this._next();
+			} else {
+				let charactersCount = node.data.length;
+				let item = node;
+
+				// If text stick out of walker range, we need to cut it and wrap by TextProxy.
+				if ( node == this._boundaryEndParent ) {
+					charactersCount = this.boundaries.end.offset;
+					item = new TextProxy( node, 0, charactersCount );
+					position = Position.createAfter( item );
+				} else {
+					// If not just keep moving forward.
+					position.offset++;
+				}
+
+				this.position = position;
+
+				return this._formatReturnValue( 'TEXT', item, previousPosition, position, charactersCount );
+			}
+		} else if ( typeof node == 'string' ) {
+			let textLength;
+
+			if ( this.singleCharacters ) {
+				textLength = 1;
+			} else {
+				// Check if text stick out of walker range.
+				const endOffset = parent === this._boundaryEndParent ? this.boundaries.end.offset : parent.data.length;
+
+				textLength = endOffset - position.offset;
+			}
+
+			const textProxy = new TextProxy( parent, position.offset, textLength );
+
+			position.offset += textLength;
+			this.position = position;
+
+			return this._formatReturnValue( 'TEXT', textProxy, previousPosition, position, textLength );
+		} else {
+			// `node` is not set, we reached the end of current `parent`.
+			position = Position.createAfter( parent );
+			this.position = position;
+
+			if ( this.ignoreElementEnd ) {
+				return this._next();
+			} else {
+				return this._formatReturnValue( 'ELEMENT_END', parent, previousPosition, position );
+			}
+		}
+	}
+
+	/**
+	 * Makes a step backward in view. Moves the {@link #position} to the previous position and returns the encountered value.
+	 *
+	 * @private
+	 * @returns {Object}
+	 * @returns {Boolean} return.done True if iterator is done.
+	 * @returns {engine.view.TreeWalkerValue} return.value Information about taken step.
+	 */
+	_previous() {
+		let position = Position.createFromPosition( this.position );
+		const previousPosition = this.position;
+		const parent = position.parent;
+
+		// We are at the beginning of the root.
+		if ( parent.parent === null && position.offset === 0 ) {
+			return { done: true };
+		}
+
+		// We reached the walker boundary.
+		if ( parent == this._boundaryStartParent && position.offset == this.boundaries.start.offset ) {
+			return { done: true };
+		}
+
+		// Get node just before current position.
+		let node;
+
+		// Text {@link engine.view.Text} element is a specific parent because contains string instead of child nodes.
+		if ( parent instanceof Text ) {
+			node = parent.data[ position.offset - 1 ];
+		} else {
+			node = parent.getChild( position.offset - 1 );
+		}
+
+		if ( node instanceof Element ) {
+			if ( !this.shallow ) {
+				position = new Position( node, node.getChildCount() );
+				this.position = position;
+
+				if ( this.ignoreElementEnd ) {
+					return this._previous();
+				} else {
+					return this._formatReturnValue( 'ELEMENT_END', node, previousPosition, position );
+				}
+			} else {
+				position.offset--;
+				this.position = position;
+
+				return this._formatReturnValue( 'ELEMENT_START', node, previousPosition, position, 1 );
+			}
+		} else if ( node instanceof Text ) {
+			if ( this.singleCharacters ) {
+				position = new Position( node, node.data.length );
+				this.position = position;
+
+				return this._previous();
+			} else {
+				let charactersCount = node.data.length;
+				let item = node;
+
+				// If text stick out of walker range, we need to cut it and wrap by TextProxy.
+				if ( node == this._boundaryStartParent ) {
+					const offset = this.boundaries.start.offset;
+
+					item = new TextProxy( node, offset );
+					charactersCount = item.data.length;
+					position = Position.createBefore( item );
+				} else {
+					// If not just keep moving backward.
+					position.offset--;
+				}
+
+				this.position = position;
+
+				return this._formatReturnValue( 'TEXT', item, previousPosition, position, charactersCount );
+			}
+		} else if ( typeof node == 'string' ) {
+			let textLength;
+
+			if ( !this.singleCharacters ) {
+				// Check if text stick out of walker range.
+				const startOffset = parent === this._boundaryStartParent ? this.boundaries.start.offset : 0;
+
+				textLength = position.offset - startOffset;
+			} else {
+				textLength = 1;
+			}
+
+			position.offset -= textLength;
+
+			const textProxy = new TextProxy( parent, position.offset, textLength );
+
+			this.position = position;
+
+			return this._formatReturnValue( 'TEXT', textProxy, previousPosition, position, textLength );
+		} else {
+			// `node` is not set, we reached the beginning of current `parent`.
+			position = Position.createBefore( parent );
+			this.position = position;
+
+			return this._formatReturnValue( 'ELEMENT_START', parent, previousPosition, position, 1 );
+		}
+	}
+
+	/**
+	 * Format returned data and adjust `previousPosition` and `nextPosition` if reach the bound of the {@link engine.view.Text}.
+	 *
+	 * @private
+	 * @param {engine.view.TreeWalkerValueType} type Type of step.
+	 * @param {engine.view.Item} item Item between old and new position.
+	 * @param {engine.view.Position} previousPosition Previous position of iterator.
+	 * @param {engine.view.Position} nextPosition Next position of iterator.
+	 * @param {Number} [length] Length of the item.
+	 * @returns {engine.view.TreeWalkerValue}
+	 */
+	_formatReturnValue( type, item, previousPosition, nextPosition, length ) {
+		// Text is a specific parent, because contains string instead of childs.
+		// Walker doesn't enter to the Text except situations when walker is iterating over every single character,
+		// or the bound starts/ends inside the Text. So when the position is at the beginning or at the end of the Text
+		// we move it just before or just after Text.
+		if ( item instanceof TextProxy ) {
+			// Position is at the end of Text.
+			if ( item.index + item.data.length == item.textNode.data.length ) {
+				if ( this.direction == 'FORWARD' ) {
+					nextPosition = Position.createAfter( item.textNode );
+					// When we change nextPosition of returned value we need also update walker current position.
+					this.position = nextPosition;
+				} else {
+					previousPosition = Position.createAfter( item.textNode );
+				}
+			}
+
+			// Position is at the begining ot the text.
+			if ( item.index === 0 ) {
+				if ( this.direction == 'FORWARD' ) {
+					previousPosition = Position.createBefore( item.textNode );
+				} else {
+					nextPosition = Position.createBefore( item.textNode );
+					// When we change nextPosition of returned value we need also update walker current position.
+					this.position = nextPosition;
+				}
+			}
+		}
+
+		return {
+			done: false,
+			value: {
+				type: type,
+				item: item,
+				previousPosition: previousPosition,
+				nextPosition: nextPosition,
+				length: length
+			}
+		};
+	}
+}
+
+/**
+ * Type of the step made by {@link engine.view.TreeWalker}.
+ * Possible values: `'ELEMENT_START'` if walker is at the beginning of a node, `'ELEMENT_END'` if walker is at the end
+ * of node, or `'TEXT'` if walker traversed over single and multiple characters.
+ * For {@link engine.view.Text} `ELEMENT_START` and `ELEMENT_END` is not returned.
+ *
+ * @typedef {String} engine.view.TreeWalkerValueType
+ */
+
+/**
+ * Object returned by {@link engine.view.TreeWalker} when traversing tree view.
+ *
+ * @typedef {Object} engine.view.TreeWalkerValue
+ * @property {engine.view.TreeWalkerValueType} type
+ * @property {engine.view.Item} item Item between old and new positions of {@link engine.view.TreeWalker}.
+ * @property {engine.view.Position} previousPosition Previous position of the iterator.
+ * * Forward iteration: For `'ELEMENT_END'` it is the last position inside the element. For all other types it is the
+ * position before the item. Note that it is more efficient to use this position then calculate the position before
+ * the node using {@link engine.view.Position.createBefore}.
+ * * Backward iteration: For `'ELEMENT_START'` it is the first position inside the element. For all other types it is
+ * the position after item.
+ * * If the position is at the beginning or at the end of the {@link engine.view.Text} it is always moved from the
+ * inside of the Text to its parent just before or just after Text.
+ * @property {engine.view.Position} nextPosition Next position of the iterator.
+ * * Forward iteration: For `'ELEMENT_START'` it is the first position inside the element. For all other types it is
+ * the position after the item.
+ * * Backward iteration: For `'ELEMENT_END'` it is last position inside element. For all other types it is the position
+ * before the item.
+ * * If the position is at the beginning or at the end of the {@link engine.view.Text} it is always moved from the
+ * inside of the Text to its parent just before or just after Text.
+ * @property {Number} [length] Length of the item. For `'ELEMENT_START'` it is 1. For `'TEXT'` it is
+ * the length of the text. For `'ELEMENT_END'` it is undefined.
+ */
+
+/**
+ * Tree walking directions.
+ *
+ * @typedef {'FORWARD'|'BACKWARD'} engine.view.TreeWalkerDirection
+ */

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 477 - 492
packages/ckeditor5-engine/src/view/writer.js


+ 2 - 2
packages/ckeditor5-engine/tests/_utils-tests/model.js

@@ -18,7 +18,7 @@ describe( 'model test utils', () => {
 
 	beforeEach( () => {
 		document = new Document();
-		root = document.createRoot( 'main', '$root' );
+		root = document.createRoot();
 		selection = document.selection;
 		sandbox = sinon.sandbox.create();
 		selection.removeAllRanges();
@@ -169,7 +169,7 @@ describe( 'model test utils', () => {
 			} );
 
 			it( 'writes selection in an empty root', () => {
-				const root = document.createRoot( 'empty', '$root' );
+				const root = document.createRoot( '$root', 'empty' );
 				selection.collapse( root );
 
 				expect( stringify( root, selection ) ).to.equal(

+ 6 - 0
packages/ckeditor5-engine/tests/_utils-tests/view.js

@@ -317,6 +317,12 @@ describe( 'view test utils', () => {
 			expect( text.data ).to.equal( 'foobar' );
 		} );
 
+		it( 'should parse text with spaces', () => {
+			const text = parse( 'foo bar' );
+			expect( text ).to.be.instanceOf( Text );
+			expect( text.data ).to.equal( 'foo bar' );
+		} );
+
 		it( 'should parse elements and texts', () => {
 			const view = parse( '<b>foobar</b>' );
 			const element = new Element( 'b' );

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

@@ -146,7 +146,7 @@ export function parse( data, options = {} ) {
 		root.removeChildren( 0, root.getChildCount() );
 	} else {
 		document = new Document();
-		root = document.createRoot( rootName );
+		root = document.createRoot( '$root', rootName );
 	}
 
 	const path = [];

+ 1 - 1
packages/ckeditor5-engine/tests/_utils/view.js

@@ -331,7 +331,7 @@ class RangeParser {
 
 		if ( node instanceof ViewText ) {
 			const regexp = new RegExp(
-				`[ ${ TEXT_RANGE_START_TOKEN }${ TEXT_RANGE_END_TOKEN }\\${ ELEMENT_RANGE_END_TOKEN }\\${ ELEMENT_RANGE_START_TOKEN } ]`,
+				`[${ TEXT_RANGE_START_TOKEN }${ TEXT_RANGE_END_TOKEN }\\${ ELEMENT_RANGE_END_TOKEN }\\${ ELEMENT_RANGE_START_TOKEN }]`,
 				'g'
 			);
 			let text = node.data;

+ 11 - 13
packages/ckeditor5-engine/tests/conversion/advanced-converters.js

@@ -20,7 +20,7 @@ import ViewElement from '/ckeditor5/engine/view/element.js';
 import ViewContainerElement from '/ckeditor5/engine/view/containerelement.js';
 import ViewAttributeElement from '/ckeditor5/engine/view/attributeelement.js';
 import ViewText from '/ckeditor5/engine/view/text.js';
-import ViewWriter from  '/ckeditor5/engine/view/writer.js';
+import viewWriter from  '/ckeditor5/engine/view/writer.js';
 import ViewPosition from '/ckeditor5/engine/view/position.js';
 import ViewRange from '/ckeditor5/engine/view/range.js';
 
@@ -41,19 +41,17 @@ import {
 } from '/ckeditor5/engine/conversion/model-to-view-converters.js';
 import { convertToModelFragment, convertText } from '/ckeditor5/engine/conversion/view-to-model-converters.js';
 
-let modelDoc, modelRoot, viewRoot, mapper, writer, modelDispatcher, viewDispatcher;
+let modelDoc, modelRoot, viewRoot, mapper, modelDispatcher, viewDispatcher;
 
 beforeEach( () => {
 	modelDoc = new ModelDocument();
-	modelRoot = modelDoc.createRoot( 'root' );
+	modelRoot = modelDoc.createRoot();
 	viewRoot = new ViewContainerElement( 'div' );
 
 	mapper = new Mapper();
 	mapper.bindElements( modelRoot, viewRoot );
 
-	writer = new ViewWriter();
-
-	modelDispatcher = new ModelConversionDispatcher( { mapper, writer } );
+	modelDispatcher = new ModelConversionDispatcher( { mapper } );
 	// Schema is mocked up because we don't care about it in those tests.
 	viewDispatcher = new ViewConversionDispatcher( { schema: { check: () => true } } );
 
@@ -177,11 +175,11 @@ describe( 'image with caption converters', () => {
 
 					conversionApi.mapper.bindElements( modelCaption, viewCaption );
 					conversionApi.mapper.bindElements( data.item, viewImageHolder );
-					conversionApi.writer.insert( insertPosition, viewImageHolder );
+					viewWriter.insert( insertPosition, viewImageHolder );
 				}
 			} else {
 				conversionApi.mapper.bindElements( data.item, viewImage );
-				conversionApi.writer.insert( insertPosition, viewImage );
+				viewWriter.insert( insertPosition, viewImage );
 			}
 
 			evt.stop();
@@ -344,8 +342,8 @@ describe( 'custom attribute handling for given element', () => {
 				const viewOldA = elementCreator( data.attributeOldValue );
 				const viewNewA = elementCreator( data.attributeNewValue );
 
-				conversionApi.writer.unwrap( viewRange, viewOldA, evt.priority );
-				conversionApi.writer.wrap( viewRange, viewNewA, evt.priority );
+				viewWriter.unwrap( viewRange, viewOldA, evt.priority );
+				viewWriter.wrap( viewRange, viewNewA, evt.priority );
 
 				evt.stop();
 			};
@@ -412,7 +410,7 @@ describe( 'custom attribute handling for given element', () => {
 			const viewElement = new ViewContainerElement( 'blockquote' );
 
 			conversionApi.mapper.bindElements( data.item, viewElement );
-			conversionApi.writer.insert( viewPosition, viewElement );
+			viewWriter.insert( viewPosition, viewElement );
 
 			if ( consumable.consume( data.item, 'addAttribute:linkHref' ) ) {
 				const viewA = new ViewAttributeElement( 'a', { href: data.item.getAttribute( 'linkHref' ) }, new ViewText( 'see source' ) );
@@ -421,7 +419,7 @@ describe( 'custom attribute handling for given element', () => {
 					viewA.setAttribute( 'title', data.item.getAttribute( 'linkTitle' ) );
 				}
 
-				conversionApi.writer.insert( new ViewPosition( viewElement, viewElement.getChildCount() ), viewA );
+				viewWriter.insert( new ViewPosition( viewElement, viewElement.getChildCount() ), viewA );
 			}
 
 			evt.stop();
@@ -454,7 +452,7 @@ describe( 'custom attribute handling for given element', () => {
 			const viewA = viewElement.getChild( viewElement.getChildCount() - 1 );
 			const aIndex = viewA.getIndex();
 
-			conversionApi.writer.remove( ViewRange.createFromParentsAndOffsets( viewElement, aIndex, viewElement, aIndex + 1 ) );
+			viewWriter.remove( ViewRange.createFromParentsAndOffsets( viewElement, aIndex, viewElement, aIndex + 1 ) );
 
 			evt.stop();
 		} );

+ 2 - 3
packages/ckeditor5-engine/tests/conversion/model-converter-builder.js

@@ -71,7 +71,7 @@ function viewToString( item ) {
 describe( 'Model converter builder', () => {
 	let dispatcher, mapper;
 	let modelDoc, modelRoot;
-	let viewDoc, viewRoot, writer, viewSelection;
+	let viewDoc, viewRoot, viewSelection;
 
 	beforeEach( () => {
 		modelDoc = new ModelDocument();
@@ -79,13 +79,12 @@ describe( 'Model converter builder', () => {
 
 		viewDoc = new ViewDocument();
 		viewRoot = viewDoc.createRoot( 'div' );
-		writer = viewDoc.writer;
 		viewSelection = viewDoc.selection;
 
 		mapper = new Mapper();
 		mapper.bindElements( modelRoot, viewRoot );
 
-		dispatcher = new ModelConversionDispatcher( { writer, mapper, viewSelection } );
+		dispatcher = new ModelConversionDispatcher( { mapper, viewSelection } );
 
 		dispatcher.on( 'insert:$text', insertText() );
 		dispatcher.on( 'move', move() );

+ 5 - 5
packages/ckeditor5-engine/tests/conversion/model-selection-to-view-converters.js

@@ -15,6 +15,7 @@ import ModelPosition from '/ckeditor5/engine/model/position.js';
 import ViewDocument from '/ckeditor5/engine/view/document.js';
 import ViewContainerElement from '/ckeditor5/engine/view/containerelement.js';
 import ViewAttributeElement from '/ckeditor5/engine/view/attributeelement.js';
+import { mergeAt } from '/ckeditor5/engine/view/writer.js';
 
 import Mapper from '/ckeditor5/engine/conversion/mapper.js';
 import ModelConversionDispatcher from '/ckeditor5/engine/conversion/modelconversiondispatcher.js';
@@ -36,22 +37,21 @@ import { setData as setModelData } from '/tests/engine/_utils/model.js';
 
 let dispatcher, mapper;
 let modelDoc, modelRoot, modelSelection;
-let viewDoc, viewRoot, writer, viewSelection;
+let viewDoc, viewRoot, viewSelection;
 
 beforeEach( () => {
 	modelDoc = new ModelDocument();
-	modelRoot = modelDoc.createRoot( 'main' );
+	modelRoot = modelDoc.createRoot();
 	modelSelection = modelDoc.selection;
 
 	viewDoc = new ViewDocument();
 	viewRoot = viewDoc.createRoot( 'div' );
-	writer = viewDoc.writer;
 	viewSelection = viewDoc.selection;
 
 	mapper = new Mapper();
 	mapper.bindElements( modelRoot, viewRoot );
 
-	dispatcher = new ModelConversionDispatcher( { mapper, writer, viewSelection } );
+	dispatcher = new ModelConversionDispatcher( { mapper, viewSelection } );
 
 	dispatcher.on( 'insert:$text', insertText() );
 	dispatcher.on( 'addAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
@@ -288,7 +288,7 @@ describe( 'clean-up', () => {
 			);
 
 			// Remove <b></b> manually.
-			writer.mergeAttributes( viewSelection.getFirstPosition() );
+			mergeAt( viewSelection.getFirstPosition() );
 
 			const modelRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 1 );
 			modelDoc.selection.setRanges( [ modelRange ] );

+ 37 - 6
packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js

@@ -17,7 +17,6 @@ import ViewElement from '/ckeditor5/engine/view/element.js';
 import ViewContainerElement from '/ckeditor5/engine/view/containerelement.js';
 import ViewAttributeElement from '/ckeditor5/engine/view/attributeelement.js';
 import ViewText from '/ckeditor5/engine/view/text.js';
-import ViewWriter from  '/ckeditor5/engine/view/writer.js';
 
 import Mapper from '/ckeditor5/engine/conversion/mapper.js';
 import ModelConversionDispatcher from '/ckeditor5/engine/conversion/modelconversiondispatcher.js';
@@ -32,19 +31,17 @@ import {
 	remove
 } from '/ckeditor5/engine/conversion/model-to-view-converters.js';
 
-let dispatcher, modelDoc, modelRoot, mapper, viewRoot, writer;
+let dispatcher, modelDoc, modelRoot, mapper, viewRoot;
 
 beforeEach( () => {
 	modelDoc = new ModelDocument();
-	modelRoot = modelDoc.createRoot( 'root' );
+	modelRoot = modelDoc.createRoot();
 	viewRoot = new ViewContainerElement( 'div' );
 
 	mapper = new Mapper();
 	mapper.bindElements( modelRoot, viewRoot );
 
-	writer = new ViewWriter();
-
-	dispatcher = new ModelConversionDispatcher( { mapper, writer } );
+	dispatcher = new ModelConversionDispatcher( { mapper } );
 } );
 
 function viewAttributesToString( item ) {
@@ -243,6 +240,40 @@ describe( 'wrap/unwrap', () => {
 
 		expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 	} );
+
+	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 viewP = new ViewContainerElement( 'p' );
+
+		const elementGenerator = ( href ) => new ViewAttributeElement( 'a', { href } );
+
+		modelRoot.appendChildren( modelElement );
+		dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
+		dispatcher.on( 'insert:$text', insertText() );
+		dispatcher.on( 'addAttribute:link', wrap( elementGenerator ) );
+		dispatcher.on( 'changeAttribute:link', wrap( elementGenerator ) );
+
+		dispatcher.convertInsert(
+			ModelRange.createFromElement( modelRoot )
+		);
+
+		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' );
+		}
+
+		dispatcher.convertAttribute(
+			'changeAttribute',
+			ModelRange.createFromElement( modelElement ),
+			'link',
+			'http://foo.com',
+			'http://foobar.com'
+		);
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><p><a href="http://foobar.com">xfoox</a></p></div>' );
+	} );
 } );
 
 describe( 'move', () => {

+ 4 - 3
packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js

@@ -14,6 +14,7 @@ import ModelElement from '/ckeditor5/engine/model/element.js';
 import ModelRange from '/ckeditor5/engine/model/range.js';
 import ModelPosition from '/ckeditor5/engine/model/position.js';
 import RemoveOperation from '/ckeditor5/engine/model/operation/removeoperation.js';
+import { wrapInDelta } from '/tests/engine/model/_utils/utils.js';
 
 describe( 'ModelConversionDispatcher', () => {
 	let dispatcher, doc, root;
@@ -21,7 +22,7 @@ describe( 'ModelConversionDispatcher', () => {
 	beforeEach( () => {
 		dispatcher = new ModelConversionDispatcher();
 		doc = new ModelDocument();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 	} );
 
 	describe( 'constructor', () => {
@@ -75,7 +76,7 @@ describe( 'ModelConversionDispatcher', () => {
 			const removeOperation = new RemoveOperation( imagePos, 1, 0 );
 
 			// Let's apply remove operation so reinsert operation won't break.
-			doc.applyOperation( removeOperation );
+			doc.applyOperation( wrapInDelta( removeOperation ) );
 
 			const cbInsertText = sinon.spy();
 			const cbInsertImage = sinon.spy();
@@ -85,7 +86,7 @@ describe( 'ModelConversionDispatcher', () => {
 			dispatcher.on( 'insert:image', cbInsertImage );
 			dispatcher.on( 'addAttribute:key:image', cbAddAttribute );
 
-			doc.applyOperation( removeOperation.getReversed() );
+			doc.applyOperation( wrapInDelta( removeOperation.getReversed() ) );
 
 			expect( cbInsertImage.called ).to.be.true;
 			expect( cbAddAttribute.called ).to.be.true;

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

@@ -92,7 +92,7 @@ describe( 'View converter builder', () => {
 		dispatcher.on( 'text', convertText() );
 
 		modelDoc = new ModelDocument();
-		modelRoot = modelDoc.createRoot( 'root', '$root' );
+		modelRoot = modelDoc.createRoot();
 	} );
 
 	it( 'should convert from view element to model element', () => {

+ 2 - 2
packages/ckeditor5-engine/tests/datacontroller.js

@@ -23,8 +23,8 @@ describe( 'DataController', () => {
 
 	beforeEach( () => {
 		modelDocument = new ModelDocument();
-		modelDocument.createRoot( 'main' );
-		modelDocument.createRoot( 'title' );
+		modelDocument.createRoot();
+		modelDocument.createRoot( '$root', 'title' );
 
 		htmlDataProcessor = new HtmlDataProcessor();
 

+ 1 - 1
packages/ckeditor5-engine/tests/dataprocessor/htmldataprocessor.js

@@ -8,7 +8,7 @@
 'use strict';
 
 import HtmlDataProcessor from '/ckeditor5/engine/dataprocessor/htmldataprocessor.js';
-import xssTemplates from './_utils/xsstemplates.js';
+import xssTemplates from '/tests/engine/dataprocessor/_utils/xsstemplates.js';
 
 describe( 'HtmlDataProcessor', () => {
 	const dataProcessor = new HtmlDataProcessor();

+ 2 - 2
packages/ckeditor5-engine/tests/editingcontroller.js

@@ -59,7 +59,7 @@ describe( 'EditingController', () => {
 		beforeEach( () => {
 			model = new ModelDocument();
 			modelRoot = model.createRoot();
-			model.createRoot( 'header' );
+			model.createRoot( '$root', 'header' );
 
 			editing = new EditingController( model );
 		} );
@@ -192,7 +192,7 @@ describe( 'EditingController', () => {
 				} );
 			} );
 
-			editing.view.focusedEditable = viewRoot;
+			editing.view.isFocused = true;
 
 			const domSelection = document.getSelection();
 			domSelection.removeAllRanges();

+ 16 - 0
packages/ckeditor5-engine/tests/model/_utils/utils.js

@@ -6,6 +6,7 @@
 'use strict';
 
 import TreeWalker from '/ckeditor5/engine/model/treewalker.js';
+import Delta from '/ckeditor5/engine/model/delta/delta.js';
 
 /**
  * Returns tree structure as a simplified string. Elements are uppercase and characters are lowercase.
@@ -43,3 +44,18 @@ export function getNodesAndText( range ) {
 export function jsonParseStringify( object ) {
 	return JSON.parse( JSON.stringify( object ) );
 }
+
+/**
+ * Adds given {@link engine.model.operation.Operation operation} to a newly created {@link engine.model.delta.Delta delta}
+ * and returns it back. Every operation, when applied, have to be added to a delta. This helper function is useful in those
+ * tests which focus on operations, not deltas.
+ *
+ * @param {engine.model.operation.Operation} operation Operation to wrap
+ * @returns {engine.model.operation.Operation}
+ */
+export function wrapInDelta( operation ) {
+	const delta = new Delta();
+	delta.addOperation( operation );
+
+	return operation;
+}

+ 32 - 0
packages/ckeditor5-engine/tests/model/batch.js

@@ -24,6 +24,20 @@ describe( 'Batch', () => {
 		expect( batch.removeAttr ).to.be.a( 'function' );
 	} );
 
+	describe( 'type', () => {
+		it( 'should default to "default"', () => {
+			const batch = new Batch( new Document() );
+
+			expect( batch.type ).to.equal( 'default' );
+		} );
+
+		it( 'should be set to the value set in constructor', () => {
+			const batch = new Batch( new Document(), 'ignore' );
+
+			expect( batch.type ).to.equal( 'ignore' );
+		} );
+	} );
+
 	describe( 'register', () => {
 		afterEach( () => {
 			delete Batch.prototype.foo;
@@ -86,4 +100,22 @@ describe( 'Batch', () => {
 			expect( batch.getOperations() ).to.have.property( 'next' );
 		} );
 	} );
+
+	describe( 'baseVersion', () => {
+		it( 'should return base version of first delta from the batch', () => {
+			const batch = new Batch( new Document() );
+			const delta = new Delta();
+			const operation = new Operation( 2 );
+			delta.addOperation( operation );
+			batch.addDelta( delta );
+
+			expect( batch.baseVersion ).to.equal( 2 );
+		} );
+
+		it( 'should return null if there are no deltas in batch', () => {
+			const batch = new Batch( new Document() );
+
+			expect( batch.baseVersion ).to.be.null;
+		} );
+	} );
 } );

+ 1 - 1
packages/ckeditor5-engine/tests/model/composer/composer.js

@@ -16,7 +16,7 @@ describe( 'Composer', () => {
 
 	beforeEach( () => {
 		document = new Document();
-		document.createRoot( 'main', '$root' );
+		document.createRoot();
 
 		composer = new Composer();
 	} );

+ 1 - 1
packages/ckeditor5-engine/tests/model/composer/deletecontents.js

@@ -16,7 +16,7 @@ describe( 'Delete utils', () => {
 
 	beforeEach( () => {
 		document = new Document();
-		document.createRoot( 'main', '$root' );
+		document.createRoot();
 
 		const schema = document.schema;
 

+ 1 - 1
packages/ckeditor5-engine/tests/model/composer/modifyselection.js

@@ -16,7 +16,7 @@ describe( 'Delete utils', () => {
 
 	beforeEach( () => {
 		document = new Document();
-		document.createRoot( 'main', '$root' );
+		document.createRoot();
 	} );
 
 	describe( 'modifySelection', () => {

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

@@ -22,7 +22,7 @@ let doc, root;
 
 beforeEach( () => {
 	doc = new Document();
-	root = doc.createRoot( 'root' );
+	root = doc.createRoot();
 } );
 
 describe( 'Batch', () => {

+ 1 - 1
packages/ckeditor5-engine/tests/model/delta/delta.js

@@ -157,7 +157,7 @@ describe( 'Delta', () => {
 			delta = new FooDelta();
 
 			doc = new Document();
-			root = doc.createRoot( 'root' );
+			root = doc.createRoot();
 		} );
 
 		it( 'should return JSON representation for empty delta', () => {

+ 1 - 1
packages/ckeditor5-engine/tests/model/delta/deltafactory.js

@@ -51,7 +51,7 @@ describe( 'DeltaFactory', () => {
 			delta = new FooDelta();
 
 			doc = new Document();
-			root = doc.createRoot( 'root' );
+			root = doc.createRoot();
 		} );
 
 		it( 'should throw error for unregistered delta', () => {

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

@@ -22,7 +22,7 @@ describe( 'Batch', () => {
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 		root.insertChildren( 0, 'abc' );
 
 		batch = doc.batch();
@@ -62,7 +62,7 @@ describe( 'InsertDelta', () => {
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 		insertDelta = new InsertDelta();
 	} );
 

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

@@ -24,7 +24,7 @@ describe( 'Batch', () => {
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 
 		p1 = new Element( 'p', { key1: 'value1' }, 'foo' );
 		p2 = new Element( 'p', { key2: 'value2' }, 'bar' );
@@ -86,7 +86,7 @@ describe( 'MergeDelta', () => {
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 		mergeDelta = new MergeDelta();
 	} );
 

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

@@ -22,7 +22,7 @@ describe( 'Batch', () => {
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 
 		div = new Element( 'div', [], 'foobar' );
 		p = new Element( 'p', [], 'abcxyz' );
@@ -84,7 +84,7 @@ describe( 'MoveDelta', () => {
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 		moveDelta = new MoveDelta();
 	} );
 

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

@@ -19,7 +19,7 @@ describe( 'Batch', () => {
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 
 		div = new Element( 'div', [], 'foobar' );
 		p = new Element( 'p', [], 'abcxyz' );

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

@@ -17,7 +17,7 @@ describe( 'Batch', () => {
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 
 		const p = new Element( 'p', null, 'abc' );
 		root.appendChildren( p );
@@ -56,7 +56,7 @@ describe( 'RenameDelta', () => {
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 		renameDelta = new RenameDelta();
 	} );
 

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

@@ -24,7 +24,7 @@ describe( 'Batch', () => {
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 
 		p = new Element( 'p', { key: 'value' }, 'foobar' );
 
@@ -107,7 +107,7 @@ describe( 'SplitDelta', () => {
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 		splitDelta = new SplitDelta();
 	} );
 

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

@@ -173,7 +173,7 @@ export function applyDelta( delta, document ) {
 
 export function getFilledDocument() {
 	const doc = new Document();
-	const root = doc.createRoot( 'root' );
+	const root = doc.createRoot();
 
 	root.insertChildren( 0, [
 		new Element( 'x' ),

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

@@ -31,7 +31,7 @@ describe( 'transform', () => {
 
 	beforeEach( () => {
 		doc = getFilledDocument();
-		root = doc.getRoot( 'root' );
+		root = doc.getRoot();
 		baseVersion = doc.version;
 	} );
 

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

@@ -26,7 +26,7 @@ describe( 'Delta', () => {
 
 	beforeEach( () => {
 		doc = getFilledDocument();
-		root = doc.getRoot( 'root' );
+		root = doc.getRoot();
 		baseVersion = doc.version;
 	} );
 

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

@@ -38,7 +38,7 @@ describe( 'transform', () => {
 
 	beforeEach( () => {
 		doc = getFilledDocument();
-		root = doc.getRoot( 'root' );
+		root = doc.getRoot();
 		gy = doc.graveyard;
 		baseVersion = doc.version;
 	} );
@@ -94,7 +94,7 @@ describe( 'transform', () => {
 					operations: [
 						{
 							type: ReinsertOperation,
-							sourcePosition: new Position( gy, [ 0 ] ),
+							sourcePosition: new Position( gy, [ 0, 0 ] ),
 							howMany: 1,
 							targetPosition: new Position( root, [ 3, 3, 3 ] ),
 							baseVersion: baseVersion

+ 3 - 7
packages/ckeditor5-engine/tests/model/delta/transform/mergedelta.js

@@ -39,7 +39,7 @@ describe( 'transform', () => {
 
 	beforeEach( () => {
 		doc = getFilledDocument();
-		root = doc.getRoot( 'root' );
+		root = doc.getRoot();
 		gy = doc.graveyard;
 		baseVersion = doc.version;
 	} );
@@ -112,14 +112,10 @@ describe( 'transform', () => {
 							baseVersion: baseVersion
 						},
 						{
-							// This is `MoveOperation` instead of `RemoveOperation` because during OT,
-							// `RemoveOperation` may get converted to `MoveOperation`. Still, this expectation is
-							// correct because `RemoveOperation` is deriving from `MoveOperation`. So we can expect
-							// that something that was `RemoveOperation` is a `MoveOperation`.
-							type: MoveOperation,
+							type: RemoveOperation,
 							sourcePosition: new Position( root, [ 3, 3, 3 ] ),
 							howMany: 1,
-							targetPosition: new Position( gy, [ 0 ] ),
+							targetPosition: new Position( gy, [ 0, 0 ] ),
 							baseVersion: baseVersion + 1
 						}
 					]

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

@@ -35,7 +35,7 @@ describe( 'transform', () => {
 
 	beforeEach( () => {
 		doc = getFilledDocument();
-		root = doc.getRoot( 'root' );
+		root = doc.getRoot();
 		gy = doc.graveyard;
 		baseVersion = doc.version;
 	} );
@@ -72,7 +72,7 @@ describe( 'transform', () => {
 							// is treated in OT as `MoveOperation` and might be converted to it. This is why we have to
 							// check whether the operation type is `MoveOperation`. This is all perfectly valid.
 							type: MoveOperation,
-							sourcePosition: new Position( gy, [ 0 ] ),
+							sourcePosition: new Position( gy, [ 0, 0 ] ),
 							howMany: 1,
 							targetPosition: new Position( root, [ 3, 3, 3 ] ),
 							baseVersion: baseVersion

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

@@ -35,7 +35,7 @@ describe( 'transform', () => {
 
 	beforeEach( () => {
 		doc = getFilledDocument();
-		root = doc.getRoot( 'root' );
+		root = doc.getRoot();
 		gy = doc.graveyard;
 		baseVersion = doc.version;
 	} );
@@ -68,7 +68,7 @@ describe( 'transform', () => {
 					operations: [
 						{
 							type: MoveOperation,
-							sourcePosition: new Position( gy, [ 0 ] ),
+							sourcePosition: new Position( gy, [ 0, 0 ] ),
 							howMany: 1,
 							targetPosition: new Position( root, [ 3, 3, 3 ] ),
 							baseVersion: baseVersion

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

@@ -40,7 +40,7 @@ describe( 'transform', () => {
 
 	beforeEach( () => {
 		doc = getFilledDocument();
-		root = doc.getRoot( 'root' );
+		root = doc.getRoot();
 		gy = doc.graveyard;
 		baseVersion = doc.version;
 	} );

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

@@ -36,7 +36,7 @@ describe( 'transform', () => {
 
 	beforeEach( () => {
 		doc = getFilledDocument();
-		root = doc.getRoot( 'root' );
+		root = doc.getRoot();
 		gy = doc.graveyard;
 		baseVersion = doc.version;
 	} );
@@ -73,7 +73,7 @@ describe( 'transform', () => {
 							type: MoveOperation,
 							sourcePosition: new Position( root, [ 3, 3, 4 ] ),
 							howMany: 1,
-							targetPosition: new Position( gy, [ 0 ] ),
+							targetPosition: new Position( gy, [ 0, 0 ] ),
 							baseVersion: baseVersion + 1
 						}
 					]
@@ -90,11 +90,10 @@ describe( 'transform', () => {
 							baseVersion: baseVersion + 2
 						},
 						{
-							// `RemoveOperation` as `MoveOperation`
 							type: MoveOperation,
 							sourcePosition: new Position( root, [ 3, 3, 15 ] ),
 							howMany: 1,
-							targetPosition: new Position( gy, [ 0 ] ),
+							targetPosition: new Position( gy, [ 1, 0 ] ),
 							baseVersion: baseVersion + 3
 						}
 					]
@@ -135,7 +134,7 @@ describe( 'transform', () => {
 							type: MoveOperation,
 							sourcePosition: new Position( root, [ 3, 4, 12 ] ),
 							howMany: 1,
-							targetPosition: new Position( gy, [ 0 ] ),
+							targetPosition: new Position( gy, [ 0, 0 ] ),
 							baseVersion: baseVersion + 1
 						}
 					]

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

@@ -34,7 +34,7 @@ describe( 'transform', () => {
 
 	beforeEach( () => {
 		doc = getFilledDocument();
-		root = doc.getRoot( 'root' );
+		root = doc.getRoot();
 		baseVersion = doc.version;
 	} );
 

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

@@ -37,7 +37,7 @@ describe( 'transform', () => {
 
 	beforeEach( () => {
 		doc = getFilledDocument();
-		root = doc.getRoot( 'root' );
+		root = doc.getRoot();
 		gy = doc.graveyard;
 		baseVersion = doc.version;
 	} );
@@ -77,7 +77,7 @@ describe( 'transform', () => {
 							type: MoveOperation,
 							sourcePosition: new Position( root, [ 3, 3, 4 ] ),
 							howMany: 1,
-							targetPosition: new Position( gy, [ 0 ] ),
+							targetPosition: new Position( gy, [ 0, 0 ] ),
 							baseVersion: baseVersion + 1
 						}
 					]

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

@@ -24,7 +24,7 @@ describe( 'Batch', () => {
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 
 		p = new Element( 'p', [], 'xyz' );
 		root.insertChildren( 0, [ 'a', p, 'b' ] );
@@ -75,7 +75,7 @@ describe( 'UnwrapDelta', () => {
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 		unwrapDelta = new UnwrapDelta();
 	} );
 

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

@@ -16,7 +16,7 @@ describe( 'Batch', () => {
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 
 		root.insertChildren( 0, 'abc' );
 

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

@@ -25,7 +25,7 @@ describe( 'Batch', () => {
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 
 		root.insertChildren( 0, 'foobar' );
 
@@ -111,7 +111,7 @@ describe( 'WrapDelta', () => {
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 		wrapDelta = new WrapDelta();
 	} );
 

+ 19 - 16
packages/ckeditor5-engine/tests/model/document/change-event.js

@@ -15,13 +15,14 @@ import AttributeOperation from '/ckeditor5/engine/model/operation/attributeopera
 import InsertOperation from '/ckeditor5/engine/model/operation/insertoperation.js';
 import MoveOperation from '/ckeditor5/engine/model/operation/moveoperation.js';
 import RemoveOperation from '/ckeditor5/engine/model/operation/removeoperation.js';
+import { wrapInDelta } from '/tests/engine/model/_utils/utils.js';
 
 describe( 'Document change event', () => {
 	let doc, root, graveyard, types, changes;
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 		graveyard = doc.graveyard;
 
 		types = [];
@@ -34,7 +35,7 @@ describe( 'Document change event', () => {
 	} );
 
 	it( 'should be fired when text is inserted', () => {
-		doc.applyOperation( new InsertOperation( new Position( root, [ 0 ] ), 'foo', doc.version ) );
+		doc.applyOperation( wrapInDelta( new InsertOperation( new Position( root, [ 0 ] ), 'foo', doc.version ) ) );
 
 		expect( changes ).to.have.length( 1 );
 		expect( types[ 0 ] ).to.equal( 'insert' );
@@ -43,7 +44,7 @@ describe( 'Document change event', () => {
 
 	it( 'should be fired when element is inserted', () => {
 		const element = new Element( 'p' );
-		doc.applyOperation( new InsertOperation( new Position( root, [ 0 ] ), element, doc.version ) );
+		doc.applyOperation( wrapInDelta( new InsertOperation( new Position( root, [ 0 ] ), element, doc.version ) ) );
 
 		expect( changes ).to.have.length( 1 );
 		expect( types[ 0 ] ).to.equal( 'insert' );
@@ -52,7 +53,7 @@ describe( 'Document change event', () => {
 
 	it( 'should be fired when nodes are inserted', () => {
 		const element = new Element( 'p' );
-		doc.applyOperation( new InsertOperation( new Position( root, [ 0 ] ), [ element, 'foo' ], doc.version ) );
+		doc.applyOperation( wrapInDelta( new InsertOperation( new Position( root, [ 0 ] ), [ element, 'foo' ], doc.version ) ) );
 
 		expect( changes ).to.have.length( 1 );
 		expect( types[ 0 ] ).to.equal( 'insert' );
@@ -67,14 +68,14 @@ describe( 'Document change event', () => {
 
 		root.insertChildren( 0, [ p1, p2 ] );
 
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new MoveOperation(
 				new Position( root, [ 0, 0 ] ),
 				3,
 				new Position( root, [ 1, 0 ] ),
 				doc.version
 			)
-		);
+		) );
 
 		expect( changes ).to.have.length( 1 );
 		expect( types[ 0 ] ).to.equal( 'move' );
@@ -86,26 +87,28 @@ describe( 'Document change event', () => {
 		root.insertChildren( 0, 'foo' );
 
 		const removeOperation = new RemoveOperation( new Position( root, [ 0 ] ), 3, doc.version );
-		doc.applyOperation( removeOperation );
+		doc.applyOperation( wrapInDelta( removeOperation ) );
 
 		const reinsertOperation = removeOperation.getReversed();
-		doc.applyOperation( reinsertOperation );
+		doc.applyOperation( wrapInDelta( reinsertOperation ) );
 
 		expect( changes ).to.have.length( 2 );
 
+		const holderElement = graveyard.getChild( 0 );
+
 		expect( types[ 0 ] ).to.equal( 'remove' );
-		expect( changes[ 0 ].range ).to.deep.equal( Range.createFromParentsAndOffsets( graveyard, 0, graveyard, 3 ) );
+		expect( changes[ 0 ].range ).to.deep.equal( Range.createFromParentsAndOffsets( holderElement, 0, holderElement, 3 ) );
 		expect( changes[ 0 ].sourcePosition ).to.deep.equal( Position.createFromParentAndOffset( root, 0 ) );
 
 		expect( types[ 1 ] ).to.equal( 'reinsert' );
 		expect( changes[ 1 ].range ).to.deep.equal( Range.createFromParentsAndOffsets( root, 0, root, 3 ) );
-		expect( changes[ 1 ].sourcePosition ).to.deep.equal( Position.createFromParentAndOffset( graveyard, 0 ) );
+		expect( changes[ 1 ].sourcePosition ).to.deep.equal( Position.createFromParentAndOffset( holderElement, 0 ) );
 	} );
 
 	it( 'should be fired when attribute is inserted', () => {
 		root.insertChildren( 0, 'foo' );
 
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new AttributeOperation(
 				Range.createFromParentsAndOffsets( root, 0, root, 3 ),
 				'key',
@@ -113,7 +116,7 @@ describe( 'Document change event', () => {
 				'new',
 				doc.version
 			)
-		);
+		) );
 
 		expect( changes ).to.have.length( 1 );
 		expect( types[ 0 ] ).to.equal( 'addAttribute' );
@@ -127,7 +130,7 @@ describe( 'Document change event', () => {
 		const elem = new Element( 'p', { key: 'old' } );
 		root.insertChildren( 0, elem );
 
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new AttributeOperation(
 				Range.createFromParentsAndOffsets( root, 0, elem, 0 ),
 				'key',
@@ -135,7 +138,7 @@ describe( 'Document change event', () => {
 				null,
 				doc.version
 			)
-		);
+		) );
 
 		expect( changes ).to.have.length( 1 );
 		expect( types[ 0 ] ).to.equal( 'removeAttribute' );
@@ -149,7 +152,7 @@ describe( 'Document change event', () => {
 		const elem = new Element( 'p', { key: 'old' } );
 		root.insertChildren( 0, elem );
 
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new AttributeOperation(
 				Range.createFromParentsAndOffsets( root, 0, elem, 0 ),
 				'key',
@@ -157,7 +160,7 @@ describe( 'Document change event', () => {
 				'new',
 				doc.version
 			)
-		);
+		) );
 
 		expect( changes ).to.have.length( 1 );
 		expect( types[ 0 ] ).to.equal( 'changeAttribute' );

+ 38 - 20
packages/ckeditor5-engine/tests/model/document/document.js

@@ -12,8 +12,10 @@ import Schema from '/ckeditor5/engine/model/schema.js';
 import Composer from '/ckeditor5/engine/model/composer/composer.js';
 import RootElement from '/ckeditor5/engine/model/rootelement.js';
 import Batch from '/ckeditor5/engine/model/batch.js';
+import Delta from '/ckeditor5/engine/model/delta/delta.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 import count from '/ckeditor5/utils/count.js';
+import { jsonParseStringify } from '/tests/engine/model/_utils/utils.js';
 
 describe( 'Document', () => {
 	let doc;
@@ -41,37 +43,40 @@ describe( 'Document', () => {
 		} );
 
 		it( 'should return an iterator of all roots without the graveyard', () => {
-			doc.createRoot( 'a' );
-			doc.createRoot( 'b' );
+			doc.createRoot( '$root', 'a' );
+			doc.createRoot( '$root', 'b' );
 
 			expect( Array.from( doc.rootNames ) ).to.deep.equal( [ 'a', 'b' ] );
 		} );
 	} );
 
 	describe( 'createRoot', () => {
-		it( 'should create a new RootElement, add it to roots map and return it', () => {
-			let root = doc.createRoot( 'root' );
+		it( 'should create a new RootElement with default element and root names, add it to roots map and return it', () => {
+			let root = doc.createRoot();
 
 			expect( doc._roots.size ).to.equal( 2 );
 			expect( root ).to.be.instanceof( RootElement );
 			expect( root.getChildCount() ).to.equal( 0 );
 			expect( root ).to.have.property( 'name', '$root' );
-			expect( root ).to.have.property( 'rootName', 'root' );
+			expect( root ).to.have.property( 'rootName', 'main' );
 		} );
 
-		it( 'should create a new RootElement with the specified name', () => {
-			let root = doc.createRoot( 'root', 'foo' );
+		it( 'should create a new RootElement with custom element and root names, add it to roots map and return it', () => {
+			let root = doc.createRoot( 'customElementName', 'customRootName' );
 
-			expect( root ).to.have.property( 'name', 'foo' );
-			expect( root ).to.have.property( 'rootName', 'root' );
+			expect( doc._roots.size ).to.equal( 2 );
+			expect( root ).to.be.instanceof( RootElement );
+			expect( root.getChildCount() ).to.equal( 0 );
+			expect( root ).to.have.property( 'name', 'customElementName' );
+			expect( root ).to.have.property( 'rootName', 'customRootName' );
 		} );
 
 		it( 'should throw an error when trying to create a second root with the same name', () => {
-			doc.createRoot( 'root', 'root' );
+			doc.createRoot( '$root', 'rootName' );
 
 			expect(
 				() => {
-					doc.createRoot( 'root', 'root' );
+					doc.createRoot( '$root', 'rootName' );
 				}
 			).to.throw( CKEditorError, /document-createRoot-name-exists/ );
 		} );
@@ -79,8 +84,8 @@ describe( 'Document', () => {
 
 	describe( 'getRoot', () => {
 		it( 'should return a RootElement previously created with given name', () => {
-			let newRoot = doc.createRoot( 'root' );
-			let getRoot = doc.getRoot( 'root' );
+			let newRoot = doc.createRoot();
+			let getRoot = doc.getRoot();
 
 			expect( getRoot ).to.equal( newRoot );
 		} );
@@ -96,9 +101,9 @@ describe( 'Document', () => {
 
 	describe( 'hasRoot', () => {
 		it( 'should return true when Document has RootElement with given name', () => {
-			doc.createRoot( 'root' );
+			doc.createRoot();
 
-			expect( doc.hasRoot( 'root' ) ).to.be.true;
+			expect( doc.hasRoot( 'main' ) ).to.be.true;
 		} );
 
 		it( 'should return false when Document does not have RootElement with given name', () => {
@@ -111,15 +116,18 @@ describe( 'Document', () => {
 			const changeCallback = sinon.spy();
 			const type = 't';
 			const data = { data: 'x' };
-			const batch = 'batch';
+			const batch = new Batch();
+			const delta = new Delta();
 
 			let operation = {
 				type: type,
-				delta: { batch: batch },
 				baseVersion: 0,
 				_execute: sinon.stub().returns( data )
 			};
 
+			delta.addOperation( operation );
+			batch.addDelta( delta );
+
 			doc.on( 'change', changeCallback );
 			doc.applyOperation( operation );
 
@@ -152,6 +160,12 @@ describe( 'Document', () => {
 			expect( batch ).to.be.instanceof( Batch );
 			expect( batch ).to.have.property( 'doc' ).that.equals( doc );
 		} );
+
+		it( 'should set given batch type', () => {
+			const batch = doc.batch( 'ignore' );
+
+			expect( batch ).to.have.property( 'type' ).that.equals( 'ignore' );
+		} );
 	} );
 
 	describe( 'enqueue', () => {
@@ -233,11 +247,15 @@ describe( 'Document', () => {
 		} );
 
 		it( 'should return the first root added to the document', () => {
-			let rootA = doc.createRoot( 'rootA' );
-			doc.createRoot( 'rootB' );
-			doc.createRoot( 'rootC' );
+			let rootA = doc.createRoot( '$root', 'rootA' );
+			doc.createRoot( '$root', 'rootB' );
+			doc.createRoot( '$root', 'rootC' );
 
 			expect( doc._getDefaultRoot() ).to.equal( rootA );
 		} );
 	} );
+
+	it( 'should be correctly converted to json', () => {
+		expect( jsonParseStringify( doc ).selection ).to.equal( '[engine.model.LiveSelection]' );
+	} );
 } );

+ 158 - 108
packages/ckeditor5-engine/tests/model/history.js

@@ -7,7 +7,8 @@
 
 import History from '/ckeditor5/engine/model/history.js';
 import Delta from '/ckeditor5/engine/model/delta/delta.js';
-import NoOperation from '/ckeditor5/engine/model/operation/nooperation.js';
+import Operation from '/ckeditor5/engine/model/operation/operation.js';
+
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 
 describe( 'History', () => {
@@ -19,166 +20,215 @@ describe( 'History', () => {
 
 	describe( 'constructor', () => {
 		it( 'should create an empty History instance', () => {
-			expect( history._deltas.length ).to.equal( 0 );
-			expect( history._historyPoints.size ).to.equal( 0 );
+			expect( Array.from( history.getDeltas() ).length ).to.equal( 0 );
 		} );
 	} );
 
-	describe( 'addOperation', () => {
-		it( 'should save delta containing passed operation in the history', () => {
+	describe( 'addDelta', () => {
+		it( 'should save delta in the history', () => {
 			let delta = new Delta();
-			let operation = new NoOperation( 0 );
+			delta.addOperation( new Operation( 0 ) );
 
-			delta.addOperation( operation );
-			history.addOperation( operation );
+			history.addDelta( delta );
 
-			expect( history._deltas.length ).to.equal( 1 );
-			expect( history._deltas[ 0 ] ).to.equal( delta );
+			const deltas = Array.from( history.getDeltas() );
+			expect( deltas.length ).to.equal( 1 );
+			expect( deltas[ 0 ] ).to.equal( delta );
 		} );
 
 		it( 'should save each delta only once', () => {
 			let delta = new Delta();
+			delta.addOperation( new Operation( 0 ) );
+
+			history.addDelta( delta );
+			history.addDelta( delta );
+
+			const deltas = Array.from( history.getDeltas() );
+			expect( deltas.length ).to.equal( 1 );
+			expect( deltas[ 0 ] ).to.equal( delta );
+		} );
 
-			delta.addOperation( new NoOperation( 0 ) );
-			delta.addOperation( new NoOperation( 1 ) );
-			delta.addOperation( new NoOperation( 2 ) );
+		it( 'should save multiple deltas and keep their order', () => {
+			let deltas = getDeltaSet();
 
-			for ( let operation of delta.operations ) {
-				history.addOperation( operation );
+			for ( let delta of deltas ) {
+				history.addDelta( delta );
 			}
 
-			expect( history._deltas.length ).to.equal( 1 );
-			expect( history._deltas[ 0 ] ).to.equal( delta );
+			const historyDeltas = Array.from( history.getDeltas() );
+			expect( historyDeltas ).to.deep.equal( deltas );
 		} );
 
-		it( 'should save multiple deltas and keep their order', () => {
-			let deltaA = new Delta();
-			let deltaB = new Delta();
-			let deltaC = new Delta();
+		it( 'should skip deltas that does not have operations', () => {
+			let delta = new Delta();
 
-			let deltas = [ deltaA, deltaB, deltaC ];
+			history.addDelta( delta );
 
-			let i = 0;
+			expect( Array.from( history.getDeltas() ).length ).to.equal( 0 );
+		} );
+	} );
 
-			for ( let delta of deltas ) {
-				delta.addOperation( new NoOperation( i++ ) );
-				delta.addOperation( new NoOperation( i++ ) );
-			}
+	describe( 'getDelta', () => {
+		it( 'should return array with one delta with given base version', () => {
+			let delta = getDelta( 0 );
+			history.addDelta( delta );
 
-			for ( let delta of deltas ) {
-				for ( let operation of delta.operations ) {
-					history.addOperation( operation );
-				}
-			}
+			const historyDelta = history.getDelta( 0 );
+			expect( historyDelta ).to.deep.equal( [ delta ] );
+		} );
+
+		it( 'should return array with all updated deltas of delta with given base version', () => {
+			let delta = getDelta( 0 );
+			history.addDelta( delta );
+
+			let deltas = getDeltaSet();
+			history.updateDelta( 0, deltas );
+
+			const historyDelta = history.getDelta( 0 );
+			expect( historyDelta ).to.deep.equal( deltas );
+		} );
+
+		it( 'should return null if delta has not been found in history', () => {
+			expect( history.getDelta( -1 ) ).to.be.null;
+			expect( history.getDelta( 2 ) ).to.be.null;
+			expect( history.getDelta( 20 ) ).to.be.null;
+		} );
 
-			expect( history._deltas.length ).to.equal( 3 );
-			expect( history._deltas[ 0 ] ).to.equal( deltaA );
-			expect( history._deltas[ 1 ] ).to.equal( deltaB );
-			expect( history._deltas[ 2 ] ).to.equal( deltaC );
+		it( 'should return null if delta has been removed by removeDelta', () => {
+			let delta = getDelta( 0 );
+			history.addDelta( delta );
+			history.removeDelta( 0 );
+
+			expect( history.getDelta( 0 ) ).to.be.null;
 		} );
 	} );
 
-	describe( 'getTransformedDelta', () => {
-		it( 'should transform given delta by deltas from history which were applied since the baseVersion of given delta', () => {
-			sinon.spy( History, '_transform' );
+	describe( 'getDeltas', () => {
+		let deltas;
 
-			let deltaA = new Delta();
-			deltaA.addOperation( new NoOperation( 0 ) );
+		beforeEach( () => {
+			deltas = getDeltaSet();
 
-			let deltaB = new Delta();
-			deltaB.addOperation( new NoOperation( 1 ) );
+			for ( let delta of deltas ) {
+				history.addDelta( delta );
+			}
+		} );
 
-			let deltaC = new Delta();
-			deltaC.addOperation( new NoOperation( 2 ) );
+		it( 'should return only history deltas from given base version', () => {
+			const historyDeltas = Array.from( history.getDeltas( 3 ) );
+			expect( historyDeltas ).to.deep.equal( deltas.slice( 1 ) );
+		} );
 
-			let deltaD = new Delta();
-			deltaD.addOperation( new NoOperation( 3 ) );
+		it( 'should return only history deltas to given base version', () => {
+			const historyDeltas = Array.from( history.getDeltas( 3, 6 ) );
+			expect( historyDeltas ).to.deep.equal( deltas.slice( 1, 2 ) );
+		} );
 
-			let deltaX = new Delta();
-			deltaX.addOperation( new NoOperation( 1 ) );
+		it( 'should return empty (finished) iterator if given history point is too high or negative', () => {
+			expect( Array.from( history.getDeltas( 20 ) ).length ).to.equal( 0 );
+			expect( Array.from( history.getDeltas( -1 ) ).length ).to.equal( 0 );
+		} );
 
-			history.addOperation( deltaA.operations[ 0 ] );
-			history.addOperation( deltaB.operations[ 0 ] );
-			history.addOperation( deltaC.operations[ 0 ] );
-			history.addOperation( deltaD.operations[ 0 ] );
+		it( 'should throw if given history point is "inside" delta', () => {
+			expect( () => {
+				Array.from( history.getDeltas( 2 ) );
+			} ).to.throw( CKEditorError, /history-wrong-version/ );
+		} );
+	} );
 
-			// `deltaX` bases on the same history point as `deltaB` -- so it already acknowledges `deltaA` existence.
-			// It should be transformed by `deltaB` and all following deltas (`deltaC` and `deltaD`).
-			history.getTransformedDelta( deltaX );
+	describe( 'updateDelta', () => {
+		it( 'should substitute delta from history by given deltas', () => {
+			history.addDelta( getDelta( 0 ) );
 
-			// `deltaX` was not transformed by `deltaA`.
-			expect( History._transform.calledWithExactly( deltaX, deltaA ) ).to.be.false;
+			const deltas = getDeltaSet();
+			history.updateDelta( 0, deltas );
 
-			expect( History._transform.calledWithExactly( deltaX, deltaB ) ).to.be.true;
-			// We can't do exact call matching because after first transformation, what we are further transforming
-			// is no longer `deltaX` but a result of transforming `deltaX` and `deltaB`.
-			expect( History._transform.calledWithExactly( sinon.match.instanceOf( Delta ), deltaC ) ).to.be.true;
-			expect( History._transform.calledWithExactly( sinon.match.instanceOf( Delta ), deltaD ) ).to.be.true;
+			const historyDeltas = Array.from( history.getDeltas() );
+			expect( historyDeltas ).to.deep.equal( deltas );
 		} );
 
-		it( 'should correctly set base versions if multiple deltas are result of transformation', () => {
-			// Let's stub History._transform so it will always return two deltas with two operations each.
-			History._transform = function() {
-				let resultA = new Delta();
-				resultA.addOperation( new NoOperation( 1 ) );
-				resultA.addOperation( new NoOperation( 1 ) );
+		it( 'should substitute all updated deltas by new deltas', () => {
+			history.addDelta( getDelta( 0 ) );
 
-				let resultB = new Delta();
-				resultB.addOperation( new NoOperation( 1 ) );
-				resultB.addOperation( new NoOperation( 1 ) );
+			// Change original single delta to three deltas generated by `getDeltaSet`.
+			// All those deltas should now be seen under base version 0.
+			history.updateDelta( 0, getDeltaSet() );
 
-				return [ resultA, resultB ];
-			};
+			const deltas = getDeltaSet();
+			// Change all three deltas from base version 0 to new set of deltas.
+			history.updateDelta( 0, deltas );
 
-			let deltaA = new Delta();
-			deltaA.addOperation( new NoOperation( 0 ) );
+			const historyDeltas = Array.from( history.getDeltas() );
+			expect( historyDeltas ).to.deep.equal( deltas );
+		} );
 
-			let deltaX = new Delta();
-			deltaX.addOperation( new NoOperation( 0 ) );
+		it( 'should do nothing if deltas for given base version has not been found in history', () => {
+			history.addDelta( getDelta( 0 ) );
+			history.removeDelta( 0 );
 
-			history.addOperation( deltaA.operations[ 0 ] );
+			const deltas = getDeltaSet();
 
-			let result = history.getTransformedDelta( deltaX );
+			history.updateDelta( 0, deltas );
 
-			expect( result[ 0 ].operations[ 0 ].baseVersion ).to.equal( 1 );
-			expect( result[ 0 ].operations[ 1 ].baseVersion ).to.equal( 2 );
-			expect( result[ 1 ].operations[ 0 ].baseVersion ).to.equal( 3 );
-			expect( result[ 1 ].operations[ 1 ].baseVersion ).to.equal( 4 );
+			expect( Array.from( history.getDeltas() ).length ).to.equal( 0 );
 		} );
+	} );
+
+	describe( 'removeDelta', () => {
+		it( 'should remove deltas that do not have graveyard related operations', () => {
+			for ( let delta of getDeltaSet() ) {
+				history.addDelta( delta );
+			}
 
-		it( 'should not transform given delta if it bases on current version of history', () => {
-			let deltaA = new Delta();
-			deltaA.addOperation( new NoOperation( 0 ) );
+			history.removeDelta( 3 );
 
-			let deltaB = new Delta();
-			let opB = new NoOperation( 1 );
-			deltaB.addOperation( opB );
+			const deltas = Array.from( history.getDeltas() );
+			expect( deltas.length ).to.equal( 2 );
+		} );
+
+		it( 'should remove multiple updated deltas', () => {
+			let delta = getDelta( 0 );
+			history.addDelta( delta );
 
-			history.addOperation( deltaA.operations[ 0 ] );
+			let updatedDeltas = getDeltaSet( 0 );
 
-			let result = history.getTransformedDelta( deltaB );
+			history.updateDelta( 0, updatedDeltas );
+			history.removeDelta( 0 );
 
-			expect( result.length ).to.equal( 1 );
-			expect( result[ 0 ] ).to.equal( deltaB );
-			expect( result[ 0 ].operations[ 0 ] ).to.equal( opB );
+			const deltas = Array.from( history.getDeltas() );
+			expect( deltas.length ).to.equal( 0 );
 		} );
 
-		it( 'should throw if given delta bases on an incorrect version of history', () => {
-			let deltaA = new Delta();
-			deltaA.addOperation( new NoOperation( 0 ) );
-			deltaA.addOperation( new NoOperation( 1 ) );
+		it( 'should do nothing if deltas for given base version has not been found in history', () => {
+			const deltas = getDeltaSet();
 
-			history.addOperation( deltaA.operations[ 0 ] );
-			history.addOperation( deltaA.operations[ 1 ] );
+			for ( let delta of deltas ) {
+				history.addDelta( delta );
+			}
 
-			let deltaB = new Delta();
-			// Wrong base version - should be either 0 or 2, operation can't be based on an operation that is
-			// in the middle of other delta, because deltas are atomic, not dividable structures.
-			deltaB.addOperation( new NoOperation( 1 ) );
+			history.removeDelta( 12 );
 
-			expect( () => {
-				history.getTransformedDelta( deltaB );
-			} ).to.throw( CKEditorError, /history-wrong-version/ );
+			expect( Array.from( history.getDeltas() ) ).to.deep.equal( deltas );
 		} );
 	} );
 } );
+
+function getDeltaSet() {
+	const deltas = [];
+
+	deltas.push( getDelta( 0 ) );
+	deltas.push( getDelta( 3 ) );
+	deltas.push( getDelta( 6 ) );
+
+	return deltas;
+}
+
+function getDelta( baseVersion ) {
+	const delta = new Delta();
+
+	for ( let i = 0; i < 3; i++ ) {
+		delta.addOperation( new Operation( i + baseVersion ) );
+	}
+
+	return delta;
+}

+ 2 - 2
packages/ckeditor5-engine/tests/model/liveposition.js

@@ -20,7 +20,7 @@ describe( 'LivePosition', () => {
 
 	before( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 
 		li1 = new Element( 'li', [], 'abcdef' );
 		li2 = new Element( 'li', [], 'foobar' );
@@ -225,7 +225,7 @@ describe( 'LivePosition', () => {
 
 		before( () => {
 			path = [ 1, 4, 6 ];
-			otherRoot = doc.createRoot( 'otherRoot' );
+			otherRoot = doc.createRoot( '$root', 'otherRoot' );
 		} );
 
 		let live;

+ 2 - 2
packages/ckeditor5-engine/tests/model/liverange.js

@@ -18,7 +18,7 @@ describe( 'LiveRange', () => {
 
 	before( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 
 		let lis = [
 			new Element( 'li', [], 'aaaaaaaaaa' ),
@@ -381,7 +381,7 @@ describe( 'LiveRange', () => {
 		let otherRoot;
 
 		before( () => {
-			otherRoot = doc.createRoot( 'otherRoot' );
+			otherRoot = doc.createRoot( '$root', 'otherRoot' );
 		} );
 
 		let live, clone;

+ 681 - 0
packages/ckeditor5-engine/tests/model/liveselection.js

@@ -0,0 +1,681 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: model */
+
+'use strict';
+
+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 Range from '/ckeditor5/engine/model/range.js';
+import Position from '/ckeditor5/engine/model/position.js';
+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 testUtils from '/tests/ckeditor5/_utils/utils.js';
+import { wrapInDelta } from '/tests/engine/model/_utils/utils.js';
+
+testUtils.createSinonSandbox();
+
+describe( 'LiveSelection', () => {
+	let attrFooBar;
+
+	before( () => {
+		attrFooBar = { foo: 'bar' };
+	} );
+
+	let doc, root, selection, liveRange, range;
+
+	beforeEach( () => {
+		doc = new Document();
+		root = doc.createRoot();
+		root.appendChildren( [
+			new Element( 'p' ),
+			new Element( 'p' ),
+			new Element( 'p', [], 'foobar' ),
+			new Element( 'p' ),
+			new Element( 'p' ),
+			new Element( 'p' ),
+			new Element( 'p', [], 'foobar' )
+		] );
+		selection = doc.selection;
+		doc.schema.registerItem( 'p', '$block' );
+
+		liveRange = new LiveRange( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) );
+		range = new Range( new Position( root, [ 2 ] ), new Position( root, [ 2, 2 ] ) );
+	} );
+
+	afterEach( () => {
+		doc.destroy();
+		liveRange.detach();
+	} );
+
+	describe( 'default range', () => {
+		it( 'should go to the first editable element', () => {
+			const ranges = Array.from( selection.getRanges() );
+
+			expect( ranges.length ).to.equal( 1 );
+			expect( selection.anchor.isEqual( new Position( root, [ 0, 0 ] ) ) ).to.be.true;
+			expect( selection.focus.isEqual( new Position( root, [ 0, 0 ] ) ) ).to.be.true;
+			expect( selection ).to.have.property( 'isBackward', false );
+		} );
+
+		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' );
+			selection = doc.selection;
+
+			const ranges = Array.from( selection.getRanges() );
+
+			expect( ranges.length ).to.equal( 1 );
+			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 );
+		} );
+
+		it( 'should skip element when you can not put selection', () => {
+			doc = new Document();
+			root = doc.createRoot();
+			root.insertChildren( 0, [
+				new Element( 'img' ),
+				new Element( 'p', [], 'foobar' )
+			] );
+			doc.schema.registerItem( 'img' );
+			doc.schema.registerItem( 'p', '$block' );
+			selection = doc.selection;
+
+			const ranges = Array.from( selection.getRanges() );
+
+			expect( ranges.length ).to.equal( 1 );
+			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 );
+		} );
+	} );
+
+	describe( 'isCollapsed', () => {
+		it( 'should return true for default range', () => {
+			expect( selection.isCollapsed ).to.be.true;
+		} );
+	} );
+
+	describe( 'rangeCount', () => {
+		it( 'should return proper range count', () => {
+			expect( selection.rangeCount ).to.equal( 1 );
+
+			selection.addRange( new Range( new Position( root, [ 0 ] ), new Position( root, [ 0 ] ) ) );
+
+			expect( selection.rangeCount ).to.equal( 1 );
+
+			selection.addRange( new Range( new Position( root, [ 2 ] ), new Position( root, [ 2 ] ) ) );
+
+			expect( selection.rangeCount ).to.equal( 2 );
+		} );
+	} );
+
+	describe( 'addRange', () => {
+		it( 'should convert added Range to LiveRange', () => {
+			selection.addRange( range );
+
+			const ranges = selection._ranges;
+
+			expect( ranges[ 0 ] ).to.be.instanceof( LiveRange );
+		} );
+	} );
+
+	describe( 'collapse', () => {
+		it( 'detaches all existing ranges', () => {
+			selection.addRange( range );
+			selection.addRange( liveRange );
+
+			const spy = testUtils.sinon.spy( LiveRange.prototype, 'detach' );
+			selection.collapse( root );
+
+			expect( spy.calledTwice ).to.be.true;
+		} );
+	} );
+
+	describe( 'destroy', () => {
+		it( 'should unbind all events', () => {
+			selection.addRange( liveRange );
+			selection.addRange( range );
+
+			const ranges = selection._ranges;
+
+			sinon.spy( ranges[ 0 ], 'detach' );
+			sinon.spy( ranges[ 1 ], 'detach' );
+
+			selection.destroy();
+
+			expect( ranges[ 0 ].detach.called ).to.be.true;
+			expect( ranges[ 1 ].detach.called ).to.be.true;
+
+			ranges[ 0 ].detach.restore();
+			ranges[ 1 ].detach.restore();
+		} );
+	} );
+
+	describe( 'setFocus', () => {
+		it( 'modifies default range', () => {
+			const startPos = selection.getFirstPosition();
+			const endPos = Position.createAt( root, 'END' );
+
+			selection.setFocus( endPos );
+
+			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'SAME' );
+			expect( selection.focus.compareWith( endPos ) ).to.equal( 'SAME' );
+		} );
+
+		it( 'detaches the range it replaces', () => {
+			const startPos = Position.createAt( root, 1 );
+			const endPos = Position.createAt( root, 2 );
+			const newEndPos = Position.createAt( root, 4 );
+			const spy = testUtils.sinon.spy( LiveRange.prototype, 'detach' );
+
+			selection.addRange( new Range( startPos, endPos ) );
+
+			selection.setFocus( newEndPos );
+
+			expect( spy.calledOnce ).to.be.true;
+		} );
+	} );
+
+	describe( 'removeAllRanges', () => {
+		let spy, ranges;
+
+		beforeEach( () => {
+			selection.addRange( liveRange );
+			selection.addRange( range );
+
+			spy = sinon.spy();
+			selection.on( 'change:range', spy );
+
+			ranges = selection._ranges;
+
+			sinon.spy( ranges[ 0 ], 'detach' );
+			sinon.spy( ranges[ 1 ], 'detach' );
+
+			selection.removeAllRanges();
+		} );
+
+		afterEach( () => {
+			ranges[ 0 ].detach.restore();
+			ranges[ 1 ].detach.restore();
+		} );
+
+		it( 'should remove all stored ranges (and reset to default range)', () => {
+			expect( Array.from( selection.getRanges() ).length ).to.equal( 1 );
+			expect( selection.anchor.isEqual( new Position( root, [ 0, 0 ] ) ) ).to.be.true;
+			expect( selection.focus.isEqual( new Position( root, [ 0, 0 ] ) ) ).to.be.true;
+		} );
+
+		it( 'should detach ranges', () => {
+			expect( ranges[ 0 ].detach.called ).to.be.true;
+			expect( ranges[ 1 ].detach.called ).to.be.true;
+		} );
+	} );
+
+	describe( 'setRanges', () => {
+		let newRanges, spy, oldRanges;
+
+		before( () => {
+			newRanges = [
+				new Range( new Position( root, [ 4 ] ), new Position( root, [ 5 ] ) ),
+				new Range( new Position( root, [ 5, 0 ] ), new Position( root, [ 6, 0 ] ) )
+			];
+		} );
+
+		beforeEach( () => {
+			selection.addRange( liveRange );
+			selection.addRange( range );
+
+			spy = sinon.spy();
+			selection.on( 'change:range', spy );
+
+			oldRanges = selection._ranges;
+
+			sinon.spy( oldRanges[ 0 ], 'detach' );
+			sinon.spy( oldRanges[ 1 ], 'detach' );
+		} );
+
+		afterEach( () => {
+			oldRanges[ 0 ].detach.restore();
+			oldRanges[ 1 ].detach.restore();
+		} );
+
+		it( 'should detach removed ranges', () => {
+			selection.setRanges( newRanges );
+			expect( oldRanges[ 0 ].detach.called ).to.be.true;
+			expect( oldRanges[ 1 ].detach.called ).to.be.true;
+		} );
+	} );
+
+	describe( 'getFirstRange', () => {
+		it( 'should return default range if no ranges were added', () => {
+			const firstRange = selection.getFirstRange();
+
+			expect( firstRange.start.isEqual( new Position( root, [ 0, 0 ] ) ) );
+			expect( firstRange.end.isEqual( new Position( root, [ 0, 0 ] ) ) );
+		} );
+	} );
+
+	describe( 'getFirstPosition', () => {
+		it( 'should return start position of default range if no ranges were added', () => {
+			const firstPosition = selection.getFirstPosition();
+
+			expect( firstPosition.isEqual( new Position( root, [ 0, 0 ] ) ) );
+		} );
+	} );
+
+	describe( 'createFromSelection', () => {
+		it( 'should return a LiveSelection instance', () => {
+			selection.addRange( range, true );
+
+			expect( LiveSelection.createFromSelection( selection ) ).to.be.instanceof( LiveSelection );
+		} );
+	} );
+
+	// LiveSelection uses LiveRanges so here are only simple test to see if integration is
+	// working well, without getting into complicated corner cases.
+	describe( 'after applying an operation should get updated and not fire update event', () => {
+		let spy;
+
+		beforeEach( () => {
+			root.insertChildren( 0, [ new Element( 'ul', [], 'abcdef' ), new Element( 'p', [], 'foobar' ), 'xyz' ] );
+
+			selection.addRange( new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 1, 4 ] ) ) );
+
+			spy = sinon.spy();
+			selection.on( 'change:range', spy );
+		} );
+
+		describe( 'InsertOperation', () => {
+			it( 'before selection', () => {
+				doc.applyOperation( wrapInDelta(
+					new InsertOperation(
+						new Position( root, [ 0, 1 ] ),
+						'xyz',
+						doc.version
+					)
+				) );
+
+				let range = selection._ranges[ 0 ];
+
+				expect( range.start.path ).to.deep.equal( [ 0, 5 ] );
+				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
+				expect( spy.called ).to.be.false;
+			} );
+
+			it( 'inside selection', () => {
+				doc.applyOperation( wrapInDelta(
+					new InsertOperation(
+						new Position( root, [ 1, 0 ] ),
+						'xyz',
+						doc.version
+					)
+				) );
+
+				let range = selection._ranges[ 0 ];
+
+				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
+				expect( range.end.path ).to.deep.equal( [ 1, 7 ] );
+				expect( spy.called ).to.be.false;
+			} );
+		} );
+
+		describe( 'MoveOperation', () => {
+			it( 'move range from before a selection', () => {
+				doc.applyOperation( wrapInDelta(
+					new MoveOperation(
+						new Position( root, [ 0, 0 ] ),
+						2,
+						new Position( root, [ 2 ] ),
+						doc.version
+					)
+				) );
+
+				let range = selection._ranges[ 0 ];
+
+				expect( range.start.path ).to.deep.equal( [ 0, 0 ] );
+				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
+				expect( spy.called ).to.be.false;
+			} );
+
+			it( 'moved into before a selection', () => {
+				doc.applyOperation( wrapInDelta(
+					new MoveOperation(
+						new Position( root, [ 2 ] ),
+						2,
+						new Position( root, [ 0, 0 ] ),
+						doc.version
+					)
+				) );
+
+				let range = selection._ranges[ 0 ];
+
+				expect( range.start.path ).to.deep.equal( [ 0, 4 ] );
+				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
+				expect( spy.called ).to.be.false;
+			} );
+
+			it( 'move range from inside of selection', () => {
+				doc.applyOperation( wrapInDelta(
+					new MoveOperation(
+						new Position( root, [ 1, 0 ] ),
+						2,
+						new Position( root, [ 2 ] ),
+						doc.version
+					)
+				) );
+
+				let range = selection._ranges[ 0 ];
+
+				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
+				expect( range.end.path ).to.deep.equal( [ 1, 2 ] );
+				expect( spy.called ).to.be.false;
+			} );
+
+			it( 'moved range intersects with selection', () => {
+				doc.applyOperation( wrapInDelta(
+					new MoveOperation(
+						new Position( root, [ 1, 3 ] ),
+						2,
+						new Position( root, [ 4 ] ),
+						doc.version
+					)
+				) );
+
+				let range = selection._ranges[ 0 ];
+
+				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
+				expect( range.end.path ).to.deep.equal( [ 1, 3 ] );
+				expect( spy.called ).to.be.false;
+			} );
+
+			it( 'split inside selection (do not break selection)', () => {
+				doc.applyOperation( wrapInDelta(
+					new InsertOperation(
+						new Position( root, [ 2 ] ),
+						new Element( 'p' ),
+						doc.version
+					)
+				) );
+
+				doc.applyOperation( wrapInDelta(
+					new MoveOperation(
+						new Position( root, [ 1, 2 ] ),
+						4,
+						new Position( root, [ 2, 0 ] ),
+						doc.version
+					)
+				) );
+
+				let range = selection._ranges[ 0 ];
+
+				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
+				expect( range.end.path ).to.deep.equal( [ 2, 2 ] );
+				expect( spy.called ).to.be.false;
+			} );
+		} );
+	} );
+
+	describe( 'attributes interface', () => {
+		let fullP, emptyP, rangeInFullP, rangeInEmptyP;
+
+		beforeEach( () => {
+			root.insertChildren( 0, [
+				new Element( 'p', [], 'foobar' ),
+				new Element( 'p', [], [] )
+			] );
+
+			fullP = root.getChild( 0 );
+			emptyP = root.getChild( 1 );
+
+			rangeInFullP = new Range( new Position( root, [ 0, 4 ] ), new Position( root, [ 0, 4 ] ) );
+			rangeInEmptyP = new Range( new Position( root, [ 1, 0 ] ), new Position( root, [ 1, 0 ] ) );
+		} );
+
+		describe( 'setAttribute', () => {
+			it( 'should set given attribute on the selection', () => {
+				selection.setRanges( [ rangeInFullP ] );
+				selection.setAttribute( 'foo', 'bar' );
+
+				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
+				expect( fullP.hasAttribute( LiveSelection._getStoreAttributeKey( 'foo' ) ) ).to.be.false;
+			} );
+
+			it( 'should store attribute if the selection is in empty node', () => {
+				selection.setRanges( [ rangeInEmptyP ] );
+				selection.setAttribute( 'foo', 'bar' );
+
+				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
+
+				expect( emptyP.getAttribute( LiveSelection._getStoreAttributeKey( 'foo' ) ) ).to.equal( 'bar' );
+			} );
+
+			it( 'should fire change:attribute event', () => {
+				let spy = sinon.spy();
+				selection.on( 'change:attribute', spy );
+
+				selection.setAttribute( 'foo', 'bar' );
+
+				expect( spy.called ).to.be.true;
+			} );
+		} );
+
+		describe( 'hasAttribute', () => {
+			it( 'should return true if element contains attribute with given key', () => {
+				selection.setRanges( [ rangeInFullP ] );
+				selection.setAttribute( 'foo', 'bar' );
+
+				expect( selection.hasAttribute( 'foo' ) ).to.be.true;
+			} );
+
+			it( 'should return false if element does not contain attribute with given key', () => {
+				expect( selection.hasAttribute( 'abc' ) ).to.be.false;
+			} );
+		} );
+
+		describe( 'getAttribute', () => {
+			it( 'should return undefined if element does not contain given attribute', () => {
+				expect( selection.getAttribute( 'abc' ) ).to.be.undefined;
+			} );
+		} );
+
+		describe( 'getAttributes', () => {
+			it( 'should return an iterator that iterates over all attributes set on the text fragment', () => {
+				selection.setRanges( [ rangeInFullP ] );
+				selection.setAttribute( 'foo', 'bar' );
+				selection.setAttribute( 'abc', 'xyz' );
+
+				let attrs = Array.from( selection.getAttributes() );
+
+				expect( attrs ).to.deep.equal( [ [ 'foo', 'bar' ], [ 'abc', 'xyz' ] ] );
+			} );
+		} );
+
+		describe( 'setAttributesTo', () => {
+			it( 'should remove all attributes set on element and set the given ones', () => {
+				selection.setRanges( [ rangeInFullP ] );
+				selection.setAttribute( 'abc', 'xyz' );
+				selection.setAttributesTo( { foo: 'bar' } );
+
+				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
+				expect( selection.getAttribute( 'abc' ) ).to.be.undefined;
+
+				expect( fullP.hasAttribute( LiveSelection._getStoreAttributeKey( 'foo' ) ) ).to.be.false;
+				expect( fullP.hasAttribute( LiveSelection._getStoreAttributeKey( 'abc' ) ) ).to.be.false;
+			} );
+
+			it( 'should remove all stored attributes and store the given ones if the selection is in empty node', () => {
+				selection.setRanges( [ rangeInEmptyP ] );
+				selection.setAttribute( 'abc', 'xyz' );
+				selection.setAttributesTo( { foo: 'bar' } );
+
+				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
+				expect( selection.getAttribute( 'abc' ) ).to.be.undefined;
+
+				expect( emptyP.getAttribute( LiveSelection._getStoreAttributeKey( 'foo' ) ) ).to.equal( 'bar' );
+				expect( emptyP.hasAttribute( LiveSelection._getStoreAttributeKey( 'abc' ) ) ).to.be.false;
+			} );
+
+			it( 'should fire change:attribute event', () => {
+				let spy = sinon.spy();
+				selection.on( 'change:attribute', spy );
+
+				selection.setAttributesTo( { foo: 'bar' } );
+
+				expect( spy.called ).to.be.true;
+			} );
+		} );
+
+		describe( 'removeAttribute', () => {
+			it( 'should remove attribute set on the text fragment', () => {
+				selection.setRanges( [ rangeInFullP ] );
+				selection.setAttribute( 'foo', 'bar' );
+				selection.removeAttribute( 'foo' );
+
+				expect( selection.getAttribute( 'foo' ) ).to.be.undefined;
+
+				expect( fullP.hasAttribute( LiveSelection._getStoreAttributeKey( 'foo' ) ) ).to.be.false;
+			} );
+
+			it( 'should remove stored attribute if the selection is in empty node', () => {
+				selection.setRanges( [ rangeInEmptyP ] );
+				selection.setAttribute( 'foo', 'bar' );
+				selection.removeAttribute( 'foo' );
+
+				expect( selection.getAttribute( 'foo' ) ).to.be.undefined;
+
+				expect( emptyP.hasAttribute( LiveSelection._getStoreAttributeKey( 'foo' ) ) ).to.be.false;
+			} );
+
+			it( 'should fire change:attribute event', () => {
+				let spy = sinon.spy();
+				selection.on( 'change:attribute', spy );
+
+				selection.removeAttribute( 'foo' );
+
+				expect( spy.called ).to.be.true;
+			} );
+		} );
+
+		describe( 'clearAttributes', () => {
+			it( 'should remove all attributes from the element', () => {
+				selection.setRanges( [ rangeInFullP ] );
+				selection.setAttribute( 'foo', 'bar' );
+				selection.setAttribute( 'abc', 'xyz' );
+
+				selection.clearAttributes();
+
+				expect( selection.getAttribute( 'foo' ) ).to.be.undefined;
+				expect( selection.getAttribute( 'abc' ) ).to.be.undefined;
+
+				expect( fullP.hasAttribute( LiveSelection._getStoreAttributeKey( 'foo' ) ) ).to.be.false;
+				expect( fullP.hasAttribute( LiveSelection._getStoreAttributeKey( 'abc' ) ) ).to.be.false;
+			} );
+
+			it( 'should remove all stored attributes if the selection is in empty node', () => {
+				selection.setRanges( [ rangeInEmptyP ] );
+				selection.setAttribute( 'foo', 'bar' );
+				selection.setAttribute( 'abc', 'xyz' );
+
+				selection.clearAttributes();
+
+				expect( selection.getAttribute( 'foo' ) ).to.be.undefined;
+				expect( selection.getAttribute( 'abc' ) ).to.be.undefined;
+
+				expect( emptyP.hasAttribute( LiveSelection._getStoreAttributeKey( 'foo' ) ) ).to.be.false;
+				expect( emptyP.hasAttribute( LiveSelection._getStoreAttributeKey( 'abc' ) ) ).to.be.false;
+			} );
+
+			it( 'should fire change:attribute event', () => {
+				let spy = sinon.spy();
+				selection.on( 'change:attribute', spy );
+
+				selection.clearAttributes();
+
+				expect( spy.called ).to.be.true;
+			} );
+		} );
+	} );
+
+	describe( '_updateAttributes', () => {
+		beforeEach( () => {
+			root.insertChildren( 0, [
+				new Element( 'p', { p: true } ),
+				new Text( 'a', { a: true } ),
+				new Element( 'p', { p: true } ),
+				new Text( 'b', { b: true } ),
+				new Text( 'c', { c: true } ),
+				new Element( 'p', [], [
+					new Text( 'd', { d: true } )
+				] ),
+				new Element( 'p', { p: true } ),
+				new Text( 'e', { e: true } )
+			] );
+		} );
+
+		it( 'if selection is a range, should find first character in it and copy it\'s attributes', () => {
+			selection.setRanges( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 5 ] ) ) ] );
+
+			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'b', true ] ] );
+
+			// Step into elements when looking for first character:
+			selection.setRanges( [ new Range( new Position( root, [ 5 ] ), new Position( root, [ 7 ] ) ) ] );
+
+			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'd', true ] ] );
+		} );
+
+		it( 'if selection is collapsed it should seek a character to copy that character\'s attributes', () => {
+			// Take styles from character before selection.
+			selection.setRanges( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 2 ] ) ) ] );
+			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'a', true ] ] );
+
+			// If there are none,
+			// Take styles from character after selection.
+			selection.setRanges( [ new Range( new Position( root, [ 3 ] ), new Position( root, [ 3 ] ) ) ] );
+			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'b', true ] ] );
+
+			// If there are none,
+			// Look from the selection position to the beginning of node looking for character to take attributes from.
+			selection.setRanges( [ new Range( new Position( root, [ 6 ] ), new Position( root, [ 6 ] ) ) ] );
+			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'c', true ] ] );
+
+			// If there are none,
+			// Look from the selection position to the end of node looking for character to take attributes from.
+			selection.setRanges( [ new Range( new Position( root, [ 0 ] ), new Position( root, [ 0 ] ) ) ] );
+			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'a', true ] ] );
+
+			// If there are no characters to copy attributes from, use stored attributes.
+			selection.setRanges( [ new Range( new Position( root, [ 0, 0 ] ), new Position( root, [ 0, 0 ] ) ) ] );
+			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [] );
+		} );
+
+		it( 'should fire change:attribute event', () => {
+			let spy = sinon.spy();
+			selection.on( 'change:attribute', spy );
+
+			selection.setRanges( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 5 ] ) ) ] );
+
+			expect( spy.called ).to.be.true;
+		} );
+	} );
+
+	describe( '_getStoredAttributes', () => {
+		it( 'should return no values if there are no ranges in selection', () => {
+			let values = Array.from( selection._getStoredAttributes() );
+
+			expect( values ).to.deep.equal( [] );
+		} );
+	} );
+} );

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

@@ -14,14 +14,14 @@ 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 { jsonParseStringify } from '/tests/engine/model/_utils/utils.js';
+import { jsonParseStringify, wrapInDelta } from '/tests/engine/model/_utils/utils.js';
 
 describe( 'AttributeOperation', () => {
 	let doc, root;
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 	} );
 
 	describe( 'type', () => {
@@ -65,7 +65,7 @@ describe( 'AttributeOperation', () => {
 	it( 'should insert attribute to the set of nodes', () => {
 		root.insertChildren( 0, 'bar' );
 
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new AttributeOperation(
 				new Range( new Position( root, [ 0 ] ), new Position( root, [ 2 ] ) ),
 				'isNew',
@@ -73,7 +73,7 @@ describe( 'AttributeOperation', () => {
 				true,
 				doc.version
 			)
-		);
+		) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 3 );
@@ -85,7 +85,7 @@ describe( 'AttributeOperation', () => {
 	it( 'should add attribute to the existing attributes', () => {
 		root.insertChildren( 0, new Text( 'x', { foo: true, bar: true } ) );
 
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new AttributeOperation(
 				new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
 				'isNew',
@@ -93,7 +93,7 @@ describe( 'AttributeOperation', () => {
 				true,
 				doc.version
 			)
-		);
+		) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 1 );
@@ -106,7 +106,7 @@ describe( 'AttributeOperation', () => {
 	it( 'should change attribute to the set of nodes', () => {
 		root.insertChildren( 0, new Text( 'bar', { isNew: false } ) );
 
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new AttributeOperation(
 				new Range( new Position( root, [ 0 ] ), new Position( root, [ 2 ] ) ),
 				'isNew',
@@ -114,7 +114,7 @@ describe( 'AttributeOperation', () => {
 				true,
 				doc.version
 			)
-		);
+		) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 3 );
@@ -129,7 +129,7 @@ describe( 'AttributeOperation', () => {
 	it( 'should change attribute in the middle of existing attributes', () => {
 		root.insertChildren( 0, new Text( 'x', { foo: true, x: 1, bar: true } ) );
 
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new AttributeOperation(
 				new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
 				'x',
@@ -137,7 +137,7 @@ describe( 'AttributeOperation', () => {
 				2,
 				doc.version
 			)
-		);
+		) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 1 );
@@ -150,7 +150,7 @@ describe( 'AttributeOperation', () => {
 	it( 'should remove attribute', () => {
 		root.insertChildren( 0, new Text( 'x', { foo: true, x: true, bar: true } ) );
 
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new AttributeOperation(
 				new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
 				'x',
@@ -158,7 +158,7 @@ describe( 'AttributeOperation', () => {
 				null,
 				doc.version
 			)
-		);
+		) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 1 );
@@ -193,8 +193,8 @@ describe( 'AttributeOperation', () => {
 
 		let reverse = operation.getReversed();
 
-		doc.applyOperation( operation );
-		doc.applyOperation( reverse );
+		doc.applyOperation( wrapInDelta( operation ) );
+		doc.applyOperation( wrapInDelta( reverse ) );
 
 		expect( doc.version ).to.equal( 2 );
 		expect( root.getChildCount() ).to.equal( 3 );
@@ -209,7 +209,7 @@ describe( 'AttributeOperation', () => {
 
 		root.insertChildren( 0, [ eleA, eleB ] );
 
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new AttributeOperation(
 				new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 1, 2 ] ) ),
 				'foo',
@@ -217,7 +217,7 @@ describe( 'AttributeOperation', () => {
 				true,
 				doc.version
 			)
-		);
+		) );
 
 		expect( root.getChild( 0 ).hasAttribute( 'foo' ) ).to.be.false;
 	} );
@@ -230,7 +230,7 @@ describe( 'AttributeOperation', () => {
 
 		root.insertChildren( 0, [ eleA, eleB ] );
 
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new AttributeOperation(
 				new Range( new Position( root, [ 0, 3 ] ), new Position( root, [ 1, 0 ] ) ),
 				'foo',
@@ -238,7 +238,7 @@ describe( 'AttributeOperation', () => {
 				null,
 				doc.version
 			)
-		);
+		) );
 
 		expect( root.getChild( 0 ).hasAttribute( 'foo' ) ).to.be.true;
 	} );
@@ -256,8 +256,8 @@ describe( 'AttributeOperation', () => {
 
 		let reverse = operation.getReversed();
 
-		doc.applyOperation( operation );
-		doc.applyOperation( reverse );
+		doc.applyOperation( wrapInDelta( operation ) );
+		doc.applyOperation( wrapInDelta( reverse ) );
 
 		expect( doc.version ).to.equal( 2 );
 		expect( root.getChildCount() ).to.equal( 3 );
@@ -282,8 +282,8 @@ describe( 'AttributeOperation', () => {
 
 		let reverse = operation.getReversed();
 
-		doc.applyOperation( operation );
-		doc.applyOperation( reverse );
+		doc.applyOperation( wrapInDelta( operation ) );
+		doc.applyOperation( wrapInDelta( reverse ) );
 
 		expect( doc.version ).to.equal( 2 );
 		expect( root.getChildCount() ).to.equal( 3 );
@@ -299,7 +299,7 @@ describe( 'AttributeOperation', () => {
 		root.insertChildren( 0, 'x' );
 
 		expect( () => {
-			doc.applyOperation(
+			doc.applyOperation( wrapInDelta(
 				new AttributeOperation(
 					new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
 					'foo',
@@ -307,7 +307,7 @@ describe( 'AttributeOperation', () => {
 					null,
 					doc.version
 				)
-			);
+			) );
 		} ).to.throw( CKEditorError, /operation-attribute-no-attr-to-remove/ );
 	} );
 
@@ -315,7 +315,7 @@ describe( 'AttributeOperation', () => {
 		root.insertChildren( 0, new Text( 'x', { x: 1 } ) );
 
 		expect( () => {
-			doc.applyOperation(
+			doc.applyOperation( wrapInDelta(
 				new AttributeOperation(
 					new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
 					'x',
@@ -323,7 +323,7 @@ describe( 'AttributeOperation', () => {
 					2,
 					doc.version
 				)
-			);
+			) );
 		} ).to.throw( CKEditorError, /operation-attribute-attr-exists/ );
 	} );
 
@@ -353,14 +353,14 @@ describe( 'AttributeOperation', () => {
 		root.insertChildren( 0, new Text( 'abc', attrA ) );
 		root.insertChildren( 3, new Text( 'xyz', attrB ) );
 
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new AttributeOperation(
 				new Range( new Position( root, [ 1 ] ), new Position( root, [ 3 ] ) ),
 				'foo',
 				'a',
 				'b',
 				doc.version
-			)
+			) )
 		);
 
 		expect( root._children._nodes[ 0 ].text ).to.equal( 'a' );

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

@@ -14,14 +14,14 @@ import InsertOperation from '/ckeditor5/engine/model/operation/insertoperation.j
 import RemoveOperation from '/ckeditor5/engine/model/operation/removeoperation.js';
 import Position from '/ckeditor5/engine/model/position.js';
 import Text from '/ckeditor5/engine/model/text.js';
-import { jsonParseStringify } from '/tests/engine/model/_utils/utils.js';
+import { jsonParseStringify, wrapInDelta } from '/tests/engine/model/_utils/utils.js';
 
 describe( 'InsertOperation', () => {
 	let doc, root;
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 	} );
 
 	it( 'should have proper type', () => {
@@ -35,13 +35,13 @@ describe( 'InsertOperation', () => {
 	} );
 
 	it( 'should insert node', () => {
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new InsertOperation(
 				new Position( root, [ 0 ] ),
 				new Text( 'x' ),
 				doc.version
 			)
-		);
+		) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 1 );
@@ -49,13 +49,13 @@ describe( 'InsertOperation', () => {
 	} );
 
 	it( 'should insert set of nodes', () => {
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new InsertOperation(
 				new Position( root, [ 0 ] ),
 				'bar',
 				doc.version
 			)
-		);
+		) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 3 );
@@ -67,13 +67,13 @@ describe( 'InsertOperation', () => {
 	it( 'should insert between existing nodes', () => {
 		root.insertChildren( 0, 'xy' );
 
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new InsertOperation(
 				new Position( root, [ 1 ] ),
 				'bar',
 				doc.version
 			)
-		);
+		) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 5 );
@@ -85,13 +85,13 @@ describe( 'InsertOperation', () => {
 	} );
 
 	it( 'should insert text', () => {
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new InsertOperation(
 				new Position( root, [ 0 ] ),
 				[ 'foo', new Text( 'x' ), 'bar' ],
 				doc.version
 			)
-		);
+		) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 7 );
@@ -129,11 +129,11 @@ describe( 'InsertOperation', () => {
 
 		let reverse = operation.getReversed();
 
-		doc.applyOperation( operation );
+		doc.applyOperation( wrapInDelta( operation ) );
 
 		expect( doc.version ).to.equal( 1 );
 
-		doc.applyOperation( reverse );
+		doc.applyOperation( wrapInDelta( reverse ) );
 
 		expect( doc.version ).to.equal( 2 );
 		expect( root.getChildCount() ).to.equal( 0 );
@@ -148,11 +148,11 @@ describe( 'InsertOperation', () => {
 
 		let reverse = operation.getReversed();
 
-		doc.applyOperation( operation );
+		doc.applyOperation( wrapInDelta( operation ) );
 
 		expect( doc.version ).to.equal( 1 );
 
-		doc.applyOperation( reverse );
+		doc.applyOperation( wrapInDelta( reverse ) );
 
 		expect( doc.version ).to.equal( 2 );
 		expect( root.getChildCount() ).to.equal( 0 );

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

@@ -12,14 +12,14 @@ 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 CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
-import { jsonParseStringify } from '/tests/engine/model/_utils/utils.js';
+import { jsonParseStringify, wrapInDelta } from '/tests/engine/model/_utils/utils.js';
 
 describe( 'MoveOperation', () => {
 	let doc, root;
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 	} );
 
 	it( 'should have proper type', () => {
@@ -50,14 +50,14 @@ describe( 'MoveOperation', () => {
 
 		root.insertChildren( 0, [ p1, p2 ] );
 
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new MoveOperation(
 				new Position( root, [ 0, 0 ] ),
 				1,
 				new Position( root, [ 1, 0 ] ),
 				doc.version
 			)
-		);
+		) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 2 );
@@ -71,14 +71,14 @@ describe( 'MoveOperation', () => {
 	it( 'should move position of children in one node backward', () => {
 		root.insertChildren( 0, 'xbarx' );
 
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new MoveOperation(
 				new Position( root, [ 2 ] ),
 				2,
 				new Position( root, [ 1 ] ),
 				doc.version
 			)
-		);
+		) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 5 );
@@ -92,14 +92,14 @@ describe( 'MoveOperation', () => {
 	it( 'should move position of children in one node forward', () => {
 		root.insertChildren( 0, 'xbarx' );
 
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new MoveOperation(
 				new Position( root, [ 1 ] ),
 				2,
 				new Position( root, [ 4 ] ),
 				doc.version
 			)
-		);
+		) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 5 );
@@ -143,7 +143,7 @@ describe( 'MoveOperation', () => {
 			doc.version
 		);
 
-		doc.applyOperation( operation );
+		doc.applyOperation( wrapInDelta( operation ) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 2 );
@@ -151,7 +151,7 @@ describe( 'MoveOperation', () => {
 		expect( p2.getChildCount() ).to.equal( 1 );
 		expect( p2.getChild( 0 ).name ).to.equal( 'x' );
 
-		doc.applyOperation( operation.getReversed() );
+		doc.applyOperation( wrapInDelta( operation.getReversed() ) );
 
 		expect( doc.version ).to.equal( 2 );
 		expect( root.getChildCount() ).to.equal( 2 );
@@ -170,7 +170,7 @@ describe( 'MoveOperation', () => {
 			doc.version
 		);
 
-		expect( () => doc.applyOperation( operation ) ).to.throw( CKEditorError, /operation-move-nodes-do-not-exist/ );
+		expect( () => doc.applyOperation( wrapInDelta( operation ) ) ).to.throw( CKEditorError, /operation-move-nodes-do-not-exist/ );
 	} );
 
 	it( 'should throw an error if target or source parent-element specified by position does not exist', () => {
@@ -187,7 +187,7 @@ describe( 'MoveOperation', () => {
 
 		root.removeChildren( 2, 1 );
 
-		expect( () => doc.applyOperation( operation ) ).to.throw( CKEditorError, /operation-move-position-invalid/ );
+		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', () => {
@@ -200,7 +200,7 @@ describe( 'MoveOperation', () => {
 			doc.version
 		);
 
-		expect( () => doc.applyOperation( operation ) ).to.throw( CKEditorError, /operation-move-range-into-itself/ );
+		expect( () => doc.applyOperation( wrapInDelta( operation ) ) ).to.throw( CKEditorError, /operation-move-range-into-itself/ );
 	} );
 
 	it( 'should throw an error if operation tries to move a range into a sub-tree of a node that is in that range', () => {
@@ -214,7 +214,7 @@ describe( 'MoveOperation', () => {
 			doc.version
 		);
 
-		expect( () => doc.applyOperation( operation ) ).to.throw( CKEditorError, /operation-move-node-into-itself/ );
+		expect( () => doc.applyOperation( wrapInDelta( operation ) ) ).to.throw( CKEditorError, /operation-move-node-into-itself/ );
 	} );
 
 	it( 'should not throw an error if operation move a range into a sibling', () => {
@@ -230,7 +230,7 @@ describe( 'MoveOperation', () => {
 
 		expect(
 			() => {
-				doc.applyOperation( operation );
+				doc.applyOperation( wrapInDelta( operation ) );
 			}
 		).not.to.throw();
 
@@ -253,7 +253,7 @@ describe( 'MoveOperation', () => {
 
 		expect(
 			() => {
-				doc.applyOperation( operation );
+				doc.applyOperation( wrapInDelta( operation ) );
 			}
 		).not.to.throw();
 	} );

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

@@ -9,7 +9,7 @@
 
 import Document from '/ckeditor5/engine/model/document.js';
 import NoOperation from '/ckeditor5/engine/model/operation/nooperation.js';
-import { jsonParseStringify } from '/tests/engine/model/_utils/utils.js';
+import { jsonParseStringify, wrapInDelta } from '/tests/engine/model/_utils/utils.js';
 
 describe( 'NoOperation', () => {
 	let noop, doc, root;
@@ -17,11 +17,11 @@ describe( 'NoOperation', () => {
 	beforeEach( () => {
 		noop = new NoOperation( 0 );
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 	} );
 
 	it( 'should not throw an error when applied', () => {
-		expect( () => doc.applyOperation( noop ) ).to.not.throw( Error );
+		expect( () => doc.applyOperation( wrapInDelta( noop ) ) ).to.not.throw( Error );
 	} );
 
 	it( 'should create a NoOperation as a reverse', () => {

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

@@ -12,17 +12,18 @@ 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 { jsonParseStringify } from '/tests/engine/model/_utils/utils.js';
+import Element from '/ckeditor5/engine/model/element.js';
+import { jsonParseStringify, wrapInDelta } from '/tests/engine/model/_utils/utils.js';
 
 describe( 'ReinsertOperation', () => {
 	let doc, root, graveyard, operation, graveyardPosition, rootPosition;
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 		graveyard = doc.graveyard;
 
-		graveyardPosition = new Position( graveyard, [ 0 ] );
+		graveyardPosition = new Position( graveyard, [ 0, 0 ] );
 		rootPosition = new Position( root, [ 0 ] );
 
 		operation = new ReinsertOperation(
@@ -70,28 +71,27 @@ describe( 'ReinsertOperation', () => {
 		expect( reverse.baseVersion ).to.equal( 1 );
 		expect( reverse.howMany ).to.equal( 2 );
 		expect( reverse.sourcePosition.isEqual( rootPosition ) ).to.be.true;
-		expect( reverse.targetPosition.isEqual( graveyardPosition ) ).to.be.true;
+		expect( reverse.targetPosition.root ).to.equal( graveyardPosition.root );
 	} );
 
 	it( 'should undo reinsert set of nodes by applying reverse operation', () => {
 		let reverse = operation.getReversed();
 
-		graveyard.insertChildren( 0, 'bar' );
+		const element = new Element();
+		element.insertChildren( 0, 'xx' );
+		graveyard.insertChildren( 0, element );
 
-		doc.applyOperation( operation );
+		doc.applyOperation( wrapInDelta( operation ) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 2 );
+		expect( element.getChildCount() ).to.equal( 0 );
 
-		doc.applyOperation( reverse );
+		doc.applyOperation( wrapInDelta( reverse ) );
 
 		expect( doc.version ).to.equal( 2 );
 		expect( root.getChildCount() ).to.equal( 0 );
-		expect( graveyard.getChildCount() ).to.equal( 3 );
-
-		expect( graveyard.getChild( 0 ).character ).to.equal( 'b' );
-		expect( graveyard.getChild( 1 ).character ).to.equal( 'a' );
-		expect( graveyard.getChild( 2 ).character ).to.equal( 'r' );
+		// Don't check `element` - nodes are moved to new holder element.
 	} );
 
 	describe( 'toJSON', () => {

+ 72 - 10
packages/ckeditor5-engine/tests/model/operation/removeoperation.js

@@ -12,14 +12,15 @@ 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 { jsonParseStringify } from '/tests/engine/model/_utils/utils.js';
+import Delta from '/ckeditor5/engine/model/delta/delta.js';
+import { jsonParseStringify, wrapInDelta } from '/tests/engine/model/_utils/utils.js';
 
 describe( 'RemoveOperation', () => {
 	let doc, root, graveyard;
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 		graveyard = doc.graveyard;
 	} );
 
@@ -53,24 +54,85 @@ describe( 'RemoveOperation', () => {
 		expect( operation ).to.be.instanceof( MoveOperation );
 	} );
 
-	it( 'should remove set of nodes and append them to graveyard root', () => {
+	it( 'should remove set of nodes and append them to holder element in graveyard root', () => {
 		root.insertChildren( 0, 'fozbar' );
 
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new RemoveOperation(
 				new Position( root, [ 2 ] ),
 				2,
 				doc.version
 			)
-		);
+		) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 4 );
 		expect( root.getChild( 2 ).character ).to.equal( 'a' );
 
-		expect( graveyard.getChildCount() ).to.equal( 2 );
-		expect( graveyard.getChild( 0 ).character ).to.equal( 'z' );
-		expect( graveyard.getChild( 1 ).character ).to.equal( 'b' );
+		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' );
+	} );
+
+	it( 'should create new holder element for remove operations in different deltas', () => {
+		root.insertChildren( 0, 'fozbar' );
+
+		doc.applyOperation( wrapInDelta(
+			new RemoveOperation(
+				new Position( root, [ 0 ] ),
+				1,
+				doc.version
+			)
+		) );
+
+		doc.applyOperation( wrapInDelta(
+			new RemoveOperation(
+				new Position( root, [ 0 ] ),
+				1,
+				doc.version
+			)
+		) );
+
+		doc.applyOperation( wrapInDelta(
+			new RemoveOperation(
+				new Position( root, [ 0 ] ),
+				1,
+				doc.version
+			)
+		) );
+
+		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' );
+	} );
+
+	it( 'should not create new holder element for remove operation if it was already created for given delta', () => {
+		root.insertChildren( 0, 'fozbar' );
+
+		let delta = new Delta();
+
+		// This simulates i.e. RemoveOperation that got split into two operations during OT.
+		let removeOpA = new RemoveOperation(
+			new Position( root, [ 1 ] ),
+			1,
+			doc.version
+		);
+		let removeOpB = new RemoveOperation(
+			new Position( root, [ 0 ] ),
+			1,
+			doc.version + 1
+		);
+
+		delta.addOperation( removeOpA );
+		delta.addOperation( removeOpB );
+
+		doc.applyOperation( removeOpA );
+		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' );
 	} );
 
 	it( 'should create RemoveOperation with same parameters when cloned', () => {
@@ -104,12 +166,12 @@ describe( 'RemoveOperation', () => {
 
 		root.insertChildren( 0, 'bar' );
 
-		doc.applyOperation( operation );
+		doc.applyOperation( wrapInDelta( operation ) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getChildCount() ).to.equal( 0 );
 
-		doc.applyOperation( reverse );
+		doc.applyOperation( wrapInDelta( reverse ) );
 
 		expect( doc.version ).to.equal( 2 );
 		expect( root.getChildCount() ).to.equal( 3 );

+ 19 - 19
packages/ckeditor5-engine/tests/model/operation/rootattributeoperation.js

@@ -10,14 +10,14 @@
 import Document from '/ckeditor5/engine/model/document.js';
 import RootAttributeOperation from '/ckeditor5/engine/model/operation/rootattributeoperation.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
-import { jsonParseStringify } from '/tests/engine/model/_utils/utils.js';
+import { jsonParseStringify, wrapInDelta } from '/tests/engine/model/_utils/utils.js';
 
 describe( 'RootAttributeOperation', () => {
 	let doc, root;
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 	} );
 
 	describe( 'type', () => {
@@ -59,7 +59,7 @@ describe( 'RootAttributeOperation', () => {
 	} );
 
 	it( 'should add attribute on the root element', () => {
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new RootAttributeOperation(
 				root,
 				'isNew',
@@ -67,7 +67,7 @@ describe( 'RootAttributeOperation', () => {
 				true,
 				doc.version
 			)
-		);
+		) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.hasAttribute( 'isNew' ) ).to.be.true;
@@ -76,7 +76,7 @@ describe( 'RootAttributeOperation', () => {
 	it( 'should change attribute on the root element', () => {
 		root.setAttribute( 'isNew', false );
 
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new RootAttributeOperation(
 				root,
 				'isNew',
@@ -84,7 +84,7 @@ describe( 'RootAttributeOperation', () => {
 				true,
 				doc.version
 			)
-		);
+		) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.getAttribute( 'isNew' ) ).to.be.true;
@@ -93,7 +93,7 @@ describe( 'RootAttributeOperation', () => {
 	it( 'should remove attribute from the root element', () => {
 		root.setAttribute( 'x', true );
 
-		doc.applyOperation(
+		doc.applyOperation( wrapInDelta(
 			new RootAttributeOperation(
 				root,
 				'x',
@@ -101,7 +101,7 @@ describe( 'RootAttributeOperation', () => {
 				null,
 				doc.version
 			)
-		);
+		) );
 
 		expect( doc.version ).to.equal( 1 );
 		expect( root.hasAttribute( 'x' ) ).to.be.false;
@@ -130,8 +130,8 @@ describe( 'RootAttributeOperation', () => {
 
 		let reverse = operation.getReversed();
 
-		doc.applyOperation( operation );
-		doc.applyOperation( reverse );
+		doc.applyOperation( wrapInDelta( operation ) );
+		doc.applyOperation( wrapInDelta( reverse ) );
 
 		expect( doc.version ).to.equal( 2 );
 		expect( root.hasAttribute( 'x' ) ).to.be.false;
@@ -150,8 +150,8 @@ describe( 'RootAttributeOperation', () => {
 
 		let reverse = operation.getReversed();
 
-		doc.applyOperation( operation );
-		doc.applyOperation( reverse );
+		doc.applyOperation( wrapInDelta( operation ) );
+		doc.applyOperation( wrapInDelta( reverse ) );
 
 		expect( doc.version ).to.equal( 2 );
 		expect( root.getAttribute( 'isNew' ) ).to.be.false;
@@ -170,8 +170,8 @@ describe( 'RootAttributeOperation', () => {
 
 		let reverse = operation.getReversed();
 
-		doc.applyOperation( operation );
-		doc.applyOperation( reverse );
+		doc.applyOperation( wrapInDelta( operation ) );
+		doc.applyOperation( wrapInDelta( reverse ) );
 
 		expect( doc.version ).to.equal( 2 );
 		expect( root.getAttribute( 'foo' ) ).to.be.true;
@@ -179,7 +179,7 @@ describe( 'RootAttributeOperation', () => {
 
 	it( 'should throw an error when one try to remove and the attribute does not exists', () => {
 		expect( () => {
-			doc.applyOperation(
+			doc.applyOperation( wrapInDelta(
 				new RootAttributeOperation(
 					root,
 					'foo',
@@ -187,7 +187,7 @@ describe( 'RootAttributeOperation', () => {
 					null,
 					doc.version
 				)
-			);
+			) );
 		} ).to.throw( CKEditorError, /operation-rootattribute-no-attr-to-remove/ );
 	} );
 
@@ -195,7 +195,7 @@ describe( 'RootAttributeOperation', () => {
 		root.setAttribute( 'x', 1 );
 
 		expect( () => {
-			doc.applyOperation(
+			doc.applyOperation( wrapInDelta(
 				new RootAttributeOperation(
 					root,
 					'x',
@@ -203,7 +203,7 @@ describe( 'RootAttributeOperation', () => {
 					2,
 					doc.version
 				)
-			);
+			) );
 		} ).to.throw( CKEditorError, /operation-rootattribute-attr-exists/ );
 	} );
 
@@ -244,7 +244,7 @@ describe( 'RootAttributeOperation', () => {
 				key: 'key',
 				newValue: 'newValue',
 				oldValue: null,
-				root: 'root'
+				root: 'main'
 			} );
 		} );
 	} );

+ 58 - 3
packages/ckeditor5-engine/tests/model/operation/transform.js

@@ -7,22 +7,27 @@
 
 'use strict';
 
+import transform from '/ckeditor5/engine/model/operation/transform.js';
+
+import Document from '/ckeditor5/engine/model/document.js';
 import RootElement from '/ckeditor5/engine/model/rootelement.js';
 import Node from '/ckeditor5/engine/model/node.js';
 import Position from '/ckeditor5/engine/model/position.js';
 import Range from '/ckeditor5/engine/model/range.js';
-import transform from '/ckeditor5/engine/model/operation/transform.js';
+
 import InsertOperation from '/ckeditor5/engine/model/operation/insertoperation.js';
 import AttributeOperation from '/ckeditor5/engine/model/operation/attributeoperation.js';
 import RootAttributeOperation from '/ckeditor5/engine/model/operation/rootattributeoperation.js';
 import MoveOperation from '/ckeditor5/engine/model/operation/moveoperation.js';
+import RemoveOperation from '/ckeditor5/engine/model/operation/removeoperation.js';
 import NoOperation from '/ckeditor5/engine/model/operation/nooperation.js';
 
 describe( 'transform', () => {
-	let root, op, nodeA, nodeB, expected, baseVersion;
+	let doc, root, op, nodeA, nodeB, expected, baseVersion;
 
 	beforeEach( () => {
-		root = new RootElement( null );
+		doc = new Document();
+		root = doc.createRoot();
 
 		nodeA = new Node();
 		nodeB = new Node();
@@ -1330,6 +1335,56 @@ describe( 'transform', () => {
 				} );
 			} );
 		} );
+
+		describe( 'by RemoveOperation', () => {
+			beforeEach( () => {
+				start = new Position( doc.graveyard, [ 2, 0 ] );
+				end = new Position( doc.graveyard, [ 2, 4 ] );
+
+				range = new Range( start, end );
+
+				op = new AttributeOperation( range, 'foo', 'abc', 'bar', baseVersion );
+
+				expected.range = new Range( start, end );
+			} );
+
+			it( 'remove operation inserted holder element before attribute operation range: increment path', () => {
+				let transformBy = new RemoveOperation(
+					new Position( root, [ 0 ] ),
+					2,
+					baseVersion
+				);
+
+				transformBy.targetPosition.path = [ 0, 0 ];
+				transformBy.movedRangeStart.path = [ 0, 0 ];
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+
+				expected.range.start.path = [ 3, 0 ];
+				expected.range.end.path = [ 3, 4 ];
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+
+			it( 'remove operation inserted holder element after attribute operation range: do nothing', () => {
+				let transformBy = new RemoveOperation(
+					new Position( root, [ 0 ] ),
+					2,
+					baseVersion
+				);
+
+				transformBy.targetPosition.path = [ 4, 0 ];
+				transformBy.movedRangeStart.path = [ 4, 0 ];
+
+				let transOp = transform( op, transformBy );
+
+				expect( transOp.length ).to.equal( 1 );
+
+				expectOperation( transOp[ 0 ], expected );
+			} );
+		} );
 	} );
 
 	describe( 'RootAttributeOperation', () => {

+ 4 - 4
packages/ckeditor5-engine/tests/model/position.js

@@ -35,8 +35,8 @@ describe( 'position', () => {
 	before( () => {
 		doc = new Document();
 
-		root = doc.createRoot( 'root' );
-		otherRoot = doc.createRoot( 'otherRoot' );
+		root = doc.createRoot();
+		otherRoot = doc.createRoot( '$root', 'otherRoot' );
 
 		li1 = new Element( 'li', [], 'foz' );
 
@@ -689,7 +689,7 @@ describe( 'position', () => {
 
 			let serialized = jsonParseStringify( position );
 
-			expect( serialized ).to.deep.equal( { root: 'root', path: [ 0 ] } );
+			expect( serialized ).to.deep.equal( { root: 'main', path: [ 0 ] } );
 		} );
 
 		it( 'should serialize position from graveyard', () => {
@@ -703,7 +703,7 @@ describe( 'position', () => {
 
 	describe( 'fromJSON', () => {
 		it( 'should create object with given document', () => {
-			let deserialized = Position.fromJSON( { root: 'root', path: [ 0, 1, 2 ] }, doc );
+			let deserialized = Position.fromJSON( { root: 'main', path: [ 0, 1, 2 ] }, doc );
 
 			expect( deserialized.root ).to.equal( root );
 			expect( deserialized.path ).to.deep.equal( [ 0, 1, 2 ] );

+ 2 - 2
packages/ckeditor5-engine/tests/model/range.js

@@ -20,8 +20,8 @@ describe( 'Range', () => {
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
-		otherRoot = doc.createRoot( 'otherRoot' );
+		root = doc.createRoot();
+		otherRoot = doc.createRoot( '$root', 'otherRoot' );
 
 		start = new Position( root, [ 1 ] );
 		end = new Position( root, [ 2 ] );

+ 2 - 2
packages/ckeditor5-engine/tests/model/schema/schema.js

@@ -261,7 +261,7 @@ describe( 'check', () => {
 
 		beforeEach( () => {
 			doc = new Document();
-			root = doc.createRoot( 'root', 'div' );
+			root = doc.createRoot( 'div' );
 
 			root.insertChildren( 0, [
 				new Element( 'div' ),
@@ -325,7 +325,7 @@ describe( '_normalizeQueryPath', () => {
 
 	it( 'should normalize model position to an array of strings', () => {
 		let doc = new Document();
-		let root = doc.createRoot( 'root', '$root' );
+		let root = doc.createRoot();
 
 		root.insertChildren( 0, [
 			new Element( 'div', null, [

+ 30 - 537
packages/ckeditor5-engine/tests/model/selection.js

@@ -9,13 +9,10 @@
 
 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 Range from '/ckeditor5/engine/model/range.js';
 import Position from '/ckeditor5/engine/model/position.js';
 import LiveRange from '/ckeditor5/engine/model/liverange.js';
 import Selection from '/ckeditor5/engine/model/selection.js';
-import InsertOperation from '/ckeditor5/engine/model/operation/insertoperation.js';
-import MoveOperation from '/ckeditor5/engine/model/operation/moveoperation.js';
 import CKEditorError from '/ckeditor5/utils/ckeditorerror.js';
 import testUtils from '/tests/ckeditor5/_utils/utils.js';
 import count from '/ckeditor5/utils/count.js';
@@ -23,12 +20,6 @@ import count from '/ckeditor5/utils/count.js';
 testUtils.createSinonSandbox();
 
 describe( 'Selection', () => {
-	let attrFooBar;
-
-	before( () => {
-		attrFooBar = { foo: 'bar' };
-	} );
-
 	let doc, root, selection, liveRange, range;
 
 	beforeEach( () => {
@@ -43,8 +34,7 @@ describe( 'Selection', () => {
 			new Element( 'p' ),
 			new Element( 'p', [], 'foobar' )
 		] );
-		selection = doc.selection;
-		doc.schema.registerItem( 'p', '$block' );
+		selection = new Selection();
 
 		liveRange = new LiveRange( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) );
 		range = new Range( new Position( root, [ 2 ] ), new Position( root, [ 2, 2 ] ) );
@@ -55,59 +45,9 @@ describe( 'Selection', () => {
 		liveRange.detach();
 	} );
 
-	describe( 'default range', () => {
-		it( 'should go to the first editable element', () => {
-			const ranges = Array.from( selection.getRanges() );
-
-			expect( ranges.length ).to.equal( 1 );
-			expect( selection.anchor.isEqual( new Position( root, [ 0, 0 ] ) ) ).to.be.true;
-			expect( selection.focus.isEqual( new Position( root, [ 0, 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 );
-		} );
-
-		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' );
-			selection = doc.selection;
-
-			const ranges = Array.from( selection.getRanges() );
-
-			expect( ranges.length ).to.equal( 1 );
-			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 );
-		} );
-
-		it( 'should skip element when you can not put selection', () => {
-			doc = new Document();
-			root = doc.createRoot();
-			root.insertChildren( 0, [
-				new Element( 'img' ),
-				new Element( 'p', [], 'foobar' )
-			] );
-			doc.schema.registerItem( 'img' );
-			doc.schema.registerItem( 'p', '$block' );
-			selection = doc.selection;
-
-			const ranges = Array.from( selection.getRanges() );
-
-			expect( ranges.length ).to.equal( 1 );
-			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 );
-		} );
-	} );
-
 	describe( 'isCollapsed', () => {
-		it( 'should return true for default range', () => {
-			expect( selection.isCollapsed ).to.be.true;
+		it( 'should return false for empty selection', () => {
+			expect( selection.isCollapsed ).to.be.false;
 		} );
 
 		it( 'should return true when there is single collapsed ranges', () => {
@@ -132,7 +72,7 @@ describe( 'Selection', () => {
 
 	describe( 'rangeCount', () => {
 		it( 'should return proper range count', () => {
-			expect( selection.rangeCount ).to.equal( 1 );
+			expect( selection.rangeCount ).to.equal( 0 );
 
 			selection.addRange( new Range( new Position( root, [ 0 ] ), new Position( root, [ 0 ] ) ) );
 
@@ -211,14 +151,6 @@ describe( 'Selection', () => {
 			expect( ranges[ 0 ].isEqual( liveRange ) ).to.be.true;
 		} );
 
-		it( 'should convert added Range to LiveRange', () => {
-			selection.addRange( range );
-
-			const ranges = selection._ranges;
-
-			expect( ranges[ 0 ] ).to.be.instanceof( LiveRange );
-		} );
-
 		it( 'should fire change:range event when adding a range', () => {
 			let spy = sinon.spy();
 			selection.on( 'change:range', spy );
@@ -228,24 +160,6 @@ describe( 'Selection', () => {
 			expect( spy.called ).to.be.true;
 		} );
 
-		it( 'should unbind all events when destroyed', () => {
-			selection.addRange( liveRange );
-			selection.addRange( range );
-
-			const ranges = selection._ranges;
-
-			sinon.spy( ranges[ 0 ], 'detach' );
-			sinon.spy( ranges[ 1 ], 'detach' );
-
-			selection.destroy();
-
-			expect( ranges[ 0 ].detach.called ).to.be.true;
-			expect( ranges[ 1 ].detach.called ).to.be.true;
-
-			ranges[ 0 ].detach.restore();
-			ranges[ 1 ].detach.restore();
-		} );
-
 		it( 'should throw an error if added range intersects with already stored range', () => {
 			selection.addRange( liveRange );
 
@@ -261,16 +175,6 @@ describe( 'Selection', () => {
 	} );
 
 	describe( 'collapse', () => {
-		it( 'detaches all existing ranges', () => {
-			selection.addRange( range );
-			selection.addRange( liveRange );
-
-			const spy = testUtils.sinon.spy( LiveRange.prototype, 'detach' );
-			selection.collapse( root );
-
-			expect( spy.calledTwice ).to.be.true;
-		} );
-
 		it( 'fires change:range', () => {
 			const spy = sinon.spy();
 
@@ -369,14 +273,12 @@ describe( 'Selection', () => {
 			expect( spy.calledOnce ).to.be.true;
 		} );
 
-		it( 'modifies default range', () => {
-			const startPos = selection.getFirstPosition();
+		it( 'throws if there are no ranges in selection', () => {
 			const endPos = Position.createAt( root, 'END' );
 
-			selection.setFocus( endPos );
-
-			expect( selection.anchor.compareWith( startPos ) ).to.equal( 'SAME' );
-			expect( selection.focus.compareWith( endPos ) ).to.equal( 'SAME' );
+			expect( () => {
+				selection.setFocus( endPos );
+			} ).to.throw( CKEditorError, /selection-setFocus-no-ranges/ );
 		} );
 
 		it( 'modifies existing collapsed selection', () => {
@@ -515,19 +417,6 @@ describe( 'Selection', () => {
 			expect( spy.calledOnce ).to.be.true;
 			expect( selection.focus.compareWith( newEndPos ) ).to.equal( 'SAME' );
 		} );
-
-		it( 'detaches the range it replaces', () => {
-			const startPos = Position.createAt( root, 1 );
-			const endPos = Position.createAt( root, 2 );
-			const newEndPos = Position.createAt( root, 4 );
-			const spy = testUtils.sinon.spy( LiveRange.prototype, 'detach' );
-
-			selection.addRange( new Range( startPos, endPos ) );
-
-			selection.setFocus( newEndPos );
-
-			expect( spy.calledOnce ).to.be.true;
-		} );
 	} );
 
 	describe( 'removeAllRanges', () => {
@@ -542,31 +431,16 @@ describe( 'Selection', () => {
 
 			ranges = selection._ranges;
 
-			sinon.spy( ranges[ 0 ], 'detach' );
-			sinon.spy( ranges[ 1 ], 'detach' );
-
 			selection.removeAllRanges();
 		} );
 
-		afterEach( () => {
-			ranges[ 0 ].detach.restore();
-			ranges[ 1 ].detach.restore();
-		} );
-
-		it( 'should remove all stored ranges (and reset to default range)', () => {
-			expect( Array.from( selection.getRanges() ).length ).to.equal( 1 );
-			expect( selection.anchor.isEqual( new Position( root, [ 0, 0 ] ) ) ).to.be.true;
-			expect( selection.focus.isEqual( new Position( root, [ 0, 0 ] ) ) ).to.be.true;
+		it( 'should remove all stored ranges', () => {
+			expect( Array.from( selection.getRanges() ).length ).to.equal( 0 );
 		} );
 
 		it( 'should fire exactly one update event', () => {
 			expect( spy.calledOnce ).to.be.true;
 		} );
-
-		it( 'should detach ranges', () => {
-			expect( ranges[ 0 ].detach.called ).to.be.true;
-			expect( ranges[ 1 ].detach.called ).to.be.true;
-		} );
 	} );
 
 	describe( 'setRanges', () => {
@@ -587,14 +461,6 @@ describe( 'Selection', () => {
 			selection.on( 'change:range', spy );
 
 			oldRanges = selection._ranges;
-
-			sinon.spy( oldRanges[ 0 ], 'detach' );
-			sinon.spy( oldRanges[ 1 ], 'detach' );
-		} );
-
-		afterEach( () => {
-			oldRanges[ 0 ].detach.restore();
-			oldRanges[ 1 ].detach.restore();
 		} );
 
 		it( 'should remove all ranges and add given ranges', () => {
@@ -623,15 +489,13 @@ describe( 'Selection', () => {
 			selection.setRanges( newRanges );
 			expect( spy.calledOnce ).to.be.true;
 		} );
-
-		it( 'should detach removed LiveRanges', () => {
-			selection.setRanges( newRanges );
-			expect( oldRanges[ 0 ].detach.called ).to.be.true;
-			expect( oldRanges[ 1 ].detach.called ).to.be.true;
-		} );
 	} );
 
 	describe( 'getFirstRange', () => {
+		it( 'should return null if no ranges were added', () => {
+			expect( selection.getFirstRange() ).to.be.null;
+		} );
+
 		it( 'should return a range which start position is before all other ranges\' start positions', () => {
 			// This will not be the first range despite being added as first
 			selection.addRange( new Range( new Position( root, [ 4 ] ), new Position( root, [ 5 ] ) ) );
@@ -650,6 +514,10 @@ describe( 'Selection', () => {
 	} );
 
 	describe( 'getFirstPosition', () => {
+		it( 'should return null if no ranges were added', () => {
+			expect( selection.getFirstPosition() ).to.be.null;
+		} );
+
 		it( 'should return a position that is in selection and is before any other position from the selection', () => {
 			// This will not be a range containing the first position despite being added as first
 			selection.addRange( new Range( new Position( root, [ 4 ] ), new Position( root, [ 5 ] ) ) );
@@ -666,398 +534,23 @@ describe( 'Selection', () => {
 		} );
 	} );
 
-	// Selection uses LiveRanges so here are only simple test to see if integration is
-	// working well, without getting into complicated corner cases.
-	describe( 'after applying an operation should get updated and not fire update event', () => {
-		let spy;
-
-		beforeEach( () => {
-			root.insertChildren( 0, [ new Element( 'ul', [], 'abcdef' ), new Element( 'p', [], 'foobar' ), 'xyz' ] );
-
-			selection.addRange( new Range( new Position( root, [ 0, 2 ] ), new Position( root, [ 1, 4 ] ) ) );
-
-			spy = sinon.spy();
-			selection.on( 'change:range', spy );
-		} );
-
-		describe( 'InsertOperation', () => {
-			it( 'before selection', () => {
-				doc.applyOperation(
-					new InsertOperation(
-						new Position( root, [ 0, 1 ] ),
-						'xyz',
-						doc.version
-					)
-				);
-
-				let range = selection._ranges[ 0 ];
-
-				expect( range.start.path ).to.deep.equal( [ 0, 5 ] );
-				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
-				expect( spy.called ).to.be.false;
-			} );
-
-			it( 'inside selection', () => {
-				doc.applyOperation(
-					new InsertOperation(
-						new Position( root, [ 1, 0 ] ),
-						'xyz',
-						doc.version
-					)
-				);
-
-				let range = selection._ranges[ 0 ];
-
-				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
-				expect( range.end.path ).to.deep.equal( [ 1, 7 ] );
-				expect( spy.called ).to.be.false;
-			} );
-		} );
-
-		describe( 'MoveOperation', () => {
-			it( 'move range from before a selection', () => {
-				doc.applyOperation(
-					new MoveOperation(
-						new Position( root, [ 0, 0 ] ),
-						2,
-						new Position( root, [ 2 ] ),
-						doc.version
-					)
-				);
-
-				let range = selection._ranges[ 0 ];
-
-				expect( range.start.path ).to.deep.equal( [ 0, 0 ] );
-				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
-				expect( spy.called ).to.be.false;
-			} );
-
-			it( 'moved into before a selection', () => {
-				doc.applyOperation(
-					new MoveOperation(
-						new Position( root, [ 2 ] ),
-						2,
-						new Position( root, [ 0, 0 ] ),
-						doc.version
-					)
-				);
-
-				let range = selection._ranges[ 0 ];
-
-				expect( range.start.path ).to.deep.equal( [ 0, 4 ] );
-				expect( range.end.path ).to.deep.equal( [ 1, 4 ] );
-				expect( spy.called ).to.be.false;
-			} );
-
-			it( 'move range from inside of selection', () => {
-				doc.applyOperation(
-					new MoveOperation(
-						new Position( root, [ 1, 0 ] ),
-						2,
-						new Position( root, [ 2 ] ),
-						doc.version
-					)
-				);
-
-				let range = selection._ranges[ 0 ];
-
-				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
-				expect( range.end.path ).to.deep.equal( [ 1, 2 ] );
-				expect( spy.called ).to.be.false;
-			} );
-
-			it( 'moved range intersects with selection', () => {
-				doc.applyOperation(
-					new MoveOperation(
-						new Position( root, [ 1, 3 ] ),
-						2,
-						new Position( root, [ 4 ] ),
-						doc.version
-					)
-				);
-
-				let range = selection._ranges[ 0 ];
-
-				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
-				expect( range.end.path ).to.deep.equal( [ 1, 3 ] );
-				expect( spy.called ).to.be.false;
-			} );
-
-			it( 'split inside selection (do not break selection)', () => {
-				doc.applyOperation(
-					new InsertOperation(
-						new Position( root, [ 2 ] ),
-						new Element( 'p' ),
-						doc.version
-					)
-				);
-
-				doc.applyOperation(
-					new MoveOperation(
-						new Position( root, [ 1, 2 ] ),
-						4,
-						new Position( root, [ 2, 0 ] ),
-						doc.version
-					)
-				);
-
-				let range = selection._ranges[ 0 ];
-
-				expect( range.start.path ).to.deep.equal( [ 0, 2 ] );
-				expect( range.end.path ).to.deep.equal( [ 2, 2 ] );
-				expect( spy.called ).to.be.false;
-			} );
-		} );
-	} );
-
-	describe( 'attributes interface', () => {
-		let fullP, emptyP, rangeInFullP, rangeInEmptyP;
-
-		beforeEach( () => {
-			root.insertChildren( 0, [
-				new Element( 'p', [], 'foobar' ),
-				new Element( 'p', [], [] )
-			] );
-
-			fullP = root.getChild( 0 );
-			emptyP = root.getChild( 1 );
-
-			rangeInFullP = new Range( new Position( root, [ 0, 4 ] ), new Position( root, [ 0, 4 ] ) );
-			rangeInEmptyP = new Range( new Position( root, [ 1, 0 ] ), new Position( root, [ 1, 0 ] ) );
-		} );
-
-		describe( 'setAttribute', () => {
-			it( 'should set given attribute on the selection', () => {
-				selection.setRanges( [ rangeInFullP ] );
-				selection.setAttribute( 'foo', 'bar' );
-
-				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
-				expect( fullP.hasAttribute( Selection._getStoreAttributeKey( 'foo' ) ) ).to.be.false;
-			} );
-
-			it( 'should store attribute if the selection is in empty node', () => {
-				selection.setRanges( [ rangeInEmptyP ] );
-				selection.setAttribute( 'foo', 'bar' );
-
-				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
-
-				expect( emptyP.getAttribute( Selection._getStoreAttributeKey( 'foo' ) ) ).to.equal( 'bar' );
-			} );
-
-			it( 'should fire change:attribute event', () => {
-				let spy = sinon.spy();
-				selection.on( 'change:attribute', spy );
-
-				selection.setAttribute( 'foo', 'bar' );
-
-				expect( spy.called ).to.be.true;
-			} );
-		} );
-
-		describe( 'hasAttribute', () => {
-			it( 'should return true if element contains attribute with given key', () => {
-				selection.setRanges( [ rangeInFullP ] );
-				selection.setAttribute( 'foo', 'bar' );
-
-				expect( selection.hasAttribute( 'foo' ) ).to.be.true;
-			} );
-
-			it( 'should return false if element does not contain attribute with given key', () => {
-				expect( selection.hasAttribute( 'abc' ) ).to.be.false;
-			} );
-		} );
-
-		describe( 'getAttribute', () => {
-			it( 'should return undefined if element does not contain given attribute', () => {
-				expect( selection.getAttribute( 'abc' ) ).to.be.undefined;
-			} );
-		} );
-
-		describe( 'getAttributes', () => {
-			it( 'should return an iterator that iterates over all attributes set on the text fragment', () => {
-				selection.setRanges( [ rangeInFullP ] );
-				selection.setAttribute( 'foo', 'bar' );
-				selection.setAttribute( 'abc', 'xyz' );
-
-				let attrs = Array.from( selection.getAttributes() );
-
-				expect( attrs ).to.deep.equal( [ [ 'foo', 'bar' ], [ 'abc', 'xyz' ] ] );
-			} );
-		} );
-
-		describe( 'setAttributesTo', () => {
-			it( 'should remove all attributes set on element and set the given ones', () => {
-				selection.setRanges( [ rangeInFullP ] );
-				selection.setAttribute( 'abc', 'xyz' );
-				selection.setAttributesTo( { foo: 'bar' } );
-
-				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
-				expect( selection.getAttribute( 'abc' ) ).to.be.undefined;
-
-				expect( fullP.hasAttribute( Selection._getStoreAttributeKey( 'foo' ) ) ).to.be.false;
-				expect( fullP.hasAttribute( Selection._getStoreAttributeKey( 'abc' ) ) ).to.be.false;
-			} );
-
-			it( 'should remove all stored attributes and store the given ones if the selection is in empty node', () => {
-				selection.setRanges( [ rangeInEmptyP ] );
-				selection.setAttribute( 'abc', 'xyz' );
-				selection.setAttributesTo( { foo: 'bar' } );
-
-				expect( selection.getAttribute( 'foo' ) ).to.equal( 'bar' );
-				expect( selection.getAttribute( 'abc' ) ).to.be.undefined;
-
-				expect( emptyP.getAttribute( Selection._getStoreAttributeKey( 'foo' ) ) ).to.equal( 'bar' );
-				expect( emptyP.hasAttribute( Selection._getStoreAttributeKey( 'abc' ) ) ).to.be.false;
-			} );
-
-			it( 'should fire change:attribute event', () => {
-				let spy = sinon.spy();
-				selection.on( 'change:attribute', spy );
-
-				selection.setAttributesTo( { foo: 'bar' } );
-
-				expect( spy.called ).to.be.true;
-			} );
-		} );
-
-		describe( 'removeAttribute', () => {
-			it( 'should remove attribute set on the text fragment', () => {
-				selection.setRanges( [ rangeInFullP ] );
-				selection.setAttribute( 'foo', 'bar' );
-				selection.removeAttribute( 'foo' );
-
-				expect( selection.getAttribute( 'foo' ) ).to.be.undefined;
-
-				expect( fullP.hasAttribute( Selection._getStoreAttributeKey( 'foo' ) ) ).to.be.false;
-			} );
-
-			it( 'should remove stored attribute if the selection is in empty node', () => {
-				selection.setRanges( [ rangeInEmptyP ] );
-				selection.setAttribute( 'foo', 'bar' );
-				selection.removeAttribute( 'foo' );
-
-				expect( selection.getAttribute( 'foo' ) ).to.be.undefined;
-
-				expect( emptyP.hasAttribute( Selection._getStoreAttributeKey( 'foo' ) ) ).to.be.false;
-			} );
-
-			it( 'should fire change:attribute event', () => {
-				let spy = sinon.spy();
-				selection.on( 'change:attribute', spy );
-
-				selection.removeAttribute( 'foo' );
-
-				expect( spy.called ).to.be.true;
-			} );
-		} );
-
-		describe( 'clearAttributes', () => {
-			it( 'should remove all attributes from the element', () => {
-				selection.setRanges( [ rangeInFullP ] );
-				selection.setAttribute( 'foo', 'bar' );
-				selection.setAttribute( 'abc', 'xyz' );
-
-				selection.clearAttributes();
-
-				expect( selection.getAttribute( 'foo' ) ).to.be.undefined;
-				expect( selection.getAttribute( 'abc' ) ).to.be.undefined;
-
-				expect( fullP.hasAttribute( Selection._getStoreAttributeKey( 'foo' ) ) ).to.be.false;
-				expect( fullP.hasAttribute( Selection._getStoreAttributeKey( 'abc' ) ) ).to.be.false;
-			} );
-
-			it( 'should remove all stored attributes if the selection is in empty node', () => {
-				selection.setRanges( [ rangeInEmptyP ] );
-				selection.setAttribute( 'foo', 'bar' );
-				selection.setAttribute( 'abc', 'xyz' );
-
-				selection.clearAttributes();
-
-				expect( selection.getAttribute( 'foo' ) ).to.be.undefined;
-				expect( selection.getAttribute( 'abc' ) ).to.be.undefined;
-
-				expect( emptyP.hasAttribute( Selection._getStoreAttributeKey( 'foo' ) ) ).to.be.false;
-				expect( emptyP.hasAttribute( Selection._getStoreAttributeKey( 'abc' ) ) ).to.be.false;
-			} );
-
-			it( 'should fire change:attribute event', () => {
-				let spy = sinon.spy();
-				selection.on( 'change:attribute', spy );
-
-				selection.clearAttributes();
-
-				expect( spy.called ).to.be.true;
-			} );
-		} );
-	} );
-
-	describe( '_updateAttributes', () => {
-		beforeEach( () => {
-			root.insertChildren( 0, [
-				new Element( 'p', { p: true } ),
-				new Text( 'a', { a: true } ),
-				new Element( 'p', { p: true } ),
-				new Text( 'b', { b: true } ),
-				new Text( 'c', { c: true } ),
-				new Element( 'p', [], [
-					new Text( 'd', { d: true } )
-				] ),
-				new Element( 'p', { p: true } ),
-				new Text( 'e', { e: true } )
-			] );
-		} );
-
-		it( 'if selection is a range, should find first character in it and copy it\'s attributes', () => {
-			selection.setRanges( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 5 ] ) ) ] );
-
-			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'b', true ] ] );
-
-			// Step into elements when looking for first character:
-			selection.setRanges( [ new Range( new Position( root, [ 5 ] ), new Position( root, [ 7 ] ) ) ] );
-
-			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'd', true ] ] );
-		} );
-
-		it( 'if selection is collapsed it should seek a character to copy that character\'s attributes', () => {
-			// Take styles from character before selection.
-			selection.setRanges( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 2 ] ) ) ] );
-			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'a', true ] ] );
-
-			// If there are none,
-			// Take styles from character after selection.
-			selection.setRanges( [ new Range( new Position( root, [ 3 ] ), new Position( root, [ 3 ] ) ) ] );
-			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'b', true ] ] );
-
-			// If there are none,
-			// Look from the selection position to the beginning of node looking for character to take attributes from.
-			selection.setRanges( [ new Range( new Position( root, [ 6 ] ), new Position( root, [ 6 ] ) ) ] );
-			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'c', true ] ] );
-
-			// If there are none,
-			// Look from the selection position to the end of node looking for character to take attributes from.
-			selection.setRanges( [ new Range( new Position( root, [ 0 ] ), new Position( root, [ 0 ] ) ) ] );
-			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [ [ 'a', true ] ] );
-
-			// If there are no characters to copy attributes from, use stored attributes.
-			selection.setRanges( [ new Range( new Position( root, [ 0, 0 ] ), new Position( root, [ 0, 0 ] ) ) ] );
-			expect( Array.from( selection.getAttributes() ) ).to.deep.equal( [] );
-		} );
+	describe( 'createFromSelection', () => {
+		it( 'should return a Selection instance with same ranges and direction as given selection', () => {
+			selection.addRange( liveRange );
+			selection.addRange( range, true );
 
-		it( 'should fire change:attribute event', () => {
-			let spy = sinon.spy();
-			selection.on( 'change:attribute', spy );
+			const snapshot = Selection.createFromSelection( selection );
 
-			selection.setRanges( [ new Range( new Position( root, [ 2 ] ), new Position( root, [ 5 ] ) ) ] );
+			expect( selection.isBackward ).to.equal( snapshot.isBackward );
 
-			expect( spy.called ).to.be.true;
-		} );
-	} );
+			const selectionRanges = Array.from( selection.getRanges() );
+			const snapshotRanges = Array.from( snapshot.getRanges() );
 
-	describe( '_getStoredAttributes', () => {
-		it( 'should return no values if there are no ranges in selection', () => {
-			let values = Array.from( selection._getStoredAttributes() );
+			expect( selectionRanges.length ).to.equal( snapshotRanges.length );
 
-			expect( values ).to.deep.equal( [] );
+			for ( let i = 0; i < selectionRanges.length; i++ ) {
+				expect( selectionRanges[ i ].isEqual( snapshotRanges[ i ] ) ).to.be.true;
+			}
 		} );
 	} );
 } );

+ 1 - 1
packages/ckeditor5-engine/tests/model/textproxy.js

@@ -18,7 +18,7 @@ describe( 'TextProxy', () => {
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 		element = new Element( 'div' );
 		root.insertChildren( 0, element );
 

+ 1 - 1
packages/ckeditor5-engine/tests/model/treewalker.js

@@ -21,7 +21,7 @@ describe( 'TreeWalker', () => {
 
 	before( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 
 		// root
 		//  |- img1

+ 14 - 2
packages/ckeditor5-engine/tests/model/utils-tests/utils.js

@@ -5,17 +5,19 @@
 
 'use strict';
 
-import { getNodesAndText, jsonParseStringify } from '/tests/engine/model/_utils/utils.js';
+import { getNodesAndText, jsonParseStringify, wrapInDelta } from '/tests/engine/model/_utils/utils.js';
 import Document from '/ckeditor5/engine/model/document.js';
 import Range from '/ckeditor5/engine/model/range.js';
 import Element from '/ckeditor5/engine/model/element.js';
+import Operation from '/ckeditor5/engine/model/operation/operation.js';
+import Delta from '/ckeditor5/engine/model/delta/delta.js';
 
 describe( 'getNodesAndText', () => {
 	let doc, root, div, p;
 
 	beforeEach( () => {
 		doc = new Document();
-		root = doc.createRoot( 'root' );
+		root = doc.createRoot();
 
 		div = new Element( 'div', [], 'foobar' );
 		p = new Element( 'p', [], 'abcxyz' );
@@ -43,3 +45,13 @@ describe( 'jsonParseStringify', () => {
 		expect( fooJsoned ).to.deep.equal( { ra: { bar: 'bar' } } );
 	} );
 } );
+
+describe( 'wrapInDelta', () => {
+	it( 'should return given operation wrapped in a delta', () => {
+		const op = new Operation( 0 );
+		const wrapped = wrapInDelta( op );
+
+		expect( wrapped ).to.equal( op );
+		expect( wrapped.delta ).to.be.instanceof( Delta );
+	} );
+} );

+ 8 - 0
packages/ckeditor5-engine/tests/tickets/475/475.html

@@ -0,0 +1,8 @@
+<head>
+	<meta charset="utf-8">
+	<link rel="stylesheet" href="%APPS_DIR%ckeditor/build/amd/theme/ckeditor.css">
+</head>
+
+<div id="editor">
+	<p>ckeditor from http://www.cksource.com is awesome!</p>
+</div>

+ 92 - 0
packages/ckeditor5-engine/tests/tickets/475/475.js

@@ -0,0 +1,92 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import ClassicEditor from '/ckeditor5/editor-classic/classic.js';
+
+import Feature from '/ckeditor5/feature.js';
+
+import TreeWalker from '/ckeditor5/engine/model/treewalker.js';
+import Position from '/ckeditor5/engine/model/position.js';
+import Range from '/ckeditor5/engine/model/range.js';
+import LivePosition from '/ckeditor5/engine/model/liveposition.js';
+
+import BuildModelConverterFor from '/ckeditor5/engine/conversion/model-converter-builder.js';
+import BuildViewConverterFor from '/ckeditor5/engine/conversion/view-converter-builder.js';
+
+import AttributeElement from '/ckeditor5/engine/view/attributeelement.js';
+
+export default class Link extends Feature {
+	init() {
+		const editor = this.editor;
+		const data = editor.data;
+		const editing = editor.editing;
+
+		// Allow bold attribute on all inline nodes.
+		editor.document.schema.allow( { name: '$inline', attributes: [ 'link' ] } );
+
+		// Build converter from model to view for data and editing pipelines.
+		BuildModelConverterFor( data.modelToView, editing.modelToView )
+			.fromAttribute( 'link' )
+			.toElement( ( href ) => new AttributeElement( 'a', { href } ) );
+
+		// Build converter from view to model for data pipeline.
+		BuildViewConverterFor( data.viewToModel )
+			.fromElement( 'a' )
+			.toAttribute( ( viewElement ) => ( { key: 'link', value: viewElement.getAttribute( 'href' ) } ) );
+	}
+}
+
+const urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/;
+
+export default class AutoLinker extends Feature {
+	init() {
+		this.editor.document.on( 'change', ( event, type, changes, batch ) => {
+			if ( type != 'insert' ) {
+				return;
+			}
+
+			for ( let value of changes.range.getItems( { singleCharacters: true } ) ) {
+				const walker = new TreeWalker( {
+					direction: 'BACKWARD',
+					startPosition: Position.createAfter( value )
+				} );
+
+				const currentValue = walker.next().value;
+				const text = currentValue.item.text;
+
+				if ( !text ) {
+					return;
+				}
+
+				let matchedUrl = urlRegex.exec( text );
+
+				if ( !matchedUrl ) {
+					return;
+				}
+
+				const doc = this.editor.document;
+				const url = matchedUrl[ 0 ];
+				const offset = _getLastPathPart( currentValue.nextPosition.path ) + matchedUrl.index;
+				const livePos = LivePosition.createFromParentAndOffset( currentValue.item.commonParent, offset );
+
+				doc.enqueueChanges( () => {
+					const urlRange = Range.createFromPositionAndShift( livePos, url.length );
+					batch.setAttr( 'link', url, urlRange );
+				} );
+			}
+		} );
+	}
+}
+
+function _getLastPathPart( path ) {
+	return path[ path.length - 1 ];
+}
+
+ClassicEditor.create( document.querySelector( '#editor' ), {
+	features: [ 'delete', 'enter', 'typing', 'paragraph', 'undo', Link, AutoLinker ],
+	toolbar: [ 'undo', 'redo' ]
+} );

+ 11 - 0
packages/ckeditor5-engine/tests/tickets/475/475.md

@@ -0,0 +1,11 @@
+@bender-ui: collapsed
+@bender-tags: ticket, 475
+
+### Auto-linking manual test [#475](https://github.com/ckeditor/ckeditor5-engine/issues/475)
+
+ - put the caret at the end of the line,
+ - type the link (http://cksource.com).
+
+Expected result: link should appear where you typed it. It should change the `src` property as you type.
+
+Note: this is not a full feature, some other cases may not work.

+ 3 - 1
packages/ckeditor5-engine/tests/view/_utils/createdocumentmock.js

@@ -8,6 +8,7 @@
 'use strict';
 
 import ObservableMixin from '/ckeditor5/utils/observablemixin.js';
+import Selection from '/ckeditor5/engine/view/selection.js';
 
 /**
  * Creates {@link engine.view.Document view Document} mock.
@@ -16,7 +17,8 @@ import ObservableMixin from '/ckeditor5/utils/observablemixin.js';
  */
 export default function createDocumentMock() {
 	const doc = Object.create( ObservableMixin );
-	doc.set( 'focusedEditable', null );
+	doc.set( 'isFocused', false );
+	doc.selection = new Selection();
 
 	return doc;
 }

+ 67 - 12
packages/ckeditor5-engine/tests/view/document/document.js

@@ -10,10 +10,13 @@
 import Document from '/ckeditor5/engine/view/document.js';
 import Observer from '/ckeditor5/engine/view/observer/observer.js';
 import Renderer from '/ckeditor5/engine/view/renderer.js';
-import Writer from '/ckeditor5/engine/view/writer.js';
+import ViewRange from '/ckeditor5/engine/view/range.js';
 import DomConverter from '/ckeditor5/engine/view/domconverter.js';
-
+import testUtils from '/tests/ckeditor5/_utils/utils.js';
 import count from '/ckeditor5/utils/count.js';
+import log from '/ckeditor5/utils/log.js';
+
+testUtils.createSinonSandbox();
 
 describe( 'Document', () => {
 	let ObserverMock, ObserverMockGlobalCount, instantiated, enabled;
@@ -54,9 +57,8 @@ describe( 'Document', () => {
 			expect( count( viewDocument.roots ) ).to.equal( 0 );
 			expect( count( viewDocument._observers ) ).to.equal( 0 );
 			expect( viewDocument ).to.have.property( 'renderer' ).that.is.instanceOf( Renderer );
-			expect( viewDocument ).to.have.property( 'writer' ).that.is.instanceOf( Writer );
 			expect( viewDocument ).to.have.property( 'domConverter' ).that.is.instanceOf( DomConverter );
-			expect( viewDocument ).to.have.property( 'focusedEditable' ).that.is.null;
+			expect( viewDocument ).to.have.property( 'isFocused' ).that.is.false;
 		} );
 	} );
 
@@ -337,18 +339,71 @@ describe( 'Document', () => {
 		} );
 	} );
 
-	describe( 'focusedEditable', () => {
-		it( 'should change renderer.focusedEditable too', () => {
+	describe( 'isFocused', () => {
+		it( 'should change renderer.isFocused too', () => {
 			const viewDocument = new Document();
-			const viewRoot = viewDocument.createRoot( 'div' );
 
-			expect( viewDocument.focusedEditable ).to.equal( null );
-			expect( viewDocument.renderer.focusedEditable ).to.equal( null );
+			expect( viewDocument.isFocused ).to.equal( false );
+			expect( viewDocument.renderer.isFocused ).to.equal( false );
+
+			viewDocument.isFocused = true;
+
+			expect( viewDocument.isFocused ).to.equal( true );
+			expect( viewDocument.renderer.isFocused ).to.equal( true );
+		} );
+	} );
+
+	describe( 'focus', () => {
+		let viewDocument, domEditable, viewEditable;
+
+		beforeEach( () => {
+			viewDocument = new Document();
+			domEditable = document.createElement( 'div' );
+			domEditable.setAttribute( 'contenteditable', 'true' );
+			document.body.appendChild( domEditable );
+			viewEditable = viewDocument.createRoot( domEditable );
+			viewDocument.selection.addRange( ViewRange.createFromParentsAndOffsets( viewEditable, 0, viewEditable, 0 ) );
+		} );
+
+		afterEach( () => {
+			document.body.removeChild( domEditable );
+		} );
+
+		it( 'should focus editable with selection', () => {
+			const converterFocusSpy = testUtils.sinon.spy( viewDocument.domConverter, 'focus' );
+			const renderSpy = testUtils.sinon.spy( viewDocument, 'render' );
+
+			viewDocument.focus();
+
+			expect( converterFocusSpy.calledOnce ).to.be.true;
+			expect( renderSpy.calledOnce ).to.be.true;
+			expect( document.activeElement ).to.equal( domEditable );
+			const domSelection = document.getSelection();
+			expect( domSelection.rangeCount ).to.equal( 1 );
+			const domRange = domSelection.getRangeAt( 0 );
+			expect( domRange.startContainer ).to.equal( domEditable );
+			expect( domRange.startOffset ).to.equal( 0 );
+			expect( domRange.collapsed ).to.be.true;
+		} );
+
+		it( 'should not focus if document is already focused', () => {
+			const converterFocusSpy = testUtils.sinon.spy( viewDocument.domConverter, 'focus' );
+			const renderSpy = testUtils.sinon.spy( viewDocument, 'render' );
+			viewDocument.isFocused = true;
+
+			viewDocument.focus();
+
+			expect( converterFocusSpy.called ).to.be.false;
+			expect( renderSpy.called ).to.be.false;
+		} );
 
-			viewDocument.focusedEditable = viewRoot;
+		it( 'should log warning when no selection', () => {
+			const logSpy = testUtils.sinon.stub( log, 'warn' );
+			viewDocument.selection.removeAllRanges();
 
-			expect( viewDocument.focusedEditable ).to.equal( viewRoot );
-			expect( viewDocument.renderer.focusedEditable ).to.equal( viewRoot );
+			viewDocument.focus();
+			expect( logSpy.calledOnce ).to.be.true;
+			expect( logSpy.args[ 0 ][ 0 ] ).to.match( /^view-focus-no-selection/ );
 		} );
 	} );
 } );

+ 2 - 2
packages/ckeditor5-engine/tests/view/document/jumpoverinlinefiller.js

@@ -24,11 +24,11 @@ describe( 'Document', () => {
 
 		viewDocument.addObserver( KeyObserver );
 
-		const viewRoot = viewDocument.createRoot( document.getElementById( 'editor' ) );
+		viewDocument.createRoot( document.getElementById( 'editor' ) );
 
 		document.getSelection().removeAllRanges();
 
-		viewDocument.focusedEditable = viewRoot;
+		viewDocument.isFocused = true;
 	} );
 
 	describe( 'jump over inline filler hack', () => {

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

@@ -327,5 +327,12 @@ describe( 'DomConverter', () => {
 
 			expect( converter.getCorrespondingDomText( viewText ) ).to.be.null;
 		} );
+
+		it( 'should return null if there is no previous sibling and parent', () => {
+			const domText = document.createTextNode( 'foo' );
+			const viewText = converter.domToView( domText );
+
+			expect( converter.getCorrespondingDomText( viewText ) ).to.be.null;
+		} );
 	} );
 } );

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä