Переглянути джерело

Merge branch 'master' into t/795

Szymon Kupś 8 роки тому
батько
коміт
0996a68e10

+ 7 - 4
packages/ckeditor5-engine/src/controller/datacontroller.js

@@ -224,10 +224,13 @@ export default class DataController {
 	}
 
 	/**
-	 * Returns the content of the given {@link module:engine/view/element~Element view element} or
+	 * Returns wrapped by {module:engine/model/documentfragment~DocumentFragment} result of the given
+	 * {@link module:engine/view/element~Element view element} or
 	 * {@link module:engine/view/documentfragment~DocumentFragment view document fragment} converted by the
-	 * {@link #viewToModel view to model converters} to a
-	 * {@link module:engine/model/documentfragment~DocumentFragment model document fragment}.
+	 * {@link #viewToModel view to model converters}.
+	 *
+	 * When marker stamps were converted during conversion process then will be set as DocumentFragment
+	 * {@link module:engine/view/documentfragment~DocumentFragment#markers static markers map}.
 	 *
 	 * @param {module:engine/view/element~Element|module:engine/view/documentfragment~DocumentFragment} viewElementOrFragment
 	 * Element or document fragment which content will be converted.
@@ -281,7 +284,7 @@ export default class DataController {
 	 * See {@link module:engine/controller/modifyselection~modifySelection}.
 	 *
 	 * @fires modifySelection
-	 * @param {module:engine/model/selection~Selection} The selection to modify.
+	 * @param {module:engine/model/selection~Selection} selection The selection to modify.
 	 * @param {Object} options See {@link module:engine/controller/modifyselection~modifySelection}'s options.
 	 */
 	modifySelection( selection, options ) {

+ 78 - 36
packages/ckeditor5-engine/src/conversion/buildmodelconverter.js

@@ -152,8 +152,7 @@ class ModelConverterBuilder {
 	}
 
 	/**
-	 * Registers what type of non-collapsed marker should be converted. For collapsed markers conversion, see
-	 * {@link #fromCollapsedMarker}.
+	 * Registers what type of marker should be converted.
 	 *
 	 * @chainable
 	 * @param {String} markerName Name of marker to convert.
@@ -163,27 +162,7 @@ class ModelConverterBuilder {
 		this._from = {
 			type: 'marker',
 			name: markerName,
-			priority: null,
-			collapsed: false
-		};
-
-		return this;
-	}
-
-	/**
-	 * Registers what type of collapsed marker should be converted. For non-collapsed markers conversion,
-	 * see {@link #fromMarker}.
-	 *
-	 * @chainable
-	 * @param {String} markerName Name of marker to convert.
-	 * @returns {module:engine/conversion/buildmodelconverter~ModelConverterBuilder}
-	 */
-	fromCollapsedMarker( markerName ) {
-		this._from = {
-			type: 'marker',
-			name: markerName,
-			priority: null,
-			collapsed: true
+			priority: null
 		};
 
 		return this;
@@ -265,22 +244,76 @@ class ModelConverterBuilder {
 
 				dispatcher.on( 'selectionAttribute:' + this._from.key, convertSelectionAttribute( element ), { priority } );
 			} else {
-				if ( this._from.collapsed ) {
-					// From collapsed marker to view element -> insertUIElement, removeUIElement.
-					element = typeof element == 'string' ? new ViewUIElement( element ) : element;
+				element = typeof element == 'string' ? new ViewAttributeElement( element ) : element;
 
-					dispatcher.on( 'addMarker:' + this._from.name, insertUIElement( element ), { priority } );
-					dispatcher.on( 'removeMarker:' + this._from.name, removeUIElement( element ), { priority } );
-				} else {
-					// From non-collapsed 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 } );
 
-					dispatcher.on( 'addMarker:' + this._from.name, wrapRange( element ), { priority } );
-					dispatcher.on( 'removeMarker:' + this._from.name, unwrapRange( element ), { priority } );
+				dispatcher.on( 'selectionMarker:' + this._from.name, convertSelectionMarker( element ), { priority } );
+			}
+		}
+	}
 
-					dispatcher.on( 'selectionMarker:' + this._from.name, convertSelectionMarker( element ), { priority } );
-				}
+	/**
+	 * Registers what view stamp will be created by converter to mark marker range bounds. Separate elements will be
+	 * created at the beginning and at the end of the range. If range is collapsed then only one element will be created.
+	 *
+	 * Method accepts various ways of providing how the view element will be created. You can pass view element name as
+	 * `string`, view element instance which will be cloned and used, or creator function which returns view element that
+	 * will be used. Keep in mind that when you provide view element instance or creator function, it has to be/return a
+	 * proper type of view element: {@link module:engine/view/uielement~UIElement UIElement}.
+	 *
+	 *		buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toStamp( 'span' );
+	 *
+	 *		buildModelConverter().for( dispatcher )
+	 *			.fromMarker( 'search' )
+	 *			.toStamp( new UIElement( 'span', { 'data-name': 'search' } ) );
+	 *
+	 *		buildModelConverter().for( dispatcher )
+	 *			.fromMarker( 'search' )
+	 *			.toStamp( ( data ) => new UIElement( 'span', { 'data-name': data.name ) );
+	 *
+	 * Creator function provides additional `data.isOpening` parameter which defined if currently converted element is
+	 * a beginning or end of the marker range. This makes possible to create different opening and closing stamp.
+	 *
+	 *		buildModelConverter().for( dispatcher )
+	 *			.fromMarker( 'search' )
+	 *			.toStamp( ( data ) => {
+	 *				if ( data.isOpening ) {
+	 *					return new UIElement( 'span', { 'data-name': data.name, 'data-start': true ) );
+	 *				}
+	 *
+	 *				return new UIElement( 'span', { 'data-name': data.name, 'data-end': true ) );
+	 *			}
+	 *
+	 * Creator function provides
+	 * {@link module:engine/conversion/buildmodelconverter~ModelConverterBuilder#StampCreatorData} parameters.
+	 *
+	 * See how markers {module:engine/model/buildviewconverter~ViewConverterBuilder#toMarker view -> model serialization}
+	 * works to find out what view element format is the best for you.
+	 *
+	 * @param {String|module:engine/view/element~UIElement|Function} element UIElement created by converter or
+	 * a function that returns view element.
+	 */
+	toStamp( element ) {
+		for ( let dispatcher of this._dispatchers ) {
+			if ( this._from.type != 'marker' ) {
+				/**
+				 * To-stamp conversion is supported only for model markers.
+				 *
+				 * @error build-model-converter-element-to-stamp
+				 */
+				throw new CKEditorError(
+					'build-model-converter-non-marker-to-stamp: To-stamp conversion is supported only from model markers.'
+				);
 			}
+
+			const priority = this._from.priority === null ? 'normal' : this._from.priority;
+
+			element = typeof element == 'string' ? new ViewUIElement( element ) : element;
+
+			dispatcher.on( 'addMarker:' + this._from.name, insertUIElement( element ), { priority } );
+			dispatcher.on( 'removeMarker:' + this._from.name, removeUIElement( element ), { priority } );
 		}
 	}
 
