8
0
Просмотр исходного кода

Changed: Introduced conversion helpers.

Szymon Cofalik 8 лет назад
Родитель
Сommit
64a0ee7355

+ 322 - 0
packages/ckeditor5-engine/src/conversion/definition-conversion.js

@@ -0,0 +1,322 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module engine/conversion/definition-conversion
+ */
+
+import {
+	elementToElement as mtvElementToElement,
+	attributeToElement as mtvAttributeToElement,
+	attributeToAttribute as mtvAttributeToAttribute
+} from './model-to-view-helpers';
+
+import {
+	elementToElement as vtmElementToElement,
+	elementToAttribute as vtmElementToAttribute,
+	attributeToAttribute as vtmAttributeToAttribute
+} from './view-to-model-helpers';
+
+/**
+ * Defines a conversion between the model and the view where a model element is represented as a view element (and vice versa).
+ * For example, model `<paragraph>Foo</paragraph>` is `<p>Foo</p>` in the view.
+ *
+ *		modelElementIsViewElement( conversion, { model: 'paragraph', view: 'p' } );
+ *
+ *		modelElementIsViewElement( conversion, {
+ *			model: 'fancyParagraph',
+ *			view: {
+ *				name: 'p',
+ *				class: 'fancy'
+ *			}
+ *		} );
+ *
+ *		modelElementIsViewElement( conversion, {
+ *			model: 'blockquote',
+ *			view: 'blockquote',
+ *			alternativeView: [
+ *				{
+ *					name: 'div',
+ *					class: 'blockquote'
+ *				}
+ *			]
+ *		} );
+ *
+ * @param {module:engine/conversion/conversion~Conversion} conversion Conversion class instance with registered conversion dispatchers.
+ * @param {Object} definition Conversion definition.
+ * @param {String} definition.model Name of the model element to convert.
+ * @param {String|module:engine/view/viewelementdefinition~ViewElementDefinition} definition.view Name or a definition of
+ * a view element to convert.
+ * @param {Array.<String|module:engine/view/viewelementdefinition~ViewElementDefinition>} [definition.alternativeView]
+ * Alternative forms of view, that also should be converted to model. Keep in mind that those will be "converted back"
+ * to the main form, given in `definition.view`.
+ */
+export function modelElementIsViewElement( conversion, definition ) {
+	// Set model-to-view conversion.
+	conversion.for( 'model' ).add( mtvElementToElement( definition ) );
+
+	// Set view-to-model conversion.
+	for ( const view of _getAllViews( definition ) ) {
+		conversion.for( 'view' ).add( vtmElementToElement( {
+			model: definition.model,
+			view
+		} ) );
+	}
+}
+
+/**
+ * Defines a conversion between the model and the view where a model attribute is represented as a view element (and vice versa).
+ * For example, model text node with data `"Foo"` and `bold` attribute is `<strong>Foo</strong>` in the view.
+ *
+ *		modelAttributeIsViewElement( conversion, 'bold', { view: 'strong' } );
+ *
+ *		modelAttributeIsViewElement( conversion, 'bold', {
+ *			view: {
+ *				name: 'span',
+ *				class: 'bold'
+ *			}
+ *		} );
+ *
+ *		modelAttributeIsViewElement( conversion, 'bold', {
+ *			view: 'strong',
+ *			alternativeView: [
+ *				'b',
+ *				{
+ *					name: 'span',
+ *					class: 'bold'
+ *				},
+ *				{
+ *					name: 'span',
+ *					style: {
+ *						'font-weight': 'bold'
+ *					}
+ *				}
+ *			]
+ *		} );
+ *
+ *		modelAttributeIsViewElement( conversion, 'styled', {
+ *			model: 'dark',
+ *			view: {
+ *				name: 'span',
+ *				class: [ 'styled', 'styled-dark' ]
+ *			}
+ *		} );
+ *
+ *		modelAttributeIsViewElement( conversion, 'fontSize', [
+ *			{
+ *				model: 'big',
+ *				view: {
+ *					name: 'span',
+ *					style: {
+ *						'font-size': '1.2em'
+ *					}
+ *				}
+ *			},
+ *			{
+ *				model: 'small',
+ *				view: {
+ *					name: 'span',
+ *					style: {
+ *						'font-size': '0.8em'
+ *					}
+ *				}
+ *			}
+ *		] );
+ *
+ *		modelAttributeIsViewElement( conversion, 'fontSize', [
+ *			{
+ *				model: 'big',
+ *				view: {
+ *					name: 'span',
+ *					style: {
+ *						'font-size': '1.2em'
+ *					}
+ *				},
+ *				alternativeView: [
+ *					{
+ *						name: 'span',
+ *						style: {
+ *							'font-size': '12px'
+ *						}
+ *					}
+ *				]
+ *			},
+ *			{
+ *				model: 'small',
+ *				view: {
+ *					name: 'span',
+ *					style: {
+ *						'font-size': '0.8em'
+ *					}
+ *				},
+ *				alternativeView: [
+ *					{
+ *						name: 'span',
+ *						style: {
+ *							'font-size': '8px'
+ *						}
+ *					}
+ *				]
+ *			}
+ *		] );
+ *
+ * @param {module:engine/conversion/conversion~Conversion} conversion Conversion class instance with registered conversion dispatchers.
+ * @param {String} modelAttributeKey The key of the model attribute to convert.
+ * @param {Object|Array.<Object>} definition Conversion definition. It is possible to provide multiple definitions in an array.
+ * @param {*} [definition.model] The value of the converted model attribute. If omitted, in model-to-view conversion,
+ * the item will be treated as a default item, that will be used when no other item matches. In view-to-model conversion,
+ * the model attribute value will be set to `true`.
+ * @param {String|module:engine/view/viewelementdefinition~ViewElementDefinition} definition.view Name or a definition of
+ * a view element to convert.
+ * @param {Array.<String|module:engine/view/viewelementdefinition~ViewElementDefinition>} [definition.alternativeView]
+ * Alternative forms of view, that also should be converted to model. Keep in mind that those will be "converted back"
+ * to the main form, given in `definition.view`.
+ */
+export function modelAttributeIsViewElement( conversion, modelAttributeKey, definition ) {
+	// Set model-to-view conversion.
+	conversion.for( 'model' ).add( mtvAttributeToElement( modelAttributeKey, definition ) );
+
+	// Set view-to-model conversion. In this case, we need to re-organise the definition config.
+	if ( !Array.isArray( definition ) ) {
+		definition = [ definition ];
+	}
+
+	for ( const item of definition ) {
+		const model = _getModelAttributeDefinition( modelAttributeKey, item.model );
+
+		for ( const view of _getAllViews( item ) ) {
+			conversion.for( 'view' ).add( vtmElementToAttribute( {
+				view,
+				model
+			} ) );
+		}
+	}
+}
+
+/**
+ * Defines a conversion between the model and the view where a model attribute is represented as a view attribute (and vice versa).
+ * For example, `<image src='foo.jpg'></image>` is converted to `<img src='foo.jpg'></img>` (same attribute name and value).
+ *
+ *		modelAttributeIsViewAttribute( conversion, 'src' );
+ *
+ *		modelAttributeIsViewAttribute( conversion, 'source', { view: 'src' } );
+ *
+ *		modelAttributeIsViewAttribute( conversion, 'aside', {
+ *			model: true,
+ *			view: {
+ *				key: 'class',
+ *				value: 'aside half-size'
+ *			}
+ *		} );
+ *
+ *		modelAttributeIsViewAttribute( conversion, 'styled', [
+ *			{
+ *				model: 'dark',
+ *				view: {
+ *					key: 'class',
+ *					value: 'styled styled-dark'
+ *				}
+ *			},
+ *			{
+ *				model: 'light',
+ *				view: {
+ *					key: 'class',
+ *					value: 'styled styled-light'
+ *				}
+ *			}
+ *		] );
+ *
+ *		modelAttributeIsViewAttribute( conversion, 'align', [
+ *			{
+ *				model: 'right',
+ *				view: {
+ *					key: 'class',
+ *					value: 'align-right'
+ *				},
+ *				alternativeView: [
+ *					{
+ *						key: 'style',
+ *						value: 'text-align:right;'
+ *					}
+ *				]
+ *			},
+ *			{
+ *				model: 'center',
+ *				view: {
+ *					key: 'class',
+ *					value: 'align-center'
+ *				},
+ *				alternativeView: [
+ *					{
+ *						key: 'style',
+ *						value: 'text-align:center;'
+ *					}
+ *				]
+ *			}
+ *		] );
+ *
+ * @param {module:engine/conversion/conversion~Conversion} conversion Conversion class instance with registered conversion dispatchers.
+ * @param {String} modelAttributeKey The key of the model attribute to convert.
+ * @param {Object|Array.<Object>} [definition] Conversion definition. It is possible to provide multiple definitions in an array.
+ * If not set, the conversion helper will assume 1-to-1 conversion, that is the model attribute key and value will be same
+ * as the view attribute key and value.
+ * @param {*} [definition.model] The value of the converted model attribute. If omitted, in model-to-view conversion,
+ * the item will be treated as a default item, that will be used when no other item matches. In view-to-model conversion,
+ * the model attribute value will be set to the same value as in the view.
+ * @param {String|module:engine/view/viewelementdefinition~ViewElementDefinition} definition.view Name or a definition of
+ * a view element to convert.
+ * @param {Array.<String|module:engine/view/viewelementdefinition~ViewElementDefinition>} [definition.alternativeView]
+ * Alternative forms of view, that also should be converted to model. Keep in mind that those will be "converted back"
+ * to the main form, given in `definition.view`.
+ */
+export function modelAttributeIsViewAttribute( conversion, modelAttributeKey, definition ) {
+	// Set model-to-view conversion.
+	conversion.for( 'model' ).add( mtvAttributeToAttribute( modelAttributeKey, definition ) );
+
+	// Set view-to-model conversion. In this case, we need to re-organise the definition config.
+	if ( !definition ) {
+		definition = { view: modelAttributeKey };
+	}
+
+	if ( !Array.isArray( definition ) ) {
+		definition = [ definition ];
+	}
+
+	for ( const item of definition ) {
+		const model = _getModelAttributeDefinition( modelAttributeKey, item.model );
+
+		for ( const view of _getAllViews( item ) ) {
+			conversion.for( 'view' ).add( vtmAttributeToAttribute( {
+				view,
+				model
+			} ) );
+		}
+	}
+}
+
+// Helper function, normalizes input data into a correct config form that can be accepted by conversion helpers. The
+// correct form is either `String` or an object with `key` and `value` properties.
+//
+// @param {String} key Model attribute key.
+// @param {*} [model] Model attribute value.
+// @returns {Object} Normalized model attribute definition.
+function _getModelAttributeDefinition( key, model ) {
+	if ( model === undefined ) {
+		return key;
+	} else {
+		return {
+			key, value: model
+		};
+	}
+}
+
+// Helper function that creates a joint array out of an item passed in `definition.view` and items passed in
+// `definition.alternativeView`.
+//
+// @param {Object} definition Conversion definition.
+// @returns {Array} Array containing view definitions.
+function _getAllViews( definition ) {
+	return [].concat( definition.view ).concat( definition.alternativeView || [] );
+}

+ 27 - 20
packages/ckeditor5-engine/src/conversion/model-to-view-converters.js

