浏览代码

Merge pull request #7602 from ckeditor/i/7556

Feature (engine): Introduced new marker conversion helpers that produce semantic HTML data output. See `DowncastHelpers#markerToData()` and `UpcastHelpers#dataToMarker()`. Closes #7556.  
Other (engine): Added `model.Schema` instance to downcast conversion API, available under `conversionApi.schema`.  
Other (engine): `UpcastHelpers#elementToMarker()` is now deprecated. Use `UpcastHelpers#dataToMarker()` instead. `DowncastHelpers#markerToElement()` should be used only for editing downcast.

BREAKING CHANGE (engine): Marker names with "," character are now disallowed.
Piotrek Koszuliński 5 年之前
父节点
当前提交
b68d310d7c

+ 4 - 2
docs/framework/guides/architecture/editing-engine.md

@@ -159,9 +159,11 @@ Markers are a special type of ranges.
 * They can only be created and changed through the {@link module:engine/model/writer~Writer model writer}.
 * They can be synchronized over the network with other collaborating clients.
 * They are automatically updated when the document's structure is changed.
-* They can be converted to attributes or elements in the [view](#view).
+* They can be converted to the editing view, to show them in the editor (as {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToHighlight highlights} or {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToElement elements}).
+* They can be {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToData converted to the data view}, to store them with the document data.
+* They can be {@link module:engine/conversion/upcasthelpers~UpcastHelpers#dataToMarker loaded with the document data}.
 
-This makes them ideal for storing and maintaining additional data in the model — such as comments, selections of other users, etc.
+Markers are ideal for storing and maintaining additional data related to portions of the document — such as comments or selections of other users.
 
 ### Schema
 

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

@@ -90,7 +90,8 @@ export default class DataController {
 		 * @member {module:engine/conversion/downcastdispatcher~DowncastDispatcher}
 		 */
 		this.downcastDispatcher = new DowncastDispatcher( {
-			mapper: this.mapper
+			mapper: this.mapper,
+			schema: model.schema
 		} );
 		this.downcastDispatcher.on( 'insert:$text', insertText(), { priority: 'lowest' } );
 

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

@@ -65,7 +65,8 @@ export default class EditingController {
 		 * @member {module:engine/conversion/downcastdispatcher~DowncastDispatcher} #downcastDispatcher
 		 */
 		this.downcastDispatcher = new DowncastDispatcher( {
-			mapper: this.mapper
+			mapper: this.mapper,
+			schema: model.schema
 		} );
 
 		const doc = this.model.document;

+ 6 - 0
packages/ckeditor5-engine/src/conversion/downcastdispatcher.js

@@ -659,6 +659,12 @@ function shouldMarkerChangeBeConverted( modelPosition, marker, mapper ) {
  */
 
 /**
+ * The {@link module:engine/model/schema~Schema} instance set for the model that is downcast.
+ *
+ * @member {module:engine/model/schema~Schema} #schema
+ */
+
+/**
  * The {@link module:engine/view/downcastwriter~DowncastWriter} instance used to manipulate data during conversion.
  *
  * @member {module:engine/view/downcastwriter~DowncastWriter} #writer

+ 283 - 6
packages/ckeditor5-engine/src/conversion/downcasthelpers.js

@@ -239,22 +239,25 @@ export default class DowncastHelpers extends ConversionHelpers {
 	/**
 	 * Model marker to view element conversion helper.
 	 *
+	 * **Note**: This method should be used only for editing downcast. For data downcast, use
+	 * {@link #markerToData `#markerToData()`} that produces valid HTML data.
+	 *
 	 * This conversion results in creating a view element on the boundaries of the converted marker. If the converted marker
 	 * is collapsed, only one element is created. For example, model marker set like this: `<paragraph>F[oo b]ar</paragraph>`
 	 * becomes `<p>F<span data-marker="search"></span>oo b<span data-marker="search"></span>ar</p>` in the view.
 	 *
-	 *		editor.conversion.for( 'downcast' ).markerToElement( {
+	 *		editor.conversion.for( 'editingDowncast' ).markerToElement( {
 	 *			model: 'search',
 	 *			view: 'marker-search'
 	 *		} );
 	 *
-	 *		editor.conversion.for( 'downcast' ).markerToElement( {
+	 *		editor.conversion.for( 'editingDowncast' ).markerToElement( {
 	 *			model: 'search',
 	 *			view: 'search-result',
 	 *			converterPriority: 'high'
 	 *		} );
 	 *
-	 *		editor.conversion.for( 'downcast' ).markerToElement( {
+	 *		editor.conversion.for( 'editingDowncast' ).markerToElement( {
 	 *			model: 'search',
 	 *			view: {
 	 *				name: 'span',
@@ -264,7 +267,7 @@ export default class DowncastHelpers extends ConversionHelpers {
 	 *			}
 	 *		} );
 	 *
-	 *		editor.conversion.for( 'downcast' ).markerToElement( {
+	 *		editor.conversion.for( 'editingDowncast' ).markerToElement( {
 	 *			model: 'search',
 	 *			view: ( markerData, viewWriter ) => {
 	 *				return viewWriter.createUIElement( 'span', {
@@ -282,8 +285,6 @@ export default class DowncastHelpers extends ConversionHelpers {
 	 * the `data.isOpening` parameter is passed, which is set to `true` for the marker start boundary element, and `false` to
 	 * the marker end boundary element.
 	 *
-	 * This kind of conversion is useful for saving data into the database, so it should be used in the data conversion pipeline.
-	 *
 	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
 	 * to the conversion process.
 	 *
@@ -357,6 +358,119 @@ export default class DowncastHelpers extends ConversionHelpers {
 	markerToHighlight( config ) {
 		return this.add( downcastMarkerToHighlight( config ) );
 	}
+
+	/**
+	 * Model marker converter for data downcast.
+	 *
+	 * This conversion creates a representation for model marker boundaries in the view:
+	 *
+	 * * if the marker boundary is at a position where text nodes are allowed, then a view element with specified tag name
+	 * and `name` attribute is added at that position,
+	 * * in other cases, a specified attribute is set on a view element that is before/after marker boundary.
+	 *
+	 * Typically, the marker names use `group:uniqueId:otherData` convention. For example: `comment:e34zfk9k2n459df53sjl34:zx32c`.
+	 * The default configuration for this conversion is that the first part is `group` part and the rest of
+	 * the marker name becomes `name` part.
+	 *
+	 * Tag and attribute names and values are generated from the marker name:
+	 *
+	 * * templates for attributes are `data-[group]-start-before="[name]"`, `data-[group]-start-after="[name]"`,
+	 * `data-[group]-end-before="[name]"` and `data-[group]-end-after="[name]"`,
+	 * * templates for view elements are `<[group]-start name="[name]">` and `<[group]-end name="[name]">`.
+	 *
+	 * Attributes mark whether given marker start or end boundary is before or after given element.
+	 * Attributes `data-[group]-start-before` and `data-[group]-end-after` are favored.
+	 * The other two are used when the former two cannot be used.
+	 *
+	 * The conversion configuration can take a function that will generate different group and name parts.
+	 * If such function is set as the `config.view` parameter, it is passed a marker name and it is expected to return an object with two
+	 * properties: `group` and `name`. If the function returns falsy value, the conversion will not take place.
+	 *
+	 * Basic usage:
+	 *
+	 *		// Using the default conversion.
+	 *		// In this case, all markers which name starts with 'comment:' will be converted.
+	 *		// The `group` parameter will be set to `comment`.
+	 *		// The `name` parameter will be the rest of the marker name (without `:`).
+	 *		editor.conversion.for( 'dataDowncast' ).markerToData( {
+	 *			model: 'comment'
+	 *		} );
+	 *
+	 * An example of a view that may be generated by this conversion (assuming a marker with name `comment:commentId:uid` marked by `[]`):
+	 *
+	 *		// Model:
+	 *		<paragraph>Foo[bar</paragraph>
+	 *		<image src="abc.jpg"></image>]
+	 *
+	 *		// View:
+	 *		<p>Foo<comment-start name="commentId:uid"></comment-start>bar</p>
+	 *		<figure data-comment-end-after="commentId:uid" class="image"><img src="abc.jpg" /></figure>
+	 *
+	 * In the example above, the comment starts before "bar" and ends after the image.
+	 *
+	 * If `name` part is empty, following view may be generated:
+	 *
+	 *		<p>Foo <myMarker-start></myMarker-start>bar</p>
+	 *		<figure data-myMarker-end-after="" class="image"><img src="abc.jpg" /></figure>
+	 *
+	 * **Note:** situation when some markers have `name` part and some don't is incorrect and should be avoided.
+	 *
+	 * Examples where `data-group-start-after` and `data-group-end-before` are used:
+	 *
+	 *		// Model:
+	 *		<blockQuote>[]<paragraph>Foo</paragraph></blockQuote>
+	 *
+	 * 		// View:
+	 *		<blockquote><p data-group-end-before="name" data-group-start-before="name">Foo</p></blockquote>
+	 *
+	 * Similarly, when marker is collapsed after the last element:
+	 *
+	 *		// Model:
+	 *		<blockQuote><paragraph>Foo</paragraph>[]</blockQuote>
+	 *
+	 *		// View:
+	 *		<blockquote><p data-group-end-after="name" data-group-start-after="name">Foo</p></blockquote>
+	 *
+	 * When there are multiple markers from the same group stored in the same attribute of the same element, their
+	 * name parts are put together in the attribute value, for example: `data-group-start-before="name1,name2,name3"`.
+	 *
+	 * Other examples of usage:
+	 *
+	 *		// Using custom function which is the same as the default conversion:
+	 *		editor.conversion.for( 'dataDowncast' ).markerToData( {
+	 *			model: 'comment'
+	 *			view: markerName => ( {
+	 *				group: 'comment',
+	 *				name: markerName.substr( 8 ) // Removes 'comment:' part.
+	 *			} )
+	 *		} );
+	 *
+	 *		// Using converter priority:
+	 *		editor.conversion.for( 'dataDowncast' ).markerToData( {
+	 *			model: 'comment'
+	 *			view: markerName => ( {
+	 *				group: 'comment',
+	 *				name: markerName.substr( 8 ) // Removes 'comment:' part.
+	 *			} ),
+	 *			converterPriority: 'high'
+	 *		} );
+	 *
+	 * This kind of conversion is useful for saving data into the database, so it should be used in the data conversion pipeline.
+	 *
+	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
+	 * to the conversion process.
+	 *
+	 * @method #markerToData
+	 * @param {Object} config Conversion configuration.
+	 * @param {String} config.model The name of the model marker (or model marker group) to convert.
+	 * @param {Function} [config.view] Function that takes the model marker name as a parameter and returns an object with `group`
+	 * and `name` properties.
+	 * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+	 * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers}
+	 */
+	markerToData( config ) {
+		return this.add( downcastMarkerToData( config ) );
+	}
 }
 
 /**
@@ -755,6 +869,141 @@ function removeUIElement() {
 	};
 }
 
+// Function factory that creates a default converter for model markers.
+//
+// See {@link DowncastHelpers#markerToData} for more information what type of view is generated.
+//
+// This converter binds created UI elements and affected view elements with the marker name
+// using {@link module:engine/conversion/mapper~Mapper#bindElementToMarker}.
+//
+// @returns {Function} Add marker converter.
+function insertMarkerData( viewCreator ) {
+	return ( evt, data, conversionApi ) => {
+		const viewMarkerData = viewCreator( data.markerName );
+
+		if ( !viewMarkerData ) {
+			return;
+		}
+
+		const markerRange = data.markerRange;
+
+		if ( !conversionApi.consumable.consume( markerRange, evt.name ) ) {
+			return;
+		}
+
+		// Adding closing data first to keep the proper order in the view.
+		handleMarkerBoundary( markerRange, false, conversionApi, data, viewMarkerData );
+		handleMarkerBoundary( markerRange, true, conversionApi, data, viewMarkerData );
+
+		evt.stop();
+	};
+}
+
+// Helper function for `insertMarkerData()` that marks a marker boundary at the beginning or end of given `range`.
+function handleMarkerBoundary( range, isStart, conversionApi, data, viewMarkerData ) {
+	const modelPosition = isStart ? range.start : range.end;
+	const canInsertElement = conversionApi.schema.checkChild( modelPosition, '$text' );
+
+	if ( canInsertElement ) {
+		const viewPosition = conversionApi.mapper.toViewPosition( modelPosition );
+
+		insertMarkerAsElement( viewPosition, isStart, conversionApi, data, viewMarkerData );
+	} else {
+		let modelElement;
+		let isBefore;
+
+		// If possible, we want to add `data-group-start-before` and `data-group-end-after` attributes.
+		// Below `if` is constructed in a way that will favor adding these attributes.
+		//
+		// Also, I assume that there will be always an element either after or before the position.
+		// If not, then it is a case when we are not in a position where text is allowed and also there are no elements around...
+		if ( isStart && modelPosition.nodeAfter || !isStart && !modelPosition.nodeBefore ) {
+			modelElement = modelPosition.nodeAfter;
+			isBefore = true;
+		} else {
+			modelElement = modelPosition.nodeBefore;
+			isBefore = false;
+		}
+
+		const viewElement = conversionApi.mapper.toViewElement( modelElement );
+
+		insertMarkerAsAttribute( viewElement, isStart, isBefore, conversionApi, data, viewMarkerData );
+	}
+}
+
+// Helper function for `insertMarkerData()` that marks a marker boundary in the view as an attribute on a view element.
+function insertMarkerAsAttribute( viewElement, isStart, isBefore, conversionApi, data, viewMarkerData ) {
+	const attributeName = `data-${ viewMarkerData.group }-${ isStart ? 'start' : 'end' }-${ isBefore ? 'before' : 'after' }`;
+
+	const markerNames = viewElement.hasAttribute( attributeName ) ? viewElement.getAttribute( attributeName ).split( ',' ) : [];
+
+	// Adding marker name at the beginning to have the same order in the attribute as there is with marker elements.
+	markerNames.unshift( viewMarkerData.name );
+
+	conversionApi.writer.setAttribute( attributeName, markerNames.join( ',' ), viewElement );
+	conversionApi.mapper.bindElementToMarker( viewElement, data.markerName );
+}
+
+// Helper function for `insertMarkerData()` that marks a marker boundary in the view as a separate view ui element.
+function insertMarkerAsElement( position, isStart, conversionApi, data, viewMarkerData ) {
+	const viewElementName = `${ viewMarkerData.group }-${ isStart ? 'start' : 'end' }`;
+
+	const attrs = viewMarkerData.name ? { 'name': viewMarkerData.name } : null;
+	const viewElement = conversionApi.writer.createUIElement( viewElementName, attrs );
+
+	conversionApi.writer.insert( position, viewElement );
+	conversionApi.mapper.bindElementToMarker( viewElement, data.markerName );
+}
+
+// Function factory that creates a converter for removing a model marker data added by the {@link #insertMarkerData} converter.
+//
+// @returns {Function} Remove marker converter.
+function removeMarkerData( viewCreator ) {
+	return ( evt, data, conversionApi ) => {
+		const viewData = viewCreator( data.markerName );
+
+		if ( !viewData ) {
+			return;
+		}
+
+		const elements = conversionApi.mapper.markerNameToElements( data.markerName );
+
+		if ( !elements ) {
+			return;
+		}
+
+		for ( const element of elements ) {
+			conversionApi.mapper.unbindElementFromMarkerName( element, data.markerName );
+
+			if ( element.is( 'containerElement' ) ) {
+				removeMarkerFromAttribute( `data-${ viewData.group }-start-before`, element );
+				removeMarkerFromAttribute( `data-${ viewData.group }-start-after`, element );
+				removeMarkerFromAttribute( `data-${ viewData.group }-end-before`, element );
+				removeMarkerFromAttribute( `data-${ viewData.group }-end-after`, element );
+			} else {
+				conversionApi.writer.clear( conversionApi.writer.createRangeOn( element ), element );
+			}
+		}
+
+		conversionApi.writer.clearClonedElementsGroup( data.markerName );
+
+		evt.stop();
+
+		function removeMarkerFromAttribute( attributeName, element ) {
+			if ( element.hasAttribute( attributeName ) ) {
+				const markerNames = new Set( element.getAttribute( attributeName ).split( ',' ) );
+				markerNames.delete( viewData.name );
+
+				if ( markerNames.size == 0 ) {
+					conversionApi.writer.removeAttribute( attributeName, element );
+				} else {
+					conversionApi.writer.setAttribute( attributeName, Array.from( markerNames ).join( ',' ), element );
+				}
+			}
+		}
+	};
+}
+
 // Function factory that creates a converter which converts set/change/remove attribute changes from the model to the view.
 //
 // Attributes from the model are converted to the view element attributes in the view. You may provide a custom function to generate
@@ -1179,6 +1428,34 @@ function downcastMarkerToElement( config ) {
 	};
 }
 
+// Model marker to view data conversion helper.
+//
+// See {@link ~DowncastHelpers#markerToData `markerToData()` downcast helper} to learn more.
+//
+// @param {Object} config
+// @param {String} config.model
+// @param {Function} [config.view]
+// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal']
+// @returns {Function} Conversion helper.
+function downcastMarkerToData( config ) {
+	config = cloneDeep( config );
+
+	const group = config.model;
+
+	// Default conversion.
+	if ( !config.view ) {
+		config.view = markerName => ( {
+			group,
+			name: markerName.substr( config.model.length + 1 )
+		} );
+	}
+
+	return dispatcher => {
+		dispatcher.on( 'addMarker:' + group, insertMarkerData( config.view ), { priority: config.converterPriority || 'normal' } );
+		dispatcher.on( 'removeMarker:' + group, removeMarkerData( config.view ), { priority: config.converterPriority || 'normal' } );
+	};
+}
+
 // Model marker to highlight conversion helper.
 //
 // See {@link ~DowncastHelpers#markerToElement `.markerToElement()` downcast helper} for examples.

+ 194 - 3
packages/ckeditor5-engine/src/conversion/upcasthelpers.js

@@ -9,6 +9,9 @@ import ConversionHelpers from './conversionhelpers';
 
 import { cloneDeep } from 'lodash-es';
 import ModelSelection from '../model/selection';
+import { attachLinkToDocumentation } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+
+/* global console */
 
 /**
  * Contains {@link module:engine/view/view view} to {@link module:engine/model/model model} converters for
@@ -287,6 +290,8 @@ export default class UpcastHelpers extends ConversionHelpers {
 	/**
 	 * View element to model marker conversion helper.
 	 *
+	 * **Note**: This method was deprecated. Please use {@link #dataToMarker} instead.
+	 *
 	 * This conversion results in creating a model marker. For example, if the marker was stored in a view as an element:
 	 * `<p>Fo<span data-marker="comment" data-comment-id="7"></span>o</p><p>B<span data-marker="comment" data-comment-id="7"></span>ar</p>`,
 	 * after the conversion is done, the marker will be available in
@@ -321,6 +326,7 @@ export default class UpcastHelpers extends ConversionHelpers {
 	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
 	 * to the conversion process.
 	 *
+	 * @deprecated
 	 * @method #elementToMarker
 	 * @param {Object} config Conversion configuration.
 	 * @param {module:engine/view/matcher~MatcherPattern} config.view Pattern matching all view elements which should be converted.
@@ -330,8 +336,93 @@ export default class UpcastHelpers extends ConversionHelpers {
 	 * @returns {module:engine/conversion/upcasthelpers~UpcastHelpers}
 	 */
 	elementToMarker( config ) {
+		/**
+		 * The {@link module:engine/conversion/upcasthelpers~UpcastHelpers#elementToMarker `UpcastHelpers#elementToMarker()`}
+		 * method has been deprecated and will be removed in the near future.
+		 * Please use {@link module:engine/conversion/upcasthelpers~UpcastHelpers#dataToMarker `UpcastHelpers#dataToMarker()`} instead.
+		 *
+		 * @error upcast-helpers-element-to-marker-deprecated
+		 */
+		console.warn(
+			attachLinkToDocumentation(
+				'upcast-helpers-element-to-marker-deprecated: ' +
+				'The UpcastHelpers#elementToMarker() method has been deprecated and will be removed in the near future. ' +
+				'Please use UpcastHelpers#dataToMarker() instead.'
+			)
+		);
+
 		return this.add( upcastElementToMarker( config ) );
 	}
+
+	/**
+	 * View to model marker conversion helper.
+	 *
+	 * Converts view data created by {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToData `#markerToData()`}
+	 * back to a model marker.
+	 *
+	 * This converter looks for specific view elements and view attributes that mark marker boundaries. See
+	 * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToData `#markerToData()`} to learn what view data
+	 * is expected by this converter.
+	 *
+	 * The `config.view` property is equal to the marker group name to convert.
+	 *
+	 * By default, this converter creates markers with `group:name` name convention (to match the default `markerToData` conversion).
+	 *
+	 * The conversion configuration can take a function that will generate a marker name.
+	 * If such function is set as the `config.model` parameter, it is passed the `name` part from the view element or attribute and it is
+	 * expected to return a string with the marker name.
+	 *
+	 * Basic usage:
+	 *
+	 *		// Using the default conversion.
+	 *		// In this case, all markers from `comment` group will be converted.
+	 *		// The conversion will look for `<comment-start>` and `<comment-end>` tags and
+	 *		// `data-comment-start-before`, `data-comment-start-after`,
+	 *		// `data-comment-end-before` and `data-comment-end-after` attributes.
+	 *		editor.conversion.for( 'upcast' ).dataToMarker( {
+	 *			view: 'comment'
+	 *		} );
+	 *
+	 * An example of a model that may be generated by this conversion:
+	 *
+	 *		// View:
+	 *		<p>Foo<comment-start name="commentId:uid"></comment-start>bar</p>
+	 *		<figure data-comment-end-after="commentId:uid" class="image"><img src="abc.jpg" /></figure>
+	 *
+	 *		// Model:
+	 *		<paragraph>Foo[bar</paragraph>
+	 *		<image src="abc.jpg"></image>]
+	 *
+	 * Where `[]` are boundaries of a marker that will receive `comment:commentId:uid` name.
+	 *
+	 * Other examples of usage:
+	 *
+	 *		// Using custom function which is the same as the default conversion:
+	 *		editor.conversion.for( 'upcast' ).dataToMarker( {
+	 *			view: 'comment',
+	 *			model: name => 'comment:' + name,
+	 *		} );
+	 *
+	 *		// Using converter priority:
+	 *		editor.conversion.for( 'upcast' ).dataToMarker( {
+	 *			view: 'comment',
+	 *			model: name => 'comment:' + name,
+	 *			converterPriority: 'high'
+	 *		} );
+	 *
+	 * See {@link module:engine/conversion/conversion~Conversion#for `conversion.for()`} to learn how to add a converter
+	 * to the conversion process.
+	 *
+	 * @method #dataToMarker
+	 * @param {Object} config Conversion configuration.
+	 * @param {String} config.view Marker group name to convert.
+	 * @param {Function} [config.model] Function that takes `name` part from the view element or attribute and returns the marker name.
+	 * @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority.
+	 * @returns {module:engine/conversion/upcasthelpers~UpcastHelpers}
+	 */
+	dataToMarker( config ) {
+		return this.add( upcastDataToMarker( config ) );
+	}
 }
 
 /**
@@ -512,11 +603,91 @@ function upcastAttributeToAttribute( config ) {
 function upcastElementToMarker( config ) {
 	config = cloneDeep( config );
 
-	normalizeToMarkerConfig( config );
+	normalizeElementToMarkerConfig( config );
 
 	return upcastElementToElement( config );
 }
 
+// View data to model marker conversion helper.
+//
+// See {@link ~UpcastHelpers#dataToMarker} to learn more.
+//
+// @param {Object} config
+// @param {String} config.view
+// @param {Function} [config.model]
+// @param {module:utils/priorities~PriorityString} [config.converterPriority='normal']
+// @returns {Function} Conversion helper.
+function upcastDataToMarker( config ) {
+	config = cloneDeep( config );
+
+	// Default conversion.
+	if ( !config.model ) {
+		config.model = name => {
+			return name ? config.view + ':' + name : config.view;
+		};
+	}
+
+	const converterStart = prepareToElementConverter( normalizeDataToMarkerConfig( config, 'start' ) );
+	const converterEnd = prepareToElementConverter( normalizeDataToMarkerConfig( config, 'end' ) );
+
+	return dispatcher => {
+		dispatcher.on( 'element:' + config.view + '-start', converterStart, { priority: config.converterPriority || 'normal' } );
+		dispatcher.on( 'element:' + config.view + '-end', converterEnd, { priority: config.converterPriority || 'normal' } );
+
+		// This is attribute upcast so it has to be done after the element upcast.
+		dispatcher.on( 'element', upcastAttributeToMarker( config ), { priority: config.converterPriority || 'low' } );
+	};
+}
+
+// Function factory, returns a callback function which converts view attributes to a model marker.
+//
+// The converter looks for elements with `data-group-start-before`, `data-group-start-after`, `data-group-end-before`
+// and `data-group-end-after` attributes and inserts `$marker` model elements before/after those elements.
+// `group` part is specified in `config.view`.
+//
+// @param {Object} config
+// @param {String} config.view
+// @param {Function} [config.model]
+// @returns {Function} Marker converter.
+function upcastAttributeToMarker( config ) {
+	return ( evt, data, conversionApi ) => {
+		const attrName = `data-${ config.view }`;
+
+		if ( conversionApi.consumable.consume( data.viewItem, { attributes: attrName + '-end-after' } ) ) {
+			addMarkerElements( data.modelRange.end, data.viewItem.getAttribute( attrName + '-end-after' ).split( ',' ) );
+		}
+
+		if ( conversionApi.consumable.consume( data.viewItem, { attributes: attrName + '-start-after' } ) ) {
+			addMarkerElements( data.modelRange.end, data.viewItem.getAttribute( attrName + '-start-after' ).split( ',' ) );
+		}
+
+		if ( conversionApi.consumable.consume( data.viewItem, { attributes: attrName + '-end-before' } ) ) {
+			addMarkerElements( data.modelRange.start, data.viewItem.getAttribute( attrName + '-end-before' ).split( ',' ) );
+		}
+
+		if ( conversionApi.consumable.consume( data.viewItem, { attributes: attrName + '-start-before' } ) ) {
+			addMarkerElements( data.modelRange.start, data.viewItem.getAttribute( attrName + '-start-before' ).split( ',' ) );
+		}
+
+		function addMarkerElements( position, markerViewNames ) {
+			for ( const markerViewName of markerViewNames ) {
+				const markerName = config.model( markerViewName );
+				const element = conversionApi.writer.createElement( '$marker', { 'data-name': markerName } );
+
+				conversionApi.writer.insert( element, position );
+
+				if ( data.modelCursor.isEqual( position ) ) {
+					data.modelCursor = data.modelCursor.getShiftedBy( 1 );
+				} else {
+					data.modelCursor = data.modelCursor._getTransformedByInsertion( position, 1 );
+				}
+
+				data.modelRange = data.modelRange._getTransformedByInsertion( position, 1 )[ 0 ];
+			}
+		}
+	};
+}
+
 // Helper function for from-view-element conversion. Checks if `config.view` directly specifies converted view element's name
 // and if so, returns it.
 //
@@ -779,10 +950,10 @@ function setAttributeOn( modelRange, modelAttribute, shallow, conversionApi ) {
 }
 
 // Helper function for upcasting-to-marker conversion. Takes the config in a format requested by `upcastElementToMarker()`
-// function and converts it to a format that is supported by `_upcastElementToElement()` function.
+// function and converts it to a format that is supported by `upcastElementToElement()` function.
 //
 // @param {Object} config Conversion configuration.
-function normalizeToMarkerConfig( config ) {
+function normalizeElementToMarkerConfig( config ) {
 	const oldModel = config.model;
 
 	config.model = ( viewElement, modelWriter ) => {
@@ -791,3 +962,23 @@ function normalizeToMarkerConfig( config ) {
 		return modelWriter.createElement( '$marker', { 'data-name': markerName } );
 	};
 }
+
+// Helper function for upcasting-to-marker conversion. Takes the config in a format requested by `upcastDataToMarker()`
+// function and converts it to a format that is supported by `upcastElementToElement()` function.
+//
+// @param {Object} config Conversion configuration.
+function normalizeDataToMarkerConfig( config, type ) {
+	const configForElements = {};
+
+	// Upcast <markerGroup-start> and <markerGroup-end> elements.
+	configForElements.view = config.view + '-' + type;
+
+	configForElements.model = ( viewElement, modelWriter ) => {
+		const viewName = viewElement.getAttribute( 'name' );
+		const markerName = config.model( viewName );
+
+		return modelWriter.createElement( '$marker', { 'data-name': markerName } );
+	};
+
+	return configForElements;
+}

+ 10 - 0
packages/ckeditor5-engine/src/model/markercollection.js

@@ -92,6 +92,16 @@ export default class MarkerCollection {
 	 */
 	_set( markerOrName, range, managedUsingOperations = false, affectsData = false ) {
 		const markerName = markerOrName instanceof Marker ? markerOrName.name : markerOrName;
+
+		if ( markerName.includes( ',' ) ) {
+			/**
+			 * Marker name cannot contain the "," character.
+			 *
+			 * @error markercollection-incorrect-marker-name
+			 */
+			throw new CKEditorError( 'markercollection-incorrect-marker-name: Marker name cannot contain "," character.', this );
+		}
+
 		const oldMarker = this._markers.get( markerName );
 
 		if ( oldMarker ) {

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

@@ -342,8 +342,8 @@ describe( 'DataController', () => {
 
 			data.set( 'foo' );
 
-			downcastHelpers.markerToElement( { model: 'marker', view: 'marker' } );
-			upcastHelpers.elementToMarker( { view: 'marker', model: 'marker' } );
+			downcastHelpers.markerToData( { model: 'marker' } );
+			upcastHelpers.dataToMarker( { view: 'marker' } );
 
 			model.change( writer => {
 				writer.addMarker( 'marker', { range: writer.createRangeIn( modelDocument.getRoot() ), usingOperation: true } );

+ 481 - 0
packages/ckeditor5-engine/tests/conversion/downcasthelpers.js

@@ -996,6 +996,487 @@ describe( 'DowncastHelpers', () => {
 		} );
 	} );
 
+	describe( 'markerToData()', () => {
+		let root;
+
+		beforeEach( () => {
+			root = model.document.getRoot();
+
+			model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
+			downcastHelpers.elementToElement( { model: 'paragraph', view: 'p' } );
+		} );
+
+		it( 'should be chainable', () => {
+			expect( downcastHelpers.markerToData( { model: 'search' } ) ).to.equal( downcastHelpers );
+		} );
+
+		it( 'default conversion, inside text, non-collapsed, no name', () => {
+			downcastHelpers.markerToData( { model: 'search' } );
+
+			setModelData( model, '<paragraph>Fo[ob]ar</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'search', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult( '<p>Fo<search-start></search-start>ob<search-end></search-end>ar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'search' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, inside text, non-collapsed, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Fo[ob]ar</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'group:foo:bar:baz', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult( '<p>Fo<group-start name="foo:bar:baz"></group-start>ob<group-end name="foo:bar:baz"></group-end>ar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar:baz' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, inside text, collapsed, no name', () => {
+			downcastHelpers.markerToData( { model: 'search' } );
+
+			setModelData( model, '<paragraph>Foo[]bar</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'search', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult( '<p>Foo<search-start></search-start><search-end></search-end>bar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'search' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, inside text, collapsed, multiple markers, no name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foo[]bar</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'group:foo', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+				writer.addMarker( 'group:abc', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p>' +
+					'Foo' +
+					'<group-start name="abc"></group-start><group-end name="abc"></group-end>' +
+					'<group-start name="foo"></group-start><group-end name="foo"></group-end>' +
+					'bar' +
+				'</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo' );
+				writer.removeMarker( 'group:abc' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, on two elements, no name', () => {
+			downcastHelpers.markerToData( { model: 'search' } );
+
+			setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRangeIn( root );
+				writer.addMarker( 'search', { range, usingOperation: false } );
+			} );
+
+			expectResult( '<p data-search-start-before="">Foo</p><p data-search-end-after="">Bar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'search' );
+			} );
+
+			expectResult( '<p>Foo</p><p>Bar</p>' );
+		} );
+
+		it( 'default conversion, on two elements, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRangeIn( root );
+				writer.addMarker( 'group:foo:bar:baz', { range, usingOperation: false } );
+			} );
+
+			expectResult( '<p data-group-start-before="foo:bar:baz">Foo</p><p data-group-end-after="foo:bar:baz">Bar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar:baz' );
+			} );
+
+			expectResult( '<p>Foo</p><p>Bar</p>' );
+		} );
+
+		it( 'default conversion, on one element, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foobar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRangeIn( root );
+				writer.addMarker( 'group:foo:bar:baz', { range, usingOperation: false } );
+			} );
+
+			expectResult( '<p data-group-end-after="foo:bar:baz" data-group-start-before="foo:bar:baz">Foobar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar:baz' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, collapsed before element, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foobar</paragraph>' );
+
+			model.change( writer => {
+				// Collapsed before <paragraph>.
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0 ] )
+				);
+
+				writer.addMarker( 'group:foo:bar:baz', { range, usingOperation: false } );
+			} );
+
+			expectResult( '<p data-group-end-before="foo:bar:baz" data-group-start-before="foo:bar:baz">Foobar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar:baz' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, collapsed after element, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foobar</paragraph>' );
+
+			model.change( writer => {
+				// Collapsed before <paragraph>.
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 1 ] )
+				);
+
+				writer.addMarker( 'group:foo:bar:baz', { range, usingOperation: false } );
+			} );
+
+			expectResult( '<p data-group-end-after="foo:bar:baz" data-group-start-after="foo:bar:baz">Foobar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar:baz' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'default conversion, mixed, multiple markers, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0 ] ),
+					writer.createPositionFromPath( root, [ 1, 2 ] )
+				);
+
+				writer.addMarker( 'group:foo:bar', { range, usingOperation: false } );
+				writer.addMarker( 'group:abc:xyz', { range, usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p data-group-start-before="abc:xyz,foo:bar">Foo</p>' +
+				'<p>Ba<group-end name="abc:xyz"></group-end><group-end name="foo:bar"></group-end>r</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar' );
+				writer.removeMarker( 'group:abc:xyz' );
+			} );
+
+			expectResult( '<p>Foo</p><p>Bar</p>' );
+		} );
+
+		it( 'default conversion, mixed #2, multiple markers, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0, 1 ] ),
+					writer.createPositionFromPath( root, [ 2 ] )
+				);
+
+				writer.addMarker( 'group:foo:bar', { range, usingOperation: false } );
+				writer.addMarker( 'group:abc:xyz', { range, usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p>F<group-start name="abc:xyz"></group-start><group-start name="foo:bar"></group-start>oo</p>' +
+				'<p data-group-end-after="abc:xyz,foo:bar">Bar</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar' );
+				writer.removeMarker( 'group:abc:xyz' );
+			} );
+
+			expectResult( '<p>Foo</p><p>Bar</p>' );
+		} );
+
+		it( 'default conversion, mixed #3, multiple markers, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foo</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0 ] ),
+					writer.createPositionFromPath( root, [ 0, 2 ] )
+				);
+
+				writer.addMarker( 'group:foo:bar', { range, usingOperation: false } );
+				writer.addMarker( 'group:abc:xyz', { range, usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p data-group-start-before="abc:xyz,foo:bar">' +
+					'Fo<group-end name="abc:xyz"></group-end><group-end name="foo:bar"></group-end>o' +
+				'</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar' );
+				writer.removeMarker( 'group:abc:xyz' );
+			} );
+
+			expectResult( '<p>Foo</p>' );
+		} );
+
+		it( 'default conversion, mixed #4, multiple markers, name', () => {
+			downcastHelpers.markerToData( { model: 'group' } );
+
+			setModelData( model, '<paragraph>Foo</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0, 2 ] ),
+					writer.createPositionFromPath( root, [ 1 ] )
+				);
+
+				writer.addMarker( 'group:foo:bar', { range, usingOperation: false } );
+				writer.addMarker( 'group:abc:xyz', { range, usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p data-group-end-after="abc:xyz,foo:bar">' +
+					'Fo<group-start name="abc:xyz"></group-start><group-start name="foo:bar"></group-start>o' +
+				'</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo:bar' );
+				writer.removeMarker( 'group:abc:xyz' );
+			} );
+
+			expectResult( '<p>Foo</p>' );
+		} );
+
+		it( 'conversion callback, mixed, multiple markers, name', () => {
+			const customData = {
+				foo: 'bar',
+				abc: 'xyz'
+			};
+
+			downcastHelpers.markerToData( {
+				model: 'group',
+				view: markerName => {
+					const namePart = markerName.split( ':' )[ 1 ];
+
+					return {
+						group: 'g',
+						name: namePart + '_' + customData[ namePart ]
+					};
+				}
+			} );
+
+			setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0 ] ),
+					writer.createPositionFromPath( root, [ 1, 2 ] )
+				);
+
+				writer.addMarker( 'group:foo', { range, usingOperation: false } );
+				writer.addMarker( 'group:abc', { range, usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p data-g-start-before="abc_xyz,foo_bar">Foo</p>' +
+				'<p>Ba<g-end name="abc_xyz"></g-end><g-end name="foo_bar"></g-end>r</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo' );
+				writer.removeMarker( 'group:abc' );
+			} );
+
+			expectResult( '<p>Foo</p><p>Bar</p>' );
+		} );
+
+		it( 'conversion callback, mixed #2, multiple markers, name', () => {
+			const customData = {
+				foo: 'bar',
+				abc: 'xyz'
+			};
+
+			downcastHelpers.markerToData( {
+				model: 'group',
+				view: markerName => {
+					const namePart = markerName.split( ':' )[ 1 ];
+
+					return {
+						group: 'g',
+						name: namePart + '_' + customData[ namePart ]
+					};
+				}
+			} );
+
+			setModelData( model, '<paragraph>Foo</paragraph><paragraph>Bar</paragraph>' );
+
+			model.change( writer => {
+				const range = writer.createRange(
+					writer.createPositionFromPath( root, [ 0, 1 ] ),
+					writer.createPositionFromPath( root, [ 2 ] )
+				);
+
+				writer.addMarker( 'group:foo', { range, usingOperation: false } );
+				writer.addMarker( 'group:abc', { range, usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p>F<g-start name="abc_xyz"></g-start><g-start name="foo_bar"></g-start>oo</p>' +
+				'<p data-g-end-after="abc_xyz,foo_bar">Bar</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo' );
+				writer.removeMarker( 'group:abc' );
+			} );
+
+			expectResult( '<p>Foo</p><p>Bar</p>' );
+		} );
+
+		it( 'can be overwritten using converterPriority', () => {
+			downcastHelpers.markerToData( {
+				model: 'group'
+			} );
+
+			downcastHelpers.markerToData( {
+				model: 'group',
+				view: markerName => {
+					const name = markerName.split( ':' )[ 1 ];
+
+					return {
+						group: 'g',
+						name
+					};
+				},
+				converterPriority: 'high'
+			} );
+
+			setModelData( model, '<paragraph>F[ooba]r</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'group:foo', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult(
+				'<p>F<g-start name="foo"></g-start>ooba<g-end name="foo"></g-end>r</p>'
+			);
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'can be overwritten by custom callback', () => {
+			downcastHelpers.markerToData( {
+				model: 'group'
+			} );
+
+			downcastHelpers.add( dispatcher => {
+				dispatcher.on( 'addMarker:group', ( evt, data, conversionApi ) => {
+					conversionApi.consumable.consume( data.markerRange, evt.name );
+				}, { priority: 'high' } );
+			} );
+
+			setModelData( model, '<paragraph>Foo[]bar</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'group:foo', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+
+		it( 'should not perform conversion if the callback returned falsy value', () => {
+			downcastHelpers.markerToData( {
+				model: 'group',
+				view: () => false
+			} );
+
+			setModelData( model, '<paragraph>F[ooba]r</paragraph>' );
+
+			model.change( writer => {
+				writer.addMarker( 'group:foo', { range: model.document.selection.getFirstRange(), usingOperation: false } );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+
+			model.change( writer => {
+				writer.removeMarker( 'group:foo' );
+			} );
+
+			expectResult( '<p>Foobar</p>' );
+		} );
+	} );
+
 	describe( 'markerToHighlight()', () => {
 		it( 'should be chainable', () => {
 			expect( downcastHelpers.markerToHighlight( { model: 'comment', view: { classes: 'comment' } } ) ).to.equal( downcastHelpers );

+ 196 - 11
packages/ckeditor5-engine/tests/conversion/upcasthelpers.js

@@ -24,12 +24,14 @@ import UpcastHelpers, { convertToModelFragment, convertText, convertSelectionCha
 import { getData as modelGetData, setData as modelSetData, stringify } from '../../src/dev-utils/model';
 import View from '../../src/view/view';
 import createViewRoot from '../view/_utils/createroot';
-import { setData as viewSetData } from '../../src/dev-utils/view';
+import { setData as viewSetData, parse as viewParse } from '../../src/dev-utils/view';
 import Mapper from '../../src/conversion/mapper';
 import ViewSelection from '../../src/view/selection';
 import ViewRange from '../../src/view/range';
 import { StylesProcessor } from '../../src/view/stylesmap';
 
+/* globals console */
+
 describe( 'UpcastHelpers', () => {
 	let upcastDispatcher, model, schema, upcastHelpers, viewDocument;
 
@@ -56,7 +58,7 @@ describe( 'UpcastHelpers', () => {
 		upcastHelpers = new UpcastHelpers( [ upcastDispatcher ] );
 	} );
 
-	describe( '.elementToElement()', () => {
+	describe( 'elementToElement()', () => {
 		it( 'should be chainable', () => {
 			expect( upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } ) ).to.equal( upcastHelpers );
 		} );
@@ -168,7 +170,7 @@ describe( 'UpcastHelpers', () => {
 		} );
 	} );
 
-	describe( '.elementToAttribute()', () => {
+	describe( 'elementToAttribute()', () => {
 		it( 'should be chainable', () => {
 			expect( upcastHelpers.elementToAttribute( { view: 'strong', model: 'bold' } ) ).to.equal( upcastHelpers );
 		} );
@@ -369,7 +371,7 @@ describe( 'UpcastHelpers', () => {
 		} );
 	} );
 
-	describe( '.attributeToAttribute()', () => {
+	describe( 'attributeToAttribute()', () => {
 		beforeEach( () => {
 			upcastHelpers.elementToElement( { view: 'img', model: 'image' } );
 
@@ -584,7 +586,17 @@ describe( 'UpcastHelpers', () => {
 		} );
 	} );
 
-	describe( '.elementToMarker()', () => {
+	describe( 'elementToMarker()', () => {
+		beforeEach( () => {
+			// Silence warning about deprecated method.
+			// This whole suite will be removed when the deprecated method is removed.
+			sinon.stub( console, 'warn' );
+		} );
+
+		afterEach( () => {
+			console.warn.restore();
+		} );
+
 		it( 'should be chainable', () => {
 			expect( upcastHelpers.elementToMarker( { view: 'marker-search', model: 'search' } ) ).to.equal( upcastHelpers );
 		} );
@@ -682,16 +694,189 @@ describe( 'UpcastHelpers', () => {
 		} );
 	} );
 
-	function expectResult( viewToConvert, modelString, marker ) {
+	describe( 'dataToMarker()', () => {
+		beforeEach( () => {
+			upcastHelpers.elementToElement( { view: 'p', model: 'paragraph' } );
+		} );
+
+		it( 'should be chainable', () => {
+			expect( upcastHelpers.dataToMarker( { view: 'search' } ) ).to.equal( upcastHelpers );
+		} );
+
+		it( 'default conversion, inside text, non-collapsed, no name', () => {
+			upcastHelpers.dataToMarker( { view: 'search' } );
+
+			expectResult(
+				viewParse( '<p>Fo<search-start></search-start>ob<search-end></search-end>ar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'search', start: [ 0, 2 ], end: [ 0, 4 ] }
+			);
+		} );
+
+		it( 'default conversion, inside text, non-collapsed, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse( '<p>Fo<group-start name="foo:bar:baz"></group-start>ob<group-end name="foo:bar:baz"></group-end>ar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'group:foo:bar:baz', start: [ 0, 2 ], end: [ 0, 4 ] }
+			);
+		} );
+
+		it( 'default conversion, inside text, collapsed, no name', () => {
+			upcastHelpers.dataToMarker( { view: 'search' } );
+
+			expectResult(
+				viewParse( '<p>Foo<search-start></search-start><search-end></search-end>bar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'search', start: [ 0, 3 ], end: [ 0, 3 ] }
+			);
+		} );
+
+		it( 'default conversion, inside text, collapsed, multiple markers, no name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse(
+					'<p>' +
+						'Foo' +
+						'<group-start name="abc"></group-start><group-end name="abc"></group-end>' +
+						'<group-start name="foo"></group-start><group-end name="foo"></group-end>' +
+						'bar' +
+					'</p>'
+				),
+				'<paragraph>Foobar</paragraph>',
+				[
+					{ name: 'group:abc', start: [ 0, 3 ], end: [ 0, 3 ] },
+					{ name: 'group:foo', start: [ 0, 3 ], end: [ 0, 3 ] }
+				]
+			);
+		} );
+
+		it( 'default conversion, on two elements, no name', () => {
+			upcastHelpers.dataToMarker( { view: 'search' } );
+
+			expectResult(
+				viewParse( '<p data-search-start-before="">Foo</p><p data-search-end-after="">Bar</p>' ),
+				'<paragraph>Foo</paragraph><paragraph>Bar</paragraph>',
+				{ name: 'search', start: [ 0 ], end: [ 2 ] }
+			);
+		} );
+
+		it( 'default conversion, on two elements, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse( '<p data-group-start-before="foo:bar:baz">Foo</p><p data-group-end-after="foo:bar:baz">Bar</p>' ),
+				'<paragraph>Foo</paragraph><paragraph>Bar</paragraph>',
+				{ name: 'group:foo:bar:baz', start: [ 0 ], end: [ 2 ] }
+			);
+		} );
+
+		it( 'default conversion, on one element, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse( '<p data-group-end-after="foo:bar:baz" data-group-start-before="foo:bar:baz">Foobar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'group:foo:bar:baz', start: [ 0 ], end: [ 1 ] }
+			);
+		} );
+
+		it( 'default conversion, collapsed before element, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse( '<p data-group-end-before="foo:bar:baz" data-group-start-before="foo:bar:baz">Foobar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'group:foo:bar:baz', start: [ 0 ], end: [ 0 ] }
+			);
+		} );
+
+		it( 'default conversion, collapsed after element, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse( '<p data-group-end-after="foo:bar:baz" data-group-start-after="foo:bar:baz">Foobar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'group:foo:bar:baz', start: [ 1 ], end: [ 1 ] }
+			);
+		} );
+
+		it( 'default conversion, mixed, multiple markers, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse(
+					'<p data-group-start-before="abc:xyz,foo:bar">Foo</p>' +
+					'<p>Ba<group-end name="abc:xyz"></group-end><group-end name="foo:bar"></group-end>r</p>'
+				),
+				'<paragraph>Foo</paragraph><paragraph>Bar</paragraph>',
+				[
+					{ name: 'group:foo:bar', start: [ 0 ], end: [ 1, 2 ] },
+					{ name: 'group:abc:xyz', start: [ 0 ], end: [ 1, 2 ] }
+				]
+			);
+		} );
+
+		it( 'default conversion, mixed #2, multiple markers, name', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+
+			expectResult(
+				viewParse(
+					'<p>F<group-start name="abc:xyz"></group-start><group-start name="foo:bar"></group-start>oo</p>' +
+					'<p data-group-end-after="abc:xyz,foo:bar">Bar</p>'
+				),
+				'<paragraph>Foo</paragraph><paragraph>Bar</paragraph>',
+				[
+					{ name: 'group:foo:bar', start: [ 0, 1 ], end: [ 2 ] },
+					{ name: 'group:abc:xyz', start: [ 0, 1 ], end: [ 2 ] }
+				]
+			);
+		} );
+
+		it( 'conversion callback, mixed, multiple markers, name', () => {
+			upcastHelpers.dataToMarker( { view: 'g', model: name => 'group:' + name.split( '_' )[ 0 ] } );
+
+			expectResult(
+				viewParse(
+					'<p data-g-start-before="abc_xyz,foo_bar">Foo</p>' +
+					'<p>Ba<g-end name="abc_xyz"></g-end><g-end name="foo_bar"></g-end>r</p>'
+				),
+				'<paragraph>Foo</paragraph><paragraph>Bar</paragraph>',
+				[
+					{ name: 'group:foo', start: [ 0 ], end: [ 1, 2 ] },
+					{ name: 'group:abc', start: [ 0 ], end: [ 1, 2 ] }
+				]
+			);
+		} );
+
+		it( 'can be overwritten using converterPriority', () => {
+			upcastHelpers.dataToMarker( { view: 'group' } );
+			upcastHelpers.dataToMarker( { view: 'group', model: name => 'g:' + name, converterPriority: 'high' } );
+
+			expectResult(
+				viewParse( '<p>Foo<group-start name="foo"></group-start><group-end name="foo"></group-end>bar</p>' ),
+				'<paragraph>Foobar</paragraph>',
+				{ name: 'g:foo', start: [ 0, 3 ], end: [ 0, 3 ] }
+			);
+		} );
+	} );
+
+	function expectResult( viewToConvert, modelString, markers ) {
 		const conversionResult = model.change( writer => upcastDispatcher.convert( viewToConvert, writer ) );
 
-		if ( marker ) {
-			expect( conversionResult.markers.has( marker.name ) ).to.be.true;
+		if ( markers ) {
+			markers = Array.isArray( markers ) ? markers : [ markers ];
+
+			for ( const marker of markers ) {
+				expect( conversionResult.markers.has( marker.name ) ).to.be.true;
 
-			const convertedMarker = conversionResult.markers.get( marker.name );
+				const convertedMarker = conversionResult.markers.get( marker.name );
 
-			expect( convertedMarker.start.path ).to.deep.equal( marker.start );
-			expect( convertedMarker.end.path ).to.deep.equal( marker.end );
+				expect( convertedMarker.start.path ).to.deep.equal( marker.start );
+				expect( convertedMarker.end.path ).to.deep.equal( marker.end );
+			}
 		}
 
 		expect( stringify( conversionResult ) ).to.equal( modelString );

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

@@ -123,6 +123,12 @@ describe( 'MarkerCollection', () => {
 
 			expect( marker.getRange().isEqual( range2 ) ).to.be.true;
 		} );
+
+		it( 'should throw if marker name with "," is added', () => {
+			expectToThrowCKEditorError( () => {
+				markers._set( 'foo,bar', range );
+			}, /^markercollection-incorrect-marker-name:/, markers );
+		} );
 	} );
 
 	describe( 'has', () => {