Przeglądaj źródła

Feature: Introduced `markerToData()` and `dataToMarker()` conversion helpers.

Szymon Cofalik 5 lat temu
rodzic
commit
4c3d0f7cd1

+ 273 - 0
packages/ckeditor5-engine/src/conversion/downcasthelpers.js

@@ -360,6 +360,113 @@ 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 case, a specified attribute is set on an view element that is before/after marker boundary.
+	 *
+	 * Tag and attribute names and values are generated from the marker name:
+	 *
+	 * * template for attributes are `data-group-start-before="name"`,`data-group-start-after="name"`,
+	 * `data-group-end-before="name"` and `data-group-end-after="name"`,
+	 * * template 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. Other two are used when former two cannot be used.
+	 *
+	 * 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.
+	 *
+	 * 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.
+	 *
+	 * An example of a view that may be generated by this conversion:
+	 *
+	 *		// 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>
+	 *
+	 * 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>
+	 *
+	 * **Note:** situation when some markers have `name` part and some don't is incorrect and should be avoided.
+	 *
+	 * When there are multiple markers from the same group starting before/ending after the same view container element, their
+	 * name parts are put together in one attribute value, for example: `data-group-start="name1,name2,name3"`.
+	 *
+	 * Examples of usage:
+	 *
+	 *		// Using the default conversion:
+	 *		editor.conversion.for( 'dataDowncast' ).markerToData( {
+	 *			model: 'comment'
+	 *		} );
+	 *
+	 *		// Using custom function which is the same as the default conversion:
+	 *		editor.conversion.for( 'downcast' ).markerToData( {
+	 *			model: 'comment'
+	 *			view: markerName => ( {
+	 *				group: 'comment',
+	 *				name: markerName.substr( config.model.length + 1 )
+	 *			} )
+	 *		} );
+	 *
+	 *		// Using converter priority:
+	 *		editor.conversion.for( 'downcast' ).markerToData( {
+	 *			model: 'comment'
+	 *			view: markerName => ( {
+	 *				group: 'comment',
+	 *				name: markerName.substr( config.model.length + 1 )
+	 *			} ),
+	 *			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 data 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 ) );
+	}
 }
 
 /**
@@ -758,6 +865,144 @@ 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 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 viewData = viewCreator( data.markerName );
+
+		if ( !viewData ) {
+			return;
+		}
+
+		const markerRange = data.markerRange;
+
+		// Marker that is collapsed has consumable build differently that non-collapsed one.
+		// For more information see `addMarker` event description.
+		// If marker's range is collapsed - check if it can be consumed.
+		if ( !conversionApi.consumable.consume( markerRange, evt.name ) ) {
+			return;
+		}
+
+		const mapper = conversionApi.mapper;
+		const viewWriter = conversionApi.writer;
+
+		// Adding closing data first to keep proper order in the view.
+		// If <group-start> is added first, then <group-end> is added before it.
+		handleMarkerBoundary( markerRange, false );
+		handleMarkerBoundary( markerRange, true );
+
+		evt.stop();
+
+		function handleMarkerBoundary( range, isStart ) {
+			const modelPosition = isStart ? range.start : range.end;
+			const canInsertElement = conversionApi.schema.checkChild( modelPosition, '$text' );
+
+			if ( canInsertElement ) {
+				const viewPosition = mapper.toViewPosition( modelPosition );
+
+				insertMarkerAsElement( viewPosition, isStart );
+			} 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 = mapper.toViewElement( modelElement );
+
+				insertMarkerAsAttribute( viewElement, isStart, isBefore );
+			}
+		}
+
+		function insertMarkerAsAttribute( viewElement, isStart, isBefore ) {
+			const attributeName = `data-${ viewData.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( viewData.name );
+
+			viewWriter.setAttribute( attributeName, markerNames.join( ',' ), viewElement );
+			conversionApi.mapper.bindElementToMarker( viewElement, data.markerName );
+		}
+
+		function insertMarkerAsElement( position, isStart ) {
+			const viewElementName = `${ viewData.group }-${ isStart ? 'start' : 'end' }`;
+
+			const attrs = viewData.name ? { 'name': viewData.name } : null;
+			const viewElement = viewWriter.createUIElement( viewElementName, attrs );
+
+			viewWriter.insert( position, viewElement );
+			conversionApi.mapper.bindElementToMarker( viewElement, data.markerName );
+		}
+	};
+}
+
+// Function factory that creates a default converter for removing a model marker that was added by {@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
@@ -1182,6 +1427,34 @@ function downcastMarkerToElement( config ) {
 	};
 }
 
+// Model marker to view conversion helper.
+//
+// See {@link ~DowncastHelpers#markerToData `.markerToData()` downcast helper} for examples.
+//
+// @param {Object} config Conversion configuration.
+// @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.

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

@@ -335,6 +335,54 @@ export default class UpcastHelpers extends ConversionHelpers {
 	elementToMarker( config ) {
 		return this.add( upcastElementToMarker( config ) );
 	}
+
+	/**
+	 * View to model marker conversion helper.
+	 *
+	 * Converts view data created by {@link module:engine/conversion/downcasthelpers~DowncastHelpers#markerToData} back to a model marker.
+	 *
+	 * This converter looks for specific view elements and view attributes that marks marker boundaries. See
+	 * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#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.
+	 *
+	 *		// Using the default conversion.
+	 *		editor.conversion.for( 'upcast' ).dataToMarker( {
+	 *			view: 'comment'
+	 *		} );
+	 *
+	 *		// 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 ) );
+	}
 }
 
 /**
@@ -515,11 +563,90 @@ 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' } );
+
+		dispatcher.on( 'element', upcastAttributeToMarker( config ), { priority: config.converterPriority || 'normal' } );
+	};
+}
+
+// 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.
 //
@@ -782,10 +909,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 ) => {
@@ -794,3 +921,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;
+}

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

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

@@ -24,7 +24,7 @@ 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';
@@ -56,7 +56,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 +168,7 @@ describe( 'UpcastHelpers', () => {
 		} );
 	} );
 
-	describe( '.elementToAttribute()', () => {
+	describe( 'elementToAttribute()', () => {
 		it( 'should be chainable', () => {
 			expect( upcastHelpers.elementToAttribute( { view: 'strong', model: 'bold' } ) ).to.equal( upcastHelpers );
 		} );
@@ -369,7 +369,7 @@ describe( 'UpcastHelpers', () => {
 		} );
 	} );
 
-	describe( '.attributeToAttribute()', () => {
+	describe( 'attributeToAttribute()', () => {
 		beforeEach( () => {
 			upcastHelpers.elementToElement( { view: 'img', model: 'image' } );
 
@@ -584,7 +584,7 @@ describe( 'UpcastHelpers', () => {
 		} );
 	} );
 
-	describe( '.elementToMarker()', () => {
+	describe( 'elementToMarker()', () => {
 		it( 'should be chainable', () => {
 			expect( upcastHelpers.elementToMarker( { view: 'marker-search', model: 'search' } ) ).to.equal( upcastHelpers );
 		} );
@@ -682,16 +682,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 );