Bladeren bron

Merge branch 'master' into t/1055

# Conflicts:
#	src/model/delta/basic-transformations.js
#	tests/model/delta/transform/attributedelta.js
Szymon Cofalik 8 jaren geleden
bovenliggende
commit
ce57f685ac
26 gewijzigde bestanden met toevoegingen van 1486 en 608 verwijderingen
  1. 1 0
      packages/ckeditor5-engine/package.json
  2. 1 1
      packages/ckeditor5-engine/src/controller/datacontroller.js
  3. 103 68
      packages/ckeditor5-engine/src/conversion/buildmodelconverter.js
  4. 11 17
      packages/ckeditor5-engine/src/conversion/model-selection-to-view-converters.js
  5. 155 119
      packages/ckeditor5-engine/src/conversion/model-to-view-converters.js
  6. 65 29
      packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js
  7. 10 10
      packages/ckeditor5-engine/src/conversion/viewconversiondispatcher.js
  8. 4 1
      packages/ckeditor5-engine/src/dev-utils/enableenginedebug.js
  9. 43 8
      packages/ckeditor5-engine/src/model/delta/basic-transformations.js
  10. 1 34
      packages/ckeditor5-engine/src/view/writer.js
  11. 2 1
      packages/ckeditor5-engine/tests/controller/editingcontroller.js
  12. 77 56
      packages/ckeditor5-engine/tests/conversion/buildmodelconverter.js
  13. 15 12
      packages/ckeditor5-engine/tests/conversion/model-selection-to-view-converters.js
  14. 318 244
      packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js
  15. 136 3
      packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js
  16. 1 1
      packages/ckeditor5-engine/tests/conversion/viewconversiondispatcher.js
  17. 35 0
      packages/ckeditor5-engine/tests/dev-utils/enableenginedebug.js
  18. 6 4
      packages/ckeditor5-engine/tests/manual/markers.js
  19. 54 0
      packages/ckeditor5-engine/tests/manual/virtualselection.html
  20. 134 0
      packages/ckeditor5-engine/tests/manual/virtualselection.js
  21. 14 0
      packages/ckeditor5-engine/tests/manual/virtualselection.md
  22. 27 0
      packages/ckeditor5-engine/tests/model/delta/transform/attributedelta.js
  23. 33 0
      packages/ckeditor5-engine/tests/model/delta/transform/renamedelta.js
  24. 175 0
      packages/ckeditor5-engine/tests/model/delta/transform/splitdelta.js
  25. 34 0
      packages/ckeditor5-engine/tests/model/delta/transform/unwrapdelta.js
  26. 31 0
      packages/ckeditor5-engine/tests/model/delta/transform/wrapdelta.js

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

@@ -19,6 +19,7 @@
     "@ckeditor/ckeditor5-paragraph": "^0.8.0",
     "@ckeditor/ckeditor5-typing": "^0.9.1",
     "@ckeditor/ckeditor5-undo": "^0.8.1",
+    "@ckeditor/ckeditor5-widget": "^0.1.1",
     "eslint-config-ckeditor5": "^1.0.5",
     "gulp": "^3.9.1",
     "guppy-pre-commit": "^0.4.0"

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

