Browse Source

Merge pull request #376 from ckeditor/t/296

T/296 Build Converters
Szymon Cofalik 9 years ago
parent
commit
ce169080c5

+ 277 - 0
packages/ckeditor5-engine/src/treecontroller/model-converter-builder.js

@@ -0,0 +1,277 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import {
+	insertElement,
+	setAttribute,
+	removeAttribute,
+	wrap,
+	unwrap
+} from '/ckeditor5/engine/treecontroller/model-to-view-converters.js';
+
+import ViewAttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
+import ViewContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
+
+/**
+ * Provides chainable, high-level API to easily build basic model-to-view converters that are appended to given
+ * dispatchers. In many cases, this is the API that should be used to specify how abstract model elements and
+ * attributes should be represented in the view (and then later in DOM). Instances of this class are created by
+ * {@link engine.treeController.BuildModelConverterFor}.
+ *
+ * If you need more complex converters, see {@link engine.treeController.ModelConversionDispatcher},
+ * {@link engine.treeController.modelToView}, {@link engine.treeController.ModelConsumable}, {@link engine.treeController.Mapper}.
+ *
+ * Using this API it is possible to create three kinds of converters:
+ *
+ * 1. Model element to view element converter. This is a converter that takes the model element and represents it
+ * in the view.
+ *
+ *		BuildModelConverterFor( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
+ *		BuildModelConverterFor( dispatcher ).fromElement( 'image' ).toElement( 'img' );
+ *
+ * 2. Model attribute to view attribute converter. This is a converter that operates on model element attributes
+ * and converts them to view element attributes. It is suitable for elements like `image` (`src`, `title` attributes).
+ *
+ *		BuildModelConverterFor( dispatcher ).fromElement( 'image' ).toElement( 'img' );
+ *		BuildModelConverterFor( dispatcher ).fromAttribute( 'src' ).toAttribute();
+ *
+ * 3. Model attribute to view element converter. This is a converter that takes model attributes and represents them
+ * as view elements. Those view elements are wrapping view elements are node that correspond to model elements and
+ * nodes which had converter attribute. It is suitable for attributes like `bold`, where `bold` attribute set on model
+ * text nodes is converter to `strong` view element.
+ *
+ *		BuildModelConverterFor( dispatcher ).fromAttribute( 'bold' ).toElement( 'strong' );
+ *
+ * It is possible to provide various different parameters for {@link engine.treeController.ModelConverterBuilder#toElement}
+ * and {@link engine.treeController.ModelConverterBuilder#toAttribute} methods. See their descriptions to learn more.
+ *
+ * It is also possible to {@link engine.treeController.ModelConverterBuilder#withPriority change default priority}
+ * of created converters to decide which converter should be fired earlier and which later. This is useful if you provide
+ * a general converter but want to provide different converter for a specific-case (i.e. given model element is converted
+ * always to given view element, but if it has given attribute it is converter to other view element). For this,
+ * use {@link engine.treeController.ModelConverterBuilder#withPriority withPriority} right after `from...` method.
+ *
+ * Note that `to...` methods are "terminators", which means that should be the last one used in building converter.
+ *
+ * You can use {@link engine.treeController.ViewConverterBuilder} to create "opposite" converters - from view to model.
+ *
+ * @memberOf engine.treeController
+ */
+class ModelConverterBuilder {
+	/**
+	 * Creates `ModelConverterBuilder` with given `dispatchers` registered to it.
+	 *
+	 * @param {Array.<engine.treeController.ModelConversionDispatcher>} dispatchers Dispatchers to which converters will
+	 * be attached.
+	 */
+	constructor( dispatchers ) {
+		/**
+		 * Dispatchers to which converters will be attached.
+		 *
+		 * @type {Array.<engine.treeController.ModelConversionDispatcher>}
+		 * @private
+		 */
+		this._dispatchers = dispatchers;
+
+		/**
+		 * Contains data about registered "from" query.
+		 *
+		 * @type {Object}
+		 * @private
+		 */
+		this._from = null;
+	}
+
+	/**
+	 * Registers what model element should be converted.
+	 *
+	 * @chainable
+	 * @param {String} elementName Name of element to convert.
+	 * @returns {engine.treeController.ModelConverterBuilder}
+	 */
+	fromElement( elementName ) {
+		this._from = {
+			type: 'element',
+			name: elementName,
+			priority: null
+		};
+
+		return this;
+	}
+
+	/**
+	 * Registers what model attribute should be converted.
+	 *
+	 * @chainable
+	 * @param {String} key Key of attribute to convert.
+	 * @returns {engine.treeController.ModelConverterBuilder}
+	 */
+	fromAttribute( key ) {
+		this._from = {
+			type: 'attribute',
+			key: key,
+			priority: null
+		};
+
+		return this;
+	}
+
+	/**
+	 * Changes default priority for built converter. The lower the number, the earlier converter will be fired.
+	 * Default priority is `10`.
+	 *
+	 * **Note:** Keep in mind that event priority, that is set by this modifier, is used for attribute priority
+	 * when {@link engine.treeView.Writer} is used. This changes how model attributes converter to view elements are
+	 * ordered, i.e.: `<strong><em>foo</em></strong>` vs `<em><strong>foo</strong></em>`. Using priority you can also
+	 * prevent node merging, i.e.: `<span class="bold"><span class="theme">foo</span><span>` vs `<span class="bold theme">foo</span>`.
+	 * If you want to prevent merging, just set different priority for both converters.
+	 *
+	 *		BuildModelConverterFor( dispatcher ).fromAttribute( 'bold' ).withPriority( 2 ).toElement( 'strong' );
+	 *		BuildModelConverterFor( dispatcher ).fromAttribute( 'italic' ).withPriority( 3 ).toElement( 'em' );
+	 *
+	 * @chainable
+	 * @param {Number} priority Converter priority.
+	 * @returns {engine.treeController.ModelConverterBuilder}
+	 */
+	withPriority( priority ) {
+		this._from.priority = priority;
+
+		return this;
+	}
+
+	/**
+	 * Registers what view element will be created by converter.
+	 *
+	 * Method accepts various ways of providing how the view element will be created. You can pass view element name as
+	 * `string`, view element instance which will be cloned and used, or creator function which returns view element that
+	 * will be used. Keep in mind that when you view element instance or creator function, it has to be/return a
+	 * proper type of view element: {@link engine.treeView.ViewContainerElement ViewContainerElement} if you convert
+	 * from element or {@link engine.treeView.ViewAttributeElement ViewAttributeElement} if you convert from attribute.
+	 *
+	 *		BuildModelConverterFor( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
+	 *
+	 *		BuildModelConverterFor( dispatcher ).fromElement( 'image' ).toElement( new ViewContainerElement( 'img' ) );
+	 *
+	 *		BuildModelConverterFor( dispatcher )
+	 *			.fromElement( 'header' )
+	 *			.toElement( ( data ) => new ViewContainerElement( 'h' + data.item.getAttribute( 'level' ) ) );
+	 *
+	 *		BuildModelConverterFor( dispatcher ).fromAttribute( 'bold' ).toElement( new ViewAttributeElement( 'strong' ) );
+	 *
+	 * Creator function will be passed different values depending whether conversion is from element or from attribute:
+	 *
+	 * * from element: dispatcher's {@link engine.treeController.ModelConversionDispatcher#event:insert insert event} parameters
+	 * will be passed
+	 * * from attribute: first parameter is attribute value, then the rest of parameters are dispatcher's
+	 * {@link engine.treeController.ModelConversionDispatcher#event:changeAttribute changeAttribute event} parameters.
+	 *
+	 * This method creates the converter and adds it as a callback to a proper
+	 * {@link engine.treeController.ModelConversionDispatcher conversion dispatcher} event.
+	 *
+	 * @param {String|engine.treeView.ViewElement|Function} element Element created by converter.
+	 */
+	toElement( element ) {
+		const priority = this._from.priority === null ? 10 : this._from.priority;
+
+		for ( let dispatcher of this._dispatchers ) {
+			if ( this._from.type == 'element' ) {
+				// From model element to view element -> insert element.
+				element = typeof element == 'string' ? new ViewContainerElement( element ) : element;
+
+				dispatcher.on( 'insert:' + this._from.name, insertElement( element ), null, priority );
+			} else {
+				// From model attribute to view element -> wrap and unwrap.
+				element = typeof element == 'string' ? new ViewAttributeElement( element ) : element;
+
+				dispatcher.on( 'addAttribute:' + this._from.key, wrap( element ), null, priority );
+				dispatcher.on( 'changeAttribute:' + this._from.key, wrap( element ), null, priority );
+				dispatcher.on( 'removeAttribute:' + this._from.key, unwrap( element ), null, priority );
+			}
+		}
+	}
+
+	/**
+	 * Registers what view attribute will be created by converter. Keep in mind, that only model attribute to
+	 * view attribute conversion is supported.
+	 *
+	 * Method accepts various ways of providing how the view attribute will be created:
+	 *
+	 * * for no passed parameter, attribute key and value will be converted 1-to-1 to view attribute,
+	 * * if you pass one `string`, it will be used as new attribute key while attribute value will be copied,
+	 * * if you pass two `string`s, first one will be used as new attribute key and second one as new attribute value,
+	 * * if you pass a function, it is expected to return an object with `key` and `value` properties representing attribute key and value.
+	 * This function will be passed model attribute value and model attribute key as first two parameters and then
+	 * all dispatcher's {engine.treeController.ModelConversionDispatcher#event:changeAttribute changeAttribute event} parameters.
+	 *
+	 *		BuildModelConverterFor( dispatcher ).fromAttribute( 'class' ).toAttribute( '' );
+	 *
+	 *		BuildModelConverterFor( dispatcher ).fromAttribute( 'linkTitle' ).toAttribute( 'title' );
+	 *
+	 *		BuildModelConverterFor( dispatcher ).fromAttribute( 'highlighted' ).toAttribute( 'style', 'background:yellow' );
+	 *
+	 *		BuildModelConverterFor( dispatcher )
+	 *			.fromAttribute( 'theme' )
+	 *			.toAttribute( ( value ) => ( { key: 'class', value: value + '-theme' } ) );
+	 *
+	 * This method creates the converter and adds it as a callback to a proper
+	 * {@link engine.treeController.ModelConversionDispatcher conversion dispatcher} event.
+	 *
+	 * @param {String|Function} [keyOrCreator] Attribute key or a creator function.
+	 * @param {*} [value] Attribute value.
+	 */
+	toAttribute( keyOrCreator, value ) {
+		if ( this._from.type == 'element' ) {
+			// Converting from model element to view attribute is unsupported.
+			return;
+		}
+
+		let attributeCreator;
+
+		if ( !keyOrCreator ) {
+			// If `keyOrCreator` is not set, we assume default behavior which is 1:1 attribute re-write.
+			// This is also a default behavior for `setAttribute` converter when no attribute creator is passed.
+			attributeCreator = undefined;
+		} else if ( typeof keyOrCreator == 'string' ) {
+			// `keyOrCreator` is an attribute key.
+
+			if ( value ) {
+				// If value is set, create "dumb" creator that always returns the same object.
+				attributeCreator = function() {
+					return { key: keyOrCreator, value: value };
+				};
+			} else {
+				// If value is not set, take it from the passed parameter.
+				attributeCreator = function( value ) {
+					return { key: keyOrCreator, value: value };
+				};
+			}
+		} else {
+			// `keyOrCreator` is an attribute creator function.
+			attributeCreator = keyOrCreator;
+		}
+
+		for ( let dispatcher of this._dispatchers ) {
+			dispatcher.on( 'addAttribute:' + this._from.key, setAttribute( attributeCreator ), null, this._from.priority || 10 );
+			dispatcher.on( 'changeAttribute:' + this._from.key, setAttribute( attributeCreator ), null, this._from.priority || 10 );
+			dispatcher.on( 'removeAttribute:' + this._from.key, removeAttribute( attributeCreator ), null, this._from.priority || 10 );
+		}
+	}
+}
+
+/**
+ * Entry point for model-to-view converters builder. This chainable API makes it easy to create basic, most common
+ * model-to-view converters and attach them to provided dispatchers. The method returns an instance of
+ * {@link engine.treeController.ModelConverterBuilder}.
+ *
+ * @external engine.treeController.BuildModelConverterFor
+ * @memberOf engine.treeController
+ * @param {...engine.treeController.ModelConversionDispatcher} dispatchers One or more dispatchers which
+ * the built converter will be attached to.
+ */
+export default function BuildModelConverterFor( ...dispatchers ) {
+	return new ModelConverterBuilder( dispatchers );
+}

