Ver código fonte

Changed: Refactored `conversion.ModelConversionDispatcher` to use `model.Differ`.

Szymon Cofalik 8 anos atrás
pai
commit
488fc5dd9a

+ 170 - 317
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,11 +83,11 @@ 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:
+ * 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).
@@ -82,25 +97,25 @@ import extend from '@ckeditor/ckeditor5-utils/src/lib/lodash/extend';
  *		// 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' );
+ *		modelConversionDispatcher.on( 'attribute:linkHref:quote', ( evt, data, consumable, conversionApi ) => {
+ *			if ( consumable.consume( data.item, 'attribute:linkHref' ) ) {
+ *				return;
+ *			}
  *
  *			// Create a button that will represent the `linkHref` attribute.
  *			let viewSourceBtn = new ViewElement( 'a', {
  *				href: data.item.getAttribute( 'linkHref' ),
- *				title: 'source'
+ *				title: 'source',
+ *				class: '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.
+ *			// Note that attribute conversion is fired after insert conversion.
+ *			// This means that we are safe to assume that the model `quote` element has already been converter to view.
+ *			// `data.item` is model element on which attribute changed.
  *			const viewQuote = conversionApi.mapper.toViewElement( data.item );
- *			const position = new ViewPosition( viewQuote, viewQuote.childCount );
+ *			// Put `viewSourceBtn` at the end of quote.
+ *			const position = ViewPosition.createAt( viewQuote, 'end' );
  *			viewWriter.insert( position, viewSourceBtn );
  *
  *			evt.stop();
@@ -108,10 +123,10 @@ import extend from '@ckeditor/ckeditor5-utils/src/lib/lodash/extend';
  */
 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 +146,49 @@ export default class ModelConversionDispatcher {
 	}
 
 	/**
-	 * Prepares data and fires a proper event.
-	 *
-	 * The method is crafted to take use of parameters passed in {@link module:engine/model/document~Document#event:change Document change
-	 * event}.
+	 * Takes {@link module:engine/model/differ~Differ model differ} object with buffered changes and fires conversion basing on it.
 	 *
-	 * @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 ( entry.position.root.rootName == '$graveyard' ) {
+				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 {
+				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`.
-	 *
-	 * Analyzes given range and fires insertion-connected events with data based on that range.
+	 * Starts conversion of a range insertion.
 	 *
-	 * **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 +211,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 conversion of a single node removal. Fires {@link #event:remove remove event} with provided data.
 	 *
-	 * 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.
+	 * @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.
 	 */
-	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 );
-		}
+	convertRemove( position, length, name ) {
+		this.fire( 'remove:' + name, { position, length }, this.conversionApi );
 	}
 
 	/**
-	 * 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.
+	 * Starts conversion of attribute change on given `range`.
 	 *
-	 * @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}).
-	 */
-	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 );
-		}
-	}
-
-	/**
-	 * Starts conversion of attribute-change on given `range`.
+	 * For each node in the given `range`, {@link #event:attribute attribute event} is fired with the passed data.
 	 *
-	 * Analyzes given attribute change and fires attributes-connected events with data based on passed values.
-	 *
-	 * @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 +254,17 @@ export default class ModelConversionDispatcher {
 				attributeNewValue: newValue
 			};
 
-			this._testAndFire( `${ type }:${ 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;
+			this._testAndFire( `attribute:${ key }`, data, consumable );
 		}
-
-		// 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 +307,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 +385,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 +393,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 +439,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 +459,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 +476,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 +490,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 +514,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 +524,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 +537,36 @@ 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.
 	 *
+	 * The event is fired for each item in the marker range, one by one.
+	 *
 	 * @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.
 	 */
 }

+ 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;
 		} );
 	} );
 } );