@@ -321,7 +354,6 @@ class ModelConverterBuilder {
 			 * 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.' );
@@ -370,3 +402,13 @@ class ModelConverterBuilder {
 export default function buildModelConverter() {
 	return new ModelConverterBuilder();
 }
+
+/**
+ * @typedef {StampCreatorData} {module:engine/conversion/buildmodelconverter~ModelConverterBuilder#StampCreatorData}
+ * @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 {Boolean} data.isOpening Defines if currently converted element is a beginning or end of the marker range.
+ * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
+ * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
+ */

+ 85 - 4
packages/ckeditor5-engine/src/conversion/buildviewconverter.js

@@ -11,6 +11,7 @@ import Matcher from '../view/matcher';
 import ModelElement from '../model/element';
 import ModelPosition from '../model/position';
 import modelWriter from '../model/writer';
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable';
 
 /**
@@ -254,7 +255,7 @@ class ViewConverterBuilder {
 	 * @param {String|Function} element Model element name or model element creator function.
 	 */
 	toElement( element ) {
-		const eventCallbackGen = function( from ) {
+		function eventCallbackGen( from ) {
 			return ( evt, data, consumable, conversionApi ) => {
 				// There is one callback for all patterns in the matcher.
 				// This will be usually just one pattern but we support matchers with many patterns too.
@@ -301,7 +302,7 @@ class ViewConverterBuilder {
 					break;
 				}
 			};
-		};
+		}
 
 		this._setCallback( eventCallbackGen, 'normal' );
 	}
@@ -322,7 +323,7 @@ class ViewConverterBuilder {
 	 * @param {String} [value] Attribute value. Required if `keyOrCreator` is a `string`. Ignored otherwise.
 	 */
 	toAttribute( keyOrCreator, value ) {
-		const eventCallbackGen = function( from ) {
+		function eventCallbackGen( from ) {
 			return ( evt, data, consumable, conversionApi ) => {
 				// There is one callback for all patterns in the matcher.
 				// This will be usually just one pattern but we support matchers with many patterns too.
@@ -356,11 +357,91 @@ class ViewConverterBuilder {
 					break;
 				}
 			};
-		};
+		}
 
 		this._setCallback( eventCallbackGen, 'low' );
 	}
 
+	/**
+	 * Registers how model element marking marker range will be created by converter.
+	 *
+	 * Created element has to match the following pattern:
+	 *
+	 * 		{ name: '$marker', attribute: { data-name: /^\w/ } }
+	 *
+	 * There are two ways of creating this element:
+	 *
+	 * 1. Makes sure that converted view element will have property `data-name` then converter will
+	 * automatically take this property value. In this case there is no need to provide creator function.
+	 * For the following view:
+	 *
+	 *		<marker data-name="search"></marker>foo<marker data-name="search"></marker>
+	 *
+	 * converter should look like this:
+	 *
+	 *		buildViewConverter().for( dispatcher ).fromElement( 'marker' ).toMarker();
+	 *
+	 * 2. Creates element by creator:
+	 *
+	 * For the following view:
+	 *
+	 * 		<span foo="search"></span>foo<span foo="search"></span>
+	 *
+	 * converter should look like this:
+	 *
+	 * 		buildViewConverter().for( dispatcher ).from( { name: 'span', { attribute: foo: /^\w/ } } ).toMarker( ( data ) => {
+	 * 			return new Element( '$marker', { 'data-name': data.getAttribute( 'foo' ) } );
+	 * 		} );
+	 *
+	 * @param {Function} [creator] Creator function.
+	 */
+	toMarker( creator ) {
+		function eventCallbackGen( from ) {
+			return ( evt, data, consumable ) => {
+				// There is one callback for all patterns in the matcher.
+				// This will be usually just one pattern but we support matchers with many patterns too.
+				const matchAll = from.matcher.matchAll( data.input );
+
+				// If there is no match, this callback should not do anything.
+				if ( !matchAll ) {
+					return;
+				}
+
+				let modelElement;
+
+				// When creator is provided then create model element basing on creator function.
+				if ( creator instanceof Function ) {
+					modelElement = creator( data.input );
+				// When there is no creator then create model element basing on data from view element.
+				} else {
+					modelElement = new ModelElement( '$marker', { 'data-name': data.input.getAttribute( 'data-name' ) } );
+				}
+
+				// Check if model element is correct (has proper name and property).
+				if ( modelElement.name != '$marker' || typeof modelElement.getAttribute( 'data-name' ) != 'string' ) {
+					throw new CKEditorError(
+						'build-view-converter-invalid-marker: Invalid model element to mark marker range.'
+					);
+				}
+
+				// Now, for every match between matcher and actual element, we will try to consume the match.
+				for ( const match of matchAll ) {
+					// Try to consume appropriate values from consumable values list.
+					if ( !consumable.consume( data.input, from.consume || match.match ) ) {
+						continue;
+					}
+
+					data.output = modelElement;
+
+					// Prevent multiple conversion if there are other correct matches.
+					break;
+				}
+			};
+		}
+
+		this._setCallback( eventCallbackGen, 'normal' );
+	}
+
 	/**
 	 * Helper function that uses given callback generator to created callback function and sets it on registered dispatchers.
 	 *

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

@@ -91,6 +91,8 @@ export function insertText() {
 /**
  * Function factory, creates a converter that converts marker adding change to the view ui element.
  * The view ui element that will be added to the view depends on passed parameter. See {@link ~insertElement}.
+ * In a case of collapsed range element will not wrap range but separate elements will be placed at the beginning
+ * and at the end of the range.
  *
  * **Note:** unlike {@link ~insertElement}, the converter does not bind view element to model, because this converter
  * uses marker as "model source of data". This means that view ui element does not have corresponding model element.
@@ -101,11 +103,20 @@ export function insertText() {
  */
 export function insertUIElement( elementCreator ) {
 	return ( evt, data, consumable, conversionApi ) => {
-		const viewElement = ( elementCreator instanceof ViewElement ) ?
-			elementCreator.clone( true ) :
-			elementCreator( data, consumable, conversionApi );
+		let viewStartElement, viewEndElement;
 
-		if ( !viewElement ) {
+		if ( elementCreator instanceof ViewElement ) {
+			viewStartElement = elementCreator.clone( true );
+			viewEndElement = elementCreator.clone( true );
+		} else {
+			data.isOpening = true;
+			viewStartElement = elementCreator( data, consumable, conversionApi );
+
+			data.isOpening = false;
+			viewEndElement = elementCreator( data, consumable, conversionApi );
+		}
+
+		if ( !viewStartElement || !viewEndElement ) {
 			return;
 		}
 
@@ -113,9 +124,13 @@ export function insertUIElement( elementCreator ) {
 			return;
 		}
 
-		const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
+		const mapper = conversionApi.mapper;
 
-		viewWriter.insert( viewPosition, viewElement );
+		viewWriter.insert( mapper.toViewPosition( data.range.start ), viewStartElement );
+
+		if ( !data.range.isCollapsed ) {
+			viewWriter.insert( mapper.toViewPosition( data.range.end ), viewEndElement );
+		}
 	};
 }
 
@@ -439,11 +454,20 @@ export function remove() {
  */
 export function removeUIElement( elementCreator ) {
 	return ( evt, data, consumable, conversionApi ) => {
-		const viewElement = ( elementCreator instanceof ViewElement ) ?
-			elementCreator.clone( true ) :
-			elementCreator( data, consumable, conversionApi );
+		let viewStartElement, viewEndElement;
 
-		if ( !viewElement ) {
+		if ( elementCreator instanceof ViewElement ) {
+			viewStartElement = elementCreator.clone( true );
+			viewEndElement = elementCreator.clone( true );
+		} else {
+			data.isOpening = true;
+			viewStartElement = elementCreator( data, consumable, conversionApi );
+
+			data.isOpening = false;
+			viewEndElement = elementCreator( data, consumable, conversionApi );
+		}
+
+		if ( !viewStartElement || !viewEndElement ) {
 			return;
 		}
 
@@ -453,7 +477,13 @@ export function removeUIElement( elementCreator ) {
 
 		const viewRange = conversionApi.mapper.toViewRange( data.range );
 
-		viewWriter.clear( viewRange.getEnlarged(), viewElement );
+		// First remove closing element.
+		viewWriter.clear( viewRange.getEnlarged(), viewEndElement );
+
+		// If closing and opening elements are not the same then remove opening element.
+		if ( !viewStartElement.isSimilar( viewEndElement ) ) {
+			viewWriter.clear( viewRange.getEnlarged(), viewStartElement );
+		}
 	};
 }
 

+ 69 - 2
packages/ckeditor5-engine/src/conversion/viewconversiondispatcher.js

@@ -11,6 +11,12 @@ import ViewConsumable from './viewconsumable';
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import extend from '@ckeditor/ckeditor5-utils/src/lib/lodash/extend';
+import ModelRange from '../model/range';
+import ModelPosition from '../model/position';
+import ModelTreeWalker from '../model/treewalker';
+import ModelNode from '../model/node';
+import ModelDocumentFragment from '../model/documentfragment';
+import { remove } from '../model/writer';
 
 /**
  * `ViewConversionDispatcher` is a central point of {@link module:engine/view/view view} conversion, which is a process of
@@ -128,14 +134,32 @@ export default class ViewConversionDispatcher {
 	 * viewItem Part of the view to be converted.
 	 * @param {Object} [additionalData] Additional data to be passed in `data` argument when firing `ViewConversionDispatcher`
 	 * events. See also {@link ~ViewConversionDispatcher#event:element element event}.
-	 * @returns {module:engine/model/documentfragment~DocumentFragment} Model document fragment that is a result of the conversion process.
+	 * @returns {module:engine/model/documentfragment~DocumentFragment} Model data that is a result of the conversion process
+	 * wrapped by DocumentFragment. Converted marker stamps will be set as DocumentFragment
+	 * {@link module:engine/view/documentfragment~DocumentFragment#markers static markers map}.
 	 */
 	convert( viewItem, additionalData = {} ) {
 		this.fire( 'viewCleanup', viewItem );
 
 		const consumable = ViewConsumable.createFrom( viewItem );
+		const conversionResult = this._convertItem( viewItem, consumable, additionalData );
 
-		return this._convertItem( viewItem, consumable, additionalData );
+		// In some cases conversion output doesn't have to be a node and in this case we do nothing additional with this data.
+		if ( !( conversionResult instanceof ModelNode || conversionResult instanceof ModelDocumentFragment ) ) {
+			return conversionResult;
+		}
+
+		let documentFragment = conversionResult;
+
+		// When conversion result is not a DocumentFragment we need to wrap it by DocumentFragment.
+		if ( !documentFragment.is( 'documentFragment' ) ) {
+			documentFragment = new ModelDocumentFragment( [ documentFragment ] );
+		}
+
+		// Extract temporary markers stamp from model and set as static markers collection.
+		documentFragment.markers = extractMarkersFromModelFragment( documentFragment );
+
+		return documentFragment;
 	}
 
 	/**
@@ -221,6 +245,49 @@ export default class ViewConversionDispatcher {
 
 mix( ViewConversionDispatcher, EmitterMixin );
 
+// Traverses given model item and searches elements which marks marker range. Found element is removed from
+// DocumentFragment but path of this element is stored in a Map which is then returned.
+//
+// @param {module:engine/view/documentfragment~DocumentFragment|module:engine/view/node~Node} modelItem Fragment of model.
+// @returns {Map<String, module:engine/model/range~Range>} List of static markers.
+function extractMarkersFromModelFragment( modelItem ) {
+	const markerStamps = new Set();
+	const markers = new Map();
+
+	// Create ModelTreeWalker.
+	const walker = new ModelTreeWalker( {
+		startPosition: ModelPosition.createAt( modelItem, 0 ),
+		ignoreElementEnd: true
+	} );
+
+	// Walk through DocumentFragment and collect marker elements.
+	for ( const value of walker ) {
+		// Check if current element is a marker stamp.
+		if ( value.item.name == '$marker' ) {
+			markerStamps.add( value.item );
+		}
+	}
+
+	// Walk through collected marker elements store its path and remove its from the DocumentFragment.
+	for ( const stamp of markerStamps ) {
+		const markerName = stamp.getAttribute( 'data-name' );
+		const currentPosition = ModelPosition.createBefore( stamp );
+
+		// When marker of given name is not stored it means that we have found the beginning of the range.
+		if ( !markers.has( markerName ) ) {
+			markers.set( markerName, new ModelRange( ModelPosition.createFromPosition( currentPosition ) ) );
+		// Otherwise is means that we have found end of the marker range.
+		} else {
+			markers.get( markerName ).end = ModelPosition.createFromPosition( currentPosition );
+		}
+
+		// Remove marker stamp element from DocumentFragment.
+		remove( ModelRange.createOn( stamp ) );
+	}
+
+	return markers;
+}
+
 /**
  * Conversion interface that is registered for given {@link module:engine/conversion/viewconversiondispatcher~ViewConversionDispatcher}
  * and is passed as one of parameters when {@link module:engine/conversion/viewconversiondispatcher~ViewConversionDispatcher dispatcher}

+ 21 - 0
packages/ckeditor5-engine/src/model/delta/insertdelta.js

@@ -13,6 +13,10 @@ import RemoveDelta from './removedelta';
 import { register } from '../batch';
 import InsertOperation from '../operation/insertoperation';
 
+import DocumentFragment from '../documentfragment';
+import Range from '../../model/range.js';
+import Position from '../../model/position.js';
+
 /**
  * @classdesc
  * To provide specific OT behavior and better collisions solving, the {@link module:engine/model/batch~Batch#insert Batch#insert} method
@@ -82,6 +86,9 @@ export default class InsertDelta extends Delta {
 /**
  * Inserts a node or nodes at the given position.
  *
+ * When inserted element is a {@link engine/model/documentfragment~DocumentFragment} and has markers its markers will
+ * be set to {@link engine/model/document~Document#markers}.
+ *
  * @chainable
  * @method module:engine/model/batch~Batch#insert
  * @param {module:engine/model/position~Position} position Position of insertion.
@@ -95,6 +102,20 @@ register( 'insert', function( position, nodes ) {
 	delta.addOperation( insert );
 	this.document.applyOperation( insert );
 
+	// When element is a DocumentFragment we need to move its markers to Document#markers.
+	if ( nodes instanceof DocumentFragment ) {
+		for ( const [ markerName, markerRange ] of nodes.markers ) {
+			// We need to migrate marker range from DocumentFragment to Document.
+			const rangeRootPosition = Position.createAt( markerRange.root );
+			const range = new Range(
+				markerRange.start._getCombined( rangeRootPosition, position ),
+				markerRange.end._getCombined( rangeRootPosition, position )
+			);
+
+			this.setMarker( markerName, range );
+		}
+	}
+
 	return this;
 } );
 

+ 13 - 0
packages/ckeditor5-engine/src/model/documentfragment.js

@@ -15,6 +15,10 @@ import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable';
 /**
  * DocumentFragment represents a part of model which does not have a common root but it's top-level nodes
  * can be seen as siblings. In other words, it is a detached part of model tree, without a root.
+ *
+ * DocumentFragment has own {@link module:engine/model/markercollection~MarkerCollection}. Markers from this collection
+ * will be set to the {@link module:engine/model/document~Document#markers document markers} by a
+ * {@link module:engine/model/writer~writer.insert} function.
  */
 export default class DocumentFragment {
 	/**
@@ -24,6 +28,15 @@ export default class DocumentFragment {
 	 * Nodes to be contained inside the `DocumentFragment`.
 	 */
 	constructor( children ) {
+		/**
+		 * DocumentFragment static markers map. This is a list of names and {@link module:engine/model/range~Range ranges}
+		 * which will be set as Markers to {@link module:engine/model/document~Document#markers document markers collection}
+		 * when DocumentFragment will be inserted to the document.
+		 *
+		 * @member {Map<String, {module:engine/model/range~Range}>} module:engine/model/documentfragment~DocumentFragment#markers
+		 */
+		this.markers = new Map();
+
 		/**
 		 * List of nodes contained inside the document fragment.
 		 *

+ 11 - 1
packages/ckeditor5-engine/src/model/element.js

@@ -83,9 +83,19 @@ export default class Element extends Node {
 	/**
 	 * Checks whether given model tree object is of given type.
 	 *
+	 *		obj.name; // 'listItem'
+	 *		obj instanceof Element; // true
+	 *
+	 *		obj.is( 'element' ); // true
+	 *		obj.is( 'listItem' ); // true
+	 *		obj.is( 'element', 'listItem' ); // true
+	 *		obj.is( 'text' ); // false
+	 *		obj.is( 'element', 'image' ); // false
+	 *
 	 * Read more in {@link module:engine/model/node~Node#is}.
 	 *
-	 * @param {String} type
+	 * @param {String} type Type to check when `name` parameter is present.
+	 * Otherwise, it acts like the `name` parameter.
 	 * @param {String} [name] Element name.
 	 * @returns {Boolean}
 	 */

+ 16 - 16
packages/ckeditor5-engine/tests/controller/datacontroller.js

@@ -11,7 +11,6 @@ import buildViewConverter  from '../../src/conversion/buildviewconverter';
 import buildModelConverter  from '../../src/conversion/buildmodelconverter';
 
 import ModelDocumentFragment from '../../src/model/documentfragment';
-import ModelElement from '../../src/model/element';
 import ModelText from '../../src/model/text';
 import ModelSelection from '../../src/model/selection';
 
@@ -124,6 +123,7 @@ describe( 'DataController', () => {
 			schema.allow( { name: '$text', inside: '$root' } );
 			const model = data.parse( '<p>foo<b>bar</b></p>' );
 
+			expect( model ).to.instanceof( ModelDocumentFragment );
 			expect( stringify( model ) ).to.equal( 'foobar' );
 		} );
 
@@ -134,6 +134,7 @@ describe( 'DataController', () => {
 
 			const model = data.parse( '<p>foo<b>bar</b></p>' );
 
+			expect( model ).to.instanceof( ModelDocumentFragment );
 			expect( stringify( model ) ).to.equal( '<paragraph>foobar</paragraph>' );
 		} );
 
@@ -144,8 +145,8 @@ describe( 'DataController', () => {
 
 			const model = data.parse( '<p>foo</p><p>bar</p>' );
 
-			expect( stringify( model ) ).to.equal(
-				'<paragraph>foo</paragraph><paragraph>bar</paragraph>' );
+			expect( model ).to.instanceof( ModelDocumentFragment );
+			expect( stringify( model ) ).to.equal( '<paragraph>foo</paragraph><paragraph>bar</paragraph>' );
 		} );
 
 		it( 'should set paragraphs with bold', () => {
@@ -157,8 +158,8 @@ describe( 'DataController', () => {
 
 			const model = data.parse( '<p>foo<b>bar</b></p>' );
 
-			expect( stringify( model ) ).to.equal(
-				'<paragraph>foo<$text bold="true">bar</$text></paragraph>' );
+			expect( model ).to.instanceof( ModelDocumentFragment );
+			expect( stringify( model ) ).to.equal( '<paragraph>foo<$text bold="true">bar</$text></paragraph>' );
 		} );
 
 		it( 'should parse in the root context by default', () => {
@@ -183,18 +184,18 @@ describe( 'DataController', () => {
 
 		it( 'should convert content of an element #1', () => {
 			const viewElement = parseView( '<p>foo</p>' );
-			const modelElement = data.toModel( viewElement );
+			const model = data.toModel( viewElement );
 
-			expect( modelElement ).to.be.instanceOf( ModelElement );
-			expect( stringify( modelElement ) ).to.equal( '<paragraph>foo</paragraph>' );
+			expect( model ).to.instanceof( ModelDocumentFragment );
+			expect( stringify( model ) ).to.equal( '<paragraph>foo</paragraph>' );
 		} );
 
 		it( 'should convert content of an element #2', () => {
 			const viewFragment = parseView( '<p>foo</p><p>bar</p>' );
-			const modelFragment = data.toModel( viewFragment );
+			const model = data.toModel( viewFragment );
 
-			expect( modelFragment ).to.be.instanceOf( ModelDocumentFragment );
-			expect( stringify( modelFragment ) ).to.equal( '<paragraph>foo</paragraph><paragraph>bar</paragraph>' );
+			expect( model ).to.be.instanceOf( ModelDocumentFragment );
+			expect( stringify( model ) ).to.equal( '<paragraph>foo</paragraph><paragraph>bar</paragraph>' );
 		} );
 
 		it( 'should accept parsing context', () => {
@@ -204,13 +205,12 @@ describe( 'DataController', () => {
 			schema.allow( { name: '$text', inside: 'inlineRoot' } );
 
 			const viewFragment = new ViewDocumentFragment( [ parseView( 'foo' ) ] );
-			const modelFragmentInRoot = data.toModel( viewFragment );
 
-			expect( stringify( modelFragmentInRoot ) ).to.equal( '' );
+			// Model fragment in root.
+			expect( stringify( data.toModel( viewFragment ) ) ).to.equal( '' );
 
-			const modelFragmentInInlineRoot = data.toModel( viewFragment, 'inlineRoot' );
-
-			expect( stringify( modelFragmentInInlineRoot ) ).to.equal( 'foo' );
+			// Model fragment in inline root.
+			expect( stringify( data.toModel( viewFragment, 'inlineRoot' ) ) ).to.equal( 'foo' );
 		} );
 	} );
 

+ 113 - 24
packages/ckeditor5-engine/tests/conversion/buildmodelconverter.js

@@ -405,10 +405,22 @@ describe( 'Model converter builder', () => {
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
+
+		it( 'should do nothing when marker range is collapsed', () => {
+			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toElement( 'strong' );
+
+			dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+
+			dispatcher.convertMarker( 'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
 	} );
 
-	describe( 'model collapsed marker to view element conversion', () => {
-		let modelText, modelElement;
+	describe( 'model marker to view stamp conversion', () => {
+		let modelText, modelElement, range;
 
 		beforeEach( () => {
 			modelText = new ModelText( 'foobar' );
@@ -422,45 +434,110 @@ describe( 'Model converter builder', () => {
 			mapper.bindElements( modelElement, viewElement );
 		} );
 
-		it( 'using passed view element name', () => {
-			buildModelConverter().for( dispatcher ).fromCollapsedMarker( 'search' ).toElement( 'span' );
+		describe( 'collapsed range', () => {
+			beforeEach( () => {
+				range = ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 );
+			} );
 
-			dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 3 ) );
+			it( 'using passed view element name', () => {
+				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toStamp( 'span' );
 
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo<span></span>bar</p></div>' );
+				dispatcher.convertMarker( 'addMarker', 'search', range );
 
-			dispatcher.convertMarker( 'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 3 ) );
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span></span>obar</p></div>' );
 
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-		} );
+				dispatcher.convertMarker( 'removeMarker', 'search', range );
 
-		it( 'using passed view element', () => {
-			const viewElement = new ViewUIElement( 'span', { class: 'search' } );
-			buildModelConverter().for( dispatcher ).fromCollapsedMarker( 'search' ).toElement( viewElement );
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			} );
 
-			dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 3 ) );
+			it( 'using passed view element', () => {
+				const viewElement = new ViewUIElement( 'span', { class: 'search' } );
+				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toStamp( viewElement );
 
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo<span class="search"></span>bar</p></div>' );
+				dispatcher.convertMarker( 'addMarker', 'search', range );
 
-			dispatcher.convertMarker( 'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 3 ) );
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="search"></span>obar</p></div>' );
 
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+				dispatcher.convertMarker( 'removeMarker', 'search', range );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			} );
+
+			it( 'using passed creator function', () => {
+				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toStamp( ( data ) => {
+					const className = 'search search-color-' + data.name.split( ':' )[ 1 ];
+
+					return new ViewUIElement( 'span', { class: className } );
+				} );
+
+				dispatcher.convertMarker( 'addMarker', 'search:red', range );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="search search-color-red"></span>obar</p></div>' );
+
+				dispatcher.convertMarker( 'removeMarker', 'search:red', range );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			} );
 		} );
 
-		it( 'using passed creator function', () => {
-			buildModelConverter().for( dispatcher ).fromCollapsedMarker( 'search' ).toElement( ( data ) => {
-				const className = 'search search-color-' + data.name.split( ':' )[ 1 ];
+		describe( 'non-collapsed range', () => {
+			beforeEach( () => {
+				range = ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 );
+			} );
+
+			it( 'using passed view element name', () => {
+				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toStamp( 'span' );
+
+				dispatcher.convertMarker( 'addMarker', 'search', range );
 
-				return new ViewUIElement( 'span', { class: className } );
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span></span>ob<span></span>ar</p></div>' );
+
+				dispatcher.convertMarker( 'removeMarker', 'search', range );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 			} );
 
-			dispatcher.convertMarker( 'addMarker', 'search:red', ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 3 ) );
+			it( 'using passed view element', () => {
+				const viewElement = new ViewUIElement( 'span', { class: 'search' } );
+				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toStamp( viewElement );
 
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo<span class="search search-color-red"></span>bar</p></div>' );
+				dispatcher.convertMarker( 'addMarker', 'search', range );
 
-			dispatcher.convertMarker( 'removeMarker', 'search:red', ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 3 ) );
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="search"></span>ob<span class="search"></span>ar</p></div>' );
 
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+				dispatcher.convertMarker( 'removeMarker', 'search', range );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			} );
+
+			it( 'using passed creator function', () => {
+				buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toStamp( ( data ) => {
+					const className = 'search search-color-' + data.name.split( ':' )[ 1 ];
+
+					return new ViewUIElement( 'span', { class: className } );
+				} );
+
+				dispatcher.convertMarker( 'addMarker', 'search:red', range );
+
+				expect( viewToString( viewRoot ) )
+					.to.equal( '<div><p>fo<span class="search search-color-red"></span>ob<span class="search search-color-red"></span>ar</p></div>' );
+
+				dispatcher.convertMarker( 'removeMarker', 'search:red', range );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			} );
+		} );
+
+		it( 'should overwrite default priority', () => {
+			range = ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 );
+
+			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).toStamp( 'normal' );
+			buildModelConverter().for( dispatcher ).fromMarker( 'search' ).withPriority( 'high' ).toStamp( 'high' );
+
+			dispatcher.convertMarker( 'addMarker', 'search', range );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<high></high>obar</p></div>' );
 		} );
 	} );
 
@@ -483,4 +560,16 @@ describe( 'Model converter builder', () => {
 			buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toAttribute( 'paragraph', true );
 		} ).to.throw( CKEditorError, /^build-model-converter-non-attribute-to-attribute/ );
 	} );
+
+	it( 'should throw when trying to build model element to view stamp converter', () => {
+		expect( () => {
+			buildModelConverter().for( dispatcher ).fromElement( 'paragraph' ).toStamp( 'span' );
+		} ).to.throw( CKEditorError, /^build-model-converter-non-marker-to-stamp/ );
+	} );
+
+	it( 'should throw when trying to build model attribute to view stamp converter', () => {
+		expect( () => {
+			buildModelConverter().for( dispatcher ).fromAttribute( 'class' ).toStamp( 'span' );
+		} ).to.throw( CKEditorError, /^build-model-converter-non-marker-to-stamp/ );
+	} );
 } );

+ 168 - 37
packages/ckeditor5-engine/tests/conversion/buildviewconverter.js

@@ -20,7 +20,10 @@ import ViewMatcher from '../../src/view/matcher';
 
 import ViewConversionDispatcher from '../../src/conversion/viewconversiondispatcher';
 
+import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
 import { convertToModelFragment, convertText } from '../../src/conversion/view-to-model-converters';
+import { stringify } from '../../src/dev-utils/model';
 
 function modelAttributesToString( item ) {
 	let result = '';
@@ -95,10 +98,10 @@ describe( 'View converter builder', () => {
 	it( 'should convert from view element to model element', () => {
 		buildViewConverter().for( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
 
-		const result = dispatcher.convert( new ViewContainerElement( 'p', null, new ViewText( 'foo' ) ), objWithContext );
-		modelRoot.appendChildren( result );
+		const conversionResult = dispatcher.convert( new ViewContainerElement( 'p', null, new ViewText( 'foo' ) ), objWithContext );
+		modelRoot.appendChildren( conversionResult );
 
-		expect( modelToString( result ) ).to.equal( '<paragraph>foo</paragraph>' );
+		expect( modelToString( conversionResult ) ).to.equal( '<paragraph>foo</paragraph>' );
 	} );
 
 	it( 'should convert from view element to model element using creator function', () => {
@@ -106,17 +109,19 @@ describe( 'View converter builder', () => {
 			.fromElement( 'img' )
 			.toElement( ( viewElement ) => new ModelElement( 'image', { src: viewElement.getAttribute( 'src' ) } ) );
 
-		const result = dispatcher.convert( new ViewContainerElement( 'img', { src: 'foo.jpg' } ), objWithContext );
-		modelRoot.appendChildren( result );
+		const conversionResult = dispatcher.convert( new ViewContainerElement( 'img', { src: 'foo.jpg' } ), objWithContext );
+		modelRoot.appendChildren( conversionResult );
 
-		expect( modelToString( result ) ).to.equal( '<image src="foo.jpg"></image>' );
+		expect( modelToString( conversionResult ) ).to.equal( '<image src="foo.jpg"></image>' );
 	} );
 
 	it( 'should convert from view element to model attribute', () => {
 		buildViewConverter().for( dispatcher ).fromElement( 'strong' ).toAttribute( 'bold', true );
 
-		const result = dispatcher.convert( new ViewAttributeElement( 'strong', null, new ViewText( 'foo' ) ), objWithContext );
-		modelRoot.appendChildren( result );
+		const conversionResult = dispatcher.convert(
+			new ViewAttributeElement( 'strong', null, new ViewText( 'foo' ) ), objWithContext
+		);
+		modelRoot.appendChildren( conversionResult );
 
 		// Have to check root because result is a ModelText.
 		expect( modelToString( modelRoot ) ).to.equal( '<$root><$text bold="true">foo</$text></$root>' );
@@ -127,8 +132,10 @@ describe( 'View converter builder', () => {
 			.fromElement( 'a' )
 			.toAttribute( ( viewElement ) => ( { key: 'linkHref', value: viewElement.getAttribute( 'href' ) } ) );
 
-		const result = dispatcher.convert( new ViewAttributeElement( 'a', { href: 'foo.html' }, new ViewText( 'foo' ) ), objWithContext );
-		modelRoot.appendChildren( result );
+		const conversionResult = dispatcher.convert(
+			new ViewAttributeElement( 'a', { href: 'foo.html' }, new ViewText( 'foo' ) ), objWithContext
+		);
+		modelRoot.appendChildren( conversionResult );
 
 		// Have to check root because result is a ModelText.
 		expect( modelToString( modelRoot ) ).to.equal( '<$root><$text linkHref="foo.html">foo</$text></$root>' );
@@ -141,10 +148,12 @@ describe( 'View converter builder', () => {
 			.fromAttribute( 'class' )
 			.toAttribute( ( viewElement ) => ( { key: 'class', value: viewElement.getAttribute( 'class' ) } ) );
 
-		const result = dispatcher.convert( new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext );
-		modelRoot.appendChildren( result );
+		const conversionResult = dispatcher.convert(
+			new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext
+		);
+		modelRoot.appendChildren( conversionResult );
 
-		expect( modelToString( result ) ).to.equal( '<paragraph class="myClass">foo</paragraph>' );
+		expect( modelToString( conversionResult ) ).to.equal( '<paragraph class="myClass">foo</paragraph>' );
 	} );
 
 	it( 'should convert from view attribute and key to model attribute', () => {
@@ -159,9 +168,9 @@ describe( 'View converter builder', () => {
 			new ViewContainerElement( 'p', { class: 'important theme-nice' }, new ViewText( 'bar' ) )
 		] );
 
-		const result = dispatcher.convert( viewStructure, objWithContext );
+		const conversionResult = dispatcher.convert( viewStructure, objWithContext );
 
-		expect( modelToString( result ) )
+		expect( modelToString( conversionResult ) )
 			.to.equal( '<paragraph important="true">foo</paragraph><paragraph important="true" theme="nice">bar</paragraph>' );
 	} );
 
@@ -182,10 +191,124 @@ describe( 'View converter builder', () => {
 			new ViewContainerElement( 'span', { style: 'font-weight:bold; font-size:20px' }, new ViewText( 'ddd' ) )
 		] );
 
-		const result = dispatcher.convert( viewElement, objWithContext );
-		modelRoot.appendChildren( result );
+		const conversionResult = dispatcher.convert( viewElement, objWithContext );
+		modelRoot.appendChildren( conversionResult );
+
+		expect( modelToString( conversionResult ) ).to.equal( '<paragraph><$text bold="true">aaabbbcccddd</$text></paragraph>' );
+	} );
+
+	it( 'should convert from pattern to marker', () => {
+		buildViewConverter().for( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+		buildViewConverter().for( dispatcher ).from( { attribute: { 'data-name': 'search' } } ).toMarker();
+
+		const viewElement = new ViewContainerElement( 'p', null, [
+			new ViewText( 'Fo' ),
+			new ViewAttributeElement( 'marker', { 'data-name': 'search' } ),
+			new ViewText( 'o ba' ),
+			new ViewAttributeElement( 'marker', { 'data-name': 'search' } ),
+			new ViewText( 'r' )
+		] );
+
+		const conversionResult = dispatcher.convert( viewElement, objWithContext );
+
+		const markerSearch = conversionResult.markers.get( 'search' );
+
+		expect( conversionResult.markers.size ).to.equal( 1 );
+		expect( stringify( conversionResult, markerSearch ) ).to.equal( '<paragraph>Fo[o ba]r</paragraph>' );
+	} );
+
+	it( 'should convert from element to marker using creator function', () => {
+		buildViewConverter().for( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+		buildViewConverter().for( dispatcher ).fromElement( 'marker' ).toMarker( ( data ) => {
+			return new ModelElement( '$marker', { 'data-name': data.getAttribute( 'class' ) } );
+		} );
+
+		const viewElement = new ViewContainerElement( 'p', null, [
+			new ViewText( 'Fo' ),
+			new ViewAttributeElement( 'marker', { 'class': 'search' } ),
+			new ViewText( 'o ba' ),
+			new ViewAttributeElement( 'marker', { 'class': 'search' } ),
+			new ViewText( 'r' )
+		] );
+
+		const conversionResult = dispatcher.convert( viewElement, objWithContext );
+
+		const markerSearch = conversionResult.markers.get( 'search' );
+
+		expect( conversionResult.markers.size ).to.equal( 1 );
+		expect( stringify( conversionResult, markerSearch ) ).to.equal( '<paragraph>Fo[o ba]r</paragraph>' );
+	} );
+
+	it( 'should convert from multiple view entities to marker', () => {
+		buildViewConverter().for( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+		buildViewConverter().for( dispatcher )
+			.from( { attribute: { 'foo': 'marker' } } )
+			.from( { attribute: { 'bar': 'marker' } } )
+			.from( { attribute: { 'foo': 'marker', 'bar': 'marker' } } )
+			.toMarker();
 
-		expect( modelToString( result ) ).to.equal( '<paragraph><$text bold="true">aaabbbcccddd</$text></paragraph>' );
+		const viewElement = new ViewContainerElement( 'p', null, [
+			new ViewText( 'Fo' ),
+			new ViewAttributeElement( 'span', { 'foo': 'marker', 'data-name': 'marker1' } ),
+			new ViewText( 'o b' ),
+			new ViewAttributeElement( 'span', { 'bar': 'marker', 'data-name': 'marker2' } ),
+			new ViewText( 'a' ),
+			new ViewAttributeElement( 'span', { 'foo': 'marker', 'bar': 'marker', 'data-name': 'marker3' } ),
+			new ViewText( 'r' )
+		] );
+
+		const conversionResult = dispatcher.convert( viewElement, objWithContext );
+
+		const marker1 = conversionResult.markers.get( 'marker1' );
+		const marker2 = conversionResult.markers.get( 'marker2' );
+		const marker3 = conversionResult.markers.get( 'marker3' );
+
+		expect( conversionResult.markers.size ).to.equal( 3 );
+		expect( stringify( conversionResult, marker1 ) ).to.equal( '<paragraph>Fo[]o bar</paragraph>' );
+		expect( stringify( conversionResult, marker2 ) ).to.equal( '<paragraph>Foo b[]ar</paragraph>' );
+		expect( stringify( conversionResult, marker3 ) ).to.equal( '<paragraph>Foo ba[]r</paragraph>' );
+	} );
+
+	it( 'should do nothing when there is no element matching to marker pattern', () => {
+		buildViewConverter().for( dispatcher ).from( { class: 'color' } ).toMarker();
+
+		const element = new ViewAttributeElement( 'span' );
+
+		expect( dispatcher.convert( element, objWithContext ) ).to.null;
+	} );
+
+	it( 'should throw an error when view element in not valid to convert to marker', () => {
+		buildViewConverter().for( dispatcher ).fromElement( 'marker' ).toMarker();
+
+		const element = new ViewAttributeElement( 'marker', { class: 'search' } );
+
+		expect( () => {
+			dispatcher.convert( element, objWithContext );
+		} ).to.throw( CKEditorError, /^build-view-converter-invalid-marker/ );
+	} );
+
+	it( 'should throw an error when model element returned by creator has not valid name', () => {
+		buildViewConverter().for( dispatcher ).fromElement( 'marker' ).toMarker( () => {
+			return new ModelElement( 'element', { 'data-name': 'search' } );
+		} );
+
+		const element = new ViewAttributeElement( 'marker', { 'data-name': 'search' } );
+
+		expect( () => {
+			dispatcher.convert( element, objWithContext );
+		} ).to.throw( CKEditorError, /^build-view-converter-invalid-marker/ );
+	} );
+
+	it( 'should throw an error when model element returned by creator has not valid data-name attribute', () => {
+		buildViewConverter().for( dispatcher ).fromElement( 'marker' ).toMarker( () => {
+			return new ModelElement( '$marker', { 'foo': 'search' } );
+		} );
+
+		const element = new ViewAttributeElement( 'marker', { 'data-name': 'search' } );
+
+		expect( () => {
+			dispatcher.convert( element, objWithContext );
+		} ).to.throw( CKEditorError, /^build-view-converter-invalid-marker/ );
 	} );
 
 	it( 'should convert from pattern to model element', () => {
@@ -200,7 +323,9 @@ describe( 'View converter builder', () => {
 		let result;
 
 		// Not quite megatron.
-		result = dispatcher.convert( new ViewContainerElement( 'span', { class: 'megatron' }, new ViewText( 'foo' ) ), objWithContext );
+		result = dispatcher.convert(
+			new ViewContainerElement( 'span', { class: 'megatron' }, new ViewText( 'foo' ) ), objWithContext
+		);
 		modelRoot.appendChildren( result );
 		expect( modelToString( result ) ).to.equal( '<span>foo</span>' );
 
@@ -254,10 +379,10 @@ describe( 'View converter builder', () => {
 			new ViewText( 'foo' )
 		);
 
-		let result = dispatcher.convert( viewElement, objWithContext );
+		let conversionResult = dispatcher.convert( viewElement, objWithContext );
 
-		modelRoot.appendChildren( result );
-		expect( modelToString( result ) ).to.equal( '<span transformer="megatron">foo</span>' );
+		modelRoot.appendChildren( conversionResult );
+		expect( modelToString( conversionResult ) ).to.equal( '<span transformer="megatron">foo</span>' );
 	} );
 
 	it( 'should set different priorities for `toElement` and `toAttribute` conversion', () => {
@@ -266,11 +391,13 @@ describe( 'View converter builder', () => {
 			.toAttribute( ( viewElement ) => ( { key: 'class', value: viewElement.getAttribute( 'class' ) } ) );
 		buildViewConverter().for( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
 
-		let result = dispatcher.convert( new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext );
-		modelRoot.appendChildren( result );
+		let conversionResult = dispatcher.convert(
+			new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext
+		);
+		modelRoot.appendChildren( conversionResult );
 
 		// Element converter was fired first even though attribute converter was added first.
-		expect( modelToString( result ) ).to.equal( '<paragraph class="myClass">foo</paragraph>' );
+		expect( modelToString( conversionResult ) ).to.equal( '<paragraph class="myClass">foo</paragraph>' );
 	} );
 
 	it( 'should overwrite default priorities for converters', () => {
@@ -281,7 +408,9 @@ describe( 'View converter builder', () => {
 
 		let result;
 
-		result = dispatcher.convert( new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext );
+		result = dispatcher.convert(
+			new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext
+		);
 		modelRoot.appendChildren( result );
 		expect( modelToString( result ) ).to.equal( '<paragraph class="myClass">foo</paragraph>' );
 
@@ -289,7 +418,9 @@ describe( 'View converter builder', () => {
 			.from( { name: 'p', class: 'myClass' } ).withPriority( 'high' )
 			.toElement( 'customP' );
 
-		result = dispatcher.convert( new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext );
+		result = dispatcher.convert(
+			new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext
+		);
 		modelRoot.appendChildren( result );
 		expect( modelToString( result ) ).to.equal( '<customP>foo</customP>' );
 	} );
@@ -310,13 +441,13 @@ describe( 'View converter builder', () => {
 
 		const viewElement = new ViewContainerElement( 'p', { class: 'decorated small' }, new ViewText( 'foo' ) );
 
-		const result = dispatcher.convert( viewElement, objWithContext );
-		modelRoot.appendChildren( result );
+		const conversionResult = dispatcher.convert( viewElement, objWithContext );
+		modelRoot.appendChildren( conversionResult );
 
 		// P element and it's children got converted by the converter (1) and the converter (1) got fired
 		// because P name was not consumed in converter (2). Converter (3) could consume class="small" because
 		// only class="decorated" was consumed in converter (2).
-		expect( modelToString( result ) ).to.equal( '<paragraph decorated="true" size="small">foo</paragraph>' );
+		expect( modelToString( conversionResult ) ).to.equal( '<paragraph decorated="true" size="small">foo</paragraph>' );
 	} );
 
 	it( 'should convert from matcher instance to model', () => {
@@ -334,10 +465,10 @@ describe( 'View converter builder', () => {
 			new ViewContainerElement( 'abcd', null, new ViewText( 'foo' ) )
 		] );
 
-		let result = dispatcher.convert( viewStructure, objWithContext );
-		modelRoot.appendChildren( result );
+		let conversionResult = dispatcher.convert( viewStructure, objWithContext );
+		modelRoot.appendChildren( conversionResult );
 
-		expect( modelToString( result ) ).to.equal( '<div class="myClass"><abcd>foo</abcd></div>' );
+		expect( modelToString( conversionResult ) ).to.equal( '<div class="myClass"><abcd>foo</abcd></div>' );
 	} );
 
 	it( 'should filter out structure that is wrong with schema - elements', () => {
@@ -354,9 +485,9 @@ describe( 'View converter builder', () => {
 			)
 		);
 
-		let result = dispatcher.convert( viewElement, objWithContext );
+		let conversionResult = dispatcher.convert( viewElement, objWithContext );
 
-		expect( modelToString( result ) ).to.equal( '<paragraph>foo</paragraph>' );
+		expect( modelToString( conversionResult ) ).to.equal( '<paragraph>foo</paragraph>' );
 	} );
 
 	it( 'should filter out structure that is wrong with schema - attributes', () => {
@@ -373,8 +504,8 @@ describe( 'View converter builder', () => {
 			)
 		);
 
-		let result = dispatcher.convert( viewElement, objWithContext );
+		let conversionResult = dispatcher.convert( viewElement, objWithContext );
 
-		expect( modelToString( result ) ).to.equal( '<paragraph>foo</paragraph>' );
+		expect( modelToString( conversionResult ) ).to.equal( '<paragraph>foo</paragraph>' );
 	} );
 } );

+ 130 - 0
packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js

@@ -669,6 +669,21 @@ describe( 'model-to-view-converters', () => {
 			dispatcher.convertMarker( 'removeMarker', 'search', range1 );
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
+
+		it( 'should do nothing when range is collapsed', () => {
+			const viewSpan = new ViewAttributeElement( 'span', { class: 'name' } );
+
+			dispatcher.on( 'addMarker:name', wrapRange( viewSpan ) );
+			dispatcher.on( 'removeMarker:name', unwrapRange( viewSpan ) );
+
+			dispatcher.convertMarker( 'addMarker', 'name', ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+
+			dispatcher.convertMarker( 'removeMarker', 'name', range );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
 	} );
 
 	describe( 'insertUIElement/removeUIElement', () => {
@@ -771,6 +786,121 @@ describe( 'model-to-view-converters', () => {
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 			expect( dispatcher.fire.calledWith( 'removeMarker:marker' ) );
 		} );
+
+		it( 'should not convert or consume if generator function returned null', () => {
+			const viewUi = () => null;
+
+			sinon.spy( dispatcher, 'fire' );
+
+			dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) );
+			dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) );
+
+			dispatcher.on( 'addMarker:marker', ( evt, data, consumable ) => {
+				expect( consumable.test( data.range, 'addMarker' ) ).to.be.true;
+			} );
+
+			dispatcher.on( 'removeMarker:marker', ( evt, data, consumable ) => {
+				expect( consumable.test( data.range, 'removeMarker' ) ).to.be.true;
+			} );
+
+			dispatcher.convertMarker( 'addMarker', 'marker', range );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			expect( dispatcher.fire.calledWith( 'addMarker:marker' ) );
+
+			dispatcher.convertMarker( 'removeMarker', 'marker', range );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			expect( dispatcher.fire.calledWith( 'removeMarker:marker' ) );
+		} );
+
+		it( 'should be possible to overwrite', () => {
+			const viewUi = new ViewUIElement( 'span', { 'class': 'marker' } );
+
+			sinon.spy( dispatcher, 'fire' );
+
+			dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) );
+			dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) );
+
+			dispatcher.on( 'addMarker:marker', ( evt, data, consumable ) => {
+				consumable.consume( data.range, 'addMarker' );
+			}, { priority: 'high' } );
+
+			dispatcher.on( 'removeMarker:marker', ( evt, data, consumable ) => {
+				consumable.consume( data.range, 'removeMarker' );
+			}, { priority: 'high' } );
+
+			dispatcher.convertMarker( 'addMarker', 'marker', range );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			expect( dispatcher.fire.calledWith( 'addMarker:marker' ) );
+
+			dispatcher.convertMarker( 'removeMarker', 'marker', range );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			expect( dispatcher.fire.calledWith( 'removeMarker:marker' ) );
+		} );
+
+		describe( 'non-collapsed range', () => {
+			beforeEach( () => {
+				range = ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 5 );
+			} );
+
+			it( 'should insert and remove ui element - element as a creator', () => {
+				const viewUi = new ViewUIElement( 'span', { 'class': 'marker' } );
+
+				dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) );
+				dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) );
+
+				dispatcher.convertMarker( 'addMarker', 'marker', range );
+
+				expect( viewToString( viewRoot ) )
+					.to.equal( '<div><p>fo<span class="marker"></span>oba<span class="marker"></span>r</p></div>' );
+
+				dispatcher.convertMarker( 'removeMarker', 'marker', range );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			} );
+
+			it( 'should insert and remove ui element - function as a creator', () => {
+				const viewUi = ( data ) => new ViewUIElement( 'span', { 'class': data.name } );
+
+				dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) );
+				dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) );
+
+				dispatcher.convertMarker( 'addMarker', 'marker', range );
+
+				expect( viewToString( viewRoot ) )
+					.to.equal( '<div><p>fo<span class="marker"></span>oba<span class="marker"></span>r</p></div>' );
+
+				dispatcher.convertMarker( 'removeMarker', 'marker', range );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			} );
+
+			it( 'should insert and remove different opening and ending element', () => {
+				function creator( data ) {
+					if ( data.isOpening ) {
+						return new ViewUIElement( 'span', { 'class': data.name, 'data-start': true } );
+					}
+
+					return new ViewUIElement( 'span', { 'class': data.name, 'data-end': true } );
+				}
+
+				dispatcher.on( 'addMarker:marker', insertUIElement( creator ) );
+				dispatcher.on( 'removeMarker:marker', removeUIElement( creator ) );
+
+				dispatcher.convertMarker( 'addMarker', 'marker', range );
+
+				expect( viewToString( viewRoot ) ).to.equal(
+					'<div><p>fo<span class="marker" data-start="true"></span>oba<span class="marker" data-end="true"></span>r</p></div>'
+				);
+
+				dispatcher.convertMarker( 'removeMarker', 'marker', range );
+
+				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			} );
+		} );
 	} );
 
 	describe( 'remove', () => {

+ 29 - 23
packages/ckeditor5-engine/tests/conversion/view-to-model-converters.js

@@ -32,10 +32,11 @@ describe( 'view-to-model-converters', () => {
 
 			dispatcher.on( 'text', convertText() );
 
-			const result = dispatcher.convert( viewText, objWithContext );
+			const conversionResult = dispatcher.convert( viewText, objWithContext );
 
-			expect( result ).to.be.instanceof( ModelText );
-			expect( result.data ).to.equal( 'foobar' );
+			expect( conversionResult ).to.be.instanceof( ModelDocumentFragment );
+			expect( conversionResult.getChild( 0 ) ).to.be.instanceof( ModelText );
+			expect( conversionResult.getChild( 0 ).data ).to.equal( 'foobar' );
 		} );
 
 		it( 'should not convert already consumed texts', () => {
@@ -50,10 +51,11 @@ describe( 'view-to-model-converters', () => {
 				}
 			} );
 
-			const result = dispatcher.convert( viewText, objWithContext );
+			const conversionResult = dispatcher.convert( viewText, objWithContext );
 
-			expect( result ).to.be.instanceof( ModelText );
-			expect( result.data ).to.equal( 'foo****ba****r' );
+			expect( conversionResult ).to.be.instanceof( ModelDocumentFragment );
+			expect( conversionResult.getChild( 0 ) ).to.be.instanceof( ModelText );
+			expect( conversionResult.getChild( 0 ).data ).to.equal( 'foo****ba****r' );
 		} );
 
 		it( 'should not convert text if it is wrong with schema', () => {
@@ -62,13 +64,15 @@ describe( 'view-to-model-converters', () => {
 			const viewText = new ViewText( 'foobar' );
 			dispatcher.on( 'text', convertText() );
 
-			let result = dispatcher.convert( viewText, objWithContext );
+			let conversionResult = dispatcher.convert( viewText, objWithContext );
 
-			expect( result ).to.be.null;
+			expect( conversionResult ).to.be.null;
 
-			result = dispatcher.convert( viewText, { context: [ '$block' ] } );
-			expect( result ).to.be.instanceof( ModelText );
-			expect( result.data ).to.equal( 'foobar' );
+			conversionResult = dispatcher.convert( viewText, { context: [ '$block' ] } );
+
+			expect( conversionResult ).to.be.instanceof( ModelDocumentFragment );
+			expect( conversionResult.getChild( 0 ) ).to.be.instanceof( ModelText );
+			expect( conversionResult.getChild( 0 ).data ).to.equal( 'foobar' );
 		} );
 
 		it( 'should support unicode', () => {
@@ -76,10 +80,11 @@ describe( 'view-to-model-converters', () => {
 
 			dispatcher.on( 'text', convertText() );
 
-			const result = dispatcher.convert( viewText, objWithContext );
+			const conversionResult = dispatcher.convert( viewText, objWithContext );
 
-			expect( result ).to.be.instanceof( ModelText );
-			expect( result.data ).to.equal( 'நிலைக்கு' );
+			expect( conversionResult ).to.be.instanceof( ModelDocumentFragment );
+			expect( conversionResult.getChild( 0 ) ).to.be.instanceof( ModelText );
+			expect( conversionResult.getChild( 0 ).data ).to.equal( 'நிலைக்கு' );
 		} );
 	} );
 
@@ -96,11 +101,11 @@ describe( 'view-to-model-converters', () => {
 			dispatcher.on( 'element', convertToModelFragment() );
 			dispatcher.on( 'documentFragment', convertToModelFragment() );
 
-			const result = dispatcher.convert( viewFragment, objWithContext );
+			const conversionResult = dispatcher.convert( viewFragment, objWithContext );
 
-			expect( result ).to.be.instanceof( ModelDocumentFragment );
-			expect( result.maxOffset ).to.equal( 6 );
-			expect( result.getChild( 0 ).data ).to.equal( 'foobar' );
+			expect( conversionResult ).to.be.instanceof( ModelDocumentFragment );
+			expect( conversionResult.maxOffset ).to.equal( 6 );
+			expect( conversionResult.getChild( 0 ).data ).to.equal( 'foobar' );
 		} );
 
 		it( 'should not convert already consumed (converted) changes', () => {
@@ -121,12 +126,13 @@ describe( 'view-to-model-converters', () => {
 				}
 			} );
 
-			const result = dispatcher.convert( viewP, objWithContext );
+			const conversionResult = dispatcher.convert( viewP, objWithContext );
 
-			expect( result ).to.be.instanceof( ModelElement );
-			expect( result.name ).to.equal( 'paragraph' );
-			expect( result.maxOffset ).to.equal( 3 );
-			expect( result.getChild( 0 ).data ).to.equal( 'foo' );
+			expect( conversionResult ).to.be.instanceof( ModelDocumentFragment );
+			expect( conversionResult.getChild( 0 ) ).to.be.instanceof( ModelElement );
+			expect( conversionResult.getChild( 0 ).name ).to.equal( 'paragraph' );
+			expect( conversionResult.getChild( 0 ).maxOffset ).to.equal( 3 );
+			expect( conversionResult.getChild( 0 ).getChild( 0 ).data ).to.equal( 'foo' );
 		} );
 	} );
 } );

+ 61 - 12
packages/ckeditor5-engine/tests/conversion/viewconversiondispatcher.js

@@ -9,6 +9,11 @@ import ViewAttributeElement from '../../src/view/attributeelement';
 import ViewDocumentFragment from '../../src/view/documentfragment';
 import ViewText from '../../src/view/text';
 
+import ModelText from '../../src/model/text';
+import ModelElement from '../../src/model/element';
+import ModelDocumentFragment from '../../src/model/documentfragment';
+import { stringify } from '../../src/dev-utils/model';
+
 describe( 'ViewConversionDispatcher', () => {
 	describe( 'constructor()', () => {
 		it( 'should create ViewConversionDispatcher with passed api', () => {
@@ -76,10 +81,10 @@ describe( 'ViewConversionDispatcher', () => {
 			} );
 
 			// Use `additionalData` parameter to check if it was passed to the event.
-			const result = dispatcher.convert( viewText, { foo: 'bar' } );
+			const conversionResult = dispatcher.convert( viewText, { foo: 'bar' } );
 
 			// Check conversion result.
-			expect( result ).to.deep.equal( {
+			expect( conversionResult ).to.deep.equal( {
 				eventName: 'text',
 				input: viewText,
 				foo: 'bar'
@@ -110,10 +115,10 @@ describe( 'ViewConversionDispatcher', () => {
 			} );
 
 			// Use `additionalData` parameter to check if it was passed to the event.
-			const result = dispatcher.convert( viewElement, { foo: 'bar' } );
+			const conversionResult = dispatcher.convert( viewElement, { foo: 'bar' } );
 
 			// Check conversion result.
-			expect( result ).to.deep.equal( {
+			expect( conversionResult ).to.deep.equal( {
 				eventName: 'element:p',
 				input: viewElement,
 				foo: 'bar'
@@ -143,15 +148,63 @@ describe( 'ViewConversionDispatcher', () => {
 			} );
 
 			// Use `additionalData` parameter to check if it was passed to the event.
-			const result = dispatcher.convert( viewFragment, { foo: 'bar' } );
+			const conversionResult = dispatcher.convert( viewFragment, { foo: 'bar' } );
 
 			// Check conversion result.
-			expect( result ).to.deep.equal( {
+			expect( conversionResult ).to.deep.equal( {
 				eventName: 'documentFragment',
 				input: viewFragment,
 				foo: 'bar'
 			} );
 		} );
+
+		it( 'should always wrap converted element by ModelDocumentFragment', () => {
+			const viewElement = new ViewContainerElement( 'p' );
+
+			dispatcher.on( 'element', ( evt, data ) => {
+				data.output = new ModelElement( 'paragraph' );
+			} );
+
+			const documentFragment = dispatcher.convert( viewElement, { foo: 'bar' } );
+
+			expect( documentFragment ).to.instanceof( ModelDocumentFragment );
+			expect( stringify( documentFragment ) ).to.equal( '<paragraph></paragraph>' );
+		} );
+
+		it( 'should not wrap ModelDocumentFragment', () => {
+			const viewFragment = new ViewDocumentFragment();
+
+			dispatcher.on( 'documentFragment', ( evt, data ) => {
+				data.output = new ModelDocumentFragment();
+			} );
+
+			const documentFragment = dispatcher.convert( viewFragment );
+
+			expect( documentFragment ).to.instanceof( ModelDocumentFragment );
+			expect( documentFragment.childCount ).to.equal( 0 );
+		} );
+
+		it( 'should extract temporary markers stamps from converter element and create static markers list', () => {
+			const viewFragment = new ViewDocumentFragment();
+
+			dispatcher.on( 'documentFragment', ( evt, data ) => {
+				data.output = new ModelDocumentFragment( [
+					new ModelText( 'fo' ),
+					new ModelElement( '$marker', { 'data-name': 'marker1' } ),
+					new ModelText( 'o' ),
+					new ModelElement( '$marker', { 'data-name': 'marker2' } ),
+					new ModelText( 'b' ),
+					new ModelElement( '$marker', { 'data-name': 'marker1' } ),
+					new ModelText( 'ar' ),
+				] );
+			} );
+
+			const conversionResult = dispatcher.convert( viewFragment );
+
+			expect( conversionResult.markers.size ).to.equal( 2 );
+			expect( stringify( conversionResult, conversionResult.markers.get( 'marker1' ) ) ).to.deep.equal( 'fo[ob]ar' );
+			expect( stringify( conversionResult, conversionResult.markers.get( 'marker2' ) ) ).to.deep.equal( 'foo[]bar' );
+		} );
 	} );
 
 	describe( 'conversionApi#convertItem', () => {
@@ -177,9 +230,7 @@ describe( 'ViewConversionDispatcher', () => {
 				}
 			} );
 
-			const result = dispatcher.convert( viewFragment );
-
-			expect( result ).to.deep.equal( [
+			expect( dispatcher.convert( viewFragment ) ).to.deep.equal( [
 				{ name: 'p' },
 				{ text: 'foobar' }
 			] );
@@ -205,9 +256,7 @@ describe( 'ViewConversionDispatcher', () => {
 				data.output = conversionApi.convertChildren( data.input );
 			} );
 
-			const result = dispatcher.convert( viewFragment );
-
-			expect( result ).to.deep.equal( [
+			expect( dispatcher.convert( viewFragment ) ).to.deep.equal( [
 				{ name: 'p' },
 				{ text: 'foobar' }
 			] );

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

@@ -5,15 +5,20 @@
 
 import Document from '../../../src/model/document';
 import Element from '../../../src/model/element';
-import Position from '../../../src/model/position';
+import DocumentFragment from '../../../src/model/documentfragment';
 import Text from '../../../src/model/text';
+import Position from '../../../src/model/position';
+import Range from '../../../src/model/range';
 
 import InsertOperation from '../../../src/model/operation/insertoperation';
+import MarkerOperation from '../../../src/model/operation/markeroperation';
 import InsertDelta from '../../../src/model/delta/insertdelta';
 
 import RemoveDelta from '../../../src/model/delta/removedelta';
 import RemoveOperation from '../../../src/model/operation/removeoperation';
 
+import { stringify } from '../../../src/dev-utils/model';
+
 describe( 'Batch', () => {
 	let doc, root, batch, p, ul, chain;
 
@@ -52,6 +57,36 @@ describe( 'Batch', () => {
 
 			expect( doc.applyOperation.calledWith( correctDeltaMatcher ) ).to.be.true;
 		} );
+
+		it( 'should transfer markers from given DocumentFragment', () => {
+			const documentFragment = new DocumentFragment( [ new Element( 'li', null, [ new Text( 'foo bar' ) ] ) ] );
+			const marker = new Range( new Position( documentFragment, [ 0, 1 ] ), new Position( documentFragment, [ 0, 5 ] ) );
+
+			documentFragment.markers.set( 'marker', marker );
+
+			batch.insert( new Position( root, [ 3, 0 ] ), documentFragment );
+
+			expect( Array.from( doc.markers ).length ).to.equal( 1 );
+			expect( stringify( root, doc.markers.get( 'marker' ).getRange() ) ).to.equal( 'ab<p></p><ul><li>f[oo b]ar</li></ul>c' );
+		} );
+
+		it( 'should set each marker as separate operation', () => {
+			sinon.spy( doc, 'applyOperation' );
+
+			const documentFragment = new DocumentFragment( [ new Element( 'li', null, [ new Text( 'foo bar' ) ] ) ] );
+			const marker1 = new Range( new Position( documentFragment, [ 0, 1 ] ), new Position( documentFragment, [ 0, 2 ] ) );
+			const marker2 = new Range( new Position( documentFragment, [ 0, 5 ] ), new Position( documentFragment, [ 0, 6 ] ) );
+
+			documentFragment.markers.set( 'marker1', marker1 );
+			documentFragment.markers.set( 'marker2', marker2 );
+
+			batch.insert( new Position( root, [ 3, 0 ] ), documentFragment );
+
+			expect( doc.applyOperation.calledThrice );
+			expect( doc.applyOperation.firstCall.calledWith( sinon.match( ( operation ) => operation instanceof InsertOperation ) ) );
+			expect( doc.applyOperation.secondCall.calledWith( sinon.match( ( operation ) => operation instanceof MarkerOperation ) ) );
+			expect( doc.applyOperation.thirdCall.calledWith( sinon.match( ( operation ) => operation instanceof MarkerOperation ) ) );
+		} );
 	} );
 } );
 

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

@@ -29,6 +29,12 @@ describe( 'DocumentFragment', () => {
 			expect( frag.getChild( 2 ) ).to.have.property( 'data' ).that.equals( 'yy' );
 		} );
 
+		it( 'should have markers list', () => {
+			let frag = new DocumentFragment();
+
+			expect( frag ).to.have.property( 'markers' ).to.instanceof( Map );
+		} );
+
 		it( 'should have root property, equal to itself', () => {
 			let frag = new DocumentFragment();