+ 20 - 11
packages/ckeditor5-engine/src/treecontroller/model-to-view-converters.js

@@ -22,7 +22,7 @@ import ViewText from '../treeview/text.js';
  * Function factory, creates a converter that converts node insertion changes from the model to the view.
  * The view element that will be added to the view depends on passed parameter. If {@link engine.treeView.Element} was passed,
  * it will be cloned and the copy will be inserted. If `Function` is provided, it is passed all the parameters of the
- * {@link engine.treeController.ModelConversionDispatcher.insert dispatcher's insert event}. It's expected that the
+ * dispatcher's {@link engine.treeController.ModelConversionDispatcher#event:insert insert event}. It's expected that the
  * function returns a {@link engine.treeView.Element}. The result of the function will be inserted to the view.
  *
  * The converter automatically consumes corresponding value from consumables list, stops the event (see
@@ -122,10 +122,10 @@ export function insertText() {
  * @returns {Function} Set/change attribute converter.
  */
 export function setAttribute( attributeCreator ) {
-	attributeCreator = attributeCreator || ( ( data ) => ( { key: data.attributeKey, value: data.attributeNewValue } ) );
+	attributeCreator = attributeCreator || ( ( value, key ) => ( { value, key } ) );
 
 	return ( evt, data, consumable, conversionApi ) => {
-		const { key, value } = attributeCreator( data, consumable, conversionApi );
+		const { key, value } = attributeCreator( data.attributeNewValue, data.attributeKey, data, consumable, conversionApi );
 
 		consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
 		conversionApi.mapper.toViewElement( data.item ).setAttribute( key, value );
@@ -164,15 +164,15 @@ export function setAttribute( attributeCreator ) {
  * @function engine.treeController.modelToView.removeAttribute
  * @param {Function} [attributeCreator] Function returning an object with two properties: `key` and `value`, which
  * represents attribute key and attribute value to be removed from {@link engine.treeView.Element view element}. The function
- * is passed all the parameters of the {@link engine.treeController.ModelConversionDispatcher.addAttribute}
- * or {@link engine.treeController.ModelConversionDispatcher.changeAttribute} event.
+ * is passed all the parameters of the {@link engine.treeController.ModelConversionDispatcher#event:addAttribute addAttribute event}
+ * or {@link engine.treeController.ModelConversionDispatcher#event:changeAttribute changeAttribute event}.
  * @returns {Function} Remove attribute converter.
  */
 export function removeAttribute( attributeCreator ) {
-	attributeCreator = attributeCreator || ( ( data ) => ( { key: data.attributeKey } ) );
+	attributeCreator = attributeCreator || ( ( value, key ) => ( { key } ) );
 
 	return ( evt, data, consumable, conversionApi ) => {
-		const { key } = attributeCreator( data, consumable, conversionApi );
+		const { key } = attributeCreator( data.attributeOldValue, data.attributeKey, data, consumable, conversionApi );
 
 		consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
 		conversionApi.mapper.toViewElement( data.item ).removeAttribute( key );
@@ -194,7 +194,7 @@ export function removeAttribute( attributeCreator ) {
  *
  * The wrapping node depends on passed parameter. If {@link engine.treeView.Element} was passed, it will be cloned and
  * the copy will become the wrapping element. If `Function` is provided, it is passed all the parameters of the
- * {@link engine.treeController.ModelConversionDispatcher.setAttribute event}. It's expected that the
+ * {@link engine.treeController.ModelConversionDispatcher#event:setAttribute setAttribute event}. It's expected that the
  * function returns a {@link engine.treeView.Element}. The result of the function will be the wrapping element.
  *
  * The converter automatically consumes corresponding value from consumables list, stops the event (see
@@ -218,6 +218,14 @@ export function wrap( elementCreator ) {
 			elementCreator.clone( true ) :
 			elementCreator( data.attributeNewValue, data, consumable, conversionApi );
 
+		// If this is a change event (because old value is not empty) and the creator is a function (so
+		// it may create different view elements basing on attribute value) we have to create
+		// view element basing on old value and unwrap it before wrapping with a newly created view element.
+		if ( data.attributeOldValue !== null && !( elementCreator instanceof ViewElement ) ) {
+			const oldViewElement = elementCreator( data.attributeOldValue, data, consumable, conversionApi );
+			conversionApi.writer.unwrap( viewRange, oldViewElement, evt.priority );
+		}
+
 		conversionApi.writer.wrap( viewRange, viewElement, evt.priority );
 
 		evt.stop();
@@ -231,9 +239,10 @@ export function wrap( elementCreator ) {
  *
  * The view element type that will be unwrapped depends on passed parameter.
  * If {@link engine.treeView.Element} was passed, it will be used to look for similar element in the view for unwrapping. If `Function`
- * is provided, it is passed all the parameters of the {@link engine.treeController.ModelConversionDispatcher.setAttribute event}.
- * It's expected that the function returns a {@link engine.treeView.Element}. The result of the function will be used to
- * look for similar element in the view for unwrapping.
+ * is provided, it is passed all the parameters of the
+ * {@link engine.treeController.ModelConversionDispatcher#event:setAttribute setAttribute event}. It's expected that the
+ * function returns a {@link engine.treeView.Element}. The result of the function will be used to look for similar element
+ * in the view for unwrapping.
  *
  * The converter automatically consumes corresponding value from consumables list, stops the event (see
  * {@link engine.treeController.ModelConversionDispatcher}) and bind model and view elements.

+ 409 - 0
packages/ckeditor5-engine/src/treecontroller/view-converter-builder.js

@@ -0,0 +1,409 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Matcher from '../treeview/matcher.js';
+import ModelElement from '../treemodel/element.js';
+import utils from '../../utils/utils';
+
+/**
+ * Provides chainable, high-level API to easily build basic view-to-model converters that are appended to given
+ * dispatchers. View-to-model converters are used when external data is added to the editor, i.e. when a user pastes
+ * HTML content to the editor. Then, converters are used to translate this structure, possibly removing unknown/incorrect
+ * nodes, and add it to the model. Also multiple, different elements might be translated into the same thing in the
+ * model, i.e. `<b>` and `<strong>` elements might be converted to `bold` attribute (even though `bold` attribute will
+ * be then converted only to `<strong>` tag). Instances of this class are created by {@link engine.treeController.BuildViewConverterFor}.
+ *
+ * If you need more complex converters, see {@link engine.treeController.ViewConversionDispatcher},
+ * {@link engine.treeController.viewToModel}, {@link engine.treeController.ViewConsumable}.
+ *
+ * Using this API it is possible to create various kind of converters:
+ *
+ * 1. View element to model element:
+ *
+ *		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+ *
+ * 2. View element to model attribute:
+ *
+ *		BuildViewConverterFor( dispatcher ).fromElement( 'b' ).fromElement( 'strong' ).toAttributes( { bold: true } );
+ *
+ * 3. View attribute to model attribute:
+ *
+ *		BuildViewConverterFor( dispatcher ).fromAttribute( 'style', { 'font-weight': 'bold' } ).toAttributes( { bold: true } );
+ *		BuildViewConverterFor( dispatcher )
+ *			.fromAttribute( 'class' )
+ *			.toAttributes( ( viewElement ) => ( { class: viewElement.getAttribute( 'class' ) } ) );
+ *
+ * 4. View elements and attributes to model attribute:
+ *
+ *		BuildViewConverterFor( dispatcher )
+ *			.fromElement( 'b' ).fromElement( 'strong' ).fromAttribute( 'style', { 'font-weight': 'bold' } )
+ *			.toAttributes( { bold: true } );
+ *
+ * 5. View {@link engine.treeView.Matcher view element matcher instance} or {@link engine.treeView.Matcher#add matcher pattern}
+ * to model element or attribute:
+ *
+ *		const matcher = new ViewMatcher();
+ *		matcher.add( 'div', { class: 'quote' } );
+ *		BuildViewConverterFor( dispatcher ).from( matcher ).toElement( 'quote' );
+ *
+ *		BuildViewConverterFor( dispatcher ).from( { name: 'span', class: 'bold' } ).toAttributes( { bold: true } );
+ *
+ * Note, that converters built using `ViewConverterBuilder` automatically check {@link engine.treeModel.Schema schema}
+ * if created model structure is valid. If given conversion would be invalid according to schema, it is ignored.
+ *
+ * It is possible to provide creator functions as parameters for {@link engine.treeController.ViewConverterBuilder#toElement}
+ * and {@link engine.treeController.ViewConverterBuilder#toAttributes} methods. See their descriptions to learn more.
+ *
+ * By default, converter will {@link engine.treeController.ViewConsumable#consume consume} every value specified in
+ * given `from...` query, i.e. `.from( { name: 'span', class: 'bold' } )` will make converter consume both `span` name
+ * and `bold` class. It is possible to change this behavior using {@link engine.treeController.ViewConverterBuilder#consuming consuming}
+ * modifier. The modifier alters the last `fromXXX` query used before it. To learn more about consuming values,
+ * see {@link engine.treeController.ViewConsumable}.
+ *
+ * It is also possible to {@link engine.treeController.ViewConverterBuilder#withPriority change default priority}
+ * of created converters to decide which converter should be fired earlier and which later. This is useful if you provide
+ * a general converter but want to provide different converter for a specific-case (i.e. given view element is converted
+ * always to given model element, but if it has given class it is converter to other model element). For this,
+ * use {@link engine.treeController.ViewConverterBuilder#withPriority withPriority} modifier. The modifier alters
+ * the last `from...` query used before it.
+ *
+ * Note that `to...` methods are "terminators", which means that should be the last one used in building converter.
+ *
+ * You can use {@link engine.treeController.ModelConverterBuilder} to create "opposite" converters - from model to view.
+ *
+ * @memberOf engine.treeController
+ */
+class ViewConverterBuilder {
+	/**
+	 * Creates `ViewConverterBuilder` with given `dispatchers` registered to it.
+	 *
+	 * @param {Array.<engine.treeController.ViewConversionDispatcher>} dispatchers Dispatchers to which converters will
+	 * be attached.
+	 */
+	constructor( dispatchers ) {
+		/**
+		 * Dispatchers to which converters will be attached.
+		 *
+		 * @type {Array.<engine.treeController.ViewConversionDispatcher>}
+		 * @private
+		 */
+		this._dispatchers = dispatchers;
+
+		/**
+		 * Stores "from" queries.
+		 *
+		 * @type {Array}
+		 * @private
+		 */
+		this._from = [];
+	}
+
+	/**
+	 * Registers what view element should be converted.
+	 *
+	 *		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+	 *
+	 * @chainable
+	 * @param {String} elementName View element name.
+	 * @returns {engine.treeController.ViewConverterBuilder}
+	 */
+	fromElement( elementName ) {
+		return this.from( { name: elementName } );
+	}
+
+	/**
+	 * Registers what view attribute should be converted.
+	 *
+	 *		BuildViewConverterFor( dispatcher ).fromAttribute( 'style', { 'font-weight': 'bold' } ).toAttributes( { bold: true } );
+	 *
+	 * @chainable
+	 * @param {String|RegExp} key View attribute key.
+	 * @param {String|RegExp} [value] View attribute value.
+	 * @returns {engine.treeController.ViewConverterBuilder}
+	 */
+	fromAttribute( key, value = /.*/ ) {
+		let pattern = {};
+		pattern[ key ] = value;
+
+		return this.from( pattern );
+	}
+
+	/**
+	 * Registers what view pattern should be converted. The method accepts either {@link engine.treeView.Matcher view matcher}
+	 * or view matcher pattern.
+	 *
+	 *		const matcher = new ViewMatcher();
+	 *		matcher.add( 'div', { class: 'quote' } );
+	 *		BuildViewConverterFor( dispatcher ).from( matcher ).toElement( 'quote' );
+	 *
+	 *		BuildViewConverterFor( dispatcher ).from( { name: 'span', class: 'bold' } ).toAttributes( { bold: true } );
+	 *
+	 * @chainable
+	 * @param {Object|engine.treeView.Matcher} matcher View matcher or view matcher pattern.
+	 * @returns {engine.treeController.ViewConverterBuilder}
+	 */
+	from( matcher ) {
+		if ( !( matcher instanceof Matcher ) ) {
+			matcher = new Matcher( matcher );
+		}
+
+		this._from.push( {
+			matcher: matcher,
+			consume: false,
+			priority: null
+		} );
+
+		return this;
+	}
+
+	/**
+	 * Modifies which consumable values will be {@link engine.treeController.ViewConsumable#consume consumed} by built converter.
+	 * It modifies the last `from...` query. Can be used after each `from...` query in given chain. Useful for providing
+	 * more specific matches.
+	 *
+	 *		// This converter will only handle class bold conversion (to proper attribute) but span element
+	 *		// conversion will have to be done in separate converter.
+	 *		// Without consuming modifier, the converter would consume both class and name, so a converter for
+	 *		// span element would not be fired.
+	 *		BuildViewConverterFor( dispatcher )
+	 *			.from( { name: 'span', class: 'bold' } ).consuming( { class: 'bold' } )
+	 *			.toAttribute( { bold: true } );
+	 *
+	 *		BuildViewConverterFor( dispatcher )
+	 *			.fromElement( 'img' ).consuming( { name: true, attributes: [ 'src', 'title' ] } )
+	 *			.toElement( ( viewElement ) => new ModelElement( 'image', { src: viewElement.getAttribute( 'src' ),
+	 *																		title: viewElement.getAttribute( 'title' ) } );
+	 *
+	 * **Note:** All and only values from passed object has to be consumable on converted view element. This means that
+	 * using `consuming` method, you can either make looser conversion conditions (like in first example) or tighter
+	 * conversion conditions (like in second example). So, the view element, to be converter, has to match query of
+	 * `from...` method and then have to have enough consumable values to consume.
+	 *
+	 * @see engine.treeController.ViewConsumable
+	 * @chainable
+	 * @param {Object} consume Values to consume.
+	 * @returns {engine.treeController.ViewConverterBuilder}
+	 */
+	consuming( consume ) {
+		let lastFrom = this._from[ this._from.length - 1 ];
+		lastFrom.consume = consume;
+
+		return this;
+	}
+
+	/**
+	 * Changes default priority for built converter. It modifies the last `from...` query. Can be used after each
+	 * `from...` query in given chain. Useful for overwriting converters. The lower the number, the earlier converter will be fired.
+	 *
+	 *		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+	 *		// Register converter with proper priority, otherwise "p" element would get consumed by first
+	 *		// converter and the second converter would not be fired.
+	 *		BuildViewConverterFor( dispatcher )
+	 *			.from( { name: 'p', class: 'custom' } ).withPriority( 9 )
+	 *			.toElement( 'customParagraph' );
+	 *
+	 * **Note:** `ViewConverterBuilder` takes care so all `toElement` conversions takes place before all `toAttributes`
+	 * conversions. This is done by setting default `toElement` priority to `10` and `toAttributes` priority to `1000`.
+	 * It is recommended to set converter priority for `toElement` conversions below `500` and `toAttributes` priority
+	 * above `500`. It is important that model elements are created before attributes, otherwise attributes would
+	 * not be applied or other errors may occur.
+	 *
+	 * @chainable
+	 * @param {Number} priority Converter priority.
+	 * @returns {engine.treeController.ViewConverterBuilder}
+	 */
+	withPriority( priority ) {
+		let lastFrom = this._from[ this._from.length - 1 ];
+		lastFrom.priority = priority;
+
+		return this;
+	}
+
+	/**
+	 * Registers what model element will be created by converter.
+	 *
+	 * Method accepts two ways of providing what kind of model element will be created. You can pass model element
+	 * name as a `string` or a function that will return model element instance. If you provide creator function,
+	 * it will be passed converted view element as first and only parameter.
+	 *
+	 *		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+	 *		BuildViewConverterFor( dispatcher )
+	 *			.fromElement( 'img' )
+	 *			.toElement( ( viewElement ) => new ModelElement( 'image', { src: viewElement.getAttribute( 'src' ) } );
+	 *
+	 * @param {String|Function} element Model element name or model element creator function.
+	 */
+	toElement( element ) {
+		const eventCallbackGen = function( from ) {
+			return ( evt, data, consumable, conversionApi ) => {
+				// There is one callback for all patterns in the matcher.
+				// This will be usually just one pattern but we support matchers with many patterns too.
+				let matchAll = from.matcher.matchAll( data.input );
+
+				// If there is no match, this callback should not do anything.
+				if ( !matchAll ) {
+					return;
+				}
+
+				// Now, for every match between matcher and actual element, we will try to consume the match.
+				for ( let match of matchAll ) {
+					// Create model element basing on creator function or element name.
+					const modelElement = element instanceof Function ? element( data.input ) : new ModelElement( element );
+
+					// Check whether generated structure is okay with `Schema`.
+					// TODO: Make it more sane after .getAttributeKeys() is available for ModelElement.
+					const keys = Array.from( modelElement.getAttributes() ).map( ( attribute ) => attribute[ 0 ] );
+
+					if ( !conversionApi.schema.check( { name: modelElement.name, attributes: keys, inside: data.context } ) ) {
+						continue;
+					}
+
+					// Try to consume appropriate values from consumable values list.
+					if ( !consumable.consume( data.input, from.consume || match.match ) ) {
+						continue;
+					}
+
+					// If everything is fine, we are ready to start the conversion.
+					// Add newly created `modelElement` to the parents stack.
+					data.context.push( modelElement );
+
+					// Convert children of converted view element and append them to `modelElement`.
+					modelElement.appendChildren( conversionApi.convertChildren( data.input, consumable, data ) );
+
+					// Remove created `modelElement` from the parents stack.
+					data.context.pop();
+
+					// Add `modelElement` as a result.
+					data.output = modelElement;
+
+					// Prevent multiple conversion if there are other correct matches.
+					break;
+				}
+			};
+		};
+
+		this._setCallback( eventCallbackGen, 10 );
+	}
+
+	/**
+	 * Registers what model attribute will be created by converter.
+	 *
+	 * Method accepts two ways of providing what kind of model attribute will be created. You can either pass two strings
+	 * representing attribute key and attribute value or a function that returns an object with `key` and `value` properties.
+	 * If you provide creator function, it will be passed converted view element as first and only parameter.
+	 *
+	 *		BuildViewConverterFor( dispatcher ).fromAttribute( 'style', { 'font-weight': 'bold' } ).toAttribute( 'bold', true );
+	 *		BuildViewConverterFor( dispatcher )
+	 *			.fromAttribute( 'class' )
+	 *			.toAttribute( ( viewElement ) => ( { key: 'class', value: viewElement.getAttribute( 'class' ) } ) );
+	 *
+	 * @param {String|Function} keyOrCreator Attribute key or a creator function.
+	 * @param {String} [value] Attribute value. Required if `keyOrCreator` is a `string`. Ignored otherwise.
+	 */
+	toAttribute( keyOrCreator, value ) {
+		const eventCallbackGen = function( from ) {
+			return ( evt, data, consumable, conversionApi ) => {
+				// There is one callback for all patterns in the matcher.
+				// This will be usually just one pattern but we support matchers with many patterns too.
+				let matchAll = from.matcher.matchAll( data.input );
+
+				// If there is no match, this callback should not do anything.
+				if ( !matchAll ) {
+					return;
+				}
+
+				// Now, for every match between matcher and actual element, we will try to consume the match.
+				for ( let match of matchAll ) {
+					// Try to consume appropriate values from consumable values list.
+					if ( !consumable.consume( data.input, from.consume || match.match ) ) {
+						continue;
+					}
+
+					// Since we are converting to attribute we need an output on which we will set the attribute.
+					// If the output is not created yet, we will create it.
+					if ( !data.output ) {
+						data.output = conversionApi.convertChildren( data.input, consumable, data );
+					}
+
+					// Use attribute creator function, if provided.
+					let attribute = keyOrCreator instanceof Function ? keyOrCreator( data.input ) : { key: keyOrCreator, value: value };
+
+					// Set attribute on current `output`. `Schema` is checked inside this helper function.
+					setAttributeOn( data.output, attribute, data, conversionApi );
+
+					// Prevent multiple conversion if there are other correct matches.
+					break;
+				}
+			};
+		};
+
+		this._setCallback( eventCallbackGen, 1000 );
+	}
+
+	/**
+	 * Helper function that uses given callback generator to created callback function and sets it on registered dispatchers.
+	 *
+	 * @param eventCallbackGen
+	 * @param defaultPriority
+	 * @private
+	 */
+	_setCallback( eventCallbackGen, defaultPriority ) {
+		// We will add separate event callback for each registered `from` entry.
+		for ( let from of this._from ) {
+			// We have to figure out event name basing on matcher's patterns.
+			// If there is exactly one pattern and it has `name` property we will used that name.
+			const matcherElementName = from.matcher.getElementName();
+			const eventName = matcherElementName ? 'element:' + matcherElementName : 'element';
+			const eventCallback = eventCallbackGen( from );
+
+			const priority = from.priority === null ? defaultPriority : from.priority;
+
+			// Add event to each registered dispatcher.
+			for ( let dispatcher of this._dispatchers ) {
+				dispatcher.on( eventName, eventCallback, null, priority );
+			}
+		}
+	}
+}
+
+// Helper function that sets given attributes on given `engine.treeModel.Item` or `engine.treeModel.DocumentFragment`.
+function setAttributeOn( toChange, attribute, data, conversionApi ) {
+	if ( utils.isIterable( toChange ) ) {
+		for ( let node of toChange ) {
+			setAttributeOn( node, attribute, data, conversionApi );
+		}
+
+		return;
+	}
+
+	// TODO: Make it more sane after .getAttributeKeys() is available for ModelElement.
+	const keys = Array.from( toChange.getAttributes() ).map( ( attribute ) => attribute[ 0 ] ).concat( attribute.key );
+
+	const schemaQuery = {
+		name: toChange.name || '$text',
+		attributes: keys,
+		inside: data.context
+	};
+
+	if ( conversionApi.schema.check( schemaQuery ) ) {
+		toChange.setAttribute( attribute.key, attribute.value );
+	}
+}
+
+/**
+ * Entry point for view-to-model converters builder. This chainable API makes it easy to create basic, most common
+ * view-to-model converters and attach them to provided dispatchers. The method returns an instance of
+ * {@link engine.treeController.ViewConverterBuilder}.
+ *
+ * @external engine.treeController.BuildViewConverterFor
+ * @memberOf engine.treeController
+ * @param {...engine.treeController.ViewConversionDispatcher} dispatchers One or more dispatchers to which
+ * the built converter will be attached.
+ */
+export default function BuildViewConverterFor( ...dispatchers ) {
+	return new ViewConverterBuilder( dispatchers );
+}

+ 11 - 4
packages/ckeditor5-engine/src/treecontroller/view-to-model-converters.js

@@ -35,7 +35,7 @@ export function convertToModelFragment() {
 	return ( evt, data, consumable, conversionApi ) => {
 		// Second argument in `consumable.test` is discarded for ViewDocumentFragment but is needed for ViewElement.
 		if ( !data.output && consumable.test( data.input, { name: true } ) ) {
-			const convertedChildren = conversionApi.convertChildren( data.input, consumable, { context: data.context } );
+			const convertedChildren = conversionApi.convertChildren( data.input, consumable, data );
 
 			data.output = new ModelDocumentFragment( convertedChildren );
 		}
@@ -50,9 +50,16 @@ export function convertToModelFragment() {
  * @returns {Function} {@link engine.treeView.Text View text} converter.
  */
 export function convertText() {
-	return ( evt, data, consumable ) => {
-		if ( consumable.consume( data.input ) ) {
-			data.output = new ModelText( data.input.data );
+	return ( evt, data, consumable, conversionApi ) => {
+		const schemaQuery = {
+			name: '$text',
+			inside: data.context
+		};
+
+		if ( conversionApi.schema.check( schemaQuery ) ) {
+			if ( consumable.consume( data.input ) ) {
+				data.output = new ModelText( data.input.data );
+			}
 		}
 	};
 }

+ 9 - 7
packages/ckeditor5-engine/src/treecontroller/viewconversiondispatcher.js

@@ -47,11 +47,12 @@ import extend from '../../utils/lib/lodash/extend.js';
  *				inside: data.context
  *			};
  *
- *			if ( conversionApi.schema.checkQuery( schemaQuery ) ) {
+ *			if ( conversionApi.schema.check( schemaQuery ) ) {
  *				if ( !consumable.consume( data.input, { name: true } ) ) {
  *					// Before converting this paragraph's children we have to update their context by this paragraph.
- *					const context = data.context.concat( paragraph );
- *					const children = conversionApi.convertChildren( data.input, consumable, { context } );
+ *					data.context.push( paragraph );
+ *					const children = conversionApi.convertChildren( data.input, consumable, data );
+ *					data.context.pop();
  *					paragraph.appendChildren( children );
  *					data.output = paragraph;
  *				}
@@ -63,7 +64,7 @@ import extend from '../../utils/lib/lodash/extend.js';
  *			if ( consumable.consume( data.input, { name: true, attributes: [ 'href' ] } ) ) {
  *				// <a> element is inline and is represented by an attribute in the model.
  *				// This is why we are not updating `context` property.
- *				data.output = conversionApi.convertChildren( data.input, consumable, { context: data.context } );
+ *				data.output = conversionApi.convertChildren( data.input, consumable, data );
  *
  *				for ( let item of Range.createFrom( data.output ) ) {
  *					const schemaQuery = {
@@ -80,8 +81,9 @@ import extend from '../../utils/lib/lodash/extend.js';
  *		} );
  *
  *		// Fire conversion.
- *		// At the beginning, the context is empty because given `viewDocumentFragment` has no parent.
- *		viewDispatcher.convert( viewDocumentFragment, { context: [] } );
+ *		// Always take care where the converted model structure will be appended to. If this `viewDocumentFragment`
+ *		// is going to be appended directly to a '$root' element, use that in `context`.
+ *		viewDispatcher.convert( viewDocumentFragment, { context: [ '$root' ] } );
  *
  * Before each conversion process, `ViewConversionDispatcher` fires {@link engine.treeController.ViewConversionDispatcher.viewCleanup}
  * event which can be used to prepare tree view for conversion.
@@ -140,7 +142,7 @@ export default class ViewConversionDispatcher {
 	 * @see engine.treeController.ViewConversionApi#convertItem
 	 */
 	_convertItem( input, consumable, additionalData = {} ) {
-		const data = extend( additionalData, {
+		const data = extend( {}, additionalData, {
 			input: input,
 			output: null
 		} );

+ 63 - 0
packages/ckeditor5-engine/src/treemodel/characterproxy.js

@@ -6,6 +6,7 @@
 'use strict';
 
 import Node from './node.js';
+import utils from '../../utils/utils.js';
 
 /**
  * A proxy object representing one character stored in the tree data model. It looks and behaves like
@@ -68,4 +69,66 @@ export default class CharacterProxy extends Node {
 		 */
 		this._index = index;
 	}
+
+	/**
+	 * Sets attribute on the text fragment. If attribute with the same key already is set, it overwrites its values.
+	 *
+	 * **Note:** Changing attributes of text fragment affects document state. This TextProxy instance properties
+	 * will be refreshed, but other may get invalidated. It is highly unrecommended to store references to TextProxy instances.
+	 *
+	 * @param {String} key Key of attribute to set.
+	 * @param {*} value Attribute value.
+	 */
+	setAttribute( key, value ) {
+		let index = this.getIndex();
+
+		this.parent._children.setAttribute( index, 1, key, value );
+		this._attrs.set( key, value );
+	}
+
+	/**
+	 * Removes all attributes from the character proxy and sets given attributes.
+	 *
+	 * **Note:** Changing attributes of character proxy affects document state. This `CharacterProxy` instance properties
+	 * will be refreshed, but other instances of `CharacterProxy` and `TextProxy` may get invalidated.
+	 * It is highly unrecommended to store references to `CharacterProxy` instances.
+	 *
+	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set. See
+	 * {@link engine.treeModel.CharacterProxy#getAttributes}.
+	 */
+	setAttributesTo( attrs ) {
+		let attrsMap = utils.toMap( attrs );
+
+		this.clearAttributes();
+
+		for ( let attr of attrsMap ) {
+			this.setAttribute( attr[ 0 ], attr[ 1 ] );
+		}
+	}
+
+	/**
+	 * Removes an attribute with given key from the character proxy.
+	 *
+	 * **Note:** Changing attributes of character proxy affects document state. This `CharacterProxy` instance properties
+	 * will be refreshed, but other instances of `CharacterProxy` and `TextProxy` may get invalidated.
+	 * It is highly unrecommended to store references to `CharacterProxy` instances.
+	 *
+	 * @param {String} key Key of attribute to remove.
+	 */
+	removeAttribute( key ) {
+		this.setAttribute( key, null );
+	}
+
+	/**
+	 * Removes all attributes from the character proxy.
+	 *
+	 * **Note:** Changing attributes of character proxy affects document state. This `CharacterProxy` instance properties
+	 * will be refreshed, but other instances of `CharacterProxy` and `TextProxy` may get invalidated.
+	 * It is highly unrecommended to store references to `CharacterProxy` instances.
+	 */
+	clearAttributes() {
+		for ( let attr of this.getAttributes() ) {
+			this.removeAttribute( attr[ 0 ] );
+		}
+	}
 }

+ 4 - 9
packages/ckeditor5-engine/src/treemodel/nodelist.js

@@ -189,15 +189,10 @@ export default class NodeList {
 	/**
 	 * Node list iterator.
 	 */
-	[ Symbol.iterator ]() {
-		let i = 0;
-
-		return {
-			next: () => ( {
-				done: i == this.length,
-				value: this.get( i++ )
-			} )
-		};
+	*[ Symbol.iterator ]() {
+		for ( let i = 0; i < this.length; i++ ) {
+			yield this.get( i );
+		}
 	}
 
 	/**

+ 5 - 0
packages/ckeditor5-engine/src/treemodel/schema.js

@@ -365,6 +365,11 @@ export default class Schema {
 		// If attributes property is a string or undefined, wrap it in an array for easier processing.
 		if ( !isArray( query.attributes ) ) {
 			query.attributes = [ query.attributes ];
+		} else if ( query.attributes.length === 0 ) {
+			// To simplify algorithms, when a SchemaItem path is added "without" attribute, it is added with
+			// attribute equal to undefined. This means that algorithms can work the same way for specified attributes
+			// and no-atrtibutes, but we have to fill empty array with "fake" undefined value for algorithms reasons.
+			query.attributes.push( undefined );
 		}
 
 		// Normalize the path to an array of strings.

+ 13 - 0
packages/ckeditor5-engine/src/treeview/matcher.js

@@ -195,6 +195,19 @@ export default class Matcher {
 
 		return results.length > 0 ? results : null;
 	}
+
+	/**
+	 * Returns the name of the element to match if there is exactly one pattern added to the matcher instance
+	 * and it matches element name defined by `string` (not `RegExp`). Otherwise, returns `null`.
+	 *
+	 * @returns {String|null} Element name trying to match.
+	 */
+	getElementName() {
+		return this._patterns.length == 1 && this._patterns[ 0 ].name && !( this._patterns[ 0 ].name instanceof RegExp ) ?
+			this._patterns[ 0 ].name :
+			null;
+	}
+
 }
 
 // Returns match information if {@link engine.treeView.Element element} is matching provided pattern.

+ 2 - 1
packages/ckeditor5-engine/tests/treecontroller/advanced-converters.js

@@ -54,7 +54,8 @@ beforeEach( () => {
 	writer = new ViewWriter();
 
 	modelDispatcher = new ModelConversionDispatcher( { mapper, writer } );
-	viewDispatcher = new ViewConversionDispatcher();
+	// Schema is mocked up because we don't care about it in those tests.
+	viewDispatcher = new ViewConversionDispatcher( { schema: { check: () => true } } );
 
 	modelDispatcher.on( 'insert:$text', insertText() );
 	modelDispatcher.on( 'move', move() );

+ 309 - 0
packages/ckeditor5-engine/tests/treecontroller/model-converter-builder.js

@@ -0,0 +1,309 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treecontroller */
+
+'use strict';
+
+import BuildModelConverterFor from '/ckeditor5/engine/treecontroller/model-converter-builder.js';
+
+import ModelDocument from '/ckeditor5/engine/treemodel/document.js';
+import ModelElement from '/ckeditor5/engine/treemodel/element.js';
+import ModelText from '/ckeditor5/engine/treemodel/text.js';
+import ModelRange from '/ckeditor5/engine/treemodel/range.js';
+
+import ViewElement from '/ckeditor5/engine/treeview/element.js';
+import ViewContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
+import ViewAttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
+import ViewText from '/ckeditor5/engine/treeview/text.js';
+import ViewWriter from '/ckeditor5/engine/treeview/writer.js';
+
+import Mapper from '/ckeditor5/engine/treecontroller/mapper.js';
+import ModelConversionDispatcher from '/ckeditor5/engine/treecontroller/modelconversiondispatcher.js';
+
+import {
+	insertText,
+	move,
+	remove
+} from '/ckeditor5/engine/treecontroller/model-to-view-converters.js';
+
+function viewAttributesToString( item ) {
+	let result = '';
+
+	for ( let key of item.getAttributeKeys() ) {
+		let value = item.getAttribute( key );
+
+		if ( value ) {
+			result += ' ' + key + '="' + value + '"';
+		}
+	}
+
+	return result;
+}
+
+function viewToString( item ) {
+	let result = '';
+
+	if ( item instanceof ViewText ) {
+		result = item.data;
+	} else {
+		// ViewElement or ViewDocumentFragment.
+		for ( let child of item.getChildren() ) {
+			result += viewToString( child );
+		}
+
+		if ( item instanceof ViewElement ) {
+			result = '<' + item.name + viewAttributesToString( item ) + '>' + result + '</' + item.name + '>';
+		}
+	}
+
+	return result;
+}
+
+describe( 'Model converter builder', () => {
+	let dispatcher, modelDoc, modelRoot, viewRoot, mapper, writer;
+
+	beforeEach( () => {
+		modelDoc = new ModelDocument();
+		modelRoot = modelDoc.createRoot( 'root', 'root' );
+
+		viewRoot = new ViewContainerElement( 'div' );
+
+		mapper = new Mapper();
+		mapper.bindElements( modelRoot, viewRoot );
+
+		writer = new ViewWriter();
+
+		dispatcher = new ModelConversionDispatcher( { writer, mapper } );
+
+		dispatcher.on( 'insert:$text', insertText() );
+		dispatcher.on( 'move', move() );
+		dispatcher.on( 'remove', remove() );
+	} );
+
+	describe( 'model element to view element conversion', () => {
+		it( 'using passed view element name', () => {
+			BuildModelConverterFor( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
+
+			let modelElement = new ModelElement( 'paragraph', null, 'foobar' );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+
+		it( 'using passed view element', () => {
+			BuildModelConverterFor( dispatcher ).fromElement( 'image' ).toElement( new ViewContainerElement( 'img' ) );
+
+			let modelElement = new ModelElement( 'image' );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><img></img></div>' );
+		} );
+
+		it( 'using passed creator function', () => {
+			BuildModelConverterFor( dispatcher )
+				.fromElement( 'header' )
+				.toElement( ( data ) => new ViewContainerElement( 'h' + data.item.getAttribute( 'level' ) ) );
+
+			let modelElement = new ModelElement( 'header', { level: 2 }, 'foobar' );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><h2>foobar</h2></div>' );
+		} );
+	} );
+
+	describe( 'model attribute to view element conversion', () => {
+		beforeEach( () => {
+			BuildModelConverterFor( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
+		} );
+
+		it( 'using passed view element name', () => {
+			BuildModelConverterFor( dispatcher ).fromAttribute( 'bold' ).toElement( 'strong' );
+
+			let modelElement = new ModelText( 'foo', { bold: true } );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><strong>foo</strong></div>' );
+
+			for ( let value of ModelRange.createFromElement( modelRoot ) ) {
+				value.item.removeAttribute( 'bold' );
+			}
+
+			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createFromElement( modelRoot ), 'bold', true, null );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div>foo</div>' );
+		} );
+
+		it( 'using passed view element', () => {
+			BuildModelConverterFor( dispatcher ).fromAttribute( 'bold' ).toElement( new ViewAttributeElement( 'strong' ) );
+
+			let modelElement = new ModelText( 'foo', { bold: true } );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><strong>foo</strong></div>' );
+
+			for ( let value of ModelRange.createFromElement( modelRoot ) ) {
+				value.item.removeAttribute( 'bold' );
+			}
+
+			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createFromElement( modelRoot ), 'bold', true, null );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div>foo</div>' );
+		} );
+
+		it( 'using passed creator function', () => {
+			BuildModelConverterFor( dispatcher ).fromAttribute( 'italic' ).toElement( ( value ) => new ViewAttributeElement( value ) );
+
+			let modelElement = new ModelText( 'foo', { italic: 'em' } );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><em>foo</em></div>' );
+
+			for ( let value of ModelRange.createFromElement( modelRoot ) ) {
+				value.item.setAttribute( 'italic', 'i' );
+			}
+
+			dispatcher.convertAttribute( 'changeAttribute', ModelRange.createFromElement( modelRoot ), 'italic', 'em', 'i' );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><i>foo</i></div>' );
+
+			for ( let value of ModelRange.createFromElement( modelRoot ) ) {
+				value.item.removeAttribute();
+			}
+
+			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createFromElement( modelRoot ), 'italic', 'i', null );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div>foo</div>' );
+		} );
+	} );
+
+	describe( 'model attribute to view attribute conversion', () => {
+		beforeEach( () => {
+			BuildModelConverterFor( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
+		} );
+
+		it( 'using default 1-to-1 conversion', () => {
+			BuildModelConverterFor( dispatcher ).fromAttribute( 'class' ).toAttribute();
+
+			let modelElement = new ModelElement( 'paragraph', { class: 'myClass' }, 'foobar' );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="myClass">foobar</p></div>' );
+
+			modelElement.setAttribute( 'class', 'newClass' );
+			dispatcher.convertAttribute( 'changeAttribute', ModelRange.createOnElement( modelElement ), 'class', 'myClass', 'newClass' );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="newClass">foobar</p></div>' );
+
+			modelElement.removeAttribute( 'class' );
+			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createOnElement( modelElement ), 'class', 'newClass', null );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+
+		it( 'using passed attribute key', () => {
+			BuildModelConverterFor( dispatcher ).fromAttribute( 'theme' ).toAttribute( 'class' );
+
+			let modelElement = new ModelElement( 'paragraph', { theme: 'abc' }, 'foobar' );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="abc">foobar</p></div>' );
+
+			modelElement.setAttribute( 'theme', 'xyz' );
+			dispatcher.convertAttribute( 'changeAttribute', ModelRange.createOnElement( modelElement ), 'theme', 'abc', 'xyz' );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="xyz">foobar</p></div>' );
+
+			modelElement.removeAttribute( 'theme' );
+			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createOnElement( modelElement ), 'theme', 'xyz', null );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+
+		it( 'using passed attribute key and value', () => {
+			BuildModelConverterFor( dispatcher ).fromAttribute( 'highlighted' ).toAttribute( 'style', 'background:yellow' );
+
+			let modelElement = new ModelElement( 'paragraph', { 'highlighted': true }, 'foobar' );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p style="background:yellow;">foobar</p></div>' );
+
+			modelElement.removeAttribute( 'highlighted' );
+			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createOnElement( modelElement ), 'highlighted', true, null );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+
+		it( 'using passed attribute creator function', () => {
+			BuildModelConverterFor( dispatcher )
+				.fromAttribute( 'theme' )
+				.toAttribute( ( value ) => ( { key: 'class', value: value + '-theme' } ) );
+
+			let modelElement = new ModelElement( 'paragraph', { theme: 'nice' }, 'foobar' );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="nice-theme">foobar</p></div>' );
+
+			modelElement.setAttribute( 'theme', 'good' );
+			dispatcher.convertAttribute( 'changeAttribute', ModelRange.createOnElement( modelElement ), 'theme', 'nice', 'good' );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p class="good-theme">foobar</p></div>' );
+
+			modelElement.removeAttribute( 'theme' );
+			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createOnElement( modelElement ), 'theme', 'good', null );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+	} );
+
+	describe( 'withPriority', () => {
+		it( 'should change default converters priority', () => {
+			BuildModelConverterFor( dispatcher ).fromElement( 'custom' ).toElement( 'custom' );
+			BuildModelConverterFor( dispatcher ).fromElement( 'custom' ).withPriority( 0 ).toElement( 'other' );
+
+			let modelElement = new ModelElement( 'custom', null, 'foobar' );
+			modelRoot.appendChildren( modelElement );
+
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><other>foobar</other></div>' );
+		} );
+	} );
+
+	it( 'should do nothing on model element to view attribute conversion', () => {
+		BuildModelConverterFor( dispatcher ).fromElement( 'div' ).toElement( 'div' );
+		// Should do nothing:
+		BuildModelConverterFor( dispatcher ).fromElement( 'paragraph' ).toAttribute( 'paragraph', true );
+		// If above would do something this one would not be fired:
+		BuildModelConverterFor( dispatcher ).fromElement( 'paragraph' ).toElement( 'p' );
+
+		let modelElement = new ModelElement( 'div', null, new ModelElement( 'paragraph', null, 'foobar' ) );
+		modelRoot.appendChildren( modelElement );
+
+		dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><div><p>foobar</p></div></div>' );
+	} );
+} );

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

@@ -158,15 +158,12 @@ describe( 'setAttribute/removeAttribute', () => {
 		const modelParagraph = new ModelElement( 'paragraph', { theme: 'nice' }, 'foobar' );
 		const modelDiv = new ModelElement( 'div', { theme: 'nice' } );
 
-		const themeConverter = ( data ) => {
-			const key = 'class';
-			let value = data.attributeNewValue;
-
-			if ( value && data.item instanceof ModelElement && data.item.getChildCount() > 0 ) {
+		const themeConverter = ( value, key, data ) => {
+			if ( data.item instanceof ModelElement && data.item.getChildCount() > 0 ) {
 				value += ' ' + 'fix-content';
 			}
 
-			return { key, value };
+			return { key: 'class', value };
 		};
 
 		modelRoot.appendChildren( [ modelParagraph, modelDiv ] );
@@ -194,7 +191,7 @@ describe( 'setAttribute/removeAttribute', () => {
 } );
 
 describe( 'wrap/unwrap', () => {
-	it( 'should convert insert/remove of attribute in model into wrapping element in a view', () => {
+	it( 'should convert insert/change/remove of attribute in model into wrapping element in a view', () => {
 		const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { bold: true } ) );
 		const viewP = new ViewContainerElement( 'p' );
 		const viewB = new ViewAttributeElement( 'b' );

+ 0 - 66
packages/ckeditor5-engine/tests/treecontroller/modelconversiondispatcher.js

@@ -39,10 +39,6 @@ describe( 'ModelConversionDispatcher', () => {
 
 		let image, imagePos;
 
-		//const flatRangeMatcher = ( parent, start, end ) => sinon.match(
-		//	( matched ) => ModelRange.createFromParentsAndOffsets( parent, start, parent, end ).isEqual( matched )
-		//);
-
 		beforeEach( () => {
 			image = new ModelElement( 'image' );
 			root.appendChildren( [ image, 'foobar' ] );
@@ -59,8 +55,6 @@ describe( 'ModelConversionDispatcher', () => {
 			const cbInsertImage = sinon.spy();
 			const cbAddAttribute = sinon.spy();
 
-			//sinon.spy( dispatcher, 'convertInsert' );
-
 			dispatcher.on( 'insert:$text', cbInsertText );
 			dispatcher.on( 'insert:image', cbInsertImage );
 			dispatcher.on( 'addAttribute:key:$text', cbAddAttribute );
@@ -71,8 +65,6 @@ describe( 'ModelConversionDispatcher', () => {
 			expect( cbInsertText.called ).to.be.true;
 			expect( cbAddAttribute.called ).to.be.true;
 			expect( cbInsertImage.called ).to.be.false;
-
-			//expect( dispatcher.convertInsert.calledWith( flatRangeMatcher( root, 0, 3 ) ) ).to.be.true;
 		} );
 
 		it( 'should fire insert and addAttribute callbacks for reinsertion changes', () => {
@@ -89,8 +81,6 @@ describe( 'ModelConversionDispatcher', () => {
 			const cbInsertImage = sinon.spy();
 			const cbAddAttribute = sinon.spy();
 
-			//sinon.spy( dispatcher, 'convertInsert' );
-
 			dispatcher.on( 'insert:$text', cbInsertText );
 			dispatcher.on( 'insert:image', cbInsertImage );
 			dispatcher.on( 'addAttribute:key:image', cbAddAttribute );
@@ -100,8 +90,6 @@ describe( 'ModelConversionDispatcher', () => {
 			expect( cbInsertImage.called ).to.be.true;
 			expect( cbAddAttribute.called ).to.be.true;
 			expect( cbInsertText.called ).to.be.false;
-
-			//expect( dispatcher.convertInsert.calledWith( flatRangeMatcher( root, 0, 1 ) ) ).to.be.true;
 		} );
 
 		it( 'should fire move callback for move changes', () => {
@@ -109,18 +97,9 @@ describe( 'ModelConversionDispatcher', () => {
 
 			dispatcher.on( 'move', cbMove );
 
-			//sinon.spy( dispatcher, 'convertMove' );
-
 			doc.batch().move( image, imagePos.getShiftedBy( 3 ) );
 
 			expect( cbMove.called );
-
-			//expect(
-			//	dispatcher.convertMove.calledWith(
-			//		sinon.match( ( position ) => imagePos.isEqual( position ) ),
-			//		flatRangeMatcher( root, 3, 4 )
-			//	)
-			//).to.be.true;
 		} );
 
 		it( 'should fire remove callback for remove changes', () => {
@@ -128,18 +107,9 @@ describe( 'ModelConversionDispatcher', () => {
 
 			dispatcher.on( 'remove', cbRemove );
 
-			//sinon.spy( dispatcher, 'convertRemove' );
-
 			doc.batch().remove( image );
 
 			expect( cbRemove.called );
-
-			//expect(
-			//	dispatcher.convertRemove.calledWith(
-			//		sinon.match( ( position ) => imagePos.isEqual( position ) ),
-			//		flatRangeMatcher( doc.graveyard, 0, 1 )
-			//	)
-			//).to.be.true;
 		} );
 
 		it( 'should fire addAttribute callbacks for add attribute change', () => {
@@ -149,8 +119,6 @@ describe( 'ModelConversionDispatcher', () => {
 			dispatcher.on( 'addAttribute:key:$text', cbAddText );
 			dispatcher.on( 'addAttribute:key:image', cbAddImage );
 
-			//sinon.spy( dispatcher, 'convertAttribute' );
-
 			doc.batch().setAttr( 'key', 'value', image );
 
 			// Callback for adding attribute on text not called.
@@ -162,16 +130,6 @@ describe( 'ModelConversionDispatcher', () => {
 			expect( cbAddText.calledOnce ).to.be.true;
 			// Callback for adding attribute on image not called this time.
 			expect( cbAddImage.calledOnce ).to.be.true;
-
-			//expect(
-			//	dispatcher.convertAttribute.calledWith(
-			//		'addAttribute',
-			//		flatRangeMatcher( root, 3, 4 ),
-			//		'key',
-			//		null,
-			//		'value'
-			//	)
-			//).to.be.true;
 		} );
 
 		it( 'should fire changeAttribute callbacks for change attribute change', () => {
@@ -181,8 +139,6 @@ describe( 'ModelConversionDispatcher', () => {
 			dispatcher.on( 'changeAttribute:key:$text', cbChangeText );
 			dispatcher.on( 'changeAttribute:key:image', cbChangeImage );
 
-			//sinon.spy( dispatcher, 'convertAttribute' );
-
 			doc.batch().setAttr( 'key', 'value', image ).setAttr( 'key', 'newValue', image );
 
 			// Callback for adding attribute on text not called.
@@ -195,16 +151,6 @@ describe( 'ModelConversionDispatcher', () => {
 			expect( cbChangeText.calledOnce ).to.be.true;
 			// Callback for adding attribute on image not called this time.
 			expect( cbChangeImage.calledOnce ).to.be.true;
-
-			//expect(
-			//	dispatcher.convertAttribute.calledWith(
-			//		'changeAttribute',
-			//		flatRangeMatcher( root, 3, 4 ),
-			//		'key',
-			//		'value',
-			//		'newValue'
-			//	)
-			//).to.be.true;
 		} );
 
 		it( 'should fire removeAttribute callbacks for remove attribute change', () => {
@@ -214,8 +160,6 @@ describe( 'ModelConversionDispatcher', () => {
 			dispatcher.on( 'removeAttribute:key:$text', cbRemoveText );
 			dispatcher.on( 'removeAttribute:key:image', cbRemoveImage );
 
-			//sinon.spy( dispatcher, 'convertAttribute' );
-
 			doc.batch().setAttr( 'key', 'value', image ).removeAttr( 'key', image );
 
 			// Callback for adding attribute on text not called.
@@ -228,16 +172,6 @@ describe( 'ModelConversionDispatcher', () => {
 			expect( cbRemoveText.calledOnce ).to.be.true;
 			// Callback for adding attribute on image not called this time.
 			expect( cbRemoveImage.calledOnce ).to.be.true;
-
-			//expect(
-			//	dispatcher.convertAttribute.calledWith(
-			//		'removeAttribute',
-			//		flatRangeMatcher( root, 3, 4 ),
-			//		'key',
-			//		'value',
-			//		null
-			//	)
-			//).to.be.true;
 		} );
 
 		it( 'should not fire any event if not recognized event type was passed', () => {

+ 368 - 0
packages/ckeditor5-engine/tests/treecontroller/view-converter-builder.js

@@ -0,0 +1,368 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treecontroller */
+
+'use strict';
+
+import BuildViewConverterFor from '/ckeditor5/engine/treecontroller/view-converter-builder.js';
+
+import ModelSchema from '/ckeditor5/engine/treemodel/schema.js';
+import ModelDocument from '/ckeditor5/engine/treemodel/document.js';
+import ModelElement from '/ckeditor5/engine/treemodel/element.js';
+import ModelTextProxy from '/ckeditor5/engine/treemodel/textproxy.js';
+import ModelRange from '/ckeditor5/engine/treemodel/range.js';
+import ModelWalker from '/ckeditor5/engine/treemodel/treewalker.js';
+
+import ViewDocumentFragment from '/ckeditor5/engine/treeview/documentfragment.js';
+import ViewContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
+import ViewAttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
+import ViewText from '/ckeditor5/engine/treeview/text.js';
+import ViewMatcher from '/ckeditor5/engine/treeview/matcher.js';
+
+import ViewConversionDispatcher from '/ckeditor5/engine/treecontroller/viewconversiondispatcher.js';
+
+import { convertToModelFragment, convertText } from '/ckeditor5/engine/treecontroller/view-to-model-converters.js';
+
+function modelAttributesToString( item ) {
+	let result = '';
+
+	for ( let attr of item.getAttributes() ) {
+		result += ' ' + attr[ 0 ] + '="' + attr[ 1 ] + '"';
+	}
+
+	return result;
+}
+
+function modelToString( item ) {
+	let result = '';
+
+	if ( item instanceof ModelTextProxy ) {
+		let attributes = modelAttributesToString( item );
+
+		result = attributes ? '<$text' + attributes + '>' + item.text + '</$text>' : item.text;
+	} else {
+		let walker = new ModelWalker( { boundaries: ModelRange.createFromElement( item ), shallow: true } );
+
+		for ( let value of walker ) {
+			result += modelToString( value.item );
+		}
+
+		if ( item instanceof ModelElement ) {
+			let attributes = modelAttributesToString( item );
+
+			result = '<' + item.name + attributes + '>' + result + '</' + item.name + '>';
+		}
+	}
+
+	return result;
+}
+
+const textAttributes = [ undefined, 'linkHref', 'linkTitle', 'bold', 'italic', 'style' ];
+const pAttributes = [ undefined, 'class', 'important', 'theme', 'decorated', 'size' ];
+
+describe( 'View converter builder', () => {
+	let dispatcher, modelDoc, modelRoot, schema, objWithContext;
+
+	beforeEach( () => {
+		// `additionalData` parameter for `.convert` calls.
+		objWithContext = { context: [ '$root' ] };
+
+		schema = new ModelSchema();
+
+		schema.registerItem( 'paragraph', '$block' );
+		schema.registerItem( 'div', '$block' );
+		schema.registerItem( 'customP', 'paragraph' );
+		schema.registerItem( 'image', '$inline' );
+		schema.registerItem( 'span', '$inline' );
+		schema.registerItem( 'MEGATRON', '$inline' ); // Yes, folks, we are building MEGATRON.
+		schema.registerItem( 'abcd', '$inline' );
+		schema.allow( { name: '$inline', attributes: textAttributes, inside: '$root' } );
+		schema.allow( { name: 'image', attributes: [ 'src' ], inside: '$root' } );
+		schema.allow( { name: 'image', attributes: [ 'src' ], inside: '$block' } );
+		schema.allow( { name: '$text', attributes: textAttributes, inside: '$block' } );
+		schema.allow( { name: '$text', attributes: textAttributes, inside: '$root' } );
+		schema.allow( { name: 'paragraph', attributes: pAttributes, inside: '$root' } );
+		schema.allow( { name: 'span', attributes: [ 'transformer' ], inside: '$root' } );
+		schema.allow( { name: 'div', attributes: [ 'class' ], inside: '$root' } );
+
+		dispatcher = new ViewConversionDispatcher( { schema } );
+		dispatcher.on( 'text', convertText() );
+
+		modelDoc = new ModelDocument();
+		modelRoot = modelDoc.createRoot( 'root', '$root' );
+	} );
+
+	it( 'should convert from view element to model element', () => {
+		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+
+		const result = dispatcher.convert( new ViewContainerElement( 'p', null, new ViewText( 'foo' ) ), objWithContext );
+		modelRoot.appendChildren( result );
+
+		expect( modelToString( result ) ).to.equal( '<paragraph>foo</paragraph>' );
+	} );
+
+	it( 'should convert from view element to model element using creator function', () => {
+		BuildViewConverterFor( dispatcher )
+			.fromElement( 'img' )
+			.toElement( ( viewElement ) => new ModelElement( 'image', { src: viewElement.getAttribute( 'src' ) } ) );
+
+		const result = dispatcher.convert( new ViewContainerElement( 'img', { src: 'foo.jpg' } ), objWithContext );
+		modelRoot.appendChildren( result );
+
+		expect( modelToString( result ) ).to.equal( '<image src="foo.jpg"></image>' );
+	} );
+
+	it( 'should convert from view element to model attribute', () => {
+		BuildViewConverterFor( dispatcher ).fromElement( 'strong' ).toAttribute( 'bold', true );
+
+		const result = dispatcher.convert( new ViewAttributeElement( 'strong', null, new ViewText( 'foo' ) ), objWithContext );
+		modelRoot.appendChildren( result );
+
+		// Have to check root because result is a ModelText.
+		expect( modelToString( modelRoot ) ).to.equal( '<$root><$text bold="true">foo</$text></$root>' );
+	} );
+
+	it( 'should convert from view element to model attributes using creator function', () => {
+		BuildViewConverterFor( dispatcher )
+			.fromElement( 'a' )
+			.toAttribute( ( viewElement ) => ( { key: 'linkHref', value: viewElement.getAttribute( 'href' ) } ) );
+
+		const result = dispatcher.convert( new ViewAttributeElement( 'a', { href: 'foo.html' }, new ViewText( 'foo' ) ), objWithContext );
+		modelRoot.appendChildren( result );
+
+		// Have to check root because result is a ModelText.
+		expect( modelToString( modelRoot ) ).to.equal( '<$root><$text linkHref="foo.html">foo</$text></$root>' );
+	} );
+
+	it( 'should convert from view attribute to model attribute', () => {
+		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+
+		BuildViewConverterFor( dispatcher )
+			.fromAttribute( 'class' )
+			.toAttribute( ( viewElement ) => ( { key: 'class', value: viewElement.getAttribute( 'class' ) } ) );
+
+		const result = dispatcher.convert( new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext );
+		modelRoot.appendChildren( result );
+
+		expect( modelToString( result ) ).to.equal( '<paragraph class="myClass">foo</paragraph>' );
+	} );
+
+	it( 'should convert from view attribute and key to model attribute', () => {
+		dispatcher.on( 'documentFragment', convertToModelFragment() );
+
+		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+		BuildViewConverterFor( dispatcher ).fromAttribute( 'class', 'important' ).toAttribute( 'important', true );
+		BuildViewConverterFor( dispatcher ).fromAttribute( 'class', 'theme-nice' ).toAttribute( 'theme', 'nice' );
+
+		const viewStructure = new ViewDocumentFragment( [
+			new ViewContainerElement( 'p', { class: 'important' }, new ViewText( 'foo' ) ),
+			new ViewContainerElement( 'p', { class: 'important theme-nice' }, new ViewText( 'bar' ) )
+		] );
+
+		const result = dispatcher.convert( viewStructure, objWithContext );
+
+		expect( modelToString( result ) )
+			.to.equal( '<paragraph important="true">foo</paragraph><paragraph important="true" theme="nice">bar</paragraph>' );
+	} );
+
+	it( 'should convert from multiple view entities to model attribute', () => {
+		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+
+		BuildViewConverterFor( dispatcher )
+			.fromElement( 'strong' )
+			.fromElement( 'b' )
+			.fromAttribute( 'class', 'bold' )
+			.fromAttribute( 'style', { 'font-weight': 'bold' } )
+			.toAttribute( 'bold', true );
+
+		const viewElement = new ViewContainerElement( 'p', null, [
+			new ViewAttributeElement( 'strong', null, new ViewText( 'aaa' ) ),
+			new ViewAttributeElement( 'b', null, new ViewText( 'bbb' ) ),
+			new ViewContainerElement( 'span', { class: 'bold' }, new ViewText( 'ccc' ) ),
+			new ViewContainerElement( 'span', { style: 'font-weight:bold; font-size:20px' }, new ViewText( 'ddd' ) )
+		] );
+
+		const result = dispatcher.convert( viewElement, objWithContext );
+		modelRoot.appendChildren( result );
+
+		expect( modelToString( result ) ).to.equal( '<paragraph><$text bold="true">aaabbbcccddd</$text></paragraph>' );
+	} );
+
+	it( 'should convert from pattern to model element', () => {
+		BuildViewConverterFor( dispatcher ).from(
+			{ name: 'span', class: 'megatron', attribute: { head: 'megatron', body: 'megatron', legs: 'megatron' } }
+		).toElement( 'MEGATRON' );
+
+		// Adding callbacks later so they are called later. MEGATRON callback is more important.
+		BuildViewConverterFor( dispatcher ).fromElement( 'span' ).toElement( 'span' );
+		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+
+		let result;
+
+		// Not quite megatron.
+		result = dispatcher.convert( new ViewContainerElement( 'span', { class: 'megatron' }, new ViewText( 'foo' ) ), objWithContext );
+		modelRoot.appendChildren( result );
+		expect( modelToString( result ) ).to.equal( '<span>foo</span>' );
+
+		// Almost a megatron. Missing a head.
+		result = dispatcher.convert(
+			new ViewContainerElement( 'span', { class: 'megatron', body: 'megatron', legs: 'megatron' }, new ViewText( 'foo' ) ),
+			objWithContext
+		);
+
+		modelRoot.appendChildren( result );
+		expect( modelToString( result ) ).to.equal( '<span>foo</span>' );
+
+		// This would be a megatron but is a paragraph.
+		result = dispatcher.convert(
+			new ViewContainerElement(
+				'p',
+				{ class: 'megatron', body: 'megatron', legs: 'megatron', head: 'megatron' },
+				new ViewText( 'foo' )
+			),
+			objWithContext
+		);
+
+		modelRoot.appendChildren( result );
+		expect( modelToString( result ) ).to.equal( '<paragraph>foo</paragraph>' );
+
+		// At last we have a megatron!
+		result = dispatcher.convert(
+			new ViewContainerElement(
+				'span',
+				{ class: 'megatron', body: 'megatron', legs: 'megatron', head: 'megatron' },
+				new ViewText( 'foo' )
+			),
+			objWithContext
+		);
+
+		modelRoot.appendChildren( result );
+		expect( modelToString( result ) ).to.equal( '<MEGATRON>foo</MEGATRON>' );
+	} );
+
+	it( 'should convert from pattern to model attribute', () => {
+		BuildViewConverterFor( dispatcher ).fromElement( 'span' ).toElement( 'span' );
+
+		// This time without name so default span converter will convert children.
+		BuildViewConverterFor( dispatcher )
+			.from( { class: 'megatron', attribute: { head: 'megatron', body: 'megatron', legs: 'megatron' } } )
+			.toAttribute( 'transformer', 'megatron' );
+
+		let viewElement = new ViewContainerElement(
+			'span',
+			{ class: 'megatron', body: 'megatron', legs: 'megatron', head: 'megatron' },
+			new ViewText( 'foo' )
+		);
+
+		let result = dispatcher.convert( viewElement, objWithContext );
+
+		modelRoot.appendChildren( result );
+		expect( modelToString( result ) ).to.equal( '<span transformer="megatron">foo</span>' );
+	} );
+
+	it( 'should set different priorities for `toElement` and `toAttribute` conversion', () => {
+		BuildViewConverterFor( dispatcher )
+			.fromAttribute( 'class' )
+			.toAttribute( ( viewElement ) => ( { key: 'class', value: viewElement.getAttribute( 'class' ) } ) );
+		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+
+		let result = dispatcher.convert( new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext );
+		modelRoot.appendChildren( result );
+
+		// Element converter was fired first even though attribute converter was added first.
+		expect( modelToString( result ) ).to.equal( '<paragraph class="myClass">foo</paragraph>' );
+	} );
+
+	it( 'should overwrite default priorities for converters', () => {
+		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+		BuildViewConverterFor( dispatcher )
+			.fromAttribute( 'class' )
+			.toAttribute( ( viewElement ) => ( { key: 'class', value: viewElement.getAttribute( 'class' ) } ) );
+
+		let result;
+
+		result = dispatcher.convert( new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext );
+		modelRoot.appendChildren( result );
+		expect( modelToString( result ) ).to.equal( '<paragraph class="myClass">foo</paragraph>' );
+
+		BuildViewConverterFor( dispatcher )
+			.from( { name: 'p', class: 'myClass' } ).withPriority( -1 ) // Default for `toElement` is 0.
+			.toElement( 'customP' );
+
+		result = dispatcher.convert( new ViewContainerElement( 'p', { class: 'myClass' }, new ViewText( 'foo' ) ), objWithContext );
+		modelRoot.appendChildren( result );
+		expect( modelToString( result ) ).to.equal( '<customP>foo</customP>' );
+	} );
+
+	it( 'should overwrite default consumed values', () => {
+		// Converter (1).
+		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+
+		// Converter (2).
+		BuildViewConverterFor( dispatcher )
+			.from( { name: 'p', class: 'decorated' } ).consuming( { class: 'decorated' } )
+			.toAttribute( 'decorated', true );
+
+		// Converter (3).
+		BuildViewConverterFor( dispatcher )
+			.fromAttribute( 'class', 'small' ).consuming( { class: 'small' } )
+			.toAttribute( 'size', 'small' );
+
+		const viewElement = new ViewContainerElement( 'p', { class: 'decorated small' }, new ViewText( 'foo' ) );
+
+		const result = dispatcher.convert( viewElement, objWithContext );
+		modelRoot.appendChildren( result );
+
+		// P element and it's children got converted by the converter (1) and the converter (1) got fired
+		// because P name was not consumed in converter (2). Converter (3) could consume class="small" because
+		// only class="decorated" was consumed in converter (2).
+		expect( modelToString( result ) ).to.equal( '<paragraph decorated="true" size="small">foo</paragraph>' );
+	} );
+
+	it( 'should convert from matcher instance to model', () => {
+		// Universal class converter, synonymous to .fromAttribute( 'class' ).
+		BuildViewConverterFor( dispatcher )
+			.from( new ViewMatcher( { class: /.*/ } ) )
+			.toAttribute( ( viewElement ) => ( { key: 'class', value: viewElement.getAttribute( 'class' ) } ) );
+
+		// Universal element converter.
+		BuildViewConverterFor( dispatcher )
+			.from( new ViewMatcher( { name: /.*/ } ) )
+			.toElement( ( viewElement ) => new ModelElement( viewElement.name ) );
+
+		let viewStructure = new ViewContainerElement( 'div', { class: 'myClass' }, [
+			new ViewContainerElement( 'abcd', null, new ViewText( 'foo' ) )
+		] );
+
+		let result = dispatcher.convert( viewStructure, objWithContext );
+		modelRoot.appendChildren( result );
+
+		expect( modelToString( result ) ).to.equal( '<div class="myClass"><abcd>foo</abcd></div>' );
+	} );
+
+	it( 'should filter out structure that is wrong with schema', () => {
+		BuildViewConverterFor( dispatcher ).fromElement( 'strong' ).toAttribute( 'bold', true );
+		BuildViewConverterFor( dispatcher ).fromElement( 'div' ).toElement( 'div' );
+		BuildViewConverterFor( dispatcher ).fromElement( 'p' ).toElement( 'paragraph' );
+
+		schema.disallow( { name: '$text', attributes: 'bold', inside: 'paragraph' } );
+		schema.disallow( { name: 'div', inside: '$root' } );
+
+		dispatcher.on( 'element', convertToModelFragment() );
+
+		let viewElement = new ViewContainerElement( 'div', null,
+			new ViewContainerElement( 'p', null,
+				new ViewAttributeElement( 'strong', null,
+					new ViewText( 'foo' )
+				)
+			)
+		);
+
+		let result = dispatcher.convert( viewElement, objWithContext );
+
+		expect( modelToString( result ) ).to.equal( '<paragraph>foo</paragraph>' );
+	} );
+} );

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

@@ -12,16 +12,21 @@ import ViewContainerElement from '/ckeditor5/engine/treeview/containerelement.js
 import ViewDocumentFragment from '/ckeditor5/engine/treeview/documentfragment.js';
 import ViewText from '/ckeditor5/engine/treeview/text.js';
 
+import ModelSchema from '/ckeditor5/engine/treemodel/schema.js';
 import ModelDocumentFragment from '/ckeditor5/engine/treemodel/documentfragment.js';
 import ModelElement from '/ckeditor5/engine/treemodel/element.js';
 import ModelText from '/ckeditor5/engine/treemodel/text.js';
 
 import { convertToModelFragment, convertText } from '/ckeditor5/engine/treecontroller/view-to-model-converters.js';
 
-let dispatcher;
+let dispatcher, schema, objWithContext;
 
 beforeEach( () => {
-	dispatcher = new ViewConversionDispatcher();
+	schema = new ModelSchema();
+	schema.registerItem( 'paragraph', '$block' );
+	schema.allow( { name: '$text', inside: '$root' } );
+	objWithContext = { context: [ '$root' ] };
+	dispatcher = new ViewConversionDispatcher( { schema } );
 } );
 
 describe( 'convertText', () => {
@@ -30,7 +35,7 @@ describe( 'convertText', () => {
 
 		dispatcher.on( 'text', convertText() );
 
-		const result = dispatcher.convert( viewText );
+		const result = dispatcher.convert( viewText, objWithContext );
 
 		expect( result ).to.be.instanceof( ModelText );
 		expect( result.text ).to.equal( 'foobar' );
@@ -48,11 +53,26 @@ describe( 'convertText', () => {
 			}
 		} );
 
-		const result = dispatcher.convert( viewText );
+		const result = dispatcher.convert( viewText, objWithContext );
 
 		expect( result ).to.be.instanceof( ModelText );
 		expect( result.text ).to.equal( 'foo****ba****r' );
 	} );
+
+	it( 'should not convert text if it is wrong with schema', () => {
+		schema.disallow( { name: '$text', inside: '$root' } );
+
+		const viewText = new ViewText( 'foobar' );
+		dispatcher.on( 'text', convertText() );
+
+		let result = dispatcher.convert( viewText, objWithContext );
+
+		expect( result ).to.be.null;
+
+		result = dispatcher.convert( viewText, { context: [ '$block' ] } );
+		expect( result ).to.be.instanceof( ModelText );
+		expect( result.text ).to.equal( 'foobar' );
+	} );
 } );
 
 describe( 'convertToModelFragment', () => {
@@ -68,7 +88,7 @@ describe( 'convertToModelFragment', () => {
 		dispatcher.on( 'element', convertToModelFragment() );
 		dispatcher.on( 'documentFragment', convertToModelFragment() );
 
-		const result = dispatcher.convert( viewFragment );
+		const result = dispatcher.convert( viewFragment, objWithContext );
 
 		expect( result ).to.be.instanceof( ModelDocumentFragment );
 		expect( result.getChildCount() ).to.equal( 6 );
@@ -91,11 +111,14 @@ describe( 'convertToModelFragment', () => {
 		dispatcher.on( 'element:p', ( evt, data, consumable, conversionApi ) => {
 			if ( consumable.consume( data.input, { name: true } ) ) {
 				data.output = new ModelElement( 'paragraph' );
-				data.output.appendChildren( conversionApi.convertChildren( data.input, consumable ) );
+
+				data.context.push( data.output );
+				data.output.appendChildren( conversionApi.convertChildren( data.input, consumable, data ) );
+				data.context.pop();
 			}
 		} );
 
-		const result = dispatcher.convert( viewP );
+		const result = dispatcher.convert( viewP, objWithContext );
 
 		expect( result ).to.be.instanceof( ModelElement );
 		expect( result.name ).to.equal( 'paragraph' );

+ 93 - 4
packages/ckeditor5-engine/tests/treemodel/characterproxy.js

@@ -15,12 +15,9 @@ import utils from '/ckeditor5/utils/utils.js';
 describe( 'CharacterProxy', () => {
 	let text, element, char;
 
-	before( () => {
+	beforeEach( () => {
 		text = new Text( 'abc', { foo: true } );
 		element = new Element( 'div', [], [ new Element( 'p' ), text, new Element( 'p' ) ] );
-	} );
-
-	beforeEach( () => {
 		char = element.getChild( 2 );
 	} );
 
@@ -43,4 +40,96 @@ describe( 'CharacterProxy', () => {
 	it( 'should return correct index in parent node', () => {
 		expect( char.getIndex() ).to.equal( 2 );
 	} );
+
+	describe( 'attributes interface', () => {
+		describe( 'hasAttribute', () => {
+			it( 'should return true if text fragment has attribute with given key', () => {
+				expect( char.hasAttribute( 'foo' ) ).to.be.true;
+			} );
+
+			it( 'should return false if text fragment does not have attribute with given key', () => {
+				expect( char.hasAttribute( 'abc' ) ).to.be.false;
+			} );
+		} );
+
+		describe( 'getAttribute', () => {
+			it( 'should return attribute with given key if text fragment has given attribute', () => {
+				expect( char.getAttribute( 'foo' ) ).to.be.true;
+			} );
+
+			it( 'should return undefined if text fragment does not have given attribute', () => {
+				expect( char.getAttribute( 'bar' ) ).to.be.undefined;
+			} );
+		} );
+
+		describe( 'getAttributes', () => {
+			it( 'should return an iterator that iterates over all attributes set on the text fragment', () => {
+				let attrs = Array.from( char.getAttributes() );
+
+				expect( attrs ).to.deep.equal( [ [ 'foo', true ] ] );
+			} );
+		} );
+
+		describe( 'setAttribute', () => {
+			it( 'should set attribute on given character', () => {
+				char.setAttribute( 'abc', 'xyz' );
+
+				expect( element.getChild( 0 ).getAttribute( 'abc' ) ).to.be.undefined;
+				expect( element.getChild( 1 ).getAttribute( 'abc' ) ).to.be.undefined;
+				expect( element.getChild( 2 ).getAttribute( 'abc' ) ).to.equal( 'xyz' );
+				expect( element.getChild( 3 ).getAttribute( 'abc' ) ).to.be.undefined;
+				expect( element.getChild( 4 ).getAttribute( 'abc' ) ).to.be.undefined;
+			} );
+
+			it( 'should remove attribute when passed attribute value is null', () => {
+				char.setAttribute( 'foo', null );
+
+				expect( element.getChild( 0 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 1 ).hasAttribute( 'foo' ) ).to.be.true;
+				expect( element.getChild( 2 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 3 ).hasAttribute( 'foo' ) ).to.be.true;
+				expect( element.getChild( 4 ).hasAttribute( 'foo' ) ).to.be.false;
+			} );
+
+			it( 'should correctly split and merge characters', () => {
+				char.setAttribute( 'abc', 'xyz' );
+				char.nextSibling.setAttribute( 'abc', 'xyz' );
+
+				expect( element._children._nodes.length ).to.equal( 4 );
+				expect( element._children._nodes[ 1 ].text ).to.equal( 'a' );
+				expect( element._children._nodes[ 2 ].text ).to.equal( 'bc' );
+			} );
+		} );
+
+		describe( 'setAttributesTo', () => {
+			it( 'should remove all attributes from character and set given ones', () => {
+				char.setAttributesTo( { abc: 'xyz' } );
+
+				expect( element.getChild( 2 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 2 ).getAttribute( 'abc' ) ).to.equal( 'xyz' );
+			} );
+		} );
+
+		describe( 'removeAttribute', () => {
+			it( 'should remove given attribute from character', () => {
+				char.removeAttribute( 'foo' );
+
+				expect( element.getChild( 0 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 1 ).hasAttribute( 'foo' ) ).to.be.true;
+				expect( element.getChild( 2 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 3 ).hasAttribute( 'foo' ) ).to.be.true;
+				expect( element.getChild( 4 ).hasAttribute( 'foo' ) ).to.be.false;
+			} );
+		} );
+
+		describe( 'clearAttributes', () => {
+			it( 'should remove all attributes from text fragment', () => {
+				char.setAttribute( 'abc', 'xyz' );
+				char.clearAttributes();
+
+				expect( element.getChild( 2 ).hasAttribute( 'foo' ) ).to.be.false;
+				expect( element.getChild( 2 ).hasAttribute( 'abc' ) ).to.be.false;
+			} );
+		} );
+	} );
 } );

+ 1 - 1
packages/ckeditor5-engine/tests/treemodel/textproxy.js

@@ -81,7 +81,7 @@ describe( 'TextProxy', () => {
 				expect( textFragment.getAttribute( 'foo' ) ).to.equal( 'bar' );
 			} );
 
-			it( 'should return null if text fragment does not have given attribute', () => {
+			it( 'should return undefined if text fragment does not have given attribute', () => {
 				expect( textFragment.getAttribute( 'bar' ) ).to.be.undefined;
 			} );
 		} );

+ 32 - 0
packages/ckeditor5-engine/tests/treeview/matcher.js

@@ -374,4 +374,36 @@ describe( 'Matcher', () => {
 			expect( matcher.matchAll( el3 ) ).to.be.null;
 		} );
 	} );
+
+	describe( 'getElementName', () => {
+		it( 'should return null if there are no patterns in the matcher instance', () => {
+			const matcher = new Matcher();
+
+			expect( matcher.getElementName() ).to.be.null;
+		} );
+
+		it( 'should return null if pattern has no name property', () => {
+			const matcher = new Matcher( { class: 'foo' } );
+
+			expect( matcher.getElementName() ).to.be.null;
+		} );
+
+		it( 'should return null if pattern has name property specified as RegExp', () => {
+			const matcher = new Matcher( { name: /foo.*/ } );
+
+			expect( matcher.getElementName() ).to.be.null;
+		} );
+
+		it( 'should return element name if matcher has one patter with name property specified as string', () => {
+			const matcher = new Matcher( { name: 'div' } );
+
+			expect( matcher.getElementName() ).to.equal( 'div' );
+		} );
+
+		it( 'should return null if matcher has more than one pattern', () => {
+			const matcher = new Matcher( { name: 'div' }, { class: 'foo' } );
+
+			expect( matcher.getElementName() ).to.be.null;
+		} );
+	} );
 } );