Przeglądaj źródła

Merge pull request #1206 from ckeditor/t/1172-b

Other: Refactoring: Conversion refactoring. Introduced `model.Differ`. Changes now will be converted after all changes in a change block are done. Closes #1172.
Piotr Jasiun 8 lat temu
rodzic
commit
38758eba9f
57 zmienionych plików z 3779 dodań i 2791 usunięć
  1. 1 1
      packages/ckeditor5-engine/src/controller/datacontroller.js
  2. 43 19
      packages/ckeditor5-engine/src/controller/editingcontroller.js
  3. 11 18
      packages/ckeditor5-engine/src/conversion/buildmodelconverter.js
  4. 53 27
      packages/ckeditor5-engine/src/conversion/mapper.js
  5. 217 268
      packages/ckeditor5-engine/src/conversion/model-to-view-converters.js
  6. 171 339
      packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js
  7. 3 0
      packages/ckeditor5-engine/src/dev-utils/enableenginedebug.js
  8. 3 3
      packages/ckeditor5-engine/src/dev-utils/model.js
  9. 867 0
      packages/ckeditor5-engine/src/model/differ.js
  10. 11 6
      packages/ckeditor5-engine/src/model/document.js
  11. 17 8
      packages/ckeditor5-engine/src/model/documentfragment.js
  12. 15 6
      packages/ckeditor5-engine/src/model/element.js
  13. 6 2
      packages/ckeditor5-engine/src/model/operation/attributeoperation.js
  14. 6 1
      packages/ckeditor5-engine/src/model/operation/detachoperation.js
  15. 19 0
      packages/ckeditor5-engine/src/model/operation/insertoperation.js
  16. 6 1
      packages/ckeditor5-engine/src/model/operation/moveoperation.js
  17. 11 2
      packages/ckeditor5-engine/src/model/operation/operation.js
  18. 8 1
      packages/ckeditor5-engine/src/model/operation/reinsertoperation.js
  19. 8 1
      packages/ckeditor5-engine/src/model/operation/removeoperation.js
  20. 9 7
      packages/ckeditor5-engine/src/model/operation/renameoperation.js
  21. 9 1
      packages/ckeditor5-engine/src/model/operation/rootattributeoperation.js
  22. 17 8
      packages/ckeditor5-engine/src/view/documentfragment.js
  23. 1 1
      packages/ckeditor5-engine/src/view/domconverter.js
  24. 17 8
      packages/ckeditor5-engine/src/view/element.js
  25. 3 1
      packages/ckeditor5-engine/src/view/range.js
  26. 7 0
      packages/ckeditor5-engine/src/view/textproxy.js
  27. 4 2
      packages/ckeditor5-engine/src/view/treewalker.js
  28. 41 20
      packages/ckeditor5-engine/src/view/writer.js
  29. 91 98
      packages/ckeditor5-engine/tests/controller/editingcontroller.js
  30. 109 326
      packages/ckeditor5-engine/tests/conversion/advanced-converters.js
  31. 159 166
      packages/ckeditor5-engine/tests/conversion/buildmodelconverter.js
  32. 48 0
      packages/ckeditor5-engine/tests/conversion/mapper.js
  33. 22 21
      packages/ckeditor5-engine/tests/conversion/model-selection-to-view-converters.js
  34. 171 598
      packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js
  35. 177 561
      packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js
  36. 7 12
      packages/ckeditor5-engine/tests/dev-utils/enableenginedebug.js
  37. 1 2
      packages/ckeditor5-engine/tests/dev-utils/model.js
  38. 3 3
      packages/ckeditor5-engine/tests/manual/highlight.html
  39. 964 0
      packages/ckeditor5-engine/tests/model/differ.js
  40. 29 30
      packages/ckeditor5-engine/tests/model/document/document.js
  41. 15 0
      packages/ckeditor5-engine/tests/model/documentfragment.js
  42. 15 0
      packages/ckeditor5-engine/tests/model/element.js
  43. 2 1
      packages/ckeditor5-engine/tests/model/model.js
  44. 77 56
      packages/ckeditor5-engine/tests/model/operation/attributeoperation.js
  45. 10 8
      packages/ckeditor5-engine/tests/model/operation/detachoperation.js
  46. 10 0
      packages/ckeditor5-engine/tests/model/operation/insertoperation.js
  47. 68 78
      packages/ckeditor5-engine/tests/model/operation/moveoperation.js
  48. 29 25
      packages/ckeditor5-engine/tests/model/operation/reinsertoperation.js
  49. 15 13
      packages/ckeditor5-engine/tests/model/operation/removeoperation.js
  50. 21 19
      packages/ckeditor5-engine/tests/model/operation/renameoperation.js
  51. 19 17
      packages/ckeditor5-engine/tests/model/operation/rootattributeoperation.js
  52. 14 0
      packages/ckeditor5-engine/tests/view/documentfragment.js
  53. 14 0
      packages/ckeditor5-engine/tests/view/element.js
  54. 6 0
      packages/ckeditor5-engine/tests/view/textproxy.js
  55. 45 6
      packages/ckeditor5-engine/tests/view/treewalker.js
  56. 27 0
      packages/ckeditor5-engine/tests/view/writer/unwrap.js
  57. 27 0
      packages/ckeditor5-engine/tests/view/writer/wrap.js

+ 1 - 1
packages/ckeditor5-engine/src/controller/datacontroller.js

@@ -167,7 +167,7 @@ export default class DataController {
 		const viewDocumentFragment = new ViewDocumentFragment();
 		this.mapper.bindElements( modelElementOrFragment, viewDocumentFragment );
 
-		this.modelToView.convertInsertion( modelRange );
+		this.modelToView.convertInsert( modelRange );
 
 		this.mapper.clearBindings();
 

+ 43 - 19
packages/ckeditor5-engine/src/controller/editingcontroller.js

@@ -7,6 +7,7 @@
  * @module engine/controller/editingcontroller
  */
 
+import ModelDiffer from '../model/differ';
 import ViewDocument from '../view/document';
 import Mapper from '../conversion/mapper';
 import ModelConversionDispatcher from '../conversion/modelconversiondispatcher';
@@ -64,10 +65,9 @@ export default class EditingController {
 		this.mapper = new Mapper();
 
 		/**
-		 * Model to view conversion dispatcher, which converts changes from the model to
-		 * {@link #view editing view}.
+		 * Model to view conversion dispatcher, which converts changes from the model to {@link #view the editing view}.
 		 *
-		 * To attach model to view converter to the editing pipeline you need to add lister to this property:
+		 * To attach model-to-view converter to the editing pipeline you need to add a listener to this dispatcher:
 		 *
 		 *		editing.modelToView( 'insert:$element', customInsertConverter );
 		 *
@@ -83,36 +83,60 @@ export default class EditingController {
 			viewSelection: this.view.selection
 		} );
 
-		// Convert changes in model to view.
-		this.listenTo( this.model.document, 'change', ( evt, type, changes ) => {
-			this.modelToView.convertChange( type, changes );
-		}, { priority: 'low' } );
+		// Model differ object. It's role is to buffer changes done on model and then calculates a diff of those changes.
+		// The diff is then passed to model conversion dispatcher which generates proper events and kicks-off conversion.
+		const modelDiffer = new ModelDiffer();
 
-		// Convert model selection to view.
-		this.listenTo( this.model.document, 'changesDone', () => {
-			const selection = this.model.document.selection;
+		// Before an operation is applied on model, buffer the change in differ.
+		this.listenTo( this.model, 'applyOperation', ( evt, args ) => {
+			const operation = args[ 0 ];
 
-			this.modelToView.convertSelection( selection );
-			this.view.render();
-		}, { priority: 'low' } );
+			if ( operation.isDocumentOperation ) {
+				modelDiffer.bufferOperation( operation );
+			}
+		}, { priority: 'high' } );
 
-		// Convert model markers changes.
+		// Buffer marker changes.
+		// This is not covered in buffering operations because markers may change outside of them (when they
+		// are modified using `model.document.markers` collection, not through `MarkerOperation`).
 		this.listenTo( this.model.markers, 'add', ( evt, marker ) => {
-			this.modelToView.convertMarker( 'addMarker', marker.name, marker.getRange() );
+			// Whenever a new marker is added, buffer that change.
+			modelDiffer.bufferMarkerChange( marker.name, null, marker.getRange() );
+
+			// Whenever marker changes, buffer that.
+			marker.on( 'change', ( evt, oldRange ) => {
+				modelDiffer.bufferMarkerChange( marker.name, oldRange, marker.getRange() );
+			} );
 		} );
 
 		this.listenTo( this.model.markers, 'remove', ( evt, marker ) => {
-			this.modelToView.convertMarker( 'removeMarker', marker.name, marker.getRange() );
+			// Whenever marker is removed, buffer that change.
+			modelDiffer.bufferMarkerChange( marker.name, marker.getRange(), null );
 		} );
 
-		// Convert view selection to model.
+		// When all changes are done, get the model diff containing all the changes and convert them to view and then render to DOM.
+		this.listenTo( this.model, 'changesDone', () => {
+			// Convert changes stored in `modelDiffer`.
+			this.modelToView.convertChanges( modelDiffer );
+
+			// Reset model diff object. When next operation is applied, new diff will be created.
+			modelDiffer.reset();
+
+			// After the view is ready, convert selection from model to view.
+			this.modelToView.convertSelection( this.model.document.selection );
+
+			// When everything is converted to the view, render it to DOM.
+			this.view.render();
+		}, { priority: 'low' } );
+
+		// Convert selection from view to model.
 		this.listenTo( this.view, 'selectionChange', convertSelectionChange( this.model, this.mapper ) );
 
-		// Attach default content converters.
+		// Attach default model converters.
 		this.modelToView.on( 'insert:$text', insertText(), { priority: 'lowest' } );
 		this.modelToView.on( 'remove', remove(), { priority: 'low' } );
 
-		// Attach default selection converters.
+		// Attach default model selection converters.
 		this.modelToView.on( 'selection', clearAttributes(), { priority: 'low' } );
 		this.modelToView.on( 'selection', clearFakeSelection(), { priority: 'low' } );
 		this.modelToView.on( 'selection', convertRangeSelection(), { priority: 'low' } );

+ 11 - 18
packages/ckeditor5-engine/src/conversion/buildmodelconverter.js

@@ -10,13 +10,12 @@
 import {
 	insertElement,
 	insertUIElement,
-	setAttribute,
-	removeAttribute,
 	removeUIElement,
-	wrapItem,
-	unwrapItem,
+	changeAttribute,
+	wrap,
 	highlightText,
-	highlightElement
+	highlightElement,
+	removeHighlight
 } from './model-to-view-converters';
 
 import { convertSelectionAttribute, convertSelectionMarker } from './model-selection-to-view-converters';
@@ -254,15 +253,13 @@ class ModelConverterBuilder {
 
 				dispatcher.on( 'insert:' + this._from.name, insertElement( element ), { priority } );
 			} else if ( this._from.type == 'attribute' ) {
-				// From model attribute to view element -> wrap and unwrap.
+				// From model attribute to view element -> wrap.
 				element = typeof element == 'string' ? new ViewAttributeElement( element ) : element;
 
-				dispatcher.on( 'addAttribute:' + this._from.key, wrapItem( element ), { priority } );
-				dispatcher.on( 'changeAttribute:' + this._from.key, wrapItem( element ), { priority } );
-				dispatcher.on( 'removeAttribute:' + this._from.key, unwrapItem( element ), { priority } );
-
+				dispatcher.on( 'attribute:' + this._from.key, wrap( element ), { priority } );
 				dispatcher.on( 'selectionAttribute:' + this._from.key, convertSelectionAttribute( element ), { priority } );
-			} else { // From marker to element.
+			} else {
+				// From marker to element.
 				const priority = this._from.priority === null ? 'normal' : this._from.priority;
 
 				element = typeof element == 'string' ? new ViewUIElement( element ) : element;
@@ -326,12 +323,10 @@ class ModelConverterBuilder {
 		}
 
 		for ( const dispatcher of this._dispatchers ) {
-			// Separate converters for converting texts and elements inside marker's range.
 			dispatcher.on( 'addMarker:' + this._from.name, highlightText( highlightDescriptor ), { priority } );
 			dispatcher.on( 'addMarker:' + this._from.name, highlightElement( highlightDescriptor ), { priority } );
 
-			dispatcher.on( 'removeMarker:' + this._from.name, highlightText( highlightDescriptor ), { priority } );
-			dispatcher.on( 'removeMarker:' + this._from.name, highlightElement( highlightDescriptor ), { priority } );
+			dispatcher.on( 'removeMarker:' + this._from.name, removeHighlight( highlightDescriptor ), { priority } );
 
 			dispatcher.on( 'selectionMarker:' + this._from.name, convertSelectionMarker( highlightDescriptor ), { priority } );
 		}
@@ -383,7 +378,7 @@ class ModelConverterBuilder {
 
 		if ( !keyOrCreator ) {
 			// If `keyOrCreator` is not set, we assume default behavior which is 1:1 attribute re-write.
-			// This is also a default behavior for `setAttribute` converter when no attribute creator is passed.
+			// This is also a default behavior for `changeAttribute` converter when no attribute creator is passed.
 			attributeCreator = undefined;
 		} else if ( typeof keyOrCreator == 'string' ) {
 			// `keyOrCreator` is an attribute key.
@@ -407,9 +402,7 @@ class ModelConverterBuilder {
 		for ( const dispatcher of this._dispatchers ) {
 			const options = { priority: this._from.priority || 'normal' };
 
-			dispatcher.on( 'addAttribute:' + this._from.key, setAttribute( attributeCreator ), options );
-			dispatcher.on( 'changeAttribute:' + this._from.key, setAttribute( attributeCreator ), options );
-			dispatcher.on( 'removeAttribute:' + this._from.key, removeAttribute( attributeCreator ), options );
+			dispatcher.on( 'attribute:' + this._from.key, changeAttribute( attributeCreator ), options );
 		}
 	}
 }

+ 53 - 27
packages/ckeditor5-engine/src/conversion/mapper.js

@@ -110,23 +110,43 @@ export default class Mapper {
 	/**
 	 * Unbinds given {@link module:engine/view/element~Element view element} from the map.
 	 *
+	 * **Note:** view-to-model binding will be removed, if it existed. However, corresponding model-to-view binding
+	 * will be removed only if model element is still bound to passed `viewElement`.
+	 *
+	 * This behavior lets for re-binding model element to another view element without fear of losing the new binding
+	 * when the previously bound view element is unbound.
+	 *
 	 * @param {module:engine/view/element~Element} viewElement View element to unbind.
 	 */
 	unbindViewElement( viewElement ) {
 		const modelElement = this.toModelElement( viewElement );
 
-		this._unbindElements( modelElement, viewElement );
+		this._viewToModelMapping.delete( viewElement );
+
+		if ( this._modelToViewMapping.get( modelElement ) == viewElement ) {
+			this._modelToViewMapping.delete( modelElement );
+		}
 	}
 
 	/**
 	 * Unbinds given {@link module:engine/model/element~Element model element} from the map.
 	 *
+	 * **Note:** model-to-view binding will be removed, if it existed. However, corresponding view-to-model binding
+	 * will be removed only if view element is still bound to passed `modelElement`.
+	 *
+	 * This behavior lets for re-binding view element to another model element without fear of losing the new binding
+	 * when the previously bound model element is unbound.
+	 *
 	 * @param {module:engine/model/element~Element} modelElement Model element to unbind.
 	 */
 	unbindModelElement( modelElement ) {
 		const viewElement = this.toViewElement( modelElement );
 
-		this._unbindElements( modelElement, viewElement );
+		this._modelToViewMapping.delete( modelElement );
+
+		if ( this._viewToModelMapping.get( viewElement ) == modelElement ) {
+			this._viewToModelMapping.delete( viewElement );
+		}
 	}
 
 	/**
@@ -202,12 +222,16 @@ export default class Mapper {
 	 *
 	 * @fires modelToViewPosition
 	 * @param {module:engine/model/position~Position} modelPosition Model position.
+	 * @param {Object} [options] Additional options for position mapping process.
+	 * @param {Boolean} [options.isPhantom=false] Should be set to `true` if the model position to map is pointing to a place
+	 * in model tree which no longer exists. For example, it could be an end of a removed model range.
 	 * @returns {module:engine/view/position~Position} Corresponding view position.
 	 */
-	toViewPosition( modelPosition ) {
+	toViewPosition( modelPosition, options = { isPhantom: false } ) {
 		const data = {
 			modelPosition,
-			mapper: this
+			mapper: this,
+			isPhantom: options.isPhantom
 		};
 
 		this.fire( 'modelToViewPosition', data );
@@ -292,18 +316,6 @@ export default class Mapper {
 		return modelOffset;
 	}
 
-	/**
-	 * Removes binding between given elements.
-	 *
-	 * @private
-	 * @param {module:engine/model/element~Element} modelElement Model element to unbind.
-	 * @param {module:engine/view/element~Element} viewElement View element to unbind.
-	 */
-	_unbindElements( modelElement, viewElement ) {
-		this._viewToModelMapping.delete( viewElement );
-		this._modelToViewMapping.delete( modelElement );
-	}
-
 	/**
 	 * Gets the length of the view element in the model.
 	 *
@@ -460,19 +472,33 @@ export default class Mapper {
 	 *			}
 	 *		} );
 	 *
-	 * **Note:** keep in mind that custom callback provided for this event should use provided `data.modelPosition` only to check
-	 * what is before the position (or position's parent). This is important when model-to-view position mapping is used in
-	 * remove change conversion. Model after the removed position (that is being mapped) does not correspond to view, so it cannot be used:
+	 * **Note:** keep in mind that sometimes a "phantom" model position is being converted. "Phantom" model position is
+	 * a position that points to a non-existing place in model. Such position might still be valid for conversion, though
+	 * (it would point to a correct place in view when converted). One example of such situation is when a range is
+	 * removed from model, there may be a need to map the range's end (which is no longer valid model position). To
+	 * handle such situation, check `data.isPhantom` flag:
+	 *
+	 * 		// Assume that there is "customElement" model element and whenever position is before it, we want to move it
+	 * 		// to the inside of the view element bound to "customElement".
+	 *		mapper.on( 'modelToViewPosition', ( evt, data ) => {
+	 *			if ( data.isPhantom ) {
+	 *				return;
+	 *			}
 	 *
-	 *		// Incorrect:
-	 *		const modelElement = data.modelPosition.nodeAfter;
-	 *		const viewElement = data.mapper.toViewElement( modelElement );
-	 *		// ... Do something with `viewElement` and set `data.viewPosition`.
+	 *			// Below line might crash for phantom position that does not exist in model.
+	 *			const sibling = data.modelPosition.nodeBefore;
+	 *
+	 *			// Check if this is the element we are interested in.
+	 *			if ( !sibling.is( 'customElement' ) ) {
+	 *				return;
+	 *			}
 	 *
-	 *		// Correct:
-	 *		const prevModelElement = data.modelPosition.nodeBefore;
-	 *		const prevViewElement = data.mapper.toViewElement( prevModelElement );
-	 *		// ... Use `prevViewElement` to find correct `data.viewPosition`.
+	 *			const viewElement = data.mapper.toViewElement( sibling );
+	 *
+	 *			data.viewPosition = new ViewPosition( sibling, 0 );
+	 *
+	 *			evt.stop();
+	 *		} );
 	 *
 	 * **Note:** default mapping callback is provided with `low` priority setting and does not cancel the event, so it is possible to
 	 * attach a custom callback after default callback and also use `data.viewPosition` calculated by default callback

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

@@ -3,17 +3,16 @@
  * For licensing, see LICENSE.md.
  */
 
+import ModelRange from '../model/range';
+
 import ViewElement from '../view/element';
 import ViewAttributeElement from '../view/attributeelement';
 import ViewText from '../view/text';
 import ViewRange from '../view/range';
-import ViewPosition from '../view/position';
-import ViewTreeWalker from '../view/treewalker';
 import viewWriter from '../view/writer';
-import ModelRange from '../model/range';
 
 /**
- * Contains {@link module:engine/model/model model} to {@link module:engine/view/view view} converters for
+ * Contains model to view converters for
  * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}.
  *
  * @module engine/conversion/model-to-view-converters
@@ -91,6 +90,34 @@ export function insertText() {
 	};
 }
 
+/**
+ * Function factory, creates a default model-to-view converter for node remove changes.
+ *
+ *		modelDispatcher.on( 'remove', remove() );
+ *
+ * @returns {Function} Remove event converter.
+ */
+export function remove() {
+	return ( evt, data, conversionApi ) => {
+		// Find view range start position by mapping model position at which the remove happened.
+		const viewStart = conversionApi.mapper.toViewPosition( data.position );
+
+		const modelEnd = data.position.getShiftedBy( data.length );
+		const viewEnd = conversionApi.mapper.toViewPosition( modelEnd, { isPhantom: true } );
+
+		const viewRange = new ViewRange( viewStart, viewEnd );
+
+		// Trim the range to remove in case some UI elements are on the view range boundaries.
+		const removed = viewWriter.remove( viewRange.getTrimmed() );
+
+		// After the range is removed, unbind all view elements from the model.
+		// Range inside view document fragment is used to unbind deeply.
+		for ( const child of ViewRange.createIn( removed ).getItems() ) {
+			conversionApi.mapper.unbindViewElement( child );
+		}
+	};
+}
+
 /**
  * Function factory, creates a converter that converts marker adding change to the view ui element.
  * The view ui element that will be added to the view depends on passed parameter. See {@link ~insertElement}.
@@ -108,15 +135,17 @@ export function insertUIElement( elementCreator ) {
 	return ( evt, data, consumable, conversionApi ) => {
 		let viewStartElement, viewEndElement;
 
+		// Create two view elements. One will be inserted at the beginning of marker, one at the end.
+		// If marker is collapsed, only "opening" element will be inserted.
 		if ( elementCreator instanceof ViewElement ) {
 			viewStartElement = elementCreator.clone( true );
 			viewEndElement = elementCreator.clone( true );
 		} else {
 			data.isOpening = true;
-			viewStartElement = elementCreator( data, consumable, conversionApi );
+			viewStartElement = elementCreator( data, conversionApi );
 
 			data.isOpening = false;
-			viewEndElement = elementCreator( data, consumable, conversionApi );
+			viewEndElement = elementCreator( data, conversionApi );
 		}
 
 		if ( !viewStartElement || !viewEndElement ) {
@@ -127,7 +156,7 @@ export function insertUIElement( elementCreator ) {
 		const eventName = evt.name;
 
 		// Marker that is collapsed has consumable build differently that non-collapsed one.
-		// For more information see `addMarker` and `removeMarker` events description.
+		// For more information see `addMarker` event description.
 		// If marker's range is collapsed - check if it can be consumed.
 		if ( markerRange.isCollapsed && !consumable.consume( markerRange, eventName ) ) {
 			return;
@@ -142,73 +171,75 @@ export function insertUIElement( elementCreator ) {
 
 		const mapper = conversionApi.mapper;
 
+		// Add "opening" element.
 		viewWriter.insert( mapper.toViewPosition( markerRange.start ), viewStartElement );
 
+		// Add "closing" element only if range is not collapsed.
 		if ( !markerRange.isCollapsed ) {
 			viewWriter.insert( mapper.toViewPosition( markerRange.end ), viewEndElement );
 		}
+
+		evt.stop();
 	};
 }
 
 /**
- * Function factory, creates a converter that converts set/change attribute changes from the model to the view. Attributes
- * from model are converted to the view element attributes in the view. You may provide a custom function to generate a
- * key-value attribute pair to add/change. If not provided, model attributes will be converted to view elements attributes
- * on 1-to-1 basis.
- *
- * **Note:** Provided attribute creator should always return the same `key` for given attribute from the model.
- *
- * The converter automatically consumes corresponding value from consumables list and stops the event (see
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}).
- *
- *		modelDispatcher.on( 'addAttribute:customAttr:myElem', setAttribute( ( data ) => {
- *			// Change attribute key from `customAttr` to `class` in view.
- *			const key = 'class';
- *			let value = data.attributeNewValue;
- *
- *			// Force attribute value to 'empty' if the model element is empty.
- *			if ( data.item.childCount === 0 ) {
- *				value = 'empty';
- *			}
- *
- *			// Return key-value pair.
- *			return { key, value };
- *		} ) );
+ * Function factory, creates a default model-to-view converter for removing {@link module:engine/view/uielement~UIElement ui element}
+ * basing on marker remove change.
  *
- * @param {Function} [attributeCreator] Function returning an object with two properties: `key` and `value`, which
- * represents attribute key and attribute value to be set on a {@link module:engine/view/element~Element view element}.
- * The function is passed all the parameters of the
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addAttribute}
- * or {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:changeAttribute} event.
- * @returns {Function} Set/change attribute converter.
+ * @param {module:engine/view/uielement~UIElement|Function} elementCreator View ui element, or function returning
+ * a view ui element, which will be used as a pattern when look for element to remove at the marker start position.
+ * @returns {Function} Remove ui element converter.
  */
-export function setAttribute( attributeCreator ) {
-	attributeCreator = attributeCreator || ( ( value, key ) => ( { value, key } ) );
+export function removeUIElement( elementCreator ) {
+	return ( evt, data, conversionApi ) => {
+		let viewStartElement, viewEndElement;
 
-	return ( evt, data, consumable, conversionApi ) => {
-		if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
+		// Create two view elements. One will be used to remove "opening element", the other for "closing element".
+		// If marker was collapsed, only "opening" element will be removed.
+		if ( elementCreator instanceof ViewElement ) {
+			viewStartElement = elementCreator.clone( true );
+			viewEndElement = elementCreator.clone( true );
+		} else {
+			data.isOpening = true;
+			viewStartElement = elementCreator( data, conversionApi );
+
+			data.isOpening = false;
+			viewEndElement = elementCreator( data, conversionApi );
+		}
+
+		if ( !viewStartElement || !viewEndElement ) {
 			return;
 		}
 
-		const { key, value } = attributeCreator( data.attributeNewValue, data.attributeKey, data, consumable, conversionApi );
+		const markerRange = data.markerRange;
 
-		conversionApi.mapper.toViewElement( data.item ).setAttribute( key, value );
+		// When removing the ui elements, we map the model range to view twice, because that view range
+		// may change after the first clearing.
+		if ( !markerRange.isCollapsed ) {
+			viewWriter.clear( conversionApi.mapper.toViewRange( markerRange ).getEnlarged(), viewEndElement );
+		}
+
+		// Remove "opening" element.
+		viewWriter.clear( conversionApi.mapper.toViewRange( markerRange ).getEnlarged(), viewStartElement );
+
+		evt.stop();
 	};
 }
 
 /**
- * Function factory, creates a converter that converts remove attribute changes from the model to the view. Removes attributes
- * that were converted to the view element attributes in the view. You may provide a custom function to generate a
- * key-value attribute pair to remove. If not provided, model attributes will be removed from view elements on 1-to-1 basis.
+ * Function factory, creates a converter that converts set/change/remove attribute changes from the model to the view.
  *
- * **Note:** Provided attribute creator should always return the same `key` for given attribute from the model.
+ * Attributes from model are converted to the view element attributes in the view. You may provide a custom function to generate
+ * a key-value attribute pair to add/change/remove. If not provided, model attributes will be converted to view elements
+ * attributes on 1-to-1 basis.
  *
- * **Note:** You can use the same attribute creator as in {@link module:engine/conversion/model-to-view-converters~setAttribute}.
+ * **Note:** Provided attribute creator should always return the same `key` for given attribute from the model.
  *
  * The converter automatically consumes corresponding value from consumables list and stops the event (see
  * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}).
  *
- *		modelDispatcher.on( 'removeAttribute:customAttr:myElem', removeAttribute( ( data ) => {
+ *		modelDispatcher.on( 'attribute:customAttr:myElem', changeAttribute( ( data ) => {
  *			// Change attribute key from `customAttr` to `class` in view.
  *			const key = 'class';
  *			let value = data.attributeNewValue;
@@ -223,31 +254,35 @@ export function setAttribute( attributeCreator ) {
  *		} ) );
  *
  * @param {Function} [attributeCreator] Function returning an object with two properties: `key` and `value`, which
- * represents attribute key and attribute value to be removed from {@link module:engine/view/element~Element view element}.
+ * represents attribute key and attribute value to be set on a {@link module:engine/view/element~Element view element}.
  * The function is passed all the parameters of the
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addAttribute addAttribute event}
- * or {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:changeAttribute changeAttribute event}.
- * @returns {Function} Remove attribute converter.
+ * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:attribute} event.
+ * @returns {Function} Set/change attribute converter.
  */
-export function removeAttribute( attributeCreator ) {
-	attributeCreator = attributeCreator || ( ( value, key ) => ( { key } ) );
+export function changeAttribute( attributeCreator ) {
+	attributeCreator = attributeCreator || ( ( value, key ) => ( { value, key } ) );
 
 	return ( evt, data, consumable, conversionApi ) => {
 		if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
 			return;
 		}
 
-		const { key } = attributeCreator( data.attributeOldValue, data.attributeKey, data, consumable, conversionApi );
+		const { key, value } = attributeCreator( data.attributeNewValue, data.attributeKey, data, consumable, conversionApi );
 
-		conversionApi.mapper.toViewElement( data.item ).removeAttribute( key );
+		if ( data.attributeNewValue !== null ) {
+			conversionApi.mapper.toViewElement( data.item ).setAttribute( key, value );
+		} else {
+			conversionApi.mapper.toViewElement( data.item ).removeAttribute( key );
+		}
 	};
 }
 
 /**
- * Function factory, creates a converter that converts set/change attribute changes from the model to the view. In this case,
- * model attributes are converted to a view element that will be wrapping view nodes which corresponding model nodes had
- * the attribute set. This is useful for attributes like `bold`, which may be set on text nodes in model but are
- * represented as an element in the view:
+ * Function factory, creates a converter that converts set/change/remove attribute changes from the model to the view.
+ *
+ * Attributes from model are converted to a view element that will be wrapping those view nodes that are bound to
+ * model elements having given attribute. This is useful for attributes like `bold`, which may be set on text nodes in model
+ * but are represented as an element in the view:
  *
  *		[paragraph]              MODEL ====> VIEW        <p>
  *			|- a {bold: true}                             |- <b>
@@ -256,7 +291,7 @@ export function removeAttribute( attributeCreator ) {
  *
  * The wrapping node depends on passed parameter. If {@link module:engine/view/element~Element} was passed, it will be cloned and
  * the copy will become the wrapping element. If `Function` is provided, it is passed attribute value and then all the parameters of the
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addAttribute addAttribute event}.
+ * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:attribute attribute event}.
  * It's expected that the function returns a {@link module:engine/view/element~Element}.
  * The result of the function will be the wrapping element.
  * When provided `Function` does not return element, then will be no conversion.
@@ -264,19 +299,26 @@ export function removeAttribute( attributeCreator ) {
  * The converter automatically consumes corresponding value from consumables list, stops the event (see
  * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}).
  *
- *		modelDispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
+ *		modelDispatcher.on( 'attribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
  *
  * @param {module:engine/view/element~Element|Function} elementCreator View element, or function returning a view element, which will
  * be used for wrapping.
  * @returns {Function} Set/change attribute converter.
  */
-export function wrapItem( elementCreator ) {
+export function wrap( elementCreator ) {
 	return ( evt, data, consumable, conversionApi ) => {
-		const viewElement = ( elementCreator instanceof ViewElement ) ?
+		// Recreate current wrapping node. It will be used to unwrap view range if the attribute value has changed
+		// or the attribute was removed.
+		const oldViewElement = ( elementCreator instanceof ViewElement ) ?
+			elementCreator.clone( true ) :
+			elementCreator( data.attributeOldValue, data, consumable, conversionApi );
+
+		// Create node to wrap with.
+		const newViewElement = ( elementCreator instanceof ViewElement ) ?
 			elementCreator.clone( true ) :
 			elementCreator( data.attributeNewValue, data, consumable, conversionApi );
 
-		if ( !viewElement ) {
+		if ( !oldViewElement && !newViewElement ) {
 			return;
 		}
 
@@ -286,208 +328,101 @@ export function wrapItem( elementCreator ) {
 
 		let viewRange = conversionApi.mapper.toViewRange( data.range );
 
-		// If this is a change event (because old value is not empty) and the creator is a function (so
-		// it may create different view elements basing on attribute value) we have to create
-		// 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 );
+		// First, unwrap the range from current wrapper.
+		if ( data.attributeOldValue !== null ) {
 			viewRange = viewWriter.unwrap( viewRange, oldViewElement );
 		}
 
-		viewWriter.wrap( viewRange, viewElement );
-	};
-}
-
-/**
- * Function factory, creates a converter that converts remove attribute changes from the model to the view. It assumes, that
- * attributes from model were converted to elements in the view. This converter will unwrap view nodes from corresponding
- * view element if given attribute was removed.
- *
- * The view element type that will be unwrapped depends on passed parameter.
- * If {@link module:engine/view/element~Element} was passed, it will be used to look for similar element in the view for unwrapping.
- * If `Function` is provided, it is passed all the parameters of the
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addAttribute addAttribute event}.
- * It's expected that the function returns a {@link module:engine/view/element~Element}.
- * The result of the function will be used to look for similar element in the view for unwrapping.
- *
- * The converter automatically consumes corresponding value from consumables list, stops the event (see
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}) and bind model and view elements.
- *
- *		modelDispatcher.on( 'removeAttribute:bold', unwrapItem( new ViewAttributeElement( 'strong' ) ) );
- *
- * @see module:engine/conversion/model-to-view-converters~wrapItem
- * @param {module:engine/view/element~Element|Function} elementCreator View element, or function returning a view element, which will
- * be used for unwrapping.
- * @returns {Function} Remove attribute converter.
- */
-export function unwrapItem( elementCreator ) {
-	return ( evt, data, consumable, conversionApi ) => {
-		const viewElement = ( elementCreator instanceof ViewElement ) ?
-			elementCreator.clone( true ) :
-			elementCreator( data.attributeOldValue, data, consumable, conversionApi );
-
-		if ( !viewElement ) {
-			return;
+		// Then wrap with the new wrapper.
+		if ( data.attributeNewValue !== null ) {
+			viewWriter.wrap( viewRange, newViewElement );
 		}
-
-		if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
-			return;
-		}
-
-		const viewRange = conversionApi.mapper.toViewRange( data.range );
-
-		viewWriter.unwrap( viewRange, viewElement );
 	};
 }
 
 /**
- * Function factory, creates a default model-to-view converter for node remove changes.
+ * Function factory, creates converter that converts text inside marker's range. Converter wraps the text with
+ * {@link module:engine/view/attributeelement~AttributeElement} created from provided descriptor.
+ * See {link module:engine/conversion/model-to-view-converters~createViewElementFromHighlightDescriptor}.
  *
- *		modelDispatcher.on( 'remove', remove() );
+ * If the highlight descriptor will not provide `priority` property, `10` will be used.
  *
- * @returns {Function} Remove event converter.
- */
-export function remove() {
-	return ( evt, data, consumable, conversionApi ) => {
-		if ( !consumable.consume( data.item, 'remove' ) ) {
-			return;
-		}
-
-		// We cannot map non-existing positions from model to view. Since a range was removed
-		// from the model, we cannot recreate that range and map it to view, because
-		// end of that range is incorrect.
-		// Instead we will use `data.sourcePosition` as this is the last correct model position and
-		// it is a position before the removed item. Then, we will calculate view range to remove "manually".
-		let viewPosition = conversionApi.mapper.toViewPosition( data.sourcePosition );
-		let viewRange;
-
-		if ( data.item.is( 'element' ) ) {
-			// Note: in remove conversion we cannot use model-to-view element mapping because `data.item` may be
-			// already mapped to another element (this happens when move change is converted).
-			// In this case however, `viewPosition` is the position before view element that corresponds to removed model element.
-			//
-			// First, fix the position. Traverse the tree forward until the container element is found. The `viewPosition`
-			// may be before a ui element, before attribute element or at the end of text element.
-			viewPosition = viewPosition.getLastMatchingPosition( value => !value.item.is( 'containerElement' ) );
-
-			if ( viewPosition.parent.is( 'text' ) && viewPosition.isAtEnd ) {
-				viewPosition = ViewPosition.createAfter( viewPosition.parent );
-			}
-
-			viewRange = ViewRange.createOn( viewPosition.nodeAfter );
-		} else {
-			// If removed item is a text node, we need to traverse view tree to find the view range to remove.
-			// Range to remove will start `viewPosition` and should contain amount of characters equal to the amount of removed characters.
-			const viewRangeEnd = _shiftViewPositionByCharacters( viewPosition, data.item.offsetSize );
-			viewRange = new ViewRange( viewPosition, viewRangeEnd );
-		}
-
-		// Trim the range to remove in case some UI elements are on the view range boundaries.
-		viewWriter.remove( viewRange.getTrimmed() );
-
-		// Unbind this element only if it was moved to graveyard.
-		// The dispatcher#remove event will also be fired if the element was moved to another place (remove+insert are fired).
-		// Let's say that <b> is moved before <a>. The view will be changed like this:
-		//
-		// 1) start:    <a></a><b></b>
-		// 2) insert:   <b (new)></b><a></a><b></b>
-		// 3) remove:   <b (new)></b><a></a>
-		//
-		// If we'll unbind the <b> element in step 3 we'll also lose binding of the <b (new)> element in the view,
-		// because unbindModelElement() cancels both bindings – (model <b> => view <b (new)>) and (view <b (new)> => model <b>).
-		// We can't lose any of these.
-		//
-		// See #847.
-		if ( data.item.root.rootName == '$graveyard' ) {
-			conversionApi.mapper.unbindModelElement( data.item );
-		}
-	};
-}
-
-/**
- * Function factory, creates converter that converts all texts inside marker's range. Converter wraps each text with
- * {@link module:engine/view/attributeelement~AttributeElement} created from provided descriptor.
- * See {link module:engine/conversion/model-to-view-converters~highlightDescriptorToAttributeElement}.
+ * If the highlight descriptor will not provide `id` property, name of the marker will be used.
  *
  * @param {module:engine/conversion/model-to-view-converters~HighlightDescriptor|Function} highlightDescriptor
  * @return {Function}
  */
 export function highlightText( highlightDescriptor ) {
 	return ( evt, data, consumable, conversionApi ) => {
-		const descriptor = typeof highlightDescriptor == 'function' ?
-			highlightDescriptor( data, consumable, conversionApi ) :
-			highlightDescriptor;
+		if ( data.markerRange.isCollapsed ) {
+			return;
+		}
 
 		const modelItem = data.item;
 
-		if ( !descriptor || data.markerRange.isCollapsed || !modelItem.is( 'textProxy' ) ) {
+		if ( !modelItem.is( 'textProxy' ) ) {
 			return;
 		}
 
-		if ( !consumable.consume( modelItem, evt.name ) ) {
+		const descriptor = _prepareDescriptor( highlightDescriptor, data, conversionApi );
+
+		if ( !descriptor ) {
 			return;
 		}
 
-		if ( !descriptor.id ) {
-			descriptor.id = data.markerName;
+		if ( !consumable.consume( modelItem, evt.name ) ) {
+			return;
 		}
 
 		const viewElement = createViewElementFromHighlightDescriptor( descriptor );
 		const viewRange = conversionApi.mapper.toViewRange( data.range );
 
-		if ( evt.name.split( ':' )[ 0 ] == 'addMarker' ) {
-			viewWriter.wrap( viewRange, viewElement );
-		} else {
-			viewWriter.unwrap( viewRange, viewElement );
-		}
+		viewWriter.wrap( viewRange, viewElement );
 	};
 }
 
 /**
- * Converter function factory. Creates a function which applies the marker's highlight to all elements inside a marker's range.
- * The converter checks if an element has the addHighlight and removeHighlight functions stored as
- * {@link module:engine/view/element~Element#setCustomProperty custom properties} and if so use them to apply the highlight.
+ * Converter function factory. Creates a function which applies the marker's highlight to an element inside the marker's range.
+ *
+ * The converter checks if an element has `addHighlight` function stored as
+ * {@link module:engine/view/element~Element#setCustomProperty custom property} and, if so, uses it to apply the highlight.
  * In such case converter will consume all element's children, assuming that they were handled by element itself.
- * If the highlight descriptor will not provide priority, priority `10` will be used as default, to be compliant with
- * {@link module:engine/conversion/model-to-view-converters~highlightText} method which uses default priority of
- * {@link module:engine/view/attributeelement~AttributeElement}.
+ *
+ * When `addHighlight` custom property is not present, element is not converted in any special way.
+ * This means that converters will proceed to convert element's child nodes.
+ *
+ * If the highlight descriptor will not provide `priority` property, `10` will be used.
  *
  * If the highlight descriptor will not provide `id` property, name of the marker will be used.
- * When `addHighlight` and `removeHighlight` custom properties are not present, element is not converted
- * in any special way. This means that converters will proceed to convert element's child nodes.
  *
  * @param {module:engine/conversion/model-to-view-converters~HighlightDescriptor|Function} highlightDescriptor
  * @return {Function}
  */
 export function highlightElement( highlightDescriptor ) {
 	return ( evt, data, consumable, conversionApi ) => {
-		const descriptor = typeof highlightDescriptor == 'function' ?
-			highlightDescriptor( data, consumable, conversionApi ) :
-			highlightDescriptor;
+		if ( data.markerRange.isCollapsed ) {
+			return;
+		}
 
 		const modelItem = data.item;
 
-		if ( !descriptor || data.markerRange.isCollapsed || !modelItem.is( 'element' ) ) {
+		if ( !modelItem.is( 'element' ) ) {
 			return;
 		}
 
-		if ( !consumable.test( data.item, evt.name ) ) {
-			return;
-		}
+		const descriptor = _prepareDescriptor( highlightDescriptor, data, conversionApi );
 
-		if ( !descriptor.priority ) {
-			descriptor.priority = 10;
+		if ( !descriptor ) {
+			return;
 		}
 
-		if ( !descriptor.id ) {
-			descriptor.id = data.markerName;
+		if ( !consumable.test( modelItem, evt.name ) ) {
+			return;
 		}
 
 		const viewElement = conversionApi.mapper.toViewElement( modelItem );
-		const addMarker = evt.name.split( ':' )[ 0 ] == 'addMarker';
-		const highlightHandlingMethod = addMarker ? 'addHighlight' : 'removeHighlight';
 
-		if ( viewElement && viewElement.getCustomProperty( highlightHandlingMethod ) ) {
+		if ( viewElement && viewElement.getCustomProperty( 'addHighlight' ) ) {
 			// Consume element itself.
 			consumable.consume( data.item, evt.name );
 
@@ -496,65 +431,99 @@ export function highlightElement( highlightDescriptor ) {
 				consumable.consume( value.item, evt.name );
 			}
 
-			viewElement.getCustomProperty( highlightHandlingMethod )( viewElement, addMarker ? descriptor : descriptor.id );
+			viewElement.getCustomProperty( 'addHighlight' )( viewElement, descriptor );
 		}
 	};
 }
 
 /**
- * Function factory, creates a default model-to-view converter for removing {@link module:engine/view/uielement~UIElement ui element}
- * basing on marker remove change.
+ * Function factory, creates a converter that converts model marker remove to the view.
  *
- * @param {module:engine/view/uielement~UIElement|Function} elementCreator View ui element, or function returning
- * a view ui element, which will be used as a pattern when look for element to remove at the marker start position.
- * @returns {Function} Remove ui element converter.
+ * Both text nodes and elements are handled by this converter by they are handled a bit differently.
+ *
+ * Text nodes are unwrapped using {@link module:engine/view/attributeelement~AttributeElement} created from provided
+ * highlight descriptor. See {link module:engine/conversion/model-to-view-converters~highlightDescriptorToAttributeElement}.
+ *
+ * For elements, the converter checks if an element has `removeHighlight` function stored as
+ * {@link module:engine/view/element~Element#setCustomProperty custom property}. If so, it uses it to remove the highlight.
+ * In such case, children of that element will not be converted.
+ *
+ * When `removeHighlight` is not present, element is not converted in any special way.
+ * Instead converter will proceed to convert element's child nodes.
+ *
+ * If the highlight descriptor will not provide `priority` property, `10` will be used.
+ *
+ * If the highlight descriptor will not provide `id` property, name of the marker will be used.
+ *
+ * @param {module:engine/conversion/model-to-view-converters~HighlightDescriptor|Function} highlightDescriptor
+ * @return {Function}
  */
-export function removeUIElement( elementCreator ) {
-	return ( evt, data, consumable, conversionApi ) => {
-		let viewStartElement, viewEndElement;
-
-		if ( elementCreator instanceof ViewElement ) {
-			viewStartElement = elementCreator.clone( true );
-			viewEndElement = elementCreator.clone( true );
-		} else {
-			data.isOpening = true;
-			viewStartElement = elementCreator( data, consumable, conversionApi );
-
-			data.isOpening = false;
-			viewEndElement = elementCreator( data, consumable, conversionApi );
-		}
-
-		if ( !viewStartElement || !viewEndElement ) {
+export function removeHighlight( highlightDescriptor ) {
+	return ( evt, data, conversionApi ) => {
+		// This conversion makes sense only for non-collapsed range.
+		if ( data.markerRange.isCollapsed ) {
 			return;
 		}
 
-		const markerRange = data.markerRange;
-		const eventName = evt.name;
+		const descriptor = _prepareDescriptor( highlightDescriptor, data, conversionApi );
 
-		// If marker's range is collapsed - check if it can be consumed.
-		if ( markerRange.isCollapsed && !consumable.consume( markerRange, eventName ) ) {
+		if ( !descriptor ) {
 			return;
 		}
 
-		// Check if all items in the range can be consumed, and consume them.
-		for ( const value of markerRange ) {
-			if ( !consumable.consume( value.item, eventName ) ) {
-				return;
+		const viewRange = conversionApi.mapper.toViewRange( data.markerRange );
+
+		// Retrieve all items in the affected range. We will process them and remove highlight from them appropriately.
+		const items = new Set( viewRange.getItems() );
+
+		// First, iterate through all items and remove highlight from those container elements that have custom highlight handling.
+		for ( const item of items ) {
+			if ( item.is( 'containerElement' ) && item.getCustomProperty( 'removeHighlight' ) ) {
+				item.getCustomProperty( 'removeHighlight' )( item, descriptor.id );
+
+				// If container element had custom handling, remove all it's children from further processing.
+				for ( const descendant of ViewRange.createIn( item ) ) {
+					items.delete( descendant );
+				}
 			}
 		}
 
-		const viewRange = conversionApi.mapper.toViewRange( markerRange );
-
-		// First remove closing element.
-		viewWriter.clear( viewRange.getEnlarged(), viewEndElement );
+		// Then, iterate through all other items. Look for text nodes and unwrap them. Start from the end
+		// to prevent errors when view structure changes when unwrapping (and, for example, some attributes are merged).
+		const viewHighlightElement = createViewElementFromHighlightDescriptor( descriptor );
 
-		// If closing and opening elements are not the same then remove opening element.
-		if ( !viewStartElement.isSimilar( viewEndElement ) ) {
-			viewWriter.clear( viewRange.getEnlarged(), viewStartElement );
+		for ( const item of Array.from( items ).reverse() ) {
+			if ( item.is( 'textProxy' ) ) {
+				viewWriter.unwrap( ViewRange.createOn( item ), viewHighlightElement );
+			}
 		}
 	};
 }
 
+// Helper function for `highlight`. Prepares the actual descriptor object using value passed to the converter.
+function _prepareDescriptor( highlightDescriptor, data, conversionApi ) {
+	// If passed descriptor is a creator function, call it. If not, just use passed value.
+	const descriptor = typeof highlightDescriptor == 'function' ?
+		highlightDescriptor( data, conversionApi ) :
+		highlightDescriptor;
+
+	if ( !descriptor ) {
+		return null;
+	}
+
+	// Apply default descriptor priority.
+	if ( !descriptor.priority ) {
+		descriptor.priority = 10;
+	}
+
+	// Default descriptor id is marker name.
+	if ( !descriptor.id ) {
+		descriptor.id = data.markerName;
+	}
+
+	return descriptor;
+}
+
 /**
  * Returns the consumable type that is to be consumed in an event, basing on that event name.
  *
@@ -567,26 +536,6 @@ export function eventNameToConsumableType( evtName ) {
 	return parts[ 0 ] + ':' + parts[ 1 ];
 }
 
-// Helper function that shifts given view `position` in a way that returned position is after `howMany` characters compared
-// to the original `position`.
-// Because in view there might be view ui elements splitting text nodes, we cannot simply use `ViewPosition#getShiftedBy()`.
-function _shiftViewPositionByCharacters( position, howMany ) {
-	// Create a walker that will walk the view tree starting from given position and walking characters one-by-one.
-	const walker = new ViewTreeWalker( { startPosition: position, singleCharacters: true } );
-	// We will count visited characters and return the position after `howMany` characters.
-	let charactersFound = 0;
-
-	for ( const value of walker ) {
-		if ( value.type == 'text' ) {
-			charactersFound++;
-
-			if ( charactersFound == howMany ) {
-				return walker.position;
-			}
-		}
-	}
-}
-
 /**
  * Creates `span` {@link module:engine/view/attributeelement~AttributeElement view attribute element} from information
  * provided by {@link module:engine/conversion/model-to-view-converters~HighlightDescriptor} object. If priority

+ 171 - 339
packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js

@@ -9,57 +9,72 @@
 
 import Consumable from './modelconsumable';
 import Range from '../model/range';
-import Position from '../model/position';
-import DocumentFragment from '../model/documentfragment';
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import extend from '@ckeditor/ckeditor5-utils/src/lib/lodash/extend';
 
 /**
- * `ModelConversionDispatcher` is a central point of {@link module:engine/model/model model} conversion, which is
- * a process of reacting to changes in the model and reflecting them by listeners that listen to those changes.
- * In default application, {@link module:engine/model/model model} is converted to {@link module:engine/view/view view}. This means
- * that changes in the model are reflected by changing the view (i.e. adding view nodes or changing attributes on view elements).
+ * `ModelConversionDispatcher` is a central point of model conversion, which is a process of reacting to changes
+ * in the model and firing a set of events. Callbacks listening to those events are called converters. Those
+ * converters role is to convert the model changes to changes in view (for example, adding view nodes or
+ * changing attributes on view elements).
  *
- * During conversion process, `ModelConversionDispatcher` fires data-manipulation events, basing on state of the model and prepares
- * data for those events. It is important to note that the events are connected with "change actions" like "inserting"
- * or "removing" so one might say that we are converting "changes". This is in contrary to view to model conversion,
- * where we convert view nodes (the structure, not "changes" to the view). Note, that because changes are converted
- * and not the structure itself, there is a need to have a mapping between model and the structure on which changes are
- * reflected. To map elements during model to view conversion use {@link module:engine/conversion/mapper~Mapper}.
+ * During conversion process, `ModelConversionDispatcher` fires events, basing on state of the model and prepares
+ * data for those events. It is important to understand that those events are connected with changes done on model,
+ * for example: "node has been inserted" or "attribute has changed". This is in contrary to view to model conversion,
+ * where we convert view state (view nodes) to a model tree.
  *
- * The main use for this class is to listen to {@link module:engine/model/document~Document#event:change Document change event}, process it
- * and then fire specific events telling what exactly has changed. For those events, `ModelConversionDispatcher`
- * creates {@link module:engine/conversion/modelconsumable~ModelConsumable list of consumable values} that should be handled by event
- * callbacks. Those events are listened to by model-to-view converters which convert changes done in the
- * {@link module:engine/model/model model} to changes in the {@link module:engine/view/view view}. `ModelConversionController` also checks
- * the current state of consumables, so it won't fire events for parts of model that were already consumed. This is
- * especially important in callbacks that consume multiple values. See {@link module:engine/conversion/modelconsumable~ModelConsumable}
- * for an example of such callback.
+ * The events are prepared basing on a diff created by {@link module:engine/model/differ~Differ Differ}, which buffers them
+ * and then passes to `ModelConversionDispatcher` as a diff between old model state and new model state.
  *
- * Although the primary usage for this class is the model-to-view conversion, `ModelConversionDispatcher` can be used
- * to build custom data processing pipelines that converts model to anything that is needed. Existing model structure can
- * be used to generate events (listening to {@link module:engine/model/document~Document#event:change Document change event} is not
- * required)
- * and custom callbacks can be added to the events (these does not have to be limited to changes in the view).
+ * Note, that because changes are converted there is a need to have a mapping between model structure and view structure.
+ * To map positions and elements during model to view conversion use {@link module:engine/conversion/mapper~Mapper}.
  *
- * When providing your own event listeners for `ModelConversionDispatcher` keep in mind that any callback that had
- * {@link module:engine/conversion/modelconsumable~ModelConsumable#consume consumed} a value from consumable (and did some changes, i.e. to
- * the view) should also stop the event. This is because whenever a callback is fired it is assumed that there is something
- * to be consumed. Thanks to that approach, you do not have to test whether there is anything to consume at the beginning
- * of your listener callback.
+ * `ModelConversionDispatcher` fires following events for model tree changes:
+ * * {@link #event:insert insert} if a range of nodes has been inserted to the model tree,
+ * * {@link #event:remove remove} if a range of nodes has been removed from the model tree,
+ * * {@link #event:attribute attribute} if attribute has been added, changed or removed from a model node.
  *
- * Example of providing a converter for `ModelConversionDispatcher`:
+ * For {@link #event:insert insert} and {@link #event:attribute attribute}, `ModelConversionDispatcher` generates
+ * {@link module:engine/conversion/modelconsumable~ModelConsumable consumables}. These are used to have a control
+ * over which changes has been already consumed. It is useful when some converters overwrite other or converts multiple
+ * changes (for example converts insertion of an element and also converts that element's attributes during insertion).
+ *
+ * Additionally, `ModelConversionDispatcher` fires events for {@link module:engine/model/markerscollection~Marker marker} changes:
+ * * {@link #event:addMarker} if a marker has been added,
+ * * {@link #event:removeMarker} if a marker has been removed.
+ *
+ * Note, that changing a marker is done through removing the marker from the old range, and adding on the new range,
+ * so both those events are fired.
+ *
+ * Finally, `ModelConversionDispatcher` also handles firing events for {@link module:engine/model/selection model selection}
+ * conversion:
+ * * {@link #event:selection} which converts selection from model to view,
+ * * {@link #event:selectionAttribute} which is fired for every selection attribute,
+ * * {@link #event:selectionMarker} which is fired for every marker which contains selection.
+ *
+ * Unlike model tree and markers, events for selection are not fired for changes but for selection state.
+ *
+ * When providing custom listeners for `ModelConversionDispatcher` remember to check whether given change has not been
+ * {@link module:engine/conversion/modelconsumable~ModelConsumable#consume consumed} yet.
+ *
+ * When providing custom listeners for `ModelConversionDispatcher` keep in mind that any callback that had
+ * {@link module:engine/conversion/modelconsumable~ModelConsumable#consume consumed} a value from a consumable and
+ * converted the change should also stop the event (for efficiency purposes).
+ *
+ * Example of a custom converter for `ModelConversionDispatcher`:
  *
  *		// We will convert inserting "paragraph" model element into the model.
  *		modelDispatcher.on( 'insert:paragraph', ( evt, data, consumable, conversionApi ) => {
- *			// Remember to consume the part of consumable.
- *			consumable.consume( data.item, 'insert' );
+ *			// Remember to check whether the change has not been consumed yet and consume it.
+ *			if ( consumable.consume( data.item, 'insert' ) ) {
+ *				return;
+ *			}
  *
- *			// Translate position in model to position in the view.
+ *			// Translate position in model to position in view.
  *			const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
  *
- *			// Create a P element (note that this converter is for inserting P elements -> 'insert:paragraph').
+ *			// Create <p> element that will be inserted in view at `viewPosition`.
  *			const viewElement = new ViewElement( 'p' );
  *
  *			// Bind the newly created view element to model element so positions will map accordingly in future.
@@ -68,50 +83,16 @@ import extend from '@ckeditor/ckeditor5-utils/src/lib/lodash/extend';
  *			// Add the newly created view element to the view.
  *			viewWriter.insert( viewPosition, viewElement );
  *
- *			// Remember to stop the event propagation if the data.item was consumed.
+ *			// Remember to stop the event propagation.
  *			evt.stop();
  *		} );
- *
- * Callback that "overrides" other callback:
- *
- *		// Special converter for `linkHref` attribute added on custom `quote` element. Note, that this
- *		// attribute may be the same as the attribute added by other features (link feature in this case).
- *		// It might be even added by that feature! It makes sense that a part of content that is a quote is linked
- *		// to an external source so it makes sense that link feature works on the custom quote element.
- *		// However, we have to make sure that the attributes added by link feature are correctly converted.
- *		// To block default `linkHref` conversion we have to:
- *		// 1) add this callback with higher priority than link feature callback,
- *		// 2) consume `linkHref` attribute add change.
- *		modelConversionDispatcher.on( 'addAttribute:linkHref:quote', ( evt, data, consumable, conversionApi ) => {
- *			consumable.consume( data.item, 'addAttribute:linkHref' );
- *
- *			// Create a button that will represent the `linkHref` attribute.
- *			let viewSourceBtn = new ViewElement( 'a', {
- *				href: data.item.getAttribute( 'linkHref' ),
- *				title: 'source'
- *			} );
- *
- *			// Add a class for the button.
- *			viewSourceBtn.addClass( 'source' );
- *
- *			// Insert the button using writer API.
- *			// If `addAttribute` event is fired by
- *			// `module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#convertInsert` it is fired
- *			// after `data.item` insert conversion was done. If the event is fired due to attribute insertion coming from
- *			// 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.childCount );
- *			viewWriter.insert( position, viewSourceBtn );
- *
- *			evt.stop();
- *		}, { priority: 'high' } );
  */
 export default class ModelConversionDispatcher {
 	/**
-	 * Creates a `ModelConversionDispatcher` that operates using passed API.
+	 * Creates a `ModelConversionDispatcher` instance.
 	 *
 	 * @param {module:engine/model/model~Model} model Data model.
-	 * @param {Object} [conversionApi] Interface passed by dispatcher to the events callbacks.
+	 * @param {Object} [conversionApi] Interface passed by dispatcher to the events calls.
 	 */
 	constructor( model, conversionApi = {} ) {
 		/**
@@ -131,64 +112,50 @@ export default class ModelConversionDispatcher {
 	}
 
 	/**
-	 * Prepares data and fires a proper event.
+	 * Takes {@link module:engine/model/differ~Differ model differ} object with buffered changes and fires conversion basing on it.
 	 *
-	 * The method is crafted to take use of parameters passed in {@link module:engine/model/document~Document#event:change Document change
-	 * event}.
-	 *
-	 * @see module:engine/model/document~Document#event:change
-	 * @fires insert
-	 * @fires remove
-	 * @fires addAttribute
-	 * @fires removeAttribute
-	 * @fires changeAttribute
-	 * @fires addMarker
-	 * @param {String} type Change type.
-	 * @param {Object} data Additional information about the change.
+	 * @param {module:engine/model/differ~Differ} differ Differ object with buffered changes.
 	 */
-	convertChange( type, data ) {
-		// Do not convert changes if they happen in graveyard.
-		// Graveyard is a special root that has no view / no other representation and changes done in it should not be converted.
-		if ( type !== 'remove' && data.range && data.range.root.rootName == '$graveyard' ) {
-			return;
+	convertChanges( differ ) {
+		// First, before changing view structure, remove all markers that has changed.
+		for ( const change of differ.getMarkersToRemove() ) {
+			this.convertMarkerRemove( change.name, change.range );
 		}
 
-		if ( type == 'remove' && data.sourcePosition.root.rootName == '$graveyard' ) {
-			return;
-		}
+		// Convert changes that happened on model tree.
+		for ( const entry of differ.getChanges() ) {
+			// Skip all the changes that happens in graveyard. These are not converted.
+			if ( _isInGraveyard( entry ) ) {
+				continue;
+			}
 
-		if ( type == 'rename' && data.element.root.rootName == '$graveyard' ) {
-			return;
+			if ( entry.type == 'insert' ) {
+				this.convertInsert( Range.createFromPositionAndShift( entry.position, entry.length ) );
+			} else if ( entry.type == 'remove' ) {
+				this.convertRemove( entry.position, entry.length, entry.name );
+			} else {
+				// entry.type == 'attribute'.
+				this.convertAttribute( entry.range, entry.attributeKey, entry.attributeOldValue, entry.attributeNewValue );
+			}
 		}
 
-		// We can safely dispatch changes.
-		if ( type == 'insert' || type == 'reinsert' ) {
-			this.convertInsertion( data.range );
-		} else if ( type == 'move' ) {
-			this.convertMove( data.sourcePosition, data.range );
-		} else if ( type == 'remove' ) {
-			this.convertRemove( data.sourcePosition, data.range );
-		} else if ( type == 'addAttribute' || type == 'removeAttribute' || type == 'changeAttribute' ) {
-			this.convertAttribute( type, data.range, data.key, data.oldValue, data.newValue );
-		} else if ( type == 'rename' ) {
-			this.convertRename( data.element, data.oldName );
+		// After the view is updated, convert markers which has changed.
+		for ( const change of differ.getMarkersToAdd() ) {
+			this.convertMarkerAdd( change.name, change.range );
 		}
 	}
 
 	/**
-	 * Starts conversion of insertion-change on given `range`.
+	 * Starts conversion of a range insertion.
 	 *
-	 * Analyzes given range and fires insertion-connected events with data based on that range.
-	 *
-	 * **Note**: This method will fire separate events for node insertion and attributes insertion. All
-	 * attributes that are set on inserted nodes are treated like they were added just after node insertion.
+	 * For each node in the range, {@link #event:insert insert event is fired}. For each attribute on each node,
+	 * {@link #event:attribute attribute event is fired}.
 	 *
 	 * @fires insert
-	 * @fires addAttribute
-	 * @fires addMarker
+	 * @fires attribute
 	 * @param {module:engine/model/range~Range} range Inserted range.
 	 */
-	convertInsertion( range ) {
+	convertInsert( range ) {
 		// Create a list of things that can be consumed, consisting of nodes and their attributes.
 		const consumable = this._createInsertConsumable( range );
 
@@ -211,91 +178,36 @@ export default class ModelConversionDispatcher {
 				data.attributeOldValue = null;
 				data.attributeNewValue = item.getAttribute( key );
 
-				this._testAndFire( `addAttribute:${ key }`, data, consumable );
-			}
-		}
-
-		for ( const marker of this._model.markers ) {
-			const markerRange = marker.getRange();
-			const intersection = markerRange.getIntersection( range );
-
-			// Check if inserted content is inserted into a marker.
-			if ( intersection && shouldMarkerChangeBeConverted( range.start, marker, this.conversionApi.mapper ) ) {
-				this.convertMarker( 'addMarker', marker.name, intersection );
+				this._testAndFire( `attribute:${ key }`, data, consumable );
 			}
 		}
 	}
 
 	/**
-	 * Starts conversion of move-change of given `range`, that was moved from given `sourcePosition`.
-	 *
-	 * Fires {@link ~#event:remove remove event} and {@link ~#event:insert insert event} based on passed parameters.
-	 *
-	 * @fires remove
-	 * @fires insert
-	 * @param {module:engine/model/position~Position} sourcePosition The original position from which the range was moved.
-	 * @param {module:engine/model/range~Range} range The range containing the moved content.
-	 */
-	convertMove( sourcePosition, range ) {
-		// Move left – convert insertion first (#847).
-		if ( range.start.isBefore( sourcePosition ) ) {
-			this.convertInsertion( range );
-
-			const sourcePositionAfterInsertion =
-				sourcePosition._getTransformedByInsertion( range.start, range.end.offset - range.start.offset );
-
-			this.convertRemove( sourcePositionAfterInsertion, range );
-		} else {
-			this.convertRemove( sourcePosition, range );
-			this.convertInsertion( range );
-		}
-	}
-
-	/**
-	 * Starts conversion of remove-change of given `range`, that was removed from given `sourcePosition`.
-	 *
-	 * Fires {@link ~#event:remove remove event} with data based on passed values.
+	 * Fires conversion of a single node removal. Fires {@link #event:remove remove event} with provided data.
 	 *
-	 * @fires remove
-	 * @param {module:engine/model/position~Position} sourcePosition Position from where the range has been removed.
-	 * @param {module:engine/model/range~Range} range Removed range (after remove, in
-	 * {@link module:engine/model/document~Document#graveyard graveyard root}).
+	 * @param {module:engine/model/position~Position} position Position from which node was removed.
+	 * @param {Number} length Offset size of removed node.
+	 * @param {String} name Name of removed node.
 	 */
-	convertRemove( sourcePosition, range ) {
-		const consumable = this._createConsumableForRange( range, 'remove' );
-
-		for ( const item of range.getItems( { shallow: true } ) ) {
-			const data = {
-				sourcePosition,
-				item
-			};
-
-			this._testAndFire( 'remove', data, consumable );
-		}
+	convertRemove( position, length, name ) {
+		this.fire( 'remove:' + name, { position, length }, this.conversionApi );
 	}
 
 	/**
-	 * Starts conversion of attribute-change on given `range`.
+	 * Starts conversion of attribute change on given `range`.
 	 *
-	 * Analyzes given attribute change and fires attributes-connected events with data based on passed values.
+	 * For each node in the given `range`, {@link #event:attribute attribute event} is fired with the passed data.
 	 *
-	 * @fires addAttribute
-	 * @fires removeAttribute
-	 * @fires changeAttribute
-	 * @param {String} type Change type. Possible values: `addAttribute`, `removeAttribute`, `changeAttribute`.
+	 * @fires attribute
 	 * @param {module:engine/model/range~Range} range Changed range.
-	 * @param {String} key Attribute key.
-	 * @param {*} oldValue Attribute value before the change or `null` if attribute has not been set.
-	 * @param {*} newValue New attribute value or `null` if attribute has been removed.
+	 * @param {String} key Key of the attribute that has changed.
+	 * @param {*} oldValue Attribute value before the change or `null` if the attribute has not been set before.
+	 * @param {*} newValue New attribute value or `null` if the attribute has been removed.
 	 */
-	convertAttribute( type, range, key, oldValue, newValue ) {
-		if ( oldValue == newValue ) {
-			// Do not convert if the attribute did not change.
-			return;
-		}
-
+	convertAttribute( range, key, oldValue, newValue ) {
 		// Create a list with attributes to consume.
-		const consumable = this._createConsumableForRange( range, type + ':' + key );
+		const consumable = this._createConsumableForRange( range, `attribute:${ key }` );
 
 		// Create a separate attribute event for each node in the range.
 		for ( const value of range ) {
@@ -309,50 +221,17 @@ export default class ModelConversionDispatcher {
 				attributeNewValue: newValue
 			};
 
-			this._testAndFire( `${ type }:${ key }`, data, consumable );
+			this._testAndFire( `attribute:${ key }`, data, consumable );
 		}
 	}
 
 	/**
-	 * Starts conversion of rename-change of given `element` that had given `oldName`.
-	 *
-	 * Fires {@link ~#event:remove remove event} and {@link ~#event:insert insert event} based on passed values.
-	 *
-	 * @fires remove
-	 * @fires insert
-	 * @param {module:engine/model/element~Element} element Renamed element.
-	 * @param {String} oldName Name of the renamed element before it was renamed.
-	 */
-	convertRename( element, oldName ) {
-		if ( element.name == oldName ) {
-			// Do not convert if the name did not change.
-			return;
-		}
-
-		// Create fake element that will be used to fire remove event. The fake element will have the old element name.
-		const fakeElement = element.clone( true );
-		fakeElement.name = oldName;
-
-		// Bind fake element with original view element so the view element will be removed.
-		this.conversionApi.mapper.bindElements(
-			fakeElement,
-			this.conversionApi.mapper.toViewElement( element )
-		);
-
-		// Create fake document fragment so a range can be created on fake element.
-		const fakeDocumentFragment = new DocumentFragment();
-		fakeDocumentFragment.appendChildren( fakeElement );
-
-		this.convertRemove( Position.createBefore( element ), Range.createOn( fakeElement ) );
-		this.convertInsertion( Range.createOn( element ) );
-	}
-
-	/**
-	 * Starts selection conversion.
+	 * Starts model selection conversion.
 	 *
 	 * Fires events for given {@link module:engine/model/selection~Selection selection} to start selection conversion.
 	 *
 	 * @fires selection
+	 * @fires selectionMarker
 	 * @fires selectionAttribute
 	 * @param {module:engine/model/selection~Selection} selection Selection to convert.
 	 */
@@ -395,65 +274,70 @@ export default class ModelConversionDispatcher {
 	}
 
 	/**
-	 * Starts marker conversion.
-	 *
-	 * Fires {@link ~#event:addMarker addMarker} or {@link ~#event:removeMarker removeMarker} events for each item
-	 * in marker's range. If range is collapsed single event is dispatched. See events description for more details.
+	 * Converts added marker. Fires {@link #event:addMarker addMarker} event for each item
+	 * in marker's range. If range is collapsed single event is dispatched. See event description for more details.
 	 *
 	 * @fires addMarker
-	 * @fires removeMarker
-	 * @param {'addMarker'|'removeMarker'} type Change type.
-	 * @param {String} name Marker name.
-	 * @param {module:engine/model/range~Range} range Marker range.
+	 * @param {String} markerName Marker name.
+	 * @param {module:engine/model/range~Range} markerRange Marker range.
 	 */
-	convertMarker( type, name, range ) {
+	convertMarkerAdd( markerName, markerRange ) {
 		// Do not convert if range is in graveyard or not in the document (e.g. in DocumentFragment).
-		if ( !range.root.document || range.root.rootName == '$graveyard' ) {
+		if ( !markerRange.root.document || markerRange.root.rootName == '$graveyard' ) {
 			return;
 		}
 
-		// In markers case, event name == consumable name.
-		const eventName = type + ':' + name;
+		// In markers' case, event name == consumable name.
+		const eventName = 'addMarker:' + markerName;
 
 		// When range is collapsed - fire single event with collapsed range in consumable.
-		if ( range.isCollapsed ) {
+		if ( markerRange.isCollapsed ) {
 			const consumable = new Consumable();
-			consumable.add( range, eventName );
+			consumable.add( markerRange, eventName );
 
 			this.fire( eventName, {
-				markerName: name,
-				markerRange: range
+				markerName,
+				markerRange
 			}, consumable, this.conversionApi );
 
 			return;
 		}
 
 		// Create consumable for each item in range.
-		const consumable = this._createConsumableForRange( range, eventName );
+		const consumable = this._createConsumableForRange( markerRange, eventName );
 
 		// Create separate event for each node in the range.
-		for ( const value of range ) {
-			const item = value.item;
-
+		for ( const item of markerRange.getItems() ) {
 			// Do not fire event for already consumed items.
 			if ( !consumable.test( item, eventName ) ) {
 				continue;
 			}
 
-			const data = {
-				item,
-				range: Range.createFromPositionAndShift( value.previousPosition, value.length ),
-				markerName: name,
-				markerRange: range
-			};
+			const data = { item, range: Range.createOn( item ), markerName, markerRange };
 
 			this.fire( eventName, data, consumable, this.conversionApi );
 		}
 	}
 
 	/**
-	 * Creates {@link module:engine/conversion/modelconsumable~ModelConsumable} with values to consume from given range, assuming that
-	 * given range has just been inserted to the model.
+	 * Fires conversion of marker removal. Fires {@link #event:removeMarker removeMarker} event with provided data.
+	 *
+	 * @fires removeMarker
+	 * @param {String} markerName Marker name.
+	 * @param {module:engine/model/range~Range} markerRange Marker range.
+	 */
+	convertMarkerRemove( markerName, markerRange ) {
+		// Do not convert if range is in graveyard or not in the document (e.g. in DocumentFragment).
+		if ( !markerRange.root.document || markerRange.root.rootName == '$graveyard' ) {
+			return;
+		}
+
+		this.fire( 'removeMarker:' + markerName, { markerName, markerRange }, this.conversionApi );
+	}
+
+	/**
+	 * Creates {@link module:engine/conversion/modelconsumable~ModelConsumable} with values to consume from given range,
+	 * assuming that the range has just been inserted to the model.
 	 *
 	 * @private
 	 * @param {module:engine/model/range~Range} range Inserted range.
@@ -468,7 +352,7 @@ export default class ModelConversionDispatcher {
 			consumable.add( item, 'insert' );
 
 			for ( const key of item.getAttributeKeys() ) {
-				consumable.add( item, 'addAttribute:' + key );
+				consumable.add( item, 'attribute:' + key );
 			}
 		}
 
@@ -476,8 +360,7 @@ export default class ModelConversionDispatcher {
 	}
 
 	/**
-	 * Creates {@link module:engine/conversion/modelconsumable~ModelConsumable} with values of given `type`
-	 * for each item from given `range`.
+	 * Creates {@link module:engine/conversion/modelconsumable~ModelConsumable} with values to consume for given range.
 	 *
 	 * @private
 	 * @param {module:engine/model/range~Range} range Affected range.
@@ -523,10 +406,7 @@ export default class ModelConversionDispatcher {
 	 *
 	 * @private
 	 * @fires insert
-	 * @fires remove
-	 * @fires addAttribute
-	 * @fires removeAttribute
-	 * @fires changeAttribute
+	 * @fires attribute
 	 * @param {String} type Event type.
 	 * @param {Object} data Event data.
 	 * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
@@ -546,8 +426,8 @@ export default class ModelConversionDispatcher {
 	 * Fired for inserted nodes.
 	 *
 	 * `insert` is a namespace for a class of events. Names of actually called events follow this pattern:
-	 * `insert:<name>`. `name` is either `'$text'` when one or more characters has been inserted or
-	 * {@link module:engine/model/element~Element#name name} of inserted element.
+	 * `insert:<name>`. `name` is either `'$text'`, when {@link module:engine/model/text~Text a text node} has been inserted,
+	 * or {@link module:engine/model/element~Element#name name} of inserted element.
 	 *
 	 * This way listeners can either listen to a general `insert` event or specific event (for example `insert:paragraph`).
 	 *
@@ -563,8 +443,8 @@ export default class ModelConversionDispatcher {
 	 * Fired for removed nodes.
 	 *
 	 * `remove` is a namespace for a class of events. Names of actually called events follow this pattern:
-	 * `remove:<name>`. `name` is either `'$text'` when one or more characters has been removed or the
-	 * {@link module:engine/model/element~Element#name name} of removed element.
+	 * `remove:<name>`. `name` is either `'$text'`, when {@link module:engine/model/text~Text a text node} has been removed,
+	 * or the {@link module:engine/model/element~Element#name name} of removed element.
 	 *
 	 * This way listeners can either listen to a general `remove` event or specific event (for example `remove:paragraph`).
 	 *
@@ -577,61 +457,16 @@ export default class ModelConversionDispatcher {
 	 */
 
 	/**
-	 * Fired when attribute has been added on a node.
+	 * Fired when attribute has been added/changed/removed from a node.
 	 *
-	 * `addAttribute` is a namespace for a class of events. Names of actually called events follow this pattern:
-	 * `addAttribute:<attributeKey>:<name>`. `attributeKey` is the key of added attribute. `name` is either `'$text'`
-	 * if attribute was added on one or more characters, or the {@link module:engine/model/element~Element#name name} of
-	 * the element on which attribute was added.
+	 * `attribute` is a namespace for a class of events. Names of actually called events follow this pattern:
+	 * `attribute:<attributeKey>:<name>`. `attributeKey` is the key of added/changed/removed attribute.
+	 * `name` is either `'$text'` if change was on {@link module:engine/model/text~Text a text node},
+	 * or the {@link module:engine/model/element~Element#name name} of element which attribute has changed.
 	 *
-	 * This way listeners can either listen to a general `addAttribute:bold` event or specific event
-	 * (for example `addAttribute:link:image`).
+	 * This way listeners can either listen to a general `attribute:bold` event or specific event (for example `attribute:src:image`).
 	 *
-	 * @event addAttribute
-	 * @param {Object} data Additional information about the change.
-	 * @param {module:engine/model/item~Item} data.item Changed item.
-	 * @param {module:engine/model/range~Range} data.range Range spanning over changed item.
-	 * @param {String} data.attributeKey Attribute key.
-	 * @param {null} data.attributeOldValue Attribute value before the change - always `null`. Kept for the sake of unifying events.
-	 * @param {*} data.attributeNewValue New attribute value.
-	 * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
-	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
-	 */
-
-	/**
-	 * Fired when attribute has been removed from a node.
-	 *
-	 * `removeAttribute` is a namespace for a class of events. Names of actually called events follow this pattern:
-	 * `removeAttribute:<attributeKey>:<name>`. `attributeKey` is the key of removed attribute. `name` is either `'$text'`
-	 * if attribute was removed from one or more characters, or the {@link module:engine/model/element~Element#name name} of
-	 * the element from which attribute was removed.
-	 *
-	 * This way listeners can either listen to a general `removeAttribute:bold` event or specific event
-	 * (for example `removeAttribute:link:image`).
-	 *
-	 * @event removeAttribute
-	 * @param {Object} data Additional information about the change.
-	 * @param {module:engine/model/item~Item} data.item Changed item.
-	 * @param {module:engine/model/range~Range} data.range Range spanning over changed item.
-	 * @param {String} data.attributeKey Attribute key.
-	 * @param {*} data.attributeOldValue Attribute value before it was removed.
-	 * @param {null} data.attributeNewValue New attribute value - always `null`. Kept for the sake of unifying events.
-	 * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
-	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
-	 */
-
-	/**
-	 * Fired when attribute of a node has been changed.
-	 *
-	 * `changeAttribute` is a namespace for a class of events. Names of actually called events follow this pattern:
-	 * `changeAttribute:<attributeKey>:<name>`. `attributeKey` is the key of changed attribute. `name` is either `'$text'`
-	 * if attribute was changed on one or more characters, or the {@link module:engine/model/element~Element#name name} of
-	 * the element on which attribute was changed.
-	 *
-	 * This way listeners can either listen to a general `changeAttribute:link` event or specific event
-	 * (for example `changeAttribute:link:image`).
-	 *
-	 * @event changeAttribute
+	 * @event attribute
 	 * @param {Object} data Additional information about the change.
 	 * @param {module:engine/model/item~Item} data.item Changed item.
 	 * @param {module:engine/model/range~Range} data.range Range spanning over changed item.
@@ -646,7 +481,7 @@ export default class ModelConversionDispatcher {
 	 * Fired for {@link module:engine/model/selection~Selection selection} changes.
 	 *
 	 * @event selection
-	 * @param {module:engine/model/selection~Selection} selection `Selection` instance that is converted.
+	 * @param {module:engine/model/selection~Selection} selection Selection that is converted.
 	 * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
 	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
 	 */
@@ -656,7 +491,7 @@ export default class ModelConversionDispatcher {
 	 *
 	 * `selectionAttribute` is a namespace for a class of events. Names of actually called events follow this pattern:
 	 * `selectionAttribute:<attributeKey>`. `attributeKey` is the key of selection attribute. This way listen can listen to
-	 * certain attribute, i.e. `addAttribute:bold`.
+	 * certain attribute, i.e. `selectionAttribute:bold`.
 	 *
 	 * @event selectionAttribute
 	 * @param {Object} data Additional information about the change.
@@ -669,51 +504,42 @@ export default class ModelConversionDispatcher {
 
 	/**
 	 * Fired when a new marker is added to the model.
-	 * * If marker's range is not collapsed, event is fired separately for each item contained in that range. In this
-	 * situation, consumable contains all items from that range.
-	 * * If marker's range is collapsed, single event is fired. In this situation, consumable contains only the collapsed range.
 	 *
 	 * `addMarker` is a namespace for a class of events. Names of actually called events follow this pattern:
 	 * `addMarker:<markerName>`. By specifying certain marker names, you can make the events even more gradual. For example,
-	 * markers can be named `foo:abc`, `foo:bar`, then it is possible to listen to `addMarker:foo` or `addMarker:foo:abc` and
+	 * if markers are named `foo:abc`, `foo:bar`, then it is possible to listen to `addMarker:foo` or `addMarker:foo:abc` and
 	 * `addMarker:foo:bar` events.
 	 *
+	 * If the marker range is not collapsed:
+	 * * the event is fired for each item in the marker range one by one,
+	 * * consumables object includes each item of the marker range and the consumable value is same as event name.
+	 *
+	 * If the marker range is collapsed:
+	 * * there is only one event,
+	 * * consumables object includes marker range with event name.
+	 *
 	 * @event addMarker
 	 * @param {Object} data Additional information about the change.
-	 * @param {module:engine/model/item~Item} [data.item] Item contained in marker's range. Not present if collapsed range
-	 * is being converted.
-	 * @param {module:engine/model/range~Range} [data.range] Range spanning over item. Not present if collapsed range
-	 * is being converted.
-	 * @param {String} data.markerName Name of the marker.
-	 * @param {module:engine/model/range~Range} data.markerRange Marker's range spanning on all items.
-	 * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume. When non-collapsed
-	 * marker is being converted then consumable contains all items in marker's range. For collapsed markers it contains
-	 * only marker's range to consume.
+	 * @param {module:engine/model/item~Item} data.item Item inside the new marker.
+	 * @param {module:engine/model/range~Range} data.range Range spanning over converted item.
+	 * @param {module:engine/model/range~Range} data.range Marker range.
+	 * @param {String} data.markerName Marker name.
+	 * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
 	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
 	 */
 
 	/**
 	 * Fired when marker is removed from the model.
-	 * * If marker's range is not collapsed, event is fired separately for each item contained in that range. In this
-	 * situation, consumable contains all items from that range.
-	 * * If marker's range is collapsed, single event is fired. In this situation, consumable contains only the collapsed range.
 	 *
 	 * `removeMarker` is a namespace for a class of events. Names of actually called events follow this pattern:
 	 * `removeMarker:<markerName>`. By specifying certain marker names, you can make the events even more gradual. For example,
-	 * markers can be named `foo:abc`, `foo:bar`, then it is possible to listen to `removeMarker:foo` or `removeMarker:foo:abc` and
+	 * if markers are named `foo:abc`, `foo:bar`, then it is possible to listen to `removeMarker:foo` or `removeMarker:foo:abc` and
 	 * `removeMarker:foo:bar` events.
 	 *
 	 * @event removeMarker
 	 * @param {Object} data Additional information about the change.
-	 * @param {module:engine/model/item~Item} [data.item] Item contained in marker's range. Not present if collapsed range
-	 * is being converted.
-	 * @param {module:engine/model/range~Range} [data.range] Range spanning over item. Not present if collapsed range
-	 * is being converted.
-	 * @param {String} data.markerName Name of the marker.
-	 * @param {module:engine/model/range~Range} data.markerRange Whole marker's range.
-	 * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume. When non-collapsed
-	 * marker is being converted then consumable contains all items in marker's range. For collapsed markers it contains
-	 * only marker's range to consume.
+	 * @param {module:engine/model/range~Range} data.range Marker range.
+	 * @param {String} data.markerName Marker name.
 	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
 	 */
 }
@@ -743,3 +569,9 @@ function shouldMarkerChangeBeConverted( modelPosition, marker, mapper ) {
 
 	return !hasCustomHandling;
 }
+
+// Checks whether entry change describes changes that happen in graveyard.
+function _isInGraveyard( entry ) {
+	return ( entry.position && entry.position.root.rootName == '$graveyard' ) ||
+		( entry.range && entry.range.root.rootName == '$graveyard' );
+}

+ 3 - 0
packages/ckeditor5-engine/src/dev-utils/enableenginedebug.js

@@ -667,6 +667,9 @@ class DebugPlugin extends Plugin {
 
 		modelDocument.on( 'change', () => {
 			dumpTrees( modelDocument, modelDocument.version );
+		}, { priority: 'lowest' } );
+
+		modelDocument.on( 'changesDone', () => {
 			dumpTrees( viewDocument, modelDocument.version );
 		}, { priority: 'lowest' } );
 	}

+ 3 - 3
packages/ckeditor5-engine/src/dev-utils/model.js

@@ -33,7 +33,7 @@ import {
 	convertCollapsedSelection,
 	convertSelectionAttribute
 } from '../conversion/model-selection-to-view-converters';
-import { insertText, insertElement, wrapItem } from '../conversion/model-to-view-converters';
+import { insertText, insertElement, wrap } from '../conversion/model-to-view-converters';
 import isPlainObject from '@ckeditor/ckeditor5-utils/src/lib/lodash/isPlainObject';
 
 /**
@@ -198,7 +198,7 @@ export function stringify( node, selectionOrPositionOrRange = null ) {
 	mapper.bindElements( node.root, viewDocumentFragment );
 
 	modelToView.on( 'insert:$text', insertText() );
-	modelToView.on( 'addAttribute', wrapItem( ( value, data ) => {
+	modelToView.on( 'attribute', wrap( ( value, data ) => {
 		if ( data.item.is( 'textProxy' ) ) {
 			return new ViewAttributeElement( 'model-text-with-attributes', { [ data.attributeKey ]: stringifyAttributeValue( value ) } );
 		}
@@ -216,7 +216,7 @@ export function stringify( node, selectionOrPositionOrRange = null ) {
 	} ) );
 
 	// Convert model to view.
-	modelToView.convertInsertion( range );
+	modelToView.convertInsert( range );
 
 	// Convert model selection to view selection.
 	if ( selection ) {

+ 867 - 0
packages/ckeditor5-engine/src/model/differ.js

@@ -0,0 +1,867 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module engine/model/differ
+ */
+
+import Position from './position';
+import Range from './range';
+
+/**
+ * Calculates difference between two model states.
+ *
+ * Receives operations that are to be applied on the model document. Marks parts of the model document tree which
+ * are changed and saves those elements state before the change. Then, it compares saved elements with the
+ * changed elements, after all changes are applied on the model document. Calculates the diff between saved
+ * elements and new ones and returns a changes set.
+ */
+export default class Differ {
+	constructor() {
+		/**
+		 * A map that stores changes that happened in given element.
+		 *
+		 * The keys of the map are references to the model elements.
+		 * The values of the map are arrays with changes that were done on this element.
+		 *
+		 * @private
+		 * @type {Map}
+		 */
+		this._changesInElement = new Map();
+
+		/**
+		 * A map that stores "element's children snapshots". A snapshot is representing children of given element before
+		 * the first change was applied on that element. Snapshot items are objects with two properties: `name`,
+		 * containing element name (or `'$text'` for text node) and `attributes` which is a map of a node's attributes.
+		 *
+		 * @private
+		 * @type {Map}
+		 */
+		this._elementSnapshots = new Map();
+
+		/**
+		 * A map that stores all changed markers.
+		 *
+		 * The keys of the map are marker names.
+		 * The values of the map are objects with properties `oldRange` and `newRange`. Those holds the marker range
+		 * state before and after the change.
+		 *
+		 * @private
+		 * @type {Map}
+		 */
+		this._changedMarkers = new Map();
+
+		/**
+		 * Stores how many changes has been processed. Used to order changes chronologically. It is important
+		 * when changes are sorted.
+		 *
+		 * @private
+		 * @type {Number}
+		 */
+		this._changeCount = 0;
+	}
+
+	/**
+	 * Buffers given operation. Operation has to be buffered before it is executed.
+	 *
+	 * Operation type is checked and it is checked which nodes it will affect. Then those nodes are stored in `Differ`
+	 * in the state before the operation is executed.
+	 *
+	 * @param {module:engine/model/operation/operation~Operation} operation Operation to buffer.
+	 */
+	bufferOperation( operation ) {
+		switch ( operation.type ) {
+			case 'insert':
+				this._markInsert( operation.position.parent, operation.position.offset, operation.nodes.maxOffset );
+
+				break;
+			case 'addAttribute':
+			case 'removeAttribute':
+			case 'changeAttribute':
+				for ( const item of operation.range.getItems() ) {
+					this._markAttribute( item );
+				}
+
+				break;
+			case 'remove':
+			case 'move':
+			case 'reinsert':
+				this._markRemove( operation.sourcePosition.parent, operation.sourcePosition.offset, operation.howMany );
+				this._markInsert( operation.targetPosition.parent, operation.getMovedRangeStart().offset, operation.howMany );
+
+				break;
+			case 'rename':
+				this._markRemove( operation.position.parent, operation.position.offset, 1 );
+				this._markInsert( operation.position.parent, operation.position.offset, 1 );
+
+				break;
+		}
+	}
+
+	/**
+	 * Buffers marker change.
+	 *
+	 * @param {String} markerName Name of marker which changed.
+	 * @param {module:engine/model/range~Range|null} oldRange Marker range before the change or `null` if marker was just created.
+	 * @param {module:engine/model/range~Range|null} newRange Marker range after the change or `null` if marker was removed.
+	 */
+	bufferMarkerChange( markerName, oldRange, newRange ) {
+		const buffered = this._changedMarkers.get( markerName );
+
+		if ( !buffered ) {
+			this._changedMarkers.set( markerName, {
+				oldRange,
+				newRange
+			} );
+		} else {
+			buffered.newRange = newRange;
+
+			if ( buffered.oldRange == null && buffered.newRange == null ) {
+				// The marker is going to be removed (`newRange == null`) but it did not exist before the change set
+				// (`buffered.oldRange == null`). In this case, do not keep the marker in buffer at all.
+				this._changedMarkers.delete( markerName );
+			}
+		}
+	}
+
+	/**
+	 * Returns all markers which should be removed as a result of buffered changes.
+	 *
+	 * @returns {Array.<Object>} Markers to remove. Each array item is an object containing `name` and `range` property.
+	 */
+	getMarkersToRemove() {
+		const result = [];
+
+		for ( const [ name, change ] of this._changedMarkers ) {
+			if ( change.oldRange != null ) {
+				result.push( { name, range: change.oldRange } );
+			}
+		}
+
+		return result;
+	}
+
+	/**
+	 * Returns all markers which should be added as a result of buffered changes.
+	 *
+	 * @returns {Array.<Object>} Markers to add. Each array item is an object containing `name` and `range` property.
+	 */
+	getMarkersToAdd() {
+		const result = [];
+
+		for ( const [ name, change ] of this._changedMarkers ) {
+			if ( change.newRange != null ) {
+				result.push( { name, range: change.newRange } );
+			}
+		}
+
+		return result;
+	}
+
+	/**
+	 * Calculates diff between old model tree state (state before the first buffered operations since the last {@link #reset} call)
+	 * and the new model tree state (actual one). Should be called after all buffered operations are executed.
+	 *
+	 * The diff set is returned as an array of diff items, each describing a change done on model. The items are sorted by
+	 * the position on which the change happened. If a position {@link module:engine/model/position~Position#isBefore is before}
+	 * another one, it will be on an earlier index in the diff set.
+	 *
+	 * @returns {Array.<Object>} Diff between old and new model tree state.
+	 */
+	getChanges() {
+		// Will contain returned results.
+		const diffSet = [];
+
+		// Check all changed elements.
+		for ( const element of this._changesInElement.keys() ) {
+			// Each item in `this._changesInElement` describes changes of the _children_ of that element.
+			// If the element itself has been inserted we should skip all the changes in it because the element will be reconverted.
+			// If the element itself has been removed we should skip all the changes in it because they would be incorrect.
+			if ( this._isInsertedOrRemoved( element ) ) {
+				continue;
+			}
+
+			// Get changes for this element and sort them.
+			const changes = this._changesInElement.get( element ).sort( ( a, b ) => {
+				if ( a.offset === b.offset ) {
+					if ( a.type != b.type ) {
+						// If there are multiple changes at the same position, "remove" change should be first.
+						// If the order is different, for example, we would first add some nodes and then removed them
+						// (instead of the nodes that we should remove).
+						return a.type == 'remove' ? -1 : 1;
+					}
+
+					return 0;
+				}
+
+				return a.offset < b.offset ? -1 : 1;
+			} );
+
+			// Get children of this element before any change was applied on it.
+			const snapshotChildren = this._elementSnapshots.get( element );
+			// Get snapshot of current element's children.
+			const elementChildren = _getChildrenSnapshot( element.getChildren() );
+
+			// Generate actions basing on changes done on element.
+			const actions = _generateActionsFromChanges( snapshotChildren.length, changes );
+
+			let i = 0; // Iterator in `elementChildren` array -- iterates through current children of element.
+			let j = 0; // Iterator in `snapshotChildren` array -- iterates through old children of element.
+
+			// Process every action.
+			for ( const action of actions ) {
+				if ( action === 'i' ) {
+					// Generate diff item for this element and insert it into the diff set.
+					diffSet.push( this._getInsertDiff( element, i, elementChildren[ i ].name ) );
+
+					i++;
+				} else if ( action === 'r' ) {
+					// Generate diff item for this element and insert it into the diff set.
+					diffSet.push( this._getRemoveDiff( element, i, snapshotChildren[ j ].name ) );
+
+					j++;
+				} else if ( action === 'a' ) {
+					// Take attributes from saved and current children.
+					const elementAttributes = elementChildren[ i ].attributes;
+					const snapshotAttributes = snapshotChildren[ j ].attributes;
+					let range;
+
+					if ( elementChildren[ i ].name == '$text' ) {
+						range = Range.createFromParentsAndOffsets( element, i, element, i + 1 );
+					} else {
+						const index = element.offsetToIndex( i );
+						range = Range.createFromParentsAndOffsets( element, i, element.getChild( index ), 0 );
+					}
+
+					// Generate diff items for this change (there might be multiple attributes changed and
+					// there is a single diff for each of them) and insert them into the diff set.
+					diffSet.push( ...this._getAttributesDiff( range, snapshotAttributes, elementAttributes ) );
+
+					i++;
+					j++;
+				} else {
+					// `action` is 'equal'. Child not changed.
+					i++;
+					j++;
+				}
+			}
+		}
+
+		// Then, sort the changes by the position (change at position before other changes is first).
+		diffSet.sort( ( a, b ) => {
+			// If the change is in different root, we don't care much, but we'd like to have all changes in given
+			// root "together" in the array. So let's just sort them by the root name. It does not matter which root
+			// will be processed first.
+			if ( a.position.root != b.position.root ) {
+				return a.position.root.rootName < b.position.root.rootName ? -1 : 1;
+			}
+
+			// If change happens at the same position...
+			if ( a.position.isEqual( b.position ) ) {
+				// Keep chronological order of operations.
+				return a.changeCount < b.changeCount ? -1 : 1;
+			}
+
+			// If positions differ, position "on the left" should be earlier in the result.
+			return a.position.isBefore( b.position ) ? -1 : 1;
+		} );
+
+		// Glue together multiple changes (mostly on text nodes).
+		for ( let i = 1; i < diffSet.length; i++ ) {
+			const prevDiff = diffSet[ i - 1 ];
+			const thisDiff = diffSet[ i ];
+
+			// Glue remove changes if they happen on text on same position.
+			const isConsecutiveTextRemove =
+				prevDiff.type == 'remove' && thisDiff.type == 'remove' &&
+				prevDiff.name == '$text' && thisDiff.name == '$text' &&
+				prevDiff.position.isEqual( thisDiff.position );
+
+			// Glue insert changes if they happen on text on consecutive fragments.
+			const isConsecutiveTextAdd =
+				prevDiff.type == 'insert' && thisDiff.type == 'insert' &&
+				prevDiff.name == '$text' && thisDiff.name == '$text' &&
+				prevDiff.position.parent == thisDiff.position.parent &&
+				prevDiff.position.offset + prevDiff.length == thisDiff.position.offset;
+
+			// Glue attribute changes if they happen on consecutive fragments and have same key, old value and new value.
+			const isConsecutiveAttributeChange =
+				prevDiff.type == 'attribute' && thisDiff.type == 'attribute' &&
+				prevDiff.position.parent == thisDiff.position.parent &&
+				prevDiff.range.isFlat && thisDiff.range.isFlat &&
+				prevDiff.position.offset + prevDiff.length == thisDiff.position.offset &&
+				prevDiff.attributeKey == thisDiff.attributeKey &&
+				prevDiff.attributeOldValue == thisDiff.attributeOldValue &&
+				prevDiff.attributeNewValue == thisDiff.attributeNewValue;
+
+			if ( isConsecutiveTextRemove || isConsecutiveTextAdd || isConsecutiveAttributeChange ) {
+				diffSet[ i - 1 ].length++;
+
+				if ( isConsecutiveAttributeChange ) {
+					diffSet[ i - 1 ].range.end = diffSet[ i - 1 ].range.end.getShiftedBy( 1 );
+				}
+
+				diffSet.splice( i, 1 );
+				i--;
+			}
+		}
+
+		// Remove `changeCount` property from diff items. It is used only for sorting and is internal thing.
+		for ( const item of diffSet ) {
+			delete item.changeCount;
+
+			if ( item.type == 'attribute' ) {
+				delete item.position;
+				delete item.length;
+			}
+		}
+
+		this._changeCount = 0;
+
+		return diffSet;
+	}
+
+	/**
+	 * Resets `Differ`. Removes all buffered changes.
+	 */
+	reset() {
+		this._changesInElement.clear();
+		this._elementSnapshots.clear();
+		this._changedMarkers.clear();
+	}
+
+	/**
+	 * Checks whether given element is inserted or removed or one of its ancestor is inserted or removed. Used to
+	 * filter out sub-changes in elements that are changed itself.
+	 *
+	 * @private
+	 * @param {module:engine/model/element~Element} element Element to check.
+	 * @returns {Boolean}
+	 */
+	_isInsertedOrRemoved( element ) {
+		let parent = element.parent;
+
+		// Check all ancestors of given element.
+		while ( parent ) {
+			// Get the checked element's offset.
+			const offset = element.startOffset;
+
+			if ( this._changesInElement.has( parent ) ) {
+				const changes = this._changesInElement.get( parent );
+
+				// If there were changes in that element's ancestor, check all of them.
+				for ( const change of changes ) {
+					// Skip attribute changes. We are interested only if the element was inserted or removed.
+					if ( change.type == 'attribute' ) {
+						continue;
+					}
+
+					if ( change.offset <= offset && change.offset + change.howMany > offset ) {
+						return true;
+					}
+				}
+			}
+
+			// Move up.
+			parent = parent.parent;
+			element = element.parent;
+		}
+
+		return false;
+	}
+
+	/**
+	 * Saves and handles insert change.
+	 *
+	 * @private
+	 * @param {module:engine/model/element~Element} parent
+	 * @param {Number} offset
+	 * @param {Number} howMany
+	 */
+	_markInsert( parent, offset, howMany ) {
+		const changeItem = { type: 'insert', offset, howMany, count: this._changeCount++ };
+
+		this._markChange( parent, changeItem );
+	}
+
+	/**
+	 * Saves and handles remove change.
+	 *
+	 * @private
+	 * @param {module:engine/model/element~Element} parent
+	 * @param {Number} offset
+	 * @param {Number} howMany
+	 */
+	_markRemove( parent, offset, howMany ) {
+		const changeItem = { type: 'remove', offset, howMany, count: this._changeCount++ };
+
+		this._markChange( parent, changeItem );
+	}
+
+	/**
+	 * Saves and handles attribute change.
+	 *
+	 * @private
+	 * @param {module:engine/model/item~Item} item
+	 */
+	_markAttribute( item ) {
+		const changeItem = { type: 'attribute', offset: item.startOffset, howMany: item.offsetSize, count: this._changeCount++ };
+
+		this._markChange( item.parent, changeItem );
+	}
+
+	/**
+	 * Saves and handles a model change.
+	 *
+	 * @private
+	 * @param {module:engine/model/element~Element} parent
+	 * @param {Object} changeItem
+	 */
+	_markChange( parent, changeItem ) {
+		// First, make a snapshot of this parent's children (it will be made only if it was not made before).
+		this._makeSnapshot( parent );
+
+		// Then, get all changes that already were done on the element (empty array if this is the first change).
+		const changes = this._getChangesForElement( parent );
+
+		// Then, look through all the changes, and transform them or the new change.
+		this._handleChange( changeItem, changes );
+
+		// Add the new change.
+		changes.push( changeItem );
+
+		// Remove incorrect changes. During transformation some change might be, for example, included in another.
+		// In that case, the change will have `howMany` property set to `0` or less. We need to remove those changes.
+		for ( let i = 0; i < changes.length; i++ ) {
+			if ( changes[ i ].howMany < 1 ) {
+				changes.splice( i, 1 );
+
+				i--;
+			}
+		}
+	}
+
+	/**
+	 * Gets an array of changes that were already saved for given element.
+	 *
+	 * @private
+	 * @param {module:engine/model/element~Element} element
+	 * @returns {Array.<Object>}
+	 */
+	_getChangesForElement( element ) {
+		let changes;
+
+		if ( this._changesInElement.has( element ) ) {
+			changes = this._changesInElement.get( element );
+		} else {
+			changes = [];
+
+			this._changesInElement.set( element, changes );
+		}
+
+		return changes;
+	}
+
+	/**
+	 * Saves a children snapshot for given element.
+	 *
+	 * @private
+	 * @param {module:engine/model/element~Element} element
+	 */
+	_makeSnapshot( element ) {
+		if ( !this._elementSnapshots.has( element ) ) {
+			this._elementSnapshots.set( element, _getChildrenSnapshot( element.getChildren() ) );
+		}
+	}
+
+	/**
+	 * For given newly saved change, compares it with a change already done on the element and modifies the incoming
+	 * change and/or the old change.
+	 *
+	 * @private
+	 * @param {Object} inc Incoming (new) change.
+	 * @param {Array.<Object>} changes Array containing all the changes done on that element.
+	 */
+	_handleChange( inc, changes ) {
+		for ( const old of changes ) {
+			const incEnd = inc.offset + inc.howMany;
+			const oldEnd = old.offset + old.howMany;
+
+			if ( inc.type == 'insert' ) {
+				if ( old.type == 'insert' ) {
+					if ( inc.offset <= old.offset ) {
+						old.offset += inc.howMany;
+					} else if ( inc.offset < oldEnd ) {
+						old.howMany += inc.howMany;
+						inc.howMany = 0;
+					}
+				}
+
+				if ( old.type == 'remove' ) {
+					if ( inc.offset < old.offset ) {
+						old.offset += inc.howMany;
+					}
+				}
+
+				if ( old.type == 'attribute' ) {
+					if ( inc.offset <= old.offset ) {
+						old.offset += inc.howMany;
+					} else if ( inc.offset < oldEnd ) {
+						// This case is more complicated, because attribute change has to be split into two.
+						// Example (assume that uppercase and lowercase letters mean different attributes):
+						//
+						// initial state:		abcxyz
+						// attribute change:	aBCXYz
+						// incoming insert:		aBCfooXYz
+						//
+						// Change ranges cannot intersect because each item has to be described exactly (it was either
+						// not changed, inserted, removed, or its attribute was changed). That's why old attribute
+						// change has to be split and both parts has to be handled separately from now on.
+						const howMany = old.howMany;
+
+						old.howMany = inc.offset - old.offset;
+
+						// Add the second part of attribute change to the beginning of processed array so it won't
+						// be processed again in this loop.
+						changes.unshift( {
+							type: 'attribute',
+							offset: incEnd,
+							howMany: howMany - old.howMany,
+							count: this._changeCount++
+						} );
+					}
+				}
+			}
+
+			if ( inc.type == 'remove' ) {
+				if ( old.type == 'insert' ) {
+					if ( incEnd <= old.offset ) {
+						old.offset -= inc.howMany;
+					} else if ( incEnd <= oldEnd ) {
+						if ( inc.offset < old.offset ) {
+							const intersectionLength = incEnd - old.offset;
+
+							old.offset = inc.offset;
+
+							old.howMany -= intersectionLength;
+							inc.howMany -= intersectionLength;
+						} else {
+							old.howMany -= inc.howMany;
+							inc.howMany = 0;
+						}
+					} else {
+						if ( inc.offset <= old.offset ) {
+							inc.howMany = inc.howMany - old.howMany;
+							old.howMany = 0;
+						} else if ( inc.offset < oldEnd ) {
+							const intersectionLength = oldEnd - inc.offset;
+
+							old.howMany -= intersectionLength;
+							inc.howMany -= intersectionLength;
+						}
+					}
+				}
+
+				if ( old.type == 'remove' ) {
+					if ( inc.offset + inc.howMany <= old.offset ) {
+						old.offset -= inc.howMany;
+					} else if ( inc.offset < old.offset ) {
+						old.offset = inc.offset;
+						old.howMany += inc.howMany;
+
+						inc.howMany = 0;
+					}
+				}
+
+				if ( old.type == 'attribute' ) {
+					if ( incEnd <= old.offset ) {
+						old.offset -= inc.howMany;
+					} else if ( inc.offset < old.offset ) {
+						const intersectionLength = incEnd - old.offset;
+
+						old.offset = inc.offset;
+						old.howMany -= intersectionLength;
+					} else if ( inc.offset < oldEnd ) {
+						if ( incEnd <= oldEnd ) {
+							// On first sight in this case we don't need to split attribute operation into two.
+							// However the changes set is later converted to actions (see `_generateActionsFromChanges`).
+							// For that reason, no two changes may intersect.
+							// So we cannot have an attribute change that "contains" remove change.
+							// Attribute change needs to be split.
+							const howMany = old.howMany;
+
+							old.howMany = inc.offset - old.offset;
+
+							const howManyAfter = howMany - old.howMany - inc.howMany;
+
+							// Add the second part of attribute change to the beginning of processed array so it won't
+							// be processed again in this loop.
+							changes.unshift( {
+								type: 'attribute',
+								offset: inc.offset,
+								howMany: howManyAfter,
+								count: this._changeCount++
+							} );
+						} else {
+							old.howMany -= oldEnd - inc.offset;
+						}
+					}
+				}
+			}
+
+			if ( inc.type == 'attribute' ) {
+				if ( old.type == 'insert' ) {
+					if ( inc.offset < old.offset && incEnd > old.offset ) {
+						if ( incEnd > oldEnd ) {
+							// This case is similar to a case described when incoming change was insert and old change was attribute.
+							// See comment above.
+							//
+							// This time incoming change is attribute. We need to split incoming change in this case too.
+							// However this time, the second part of the attribute change needs to be processed further
+							// because there might be other changes that it collides with.
+							const attributePart = {
+								type: 'attribute',
+								offset: oldEnd,
+								howMany: incEnd - oldEnd,
+								count: this._changeCount++
+							};
+
+							this._handleChange( attributePart, changes );
+
+							changes.push( attributePart );
+						}
+
+						inc.howMany = old.offset - inc.offset;
+					} else if ( inc.offset >= old.offset && inc.offset < oldEnd ) {
+						if ( incEnd > oldEnd ) {
+							inc.howMany = incEnd - oldEnd;
+							inc.offset = oldEnd;
+						} else {
+							inc.howMany = 0;
+						}
+					}
+				}
+
+				if ( old.type == 'attribute' ) {
+					if ( inc.offset >= old.offset && incEnd <= oldEnd ) {
+						inc.howMany = 0;
+					}
+				}
+			}
+		}
+	}
+
+	/**
+	 * Returns an object with a single insert change description.
+	 *
+	 * @private
+	 * @param {module:engine/model/element~Element} parent Element in which change happened.
+	 * @param {Number} offset Offset at which change happened.
+	 * @param {String} name Removed element name or `'$text'` for character.
+	 * @returns {Object} Diff item.
+	 */
+	_getInsertDiff( parent, offset, name ) {
+		return {
+			type: 'insert',
+			position: Position.createFromParentAndOffset( parent, offset ),
+			name,
+			length: 1,
+			changeCount: this._changeCount++
+		};
+	}
+
+	/**
+	 * Returns an object with a single remove change description.
+	 *
+	 * @private
+	 * @param {module:engine/model/element~Element} parent Element in which change happened.
+	 * @param {Number} offset Offset at which change happened.
+	 * @param {String} name Removed element name or `'$text'` for character.
+	 * @returns {Object} Diff item.
+	 */
+	_getRemoveDiff( parent, offset, name ) {
+		return {
+			type: 'remove',
+			position: Position.createFromParentAndOffset( parent, offset ),
+			name,
+			length: 1,
+			changeCount: this._changeCount++
+		};
+	}
+
+	/**
+	 * Returns an array of objects that each is a single attribute change description.
+	 *
+	 * @private
+	 * @param {module:engine/model/range~Range} range Range on which change happened.
+	 * @param {Map} oldAttributes Map, map iterator or compatible object that contains attributes before change.
+	 * @param {Map} newAttributes Map, map iterator or compatible object that contains attributes after change.
+	 * @returns {Array.<Object>} Array containing one or more diff items.
+	 */
+	_getAttributesDiff( range, oldAttributes, newAttributes ) {
+		// Results holder.
+		const diffs = [];
+
+		// Clone new attributes as we will be performing changes on this object.
+		newAttributes = new Map( newAttributes );
+
+		// Look through old attributes.
+		for ( const [ key, oldValue ] of oldAttributes ) {
+			// Check what is the new value of the attribute (or if it was removed).
+			const newValue = newAttributes.has( key ) ? newAttributes.get( key ) : null;
+
+			// If values are different (or attribute was removed)...
+			if ( newValue !== oldValue ) {
+				// Add diff item.
+				diffs.push( {
+					type: 'attribute',
+					position: range.start,
+					range: Range.createFromRange( range ),
+					length: 1,
+					attributeKey: key,
+					attributeOldValue: oldValue,
+					attributeNewValue: newValue,
+					changeCount: this._changeCount++
+				} );
+
+				// Prevent returning two diff items for the same change.
+				newAttributes.delete( key );
+			}
+		}
+
+		// Look through new attributes that weren't handled above.
+		for ( const [ key, newValue ] of newAttributes ) {
+			// Each of them is a new attribute. Add diff item.
+			diffs.push( {
+				type: 'attribute',
+				position: range.start,
+				range: Range.createFromRange( range ),
+				length: 1,
+				attributeKey: key,
+				attributeOldValue: null,
+				attributeNewValue: newValue,
+				changeCount: this._changeCount++
+			} );
+		}
+
+		return diffs;
+	}
+}
+
+// Returns an array that is a copy of passed child list with the exception that text nodes are split to one or more
+// objects, each representing one character and attributes set on that character.
+function _getChildrenSnapshot( children ) {
+	const snapshot = [];
+
+	for ( const child of children ) {
+		if ( child.is( 'text' ) ) {
+			for ( let i = 0; i < child.data.length; i++ ) {
+				snapshot.push( {
+					name: '$text',
+					attributes: new Map( child.getAttributes() )
+				} );
+			}
+		} else {
+			snapshot.push( {
+				name: child.name,
+				attributes: new Map( child.getAttributes() )
+			} );
+		}
+	}
+
+	return snapshot;
+}
+
+// Generates array of actions for given changes set.
+// It simulates what `diff` function does.
+// Generated actions are:
+// - 'e' for 'equal' - when item at that position did not change,
+// - 'i' for 'insert' - when item at that position was inserted,
+// - 'r' for 'remove' - when item at that position was removed,
+// - 'a' for 'attribute' - when item at that position has it attributes changed.
+//
+// Example (assume that uppercase letters have bold attribute, compare with function code):
+//
+// children before:	fooBAR
+// children after:	foxybAR
+//
+// changes: type: remove, offset: 1, howMany: 1
+//			type: insert, offset: 2, howMany: 2
+//			type: attribute, offset: 4, howMany: 1
+//
+// expected actions: equal (f), remove (o), equal (o), insert (x), insert (y), attribute (b), equal (A), equal (R)
+//
+// steps taken by th script:
+//
+// 1. change = "type: remove, offset: 1, howMany: 1"; offset = 0; oldChildrenHandled = 0
+//    1.1 between this change and the beginning is one not-changed node, fill with one equal action, one old child has been handled
+//    1.2 this change removes one node, add one remove action
+//    1.3 change last visited `offset` to 1
+//    1.4 since an old child has been removed, one more old child has been handled
+//    1.5 actions at this point are: equal, remove
+//
+// 2. change = "type: insert, offset: 2, howMany: 2"; offset = 1; oldChildrenHandled = 2
+//    2.1 between this change and previous change is one not-changed node, add equal action, another one old children has been handled
+//    2.2 this change inserts two nodes, add two insert actions
+//    2.3 change last visited offset to the end of the inserted range, that is 4
+//    2.4 actions at this point are: equal, remove, equal, insert, insert
+//
+// 3. change = "type: attribute, offset: 4, howMany: 1"; offset = 4, oldChildrenHandled = 3
+//    3.1 between this change and previous change are no not-changed nodes
+//    3.2 this change changes one node, add one attribute action
+//    3.3 change last visited `offset` to the end of change range, that is 5
+//    3.4 since an old child has been changed, one more old child has been handled
+//    3.5 actions at this point are: equal, remove, equal, insert, insert, attribute
+//
+// 4. after loop oldChildrenHandled = 4, oldChildrenLength = 6 (fooBAR is 6 characters)
+//    4.1 fill up with two equal actions
+//
+// The result actions are: equal, remove, equal, insert, insert, attribute, equal, equal.
+function _generateActionsFromChanges( oldChildrenLength, changes ) {
+	const actions = [];
+
+	let offset = 0;
+	let oldChildrenHandled = 0;
+
+	// Go through all buffered changes.
+	for ( const change of changes ) {
+		// First, fill "holes" between changes with "equal" actions.
+		if ( change.offset > offset ) {
+			actions.push( ...'e'.repeat( change.offset - offset ).split( '' ) );
+
+			oldChildrenHandled += change.offset - offset;
+		}
+
+		// Then, fill up actions accordingly to change type.
+		if ( change.type == 'insert' ) {
+			actions.push( ...'i'.repeat( change.howMany ).split( '' ) );
+
+			// The last handled offset is after inserted range.
+			offset = change.offset + change.howMany;
+		} else if ( change.type == 'remove' ) {
+			actions.push( ...'r'.repeat( change.howMany ).split( '' ) );
+
+			// The last handled offset is at the position where the nodes were removed.
+			offset = change.offset;
+			// We removed `howMany` old nodes, update `oldChildrenHandled`.
+			oldChildrenHandled += change.howMany;
+		} else {
+			actions.push( ...'a'.repeat( change.howMany ).split( '' ) );
+
+			// The last handled offset isa at the position after the changed range.
+			offset = change.offset + change.howMany;
+			// We changed `howMany` old nodes, update `oldChildrenHandled`.
+			oldChildrenHandled += change.howMany;
+		}
+	}
+
+	// Fill "equal" actions at the end of actions set. Use `oldChildrenHandled` to see how many children
+	// has not been changed / removed at the end of their parent.
+	if ( oldChildrenHandled < oldChildrenLength ) {
+		actions.push( ...'e'.repeat( oldChildrenLength - oldChildrenHandled ).split( '' ) );
+	}
+
+	return actions;
+}

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

@@ -96,8 +96,10 @@ export default class Document {
 					 * @error document-selection-wrong-position
 					 * @param {module:engine/model/range~Range} range
 					 */
-					throw new CKEditorError( 'document-selection-wrong-position: ' +
-						'Range from document selection starts or ends at incorrect position.', { range } );
+					throw new CKEditorError(
+						'document-selection-wrong-position: Range from document selection starts or ends at incorrect position.',
+						{ range }
+					);
 				}
 			}
 		} );
@@ -117,9 +119,12 @@ export default class Document {
 				 */
 				throw new CKEditorError(
 					'model-document-applyOperation-wrong-version: Only operations with matching versions can be applied.',
-					{ operation } );
+					{ operation }
+				);
 			}
-		}, { priority: 'high' } );
+
+			operation._validate();
+		}, { priority: 'highest' } );
 
 		this.listenTo( model, 'applyOperation', ( evt, args ) => {
 			const operation = args[ 0 ];
@@ -363,7 +368,7 @@ export default class Document {
 	/**
 	 * Fired when document changes by applying an operation.
 	 *
-	 * There are a few types of change:
+	 * There are several types of change:
 	 *
 	 * * 'insert' when nodes are inserted,
 	 * * 'remove' when nodes are removed,
@@ -379,7 +384,7 @@ export default class Document {
 	 * * 'changeRootAttribute' when attribute for root changes.
 	 *
 	 * @event change
-	 * @param {String} type Change type, possible option: 'insert', 'remove', 'reinsert', 'move', 'attribute'.
+	 * @param {String} type Change type.
 	 * @param {Object} data Additional information about the change.
 	 * @param {module:engine/model/range~Range} [data.range] Range in model containing changed nodes. Note that the range state is
 	 * after changes has been done, i.e. for 'remove' the range will be in the {@link #graveyard graveyard root}.

+ 17 - 8
packages/ckeditor5-engine/src/model/documentfragment.js

@@ -10,6 +10,7 @@
 import NodeList from './nodelist';
 import Element from './element';
 import Text from './text';
+import TextProxy from './textproxy';
 import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable';
 
 /**
@@ -218,10 +219,10 @@ export default class DocumentFragment {
 	/**
 	 * {@link #insertChildren Inserts} one or more nodes at the end of this document fragment.
 	 *
-	 * @param {module:engine/model/node~Node|Iterable.<module:engine/model/node~Node>} nodes Nodes to be inserted.
+	 * @param {module:engine/model/item~Item|Iterable.<module:engine/model/item~Item>} items Items to be inserted.
 	 */
-	appendChildren( nodes ) {
-		this.insertChildren( this.childCount, nodes );
+	appendChildren( items ) {
+		this.insertChildren( this.childCount, items );
 	}
 
 	/**
@@ -229,10 +230,10 @@ export default class DocumentFragment {
 	 * to this document fragment.
 	 *
 	 * @param {Number} index Index at which nodes should be inserted.
-	 * @param {module:engine/model/node~Node|Iterable.<module:engine/model/node~Node>} nodes Nodes to be inserted.
+	 * @param {module:engine/model/item~Item|Iterable.<module:engine/model/item~Item>} items Items to be inserted.
 	 */
-	insertChildren( index, nodes ) {
-		nodes = normalize( nodes );
+	insertChildren( index, items ) {
+		const nodes = normalize( items );
 
 		for ( const node of nodes ) {
 			// If node that is being added to this element is already inside another element, first remove it from the old parent.
@@ -306,7 +307,7 @@ export default class DocumentFragment {
 
 // Converts strings to Text and non-iterables to arrays.
 //
-// @param {String|module:engine/model/node~Node|Iterable.<String|module:engine/model/node~Node>}
+// @param {String|module:engine/model/item~Item|Iterable.<module:engine/model/item~Item>}
 // @return {Iterable.<module:engine/model/node~Node>}
 function normalize( nodes ) {
 	// Separate condition because string is iterable.
@@ -321,6 +322,14 @@ function normalize( nodes ) {
 	// Array.from to enable .map() on non-arrays.
 	return Array.from( nodes )
 		.map( node => {
-			return typeof node == 'string' ? new Text( node ) : node;
+			if ( typeof node == 'string' ) {
+				return new Text( node );
+			}
+
+			if ( node instanceof TextProxy ) {
+				return new Text( node.data, node.getAttributes() );
+			}
+
+			return node;
 		} );
 }

+ 15 - 6
packages/ckeditor5-engine/src/model/element.js

@@ -10,6 +10,7 @@
 import Node from './node';
 import NodeList from './nodelist';
 import Text from './text';
+import TextProxy from './textproxy';
 import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable';
 
 /**
@@ -187,7 +188,7 @@ export default class Element extends Node {
 	/**
 	 * {@link module:engine/model/element~Element#insertChildren Inserts} one or more nodes at the end of this element.
 	 *
-	 * @param {module:engine/model/node~Node|Iterable.<module:engine/model/node~Node>} nodes Nodes to be inserted.
+	 * @param {module:engine/model/item~Item|Iterable.<module:engine/model/item~Item>} nodes Nodes to be inserted.
 	 */
 	appendChildren( nodes ) {
 		this.insertChildren( this.childCount, nodes );
@@ -198,10 +199,10 @@ export default class Element extends Node {
 	 * to this element.
 	 *
 	 * @param {Number} index Index at which nodes should be inserted.
-	 * @param {module:engine/model/node~Node|Iterable.<module:engine/model/node~Node>} nodes Nodes to be inserted.
+	 * @param {module:engine/model/item~Item|Iterable.<module:engine/model/item~Item>} items Items to be inserted.
 	 */
-	insertChildren( index, nodes ) {
-		nodes = normalize( nodes );
+	insertChildren( index, items ) {
+		const nodes = normalize( items );
 
 		for ( const node of nodes ) {
 			// If node that is being added to this element is already inside another element, first remove it from the old parent.
@@ -305,7 +306,7 @@ export default class Element extends Node {
 
 // Converts strings to Text and non-iterables to arrays.
 //
-// @param {String|module:engine/model/node~Node|Iterable.<String|module:engine/model/node~Node>}
+// @param {String|module:engine/model/item~Item|Iterable.<String|module:engine/model/item~Item>}
 // @return {Iterable.<module:engine/model/node~Node>}
 function normalize( nodes ) {
 	// Separate condition because string is iterable.
@@ -320,6 +321,14 @@ function normalize( nodes ) {
 	// Array.from to enable .map() on non-arrays.
 	return Array.from( nodes )
 		.map( node => {
-			return typeof node == 'string' ? new Text( node ) : node;
+			if ( typeof node == 'string' ) {
+				return new Text( node );
+			}
+
+			if ( node instanceof TextProxy ) {
+				return new Text( node.data, node.getAttributes() );
+			}
+
+			return node;
 		} );
 }

+ 6 - 2
packages/ckeditor5-engine/src/model/operation/attributeoperation.js

@@ -114,8 +114,7 @@ export default class AttributeOperation extends Operation {
 	/**
 	 * @inheritDoc
 	 */
-	_execute() {
-		// Validation.
+	_validate() {
 		for ( const item of this.range.getItems() ) {
 			if ( this.oldValue !== null && !isEqual( item.getAttribute( this.key ), this.oldValue ) ) {
 				/**
@@ -147,7 +146,12 @@ export default class AttributeOperation extends Operation {
 				);
 			}
 		}
+	}
 
+	/**
+	 * @inheritDoc
+	 */
+	_execute() {
 		// If value to set is same as old value, don't do anything.
 		if ( !isEqual( this.oldValue, this.newValue ) ) {
 			// Execution.

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

@@ -62,7 +62,7 @@ export default class DetachOperation extends Operation {
 	/**
 	 * @inheritDoc
 	 */
-	_execute() {
+	_validate() {
 		if ( this.sourcePosition.root.document ) {
 			/**
 			 * Cannot detach document node.
@@ -72,7 +72,12 @@ export default class DetachOperation extends Operation {
 			 */
 			throw new CKEditorError( 'detach-operation-on-document-node: Cannot detach document node.' );
 		}
+	}
 
+	/**
+	 * @inheritDoc
+	 */
+	_execute() {
 		const nodes = _remove( Range.createFromPositionAndShift( this.sourcePosition, this.howMany ) );
 
 		return { nodes };

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

@@ -14,6 +14,7 @@ import RemoveOperation from './removeoperation';
 import { _insert, _normalizeNodes } from './utils';
 import Text from '../text';
 import Element from '../element';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 /**
  * Operation to insert one or more nodes at given position in the model.
@@ -83,6 +84,24 @@ export default class InsertOperation extends Operation {
 		return new RemoveOperation( this.position, this.nodes.maxOffset, gyPosition, this.baseVersion + 1 );
 	}
 
+	/**
+	 * @inheritDoc
+	 */
+	_validate() {
+		const targetElement = this.position.parent;
+
+		if ( !targetElement || targetElement.maxOffset < this.position.offset ) {
+			/**
+			 * Insertion position is invalid.
+			 *
+			 * @error insert-operation-position-invalid
+			 */
+			throw new CKEditorError(
+				'insert-operation-position-invalid: Insertion position is invalid.'
+			);
+		}
+	}
+
 	/**
 	 * @inheritDoc
 	 */

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

@@ -131,7 +131,7 @@ export default class MoveOperation extends Operation {
 	/**
 	 * @inheritDoc
 	 */
-	_execute() {
+	_validate() {
 		const sourceElement = this.sourcePosition.parent;
 		const targetElement = this.targetPosition.parent;
 		const sourceOffset = this.sourcePosition.offset;
@@ -183,7 +183,12 @@ export default class MoveOperation extends Operation {
 				}
 			}
 		}
+	}
 
+	/**
+	 * @inheritDoc
+	 */
+	_execute() {
 		const range = _move( Range.createFromPositionAndShift( this.sourcePosition, this.howMany ), this.targetPosition );
 
 		return {

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

@@ -73,8 +73,7 @@ export default class Operation {
 		 */
 
 		/**
-		 * Executes the operation - modifications described by the operation attributes
-		 * will be applied to the tree model.
+		 * Executes the operation - modifications described by the operation attributes will be applied to the tree model.
 		 *
 		 * @protected
 		 * @method #_execute
@@ -83,6 +82,16 @@ export default class Operation {
 		 */
 	}
 
+	/**
+	 * Checks whether the operation's parameters are correct and the operation can be correctly executed. Throws
+	 * an error if operation is not valid.
+	 *
+	 * @protected
+	 * @method #_validate
+	 */
+	_validate() {
+	}
+
 	/**
 	 * Custom toJSON method to solve child-parent circular dependencies.
 	 *

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

@@ -70,7 +70,9 @@ export default class ReinsertOperation extends MoveOperation {
 	/**
 	 * @inheritDoc
 	 */
-	_execute() {
+	_validate() {
+		super._validate();
+
 		if ( !this.sourcePosition.root.document ) {
 			throw new CKEditorError( 'reinsert-operation-on-detached-item: Cannot reinsert detached item.' );
 		}
@@ -78,7 +80,12 @@ export default class ReinsertOperation extends MoveOperation {
 		if ( !this.targetPosition.root.document ) {
 			throw new CKEditorError( 'reinsert-operation-to-detached-parent: Cannot reinsert item to detached parent.' );
 		}
+	}
 
+	/**
+	 * @inheritDoc
+	 */
+	_execute() {
 		return super._execute();
 	}
 

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

@@ -52,7 +52,9 @@ export default class RemoveOperation extends MoveOperation {
 	/**
 	 * @inheritDoc
 	 */
-	_execute() {
+	_validate() {
+		super._validate();
+
 		if ( !this.sourcePosition.root.document ) {
 			/**
 			 * Item that is going to be removed needs to be a {@link module:engine/model/document~Document document} child.
@@ -63,7 +65,12 @@ export default class RemoveOperation extends MoveOperation {
 			 */
 			throw new CKEditorError( 'remove-operation-on-detached-item: Cannot remove detached item.' );
 		}
+	}
 
+	/**
+	 * @inheritDoc
+	 */
+	_execute() {
 		return super._execute();
 	}
 

+ 9 - 7
packages/ckeditor5-engine/src/model/operation/renameoperation.js

@@ -86,8 +86,7 @@ export default class RenameOperation extends Operation {
 	/**
 	 * @inheritDoc
 	 */
-	_execute() {
-		// Validation.
+	_validate() {
 		const element = this.position.nodeAfter;
 
 		if ( !( element instanceof Element ) ) {
@@ -109,12 +108,15 @@ export default class RenameOperation extends Operation {
 				'rename-operation-wrong-name: Element to change has different name than operation\'s old name.'
 			);
 		}
+	}
 
-		// If value to set is same as old value, don't do anything.
-		if ( element.name != this.newName ) {
-			// Execution.
-			element.name = this.newName;
-		}
+	/**
+	 * @inheritDoc
+	 */
+	_execute() {
+		const element = this.position.nodeAfter;
+
+		element.name = this.newName;
 
 		return { element, oldName: this.oldName };
 	}

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

@@ -105,7 +105,10 @@ export default class RootAttributeOperation extends Operation {
 		return new RootAttributeOperation( this.root, this.key, this.newValue, this.oldValue, this.baseVersion + 1 );
 	}
 
-	_execute() {
+	/**
+	 * @inheritDoc
+	 */
+	_validate() {
 		if ( this.oldValue !== null && this.root.getAttribute( this.key ) !== this.oldValue ) {
 			/**
 			 * The attribute which should be removed does not exists for the given node.
@@ -135,7 +138,12 @@ export default class RootAttributeOperation extends Operation {
 				{ root: this.root, key: this.key }
 			);
 		}
+	}
 
+	/**
+	 * @inheritDoc
+	 */
+	_execute() {
 		if ( this.newValue !== null ) {
 			this.root.setAttribute( this.key, this.newValue );
 		} else {

+ 17 - 8
packages/ckeditor5-engine/src/view/documentfragment.js

@@ -8,6 +8,7 @@
  */
 
 import Text from './text';
+import TextProxy from './textproxy';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable';
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
@@ -99,11 +100,11 @@ export default class DocumentFragment {
 	 * {@link module:engine/view/documentfragment~DocumentFragment#insertChildren Insert} a child node or a list of child nodes at the end
 	 * and sets the parent of these nodes to this fragment.
 	 *
-	 * @param {module:engine/view/node~Node|Iterable.<module:engine/view/node~Node>} nodes Node or the list of nodes to be inserted.
+	 * @param {module:engine/view/item~Item|Iterable.<module:engine/view/item~Item>} items Items to be inserted.
 	 * @returns {Number} Number of appended nodes.
 	 */
-	appendChildren( nodes ) {
-		return this.insertChildren( this.childCount, nodes );
+	appendChildren( items ) {
+		return this.insertChildren( this.childCount, items );
 	}
 
 	/**
@@ -140,14 +141,14 @@ export default class DocumentFragment {
 	 * this fragment.
 	 *
 	 * @param {Number} index Position where nodes should be inserted.
-	 * @param {module:engine/view/node~Node|Iterable.<module:engine/view/node~Node>} nodes Node or list of nodes to be inserted.
+	 * @param {module:engine/view/item~Item|Iterable.<module:engine/view/item~Item>} items Items to be inserted.
 	 * @returns {Number} Number of inserted nodes.
 	 */
-	insertChildren( index, nodes ) {
+	insertChildren( index, items ) {
 		this._fireChange( 'children', this );
 		let count = 0;
 
-		nodes = normalize( nodes );
+		const nodes = normalize( items );
 
 		for ( const node of nodes ) {
 			// If node that is being added to this element is already inside another element, first remove it from the old parent.
@@ -199,7 +200,7 @@ mix( DocumentFragment, EmitterMixin );
 
 // Converts strings to Text and non-iterables to arrays.
 //
-// @param {String|module:engine/view/node~Node|Iterable.<String|module:engine/view/node~Node>}
+// @param {String|module:engine/view/item~Item|Iterable.<String|module:engine/view/item~Item>}
 // @return {Iterable.<module:engine/view/node~Node>}
 function normalize( nodes ) {
 	// Separate condition because string is iterable.
@@ -214,6 +215,14 @@ function normalize( nodes ) {
 	// Array.from to enable .map() on non-arrays.
 	return Array.from( nodes )
 		.map( node => {
-			return typeof node == 'string' ? new Text( node ) : node;
+			if ( typeof node == 'string' ) {
+				return new Text( node );
+			}
+
+			if ( node instanceof TextProxy ) {
+				return new Text( node.data );
+			}
+
+			return node;
 		} );
 }

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

@@ -1036,7 +1036,7 @@ export default class DomConverter {
 				// ViewContainerElement is found on a way to next ViewText node, so given `node` was first/last
 				// text node in its container element.
 				return null;
-			} else if ( value.item.is( 'text' ) ) {
+			} else if ( value.item.is( 'textProxy' ) ) {
 				// Found a text node in the same container element.
 				return value.item;
 			}

+ 17 - 8
packages/ckeditor5-engine/src/view/element.js

@@ -9,6 +9,7 @@
 
 import Node from './node';
 import Text from './text';
+import TextProxy from './textproxy';
 import objectToMap from '@ckeditor/ckeditor5-utils/src/objecttomap';
 import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable';
 import isPlainObject from '@ckeditor/ckeditor5-utils/src/lib/lodash/isPlainObject';
@@ -193,11 +194,11 @@ export default class Element extends Node {
 	 * and sets the parent of these nodes to this element.
 	 *
 	 * @fires module:engine/view/node~Node#change
-	 * @param {module:engine/view/node~Node|Iterable.<module:engine/view/node~Node>} nodes Node or the list of nodes to be inserted.
+	 * @param {module:engine/view/item~Item|Iterable.<module:engine/view/item~Item>} items Items to be inserted.
 	 * @returns {Number} Number of appended nodes.
 	 */
-	appendChildren( nodes ) {
-		return this.insertChildren( this.childCount, nodes );
+	appendChildren( items ) {
+		return this.insertChildren( this.childCount, items );
 	}
 
 	/**
@@ -344,15 +345,15 @@ export default class Element extends Node {
 	 * this element.
 	 *
 	 * @param {Number} index Position where nodes should be inserted.
-	 * @param {module:engine/view/node~Node|Iterable.<module:engine/view/node~Node>} nodes Node or the list of nodes to be inserted.
+	 * @param {module:engine/view/item~Item|Iterable.<module:engine/view/item~Item>} items Items to be inserted.
 	 * @fires module:engine/view/node~Node#change
 	 * @returns {Number} Number of inserted nodes.
 	 */
-	insertChildren( index, nodes ) {
+	insertChildren( index, items ) {
 		this._fireChange( 'children', this );
 		let count = 0;
 
-		nodes = normalize( nodes );
+		const nodes = normalize( items );
 
 		for ( const node of nodes ) {
 			// If node that is being added to this element is already inside another element, first remove it from the old parent.
@@ -809,7 +810,7 @@ function parseClasses( classesSet, classesString ) {
 
 // Converts strings to Text and non-iterables to arrays.
 //
-// @param {String|module:engine/view/node~Node|Iterable.<String|module:engine/view/node~Node>}
+// @param {String|module:engine/view/item~Item|Iterable.<String|module:engine/view/item~Item>}
 // @return {Iterable.<module:engine/view/node~Node>}
 function normalize( nodes ) {
 	// Separate condition because string is iterable.
@@ -824,6 +825,14 @@ function normalize( nodes ) {
 	// Array.from to enable .map() on non-arrays.
 	return Array.from( nodes )
 		.map( node => {
-			return typeof node == 'string' ? new Text( node ) : node;
+			if ( typeof node == 'string' ) {
+				return new Text( node );
+			}
+
+			if ( node instanceof TextProxy ) {
+				return new Text( node.data );
+			}
+
+			return node;
 		} );
 }

+ 3 - 1
packages/ckeditor5-engine/src/view/range.js

@@ -438,7 +438,9 @@ export default class Range {
 	 * @returns {module:engine/view/range~Range}
 	 */
 	static createOn( item ) {
-		return this.createFromPositionAndShift( Position.createBefore( item ), 1 );
+		const size = item.is( 'textProxy' ) ? item.offsetSize : 1;
+
+		return this.createFromPositionAndShift( Position.createBefore( item ), size );
 	}
 
 	/**

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

@@ -84,6 +84,13 @@ export default class TextProxy {
 		this.offsetInText = offsetInText;
 	}
 
+	/**
+	 * @inheritDoc
+	 */
+	get offsetSize() {
+		return this.data.length;
+	}
+
 	/**
 	 * Flag indicating whether `TextProxy` instance covers only part of the original {@link module:engine/view/text~Text text node}
 	 * (`true`) or the whole text node (`false`).

+ 4 - 2
packages/ckeditor5-engine/src/view/treewalker.js

@@ -236,7 +236,7 @@ export default class TreeWalker {
 				return this._next();
 			} else {
 				let charactersCount = node.data.length;
-				let item = node;
+				let item;
 
 				// If text stick out of walker range, we need to cut it and wrap by TextProxy.
 				if ( node == this._boundaryEndParent ) {
@@ -244,6 +244,7 @@ export default class TreeWalker {
 					item = new TextProxy( node, 0, charactersCount );
 					position = Position.createAfter( item );
 				} else {
+					item = new TextProxy( node, 0, node.data.length );
 					// If not just keep moving forward.
 					position.offset++;
 				}
@@ -347,7 +348,7 @@ export default class TreeWalker {
 				return this._previous();
 			} else {
 				let charactersCount = node.data.length;
-				let item = node;
+				let item;
 
 				// If text stick out of walker range, we need to cut it and wrap by TextProxy.
 				if ( node == this._boundaryStartParent ) {
@@ -357,6 +358,7 @@ export default class TreeWalker {
 					charactersCount = item.data.length;
 					position = Position.createBefore( item );
 				} else {
+					item = new TextProxy( node, 0, node.data.length );
 					// If not just keep moving backward.
 					position.offset--;
 				}

+ 41 - 20
packages/ckeditor5-engine/src/view/writer.js

@@ -391,8 +391,8 @@ export function clear( range, element ) {
 		if ( item.is( 'element' ) && element.isSimilar( item ) ) {
 			// Create range on this element.
 			rangeToRemove = Range.createOn( item );
-		// When range starts inside Text or TextProxy element.
-		} else if ( !current.nextPosition.isAfter( range.start ) && ( item.is( 'text' ) || item.is( 'textProxy' ) ) ) {
+			// When range starts inside Text or TextProxy element.
+		} else if ( !current.nextPosition.isAfter( range.start ) && item.is( 'textProxy' ) ) {
 			// We need to check if parent of this text matches to given element.
 			const parentElement = item.getAncestors().find( ancestor => {
 				return ancestor.is( 'element' ) && element.isSimilar( ancestor );
@@ -480,25 +480,36 @@ export function wrap( range, attribute ) {
 		return range;
 	}
 
-	// Range around one element.
-	if ( range.end.isEqual( range.start.getShiftedBy( 1 ) ) ) {
-		const node = range.start.nodeAfter;
-
-		if ( node instanceof AttributeElement && wrapAttributeElement( attribute, node ) ) {
-			return range;
-		}
-	}
-
 	// Range is inside single attribute and spans on all children.
 	if ( rangeSpansOnAllChildren( range ) && wrapAttributeElement( attribute, range.start.parent ) ) {
-		const parent = range.start.parent.parent;
-		const index = range.start.parent.index;
+		const parent = range.start.parent;
 
-		return Range.createFromParentsAndOffsets( parent, index, parent, index + 1 );
+		const end = mergeAttributes( Position.createAfter( parent ) );
+		const start = mergeAttributes( Position.createBefore( parent ) );
+
+		return new Range( start, end );
 	}
 
 	// Break attributes at range start and end.
 	const { start: breakStart, end: breakEnd } = _breakAttributesRange( range, true );
+
+	// Range around one element.
+	if ( breakEnd.isEqual( breakStart.getShiftedBy( 1 ) ) ) {
+		const node = breakStart.nodeAfter;
+
+		if ( node instanceof AttributeElement && wrapAttributeElement( attribute, node ) ) {
+			const start = mergeAttributes( breakStart );
+
+			if ( !start.isEqual( breakStart ) ) {
+				breakEnd.offset--;
+			}
+
+			const end = mergeAttributes( breakEnd );
+
+			return new Range( start, end );
+		}
+	}
+
 	const parentContainer = breakStart.parent;
 
 	// Unwrap children located between break points.
@@ -583,7 +594,7 @@ export function wrapPosition( position, attribute ) {
  * same parent container.
  *
  * @param {module:engine/view/range~Range} range
- * @param {module:engine/view/attributeelement~AttributeElement} element
+ * @param {module:engine/view/attributeelement~AttributeElement} attribute
  */
 export function unwrap( range, attribute ) {
 	if ( !( attribute instanceof AttributeElement ) ) {
@@ -602,20 +613,29 @@ export function unwrap( range, attribute ) {
 		return range;
 	}
 
+	// Break attributes at range start and end.
+	const { start: breakStart, end: breakEnd } = _breakAttributesRange( range, true );
+
 	// Range around one element - check if AttributeElement can be unwrapped partially when it's not similar.
 	// For example:
 	// <b class="foo bar" title="baz"></b> unwrap with:	<b class="foo"></p> result: <b class"bar" title="baz"></b>
-	if ( range.end.isEqual( range.start.getShiftedBy( 1 ) ) ) {
-		const node = range.start.nodeAfter;
+	if ( breakEnd.isEqual( breakStart.getShiftedBy( 1 ) ) ) {
+		const node = breakStart.nodeAfter;
 
 		// Unwrap single attribute element.
 		if ( !attribute.isSimilar( node ) && node instanceof AttributeElement && unwrapAttributeElement( attribute, node ) ) {
-			return range;
+			const start = mergeAttributes( breakStart );
+
+			if ( !start.isEqual( breakStart ) ) {
+				breakEnd.offset--;
+			}
+
+			const end = mergeAttributes( breakEnd );
+
+			return new Range( start, end );
 		}
 	}
 
-	// Break attributes at range start and end.
-	const { start: breakStart, end: breakEnd } = _breakAttributesRange( range, true );
 	const parentContainer = breakStart.parent;
 
 	// Unwrap children located between break points.
@@ -628,6 +648,7 @@ export function unwrap( range, attribute ) {
 	if ( !start.isEqual( newRange.start ) ) {
 		newRange.end.offset--;
 	}
+
 	const end = mergeAttributes( newRange.end );
 
 	return new Range( start, end );

+ 91 - 98
packages/ckeditor5-engine/tests/controller/editingcontroller.js

@@ -17,8 +17,6 @@ import buildModelConverter from '../../src/conversion/buildmodelconverter';
 
 import Model from '../../src/model/model';
 import ModelPosition from '../../src/model/position';
-import ModelElement from '../../src/model/element';
-import ModelText from '../../src/model/text';
 import ModelRange from '../../src/model/range';
 import ModelDocumentFragment from '../../src/model/documentfragment';
 
@@ -146,6 +144,7 @@ describe( 'EditingController', () => {
 			model.schema.registerItem( 'div', '$block' );
 			buildModelConverter().for( editing.modelToView ).fromElement( 'paragraph' ).toElement( 'p' );
 			buildModelConverter().for( editing.modelToView ).fromElement( 'div' ).toElement( 'div' );
+			buildModelConverter().for( editing.modelToView ).fromMarker( 'marker' ).toHighlight( {} );
 
 			// Note: The below code is highly overcomplicated due to #455.
 			model.document.selection.removeAllRanges();
@@ -160,10 +159,12 @@ describe( 'EditingController', () => {
 				model.schema
 			)._children );
 
-			model.enqueueChange( writer => {
+			model.change( writer => {
 				writer.insert( modelData, model.document.getRoot() );
+
 				model.document.selection.addRange( ModelRange.createFromParentsAndOffsets(
-					modelRoot.getChild( 0 ), 1, modelRoot.getChild( 0 ), 1 ) );
+					modelRoot.getChild( 0 ), 1, modelRoot.getChild( 0 ), 1 )
+				);
 			} );
 		} );
 
@@ -180,8 +181,9 @@ describe( 'EditingController', () => {
 		it( 'should convert split', () => {
 			expect( getViewData( editing.view ) ).to.equal( '<p>f{}oo</p><p></p><p>bar</p>' );
 
-			model.enqueueChange( writer => {
+			model.change( writer => {
 				writer.split( model.document.selection.getFirstPosition() );
+
 				model.document.selection.setRanges( [
 					ModelRange.createFromParentsAndOffsets(	modelRoot.getChild( 1 ), 0, modelRoot.getChild( 1 ), 0 )
 				] );
@@ -193,7 +195,7 @@ describe( 'EditingController', () => {
 		it( 'should convert rename', () => {
 			expect( getViewData( editing.view ) ).to.equal( '<p>f{}oo</p><p></p><p>bar</p>' );
 
-			model.enqueueChange( writer => {
+			model.change( writer => {
 				writer.rename( modelRoot.getChild( 0 ), 'div' );
 			} );
 
@@ -201,10 +203,11 @@ describe( 'EditingController', () => {
 		} );
 
 		it( 'should convert delete', () => {
-			model.enqueueChange( writer => {
+			model.change( writer => {
 				writer.remove(
 					ModelRange.createFromPositionAndShift( model.document.selection.getFirstPosition(), 1 )
 				);
+
 				model.document.selection.setRanges( [
 					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 0 ), 1, modelRoot.getChild( 0 ), 1 )
 				] );
@@ -239,7 +242,7 @@ describe( 'EditingController', () => {
 		} );
 
 		it( 'should convert collapsed selection', () => {
-			model.enqueueChange( () => {
+			model.change( () => {
 				model.document.selection.setRanges( [
 					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 2 ), 1, modelRoot.getChild( 2 ), 1 )
 				] );
@@ -249,7 +252,7 @@ describe( 'EditingController', () => {
 		} );
 
 		it( 'should convert not collapsed selection', () => {
-			model.enqueueChange( () => {
+			model.change( () => {
 				model.document.selection.setRanges( [
 					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 2 ), 1, modelRoot.getChild( 2 ), 2 )
 				] );
@@ -259,7 +262,7 @@ describe( 'EditingController', () => {
 		} );
 
 		it( 'should clear previous selection', () => {
-			model.enqueueChange( () => {
+			model.change( () => {
 				model.document.selection.setRanges( [
 					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 2 ), 1, modelRoot.getChild( 2 ), 1 )
 				] );
@@ -267,7 +270,7 @@ describe( 'EditingController', () => {
 
 			expect( getViewData( editing.view ) ).to.equal( '<p>foo</p><p></p><p>b{}ar</p>' );
 
-			model.enqueueChange( () => {
+			model.change( () => {
 				model.document.selection.setRanges( [
 					ModelRange.createFromParentsAndOffsets( modelRoot.getChild( 2 ), 2, modelRoot.getChild( 2 ), 2 )
 				] );
@@ -276,127 +279,116 @@ describe( 'EditingController', () => {
 			expect( getViewData( editing.view ) ).to.equal( '<p>foo</p><p></p><p>ba{}r</p>' );
 		} );
 
-		it( 'should forward marker events to model conversion dispatcher', () => {
-			const range = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 );
-			const markerStub = {
-				name: 'name',
-				getRange: () => range
-			};
+		it( 'should convert adding marker', () => {
+			const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 2, 2 ] ) );
 
-			sinon.spy( editing.modelToView, 'convertMarker' );
+			model.change( () => {
+				model.markers.set( 'marker', range );
+			} );
 
-			model.markers.fire( 'add', markerStub );
+			expect( getViewData( editing.view, { withoutSelection: true } ) )
+				.to.equal( '<p>f<span>oo</span></p><p></p><p><span>ba</span>r</p>' );
+		} );
 
-			expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', range ) ).to.be.true;
+		it( 'should convert removing marker', () => {
+			const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 2, 2 ] ) );
 
-			model.markers.fire( 'remove', markerStub );
+			model.change( () => {
+				model.markers.set( 'marker', range );
+			} );
 
-			expect( editing.modelToView.convertMarker.calledWithExactly( 'removeMarker', 'name', range ) ).to.be.true;
+			model.change( () => {
+				model.markers.remove( 'marker' );
+			} );
 
-			editing.modelToView.convertMarker.restore();
+			expect( getViewData( editing.view, { withoutSelection: true } ) )
+				.to.equal( '<p>foo</p><p></p><p>bar</p>' );
 		} );
 
-		it( 'should forward add marker event if content is inserted into a marker range', () => {
-			const markerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 );
-			const innerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 2 );
+		it( 'should convert changing marker', () => {
+			const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 2, 2 ] ) );
 
-			model.markers.set( 'name', markerRange );
-
-			sinon.spy( editing.modelToView, 'convertMarker' );
+			model.change( () => {
+				model.markers.set( 'marker', range );
+			} );
 
-			editing.modelToView.convertInsertion( innerRange );
+			const range2 = new ModelRange( new ModelPosition( modelRoot, [ 0, 0 ] ), new ModelPosition( modelRoot, [ 0, 2 ] ) );
 
-			expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', innerRange ) ).to.be.true;
+			model.change( () => {
+				model.markers.set( 'marker', range2 );
+			} );
 
-			editing.modelToView.convertMarker.restore();
+			expect( getViewData( editing.view, { withoutSelection: true } ) )
+				.to.equal( '<p><span>fo</span>o</p><p></p><p>bar</p>' );
 		} );
 
-		describe( 'should forward add marker event if inserted content has a marker', () => {
-			let element, outerRange;
-
-			beforeEach( () => {
-				element = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) );
-				modelRoot.appendChildren( element );
+		it( 'should convert insertion into marker', () => {
+			const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 2, 2 ] ) );
 
-				outerRange = ModelRange.createOn( element );
-
-				sinon.spy( editing.modelToView, 'convertMarker' );
+			model.change( () => {
+				model.markers.set( 'marker', range );
 			} );
 
-			afterEach( () => {
-				editing.modelToView.convertMarker.restore();
+			model.change( writer => {
+				writer.insertText( 'xyz', new ModelPosition( modelRoot, [ 1, 0 ] ) );
 			} );
 
-			it( 'marker strictly contained', () => {
-				const markerRange = ModelRange.createFromParentsAndOffsets( element, 1, element, 2 );
-				model.markers.set( 'name', markerRange );
+			expect( getViewData( editing.view, { withoutSelection: true } ) )
+				.to.equal( '<p>f<span>oo</span></p><p><span>xyz</span></p><p><span>ba</span>r</p>' );
+		} );
 
-				editing.modelToView.convertInsertion( outerRange );
-				expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true;
-			} );
+		it( 'should convert move to marker', () => {
+			const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 2, 2 ] ) );
 
-			it( 'marker starts at same position', () => {
-				const markerRange = ModelRange.createFromParentsAndOffsets( element, 0, element, 2 );
-				model.markers.set( 'name', markerRange );
-				editing.modelToView.convertInsertion( outerRange );
-				expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true;
+			model.change( () => {
+				model.markers.set( 'marker', range );
 			} );
 
-			it( 'marker ends at same position', () => {
-				const markerRange = ModelRange.createFromParentsAndOffsets( element, 1, element, 3 );
-				model.markers.set( 'name', markerRange );
-				editing.modelToView.convertInsertion( outerRange );
-				expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true;
+			model.change( writer => {
+				writer.move(
+					new ModelRange( new ModelPosition( modelRoot, [ 2, 2 ] ), new ModelPosition( modelRoot, [ 2, 3 ] ) ),
+					new ModelPosition( modelRoot, [ 0, 3 ] )
+				);
 			} );
 
-			it( 'marker is same as range', () => {
-				const markerRange = ModelRange.createFromParentsAndOffsets( element, 0, element, 3 );
-				model.markers.set( 'name', markerRange );
-				editing.modelToView.convertInsertion( outerRange );
-				expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true;
-			} );
+			expect( getViewData( editing.view, { withoutSelection: true } ) )
+				.to.equal( '<p>f<span>oor</span></p><p></p><p><span>ba</span></p>' );
 		} );
 
-		it( 'should not start marker conversion if content is not inserted into any marker range', () => {
-			const markerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 );
-			const insertRange = ModelRange.createFromParentsAndOffsets( modelRoot, 6, modelRoot, 8 );
-			const consumableMock = {
-				consume: () => true,
-				test: () => true
-			};
-
-			model.markers.set( 'name', markerRange );
-
-			sinon.spy( editing.modelToView, 'convertMarker' );
-
-			editing.modelToView.fire( 'insert', {
-				range: insertRange
-			}, consumableMock, { dispatcher: editing.modelToView } );
-
-			expect( editing.modelToView.convertMarker.called ).to.be.false;
-
-			editing.modelToView.convertMarker.restore();
-		} );
+		it( 'should convert move from marker', () => {
+			const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 2, 2 ] ) );
 
-		it( 'should forward add marker event if content is moved into a marker range', () => {
-			model.enqueueChange( writer => {
-				writer.appendElement( 'paragraph', model.document.getRoot() );
+			model.change( () => {
+				model.markers.set( 'marker', range );
 			} );
 
-			const markerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 );
+			model.change( writer => {
+				writer.move(
+					new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 0, 3 ] ) ),
+					new ModelPosition( modelRoot, [ 2, 3 ] )
+				);
+			} );
 
-			model.markers.set( 'name', markerRange );
+			expect( getViewData( editing.view, { withoutSelection: true } ) )
+				.to.equal( '<p>f</p><p></p><p><span>ba</span>roo</p>' );
+		} );
 
-			sinon.spy( editing.modelToView, 'convertMarker' );
+		it( 'should convert the whole marker move', () => {
+			const range = new ModelRange( new ModelPosition( modelRoot, [ 0, 1 ] ), new ModelPosition( modelRoot, [ 0, 3 ] ) );
 
-			editing.modelToView.convertMove(
-				ModelPosition.createAt( modelRoot, 3 ),
-				ModelRange.createOn( modelRoot.getChild( 1 ) )
-			);
+			model.change( () => {
+				model.markers.set( 'marker', range );
+			} );
 
-			expect( editing.modelToView.convertMarker.calledWith( 'addMarker', 'name' ) ).to.be.true;
+			model.change( writer => {
+				writer.move(
+					new ModelRange( new ModelPosition( modelRoot, [ 0, 0 ] ), new ModelPosition( modelRoot, [ 0, 3 ] ) ),
+					new ModelPosition( modelRoot, [ 1, 0 ] )
+				);
+			} );
 
-			editing.modelToView.convertMarker.restore();
+			expect( getViewData( editing.view, { withoutSelection: true } ) )
+				.to.equal( '<p></p><p>f<span>oo</span></p><p>bar</p>' );
 		} );
 	} );
 
@@ -414,8 +406,9 @@ describe( 'EditingController', () => {
 
 			editing.destroy();
 
-			model.enqueueChange( writer => {
+			model.change( writer => {
 				const modelData = parse( '<paragraph>foo</paragraph>', model.schema ).getChild( 0 );
+
 				writer.insert( modelData, model.document.getRoot() );
 			} );
 

+ 109 - 326
packages/ckeditor5-engine/tests/conversion/advanced-converters.js

@@ -10,8 +10,6 @@ import ModelTextProxy from '../../src/model/textproxy';
 import ModelRange from '../../src/model/range';
 import ModelPosition from '../../src/model/position';
 import ModelWalker from '../../src/model/treewalker';
-import ModelWriter from '../../src/model/writer';
-import Batch from '../../src/model/batch';
 
 import ViewElement from '../../src/view/element';
 import ViewContainerElement from '../../src/view/containerelement';
@@ -21,47 +19,34 @@ import viewWriter from '../../src/view/writer';
 import ViewPosition from '../../src/view/position';
 import ViewRange from '../../src/view/range';
 
-import Mapper from '../../src/conversion/mapper';
-import ModelConversionDispatcher from '../../src/conversion/modelconversiondispatcher';
+import EditingController from '../../src/controller/editingcontroller';
+
 import ViewConversionDispatcher from '../../src/conversion/viewconversiondispatcher';
 
 import {
 	insertElement,
-	insertText,
-	setAttribute,
-	removeAttribute,
-	wrapItem,
-	unwrapItem,
-	remove,
-	eventNameToConsumableType
+	changeAttribute,
+	wrap
 } from '../../src/conversion/model-to-view-converters';
 import { convertToModelFragment, convertText } from '../../src/conversion/view-to-model-converters';
 
-import { createRangeOnElementOnly } from '../../tests/model/_utils/utils';
-
 describe( 'advanced-converters', () => {
-	let model, modelDoc, modelRoot, viewRoot, mapper, modelDispatcher, viewDispatcher, modelWriter;
+	let model, modelDoc, modelRoot, viewRoot, modelDispatcher, viewDispatcher;
 
 	beforeEach( () => {
 		model = new Model();
 		modelDoc = model.document;
 		modelRoot = modelDoc.createRoot();
-		viewRoot = new ViewContainerElement( 'div' );
 
-		mapper = new Mapper();
-		mapper.bindElements( modelRoot, viewRoot );
+		const editing = new EditingController( model );
 
-		modelDispatcher = new ModelConversionDispatcher( model, { mapper } );
-		// Schema is mocked up because we don't care about it in those tests.
-		viewDispatcher = new ViewConversionDispatcher( model, { schema: { check: () => true } } );
+		viewRoot = editing.createRoot( 'div' );
 
-		modelDispatcher.on( 'insert:$text', insertText() );
-		modelDispatcher.on( 'remove', remove() );
+		viewDispatcher = new ViewConversionDispatcher( model, { schema: { check: () => true } } );
 		viewDispatcher.on( 'text', convertText() );
 		viewDispatcher.on( 'documentFragment', convertToModelFragment() );
 
-		// We need to create a model writer to modify model tree in tests.
-		modelWriter = new ModelWriter( model, new Batch() );
+		modelDispatcher = editing.modelToView;
 	} );
 
 	function viewAttributesToString( item ) {
@@ -131,179 +116,6 @@ describe( 'advanced-converters', () => {
 		return result;
 	}
 
-	// Converter for custom `image` element that might have a `caption` element inside which changes
-	// how the image is displayed in the view:
-	//
-	// Model:
-	//
-	// [image {src="foo.jpg" title="foo"}]
-	//   └─ [caption]
-	//       ├─ f
-	//       ├─ o
-	//       └─ o
-	//
-	// [image {src="bar.jpg" title="bar"}]
-	//
-	// View:
-	//
-	// <figure>
-	//   ├─ <img src="foo.jpg" title="foo" />
-	//   └─ <caption>
-	//       └─ foo
-	//
-	// <img src="bar.jpg" title="bar" />
-	describe( 'image with caption converters', () => {
-		beforeEach( () => {
-			const modelImageConverter = function( evt, data, consumable, conversionApi ) {
-				// First, consume the `image` element.
-				consumable.consume( data.item, 'insert' );
-
-				// Just create normal image element for the view.
-				// Maybe it will be "decorated" later.
-				const viewImage = new ViewContainerElement( 'img' );
-				const insertPosition = conversionApi.mapper.toViewPosition( data.range.start );
-
-				// Check if the `image` element has children.
-				if ( data.item.childCount > 0 ) {
-					const modelCaption = data.item.getChild( 0 );
-
-					// `modelCaption` insertion change is consumed from consumable values.
-					// It will not be converted by other converters, but it's children (probably some text) will be.
-					// Through mapping, converters for text will know where to insert contents of `modelCaption`.
-					if ( consumable.consume( modelCaption, 'insert' ) ) {
-						const viewCaption = new ViewContainerElement( 'figcaption' );
-
-						const viewImageHolder = new ViewContainerElement( 'figure', null, [ viewImage, viewCaption ] );
-
-						conversionApi.mapper.bindElements( modelCaption, viewCaption );
-						conversionApi.mapper.bindElements( data.item, viewImageHolder );
-						viewWriter.insert( insertPosition, viewImageHolder );
-					}
-				} else {
-					conversionApi.mapper.bindElements( data.item, viewImage );
-					viewWriter.insert( insertPosition, viewImage );
-				}
-
-				evt.stop();
-			};
-
-			const modelImageAttributesConverter = function( evt, data, consumable, conversionApi ) {
-				if ( data.item.name != 'image' ) {
-					return;
-				}
-
-				let viewElement = conversionApi.mapper.toViewElement( data.item );
-
-				if ( viewElement.name == 'figure' ) {
-					viewElement = viewElement.getChild( 0 );
-				}
-
-				consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
-
-				if ( !data.attributeNewValue ) {
-					viewElement.removeAttribute( data.attributeKey );
-				} else {
-					viewElement.setAttribute( data.attributeKey, data.attributeNewValue );
-				}
-
-				evt.stop();
-			};
-
-			const viewFigureConverter = function( evt, data, consumable, conversionApi ) {
-				if ( consumable.consume( data.input, { name: true } ) ) {
-					const modelImage = conversionApi.convertItem( data.input.getChild( 0 ), consumable );
-					const modelCaption = conversionApi.convertItem( data.input.getChild( 1 ), consumable );
-
-					modelImage.appendChildren( modelCaption );
-
-					data.output = modelImage;
-				}
-			};
-
-			const viewImageConverter = function( evt, data, consumable, conversionApi ) {
-				if ( consumable.consume( data.input, { name: true } ) ) {
-					data.output = conversionApi.writer.createElement( 'image', data.input.getAttributes() );
-				}
-			};
-
-			const viewFigcaptionConverter = function( evt, data, consumable, conversionApi ) {
-				if ( consumable.consume( data.input, { name: true } ) ) {
-					const modelCaption = conversionApi.writer.createElement( 'caption' );
-					const children = conversionApi.convertChildren( data.input, consumable );
-
-					conversionApi.writer.append( children, modelCaption );
-
-					data.output = modelCaption;
-				}
-			};
-
-			modelDispatcher.on( 'insert:image', modelImageConverter );
-			modelDispatcher.on( 'addAttribute', modelImageAttributesConverter );
-			modelDispatcher.on( 'changeAttribute', modelImageAttributesConverter );
-			modelDispatcher.on( 'removeAttribute', modelImageAttributesConverter );
-			viewDispatcher.on( 'element:figure', viewFigureConverter );
-			viewDispatcher.on( 'element:img', viewImageConverter );
-			viewDispatcher.on( 'element:figcaption', viewFigcaptionConverter );
-		} );
-
-		it( 'should convert model images changes without caption to view', () => {
-			const modelElement = new ModelElement( 'image', { src: 'bar.jpg', title: 'bar' } );
-			modelRoot.appendChildren( modelElement );
-			modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><img src="bar.jpg" title="bar"></img></div>' );
-
-			modelElement.setAttribute( 'src', 'new.jpg' );
-			modelElement.removeAttribute( 'title' );
-			modelDispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'src', 'bar.jpg', 'new.jpg' );
-			modelDispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'title', 'bar', null );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><img src="new.jpg"></img></div>' );
-		} );
-
-		it( 'should convert model images changes with caption to view', () => {
-			const modelElement = new ModelElement( 'image', { src: 'foo.jpg', title: 'foo' }, [
-				new ModelElement( 'caption', {}, new ModelText( 'foobar' ) )
-			] );
-			modelRoot.appendChildren( [ modelElement ] );
-			modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-
-			expect( viewToString( viewRoot ) ).to.equal(
-				'<div><figure><img src="foo.jpg" title="foo"></img><figcaption>foobar</figcaption></figure></div>'
-			);
-
-			modelElement.setAttribute( 'src', 'new.jpg' );
-			modelElement.removeAttribute( 'title' );
-			modelDispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'src', 'bar.jpg', 'new.jpg' );
-			modelDispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'title', 'bar', null );
-
-			expect( viewToString( viewRoot ) ).to.equal(
-				'<div><figure><img src="new.jpg"></img><figcaption>foobar</figcaption></figure></div>'
-			);
-		} );
-
-		it( 'should convert view image to model', () => {
-			const viewElement = new ViewContainerElement( 'img', { src: 'bar.jpg', title: 'bar' } );
-			const modelElement = viewDispatcher.convert( viewElement );
-
-			expect( modelToString( modelElement ) ).to.equal( '<image src="bar.jpg" title="bar"></image>' );
-		} );
-
-		it( 'should convert view figure to model', () => {
-			const viewElement = new ViewContainerElement(
-				'figure',
-				null,
-				[
-					new ViewContainerElement( 'img', { src: 'bar.jpg', title: 'bar' } ),
-					new ViewContainerElement( 'figcaption', null, new ViewText( 'foobar' ) )
-				]
-			);
-			const modelElement = viewDispatcher.convert( viewElement );
-
-			expect( modelToString( modelElement ) ).to.equal( '<image src="bar.jpg" title="bar"><caption>foobar</caption></image>' );
-		} );
-	} );
-
 	// Converter overwrites default attribute converter for `linkHref` and `linkTitle` attribute is set on `quote` element.
 	//
 	// Model:
@@ -326,46 +138,11 @@ describe( 'advanced-converters', () => {
 	//	 └─ foo
 	describe( 'custom attribute handling for given element', () => {
 		beforeEach( () => {
-			// NORMAL LINK MODEL TO VIEW CONVERTERS
-			modelDispatcher.on( 'addAttribute:linkHref', wrapItem( value => new ViewAttributeElement( 'a', { href: value } ) ) );
-			modelDispatcher.on( 'addAttribute:linkTitle', wrapItem( value => new ViewAttributeElement( 'a', { title: value } ) ) );
-
-			const changeLinkAttribute = function( elementCreator ) {
-				return ( evt, data, consumable, conversionApi ) => {
-					consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
-
-					const viewRange = conversionApi.mapper.toViewRange( data.range );
-					const viewOldA = elementCreator( data.attributeOldValue );
-					const viewNewA = elementCreator( data.attributeNewValue );
-
-					viewWriter.unwrap( viewRange, viewOldA, evt.priority );
-					viewWriter.wrap( viewRange, viewNewA, evt.priority );
+			// Normal model-to-view converters for links.
+			modelDispatcher.on( 'attribute:linkHref', wrap( value => new ViewAttributeElement( 'a', { href: value } ) ) );
+			modelDispatcher.on( 'attribute:linkTitle', wrap( value => new ViewAttributeElement( 'a', { title: value } ) ) );
 
-					evt.stop();
-				};
-			};
-
-			modelDispatcher.on(
-				'changeAttribute:linkHref',
-				changeLinkAttribute( value => new ViewAttributeElement( 'a', { href: value } ) )
-			);
-
-			modelDispatcher.on(
-				'changeAttribute:linkTitle',
-				changeLinkAttribute( value => new ViewAttributeElement( 'a', { title: value } ) )
-			);
-
-			modelDispatcher.on(
-				'removeAttribute:linkHref',
-				unwrapItem( value => new ViewAttributeElement( 'a', { href: value } ) )
-			);
-
-			modelDispatcher.on(
-				'removeAttribute:linkTitle',
-				unwrapItem( value => new ViewAttributeElement( 'a', { title: value } ) )
-			);
-
-			// NORMAL LINK VIEW TO MODEL CONVERTERS
+			// Normal view-to-model converters for links.
 			viewDispatcher.on( 'element:a', ( evt, data, consumable, conversionApi ) => {
 				if ( consumable.consume( data.input, { name: true, attribute: 'href' } ) ) {
 					if ( !data.output ) {
@@ -390,7 +167,7 @@ describe( 'advanced-converters', () => {
 				}
 			} );
 
-			// QUOTE MODEL TO VIEW CONVERTERS
+			// Model-to-view converter for quote element.
 			modelDispatcher.on( 'insert:quote', ( evt, data, consumable, conversionApi ) => {
 				consumable.consume( data.item, 'insert' );
 
@@ -399,56 +176,63 @@ describe( 'advanced-converters', () => {
 
 				conversionApi.mapper.bindElements( data.item, viewElement );
 				viewWriter.insert( viewPosition, viewElement );
+			}, { priority: 'high' } );
 
-				if ( consumable.consume( data.item, 'addAttribute:linkHref' ) ) {
-					const viewA = new ViewAttributeElement(
-						'a', { href: data.item.getAttribute( 'linkHref' ) }, new ViewText( 'see source' )
-					);
-
-					if ( consumable.consume( data.item, 'addAttribute:linkTitle' ) ) {
-						viewA.setAttribute( 'title', data.item.getAttribute( 'linkTitle' ) );
-					}
+			modelDispatcher.on( 'attribute:linkHref:quote', linkHrefOnQuoteConverter, { priority: 'high' } );
+			modelDispatcher.on( 'attribute:linkTitle:quote', linkTitleOnQuoteConverter, { priority: 'high' } );
 
-					viewWriter.insert( new ViewPosition( viewElement, viewElement.childCount ), viewA );
+			function linkHrefOnQuoteConverter( evt, data, consumable, conversionApi ) {
+				if ( !consumable.consume( data.item, 'attribute:linkHref' ) ) {
+					return;
 				}
 
-				evt.stop();
-			}, { priority: 'high' } );
+				const viewQuote = conversionApi.mapper.toViewElement( data.item );
 
-			const modelChangeLinkAttrQuoteConverter = function( evt, data, consumable, conversionApi ) {
-				const viewKey = data.attributeKey.substr( 4 ).toLowerCase();
+				if ( data.attributeNewValue === null ) {
+					// Attribute was removed -> remove the view link.
+					const viewLink = viewQuote.getChild( viewQuote.childCount - 1 );
 
-				consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
+					viewWriter.remove( ViewRange.createOn( viewLink ) );
 
-				const viewElement = conversionApi.mapper.toViewElement( data.item );
-				const viewA = viewElement.getChild( viewElement.childCount - 1 );
+					consumable.consume( data.item, 'attribute:linkTitle' );
+				} else if ( data.attributeOldValue === null ) {
+					// Attribute was added -> add the view link.
+					const viewLink = new ViewAttributeElement(
+						'a', { href: data.item.getAttribute( 'linkHref' ) }, new ViewText( 'see source' )
+					);
 
-				if ( data.attributeNewValue !== null ) {
-					viewA.setAttribute( viewKey, data.attributeNewValue );
+					if ( consumable.consume( data.item, 'attribute:linkTitle' ) && data.item.getAttribute( 'linkTitle' ) !== null ) {
+						viewLink.setAttribute( 'title', data.item.getAttribute( 'linkTitle' ) );
+					}
+
+					viewWriter.insert( new ViewPosition( viewQuote, viewQuote.childCount ), viewLink );
 				} else {
-					viewA.removeAttribute( viewKey );
+					// Attribute has changed -> change the existing view link.
+					const viewLink = viewQuote.getChild( viewQuote.childCount - 1 );
+					viewLink.setAttribute( 'href', data.attributeNewValue );
 				}
+			}
 
-				evt.stop();
-			};
-
-			modelDispatcher.on( 'changeAttribute:linkHref:quote', modelChangeLinkAttrQuoteConverter, { priority: 'high' } );
-			modelDispatcher.on( 'changeAttribute:linkTitle:quote', modelChangeLinkAttrQuoteConverter, { priority: 'high' } );
-
-			modelDispatcher.on( 'removeAttribute:linkHref:quote', ( evt, data, consumable, conversionApi ) => {
-				consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
+			function linkTitleOnQuoteConverter( evt, data, consumable, conversionApi ) {
+				if ( !consumable.consume( data.item, 'attribute:linkTitle' ) ) {
+					return;
+				}
 
-				const viewElement = conversionApi.mapper.toViewElement( data.item );
-				const viewA = viewElement.getChild( viewElement.childCount - 1 );
-				const aIndex = viewA.index;
+				const viewQuote = conversionApi.mapper.toViewElement( data.item );
+				const viewLink = viewQuote.getChild( viewQuote.childCount - 1 );
 
-				viewWriter.remove( ViewRange.createFromParentsAndOffsets( viewElement, aIndex, viewElement, aIndex + 1 ) );
+				if ( !viewLink ) {
+					return;
+				}
 
-				evt.stop();
-			}, { priority: 'high' } );
-			modelDispatcher.on( 'removeAttribute:linkTitle:quote', modelChangeLinkAttrQuoteConverter, { priority: 'high' } );
+				if ( data.attributeNewValue === null ) {
+					viewLink.removeAttribute( 'title' );
+				} else {
+					viewLink.setAttribute( 'title', data.attributeNewValue );
+				}
+			}
 
-			// QUOTE VIEW TO MODEL CONVERTERS
+			// View-to-model converter for quote element.
 			viewDispatcher.on( 'element:blockquote', ( evt, data, consumable, conversionApi ) => {
 				if ( consumable.consume( data.input, { name: true } ) ) {
 					data.output = new ModelElement( 'quote' );
@@ -474,43 +258,47 @@ describe( 'advanced-converters', () => {
 
 		it( 'should convert model text with linkHref and linkTitle to view', () => {
 			const modelText = new ModelText( 'foo', { linkHref: 'foo.html', linkTitle: 'Foo title' } );
-			modelRoot.appendChildren( modelText );
 
-			let range = ModelRange.createIn( modelRoot );
+			// Let's insert text with link attributes.
+			model.change( writer => {
+				writer.insert(
+					modelText,
+					new ModelPosition( modelRoot, [ 0 ] )
+				);
+			} );
 
-			modelDispatcher.convertInsertion( range );
+			let range = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><a href="foo.html" title="Foo title">foo</a></div>' );
 
 			// Let's change link's attributes.
-			modelWriter.setAttributes( {
-				linkHref: 'bar.html',
-				linkTitle: 'Bar title'
-			}, range );
-			modelDispatcher.convertAttribute( 'changeAttribute', range, 'linkHref', 'foo.html', 'bar.html' );
-			modelDispatcher.convertAttribute( 'changeAttribute', range, 'linkTitle', 'Foo title', 'Bar title' );
+			model.change( writer => {
+				writer.setAttribute( 'linkHref', 'bar.html', range );
+				writer.setAttribute( 'linkTitle', 'Bar title', range );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><a href="bar.html" title="Bar title">foo</a></div>' );
 
-			modelWriter.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ) );
-			modelDispatcher.convertRemove(
-				ModelPosition.createFromParentAndOffset( modelRoot, 0 ),
-				ModelRange.createIn( modelDoc.graveyard )
-			);
+			// Let's remove a letter from the link.
+			model.change( writer => {
+				writer.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><a href="bar.html" title="Bar title">oo</a></div>' );
 
-			range = ModelRange.createIn( modelRoot );
-
 			// Let's remove just one attribute.
-			modelWriter.removeAttribute( 'linkTitle', range );
-			modelDispatcher.convertAttribute( 'removeAttribute', range, 'linkTitle', 'Bar title', null );
+			model.change( writer => {
+				range = ModelRange.createIn( modelRoot );
+				writer.removeAttribute( 'linkTitle', range );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><a href="bar.html">oo</a></div>' );
 
 			// Let's remove the other attribute.
-			modelWriter.removeAttribute( 'linkHref', range );
-			modelDispatcher.convertAttribute( 'removeAttribute', range, 'linkHref', 'bar.html', null );
+			model.change( writer => {
+				range = ModelRange.createIn( modelRoot );
+				writer.removeAttribute( 'linkHref', range );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div>oo</div>' );
 		} );
@@ -527,46 +315,45 @@ describe( 'advanced-converters', () => {
 		} );
 
 		it( 'should convert quote model element with linkHref and linkTitle attribute to view', () => {
+			modelDispatcher.on( 'attribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
+
 			const modelElement = new ModelElement( 'quote', { linkHref: 'foo.html', linkTitle: 'Foo source' }, new ModelText( 'foo' ) );
-			modelRoot.appendChildren( modelElement );
-			modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+
+			// Let's insert a quote element with link attribute.
+			model.change( writer => {
+				writer.insert(
+					modelElement,
+					new ModelPosition( modelRoot, [ 0 ] )
+				);
+			} );
 
 			let expected = '<div><blockquote>foo<a href="foo.html" title="Foo source">see source</a></blockquote></div>';
 			expect( viewToString( viewRoot ) ).to.equal( expected );
 
-			modelDispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
-			modelDispatcher.on( 'changeAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
-			modelDispatcher.on( 'removeAttribute:bold', unwrapItem( new ViewAttributeElement( 'strong' ) ) );
-
-			modelElement.appendChildren( new ModelText( 'bar', { bold: true } ) );
-			modelDispatcher.convertInsertion( ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 6 ) );
+			// And insert some additional content into it.
+			model.change( writer => {
+				writer.insert(
+					new ModelText( 'bar', { bold: true } ),
+					new ModelPosition( modelRoot, [ 0, 3 ] )
+				);
+			} );
 
 			expected = '<div><blockquote>foo<strong>bar</strong><a href="foo.html" title="Foo source">see source</a></blockquote></div>';
 			expect( viewToString( viewRoot ) ).to.equal( expected );
 
-			modelElement.removeAttribute( 'linkTitle' );
-			modelElement.setAttribute( 'linkHref', 'bar.html' );
-
-			modelDispatcher.convertAttribute(
-				'removeAttribute',
-				createRangeOnElementOnly( modelElement ),
-				'linkTitle',
-				'Foo source',
-				null
-			);
-			modelDispatcher.convertAttribute(
-				'changeAttribute',
-				createRangeOnElementOnly( modelElement ),
-				'linkHref',
-				'foo.html',
-				'bar.html'
-			);
+			// Let's change some attributes.
+			model.change( writer => {
+				writer.removeAttribute( 'linkTitle', modelElement );
+				writer.setAttribute( 'linkHref', 'bar.html', modelElement );
+			} );
 
 			expected = '<div><blockquote>foo<strong>bar</strong><a href="bar.html">see source</a></blockquote></div>';
 			expect( viewToString( viewRoot ) ).to.equal( expected );
 
-			modelElement.removeAttribute( 'linkHref' );
-			modelDispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelRoot ), 'linkHref', 'bar.html', null );
+			// Let's remove the only attribute connected with link.
+			model.change( writer => {
+				writer.removeAttribute( 'linkHref', modelElement );
+			} );
 
 			expected = '<div><blockquote>foo<strong>bar</strong></blockquote></div>';
 			expect( viewToString( viewRoot ) ).to.equal( expected );
@@ -662,9 +449,7 @@ describe( 'advanced-converters', () => {
 		beforeEach( () => {
 			// "Universal" converters
 			modelDispatcher.on( 'insert', insertElement( data => new ViewContainerElement( data.item.name ) ), { priority: 'lowest' } );
-			modelDispatcher.on( 'addAttribute', setAttribute(), { priority: 'lowest' } );
-			modelDispatcher.on( 'changeAttribute', setAttribute(), { priority: 'lowest' } );
-			modelDispatcher.on( 'removeAttribute', removeAttribute(), { priority: 'lowest' } );
+			modelDispatcher.on( 'attribute', changeAttribute(), { priority: 'lowest' } );
 
 			viewDispatcher.on( 'element', ( evt, data, consumable, conversionApi ) => {
 				if ( consumable.consume( data.input, { name: true } ) ) {
@@ -682,9 +467,7 @@ describe( 'advanced-converters', () => {
 
 			// "Real" converters -- added with higher priority. Should overwrite the "universal" converters.
 			modelDispatcher.on( 'insert:image', insertElement( new ViewContainerElement( 'img' ) ) );
-			modelDispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
-			modelDispatcher.on( 'changeAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
-			modelDispatcher.on( 'removeAttribute:bold', unwrapItem( new ViewAttributeElement( 'strong' ) ) );
+			modelDispatcher.on( 'attribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
 
 			viewDispatcher.on( 'element:img', ( evt, data, consumable ) => {
 				if ( consumable.consume( data.input, { name: true } ) ) {
@@ -725,7 +508,7 @@ describe( 'advanced-converters', () => {
 			] );
 
 			modelRoot.appendChildren( modelElement );
-			modelDispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			modelDispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
 
 			expect( viewToString( viewRoot ) ).to.equal(
 				'<div>' +

+ 159 - 166
packages/ckeditor5-engine/tests/conversion/buildmodelconverter.js

@@ -11,27 +11,13 @@ import ModelText from '../../src/model/text';
 import ModelRange from '../../src/model/range';
 import ModelPosition from '../../src/model/position';
 
-import ViewDocument from '../../src/view/document';
 import ViewElement from '../../src/view/element';
 import ViewContainerElement from '../../src/view/containerelement';
 import ViewAttributeElement from '../../src/view/attributeelement';
 import ViewUIElement from '../../src/view/uielement';
 import ViewText from '../../src/view/text';
 
-import Mapper from '../../src/conversion/mapper';
-import ModelConversionDispatcher from '../../src/conversion/modelconversiondispatcher';
-
-import {
-	insertText,
-	remove
-} from '../../src/conversion/model-to-view-converters';
-
-import {
-	convertCollapsedSelection,
-	clearAttributes
-} from '../../src/conversion/model-selection-to-view-converters';
-
-import { createRangeOnElementOnly } from '../../tests/model/_utils/utils';
+import EditingController from '../../src/controller/editingcontroller';
 
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
@@ -69,38 +55,31 @@ function viewToString( item ) {
 }
 
 describe( 'Model converter builder', () => {
-	let dispatcher, mapper, modelDoc, modelRoot, viewDoc, viewRoot, viewSelection, model;
+	let dispatcher, modelDoc, modelRoot, viewRoot, viewSelection, model, controller;
 
 	beforeEach( () => {
 		model = new Model();
 		modelDoc = model.document;
-		modelRoot = modelDoc.createRoot( 'root', 'root' );
+		modelRoot = modelDoc.createRoot();
 
-		viewDoc = new ViewDocument();
-		viewRoot = viewDoc.createRoot( 'div' );
-		viewSelection = viewDoc.selection;
+		controller = new EditingController( model );
+		controller.createRoot( 'div' );
 
-		mapper = new Mapper();
-		mapper.bindElements( modelRoot, viewRoot );
+		dispatcher = controller.modelToView;
 
-		dispatcher = new ModelConversionDispatcher( model, { mapper, viewSelection } );
-
-		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'remove', remove() );
-	} );
+		viewRoot = controller.view.getRoot();
+		viewSelection = controller.view.selection;
 
-	afterEach( () => {
-		viewDoc.destroy();
+		buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
 	} );
 
 	describe( 'model element to view element conversion', () => {
 		it( 'using passed view element name', () => {
-			buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
-
 			const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
@@ -109,9 +88,10 @@ describe( 'Model converter builder', () => {
 			buildModelConverter().for( dispatcher ).fromElement( 'image' ).toElement( new ViewContainerElement( 'img' ) );
 
 			const modelElement = new ModelElement( 'image' );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><img></img></div>' );
 		} );
@@ -122,32 +102,30 @@ describe( 'Model converter builder', () => {
 				.toElement( data => new ViewContainerElement( 'h' + data.item.getAttribute( 'level' ) ) );
 
 			const modelElement = new ModelElement( 'header', { level: 2 }, new ModelText( 'foobar' ) );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><h2>foobar</h2></div>' );
 		} );
 	} );
 
 	describe( 'model attribute to view element conversion', () => {
-		beforeEach( () => {
-			buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
-		} );
-
 		it( 'using passed view element name', () => {
 			buildModelConverter().for( dispatcher ).fromAttribute( 'bold' ).toElement( 'strong' );
 
 			const modelElement = new ModelText( 'foo', { bold: true } );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><strong>foo</strong></div>' );
 
-			modelRoot.removeAttribute( 'bold' );
-
-			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelRoot ), 'bold', true, null );
+			model.change( writer => {
+				writer.removeAttribute( 'bold', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div>foo</div>' );
 		} );
@@ -156,15 +134,16 @@ describe( 'Model converter builder', () => {
 			buildModelConverter().for( dispatcher ).fromAttribute( 'bold' ).toElement( new ViewAttributeElement( 'strong' ) );
 
 			const modelElement = new ModelText( 'foo', { bold: true } );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><strong>foo</strong></div>' );
 
-			modelRoot.removeAttribute( 'bold' );
-
-			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelRoot ), 'bold', true, null );
+			model.change( writer => {
+				writer.removeAttribute( 'bold', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div>foo</div>' );
 		} );
@@ -173,43 +152,38 @@ describe( 'Model converter builder', () => {
 			buildModelConverter().for( dispatcher ).fromAttribute( 'italic' ).toElement( value => new ViewAttributeElement( value ) );
 
 			const modelElement = new ModelText( 'foo', { italic: 'em' } );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>foo</em></div>' );
 
-			modelRoot.setAttribute( 'italic', 'i' );
-
-			dispatcher.convertAttribute( 'changeAttribute', ModelRange.createIn( modelRoot ), 'italic', 'em', 'i' );
+			model.change( writer => {
+				writer.setAttribute( 'italic', 'i', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><i>foo</i></div>' );
 
-			modelRoot.removeAttribute( 'italic' );
-
-			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelRoot ), 'italic', 'i', null );
+			model.change( writer => {
+				writer.removeAttribute( 'italic', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div>foo</div>' );
 		} );
 
 		it( 'selection conversion', () => {
-			// This test requires collapsed range selection converter (breaking attributes)  and clearing "artifacts".
-			dispatcher.on( 'selection', clearAttributes() );
-			dispatcher.on( 'selection', convertCollapsedSelection() );
-
 			// Model converter builder should add selection converter.
 			buildModelConverter().for( dispatcher ).fromAttribute( 'italic' ).toElement( value => new ViewAttributeElement( value ) );
 
-			modelRoot.appendChildren( new ModelText( 'foo', { italic: 'em' } ) );
-
-			// Set collapsed selection after "f".
-			const position = new ModelPosition( modelRoot, [ 1 ] );
-			modelDoc.selection.setRanges( [ new ModelRange( position, position ) ] );
-			modelDoc.selection._updateAttributes();
+			model.change( writer => {
+				writer.insert( new ModelText( 'foo', { italic: 'em' } ), ModelPosition.createAt( modelRoot, 0 ) );
 
-			// Convert stuff.
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-			dispatcher.convertSelection( modelDoc.selection, [] );
+				// Set collapsed selection after "f".
+				const position = new ModelPosition( modelRoot, [ 1 ] );
+				modelDoc.selection.setRanges( [ new ModelRange( position, position ) ] );
+				modelDoc.selection._updateAttributes();
+			} );
 
 			// Check if view structure is ok.
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>foo</em></div>' );
@@ -222,8 +196,9 @@ describe( 'Model converter builder', () => {
 			expect( ranges[ 0 ].start.offset ).to.equal( 1 );
 
 			// Change selection attribute, convert it.
-			modelDoc.selection.setAttribute( 'italic', 'i' );
-			dispatcher.convertSelection( modelDoc.selection, [] );
+			model.change( () => {
+				modelDoc.selection.setAttribute( 'italic', 'i' );
+			} );
 
 			// Check if view structure has changed.
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>f</em><i></i><em>oo</em></div>' );
@@ -236,16 +211,18 @@ describe( 'Model converter builder', () => {
 			expect( ranges[ 0 ].start.offset ).to.equal( 0 );
 
 			// Some more tests checking how selection attributes changes are converted:
-			modelDoc.selection.removeAttribute( 'italic' );
-			dispatcher.convertSelection( modelDoc.selection, [] );
+			model.change( () => {
+				modelDoc.selection.removeAttribute( 'italic' );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>f</em><em>oo</em></div>' );
 			ranges = Array.from( viewSelection.getRanges() );
 			expect( ranges[ 0 ].start.parent.name ).to.equal( 'div' );
 			expect( ranges[ 0 ].start.offset ).to.equal( 1 );
 
-			modelDoc.selection.setAttribute( 'italic', 'em' );
-			dispatcher.convertSelection( modelDoc.selection, [] );
+			model.change( () => {
+				modelDoc.selection.setAttribute( 'italic', 'em' );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><em>foo</em></div>' );
 			ranges = Array.from( viewSelection.getRanges() );
@@ -257,27 +234,26 @@ describe( 'Model converter builder', () => {
 	} );
 
 	describe( 'model attribute to view attribute conversion', () => {
-		beforeEach( () => {
-			buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
-		} );
-
 		it( 'using default 1-to-1 conversion', () => {
 			buildModelConverter().for( dispatcher ).fromAttribute( 'class' ).toAttribute();
 
 			const modelElement = new ModelElement( 'paragraph', { class: 'myClass' }, new ModelText( 'foobar' ) );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="myClass">foobar</p></div>' );
 
-			modelElement.setAttribute( 'class', 'newClass' );
-			dispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'class', 'myClass', 'newClass' );
+			model.change( writer => {
+				writer.setAttribute( 'class', 'newClass', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="newClass">foobar</p></div>' );
 
-			modelElement.removeAttribute( 'class' );
-			dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'class', 'newClass', null );
+			model.change( writer => {
+				writer.removeAttribute( 'class', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
@@ -286,19 +262,22 @@ describe( 'Model converter builder', () => {
 			buildModelConverter().for( dispatcher ).fromAttribute( 'theme' ).toAttribute( 'class' );
 
 			const modelElement = new ModelElement( 'paragraph', { theme: 'abc' }, new ModelText( 'foobar' ) );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="abc">foobar</p></div>' );
 
-			modelElement.setAttribute( 'theme', 'xyz' );
-			dispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'theme', 'abc', 'xyz' );
+			model.change( writer => {
+				writer.setAttribute( 'theme', 'xyz', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="xyz">foobar</p></div>' );
 
-			modelElement.removeAttribute( 'theme' );
-			dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'theme', 'xyz', null );
+			model.change( writer => {
+				writer.removeAttribute( 'theme', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
@@ -307,14 +286,16 @@ describe( 'Model converter builder', () => {
 			buildModelConverter().for( dispatcher ).fromAttribute( 'highlighted' ).toAttribute( 'style', 'background:yellow' );
 
 			const modelElement = new ModelElement( 'paragraph', { 'highlighted': true }, new ModelText( 'foobar' ) );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p style="background:yellow;">foobar</p></div>' );
 
-			modelElement.removeAttribute( 'highlighted' );
-			dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'highlighted', true, null );
+			model.change( writer => {
+				writer.removeAttribute( 'highlighted', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
@@ -325,19 +306,22 @@ describe( 'Model converter builder', () => {
 				.toAttribute( value => ( { key: 'class', value: value + '-theme' } ) );
 
 			const modelElement = new ModelElement( 'paragraph', { theme: 'nice' }, new ModelText( 'foobar' ) );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="nice-theme">foobar</p></div>' );
 
-			modelElement.setAttribute( 'theme', 'good' );
-			dispatcher.convertAttribute( 'changeAttribute', createRangeOnElementOnly( modelElement ), 'theme', 'nice', 'good' );
+			model.change( writer => {
+				writer.setAttribute( 'theme', 'good', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="good-theme">foobar</p></div>' );
 
-			modelElement.removeAttribute( 'theme' );
-			dispatcher.convertAttribute( 'removeAttribute', createRangeOnElementOnly( modelElement ), 'theme', 'good', null );
+			model.change( writer => {
+				writer.removeAttribute( 'theme', modelElement );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
@@ -349,13 +333,10 @@ describe( 'Model converter builder', () => {
 		beforeEach( () => {
 			modelText = new ModelText( 'foobar' );
 			modelElement = new ModelElement( 'paragraph', null, [ modelText ] );
-			modelRoot.appendChildren( modelElement );
-
-			const viewText = new ViewText( 'foobar' );
-			const viewElement = new ViewContainerElement( 'p', null, [ viewText ] );
-			viewRoot.appendChildren( viewElement );
 
-			mapper.bindElements( modelElement, viewElement );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 		} );
 
 		it( 'using passed highlight descriptor object', () => {
@@ -365,7 +346,9 @@ describe( 'Model converter builder', () => {
 				attributes: { title: 'highlight title' }
 			} );
 
-			dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) );
+			model.change( () => {
+				model.markers.set( 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal(
 				'<div>' +
@@ -378,9 +361,9 @@ describe( 'Model converter builder', () => {
 
 			expect( viewRoot.getChild( 0 ).getChild( 1 ).priority ).to.equal( 3 );
 
-			dispatcher.convertMarker(
-				'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 )
-			);
+			model.change( () => {
+				model.markers.remove( 'search' );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
@@ -392,7 +375,9 @@ describe( 'Model converter builder', () => {
 				attributes: { title: 'highlight title' }
 			} ) );
 
-			dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) );
+			model.change( () => {
+				model.markers.set( 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal(
 				'<div>' +
@@ -405,9 +390,9 @@ describe( 'Model converter builder', () => {
 
 			expect( viewRoot.getChild( 0 ).getChild( 1 ).priority ).to.equal( 12 );
 
-			dispatcher.convertMarker(
-				'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 )
-			);
+			model.change( () => {
+				model.markers.remove( 'search' );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
@@ -417,28 +402,27 @@ describe( 'Model converter builder', () => {
 				class: 'highlight'
 			} );
 
-			dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 ) );
+			model.change( () => {
+				model.markers.set( 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 ) );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 
-			dispatcher.convertMarker(
-				'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 )
-			);
+			model.change( () => {
+				model.markers.remove( 'search' );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
 
 		it( 'should create converters with provided priority', () => {
-			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toHighlight( {
-				class: 'highlight'
-			} );
+			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toHighlight( { class: 'highlight' } );
+			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).withPriority( 'high' ).toHighlight( { class: 'override' } );
 
-			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).withPriority( 'high' ).toHighlight( {
-				class: 'override'
+			model.change( () => {
+				model.markers.set( 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) );
 			} );
 
-			dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) );
-
 			expect( viewToString( viewRoot ) ).to.equal(
 				'<div>' +
 					'<p>' +
@@ -468,13 +452,10 @@ describe( 'Model converter builder', () => {
 		beforeEach( () => {
 			modelText = new ModelText( 'foobar' );
 			modelElement = new ModelElement( 'paragraph', null, [ modelText ] );
-			modelRoot.appendChildren( modelElement );
-
-			const viewText = new ViewText( 'foobar' );
-			const viewElement = new ViewContainerElement( 'p', null, [ viewText ] );
-			viewRoot.appendChildren( viewElement );
 
-			mapper.bindElements( modelElement, viewElement );
+			model.change( writer => {
+				writer.insert( modelElement, ModelPosition.createAt( modelRoot, 0 ) );
+			} );
 		} );
 
 		describe( 'collapsed range', () => {
@@ -485,11 +466,15 @@ describe( 'Model converter builder', () => {
 			it( 'using passed view element name', () => {
 				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( 'span' );
 
-				dispatcher.convertMarker( 'addMarker', 'search', range );
+				model.change( () => {
+					model.markers.set( 'search', range );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span></span>obar</p></div>' );
 
-				dispatcher.convertMarker( 'removeMarker', 'search', range );
+				model.change( () => {
+					model.markers.remove( 'search' );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 			} );
@@ -498,11 +483,15 @@ describe( 'Model converter builder', () => {
 				const viewElement = new ViewUIElement( 'span', { class: 'search' } );
 				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( viewElement );
 
-				dispatcher.convertMarker( 'addMarker', 'search', range );
+				model.change( () => {
+					model.markers.set( 'search', range );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="search"></span>obar</p></div>' );
 
-				dispatcher.convertMarker( 'removeMarker', 'search', range );
+				model.change( () => {
+					model.markers.remove( 'search' );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 			} );
@@ -514,11 +503,15 @@ describe( 'Model converter builder', () => {
 					return new ViewUIElement( 'span', { class: className } );
 				} );
 
-				dispatcher.convertMarker( 'addMarker', 'search:red', range );
+				model.change( () => {
+					model.markers.set( 'search:red', range );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="search search-color-red"></span>obar</p></div>' );
 
-				dispatcher.convertMarker( 'removeMarker', 'search:red', range );
+				model.change( () => {
+					model.markers.remove( 'search:red' );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 			} );
@@ -532,11 +525,15 @@ describe( 'Model converter builder', () => {
 			it( 'using passed view element name', () => {
 				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( 'span' );
 
-				dispatcher.convertMarker( 'addMarker', 'search', range );
+				model.change( () => {
+					model.markers.set( 'search', range );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span></span>ob<span></span>ar</p></div>' );
 
-				dispatcher.convertMarker( 'removeMarker', 'search', range );
+				model.change( () => {
+					model.markers.remove( 'search' );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 			} );
@@ -545,13 +542,17 @@ describe( 'Model converter builder', () => {
 				const viewElement = new ViewUIElement( 'span', { class: 'search' } );
 				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( viewElement );
 
-				dispatcher.convertMarker( 'addMarker', 'search', range );
+				model.change( () => {
+					model.markers.set( 'search', range );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal(
 					'<div><p>fo<span class="search"></span>ob<span class="search"></span>ar</p></div>'
 				);
 
-				dispatcher.convertMarker( 'removeMarker', 'search', range );
+				model.change( () => {
+					model.markers.remove( 'search' );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 			} );
@@ -563,13 +564,17 @@ describe( 'Model converter builder', () => {
 					return new ViewUIElement( 'span', { class: className } );
 				} );
 
-				dispatcher.convertMarker( 'addMarker', 'search:red', range );
+				model.change( () => {
+					model.markers.set( 'search:red', range );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal(
 					'<div><p>fo<span class="search search-color-red"></span>ob<span class="search search-color-red"></span>ar</p></div>'
 				);
 
-				dispatcher.convertMarker( 'removeMarker', 'search:red', range );
+				model.change( () => {
+					model.markers.remove( 'search:red' );
+				} );
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 			} );
@@ -581,29 +586,17 @@ describe( 'Model converter builder', () => {
 			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( 'normal' );
 			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).withPriority( 'high' ).toElement( 'high' );
 
-			dispatcher.convertMarker( 'addMarker', 'search', range );
+			model.change( () => {
+				model.markers.set( 'search', range );
+			} );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<high></high>obar</p></div>' );
 		} );
-	} );
-
-	describe( 'withPriority', () => {
-		it( 'should change default converters priority', () => {
-			buildModelConverter().for( dispatcher ).fromElement( 'custom' ).toElement( 'custom' );
-			buildModelConverter().for( dispatcher ).fromElement( 'custom' ).withPriority( 'high' ).toElement( 'other' );
-
-			const modelElement = new ModelElement( 'custom', null, new ModelText( 'foobar' ) );
-			modelRoot.appendChildren( modelElement );
 
-			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><other>foobar</other></div>' );
+		it( 'should throw when trying to build model element to view attribute converter', () => {
+			expect( () => {
+				buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toAttribute( 'paragraph', true );
+			} ).to.throw( CKEditorError, /^build-model-converter-non-attribute-to-attribute/ );
 		} );
 	} );
-
-	it( 'should throw when trying to build model element to view attribute converter', () => {
-		expect( () => {
-			buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toAttribute( 'paragraph', true );
-		} ).to.throw( CKEditorError, /^build-model-converter-non-attribute-to-attribute/ );
-	} );
 } );

+ 48 - 0
packages/ckeditor5-engine/tests/conversion/mapper.js

@@ -69,6 +69,24 @@ describe( 'Mapper', () => {
 			expect( mapper.toModelElement( viewA ) ).to.be.undefined;
 			expect( mapper.toViewElement( modelA ) ).to.be.undefined;
 		} );
+
+		it( 'should not remove binding between view and model element if view element got rebound', () => {
+			const viewA = new ViewElement( 'a' );
+			const modelA = new ModelElement( 'a' );
+			const modelB = new ModelElement( 'b' );
+
+			const mapper = new Mapper();
+			mapper.bindElements( modelA, viewA );
+			mapper.bindElements( modelB, viewA );
+
+			// `modelA` is still bound to `viewA` even though `viewA` got rebound.
+			expect( mapper.toViewElement( modelA ) ).to.equal( viewA );
+
+			mapper.unbindModelElement( modelA );
+
+			expect( mapper.toViewElement( modelA ) ).to.be.undefined;
+			expect( mapper.toModelElement( viewA ) ).to.equal( modelB );
+		} );
 	} );
 
 	describe( 'unbindViewElement', () => {
@@ -87,6 +105,24 @@ describe( 'Mapper', () => {
 			expect( mapper.toModelElement( viewA ) ).to.be.undefined;
 			expect( mapper.toViewElement( modelA ) ).to.be.undefined;
 		} );
+
+		it( 'should not remove binding between model and view element if model element got rebound', () => {
+			const viewA = new ViewElement( 'a' );
+			const viewB = new ViewElement( 'b' );
+			const modelA = new ModelElement( 'a' );
+
+			const mapper = new Mapper();
+			mapper.bindElements( modelA, viewA );
+			mapper.bindElements( modelA, viewB );
+
+			// `viewA` is still bound to `modelA`, even though `modelA` got rebound.
+			expect( mapper.toModelElement( viewA ) ).to.equal( modelA );
+
+			mapper.unbindViewElement( viewA );
+
+			expect( mapper.toModelElement( viewA ) ).to.be.undefined;
+			expect( mapper.toViewElement( modelA ) ).to.equal( viewB );
+		} );
 	} );
 
 	describe( 'Standard mapping', () => {
@@ -574,6 +610,18 @@ describe( 'Mapper', () => {
 		}
 	} );
 
+	it( 'should pass isPhantom flag to model-to-view position mapping callback', () => {
+		const mapper = new Mapper();
+
+		mapper.on( 'modelToViewPosition', ( evt, data ) => {
+			expect( data.isPhantom ).to.be.true;
+
+			evt.stop();
+		} );
+
+		mapper.toViewPosition( {}, { isPhantom: true } );
+	} );
+
 	describe( 'getModelLength', () => {
 		let mapper;
 

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

@@ -28,9 +28,10 @@ import {
 import {
 	insertElement,
 	insertText,
-	wrapItem,
+	wrap,
+	highlightElement,
 	highlightText,
-	highlightElement
+	removeHighlight
 } from '../../src/conversion/model-to-view-converters';
 
 import { stringify as stringifyView } from '../../src/dev-utils/view';
@@ -59,12 +60,11 @@ describe( 'model-selection-to-view-converters', () => {
 		dispatcher = new ModelConversionDispatcher( model, { mapper, viewSelection } );
 
 		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
+		dispatcher.on( 'attribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
 
 		dispatcher.on( 'addMarker:marker', highlightText( highlightDescriptor ) );
 		dispatcher.on( 'addMarker:marker', highlightElement( highlightDescriptor ) );
-		dispatcher.on( 'removeMarker:marker', highlightText( highlightDescriptor ) );
-		dispatcher.on( 'removeMarker:marker', highlightElement( highlightDescriptor ) );
+		dispatcher.on( 'removeMarker:marker', removeHighlight( highlightDescriptor ) );
 
 		// Default selection converters.
 		dispatcher.on( 'selection', clearAttributes(), { priority: 'low' } );
@@ -231,8 +231,8 @@ describe( 'model-selection-to-view-converters', () => {
 				viewRoot.removeChildren( 0, viewRoot.childCount );
 
 				// Convert model to view.
-				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-				dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() );
+				dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
+				dispatcher.convertMarkerAdd( marker.name, marker.getRange() );
 
 				const markers = Array.from( model.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) );
 				dispatcher.convertSelection( modelSelection, markers );
@@ -258,8 +258,8 @@ describe( 'model-selection-to-view-converters', () => {
 				viewRoot.removeChildren( 0, viewRoot.childCount );
 
 				// Convert model to view.
-				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-				dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() );
+				dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
+				dispatcher.convertMarkerAdd( marker.name, marker.getRange() );
 
 				const markers = Array.from( model.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) );
 				dispatcher.convertSelection( modelSelection, markers );
@@ -283,8 +283,8 @@ describe( 'model-selection-to-view-converters', () => {
 				viewRoot.removeChildren( 0, viewRoot.childCount );
 
 				// Convert model to view.
-				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-				dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() );
+				dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
+				dispatcher.convertMarkerAdd( marker.name, marker.getRange() );
 
 				const markers = Array.from( model.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) );
 				dispatcher.convertSelection( modelSelection, markers );
@@ -296,6 +296,7 @@ describe( 'model-selection-to-view-converters', () => {
 
 			it( 'in marker - should merge with the rest of attribute elements', () => {
 				dispatcher.on( 'addMarker:marker2', highlightText( data => ( { 'class': data.markerName } ) ) );
+				dispatcher.on( 'addMarker:marker2', highlightElement( data => ( { 'class': data.markerName } ) ) );
 				dispatcher.on( 'selectionMarker:marker2', convertSelectionMarker( data => ( { 'class': data.markerName } ) ) );
 
 				setModelData( model, 'foobar' );
@@ -307,8 +308,8 @@ describe( 'model-selection-to-view-converters', () => {
 				viewRoot.removeChildren( 0, viewRoot.childCount );
 
 				// Convert model to view.
-				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-				dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() );
+				dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
+				dispatcher.convertMarkerAdd( marker.name, marker.getRange() );
 
 				const markers = Array.from( model.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) );
 				dispatcher.convertSelection( modelSelection, markers );
@@ -332,8 +333,8 @@ describe( 'model-selection-to-view-converters', () => {
 				viewRoot.removeChildren( 0, viewRoot.childCount );
 
 				// Convert model to view.
-				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-				dispatcher.convertMarker( 'addMarker', marker.name, marker.getRange() );
+				dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
+				dispatcher.convertMarkerAdd( marker.name, marker.getRange() );
 
 				const markers = Array.from( model.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) );
 				dispatcher.convertSelection( modelSelection, markers );
@@ -372,7 +373,7 @@ describe( 'model-selection-to-view-converters', () => {
 				modelSelection.setAttribute( 'bold', true );
 
 				// Convert model to view.
-				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+				dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
 
 				// Add ui element to view.
 				const uiElement = new ViewUIElement( 'span' );
@@ -393,7 +394,7 @@ describe( 'model-selection-to-view-converters', () => {
 				modelSelection.setAttribute( 'bold', true );
 
 				// Convert model to view.
-				dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+				dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
 
 				// Add ui element to view.
 				const uiElement = new ViewUIElement( 'span' );
@@ -467,7 +468,7 @@ describe( 'model-selection-to-view-converters', () => {
 		describe( 'clearAttributes', () => {
 			it( 'should remove all ranges before adding new range', () => {
 				dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'b' ) ) );
-				dispatcher.on( 'addAttribute:style', wrapItem( new ViewAttributeElement( 'b' ) ) );
+				dispatcher.on( 'attribute:style', wrap( new ViewAttributeElement( 'b' ) ) );
 
 				test(
 					[ 3, 3 ],
@@ -489,7 +490,7 @@ describe( 'model-selection-to-view-converters', () => {
 
 			it( 'should do nothing if the attribute element had been already removed', () => {
 				dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'b' ) ) );
-				dispatcher.on( 'addAttribute:style', wrapItem( new ViewAttributeElement( 'b' ) ) );
+				dispatcher.on( 'attribute:style', wrap( new ViewAttributeElement( 'b' ) ) );
 
 				test(
 					[ 3, 3 ],
@@ -536,7 +537,7 @@ describe( 'model-selection-to-view-converters', () => {
 			}
 
 			dispatcher.on( 'selectionAttribute:theme', convertSelectionAttribute( themeElementCreator ) );
-			dispatcher.on( 'addAttribute:theme', wrapItem( themeElementCreator ) );
+			dispatcher.on( 'attribute:theme', wrap( themeElementCreator ) );
 
 			dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
 		} );
@@ -736,7 +737,7 @@ describe( 'model-selection-to-view-converters', () => {
 		viewRoot.removeChildren( 0, viewRoot.childCount );
 
 		// Convert model to view.
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+		dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
 		dispatcher.convertSelection( modelSelection, [] );
 
 		// Stringify view and check if it is same as expected.

Plik diff jest za duży
+ 171 - 598
packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js


+ 177 - 561
packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js

@@ -9,18 +9,11 @@ import ModelText from '../../src/model/text';
 import ModelElement from '../../src/model/element';
 import ModelRange from '../../src/model/range';
 import ModelPosition from '../../src/model/position';
-import ModelWriter from '../../src/model/writer';
-import Batch from '../../src/model/batch';
-import RemoveOperation from '../../src/model/operation/removeoperation';
-import NoOperation from '../../src/model/operation/nooperation';
-import RenameOperation from '../../src/model/operation/renameoperation';
-import AttributeOperation from '../../src/model/operation/attributeoperation';
-import { wrapInDelta } from '../../tests/model/_utils/utils';
 
 import ViewContainerElement from '../../src/view/containerelement';
 
 describe( 'ModelConversionDispatcher', () => {
-	let dispatcher, doc, root, gyPos, model, writer;
+	let dispatcher, doc, root, differStub, model;
 
 	beforeEach( () => {
 		model = new Model();
@@ -28,10 +21,11 @@ describe( 'ModelConversionDispatcher', () => {
 		dispatcher = new ModelConversionDispatcher( model );
 		root = doc.createRoot();
 
-		gyPos = new ModelPosition( doc.graveyard, [ 0 ] );
-
-		// As an util for modifying model tree.
-		writer = new ModelWriter( model, new Batch() );
+		differStub = {
+			getMarkersToRemove: () => [],
+			getChanges: () => [],
+			getMarkersToAdd: () => []
+		};
 	} );
 
 	describe( 'constructor()', () => {
@@ -43,224 +37,102 @@ describe( 'ModelConversionDispatcher', () => {
 		} );
 	} );
 
-	describe( 'convertChange', () => {
-		// We will do integration tests here. Unit tests will be done for methods that are used
-		// by `convertChange` internally. This way we will have two kinds of tests.
-
-		let image, imagePos;
-
-		beforeEach( () => {
-			image = new ModelElement( 'image' );
-			root.appendChildren( [ image, new ModelText( 'foobar' ) ] );
-
-			imagePos = ModelPosition.createBefore( image );
-
-			dispatcher.listenTo( doc, 'change', ( evt, type, changes ) => {
-				dispatcher.convertChange( type, changes );
-			} );
-		} );
-
-		it( 'should fire insert and addAttribute callbacks for insertion changes', () => {
-			const cbInsertText = sinon.spy();
-			const cbInsertImage = sinon.spy();
-			const cbAddAttribute = sinon.spy();
-
-			dispatcher.on( 'insert:$text', cbInsertText );
-			dispatcher.on( 'insert:image', cbInsertImage );
-			dispatcher.on( 'addAttribute:key:$text', cbAddAttribute );
-
-			model.change( writer => {
-				writer.insertText( 'foo', { key: 'value' }, root );
-			} );
-
-			expect( cbInsertText.called ).to.be.true;
-			expect( cbAddAttribute.called ).to.be.true;
-			expect( cbInsertImage.called ).to.be.false;
-		} );
-
-		it( 'should fire insert and addAttribute callbacks for reinsertion changes', () => {
-			image.setAttribute( 'key', 'value' );
-
-			// We will just create reinsert operation by reverting remove operation
-			// because creating reinsert change is tricky and not available through batch API.
-			const removeOperation = new RemoveOperation( imagePos, 1, gyPos, 0 );
+	describe( 'convertChanges', () => {
+		it( 'should call convertInsert for insert change', () => {
+			sinon.stub( dispatcher, 'convertInsert' );
 
-			// Let's apply remove operation so reinsert operation won't break.
-			model.applyOperation( wrapInDelta( removeOperation ) );
-
-			const cbInsertText = sinon.spy();
-			const cbInsertImage = sinon.spy();
-			const cbAddAttribute = sinon.spy();
-
-			dispatcher.on( 'insert:$text', cbInsertText );
-			dispatcher.on( 'insert:image', cbInsertImage );
-			dispatcher.on( 'addAttribute:key:image', cbAddAttribute );
-
-			model.applyOperation( wrapInDelta( removeOperation.getReversed() ) );
-
-			expect( cbInsertImage.called ).to.be.true;
-			expect( cbAddAttribute.called ).to.be.true;
-			expect( cbInsertText.called ).to.be.false;
-		} );
-
-		it( 'should fire remove callback for remove changes', () => {
-			const cbRemove = sinon.spy();
-
-			dispatcher.on( 'remove', cbRemove );
-
-			model.change( writer => {
-				writer.remove( image );
-			} );
-
-			expect( cbRemove.called ).to.be.true;
-		} );
-
-		it( 'should fire addAttribute callbacks for add attribute change', () => {
-			const cbAddText = sinon.spy();
-			const cbAddImage = sinon.spy();
-
-			dispatcher.on( 'addAttribute:key:$text', cbAddText );
-			dispatcher.on( 'addAttribute:key:image', cbAddImage );
-
-			model.change( writer => {
-				writer.setAttribute( 'key', 'value', image );
-			} );
+			const position = new ModelPosition( root, [ 0 ] );
+			const range = ModelRange.createFromPositionAndShift( position, 1 );
 
-			// Callback for adding attribute on text not called.
-			expect( cbAddText.called ).to.be.false;
-			expect( cbAddImage.calledOnce ).to.be.true;
+			differStub.getChanges = () => [ { type: 'insert', position, length: 1 } ];
 
-			model.change( writer => {
-				writer.setAttribute( 'key', 'value', ModelRange.createFromParentsAndOffsets( root, 3, root, 4 ) );
-			} );
+			dispatcher.convertChanges( differStub );
 
-			expect( cbAddText.calledOnce ).to.be.true;
-			// Callback for adding attribute on image not called this time.
-			expect( cbAddImage.calledOnce ).to.be.true;
+			expect( dispatcher.convertInsert.calledOnce ).to.be.true;
+			expect( dispatcher.convertInsert.firstCall.args[ 0 ].isEqual( range ) ).to.be.true;
 		} );
 
-		it( 'should fire changeAttribute callbacks for change attribute change', () => {
-			const cbChangeText = sinon.spy();
-			const cbChangeImage = sinon.spy();
+		it( 'should call convertRemove for remove change', () => {
+			sinon.stub( dispatcher, 'convertRemove' );
 
-			dispatcher.on( 'changeAttribute:key:$text', cbChangeText );
-			dispatcher.on( 'changeAttribute:key:image', cbChangeImage );
+			const position = new ModelPosition( root, [ 0 ] );
 
-			model.change( writer => {
-				writer.setAttribute( 'key', 'value', image );
-				writer.setAttribute( 'key', 'newValue', image );
-			} );
+			differStub.getChanges = () => [ { type: 'remove', position, length: 2, name: '$text' } ];
 
-			// Callback for adding attribute on text not called.
-			expect( cbChangeText.called ).to.be.false;
-			expect( cbChangeImage.calledOnce ).to.be.true;
+			dispatcher.convertChanges( differStub );
 
-			const range = ModelRange.createFromParentsAndOffsets( root, 3, root, 4 );
-			model.change( writer => {
-				writer.setAttribute( 'key', 'value', range );
-				writer.setAttribute( 'key', 'newValue', range );
-			} );
-
-			expect( cbChangeText.calledOnce ).to.be.true;
-			// Callback for adding attribute on image not called this time.
-			expect( cbChangeImage.calledOnce ).to.be.true;
+			expect( dispatcher.convertRemove.calledWith( position, 2, '$text' ) ).to.be.true;
 		} );
 
-		it( 'should fire removeAttribute callbacks for remove attribute change', () => {
-			const cbRemoveText = sinon.spy();
-			const cbRemoveImage = sinon.spy();
+		it( 'should call convertAttribute for attribute change', () => {
+			sinon.stub( dispatcher, 'convertAttribute' );
 
-			dispatcher.on( 'removeAttribute:key:$text', cbRemoveText );
-			dispatcher.on( 'removeAttribute:key:image', cbRemoveImage );
+			const position = new ModelPosition( root, [ 0 ] );
+			const range = ModelRange.createFromPositionAndShift( position, 1 );
 
-			writer.setAttribute( 'key', 'value', image );
-			writer.removeAttribute( 'key', image );
+			differStub.getChanges = () => [
+				{ type: 'attribute', position, range, attributeKey: 'key', attributeOldValue: null, attributeNewValue: 'foo' }
+			];
 
-			// Callback for adding attribute on text not called.
-			expect( cbRemoveText.called ).to.be.false;
-			expect( cbRemoveImage.calledOnce ).to.be.true;
+			dispatcher.convertChanges( differStub );
 
-			const range = ModelRange.createFromParentsAndOffsets( root, 3, root, 4 );
-			writer.setAttribute( 'key', 'value', range );
-			writer.removeAttribute( 'key', range );
-
-			expect( cbRemoveText.calledOnce ).to.be.true;
-			// Callback for adding attribute on image not called this time.
-			expect( cbRemoveImage.calledOnce ).to.be.true;
+			expect( dispatcher.convertAttribute.calledWith( range, 'key', null, 'foo' ) ).to.be.true;
 		} );
 
-		it( 'should not fire any event if not recognized event type was passed', () => {
-			sinon.spy( dispatcher, 'fire' );
-
-			dispatcher.convertChange( 'unknown', { foo: 'bar' } );
-
-			expect( dispatcher.fire.called ).to.be.false;
-		} );
-
-		it( 'should not fire any event if changed range in graveyard root and change type is different than remove', () => {
-			sinon.spy( dispatcher, 'fire' );
-
-			const gyNode = new ModelElement( 'image' );
-			doc.graveyard.appendChildren( gyNode );
-
-			writer.setAttribute( 'key', 'value', gyNode );
+		it( 'should handle multiple changes', () => {
+			sinon.stub( dispatcher, 'convertInsert' );
+			sinon.stub( dispatcher, 'convertRemove' );
+			sinon.stub( dispatcher, 'convertAttribute' );
 
-			expect( dispatcher.fire.called ).to.be.false;
-		} );
+			const position = new ModelPosition( root, [ 0 ] );
+			const range = ModelRange.createFromPositionAndShift( position, 1 );
 
-		it( 'should not fire any event if remove operation moves nodes between graveyard holders', () => {
-			// This may happen during OT.
-			sinon.spy( dispatcher, 'fire' );
+			differStub.getChanges = () => [
+				{ type: 'insert', position, length: 1 },
+				{ type: 'attribute', position, range, attributeKey: 'key', attributeOldValue: null, attributeNewValue: 'foo' },
+				{ type: 'remove', position, length: 1, name: 'paragraph' },
+				{ type: 'insert', position, length: 3 },
+			];
 
-			const gyNode = new ModelElement( 'image' );
-			doc.graveyard.appendChildren( gyNode );
+			dispatcher.convertChanges( differStub );
 
-			writer.remove( gyNode );
-
-			expect( dispatcher.fire.called ).to.be.false;
+			expect( dispatcher.convertInsert.calledTwice ).to.be.true;
+			expect( dispatcher.convertRemove.calledOnce ).to.be.true;
+			expect( dispatcher.convertAttribute.calledOnce ).to.be.true;
 		} );
 
-		it( 'should not fire any event if element in graveyard was removed', () => {
-			// This may happen during OT.
-			sinon.spy( dispatcher, 'fire' );
+		it( 'should call convertMarkerAdd when markers are added', () => {
+			sinon.stub( dispatcher, 'convertMarkerAdd' );
 
-			const gyNode = new ModelElement( 'image' );
-			doc.graveyard.appendChildren( gyNode );
+			const fooRange = ModelRange.createFromParentsAndOffsets( root, 0, root, 1 );
+			const barRange = ModelRange.createFromParentsAndOffsets( root, 3, root, 6 );
 
-			writer.rename( gyNode, 'p' );
+			differStub.getMarkersToAdd = () => [
+				{ name: 'foo', range: fooRange },
+				{ name: 'bar', range: barRange }
+			];
 
-			expect( dispatcher.fire.called ).to.be.false;
-		} );
+			dispatcher.convertChanges( differStub );
 
-		it( 'should not fire any event after NoOperation is applied', () => {
-			sinon.spy( dispatcher, 'fire' );
-
-			model.applyOperation( wrapInDelta( new NoOperation( 0 ) ) );
-
-			expect( dispatcher.fire.called ).to.be.false;
+			expect( dispatcher.convertMarkerAdd.calledWith( 'foo', fooRange ) );
+			expect( dispatcher.convertMarkerAdd.calledWith( 'bar', barRange ) );
 		} );
 
-		it( 'should not fire any event after RenameOperation with same old and new value is applied', () => {
-			sinon.spy( dispatcher, 'fire' );
-
-			root.removeChildren( 0, root.childCount );
-			root.appendChildren( [ new ModelElement( 'paragraph' ) ] );
-
-			model.applyOperation( wrapInDelta( new RenameOperation( new ModelPosition( root, [ 0 ] ), 'paragraph', 'paragraph', 0 ) ) );
-
-			expect( dispatcher.fire.called ).to.be.false;
-		} );
+		it( 'should call convertMarkerRemove when markers are removed', () => {
+			sinon.stub( dispatcher, 'convertMarkerRemove' );
 
-		it( 'should not fire any event after AttributeOperation with same old an new value is applied', () => {
-			sinon.spy( dispatcher, 'fire' );
+			const fooRange = ModelRange.createFromParentsAndOffsets( root, 0, root, 1 );
+			const barRange = ModelRange.createFromParentsAndOffsets( root, 3, root, 6 );
 
-			root.removeChildren( 0, root.childCount );
-			root.appendChildren( [ new ModelElement( 'paragraph', { foo: 'bar' } ) ] );
+			differStub.getMarkersToRemove = () => [
+				{ name: 'foo', range: fooRange },
+				{ name: 'bar', range: barRange }
+			];
 
-			const range = new ModelRange( new ModelPosition( root, [ 0 ] ), new ModelPosition( root, [ 0, 0 ] ) );
-			model.applyOperation( wrapInDelta( new AttributeOperation( range, 'foo', 'bar', 'bar', 0 ) ) );
+			dispatcher.convertChanges( differStub );
 
-			expect( dispatcher.fire.called ).to.be.false;
+			expect( dispatcher.convertMarkerRemove.calledWith( 'foo', fooRange ) );
+			expect( dispatcher.convertMarkerRemove.calledWith( 'bar', barRange ) );
 		} );
 	} );
 
@@ -292,30 +164,30 @@ describe( 'ModelConversionDispatcher', () => {
 			} );
 
 			// Same here.
-			dispatcher.on( 'addAttribute', ( evt, data, consumable ) => {
+			dispatcher.on( 'attribute', ( evt, data, consumable ) => {
 				const itemId = data.item.name ? data.item.name : '$text:' + data.item.data;
 				const key = data.attributeKey;
 				const value = data.attributeNewValue;
-				const log = 'addAttribute:' + key + ':' + value + ':' + itemId + ':' + data.range.start.path + ':' + data.range.end.path;
+				const log = 'attribute:' + key + ':' + value + ':' + itemId + ':' + data.range.start.path + ':' + data.range.end.path;
 
 				loggedEvents.push( log );
 
-				expect( evt.name ).to.equal( 'addAttribute:' + key + ':' + ( data.item.name || '$text' ) );
-				expect( consumable.consume( data.item, 'addAttribute:' + key ) ).to.be.true;
+				expect( evt.name ).to.equal( 'attribute:' + key + ':' + ( data.item.name || '$text' ) );
+				expect( consumable.consume( data.item, 'attribute:' + key ) ).to.be.true;
 			} );
 
-			dispatcher.convertInsertion( range );
+			dispatcher.convertInsert( range );
 
 			// Check the data passed to called events and the order of them.
 			expect( loggedEvents ).to.deep.equal( [
 				'insert:$text:foo:0:3',
-				'addAttribute:bold:true:$text:foo:0:3',
+				'attribute:bold:true:$text:foo:0:3',
 				'insert:image:3:4',
 				'insert:$text:bar:4:7',
 				'insert:paragraph:7:8',
-				'addAttribute:class:nice:paragraph:7:8',
+				'attribute:class:nice:paragraph:7:8',
 				'insert:$text:xx:7,0:7,2',
-				'addAttribute:italic:true:$text:xx:7,0:7,2'
+				'attribute:italic:true:$text:xx:7,0:7,2'
 			] );
 		} );
 
@@ -330,289 +202,38 @@ describe( 'ModelConversionDispatcher', () => {
 
 			dispatcher.on( 'insert:image', ( evt, data, consumable ) => {
 				consumable.consume( data.item.getChild( 0 ), 'insert' );
-				consumable.consume( data.item, 'addAttribute:bold' );
+				consumable.consume( data.item, 'attribute:bold' );
 			} );
 
 			const range = ModelRange.createIn( root );
 
-			dispatcher.convertInsertion( range );
+			dispatcher.convertInsert( range );
 
 			expect( dispatcher.fire.calledWith( 'insert:image' ) ).to.be.true;
-			expect( dispatcher.fire.calledWith( 'addAttribute:src:image' ) ).to.be.true;
-			expect( dispatcher.fire.calledWith( 'addAttribute:title:image' ) ).to.be.true;
+			expect( dispatcher.fire.calledWith( 'attribute:src:image' ) ).to.be.true;
+			expect( dispatcher.fire.calledWith( 'attribute:title:image' ) ).to.be.true;
 			expect( dispatcher.fire.calledWith( 'insert:$text' ) ).to.be.true;
 
-			expect( dispatcher.fire.calledWith( 'addAttribute:bold:image' ) ).to.be.false;
+			expect( dispatcher.fire.calledWith( 'attribute:bold:image' ) ).to.be.false;
 			expect( dispatcher.fire.calledWith( 'insert:caption' ) ).to.be.false;
 		} );
-
-		it( 'should fire marker converter if content is inserted into marker', () => {
-			const convertMarkerSpy = sinon.spy( dispatcher, 'convertMarker' );
-			const paragraph1 = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) );
-			const paragraph2 = new ModelElement( 'paragraph', null, new ModelText( 'bar' ) );
-			root.appendChildren( [ paragraph1, paragraph2 ] );
-
-			const markerRange = ModelRange.createFromParentsAndOffsets( root, 0, root, 2 );
-			model.markers.set( 'marker', markerRange );
-
-			const insertionRange = ModelRange.createOn( paragraph2 );
-			dispatcher.convertInsertion( insertionRange );
-
-			sinon.assert.calledOnce( convertMarkerSpy );
-			const callArgs = convertMarkerSpy.args[ 0 ];
-			expect( callArgs[ 0 ] ).to.equal( 'addMarker' );
-			expect( callArgs[ 1 ] ).to.equal( 'marker' );
-			expect( callArgs[ 2 ].isEqual( markerRange.getIntersection( insertionRange ) ) ).to.be.true;
-		} );
-
-		it( 'should fire marker converter if content has marker', () => {
-			const convertMarkerSpy = sinon.spy( dispatcher, 'convertMarker' );
-			const paragraph1 = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) );
-			const paragraph2 = new ModelElement( 'paragraph', null, new ModelText( 'bar' ) );
-			root.appendChildren( [ paragraph1, paragraph2 ] );
-
-			const markerRange = ModelRange.createIn( paragraph2 );
-			model.markers.set( 'marker', markerRange );
-
-			const insertionRange = ModelRange.createOn( paragraph2 );
-			dispatcher.convertInsertion( insertionRange );
-
-			sinon.assert.calledOnce( convertMarkerSpy );
-			const callArgs = convertMarkerSpy.args[ 0 ];
-			expect( callArgs[ 0 ] ).to.equal( 'addMarker' );
-			expect( callArgs[ 1 ] ).to.equal( 'marker' );
-			expect( callArgs[ 2 ].isEqual( markerRange ) ).to.be.true;
-		} );
-
-		it( 'should not fire marker conversion if content is inserted into element with custom highlight handling', () => {
-			sinon.spy( dispatcher, 'convertMarker' );
-
-			const text = new ModelText( 'abc' );
-			const caption = new ModelElement( 'caption', null, text );
-			const image = new ModelElement( 'image', null, caption );
-			root.appendChildren( [ image ] );
-
-			// Create view elements that will be "mapped" to model elements.
-			const viewCaption = new ViewContainerElement( 'caption' );
-			const viewFigure = new ViewContainerElement( 'figure', null, viewCaption );
-
-			// Create custom highlight handler mock.
-			viewFigure.setCustomProperty( 'addHighlight', () => {} );
-			viewFigure.setCustomProperty( 'removeHighlight', () => {} );
-
-			// Create mapper mock.
-			dispatcher.conversionApi.mapper = {
-				toViewElement( modelElement ) {
-					if ( modelElement == image ) {
-						return viewFigure;
-					} else if ( modelElement == caption ) {
-						return viewCaption;
-					}
-				}
-			};
-
-			const markerRange = ModelRange.createFromParentsAndOffsets( root, 0, root, 1 );
-			model.markers.set( 'marker', markerRange );
-
-			const insertionRange = ModelRange.createFromParentsAndOffsets( caption, 1, caption, 2 );
-			dispatcher.convertInsertion( insertionRange );
-
-			expect( dispatcher.convertMarker.called ).to.be.false;
-		} );
-
-		it( 'should fire marker conversion if inserted into element with highlight handling but element is not in marker range', () => {
-			sinon.spy( dispatcher, 'convertMarker' );
-
-			const text = new ModelText( 'abc' );
-			const caption = new ModelElement( 'caption', null, text );
-			const image = new ModelElement( 'image', null, caption );
-			root.appendChildren( [ image ] );
-
-			// Create view elements that will be "mapped" to model elements.
-			const viewCaption = new ViewContainerElement( 'caption' );
-			const viewFigure = new ViewContainerElement( 'figure', null, viewCaption );
-
-			// Create custom highlight handler mock.
-			viewFigure.setCustomProperty( 'addHighlight', () => {} );
-			viewFigure.setCustomProperty( 'removeHighlight', () => {} );
-
-			// Create mapper mock.
-			dispatcher.conversionApi.mapper = {
-				toViewElement( modelElement ) {
-					if ( modelElement == image ) {
-						return viewFigure;
-					} else if ( modelElement == caption ) {
-						return viewCaption;
-					}
-				}
-			};
-
-			const markerRange = ModelRange.createFromParentsAndOffsets( caption, 0, caption, 3 );
-			model.markers.set( 'marker', markerRange );
-
-			const insertionRange = ModelRange.createFromParentsAndOffsets( caption, 2, caption, 3 );
-			dispatcher.convertInsertion( insertionRange );
-
-			expect( dispatcher.convertMarker.called ).to.be.true;
-		} );
-	} );
-
-	describe( 'convertMove', () => {
-		let loggedEvents;
-
-		beforeEach( () => {
-			loggedEvents = [];
-
-			dispatcher.on( 'remove', ( evt, data ) => {
-				const log = 'remove:' + data.sourcePosition.path + ':' + data.item.offsetSize;
-				loggedEvents.push( log );
-			} );
-
-			dispatcher.on( 'insert', ( evt, data ) => {
-				const log = 'insert:' + data.range.start.path + ':' + data.range.end.path;
-				loggedEvents.push( log );
-			} );
-		} );
-
-		it( 'should first fire remove and then insert if moving "right"', () => {
-			// <root>[ab]cd^ef</root> -> <root>cdabef</root>
-			root.appendChildren( new ModelText( 'cdabef' ) );
-
-			const sourcePosition = ModelPosition.createFromParentAndOffset( root, 0 );
-			const movedRange = ModelRange.createFromParentsAndOffsets( root, 2, root, 4 );
-
-			dispatcher.convertMove( sourcePosition, movedRange );
-
-			// after remove: cdef
-			// after insert: cd[ab]ef
-			expect( loggedEvents ).to.deep.equal( [ 'remove:0:2', 'insert:2:4' ] );
-		} );
-
-		it( 'should first fire insert and then remove if moving "left"', () => {
-			// <root>ab^cd[ef]</root> -> <root>abefcd</root>
-			root.appendChildren( new ModelText( 'abefcd' ) );
-
-			const sourcePosition = ModelPosition.createFromParentAndOffset( root, 4 );
-			const movedRange = ModelRange.createFromParentsAndOffsets( root, 2, root, 4 );
-
-			dispatcher.convertMove( sourcePosition, movedRange );
-
-			// after insert: ab[ef]cd[ef]
-			// after remove: ab[ef]cd
-			expect( loggedEvents ).to.deep.equal( [ 'insert:2:4', 'remove:6:2' ] );
-		} );
-
-		it( 'should first fire insert and then remove when moving like in unwrap', () => {
-			// <root>a^<w>[xyz]</w>b</root> -> <root>axyz<w></w>b</root>
-			root.appendChildren( [
-				new ModelText( 'axyz' ),
-				new ModelElement( 'w' ),
-				new ModelText( 'b' )
-			] );
-
-			const sourcePosition = new ModelPosition( root, [ 1, 0 ] );
-			const movedRange = ModelRange.createFromParentsAndOffsets( root, 1, root, 4 );
-
-			dispatcher.convertMove( sourcePosition, movedRange );
-
-			// before:       a<w>[xyz]</w>b
-			// after insert: a[xyz]<w>[xyz]</w>b
-			// after remove: a[xyz]<w></w>b
-			expect( loggedEvents ).to.deep.equal( [ 'insert:1:4', 'remove:4,0:3' ] );
-		} );
-
-		it( 'should first fire remove and then insert when moving like in wrap', () => {
-			// <root>a[xyz]<w>^</w>b</root> -> <root>a<w>xyz</w>b</root>
-			root.appendChildren( [
-				new ModelText( 'a' ),
-				new ModelElement( 'w', null, [ new ModelText( 'xyz' ) ] ),
-				new ModelText( 'b' )
-			] );
-
-			const sourcePosition = ModelPosition.createFromParentAndOffset( root, 1 );
-			const movedRange = ModelRange.createFromPositionAndShift( new ModelPosition( root, [ 1, 0 ] ), 3 );
-
-			dispatcher.convertMove( sourcePosition, movedRange );
-
-			// before:       a[xyz]<w></w>b
-			// after remove: a<w></w>b
-			// after insert: a<w>[xyz]</w>b
-			expect( loggedEvents ).to.deep.equal( [ 'remove:1:3', 'insert:1,0:1,3' ] );
-		} );
 	} );
 
 	describe( 'convertRemove', () => {
 		it( 'should fire event for removed range', () => {
-			root.appendChildren( new ModelText( 'foo' ) );
-			doc.graveyard.appendChildren( new ModelText( 'bar' ) );
-
-			const range = ModelRange.createFromParentsAndOffsets( doc.graveyard, 0, doc.graveyard, 3 );
 			const loggedEvents = [];
 
-			dispatcher.on( 'remove', ( evt, data ) => {
-				const log = 'remove:' + data.sourcePosition.path + ':' + data.item.offsetSize;
+			dispatcher.on( 'remove:$text', ( evt, data ) => {
+				const log = 'remove:' + data.position.path + ':' + data.length;
 				loggedEvents.push( log );
 			} );
 
-			dispatcher.convertRemove( ModelPosition.createFromParentAndOffset( root, 3 ), range );
+			dispatcher.convertRemove( ModelPosition.createFromParentAndOffset( root, 3 ), 3, '$text' );
 
 			expect( loggedEvents ).to.deep.equal( [ 'remove:3:3' ] );
 		} );
 	} );
 
-	describe( 'convertAttribute', () => {
-		it( 'should fire event for every item in passed range', () => {
-			root.appendChildren( [
-				new ModelText( 'foo', { bold: true } ),
-				new ModelElement( 'image', { bold: true } ),
-				new ModelElement( 'paragraph', { bold: true, class: 'nice' }, new ModelText( 'xx', { bold: true, italic: true } ) )
-			] );
-
-			const range = ModelRange.createIn( root );
-			const loggedEvents = [];
-
-			dispatcher.on( 'addAttribute', ( evt, data, consumable ) => {
-				const itemId = data.item.name ? data.item.name : '$text:' + data.item.data;
-				const key = data.attributeKey;
-				const value = data.attributeNewValue;
-				const log = 'addAttribute:' + key + ':' + value + ':' + itemId + ':' + data.range.start.path + ':' + data.range.end.path;
-
-				loggedEvents.push( log );
-
-				expect( evt.name ).to.equal( 'addAttribute:' + key + ':' + ( data.item.name || '$text' ) );
-				expect( consumable.consume( data.item, 'addAttribute:' + key ) ).to.be.true;
-			} );
-
-			dispatcher.convertAttribute( 'addAttribute', range, 'bold', null, true );
-
-			expect( loggedEvents ).to.deep.equal( [
-				'addAttribute:bold:true:$text:foo:0:3',
-				'addAttribute:bold:true:image:3:4',
-				'addAttribute:bold:true:paragraph:4:5',
-				'addAttribute:bold:true:$text:xx:4,0:4,2'
-			] );
-		} );
-
-		it( 'should not fire events for already consumed parts of model', () => {
-			root.appendChildren( [
-				new ModelElement( 'element', null, new ModelElement( 'inside' ) )
-			] );
-
-			sinon.spy( dispatcher, 'fire' );
-
-			dispatcher.on( 'removeAttribute:attr:element', ( evt, data, consumable ) => {
-				consumable.consume( data.item.getChild( 0 ), 'removeAttribute:attr' );
-			} );
-
-			const range = ModelRange.createIn( root );
-
-			dispatcher.convertAttribute( 'removeAttribute', range, 'attr', 'value', null );
-
-			expect( dispatcher.fire.calledWith( 'removeAttribute:attr:element' ) ).to.be.true;
-			expect( dispatcher.fire.calledWith( 'removeAttribute:attr:inside' ) ).to.be.false;
-		} );
-	} );
-
 	describe( 'convertSelection', () => {
 		beforeEach( () => {
 			dispatcher.off( 'selection' );
@@ -636,7 +257,7 @@ describe( 'ModelConversionDispatcher', () => {
 		} );
 
 		it( 'should prepare correct list of consumable values', () => {
-			model.enqueueChange( writer => {
+			model.change( writer => {
 				writer.setAttribute( 'bold', true, ModelRange.createIn( root ) );
 				writer.setAttribute( 'italic', true, ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ) );
 			} );
@@ -653,7 +274,7 @@ describe( 'ModelConversionDispatcher', () => {
 		it( 'should fire attributes events for selection', () => {
 			sinon.spy( dispatcher, 'fire' );
 
-			model.enqueueChange( writer => {
+			model.change( writer => {
 				writer.setAttribute( 'bold', true, ModelRange.createIn( root ) );
 				writer.setAttribute( 'italic', true, ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ) );
 			} );
@@ -671,7 +292,7 @@ describe( 'ModelConversionDispatcher', () => {
 				consumable.consume( data.selection, 'selectionAttribute:bold' );
 			} );
 
-			model.enqueueChange( writer => {
+			model.change( writer => {
 				writer.setAttribute( 'bold', true, ModelRange.createIn( root ) );
 				writer.setAttribute( 'italic', true, ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ) );
 			} );
@@ -750,37 +371,30 @@ describe( 'ModelConversionDispatcher', () => {
 		} );
 	} );
 
-	describe( 'convertMarker', () => {
-		let range;
+	describe( 'convertMarkerAdd', () => {
+		let range, element, text;
 
 		beforeEach( () => {
-			const element = new ModelElement( 'paragraph', null, [ new ModelText( 'foo bar baz' ) ] );
+			text = new ModelText( 'foo bar baz' );
+			element = new ModelElement( 'paragraph', null, [ text ] );
 			root.appendChildren( [ element ] );
 
 			range = ModelRange.createFromParentsAndOffsets( element, 0, element, 4 );
 		} );
 
-		it( 'should fire event based on passed parameters', () => {
+		it( 'should fire addMarker event', () => {
 			sinon.spy( dispatcher, 'fire' );
 
-			dispatcher.convertMarker( 'addMarker', 'name', range );
+			dispatcher.convertMarkerAdd( 'name', range );
 
 			expect( dispatcher.fire.calledWith( 'addMarker:name' ) ).to.be.true;
-
-			dispatcher.convertMarker( 'removeMarker', 'name', range );
-
-			expect( dispatcher.fire.calledWith( 'removeMarker:name' ) ).to.be.true;
 		} );
 
-		it( 'should not convert marker if it is added in graveyard', () => {
+		it( 'should not convert marker if it is in graveyard', () => {
 			const gyRange = ModelRange.createFromParentsAndOffsets( doc.graveyard, 0, doc.graveyard, 0 );
 			sinon.spy( dispatcher, 'fire' );
 
-			dispatcher.convertMarker( 'addMarker', 'name', gyRange );
-
-			expect( dispatcher.fire.called ).to.be.false;
-
-			dispatcher.convertMarker( 'removeMarker', 'name', gyRange );
+			dispatcher.convertMarkerAdd( 'name', gyRange );
 
 			expect( dispatcher.fire.called ).to.be.false;
 		} );
@@ -790,118 +404,120 @@ describe( 'ModelConversionDispatcher', () => {
 			const eleRange = ModelRange.createFromParentsAndOffsets( element, 1, element, 2 );
 			sinon.spy( dispatcher, 'fire' );
 
-			dispatcher.convertMarker( 'addMarker', 'name', eleRange );
+			dispatcher.convertMarkerAdd( 'name', eleRange );
 
 			expect( dispatcher.fire.called ).to.be.false;
+		} );
 
-			dispatcher.convertMarker( 'removeMarker', 'name', eleRange );
+		it( 'should fire conversion for each item in the range', () => {
+			range = ModelRange.createIn( root );
 
-			expect( dispatcher.fire.called ).to.be.false;
-		} );
+			const items = [];
 
-		it( 'should prepare consumable values', () => {
 			dispatcher.on( 'addMarker:name', ( evt, data, consumable ) => {
-				expect( consumable.test( data.item, 'addMarker:name' ) ).to.be.true;
-			} );
+				expect( data.markerName ).to.equal( 'name' );
+				expect( data.markerRange.isEqual( range ) ).to.be.true;
+				expect( consumable.test( data.item, 'addMarker:name' ) );
 
-			dispatcher.on( 'removeMarker:name', ( evt, data, consumable ) => {
-				expect( consumable.test( data.item, 'removeMarker:name' ) ).to.be.true;
+				items.push( data.item );
 			} );
 
-			dispatcher.convertMarker( 'addMarker', 'name', range );
-			dispatcher.convertMarker( 'removeMarker', 'name', range );
+			dispatcher.convertMarkerAdd( 'name', range );
+
+			expect( items[ 0 ] ).to.equal( element );
+			expect( items[ 1 ].data ).to.equal( text.data );
 		} );
 
-		it( 'should fire conversion for each item in the range', () => {
-			const element = new ModelElement( 'paragraph', null, [ new ModelText( 'foo bar baz' ) ] );
-			root.appendChildren( [ element ] );
+		it( 'should be possible to override', () => {
 			range = ModelRange.createIn( root );
 
-			const addMarkerData = [];
-			const removeMarkerData = [];
-
-			dispatcher.on( 'addMarker:name', ( evt, data ) => addMarkerData.push( data ) );
-			dispatcher.on( 'removeMarker:name', ( evt, data ) => removeMarkerData.push( data ) );
+			const addMarkerSpy = sinon.spy();
+			const highAddMarkerSpy = sinon.spy();
 
-			dispatcher.convertMarker( 'addMarker', 'name', range );
-			dispatcher.convertMarker( 'removeMarker', 'name', range );
+			dispatcher.on( 'addMarker:marker', addMarkerSpy );
 
-			// Check if events for all elements were fired.
-			let i = 0;
-			for ( const val of range ) {
-				const nodeInRange = val.item;
-				const addData = addMarkerData[ i ];
-				const removeData = removeMarkerData[ i ];
+			dispatcher.on( 'addMarker:marker', evt => {
+				highAddMarkerSpy();
 
-				expect( addData.markerName ).to.equal( 'name' );
-				expect( addData.markerRange ).to.equal( range );
-				expect( addData.range.isEqual( ModelRange.createOn( nodeInRange ) ) );
+				evt.stop();
+			}, { priority: 'high' } );
 
-				expect( removeData.markerName ).to.equal( 'name' );
-				expect( removeData.markerRange ).to.equal( range );
-				expect( removeData.range.isEqual( ModelRange.createOn( nodeInRange ) ) );
+			dispatcher.convertMarkerAdd( 'marker', range );
 
-				if ( nodeInRange.is( 'textProxy' ) ) {
-					expect( nodeInRange.data ).to.equal( addData.item.data );
-					expect( nodeInRange.data ).to.equal( removeData.item.data );
-				} else {
-					expect( nodeInRange ).to.equal( addData.item );
-					expect( nodeInRange ).to.equal( removeData.item );
-				}
+			expect( addMarkerSpy.called ).to.be.false;
 
-				i++;
-			}
+			// Called once for each item, twice total.
+			expect( highAddMarkerSpy.calledTwice ).to.be.true;
 		} );
+	} );
+
+	describe( 'convertMarkerRemove', () => {
+		let range, element, text;
 
-		it( 'should not fire events for already consumed items', () => {
-			const element = new ModelElement( 'paragraph', null, [ new ModelText( 'foo bar baz' ) ] );
+		beforeEach( () => {
+			text = new ModelText( 'foo bar baz' );
+			element = new ModelElement( 'paragraph', null, [ text ] );
 			root.appendChildren( [ element ] );
-			const range = ModelRange.createIn( root );
-			const addMarkerSpy = sinon.spy( ( evt, data, consumable ) => {
-				// Consume all items in marker range.
-				for ( const value of data.markerRange ) {
-					consumable.consume( value.item, 'addMarker:marker' );
-				}
-			} );
 
-			const removeMarkerSpy = sinon.spy( ( evt, data, consumable ) => {
-				// Consume all items in marker range.
-				for ( const value of data.markerRange ) {
-					consumable.consume( value.item, 'removeMarker:marker' );
-				}
-			} );
+			range = ModelRange.createFromParentsAndOffsets( element, 0, element, 4 );
+		} );
 
-			dispatcher.on( 'addMarker:marker', addMarkerSpy );
-			dispatcher.on( 'addMarker:marker', removeMarkerSpy );
+		it( 'should fire removeMarker event', () => {
+			sinon.spy( dispatcher, 'fire' );
 
-			dispatcher.convertMarker( 'addMarker', 'marker', range );
-			dispatcher.convertMarker( 'removeMarker', 'marker', range );
+			dispatcher.convertMarkerRemove( 'name', range );
 
-			sinon.assert.calledOnce( addMarkerSpy );
-			sinon.assert.calledOnce( removeMarkerSpy );
+			expect( dispatcher.fire.calledWith( 'removeMarker:name' ) ).to.be.true;
 		} );
 
-		it( 'should fire event for collapsed marker', () => {
-			const range = ModelRange.createFromParentsAndOffsets( root, 1, root, 1 );
-			const addMarkerSpy = sinon.spy( ( evt, data, consumable ) => {
-				expect( data.markerRange ).to.equal( range );
-				expect( data.markerName ).to.equal( 'marker' );
-				expect( consumable.test( data.markerRange, evt.name ) ).to.be.true;
-			} );
-			const removeMarkerSpy = sinon.spy( ( evt, data, consumable ) => {
-				expect( data.markerRange ).to.equal( range );
-				expect( data.markerName ).to.equal( 'marker' );
-				expect( consumable.test( data.markerRange, evt.name ) ).to.be.true;
+		it( 'should not convert marker if it is in graveyard', () => {
+			const gyRange = ModelRange.createFromParentsAndOffsets( doc.graveyard, 0, doc.graveyard, 0 );
+			sinon.spy( dispatcher, 'fire' );
+
+			dispatcher.convertMarkerRemove( 'name', gyRange );
+
+			expect( dispatcher.fire.called ).to.be.false;
+		} );
+
+		it( 'should not convert marker if it is not in model root', () => {
+			const element = new ModelElement( 'element', null, new ModelText( 'foo' ) );
+			const eleRange = ModelRange.createFromParentsAndOffsets( element, 1, element, 2 );
+			sinon.spy( dispatcher, 'fire' );
+
+			dispatcher.convertMarkerRemove( 'name', eleRange );
+
+			expect( dispatcher.fire.called ).to.be.false;
+		} );
+
+		it( 'should fire conversion for the range', () => {
+			range = ModelRange.createIn( root );
+
+			dispatcher.on( 'addMarker:name', ( evt, data ) => {
+				expect( data.markerName ).to.equal( 'name' );
+				expect( data.markerRange.isEqual( range ) ).to.be.true;
 			} );
 
-			dispatcher.on( 'addMarker:marker', addMarkerSpy );
-			dispatcher.on( 'addMarker:marker', removeMarkerSpy );
+			dispatcher.convertMarkerRemove( 'name', range );
+		} );
+
+		it( 'should be possible to override', () => {
+			range = ModelRange.createIn( root );
+
+			const removeMarkerSpy = sinon.spy();
+			const highRemoveMarkerSpy = sinon.spy();
+
+			dispatcher.on( 'removeMarker:marker', removeMarkerSpy );
+
+			dispatcher.on( 'removeMarker:marker', evt => {
+				highRemoveMarkerSpy();
+
+				evt.stop();
+			}, { priority: 'high' } );
 
-			dispatcher.convertMarker( 'addMarker', 'marker', range );
-			dispatcher.convertMarker( 'removeMarker', 'marker', range );
+			dispatcher.convertMarkerRemove( 'marker', range );
 
-			sinon.assert.calledOnce( addMarkerSpy );
-			sinon.assert.calledOnce( removeMarkerSpy );
+			expect( removeMarkerSpy.called ).to.be.false;
+			expect( highRemoveMarkerSpy.calledOnce ).to.be.true;
 		} );
 	} );
 } );

+ 7 - 12
packages/ckeditor5-engine/tests/dev-utils/enableenginedebug.js

@@ -802,12 +802,14 @@ describe( 'debug tools', () => {
 			const modelRoot = modelDoc.getRoot();
 			const viewDoc = editor.editing.view;
 
-			const insert = new InsertOperation( ModelPosition.createAt( modelRoot, 0 ), new ModelText( 'foobar' ), 0 );
-			model.applyOperation( wrapInDelta( insert ) );
+			model.change( () => {
+				const insert = new InsertOperation( ModelPosition.createAt( modelRoot, 0 ), new ModelText( 'foobar' ), 0 );
+				model.applyOperation( wrapInDelta( insert ) );
 
-			const graveyard = modelDoc.graveyard;
-			const remove = new RemoveOperation( ModelPosition.createAt( modelRoot, 1 ), 2, ModelPosition.createAt( graveyard, 0 ), 1 );
-			model.applyOperation( wrapInDelta( remove ) );
+				const graveyard = modelDoc.graveyard;
+				const remove = new RemoveOperation( ModelPosition.createAt( modelRoot, 1 ), 2, ModelPosition.createAt( graveyard, 0 ), 1 );
+				model.applyOperation( wrapInDelta( remove ) );
+			} );
 
 			log.reset();
 
@@ -850,13 +852,6 @@ describe( 'debug tools', () => {
 				'<div></div>'
 			);
 
-			viewDoc.log( 1 );
-			expectLog(
-				'<div>' +
-				'\n\tfoobar' +
-				'\n</div>'
-			);
-
 			viewDoc.log( 2 );
 			expectLog(
 				'<div>' +

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

@@ -244,8 +244,7 @@ describe( 'model test utils', () => {
 			] );
 
 			expect( stringify( root ) ).to.equal(
-				// Because of https://github.com/ckeditor/ckeditor5-engine/issues/562 attributes are not merged
-				'<$text bold="true">foo</$text>bar<$text bold="true"><$text italic="true">bom</$text></$text>' +
+				'<$text bold="true">foo</$text>bar<$text bold="true" italic="true">bom</$text>' +
 				'<a><$text bold="true" underline="true">pom</$text></a>'
 			);
 		} );

+ 3 - 3
packages/ckeditor5-engine/tests/manual/highlight.html

@@ -1,14 +1,14 @@
 <style>
 	span.highlight-yellow {
-		background-color: yellow;
+		background-color: rgba( 255, 255, 0, 0.5 );
 	}
 
 	span.highlight-blue {
-		background-color: blue;
+		background-color: rgba( 0, 0, 255, 0.5 );
 	}
 
 	span.highlight-red {
-		background-color: red;
+		background-color: rgba( 255, 0, 0, 0.5 );
 	}
 
 	.ck-widget.fancy-widget {

+ 964 - 0
packages/ckeditor5-engine/tests/model/differ.js

@@ -0,0 +1,964 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Model from '../../src/model/model';
+import Document from '../../src/model/document';
+import Differ from '../../src/model/differ';
+import Element from '../../src/model/element';
+import Text from '../../src/model/text';
+import Position from '../../src/model/position';
+import Range from '../../src/model/range';
+
+import InsertOperation from '../../src/model/operation/insertoperation';
+import RemoveOperation from '../../src/model/operation/removeoperation';
+import MoveOperation from '../../src/model/operation/moveoperation';
+import RenameOperation from '../../src/model/operation/renameoperation';
+import AttributeOperation from '../../src/model/operation/attributeoperation';
+
+import { wrapInDelta } from '../../tests/model/_utils/utils';
+
+describe( 'Differ', () => {
+	let doc, differ, root, model;
+
+	beforeEach( () => {
+		model = new Model();
+		doc = new Document( model );
+		differ = new Differ();
+
+		root = doc.createRoot();
+
+		root.appendChildren( [
+			new Element( 'paragraph', null, [
+				new Text( 'foo' )
+			] ),
+			new Element( 'paragraph', null, [
+				new Text( 'bar' )
+			] )
+		] );
+	} );
+
+	describe( 'insert', () => {
+		// Simple.
+		it( 'an element', () => {
+			const position = new Position( root, [ 1 ] );
+
+			insert( new Element( 'image' ), position );
+
+			expectChanges( [
+				{ type: 'insert', name: 'image', length: 1, position }
+			] );
+		} );
+
+		it( 'a non-empty element with attributes', () => {
+			const position = new Position( root, [ 1 ] );
+
+			insert(
+				new Element( 'image', { src: 'foo.jpg' }, new Element( 'caption', null, new Text( 'bar' ) ) ),
+				position
+			);
+
+			expectChanges( [
+				{ type: 'insert', name: 'image', length: 1, position }
+			] );
+		} );
+
+		it( 'multiple elements', () => {
+			const position = new Position( root, [ 1 ] );
+
+			insert( [ new Element( 'image' ), new Element( 'paragraph' ) ], position );
+
+			expectChanges( [
+				{ type: 'insert', name: 'image', length: 1, position },
+				{ type: 'insert', name: 'paragraph', length: 1, position: position.getShiftedBy( 1 ) }
+			] );
+		} );
+
+		it( 'a character', () => {
+			const position = new Position( root, [ 0, 2 ] );
+
+			insert( new Text( 'x' ), position );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 1, position }
+			] );
+		} );
+
+		it( 'multiple characters', () => {
+			const position = new Position( root, [ 0, 2 ] );
+
+			insert( new Text( 'xyz' ), position );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 3, position }
+			] );
+		} );
+
+		it( 'multiple consecutive characters in multiple operations', () => {
+			const position = new Position( root, [ 0, 2 ] );
+
+			insert( new Text( 'xy' ), position );
+			insert( new Text( 'z' ), position.getShiftedBy( 2 ) );
+			insert( new Text( 'ab' ), position );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 5, position }
+			] );
+		} );
+
+		it( 'multiple non-consecutive characters in multiple operations', () => {
+			const position = new Position( root, [ 0, 0 ] );
+
+			insert( new Text( 'xy' ), position );
+			insert( new Text( 'z' ), position.getShiftedBy( 3 ) );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 2, position },
+				{ type: 'insert', name: '$text', length: 1, position: position.getShiftedBy( 3 ) }
+			] );
+		} );
+
+		// Combined.
+		it( 'node in a new element', () => {
+			const image = new Element( 'image' );
+			const position = new Position( root, [ 1 ] );
+
+			insert( image, position );
+
+			const caption = new Element( 'caption' );
+			insert( caption, Position.createAt( image, 0 ) );
+
+			insert( new Text( 'foo' ), Position.createAt( caption, 0 ) );
+
+			expectChanges( [
+				{ type: 'insert', name: 'image', length: 1, position }
+			] );
+		} );
+
+		it( 'node in a renamed element', () => {
+			const text = new Text( 'xyz', { bold: true } );
+			const position = new Position( root, [ 0, 3 ] );
+
+			insert( text, position );
+			rename( root.getChild( 0 ), 'listItem' );
+
+			// Note that since renamed element is removed and then re-inserted, there is no diff for text inserted inside it.
+			expectChanges( [
+				{ type: 'remove', name: 'paragraph', length: 1, position: new Position( root, [ 0 ] ) },
+				{ type: 'insert', name: 'listItem', length: 1, position: new Position( root, [ 0 ] ) }
+			] );
+		} );
+
+		it( 'node in a element with changed attribute', () => {
+			const text = new Text( 'xyz', { bold: true } );
+			const position = new Position( root, [ 0, 3 ] );
+			const range = Range.createFromParentsAndOffsets( root, 0, root.getChild( 0 ), 0 );
+
+			insert( text, position );
+			attribute( range, 'align', null, 'center' );
+
+			// Compare to scenario above, this time there is only an attribute change on parent element, so there is also a diff for text.
+			expectChanges( [
+				{ type: 'attribute', range, attributeKey: 'align', attributeOldValue: null, attributeNewValue: 'center' },
+				{ type: 'insert', name: '$text', length: 3, position },
+			] );
+		} );
+
+		it( 'nodes between other inserted nodes', () => {
+			insert( new Text( 'xx' ), new Position( root, [ 0, 1 ] ) );
+			insert( new Text( 'yy' ), new Position( root, [ 0, 2 ] ) );
+
+			expectChanges( [
+				{ type: 'insert', position: new Position( root, [ 0, 1 ] ), length: 4, name: '$text' }
+			] );
+		} );
+
+		it( 'nodes before nodes with changed attributes', () => {
+			const p1 = root.getChild( 0 );
+			const range = Range.createFromParentsAndOffsets( p1, 1, p1, 3 );
+			const position = new Position( root, [ 0, 0 ] );
+
+			attribute( range, 'bold', null, true );
+			insert( new Text( 'xx' ), position );
+
+			const rangeAfter = Range.createFromParentsAndOffsets( p1, 3, p1, 5 );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 2, position },
+				{ type: 'attribute', range: rangeAfter, attributeKey: 'bold', attributeOldValue: null, attributeNewValue: true }
+			] );
+		} );
+
+		it( 'nodes between nodes with changed attributes', () => {
+			const p1 = root.getChild( 0 );
+			const range = Range.createFromParentsAndOffsets( p1, 1, p1, 3 );
+			const position = new Position( root, [ 0, 2 ] );
+
+			attribute( range, 'bold', null, true );
+			insert( new Text( 'xx' ), position );
+
+			const rangeBefore = Range.createFromParentsAndOffsets( p1, 1, p1, 2 );
+			const rangeAfter = Range.createFromParentsAndOffsets( p1, 4, p1, 5 );
+
+			expectChanges( [
+				{ type: 'attribute', range: rangeBefore, attributeKey: 'bold', attributeOldValue: null, attributeNewValue: true },
+				{ type: 'insert', name: '$text', length: 2, position },
+				{ type: 'attribute', range: rangeAfter, attributeKey: 'bold', attributeOldValue: null, attributeNewValue: true }
+			] );
+		} );
+
+		it( 'nodes after nodes with changed attributes', () => {
+			const p1 = root.getChild( 0 );
+			const range = Range.createFromParentsAndOffsets( p1, 1, p1, 3 );
+			const position = new Position( root, [ 0, 3 ] );
+
+			attribute( range, 'bold', null, true );
+			insert( new Text( 'xx' ), position );
+
+			expectChanges( [
+				{ type: 'attribute', range, attributeKey: 'bold', attributeOldValue: null, attributeNewValue: true },
+				{ type: 'insert', name: '$text', length: 2, position }
+			] );
+		} );
+	} );
+
+	describe( 'remove', () => {
+		it( 'an element', () => {
+			const position = new Position( root, [ 0 ] );
+
+			remove( position, 1 );
+
+			expectChanges( [
+				{ type: 'insert', name: 'paragraph', length: 1, position: Position.createAt( doc.graveyard, 0 ) },
+				{ type: 'remove', name: 'paragraph', length: 1, position }
+			] );
+		} );
+
+		it( 'multiple elements', () => {
+			const position = new Position( root, [ 0 ] );
+
+			remove( position, 2 );
+
+			expectChanges( [
+				{ type: 'insert', name: 'paragraph', length: 1, position: Position.createAt( doc.graveyard, 0 ) },
+				{ type: 'insert', name: 'paragraph', length: 1, position: Position.createAt( doc.graveyard, 1 ) },
+				{ type: 'remove', name: 'paragraph', length: 1, position },
+				{ type: 'remove', name: 'paragraph', length: 1, position }
+			] );
+		} );
+
+		it( 'a character', () => {
+			const position = new Position( root, [ 0, 1 ] );
+
+			remove( position, 1 );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 1, position: Position.createAt( doc.graveyard, 0 ) },
+
+				{ type: 'remove', name: '$text', length: 1, position: new Position( root, [ 0, 1 ] ) }
+			] );
+		} );
+
+		it( 'multiple characters', () => {
+			const position = new Position( root, [ 0, 1 ] );
+
+			remove( position, 2 );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 2, position: Position.createAt( doc.graveyard, 0 ) },
+				{ type: 'remove', name: '$text', length: 2, position }
+			] );
+		} );
+
+		it( 'multiple consecutive characters in multiple operations', () => {
+			const position = new Position( root, [ 0, 0 ] );
+
+			remove( position, 1 );
+			remove( position, 1 );
+			remove( position, 1 );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 3, position: Position.createAt( doc.graveyard, 0 ) },
+				{ type: 'remove', name: '$text', length: 3, position }
+			] );
+		} );
+
+		it( 'multiple non-consecutive characters in multiple operations', () => {
+			const position = new Position( root, [ 0, 0 ] );
+
+			remove( position, 1 );
+			remove( position.getShiftedBy( 1 ), 1 );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 2, position: Position.createAt( doc.graveyard, 0 ) },
+				{ type: 'remove', name: '$text', length: 1, position },
+				{ type: 'remove', name: '$text', length: 1, position: position.getShiftedBy( 1 ) }
+			] );
+		} );
+
+		it( 'item just before inserted item', () => {
+			// This tests proper changes sorting.
+			const insertPosition = new Position( root, [ 0, 2 ] );
+			const removePosition = new Position( root, [ 0, 1 ] );
+
+			insert( new Text( 'x' ), insertPosition );
+			remove( removePosition, 1 );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 1, position: Position.createAt( doc.graveyard, 0 ) },
+				{ type: 'remove', name: '$text', length: 1, position: removePosition },
+				{ type: 'insert', name: '$text', length: 1, position: removePosition }
+			] );
+		} );
+
+		it( 'nodes before inserted nodes (together with some inserted nodes)', () => {
+			const insertPosition = new Position( root, [ 0, 2 ] );
+			const removePosition = new Position( root, [ 0, 1 ] );
+
+			insert( new Text( 'xyz' ), insertPosition );
+			remove( removePosition, 2 );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 2, position: Position.createAt( doc.graveyard, 0 ) },
+				{ type: 'remove', name: '$text', length: 1, position: removePosition },
+				{ type: 'insert', name: '$text', length: 2, position: removePosition }
+			] );
+		} );
+
+		it( 'inserted nodes and some nodes after inserted nodes', () => {
+			const insertPosition = new Position( root, [ 0, 2 ] );
+			const removePosition = new Position( root, [ 0, 3 ] );
+
+			insert( new Text( 'xyz' ), insertPosition );
+			remove( removePosition, 3 );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 3, position: Position.createAt( doc.graveyard, 0 ) },
+				{ type: 'insert', name: '$text', length: 1, position: insertPosition },
+				{ type: 'remove', name: '$text', length: 1, position: removePosition }
+			] );
+		} );
+
+		it( 'all inserted nodes', () => {
+			const insertPosition = new Position( root, [ 0, 2 ] );
+			const removePosition = new Position( root, [ 0, 1 ] );
+
+			insert( new Text( 'xy' ), insertPosition );
+			remove( removePosition, 4 );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 4, position: Position.createAt( doc.graveyard, 0 ) },
+				{ type: 'remove', name: '$text', length: 2, position: removePosition }
+			] );
+		} );
+
+		it( 'before removed nodes', () => {
+			const removePositionA = new Position( root, [ 0, 2 ] );
+			const removePositionB = new Position( root, [ 0, 0 ] );
+
+			remove( removePositionA, 1 );
+			remove( removePositionB, 1 );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 2, position: Position.createAt( doc.graveyard, 0 ) },
+				{ type: 'remove', name: '$text', length: 1, position: removePositionB },
+				{ type: 'remove', name: '$text', length: 1, position: new Position( root, [ 0, 1 ] ) }
+			] );
+		} );
+
+		it( 'before and after removed nodes in one operation', () => {
+			const removePositionA = new Position( root, [ 0, 1 ] );
+			const removePositionB = new Position( root, [ 0, 0 ] );
+
+			remove( removePositionA, 1 );
+			remove( removePositionB, 2 );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 3, position: Position.createAt( doc.graveyard, 0 ) },
+				{ type: 'remove', name: '$text', length: 3, position: removePositionB },
+			] );
+		} );
+
+		it( 'before nodes that changed attributes', () => {
+			const position = new Position( root, [ 0, 0 ] );
+
+			const p1 = root.getChild( 0 );
+			const range = Range.createFromParentsAndOffsets( p1, 2, p1, 3 );
+
+			attribute( range, 'bold', null, true );
+			remove( position, 1 );
+
+			const newRange = Range.createFromParentsAndOffsets( p1, 1, p1, 2 );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 1, position: Position.createAt( doc.graveyard, 0 ) },
+				{ type: 'remove', name: '$text', length: 1, position },
+				{ type: 'attribute', range: newRange, attributeKey: 'bold', attributeOldValue: null, attributeNewValue: true }
+			] );
+		} );
+
+		it( 'before nodes that changed attributes together with some changed nodes', () => {
+			const position = new Position( root, [ 0, 0 ] );
+
+			const p1 = root.getChild( 0 );
+			const range = Range.createFromParentsAndOffsets( p1, 1, p1, 3 );
+
+			attribute( range, 'bold', null, true );
+			remove( position, 2 );
+
+			const newRange = Range.createFromParentsAndOffsets( p1, 0, p1, 1 );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 2, position: Position.createAt( doc.graveyard, 0 ) },
+				{ type: 'remove', name: '$text', length: 2, position },
+				{ type: 'attribute', range: newRange, attributeKey: 'bold', attributeOldValue: null, attributeNewValue: true }
+			] );
+		} );
+
+		it( 'some changed nodes', () => {
+			const position = new Position( root, [ 0, 1 ] );
+
+			const p1 = root.getChild( 0 );
+			const range = Range.createFromParentsAndOffsets( p1, 0, p1, 3 );
+
+			attribute( range, 'bold', null, true );
+			remove( position, 1 );
+
+			const rangeBefore = Range.createFromParentsAndOffsets( p1, 0, p1, 1 );
+			const rangeAfter = Range.createFromParentsAndOffsets( p1, 1, p1, 2 );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 1, position: Position.createAt( doc.graveyard, 0 ) },
+				{ type: 'attribute', range: rangeBefore, attributeKey: 'bold', attributeOldValue: null, attributeNewValue: true },
+				{ type: 'remove', name: '$text', length: 1, position },
+				{ type: 'attribute', range: rangeAfter, attributeKey: 'bold', attributeOldValue: null, attributeNewValue: true }
+			] );
+		} );
+
+		it( 'some changed nodes and some nodes after', () => {
+			const position = new Position( root, [ 0, 1 ] );
+
+			const p1 = root.getChild( 0 );
+			const range = Range.createFromParentsAndOffsets( p1, 0, p1, 2 );
+
+			attribute( range, 'bold', null, true );
+			remove( position, 2 );
+
+			const newRange = Range.createFromParentsAndOffsets( p1, 0, p1, 1 );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 2, position: Position.createAt( doc.graveyard, 0 ) },
+				{ type: 'attribute', range: newRange, attributeKey: 'bold', attributeOldValue: null, attributeNewValue: true },
+				{ type: 'remove', name: '$text', length: 2, position }
+			] );
+		} );
+
+		it( 'after changed nodes', () => {
+			const position = new Position( root, [ 0, 2 ] );
+
+			const p1 = root.getChild( 0 );
+			const range = Range.createFromParentsAndOffsets( p1, 0, p1, 1 );
+
+			attribute( range, 'bold', null, true );
+			remove( position, 1 );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 1, position: Position.createAt( doc.graveyard, 0 ) },
+				{ type: 'attribute', range, attributeKey: 'bold', attributeOldValue: null, attributeNewValue: true },
+				{ type: 'remove', name: '$text', length: 1, position }
+			] );
+		} );
+	} );
+
+	// The only main difference between remove operation and move operation is target position.
+	// In differ, graveyard is treated as other roots. In remove suite, simple cases for move are covered.
+	// This suite will have only a few cases, focused on things specific to move operation.
+	describe( 'move', () => {
+		it( 'an element to the same parent - target position is after source position', () => {
+			const sourcePosition = new Position( root, [ 0 ] );
+			const targetPosition = new Position( root, [ 2 ] );
+
+			move( sourcePosition, 1, targetPosition );
+
+			expectChanges( [
+				{ type: 'remove', name: 'paragraph', length: 1, position: new Position( root, [ 0 ] ) },
+				{ type: 'insert', name: 'paragraph', length: 1, position: new Position( root, [ 1 ] ) }
+			] );
+		} );
+
+		it( 'an element to the same parent - target position is before source position', () => {
+			const sourcePosition = new Position( root, [ 1 ] );
+			const targetPosition = new Position( root, [ 0 ] );
+
+			move( sourcePosition, 1, targetPosition );
+
+			expectChanges( [
+				{ type: 'insert', name: 'paragraph', length: 1, position: new Position( root, [ 0 ] ) },
+				{ type: 'remove', name: 'paragraph', length: 1, position: new Position( root, [ 2 ] ) }
+			] );
+		} );
+
+		it( 'multiple consecutive characters between different roots in multiple operations', () => {
+			const sourcePosition = new Position( root, [ 0, 1 ] );
+			const targetPosition = new Position( root, [ 1, 0 ] );
+
+			move( sourcePosition, 1, targetPosition );
+			move( sourcePosition, 1, targetPosition.getShiftedBy( 1 ) );
+
+			expectChanges( [
+				{ type: 'remove', name: '$text', length: 2, position: sourcePosition },
+				{ type: 'insert', name: '$text', length: 2, position: targetPosition }
+			] );
+		} );
+
+		it( 'reinsert removed element', () => {
+			doc.graveyard.appendChildren( new Element( 'listItem' ) );
+
+			const sourcePosition = new Position( doc.graveyard, [ 0 ] );
+			const targetPosition = new Position( root, [ 2 ] );
+
+			move( sourcePosition, 1, targetPosition );
+
+			expectChanges( [
+				{ type: 'remove', name: 'listItem', length: 1, position: sourcePosition },
+				{ type: 'insert', name: 'listItem', length: 1, position: targetPosition }
+			] );
+		} );
+	} );
+
+	describe( 'rename', () => {
+		it( 'an element', () => {
+			rename( root.getChild( 1 ), 'listItem' );
+
+			expectChanges( [
+				{ type: 'remove', name: 'paragraph', length: 1, position: new Position( root, [ 1 ] ) },
+				{ type: 'insert', name: 'listItem', length: 1, position: new Position( root, [ 1 ] ) }
+			] );
+		} );
+	} );
+
+	describe( 'attribute', () => {
+		const attributeKey = 'key';
+		const attributeOldValue = null;
+		const attributeNewValue = 'foo';
+
+		it( 'on an element', () => {
+			const range = Range.createFromParentsAndOffsets( root, 0, root.getChild( 0 ), 0 );
+
+			attribute( range, attributeKey, attributeOldValue, attributeNewValue );
+
+			expectChanges( [
+				{ type: 'attribute', range, attributeKey, attributeOldValue, attributeNewValue }
+			] );
+		} );
+
+		it( 'on a character', () => {
+			const parent = root.getChild( 1 );
+			const range = Range.createFromParentsAndOffsets( parent, 1, parent, 2 );
+
+			attribute( range, attributeKey, attributeOldValue, attributeNewValue );
+
+			expectChanges( [
+				{ type: 'attribute', range, attributeKey, attributeOldValue, attributeNewValue }
+			] );
+		} );
+
+		it( 'on a character - case with same characters next to each other', () => {
+			const parent = root.getChild( 0 );
+			const range = Range.createFromParentsAndOffsets( parent, 1, parent, 2 );
+
+			attribute( range, attributeKey, attributeOldValue, attributeNewValue );
+
+			expectChanges( [
+				{ type: 'attribute', range, attributeKey, attributeOldValue, attributeNewValue }
+			] );
+		} );
+
+		it( 'on multiple characters', () => {
+			const parent = root.getChild( 0 );
+			const range = Range.createFromParentsAndOffsets( parent, 0, parent, 3 );
+
+			attribute( range, attributeKey, attributeOldValue, attributeNewValue );
+
+			expectChanges( [
+				{ type: 'attribute', range, attributeKey, attributeOldValue, attributeNewValue }
+			] );
+		} );
+
+		it( 'on multiple consecutive characters in multiple operations', () => {
+			const parent = root.getChild( 0 );
+
+			const range1 = Range.createFromParentsAndOffsets( parent, 1, parent, 2 );
+			const range2 = Range.createFromParentsAndOffsets( parent, 2, parent, 3 );
+
+			attribute( range1, attributeKey, attributeOldValue, attributeNewValue );
+			attribute( range2, attributeKey, attributeOldValue, attributeNewValue );
+
+			const range = Range.createFromParentsAndOffsets( parent, 1, parent, 3 );
+
+			expectChanges( [
+				{ type: 'attribute', range, attributeKey, attributeOldValue, attributeNewValue }
+			] );
+		} );
+
+		it( 'on multiple non-consecutive characters in multiple operations', () => {
+			const parent = root.getChild( 0 );
+
+			const range1 = Range.createFromParentsAndOffsets( parent, 0, parent, 1 );
+			const range2 = Range.createFromParentsAndOffsets( parent, 2, parent, 3 );
+
+			// Note "reversed" order of ranges. Further range is changed first.
+			attribute( range2, attributeKey, attributeOldValue, attributeNewValue );
+			attribute( range1, attributeKey, attributeOldValue, attributeNewValue );
+
+			// Note that changes has been sorted.
+			expectChanges( [
+				{ type: 'attribute', range: range1, attributeKey, attributeOldValue, attributeNewValue },
+				{ type: 'attribute', range: range2, attributeKey, attributeOldValue, attributeNewValue }
+			] );
+		} );
+
+		it( 'on range containing various nodes', () => {
+			const range = Range.createFromParentsAndOffsets( root, 0, root, 2 );
+
+			attribute( range, attributeKey, attributeOldValue, attributeNewValue );
+
+			const p1 = root.getChild( 0 );
+			const p2 = root.getChild( 1 );
+			const type = 'attribute';
+
+			expectChanges( [
+				{ type, range: Range.createFromParentsAndOffsets( root, 0, p1, 0 ), attributeKey, attributeOldValue, attributeNewValue },
+				{ type, range: Range.createFromParentsAndOffsets( p1, 0, p1, 3 ), attributeKey, attributeOldValue, attributeNewValue },
+				{ type, range: Range.createFromParentsAndOffsets( root, 1, p2, 0 ), attributeKey, attributeOldValue, attributeNewValue },
+				{ type, range: Range.createFromParentsAndOffsets( p2, 0, p2, 3 ), attributeKey, attributeOldValue, attributeNewValue }
+			] );
+		} );
+
+		it( 'remove and add attribute on text', () => {
+			const p = root.getChild( 1 );
+
+			p.getChild( 0 ).setAttribute( 'bold', true );
+
+			const range = Range.createFromParentsAndOffsets( p, 1, p, 3 );
+
+			attribute( range, 'bold', true, null );
+			attribute( range, 'italic', null, true );
+
+			const range1 = Range.createFromParentsAndOffsets( p, 1, p, 2 );
+			const range2 = Range.createFromParentsAndOffsets( p, 2, p, 3 );
+
+			// Attribute change glueing does not work 100% correct.
+			expectChanges( [
+				{ type: 'attribute', range: range1, attributeKey: 'bold', attributeOldValue: true, attributeNewValue: null },
+				{ type: 'attribute', range: range1, attributeKey: 'italic', attributeOldValue: null, attributeNewValue: true },
+				{ type: 'attribute', range: range2, attributeKey: 'bold', attributeOldValue: true, attributeNewValue: null },
+				{ type: 'attribute', range: range2, attributeKey: 'italic', attributeOldValue: null, attributeNewValue: true }
+			] );
+		} );
+
+		it( 'on some old nodes and inserted nodes', () => {
+			const position = new Position( root, [ 0, 1 ] );
+
+			const p1 = root.getChild( 0 );
+			const range = Range.createFromParentsAndOffsets( p1, 0, p1, 2 );
+
+			insert( new Text( 'xx' ), position );
+			attribute( range, attributeKey, attributeOldValue, attributeNewValue );
+
+			const rangeBefore = Range.createFromParentsAndOffsets( p1, 0, p1, 1 );
+
+			expectChanges( [
+				{ type: 'attribute', range: rangeBefore, attributeKey, attributeOldValue, attributeNewValue },
+				{ type: 'insert', name: '$text', length: 2, position }
+			] );
+		} );
+
+		it( 'over all inserted nodes and some old nodes', () => {
+			const position = new Position( root, [ 0, 1 ] );
+
+			const p1 = root.getChild( 0 );
+			const range = Range.createFromParentsAndOffsets( p1, 2, p1, 3 );
+
+			insert( new Text( 'xxx' ), position );
+			attribute( range, attributeKey, attributeOldValue, attributeNewValue );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 3, position }
+			] );
+		} );
+
+		it( 'only on inserted nodes', () => {
+			const position = new Position( root, [ 0, 1 ] );
+
+			const p1 = root.getChild( 0 );
+			const range = Range.createFromParentsAndOffsets( p1, 2, p1, 4 );
+
+			insert( new Text( 'xx' ), position );
+			attribute( range, attributeKey, attributeOldValue, attributeNewValue );
+
+			const rangeAfter = Range.createFromParentsAndOffsets( p1, 3, p1, 4 );
+
+			expectChanges( [
+				{ type: 'insert', name: '$text', length: 2, position },
+				{ type: 'attribute', range: rangeAfter, attributeKey, attributeOldValue, attributeNewValue }
+			] );
+		} );
+
+		it( 'on some inserted nodes and old nodes', () => {
+			const position = new Position( root, [ 0, 1 ] );
+
+			const p1 = root.getChild( 0 );
+			const range = Range.createFromParentsAndOffsets( p1, 0, p1, 4 );
+
+			insert( new Text( 'xx' ), position );
+			attribute( range, attributeKey, attributeOldValue, attributeNewValue );
+
+			const rangeBefore = Range.createFromParentsAndOffsets( p1, 0, p1, 1 );
+			const rangeAfter = Range.createFromParentsAndOffsets( p1, 3, p1, 4 );
+
+			expectChanges( [
+				{ type: 'attribute', range: rangeBefore, attributeKey, attributeOldValue, attributeNewValue },
+				{ type: 'insert', name: '$text', length: 2, position },
+				{ type: 'attribute', range: rangeAfter, attributeKey, attributeOldValue, attributeNewValue }
+			] );
+		} );
+
+		it( 'on some not changed and some changed nodes', () => {
+			const p = root.getChild( 0 );
+
+			const rangeA = Range.createFromParentsAndOffsets( p, 1, p, 3 );
+			const rangeB = Range.createFromParentsAndOffsets( p, 0, p, 2 );
+
+			attribute( rangeA, 'a', null, true );
+			attribute( rangeB, 'b', null, true );
+
+			const type = 'attribute';
+			const attributeOldValue = null;
+			const attributeNewValue = true;
+
+			// Attribute change glueing does not work 100% correct.
+			expectChanges( [
+				{ type, range: Range.createFromParentsAndOffsets( p, 0, p, 1 ), attributeKey: 'b', attributeOldValue, attributeNewValue },
+				{ type, range: Range.createFromParentsAndOffsets( p, 1, p, 2 ), attributeKey: 'a', attributeOldValue, attributeNewValue },
+				{ type, range: Range.createFromParentsAndOffsets( p, 1, p, 2 ), attributeKey: 'b', attributeOldValue, attributeNewValue },
+				{ type, range: Range.createFromParentsAndOffsets( p, 2, p, 3 ), attributeKey: 'a', attributeOldValue, attributeNewValue }
+			] );
+		} );
+
+		it( 'on already changed nodes', () => {
+			const p = root.getChild( 1 );
+
+			const rangeA = Range.createFromParentsAndOffsets( p, 0, p, 3 );
+			const rangeB = Range.createFromParentsAndOffsets( p, 1, p, 2 );
+
+			attribute( rangeA, 'a', null, true );
+			attribute( rangeB, 'b', null, true );
+
+			const type = 'attribute';
+			const attributeOldValue = null;
+			const attributeNewValue = true;
+
+			// Attribute change glueing does not work 100% correct.
+			expectChanges( [
+				{ type, range: Range.createFromParentsAndOffsets( p, 0, p, 2 ), attributeKey: 'a', attributeOldValue, attributeNewValue },
+				{ type, range: Range.createFromParentsAndOffsets( p, 1, p, 2 ), attributeKey: 'b', attributeOldValue, attributeNewValue },
+				{ type, range: Range.createFromParentsAndOffsets( p, 2, p, 3 ), attributeKey: 'a', attributeOldValue, attributeNewValue }
+			] );
+		} );
+
+		it( 'on some changed and some not changed nodes', () => {
+			const p = root.getChild( 1 );
+
+			const rangeA = Range.createFromParentsAndOffsets( p, 0, p, 2 );
+			const rangeB = Range.createFromParentsAndOffsets( p, 1, p, 3 );
+
+			attribute( rangeA, 'a', null, true );
+			attribute( rangeB, 'b', null, true );
+
+			const type = 'attribute';
+			const attributeOldValue = null;
+			const attributeNewValue = true;
+
+			expectChanges( [
+				{ type, range: Range.createFromParentsAndOffsets( p, 0, p, 2 ), attributeKey: 'a', attributeOldValue, attributeNewValue },
+				{ type, range: Range.createFromParentsAndOffsets( p, 1, p, 3 ), attributeKey: 'b', attributeOldValue, attributeNewValue },
+			] );
+		} );
+
+		it( 'over all changed nodes and some not changed nodes', () => {
+			const p = root.getChild( 0 );
+
+			const rangeA = Range.createFromParentsAndOffsets( p, 1, p, 2 );
+			const rangeB = Range.createFromParentsAndOffsets( p, 0, p, 3 );
+
+			attribute( rangeA, 'a', null, true );
+			attribute( rangeB, 'b', null, true );
+
+			const type = 'attribute';
+			const attributeOldValue = null;
+			const attributeNewValue = true;
+
+			// Attribute change glueing does not work 100% correct.
+			expectChanges( [
+				{ type, range: Range.createFromParentsAndOffsets( p, 0, p, 1 ), attributeKey: 'b', attributeOldValue, attributeNewValue },
+				{ type, range: Range.createFromParentsAndOffsets( p, 1, p, 2 ), attributeKey: 'a', attributeOldValue, attributeNewValue },
+				{ type, range: Range.createFromParentsAndOffsets( p, 1, p, 3 ), attributeKey: 'b', attributeOldValue, attributeNewValue },
+			] );
+		} );
+	} );
+
+	describe( 'markers', () => {
+		let range, rangeB;
+
+		beforeEach( () => {
+			range = Range.createFromParentsAndOffsets( root, 0, root, 1 );
+			rangeB = Range.createFromParentsAndOffsets( root, 1, root, 2 );
+		} );
+
+		it( 'add marker', () => {
+			differ.bufferMarkerChange( 'name', null, range );
+
+			expect( differ.getMarkersToRemove() ).to.deep.equal( [] );
+
+			expect( differ.getMarkersToAdd() ).to.deep.equal( [
+				{ name: 'name', range }
+			] );
+		} );
+
+		it( 'remove marker', () => {
+			differ.bufferMarkerChange( 'name', range, null );
+
+			expect( differ.getMarkersToRemove() ).to.deep.equal( [
+				{ name: 'name', range }
+			] );
+
+			expect( differ.getMarkersToAdd() ).to.deep.equal( [] );
+		} );
+
+		it( 'change marker', () => {
+			differ.bufferMarkerChange( 'name', range, rangeB );
+
+			expect( differ.getMarkersToRemove() ).to.deep.equal( [
+				{ name: 'name', range }
+			] );
+
+			expect( differ.getMarkersToAdd() ).to.deep.equal( [
+				{ name: 'name', range: rangeB }
+			] );
+		} );
+
+		it( 'add marker and remove it', () => {
+			differ.bufferMarkerChange( 'name', null, range );
+			differ.bufferMarkerChange( 'name', range, null );
+
+			expect( differ.getMarkersToRemove() ).to.deep.equal( [] );
+			expect( differ.getMarkersToAdd() ).to.deep.equal( [] );
+		} );
+
+		it( 'add marker and change it', () => {
+			differ.bufferMarkerChange( 'name', null, range );
+			differ.bufferMarkerChange( 'name', range, rangeB );
+
+			expect( differ.getMarkersToRemove() ).to.deep.equal( [] );
+
+			expect( differ.getMarkersToAdd() ).to.deep.equal( [
+				{ name: 'name', range: rangeB }
+			] );
+		} );
+
+		it( 'change marker and remove it', () => {
+			differ.bufferMarkerChange( 'name', range, rangeB );
+			differ.bufferMarkerChange( 'name', rangeB, null );
+
+			expect( differ.getMarkersToRemove() ).to.deep.equal( [
+				{ name: 'name', range }
+			] );
+
+			expect( differ.getMarkersToAdd() ).to.deep.equal( [] );
+		} );
+
+		it( 'remove marker and add it at same range', () => {
+			differ.bufferMarkerChange( 'name', range, null );
+			differ.bufferMarkerChange( 'name', null, range );
+
+			expect( differ.getMarkersToRemove() ).to.deep.equal( [
+				{ name: 'name', range }
+			] );
+
+			expect( differ.getMarkersToAdd() ).to.deep.equal( [
+				{ name: 'name', range }
+			] );
+		} );
+
+		it( 'change marker to the same range', () => {
+			differ.bufferMarkerChange( 'name', range, range );
+
+			expect( differ.getMarkersToRemove() ).to.deep.equal( [
+				{ name: 'name', range }
+			] );
+
+			expect( differ.getMarkersToAdd() ).to.deep.equal( [
+				{ name: 'name', range }
+			] );
+		} );
+	} );
+
+	function insert( item, position ) {
+		const operation = new InsertOperation( position, item, doc.version );
+
+		differ.bufferOperation( operation );
+
+		model.applyOperation( wrapInDelta( operation ) );
+	}
+
+	function remove( sourcePosition, howMany ) {
+		const targetPosition = Position.createAt( doc.graveyard, doc.graveyard.maxOffset );
+		const operation = new RemoveOperation( sourcePosition, howMany, targetPosition, doc.version );
+
+		differ.bufferOperation( operation );
+
+		model.applyOperation( wrapInDelta( operation ) );
+	}
+
+	function move( sourcePosition, howMany, targetPosition ) {
+		const operation = new MoveOperation( sourcePosition, howMany, targetPosition, doc.version );
+
+		differ.bufferOperation( operation );
+
+		model.applyOperation( wrapInDelta( operation ) );
+	}
+
+	function rename( element, newName ) {
+		const operation = new RenameOperation( Position.createBefore( element ), element.name, newName, doc.version );
+
+		differ.bufferOperation( operation );
+
+		model.applyOperation( wrapInDelta( operation ) );
+	}
+
+	function attribute( range, key, oldValue, newValue ) {
+		const operation = new AttributeOperation( range, key, oldValue, newValue, doc.version );
+
+		differ.bufferOperation( operation );
+
+		model.applyOperation( wrapInDelta( operation ) );
+	}
+
+	function expectChanges( expected ) {
+		const changes = differ.getChanges();
+
+		for ( let i = 0; i < expected.length; i++ ) {
+			for ( const key in expected[ i ] ) {
+				if ( expected[ i ].hasOwnProperty( key ) ) {
+					if ( key == 'position' || key == 'range' ) {
+						expect( changes[ i ][ key ].isEqual( expected[ i ][ key ] ), `item ${ i }, key "${ key }"` ).to.be.true;
+					} else {
+						expect( changes[ i ][ key ], `item ${ i }, key "${ key }"` ).to.equal( expected[ i ][ key ] );
+					}
+				}
+			}
+		}
+	}
+} );

+ 29 - 30
packages/ckeditor5-engine/tests/model/document/document.js

@@ -42,24 +42,29 @@ describe( 'Document', () => {
 	} );
 
 	describe( 'model#applyOperation listener', () => {
-		it( 'should increase document version, execute operation and fire event with proper data ' +
-			'when operation is a document operation', () => {
-			const changeCallback = sinon.spy();
-			const type = 't';
-			const data = { data: 'x' };
-			const batch = new Batch();
-			const delta = new Delta();
-			delta.type = 'type';
+		let operation, data, delta, batch;
 
-			const operation = {
-				type,
+		beforeEach( () => {
+			data = { data: 'x' };
+
+			operation = {
+				type: 't',
 				baseVersion: 0,
 				isDocumentOperation: true,
-				_execute: sinon.stub().returns( data )
+				_execute: sinon.stub().returns( data ),
+				_validate: () => {}
 			};
 
+			delta = new Delta();
 			delta.addOperation( operation );
+			delta.type = 'delta';
+
+			batch = new Batch();
 			batch.addDelta( delta );
+		} );
+
+		it( 'for document operation: should increase document version, execute operation and fire change event with proper data', () => {
+			const changeCallback = sinon.spy();
 
 			doc.on( 'change', changeCallback );
 			model.applyOperation( operation );
@@ -69,30 +74,15 @@ describe( 'Document', () => {
 			sinon.assert.calledOnce( operation._execute );
 
 			sinon.assert.calledOnce( changeCallback );
-			expect( changeCallback.args[ 0 ][ 1 ] ).to.equal( type );
+			expect( changeCallback.args[ 0 ][ 1 ] ).to.equal( 't' );
 			expect( changeCallback.args[ 0 ][ 2 ] ).to.equal( data );
-			expect( changeCallback.args[ 0 ][ 3 ] ).to.deep.equal( batch );
+			expect( changeCallback.args[ 0 ][ 3 ] ).to.equal( batch );
 			expect( changeCallback.args[ 0 ][ 4 ] ).to.equal( delta.type );
 		} );
 
-		it( 'should execute operation, not fire event and not increase document version ' +
-			'when operation is not a document operation', () => {
+		it( 'for non-document operation: should only execute operation', () => {
 			const changeCallback = sinon.spy();
-			const type = 't';
-			const data = { data: 'x' };
-			const batch = new Batch();
-			const delta = new Delta();
-			delta.type = 'type';
-
-			const operation = {
-				type,
-				baseVersion: 0,
-				isDocumentOperation: false,
-				_execute: sinon.stub().returns( data )
-			};
-
-			delta.addOperation( operation );
-			batch.addDelta( delta );
+			operation.isDocumentOperation = false;
 
 			doc.on( 'change', changeCallback );
 			model.applyOperation( operation );
@@ -104,6 +94,15 @@ describe( 'Document', () => {
 			sinon.assert.notCalled( changeCallback );
 		} );
 
+		it( 'should do nothing if operation event was cancelled', () => {
+			model.on( 'applyOperation', evt => evt.stop(), { priority: 'highest' } );
+
+			model.applyOperation( operation );
+
+			expect( doc.version ).to.equal( 0 );
+			expect( operation._execute.called ).to.be.false;
+		} );
+
 		it( 'should throw an error on the operation base version and the document version is different', () => {
 			const operation = {
 				baseVersion: 1,

+ 15 - 0
packages/ckeditor5-engine/tests/model/documentfragment.js

@@ -5,6 +5,7 @@
 
 import Element from '../../src/model/element';
 import Text from '../../src/model/text';
+import TextProxy from '../../src/model/textproxy';
 import DocumentFragment from '../../src/model/documentfragment';
 import { jsonParseStringify } from '../../tests/model/_utils/utils';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
@@ -151,6 +152,20 @@ describe( 'DocumentFragment', () => {
 			expect( frag.getChild( 0 ) ).to.have.property( 'name' ).that.equals( 'p' );
 			expect( frag.getChild( 1 ) ).to.have.property( 'data' ).that.equals( 'abc' );
 		} );
+
+		it( 'should accept and correctly handle text proxies', () => {
+			const frag = new DocumentFragment();
+			const text = new Text( 'abcxyz', { bold: true } );
+			const textProxy = new TextProxy( text, 2, 3 );
+
+			frag.insertChildren( 0, textProxy );
+
+			expect( frag.childCount ).to.equal( 1 );
+			expect( frag.maxOffset ).to.equal( 3 );
+			expect( frag.getChild( 0 ) ).to.be.instanceof( Text );
+			expect( frag.getChild( 0 ).data ).to.equal( 'cxy' );
+			expect( frag.getChild( 0 ).getAttribute( 'bold' ) ).to.equal( true );
+		} );
 	} );
 
 	describe( 'appendChildren', () => {

+ 15 - 0
packages/ckeditor5-engine/tests/model/element.js

@@ -6,6 +6,7 @@
 import Node from '../../src/model/node';
 import Element from '../../src/model/element';
 import Text from '../../src/model/text';
+import TextProxy from '../../src/model/textproxy';
 import { jsonParseStringify } from '../../tests/model/_utils/utils';
 import count from '@ckeditor/ckeditor5-utils/src/count';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
@@ -126,6 +127,20 @@ describe( 'Element', () => {
 			expect( element.maxOffset ).to.equal( 3 );
 			expect( element.getChild( 0 ) ).to.have.property( 'data' ).that.equals( 'abc' );
 		} );
+
+		it( 'should accept and correctly handle text proxies', () => {
+			const element = new Element( 'div' );
+			const text = new Text( 'abcxyz', { bold: true } );
+			const textProxy = new TextProxy( text, 2, 3 );
+
+			element.insertChildren( 0, textProxy );
+
+			expect( element.childCount ).to.equal( 1 );
+			expect( element.maxOffset ).to.equal( 3 );
+			expect( element.getChild( 0 ) ).to.be.instanceof( Text );
+			expect( element.getChild( 0 ).data ).to.equal( 'cxy' );
+			expect( element.getChild( 0 ).getAttribute( 'bold' ) ).to.equal( true );
+		} );
 	} );
 
 	describe( 'appendChildren', () => {

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

@@ -311,7 +311,8 @@ describe( 'Model', () => {
 			const returnValue = { foo: 'bar' };
 
 			const operation = {
-				_execute: sinon.stub().returns( returnValue )
+				_execute: sinon.stub().returns( returnValue ),
+				_validate: () => true
 			};
 
 			model.applyOperation( operation );

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

@@ -173,6 +173,25 @@ describe( 'AttributeOperation', () => {
 		expect( root.getChild( 0 ).getAttribute( 'bar' ) ).to.be.true;
 	} );
 
+	it( 'should work correctly if old and new value are same', () => {
+		root.insertChildren( 0, new Text( 'bar', { foo: 'bar' } ) );
+
+		model.applyOperation( wrapInDelta(
+			new AttributeOperation(
+				new Range( new Position( root, [ 0 ] ), new Position( root, [ 2 ] ) ),
+				'foo',
+				'bar',
+				'bar',
+				doc.version
+			)
+		) );
+
+		expect( doc.version ).to.equal( 1 );
+		expect( root.childCount ).to.equal( 1 );
+		expect( count( root.getChild( 0 ).getAttributes() ) ).to.equal( 1 );
+		expect( root.getChild( 0 ).getAttribute( 'foo' ) ).to.equal( 'bar' );
+	} );
+
 	it( 'should remove attribute', () => {
 		root.insertChildren( 0, new Text( 'x', { foo: true, x: true, bar: true } ) );
 
@@ -193,20 +212,70 @@ describe( 'AttributeOperation', () => {
 		expect( root.getChild( 0 ).hasAttribute( 'bar' ) ).to.be.true;
 	} );
 
-	it( 'should not throw for non-primitive attribute values', () => {
-		root.insertChildren( 0, new Text( 'x', { foo: [ 'bar', 'xyz' ] } ) );
+	describe( '_validate()', () => {
+		it( 'should not throw for non-primitive attribute values', () => {
+			root.insertChildren( 0, new Text( 'x', { foo: [ 'bar', 'xyz' ] } ) );
 
-		expect( () => {
-			model.applyOperation( wrapInDelta(
-				new AttributeOperation(
+			expect( () => {
+				const operation = new AttributeOperation(
 					new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
 					'foo',
 					[ 'bar', 'xyz' ],
 					true,
 					doc.version
-				)
-			) );
-		} ).to.not.throw( Error );
+				);
+
+				operation._validate();
+			} ).to.not.throw( Error );
+		} );
+
+		it( 'should throw an error when one try to remove and the attribute does not exists', () => {
+			root.insertChildren( 0, new Text( 'x' ) );
+
+			expect( () => {
+				const operation = new AttributeOperation(
+					new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
+					'foo',
+					true,
+					null,
+					doc.version
+				);
+
+				operation._validate();
+			} ).to.throw( CKEditorError, /attribute-operation-wrong-old-value/ );
+		} );
+
+		it( 'should throw an error when one try to insert and the attribute already exists', () => {
+			root.insertChildren( 0, new Text( 'x', { x: 1 } ) );
+
+			expect( () => {
+				const operation = new AttributeOperation(
+					new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
+					'x',
+					null,
+					2,
+					doc.version
+				);
+
+				operation._validate();
+			} ).to.throw( CKEditorError, /attribute-operation-attribute-exists/ );
+		} );
+
+		it( 'should not throw when attribute value is the same', () => {
+			root.insertChildren( 0, new Text( 'x', { foo: true } ) );
+
+			expect( () => {
+				const operation = new AttributeOperation(
+					new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
+					'foo',
+					true,
+					true,
+					doc.version
+				);
+
+				operation._validate();
+			} ).to.not.throw();
+		} );
 	} );
 
 	it( 'should create an AttributeOperation as a reverse', () => {
@@ -327,38 +396,6 @@ describe( 'AttributeOperation', () => {
 		expect( root.getChild( 0 ).getAttribute( 'foo' ) ).to.be.true;
 	} );
 
-	it( 'should throw an error when one try to remove and the attribute does not exists', () => {
-		root.insertChildren( 0, new Text( 'x' ) );
-
-		expect( () => {
-			model.applyOperation( wrapInDelta(
-				new AttributeOperation(
-					new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
-					'foo',
-					true,
-					null,
-					doc.version
-				)
-			) );
-		} ).to.throw( CKEditorError, /attribute-operation-wrong-old-value/ );
-	} );
-
-	it( 'should throw an error when one try to insert and the attribute already exists', () => {
-		root.insertChildren( 0, new Text( 'x', { x: 1 } ) );
-
-		expect( () => {
-			model.applyOperation( wrapInDelta(
-				new AttributeOperation(
-					new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
-					'x',
-					null,
-					2,
-					doc.version
-				)
-			) );
-		} ).to.throw( CKEditorError, /attribute-operation-attribute-exists/ );
-	} );
-
 	it( 'should create an AttributeOperation with the same parameters when cloned', () => {
 		const range = new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) );
 		const baseVersion = doc.version;
@@ -399,22 +436,6 @@ describe( 'AttributeOperation', () => {
 		expect( root.getChild( 1 ).data ).to.equal( 'bcxyz' );
 	} );
 
-	it( 'should do nothing when attribute value is the same', () => {
-		root.insertChildren( 0, new Text( 'x', { foo: true } ) );
-
-		expect( () => {
-			model.applyOperation( wrapInDelta(
-				new AttributeOperation(
-					new Range( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ),
-					'foo',
-					true,
-					true,
-					doc.version
-				)
-			) );
-		} ).to.not.throw();
-	} );
-
 	describe( 'toJSON', () => {
 		it( 'should create proper serialized object', () => {
 			const range = new Range( new Position( root, [ 0 ] ), new Position( root, [ 2 ] ) );

+ 10 - 8
packages/ckeditor5-engine/tests/model/operation/detachoperation.js

@@ -35,17 +35,19 @@ describe( 'DetachOperation', () => {
 		expect( docFrag.childCount ).to.equal( 0 );
 	} );
 
-	it( 'should throw when is executed on element from document', () => {
-		const root = doc.createRoot();
-		const element = new Element( 'element' );
+	describe( '_validate()', () => {
+		it( 'should throw when is executed on element from document', () => {
+			const root = doc.createRoot();
+			const element = new Element( 'element' );
 
-		root.appendChildren( [ element ] );
+			root.appendChildren( [ element ] );
 
-		const op = new DetachOperation( Position.createBefore( element ), 1, doc.version );
+			const op = new DetachOperation( Position.createBefore( element ), 1, doc.version );
 
-		expect( () => {
-			op._execute();
-		} ).to.throw( CKEditorError, /^detach-operation-on-document-node/ );
+			expect( () => {
+				op._validate();
+			} ).to.throw( CKEditorError, /^detach-operation-on-document-node/ );
+		} );
 	} );
 
 	it( 'should be not a document operation', () => {

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

@@ -11,6 +11,7 @@ import InsertOperation from '../../../src/model/operation/insertoperation';
 import RemoveOperation from '../../../src/model/operation/removeoperation';
 import Position from '../../../src/model/position';
 import Text from '../../../src/model/text';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import { jsonParseStringify, wrapInDelta } from '../../../tests/model/_utils/utils';
 
 describe( 'InsertOperation', () => {
@@ -232,6 +233,15 @@ describe( 'InsertOperation', () => {
 		} );
 	} );
 
+	describe( '_validate()', () => {
+		it( 'should throw an error if target position does not exist', () => {
+			const element = new Element( 'p' );
+			const op = new InsertOperation( new Position( root, [ 4 ] ), element, doc.version );
+
+			expect( () => op._validate() ).to.throw( CKEditorError, /insert-operation-position-invalid/ );
+		} );
+	} );
+
 	describe( 'toJSON', () => {
 		it( 'should create proper json object', () => {
 			const position = new Position( root, [ 0 ] );

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

@@ -151,102 +151,92 @@ describe( 'MoveOperation', () => {
 		expect( p2.maxOffset ).to.equal( 0 );
 	} );
 
-	it( 'should throw an error if number of nodes to move exceeds the number of existing nodes in given element', () => {
-		root.insertChildren( 0, new Text( 'xbarx' ) );
+	describe( '_validate()', () => {
+		it( 'should throw an error if number of nodes to move exceeds the number of existing nodes in given element', () => {
+			root.insertChildren( 0, new Text( 'xbarx' ) );
 
-		const operation = new MoveOperation(
-			new Position( root, [ 3 ] ),
-			3,
-			new Position( root, [ 1 ] ),
-			doc.version
-		);
+			const operation = new MoveOperation(
+				new Position( root, [ 3 ] ),
+				3,
+				new Position( root, [ 1 ] ),
+				doc.version
+			);
 
-		expect( () => model.applyOperation( wrapInDelta( operation ) ) ).to.throw( CKEditorError, /move-operation-nodes-do-not-exist/ );
-	} );
+			expect( () => operation._validate() ).to.throw( CKEditorError, /move-operation-nodes-do-not-exist/ );
+		} );
 
-	it( 'should throw an error if target or source parent-element specified by position does not exist', () => {
-		const p = new Element( 'p' );
-		p.insertChildren( 0, new Text( 'foo' ) );
-		root.insertChildren( 0, [ new Text( 'ab' ), p ] );
+		it( 'should throw an error if target or source parent-element specified by position does not exist', () => {
+			const p = new Element( 'p' );
+			p.insertChildren( 0, new Text( 'foo' ) );
+			root.insertChildren( 0, [ new Text( 'ab' ), p ] );
 
-		const operation = new MoveOperation(
-			new Position( root, [ 2, 0 ] ),
-			3,
-			new Position( root, [ 1 ] ),
-			doc.version
-		);
+			const operation = new MoveOperation(
+				new Position( root, [ 2, 0 ] ),
+				3,
+				new Position( root, [ 1 ] ),
+				doc.version
+			);
 
-		root.removeChildren( 1 );
+			root.removeChildren( 1 );
 
-		expect( () => model.applyOperation( wrapInDelta( operation ) ) ).to.throw( CKEditorError, /move-operation-position-invalid/ );
-	} );
+			expect( () => operation._validate() ).to.throw( CKEditorError, /move-operation-position-invalid/ );
+		} );
 
-	it( 'should throw an error if operation tries to move a range between the beginning and the end of that range', () => {
-		root.insertChildren( 0, new Text( 'xbarx' ) );
+		it( 'should throw an error if operation tries to move a range between the beginning and the end of that range', () => {
+			root.insertChildren( 0, new Text( 'xbarx' ) );
 
-		const operation = new MoveOperation(
-			new Position( root, [ 1 ] ),
-			3,
-			new Position( root, [ 2 ] ),
-			doc.version
-		);
+			const operation = new MoveOperation(
+				new Position( root, [ 1 ] ),
+				3,
+				new Position( root, [ 2 ] ),
+				doc.version
+			);
 
-		expect( () => model.applyOperation( wrapInDelta( operation ) ) ).to.throw( CKEditorError, /move-operation-range-into-itself/ );
-	} );
+			expect( () => operation._validate() ).to.throw( CKEditorError, /move-operation-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', () => {
-		const p = new Element( 'p', [], [ new Element( 'p' ) ] );
-		root.insertChildren( 0, [ new Text( 'ab' ), p, new Text( 'xy' ) ] );
+		it( 'should throw an error if operation tries to move a range into a sub-tree of a node that is in that range', () => {
+			const p = new Element( 'p', [], [ new Element( 'p' ) ] );
+			root.insertChildren( 0, [ new Text( 'ab' ), p, new Text( 'xy' ) ] );
 
-		const operation = new MoveOperation(
-			new Position( root, [ 1 ] ),
-			3,
-			new Position( root, [ 2, 0, 0 ] ),
-			doc.version
-		);
-
-		expect( () => model.applyOperation( wrapInDelta( operation ) ) ).to.throw( CKEditorError, /move-operation-node-into-itself/ );
-	} );
+			const operation = new MoveOperation(
+				new Position( root, [ 1 ] ),
+				3,
+				new Position( root, [ 2, 0, 0 ] ),
+				doc.version
+			);
 
-	it( 'should not throw an error if operation move a range into a sibling', () => {
-		const p = new Element( 'p' );
-		root.insertChildren( 0, [ new Text( 'ab' ), p, new Text( 'xy' ) ] );
+			expect( () => operation._validate() ).to.throw( CKEditorError, /move-operation-node-into-itself/ );
+		} );
 
-		const operation = new MoveOperation(
-			new Position( root, [ 1 ] ),
-			1,
-			new Position( root, [ 2, 0 ] ),
-			doc.version
-		);
+		it( 'should not throw an error if operation move a range into a sibling', () => {
+			const p = new Element( 'p' );
+			root.insertChildren( 0, [ new Text( 'ab' ), p, new Text( 'xy' ) ] );
 
-		expect(
-			() => {
-				model.applyOperation( wrapInDelta( operation ) );
-			}
-		).not.to.throw();
+			const operation = new MoveOperation(
+				new Position( root, [ 1 ] ),
+				1,
+				new Position( root, [ 2, 0 ] ),
+				doc.version
+			);
 
-		expect( root.maxOffset ).to.equal( 4 );
-		expect( p.maxOffset ).to.equal( 1 );
-		expect( p.getChild( 0 ).data ).to.equal( 'b' );
-	} );
+			expect( () => operation._validate() ).not.to.throw();
+		} );
 
-	it( 'should not throw when operation paths looks like incorrect but move is between different roots', () => {
-		const p = new Element( 'p' );
-		root.insertChildren( 0, [ new Text( 'a' ), p, new Text( 'b' ) ] );
-		doc.graveyard.insertChildren( 0, new Text( 'abc' ) );
+		it( 'should not throw when operation paths looks like incorrect but move is between different roots', () => {
+			const p = new Element( 'p' );
+			root.insertChildren( 0, [ new Text( 'a' ), p, new Text( 'b' ) ] );
+			doc.graveyard.insertChildren( 0, new Text( 'abc' ) );
 
-		const operation = new MoveOperation(
-			new Position( doc.graveyard, [ 0 ] ),
-			2,
-			new Position( root, [ 1, 0 ] ),
-			doc.version
-		);
+			const operation = new MoveOperation(
+				new Position( doc.graveyard, [ 0 ] ),
+				2,
+				new Position( root, [ 1, 0 ] ),
+				doc.version
+			);
 
-		expect(
-			() => {
-				model.applyOperation( wrapInDelta( operation ) );
-			}
-		).not.to.throw();
+			expect( () => operation._validate() ).not.to.throw();
+		} );
 	} );
 
 	it( 'should create MoveOperation with the same parameters when cloned', () => {

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

@@ -106,34 +106,38 @@ describe( 'ReinsertOperation', () => {
 		expect( operation.isDocumentOperation ).to.true;
 	} );
 
-	it( 'should throw when target position is not in the document', () => {
-		const docFrag = new DocumentFragment();
-
-		operation = new ReinsertOperation(
-			graveyardPosition,
-			1,
-			Position.createAt( docFrag ),
-			doc.version
-		);
-
-		expect( () => {
-			operation._execute();
-		} ).to.throw( CKEditorError, /^reinsert-operation-to-detached-parent/ );
-	} );
+	describe( '_validate()', () => {
+		it( 'should throw when target position is not in the document', () => {
+			const docFrag = new DocumentFragment();
+
+			graveyard.insertChildren( 0, new Text( 'xx' ) );
+
+			operation = new ReinsertOperation(
+				graveyardPosition,
+				1,
+				Position.createAt( docFrag ),
+				doc.version
+			);
+
+			expect( () => {
+				operation._validate();
+			} ).to.throw( CKEditorError, /^reinsert-operation-to-detached-parent/ );
+		} );
 
-	it( 'should throw when source position is not in the document', () => {
-		const docFrag = new DocumentFragment();
+		it( 'should throw when source position is not in the document', () => {
+			const docFrag = new DocumentFragment( new Text( 'xx' ) );
 
-		operation = new ReinsertOperation(
-			Position.createAt( docFrag ),
-			1,
-			rootPosition,
-			doc.version
-		);
+			operation = new ReinsertOperation(
+				Position.createAt( docFrag ),
+				1,
+				rootPosition,
+				doc.version
+			);
 
-		expect( () => {
-			operation._execute();
-		} ).to.throw( CKEditorError, /^reinsert-operation-on-detached-item/ );
+			expect( () => {
+				operation._validate();
+			} ).to.throw( CKEditorError, /^reinsert-operation-on-detached-item/ );
+		} );
 	} );
 
 	describe( 'toJSON', () => {

+ 15 - 13
packages/ckeditor5-engine/tests/model/operation/removeoperation.js

@@ -143,22 +143,24 @@ describe( 'RemoveOperation', () => {
 		expect( doc.graveyard.getChild( 2 ).name ).to.equal( 'y' );
 	} );
 
-	it( 'should throw when is executed on detached item', () => {
-		const docFrag = new DocumentFragment();
-		const item = new Element( 'foo' );
+	describe( '_validate()', () => {
+		it( 'should throw when is executed on detached item', () => {
+			const docFrag = new DocumentFragment();
+			const item = new Element( 'foo' );
 
-		docFrag.appendChildren( [ item ] );
+			docFrag.appendChildren( [ item ] );
 
-		const op = new RemoveOperation(
-			new Position( docFrag, [ 0 ] ),
-			1,
-			new Position( doc.graveyard, [ 0 ] ),
-			doc.version
-		);
+			const op = new RemoveOperation(
+				new Position( docFrag, [ 0 ] ),
+				1,
+				new Position( doc.graveyard, [ 0 ] ),
+				doc.version
+			);
 
-		expect( () => {
-			op._execute();
-		} ).to.throw( CKEditorError, /^remove-operation-on-detached-item/ );
+			expect( () => {
+				op._validate();
+			} ).to.throw( CKEditorError, /^remove-operation-on-detached-item/ );
+		} );
 	} );
 
 	it( 'should always be a document operation', () => {

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

@@ -64,20 +64,30 @@ describe( 'RenameOperation', () => {
 		expect( element.name ).to.equal( oldName );
 	} );
 
-	it( 'should throw an error if position is not before an element', () => {
-		const op = new RenameOperation( Position.createAt( root, 'end' ), oldName, newName, doc.version );
+	describe( '_validate()', () => {
+		it( 'should throw an error if position is not before an element', () => {
+			const op = new RenameOperation( Position.createAt( root, 'end' ), oldName, newName, doc.version );
 
-		expect( () => {
-			model.applyOperation( wrapInDelta( op ) );
-		} ).to.throw( CKEditorError, /rename-operation-wrong-position/ );
-	} );
+			expect( () => {
+				op._validate();
+			} ).to.throw( CKEditorError, /rename-operation-wrong-position/ );
+		} );
+
+		it( 'should throw an error if oldName is different than renamed element name', () => {
+			const op = new RenameOperation( position, 'foo', newName, doc.version );
 
-	it( 'should throw an error if oldName is different than renamed element name', () => {
-		const op = new RenameOperation( position, 'foo', newName, doc.version );
+			expect( () => {
+				op._validate();
+			} ).to.throw( CKEditorError, /rename-operation-wrong-name/ );
+		} );
 
-		expect( () => {
-			model.applyOperation( wrapInDelta( op ) );
-		} ).to.throw( CKEditorError, /rename-operation-wrong-name/ );
+		it( 'should not throw when new name is the same as previous', () => {
+			const op = new RenameOperation( position, oldName, oldName, doc.version );
+
+			expect( () => {
+				op._validate();
+			} ).to.not.throw();
+		} );
 	} );
 
 	it( 'should create a RenameOperation with the same parameters when cloned', () => {
@@ -94,14 +104,6 @@ describe( 'RenameOperation', () => {
 		expect( clone.newName ).to.equal( newName );
 	} );
 
-	it( 'should do nothing when new name is the same as previous', () => {
-		const op = new RenameOperation( position, oldName, oldName, doc.version );
-
-		expect( () => {
-			model.applyOperation( wrapInDelta( op ) );
-		} ).to.not.throw();
-	} );
-
 	describe( 'isDocumentOperation', () => {
 		it( 'should be true when target item is in the document', () => {
 			const op = new RenameOperation( position, oldName, newName, doc.version );

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

@@ -203,34 +203,36 @@ describe( 'RootAttributeOperation', () => {
 		expect( root.getAttribute( 'foo' ) ).to.be.true;
 	} );
 
-	it( 'should throw an error when one try to remove and the attribute does not exists', () => {
-		expect( () => {
-			model.applyOperation( wrapInDelta(
-				new RootAttributeOperation(
+	describe( '_validate()', () => {
+		it( 'should throw an error when one try to remove and the attribute does not exists', () => {
+			expect( () => {
+				const op = new RootAttributeOperation(
 					root,
 					'foo',
 					true,
 					null,
 					doc.version
-				)
-			) );
-		} ).to.throw( CKEditorError, /rootattribute-operation-wrong-old-value/ );
-	} );
+				);
+
+				op._validate();
+			} ).to.throw( CKEditorError, /rootattribute-operation-wrong-old-value/ );
+		} );
 
-	it( 'should throw an error when one try to insert and the attribute already exists', () => {
-		root.setAttribute( 'x', 1 );
+		it( 'should throw an error when one try to insert and the attribute already exists', () => {
+			root.setAttribute( 'x', 1 );
 
-		expect( () => {
-			model.applyOperation( wrapInDelta(
-				new RootAttributeOperation(
+			expect( () => {
+				const op = new RootAttributeOperation(
 					root,
 					'x',
 					null,
 					2,
 					doc.version
-				)
-			) );
-		} ).to.throw( CKEditorError, /rootattribute-operation-attribute-exists/ );
+				);
+
+				op._validate();
+			} ).to.throw( CKEditorError, /rootattribute-operation-attribute-exists/ );
+		} );
 	} );
 
 	it( 'should create a RootAttributeOperation with the same parameters when cloned', () => {
@@ -299,7 +301,7 @@ describe( 'RootAttributeOperation', () => {
 
 			expect( () => {
 				RootAttributeOperation.fromJSON( serialized, doc );
-			} ).to.throw( CKEditorError, /rootattribute-operation-fromjson-no-roo/ );
+			} ).to.throw( CKEditorError, /rootattribute-operation-fromjson-no-root/ );
 		} );
 	} );
 } );

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

@@ -6,6 +6,8 @@
 import DocumentFragment from '../../src/view/documentfragment';
 import Element from '../../src/view/element';
 import Node from '../../src/view/node';
+import Text from '../../src/view/text';
+import TextProxy from '../../src/view/textproxy';
 
 describe( 'DocumentFragment', () => {
 	describe( 'constructor()', () => {
@@ -160,6 +162,18 @@ describe( 'DocumentFragment', () => {
 
 				fragment.appendChildren( el1 );
 			} );
+
+			it( 'should accept and correctly handle text proxies', () => {
+				const frag = new DocumentFragment();
+				const text = new Text( 'abcxyz' );
+				const textProxy = new TextProxy( text, 2, 3 );
+
+				frag.insertChildren( 0, textProxy );
+
+				expect( frag.childCount ).to.equal( 1 );
+				expect( frag.getChild( 0 ) ).to.be.instanceof( Text );
+				expect( frag.getChild( 0 ).data ).to.equal( 'cxy' );
+			} );
 		} );
 
 		describe( 'getChildIndex', () => {

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

@@ -6,6 +6,8 @@
 import count from '@ckeditor/ckeditor5-utils/src/count';
 import Node from '../../src/view/node';
 import Element from '../../src/view/element';
+import Text from '../../src/view/text';
+import TextProxy from '../../src/view/textproxy';
 
 import encodedImage from './_utils/encodedimage.txt';
 
@@ -332,6 +334,18 @@ describe( 'Element', () => {
 				expect( count2 ).to.equal( 1 );
 				expect( count3 ).to.equal( 1 );
 			} );
+
+			it( 'should accept and correctly handle text proxies', () => {
+				const element = new Element( 'div' );
+				const text = new Text( 'abcxyz' );
+				const textProxy = new TextProxy( text, 2, 3 );
+
+				element.insertChildren( 0, textProxy );
+
+				expect( element.childCount ).to.equal( 1 );
+				expect( element.getChild( 0 ) ).to.be.instanceof( Text );
+				expect( element.getChild( 0 ).data ).to.equal( 'cxy' );
+			} );
 		} );
 
 		describe( 'getChildIndex', () => {

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

@@ -78,6 +78,12 @@ describe( 'TextProxy', () => {
 		} );
 	} );
 
+	describe( 'offsetSize', () => {
+		it( 'should be equal to the number of characters in text proxy', () => {
+			expect( textProxy.offsetSize ).to.equal( 3 );
+		} );
+	} );
+
 	describe( 'getDocument', () => {
 		it( 'should return null if any parent has not set Document', () => {
 			expect( textProxy.document ).to.be.null;

+ 45 - 6
packages/ckeditor5-engine/tests/view/treewalker.js

@@ -1005,14 +1005,53 @@ describe( 'TreeWalker', () => {
 		const b = new AttributeElement( 'b', null, bar );
 		const docFrag = new DocumentFragment( [ p, b ] );
 
-		const iterator = new TreeWalker( {
-			startPosition: new Position( docFrag, 0 ),
-			ignoreElementEnd: true
-		} );
+		const expected = [
+			{
+				type: 'elementStart',
+				item: p,
+				previousPosition: new Position( docFrag, 0 ),
+				nextPosition: new Position( p, 0 )
+			},
+			{
+				type: 'text',
+				text: 'foo',
+				previousPosition: new Position( p, 0 ),
+				nextPosition: new Position( p, 1 )
+			},
+			{
+				type: 'elementEnd',
+				item: p,
+				previousPosition: new Position( p, 1 ),
+				nextPosition: new Position( docFrag, 1 )
+			},
+			{
+				type: 'elementStart',
+				item: b,
+				previousPosition: new Position( docFrag, 1 ),
+				nextPosition: new Position( b, 0 )
+			},
+			{
+				type: 'text',
+				text: 'bar',
+				previousPosition: new Position( b, 0 ),
+				nextPosition: new Position( b, 1 )
+			},
+			{
+				type: 'elementEnd',
+				item: b,
+				previousPosition: new Position( b, 1 ),
+				nextPosition: new Position( docFrag, 2 )
+			}
+		];
+
+		const iterator = new TreeWalker( { boundaries: Range.createIn( docFrag ) } );
+		let i = 0;
 
-		const nodes = Array.from( iterator ).map( step => step.item );
+		for ( const value of iterator ) {
+			expectValue( value, expected[ i++ ] );
+		}
 
-		expect( nodes ).to.deep.equal( [ p, foo, b, bar ] );
+		expect( i ).to.equal( expected.length );
 	} );
 
 	describe( 'skip', () => {

+ 27 - 0
packages/ckeditor5-engine/tests/view/writer/unwrap.js

@@ -295,6 +295,33 @@ describe( 'writer', () => {
 			);
 		} );
 
+		it( 'should partially unwrap part of a node', () => {
+			test(
+				'<container:p>' +
+					'[<attribute:b view-priority="1" baz="qux" foo="bar">foo}bar</attribute:b>' +
+				'</container:p>',
+				'<attribute:b view-priority="1" foo="bar"></attribute:b>',
+				'<container:p>' +
+					'[<attribute:b view-priority="1" baz="qux">foo</attribute:b>]' +
+					'<attribute:b view-priority="1" baz="qux" foo="bar">bar</attribute:b>' +
+				'</container:p>'
+			);
+		} );
+
+		it( 'should be merged after being partially unwrapped', () => {
+			test(
+				'<container:p>' +
+					'<attribute:b view-priority="1" baz="qux">xyz</attribute:b>' +
+					'[<attribute:b view-priority="1" baz="qux" foo="bar">foo}bar</attribute:b>' +
+				'</container:p>',
+				'<attribute:b view-priority="1" foo="bar"></attribute:b>',
+				'<container:p>' +
+					'<attribute:b view-priority="1" baz="qux">xyz{foo</attribute:b>]' +
+					'<attribute:b view-priority="1" baz="qux" foo="bar">bar</attribute:b>' +
+				'</container:p>'
+			);
+		} );
+
 		it( 'should unwrap single node in document fragment', () => {
 			test(
 				'<container:p>[<attribute:b view-priority="1">foobar</attribute:b>]</container:p>',

+ 27 - 0
packages/ckeditor5-engine/tests/view/writer/wrap.js

@@ -283,6 +283,33 @@ describe( 'writer', () => {
 			);
 		} );
 
+		it( 'should be merged with broken element', () => {
+			test(
+				'<container:p>' +
+					'[<attribute:b view-priority="1" foo="bar">foo}bar</attribute:b>' +
+				'</container:p>',
+				'<attribute:b view-priority="1" baz="qux"></attribute:b>',
+				'<container:p>' +
+					'[<attribute:b view-priority="1" baz="qux" foo="bar">foo</attribute:b>]' +
+					'<attribute:b view-priority="1" foo="bar">bar</attribute:b>' +
+				'</container:p>'
+			);
+		} );
+
+		it( 'should be merged with broken element and merged with siblings', () => {
+			test(
+				'<container:p>' +
+					'<attribute:b view-priority="1" baz="qux" foo="bar">xyz</attribute:b>' +
+					'[<attribute:b view-priority="1" foo="bar">foo}bar</attribute:b>' +
+				'</container:p>',
+				'<attribute:b view-priority="1" baz="qux"></attribute:b>',
+				'<container:p>' +
+					'<attribute:b view-priority="1" baz="qux" foo="bar">xyz{foo</attribute:b>]' +
+					'<attribute:b view-priority="1" foo="bar">bar</attribute:b>' +
+				'</container:p>'
+			);
+		} );
+
 		it( 'should wrap EmptyElement', () => {
 			test(
 				'<container:p>[<empty:img></empty:img>]</container:p>',

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików