Procházet zdrojové kódy

Merge pull request #700 from ckeditor/t/643

T/643 Markers in engine
Piotr Jasiun před 9 roky
rodič
revize
7c270345b0
27 změnil soubory, kde provedl 1914 přidání a 231 odebrání
  1. 26 6
      packages/ckeditor5-engine/src/controller/editingcontroller.js
  2. 53 11
      packages/ckeditor5-engine/src/conversion/buildmodelconverter.js
  3. 40 24
      packages/ckeditor5-engine/src/conversion/mapper.js
  4. 186 49
      packages/ckeditor5-engine/src/conversion/model-to-view-converters.js
  5. 12 5
      packages/ckeditor5-engine/src/conversion/modelconsumable.js
  6. 68 3
      packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js
  7. 2 2
      packages/ckeditor5-engine/src/dev-utils/model.js
  8. 10 1
      packages/ckeditor5-engine/src/model/document.js
  9. 34 44
      packages/ckeditor5-engine/src/model/liverange.js
  10. 154 0
      packages/ckeditor5-engine/src/model/markerscollection.js
  11. 131 16
      packages/ckeditor5-engine/src/model/range.js
  12. 1 1
      packages/ckeditor5-engine/src/view/element.js
  13. 50 6
      packages/ckeditor5-engine/src/view/writer.js
  14. 163 9
      packages/ckeditor5-engine/tests/controller/editingcontroller.js
  15. 12 12
      packages/ckeditor5-engine/tests/conversion/advanced-converters.js
  16. 68 13
      packages/ckeditor5-engine/tests/conversion/buildmodelconverter.js
  17. 5 5
      packages/ckeditor5-engine/tests/conversion/model-selection-to-view-converters.js
  18. 314 13
      packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js
  19. 61 2
      packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js
  20. 33 0
      packages/ckeditor5-engine/tests/manual/tickets/643/1.html
  21. 116 0
      packages/ckeditor5-engine/tests/manual/tickets/643/1.js
  22. 16 0
      packages/ckeditor5-engine/tests/manual/tickets/643/1.md
  23. 11 0
      packages/ckeditor5-engine/tests/model/delta/transform/_utils/utils.js
  24. 1 1
      packages/ckeditor5-engine/tests/model/liverange.js
  25. 147 0
      packages/ckeditor5-engine/tests/model/markerscollection.js
  26. 189 8
      packages/ckeditor5-engine/tests/model/range.js
  27. 11 0
      packages/ckeditor5-engine/tests/view/writer/move.js

+ 26 - 6
packages/ckeditor5-engine/src/controller/editingcontroller.js

@@ -10,7 +10,14 @@
 import ViewDocument from '../view/document.js';
 import Mapper from '../conversion/mapper.js';
 import ModelConversionDispatcher from '../conversion/modelconversiondispatcher.js';