@@ -36,10 +36,10 @@ import DocumentSelection from '../model/documentselection';
  *
  *		modelDispatcher.on(
  *			'insert:myElem',
- *			insertElement( ( data, consumable, conversionApi ) => {
- *				let myElem = new ViewElement( 'myElem', { myAttr: true }, new ViewText( 'myText' ) );
+ *			insertElement( ( modelItem, consumable, conversionApi ) => {
+ *				let myElem = new ViewElement( 'myElem', { myAttr: 'my-' + modelItem.getAttribute( 'myAttr' ) }, new ViewText( 'myText' ) );
  *
- *				// Do something fancy with myElem using data/consumable/conversionApi ...
+ *				// Do something fancy with myElem using `modelItem` or other parameters.
  *
  *				return myElem;
  *			}
@@ -53,7 +53,7 @@ export function insertElement( elementCreator ) {
 	return ( evt, data, consumable, conversionApi ) => {
 		const viewElement = ( elementCreator instanceof ViewElement ) ?
 			elementCreator.clone( true ) :
-			elementCreator( data, consumable, conversionApi );
+			elementCreator( data.item, consumable, conversionApi );
 
 		if ( !viewElement ) {
 			return;
@@ -241,7 +241,7 @@ export function removeUIElement( elementCreator ) {
  * The converter automatically consumes corresponding value from consumables list and stops the event (see
  * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher}).
  *
- *		modelDispatcher.on( 'attribute:customAttr:myElem', changeAttribute( ( data ) => {
+ *		modelDispatcher.on( 'attribute:customAttr:myElem', changeAttribute( ( value, data ) => {
  *			// Change attribute key from `customAttr` to `class` in view.
  *			const key = 'class';
  *			let value = data.attributeNewValue;
@@ -262,19 +262,25 @@ export function removeUIElement( elementCreator ) {
  * @returns {Function} Set/change attribute converter.
  */
 export function changeAttribute( attributeCreator ) {
-	attributeCreator = attributeCreator || ( ( value, key ) => ( { value, key } ) );
+	attributeCreator = attributeCreator || ( ( value, data ) => ( { value, key: data.attributeKey } ) );
 
 	return ( evt, data, consumable, conversionApi ) => {
 		if ( !consumable.consume( data.item, evt.name ) ) {
 			return;
 		}
 
-		const { key, value } = attributeCreator( data.attributeNewValue, data.attributeKey, data, consumable, conversionApi );
+		// First remove the old attribute if there was one.
+		const oldAttribute = attributeCreator( data.attributeOldValue, data, consumable, conversionApi );
 
-		if ( data.attributeNewValue !== null ) {
-			conversionApi.mapper.toViewElement( data.item ).setAttribute( key, value );
-		} else {
-			conversionApi.mapper.toViewElement( data.item ).removeAttribute( key );
+		if ( data.attributeOldValue !== null && oldAttribute ) {
+			conversionApi.mapper.toViewElement( data.item ).removeAttribute( oldAttribute.key );
+		}
+
+		// Then, if conversion was successful, set the new attribute.
+		const newAttribute = attributeCreator( data.attributeNewValue, data, consumable, conversionApi );
+
+		if ( data.attributeNewValue !== null && newAttribute ) {
+			conversionApi.mapper.toViewElement( data.item ).setAttribute( newAttribute.key, newAttribute.value );
 		}
 	};
 }
@@ -338,11 +344,11 @@ export function wrap( elementCreator ) {
 			let viewRange = conversionApi.mapper.toViewRange( data.range );
 
 			// First, unwrap the range from current wrapper.
-			if ( data.attributeOldValue !== null ) {
+			if ( data.attributeOldValue !== null && oldViewElement ) {
 				viewRange = viewWriter.unwrap( viewRange, oldViewElement );
 			}
 
-			if ( data.attributeNewValue !== null ) {
+			if ( data.attributeNewValue !== null && newViewElement ) {
 				viewWriter.wrap( viewRange, newViewElement );
 			}
 		}
@@ -583,16 +589,17 @@ class HighlightAttributeElement extends ViewAttributeElement {
 }
 
 /**
- * Object describing how the content highlight should be created in the view.
+ * Object describing how the marker highlight should be represented in the view.
+ *
+ * Each text node contained in a highlighted range will be wrapped in a `span` {@link module:engine/view/attributeelement~AttributeElement}
+ * with CSS class(es), attributes and priority described by this object.
  *
- * Each text node contained in the highlight will be wrapped with `span` element with CSS class(es), attributes and priority
- * described by this object.
+ * Additionally, each {@link module:engine/view/containerelement~ContainerElement} can handle displaying the highlight separately
+ * by providing `addHighlight` and `removeHighlight` custom properties. In this case:
  *
- * Each element can handle displaying the highlight separately by providing `addHighlight` and `removeHighlight` custom
- * properties:
- *  * `HighlightDescriptor` is passed to the `addHighlight` function upon conversion and should be used to apply the highlight to
+ *  * `HighlightDescriptor` object is passed to the `addHighlight` function upon conversion and should be used to apply the highlight to
  *  the element,
- *  * descriptor id is passed to the `removeHighlight` function upon conversion and should be used to remove the highlight of given
+ *  * descriptor `id` is passed to the `removeHighlight` function upon conversion and should be used to remove the highlight of given
  *  id from the element.
  *
  * @typedef {Object} module:engine/conversion/model-to-view-converters~HighlightDescriptor

+ 467 - 0
packages/ckeditor5-engine/src/conversion/model-to-view-helpers.js

@@ -0,0 +1,467 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ViewContainerElement from '../view/containerelement';
+import ViewAttributeElement from '../view/attributeelement';
+import ViewUIElement from '../view/uielement';
+
+import {
+	insertElement, wrap, changeAttribute,
+	insertUIElement, removeUIElement, highlightText, highlightElement, removeHighlight
+} from './model-to-view-converters';
+
+import cloneDeep from '@ckeditor/ckeditor5-utils/src/lib/lodash/cloneDeep';
+
+/**
+ * @module engine/conversion/model-to-view-helpers
+ */
+
+/**
+ * Model element to view element conversion helper.
+ *
+ * This conversion results in creating a view element. For example, model `<paragraph>Foo</paragraph>` becomes `<p>Foo</p>` in the view.
+ *
+ *		elementToElement( { model: 'paragraph', view: 'p' } );
+ *
+ *		elementToElement( { model: 'paragraph', view: 'p' }, 'high' );
+ *
+ *		elementToElement( { model: 'paragraph', view: new ViewContainerElement( 'p' ) } );
+ *
+ *		elementToElement( {
+ *			model: 'fancyParagraph',
+ *			view: {
+ *				name: 'p',
+ *				class: 'fancy'
+ *			}
+ *		} );
+ *
+ * 		elementToElement( {
+ * 			model: 'heading',
+ * 			view: modelElement => new ViewContainerElement( 'h' + modelElement.getAttribute( 'level' ) )
+ * 		} );
+ *
+ * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add converter to conversion process.
+ *
+ * @param {Object} config Conversion configuration.
+ * @param {String} config.model Name of the model element to convert.
+ * @param {String|module:engine/view/viewelementdefinition~ViewElementDefinition|Function|
+ * module:engine/view/containerelement~ContainerElement} config.view View element name, or a view element definition,
+ * or a function that takes model element as a parameter and returns a view container element,
+ * or a view container element instance. The view element will be used then in conversion.
+ * @param {module:utils/priorities~PriorityString} [priority='normal'] Converter priority.
+ * @returns {Function} Conversion helper.
+ */
+export function elementToElement( config, priority = 'normal' ) {
+	config = cloneDeep( config );
+
+	_normalizeToElementConfig( config, ViewContainerElement );
+
+	return dispatcher => {
+		dispatcher.on( 'insert:' + config.model, insertElement( config.view ), { priority } );
+	};
+}
+
+/**
+ * Model attribute to view element conversion helper.
+ *
+ * This conversion results in wrapping view nodes in a view attribute element. For example, model text node with data
+ * `"Foo"` and `bold` attribute becomes `<strong>Foo</strong>` in the view.
+ *
+ *		attributeToElement( 'bold', { view: 'strong' } );
+ *
+ *		attributeToElement( 'bold', { view: 'strong' }, 'high' );
+ *
+ *		attributeToElement( 'bold', { view: new ViewAttributeElement( 'strong' ) } );
+ *
+ *		attributeToElement( 'bold', {
+ *			view: {
+ *				name: 'span',
+ *				class: 'bold'
+ *			}
+ *		} );
+ *
+ *		attributeToElement( 'styled', {
+ *			model: 'dark',
+ *			view: {
+ *				name: 'span',
+ *				class: [ 'styled', 'styled-dark' ]
+ *			}
+ *		} );
+ *
+ *		attributeToElement( 'fontSize', [
+ *			{
+ *				model: 'big',
+ *				view: {
+ *					name: 'span',
+ *					style: {
+ *						'font-size': '1.2em'
+ *					}
+ *				}
+ *			},
+ *			{
+ *				model: 'small',
+ *				view: {
+ *					name: 'span',
+ *					style: {
+ *						'font-size': '0.8em'
+ *					}
+ *				}
+ *			}
+ *		] );
+ *
+ * 		attributeToElement( 'bold', {
+ * 			view: modelAttributeValue => new ViewAttributeElement( 'span', { style: 'font-weight:' + modelAttributeValue } )
+ * 		} );
+ *
+ * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add converter to conversion process.
+ *
+ * @param {String} modelAttributeKey The key of the attribute to convert.
+ * @param {Object|Array.<Object>} config Conversion configuration. It is possible to provide multiple configurations in an array.
+ * @param {*} [config.model] The value of the converted model attribute for which the `view` property is defined.
+ * If omitted, the configuration item will be used as a "default" configuration when no other item matches the attribute value.
+ * @param {String|module:engine/view/viewelementdefinition~ViewElementDefinition|Function|
+ * module:engine/view/attributeelement~AttributeElement} config.view View element name, or a view element definition,
+ * or a function that takes model element as a parameter and returns a view attribute element, or a view attribute element instance.
+ * The view element will be used then in conversion.
+ * @param {module:utils/priorities~PriorityString} [priority='normal'] Converter priority.
+ * @returns {Function} Conversion helper.
+ */
+export function attributeToElement( modelAttributeKey, config, priority = 'normal' ) {
+	config = cloneDeep( config );
+
+	_normalizeToElementConfig( config, ViewAttributeElement );
+
+	const elementCreator = _getCreatorForArrayConfig( config );
+
+	return dispatcher => {
+		dispatcher.on( 'attribute:' + modelAttributeKey, wrap( elementCreator ), { priority } );
+	};
+}
+
+/**
+ * Model attribute to view attribute conversion helper.
+ *
+ * This conversion results in adding an attribute on a view node, basing on an attribute from a model node. For example,
+ * `<image src='foo.jpg'></image>` is converted to `<img src='foo.jpg'></img>`.
+ *
+ *		attributeToAttribute( 'src' );
+ *
+ *		attributeToAttribute( 'source', { view: 'src' } );
+ *
+ *		attributeToAttribute( 'source', { view: 'src' }, 'high' );
+ *
+ *		attributeToAttribute( 'stylish', {
+ *			view: {
+ *				key: 'class',
+ *				value: 'styled'
+ *			}
+ *		} );
+ *
+ *		attributeToAttribute( 'styled', {
+ *			model: 'dark',
+ *			view: {
+ *				key: 'class',
+ *				value: 'styled styled-dark'
+ *			}
+ *		} );
+ *
+ *		attributeToAttribute( 'style', [
+ *			{
+ *				model: 'dark',
+ *				view: {
+ *					key: 'class',
+ *					value: 'styled-dark'
+ *				}
+ *			},
+ *			{
+ *				model: 'light',
+ *				view: {
+ *					key: 'class',
+ *					value: 'styled-light'
+ *				}
+ *			}
+ *		] );
+ *
+ *		attributeToAttribute( 'style', {
+ *			view: attributeValue => ( { key: 'class', value: 'style-' + attributeValue } )
+ *		} );
+ *
+ * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add converter to conversion process.
+ *
+ * @param {String} modelAttributeKey The key of the attribute to convert.
+ * @param {Object|Array.<Object>} [config] Conversion configuration. It is possible to provide multiple configurations in an array.
+ * If not set, the conversion helper will assume 1-to-1 conversion, that is the view attribute key and view attribute value
+ * will be same as model attribute key and model attribute value.
+ * @param {*} [config.model] The value of the converted model attribute for which the `view` property is defined.
+ * If `true` is provided, the configuration item will be used as a "default" configuration when no other item matches
+ * the attribute value.
+ * @param {String|Object|Function} [config.view] View attribute key, or an object with `key` and `value` properties (both `String`),
+ * or a function that takes model attribute value and returns an object with `key` and `value` properties (both `String`).
+ * If nothing is passed, the view attribute key and value will be equal to the model attribute key and value.
+ * If a `String` is passed, it will be used as view attribute key and view attribute value will be equal to model attribute value.
+ * If an object or a function returning an object is passed, its properties will be used to set view attribute key and value.
+ * @param {module:utils/priorities~PriorityString} [priority='normal'] Converter priority.
+ * @returns {Function} Conversion helper.
+ */
+export function attributeToAttribute( modelAttributeKey, config = {}, priority = 'normal' ) {
+	config = cloneDeep( config );
+
+	_normalizeToAttributeConfig( modelAttributeKey, config );
+
+	const elementCreator = _getCreatorForArrayConfig( config );
+
+	return dispatcher => {
+		dispatcher.on( 'attribute:' + modelAttributeKey, changeAttribute( elementCreator ), { priority } );
+	};
+}
+
+/**
+ * Model marker to view element conversion helper.
+ *
+ * This conversion results in creating a view element on the boundaries of the converted marker. If 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.
+ *
+ *		markerToElement( { model: 'search', view: 'marker-search' } );
+ *
+ *		markerToElement( { model: 'search', view: 'marker-search' }, 'high' );
+ *
+ *		markerToElement( { model: 'search', view: new ViewUIElement( 'span', { data-marker: 'search' } ) } );
+ *
+ *		markerToElement( {
+ *			model: 'search',
+ *			view: {
+ *				name: 'span',
+ *				attribute: {
+ *					'data-marker': 'search'
+ *				}
+ *			}
+ *		} );
+ *
+ * 		markerToElement( {
+ * 			model: 'search',
+ * 			view: data => {
+ *	 			return new ViewUIElement( 'span', { 'data-marker': 'search', 'data-start': data.isOpening } );
+ * 			}
+ * 		} );
+ *
+ * If function is passed as `config.view` parameter, it will be used to generate both boundary elements. The function
+ * receives `data` object as parameter and should return an instance of {@link module:engine/view/uielement~UIElement view.UIElement}.
+ * The `data` object properties are passed from
+ * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addMarker}. Additionally,
+ * `data.isOpening` parameter is passed, which is set to `true` for marker start boundary element, and `false` to
+ * marker end boundary element.
+ *
+ * This kind of conversion is useful for saving data into data base, so it should be used in data conversion pipeline.
+ *
+ * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add converter to conversion process.
+ *
+ * @param {Object} config Conversion configuration.
+ * @param {String} config.model Name of the model marker (or model marker group) to convert.
+ * @param {module:engine/view/viewelementdefinition~ViewElementDefinition|Function} config.view View element definition
+ * which will be used to build a view element for conversion or a function that takes model marker data as a parameter and
+ * returns view element to use in conversion.
+ * @param {module:utils/priorities~PriorityString} [priority='normal'] Converter priority.
+ * @returns {Function} Conversion helper.
+ */
+export function markerToElement( config, priority = 'normal' ) {
+	config = cloneDeep( config );
+
+	_normalizeToElementConfig( config, ViewUIElement );
+
+	return dispatcher => {
+		dispatcher.on( 'addMarker:' + config.model, insertUIElement( config.view ), { priority } );
+		dispatcher.on( 'removeMarker:' + config.model, removeUIElement( config.view ), { priority } );
+	};
+}
+
+/**
+ * Model marker to highlight conversion helper.
+ *
+ * This conversion results in creating a highlight on view nodes. For this kind of conversion,
+ * {@link module:engine/conversion/model-to-view-converters~HighlightDescriptor} should be provided.
+ *
+ * For text nodes, a `span` {@link module:engine/view/attributeelement~AttributeElement} is created and it wraps all text nodes
+ * in the converted marker range. For example, model marker set like this `<paragraph>F[oo b]ar</paragraph>` becomes
+ * `<p>F<span class="comment">oo b</span>ar</p>` in the view.
+ *
+ * {@link module:engine/view/containerelement~ContainerElement} may provide custom way of handling highlight. Most often,
+ * the element itself is given classes and attributes described in the highlight descriptor (instead of being wrapped in `span`).
+ * For example, model marker set like this `[<image src="foo.jpg"></image>]` becomes `<img src="foo.jpg" class="comment"></img>`
+ * in the view.
+ *
+ * For container elements, the conversion is two-step. While the converter processes highlight descriptor and passes it
+ * to a container element, it is the container element instance itself which applies values from highlight descriptor.
+ * So, in a sense, converter takes care of stating what should be applied on what, while element decides how to apply that.
+ *
+ *		markerToHighlight( { model: 'comment', view: { class: 'comment' } } );
+ *
+ *		markerToHighlight( { model: 'comment', view: { class: 'new-comment' } }, 'high' );
+ *
+ * 		markerToHighlight( {
+ * 			model: 'comment',
+ * 			view: data => {
+ * 				// Assuming that marker name is in a form of comment:commentType.
+ *	 			const commentType = data.markerName.split( ':' )[ 1 ];
+ *
+ *	 			return {
+ *	 				class: [ 'comment', 'comment-' + commentType ]
+ *	 			};
+ * 			}
+ * 		} );
+ *
+ * If function is passed as `config.view` parameter, it will be used to generate highlight descriptor. The function
+ * receives `data` object as parameter and should return an instance of {@link module:engine/view/uielement~UIElement view.UIElement}.
+ * The `data` object properties are passed from
+ * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addMarker}.
+ *
+ * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add converter to conversion process.
+ *
+ * @param {Object} config Conversion configuration.
+ * @param {String} config.model Name of the model marker (or model marker group) to convert.
+ * @param {module:engine/conversion/model-to-view-converters~HighlightDescriptor|Function} config.view Highlight descriptor
+ * which will be used for highlighting or a function that takes model marker data as a parameter and returns a highlight descriptor.
+ * @param {module:utils/priorities~PriorityString} [priority='normal'] Converter priority.
+ * @returns {Function} Conversion helper.
+ */
+export function markerToHighlight( config, priority = 'normal' ) {
+	return dispatcher => {
+		dispatcher.on( 'addMarker:' + config.model, highlightText( config.view ), { priority } );
+		dispatcher.on( 'addMarker:' + config.model, highlightElement( config.view ), { priority } );
+		dispatcher.on( 'removeMarker:' + config.model, removeHighlight( config.view ), { priority } );
+	};
+}
+
+// Takes config and adds default parameters if they don't exist and normalizes other parameters to be used in model-to-view converters
+// for generating a view element.
+//
+// @param {Object} config Object with conversion helper configuration.
+// @param {Function} ViewElementClass View element class to use when generating view element from config.
+function _normalizeToElementConfig( config, ViewElementClass ) {
+	// If config is given as an array, normalize each entry separately.
+	if ( Array.isArray( config ) ) {
+		for ( const configEntry of config ) {
+			_normalizeToElementConfig( configEntry, ViewElementClass );
+		}
+
+		return;
+	}
+
+	// Build `.view` property.
+	// It is expected to be either creator function or view element instance.
+	if ( typeof config.view == 'string' ) {
+		// If `.view` is a string, create a proper view element instance out of given `ViewElementClass` and name given in `.view`.
+		config.view = new ViewElementClass( config.view );
+	} else if ( typeof config.view == 'object' && !( config.view instanceof ViewElementClass ) ) {
+		// If `.view` is an object, use it to build view element instance.
+		config.view = _createViewElementFromDefinition( config.view, ViewElementClass );
+	}
+	// `.view` can be also a function or already a view element instance.
+	// These are already valid types which don't have to be normalized.
+}
+
+// Creates view element instance from provided viewElementDefinition and class.
+//
+// @param {module:engine/view/viewelementdefinition~ViewElementDefinition} viewElementDefinition
+// @param {Function} ViewElementClass
+// @returns {module:engine/view/element~Element}
+function _createViewElementFromDefinition( viewElementDefinition, ViewElementClass ) {
+	const element = new ViewElementClass( viewElementDefinition.name, Object.assign( {}, viewElementDefinition.attribute ) );
+
+	if ( viewElementDefinition.style ) {
+		element.setStyle( viewElementDefinition.style );
+	}
+
+	if ( viewElementDefinition.class ) {
+		const classes = viewElementDefinition.class;
+
+		if ( typeof classes == 'string' ) {
+			element.addClass( classes );
+		} else {
+			element.addClass( ...classes );
+		}
+	}
+
+	return element;
+}
+
+// Takes config and adds default parameters if they don't exist and normalizes other parameters to be used in model-to-view converters
+// for generating view attribute.
+//
+// @param {String} modelAttributeKey Model attribute key for which config is defined.
+// @param {Object} [config] Config with conversion helper configuration.
+function _normalizeToAttributeConfig( modelAttributeKey, config ) {
+	// If config is given as an array, normalize each entry separately.
+	if ( Array.isArray( config ) ) {
+		for ( const configEntry of config ) {
+			_normalizeToAttributeConfig( modelAttributeKey, configEntry );
+		}
+
+		return;
+	}
+
+	// Build `.view` property.
+	// It is expected to be a creator function, that takes attribute value and model item and returns an object
+	// with `key` property and `value` property which are view attribute key and view attribute value.
+	if ( !config.view ) {
+		// If `.view` is not set, take both attribute name and attribute value from model.
+		const viewAttributeKey = modelAttributeKey;
+		config.view = modelAttributeValue => ( { key: viewAttributeKey, value: modelAttributeValue } );
+	} else if ( typeof config.view == 'string' ) {
+		// If `.view` is set as a string, use it as a view attribute name. Value will be taken from model attribute value.
+		const viewAttributeKey = config.view;
+		config.view = modelAttributeValue => ( { key: viewAttributeKey, value: modelAttributeValue } );
+	} else if ( typeof config.view == 'object' ) {
+		// If `.view` is set as an object, use set key and value.
+		const viewAttributeKey = config.view.key;
+		const viewAttributeValue = config.view.value;
+		config.view = () => ( { key: viewAttributeKey, value: viewAttributeValue } );
+	}
+	// `.view` can be also already a function.
+}
+
+// Takes config and creates a view element creator function that chooses an appropriate entry from the config depending on
+// the value of model attribute.
+//
+// Supports specifying config as a single object or an array of objects.
+// Supports `.view` defined as an object and as a function.
+//
+// @param {Object|Array.<Object>} config Config with conversion helper configuration.
+function _getCreatorForArrayConfig( config ) {
+	if ( !Array.isArray( config ) ) {
+		config = [ config ];
+	}
+
+	// Get "default config" entry. It is the entry with `.model` property set to `true`.
+	// "Default" entry should be used if no other entry matched model attribute value.
+	const defaultConfig = config.find( configEntry => configEntry.model === undefined );
+
+	// Return a creator function.
+	return modelAttributeValue => {
+		// Set default config at first. It will be used if no other entry matches model attribute value.
+		let matchedConfigEntry = defaultConfig;
+
+		// Creator should check all entries from the config...
+		for ( const configEntry of config ) {
+			if ( configEntry.model === modelAttributeValue ) {
+				// If `.model` specified in entry matches converted attribute's value, choose it.
+				matchedConfigEntry = configEntry;
+				break;
+			}
+		}
+
+		// If there was default config or matched config...
+		if ( matchedConfigEntry ) {
+			// If the entry `.view` is a function, execute it and return the value...
+			if ( typeof matchedConfigEntry.view == 'function' ) {
+				return matchedConfigEntry.view( modelAttributeValue );
+			}
+			// Else, just return `.view`, it should be a view element instance after it got normalized earlier.
+			return matchedConfigEntry.view;
+		}
+
+		return null;
+	};
+}

+ 447 - 0
packages/ckeditor5-engine/src/conversion/view-to-model-helpers.js

@@ -0,0 +1,447 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Matcher from '../view/matcher';
+
+import ModelRange from '../model/range';
+import ModelPosition from '../model/position';
+
+import cloneDeep from '@ckeditor/ckeditor5-utils/src/lib/lodash/cloneDeep';
+
+/**
+ * @module engine/conversion/view-to-model-helpers
+ */
+
+/**
+ * View element to model element conversion helper.
+ *
+ * This conversion results in creating a model element. For example, view `<p>Foo</p>` becomes `<paragraph>Foo</paragraph>` in the model.
+ *
+ * Keep in mind that the element will be inserted only if it is allowed by {@link module:engine/model/schema~Schema schema} configuration.
+ *
+ *		elementToElement( { view: 'p', model: 'paragraph' } );
+ *
+ *		elementToElement( { view: 'p', model: 'paragraph' }, 'high' );
+ *
+ *		elementToElement( {
+ *			view: {
+ *				name: 'p',
+ *				class: 'fancy'
+ *			},
+ *			model: 'fancyParagraph'
+ *		} );
+ *
+ *		elementToElement( {
+ *			view: {
+ *				name: 'p',
+ *				class: 'fancy'
+ *			},
+ *			model: new ModelElement( 'p', { fancy: true } )
+ *		} );
+ *
+ *		elementToElement( {
+ * 			view: {
+ *				name: 'p',
+ *				class: 'heading'
+ * 			},
+ * 			model: viewElement => new ModelElement( 'heading', { level: viewElement.getAttribute( 'data-level' ) } )
+ * 		} );
+ *
+ * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add converter to conversion process.
+ *
+ * @param {Object} config Conversion configuration.
+ * @param {String|module:engine/view/viewelementdefinition~ViewElementDefinition} config.view View element name, or
+ * a view element definition. Conversion will be set for view elements which match the name or the definition.
+ * @param {String|module:engine/model/element~Element|Function} config.model Name of the model element, a model element
+ * instance or a function that takes a view element and returns a model element. The model element will be inserted in the model.
+ * @param {module:utils/priorities~PriorityString} [priority='normal'] Converter priority.
+ * @returns {Function} Conversion helper.
+ */
+export function elementToElement( config, priority = 'normal' ) {
+	config = cloneDeep( config );
+
+	const converter = _prepareToElementConverter( config );
+	const elementName = typeof config.view == 'string' ? config.view : config.view.name;
+
+	return dispatcher => {
+		dispatcher.on( 'element:' + elementName, converter, { priority } );
+	};
+}
+
+/**
+ * View element to model attribute conversion helper.
+ *
+ * This conversion results in setting an attribute on a model node. For example, view `<strong>Foo</strong>` becomes
+ * `Foo` {@link module:engine/model/text~Text model text node} with `bold` attribute set to `true`.
+ *
+ * Keep in mind that the attribute will be set only if it is allowed by {@link module:engine/model/schema~Schema schema} configuration.
+ *
+ *		elementToAttribute( { view: 'strong', model: 'bold' } );
+ *
+ *		elementToAttribute( { view: 'strong', model: 'bold' }, 'normal' );
+ *
+ *		elementToAttribute( {
+ *			view: {
+ *				name: 'span',
+ *				class: 'bold'
+ *			},
+ *			model: 'bold'
+ *		} );
+ *
+ *		elementToAttribute( {
+ *			view: {
+ *				name: 'span',
+ *				class: [ 'styled', 'styled-dark' ]
+ *			},
+ *			model: {
+ *				key: 'styled',
+ *				value: 'dark'
+ *			}
+ *		} );
+ *
+ * 		elementToAttribute( {
+ *			view: {
+ *				name: 'span',
+ *				style: {
+ *					'font-size': /[\s\S]+/
+ *				}
+ *			},
+ *			model: {
+ *				key: 'fontSize',
+ *				value: viewElement => {
+ *					const fontSize = viewElement.getStyle( 'font-size' );
+ *					const value = fontSize.substr( 0, fontSize.length - 2 );
+ *
+ *					if ( value <= 10 ) {
+ *						return 'small';
+ *					} else if ( value > 12 ) {
+ *						return 'big';
+ *					}
+ *
+ *					return null;
+ *				}
+ *			}
+ *		} );
+ *
+ * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add converter to conversion process.
+ *
+ * @param {Object} config Conversion configuration.
+ * @param {String|module:engine/view/viewelementdefinition~ViewElementDefinition} config.view View element name, or
+ * a view element definition. Conversion will be set for view elements which match the name or the definition.
+ * @param {String|Object} config.model Model attribute key or an object with `key` and `value` properties, describing
+ * the model attribute. `value` property may be set as a function that takes a view element and returns the value.
+ * If `String` is given, the model attribute value will be set to `true`.
+ * @param {module:utils/priorities~PriorityString} [priority='low'] Converter priority.
+ * @returns {Function} Conversion helper.
+ */
+export function elementToAttribute( config, priority = 'low' ) {
+	config = cloneDeep( config );
+
+	_normalizeModelAttributeConfig( config );
+
+	const converter = _prepareToAttributeConverter( config );
+
+	return dispatcher => {
+		dispatcher.on( 'element', converter, { priority } );
+	};
+}
+
+/**
+ * View attribute to model attribute conversion helper.
+ *
+ * This conversion results in setting an attribute on a model node. For example, view `<img src="foo.jpg"></img>` becomes
+ * `<image source="foo.jpg"></image>` in the model.
+ *
+ * Keep in mind that the attribute will be set only if it is allowed by {@link module:engine/model/schema~Schema schema} configuration.
+ *
+ *		attributeToAttribute( { view: 'src', model: 'source' } );
+ *
+ *		attributeToAttribute( { view: 'src', model: 'source' }, 'normal' );
+ *
+ *		attributeToAttribute( {
+ *			view: {
+ *				key: 'class',
+ *				value: 'styled'
+ *			},
+ *			model: 'styled'
+ *		} );
+ *
+ *		attributeToAttribute( {
+ *			view: {
+ *				key: 'class',
+ *				value: 'styled-dark'
+ *			},
+ *			model: {
+ *				key: 'styled',
+ *				value: 'dark'
+ *			}
+ *		} );
+ *
+ *		attributeToAttribute( {
+ *			view: {
+ *				key: 'class',
+ *				value: /styled-[\S]+/
+ *			},
+ *			model: {
+ *				key: 'styled'
+ *				value: viewElement => {
+ *					const regexp = /styled-([\S]+)/;
+ *					const match = viewElement.getAttribute( 'class' ).match( regexp );
+ *
+ *					return match[ 1 ];
+ *				}
+ *			}
+ *		} );
+ *
+ * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add converter to conversion process.
+ *
+ * @param {String|Object} config Conversion configuration. If given as a `String`, the conversion will be set for a
+ * view attribute with given key. The model attribute key and value will be same as view attribute key and value.
+ * @param {String|Object} config.view View attribute key or an object with `key` and `value` properties.
+ * Conversion will be set for view attributes with given key or which match given object.
+ * @param {String|Object} config.model Model attribute key or an object with `key` and `value` properties, describing
+ * the model attribute. `value` property may be set as a function that takes a view element and returns the value.
+ * If `String` is given, the model attribute value will be same as view attribute value.
+ * @param {module:utils/priorities~PriorityString} [priority='low'] Converter priority.
+ * @returns {Function} Conversion helper.
+ */
+export function attributeToAttribute( config, priority = 'low' ) {
+	config = cloneDeep( config );
+
+	const viewKey = _normalizeFromAttributeConfig( config );
+	_normalizeModelAttributeConfig( config, viewKey );
+
+	const converter = _prepareToAttributeConverter( config );
+
+	return dispatcher => {
+		dispatcher.on( 'element', converter, { priority } );
+	};
+}
+
+/**
+ * View element to model marker conversion helper.
+ *
+ * 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
+ * {@link module:engine/model/document~Document#markers model document markers}.
+ *
+ *		elementToMarker( { view: 'marker-search', model: 'search' } );
+ *
+ *		elementToMarker( { view: 'marker-search', model: 'search' }, 'high' );
+ *
+ *		elementToMarker( { view: 'marker-search', model: viewElement => 'comment:' + viewElement.getAttribute( 'data-comment-id' ) } );
+ *
+ *		elementToMarker( {
+ *			view: {
+ *				name: 'span',
+ *				attribute: {
+ *					'data-marker': 'search'
+ *				}
+ *			},
+ *			model: 'search'
+ *		} );
+ *
+ * See {@link module:engine/conversion/conversion~Conversion#for} to learn how to add converter to conversion process.
+ *
+ * @param {Object} config Conversion configuration.
+ * @param {String|module:engine/view/viewelementdefinition~ViewElementDefinition} config.view View element name, or
+ * a view element definition. Conversion will be set for view elements which match the name or the definition.
+ * @param {String|Function} config.model Name of the model marker, or a function that takes a view element and returns
+ * a model marker name.
+ * @param {module:utils/priorities~PriorityString} [priority='normal'] Converter priority.
+ * @returns {Function} Conversion helper.
+ */
+export function elementToMarker( config, priority = 'normal' ) {
+	config = cloneDeep( config );
+
+	_normalizeToMarkerConfig( config );
+
+	return elementToElement( config, priority );
+}
+
+// Helper for to-model-element conversion. Takes a config object and returns a proper converter function.
+//
+// @param {Object} config Conversion configuration.
+// @returns {Function} View to model converter.
+function _prepareToElementConverter( config ) {
+	const matcher = new Matcher( config.view );
+
+	return ( evt, data, conversionApi ) => {
+		// This will be usually just one pattern but we support matchers with many patterns too.
+		const match = matcher.match( data.viewItem );
+
+		// If there is no match, this callback should not do anything.
+		if ( !match ) {
+			return;
+		}
+
+		// Create model element basing on config.
+		const modelElement = _getModelElement( config.model, data.viewItem, conversionApi.writer );
+
+		// Do not convert if element building function returned falsy value.
+		if ( !modelElement ) {
+			return;
+		}
+
+		// When element was already consumed then skip it.
+		if ( !conversionApi.consumable.test( data.viewItem, match.match ) ) {
+			return;
+		}
+
+		// Find allowed parent for element that we are going to insert.
+		// If current parent does not allow to insert element but one of the ancestors does
+		// then split nodes to allowed parent.
+		const splitResult = conversionApi.splitToAllowedParent( modelElement, data.cursorPosition );
+
+		// When there is no split result it means that we can't insert element to model tree, so let's skip it.
+		if ( !splitResult ) {
+			return;
+		}
+
+		// Insert element on allowed position.
+		conversionApi.writer.insert( modelElement, splitResult.position );
+
+		// Convert children and insert to element.
+		const childrenResult = conversionApi.convertChildren( data.viewItem, ModelPosition.createAt( modelElement ) );
+
+		// Consume appropriate value from consumable values list.
+		conversionApi.consumable.consume( data.viewItem, match.match );
+
+		// Set conversion result range.
+		data.modelRange = new ModelRange(
+			// Range should start before inserted element
+			ModelPosition.createBefore( modelElement ),
+			// Should end after but we need to take into consideration that children could split our
+			// element, so we need to move range after parent of the last converted child.
+			// before: <allowed>[]</allowed>
+			// after: <allowed>[<converted><child></child></converted><child></child><converted>]</converted></allowed>
+			ModelPosition.createAfter( childrenResult.cursorPosition.parent )
+		);
+
+		// Now we need to check where the cursorPosition should be.
+		// If we had to split parent to insert our element then we want to continue conversion inside split parent.
+		//
+		// before: <allowed><notAllowed>[]</notAllowed></allowed>
+		// after:  <allowed><notAllowed></notAllowed><converted></converted><notAllowed>[]</notAllowed></allowed>
+		if ( splitResult.cursorParent ) {
+			data.cursorPosition = ModelPosition.createAt( splitResult.cursorParent );
+
+			// Otherwise just continue after inserted element.
+		} else {
+			data.cursorPosition = data.modelRange.end;
+		}
+	};
+}
+
+// Helper function for view-to-model-element converter. Takes the model configuration, the converted view element
+// and a writer instance and returns a model element instance to be inserted in the model.
+//
+// @param {String|Function|module:engine/model/element~Element} model Model conversion configuration.
+// @param {module:engine/view/node~Node} input The converted view node.
+// @param {module:engine/model/writer~Writer} writer A writer instance to use to create the model element.
+function _getModelElement( model, input, writer ) {
+	if ( model instanceof Function ) {
+		return model( input, writer );
+	} else if ( typeof model == 'string' ) {
+		return writer.createElement( model );
+	} else {
+		return model;
+	}
+}
+
+function _normalizeFromAttributeConfig( config ) {
+	const key = typeof config.view == 'string' ? config.view : config.view.key;
+	const value = typeof config.view == 'string' ? /[\s\S]*/ : config.view.value;
+
+	config.view = { attribute: {
+		[ key ]: value
+	} };
+
+	return key;
+}
+
+function _normalizeModelAttributeConfig( config, viewAttributeKeyToCopy = null ) {
+	const defaultModelValue = viewAttributeKeyToCopy === null ? true : viewElement => viewElement.getAttribute( viewAttributeKeyToCopy );
+
+	const key = typeof config.model != 'object' ? config.model : config.model.key;
+	const value = typeof config.model != 'object' ? defaultModelValue : config.model.value;
+
+	config.model = { key, value };
+}
+
+// Helper for to-model-attribute conversion. Takes the model attribute name and conversion configuration and returns
+// a proper converter function.
+//
+// @param {String} modelAttributeKey The key of the model attribute to set on a model node.
+// @param {Object|Array.<Object>} config Conversion configuration. It is possible to provide multiple configurations in an array.
+function _prepareToAttributeConverter( config ) {
+	const matcher = new Matcher( config.view );
+
+	return ( evt, data, conversionApi ) => {
+		const match = matcher.match( data.viewItem );
+
+		// If there is no match, this callback should not do anything.
+		if ( !match ) {
+			return;
+		}
+
+		const modelKey = config.model.key;
+		const modelValue = typeof config.model.value == 'function' ? config.model.value( data.viewItem ) : config.model.value;
+
+		// Do not convert if attribute building function returned falsy value.
+		if ( modelValue === null ) {
+			return;
+		}
+
+		// Try to consume appropriate values from consumable values list.
+		if ( !conversionApi.consumable.test( data.viewItem, match.match ) ) {
+			return;
+		}
+
+		// Since we are converting to attribute we need an range on which we will set the attribute.
+		// If the range is not created yet, we will create it.
+		if ( !data.modelRange ) {
+			// Convert children and set conversion result as a current data.
+			data = Object.assign( data, conversionApi.convertChildren( data.viewItem, data.cursorPosition ) );
+		}
+
+		// Set attribute on current `output`. `Schema` is checked inside this helper function.
+		const attributeWasSet = _setAttributeOn( data.modelRange, { key: modelKey, value: modelValue }, conversionApi );
+
+		if ( attributeWasSet ) {
+			conversionApi.consumable.consume( data.viewItem, match.match );
+		}
+	};
+}
+
+function _setAttributeOn( modelRange, modelAttribute, conversionApi ) {
+	let result = false;
+
+	// Set attribute on each item in range according to Schema.
+	for ( const node of Array.from( modelRange.getItems() ) ) {
+		if ( conversionApi.schema.checkAttribute( node, modelAttribute.key ) ) {
+			conversionApi.writer.setAttribute( modelAttribute.key, modelAttribute.value, node );
+
+			result = true;
+		}
+	}
+
+	return result;
+}
+
+// Helper function for view-to-model-marker conversion. Takes the config in a format requested by `elementToMarker()`
+// function and converts it to a format that is supported by `elementToElement()` function.
+//
+// @param {Object} config Conversion configuration.
+function _normalizeToMarkerConfig( config ) {
+	const oldModel = config.model;
+
+	config.model = ( viewElement, writer ) => {
+		const markerName = typeof oldModel == 'string' ? oldModel : oldModel( viewElement );
+
+		return writer.createElement( '$marker', { 'data-name': markerName } );
+	};
+}

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

@@ -212,11 +212,11 @@ export function stringify( node, selectionOrPositionOrRange = null ) {
 			return new ViewAttributeElement( 'model-text-with-attributes', { [ data.attributeKey ]: stringifyAttributeValue( value ) } );
 		}
 	} ) );
-	modelToView.on( 'insert', insertElement( data => {
+	modelToView.on( 'insert', insertElement( modelItem => {
 		// Stringify object types values for properly display as an output string.
-		const attributes = convertAttributes( data.item.getAttributes(), stringifyAttributeValue );
+		const attributes = convertAttributes( modelItem.getAttributes(), stringifyAttributeValue );
 
-		return new ViewContainerElement( data.item.name, attributes );
+		return new ViewContainerElement( modelItem.name, attributes );
 	} ) );
 	modelToView.on( 'selection', convertRangeSelection() );
 	modelToView.on( 'selection', convertCollapsedSelection() );

+ 432 - 0
packages/ckeditor5-engine/tests/conversion/definition-conversion.js

@@ -0,0 +1,432 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import {
+	modelElementIsViewElement, modelAttributeIsViewElement, modelAttributeIsViewAttribute
+} from '../../src/conversion/definition-conversion';
+
+import Conversion from '../../src/conversion/conversion';
+import ViewConversionDispatcher from '../../src/conversion/viewconversiondispatcher';
+
+import { convertText, convertToModelFragment } from '../../src/conversion/view-to-model-converters';
+
+import EditingController from '../../src/controller/editingcontroller';
+
+import Model from '../../src/model/model';
+import ModelRange from '../../src/model/range';
+
+import { stringify as viewStringify, parse as viewParse } from '../../src/dev-utils/view';
+import { stringify as modelStringify } from '../../src/dev-utils/model';
+
+describe( 'definition-conversion', () => {
+	let viewDispatcher, model, schema, conversion, modelRoot, viewRoot;
+
+	beforeEach( () => {
+		model = new Model();
+		const controller = new EditingController( model );
+
+		const modelDoc = model.document;
+		modelRoot = modelDoc.createRoot();
+
+		viewRoot = controller.view.getRoot();
+		// Set name of view root the same as dom root.
+		// This is a mock of attaching view root to dom root.
+		viewRoot._name = 'div';
+
+		schema = model.schema;
+
+		schema.extend( '$text', {
+			allowAttributes: [ 'bold' ]
+		} );
+
+		schema.register( 'paragraph', {
+			inheritAllFrom: '$block'
+		} );
+
+		viewDispatcher = new ViewConversionDispatcher( model, { schema } );
+		viewDispatcher.on( 'text', convertText() );
+		viewDispatcher.on( 'element', convertToModelFragment(), { priority: 'lowest' } );
+		viewDispatcher.on( 'documentFragment', convertToModelFragment(), { priority: 'lowest' } );
+
+		conversion = new Conversion();
+		conversion.register( 'view', [ viewDispatcher ] );
+		conversion.register( 'model', [ controller.modelToView ] );
+	} );
+
+	describe( 'modelElementIsViewElement', () => {
+		it( 'config.view is a string', () => {
+			modelElementIsViewElement( conversion, { model: 'paragraph', view: 'p' } );
+
+			test( '<p>Foo</p>', '<paragraph>Foo</paragraph>' );
+		} );
+
+		it( 'config.view is an object', () => {
+			schema.register( 'fancyParagraph', {
+				inheritAllFrom: 'paragraph'
+			} );
+
+			modelElementIsViewElement( conversion, {
+				model: 'fancyParagraph',
+				view: {
+					name: 'p',
+					class: 'fancy'
+				}
+			} );
+
+			test( '<p class="fancy">Foo</p>', '<fancyParagraph>Foo</fancyParagraph>' );
+		} );
+
+		it( 'config.view is an object with alternative view', () => {
+			schema.register( 'blockquote', {
+				inheritAllFrom: '$block'
+			} );
+
+			modelElementIsViewElement( conversion, {
+				model: 'blockquote',
+				view: 'blockquote',
+				alternativeView: [
+					{
+						name: 'div',
+						class: 'blockquote'
+					}
+				]
+			} );
+
+			test( '<blockquote>Foo</blockquote>', '<blockquote>Foo</blockquote>' );
+			test( '<div class="blockquote">Foo</div>', '<blockquote>Foo</blockquote>', '<blockquote>Foo</blockquote>' );
+		} );
+	} );
+
+	describe( 'modelAttributeIsViewElement', () => {
+		beforeEach( () => {
+			modelElementIsViewElement( conversion, { model: 'paragraph', view: 'p' } );
+		} );
+
+		it( 'config.view is a string', () => {
+			modelAttributeIsViewElement( conversion, 'bold', { view: 'strong' } );
+
+			test( '<p><strong>Foo</strong> bar</p>', '<paragraph><$text bold="true">Foo</$text> bar</paragraph>' );
+		} );
+
+		it( 'config.view is an object', () => {
+			modelAttributeIsViewElement( conversion, 'bold', {
+				view: {
+					name: 'span',
+					class: 'bold'
+				}
+			} );
+
+			test( '<p><span class="bold">Foo</span> bar</p>', '<paragraph><$text bold="true">Foo</$text> bar</paragraph>' );
+		} );
+
+		it( 'config.view is an object with alternative view', () => {
+			modelAttributeIsViewElement( conversion, 'bold', {
+				view: 'strong',
+				alternativeView: [
+					'b',
+					{
+						name: 'span',
+						class: 'bold'
+					},
+					{
+						name: 'span',
+						style: {
+							'font-weight': 'bold'
+						}
+					}
+				]
+			} );
+
+			test(
+				'<p><strong>Foo</strong></p>',
+				'<paragraph><$text bold="true">Foo</$text></paragraph>'
+			);
+
+			test(
+				'<p><b>Foo</b></p>',
+				'<paragraph><$text bold="true">Foo</$text></paragraph>',
+				'<p><strong>Foo</strong></p>'
+			);
+
+			test(
+				'<p><span class="bold">Foo</span></p>',
+				'<paragraph><$text bold="true">Foo</$text></paragraph>',
+				'<p><strong>Foo</strong></p>'
+			);
+
+			test(
+				'<p><span style="font-weight: bold;">Foo</span></p>',
+				'<paragraph><$text bold="true">Foo</$text></paragraph>',
+				'<p><strong>Foo</strong></p>'
+			);
+		} );
+
+		it( 'config.model is a string', () => {
+			schema.extend( '$text', {
+				allowAttributes: [ 'styled' ]
+			} );
+
+			modelAttributeIsViewElement( conversion, 'styled', {
+				model: 'dark',
+				view: {
+					name: 'span',
+					class: [ 'styled', 'styled-dark' ]
+				}
+			} );
+
+			test( '<p><span class="styled styled-dark">Foo</span> bar</p>', '<paragraph><$text styled="dark">Foo</$text> bar</paragraph>' );
+		} );
+
+		it( 'config is an array', () => {
+			schema.extend( '$text', {
+				allowAttributes: [ 'fontSize' ]
+			} );
+
+			modelAttributeIsViewElement( conversion, 'fontSize', [
+				{
+					model: 'big',
+					view: {
+						name: 'span',
+						style: {
+							'font-size': '1.2em'
+						}
+					}
+				},
+				{
+					model: 'small',
+					view: {
+						name: 'span',
+						style: {
+							'font-size': '0.8em'
+						}
+					}
+				}
+			] );
+
+			test(
+				'<p><span style="font-size:1.2em">Foo</span> bar</p>',
+				'<paragraph><$text fontSize="big">Foo</$text> bar</paragraph>'
+			);
+
+			test(
+				'<p><span style="font-size:0.8em">Foo</span> bar</p>',
+				'<paragraph><$text fontSize="small">Foo</$text> bar</paragraph>'
+			);
+		} );
+
+		it( 'config is an array with alternative view', () => {
+			schema.extend( '$text', {
+				allowAttributes: [ 'fontSize' ]
+			} );
+
+			modelAttributeIsViewElement( conversion, 'fontSize', [
+				{
+					model: 'big',
+					view: {
+						name: 'span',
+						style: {
+							'font-size': '1.2em'
+						}
+					},
+					alternativeView: [
+						{
+							name: 'span',
+							style: {
+								'font-size': '12px'
+							}
+						}
+					]
+				},
+				{
+					model: 'small',
+					view: {
+						name: 'span',
+						style: {
+							'font-size': '0.8em'
+						}
+					},
+					alternativeView: [
+						{
+							name: 'span',
+							style: {
+								'font-size': '8px'
+							}
+						}
+					]
+				}
+			] );
+
+			test(
+				'<p><span style="font-size:1.2em">Foo</span> bar</p>',
+				'<paragraph><$text fontSize="big">Foo</$text> bar</paragraph>'
+			);
+
+			test(
+				'<p><span style="font-size:12px">Foo</span> bar</p>',
+				'<paragraph><$text fontSize="big">Foo</$text> bar</paragraph>',
+				'<p><span style="font-size:1.2em">Foo</span> bar</p>'
+			);
+
+			test(
+				'<p><span style="font-size:0.8em">Foo</span> bar</p>',
+				'<paragraph><$text fontSize="small">Foo</$text> bar</paragraph>'
+			);
+
+			test(
+				'<p><span style="font-size:8px">Foo</span> bar</p>',
+				'<paragraph><$text fontSize="small">Foo</$text> bar</paragraph>',
+				'<p><span style="font-size:0.8em">Foo</span> bar</p>'
+			);
+		} );
+	} );
+
+	describe( 'modelAttributeIsViewAttribute', () => {
+		beforeEach( () => {
+			modelElementIsViewElement( conversion, { model: 'image', view: 'img' } );
+
+			schema.register( 'image', {
+				inheritAllFrom: '$block',
+			} );
+		} );
+
+		it( 'config is not set', () => {
+			schema.extend( 'image', {
+				allowAttributes: [ 'src' ]
+			} );
+
+			modelAttributeIsViewAttribute( conversion, 'src' );
+
+			test( '<img src="foo.jpg"></img>', '<image src="foo.jpg"></image>' );
+		} );
+
+		it( 'config.view is a string', () => {
+			schema.extend( 'image', {
+				allowAttributes: [ 'source' ]
+			} );
+
+			modelAttributeIsViewAttribute( conversion, 'source', { view: 'src' } );
+
+			test( '<img src="foo.jpg"></img>', '<image source="foo.jpg"></image>' );
+		} );
+
+		it( 'config.view is an object', () => {
+			schema.extend( 'image', {
+				allowAttributes: [ 'aside' ]
+			} );
+
+			modelAttributeIsViewAttribute( conversion, 'aside', {
+				model: true,
+				view: {
+					key: 'class',
+					value: 'aside half-size'
+				}
+			} );
+
+			test( '<img class="aside half-size"></img>', '<image aside="true"></image>' );
+		} );
+
+		it( 'config is an array', () => {
+			schema.extend( 'image', {
+				allowAttributes: [ 'styled' ]
+			} );
+
+			modelAttributeIsViewAttribute( conversion, 'styled', [
+				{
+					model: 'dark',
+					view: {
+						key: 'class',
+						value: 'styled styled-dark'
+					}
+				},
+				{
+					model: 'light',
+					view: {
+						key: 'class',
+						value: 'styled styled-light'
+					}
+				}
+			] );
+
+			test( '<img class="styled styled-dark"></img>', '<image styled="dark"></image>' );
+			test( '<img class="styled styled-light"></img>', '<image styled="light"></image>' );
+		} );
+
+		it( 'config is an array with alternative view', () => {
+			modelElementIsViewElement( conversion, { model: 'paragraph', view: 'p' } );
+			schema.extend( 'paragraph', {
+				allowAttributes: [ 'align' ]
+			} );
+
+			modelAttributeIsViewAttribute( conversion, 'align', [
+				{
+					model: 'right',
+					view: {
+						key: 'class',
+						value: 'align-right'
+					},
+					alternativeView: [
+						{
+							key: 'style',
+							value: 'text-align:right;'
+						}
+					]
+				},
+				{
+					model: 'center',
+					view: {
+						key: 'class',
+						value: 'align-center'
+					},
+					alternativeView: [
+						{
+							key: 'style',
+							value: 'text-align:center;'
+						}
+					]
+				}
+			] );
+
+			test(
+				'<p class="align-right">Foo</p>',
+				'<paragraph align="right">Foo</paragraph>'
+			);
+
+			test(
+				'<p style="text-align:right">Foo</p>',
+				'<paragraph align="right">Foo</paragraph>',
+				'<p class="align-right">Foo</p>'
+			);
+
+			test(
+				'<p class="align-center">Foo</p>',
+				'<paragraph align="center">Foo</paragraph>'
+			);
+
+			test(
+				'<p style="text-align:center">Foo</p>',
+				'<paragraph align="center">Foo</paragraph>',
+				'<p class="align-center">Foo</p>'
+			);
+		} );
+	} );
+
+	function test( input, expectedModel, expectedView = null ) {
+		loadData( input );
+
+		expect( modelStringify( model.document.getRoot() ) ).to.equal( expectedModel );
+		expect( viewStringify( viewRoot, null, { ignoreRoot: true } ) ).to.equal( expectedView || input );
+	}
+
+	function loadData( input ) {
+		const parsedView = viewParse( input );
+
+		const convertedModel = viewDispatcher.convert( parsedView );
+
+		model.change( writer => {
+			writer.remove( ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, modelRoot.maxOffset ) );
+			writer.insert( convertedModel, modelRoot, 0 );
+		} );
+	}
+} );

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

@@ -492,7 +492,7 @@ describe( 'model-selection-to-view-converters', () => {
 			model.schema.extend( '$text', { allowIn: 'td' } );
 
 			// "Universal" converter to convert table structure.
-			const tableConverter = insertElement( data => new ViewContainerElement( data.item.name ) );
+			const tableConverter = insertElement( modelItem => new ViewContainerElement( modelItem.name ) );
 			dispatcher.on( 'insert:table', tableConverter );
 			dispatcher.on( 'insert:tr', tableConverter );
 			dispatcher.on( 'insert:td', tableConverter );

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

@@ -127,8 +127,8 @@ describe( 'model-to-view-converters', () => {
 		} );
 
 		it( 'should take view element function generator as a parameter', () => {
-			const elementGenerator = ( data, consumable ) => {
-				if ( consumable.consume( data.item, 'attribute:nice' ) ) {
+			const elementGenerator = ( modelItem, consumable ) => {
+				if ( consumable.consume( modelItem, 'attribute:nice' ) ) {
 					return new ViewContainerElement( 'div' );
 				}
 
@@ -173,7 +173,7 @@ describe( 'model-to-view-converters', () => {
 		} );
 
 		it( 'should convert insert/change/remove with attribute generating function as a parameter', () => {
-			const themeConverter = ( value, key, data ) => {
+			const themeConverter = ( value, data ) => {
 				if ( data.item instanceof ModelElement && data.item.childCount > 0 ) {
 					value += ' fix-content';
 				}
@@ -930,7 +930,7 @@ describe( 'model-to-view-converters', () => {
 		it( 'should return attribute element from descriptor object', () => {
 			const descriptor = {
 				class: 'foo-class',
-				attributes: { one: 1, two: 2 },
+				attributes: { one: '1', two: '2' },
 				priority: 7,
 			};
 			const element = createViewElementFromHighlightDescriptor( descriptor );
@@ -948,7 +948,7 @@ describe( 'model-to-view-converters', () => {
 		it( 'should return attribute element from descriptor object - array with classes', () => {
 			const descriptor = {
 				class: [ 'foo-class', 'bar-class' ],
-				attributes: { one: 1, two: 2 },
+				attributes: { one: '1', two: '2' },
 				priority: 7,
 			};
 			const element = createViewElementFromHighlightDescriptor( descriptor );
@@ -966,7 +966,7 @@ describe( 'model-to-view-converters', () => {
 
 		it( 'should create element without class', () => {
 			const descriptor = {
-				attributes: { one: 1, two: 2 },
+				attributes: { one: '1', two: '2' },
 				priority: 7,
 			};
 			const element = createViewElementFromHighlightDescriptor( descriptor );
@@ -983,7 +983,7 @@ describe( 'model-to-view-converters', () => {
 		it( 'should create element without priority', () => {
 			const descriptor = {
 				class: 'foo-class',
-				attributes: { one: 1, two: 2 },
+				attributes: { one: '1', two: '2' },
 			};
 			const element = createViewElementFromHighlightDescriptor( descriptor );
 

+ 526 - 0
packages/ckeditor5-engine/tests/conversion/model-to-view-helpers.js

@@ -0,0 +1,526 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import {
+	elementToElement, attributeToElement, attributeToAttribute, markerToElement, markerToHighlight
+} from '../../src/conversion/model-to-view-helpers';
+
+import Conversion from '../../src/conversion/conversion';
+import EditingController from '../../src/controller/editingcontroller';
+
+import Model from '../../src/model/model';
+import ModelRange from '../../src/model/range';
+
+import ViewContainerElement from '../../src/view/containerelement';
+import ViewAttributeElement from '../../src/view/attributeelement';
+import ViewUIElement from '../../src/view/uielement';
+
+import { stringify } from '../../src/dev-utils/view';
+
+describe( 'model-to-view-helpers', () => {
+	let conversion, model, modelRoot, viewRoot;
+
+	beforeEach( () => {
+		model = new Model();
+		const modelDoc = model.document;
+		modelRoot = modelDoc.createRoot();
+
+		const controller = new EditingController( model );
+
+		// Set name of view root the same as dom root.
+		// This is a mock of attaching view root to dom root.
+		controller.view.getRoot()._name = 'div';
+
+		viewRoot = controller.view.getRoot();
+
+		conversion = new Conversion();
+		conversion.register( 'model', [ controller.modelToView ] );
+	} );
+
+	describe( 'elementToElement', () => {
+		it( 'config.view is a string', () => {
+			const helper = elementToElement( { model: 'paragraph', view: 'p' } );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertElement( 'paragraph', modelRoot, 0 );
+			} );
+
+			expectResult( '<p></p>' );
+		} );
+
+		it( 'can be overwritten using priority', () => {
+			const helperA = elementToElement( { model: 'paragraph', view: 'p' } );
+			const helperB = elementToElement( { model: 'paragraph', view: 'foo' }, 'high' );
+
+			conversion.for( 'model' ).add( helperA ).add( helperB );
+
+			model.change( writer => {
+				writer.insertElement( 'paragraph', modelRoot, 0 );
+			} );
+
+			expectResult( '<foo></foo>' );
+		} );
+
+		it( 'config.view is an element instance', () => {
+			const helper = elementToElement( {
+				model: 'paragraph',
+				view: new ViewContainerElement( 'p' )
+			} );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertElement( 'paragraph', modelRoot, 0 );
+			} );
+
+			expectResult( '<p></p>' );
+		} );
+
+		it( 'config.view is a view element definition', () => {
+			const helper = elementToElement( {
+				model: 'fancyParagraph',
+				view: {
+					name: 'p',
+					class: 'fancy'
+				}
+			} );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertElement( 'fancyParagraph', modelRoot, 0 );
+			} );
+
+			expectResult( '<p class="fancy"></p>' );
+		} );
+
+		it( 'config.view is a function', () => {
+			const helper = elementToElement( {
+				model: 'heading',
+				view: modelElement => new ViewContainerElement( 'h' + modelElement.getAttribute( 'level' ) )
+			} );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertElement( 'heading', { level: 2 }, modelRoot, 0 );
+			} );
+
+			expectResult( '<h2></h2>' );
+		} );
+	} );
+
+	describe( 'attributeToElement', () => {
+		it( 'config.view is a string', () => {
+			const helper = attributeToElement( 'bold', { view: 'strong' } );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertText( 'foo', { bold: true }, modelRoot, 0 );
+			} );
+
+			expectResult( '<strong>foo</strong>' );
+		} );
+
+		it( 'can be overwritten using priority', () => {
+			const helperA = attributeToElement( 'bold', { view: 'strong' } );
+			const helperB = attributeToElement( 'bold', { view: 'b' }, 'high' );
+
+			conversion.for( 'model' ).add( helperA ).add( helperB );
+
+			model.change( writer => {
+				writer.insertText( 'foo', { bold: true }, modelRoot, 0 );
+			} );
+
+			expectResult( '<b>foo</b>' );
+		} );
+
+		it( 'config.view is an element instance', () => {
+			const helper = attributeToElement( 'bold', {
+				view: new ViewAttributeElement( 'strong' )
+			} );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertText( 'foo', { bold: true }, modelRoot, 0 );
+			} );
+
+			expectResult( '<strong>foo</strong>' );
+		} );
+
+		it( 'config.view is a view element definition', () => {
+			const helper = attributeToElement( 'bold', {
+				view: {
+					name: 'span',
+					class: 'bold'
+				}
+			} );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertText( 'foo', { bold: true }, modelRoot, 0 );
+			} );
+
+			expectResult( '<span class="bold">foo</span>' );
+		} );
+
+		it( 'config.view is a view element definition, model attribute value specified', () => {
+			const helper = attributeToElement( 'styled', {
+				model: 'dark',
+				view: {
+					name: 'span',
+					class: [ 'styled', 'styled-dark' ]
+				}
+			} );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertText( 'foo', { styled: 'dark' }, modelRoot, 0 );
+			} );
+
+			expectResult( '<span class="styled styled-dark">foo</span>' );
+
+			model.change( writer => {
+				writer.setAttribute( 'styled', 'xyz', modelRoot.getChild( 0 ) );
+			} );
+
+			expectResult( 'foo' );
+		} );
+
+		it( 'multiple config items', () => {
+			const helper = attributeToElement( 'fontSize', [
+				{
+					model: 'big',
+					view: {
+						name: 'span',
+						style: {
+							'font-size': '1.2em'
+						}
+					}
+				},
+				{
+					model: 'small',
+					view: {
+						name: 'span',
+						style: {
+							'font-size': '0.8em'
+						}
+					}
+				}
+			] );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertText( 'foo', { fontSize: 'big' }, modelRoot, 0 );
+			} );
+
+			expectResult( '<span style="font-size:1.2em">foo</span>' );
+
+			model.change( writer => {
+				writer.setAttribute( 'fontSize', 'small', modelRoot.getChild( 0 ) );
+			} );
+
+			expectResult( '<span style="font-size:0.8em">foo</span>' );
+
+			model.change( writer => {
+				writer.removeAttribute( 'fontSize', modelRoot.getChild( 0 ) );
+			} );
+
+			expectResult( 'foo' );
+		} );
+
+		it( 'config.view is a function', () => {
+			const helper = attributeToElement( 'bold', {
+				view: attributeValue => new ViewAttributeElement( 'span', { style: 'font-weight:' + attributeValue } )
+			} );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertText( 'foo', { bold: '500' }, modelRoot, 0 );
+			} );
+
+			expectResult( '<span style="font-weight:500">foo</span>' );
+		} );
+	} );
+
+	describe( 'attributeToAttribute', () => {
+		beforeEach( () => {
+			conversion.for( 'model' ).add( elementToElement( { model: 'image', view: 'img' } ) );
+		} );
+
+		it( 'config not set', () => {
+			const helper = attributeToAttribute( 'src' );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertElement( 'image', { src: 'foo.jpg' }, modelRoot, 0 );
+			} );
+
+			expectResult( '<img src="foo.jpg"></img>' );
+		} );
+
+		it( 'config.view is a string', () => {
+			const helper = attributeToAttribute( 'source', { view: 'src' } );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertElement( 'image', { source: 'foo.jpg' }, modelRoot, 0 );
+			} );
+
+			expectResult( '<img src="foo.jpg"></img>' );
+		} );
+
+		it( 'can be overwritten using priority', () => {
+			const helperA = attributeToAttribute( 'source', { view: 'href' } );
+			const helperB = attributeToAttribute( 'source', { view: 'src' }, 'high' );
+
+			conversion.for( 'model' ).add( helperA ).add( helperB );
+
+			model.change( writer => {
+				writer.insertElement( 'image', { source: 'foo.jpg' }, modelRoot, 0 );
+			} );
+
+			expectResult( '<img src="foo.jpg"></img>' );
+		} );
+
+		it( 'config.view is an object', () => {
+			const helper = attributeToAttribute( 'stylish', { view: { key: 'class', value: 'styled' } } );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertElement( 'image', { stylish: true }, modelRoot, 0 );
+			} );
+
+			expectResult( '<img class="styled"></img>' );
+		} );
+
+		it( 'config.view is an object, model attribute value specified', () => {
+			const helper = attributeToAttribute( 'styled', {
+				model: 'dark',
+				view: {
+					key: 'class',
+					value: 'styled-dark styled'
+				}
+			} );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertElement( 'image', { styled: 'dark' }, modelRoot, 0 );
+			} );
+
+			expectResult( '<img class="styled styled-dark"></img>' );
+
+			model.change( writer => {
+				writer.setAttribute( 'styled', 'xyz', modelRoot.getChild( 0 ) );
+			} );
+
+			expectResult( '<img></img>' );
+		} );
+
+		it( 'multiple config items', () => {
+			const helper = attributeToAttribute( 'styled', [
+				{
+					model: 'dark',
+					view: {
+						key: 'class',
+						value: 'styled-dark'
+					}
+				},
+				{
+					model: 'light',
+					view: {
+						key: 'class',
+						value: 'styled-light'
+					}
+				}
+			] );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertElement( 'image', { styled: 'dark' }, modelRoot, 0 );
+			} );
+
+			expectResult( '<img class="styled-dark"></img>' );
+
+			model.change( writer => {
+				writer.setAttribute( 'styled', 'light', modelRoot.getChild( 0 ) );
+			} );
+
+			expectResult( '<img class="styled-light"></img>' );
+
+			model.change( writer => {
+				writer.setAttribute( 'styled', 'xyz', modelRoot.getChild( 0 ) );
+			} );
+
+			expectResult( '<img></img>' );
+		} );
+
+		it( 'config.view is a function', () => {
+			const helper = attributeToAttribute( 'styled', {
+				view: attributeValue => ( { key: 'class', value: 'styled-' + attributeValue } )
+			} );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertElement( 'image', { styled: 'pull-out' }, modelRoot, 0 );
+			} );
+
+			expectResult( '<img class="styled-pull-out"></img>' );
+		} );
+	} );
+
+	describe( 'markerToElement', () => {
+		it( 'config.view is a string', () => {
+			const helper = markerToElement( { model: 'search', view: 'marker-search' } );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertText( 'foo', modelRoot, 0 );
+				writer.setMarker( 'search', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 2 ) );
+			} );
+
+			expectResult( 'f<marker-search></marker-search>o<marker-search></marker-search>o' );
+		} );
+
+		it( 'can be overwritten using priority', () => {
+			const helperA = markerToElement( { model: 'search', view: 'marker-search' } );
+			const helperB = markerToElement( { model: 'search', view: 'search' }, 'high' );
+
+			conversion.for( 'model' ).add( helperA ).add( helperB );
+
+			model.change( writer => {
+				writer.insertText( 'foo', modelRoot, 0 );
+				writer.setMarker( 'search', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 2 ) );
+			} );
+
+			expectResult( 'f<search></search>o<search></search>o' );
+		} );
+
+		it( 'config.view is an element instance', () => {
+			const helper = markerToElement( {
+				model: 'search',
+				view: new ViewUIElement( 'span', { 'data-marker': 'search' } )
+			} );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertText( 'foo', modelRoot, 0 );
+				writer.setMarker( 'search', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 2 ) );
+			} );
+
+			expectResult( 'f<span data-marker="search"></span>o<span data-marker="search"></span>o' );
+		} );
+
+		it( 'config.view is a view element definition', () => {
+			const helper = markerToElement( {
+				model: 'search',
+				view: {
+					name: 'span',
+					attribute: {
+						'data-marker': 'search'
+					}
+				}
+			} );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertText( 'foo', modelRoot, 0 );
+				writer.setMarker( 'search', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 2 ) );
+			} );
+
+			expectResult( 'f<span data-marker="search"></span>o<span data-marker="search"></span>o' );
+		} );
+
+		it( 'config.view is a function', () => {
+			const helper = markerToElement( {
+				model: 'search',
+				view: data => {
+					return new ViewUIElement( 'span', { 'data-marker': 'search', 'data-start': data.isOpening } );
+				}
+			} );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertText( 'foo', modelRoot, 0 );
+				writer.setMarker( 'search', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 2 ) );
+			} );
+
+			expectResult( 'f<span data-marker="search" data-start="true"></span>o<span data-marker="search" data-start="false"></span>o' );
+		} );
+	} );
+
+	describe( 'markerToHighlight', () => {
+		it( 'config.view is a highlight descriptor', () => {
+			const helper = markerToHighlight( { model: 'comment', view: { class: 'comment' } } );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertText( 'foo', modelRoot, 0 );
+				writer.setMarker( 'comment', ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 ) );
+			} );
+
+			expectResult( '<span class="comment">foo</span>' );
+		} );
+
+		it( 'can be overwritten using priority', () => {
+			const helperA = markerToHighlight( { model: 'comment', view: { class: 'comment' } } );
+			const helperB = markerToHighlight( { model: 'comment', view: { class: 'new-comment' } }, 'high' );
+
+			conversion.for( 'model' ).add( helperA ).add( helperB );
+
+			model.change( writer => {
+				writer.insertText( 'foo', modelRoot, 0 );
+				writer.setMarker( 'comment', ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 ) );
+			} );
+
+			expectResult( '<span class="new-comment">foo</span>' );
+		} );
+
+		it( 'config.view is a function', () => {
+			const helper = markerToHighlight( {
+				model: 'comment',
+				view: data => {
+					const commentType = data.markerName.split( ':' )[ 1 ];
+
+					return {
+						class: [ 'comment', 'comment-' + commentType ]
+					};
+				}
+			} );
+
+			conversion.for( 'model' ).add( helper );
+
+			model.change( writer => {
+				writer.insertText( 'foo', modelRoot, 0 );
+				writer.setMarker( 'comment:abc', ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 3 ) );
+			} );
+
+			expectResult( '<span class="comment comment-abc">foo</span>' );
+		} );
+	} );
+
+	function expectResult( string ) {
+		expect( stringify( viewRoot, null, { ignoreRoot: true } ) ).to.equal( string );
+	}
+} );