@@ -225,7 +225,7 @@ export default class DataController {
 	 * {@link module:engine/view/documentfragment~DocumentFragment view document fragment} converted by the
 	 * {@link #viewToModel view to model converters}.
 	 *
-	 * When marker stamps were converted during conversion process then will be set as DocumentFragment's
+	 * When marker elements were converted during conversion process then will be set as DocumentFragment's
 	 * {@link module:engine/model/documentfragment~DocumentFragment#markers static markers map}.
 	 *
 	 * @param {module:engine/view/element~Element|module:engine/view/documentfragment~DocumentFragment} viewElementOrFragment

+ 103 - 68
packages/ckeditor5-engine/src/conversion/buildmodelconverter.js

@@ -15,8 +15,8 @@ import {
 	removeUIElement,
 	wrapItem,
 	unwrapItem,
-	wrapRange,
-	unwrapRange
+	convertTextsInsideMarker,
+	convertElementsInsideMarker
 } from './model-to-view-converters';
 
 import { convertSelectionAttribute, convertSelectionMarker } from './model-selection-to-view-converters';
@@ -37,7 +37,7 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  * {@link module:engine/conversion/model-to-view-converters}, {@link module:engine/conversion/modelconsumable~ModelConsumable},
  * {@link module:engine/conversion/mapper~Mapper}.
  *
- * Using this API it is possible to create four kinds of converters:
+ * Using this API it is possible to create five kinds of converters:
  *
  * 1. Model element to view element converter. This is a converter that takes the model element and represents it
  * in the view.
@@ -58,17 +58,25 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  *
  *		buildModelConverter().for( dispatcher ).fromAttribute( 'bold' ).toElement( 'strong' );
  *
- * 4. Model marker to view element converter. This is a converter that converts markers from given group to view attribute element.
- * Markers, basically, are {@link module:engine/model/liverange~LiveRange} instances, that are named. In this conversion, model range is
- * converted to view range, then that view range is wrapped (or unwrapped, if range is removed) in a view attribute element.
- * To learn more about markers, see {@link module:engine/model/markercollection~MarkerCollection}.
+ * 4. Model marker to virtual selection converter. This is a converter that converts model markers to virtual
+ * selection described by {@link module:engine/conversion/buildmodelconverter~VirtualSelectionDescriptor} object passed to
+ * {@link module:engine/conversion/buildmodelconverter~ModelConverterBuilder#toVirtualSelection} method.
  *
- *		const viewSpanSearchResult = new ViewAttributeElement( 'span', { class: 'search-result' } );
- *		buildModelConverter().for( dispatcher ).fromMarker( 'searchResult' ).toElement( viewSpanSearchResult );
+ *		buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toVirtualSelection( {
+ *			class: 'search',
+ *			priority: 20
+ *		} );
+ *
+ * 5. Model marker to element converter. This is a converter that takes model marker and creates separate elements at
+ * the beginning and at the end of the marker's range. For more information see
+ * {@link module:engine/conversion/buildmodelconverter~ModelConverterBuilder#toElement} method.
+ *
+ *		buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( 'span' );
  *
  * It is possible to provide various different parameters for
- * {@link module:engine/conversion/buildmodelconverter~ModelConverterBuilder#toElement}
- * and {@link module:engine/conversion/buildmodelconverter~ModelConverterBuilder#toAttribute} methods.
+ * {@link module:engine/conversion/buildmodelconverter~ModelConverterBuilder#toElement},
+ * {@link module:engine/conversion/buildmodelconverter~ModelConverterBuilder#toAttribute} and
+ * {@link module:engine/conversion/buildmodelconverter~ModelConverterBuilder#toVirtualSelection} methods.
  * See their descriptions to learn more.
  *
  * It is also possible to {@link module:engine/conversion/buildmodelconverter~ModelConverterBuilder#withPriority change default priority}
@@ -198,7 +206,13 @@ class ModelConverterBuilder {
 	 * `string`, view element instance which will be cloned and used, or creator function which returns view element that
 	 * will be used. Keep in mind that when you view element instance or creator function, it has to be/return a
 	 * proper type of view element: {@link module:engine/view/containerelement~ContainerElement ViewContainerElement} if you convert
-	 * from element or {@link module:engine/view/attributeelement~AttributeElement ViewAttributeElement} if you convert from attribute.
+	 * from element, {@link module:engine/view/attributeelement~AttributeElement ViewAttributeElement} if you convert
+	 * from attribute and {@link module:engine/view/uielement~UIElement ViewUIElement} if you convert from marker.
+	 *
+	 * NOTE: When converting from model's marker, separate elements will be created at the beginning and at the end of the
+	 * marker's range. If range is collapsed then only one element will be created. See how markers
+	 * {module:engine/model/buildviewconverter~ViewConverterBuilder#toMarker view -> model serialization}
+	 * works to find out what view element format is the best for you.
 	 *
 	 *		buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
 	 *
@@ -210,12 +224,17 @@ class ModelConverterBuilder {
 	 *
 	 *		buildModelConverter().for( dispatcher ).fromAttribute( 'bold' ).toElement( new ViewAttributeElement( 'strong' ) );
 	 *
+	 *		buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( 'span' );
+	 *
+	 *		buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( new ViewUIElement( 'span' ) );
+	 *
 	 * Creator function will be passed different values depending whether conversion is from element or from attribute:
 	 *
 	 * * from element: dispatcher's
 	 * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:insert insert event}
 	 * parameters will be passed,
-	 * * from attribute: there is one parameter and it is attribute value.
+	 * * from attribute: there is one parameter and it is attribute value,
+	 * * from marker: {@link module:engine/conversion/buildmodelconverter~MarkerViewElementCreatorData}.
 	 *
 	 * This method also registers model selection to view selection converter, if conversion is from attribute.
 	 *
@@ -243,77 +262,78 @@ class ModelConverterBuilder {
 				dispatcher.on( 'removeAttribute:' + this._from.key, unwrapItem( element ), { priority } );
 
 				dispatcher.on( 'selectionAttribute:' + this._from.key, convertSelectionAttribute( element ), { priority } );
-			} else {
-				element = typeof element == 'string' ? new ViewAttributeElement( element ) : element;
+			} else { // From marker to element.
+				const priority = this._from.priority === null ? 'normal' : this._from.priority;
 
-				dispatcher.on( 'addMarker:' + this._from.name, wrapRange( element ), { priority } );
-				dispatcher.on( 'removeMarker:' + this._from.name, unwrapRange( element ), { priority } );
+				element = typeof element == 'string' ? new ViewUIElement( element ) : element;
 
-				dispatcher.on( 'selectionMarker:' + this._from.name, convertSelectionMarker( element ), { priority } );
+				dispatcher.on( 'addMarker:' + this._from.name, insertUIElement( element ), { priority } );
+				dispatcher.on( 'removeMarker:' + this._from.name, removeUIElement( element ), { priority } );
 			}
 		}
 	}
 
 	/**
-	 * Registers what view stamp will be created by converter to mark marker range bounds. Separate elements will be
-	 * created at the beginning and at the end of the range. If range is collapsed then only one element will be created.
+	 * Registers that marker should be converted to virtual selection. Markers, basically,
+	 * are {@link module:engine/model/liverange~LiveRange} instances, that are named. Virtual selection is
+	 * a representation of the model marker in the view:
+	 * * each {@link module:engine/view/text~Text view text node} in the marker's range will be wrapped with `span`
+	 * {@link module:engine/view/attributeelement~AttributeElement},
+	 * * each {@link module:engine/view/containerelement~ContainerElement container view element} in the marker's
+	 * range can handle the virtual selection individually by providing `setVirtualSelection` and `removeVirtualSelection`
+	 * custom properties:
 	 *
-	 * Method accepts various ways of providing how the view element will be created. You can pass view element name as
-	 * `string`, view element instance which will be cloned and used, or creator function which returns view element that
-	 * will be used. Keep in mind that when you provide view element instance or creator function, it has to be/return a
-	 * proper type of view element: {@link module:engine/view/uielement~UIElement UIElement}.
+	 *		viewElement.setCustomProperty( 'setVirtualSelection', ( element, descriptor ) => {} );
+	 *		viewElement.setCustomProperty( 'removeVirtualSelection', ( element, descriptor ) => {} );
 	 *
-	 *		buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toStamp( 'span' );
+	 * {@link module:engine/conversion/buildmodelconverter~VirtualSelectionDescriptor Descriptor} will be used to create
+	 * spans over text nodes and also will be provided to `setVirtualSelection` and `removeVirtualSelection` methods
+	 * each time virtual selection should be set or removed from view elements.
+	 * NOTE: When `setVirtualSelection` and `removeVirtualSelection` custom properties are present, converter assumes
+	 * that element itself is taking care of presenting virtual selection on its child nodes, so it won't convert virtual
+	 * selection on them.
 	 *
-	 *		buildModelConverter().for( dispatcher )
-	 *			.fromMarker( 'search' )
-	 *			.toStamp( new UIElement( 'span', { 'data-name': 'search' } ) );
+	 * Virtual selection descriptor can be provided as plain object:
 	 *
-	 *		buildModelConverter().for( dispatcher )
-	 *			.fromMarker( 'search' )
-	 *			.toStamp( ( data ) => new UIElement( 'span', { 'data-name': data.name ) );
-	 *
-	 * Creator function provides additional `data.isOpening` parameter which defined if currently converted element is
-	 * a beginning or end of the marker range. This makes possible to create different opening and closing stamp.
-	 *
-	 *		buildModelConverter().for( dispatcher )
-	 *			.fromMarker( 'search' )
-	 *			.toStamp( ( data ) => {
-	 *				if ( data.isOpening ) {
-	 *					return new UIElement( 'span', { 'data-name': data.name, 'data-start': true ) );
-	 *				}
+	 *		buildModelConverter.for( dispatcher ).fromMarker( 'search' ).toVirtualSelection( { class: 'search-mark' } );
+ 	 *
+	 * Also, descriptor creator function can be provided:
 	 *
-	 *				return new UIElement( 'span', { 'data-name': data.name, 'data-end': true ) );
-	 *			}
+	 *		buildModelConverter.for( dispatcher ).fromMarker( 'search:blue' ).toVirtualSelection( data => {
+	 *			const color = data.markerName.split( ':' )[ 1 ];
 	 *
-	 * Creator function provides
-	 * {@link module:engine/conversion/buildmodelconverter~StampCreatorData} parameters.
+	 *			return { class: 'search-' + color };
+	 *		} );
 	 *
-	 * See how markers {module:engine/model/buildviewconverter~ViewConverterBuilder#toMarker view -> model serialization}
-	 * works to find out what view element format is the best for you.
+	 * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError}
+	 * `build-model-converter-non-marker-to-virtual-selection` when trying to convert not from marker.
 	 *
-	 * @param {String|module:engine/view/uielement~UIElement|Function} element UIElement created by converter or
-	 * a function that returns view element.
+	 * @param {function|module:engine/conversion/buildmodelconverter~VirtualSelectionDescriptor} selectionDescriptor
 	 */
-	toStamp( element ) {
-		for ( const dispatcher of this._dispatchers ) {
-			if ( this._from.type != 'marker' ) {
-				/**
-				 * To-stamp conversion is supported only for model markers.
-				 *
-				 * @error build-model-converter-element-to-stamp
-				 */
-				throw new CKEditorError(
-					'build-model-converter-non-marker-to-stamp: To-stamp conversion is supported only from model markers.'
-				);
-			}
+	toVirtualSelection( selectionDescriptor ) {
+		const priority = this._from.priority === null ? 'normal' : this._from.priority;
 
-			const priority = this._from.priority === null ? 'normal' : this._from.priority;
+		if ( this._from.type != 'marker' ) {
+			/**
+			 * To virtual selection conversion is supported only for model markers.
+			 *
+			 * @error build-model-converter-non-marker-to-virtual-selection
+			 */
+			throw new CKEditorError(
+				'build-model-converter-non-marker-to-virtual-selection: Conversion to virtual selection is supported ' +
+				'only from model markers.'
+			);
+		}
+
+		for ( const dispatcher of this._dispatchers ) {
+			// Separate converters for converting texts and elements inside marker's range.
+			dispatcher.on( 'addMarker:' + this._from.name, convertTextsInsideMarker( selectionDescriptor ), { priority } );
+			dispatcher.on( 'addMarker:' + this._from.name, convertElementsInsideMarker( selectionDescriptor ), { priority } );
 
-			element = typeof element == 'string' ? new ViewUIElement( element ) : element;
+			dispatcher.on( 'removeMarker:' + this._from.name, convertTextsInsideMarker( selectionDescriptor ), { priority } );
+			dispatcher.on( 'removeMarker:' + this._from.name, convertElementsInsideMarker( selectionDescriptor ), { priority } );
 
-			dispatcher.on( 'addMarker:' + this._from.name, insertUIElement( element ), { priority } );
-			dispatcher.on( 'removeMarker:' + this._from.name, removeUIElement( element ), { priority } );
+			dispatcher.on( 'selectionMarker:' + this._from.name, convertSelectionMarker( selectionDescriptor ), { priority } );
 		}
 	}
 
@@ -404,11 +424,26 @@ export default function buildModelConverter() {
 }
 
 /**
- * @typedef StampCreatorData
+ * @typedef MarkerViewElementCreatorData
  * @param {Object} data Additional information about the change.
- * @param {String} data.name Marker name.
- * @param {module:engine/model/range~Range} data.range Marker range.
+ * @param {String} data.markerName Marker name.
+ * @param {module:engine/model/range~Range} data.markerRange Marker range.
  * @param {Boolean} data.isOpening Defines if currently converted element is a beginning or end of the marker range.
  * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
  * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
  */
+
+/**
+ * @typedef VirtualSelectionDescriptor
+ * Object describing how virtual selection should be created in the view. Each text node in virtual selection
+ * will be wrapped with `span` element with CSS class, attributes and priority described by this object. Each element
+ * can handle virtual selection separately by providing `setVirtualSelection` and `removeVirtualSelection` custom
+ * properties.
+ *
+ * @property {String} class CSS class that will be added to `span`
+ * {@link module:engine/view/attributeelement~AttributeElement} wrapping each text node in the virtual selection.
+ * @property {Number} [priority] {@link module:engine/view/attributeelement~AttributeElement#priority} of the `span`
+ * wrapping each text node in the virtual selection. If not provided, default 10 priority will be used.
+ * @property {Object} [attributes] Attributes that will be added to `span`
+ * {@link module:engine/view/attributeelement~AttributeElement} wrapping each text node it the virtual selection.
+ */

+ 11 - 17
packages/ckeditor5-engine/src/conversion/model-selection-to-view-converters.js

@@ -6,6 +6,7 @@
 import ViewElement from '../view/element';
 import ViewRange from '../view/range';
 import viewWriter from '../view/writer';
+import { virtualSelectionDescriptorToAttributeElement } from './model-to-view-converters';
 
 /**
  * Contains {@link module:engine/model/selection~Selection model selection} to
@@ -112,11 +113,6 @@ export function convertCollapsedSelection() {
  *			}
  *		}
  *		modelDispatcher.on( 'selectionAttribute:style', convertSelectionAttribute( styleCreator ) );
- *
- * **Note:** You can use the same `elementCreator` function for this converter factory
- * and {@link module:engine/conversion/model-to-view-converters~wrapRange}
- * model to view converter, as long as the `elementCreator` function uses only the first parameter (attribute value).
- *
  *		modelDispatcher.on( 'selection', convertCollapsedSelection() );
  *		modelDispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
  *		modelDispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'strong' ) ) );
@@ -163,27 +159,25 @@ export function convertSelectionAttribute( elementCreator ) {
  * Performs similar conversion as {@link ~convertSelectionAttribute}, but depends on a marker name of a marker in which
  * collapsed selection is placed.
  *
- *		modelDispatcher.on( 'selectionMarker:searchResult', wrapRange( new ViewAttributeElement( 'span', { class: 'searchResult' } ) ) );
- *
- * **Note:** You can use the same `elementCreator` function for this converter factory
- * and {@link module:engine/conversion/model-to-view-converters~wrapRange}.
+ *		modelDispatcher.on( 'selectionMarker:searchResult', convertSelectionMarker( { class: 'search' } ) );
  *
  * @see module:engine/conversion/model-selection-to-view-converters~convertSelectionAttribute
- * @param {module:engine/view/attributeelement~AttributeElement|Function} elementCreator View element,
- * or function returning a view element, which will be used for wrapping.
+ * @param {module:engine/conversion/buildmodelconverter~VirtualSelectionDescriptor|Function} selectionDescriptor Virtual
+ * selection descriptor object or function returning a descriptor object.
  * @returns {Function} Selection converter.
  */
-export function convertSelectionMarker( elementCreator ) {
+export function convertSelectionMarker( selectionDescriptor ) {
 	return ( evt, data, consumable, conversionApi ) => {
-		const viewElement = elementCreator instanceof ViewElement ?
-			elementCreator.clone( true ) :
-			elementCreator( data, consumable, conversionApi );
+		const descriptor = typeof selectionDescriptor == 'function' ?
+			selectionDescriptor( data, consumable, conversionApi ) :
+			selectionDescriptor;
 
-		if ( !viewElement ) {
+		if ( !descriptor ) {
 			return;
 		}
 
-		const consumableName = 'selectionMarker:' + data.name;
+		const viewElement = virtualSelectionDescriptorToAttributeElement( descriptor );
+		const consumableName = 'selectionMarker:' + data.markerName;
 
 		wrapCollapsedSelectionPosition( data.selection, conversionApi.viewSelection, viewElement, consumable, consumableName );
 	};

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

@@ -4,11 +4,13 @@
  */
 
 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
@@ -121,16 +123,29 @@ export function insertUIElement( elementCreator ) {
 			return;
 		}
 
-		if ( !consumable.consume( data.range, 'addMarker' ) ) {
+		const markerRange = data.markerRange;
+		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.
+		// If marker's range is collapsed - check if it can be consumed.
+		if ( markerRange.isCollapsed && !consumable.consume( markerRange, eventName ) ) {
 			return;
 		}
 
+		// If marker's range is not collapsed - consume all items inside.
+		for ( const value of markerRange ) {
+			if ( !consumable.consume( value.item, eventName ) ) {
+				return;
+			}
+		}
+
 		const mapper = conversionApi.mapper;
 
-		viewWriter.insert( mapper.toViewPosition( data.range.start ), viewStartElement );
+		viewWriter.insert( mapper.toViewPosition( markerRange.start ), viewStartElement );
 
-		if ( !data.range.isCollapsed ) {
-			viewWriter.insert( mapper.toViewPosition( data.range.end ), viewEndElement );
+		if ( !markerRange.isCollapsed ) {
+			viewWriter.insert( mapper.toViewPosition( markerRange.end ), viewEndElement );
 		}
 	};
 }
@@ -326,105 +341,6 @@ export function unwrapItem( elementCreator ) {
 }
 
 /**
- * Function factory, creates a converter that wraps model range.
- *
- * In contrary to {@link module:engine/conversion/model-to-view-converters~wrapItem}, this converter's input is a
- * {@link module:engine/model/range~Range model range} (not changed model item). The model range is mapped
- * to {@link module:engine/view/range~Range view range} and then, view items within that view range are wrapped in a
- * {@link module:engine/view/attributeelement~AttributeElement view attribute element}. Note, that `elementCreator`
- * function of this converter takes different parameters that `elementCreator` of `wrapItem`.
- *
- * Let's assume following model and view. `{}` represents a range that is added as a marker with `searchResult` name.
- * The range represents a result of search `ab` string in the model document. The range has to be visualized in view.
- *
- *		[paragraph]              MODEL ====> VIEW        <p>
- *			|- {a                                         |- <span class="searchResult">
- *			|-  b}                                        |   |- ab
- *			|-  c                                         |- c
- *
- * The wrapping node depends on passed parameter. If {@link module:engine/view/attributeelement~AttributeElement} was passed, it
- * will be cloned and the copy will become the wrapping element. If `Function` is provided, it is passed all the parameters of the
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addMarker addMarker event}. It's expected
- * that the function returns a {@link module:engine/view/attributeelement~AttributeElement}. The result of the function will be the
- * wrapping element. When provided `Function` does not return element, then will be no conversion.
- *
- * The converter automatically consumes corresponding value from consumables list, stops the event (see
- * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}).
- *
- *		modelDispatcher.on( 'addMarker:searchResult', wrapRange( new ViewAttributeElement( 'span', { class: 'searchResult' } ) ) );
- *
- * @param {module:engine/view/attributeelement~AttributeElement|Function} elementCreator View attribute element, or function returning
- * a view attribute element, which will be used for wrapping.
- * @returns {Function} Wrap range converter.
- */
-export function wrapRange( elementCreator ) {
-	return ( evt, data, consumable, conversionApi ) => {
-		const viewElement = ( elementCreator instanceof ViewElement ) ?
-			elementCreator.clone( true ) :
-			elementCreator( data, consumable, conversionApi );
-
-		if ( !viewElement ) {
-			return;
-		}
-
-		if ( !consumable.consume( data.range, 'addMarker' ) ) {
-			return;
-		}
-
-		const viewRange = conversionApi.mapper.toViewRange( data.range );
-		const flatViewRanges = viewWriter.breakViewRangePerContainer( viewRange );
-
-		for ( const range of flatViewRanges ) {
-			viewWriter.wrap( range, viewElement );
-		}
-	};
-}
-
-/**
- * Function factory, creates a converter that converts removing of a model marker to view attribute element.
- * This converter will unwrap view nodes from corresponding view range.
- *
- * The view element that will be unwrapped depends on passed parameter. If {@link module:engine/view/attributeelement~AttributeElement}
- * 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:removeMarker removeMarker event}.
- * It's expected that the function returns a {@link module:engine/view/attributeelement~AttributeElement}. 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( 'removeMarker:searchResult', unwrapRange( new ViewAttributeElement( 'span', { class: 'searchResult' } ) ) );
- *
- * @see module:engine/conversion/model-to-view-converters~wrapRange
- * @param {module:engine/view/attributeelement~AttributeElement|Function} elementCreator View attribute element, or function returning
- * a view attribute element, which will be used for unwrapping.
- * @returns {Function} Unwrap range converter.
- */
-export function unwrapRange( elementCreator ) {
-	return ( evt, data, consumable, conversionApi ) => {
-		const viewElement = ( elementCreator instanceof ViewElement ) ?
-			elementCreator.clone( true ) :
-			elementCreator( data, consumable, conversionApi );
-
-		if ( !viewElement ) {
-			return;
-		}
-
-		if ( !consumable.consume( data.range, 'removeMarker' ) ) {
-			return;
-		}
-
-		const viewRange = conversionApi.mapper.toViewRange( data.range );
-		const flatViewRanges = viewWriter.breakViewRangePerContainer( viewRange );
-
-		for ( const range of flatViewRanges ) {
-			viewWriter.unwrap( range, viewElement );
-		}
-	};
-}
-
-/**
  * Function factory, creates a default model-to-view converter for node remove changes.
  *
  *		modelDispatcher.on( 'remove', remove() );
@@ -488,24 +404,91 @@ export function remove() {
 	};
 }
 
-// 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;
+/**
+ * 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~virtualSelectionDescriptorToAttributeElement}.
+ *
+ * @param {module:engine/conversion/buildmodelconverter~VirtualSelectionDescriptor|Function} selectionDescriptor
+ * @return {Function}
+ */
+export function convertTextsInsideMarker( selectionDescriptor ) {
+	return ( evt, data, consumable, conversionApi ) => {
+		const descriptor = typeof selectionDescriptor == 'function' ?
+			selectionDescriptor( data, consumable, conversionApi ) :
+			selectionDescriptor;
 
-	for ( const value of walker ) {
-		if ( value.type == 'text' ) {
-			charactersFound++;
+		const modelItem = data.item;
 
-			if ( charactersFound == howMany ) {
-				return walker.position;
+		if ( !descriptor || data.markerRange.isCollapsed || !modelItem.is( 'textProxy' ) ) {
+			return;
+		}
+
+		if ( !consumable.consume( modelItem, evt.name ) ) {
+			return;
+		}
+
+		const viewElement = virtualSelectionDescriptorToAttributeElement( descriptor );
+		const viewRange = conversionApi.mapper.toViewRange( data.range );
+
+		if ( evt.name.split( ':' )[ 0 ] == 'addMarker' ) {
+			viewWriter.wrap( viewRange, viewElement );
+		} else {
+			viewWriter.unwrap( viewRange, viewElement );
+		}
+	};
+}
+
+/**
+ * Function factory, creates converter that converts all elements inside marker's range. Converter checks if element has
+ * functions stored under `setVirtualSelection` and `removeVirtualSelection` custom properties and calls them passing
+ * {@link module:engine/conversion/buildmodelconverter~VirtualSelectionDescriptor}. In such case converter will consume
+ * all element's children, assuming that they were handled by element itself. If selection descriptor will not provide
+ * priority, priority 10 will be used as default, to be compliant with
+ * {@link module:engine/conversion/model-to-view-converters~convertTextsInsideMarker} method which uses default priority of
+ * {@link module:engine/view/attributeelement~AttributeElement}.
+ * When `setVirtualSelection` and `removeVirtualSelection` 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/buildmodelconverter~VirtualSelectionDescriptor|Function} selectionDescriptor
+ * @return {Function}
+ */
+export function convertElementsInsideMarker( selectionDescriptor ) {
+	return ( evt, data, consumable, conversionApi ) => {
+		const descriptor = typeof selectionDescriptor == 'function' ?
+			selectionDescriptor( data, consumable, conversionApi ) :
+			selectionDescriptor;
+
+		const modelItem = data.item;
+
+		if ( !descriptor || data.markerRange.isCollapsed || !modelItem.is( 'element' ) ) {
+			return;
+		}
+
+		if ( !consumable.test( data.item, evt.name ) ) {
+			return;
+		}
+
+		if ( !descriptor.priority ) {
+			descriptor.priority = 10;
+		}
+
+		const viewElement = conversionApi.mapper.toViewElement( modelItem );
+		const addMarker = evt.name.split( ':' )[ 0 ] == 'addMarker';
+		const selectionHandlingMethod = addMarker ? 'setVirtualSelection' : 'removeVirtualSelection';
+
+		if ( viewElement && viewElement.getCustomProperty( selectionHandlingMethod ) ) {
+			// Consume element itself.
+			consumable.consume( data.item, evt.name );
+
+			// Consume all children nodes.
+			for ( const value of ModelRange.createIn( modelItem ) ) {
+				consumable.consume( value.item, evt.name );
 			}
+
+			viewElement.getCustomProperty( selectionHandlingMethod )( viewElement, descriptor );
 		}
-	}
+	};
 }
 
 /**
@@ -535,11 +518,22 @@ export function removeUIElement( elementCreator ) {
 			return;
 		}
 
-		if ( !consumable.consume( data.range, 'removeMarker' ) ) {
+		const markerRange = data.markerRange;
+		const eventName = evt.name;
+
+		// If marker's range is collapsed - check if it can be consumed.
+		if ( markerRange.isCollapsed && !consumable.consume( markerRange, eventName ) ) {
 			return;
 		}
 
-		const viewRange = conversionApi.mapper.toViewRange( data.range );
+		// 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( markerRange );
 
 		// First remove closing element.
 		viewWriter.clear( viewRange.getEnlarged(), viewEndElement );
@@ -562,3 +556,45 @@ export function eventNameToConsumableType( evtName ) {
 
 	return parts[ 0 ] + ':' + parts[ 1 ];
 }
+
+/**
+ * Creates `span` {@link module:engine/view/attributeelement~AttributeElement view attribute element} from information
+ * provided by {@link module:engine/conversion/buildmodelconverter~VirtualSelectionDescriptor} object. If priority
+ * is not provided in selection descriptor - default priority will be used.
+ *
+ * @param {module:engine/conversion/buildmodelconverter~VirtualSelectionDescriptor } descriptor
+ * @return {module:engine/view/attributeelement~AttributeElement}
+ */
+export function virtualSelectionDescriptorToAttributeElement( descriptor ) {
+	const attributeElement = new ViewAttributeElement( 'span', descriptor.attributes );
+
+	if ( descriptor.class ) {
+		attributeElement.addClass( descriptor.class );
+	}
+
+	if ( descriptor.priority ) {
+		attributeElement.priority = descriptor.priority;
+	}
+
+	return attributeElement;
+}
+
+// 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;
+			}
+		}
+	}
+}

+ 65 - 29
packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js

@@ -359,7 +359,8 @@ export default class ModelConversionDispatcher {
 		for ( const marker of markers ) {
 			const data = {
 				selection,
-				name: marker.name
+				markerName: marker.name,
+				markerRange: marker.getRange()
 			};
 
 			if ( consumable.test( selection, 'selectionMarker:' + marker.name ) ) {
@@ -382,10 +383,10 @@ export default class ModelConversionDispatcher {
 	}
 
 	/**
-	 * Starts marker-conversion.
+	 * Starts marker conversion.
 	 *
-	 * Fires {@link ~#event:addMarker addMarker event} or {@link ~#event:removeMarker removeMarker event} based on
-	 * given `type` with data based on passed parameters.
+	 * 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.
 	 *
 	 * @fires addMarker
 	 * @fires removeMarker
@@ -399,10 +400,43 @@ export default class ModelConversionDispatcher {
 			return;
 		}
 
-		const consumable = this._createMarkerConsumable( type, range );
-		const data = { name, range };
+		// In markers case, event name == consumable name.
+		const eventName = type + ':' + name;
 
-		this.fire( type + ':' + name, data, consumable, this.conversionApi );
+		// When range is collapsed - fire single event with collapsed range in consumable.
+		if ( range.isCollapsed ) {
+			const consumable = new Consumable();
+			consumable.add( range, eventName );
+
+			this.fire( eventName, {
+				markerName: name,
+				markerRange: range
+			}, consumable, this.conversionApi );
+
+			return;
+		}
+
+		// Create consumable for each item in range.
+		const consumable = this._createConsumableForRange( range, eventName );
+
+		// Create separate event for each node in the range.
+		for ( const value of range ) {
+			const item = value.item;
+
+			// 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
+			};
+
+			this.fire( eventName, data, consumable, this.conversionApi );
+		}
 	}
 
 	/**
@@ -473,22 +507,6 @@ export default class ModelConversionDispatcher {
 	}
 
 	/**
-	 * Creates {@link module:engine/conversion/modelconsumable~ModelConsumable} for adding or removing marker on given `range`.
-	 *
-	 * @private
-	 * @param {'addMarker'|'removeMarker'} type Change type.
-	 * @param {module:engine/model/range~Range} range Range on which marker was added or removed.
-	 * @returns {module:engine/conversion/modelconsumable~ModelConsumable} Values to consume.
-	 */
-	_createMarkerConsumable( type, range ) {
-		const consumable = new Consumable();
-
-		consumable.add( range, type );
-
-		return consumable;
-	}
-
-	/**
 	 * Tests passed `consumable` to check whether given event can be fired and if so, fires it.
 	 *
 	 * @private
@@ -639,6 +657,9 @@ 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,
@@ -647,14 +668,23 @@ export default class ModelConversionDispatcher {
 	 *
 	 * @event addMarker
 	 * @param {Object} data Additional information about the change.
-	 * @param {String} data.name Marker name.
-	 * @param {module:engine/model/range~Range} data.range Marker range.
-	 * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
+	 * @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 {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,
@@ -663,9 +693,15 @@ export default class ModelConversionDispatcher {
 	 *
 	 * @event removeMarker
 	 * @param {Object} data Additional information about the change.
-	 * @param {String} data.name Marker name.
-	 * @param {module:engine/model/range~Range} data.range Marker range.
-	 * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
+	 * @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 {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
 	 */
 }

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

@@ -137,7 +137,7 @@ export default class ViewConversionDispatcher {
 	 * @param {Object} [additionalData] Additional data to be passed in `data` argument when firing `ViewConversionDispatcher`
 	 * events. See also {@link ~ViewConversionDispatcher#event:element element event}.
 	 * @returns {module:engine/model/documentfragment~DocumentFragment} Model data that is a result of the conversion process
-	 * wrapped in `DocumentFragment`. Converted marker stamps will be set as that document fragment's
+	 * wrapped in `DocumentFragment`. Converted marker elements will be set as that document fragment's
 	 * {@link module:engine/model/documentfragment~DocumentFragment#markers static markers map}.
 	 */
 	convert( viewItem, additionalData = {} ) {
@@ -157,7 +157,7 @@ export default class ViewConversionDispatcher {
 			conversionResult = new ModelDocumentFragment( [ conversionResult ] );
 		}
 
-		// Extract temporary markers stamp from model and set as static markers collection.
+		// Extract temporary markers elements from model and set as static markers collection.
 		conversionResult.markers = extractMarkersFromModelFragment( conversionResult );
 
 		return conversionResult;
@@ -279,7 +279,7 @@ mix( ViewConversionDispatcher, EmitterMixin );
 // @param {module:engine/view/documentfragment~DocumentFragment|module:engine/view/node~Node} modelItem Fragment of model.
 // @returns {Map<String, module:engine/model/range~Range>} List of static markers.
 function extractMarkersFromModelFragment( modelItem ) {
-	const markerStamps = new Set();
+	const markerElements = new Set();
 	const markers = new Map();
 
 	// Create ModelTreeWalker.
@@ -290,16 +290,16 @@ function extractMarkersFromModelFragment( modelItem ) {
 
 	// Walk through DocumentFragment and collect marker elements.
 	for ( const value of walker ) {
-		// Check if current element is a marker stamp.
+		// Check if current element is a marker.
 		if ( value.item.name == '$marker' ) {
-			markerStamps.add( value.item );
+			markerElements.add( value.item );
 		}
 	}
 
 	// Walk through collected marker elements store its path and remove its from the DocumentFragment.
-	for ( const stamp of markerStamps ) {
-		const markerName = stamp.getAttribute( 'data-name' );
-		const currentPosition = ModelPosition.createBefore( stamp );
+	for ( const markerElement of markerElements ) {
+		const markerName = markerElement.getAttribute( 'data-name' );
+		const currentPosition = ModelPosition.createBefore( markerElement );
 
 		// When marker of given name is not stored it means that we have found the beginning of the range.
 		if ( !markers.has( markerName ) ) {
@@ -309,8 +309,8 @@ function extractMarkersFromModelFragment( modelItem ) {
 			markers.get( markerName ).end = ModelPosition.createFromPosition( currentPosition );
 		}
 
-		// Remove marker stamp element from DocumentFragment.
-		remove( ModelRange.createOn( stamp ) );
+		// Remove marker element from DocumentFragment.
+		remove( ModelRange.createOn( markerElement ) );
 	}
 
 	return markers;

+ 4 - 1
packages/ckeditor5-engine/src/dev-utils/enableenginedebug.js

@@ -414,7 +414,10 @@ function enableLoggingTools() {
 
 	SplitDelta.prototype.toString = function() {
 		return getClassName( this ) + `( ${ this.baseVersion } ): ` +
-			this.position.toString();
+			( this.position ?
+				this.position.toString() :
+				`(clone to ${ this._cloneOperation.position || this._cloneOperation.targetPosition })`
+			);
 	};
 
 	UnwrapDelta.prototype.toString = function() {

+ 43 - 8
packages/ckeditor5-engine/src/model/delta/basic-transformations.js

@@ -50,6 +50,11 @@ addTransformationCase( AttributeDelta, WeakInsertDelta, ( a, b, context ) => {
 
 // Add special case for AttributeDelta x SplitDelta transformation.
 addTransformationCase( AttributeDelta, SplitDelta, ( a, b, context ) => {
+	// Do not apply special transformation case if `SplitDelta` has `NoOperation` as the second operation.
+	if ( !b.position ) {
+		return defaultTransform( a, b, context );
+	}
+
 	const undoMode = context.aWasUndone || context.bWasUndone;
 	const splitPosition = new Position( b.position.root, b.position.path.slice( 0, -1 ) );
 
@@ -179,6 +184,11 @@ addTransformationCase( MergeDelta, MoveDelta, ( a, b, context ) => {
 
 // Add special case for SplitDelta x SplitDelta transformation.
 addTransformationCase( SplitDelta, SplitDelta, ( a, b, context ) => {
+	// Do not apply special transformation case if `SplitDelta` has `NoOperation` as the second operation.
+	if ( !a.position || !b.position ) {
+		return defaultTransform( a, b, context );
+	}
+
 	const pathA = a.position.getParentPath();
 	const pathB = b.position.getParentPath();
 
@@ -208,6 +218,11 @@ addTransformationCase( SplitDelta, SplitDelta, ( a, b, context ) => {
 
 // Add special case for SplitDelta x UnwrapDelta transformation.
 addTransformationCase( SplitDelta, UnwrapDelta, ( a, b, context ) => {
+	// Do not apply special transformation case if `SplitDelta` has `NoOperation` as the second operation.
+	if ( !a.position ) {
+		return defaultTransform( a, b, context );
+	}
+
 	// If incoming split delta tries to split a node that just got unwrapped, there is actually nothing to split,
 	// so we discard that delta.
 	if ( a.position.root == b.position.root && compareArrays( b.position.path, a.position.getParentPath() ) === 'same' ) {
@@ -219,6 +234,11 @@ addTransformationCase( SplitDelta, UnwrapDelta, ( a, b, context ) => {
 
 // Add special case for SplitDelta x WrapDelta transformation.
 addTransformationCase( SplitDelta, WrapDelta, ( a, b, context ) => {
+	// Do not apply special transformation case if `SplitDelta` has `NoOperation` as the second operation.
+	if ( !a.position ) {
+		return defaultTransform( a, b, context );
+	}
+
 	// If split is applied at the position between wrapped nodes, we cancel the split as it's results may be unexpected and
 	// very weird. Even if we do some "magic" we don't know what really are users' expectations.
 
@@ -271,6 +291,11 @@ addTransformationCase( SplitDelta, WrapDelta, ( a, b, context ) => {
 
 // Add special case for SplitDelta x WrapDelta transformation.
 addTransformationCase( SplitDelta, AttributeDelta, ( a, b, context ) => {
+	// Do not apply special transformation case if `SplitDelta` has `NoOperation` as the second operation.
+	if ( !a.position ) {
+		return defaultTransform( a, b, context );
+	}
+
 	a = a.clone();
 
 	const undoMode = context.aWasUndone || context.bWasUndone;
@@ -300,6 +325,11 @@ addTransformationCase( SplitDelta, AttributeDelta, ( a, b, context ) => {
 
 // Add special case for UnwrapDelta x SplitDelta transformation.
 addTransformationCase( UnwrapDelta, SplitDelta, ( a, b, context ) => {
+	// Do not apply special transformation case if `SplitDelta` has `NoOperation` as the second operation.
+	if ( !b.position ) {
+		return defaultTransform( a, b, context );
+	}
+
 	// If incoming unwrap delta tries to unwrap node that got split we should unwrap the original node and the split copy.
 	// This can be achieved either by reverting split and applying unwrap to singular node, or creating additional unwrap delta.
 	if ( a.position.root == b.position.root && compareArrays( a.position.path, b.position.getParentPath() ) === 'same' ) {
@@ -326,9 +356,13 @@ addTransformationCase( WeakInsertDelta, AttributeDelta, ( a, b ) => {
 
 // Add special case for WrapDelta x SplitDelta transformation.
 addTransformationCase( WrapDelta, SplitDelta, ( a, b, context ) => {
+	// Do not apply special transformation case if `SplitDelta` has `NoOperation` as the second operation.
+	if ( !b.position ) {
+		return defaultTransform( a, b, context );
+	}
+
 	// If incoming wrap delta tries to wrap range that contains split position, we have to cancel the split and apply
 	// the wrap. Since split was already applied, we have to revert it.
-
 	const sameRoot = a.range.start.root == b.position.root;
 	const operateInSameParent = sameRoot && compareArrays( a.range.start.getParentPath(), b.position.getParentPath() ) === 'same';
 	const splitInsideWrapRange = a.range.start.offset < b.position.offset && a.range.end.offset >= b.position.offset;
@@ -359,8 +393,6 @@ addTransformationCase( WrapDelta, SplitDelta, ( a, b, context ) => {
 // Add special case for RenameDelta x SplitDelta transformation.
 addTransformationCase( RenameDelta, SplitDelta, ( a, b, context ) => {
 	const undoMode = context.aWasUndone || context.bWasUndone;
-	const posBeforeSplitParent = new Position( b.position.root, b.position.path.slice( 0, -1 ) );
-
 	const deltas = defaultTransform( a, b, context );
 
 	// Special case applies only if undo is not a context and only if `SplitDelta` has `InsertOperation` (not `ReinsertOperation`).
@@ -368,11 +400,13 @@ addTransformationCase( RenameDelta, SplitDelta, ( a, b, context ) => {
 		return deltas;
 	}
 
-	if ( a.operations[ 0 ].position.isEqual( posBeforeSplitParent ) ) {
+	const insertPosition = b._cloneOperation.position.getShiftedBy( -1 );
+
+	if ( insertPosition && a.operations[ 0 ].position.isEqual( insertPosition ) ) {
 		// If a node that has been split has it's name changed, we should also change name of
 		// the node created during splitting.
 		const additionalRenameDelta = a.clone();
-		additionalRenameDelta.operations[ 0 ].position = posBeforeSplitParent.getShiftedBy( 1 );
+		additionalRenameDelta.operations[ 0 ].position = insertPosition.getShiftedBy( 1 );
 
 		deltas.push( additionalRenameDelta );
 	}
@@ -385,17 +419,18 @@ addTransformationCase( SplitDelta, RenameDelta, ( a, b, context ) => {
 	a = a.clone();
 
 	const undoMode = context.aWasUndone || context.bWasUndone;
-	const posBeforeSplitParent = new Position( a.position.root, a.position.path.slice( 0, -1 ) );
 
 	// Special case applies only if undo is not a context and only if `SplitDelta` has `InsertOperation` (not `ReinsertOperation`).
 	if ( undoMode || !( a._cloneOperation instanceof InsertOperation ) ) {
 		return [ a ];
 	}
 
+	const insertPosition = a._cloneOperation.position.getShiftedBy( -1 );
+
 	// If element to split had it's name changed, we have to reflect this by creating additional rename operation.
-	if ( b.operations[ 0 ].position.isEqual( posBeforeSplitParent ) ) {
+	if ( insertPosition && !undoMode && b.operations[ 0 ].position.isEqual( insertPosition ) ) {
 		const additionalRenameDelta = b.clone();
-		additionalRenameDelta.operations[ 0 ].position = posBeforeSplitParent.getShiftedBy( 1 );
+		additionalRenameDelta.operations[ 0 ].position = insertPosition.getShiftedBy( 1 );
 		additionalRenameDelta.operations[ 0 ].oldName = a._cloneOperation.nodes.getNode( 0 ).name;
 
 		return [ a, additionalRenameDelta ];

+ 1 - 34
packages/ckeditor5-engine/src/view/writer.js

@@ -14,7 +14,6 @@ import EmptyElement from './emptyelement';
 import UIElement from './uielement';
 import Text from './text';
 import Range from './range';
-import TreeWalker from './treewalker';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import DocumentFragment from './documentfragment';
 import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable';
@@ -37,8 +36,7 @@ const writer = {
 	wrap,
 	wrapPosition,
 	unwrap,
-	rename,
-	breakViewRangePerContainer
+	rename
 };
 
 export default writer;
@@ -268,37 +266,6 @@ export function mergeContainers( position ) {
 }
 
 /**
- * Breaks given `range` on a set of {@link module:engine/view/range~Range ranges}, that each are contained within a
- * {@link module:engine/view/containerelement~ContainerElement container element}. After `range` is broken, it's "pieces" can
- * be used by other {@link module:engine/view/writer~writer} methods (which expect that passed ranges are contained within
- * one container element).
- *
- * @function module:engine/view/writer~writer.breakViewRangePerContainer
- * @param {module:engine/view/range~Range} range Range to break.
- * @returns {Array.<module:engine/view/range~Range>} Ranges that combine into passed `viewRange`.
- */
-export function breakViewRangePerContainer( range ) {
-	const ranges = [];
-	const walker = new TreeWalker( { boundaries: range } );
-
-	let start = range.start;
-
-	for ( const value of walker ) {
-		if ( value.item.is( 'containerElement' ) ) {
-			if ( !start.isEqual( value.previousPosition ) ) {
-				ranges.push( new Range( start, value.previousPosition ) );
-			}
-
-			start = value.nextPosition;
-		}
-	}
-
-	ranges.push( new Range( start, range.end ) );
-
-	return ranges;
-}
-
-/**
  * Insert node or nodes at specified position. Takes care about breaking attributes before insertion
  * and merging them afterwards.
  *

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

@@ -310,7 +310,7 @@ describe( 'EditingController', () => {
 			let element, outerRange;
 
 			beforeEach( () => {
-				element = new ModelElement( new ModelText( 'foo' ) );
+				element = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) );
 				modelRoot.appendChildren( element );
 
 				outerRange = ModelRange.createOn( element );
@@ -325,6 +325,7 @@ describe( 'EditingController', () => {
 			it( 'marker strictly contained', () => {
 				const markerRange = ModelRange.createFromParentsAndOffsets( element, 1, element, 2 );
 				model.markers.set( 'name', markerRange );
+
 				editing.modelToView.convertInsertion( outerRange );
 				expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', markerRange ) ).to.be.true;
 			} );

+ 77 - 56
packages/ckeditor5-engine/tests/conversion/buildmodelconverter.js

@@ -343,7 +343,7 @@ describe( 'Model converter builder', () => {
 		} );
 	} );
 
-	describe( 'model marker to view element conversion', () => {
+	describe( 'model marker to virtual selection converter', () => {
 		let modelText, modelElement;
 
 		beforeEach( () => {
@@ -358,12 +358,25 @@ describe( 'Model converter builder', () => {
 			mapper.bindElements( modelElement, viewElement );
 		} );
 
-		it( 'using passed view element name', () => {
-			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( 'strong' );
+		it( 'using passed virtual selection descriptor object', () => {
+			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toVirtualSelection( {
+				class: 'virtual-selection',
+				priority: 3,
+				attributes: { title: 'marker' }
+			} );
 
 			dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) );
 
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<strong>ob</strong>ar</p></div>' );
+			expect( viewToString( viewRoot ) ).to.equal(
+				'<div>' +
+					'<p>' +
+						'fo' +
+						'<span class="virtual-selection" title="marker">ob</span>' +
+						'ar' +
+					'</p>' +
+				'</div>' );
+
+			expect( viewRoot.getChild( 0 ).getChild( 1 ).priority ).to.equal( 3 );
 
 			dispatcher.convertMarker(
 				'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 )
@@ -372,13 +385,25 @@ describe( 'Model converter builder', () => {
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
 
-		it( 'using passed view element', () => {
-			const viewElement = new ViewAttributeElement( 'span', { class: 'search' } );
-			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( viewElement );
+		it( 'using passed virtual selection descriptor object creator', () => {
+			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toVirtualSelection( () => ( {
+				class: 'virtual-selection',
+				priority: 12,
+				attributes: { title: 'marker' }
+			} ) );
 
 			dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) );
 
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="search">ob</span>ar</p></div>' );
+			expect( viewToString( viewRoot ) ).to.equal(
+				'<div>' +
+					'<p>' +
+						'fo' +
+						'<span class="virtual-selection" title="marker">ob</span>' +
+						'ar' +
+					'</p>' +
+				'</div>' );
+
+			expect( viewRoot.getChild( 0 ).getChild( 1 ).priority ).to.equal( 12 );
 
 			dispatcher.convertMarker(
 				'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 )
@@ -387,49 +412,57 @@ describe( 'Model converter builder', () => {
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
 
-		it( 'using passed creator function', () => {
-			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( data => {
-				const className = 'search search-color-' + data.name.split( ':' )[ 1 ];
-
-				return new ViewAttributeElement( 'span', { class: className } );
+		it( 'should do nothing when marker range is collapsed', () => {
+			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toVirtualSelection( {
+				class: 'virtual-selection'
 			} );
 
-			dispatcher.convertMarker(
-				'addMarker', 'search:red', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 )
-			);
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="search search-color-red">ob</span>ar</p></div>' );
-
-			dispatcher.convertMarker(
-				'removeMarker', 'search:blue', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 )
-			);
+			dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 ) );
 
-			// Nothing should change as we remove a marker with different name, which should generate different view attribute element.
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="search search-color-red">ob</span>ar</p></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 
 			dispatcher.convertMarker(
-				'removeMarker', 'search:red', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 )
+				'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 )
 			);
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
 
-		it( 'should do nothing when marker range is collapsed', () => {
-			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( 'strong' );
+		it( 'should create converters with provided priority', () => {
+			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toVirtualSelection( {
+				class: 'virtual-selection'
+			} );
 
-			dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 ) );
+			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).withPriority( 'high' ).toVirtualSelection( {
+				class: 'override'
+			} );
 
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) );
 
-			dispatcher.convertMarker(
-				'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 )
-			);
+			expect( viewToString( viewRoot ) ).to.equal(
+				'<div>' +
+					'<p>' +
+						'fo' +
+						'<span class="override">ob</span>' +
+						'ar' +
+					'</p>' +
+				'</div>' );
+		} );
 
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		it( 'should throw if trying to convert from attribute', () => {
+			expect( () => {
+				buildModelConverter().for( dispatcher ).fromAttribute( 'bold' ).toVirtualSelection( { class: 'foo' } );
+			} ).to.throw( CKEditorError, /^build-model-converter-non-marker-to-virtual-selection/ );
+		} );
+
+		it( 'should throw if trying to convert from element', () => {
+			expect( () => {
+				buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toVirtualSelection( { class: 'foo' } );
+			} ).to.throw( CKEditorError, /^build-model-converter-non-marker-to-virtual-selection/ );
 		} );
 	} );
 
-	describe( 'model marker to view stamp conversion', () => {
+	describe( 'model marker to view element conversion', () => {
 		let modelText, modelElement, range;
 
 		beforeEach( () => {
@@ -450,7 +483,7 @@ describe( 'Model converter builder', () => {
 			} );
 
 			it( 'using passed view element name', () => {
-				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toStamp( 'span' );
+				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( 'span' );
 
 				dispatcher.convertMarker( 'addMarker', 'search', range );
 
@@ -463,7 +496,7 @@ describe( 'Model converter builder', () => {
 
 			it( 'using passed view element', () => {
 				const viewElement = new ViewUIElement( 'span', { class: 'search' } );
-				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toStamp( viewElement );
+				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( viewElement );
 
 				dispatcher.convertMarker( 'addMarker', 'search', range );
 
@@ -475,8 +508,8 @@ describe( 'Model converter builder', () => {
 			} );
 
 			it( 'using passed creator function', () => {
-				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toStamp( data => {
-					const className = 'search search-color-' + data.name.split( ':' )[ 1 ];
+				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( data => {
+					const className = 'search search-color-' + data.markerName.split( ':' )[ 1 ];
 
 					return new ViewUIElement( 'span', { class: className } );
 				} );
@@ -497,7 +530,7 @@ describe( 'Model converter builder', () => {
 			} );
 
 			it( 'using passed view element name', () => {
-				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toStamp( 'span' );
+				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( 'span' );
 
 				dispatcher.convertMarker( 'addMarker', 'search', range );
 
@@ -510,7 +543,7 @@ describe( 'Model converter builder', () => {
 
 			it( 'using passed view element', () => {
 				const viewElement = new ViewUIElement( 'span', { class: 'search' } );
-				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toStamp( viewElement );
+				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( viewElement );
 
 				dispatcher.convertMarker( 'addMarker', 'search', range );
 
@@ -524,8 +557,8 @@ describe( 'Model converter builder', () => {
 			} );
 
 			it( 'using passed creator function', () => {
-				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toStamp( data => {
-					const className = 'search search-color-' + data.name.split( ':' )[ 1 ];
+				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( data => {
+					const className = 'search search-color-' + data.markerName.split( ':' )[ 1 ];
 
 					return new ViewUIElement( 'span', { class: className } );
 				} );
@@ -545,8 +578,8 @@ describe( 'Model converter builder', () => {
 		it( 'should overwrite default priority', () => {
 			range = ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 );
 
-			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toStamp( 'normal' );
-			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).withPriority( 'high' ).toStamp( 'high' );
+			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( 'normal' );
+			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).withPriority( 'high' ).toElement( 'high' );
 
 			dispatcher.convertMarker( 'addMarker', 'search', range );
 
@@ -573,16 +606,4 @@ describe( 'Model converter builder', () => {
 			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 stamp converter', () => {
-		expect( () => {
-			buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toStamp( 'span' );
-		} ).to.throw( CKEditorError, /^build-model-converter-non-marker-to-stamp/ );
-	} );
-
-	it( 'should throw when trying to build model attribute to view stamp converter', () => {
-		expect( () => {
-			buildModelConverter().for( dispatcher ).fromAttribute( 'class' ).toStamp( 'span' );
-		} ).to.throw( CKEditorError, /^build-model-converter-non-marker-to-stamp/ );
-	} );
 } );

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

@@ -29,15 +29,14 @@ import {
 	insertElement,
 	insertText,
 	wrapItem,
-	wrapRange,
-	unwrapRange
+	convertTextsInsideMarker,
+	convertElementsInsideMarker
 } from '../../src/conversion/model-to-view-converters';
 
 import { stringify as stringifyView } from '../../src/dev-utils/view';
 import { setData as setModelData } from '../../src/dev-utils/model';
 
-const spanViewElementForMarker = new ViewAttributeElement( 'span' );
-spanViewElementForMarker.priority = 1;
+const virtualSelectionDescriptor = { class: 'marker', priority: 1 };
 
 describe( 'model-selection-to-view-converters', () => {
 	let dispatcher, mapper, modelDoc, modelRoot, modelSelection, viewDoc, viewRoot, viewSelection;
@@ -60,8 +59,11 @@ describe( 'model-selection-to-view-converters', () => {
 
 		dispatcher.on( 'insert:$text', insertText() );
 		dispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
-		dispatcher.on( 'addMarker:marker', wrapRange( spanViewElementForMarker ) );
-		dispatcher.on( 'removeMarker:marker', unwrapRange( spanViewElementForMarker ) );
+
+		dispatcher.on( 'addMarker:marker', convertTextsInsideMarker( virtualSelectionDescriptor ) );
+		dispatcher.on( 'addMarker:marker', convertElementsInsideMarker( virtualSelectionDescriptor ) );
+		dispatcher.on( 'removeMarker:marker', convertTextsInsideMarker( virtualSelectionDescriptor ) );
+		dispatcher.on( 'removeMarker:marker', convertElementsInsideMarker( virtualSelectionDescriptor ) );
 
 		// Default selection converters.
 		dispatcher.on( 'selection', clearAttributes(), { priority: 'low' } );
@@ -78,7 +80,7 @@ describe( 'model-selection-to-view-converters', () => {
 			// Selection converters for selection attributes.
 			dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'strong' ) ) );
 			dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
-			dispatcher.on( 'selectionMarker:marker', convertSelectionMarker( spanViewElementForMarker ) );
+			dispatcher.on( 'selectionMarker:marker', convertSelectionMarker( virtualSelectionDescriptor ) );
 		} );
 
 		describe( 'range selection', () => {
@@ -235,8 +237,9 @@ describe( 'model-selection-to-view-converters', () => {
 				dispatcher.convertSelection( modelSelection, markers );
 
 				// Stringify view and check if it is same as expected.
-				expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
-					.to.equal( '<div>f<span>o<strong>o{}b</strong>a</span>r</div>' );
+				expect( stringifyView( viewRoot, viewSelection, { showType: false } ) ).to.equal(
+					'<div>f<span class="marker">o<strong>o{}b</strong>a</span>r</div>'
+				);
 			} );
 
 			it( 'in attribute and marker - no attribute', () => {
@@ -262,12 +265,12 @@ describe( 'model-selection-to-view-converters', () => {
 
 				// Stringify view and check if it is same as expected.
 				expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
-					.to.equal( '<div>f<span>o<strong>o</strong>[]<strong>b</strong>a</span>r</div>' );
+					.to.equal( '<div>f<span class="marker">o<strong>o</strong>[]<strong>b</strong>a</span>r</div>' );
 			} );
 
-			it( 'in marker - using view element as element creator function for selection marker conversion', () => {
+			it( 'in marker - using virtual selection descriptor creator', () => {
 				dispatcher.on( 'selectionMarker:marker2', convertSelectionMarker(
-					data => new ViewAttributeElement( 'span', { 'class': data.name } )
+					data => ( { 'class': data.markerName } )
 				) );
 
 				setModelData( modelDoc, 'foobar' );

+ 318 - 244
packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js

@@ -26,10 +26,11 @@ import {
 	removeAttribute,
 	wrapItem,
 	unwrapItem,
-	wrapRange,
-	unwrapRange,
 	remove,
-	removeUIElement
+	removeUIElement,
+	convertTextsInsideMarker,
+	convertElementsInsideMarker,
+	virtualSelectionDescriptorToAttributeElement
 } from '../../src/conversion/model-to-view-converters';
 
 import { createRangeOnElementOnly } from '../../tests/model/_utils/utils';
@@ -81,6 +82,215 @@ describe( 'model-to-view-converters', () => {
 		return result;
 	}
 
+	describe( 'convertTextsInsideMarker()', () => {
+		let modelElement1, modelElement2, markerRange;
+		const virtualSelectionDescriptor = {
+			class: 'marker-class',
+			priority: 7,
+			attributes: { title: 'title' }
+		};
+
+		beforeEach( () => {
+			const modelText1 = new ModelText( 'foo' );
+			modelElement1 = new ModelElement( 'paragraph', null, modelText1 );
+			const modelText2 = new ModelText( 'bar' );
+			modelElement2 = new ModelElement( 'paragraph', null, modelText2 );
+
+			modelRoot.appendChildren( modelElement1 );
+			modelRoot.appendChildren( modelElement2 );
+			dispatcher.on( 'insert:paragraph', insertElement( () => new ViewContainerElement( 'p' ) ) );
+			dispatcher.on( 'insert:$text', insertText() );
+
+			markerRange = ModelRange.createIn( modelRoot );
+		} );
+
+		it( 'should wrap and unwrap text nodes', () => {
+			dispatcher.on( 'addMarker:marker', convertTextsInsideMarker( virtualSelectionDescriptor ) );
+			dispatcher.on( 'removeMarker:marker', convertTextsInsideMarker( virtualSelectionDescriptor ) );
+			dispatcher.convertInsertion( markerRange );
+
+			modelDoc.markers.set( 'marker', markerRange );
+			dispatcher.convertMarker( 'addMarker', 'marker', markerRange );
+
+			expect( viewToString( viewRoot ) ).to.equal(
+				'<div>' +
+					'<p>' +
+						'<span class="marker-class" title="title">foo</span>' +
+					'</p>' +
+					'<p>' +
+						'<span class="marker-class" title="title">bar</span>' +
+					'</p>' +
+				'</div>'
+			);
+
+			dispatcher.convertMarker( 'removeMarker', 'marker', markerRange );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+		} );
+
+		it( 'should not convert marker on elements already consumed', () => {
+			const newDescriptor = { class: 'override-class' };
+
+			dispatcher.on( 'addMarker:marker', convertTextsInsideMarker( virtualSelectionDescriptor ) );
+			dispatcher.on( 'addMarker:marker', convertTextsInsideMarker( newDescriptor ), { priority: 'high' } );
+			dispatcher.on( 'removeMarker:marker', convertTextsInsideMarker( virtualSelectionDescriptor ) );
+			dispatcher.on( 'removeMarker:marker', convertTextsInsideMarker( newDescriptor ), { priority: 'high' } );
+			dispatcher.convertInsertion( markerRange );
+
+			modelDoc.markers.set( 'marker', markerRange );
+			dispatcher.convertMarker( 'addMarker', 'marker', markerRange );
+
+			expect( viewToString( viewRoot ) ).to.equal(
+				'<div>' +
+					'<p>' +
+						'<span class="override-class">foo</span>' +
+					'</p>' +
+					'<p>' +
+						'<span class="override-class">bar</span>' +
+					'</p>' +
+				'</div>'
+			);
+
+			dispatcher.convertMarker( 'removeMarker', 'marker', markerRange );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+		} );
+
+		it( 'should do nothing if descriptor is not provided', () => {
+			dispatcher.on( 'addMarker:marker', convertTextsInsideMarker( () => null ) );
+			dispatcher.on( 'removeMarker:marker', convertTextsInsideMarker( () => null ) );
+
+			dispatcher.convertInsertion( markerRange );
+
+			modelDoc.markers.set( 'marker', markerRange );
+			dispatcher.convertMarker( 'addMarker', 'marker', markerRange );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+			dispatcher.convertMarker( 'removeMarker', 'marker', markerRange );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+		} );
+	} );
+
+	describe( 'convertElementsInsideMarker()', () => {
+		let modelElement1, modelElement2, markerRange;
+		const virtualSelectionDescriptor = {
+			class: 'marker-class',
+			priority: 7,
+			attributes: { title: 'title' }
+		};
+
+		beforeEach( () => {
+			const modelText1 = new ModelText( 'foo' );
+			modelElement1 = new ModelElement( 'paragraph', null, modelText1 );
+			const modelText2 = new ModelText( 'bar' );
+			modelElement2 = new ModelElement( 'paragraph', null, modelText2 );
+
+			modelRoot.appendChildren( modelElement1 );
+			modelRoot.appendChildren( modelElement2 );
+			dispatcher.on( 'insert:paragraph', insertElement( () => new ViewContainerElement( 'p' ) ) );
+			dispatcher.on( 'insert:$text', insertText() );
+
+			markerRange = ModelRange.createIn( modelRoot );
+		} );
+
+		it( 'should use setVirtualSelection and removeVirtualSelection on elements and not convert children nodes', () => {
+			dispatcher.on( 'addMarker:marker', convertElementsInsideMarker( virtualSelectionDescriptor ) );
+			dispatcher.on( 'removeMarker:marker', convertElementsInsideMarker( virtualSelectionDescriptor ) );
+			dispatcher.on( 'insert:paragraph', insertElement( data => {
+				// Use special converter only for first paragraph.
+				if ( data.item == modelElement2 ) {
+					return;
+				}
+
+				const viewContainer = new ViewContainerElement( 'p' );
+
+				viewContainer.setCustomProperty( 'setVirtualSelection', ( element, descriptor ) => {
+					element.addClass( 'virtual-selection-own-class' );
+
+					expect( descriptor ).to.equal( virtualSelectionDescriptor );
+				} );
+
+				viewContainer.setCustomProperty( 'removeVirtualSelection', ( element, descriptor ) => {
+					element.removeClass( 'virtual-selection-own-class' );
+
+					expect( descriptor ).to.equal( virtualSelectionDescriptor );
+				} );
+
+				return viewContainer;
+			} ), { priority: 'high' } );
+
+			dispatcher.convertInsertion( markerRange );
+			modelDoc.markers.set( 'marker', markerRange );
+			dispatcher.convertMarker( 'addMarker', 'marker', markerRange );
+
+			expect( viewToString( viewRoot ) ).to.equal(
+				'<div>' +
+					'<p class="virtual-selection-own-class">' +
+						'foo' +
+					'</p>' +
+					'<p>' +
+						'bar' +
+					'</p>' +
+				'</div>'
+			);
+
+			dispatcher.convertMarker( 'removeMarker', 'marker', markerRange );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+		} );
+
+		it( 'should not convert marker on elements already consumed', () => {
+			const newDescriptor = { class: 'override-class' };
+
+			dispatcher.on( 'addMarker:marker', convertElementsInsideMarker( virtualSelectionDescriptor ) );
+			dispatcher.on( 'removeMarker:marker', convertElementsInsideMarker( virtualSelectionDescriptor ) );
+
+			dispatcher.on( 'addMarker:marker', convertElementsInsideMarker( newDescriptor ), { priority: 'high' } );
+			dispatcher.on( 'removeMarker:marker', convertElementsInsideMarker( newDescriptor ), { priority: 'high' } );
+
+			dispatcher.on( 'insert:paragraph', insertElement( () => {
+				const element = new ViewContainerElement( 'p' );
+				element.setCustomProperty( 'setVirtualSelection', ( element, data ) => element.addClass( data.class ) );
+				element.setCustomProperty( 'removeVirtualSelection', ( element, data ) => element.removeClass( data.class ) );
+
+				return element;
+			} ), { priority: 'high' } );
+
+			dispatcher.convertInsertion( markerRange );
+			modelDoc.markers.set( 'marker', markerRange );
+			dispatcher.convertMarker( 'addMarker', 'marker', markerRange );
+
+			expect( viewToString( viewRoot ) ).to.equal(
+				'<div>' +
+					'<p class="override-class">' +
+						'foo' +
+					'</p>' +
+					'<p class="override-class">' +
+						'bar' +
+					'</p>' +
+				'</div>'
+			);
+
+			dispatcher.convertMarker( 'removeMarker', 'marker', markerRange );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+		} );
+
+		it( 'should do nothing if descriptor is not provided', () => {
+			dispatcher.on( 'addMarker:marker', convertElementsInsideMarker( () => null ) );
+			dispatcher.on( 'removeMarker:marker', convertElementsInsideMarker( () => null ) );
+
+			dispatcher.convertInsertion( markerRange );
+
+			modelDoc.markers.set( 'marker', markerRange );
+			dispatcher.convertMarker( 'addMarker', 'marker', markerRange );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+			dispatcher.convertMarker( 'removeMarker', 'marker', markerRange );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo</p><p>bar</p></div>' );
+		} );
+	} );
+
 	describe( 'insertText', () => {
 		it( 'should convert text insertion in model to view text', () => {
 			modelRoot.appendChildren( new ModelText( 'foobar' ) );
@@ -457,235 +667,6 @@ describe( 'model-to-view-converters', () => {
 		} );
 	} );
 
-	describe( 'wrapRange/unwrapRange', () => {
-		let modelText, range, modelElement;
-
-		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 );
-
-			range = ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 );
-		} );
-
-		it( 'should convert adding/removing of marker into wrapping element in a view', () => {
-			const viewSpan = new ViewAttributeElement( 'span', { class: 'name' } );
-
-			dispatcher.on( 'addMarker:name', wrapRange( viewSpan ) );
-			dispatcher.on( 'removeMarker:name', unwrapRange( viewSpan ) );
-
-			dispatcher.convertMarker( 'addMarker', 'name', range );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="name">ob</span>ar</p></div>' );
-
-			dispatcher.convertMarker( 'removeMarker', 'name', range );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-		} );
-
-		it( 'should convert insert/remove of attribute in model with wrapping element generating function as a parameter', () => {
-			const converterCallback = data => {
-				const name = data.name.split( ':' )[ 1 ];
-
-				return new ViewAttributeElement( 'span', { class: name } );
-			};
-
-			dispatcher.on( 'addMarker:name', wrapRange( converterCallback ) );
-			dispatcher.on( 'removeMarker:name', unwrapRange( converterCallback ) );
-
-			dispatcher.convertMarker( 'addMarker', 'name:john', range );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="john">ob</span>ar</p></div>' );
-
-			dispatcher.convertMarker( 'removeMarker', 'name:john', range );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-		} );
-
-		it( 'should support non-flat markers', () => {
-			const modelElement2 = new ModelElement( 'paragraph', null, new ModelText( '22' ) );
-			modelRoot.appendChildren( modelElement2 );
-
-			const modelElement3 = new ModelElement( 'paragraph', null, new ModelText( '333' ) );
-			modelRoot.appendChildren( modelElement3 );
-
-			const viewElement2 = new ViewContainerElement( 'p', null, new ViewText( '22' ) );
-			viewRoot.appendChildren( viewElement2 );
-
-			const viewElement3 = new ViewContainerElement( 'p', null, new ViewText( '333' ) );
-			viewRoot.appendChildren( viewElement3 );
-
-			mapper.bindElements( modelElement2, viewElement2 );
-			mapper.bindElements( modelElement3, viewElement3 );
-
-			const range = ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement3, 1 );
-			const viewSpan = new ViewAttributeElement( 'span', { class: 'name' } );
-
-			dispatcher.on( 'addMarker:name', wrapRange( viewSpan ) );
-			dispatcher.on( 'removeMarker:name', unwrapRange( viewSpan ) );
-
-			dispatcher.convertMarker( 'addMarker', 'name', range );
-
-			expect( viewToString( viewRoot ) ).to.equal(
-				'<div><p>fo<span class="name">obar</span></p><p><span class="name">22</span></p><p><span class="name">3</span>33</p></div>'
-			);
-
-			dispatcher.convertMarker( 'removeMarker', 'name', range );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p><p>22</p><p>333</p></div>' );
-		} );
-
-		it( 'should be possible to override wrapRange', () => {
-			const converterCallback = data => {
-				const name = data.name.split( ':' )[ 1 ];
-
-				return new ViewAttributeElement( 'span', { class: name } );
-			};
-
-			dispatcher.on( 'addMarker:name', wrapRange( converterCallback ) );
-			dispatcher.on( 'addMarker:name:john', ( evt, data, consumable ) => {
-				consumable.consume( data.range, 'addMarker' );
-			}, { priority: 'high' } );
-
-			dispatcher.convertMarker( 'addMarker', 'name:john', range );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-		} );
-
-		it( 'should be possible to override unwrapRange', () => {
-			const converterCallback = data => {
-				const name = data.name.split( ':' )[ 1 ];
-
-				return new ViewAttributeElement( 'span', { class: name } );
-			};
-
-			dispatcher.on( 'addMarker:name', wrapRange( converterCallback ) );
-			dispatcher.on( 'removeMarker:name', unwrapRange( converterCallback ) );
-			dispatcher.on( 'removeMarker:name:john', ( evt, data, consumable ) => {
-				consumable.consume( data.range, 'removeMarker' );
-			}, { priority: 'high' } );
-
-			dispatcher.convertMarker( 'addMarker', 'name:john', range );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="john">ob</span>ar</p></div>' );
-
-			dispatcher.convertMarker( 'removeMarker', 'name:john', range );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="john">ob</span>ar</p></div>' );
-		} );
-
-		it( 'should not convert or consume if view element is not returned by element generating function', () => {
-			const converterCallback = () => null;
-
-			sinon.spy( dispatcher, 'fire' );
-
-			dispatcher.on( 'addMarker:name', wrapRange( converterCallback ) );
-			dispatcher.on( 'addMarker:name', ( evt, data, consumable ) => {
-				// Check whether value was not consumed from `consumable`.
-				expect( consumable.test( data.range, 'addMarker' ) ).to.be.true;
-			} );
-
-			dispatcher.on( 'removeMarker:name', unwrapRange( converterCallback ) );
-			dispatcher.on( 'removeMarker:name', ( evt, data, consumable ) => {
-				// Check whether value was not consumed from `consumable`.
-				expect( consumable.test( data.range, 'removeMarker' ) ).to.be.true;
-			} );
-
-			dispatcher.convertMarker( 'addMarker', 'name', range );
-
-			expect( dispatcher.fire.calledWith( 'addMarker:name' ) ).to.be.true;
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-
-			dispatcher.convertMarker( 'removeMarker', 'name', range );
-
-			expect( dispatcher.fire.calledWith( 'removeMarker:name' ) ).to.be.true;
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-		} );
-
-		it( 'multiple overlapping non-collapsed markers', () => {
-			const converterCallbackName = data => {
-				const name = data.name.split( ':' )[ 1 ];
-				const element = new ViewAttributeElement( 'span', { class: name } );
-				element.priority = name.charCodeAt( 0 );
-
-				return element;
-			};
-			const converterCallbackSearch = () => new ViewAttributeElement( 'em', { class: 'search' } );
-
-			dispatcher.on( 'addMarker:name', wrapRange( converterCallbackName ) );
-			dispatcher.on( 'removeMarker:name', unwrapRange( converterCallbackName ) );
-			dispatcher.on( 'addMarker:search', wrapRange( converterCallbackSearch ) );
-			dispatcher.on( 'removeMarker:search', unwrapRange( converterCallbackSearch ) );
-
-			dispatcher.convertMarker( 'addMarker', 'name:a', range );
-			dispatcher.convertMarker( 'addMarker', 'name:b', range );
-			dispatcher.convertMarker( 'addMarker', 'search', range );
-
-			expect( viewToString( viewRoot ) ).to.equal(
-				'<div><p>fo<em class="search"><span class="a"><span class="b">ob</span></span></em>ar</p></div>'
-			);
-
-			dispatcher.convertMarker( 'removeMarker', 'name:b', range );
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<em class="search"><span class="a">ob</span></em>ar</p></div>' );
-
-			dispatcher.convertMarker( 'removeMarker', 'search', range );
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="a">ob</span>ar</p></div>' );
-
-			dispatcher.convertMarker( 'removeMarker', 'name:a', range );
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-		} );
-
-		it( 'multiple overlapping non-collapsed markers intersecting with same priority', () => {
-			const converterCallbackSearch = () => new ViewAttributeElement( 'em', { class: 'search' } );
-
-			dispatcher.on( 'addMarker:search', wrapRange( converterCallbackSearch ) );
-			dispatcher.on( 'removeMarker:search', unwrapRange( converterCallbackSearch ) );
-
-			const range1 = ModelRange.createFromParentsAndOffsets( modelElement, 1, modelElement, 3 );
-			const range2 = ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 );
-			const range3 = ModelRange.createFromParentsAndOffsets( modelElement, 4, modelElement, 6 );
-
-			dispatcher.convertMarker( 'addMarker', 'search', range1 );
-			dispatcher.convertMarker( 'addMarker', 'search', range2 );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<em class="search">oob</em>ar</p></div>' );
-
-			dispatcher.convertMarker( 'addMarker', 'search', range3 );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<em class="search">oobar</em></p></div>' );
-
-			dispatcher.convertMarker( 'removeMarker', 'search', range2 );
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<em class="search">o</em>ob<em class="search">ar</em></p></div>' );
-
-			dispatcher.convertMarker( 'removeMarker', 'search', range3 );
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<em class="search">o</em>obar</p></div>' );
-
-			dispatcher.convertMarker( 'removeMarker', 'search', range1 );
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-		} );
-
-		it( 'should do nothing when range is collapsed', () => {
-			const viewSpan = new ViewAttributeElement( 'span', { class: 'name' } );
-
-			dispatcher.on( 'addMarker:name', wrapRange( viewSpan ) );
-			dispatcher.on( 'removeMarker:name', unwrapRange( viewSpan ) );
-
-			dispatcher.convertMarker( 'addMarker', 'name', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 ) );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-
-			dispatcher.convertMarker( 'removeMarker', 'name', range );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-		} );
-	} );
-
 	describe( 'insertUIElement/removeUIElement', () => {
 		let modelText, range, modelElement;
 
@@ -719,7 +700,7 @@ describe( 'model-to-view-converters', () => {
 		} );
 
 		it( 'should insert and remove ui element - function as a creator', () => {
-			const viewUi = data => new ViewUIElement( 'span', { 'class': data.name } );
+			const viewUi = data => new ViewUIElement( 'span', { 'class': data.markerName } );
 
 			dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) );
 			dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) );
@@ -742,11 +723,11 @@ describe( 'model-to-view-converters', () => {
 			dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) );
 
 			dispatcher.on( 'addMarker:marker', ( evt, data, consumable ) => {
-				expect( consumable.test( data.range, 'addMarker' ) ).to.be.true;
+				expect( consumable.test( data.markerRange, 'addMarker:marker' ) ).to.be.true;
 			} );
 
 			dispatcher.on( 'removeMarker:marker', ( evt, data, consumable ) => {
-				expect( consumable.test( data.range, 'removeMarker' ) ).to.be.true;
+				expect( consumable.test( data.markerRange, 'removeMarker:marker' ) ).to.be.true;
 			} );
 
 			dispatcher.convertMarker( 'addMarker', 'marker', range );
@@ -769,11 +750,11 @@ describe( 'model-to-view-converters', () => {
 			dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) );
 
 			dispatcher.on( 'addMarker:marker', ( evt, data, consumable ) => {
-				consumable.consume( data.range, 'addMarker' );
+				consumable.consume( data.markerRange, 'addMarker:marker' );
 			}, { priority: 'high' } );
 
 			dispatcher.on( 'removeMarker:marker', ( evt, data, consumable ) => {
-				consumable.consume( data.range, 'removeMarker' );
+				consumable.consume( data.markerRange, 'removeMarker:marker' );
 			}, { priority: 'high' } );
 
 			dispatcher.convertMarker( 'addMarker', 'marker', range );
@@ -796,11 +777,11 @@ describe( 'model-to-view-converters', () => {
 			dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) );
 
 			dispatcher.on( 'addMarker:marker', ( evt, data, consumable ) => {
-				expect( consumable.test( data.range, 'addMarker' ) ).to.be.true;
+				expect( consumable.test( data.markerRange, 'addMarker:marker' ) ).to.be.true;
 			} );
 
 			dispatcher.on( 'removeMarker:marker', ( evt, data, consumable ) => {
-				expect( consumable.test( data.range, 'removeMarker' ) ).to.be.true;
+				expect( consumable.test( data.markerRange, 'removeMarker:marker' ) ).to.be.true;
 			} );
 
 			dispatcher.convertMarker( 'addMarker', 'marker', range );
@@ -823,11 +804,11 @@ describe( 'model-to-view-converters', () => {
 			dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) );
 
 			dispatcher.on( 'addMarker:marker', ( evt, data, consumable ) => {
-				consumable.consume( data.range, 'addMarker' );
+				consumable.consume( data.markerRange, 'addMarker:marker' );
 			}, { priority: 'high' } );
 
 			dispatcher.on( 'removeMarker:marker', ( evt, data, consumable ) => {
-				consumable.consume( data.range, 'removeMarker' );
+				consumable.consume( data.markerRange, 'removeMarker:marker' );
 			}, { priority: 'high' } );
 
 			dispatcher.convertMarker( 'addMarker', 'marker', range );
@@ -863,7 +844,7 @@ describe( 'model-to-view-converters', () => {
 			} );
 
 			it( 'should insert and remove ui element - function as a creator', () => {
-				const viewUi = data => new ViewUIElement( 'span', { 'class': data.name } );
+				const viewUi = data => new ViewUIElement( 'span', { 'class': data.markerName } );
 
 				dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) );
 				dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) );
@@ -881,10 +862,10 @@ describe( 'model-to-view-converters', () => {
 			it( 'should insert and remove different opening and ending element', () => {
 				function creator( data ) {
 					if ( data.isOpening ) {
-						return new ViewUIElement( 'span', { 'class': data.name, 'data-start': true } );
+						return new ViewUIElement( 'span', { 'class': data.markerName, 'data-start': true } );
 					}
 
-					return new ViewUIElement( 'span', { 'class': data.name, 'data-end': true } );
+					return new ViewUIElement( 'span', { 'class': data.markerName, 'data-end': true } );
 				}
 
 				dispatcher.on( 'addMarker:marker', insertUIElement( creator ) );
@@ -900,6 +881,33 @@ describe( 'model-to-view-converters', () => {
 
 				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 			} );
+
+			it( 'should be possible to overwrite', () => {
+				const viewUi = new ViewUIElement( 'span', { 'class': 'marker' } );
+
+				sinon.spy( dispatcher, 'fire' );
+
+				dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) );
+				dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) );
+
+				dispatcher.on( 'addMarker:marker', ( evt, data, consumable ) => {
+					consumable.consume( data.item, 'addMarker:marker' );
+				}, { priority: 'high' } );
+
+				dispatcher.on( 'removeMarker:marker', ( evt, data, consumable ) => {
+					consumable.consume( data.item, 'removeMarker:marker' );
+				}, { priority: 'high' } );
+
+				dispatcher.convertMarker( 'addMarker', 'marker', range );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+				expect( dispatcher.fire.calledWith( 'addMarker:marker' ) );
+
+				dispatcher.convertMarker( 'removeMarker', 'marker', range );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+				expect( dispatcher.fire.calledWith( 'removeMarker:marker' ) );
+			} );
 		} );
 	} );
 
@@ -1183,4 +1191,70 @@ describe( 'model-to-view-converters', () => {
 			expect( viewToString( viewRoot ) ).to.equal( '<div>foo</div>' );
 		} );
 	} );
+
+	describe( 'virtualSelectionDescriptorToAttributeElement()', () => {
+		it( 'should return attribute element from descriptor object', () => {
+			const descriptor = {
+				class: 'foo-class',
+				attributes: { one: 1, two: 2 },
+				priority: 7,
+			};
+			const element = virtualSelectionDescriptorToAttributeElement( descriptor );
+
+			expect( element.is( 'attributeElement' ) ).to.be.true;
+			expect( element.name ).to.equal( 'span' );
+			expect( element.priority ).to.equal( 7 );
+			expect( element.hasClass( 'foo-class' ) ).to.be.true;
+
+			for ( const key of Object.keys( descriptor.attributes ) ) {
+				expect( element.getAttribute( key ) ).to.equal( descriptor.attributes[ key ] );
+			}
+		} );
+
+		it( 'should create element without class', () => {
+			const descriptor = {
+				attributes: { one: 1, two: 2 },
+				priority: 7,
+			};
+			const element = virtualSelectionDescriptorToAttributeElement( descriptor );
+
+			expect( element.is( 'attributeElement' ) ).to.be.true;
+			expect( element.name ).to.equal( 'span' );
+			expect( element.priority ).to.equal( 7 );
+
+			for ( const key of Object.keys( descriptor.attributes ) ) {
+				expect( element.getAttribute( key ) ).to.equal( descriptor.attributes[ key ] );
+			}
+		} );
+
+		it( 'should create element without priority', () => {
+			const descriptor = {
+				class: 'foo-class',
+				attributes: { one: 1, two: 2 },
+			};
+			const element = virtualSelectionDescriptorToAttributeElement( descriptor );
+
+			expect( element.is( 'attributeElement' ) ).to.be.true;
+			expect( element.name ).to.equal( 'span' );
+			expect( element.priority ).to.equal( 10 );
+			expect( element.hasClass( 'foo-class' ) ).to.be.true;
+
+			for ( const key of Object.keys( descriptor.attributes ) ) {
+				expect( element.getAttribute( key ) ).to.equal( descriptor.attributes[ key ] );
+			}
+		} );
+
+		it( 'should create element without attributes', () => {
+			const descriptor = {
+				class: 'foo-class',
+				priority: 7
+			};
+			const element = virtualSelectionDescriptorToAttributeElement( descriptor );
+
+			expect( element.is( 'attributeElement' ) ).to.be.true;
+			expect( element.name ).to.equal( 'span' );
+			expect( element.priority ).to.equal( 7 );
+			expect( element.hasClass( 'foo-class' ) ).to.be.true;
+		} );
+	} );
 } );

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

@@ -288,6 +288,44 @@ describe( 'ModelConversionDispatcher', () => {
 			expect( dispatcher.fire.calledWith( 'addAttribute: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 );
+			doc.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 );
+			doc.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;
+		} );
 	} );
 
 	describe( 'convertMove', () => {
@@ -550,7 +588,10 @@ describe( 'ModelConversionDispatcher', () => {
 		let range;
 
 		beforeEach( () => {
-			range = ModelRange.createFromParentsAndOffsets( root, 0, root, 4 );
+			const element = new ModelElement( 'paragraph', null, [ new ModelText( 'foo bar baz' ) ] );
+			root.appendChildren( [ element ] );
+
+			range = ModelRange.createFromParentsAndOffsets( element, 0, element, 4 );
 		} );
 
 		it( 'should fire event based on passed parameters', () => {
@@ -594,15 +635,107 @@ describe( 'ModelConversionDispatcher', () => {
 
 		it( 'should prepare consumable values', () => {
 			dispatcher.on( 'addMarker:name', ( evt, data, consumable ) => {
-				expect( consumable.test( data.range, 'addMarker' ) ).to.be.true;
+				expect( consumable.test( data.item, 'addMarker:name' ) ).to.be.true;
 			} );
 
 			dispatcher.on( 'removeMarker:name', ( evt, data, consumable ) => {
-				expect( consumable.test( data.range, 'removeMarker' ) ).to.be.true;
+				expect( consumable.test( data.item, 'removeMarker:name' ) ).to.be.true;
 			} );
 
 			dispatcher.convertMarker( 'addMarker', 'name', range );
 			dispatcher.convertMarker( 'removeMarker', 'name', range );
 		} );
+
+		it( 'should fire conversion for each item in the range', () => {
+			const element = new ModelElement( 'paragraph', null, [ new ModelText( 'foo bar baz' ) ] );
+			root.appendChildren( [ element ] );
+			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 ) );
+
+			dispatcher.convertMarker( 'addMarker', 'name', range );
+			dispatcher.convertMarker( 'removeMarker', 'name', range );
+
+			// 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 ];
+
+				expect( addData.markerName ).to.equal( 'name' );
+				expect( addData.markerRange ).to.equal( range );
+				expect( addData.range.isEqual( ModelRange.createOn( nodeInRange ) ) );
+
+				expect( removeData.markerName ).to.equal( 'name' );
+				expect( removeData.markerRange ).to.equal( range );
+				expect( removeData.range.isEqual( ModelRange.createOn( nodeInRange ) ) );
+
+				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 );
+				}
+
+				i++;
+			}
+		} );
+
+		it( 'should not fire events for already consumed items', () => {
+			const element = new ModelElement( 'paragraph', null, [ new ModelText( 'foo bar baz' ) ] );
+			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' );
+				}
+			} );
+
+			dispatcher.on( 'addMarker:marker', addMarkerSpy );
+			dispatcher.on( 'addMarker:marker', removeMarkerSpy );
+
+			dispatcher.convertMarker( 'addMarker', 'marker', range );
+			dispatcher.convertMarker( 'removeMarker', 'marker', range );
+
+			sinon.assert.calledOnce( addMarkerSpy );
+			sinon.assert.calledOnce( removeMarkerSpy );
+		} );
+
+		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;
+			} );
+
+			dispatcher.on( 'addMarker:marker', addMarkerSpy );
+			dispatcher.on( 'addMarker:marker', removeMarkerSpy );
+
+			dispatcher.convertMarker( 'addMarker', 'marker', range );
+			dispatcher.convertMarker( 'removeMarker', 'marker', range );
+
+			sinon.assert.calledOnce( addMarkerSpy );
+			sinon.assert.calledOnce( removeMarkerSpy );
+		} );
 	} );
 } );

+ 1 - 1
packages/ckeditor5-engine/tests/conversion/viewconversiondispatcher.js

@@ -172,7 +172,7 @@ describe( 'ViewConversionDispatcher', () => {
 			expect( spy.calledOnce ).to.be.true;
 		} );
 
-		it( 'should extract temporary markers stamps from converter element and create static markers list', () => {
+		it( 'should extract temporary markers elements from converter element and create static markers list', () => {
 			const viewFragment = new ViewDocumentFragment();
 
 			dispatcher.on( 'documentFragment', ( evt, data ) => {

+ 35 - 0
packages/ckeditor5-engine/tests/dev-utils/enableenginedebug.js

@@ -454,6 +454,41 @@ describe( 'debug tools', () => {
 				expect( log.calledWithExactly( delta.toString() ) ).to.be.true;
 			} );
 
+			it( 'SplitDelta - NoOperation as second operation', () => {
+				const otherRoot = modelDoc.createRoot( 'main', 'otherRoot' );
+				const splitEle = new ModelElement( 'paragraph', null, [ new ModelText( 'foo' ) ] );
+
+				otherRoot.appendChildren( [ splitEle ] );
+
+				const delta = new SplitDelta();
+				const insert = new InsertOperation( ModelPosition.createAt( otherRoot, 1 ), [ new ModelElement( 'paragraph' ) ], 0 );
+				const move = new NoOperation( 1 );
+
+				delta.addOperation( insert );
+				delta.addOperation( move );
+
+				expect( delta.toString() ).to.equal( 'SplitDelta( 0 ): (clone to otherRoot [ 1 ])' );
+
+				delta.log();
+				expect( log.calledWithExactly( delta.toString() ) ).to.be.true;
+			} );
+
+			it( 'SplitDelta - NoOperation as second operation, MoveOperation as first operation', () => {
+				const otherRoot = modelDoc.createRoot( 'main', 'otherRoot' );
+
+				const delta = new SplitDelta();
+				const insert = new MoveOperation( ModelPosition.createAt( modelRoot, 1 ), 1, ModelPosition.createAt( otherRoot, 1 ), 0 );
+				const move = new NoOperation( 1 );
+
+				delta.addOperation( insert );
+				delta.addOperation( move );
+
+				expect( delta.toString() ).to.equal( 'SplitDelta( 0 ): (clone to otherRoot [ 1 ])' );
+
+				delta.log();
+				expect( log.calledWithExactly( delta.toString() ) ).to.be.true;
+			} );
+
 			it( 'UnwrapDelta', () => {
 				const otherRoot = modelDoc.createRoot( 'main', 'otherRoot' );
 				const unwrapEle = new ModelElement( 'paragraph', null, [ new ModelText( 'foo' ) ] );

+ 6 - 4
packages/ckeditor5-engine/tests/manual/markers.js

@@ -18,7 +18,6 @@ import Undo from '@ckeditor/ckeditor5-undo/src/undo';
 import buildModelConverter from '../../src/conversion/buildmodelconverter';
 import Position from '../../src/model/position';
 import Range from '../../src/model/range';
-import ViewAttributeElement from '../../src/view/attributeelement';
 
 const markerNames = [];
 let model = null;
@@ -35,10 +34,13 @@ ClassicEditor
 
 		buildModelConverter().for( editor.editing.modelToView )
 			.fromMarker( 'highlight' )
-			.toElement( data => {
-				const color = data.name.split( ':' )[ 1 ];
+			.toVirtualSelection( data => {
+				const color = data.markerName.split( ':' )[ 1 ];
 
-				return new ViewAttributeElement( 'span', { class: 'h-' + color } );
+				return {
+					class: 'h-' + color,
+					priority: 1
+				};
 			} );
 
 		window.document.getElementById( 'add-yellow' ).addEventListener( 'mousedown', e => {

+ 54 - 0
packages/ckeditor5-engine/tests/manual/virtualselection.html

@@ -0,0 +1,54 @@
+<style>
+	span.virtual-selection-yellow {
+		background-color: yellow;
+	}
+
+	span.virtual-selection-blue {
+		background-color: blue;
+	}
+
+	span.virtual-selection-red {
+		background-color: red;
+	}
+
+	.ck-widget.fancy-widget {
+		background-color: black;
+		color: white;
+		text-align: center;
+		height: 40px;
+		line-height: 40px;
+	}
+
+	.ck-widget.fancy-widget.virtual-selection-yellow {
+		background-color: yellow;
+		color: black;
+	}
+
+	.ck-widget.fancy-widget.virtual-selection-blue {
+		background-color: blue;
+		color: black;
+	}
+
+	.ck-widget.fancy-widget.virtual-selection-red {
+		background-color: red;
+		color: black;
+	}
+
+	button {
+		width: 200px;
+	}
+</style>
+
+<div id="editor">
+	<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas at diam quis justo imperdiet posuere non eu tortor. Mauris vel magna eu sem hendrerit varius. Ut imperdiet velit ut ante interdum convallis. Vestibulum vitae lacinia mi. </p>
+	<figure></figure>
+	<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas at diam quis justo imperdiet posuere non eu tortor. Mauris vel magna eu sem hendrerit varius. Ut imperdiet velit ut ante interdum convallis. Vestibulum vitae lacinia mi. </p>
+</div>
+<h2>Markers</h2>
+<button id="add-marker-yellow">Set yellow marker</button><button id="remove-marker-yellow">Remove yellow marker</button>
+<br />
+<button id="add-marker-red">Set red marker</button><button id="remove-marker-red">Remove red marker</button>
+<br />
+<button id="add-marker-blue">Set blue marker</button><button id="remove-marker-blue">Remove blue marker</button>
+<br />
+<button id="remove-markers">Remove all markers</button>

+ 134 - 0
packages/ckeditor5-engine/tests/manual/virtualselection.js

@@ -0,0 +1,134 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Typing from '@ckeditor/ckeditor5-typing/src/typing';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
+import List from '@ckeditor/ckeditor5-list/src/list';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import buildModelConverter from '../../src/conversion/buildmodelconverter';
+import buildViewConverter from '../../src/conversion/buildviewconverter';
+import ModelRange from '../../src/model/range';
+import ModelElement from '../../src/model/element';
+import ViewContainerElement from '../../src/view/containerelement';
+import ViewText from '../../src/view/text';
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Widget from '@ckeditor/ckeditor5-widget/src/widget';
+import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
+
+class FancyWidget extends Plugin {
+	static get requires() {
+		return [ Widget ];
+	}
+
+	init() {
+		const editor = this.editor;
+		const doc = editor.document;
+		const schema = doc.schema;
+		const data = editor.data;
+		const editing = editor.editing;
+
+		// Configure schema.
+		schema.registerItem( 'fancywidget' );
+		schema.allow( { name: 'fancywidget', inside: '$root' } );
+		schema.objects.add( 'fancywidget' );
+
+		// Build converter from model to view for editing pipeline.
+		buildModelConverter().for( editing.modelToView )
+			.fromElement( 'fancywidget' )
+			.toElement( () => {
+				const widgetElement = new ViewContainerElement( 'figure', { class: 'fancy-widget' }, new ViewText( 'widget' ) );
+
+				return toWidget( widgetElement );
+			} );
+
+		// Build converter from view element to model element for data pipeline.
+		buildViewConverter().for( data.viewToModel )
+			.fromElement( 'figure' )
+			.toElement( () => new ModelElement( 'fancywidget' ) );
+	}
+}
+
+ClassicEditor.create( global.document.querySelector( '#editor' ), {
+	plugins: [ Enter, Typing, Paragraph, Undo, Heading, Bold, Italic, List, FancyWidget ],
+	toolbar: [ 'headings', 'undo', 'redo', 'bold', 'italic', 'numberedList', 'bulletedList' ]
+} )
+	.then( editor => {
+		window.editor = editor;
+
+		buildModelConverter()
+			.for( editor.editing.modelToView )
+			.fromMarker( 'marker' )
+			.toVirtualSelection( data => ( { class: 'virtual-selection-' + data.markerName.split( ':' )[ 1 ] } ) );
+
+		document.getElementById( 'add-marker-yellow' ).addEventListener( 'mousedown', evt => {
+			addMarker( editor, 'yellow' );
+			evt.preventDefault();
+		} );
+
+		document.getElementById( 'add-marker-blue' ).addEventListener( 'mousedown', evt => {
+			addMarker( editor, 'blue' );
+			evt.preventDefault();
+		} );
+
+		document.getElementById( 'add-marker-red' ).addEventListener( 'mousedown', evt => {
+			addMarker( editor, 'red' );
+			evt.preventDefault();
+		} );
+
+		document.getElementById( 'remove-marker-yellow' ).addEventListener( 'mousedown', evt => {
+			removeMarker( editor, 'yellow' );
+			evt.preventDefault();
+		} );
+
+		document.getElementById( 'remove-marker-blue' ).addEventListener( 'mousedown', evt => {
+			removeMarker( editor, 'blue' );
+			evt.preventDefault();
+		} );
+
+		document.getElementById( 'remove-marker-red' ).addEventListener( 'mousedown', evt => {
+			removeMarker( editor, 'red' );
+			evt.preventDefault();
+		} );
+
+		document.getElementById( 'remove-markers' ).addEventListener( 'mousedown', evt => {
+			const markers = editor.document.markers;
+
+			editor.document.enqueueChanges( () => {
+				for ( const marker of markers ) {
+					markers.remove( marker );
+				}
+			} );
+
+			evt.preventDefault();
+		} );
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+
+function addMarker( editor, color ) {
+	const model = editor.document;
+
+	editor.document.enqueueChanges( () => {
+		const range = ModelRange.createFromRange( model.selection.getFirstRange() );
+		model.markers.set( 'marker:' + color, range );
+	} );
+}
+
+function removeMarker( editor, color ) {
+	const model = editor.document;
+
+	editor.document.enqueueChanges( () => {
+		model.markers.remove( 'marker:' + color );
+	} );
+}

+ 14 - 0
packages/ckeditor5-engine/tests/manual/virtualselection.md

@@ -0,0 +1,14 @@
+### Virtual selection manual test
+
+1. Create selection that starts before the last word of the first paragraph and ends after first
+word of the second paragraph and press `Set yellow marker`.
+1. Yellow marker should be visible on text and widget.
+1. Select whole contents of the editor and press `Set red marker`.
+1. Yellow marker should be visible as before, red marker should be visible on ranges where yellow marker is not
+applied.
+1. Click `Remove yellow marker`.
+1. Red marker should be visible on the whole content, including the widget.
+1. Play with markers, add and remove them, check if they are applied correctly.
+
+NOTE: Yellow marker should be visible over red and blue one. Red marker should be visible over blue one. 
+

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

@@ -307,6 +307,33 @@ describe( 'transform', () => {
 					]
 				} );
 			} );
+
+			it( 'should use default algorithm and not throw if split delta has NoOperation', () => {
+				const range = new Range( new Position( root, [ 1 ] ), new Position( root, [ 2, 3 ] ) );
+				const attrDelta = getAttributeDelta( range, 'foo', null, 'bar', 0 );
+				const splitDelta = getSplitDelta( new Position( root, [ 0, 2 ] ), new Element( 'paragraph' ), 3, 0 );
+				splitDelta.operations[ 1 ] = new NoOperation( 1 );
+
+				const transformed = transform( attrDelta, splitDelta, context );
+
+				baseVersion = splitDelta.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: AttributeDelta,
+					operations: [
+						{
+							type: AttributeOperation,
+							range: new Range( new Position( root, [ 2 ] ), new Position( root, [ 3, 3 ] ) ),
+							key: 'foo',
+							oldValue: null,
+							newValue: 'bar',
+							baseVersion
+						}
+					]
+				} );
+			} );
 		} );
 
 		describe( 'AttributeDelta', () => {

+ 33 - 0
packages/ckeditor5-engine/tests/model/delta/transform/renamedelta.js

@@ -11,8 +11,10 @@ import Element from '../../../../src/model/element';
 import Position from '../../../../src/model/position';
 
 import RenameDelta from '../../../../src/model/delta/renamedelta';
+import SplitDelta from '../../../../src/model/delta/splitdelta';
 import Delta from '../../../../src/model/delta/delta';
 import RenameOperation from '../../../../src/model/operation/renameoperation';
+import MoveOperation from '../../../../src/model/operation/moveoperation';
 import NoOperation from '../../../../src/model/operation/nooperation';
 
 import {
@@ -139,6 +141,37 @@ describe( 'transform', () => {
 					]
 				} );
 			} );
+
+			it( 'should not throw if clone operation is NoOperation and use default transformation in that case', () => {
+				const noOpSplitDelta = new SplitDelta();
+				noOpSplitDelta.addOperation( new NoOperation( 0 ) );
+				noOpSplitDelta.addOperation( new MoveOperation( new Position( root, [ 1, 2 ] ), 3, new Position( root, [ 2, 0 ] ), 1 ) );
+
+				const renameDelta = new RenameDelta();
+				renameDelta.addOperation( new RenameOperation(
+					new Position( root, [ 1 ] ),
+					'p',
+					'li',
+					baseVersion
+				) );
+
+				const transformed = transform( renameDelta, noOpSplitDelta, context );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: RenameDelta,
+					operations: [
+						{
+							type: RenameOperation,
+							position: new Position( root, [ 1 ] ),
+							oldName: 'p',
+							newName: 'li',
+							baseVersion: 2
+						}
+					]
+				} );
+			} );
 		} );
 
 		describe( 'RenameDelta', () => {

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

@@ -322,6 +322,61 @@ describe( 'transform', () => {
 				// DIV and P elements are correctly split.
 				expect( nodesAndText ).to.equal( 'DIVXXXXXabcdXDIVDIVPabcPPfoobarxyzPDIV' );
 			} );
+
+			it( 'should use default algorithm and not throw if transformed split delta has NoOperation', () => {
+				splitDelta.operations[ 1 ] = new NoOperation( 1 );
+				const splitDeltaB = getSplitDelta( new Position( root, [ 3, 3, 3, 1 ] ), new Element( 'p' ), 11, baseVersion );
+
+				const transformed = transform( splitDelta, splitDeltaB, context );
+
+				baseVersion = splitDeltaB.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 5 ] ),
+							baseVersion
+						},
+						{
+							type: NoOperation,
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+			} );
+
+			it( 'should use default algorithm and not throw if split delta to transform by has NoOperation', () => {
+				const splitDeltaB = getSplitDelta( new Position( root, [ 3, 3, 3, 1 ] ), new Element( 'p' ), 11, baseVersion );
+				splitDeltaB.operations[ 1 ] = new NoOperation( 1 );
+
+				const transformed = transform( splitDelta, splitDeltaB, context );
+
+				baseVersion = splitDeltaB.operations.length;
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 5 ] ),
+							baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 3, 3 ] ),
+							howMany: 9,
+							targetPosition: new Position( root, [ 3, 3, 5, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+			} );
 		} );
 
 		describe( 'UnwrapDelta', () => {
@@ -389,6 +444,33 @@ describe( 'transform', () => {
 				// UnwrapDelta and SplitDelta are correctly applied.
 				expect( nodesAndText ).to.equal( 'DIVXXXXXaXXXXXXabcdXPabcPPfoobarxyzPDIV' );
 			} );
+
+			it( 'should use default algorithm and not throw if split delta has NoOperation', () => {
+				splitDelta.operations[ 1 ] = new NoOperation( 1 );
+
+				const unwrapDelta = getUnwrapDelta( new Position( root, [ 3, 3 ] ), 4, baseVersion );
+
+				const transformed = transform( splitDelta, unwrapDelta, context );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = unwrapDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 7 ] ),
+							baseVersion
+						},
+						{
+							type: NoOperation,
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+			} );
 		} );
 
 		describe( 'WrapDelta', () => {
@@ -500,6 +582,35 @@ describe( 'transform', () => {
 				// WrapDelta and SplitDelta are correctly applied.
 				expect( nodesAndText ).to.equal( 'EXabcdXPabcPPfoobarxyzPE' );
 			} );
+
+			it( 'should use default algorithm and not throw if split delta has NoOperation', () => {
+				splitDelta.operations[ 1 ] = new NoOperation( 1 );
+
+				const wrapRange = new Range( new Position( root, [ 3, 3, 2 ] ), new Position( root, [ 3, 3, 4 ] ) );
+				const wrapElement = new Element( 'E' );
+				const wrapDelta = getWrapDelta( wrapRange, wrapElement, baseVersion );
+
+				const transformed = transform( splitDelta, wrapDelta, context );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = wrapDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 3 ] ),
+							baseVersion
+						},
+						{
+							type: NoOperation,
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+			} );
 		} );
 
 		describe( 'AttributeDelta', () => {
@@ -619,6 +730,35 @@ describe( 'transform', () => {
 					]
 				} );
 			} );
+
+			it( 'should use default algorithm and not throw if split delta has NoOperation', () => {
+				splitDelta.operations[ 1 ] = new NoOperation( 1 );
+
+				const attributeDelta = new AttributeDelta();
+				attributeDelta.addOperation( new AttributeOperation(
+					Range.createFromParentsAndOffsets( root, 0, root, 4 ), 'key', 'oldValue', 'newValue', baseVersion
+				) );
+
+				const transformed = transform( splitDelta, attributeDelta, context );
+
+				baseVersion = attributeDelta.operations.length;
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 4 ] ),
+							baseVersion
+						},
+						{
+							type: NoOperation,
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+			} );
 		} );
 
 		describe( 'RenameDelta', () => {
@@ -737,6 +877,41 @@ describe( 'transform', () => {
 					]
 				} );
 			} );
+
+			it( 'should not throw if clone operation is NoOperation and use default transformation in that case', () => {
+				const noOpSplitDelta = new SplitDelta();
+				noOpSplitDelta.addOperation( new NoOperation( 0 ) );
+				noOpSplitDelta.addOperation( new MoveOperation( new Position( root, [ 1, 2 ] ), 3, new Position( root, [ 2, 0 ] ), 1 ) );
+
+				const renameDelta = new RenameDelta();
+				renameDelta.addOperation( new RenameOperation(
+					new Position( root, [ 1 ] ),
+					'p',
+					'li',
+					baseVersion
+				) );
+
+				const transformed = transform( noOpSplitDelta, renameDelta, context );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				expectDelta( transformed[ 0 ], {
+					type: SplitDelta,
+					operations: [
+						{
+							type: NoOperation,
+							baseVersion: 1
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 1, 2 ] ),
+							howMany: 3,
+							targetPosition: new Position( root, [ 2, 0 ] ),
+							baseVersion: 2
+						}
+					]
+				} );
+			} );
 		} );
 
 		describe( 'RemoveDelta', () => {

+ 34 - 0
packages/ckeditor5-engine/tests/model/delta/transform/unwrapdelta.js

@@ -13,6 +13,8 @@ import Position from '../../../../src/model/position';
 import Range from '../../../../src/model/range';
 
 import MoveOperation from '../../../../src/model/operation/moveoperation';
+import RemoveOperation from '../../../../src/model/operation/removeoperation';
+import NoOperation from '../../../../src/model/operation/nooperation';
 
 import MergeDelta from '../../../../src/model/delta/mergedelta';
 import UnwrapDelta from '../../../../src/model/delta/unwrapdelta';
@@ -146,6 +148,38 @@ describe( 'transform', () => {
 				// UnwrapDelta and SplitDelta are applied.
 				expect( nodesAndText ).to.equal( 'DIVXXXXXabcdXDIVDIVabcfoobarxyzDIV' );
 			} );
+
+			it( 'should use default algorithm and not throw if split delta has NoOperation', () => {
+				const splitPosition = new Position( root, [ 3, 3, 2, 1 ] );
+				const splitDelta = getSplitDelta( splitPosition, new Element( 'div' ), 1, baseVersion );
+				splitDelta.operations[ 1 ] = new NoOperation( 1 );
+
+				const transformed = transform( unwrapDelta, splitDelta, context );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = splitDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: UnwrapDelta,
+					operations: [
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 4, 0 ] ),
+							howMany: 12,
+							targetPosition: new Position( root, [ 3, 3, 4 ] ),
+							baseVersion
+						},
+						{
+							type: RemoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 16 ] ),
+							howMany: 1,
+							targetPosition: new Position( gy, [ 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+			} );
 		} );
 	} );
 } );

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

@@ -14,6 +14,7 @@ import Range from '../../../../src/model/range';
 
 import MoveOperation from '../../../../src/model/operation/moveoperation';
 import InsertOperation from '../../../../src/model/operation/insertoperation';
+import NoOperation from '../../../../src/model/operation/nooperation';
 
 import MergeDelta from '../../../../src/model/delta/mergedelta';
 import WrapDelta from '../../../../src/model/delta/wrapdelta';
@@ -190,6 +191,36 @@ describe( 'transform', () => {
 				// WrapDelta and SplitDelta are correctly applied.
 				expect( nodesAndText ).to.equal( 'EXabcdXPabcPPfoobarxyzPE' );
 			} );
+
+			it( 'should use default algorithm and not throw if split delta has NoOperation', () => {
+				const splitPosition = new Position( root, [ 3, 3, 2, 1 ] );
+				const splitDelta = getSplitDelta( splitPosition, new Element( 'p' ), 11, baseVersion );
+				splitDelta.operations[ 1 ] = new NoOperation( 1 );
+
+				const transformed = transform( wrapDelta, splitDelta, context );
+
+				expect( transformed.length ).to.equal( 1 );
+
+				baseVersion = wrapDelta.operations.length;
+
+				expectDelta( transformed[ 0 ], {
+					type: WrapDelta,
+					operations: [
+						{
+							type: InsertOperation,
+							position: new Position( root, [ 3, 3, 4, 5 ] ),
+							baseVersion
+						},
+						{
+							type: MoveOperation,
+							sourcePosition: new Position( root, [ 3, 3, 4, 1 ] ),
+							howMany: 4,
+							targetPosition: new Position( root, [ 3, 3, 4, 5, 0 ] ),
+							baseVersion: baseVersion + 1
+						}
+					]
+				} );
+			} );
 		} );
 	} );
 } );