-import { insertText, remove, move, rename } from '../conversion/model-to-view-converters.js';
+import {
+	insertText,
+	remove,
+	move,
+	rename,
+	insertIntoMarker,
+	moveInOutOfMarker
+} from '../conversion/model-to-view-converters.js';
 import { convertSelectionChange } from '../conversion/view-selection-to-model-converters.js';
 import {
 	convertRangeSelection,
@@ -84,21 +91,30 @@ export default class EditingController {
 		 * @private
 		 * @member {utils.EmitterMixin} #_listenter
 		 */
-		this._listenter = Object.create( EmitterMixin );
+		this._listener = Object.create( EmitterMixin );
 
 		// Convert changes in model to view.
-		this._listenter.listenTo( this.model, 'change', ( evt, type, changes ) => {
+		this._listener.listenTo( this.model, 'change', ( evt, type, changes ) => {
 			this.modelToView.convertChange( type, changes );
 		}, { priority: 'low' } );
 
 		// Convert model selection to view.
-		this._listenter.listenTo( this.model, 'changesDone', () => {
+		this._listener.listenTo( this.model, 'changesDone', () => {
 			this.modelToView.convertSelection( model.selection );
 			this.view.render();
 		}, { priority: 'low' } );
 
+		// Convert model markers changes.
+		this._listener.listenTo( this.model.markers, 'add', ( evt, name, range ) => {
+			this.modelToView.convertMarker( 'addMarker', name, range );
+		} );
+
+		this._listener.listenTo( this.model.markers, 'remove', ( evt, name, range ) => {
+			this.modelToView.convertMarker( 'removeMarker', name, range );
+		} );
+
 		// Convert view selection to model.
-		this._listenter.listenTo( this.view, 'selectionChange', convertSelectionChange( model, this.mapper ) );
+		this._listener.listenTo( this.view, 'selectionChange', convertSelectionChange( model, this.mapper ) );
 
 		// Attach default content converters.
 		this.modelToView.on( 'insert:$text', insertText(), { priority: 'lowest' } );
@@ -106,6 +122,10 @@ export default class EditingController {
 		this.modelToView.on( 'move', move(), { priority: 'low' } );
 		this.modelToView.on( 'rename', rename(), { priority: 'low' } );
 
+		// Attach default markers converters.
+		this.modelToView.on( 'insert', insertIntoMarker( this.model.markers ), { priority: 'lowest' } );
+		this.modelToView.on( 'move', moveInOutOfMarker( this.model.markers ), { priority: 'lowest' } );
+
 		// Attach default selection converters.
 		this.modelToView.on( 'selection', clearAttributes(), { priority: 'low' } );
 		this.modelToView.on( 'selection', clearFakeSelection(), { priority: 'low' } );
@@ -146,6 +166,6 @@ export default class EditingController {
 	 */
 	destroy() {
 		this.view.destroy();
-		this._listenter.stopListening();
+		this._listener.stopListening();
 	}
 }

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

@@ -11,8 +11,10 @@ import {
 	insertElement,
 	setAttribute,
 	removeAttribute,
-	wrap,
-	unwrap
+	wrapItem,
+	unwrapItem,
+	wrapRange,
+	unwrapRange
 } from './model-to-view-converters.js';
 
 import { convertSelectionAttribute } from './model-selection-to-view-converters.js';
@@ -20,6 +22,8 @@ import { convertSelectionAttribute } from './model-selection-to-view-converters.
 import ViewAttributeElement from '../view/attributeelement.js';
 import ViewContainerElement from '../view/containerelement.js';
 
+import CKEditorError from '../../utils/ckeditorerror.js';
+
 /**
  * Provides chainable, high-level API to easily build basic model-to-view converters that are appended to given
  * dispatchers. In many cases, this is the API that should be used to specify how abstract model elements and
@@ -30,7 +34,7 @@ import ViewContainerElement from '../view/containerelement.js';
  * {@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 three kinds of converters:
+ * Using this API it is possible to create four 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.
@@ -51,6 +55,14 @@ import ViewContainerElement from '../view/containerelement.js';
  *
  *		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/markerscollection~MarkersCollection}.
+ *
+ *		const viewSpanSearchResult = new ViewAttributeElement( 'span', { class: 'search-result' } );
+ *		buildModelConverter().for( dispatcher ).fromMarker( 'searchResult' ).toElement( viewSpanSearchResult );
+ *
  * 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.
@@ -136,6 +148,23 @@ class ModelConverterBuilder {
 		return this;
 	}
 
+	/**
+	 * Registers what type of marker should be converted.
+	 *
+	 * @chainable
+	 * @param {String} markerName Name of marker to convert.
+	 * @returns {module:engine/conversion/modelconverterbuilder~ModelConverterBuilder}
+	 */
+	fromMarker( markerName ) {
+		this._from = {
+			type: 'marker',
+			name: markerName,
+			priority: null
+		};
+
+		return this;
+	}
+
 	/**
 	 * Changes default priority for built converter. The lower the number, the earlier converter will be fired.
 	 * Default priority is `10`.
@@ -190,7 +219,8 @@ class ModelConverterBuilder {
 	 * This method creates the converter and adds it as a callback to a proper
 	 * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher conversion dispatcher} event.
 	 *
-	 * @param {String|module:engine/view/element~Element|Function} element Element created by converter.
+	 * @param {String|module:engine/view/element~ViewElement|Function} element Element created by converter or
+	 * a function that returns view element.
 	 */
 	toElement( element ) {
 		const priority = this._from.priority === null ? 'normal' : this._from.priority;
@@ -201,15 +231,21 @@ class ModelConverterBuilder {
 				element = typeof element == 'string' ? new ViewContainerElement( element ) : element;
 
 				dispatcher.on( 'insert:' + this._from.name, insertElement( element ), { priority } );
-			} else {
+			} else if ( this._from.type == 'attribute' ) {
 				// From model attribute to view element -> wrap and unwrap.
 				element = typeof element == 'string' ? new ViewAttributeElement( element ) : element;
 
-				dispatcher.on( 'addAttribute:' + this._from.key, wrap( element ), { priority } );
-				dispatcher.on( 'changeAttribute:' + this._from.key, wrap( element ), { priority } );
-				dispatcher.on( 'removeAttribute:' + this._from.key, unwrap( element ), { priority } );
+				dispatcher.on( 'addAttribute:' + this._from.key, wrapItem( element ), { priority } );
+				dispatcher.on( 'changeAttribute:' + this._from.key, wrapItem( element ), { priority } );
+				dispatcher.on( 'removeAttribute:' + this._from.key, unwrapItem( element ), { priority } );
 
 				dispatcher.on( 'selectionAttribute:' + this._from.key, convertSelectionAttribute( element ), { priority } );
+			} else {
+				// From marker to view element -> wrapRange and unwrapRange.
+				element = typeof element == 'string' ? new ViewAttributeElement( element ) : element;
+
+				dispatcher.on( 'addMarker:' + this._from.name, wrapRange( element ), { priority } );
+				dispatcher.on( 'removeMarker:' + this._from.name, unwrapRange( element ), { priority } );
 			}
 		}
 	}
@@ -246,9 +282,15 @@ class ModelConverterBuilder {
 	 * @param {*} [value] Attribute value.
 	 */
 	toAttribute( keyOrCreator, value ) {
-		if ( this._from.type == 'element' ) {
-			// Converting from model element to view attribute is unsupported.
-			return;
+		if ( this._from.type != 'attribute' ) {
+			/**
+			 * To-attribute conversion is supported only for model attributes.
+			 *
+			 * @error build-model-converter-element-to-attribute
+			 * @param {module:engine/model/range~Range} range
+			 */
+			throw new CKEditorError( 'build-model-converter-non-attribute-to-attribute: ' +
+				'To-attribute conversion is supported only from model attributes.' );
 		}
 
 		let attributeCreator;

+ 40 - 24
packages/ckeditor5-engine/src/conversion/mapper.js

@@ -61,28 +61,6 @@ export default class Mapper {
 		 * @member {Map}
 		 */
 		this._viewToModelLengthCallbacks = new Map();
-
-		// Add default callback for model to view position mapping.
-		this.on( 'modelToViewPosition', ( evt, data ) => {
-			let viewContainer = this._modelToViewMapping.get( data.modelPosition.parent );
-
-			data.viewPosition = this._findPositionIn( viewContainer, data.modelPosition.offset );
-		}, { priority: 'lowest' } );
-
-		// Add default callback for view to model position mapping.
-		this.on( 'viewToModelPosition', ( evt, data ) => {
-			let viewBlock = data.viewPosition.parent;
-			let modelParent = this._viewToModelMapping.get( viewBlock );
-
-			while ( !modelParent ) {
-				viewBlock = viewBlock.parent;
-				modelParent = this._viewToModelMapping.get( viewBlock );
-			}
-
-			let modelOffset = this._toModelOffset( data.viewPosition.parent, data.viewPosition.offset, viewBlock );
-
-			data.modelPosition = ModelPosition.createFromParentAndOffset( modelParent, modelOffset );
-		}, { priority: 'lowest' } );
 	}
 
 	/**
@@ -179,7 +157,7 @@ export default class Mapper {
 	toModelPosition( viewPosition ) {
 		const data = {
 			viewPosition: viewPosition,
-			modelPosition: null,
+			modelPosition: this._defaultToModelPosition( viewPosition ),
 			mapper: this
 		};
 
@@ -188,6 +166,19 @@ export default class Mapper {
 		return data.modelPosition;
 	}
 
+	/**
+	 * Maps model position to view position using default mapper algorithm.
+	 *
+	 * @private
+	 * @param {module:engine/model/position~Position} modelPosition
+	 * @returns {module:engine/view/position~Position} View position mapped from model position.
+	 */
+	_defaultToViewPosition( modelPosition ) {
+		let viewContainer = this._modelToViewMapping.get( modelPosition.parent );
+
+		return this._findPositionIn( viewContainer, modelPosition.offset );
+	}
+
 	/**
 	 * Gets the corresponding view position.
 	 *
@@ -197,7 +188,7 @@ export default class Mapper {
 	 */
 	toViewPosition( modelPosition ) {
 		const data = {
-			viewPosition: null,
+			viewPosition: this._defaultToViewPosition( modelPosition ),
 			modelPosition: modelPosition,
 			mapper: this
 		};
@@ -207,6 +198,27 @@ export default class Mapper {
 		return data.viewPosition;
 	}
 
+	/**
+	 * Maps view position to model position using default mapper algorithm.
+	 *
+	 * @private
+	 * @param {module:engine/view/position~Position} viewPosition
+	 * @returns {module:engine/model/position~Position} Model position mapped from view position.
+	 */
+	_defaultToModelPosition( viewPosition ) {
+		let viewBlock = viewPosition.parent;
+		let modelParent = this._viewToModelMapping.get( viewBlock );
+
+		while ( !modelParent ) {
+			viewBlock = viewBlock.parent;
+			modelParent = this._viewToModelMapping.get( viewBlock );
+		}
+
+		let modelOffset = this._toModelOffset( viewPosition.parent, viewPosition.offset, viewBlock );
+
+		return ModelPosition.createFromParentAndOffset( modelParent, modelOffset );
+	}
+
 	/**
 	 * Registers a callback that evaluates the length in the model of a view element with given name.
 	 *
@@ -454,6 +466,8 @@ mix( Mapper, EmitterMixin );
  * @param {Object} data Data pipeline object that can store and pass data between callbacks. The callback should add
  * `viewPosition` value to that object with calculated {@link module:engine/view/position~Position view position}.
  * @param {module:engine/model/position~Position} data.modelPosition Model position to be mapped.
+ * @param {module:engine/view/position~Position} data.viewPosition View position that is a result of mapping
+ * `modelPosition` using `Mapper` default algorithm.
  * @param {module:engine/conversion/mapper~Mapper} data.mapper Mapper instance that fired the event.
  */
 
@@ -478,5 +492,7 @@ mix( Mapper, EmitterMixin );
  * @param {Object} data Data pipeline object that can store and pass data between callbacks. The callback should add
  * `modelPosition` value to that object with calculated {@link module:engine/model/position~Position model position}.
  * @param {module:engine/view/position~Position} data.viewPosition View position to be mapped.
+ * @param {module:engine/model/position~Position} data.modelPosition Model position that is a result of mapping
+ * `viewPosition` using `Mapper` default algorithm.
  * @param {module:engine/conversion/mapper~Mapper} data.mapper Mapper instance that fired the event.
  */

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

@@ -8,10 +8,9 @@ import ModelPosition from '../model/position.js';
 import ModelElement from '../model/element.js';
 
 import ViewRange from '../view/range.js';
+import ViewPosition from '../view/position.js';
 import ViewElement from '../view/element.js';
 import ViewText from '../view/text.js';
-import ViewTextProxy from '../view/textproxy.js';
-import ViewTreeWalker from '../view/treewalker.js';
 import viewWriter from '../view/writer.js';
 
 /**
@@ -193,7 +192,7 @@ export function removeAttribute( attributeCreator ) {
  *			|- c                                          |- c
  *
  * The wrapping node depends on passed parameter. If {@link module:engine/view/element~Element} was passed, it will be cloned and
- * the copy will become the wrapping element. If `Function` is provided, it is passed all the parameters of the
+ * the copy will become the wrapping element. If `Function` is provided, it is passed attribute value and then all the parameters of the
  * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addAttribute addAttribute event}.
  * It's expected that the function returns a {@link module:engine/view/element~Element}.
  * The result of the function will be the wrapping element.
@@ -202,13 +201,13 @@ export function removeAttribute( attributeCreator ) {
  * The converter automatically consumes corresponding value from consumables list, stops the event (see
  * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}).
  *
- *		modelDispatcher.on( 'addAttribute:bold', wrap( new ViewElement( 'strong' ) ) );
+ *		modelDispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
  *
  * @param {module:engine/view/element~Element|Function} elementCreator View element, or function returning a view element, which will
  * be used for wrapping.
  * @returns {Function} Set/change attribute converter.
  */
-export function wrap( elementCreator ) {
+export function wrapItem( elementCreator ) {
 	return ( evt, data, consumable, conversionApi ) => {
 		let viewRange = conversionApi.mapper.toViewRange( data.range );
 
@@ -226,7 +225,7 @@ export function wrap( elementCreator ) {
 			// view element basing on old value and unwrap it before wrapping with a newly created view element.
 			if ( data.attributeOldValue !== null && !( elementCreator instanceof ViewElement ) ) {
 				const oldViewElement = elementCreator( data.attributeOldValue, data, consumable, conversionApi );
-				viewRange = viewWriter.unwrap( viewRange, oldViewElement, evt.priority );
+				viewRange = viewWriter.unwrap( viewRange, oldViewElement );
 			}
 
 			viewWriter.wrap( viewRange, viewElement );
@@ -249,14 +248,14 @@ export function wrap( elementCreator ) {
  * The converter automatically consumes corresponding value from consumables list, stops the event (see
  * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}) and bind model and view elements.
  *
- *		modelDispatcher.on( 'removeAttribute:bold', unwrap( new ViewElement( 'strong' ) ) );
+ *		modelDispatcher.on( 'removeAttribute:bold', unwrapItem( new ViewAttributeElement( 'strong' ) ) );
  *
- * @see module:engine/conversion/model-to-view-converters~wrap
+ * @see module:engine/conversion/model-to-view-converters~wrapItem
  * @param {module:engine/view/element~Element|Function} elementCreator View element, or function returning a view element, which will
  * be used for unwrapping.
  * @returns {Function} Remove attribute converter.
  */
-export function unwrap( elementCreator ) {
+export function unwrapItem( elementCreator ) {
 	return ( evt, data, consumable, conversionApi ) => {
 		if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
 			return;
@@ -271,6 +270,133 @@ export function unwrap( 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 viewRange = conversionApi.mapper.toViewRange( data.range );
+
+		const viewElement = ( elementCreator instanceof ViewElement ) ?
+			elementCreator.clone( true ) :
+			elementCreator( data, consumable, conversionApi );
+
+		if ( viewElement ) {
+			if ( !consumable.consume( data.range, 'addMarker' ) ) {
+				return;
+			}
+
+			if ( viewRange.isCollapsed ) {
+				viewWriter.wrapPosition( viewRange.start, viewElement );
+			} else {
+				const flatViewRanges = viewWriter.breakViewRangePerContainer( viewRange );
+
+				for ( let 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 ) => {
+		if ( !consumable.consume( data.range, 'removeMarker' ) ) {
+			return;
+		}
+
+		const viewElement = ( elementCreator instanceof ViewElement ) ?
+			elementCreator.clone( true ) :
+			elementCreator( data, consumable, conversionApi );
+
+		if ( data.range.isCollapsed ) {
+			const modelStart = data.range.start;
+			let viewStart = conversionApi.mapper.toViewPosition( modelStart );
+
+			if ( viewStart.parent instanceof ViewText ) {
+				viewStart = ViewPosition.createAfter( viewStart.parent );
+			}
+
+			const viewRange = enlargeViewPosition( viewStart, conversionApi.mapper );
+
+			viewWriter.unwrap( viewRange, viewElement );
+		} else {
+			const viewRange = conversionApi.mapper.toViewRange( data.range );
+			const flatViewRanges = viewWriter.breakViewRangePerContainer( viewRange );
+
+			for ( let range of flatViewRanges ) {
+				viewWriter.unwrap( range, viewElement );
+			}
+		}
+	};
+}
+
+// Takes given `viewPosition` and returns a widest possible range that contains this position and all view elements
+// before that position and after that position which has zero length in model (in most cases empty `ViewAttributeElement`s).
+// @param {module:engine/view/position~Position} viewPosition Position to start from when looking for furthest zero length position.
+// @param {module:engine/conversion/mapper~Mapper} mapper Mapper to use when looking for furthest zero length position.
+// @returns {module:engine/view/range~Range}
+function enlargeViewPosition( viewPosition, mapper ) {
+	const start = ViewPosition.createFromPosition( viewPosition );
+	const end = ViewPosition.createFromPosition( viewPosition );
+
+	while ( end.nodeAfter && mapper.getModelLength( end.nodeAfter ) === 0 ) {
+		end.offset++;
+	}
+
+	return new ViewRange( start, end );
+}
+
 /**
  * Function factory, creates a default model-to-view converter for nodes move changes.
  *
@@ -284,16 +410,8 @@ export function move() {
 			return;
 		}
 
-		let sourceViewRange;
-
-		if ( data.item instanceof ModelElement ) {
-			const viewElement = conversionApi.mapper.toViewElement( data.item );
-			sourceViewRange = ViewRange.createOn( viewElement );
-		} else {
-			const viewPosition = conversionApi.mapper.toViewPosition( data.sourcePosition );
-
-			sourceViewRange = findViewTextRange( viewPosition, data.item.offsetSize );
-		}
+		const modelRange = ModelRange.createFromPositionAndShift( data.sourcePosition, data.item.offsetSize );
+		const sourceViewRange = conversionApi.mapper.toViewRange( modelRange );
 
 		const targetViewPosition = conversionApi.mapper.toViewPosition( data.targetPosition );
 
@@ -314,42 +432,14 @@ export function remove() {
 			return;
 		}
 
-		let viewRange = null;
-
-		if ( data.item instanceof ModelElement ) {
-			const viewElement = conversionApi.mapper.toViewElement( data.item );
-			viewRange = ViewRange.createOn( viewElement );
-		} else {
-			const viewPosition = conversionApi.mapper.toViewPosition( data.sourcePosition );
-
-			viewRange = findViewTextRange( viewPosition, data.item.offsetSize );
-		}
+		const modelRange = ModelRange.createFromPositionAndShift( data.sourcePosition, data.item.offsetSize );
+		const viewRange = conversionApi.mapper.toViewRange( modelRange );
 
 		viewWriter.remove( viewRange );
 		conversionApi.mapper.unbindModelElement( data.item );
 	};
 }
 
-// Helper function for `remove` and `move` converters. It returns a ViewRange that starts at ViewPosition `start` and
-// includes `size` characters.
-// This method is used to find a ViewRange basing on ModelPosition and ModelTextProxy item size in `move` and `remove`
-// converters where it is impossible to just map positions because those positions already are invalid in model
-// (because they got moved or removed).
-function findViewTextRange( start, size ) {
-	const walker = new ViewTreeWalker( { startPosition: start, singleCharacters: true, ignoreElementEnd: true } );
-	let offset = 0;
-
-	for ( let value of walker ) {
-		if ( value.item instanceof ViewTextProxy ) {
-			offset++;
-
-			if ( offset == size ) {
-				return new ViewRange( start, walker.position );
-			}
-		}
-	}
-}
-
 /**
  * Function factory, creates default model-to-view converter for elements which name has changed.
  *
@@ -401,6 +491,53 @@ export function rename() {
 	};
 }
 
+/**
+ * Function factory, creates a default converter for inserting {@link module:engine/model/item~Item model item} into a marker range.
+ *
+ *		modelDispatcher.on( 'insert', insertIntoRange( modelDocument.markers ) );
+ *
+ * @param {module:engine/model/markerscollection~MarkersCollection} markersCollection Markers collection to check when
+ * inserting.
+ * @returns {Function}
+ */
+export function insertIntoMarker( markersCollection ) {
+	return ( evt, data, consumable, conversionApi ) => {
+		for ( let [ name, range ] of markersCollection ) {
+			if ( range.containsPosition( data.range.start ) ) {
+				conversionApi.dispatcher.convertMarker( 'addMarker', name, data.range );
+			}
+		}
+	};
+}
+
+/**
+ * Function factory, creates a default converter for move changes that move {@link module:engine/model/item~Item model items}
+ * into or outside of a marker range.
+ *
+ *		modelDispatcher.on( 'move', moveInOutOfMarker( modelDocument.markers ) );
+ *
+ * @param {module:engine/model/markerscollection~MarkersCollection} markersCollection Markers collection to check when
+ * moving.
+ * @returns {Function}
+ */
+export function moveInOutOfMarker( markersCollection ) {
+	return ( evt, data, consumable, conversionApi ) => {
+		const sourcePos = data.sourcePosition._getTransformedByInsertion( data.targetPosition, data.item.offsetSize );
+		const movedRange = ModelRange.createOn( data.item );
+
+		for ( let [ name, range ] of markersCollection ) {
+			const wasInMarker = range.containsPosition( sourcePos ) || range.start.isEqual( sourcePos ) || range.end.isEqual( sourcePos );
+			const common = movedRange.getIntersection( range );
+
+			if ( wasInMarker && common === null ) {
+				conversionApi.dispatcher.convertMarker( 'removeMarker', name, movedRange );
+			} else if ( common !== null ) {
+				conversionApi.dispatcher.convertMarker( 'addMarker', name, common );
+			}
+		}
+	};
+}
+
 /**
  * Returns the consumable type that is to be consumed in an event, basing on that event name.
  *

+ 12 - 5
packages/ckeditor5-engine/src/conversion/modelconsumable.js

@@ -121,8 +121,10 @@ export default class ModelConsumable {
 	 *		modelConsumable.add( modelElement, 'removeAttribute:bold' ); // Add `bold` attribute removal on `modelElement` change.
 	 *		modelConsumable.add( modelSelection, 'selection' ); // Add `modelSelection` to consumable values.
 	 *		modelConsumable.add( modelSelection, 'selectionAttribute:bold' ); // Add `bold` attribute on `modelSelection` to consumables.
+	 *		modelConsumable.add( modelRange, 'range' ); // Add `modelRange` to consumable values.
 	 *
-	 * @param {module:engine/model/item~Item|module:engine/model/selection~Selection} item Model item or selection that has the consumable.
+	 * @param {module:engine/model/item~Item|module:engine/model/selection~Selection|module:engine/model/range~Range} item
+	 * Model item, range or selection that has the consumable.
 	 * @param {String} type Consumable type.
 	 */
 	add( item, type ) {
@@ -145,9 +147,10 @@ export default class ModelConsumable {
 	 *		modelConsumable.consume( modelElement, 'removeAttribute:bold' ); // Remove `bold` attribute removal on `modelElement` change.
 	 *		modelConsumable.consume( modelSelection, 'selection' ); // Remove `modelSelection` from consumable values.
 	 *		modelConsumable.consume( modelSelection, 'selectionAttribute:bold' ); // Remove `bold` on `modelSelection` from consumables.
+	 *		modelConsumable.consume( modelRange, 'range' ); // Remove 'modelRange' from consumable values.
 	 *
-	 * @param {module:engine/model/item~Item|module:engine/model/selection~Selection} item
-	 * Model item or selection from which consumable will be consumed.
+	 * @param {module:engine/model/item~Item|module:engine/model/selection~Selection|module:engine/model/range~Range} item
+	 * Model item, range or selection from which consumable will be consumed.
 	 * @param {String} type Consumable type.
 	 * @returns {Boolean} `true` if consumable value was available and was consumed, `false` otherwise.
 	 */
@@ -173,8 +176,10 @@ export default class ModelConsumable {
 	 *		modelConsumable.test( modelElement, 'removeAttribute:bold' ); // Check for `bold` attribute removal on `modelElement` change.
 	 *		modelConsumable.test( modelSelection, 'selection' ); // Check if `modelSelection` is consumable.
 	 *		modelConsumable.test( modelSelection, 'selectionAttribute:bold' ); // Check if `bold` on `modelSelection` is consumable.
+	 *		modelConsumable.test( modelRange, 'range' ); // Check if `modelRange` is consumable.
 	 *
-	 * @param {module:engine/model/item~Item|module:engine/model/selection~Selection} item Model item or selection that will be tested.
+	 * @param {module:engine/model/item~Item|module:engine/model/selection~Selection|module:engine/model/range~Range} item
+	 * Model item, range or selection to be tested.
 	 * @param {String} type Consumable type.
 	 * @returns {null|Boolean} `null` if such consumable was never added, `false` if the consumable values was
 	 * already consumed or `true` if it was added and not consumed yet.
@@ -207,8 +212,10 @@ export default class ModelConsumable {
 	 *		modelConsumable.revert( modelElement, 'removeAttribute:bold' ); // Revert consuming `bold` attribute remove from `modelElement`.
 	 *		modelConsumable.revert( modelSelection, 'selection' ); // Revert consuming `modelSelection`.
 	 *		modelConsumable.revert( modelSelection, 'selectionAttribute:bold' ); // Revert consuming `bold` from `modelSelection`.
+	 *		modelConsumable.revert( modelRange, 'range' ); // Revert consuming `modelRange`.
 	 *
-	 * @param {module:engine/model/item~Item|module:engine/model/selection~Selection} item Model item or selection that will be reverted.
+	 * @param {module:engine/model/item~Item|module:engine/model/selection~Selection|module:engine/model/range~Range} item
+	 * Model item, range or selection to be reverted.
 	 * @param {String} type Consumable type.
 	 * @returns {null|Boolean} `true` if consumable has been reversed, `false` otherwise. `null` if the consumable has
 	 * never been added.

+ 68 - 3
packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js

@@ -216,15 +216,15 @@ export default class ModelConversionDispatcher {
 		const consumable = this._createConsumableForRange( range, 'move' );
 
 		const items = Array.from( range.getItems( { shallow: true } ) );
-		const rangeSize = range.end.offset - range.start.offset;
 		const inSameParent = sourcePosition.parent == range.start.parent;
+		const targetsAfter = range.start.isAfter( sourcePosition );
 
 		let offset = 0;
 
 		for ( let item of items ) {
 			const data = {
 				sourcePosition: sourcePosition,
-				targetPosition: inSameParent ? range.start.getShiftedBy( rangeSize ) : range.start.getShiftedBy( offset ),
+				targetPosition: inSameParent && targetsAfter ? range.end : range.start.getShiftedBy( offset ),
 				item: item
 			};
 
@@ -328,6 +328,22 @@ export default class ModelConversionDispatcher {
 		}
 	}
 
+	/**
+	 * Fires event for given marker change.
+	 *
+	 * @fires addMarker
+	 * @fires removeMarker
+	 * @param {String} type Change type.
+	 * @param {String} name Marker name.
+	 * @param {module:engine/model/range~Range} range Marker range.
+	 */
+	convertMarker( type, name, range ) {
+		const consumable = this._createMarkerConsumable( type, range );
+		const data = { name, range };
+
+		this.fire( type + ':' + name, data, consumable, this.conversionApi );
+	}
+
 	/**
 	 * Creates {@link module:engine/conversion/modelconsumable~ModelConsumable} with values to consume from given range, assuming that
 	 * given range has just been inserted to the model.
@@ -353,7 +369,8 @@ export default class ModelConversionDispatcher {
 	}
 
 	/**
-	 * Creates {@link module:engine/conversion/modelconsumable~ModelConsumable} with values of given `type` for each item from given `range`.
+	 * Creates {@link module:engine/conversion/modelconsumable~ModelConsumable} with values of given `type`
+	 * for each item from given `range`.
 	 *
 	 * @private
 	 * @param {module:engine/model/range~Range} range Affected range.
@@ -389,6 +406,22 @@ export default class ModelConversionDispatcher {
 		return consumable;
 	}
 
+	/**
+	 * 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.
 	 *
@@ -555,6 +588,38 @@ export default class ModelConversionDispatcher {
 	 * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
 	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
 	 */
+
+	/**
+	 * Fired when a new marker is added to the model.
+	 *
+	 * `addMarker` is a namespace for a class of events. Names of actually called events follow this pattern:
+	 * `addMarker:<markerName>`. By specifying certain marker names, you can make the events even more gradual. For example,
+	 * markers can be named `foo:abc`, `foo:bar`, then it is possible to listen to `addMarker:foo` or `addMarker:foo:abc` and
+	 * `addMarker:foo:bar` events.
+	 *
+	 * @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 {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
+	 */
+
+	/**
+	 * Fired when marker is removed from the model.
+	 *
+	 * `removeMarker` is a namespace for a class of events. Names of actually called events follow this pattern:
+	 * `removeMarker:<markerName>`. By specifying certain marker names, you can make the events even more gradual. For example,
+	 * markers can be named `foo:abc`, `foo:bar`, then it is possible to listen to `removeMarker:foo` or `removeMarker:foo:abc` and
+	 * `removeMarker:foo:bar` events.
+	 *
+	 * @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 {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
+	 */
 }
 
 mix( ModelConversionDispatcher, EmitterMixin );

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

@@ -36,7 +36,7 @@ import {
 	convertCollapsedSelection,
 	convertSelectionAttribute
 } from '../conversion/model-selection-to-view-converters.js';
-import { insertText, insertElement, wrap } from '../conversion/model-to-view-converters.js';
+import { insertText, insertElement, wrapItem } from '../conversion/model-to-view-converters.js';
 import isPlainObject from '../../utils/lib/lodash/isPlainObject.js';
 
 /**
@@ -214,7 +214,7 @@ export function stringify( node, selectionOrPositionOrRange = null ) {
 	mapper.bindElements( node.root, viewDocumentFragment );
 
 	modelToView.on( 'insert:$text', insertText() );
-	modelToView.on( 'addAttribute', wrap( ( value, data ) => {
+	modelToView.on( 'addAttribute', wrapItem( ( value, data ) => {
 		if ( data.item instanceof ModelTextProxy ) {
 			return new ViewAttributeElement( 'model-text-with-attributes', { [ data.attributeKey ]: stringifyAttributeValue( value ) } );
 		}

+ 10 - 1
packages/ckeditor5-engine/src/model/document.js

@@ -19,6 +19,7 @@ import History from './history.js';
 import LiveSelection from './liveselection.js';
 import Schema from './schema.js';
 import TreeWalker from './treewalker.js';
+import MarkersCollection from './markerscollection.js';
 import clone from '../../utils/lib/lodash/clone.js';
 import EmitterMixin from '../../utils/emittermixin.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
@@ -75,13 +76,21 @@ export default class Document {
 		/**
 		 * Document's history.
 		 *
-		 * **Note:** Be aware that deltas applied to the stored deltas might be removed or changed.
+		 * **Note:** Be aware that deltas applied to the document might get removed or changed.
 		 *
 		 * @readonly
 		 * @member {module:engine/model/history~History}
 		 */
 		this.history = new History( this );
 
+		/**
+		 * Document's markers' collection.
+		 *
+		 * @readonly
+		 * @member {module:engine/model/markerscollection~MarkersCollection}
+		 */
+		this.markers = new MarkersCollection();
+
 		/**
 		 * Array of pending changes. See: {@link #enqueueChanges}.
 		 *

+ 34 - 44
packages/ckeditor5-engine/src/model/liverange.js

@@ -95,7 +95,7 @@ export default class LiveRange extends Range {
  */
 function bindWithDocument() {
 	/*jshint validthis: true */
-	// Operation types handled by LiveRange (these are operations that change model tree structure).
+	// Operation types that a range can be transformed by.
 	const supportedTypes = new Set( [ 'insert', 'move', 'remove', 'reinsert' ] );
 
 	this.listenTo(
@@ -116,55 +116,45 @@ function bindWithDocument() {
  * @ignore
  * @private
  * @method transform
- * @param {String} type Type of changes applied to the Tree Model.
- * @param {module:engine/model/range~Range} range Range containing the result of applied change.
- * @param {module:engine/model/position~Position} [position] Additional position parameter provided by some change events.
+ * @param {String} type Type of changes applied to the model document.
+ * @param {module:engine/model/range~Range} targetRange Range containing the result of applied change.
+ * @param {module:engine/model/position~Position} [sourcePosition] Source position for move, remove and reinsert change types.
  */
-function transform( type, range, position ) {
+function transform( type, targetRange, sourcePosition ) {
 	/* jshint validthis: true */
-	let updated;
-	const howMany = range.end.offset - range.start.offset;
-
-	switch ( type ) {
-		case 'insert':
-			updated = this._getTransformedByInsertion( range.start, howMany, false, true )[ 0 ];
-			break;
-
-		case 'move':
-		case 'remove':
-		case 'reinsert':
-			const sourcePosition = position;
-
-			// Range._getTransformedByMove is expecting `targetPosition` to be "before" move
-			// (before transformation). `range.start` is already after the move happened.
-			// We have to revert `range.start` to the state before the move.
-			const targetPosition = range.start._getTransformedByInsertion( sourcePosition, howMany );
-
-			const result = this._getTransformedByMove( sourcePosition, targetPosition, howMany, false, true );
-
-			// First item in the array is the "difference" part, so a part of the range
-			// that did not get moved. We use it as reference range and expand if possible.
-			updated = result[ 0 ];
-
-			// We will check if there is other range and if it is touching the reference range.
-			// If it does, we will expand the reference range (at the beginning or at the end).
-			// Keep in mind that without settings `spread` flag, `_getTransformedByMove` may
-			// return maximum two ranges.
-			if ( result.length > 1 ) {
-				let otherRange = result[ 1 ];
-
-				if ( updated.start.isTouching( otherRange.end ) ) {
-					updated.start = otherRange.start;
-				} else if ( updated.end.isTouching( otherRange.start ) ) {
-					updated.end = otherRange.end;
-				}
-			}
+	const howMany = targetRange.end.offset - targetRange.start.offset;
+	let targetPosition = targetRange.start;
+
+	if ( type == 'move' ) {
+		// Range._getTransformedByDocumentChange is expecting `targetPosition` to be "before" move
+		// (before transformation). `targetRange.start` is already after the move happened.
+		// We have to revert `targetPosition` to the state before the move.
+		targetPosition = targetPosition._getTransformedByInsertion( sourcePosition, howMany );
+	}
 
-			break;
+	const result = this._getTransformedByDocumentChange( type, targetPosition, howMany, sourcePosition );
+
+	// Decide whether inserted part should be included in the range.
+	// This extra step is needed only for `move` change type and only if the ranges have common part.
+	// If ranges are not intersecting and `targetRange` is moved to this range, it is included (in `_getTransformedByDocumentChange`).
+	//
+	// If ranges are intersecting, `result` contains spread range. `targetRange` need to be included if it is moved
+	// to this range, but only if it is moved to a position that is not touching this range boundary.
+	//
+	// First, this concerns only `move` change, because insert change includes inserted part always (type == 'move').
+	// Second, this is a case only if moved range was intersecting with this range and was inserted into this range (result.length == 3).
+	// Third, we check range `result[ 1 ]`, that is the range created by splitting this range by inserting `targetRange`.
+	// If that range is not empty, it means that we are in fact inserting inside `targetRange`.
+	if ( type == 'move' && result.length == 3 && !result[ 1 ].isEmpty ) {
+		// `result[ 2 ]` is a "common part" of this range and moved range. We substitute that common part with the whole
+		// `targetRange` because we want to include whole `targetRange` in this range.
+		result[ 2 ] = targetRange;
 	}
 
+	const updated = Range.createFromRanges( result );
+
 	// If anything changed, update the range and fire an event.
-	if ( !updated.start.isEqual( this.start ) || !updated.end.isEqual( this.end ) ) {
+	if ( !updated.isEqual( this ) ) {
 		const oldRange = Range.createFromRange( this );
 
 		this.start = updated.start;

+ 154 - 0
packages/ckeditor5-engine/src/model/markerscollection.js

@@ -0,0 +1,154 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import LiveRange from './liverange.js';
+import Range from './range.js';
+import EmitterMixin from '../../utils/emittermixin.js';
+import CKEditorError from '../../utils/ckeditorerror.js';
+import mix from '../../utils/mix.js';
+
+/**
+ * Manages and stores markers.
+ *
+ * Markers are simply {@link module:engine/model/liverange~LiveRange live ranges} that were added to `MarkersCollection`.
+ * Markers are used to represent information connected with model document. In contrary to
+ * {@link module:engine/model/node~Node nodes}, which are bits of data, markers are marking a part of model document.
+ * Each live range is added with `name` parameter. Name is used to group and identify markers. Names have to be unique, but
+ * markers can be grouped by using common prefixes, separated with `:`, for example: `user:john` or `search:3`.
+ *
+ * Whenever live range is added or removed from `MarkersCollection`,
+ * {@link module:engine/model/markerscollection~MarkersCollection#event:addMarker addMarker event} and
+ * {@link module:engine/model/markerscollection~MarkersCollection#event:addMarker removeMarker event} are fired.
+ *
+ * Markers can be converted to view by adding appropriate converters for
+ * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addMarker addMarker} and
+ * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:removeMarker removeMarker}
+ * events, or by building converters for {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}
+ * using {@link module:engine/conversion/buildmodelconverter~buildModelConverter model converter builder}.
+ *
+ * Markers are similar to adding and converting attributes on nodes. The difference is that attribute is connected to
+ * a given node (e.g. a character is bold no matter if it gets moved or content around it changes). Markers on the
+ * other hand are continuous ranges (e.g. if a character from inside of marker range is moved somewhere else, marker
+ * range is shrunk and the character does not have any attribute or information that it was in the marked range). Another
+ * upside of markers is that finding marked text is fast and easy. Using attributes to mark some nodes and then trying to
+ * find that part of document would require traversing whole document tree. For markers, only marker name is needed
+ * and a proper range can {@link module:engine/model/markerscollection~MarkersCollection#get be obtained} from the collection.
+ */
+export default class MarkersCollection {
+	/**
+	 * Creates a markers collection.
+	 */
+	constructor() {
+		/**
+		 * Stores marker name to range bindings for added ranges.
+		 *
+		 * @private
+		 * @member {Map} #_nameToRange
+		 */
+		this._nameToRange = new Map();
+	}
+
+	/**
+	 * Returns an iterator that iterates over all markers added to the collection. Each item returned by the iterator is an array
+	 * containing two elements, first is a marker {String name} and second is a marker {@link module:engine/model/range~Range range}.
+	 *
+	 * @returns {Iterator}
+	 */
+	[ Symbol.iterator ]() {
+		return this._nameToRange.entries();
+	}
+
+	/**
+	 * Sets a name for given live range and adds it to the markers collection.
+	 *
+	 * Throws, if given `markerName` was already used.
+	 *
+	 * Throws, if given `liveRange` is not an instance of {@link module:engine/model/liverange~LiveRange}.
+	 *
+	 * @param {String} markerName Name to be associated with given `liveRange`.
+	 * @param {module:engine/model/liverange~LiveRange} liveRange Live range to be added as a marker to markers collection.
+	 */
+	add( markerName, liveRange ) {
+		if ( this._nameToRange.has( markerName ) ) {
+			/**
+			 * Marker with given name is already added.
+			 *
+			 * @error markers-collection-add-name-exists
+			 */
+			throw new CKEditorError( 'markers-collection-add-name-exists: Marker with given name is already added.' );
+		}
+
+		if ( !( liveRange instanceof LiveRange ) ) {
+			/**
+			 * Added range is not an instance of LiveRange.
+			 *
+			 * @error markers-collection-add-range-not-live-range
+			 */
+			throw new CKEditorError( 'markers-collection-add-range-not-live-range: Added range is not an instance of LiveRange.' );
+		}
+
+		this._nameToRange.set( markerName, liveRange );
+		this.fire( 'add', markerName, Range.createFromRange( liveRange ) );
+	}
+
+	/**
+	 * Returns the live range that was added to `MarkersCollection` under given `markerName`.
+	 *
+	 * @param {String} markerName Name of range to get.
+	 * @returns {module:engine/model/liverange~LiveRange|null} Range added to collection under given name or `null` if
+	 * no range was added with that name.
+	 */
+	get( markerName ) {
+		return this._nameToRange.get( markerName ) || null;
+	}
+
+	/**
+	 * Removes a live range having given `name` from markers collection.
+	 *
+	 * @param {String} name Name of live range to remove.
+	 * @returns {Boolean} `true` is passed if range was found and removed from the markers collection, `false` otherwise.
+	 */
+	remove( name ) {
+		const range = this._nameToRange.get( name );
+
+		if ( range ) {
+			this._nameToRange.delete( name );
+			this.fire( 'remove', name, Range.createFromRange( range ) );
+
+			return true;
+		}
+
+		return false;
+	}
+
+	/**
+	 * Substitutes range having given `name`, that was already added to the markers collection, with given `newLiveRange`.
+	 *
+	 * This method is basically a wrapper for using {@link module:engine/model/markerscollection~MarkersCollection#removeRange removeRange}
+	 * followed by using {@link module:engine/model/markerscollection~MarkersCollection#addRange addRange}.
+	 *
+	 * @param {String} name Name of a range to be changed.
+	 * @param {module:engine/model/liverange~LiveRange} newLiveRange Live range to be added.
+	 * @returns {Boolean} `true` if range for given `name` was found and changed, `false` otherwise.
+	 */
+	update( name, newLiveRange ) {
+		const removed = this.remove( name );
+
+		if ( removed ) {
+			this.add( name, newLiveRange );
+		}
+
+		return removed;
+	}
+
+	/**
+	 * Destroys markers collection.
+	 */
+	destroy() {
+		this.stopListening();
+	}
+}
+
+mix( MarkersCollection, EmitterMixin );

+ 131 - 16
packages/ckeditor5-engine/src/model/range.js

@@ -9,6 +9,7 @@
 
 import Position from './position.js';
 import TreeWalker from './treewalker.js';
+import CKEditorError from '../../utils/ckeditorerror.js';
 
 /**
  * Range class. Range is iterable.
@@ -368,6 +369,56 @@ export default class Range {
 		}
 	}
 
+	/**
+	 * Returns a range that is a result of transforming this range by given `delta`.
+	 *
+	 * @param {module:engine/model/delta~Delta} delta Delta to transform range by.
+	 * @returns {Array.<module:engine/model/range~Range>} Range which is the result of transformation.
+	 */
+	getTransformedByDelta( delta ) {
+		let ranges = [ Range.createFromRange( this ) ];
+
+		// Operation types that a range can be transformed by.
+		const supportedTypes = new Set( [ 'insert', 'move', 'remove', 'reinsert' ] );
+
+		for ( let operation of delta.operations ) {
+			if ( supportedTypes.has( operation.type ) ) {
+				for ( let i = 0; i < ranges.length; i++ ) {
+					const result = ranges[ i ]._getTransformedByDocumentChange(
+						operation.type,
+						operation.targetPosition || operation.position,
+						operation.howMany || operation.nodes.maxOffset,
+						operation.sourcePosition
+					);
+
+					ranges.splice( i, 1, ...result );
+
+					i += result.length - 1;
+				}
+			}
+		}
+
+		return ranges;
+	}
+
+	/**
+	 * Returns a range that is a result of transforming this range by a change in the model document.
+	 *
+	 * @protected
+	 * @param {'insert'|'move'|'remove'|'reinsert'} type Change type.
+	 * @param {module:engine/model/position~Position} targetPosition Position before the first changed node.
+	 * @param {Number} howMany How many nodes has been changed.
+	 * @param {module:engine/model/position~Position} sourcePosition Source position of changes.
+	 * @returns {Array.<module:engine/model/range~Range>}
+	 */
+	_getTransformedByDocumentChange( type, targetPosition, howMany, sourcePosition ) {
+		if ( type == 'insert' ) {
+			return this._getTransformedByInsertion( targetPosition, howMany, false, false );
+		} else {
+			return this._getTransformedByMove( sourcePosition, targetPosition, howMany );
+		}
+	}
+
 	/**
 	 * Returns an array containing one or two {@link ~Range ranges} that are a result of transforming this
 	 * {@link ~Range range} by inserting `howMany` nodes at `insertPosition`. Two {@link ~Range ranges} are
@@ -419,8 +470,8 @@ export default class Range {
 		} else {
 			const range = Range.createFromRange( this );
 
-			let insertBeforeStart = range.isCollapsed ? isSticky : !isSticky;
-			let insertBeforeEnd = isSticky;
+			let insertBeforeStart = range.isCollapsed ? true : !isSticky;
+			let insertBeforeEnd = range.isCollapsed ? true : isSticky;
 
 			range.start = range.start._getTransformedByInsertion( insertPosition, howMany, insertBeforeStart );
 			range.end = range.end._getTransformedByInsertion( insertPosition, howMany, insertBeforeEnd );
@@ -441,7 +492,7 @@ export default class Range {
 	 * was inside the range. Defaults to `false`.
 	 * @returns {Array.<module:engine/model/range~Range>} Result of the transformation.
 	 */
-	_getTransformedByMove( sourcePosition, targetPosition, howMany, spread, isSticky = false ) {
+	_getTransformedByMove( sourcePosition, targetPosition, howMany ) {
 		if ( this.isCollapsed ) {
 			const newPos = this.start._getTransformedByMove( sourcePosition, targetPosition, howMany, true, true );
 
@@ -453,38 +504,33 @@ export default class Range {
 		const moveRange = new Range( sourcePosition, sourcePosition.getShiftedBy( howMany ) );
 
 		const differenceSet = this.getDifference( moveRange );
-		let difference;
+		let difference = null;
+
+		const common = this.getIntersection( moveRange );
 
 		if ( differenceSet.length == 1 ) {
+			// `moveRange` and this range intersects.
 			difference = new Range(
 				differenceSet[ 0 ].start._getTransformedByDeletion( sourcePosition, howMany ),
 				differenceSet[ 0 ].end._getTransformedByDeletion( sourcePosition, howMany )
 			);
 		} else if ( differenceSet.length == 2 ) {
-			// This means that ranges were moved from the inside of this range.
-			// So we can operate on this range positions and we don't have to transform starting position.
+			// `moveRange` is inside this range.
 			difference = new Range(
 				this.start,
 				this.end._getTransformedByDeletion( sourcePosition, howMany )
 			);
-		} else {
-			// 0.
-			difference = null;
-		}
+		} // else, `moveRange` wholly contains this range.
 
 		const insertPosition = targetPosition._getTransformedByDeletion( sourcePosition, howMany );
 
 		if ( difference ) {
-			result = difference._getTransformedByInsertion( insertPosition, howMany, spread, isSticky );
+			result = difference._getTransformedByInsertion( insertPosition, howMany, common !== null );
 		} else {
 			result = [];
 		}
 
-		const common = this.getIntersection( moveRange );
-
-		// Add common part of the range only if there is any and only if it is not
-		// already included in `difference` part.
-		if ( common && ( spread || difference === null || !difference.containsPosition( insertPosition ) ) ) {
+		if ( common ) {
 			result.push( new Range(
 				common.start._getCombined( moveRange.start, insertPosition ),
 				common.end._getCombined( moveRange.start, insertPosition )
@@ -556,6 +602,75 @@ export default class Range {
 		return this.createFromPositionAndShift( Position.createBefore( item ), item.offsetSize );
 	}
 
+	/**
+	 * Combines all ranges from the passed array into a one range. At least one range has to be passed.
+	 * Passed ranges must not have common parts.
+	 *
+	 * The first range from the array is a reference range. If other ranges
+	 * {@link module:engine/model/position~Position#isTouching are touching} the reference range, they will get combined into one range.
+	 *
+	 *		[  ][]  [    ][ ][  ref range  ][ ][]  [  ]  // Passed ranges, shown sorted. "Ref range" was the first range in original array.
+	 *		        [      returned range       ]  [  ]  // The combined range.
+	 *		[    ]                                       // The result of the function if the first range was a reference range.
+	 *	            [                           ]        // The result of the function if the third-to-seventh range was a reference range.
+	 *	                                           [  ]  // The result of the function if the last range was a reference range.
+	 *
+	 * @param {Array.<module:engine/model/range~Range>} ranges Ranges to combine.
+	 * @returns {module:engine/model/range~Range} Combined range.
+	 */
+	static createFromRanges( ranges ) {
+		if ( ranges.length === 0 ) {
+			/**
+			 * At least one range has to be passed.
+			 *
+			 * @error range-create-from-ranges-empty-array
+			 */
+			throw new CKEditorError( 'range-create-from-ranges-empty-array: At least one range has to be passed.' );
+		} else if ( ranges.length == 1 ) {
+			return Range.createFromRange( ranges[ 0 ] );
+		}
+
+		// 1. Set the first range in `ranges` array as a reference range.
+		// If we are going to return just a one range, one of the ranges need to be the reference one.
+		// Other ranges will be stuck to that range, if possible.
+		const ref = ranges[ 0 ];
+
+		// 2. Sort all the ranges so it's easier to process them.
+		ranges.sort( ( a, b ) => a.start.isAfter( b.start ) );
+
+		// 3. Check at which index the reference range is now.
+		const refIndex = ranges.indexOf( ref );
+
+		// 4. At this moment we don't need the original range.
+		// We are going to modify the result and we need to return a new instance of Range.
+		// We have to create a copy of the reference range.
+		const result = new this( ref.start, ref.end );
+
+		// 5. Ranges before reference range should be glued starting from the "last one", that is the range
+		// that is closest to the reference range.
+		for ( let i = refIndex - 1; i >= 0; i++ ) {
+			if ( ranges[ i ].end.isTouching( result.start ) ) {
+				result.start = Position.createFromPosition( ranges[ i ].start );
+			} else {
+				// If range do not touch with reference range there is no point in looking further.
+				break;
+			}
+		}
+
+		// 5. Ranges after reference range should be glued starting from the "first one", that is the range
+		// that is closest to the reference range.
+		for ( let i = refIndex + 1; i < ranges.length; i++ ) {
+			if ( ranges[ i ].start.isTouching( result.end ) ) {
+				result.end = Position.createFromPosition( ranges[ i ].end );
+			} else {
+				// If range do not touch with reference range there is no point in looking further.
+				break;
+			}
+		}
+
+		return result;
+	}
+
 	/**
 	 * Creates a `Range` instance from given plain object (i.e. parsed JSON string).
 	 *

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

@@ -111,7 +111,7 @@ export default class Element extends Node {
 		 * Custom properties can be added to element instance, will be cloned but not rendered into DOM.
 		 *
 		 * @protected
-		 * @memeber {Map} engine.view.Element#_customProperties.
+		 * @memeber {Map}
 		 */
 		this._customProperties = new Map();
 	}

+ 50 - 6
packages/ckeditor5-engine/src/view/writer.js

@@ -13,6 +13,7 @@ import AttributeElement from './attributeelement.js';
 import EmptyElement from './emptyelement.js';
 import Text from './text.js';
 import Range from './range.js';
+import TreeWalker from './treewalker.js';
 import CKEditorError from '../../utils/ckeditorerror.js';
 import DocumentFragment from './documentfragment.js';
 import isIterable from '../../utils/isiterable.js';
@@ -34,7 +35,8 @@ const writer = {
 	wrap,
 	wrapPosition,
 	unwrap,
-	rename
+	rename,
+	breakViewRangePerContainer
 };
 
 export default writer;
@@ -270,6 +272,37 @@ export function mergeContainers( position ) {
 	return newPosition;
 }
 
+/**
+ * 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 ( let value of walker ) {
+		if ( value.item instanceof 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.
@@ -379,11 +412,22 @@ export function remove( range ) {
  * {@link module:engine/view/range~Range#start start} and {@link module:engine/view/range~Range#end end} positions.
  */
 export function move( sourceRange, targetPosition ) {
-	if ( sourceRange.start.parent == targetPosition.parent ) {
-		targetPosition.offset -= sourceRange.end.offset - sourceRange.start.offset;
-	}
+	let nodes;
+
+	if ( targetPosition.isAfter( sourceRange.end ) ) {
+		targetPosition = _breakAttributes( targetPosition, true );
 
-	const nodes = remove( sourceRange );
+		const parent = targetPosition.parent;
+		const countBefore = parent.childCount;
+
+		sourceRange = _breakAttributesRange( sourceRange, true );
+
+		nodes = remove( sourceRange );
+
+		targetPosition.offset += ( parent.childCount - countBefore );
+	} else {
+		nodes = remove( sourceRange );
+	}
 
 	return insert( targetPosition, nodes );
 }
@@ -1100,7 +1144,7 @@ function isContainerOrFragment( node ) {
 }
 
 // Checks if {@link module:engine/view/range~Range#start range start} and {@link module:engine/view/range~Range#end range end} are placed
-// inside same {@link module:engine/view/containerelement~ContainerElement container}.
+// inside same {@link module:engine/view/containerelement~ContainerElement container element}.
 // Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `view-writer-invalid-range-container` when validation fails.
 //
 // @param {module:engine/view/range~Range} range

+ 163 - 9
packages/ckeditor5-engine/tests/controller/editingcontroller.js

@@ -17,7 +17,9 @@ import buildModelConverter from 'ckeditor5/engine/conversion/buildmodelconverter
 
 import ModelDocument from 'ckeditor5/engine/model/document.js';
 import ModelPosition from 'ckeditor5/engine/model/position.js';
+import ModelElement from 'ckeditor5/engine/model/element.js';
 import ModelRange from 'ckeditor5/engine/model/range.js';
+import ModelLiveRange from 'ckeditor5/engine/model/liverange.js';
 import ModelDocumentFragment from 'ckeditor5/engine/model/documentfragment.js';
 
 import createElement from 'ckeditor5/utils/dom/createelement.js';
@@ -108,7 +110,7 @@ describe( 'EditingController', () => {
 	describe( 'conversion', () => {
 		let model, modelRoot, viewRoot, domRoot, editing, listener;
 
-		before( () => {
+		beforeEach( () => {
 			listener = Object.create( EmitterMixin );
 
 			model = new ModelDocument();
@@ -124,15 +126,7 @@ describe( 'EditingController', () => {
 			model.schema.registerItem( 'div', '$block' );
 			buildModelConverter().for( editing.modelToView ).fromElement( 'paragraph' ).toElement( 'p' );
 			buildModelConverter().for( editing.modelToView ).fromElement( 'div' ).toElement( 'div' );
-		} );
 
-		after( () => {
-			document.body.removeChild( domRoot );
-			listener.stopListening();
-			editing.destroy();
-		} );
-
-		beforeEach( () => {
 			// Note: The below code is highly overcomplicated due to #455.
 			model.selection.removeAllRanges();
 			modelRoot.removeChildren( 0, modelRoot.childCount );
@@ -153,6 +147,12 @@ describe( 'EditingController', () => {
 			} );
 		} );
 
+		afterEach( () => {
+			document.body.removeChild( domRoot );
+			listener.stopListening();
+			editing.destroy();
+		} );
+
 		it( 'should convert insertion', () => {
 			expect( getViewData( editing.view ) ).to.equal( '<p>f{}oo</p><p></p><p>bar</p>' );
 		} );
@@ -252,6 +252,160 @@ describe( 'EditingController', () => {
 
 			expect( getViewData( editing.view ) ).to.equal( '<p>foo</p><p></p><p>ba{}r</p>' );
 		} );
+
+		it( 'should forward marker events to model conversion dispatcher', () => {
+			const range = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 );
+
+			sinon.spy( editing.modelToView, 'convertMarker' );
+
+			model.markers.fire( 'add', range, 'name' );
+
+			expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', range, 'name' ) ).to.be.true;
+
+			model.markers.fire( 'remove', range, 'name' );
+
+			expect( editing.modelToView.convertMarker.calledWithExactly( 'removeMarker', range, 'name' ) ).to.be.true;
+
+			editing.modelToView.convertMarker.restore();
+		} );
+
+		it( 'should forward add marker event if content is inserted into a marker range', () => {
+			const markerRange = ModelLiveRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 );
+			const innerRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 2 );
+			const consumableMock = {
+				consume: () => true,
+				test: () => true
+			};
+
+			model.markers.add( 'name', markerRange );
+
+			sinon.spy( editing.modelToView, 'convertMarker' );
+
+			editing.modelToView.fire( 'insert', {
+				range: innerRange
+			}, consumableMock, { dispatcher: editing.modelToView } );
+
+			expect( editing.modelToView.convertMarker.calledWithExactly( 'addMarker', 'name', innerRange ) ).to.be.true;
+
+			editing.modelToView.convertMarker.restore();
+		} );
+
+		it( 'should not start marker conversion if content is not inserted into any marker range', () => {
+			const markerRange = ModelLiveRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 );
+			const insertRange = ModelRange.createFromParentsAndOffsets( modelRoot, 6, modelRoot, 8 );
+			const consumableMock = {
+				consume: () => true,
+				test: () => true
+			};
+
+			model.markers.add( 'name', markerRange );
+
+			sinon.spy( editing.modelToView, 'convertMarker' );
+
+			editing.modelToView.fire( 'insert', {
+				range: insertRange
+			}, consumableMock, { dispatcher: editing.modelToView } );
+
+			expect( editing.modelToView.convertMarker.called ).to.be.false;
+
+			editing.modelToView.convertMarker.restore();
+		} );
+
+		it( 'should forward remove marker event if part of marker range is moved - intersecting', () => {
+			const markerRange = ModelLiveRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 );
+			const consumableMock = {
+				consume: () => true,
+				test: () => true
+			};
+
+			model.markers.add( 'name', markerRange );
+
+			sinon.spy( editing.modelToView, 'convertMarker' );
+
+			editing.modelToView.fire( 'move', {
+				sourcePosition: ModelPosition.createAt( modelRoot, 1 ),
+				targetPosition: ModelPosition.createAt( modelRoot, 2 ),
+				item: modelRoot.getChild( 2 )
+			}, consumableMock, { dispatcher: editing.modelToView, mapper: editing.mapper } );
+
+			expect( editing.modelToView.convertMarker.calledWith( 'removeMarker', 'name' ) ).to.be.true;
+
+			editing.modelToView.convertMarker.restore();
+		} );
+
+		it( 'should forward remove marker event if part of marker range is moved - inside', () => {
+			model.enqueueChanges( () => {
+				model.batch().insert( ModelPosition.createAt( model.getRoot(), 'end' ), new ModelElement( 'paragraph' ) );
+			} );
+
+			const markerRange = ModelLiveRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 2 );
+			const consumableMock = {
+				consume: () => true,
+				test: () => true
+			};
+
+			model.markers.add( 'name', markerRange );
+
+			sinon.spy( editing.modelToView, 'convertMarker' );
+
+			editing.modelToView.fire( 'move', {
+				sourcePosition: ModelPosition.createAt( modelRoot, 1 ),
+				targetPosition: ModelPosition.createAt( modelRoot, 3 ),
+				item: modelRoot.getChild( 3 )
+			}, consumableMock, { dispatcher: editing.modelToView, mapper: editing.mapper } );
+
+			expect( editing.modelToView.convertMarker.calledWith( 'removeMarker', 'name' ) ).to.be.true;
+
+			editing.modelToView.convertMarker.restore();
+		} );
+
+		it( 'should forward add marker event if content is moved into a marker range', () => {
+			model.enqueueChanges( () => {
+				model.batch().insert( ModelPosition.createAt( model.getRoot(), 'end' ), new ModelElement( 'paragraph' ) );
+			} );
+
+			const markerRange = ModelLiveRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 );
+			const consumableMock = {
+				consume: () => true,
+				test: () => true
+			};
+
+			model.markers.add( 'name', markerRange );
+
+			sinon.spy( editing.modelToView, 'convertMarker' );
+
+			editing.modelToView.fire( 'move', {
+				sourcePosition: ModelPosition.createAt( modelRoot, 3 ),
+				targetPosition: ModelPosition.createAt( modelRoot, 1 ),
+				item: modelRoot.getChild( 1 )
+			}, consumableMock, { dispatcher: editing.modelToView, mapper: editing.mapper } );
+
+			expect( editing.modelToView.convertMarker.calledWith( 'addMarker', 'name' ) ).to.be.true;
+
+			editing.modelToView.convertMarker.restore();
+		} );
+
+		it( 'should not start marker conversion if moved content does not affect the marker', () => {
+			const markerRange = ModelLiveRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 1 );
+			const consumableMock = {
+				consume: () => true,
+				test: () => true
+			};
+
+			model.markers.add( 'name', markerRange );
+
+			sinon.spy( editing.modelToView, 'convertMarker' );
+
+			editing.modelToView.fire( 'move', {
+				sourcePosition: ModelPosition.createAt( modelRoot, 2 ),
+				targetPosition: ModelPosition.createAt( modelRoot, 0 ),
+				item: modelRoot.getChild( 2 )
+			}, consumableMock, { dispatcher: editing.modelToView, mapper: editing.mapper } );
+
+			expect( editing.modelToView.convertMarker.called ).to.be.false;
+
+			editing.modelToView.convertMarker.restore();
+		} );
 	} );
 
 	describe( 'destroy', () => {

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

@@ -32,8 +32,8 @@ import {
 	insertText,
 	setAttribute,
 	removeAttribute,
-	wrap,
-	unwrap,
+	wrapItem,
+	unwrapItem,
 	move,
 	remove,
 	eventNameToConsumableType
@@ -335,8 +335,8 @@ describe( 'advanced-converters', () => {
 	describe( 'custom attribute handling for given element', () => {
 		beforeEach( () => {
 			// NORMAL LINK MODEL TO VIEW CONVERTERS
-			modelDispatcher.on( 'addAttribute:linkHref', wrap( ( value ) => new ViewAttributeElement( 'a', { href: value } ) ) );
-			modelDispatcher.on( 'addAttribute:linkTitle', wrap( ( value ) => new ViewAttributeElement( 'a', { title: value } ) ) );
+			modelDispatcher.on( 'addAttribute:linkHref', wrapItem( ( value ) => new ViewAttributeElement( 'a', { href: value } ) ) );
+			modelDispatcher.on( 'addAttribute:linkTitle', wrapItem( ( value ) => new ViewAttributeElement( 'a', { title: value } ) ) );
 
 			const changeLinkAttribute = function( elementCreator ) {
 				return ( evt, data, consumable, conversionApi ) => {
@@ -365,12 +365,12 @@ describe( 'advanced-converters', () => {
 
 			modelDispatcher.on(
 				'removeAttribute:linkHref',
-				unwrap( ( value ) => new ViewAttributeElement( 'a', { href: value } ) )
+				unwrapItem( ( value ) => new ViewAttributeElement( 'a', { href: value } ) )
 			);
 
 			modelDispatcher.on(
 				'removeAttribute:linkTitle',
-				unwrap( ( value ) => new ViewAttributeElement( 'a', { title: value } ) )
+				unwrapItem( ( value ) => new ViewAttributeElement( 'a', { title: value } ) )
 			);
 
 			// NORMAL LINK VIEW TO MODEL CONVERTERS
@@ -539,9 +539,9 @@ describe( 'advanced-converters', () => {
 			let expected = '<div><blockquote>foo<a href="foo.html" title="Foo source">see source</a></blockquote></div>';
 			expect( viewToString( viewRoot ) ).to.equal( expected );
 
-			modelDispatcher.on( 'addAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
-			modelDispatcher.on( 'changeAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
-			modelDispatcher.on( 'removeAttribute:bold', unwrap( new ViewAttributeElement( 'strong' ) ) );
+			modelDispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
+			modelDispatcher.on( 'changeAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
+			modelDispatcher.on( 'removeAttribute:bold', unwrapItem( new ViewAttributeElement( 'strong' ) ) );
 
 			modelElement.appendChildren( new ModelText( 'bar', { bold: true } ) );
 			modelDispatcher.convertInsertion( ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 6 ) );
@@ -676,9 +676,9 @@ describe( 'advanced-converters', () => {
 
 			// "Real" converters -- added with higher priority. Should overwrite the "universal" converters.
 			modelDispatcher.on( 'insert:image', insertElement( new ViewContainerElement( 'img' ) ) );
-			modelDispatcher.on( 'addAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
-			modelDispatcher.on( 'changeAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
-			modelDispatcher.on( 'removeAttribute:bold', unwrap( new ViewAttributeElement( 'strong' ) ) );
+			modelDispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
+			modelDispatcher.on( 'changeAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
+			modelDispatcher.on( 'removeAttribute:bold', unwrapItem( new ViewAttributeElement( 'strong' ) ) );
 
 			viewDispatcher.on( 'element:img', ( evt, data, consumable ) => {
 				if ( consumable.consume( data.input, { name: true } ) ) {

+ 68 - 13
packages/ckeditor5-engine/tests/conversion/buildmodelconverter.js

@@ -36,6 +36,8 @@ import {
 
 import { createRangeOnElementOnly } from 'tests/engine/model/_utils/utils.js';
 
+import CKEditorError from 'ckeditor5/utils/ckeditorerror.js';
+
 function viewAttributesToString( item ) {
 	let result = '';
 
@@ -346,6 +348,68 @@ describe( 'Model converter builder', () => {
 		} );
 	} );
 
+	describe( 'model marker to view element conversion', () => {
+		let modelText, modelElement;
+
+		beforeEach( () => {
+			modelText = new ModelText( 'foobar' );
+			modelElement = new ModelElement( 'paragraph', null, [ modelText ] );
+			modelRoot.appendChildren( modelElement );
+
+			let viewText = new ViewText( 'foobar' );
+			let viewElement = new ViewContainerElement( 'p', null, [ viewText ] );
+			viewRoot.appendChildren( viewElement );
+
+			mapper.bindElements( modelElement, viewElement );
+		} );
+
+		it( 'using passed view element name', () => {
+			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( 'strong' );
+
+			dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<strong>ob</strong>ar</p></div>' );
+
+			dispatcher.convertMarker( 'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) );
+
+			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 );
+
+			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>' );
+
+			dispatcher.convertMarker( 'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) );
+
+			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 } );
+			} );
+
+			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 ) );
+
+			// 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>' );
+
+			dispatcher.convertMarker( 'removeMarker', 'search:red', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+	} );
+
 	describe( 'withPriority', () => {
 		it( 'should change default converters priority', () => {
 			buildModelConverter().for( dispatcher ).fromElement( 'custom' ).toElement( 'custom' );
@@ -360,18 +424,9 @@ describe( 'Model converter builder', () => {
 		} );
 	} );
 
-	it( 'should do nothing on model element to view attribute conversion', () => {
-		buildModelConverter().for( dispatcher ).fromElement( 'div' ).toElement( 'div' );
-		// Should do nothing:
-		buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toAttribute( 'paragraph', true );
-		// If above would do something this one would not be fired:
-		buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
-
-		let modelElement = new ModelElement( 'div', null, new ModelElement( 'paragraph', null, new ModelText( 'foobar' ) ) );
-		modelRoot.appendChildren( modelElement );
-
-		dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
-
-		expect( viewToString( viewRoot ) ).to.equal( '<div><div><p>foobar</p></div></div>' );
+	it( 'should throw when trying to build model element to view attribute converter', () => {
+		expect( () => {
+			buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toAttribute( 'paragraph', true );
+		} ).to.throw( CKEditorError, /^build-model-converter-non-attribute-to-attribute/ );
 	} );
 } );

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

@@ -28,7 +28,7 @@ import {
 import {
 	insertElement,
 	insertText,
-	wrap
+	wrapItem
 } from 'ckeditor5/engine/conversion/model-to-view-converters.js';
 
 import { stringify as stringifyView } from 'ckeditor5/engine/dev-utils/view.js';
@@ -56,7 +56,7 @@ describe( 'model-selection-to-view-converters', () => {
 		dispatcher = new ModelConversionDispatcher( { mapper, viewSelection } );
 
 		dispatcher.on( 'insert:$text', insertText() );
-		dispatcher.on( 'addAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
+		dispatcher.on( 'addAttribute:bold', wrapItem( new ViewAttributeElement( 'strong' ) ) );
 
 		// Default selection converters.
 		dispatcher.on( 'selection', clearAttributes(), { priority: 'low' } );
@@ -270,7 +270,7 @@ describe( 'model-selection-to-view-converters', () => {
 		describe( 'clearAttributes', () => {
 			it( 'should remove all ranges before adding new range', () => {
 				dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'b' ) ) );
-				dispatcher.on( 'addAttribute:style', wrap( new ViewAttributeElement( 'b' ) ) );
+				dispatcher.on( 'addAttribute:style', wrapItem( new ViewAttributeElement( 'b' ) ) );
 
 				test(
 					[ 3, 3 ],
@@ -292,7 +292,7 @@ describe( 'model-selection-to-view-converters', () => {
 
 			it( 'should do nothing if the attribute element had been already removed', () => {
 				dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'b' ) ) );
-				dispatcher.on( 'addAttribute:style', wrap( new ViewAttributeElement( 'b' ) ) );
+				dispatcher.on( 'addAttribute:style', wrapItem( new ViewAttributeElement( 'b' ) ) );
 
 				test(
 					[ 3, 3 ],
@@ -339,7 +339,7 @@ describe( 'model-selection-to-view-converters', () => {
 			}
 
 			dispatcher.on( 'selectionAttribute:theme', convertSelectionAttribute( themeElementCreator ) );
-			dispatcher.on( 'addAttribute:theme', wrap( themeElementCreator ) );
+			dispatcher.on( 'addAttribute:theme', wrapItem( themeElementCreator ) );
 
 			dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
 		} );

+ 314 - 13
packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js

@@ -24,8 +24,10 @@ import {
 	insertText,
 	setAttribute,
 	removeAttribute,
-	wrap,
-	unwrap,
+	wrapItem,
+	unwrapItem,
+	wrapRange,
+	unwrapRange,
 	move,
 	remove,
 	rename
@@ -262,8 +264,8 @@ describe( 'model-to-view-converters', () => {
 			modelRoot.appendChildren( modelElement );
 			dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
 			dispatcher.on( 'insert:$text', insertText() );
-			dispatcher.on( 'addAttribute:bold', wrap( viewB ) );
-			dispatcher.on( 'removeAttribute:bold', unwrap( viewB ) );
+			dispatcher.on( 'addAttribute:bold', wrapItem( viewB ) );
+			dispatcher.on( 'removeAttribute:bold', unwrapItem( viewB ) );
 
 			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
@@ -289,8 +291,8 @@ describe( 'model-to-view-converters', () => {
 			modelRoot.appendChildren( modelElement );
 			dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
 			dispatcher.on( 'insert:$text', insertText() );
-			dispatcher.on( 'addAttribute:style', wrap( elementGenerator ) );
-			dispatcher.on( 'removeAttribute:style', unwrap( elementGenerator ) );
+			dispatcher.on( 'addAttribute:style', wrapItem( elementGenerator ) );
+			dispatcher.on( 'removeAttribute:style', unwrapItem( elementGenerator ) );
 
 			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
@@ -317,8 +319,8 @@ describe( 'model-to-view-converters', () => {
 			modelRoot.appendChildren( modelElement );
 			dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
 			dispatcher.on( 'insert:$text', insertText() );
-			dispatcher.on( 'addAttribute:link', wrap( elementGenerator ) );
-			dispatcher.on( 'changeAttribute:link', wrap( elementGenerator ) );
+			dispatcher.on( 'addAttribute:link', wrapItem( elementGenerator ) );
+			dispatcher.on( 'changeAttribute:link', wrapItem( elementGenerator ) );
 
 			dispatcher.convertInsertion(
 				ModelRange.createIn( modelRoot )
@@ -347,8 +349,8 @@ describe( 'model-to-view-converters', () => {
 			modelRoot.appendChildren( modelElement );
 			dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
 			dispatcher.on( 'insert:$text', insertText() );
-			dispatcher.on( 'addAttribute:bold', wrap( viewB ) );
-			dispatcher.on( 'removeAttribute:bold', unwrap( viewB ) );
+			dispatcher.on( 'addAttribute:bold', wrapItem( viewB ) );
+			dispatcher.on( 'removeAttribute:bold', unwrapItem( viewB ) );
 
 			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
 
@@ -369,7 +371,7 @@ describe( 'model-to-view-converters', () => {
 			modelRoot.appendChildren( modelElement );
 			dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
 			dispatcher.on( 'insert:$text', insertText() );
-			dispatcher.on( 'addAttribute:bold', wrap( viewB ) );
+			dispatcher.on( 'addAttribute:bold', wrapItem( viewB ) );
 			dispatcher.on( 'addAttribute:bold', ( evt, data, consumable ) => {
 				consumable.consume( data.item, 'addAttribute:bold' );
 			}, { priority: 'high' } );
@@ -387,8 +389,8 @@ describe( 'model-to-view-converters', () => {
 			modelRoot.appendChildren( modelElement );
 			dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
 			dispatcher.on( 'insert:$text', insertText() );
-			dispatcher.on( 'addAttribute:bold', wrap( viewB ) );
-			dispatcher.on( 'removeAttribute:bold', unwrap( viewB ) );
+			dispatcher.on( 'addAttribute:bold', wrapItem( viewB ) );
+			dispatcher.on( 'removeAttribute:bold', unwrapItem( viewB ) );
 			dispatcher.on( 'removeAttribute:bold', ( evt, data, consumable ) => {
 				consumable.consume( data.item, 'removeAttribute:bold' );
 			}, { priority: 'high' } );
@@ -406,6 +408,305 @@ describe( 'model-to-view-converters', () => {
 		} );
 	} );
 
+	describe( 'wrapRange/unwrapRange', () => {
+		let modelText, rangeJohn, rangeAlice, 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 );
+
+			rangeJohn = ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 );
+			rangeAlice = ModelRange.createFromParentsAndOffsets( modelElement, 1, modelElement, 1 );
+		} );
+
+		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', rangeJohn );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="name">ob</span>ar</p></div>' );
+
+			dispatcher.convertMarker( 'removeMarker', 'name', rangeJohn );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+
+		it( 'should support collapsed markers', () => {
+			const viewSpan = new ViewAttributeElement( 'span' );
+
+			dispatcher.on( 'addMarker:name', wrapRange( viewSpan ) );
+			dispatcher.on( 'removeMarker:name', unwrapRange( viewSpan ) );
+
+			const rangeP = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 0 );
+
+			dispatcher.convertMarker( 'addMarker', 'name', rangeP );
+			dispatcher.convertMarker( 'addMarker', 'name', rangeAlice );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><span></span><p>f<span></span>oobar</p></div>' );
+
+			dispatcher.convertMarker( 'removeMarker', 'name', rangeP );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<span></span>oobar</p></div>' );
+
+			dispatcher.convertMarker( 'removeMarker', 'name', rangeAlice );
+
+			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', rangeJohn );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="john">ob</span>ar</p></div>' );
+
+			dispatcher.convertMarker( 'addMarker', 'name:alice', rangeAlice );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<span class="alice"></span>o<span class="john">ob</span>ar</p></div>' );
+
+			dispatcher.convertMarker( 'removeMarker', 'name:john', rangeJohn );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<span class="alice"></span>oobar</p></div>' );
+
+			dispatcher.convertMarker( 'removeMarker', 'name:alice', rangeAlice );
+
+			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:alice', ( evt, data, consumable ) => {
+				consumable.consume( data.range, 'addMarker' );
+			}, { priority: 'high' } );
+
+			dispatcher.convertMarker( 'addMarker', 'name:john', rangeJohn );
+			dispatcher.convertMarker( 'addMarker', 'name:alice', rangeAlice );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="john">ob</span>ar</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', rangeJohn );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="john">ob</span>ar</p></div>' );
+
+			dispatcher.convertMarker( 'removeMarker', 'name:john', rangeJohn );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="john">ob</span>ar</p></div>' );
+		} );
+
+		it( 'should not convert if view element is not returned by element generating function', () => {
+			const converterCallback = () => null;
+
+			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.convertMarker( 'addMarker', 'name', rangeJohn );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+
+		describe( 'multiple overlapping markers', () => {
+			it( 'collapsed markers', () => {
+				const converterCallbackName = ( data ) => new ViewAttributeElement( 'span', { class: data.name.split( ':' )[ 1 ] } );
+				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 ) );
+
+				const range = rangeAlice;
+
+				dispatcher.convertMarker( 'addMarker', 'name:a', range );
+				dispatcher.convertMarker( 'addMarker', 'name:b', range );
+				dispatcher.convertMarker( 'addMarker', 'search', range );
+
+				expect( viewToString( viewRoot ) ).to.equal(
+					'<div><p>f<em class="search"></em><span class="b"></span><span class="a"></span>oobar</p></div>'
+				);
+
+				dispatcher.convertMarker( 'removeMarker', 'name:b', range );
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<em class="search"></em><span class="a"></span>oobar</p></div>' );
+
+				dispatcher.convertMarker( 'removeMarker', 'search', range );
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<span class="a"></span>oobar</p></div>' );
+
+				dispatcher.convertMarker( 'removeMarker', 'name:a', range );
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			} );
+
+			it( '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 ) );
+
+				const range = rangeJohn;
+
+				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( '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( 'collapsed and 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;
+				};
+
+				dispatcher.on( 'addMarker:name', wrapRange( converterCallbackName ) );
+				dispatcher.on( 'removeMarker:name', unwrapRange( converterCallbackName ) );
+
+				const range1 = ModelRange.createFromParentsAndOffsets( modelElement, 1, modelElement, 5 );
+				const range2 = ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 );
+				const range3 = ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 );
+
+				dispatcher.convertMarker( 'addMarker', 'name:a', range1 );
+				dispatcher.convertMarker( 'addMarker', 'name:b', range2 );
+				dispatcher.convertMarker( 'addMarker', 'name:c', range3 );
+
+				expect( viewToString( viewRoot ) ).to.equal(
+					'<div><p>f<span class="a">o<span class="b"></span><span class="c">ob</span>a</span>r</p></div>'
+				);
+
+				dispatcher.convertMarker( 'removeMarker', 'name:c', range3 );
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<span class="a">o<span class="b"></span>oba</span>r</p></div>' );
+
+				dispatcher.convertMarker( 'removeMarker', 'name:b', range2 );
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<span class="a">ooba</span>r</p></div>' );
+
+				dispatcher.convertMarker( 'removeMarker', 'name:a', range1 );
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			} );
+		} );
+	} );
+
 	describe( 'move', () => {
 		it( 'should move items in view accordingly to changes in model', () => {
 			const modelDivA = new ModelElement( 'div', null, [

+ 61 - 2
packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js

@@ -311,7 +311,7 @@ describe( 'ModelConversionDispatcher', () => {
 	} );
 
 	describe( 'convertMove', () => {
-		it( 'should fire event for moved range', () => {
+		it( 'should fire event for moved range - move before source position', () => {
 			root.appendChildren( new ModelText( 'barfoo' ) );
 
 			const range = ModelRange.createFromParentsAndOffsets( root, 0, root, 3 );
@@ -322,9 +322,25 @@ describe( 'ModelConversionDispatcher', () => {
 				loggedEvents.push( log );
 			} );
 
+			dispatcher.convertMove( ModelPosition.createFromParentAndOffset( root , 3 ), range );
+
+			expect( loggedEvents ).to.deep.equal( [ 'move:3:0:3' ] );
+		} );
+
+		it( 'should fire event for moved range - move after source position', () => {
+			root.appendChildren( new ModelText( 'barfoo' ) );
+
+			const range = ModelRange.createFromParentsAndOffsets( root, 3, root, 6 );
+			const loggedEvents = [];
+
+			dispatcher.on( 'move', ( evt, data ) => {
+				const log = 'move:' + data.sourcePosition.path + ':' + data.targetPosition.path + ':' + data.item.offsetSize;
+				loggedEvents.push( log );
+			} );
+
 			dispatcher.convertMove( ModelPosition.createFromParentAndOffset( root , 0 ), range );
 
-			expect( loggedEvents ).to.deep.equal( [ 'move:0:3:3' ] );
+			expect( loggedEvents ).to.deep.equal( [ 'move:0:6:3' ] );
 		} );
 	} );
 
@@ -490,4 +506,47 @@ describe( 'ModelConversionDispatcher', () => {
 			expect( dispatcher.fire.calledWith( 'selectionAttribute:bold' ) ).to.be.false;
 		} );
 	} );
+
+	describe( 'convertMarker', () => {
+		let range;
+
+		beforeEach( () => {
+			range = ModelRange.createFromParentsAndOffsets( root, 0, root, 4 );
+		} );
+
+		it( 'should fire event based on passed parameters', () => {
+			sinon.spy( dispatcher, 'fire' );
+
+			const data = {
+				name: 'name',
+				range: range
+			};
+
+			dispatcher.convertMarker( 'addMarker', data );
+
+			expect( dispatcher.fire.calledWith( 'addMarker:name', data ) );
+
+			dispatcher.convertMarker( 'removeMarker', data );
+
+			expect( dispatcher.fire.calledWith( 'removeMarker:name', data ) );
+		} );
+
+		it( 'should prepare consumable values', () => {
+			const data = {
+				name: 'name',
+				range: range
+			};
+
+			dispatcher.on( 'addMarker:name', ( evt, data, consumable ) => {
+				expect( consumable.test( data.range, 'range' ) ).to.be.true;
+			} );
+
+			dispatcher.on( 'removeMarker:name', ( evt, data, consumable ) => {
+				expect( consumable.test( data.range, 'range' ) ).to.be.true;
+			} );
+
+			dispatcher.convertMarker( 'addMarker', data );
+			dispatcher.convertMarker( 'removeMarker', data );
+		} );
+	} );
 } );

+ 33 - 0
packages/ckeditor5-engine/tests/manual/tickets/643/1.html

@@ -0,0 +1,33 @@
+<head>
+	<link rel="stylesheet" href="/theme/ckeditor.css">
+	<style>
+		.h-red { display: inline-block; background: #FF0000; }
+		.h-yellow { display: inline-block; background: #FFFF00; }
+	</style>
+</head>
+
+<div id="editor">
+	<p>Lorem ipsum dolor sit amet.</p>
+	<p>Foobar.</p>
+</div>
+<br/>
+<div class="buttons">
+	<button id="add-yellow">Add yellow</button>
+	<button id="add-red">Add red</button>
+	<button id="remove-marker">Remove marker</button>
+	<br />
+	<button id="move-to-start">Move selection to start</button>
+	<button id="move-left">Move selection one char left</button>
+	<button id="move-right">Move selection one char right</button>
+</div>
+<br /><br />
+<small>
+	<em>
+		Keep in mind that this is just a simple demo.<br />
+		Highlighted markers do not merge (in model), remove button removes the first marker range which contains selection anchor.<br />
+		Once highlighted, highlight cannot be overwritten.<br />
+		Multi-element ranges are not supported<br />
+		Moving to the start button works only for flat ranges<br />
+		These are all known limitations of the demo.
+	</em>
+</small>

+ 116 - 0
packages/ckeditor5-engine/tests/manual/tickets/643/1.js

@@ -0,0 +1,116 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from 'ckeditor5/editor-classic/classic.js';
+import Enter from 'ckeditor5/enter/enter.js';
+import Typing from 'ckeditor5/typing/typing.js';
+import Paragraph from 'ckeditor5/paragraph/paragraph.js';
+import Bold from 'ckeditor5/basic-styles/bold.js';
+import Italic from 'ckeditor5/basic-styles/italic.js';
+import List from 'ckeditor5/list/list.js';
+import Heading from 'ckeditor5/heading/heading.js';
+import Undo from 'ckeditor5/undo/undo.js';
+
+import buildModelConverter from 'ckeditor5/engine/conversion/buildmodelconverter.js';
+import Position from 'ckeditor5/engine/model/position.js';
+import LiveRange from 'ckeditor5/engine/model/liverange.js';
+import ViewAttributeElement from 'ckeditor5/engine/view/attributeelement.js';
+
+let model = null;
+
+ClassicEditor.create( document.querySelector( '#editor' ), {
+	plugins: [ Enter, Typing, Paragraph, Bold, Italic, List, Heading, Undo ],
+	toolbar: [ 'headings', 'bold', 'italic', 'bulletedList', 'numberedList', 'undo', 'redo' ]
+} )
+.then( editor => {
+	window.editor = editor;
+	model = window.editor.editing.model;
+
+	buildModelConverter().for( editor.editing.modelToView ).
+		fromMarker( 'highlight' ).
+		toElement( ( data ) => {
+			const color = data.name.split( ':' )[ 1 ];
+
+			return new ViewAttributeElement( 'span', { class: 'h-' + color } );
+		} );
+
+	window.document.getElementById( 'add-yellow' ).addEventListener( 'click', () => addHighlight( 'yellow' ) );
+	window.document.getElementById( 'add-red' ).addEventListener( 'click', () => addHighlight( 'red' ) );
+	window.document.getElementById( 'remove-marker' ).addEventListener( 'click', () => removeHighlight() );
+	window.document.getElementById( 'move-to-start' ).addEventListener( 'click', () => moveSelectionToStart() );
+	window.document.getElementById( 'move-left' ).addEventListener( 'click', () => moveSelectionByOffset( -1 ) );
+	window.document.getElementById( 'move-right' ).addEventListener( 'click', () => moveSelectionByOffset( 1 ) );
+
+	model.enqueueChanges( () => {
+		const root = model.getRoot();
+		const range = new LiveRange( new Position( root, [ 0, 10 ] ), new Position( root, [ 0, 16 ] ) );
+		const name = 'highlight:yellow:' + uid();
+
+		markerNames.push( name );
+		model.markers.add( name, range );
+	} );
+} )
+.catch( err => {
+	console.error( err.stack );
+} );
+
+const markerNames = [];
+let _uid = 1;
+
+function uid() {
+	return _uid++;
+}
+
+function addHighlight( color ) {
+	model.enqueueChanges( () => {
+		const range = LiveRange.createFromRange( model.selection.getFirstRange() );
+		const name = 'highlight:' + color + ':' + uid();
+
+		markerNames.push( name );
+		model.markers.add( name, range );
+	} );
+}
+
+function removeHighlight() {
+	model.enqueueChanges( () => {
+		const pos = model.selection.getFirstPosition();
+
+		for ( let i = 0; i < markerNames.length; i++ ) {
+			const name = markerNames[ i ];
+			const range = model.markers.get( name );
+
+			if ( range.containsPosition( pos ) || range.start.isEqual( pos ) || range.end.isEqual( pos ) ) {
+				model.markers.remove( name );
+				range.detach();
+
+				markerNames.splice( i, 1 );
+				break;
+			}
+		}
+	} );
+}
+
+function moveSelectionToStart() {
+	const range = model.selection.getFirstRange();
+
+	if ( range.isFlat ) {
+		model.enqueueChanges( () => {
+			model.batch().move( range, new Position( model.getRoot(), [ 0, 0 ] ) );
+		} );
+	}
+}
+
+function moveSelectionByOffset( offset ) {
+	const range = model.selection.getFirstRange();
+	const pos = offset < 0 ? range.start : range.end;
+
+	if ( range.isFlat ) {
+		model.enqueueChanges( () => {
+			model.batch().move( range, pos.getShiftedBy( offset ) );
+		} );
+	}
+}

+ 16 - 0
packages/ckeditor5-engine/tests/manual/tickets/643/1.md

@@ -0,0 +1,16 @@
+@bender-ui: collapsed
+@bender-tags: ticket, 643, iteration5
+
+### Markers integration [#643](https://github.com/ckeditor/ckeditor5-engine/issues/643)
+
+1. Write before, after and inside marked text.
+2. Remove before, after and inside marked text.
+3. Make selection intersecting with marked text and remove it.
+4. Make selection intersecting with marked text and start typing.
+5. Style text and see if marked selection brakes in some way.
+6. Click enter inside marked text.
+7. Change a paragraph with marked text to a list item or header.
+8. Remove all marked text and see if there are no artifacts.
+9. Use undo.
+10. Use buttons to create and remove markers.
+11. Make different selections and play with "move to start" button.

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

@@ -15,6 +15,7 @@ import Range from 'ckeditor5/engine/model/range.js';
 import AttributeDelta from 'ckeditor5/engine/model/delta/attributedelta.js';
 import InsertDelta from 'ckeditor5/engine/model/delta/insertdelta.js';
 import WeakInsertDelta from 'ckeditor5/engine/model/delta/weakinsertdelta.js';
+import RenameDelta from 'ckeditor5/engine/model/delta/renamedelta.js';
 import RemoveDelta from 'ckeditor5/engine/model/delta/removedelta.js';
 import MoveDelta from 'ckeditor5/engine/model/delta/movedelta.js';
 import MergeDelta from 'ckeditor5/engine/model/delta/mergedelta.js';
@@ -26,6 +27,7 @@ import AttributeOperation from 'ckeditor5/engine/model/operation/attributeoperat
 import InsertOperation from 'ckeditor5/engine/model/operation/insertoperation.js';
 import MoveOperation from 'ckeditor5/engine/model/operation/moveoperation.js';
 import RemoveOperation from 'ckeditor5/engine/model/operation/removeoperation.js';
+import RenameOperation from 'ckeditor5/engine/model/operation/renameoperation.js';
 
 export function getAttributeDelta( range, key, oldValue, newValue, version ) {
 	let delta = new AttributeDelta();
@@ -85,6 +87,15 @@ export function getRemoveDelta( sourcePosition, howMany, baseVersion ) {
 	return delta;
 }
 
+export function getRenameDelta( position, oldName, newName, baseVersion ) {
+	let delta = new RenameDelta();
+
+	let rename = new RenameOperation( position, oldName, newName, baseVersion );
+	delta.addOperation( rename );
+
+	return delta;
+}
+
 export function getSplitDelta( position, nodeCopy, howManyMove, version ) {
 	let delta = new SplitDelta();
 

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

@@ -367,7 +367,7 @@ describe( 'LiveRange', () => {
 				live.end.path = [ 0, 1, 12 ];
 
 				let moveSource = new Position( root, [ 0, 1, 2 ] );
-				let moveRange = new Range( new Position( root, [ 0, 1, 5 ] ), new Position( root, [ 0, 1, 9 ] ) );
+				let moveRange = new Range( new Position( root, [ 0, 1, 7 ] ), new Position( root, [ 0, 1, 10 ] ) );
 
 				let changes = {
 					range: moveRange,

+ 147 - 0
packages/ckeditor5-engine/tests/model/markerscollection.js

@@ -0,0 +1,147 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: model */
+
+import MarkersCollection from 'ckeditor5/engine/model/markerscollection.js';
+import Range from 'ckeditor5/engine/model/range.js';
+import LiveRange from 'ckeditor5/engine/model/liverange.js';
+import Document from 'ckeditor5/engine/model/document.js';
+import CKEditorError from 'ckeditor5/utils/ckeditorerror.js';
+
+describe( 'MarkersCollection', () => {
+	let markers, live, doc, root;
+
+	beforeEach( () => {
+		doc = new Document();
+		markers = new MarkersCollection();
+
+		root = doc.createRoot();
+		live = LiveRange.createFromParentsAndOffsets( root, 0, root, 4 );
+	} );
+
+	afterEach( () => {
+		markers.destroy();
+		live.detach();
+	} );
+
+	describe( 'add', () => {
+		it( 'should throw if passed parameter is not a LiveRange', () => {
+			const range = Range.createFromParentsAndOffsets( root, 1, root, 3 );
+
+			expect( () => {
+				markers.add( 'name', range );
+			} ).to.throw( CKEditorError, /^markers-collection-add-range-not-live-range/ );
+		} );
+
+		it( 'should fire add event when range is added', () => {
+			sinon.spy( markers, 'fire' );
+
+			markers.on( 'add', ( evt, name, range ) => {
+				expect( name ).to.equal( 'name' );
+				expect( range.isEqual( live ) ).to.be.true;
+				expect( range ).not.to.equal( live );
+			} );
+
+			markers.add( 'name', live );
+
+			expect( markers.fire.calledWith( 'add' ) ).to.be.true;
+		} );
+
+		it( 'should throw if given name was already added', () => {
+			const other = LiveRange.createFromParentsAndOffsets( root, 0, root, 4 );
+			markers.add( 'name', other );
+
+			other.detach();
+
+			expect( () => {
+				markers.add( 'name', live );
+			} ).to.throw( CKEditorError, /^markers-collection-add-name-exists/ );
+		} );
+	} );
+
+	describe( 'get', () => {
+		it( 'should return range added to the collection with given name', () => {
+			markers.add( 'name', live );
+
+			expect( markers.get( 'name' ) ).to.equal( live );
+		} );
+
+		it( 'should return null if range with given name has not been found', () => {
+			expect( markers.get( 'name' ) ).to.be.null;
+		} );
+	} );
+
+	describe( 'remove', () => {
+		it( 'should return true and fire remove event if range is removed', () => {
+			markers.add( 'name', live );
+
+			sinon.spy( markers, 'fire' );
+
+			markers.on( 'remove', ( evt, name, range ) => {
+				expect( name ).to.equal( 'name' );
+				expect( range.isEqual( live ) ).to.be.true;
+				expect( range ).not.to.equal( live );
+			} );
+
+			const result = markers.remove( 'name' );
+
+			expect( result ).to.be.true;
+			expect( markers.fire.calledWith( 'remove' ) ).to.be.true;
+		} );
+
+		it( 'should return false if name has not been found in collection', () => {
+			markers.add( 'name', live );
+
+			const result = markers.remove( 'other' );
+
+			expect( result ).to.be.false;
+		} );
+	} );
+
+	describe( 'update', () => {
+		let newLive;
+
+		beforeEach( () => {
+			newLive = LiveRange.createFromParentsAndOffsets( root, 1, root, 5 );
+		} );
+
+		afterEach( () => {
+			newLive.detach();
+		} );
+
+		it( 'should return true and use remove and add methods if range was found in collection', () => {
+			const newLive = LiveRange.createFromParentsAndOffsets( root, 1, root, 5 );
+			markers.add( 'name', live );
+
+			sinon.spy( markers, 'remove' );
+			sinon.spy( markers, 'add' );
+
+			const result = markers.update( 'name', newLive );
+
+			expect( markers.remove.calledWith( 'name' ) ).to.be.true;
+			expect( markers.add.calledWith( 'name', newLive ) ).to.be.true;
+			expect( result ).to.be.true;
+
+			newLive.detach();
+		} );
+
+		it( 'should return false if given name was not found in collection', () => {
+			const result = markers.update( 'name', newLive );
+
+			expect( result ).to.be.false;
+		} );
+	} );
+
+	describe( 'destroy', () => {
+		it( 'should make MarkersCollection stop listening to all events', () => {
+			sinon.spy( markers, 'stopListening' );
+
+			markers.destroy();
+
+			expect( markers.stopListening.calledWithExactly() ).to.be.true;
+		} );
+	} );
+} );

+ 189 - 8
packages/ckeditor5-engine/tests/model/range.js

@@ -11,8 +11,18 @@ import Element from 'ckeditor5/engine/model/element.js';
 import Text from 'ckeditor5/engine/model/text.js';
 import Document from 'ckeditor5/engine/model/document.js';
 import TreeWalker from 'ckeditor5/engine/model/treewalker.js';
+import CKEditorError from 'ckeditor5/utils/ckeditorerror.js';
 import { jsonParseStringify } from 'tests/engine/model/_utils/utils.js';
 
+import {
+	getAttributeDelta,
+	getInsertDelta,
+	getMoveDelta,
+	getRemoveDelta,
+	getRenameDelta,
+	getSplitDelta
+} from 'tests/engine/model/delta/transform/_utils/utils.js';
+
 describe( 'Range', () => {
 	let doc, range, start, end, root, otherRoot;
 
@@ -194,6 +204,43 @@ describe( 'Range', () => {
 				expect( clone.isEqual( range ) ).to.be.true; // but they are equal in the position-sense
 			} );
 		} );
+
+		describe( 'createFromRanges', () => {
+			function makeRanges( root, ...points ) {
+				let ranges = [];
+
+				for ( let i = 0; i < points.length; i += 2 ) {
+					ranges.push( Range.createFromParentsAndOffsets( root, points[ i ], root, points[ i + 1 ] ) );
+				}
+
+				return ranges;
+			}
+
+			beforeEach( () => {
+				root.appendChildren( new Text( 'abcdefghijklmnopqrtuvwxyz' ) );
+			} );
+
+			it( 'should throw if empty array is passed', () => {
+				expect( () => {
+					Range.createFromRanges( [] );
+				} ).to.throw( CKEditorError, /^range-create-from-ranges-empty-array/ );
+			} );
+
+			it( 'should return a copy of the range if only one range was passed', () => {
+				const original = Range.createFromParentsAndOffsets( root, 2, root, 3 );
+				const range = Range.createFromRanges( [ original ] );
+
+				expect( range.isEqual( original ) ).to.be.true;
+				expect( range ).not.to.be.equal( original );
+			} );
+
+			it( 'should combine ranges with reference range', () => {
+				const range = Range.createFromRanges( makeRanges( root, 3, 7, 2, 3, 7, 9, 11, 14, 0, 1 ) );
+
+				expect( range.start.offset ).to.equal( 2 );
+				expect( range.end.offset ).to.equal( 9 );
+			} );
+		} );
 	} );
 
 	describe( 'iterator', () => {
@@ -440,14 +487,6 @@ describe( 'Range', () => {
 			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 4, 4 ] );
 		} );
 
-		it( 'should not change if the range is collapsed and isSticky is false', () => {
-			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 2 ] ) );
-			const transformed = range._getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 3, false, false );
-
-			expect( transformed[ 0 ].start.path ).to.deep.equal( [ 3, 2 ] );
-			expect( transformed[ 0 ].end.path ).to.deep.equal( [ 3, 2 ] );
-		} );
-
 		it( 'should move after inserted nodes if the range is collapsed and isSticky is true', () => {
 			const range = new Range( new Position( root, [ 3, 2 ] ), new Position( root, [ 3, 2 ] ) );
 			const transformed = range._getTransformedByInsertion( new Position( root, [ 3, 2 ] ), 3, false, true );
@@ -662,6 +701,148 @@ describe( 'Range', () => {
 		} );
 	} );
 
+	describe( 'getTransformedByDelta', () => {
+		beforeEach( () => {
+			root.appendChildren( new Text( 'foobar' ) );
+			range = Range.createFromParentsAndOffsets( root, 2, root, 5 );
+		} );
+
+		function expectRange( range, startOffset, endOffset ) {
+			expect( range.start.offset ).to.equal( startOffset );
+			expect( range.end.offset ).to.equal( endOffset );
+		}
+
+		describe( 'by AttributeDelta', () => {
+			it( 'nothing should change', () => {
+				const delta = getAttributeDelta( Range.createFromParentsAndOffsets( root, 1, root, 6 ), 'key', true, false, 1 );
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 2, 5 );
+			} );
+		} );
+
+		describe( 'by InsertDelta', () => {
+			it( 'insert before range', () => {
+				const delta = getInsertDelta( new Position( root, [ 1 ] ), new Text( 'abc' ), 1 );
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 5, 8 );
+			} );
+
+			it( 'insert inside range', () => {
+				const delta = getInsertDelta( new Position( root, [ 3 ] ), new Text( 'abc' ), 1 );
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 2, 8 );
+			} );
+
+			it( 'insert after range', () => {
+				const delta = getInsertDelta( new Position( root, [ 6 ] ), new Text( 'abc' ), 1 );
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 2, 5 );
+			} );
+		} );
+
+		describe( 'by MoveDelta', () => {
+			it( 'move before range', () => {
+				const start = new Position( root, [ 0 ] );
+				const end = new Position( otherRoot, [ 0 ] );
+				const delta = getMoveDelta( start, 2, end, 1 );
+
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 0, 3 );
+			} );
+
+			it( 'move intersecting with range (and targeting before range)', () => {
+				const start = new Position( root, [ 4 ] );
+				const end = new Position( root, [ 0 ] );
+				const delta = getMoveDelta( start, 2, end, 1 );
+
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 4, 6 );
+				expectRange( transformed[ 1 ], 0, 1 );
+			} );
+
+			it( 'move inside the range', () => {
+				range.end.offset = 6;
+				const start = new Position( root, [ 3 ] );
+				const end = new Position( root, [ 5 ] );
+				const delta = getMoveDelta( start, 1, end, 1 );
+
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 2, 4 );
+				expectRange( transformed[ 1 ], 5, 6 );
+				expectRange( transformed[ 2 ], 4, 5 );
+			} );
+		} );
+
+		describe( 'by RemoveDelta', () => {
+			it( 'remove before range', () => {
+				const start = new Position( root, [ 0 ] );
+				const delta = getRemoveDelta( start, 2, 1 );
+
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 0, 3 );
+			} );
+
+			it( 'remove intersecting with range', () => {
+				const start = new Position( root, [ 4 ] );
+				const delta = getRemoveDelta( start, 2, 1 );
+
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 2, 4 );
+				expect( transformed[ 1 ].root ).to.equal( doc.graveyard );
+				expect( transformed[ 1 ].end.offset - transformed[ 1 ].start.offset ).to.equal( 1 );
+			} );
+
+			it( 'remove inside the range', () => {
+				const start = new Position( root, [ 3 ] );
+				const delta = getRemoveDelta( start, 2, 1 );
+
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 2, 3 );
+				expect( transformed[ 1 ].root ).to.equal( doc.graveyard );
+				expect( transformed[ 1 ].end.offset - transformed[ 1 ].start.offset ).to.equal( 2 );
+			} );
+		} );
+
+		describe( 'by RenameDelta', () => {
+			it( 'nothing should change', () => {
+				const delta = getRenameDelta( new Position( root, [ 3 ] ), 'old', 'new', 1 );
+				const transformed = range.getTransformedByDelta( delta );
+
+				expectRange( transformed[ 0 ], 2, 5 );
+			} );
+		} );
+
+		describe( 'by SplitDelta', () => {
+			it( 'split inside range', () => {
+				root.removeChildren( root.childCount );
+				root.appendChildren( new Element( 'p', null, new Text( 'foobar' ) ) );
+
+				range.start = new Position( root, [ 0, 2 ] );
+				range.end = new Position( root, [ 0, 4 ] );
+
+				const delta = getSplitDelta( new Position( root, [ 0, 3 ] ), new Element( 'p' ), 3, 1 );
+
+				const transformed = range.getTransformedByDelta( delta );
+
+				expect( transformed.length ).to.equal( 2 );
+				expect( transformed[ 0 ].start.path ).to.deep.equal( [ 0, 2 ] );
+				expect( transformed[ 0 ].end.path ).to.deep.equal( [ 0, 3 ] );
+				expect( transformed[ 1 ].start.path ).to.deep.equal( [ 1, 0 ] );
+				expect( transformed[ 1 ].end.path ).to.deep.equal( [ 1, 1 ] );
+			} );
+		} );
+	} );
+
 	describe( 'getMinimalFlatRanges', () => {
 		beforeEach( () => {
 			prepareRichRoot( root );

+ 11 - 0
packages/ckeditor5-engine/tests/view/writer/move.js

@@ -119,6 +119,17 @@ describe( 'writer', () => {
 			expect( stringify( view, newRange, { showType: true } ) ).to.equal( expectedView );
 		} );
 
+		it( 'should correctly move text nodes inside same container', () => {
+			let { view, selection } = parse( '<container:p><attribute:b>a{b</attribute:b>xx<attribute:b>c}d</attribute:b>yy</container:p>' );
+
+			const viewText = view.getChild( 3 );
+			const newRange = move( selection.getFirstRange(), ViewPosition.createAt( viewText, 1 ) );
+
+			expect( stringify( view, newRange, { showType: true } ) ).to.equal(
+				'<container:p><attribute:b>ad</attribute:b>y[<attribute:b>b</attribute:b>xx<attribute:b>c</attribute:b>]y</container:p>'
+			);
+		} );
+
 		it( 'should move EmptyElement', () => {
 			test(
 				'<container:p>foo[<empty:img></empty:img>]bar</container:p>',