+ 581 - 0
packages/ckeditor5-engine/tests/conversion/view-to-model-helpers.js

@@ -0,0 +1,581 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import {
+	elementToElement, elementToAttribute, attributeToAttribute, elementToMarker
+} from '../../src/conversion/view-to-model-helpers';
+
+import Model from '../../src/model/model';
+import Conversion from '../../src/conversion/conversion';
+import ViewConversionDispatcher from '../../src/conversion/viewconversiondispatcher';
+
+import ModelElement from '../../src/model/element';
+
+import ViewDocumentFragment from '../../src/view/documentfragment';
+import ViewUIElement from '../../src/view/uielement';
+import ViewContainerElement from '../../src/view/containerelement';
+import ViewAttributeElement from '../../src/view/attributeelement';
+import ViewText from '../../src/view/text';
+
+import { convertText, convertToModelFragment } from '../../src/conversion/view-to-model-converters';
+import { stringify } from '../../src/dev-utils/model';
+
+describe( 'view-to-model-helpers', () => {
+	let dispatcher, model, schema, conversion;
+
+	beforeEach( () => {
+		model = new Model();
+
+		schema = model.schema;
+
+		schema.extend( '$text', {
+			allowIn: '$root'
+		} );
+
+		schema.register( '$marker', {
+			inheritAllFrom: '$block'
+		} );
+
+		schema.register( 'paragraph', {
+			inheritAllFrom: '$block'
+		} );
+
+		schema.extend( '$text', {
+			allowAttributes: [ 'bold' ]
+		} );
+
+		dispatcher = new ViewConversionDispatcher( model, { schema } );
+		dispatcher.on( 'text', convertText() );
+		dispatcher.on( 'element', convertToModelFragment(), { priority: 'lowest' } );
+		dispatcher.on( 'documentFragment', convertToModelFragment(), { priority: 'lowest' } );
+
+		conversion = new Conversion();
+		conversion.register( 'view', [ dispatcher ] );
+	} );
+
+	describe( 'elementToElement', () => {
+		it( 'config.view is a string', () => {
+			const helper = elementToElement( { view: 'p', model: 'paragraph' } );
+
+			conversion.for( 'view' ).add( helper );
+
+			expectResult( new ViewContainerElement( 'p' ), '<paragraph></paragraph>' );
+		} );
+
+		it( 'can be overwritten using priority', () => {
+			schema.register( 'p', {
+				inheritAllFrom: '$block'
+			} );
+
+			const helperA = elementToElement( { view: 'p', model: 'p' } );
+			const helperB = elementToElement( { view: 'p', model: 'paragraph' }, 'high' );
+
+			conversion.for( 'view' ).add( helperA ).add( helperB );
+
+			expectResult( new ViewContainerElement( 'p' ), '<paragraph></paragraph>' );
+		} );
+
+		it( 'config.view is an object', () => {
+			schema.register( 'fancyParagraph', {
+				inheritAllFrom: '$block'
+			} );
+
+			const helperParagraph = elementToElement( { view: 'p', model: 'paragraph' } );
+			const helperFancy = elementToElement( {
+				view: {
+					name: 'p',
+					class: 'fancy'
+				},
+				model: 'fancyParagraph'
+			}, 'high' );
+
+			conversion.for( 'view' ).add( helperParagraph ).add( helperFancy );
+
+			expectResult( new ViewContainerElement( 'p' ), '<paragraph></paragraph>' );
+			expectResult( new ViewContainerElement( 'p', { class: 'fancy' } ), '<fancyParagraph></fancyParagraph>' );
+		} );
+
+		it( 'config.model is element instance', () => {
+			schema.extend( 'paragraph', {
+				allowAttributes: [ 'fancy' ]
+			} );
+
+			const helper = elementToElement( {
+				view: {
+					name: 'p',
+					class: 'fancy'
+				},
+				model: new ModelElement( 'paragraph', { fancy: true } )
+			} );
+
+			conversion.for( 'view' ).add( helper );
+
+			expectResult( new ViewContainerElement( 'p', { class: 'fancy' } ), '<paragraph fancy="true"></paragraph>' );
+		} );
+
+		it( 'config.model is a function', () => {
+			schema.register( 'heading', {
+				inheritAllFrom: '$block',
+				allowAttributes: [ 'level' ]
+			} );
+
+			const helper = elementToElement( {
+				view: {
+					name: 'p',
+					class: 'heading'
+				},
+				model: viewElement => new ModelElement( 'heading', { level: viewElement.getAttribute( 'data-level' ) } )
+			} );
+
+			conversion.for( 'view' ).add( helper );
+
+			expectResult( new ViewContainerElement( 'p', { class: 'heading', 'data-level': 2 } ), '<heading level="2"></heading>' );
+		} );
+
+		it( 'should fire conversion of the element children', () => {
+			const helper = elementToElement( { view: 'p', model: 'paragraph' } );
+
+			conversion.for( 'view' ).add( helper );
+
+			expectResult( new ViewContainerElement( 'p', null, new ViewText( 'foo' ) ), '<paragraph>foo</paragraph>' );
+		} );
+
+		it( 'should not insert a model element if it is not allowed by schema', () => {
+			const helper = elementToElement( { view: 'h2', model: 'heading' } );
+
+			conversion.for( 'view' ).add( helper );
+
+			expectResult( new ViewContainerElement( 'h2' ), '' );
+		} );
+
+		it( 'should auto-break elements', () => {
+			schema.register( 'heading', {
+				inheritAllFrom: '$block'
+			} );
+
+			const helperParagraph = elementToElement( { view: 'p', model: 'paragraph' } );
+			const helperHeading = elementToElement( { view: 'h2', model: 'heading' } );
+
+			conversion.for( 'view' ).add( helperParagraph ).add( helperHeading );
+
+			expectResult(
+				new ViewContainerElement( 'p', null, [
+					new ViewText( 'Foo' ),
+					new ViewContainerElement( 'h2', null, new ViewText( 'Xyz' ) ),
+					new ViewText( 'Bar' )
+				] ),
+				'<paragraph>Foo</paragraph><heading>Xyz</heading><paragraph>Bar</paragraph>'
+			);
+		} );
+
+		it( 'should not do anything if returned model element is null', () => {
+			const helperA = elementToElement( { view: 'p', model: 'paragraph' } );
+			const helperB = elementToElement( { view: 'p', model: () => null }, 'high' );
+
+			conversion.for( 'view' ).add( helperA ).add( helperB );
+
+			expectResult( new ViewContainerElement( 'p' ), '<paragraph></paragraph>' );
+		} );
+	} );
+
+	describe( 'elementToAttribute', () => {
+		it( 'config.view is string', () => {
+			const helper = elementToAttribute( { view: 'strong', model: 'bold' } );
+
+			conversion.for( 'view' ).add( helper );
+
+			expectResult(
+				new ViewAttributeElement( 'strong', null, new ViewText( 'foo' ) ),
+				'<$text bold="true">foo</$text>'
+			);
+		} );
+
+		it( 'can be overwritten using priority', () => {
+			const helperA = elementToAttribute( { view: 'strong', model: 'strong' } );
+			const helperB = elementToAttribute( { view: 'strong', model: 'bold' }, 'high' );
+
+			conversion.for( 'view' ).add( helperA ).add( helperB );
+
+			expectResult(
+				new ViewAttributeElement( 'strong', null, new ViewText( 'foo' ) ),
+				'<$text bold="true">foo</$text>'
+			);
+		} );
+
+		it( 'config.view is an object', () => {
+			const helper = elementToAttribute( {
+				view: {
+					name: 'span',
+					class: 'bold'
+				},
+				model: 'bold'
+			} );
+
+			conversion.for( 'view' ).add( helper );
+
+			expectResult(
+				new ViewAttributeElement( 'span', { class: 'bold' }, new ViewText( 'foo' ) ),
+				'<$text bold="true">foo</$text>'
+			);
+		} );
+
+		it( 'model attribute value is given', () => {
+			schema.extend( '$text', {
+				allowAttributes: [ 'styled' ]
+			} );
+
+			const helper = elementToAttribute( {
+				view: {
+					name: 'span',
+					class: [ 'styled', 'styled-dark' ]
+				},
+				model: {
+					key: 'styled',
+					value: 'dark'
+				}
+			} );
+
+			conversion.for( 'view' ).add( helper );
+
+			expectResult(
+				new ViewAttributeElement( 'span', { class: 'styled styled-dark' }, new ViewText( 'foo' ) ),
+				'<$text styled="dark">foo</$text>'
+			);
+		} );
+
+		it( 'model attribute value is a function', () => {
+			schema.extend( '$text', {
+				allowAttributes: [ 'fontSize' ]
+			} );
+
+			const helper = elementToAttribute( {
+				view: {
+					name: 'span',
+					style: {
+						'font-size': /[\s\S]+/
+					}
+				},
+				model: {
+					key: 'fontSize',
+					value: viewElement => {
+						const fontSize = viewElement.getStyle( 'font-size' );
+						const value = fontSize.substr( 0, fontSize.length - 2 );
+
+						if ( value <= 10 ) {
+							return 'small';
+						} else if ( value > 12 ) {
+							return 'big';
+						}
+
+						return null;
+					}
+				}
+			} );
+
+			conversion.for( 'view' ).add( helper );
+
+			expectResult(
+				new ViewAttributeElement( 'span', { style: 'font-size:9px' }, new ViewText( 'foo' ) ),
+				'<$text fontSize="small">foo</$text>'
+			);
+
+			expectResult(
+				new ViewAttributeElement( 'span', { style: 'font-size:12px' }, new ViewText( 'foo' ) ),
+				'foo'
+			);
+
+			expectResult(
+				new ViewAttributeElement( 'span', { style: 'font-size:14px' }, new ViewText( 'foo' ) ),
+				'<$text fontSize="big">foo</$text>'
+			);
+		} );
+
+		it( 'should not set an attribute if it is not allowed by schema', () => {
+			const helper = elementToAttribute( { view: 'em', model: 'italic' } );
+
+			conversion.for( 'view' ).add( helper );
+
+			expectResult(
+				new ViewAttributeElement( 'em', null, new ViewText( 'foo' ) ),
+				'foo'
+			);
+		} );
+
+		it( 'should not do anything if returned model attribute is null', () => {
+			const helperA = elementToAttribute( { view: 'strong', model: 'bold' } );
+			const helperB = elementToAttribute( {
+				view: 'strong',
+				model: {
+					key: 'bold',
+					value: () => null
+				}
+			}, 'high' );
+
+			conversion.for( 'view' ).add( helperA ).add( helperB );
+
+			expectResult(
+				new ViewAttributeElement( 'strong', null, new ViewText( 'foo' ) ),
+				'<$text bold="true">foo</$text>'
+			);
+		} );
+	} );
+
+	describe( 'attributeToAttribute', () => {
+		beforeEach( () => {
+			conversion.for( 'view' ).add( elementToElement( { view: 'img', model: 'image' } ) );
+
+			schema.register( 'image', {
+				inheritAllFrom: '$block'
+			} );
+		} );
+
+		it( 'config.view is a string', () => {
+			schema.extend( 'image', {
+				allowAttributes: [ 'source' ]
+			} );
+
+			const helper = attributeToAttribute( { view: 'src', model: 'source' } );
+
+			conversion.for( 'view' ).add( helper );
+
+			expectResult(
+				new ViewAttributeElement( 'img', { src: 'foo.jpg' } ),
+				'<image source="foo.jpg"></image>'
+			);
+		} );
+
+		it( 'can be overwritten using priority', () => {
+			schema.extend( 'image', {
+				allowAttributes: [ 'src', 'source' ]
+			} );
+
+			const helperA = attributeToAttribute( { view: 'src', model: 'src' } );
+			const helperB = attributeToAttribute( { view: 'src', model: 'source' }, 'normal' );
+
+			conversion.for( 'view' ).add( helperA ).add( helperB );
+
+			expectResult(
+				new ViewAttributeElement( 'img', { src: 'foo.jpg' } ),
+				'<image source="foo.jpg"></image>'
+			);
+		} );
+
+		it( 'config.view is an object', () => {
+			schema.extend( 'image', {
+				allowAttributes: [ 'styled' ]
+			} );
+
+			const helper = attributeToAttribute( {
+				view: {
+					key: 'data-style',
+					value: /[\s\S]*/
+				},
+				model: 'styled'
+			} );
+
+			conversion.for( 'view' ).add( helper );
+
+			expectResult(
+				new ViewAttributeElement( 'img', { 'data-style': 'dark' } ),
+				'<image styled="dark"></image>'
+			);
+		} );
+
+		it( 'model attribute value is a string', () => {
+			schema.extend( 'image', {
+				allowAttributes: [ 'styled' ]
+			} );
+
+			const helper = attributeToAttribute( {
+				view: {
+					key: 'class',
+					value: 'styled-dark'
+				},
+				model: {
+					key: 'styled',
+					value: 'dark'
+				}
+			} );
+
+			conversion.for( 'view' ).add( helper );
+
+			expectResult(
+				new ViewAttributeElement( 'img', { class: 'styled-dark' } ),
+				'<image styled="dark"></image>'
+			);
+
+			expectResult(
+				new ViewAttributeElement( 'img', { class: 'styled-xxx' } ),
+				'<image></image>'
+			);
+		} );
+
+		it( 'model attribute value is a function', () => {
+			schema.extend( 'image', {
+				allowAttributes: [ 'styled' ]
+			} );
+
+			const helper = attributeToAttribute( {
+				view: 'data-style',
+				model: {
+					key: 'styled',
+					value: viewElement => viewElement.getAttribute( 'data-style' ).substr( 6 )
+				}
+			} );
+
+			conversion.for( 'view' ).add( helper );
+
+			expectResult(
+				new ViewAttributeElement( 'img', { 'data-style': 'style-dark' } ),
+				'<image styled="dark"></image>'
+			);
+		} );
+
+		it( 'should not set an attribute if it is not allowed by schema', () => {
+			const helper = attributeToAttribute( { view: 'src', model: 'source' } );
+
+			conversion.for( 'view' ).add( helper );
+
+			expectResult(
+				new ViewAttributeElement( 'img', { src: 'foo.jpg' } ),
+				'<image></image>'
+			);
+		} );
+
+		it( 'should not do anything if returned model attribute is null', () => {
+			schema.extend( 'image', {
+				allowAttributes: [ 'styled' ]
+			} );
+
+			const helperA = attributeToAttribute( {
+				view: {
+					key: 'class',
+					value: 'styled'
+				},
+				model: {
+					key: 'styled',
+					value: true
+				}
+			} );
+
+			const helperB = attributeToAttribute( {
+				view: {
+					key: 'class',
+					value: 'styled'
+				},
+				model: {
+					key: 'styled',
+					value: () => null
+				}
+			} );
+
+			conversion.for( 'view' ).add( helperA ).add( helperB );
+
+			expectResult(
+				new ViewAttributeElement( 'img', { class: 'styled' } ),
+				'<image styled="true"></image>'
+			);
+		} );
+	} );
+
+	describe( 'elementToMarker', () => {
+		it( 'config.view is a string', () => {
+			const helper = elementToMarker( { view: 'marker-search', model: 'search' } );
+
+			conversion.for( 'view' ).add( helper );
+
+			const frag = new ViewDocumentFragment( [
+				new ViewText( 'fo' ),
+				new ViewUIElement( 'marker-search' ),
+				new ViewText( 'oba' ),
+				new ViewUIElement( 'marker-search' ),
+				new ViewText( 'r' )
+			] );
+
+			const marker = { name: 'search', start: [ 2 ], end: [ 5 ] };
+
+			expectResult( frag, 'foobar', marker );
+		} );
+
+		it( 'can be overwritten using priority', () => {
+			const helperA = elementToMarker( { view: 'marker-search', model: 'search-result' } );
+			const helperB = elementToMarker( { view: 'marker-search', model: 'search' }, 'high' );
+
+			conversion.for( 'view' ).add( helperA ).add( helperB );
+
+			const frag = new ViewDocumentFragment( [
+				new ViewText( 'fo' ),
+				new ViewUIElement( 'marker-search' ),
+				new ViewText( 'oba' ),
+				new ViewUIElement( 'marker-search' ),
+				new ViewText( 'r' )
+			] );
+
+			const marker = { name: 'search', start: [ 2 ], end: [ 5 ] };
+
+			expectResult( frag, 'foobar', marker );
+		} );
+
+		it( 'config.view is an object', () => {
+			const helper = elementToMarker( {
+				view: {
+					name: 'span',
+					'data-marker': 'search'
+				},
+				model: 'search'
+			} );
+
+			conversion.for( 'view' ).add( helper );
+
+			const frag = new ViewDocumentFragment( [
+				new ViewText( 'f' ),
+				new ViewUIElement( 'span', { 'data-marker': 'search' } ),
+				new ViewText( 'oob' ),
+				new ViewUIElement( 'span', { 'data-marker': 'search' } ),
+				new ViewText( 'ar' )
+			] );
+
+			const marker = { name: 'search', start: [ 1 ], end: [ 4 ] };
+
+			expectResult( frag, 'foobar', marker );
+		} );
+
+		it( 'config.model is a function', () => {
+			const helper = elementToMarker( {
+				view: 'comment',
+				model: viewElement => 'comment:' + viewElement.getAttribute( 'data-comment-id' )
+			} );
+
+			conversion.for( 'view' ).add( helper );
+
+			const frag = new ViewDocumentFragment( [
+				new ViewText( 'foo' ),
+				new ViewUIElement( 'comment', { 'data-comment-id': 4 } ),
+				new ViewText( 'b' ),
+				new ViewUIElement( 'comment', { 'data-comment-id': 4 } ),
+				new ViewText( 'ar' )
+			] );
+
+			const marker = { name: 'comment:4', start: [ 3 ], end: [ 4 ] };
+
+			expectResult( frag, 'foobar', marker );
+		} );
+	} );
+
+	function expectResult( viewToConvert, modelString, marker ) {
+		const model = dispatcher.convert( viewToConvert );
+
+		if ( marker ) {
+			expect( model.markers.has( marker.name ) ).to.be.true;
+
+			const convertedMarker = model.markers.get( marker.name );
+
+			expect( convertedMarker.start.path ).to.deep.equal( marker.start );
+			expect( convertedMarker.end.path ).to.deep.equal( marker.end );
+		}
+
+		expect( stringify( model ) ).to.equal( modelString );
+	}
+} );