Преглед изворни кода

Merge pull request #347 from ckeditor/t/179

T/179 Converters API
Piotr Jasiun пре 9 година
родитељ
комит
f120c28dd6
36 измењених фајлова са 3843 додато и 115 уклоњено
  1. 22 0
      packages/ckeditor5-engine/src/treecontroller/mapper.js
  2. 329 0
      packages/ckeditor5-engine/src/treecontroller/model-to-view-converters.js
  3. 293 0
      packages/ckeditor5-engine/src/treecontroller/modelconsumable.js
  4. 427 0
      packages/ckeditor5-engine/src/treecontroller/modelconversiondispatcher.js
  5. 58 0
      packages/ckeditor5-engine/src/treecontroller/view-to-model-converters.js
  6. 57 56
      packages/ckeditor5-engine/src/treecontroller/viewconsumable.js
  7. 259 0
      packages/ckeditor5-engine/src/treecontroller/viewconversiondispatcher.js
  8. 2 2
      packages/ckeditor5-engine/src/treemodel/delta/attributedelta.js
  9. 6 4
      packages/ckeditor5-engine/src/treemodel/delta/transform.js
  10. 21 17
      packages/ckeditor5-engine/src/treemodel/document.js
  11. 27 0
      packages/ckeditor5-engine/src/treemodel/documentfragment.js
  12. 1 1
      packages/ckeditor5-engine/src/treemodel/element.js
  13. 1 1
      packages/ckeditor5-engine/src/treemodel/node.js
  14. 12 1
      packages/ckeditor5-engine/src/treemodel/nodelist.js
  15. 7 1
      packages/ckeditor5-engine/src/treemodel/operation/attributeoperation.js
  16. 7 1
      packages/ckeditor5-engine/src/treemodel/operation/rootattributeoperation.js
  17. 1 0
      packages/ckeditor5-engine/src/treemodel/position.js
  18. 10 0
      packages/ckeditor5-engine/src/treemodel/range.js
  19. 65 0
      packages/ckeditor5-engine/src/treemodel/text.js
  20. 3 3
      packages/ckeditor5-engine/src/treemodel/textproxy.js
  21. 8 1
      packages/ckeditor5-engine/src/treeview/documentfragment.js
  22. 774 0
      packages/ckeditor5-engine/tests/treecontroller/advanced-converters.js
  23. 24 0
      packages/ckeditor5-engine/tests/treecontroller/mapper.js
  24. 298 0
      packages/ckeditor5-engine/tests/treecontroller/model-to-view-converters.js
  25. 144 0
      packages/ckeditor5-engine/tests/treecontroller/modelconsumable.js
  26. 422 0
      packages/ckeditor5-engine/tests/treecontroller/modelconversiondispatcher.js
  27. 107 0
      packages/ckeditor5-engine/tests/treecontroller/view-to-model-converters.js
  28. 4 4
      packages/ckeditor5-engine/tests/treecontroller/viewconsumable.js
  29. 252 0
      packages/ckeditor5-engine/tests/treecontroller/viewconversiondispatcher.js
  30. 3 3
      packages/ckeditor5-engine/tests/treemodel/document/change-event.js
  31. 26 0
      packages/ckeditor5-engine/tests/treemodel/documentfragment.js
  32. 35 9
      packages/ckeditor5-engine/tests/treemodel/operation/attributeoperation.js
  33. 35 9
      packages/ckeditor5-engine/tests/treemodel/operation/rootattributeoperation.js
  34. 10 1
      packages/ckeditor5-engine/tests/treemodel/range.js
  35. 79 0
      packages/ckeditor5-engine/tests/treemodel/text.js
  36. 14 1
      packages/ckeditor5-engine/tests/treeview/documentfragment.js

+ 22 - 0
packages/ckeditor5-engine/src/treecontroller/mapper.js

@@ -7,6 +7,8 @@
 
 import ModelPosition from '../treemodel/position.js';
 import ViewPosition from '../treeview/position.js';
+import ModelRange from '../treemodel/range.js';
+import ViewRange from '../treeview/range.js';
 import ViewText from '../treeview/text.js';
 
 /**
@@ -73,6 +75,26 @@ export default class Mapper {
 		return this._modelToViewMapping.get( modelElement );
 	}
 
+	/**
+	 * Gets the corresponding model range.
+	 *
+	 * @param {engine.treeView.Range} viewRange View range.
+	 * @returns {engine.treeModel.Range} Corresponding model range.
+	 */
+	toModelRange( viewRange ) {
+		return new ModelRange( this.toModelPosition( viewRange.start ), this.toModelPosition( viewRange.end ) );
+	}
+
+	/**
+	 * Gets the corresponding view range.
+	 *
+	 * @param {engine.treeModel.Range} modelRange Model range.
+	 * @returns {engine.treeView.Range} Corresponding view range.
+	 */
+	toViewRange( modelRange ) {
+		return new ViewRange( this.toViewPosition( modelRange.start ), this.toViewPosition( modelRange.end ) );
+	}
+
 	/**
 	 * Gets the corresponding model position.
 	 *

+ 329 - 0
packages/ckeditor5-engine/src/treecontroller/model-to-view-converters.js

@@ -0,0 +1,329 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import ModelTreeWalker from '../treemodel/treewalker.js';
+import ModelRange from '../treemodel/range.js';
+
+import ViewElement from '../treeview/element.js';
+import ViewText from '../treeview/text.js';
+
+/**
+ * Contains {@link engine.treeModel model} to {@link engine.treeView view} converters for
+ * {@link engine.treeController.ModelConversionDispatcher}.
+ *
+ * @namespace engine.treeController.modelToView
+ */
+
+/**
+ * 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
+ * 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
+ * {@link engine.treeController.ModelConversionDispatcher}) and bind model and view elements.
+ *
+ *		modelDispatcher.on( 'insert:paragraph', insertElement( new ViewElement( 'p' ) ) );
+ *
+ *		modelDispatcher.on(
+ *			'insert:myElem',
+ *			insertElement( ( data, consumable, conversionApi ) => {
+ *				let myElem = new ViewElement( 'myElem', { myAttr: true }, new ViewText( 'myText' ) );
+ *
+ *				// Do something fancy with myElem using data/consumable/conversionApi ...
+ *
+ *				return myElem;
+ *			}
+ *		) );
+ *
+ * @external engine.treeController.modelToView
+ * @function engine.treeController.modelToView.insertElement
+ * @param {engine.treeView.Element|Function} elementCreator View element, or function returning a view element, which
+ * will be inserted.
+ * @returns {Function} Insert element event converter.
+ */
+export function insertElement( elementCreator ) {
+	return ( evt, data, consumable, conversionApi ) => {
+		consumable.consume( data.item, 'insert' );
+
+		const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
+		const viewElement = ( elementCreator instanceof ViewElement ) ?
+			elementCreator.clone( true ) :
+			elementCreator( data, consumable, conversionApi );
+
+		conversionApi.mapper.bindElements( data.item, viewElement );
+		conversionApi.writer.insert( viewPosition, viewElement );
+
+		evt.stop();
+	};
+}
+
+/**
+ * Function factory, creates a default model-to-view converter for text insertion changes.
+ *
+ * The converter automatically consumes corresponding value from consumables list and stops the event (see
+ * {@link engine.treeController.ModelConversionDispatcher}).
+ *
+ *		modelDispatcher.on( 'insert:$text', insertText() );
+ *
+ * @external engine.treeController.modelToView
+ * @function engine.treeController.modelToView.insertText
+ * @returns {Function} Insert text event converter.
+ */
+export function insertText() {
+	return ( evt, data, consumable, conversionApi ) => {
+		consumable.consume( data.item, 'insert' );
+
+		const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
+		const viewText = new ViewText( data.item.text );
+
+		conversionApi.writer.insert( viewPosition, viewText );
+
+		evt.stop();
+	};
+}
+
+/**
+ * Function factory, creates a converter that converts set/change attribute changes from the model to the view. Attributes
+ * from model are converted to the view element attributes in the view. You may provide a custom function to generate a
+ * key-value attribute pair to add/change. If not provided, model attributes will be converted to view elements attributes
+ * on 1-to-1 basis.
+ *
+ * **Note:** Provided attribute creator should always return the same `key` for given attribute from the model.
+ *
+ * The converter automatically consumes corresponding value from consumables list and stops the event (see
+ * {@link engine.treeController.ModelConversionDispatcher}).
+ *
+ *		modelDispatcher.on( 'addAttribute:customAttr:myElem', setAttribute( ( data ) => {
+ *			// Change attribute key from `customAttr` to `class` in view.
+ *			const key = 'class';
+ *			let value = data.attributeNewValue;
+ *
+ *			// Force attribute value to 'empty' if the model element is empty.
+ *			if ( data.item.getChildCount() === 0 ) {
+ *				value = 'empty';
+ *			}
+ *
+ *			// Return key-value pair.
+ *			return { key, value };
+ *		} ) );
+ *
+ * @external engine.treeController.modelToView
+ * @function engine.treeController.modelToView.setAttribute
+ * @param {Function} [attributeCreator] Function returning an object with two properties: `key` and `value`, which
+ * represents attribute key and attribute value to be set on a {@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.
+ * @returns {Function} Set/change attribute converter.
+ */
+export function setAttribute( attributeCreator ) {
+	attributeCreator = attributeCreator || ( ( data ) => ( { key: data.attributeKey, value: data.attributeNewValue } ) );
+
+	return ( evt, data, consumable, conversionApi ) => {
+		const { key, value } = attributeCreator( data, consumable, conversionApi );
+
+		consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
+		conversionApi.mapper.toViewElement( data.item ).setAttribute( key, value );
+
+		evt.stop();
+	};
+}
+
+/**
+ * Function factory, creates a converter that converts remove attribute changes from the model to the view. Removes attributes
+ * that were converted to the view element attributes in the view. You may provide a custom function to generate a
+ * key-value attribute pair to remove. If not provided, model attributes will be removed from view elements on 1-to-1 basis.
+ *
+ * **Note:** Provided attribute creator should always return the same `key` for given attribute from the model.
+ *
+ * **Note:** You can use the same attribute creator as in {@link engine.treeController.modelToView.setAttribute}.
+ *
+ * The converter automatically consumes corresponding value from consumables list and stops the event (see
+ * {@link engine.treeController.ModelConversionDispatcher}).
+ *
+ *		modelDispatcher.on( 'removeAttribute:customAttr:myElem', removeAttribute( ( data ) => {
+ *			// Change attribute key from `customAttr` to `class` in view.
+ *			const key = 'class';
+ *			let value = data.attributeNewValue;
+ *
+ *			// Force attribute value to 'empty' if the model element is empty.
+ *			if ( data.item.getChildCount() === 0 ) {
+ *				value = 'empty';
+ *			}
+ *
+ *			// Return key-value pair.
+ *			return { key, value };
+ *		} ) );
+ *
+ * @external engine.treeController.modelToView
+ * @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.
+ * @returns {Function} Remove attribute converter.
+ */
+export function removeAttribute( attributeCreator ) {
+	attributeCreator = attributeCreator || ( ( data ) => ( { key: data.attributeKey } ) );
+
+	return ( evt, data, consumable, conversionApi ) => {
+		const { key } = attributeCreator( data, consumable, conversionApi );
+
+		consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
+		conversionApi.mapper.toViewElement( data.item ).removeAttribute( key );
+
+		evt.stop();
+	};
+}
+
+/**
+ * Function factory, creates a converter that converts set/change attribute changes from the model to the view. In this case,
+ * model attributes are converted to a view element that will be wrapping view nodes which corresponding model nodes had
+ * the attribute set. This is useful for attributes like `bold`, which may be set on a text nodes in model but are
+ * represented as an element in the view:
+ *
+ *		[paragraph]              MODEL ====> VIEW        <p>
+ *			|- a {bold: true}                             |- <b>
+ *			|- b {bold: true}                             |   |- ab
+ *			|- c                                          |- c
+ *
+ * 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
+ * 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
+ * {@link engine.treeController.ModelConversionDispatcher}).
+ *
+ *		modelDispatcher.on( 'addAttribute:bold', wrap( new ViewElement( 'strong' ) ) );
+ *
+ * @external engine.treeController.modelToView
+ * @function engine.treeController.modelToView.wrap
+ * @param {engine.treeView.Element|Function} elementCreator View element, or function returning a view element, which will
+ * be used for wrapping.
+ * @returns {Function} Set/change attribute converter.
+ */
+export function wrap( elementCreator ) {
+	return ( evt, data, consumable, conversionApi ) => {
+		consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
+
+		const viewRange = conversionApi.mapper.toViewRange( data.range );
+
+		const viewElement = ( elementCreator instanceof ViewElement ) ?
+			elementCreator.clone( true ) :
+			elementCreator( data.attributeNewValue, data, consumable, conversionApi );
+
+		conversionApi.writer.wrap( viewRange, viewElement, evt.priority );
+
+		evt.stop();
+	};
+}
+
+/**
+ * Function factory, creates a converter that converts remove attribute changes from the model to the view. It assumes, that
+ * attributes from model were converted to elements in the view. This converter will unwrap view nodes from corresponding
+ * view element if given attribute was removed.
+ *
+ * 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.
+ *
+ * The converter automatically consumes corresponding value from consumables list, stops the event (see
+ * {@link engine.treeController.ModelConversionDispatcher}) and bind model and view elements.
+ *
+ *		modelDispatcher.on( 'removeAttribute:bold', unwrap( new ViewElement( 'strong' ) ) );
+ *
+ * @see engine.treeController.modelToView.wrap
+ * @external engine.treeController.modelToView
+ * @function engine.treeController.modelToView.unwrap
+ * @param {engine.treeView.Element|Function} elementCreator View element, or function returning a view element, which will
+ * be used for unwrapping.
+ * @returns {Function} Remove attribute converter.
+ */
+export function unwrap( elementCreator ) {
+	return ( evt, data, consumable, conversionApi ) => {
+		consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
+
+		const viewRange = conversionApi.mapper.toViewRange( data.range );
+		const viewNode = ( elementCreator instanceof ViewElement ) ?
+			elementCreator.clone( true ) :
+			elementCreator( data.attributeOldValue, data, consumable, conversionApi );
+
+		conversionApi.writer.unwrap( viewRange, viewNode );
+
+		evt.stop();
+	};
+}
+
+/**
+ * Function factory, creates a default model-to-view converter for nodes move changes.
+ *
+ *		modelDispatcher.on( 'move', move() );
+ *
+ * @external engine.treeController.modelToView
+ * @function engine.treeController.modelToView.move
+ * @returns {Function} Move event converter.
+ */
+export function move() {
+	return ( evt, data, conversionApi ) => {
+		const walker = new ModelTreeWalker( { boundaries: data.range, shallow: true } );
+
+		let length = 0;
+
+		for ( let value of walker ) {
+			length += value.length;
+		}
+
+		const sourceModelRange = ModelRange.createFromPositionAndShift( data.sourcePosition, length );
+
+		const sourceViewRange = conversionApi.mapper.toViewRange( sourceModelRange );
+		const targetViewPosition = conversionApi.mapper.toViewPosition( data.range.start );
+
+		conversionApi.writer.move( sourceViewRange, targetViewPosition );
+	};
+}
+
+/**
+ * Function factory, creates a default model-to-view converter for nodes remove changes.
+ *
+ *		modelDispatcher.on( 'remove', remove() );
+ *
+ * @external engine.treeController.modelToView
+ * @function engine.treeController.modelToView.remove
+ * @returns {Function} Remove event converter.
+ */
+export function remove() {
+	return ( evt, data, conversionApi ) => {
+		const walker = new ModelTreeWalker( { boundaries: data.range, shallow: true } );
+
+		let length = 0;
+
+		for ( let value of walker ) {
+			length += value.length;
+		}
+
+		const sourceModelRange = ModelRange.createFromPositionAndShift( data.sourcePosition, length );
+		const sourceViewRange = conversionApi.mapper.toViewRange( sourceModelRange );
+
+		conversionApi.writer.remove( sourceViewRange );
+	};
+}
+
+/**
+ * Returns the consumable type that is to be consumed in an event, basing on that event name.
+ *
+ * @param {String} evtName Event name.
+ * @returns {String} Consumable type.
+ */
+export function eventNameToConsumableType( evtName ) {
+	const parts = evtName.split( ':' );
+
+	return parts[ 0 ] + ':' + parts[ 1 ];
+}

+ 293 - 0
packages/ckeditor5-engine/src/treecontroller/modelconsumable.js

@@ -0,0 +1,293 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import TextProxy from '../treemodel/textproxy.js';
+
+/**
+ * Manages a list of consumable values for {@link engine.treeModel.Item model items}.
+ *
+ * Consumables are various aspects of the model items. A model item can be broken down into singular properties
+ * that might be taken into consideration when converting that item.
+ *
+ * `ModelConsumable` is used by {@link engine.treeController.ModelConversionDispatcher} while analyzing changed
+ * parts of {@link engine.treeModel.Document the document}. The added / changed / removed model items are broken down
+ * into singular properties (the item itself and it's attributes). All those parts are saved in `ModelConsumable`. Then,
+ * during conversion, when given part of model item is converted (i.e. the view element has been inserted into the view,
+ * but without attributes), consumable value is removed from `ModelConsumable`.
+ *
+ * Although `ModelConsumable` might store consumable values of any type (see {@link engine.treeController.ModelConsumable#add}),
+ * the commonly used ones are similar to events names fired by {@link engine.treeController.ModelConversionDispatcher}:
+ * `insert`, `addAttribute:<attributeKey>`, `changeAttribute:<attributeKey>`, `removeAttribute:<attributeKey>`.
+ *
+ * Most commonly, {@link engine.treeController.ModelConsumable#add add method} will be used only by
+ * {@link engine.treeController.ModelConversionDispatcher} to gather consumable values. However, it is important to
+ * understand how consumable values can be {@link engine.treeController.ModelConsumable#consume consumed}. See also:
+ * {@link engine.treeController.modelToView}.
+ *
+ * Keep in mind, that one conversion event may have multiple callbacks (converters) attached to it. Each of those is
+ * able to convert one or more parts of the model. However, when one of those callbacks actually converts
+ * something, other should not, because they would duplicate the results. Using `ModelConsumable` helps avoiding
+ * this situation, because callbacks should only convert those values, which were not yet consumed from `ModelConsumable`.
+ *
+ * Consuming multiple values in a single callback:
+ *
+ *		// Converter for custom `image` element that might have a `caption` element inside which changes
+ *		// how the image is displayed in the view:
+ *		//
+ *		// Model:
+ *		//
+ *		// [image]
+ *		//   └─ [caption]
+ *		//       ├─ f
+ *		//       ├─ o
+ *		//       └─ o
+ *		//
+ *		// View:
+ *		//
+ *		// <figure>
+ *		//   ├─ <img />
+ *		//   └─ <caption>
+ *		//       └─ foo
+ *		modelConversionDispatcher.on( 'insert:image', ( evt, data, consumable, conversionApi ) => {
+ *			// First, consume the `image` element.
+ *			consumable.consume( data.item, 'insert' );
+ *
+ *			// Just create normal image element for the view.
+ *			// Maybe it will be "decorated" later.
+ *			const viewImage = new ViewElement( 'img' );
+ *			const insertPosition = conversionApi.mapper.toViewPosition( data.range.start );
+ *
+ *			// Check if the `image` element has children.
+ *			if ( data.item.getChildCount() > 0 ) {
+ *				const modelCaption = data.item.getChild( 0 );
+ *
+ *				// `modelCaption` insertion change is consumed from consumable values.
+ *				// It will not be converted by other converters, but it's children (probably some text) will be.
+ *				// Through mapping, converters for text will know where to insert contents of `modelCaption`.
+ *				if ( consumable.consume( modelCaption, 'insert' ) ) {
+ *					const viewCaption = new ViewElement( 'figcaption' );
+ *
+ *					const viewImageHolder = new ViewElement( 'figure', null, [ viewImage, viewCaption ] );
+ *
+ *					conversionApi.mapper.bindElements( modelCaption, viewCaption );
+ *					conversionApi.mapper.bindElements( data.item, viewImageHolder );
+ *					conversionApi.writer.insert( insertPosition, viewImageHolder );
+ *				}
+ *			} else {
+ *				conversionApi.mapper.bindElements( data.item, viewImage );
+ *				conversionApi.writer.insert( insertPosition, viewImage );
+ *			}
+ *
+ *			evt.stop();
+ *		} );
+ *
+ * @memberOf engine.treeController
+ */
+export default class ModelConsumable {
+	/**
+	 * Creates an empty consumables list.
+	 */
+	constructor() {
+		/**
+		 * Contains list of consumable values.
+		 *
+		 * @private
+		 * @member {Map} engine.treeController.ModelConsumable#_consumable
+		 */
+		this._consumable = new Map();
+
+		/**
+		 * For each {@link engine.treeModel.TextProxy} added to `ModelConsumable`, this registry holds parent
+		 * of that `TextProxy` and start and end indices of that `TextProxy`. This allows identification of `TextProxy`
+		 * instances that points to the same part of the model but are different instances. Each distinct `TextProxy`
+		 * is given unique `Symbol` which is then registered as consumable. This process is transparent for `ModelConsumable`
+		 * API user because whenever `TextProxy` is added, tested, consumed or reverted, internal mechanisms of
+		 * `ModelConsumable` translates `TextProxy` to that unique `Symbol`.
+		 *
+		 * @private
+		 * @member {Map} engine.treeController.ModelConsumable#_textParentsRegistry
+		 */
+		this._textProxyRegistry = new Map();
+	}
+
+	/**
+	 * Adds a consumable value to the consumables list and links it with given model item.
+	 *
+	 *		modelConsumable.add( modelElement, 'insert' ); // Add `modelElement` insertion change to consumable values.
+	 *		modelConsumable.add( modelElement, 'addAttribute:bold' ); // Add `bold` attribute insertion on `modelElement` change.
+	 *		modelConsumable.add( modelElement, 'removeAttribute:bold' ); // Add `bold` attribute removal on `modelElement` change.
+	 *
+	 * @param {engine.treeModel.Item} item Model item that has the consumable.
+	 * @param {String} type Consumable type.
+	 */
+	add( item, type ) {
+		if ( item instanceof TextProxy ) {
+			item = this._getSymbolForTextProxy( item );
+		}
+
+		if ( !this._consumable.has( item ) ) {
+			this._consumable.set( item, new Map() );
+		}
+
+		this._consumable.get( item ).set( type, true );
+	}
+
+	/**
+	 * Removes given consumable value from given model item.
+	 *
+	 *		modelConsumable.consume( modelElement, 'insert' ); // Remove `modelElement` insertion change from consumable values.
+	 *		modelConsumable.consume( modelElement, 'addAttribute:bold' ); // Remove `bold` attribute insertion on `modelElement` change.
+	 *		modelConsumable.consume( modelElement, 'removeAttribute:bold' ); // Remove `bold` attribute removal on `modelElement` change.
+	 *
+	 * @param {engine.treeModel.Item} item Model item from which consumable will be consumed.
+	 * @param {String} type Consumable type.
+	 * @returns {Boolean} `true` if consumable value was available and was consumed, `false` otherwise.
+	 */
+	consume( item, type ) {
+		if ( item instanceof TextProxy ) {
+			item = this._getSymbolForTextProxy( item );
+		}
+
+		if ( this.test( item, type ) ) {
+			this._consumable.get( item ).set( type, false );
+
+			return true;
+		} else {
+			return false;
+		}
+	}
+
+	/**
+	 * Tests whether there is a consumable value of given type connected with given model item.
+	 *
+	 *		modelConsumable.test( modelElement, 'insert' ); // Check for `modelElement` insertion change.
+	 *		modelConsumable.test( modelElement, 'addAttribute:bold' ); // Check for `bold` attribute insertion on `modelElement` change.
+	 *		modelConsumable.test( modelElement, 'removeAttribute:bold' ); // Check for `bold` attribute removal on `modelElement` change.
+	 *
+	 * @param {engine.treeModel.Item} item Model item that will be tested.
+	 * @param {String} type Consumable type.
+	 * @returns {null|Boolean} `null` if such consumable was never added, `false` if the consumable values was
+	 * already consumed or `true` if it was added and not consumed yet.
+	 */
+	test( item, type ) {
+		if ( item instanceof TextProxy ) {
+			item = this._getSymbolForTextProxy( item );
+		}
+
+		const itemConsumables = this._consumable.get( item );
+
+		if ( itemConsumables === undefined ) {
+			return null;
+		}
+
+		const value = itemConsumables.get( type );
+
+		if ( value === undefined ) {
+			return null;
+		}
+
+		return value;
+	}
+
+	/**
+	 * Reverts consuming of consumable value.
+	 *
+	 *		modelConsumable.revert( modelElement, 'insert' ); // Revert consuming `modelElement` insertion change.
+	 *		modelConsumable.revert( modelElement, 'addAttribute:bold' ); // Revert consuming `bold` attribute insert from `modelElement`.
+	 *		modelConsumable.revert( modelElement, 'removeAttribute:bold' ); // Revert consuming `bold` attribute remove from `modelElement`.
+	 *
+	 * @param {engine.treeModel.Item} item Model item that will be tested.
+	 * @param {String} type Consumable type.
+	 * @returns {null|Boolean} `true` if consumable has been reversed, `false` otherwise. `null` if the consumable has
+	 * never been added.
+	 */
+	revert( item, type ) {
+		if ( item instanceof TextProxy ) {
+			item = this._getSymbolForTextProxy( item );
+		}
+
+		const test = this.test( item, type );
+
+		if ( test === false ) {
+			this._consumable.get( item ).set( type, true );
+
+			return true;
+		} else if ( test === true ) {
+			return false;
+		}
+
+		return null;
+	}
+
+	/**
+	 * Gets a unique symbol for passed {@link engine.treeModel.TextProxy} instance. All `TextProxy` instances that
+	 * have same parent, same start index and same end index will get the same symbol.
+	 *
+	 * Used internally to correctly consume `TextProxy` instances.
+	 *
+	 * @private
+	 * @param {engine.treeModel.TextProxy} textProxy `TextProxy` instance to get a symbol for.
+	 * @returns {Symbol} Symbol representing all equal instances of `TextProxy`.
+	 */
+	_getSymbolForTextProxy( textProxy ) {
+		let symbol = null;
+
+		const startIndex = textProxy.first.getIndex();
+		const endIndex = startIndex + textProxy.text.length;
+		const parent = textProxy.commonParent;
+
+		const startIndexMap = this._textProxyRegistry.get( startIndex );
+
+		if ( startIndexMap ) {
+			const endIndexMap = startIndexMap.get( endIndex );
+
+			if ( endIndexMap ) {
+				symbol = endIndexMap.get( parent );
+			}
+		}
+
+		if ( !symbol ) {
+			symbol = this._addSymbolForTextProxy( startIndex, endIndex, parent );
+		}
+
+		return symbol;
+	}
+
+	/**
+	 * Adds a symbol for given properties that characterizes a {@link engine.treeModel.TextProxy} instance.
+	 *
+	 * Used internally to correctly consume `TextProxy` instances.
+	 *
+	 * @private
+	 * @param {Number} startIndex Text proxy start index in it's parent.
+	 * @param {Number} endIndex Text proxy end index in it's parent.
+	 * @param {engine.treeModel.Element} parent Text proxy parent.
+	 * @returns {Symbol} Symbol generated for given properties.
+	 */
+	_addSymbolForTextProxy( startIndex, endIndex, parent ) {
+		const symbol = Symbol();
+		let startIndexMap, endIndexMap;
+
+		startIndexMap = this._textProxyRegistry.get( startIndex );
+
+		if ( !startIndexMap ) {
+			startIndexMap = new Map();
+			this._textProxyRegistry.set( startIndex, startIndexMap );
+		}
+
+		endIndexMap = startIndexMap.get( endIndex );
+
+		if ( !endIndexMap ) {
+			endIndexMap = new Map();
+			startIndexMap.set( endIndex, endIndexMap );
+		}
+
+		endIndexMap.set( parent, symbol );
+
+		return symbol;
+	}
+}

+ 427 - 0
packages/ckeditor5-engine/src/treecontroller/modelconversiondispatcher.js

@@ -0,0 +1,427 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import Consumable from './modelconsumable.js';
+import Range from '../treemodel/range.js';
+import TextProxy from '../treemodel/textproxy.js';
+import EmitterMixin from '../../utils/emittermixin.js';
+import utils from '../../utils/utils.js';
+import extend from '../../utils/lib/lodash/extend.js';
+
+/**
+ * `ModelConversionDispatcher` is a central point of {@link engine.treeModel model} conversion, which is
+ * a process of reacting to changes in the model and reflecting them by listeners that listen to those changes.
+ * In default application, {@link engine.treeModel model} is converted to {@link engine.treeView view}. This means
+ * that changes in the model are reflected by changing the view (i.e. adding view nodes or changing attributes on view elements).
+ *
+ * During conversion process, `ModelConversionDispatcher` fires data-manipulation events, basing on state of the model and prepares
+ * data for those events. It is important to note that the events are connected with "change actions" like "inserting"
+ * or "removing" so one might say that we are converting "changes". This is in contrary to view to model conversion,
+ * where we convert view nodes (the structure, not "changes" to the view). Note, that because changes are converted
+ * and not the structure itself, there is a need to have a mapping between model and the structure on which changes are
+ * reflected. To map elements during model to view conversion use {@link engine.treeController.Mapper}.
+ *
+ * The main use for this class is to listen to {@link engine.treeModel.Document.change Document change event}, process it
+ * and then fire specific events telling what exactly has changed. For those events, `ModelConversionDispatcher`
+ * creates {@link engine.treeController.ModelConsumable list of consumable values} that should be handled by event
+ * callbacks. Those events are listened to by model-to-view converters which convert changes done in the
+ * {@link engine.treeModel model} to changes in the {@link engine.treeView view}. `ModelConversionController` also checks
+ * the current state of consumables, so it won't fire events for parts of model that were already consumed. This is
+ * especially important in callbacks that consume multiple values. See {@link engine.treeController.ModelConsumable}
+ * for an example of such callback.
+ *
+ * Although the primary usage for this class is the model-to-view conversion, `ModelConversionDispatcher` can be used
+ * to build custom data processing pipelines that converts model to anything that is needed. Existing model structure can
+ * be used to generate events (listening to {@link engine.treeModel.Document.change Document change event} is not required)
+ * and custom callbacks can be added to the events (these does not have to be limited to changes in the view).
+ *
+ * When providing your own event listeners for `ModelConversionDispatcher` keep in mind that any callback that had
+ * {@link engine.treeController.ModelConsumable#consume consumed} a value from consumable (and did some changes, i.e. to
+ * the view) should also stop the event. This is because whenever a callback is fired it is assumed that there is something
+ * to be consumed. Thanks to that approach, you do not have to test whether there is anything to consume at the beginning
+ * of your listener callback.
+ *
+ * Example of providing a converter for `ModelConversionDispatcher`:
+ *
+ *		// We will convert inserting "paragraph" model element into the model.
+ *		modelDispatcher.on( 'insert:paragraph', ( evt, data, consumable, conversionApi ) => {
+ *			// Remember to consume the part of consumable.
+ *			consumable.consume( data.item, 'insert' );
+ *
+ *			// Translate position in model to position in the view.
+ *			const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
+ *
+ *			// Create a P element (note that this converter is for inserting P elements -> 'insert:paragraph').
+ *			const viewElement = new ViewElement( 'p' );
+ *
+ *			// Bind the newly created view element to model element so positions will map accordingly in future.
+ *			conversionApi.mapper.bindElements( data.item, viewNode );
+ *
+ *			// Add the newly created view element to the view.
+ *			conversionApi.writer.insert( viewPosition, viewElement );
+ *
+ *			// Remember to stop the event propagation if the data.item was consumed.
+ *			evt.stop();
+ *		} );
+ *
+ * Callback that "overrides" other callback:
+ *
+ *		// Special converter for `linkHref` attribute added on custom `quote` element. Note, that this
+ *		// attribute may be the same as the attribute added by other features (link feature in this case).
+ *		// It might be even added by that feature! It makes sense that a part of content that is a quote is linked
+ *		// to an external source so it makes sense that link feature works on the custom quote element.
+ *		// However, we have to make sure that the attributes added by link feature are correctly converted.
+ *		// To block default `linkHref` conversion we have to:
+ *		// 1) add this callback with higher priority than link feature callback,
+ *		// 2) consume `linkHref` attribute add change.
+ *		modelConversionDispatcher.on( 'addAttribute:linkHref:quote', ( evt, data, consumable, conversionApi ) => {
+ *			consumable.consume( data.item, 'addAttribute:linkHref' );
+ *
+ *			// Create a button that will represent the `linkHref` attribute.
+ *			let viewSourceBtn = new ViewElement( 'a', {
+ *				href: data.item.getAttribute( 'linkHref' ),
+ *				title: 'source'
+ *			} );
+ *
+ *			// Add a class for the button.
+ *			viewSourceBtn.addClass( 'source' );
+ *
+ *			// Insert the button using writer API.
+ *			// If `addAttribute` event is fired by `engine.treeController.ModelConversionDispatcher#convertInsert` it is fired
+ *			// after `data.item` insert conversion was done. If the event is fired due to attribute insertion coming from
+ *			// different source, `data.item` already existed. This means we are safe to get `viewQuote` from mapper.
+ *			const viewQuote = conversionApi.mapper.toViewElement( data.item );
+ *			const position = new ViewPosition( viewQuote, viewQuote.getChildCount() );
+ *			conversionApi.writer.insert( position, viewSourceBtn );
+ *
+ *			evt.stop();
+ *		}, 1 );
+ *
+ * @memberOf engine.treeController
+ */
+export default class ModelConversionDispatcher {
+	/**
+	 * Creates a `ModelConversionDispatcher` that operates using passed API.
+	 *
+	 * @param {Object} [conversionApi] Interface passed by dispatcher to the events callbacks.
+	 */
+	constructor( conversionApi = {} ) {
+		/**
+		 * Interface passed by dispatcher to the events callbacks.
+		 *
+		 * @member {Object} engine.treeController.ModelConversionDispatcher#conversionApi
+		 */
+		this.conversionApi = extend( {}, conversionApi );
+	}
+
+	/**
+	 * Prepares data and fires a proper event.
+	 *
+	 * The method is crafted to take use of parameters passed in {@link engine.treeModel.Document.change Document change event}.
+	 *
+	 * @see engine.treeModel.Document.change
+	 * @fires engine.treeController.ModelConversionDispatcher#insert
+	 * @fires engine.treeController.ModelConversionDispatcher#move
+	 * @fires engine.treeController.ModelConversionDispatcher#remove
+	 * @fires engine.treeController.ModelConversionDispatcher#addAttribute
+	 * @fires engine.treeController.ModelConversionDispatcher#removeAttribute
+	 * @fires engine.treeController.ModelConversionDispatcher#changeAttribute
+	 * @param {String} type Change type.
+	 * @param {Object} data Additional information about the change.
+	 */
+	convertChange( type, data ) {
+		if ( type == 'insert' || type == 'reinsert' ) {
+			this.convertInsert( data.range );
+		} else if ( type == 'move' ) {
+			this.convertMove( data.sourcePosition, data.range );
+		} else if ( type == 'remove' ) {
+			this.convertRemove( data.sourcePosition, data.range );
+		} else if ( type == 'addAttribute' || type == 'removeAttribute' || type == 'changeAttribute' ) {
+			this.convertAttribute( type, data.range, data.key, data.oldValue, data.newValue );
+		}
+	}
+
+	/**
+	 * Analyzes given range and fires insertion-connected events with data based on that range.
+	 *
+	 * **Note**: This method will fire separate events for node insertion and attributes insertion. All
+	 * attributes that are set on inserted nodes are treated like they were added just after node insertion.
+	 *
+	 * @fires engine.treeController.ModelConversionDispatcher#insert
+	 * @fires engine.treeController.ModelConversionDispatcher#addAttribute
+	 * @param {engine.treeModel.Range} range Inserted range.
+	 */
+	convertInsert( range ) {
+		// Create a list of things that can be consumed, consisting of nodes and their attributes.
+		const consumable = this._createInsertConsumable( range );
+
+		// Fire a separate insert event for each node and text fragment contained in the range.
+		for ( let value of range ) {
+			const item = value.item;
+			const itemRange = Range.createFromPositionAndShift( value.previousPosition, value.length );
+			const data = {
+				item: item,
+				range: itemRange
+			};
+
+			this._testAndFire( 'insert', data, consumable );
+
+			// Fire a separate addAttribute event for each attribute that was set on inserted items.
+			// This is important because most attributes converters will listen only to add/change/removeAttribute events.
+			// If we would not add this part, attributes on inserted nodes would not be converted.
+			for ( let attr of item.getAttributes() ) {
+				data.attributeKey = attr[ 0 ];
+				data.attributeOldValue = null;
+				data.attributeNewValue = attr[ 1 ];
+
+				this._testAndFire( 'addAttribute:' + attr[ 0 ], data, consumable );
+			}
+		}
+	}
+
+	/**
+	 * Fires move event with data based on passed values.
+	 *
+	 * @fires engine.treeController.ModelConversionDispatcher#move
+	 * @param {engine.treeModel.Position} sourcePosition Position from where the range has been moved.
+	 * @param {engine.treeModel.Range} range Moved range (after move).
+	 */
+	convertMove( sourcePosition, range ) {
+		const data = {
+			sourcePosition: sourcePosition,
+			range: range
+		};
+
+		this.fire( 'move', data, this.conversionApi );
+	}
+
+	/**
+	 * Fires remove event with data based on passed values.
+	 *
+	 * @fires engine.treeController.ModelConversionDispatcher#remove
+	 * @param {engine.treeModel.Position} sourcePosition Position from where the range has been removed.
+	 * @param {engine.treeModel.Range} range Removed range (after remove, in {@link engine.treeModel.Document#graveyard graveyard root}).
+	 */
+	convertRemove( sourcePosition, range ) {
+		const data = {
+			sourcePosition: sourcePosition,
+			range: range
+		};
+
+		this.fire( 'remove', data, this.conversionApi );
+	}
+
+	/**
+	 * Analyzes given attribute change and fires attributes-connected events with data based on passed values.
+	 *
+	 * @fires engine.treeController.ModelConversionDispatcher#addAttribute
+	 * @fires engine.treeController.ModelConversionDispatcher#removeAttribute
+	 * @fires engine.treeController.ModelConversionDispatcher#changeAttribute
+	 * @param {String} type Change type. Possible values: `addAttribute`, `removeAttribute`, `changeAttribute`.
+	 * @param {engine.treeModel.Range} range Changed range.
+	 * @param {String} key Attribute key.
+	 * @param {*} oldValue Attribute value before the change or `null` if attribute has not been set.
+	 * @param {*} newValue New attribute value or `null` if attribute has been removed.
+	 */
+	convertAttribute( type, range, key, oldValue, newValue ) {
+		// Create a list with attributes to consume.
+		const consumable = this._createAttributeConsumable( type, range, key );
+
+		// Create a separate attribute event for each node in the range.
+		for ( let value of range ) {
+			const item = value.item;
+			const itemRange = Range.createFromPositionAndShift( value.previousPosition, value.length );
+			const data = {
+				item: item,
+				range: itemRange,
+				attributeKey: key,
+				attributeOldValue: oldValue,
+				attributeNewValue: newValue
+			};
+
+			this._testAndFire( type + ':' + key, data, consumable, this.conversionApi );
+		}
+	}
+
+	/**
+	 * Creates {@link engine.treeController.ModelConsumable} with values to consume from given range, assuming that
+	 * given range has just been inserted to the model.
+	 *
+	 * @private
+	 * @param {engine.treeModel.Range} range Inserted range.
+	 * @returns {engine.treeController.ModelConsumable} Values to consume.
+	 */
+	_createInsertConsumable( range ) {
+		const consumable = new Consumable();
+
+		for ( let value of range ) {
+			const item = value.item;
+
+			consumable.add( item, 'insert' );
+
+			for ( let attr of item.getAttributes() ) {
+				consumable.add( item, 'addAttribute:' + attr[ 0 ] );
+			}
+		}
+
+		return consumable;
+	}
+
+	/**
+	 * Creates {@link engine.treeController.ModelConsumable} with values to consume from given range, assuming that
+	 * given range has just had it's attributes changed.
+	 *
+	 * @private
+	 * @param {String} type Change type. Possible values: `addAttribute`, `removeAttribute`, `changeAttribute`.
+	 * @param {engine.treeController.Range} range Changed range.
+	 * @param {String} key Attribute key.
+	 * @returns {engine.treeController.ModelConsumable} Values to consume.
+	 */
+	_createAttributeConsumable( type, range, key ) {
+		const consumable = new Consumable();
+
+		for ( let value of range ) {
+			const item = value.item;
+
+			consumable.add( item, type + ':' + key );
+		}
+
+		return consumable;
+	}
+
+	/**
+	 * Tests passed `consumable` to check whether given event can be fired and if so, fires it.
+	 *
+	 * @private
+	 * @fires engine.treeController.ModelConversionDispatcher#insert
+	 * @fires engine.treeController.ModelConversionDispatcher#addAttribute
+	 * @fires engine.treeController.ModelConversionDispatcher#removeAttribute
+	 * @fires engine.treeController.ModelConversionDispatcher#changeAttribute
+	 * @param {String} type Event type.
+	 * @param {Object} data Event data.
+	 * @param {engine.treeController.ModelConsumable} consumable Values to consume.
+	 */
+	_testAndFire( type, data, consumable ) {
+		if ( !consumable.test( data.item, type ) ) {
+			// Do not fire event if the item was consumed.
+			return;
+		}
+
+		if ( type === 'insert' ) {
+			if ( data.item instanceof TextProxy ) {
+				// Example: insert:$text.
+				this.fire( type + ':$text', data, consumable, this.conversionApi );
+			} else {
+				// Example: insert:paragraph.
+				this.fire( type + ':' + data.item.name, data, consumable, this.conversionApi );
+			}
+		} else {
+			// Example addAttribute:alt:img.
+			// Example addAttribute:bold:$text.
+			const name = data.item.name || '$text';
+
+			this.fire( type + ':' + name, data, consumable, this.conversionApi );
+		}
+	}
+
+	/**
+	 * Fired for inserted nodes.
+	 *
+	 * `insert` is a namespace for a class of events. Names of actually called events follow this pattern:
+	 * `insert:<type>:<elementName>`. `type` is either `text` when one or more characters has been inserted or `element`
+	 * when {@link engine.treeModel.Element} has been inserted. If `type` is `element`, `elementName` is added and is
+	 * equal to the {@link engine.treeModel.Element#name name} of inserted element. This way listeners can either
+	 * listen to very general `insert` event or, i.e., very specific `insert:paragraph` event, which is fired only for
+	 * model elements with name `paragraph`.
+	 *
+	 * @event engine.treeController.ModelConversionDispatcher.insert
+	 * @param {Object} data Additional information about the change.
+	 * @param {engine.treeModel.Item} data.item Inserted item.
+	 * @param {engine.treeModel.Range} data.range Range spanning over inserted item.
+	 * @param {engine.treeController.ModelConsumable} consumable Values to consume.
+	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
+	 */
+
+	/**
+	 * Fired for moved nodes.
+	 *
+	 * @event engine.treeController.ModelConversionDispatcher.move
+	 * @param {Object} data Additional information about the change.
+	 * @param {engine.treeModel.Position} data.sourcePosition Position from where the range has been moved.
+	 * @param {engine.treeModel.Range} data.range Moved range (after move).
+	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
+	 */
+
+	/**
+	 * Fired for removed nodes.
+	 *
+	 * @event engine.treeController.ModelConversionDispatcher.remove
+	 * @param {Object} data Additional information about the change.
+	 * @param {engine.treeModel.Position} data.sourcePosition Position from where the range has been removed.
+	 * @param {engine.treeModel.Range} data.range Removed range (in {@link engine.treeModel.Document#graveyard graveyard root}).
+	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
+	 */
+
+	/**
+	 * Fired when attribute has been added on a node.
+	 *
+	 * `addAttribute` is a namespace for a class of events. Names of actually called events follow this pattern:
+	 * `addAttribute:<attributeKey>:<elementName>`. `attributeKey` is the key of added attribute. `elementName` is
+	 * equal to the {@link engine.treeModel.Element#name name} of the element which got the attribute. This way listeners
+	 * can either listen to adding certain attribute, i.e. `addAttribute:bold`, or be more specific, i.e. `addAttribute:link:img`.
+	 *
+	 * @event engine.treeController.ModelConversionDispatcher.addAttribute
+	 * @param {Object} data Additional information about the change.
+	 * @param {engine.treeModel.Item} data.item Changed item.
+	 * @param {engine.treeModel.Range} data.range Range spanning over changed item.
+	 * @param {String} data.attributeKey Attribute key.
+	 * @param {null} data.attributeOldValue Attribute value before the change - always `null`. Kept for the sake of unifying events.
+	 * @param {*} data.attributeNewValue New attribute value.
+	 * @param {engine.treeController.ModelConsumable} consumable Values to consume.
+	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
+	 */
+
+	/**
+	 * Fired when attribute has been removed from a node.
+	 *
+	 * `removeAttribute` is a namespace for a class of events. Names of actually called events follow this pattern:
+	 * `removeAttribute:<attributeKey>:<elementName>`. `attributeKey` is the key of removed attribute. `elementName` is
+	 * equal to the {@link engine.treeModel.Element#name name} of the element which got the attribute removed. This way listeners
+	 * can either listen to removing certain attribute, i.e. `removeAttribute:bold`, or be more specific, i.e. `removeAttribute:link:img`.
+	 *
+	 * @event engine.treeController.ModelConversionDispatcher.removeAttribute
+	 * @param {Object} data Additional information about the change.
+	 * @param {engine.treeModel.Item} data.item Changed item.
+	 * @param {engine.treeModel.Range} data.range Range spanning over changed item.
+	 * @param {String} data.attributeKey Attribute key.
+	 * @param {*} data.attributeOldValue Attribute value before it was removed.
+	 * @param {null} data.attributeNewValue New attribute value - always `null`. Kept for the sake of unifying events.
+	 * @param {engine.treeController.ModelConsumable} consumable Values to consume.
+	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
+	 */
+
+	/**
+	 * Fired when attribute of a node has been changed.
+	 *
+	 * `changeAttribute` is a namespace for a class of events. Names of actually called events follow this pattern:
+	 * `changeAttribute:<attributeKey>:<elementName>`. `attributeKey` is the key of changed attribute. `elementName` is
+	 * equal to the {@link engine.treeModel.Element#name name} of the element which got the attribute changed. This way listeners
+	 * can either listen to changing certain attribute, i.e. `changeAttribute:link`, or be more specific, i.e. `changeAttribute:link:img`.
+	 *
+	 * @event engine.treeController.ModelConversionDispatcher.changeAttribute
+	 * @param {Object} data Additional information about the change.
+	 * @param {engine.treeModel.Item} data.item Changed item.
+	 * @param {engine.treeModel.Range} data.range Range spanning over changed item.
+	 * @param {String} data.attributeKey Attribute key.
+	 * @param {*} data.attributeOldValue Attribute value before the change.
+	 * @param {*} data.attributeNewValue New attribute value.
+	 * @param {engine.treeController.ModelConsumable} consumable Values to consume.
+	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
+	 */
+}
+
+utils.mix( ModelConversionDispatcher, EmitterMixin );

+ 58 - 0
packages/ckeditor5-engine/src/treecontroller/view-to-model-converters.js

@@ -0,0 +1,58 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import ModelDocumentFragment from '../treemodel/documentfragment.js';
+import ModelText from '../treemodel/text.js';
+
+/**
+ * Contains {@link engine.treeView view} to {@link engine.treeModel model} converters for
+ * {@link engine.treeController.ViewConversionDispatcher}.
+ *
+ * @namespace engine.treeController.viewToModel
+ */
+
+/**
+ * Function factory, creates a converter that converts {@link engine.treeView.DocumentFragment view document fragment} or
+ * all children of {@link engine.treeView.Element} into {@link engine.treeModel.DocumentFragment model document fragment}.
+ * This is the "entry-point" converter for view to model conversion. This converter starts the conversion of all "children"
+ * of passed view document fragment. Those "children" {@link engine.treeView.Node view nodes} are then handled by other converters.
+ *
+ * This also a "default", last resort converter for all view elements that has not been converted by other converters.
+ * When a view element is converted to the model and it does not have it's converter specified, all of that elements
+ * children will be converted to {@link engine.treeModel.DocumentFragment} and returned.
+ *
+ * @external engine.treeController.viewToModel
+ * @function engine.treeController.viewToModel.convertToModelFragment
+ * @returns {Function} Universal converter for view {@link engine.treeView.DocumentFragment fragments} and
+ * {@link engine.treeView.Element elements} that returns {@link engine.treeModel.DocumentFragment model fragment} with
+ * children of converted view item.
+ */
+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 } );
+
+			data.output = new ModelDocumentFragment( convertedChildren );
+		}
+	};
+}
+
+/**
+ * Function factory, creates a converter that converts {@link engine.treeView.Text} to {@link engine.treeModel.Text}.
+ *
+ * @external engine.treeController.viewToModel
+ * @function engine.treeController.viewToModel.convertText
+ * @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 );
+		}
+	};
+}

+ 57 - 56
packages/ckeditor5-engine/src/treecontroller/viewconsumable.js

@@ -25,7 +25,7 @@ class ViewElementConsumables {
 	 */
 	constructor()  {
 		/**
-		 * Boolean value that indicates if name of the element can be consumed.
+		 * Flag indicating if name of the element can be consumed.
 		 *
 		 * @private
 		 * @member {Boolean} engine.treeController.ViewElementConsumables#_canConsumeName
@@ -33,7 +33,7 @@ class ViewElementConsumables {
 		this._canConsumeName = null;
 
 		/**
-		 * Object containing maps of element's consumables: attributes, classes and styles.
+		 * Contains maps of element's consumables: attributes, classes and styles.
 		 *
 		 * @private
 		 * @member {Object} engine.treeController.ViewElementConsumables#_consumables
@@ -46,7 +46,7 @@ class ViewElementConsumables {
 	}
 
 	/**
-	 * Adds consumable parts of the {@link engine.treeView.Element view Element}.
+	 * Adds consumable parts of the {@link engine.treeView.Element view element}.
 	 * Element's name itself can be marked to be consumed (when element's name is consumed its attributes, classes and
 	 * styles still could be consumed):
 	 *
@@ -62,9 +62,9 @@ class ViewElementConsumables {
 	 *
 	 * @param {Object} consumables Object describing which parts of the element can be consumed.
 	 * @param {Boolean} consumables.name If set to `true` element's name will be added as consumable.
-	 * @param {String|Array} consumables.attribute Attribute name or array of attribute names to add as consumable.
-	 * @param {String|Array} consumables.class Class name or array of class names to add as consumable.
-	 * @param {String|Array} consumables.style Style name or array of style names to add as consumable.
+	 * @param {String|Array.<String>} consumables.attribute Attribute name or array of attribute names to add as consumable.
+	 * @param {String|Array.<String>} consumables.class Class name or array of class names to add as consumable.
+	 * @param {String|Array.<String>} consumables.style Style name or array of style names to add as consumable.
 	 */
 	add( consumables ) {
 		if ( consumables.name ) {
@@ -79,9 +79,7 @@ class ViewElementConsumables {
 	}
 
 	/**
-	 * Tests if parts of the {@link engine.treeView.Element view Element} can be consumed. Returns `true` when all tested
-	 * items can be consumed, `null` when even one of the items were never marked for consumption and `false` when even
-	 * one of the items were already consumed.
+	 * Tests if parts of the {@link engine.treeView.Element view element} can be consumed.
 	 *
 	 * Element's name can be tested:
 	 *
@@ -94,11 +92,11 @@ class ViewElementConsumables {
 	 *
 	 * @param {Object} consumables Object describing which parts of the element should be tested.
 	 * @param {Boolean} consumables.name If set to `true` element's name will be tested.
-	 * @param {String|Array} consumables.attribute Attribute name or array of attribute names to test.
-	 * @param {String|Array} consumables.class Class name or array of class names to test.
-	 * @param {String|Array} consumables.style Style name or array of style names to test.
-	 * @returns {Boolean|null} Returns `true` when all tested items can be consumed, `null` when even one of the items
-	 * were never marked for consumption and `false` when even one of the items were already consumed.
+	 * @param {String|Array.<String>} consumables.attribute Attribute name or array of attribute names to test.
+	 * @param {String|Array.<String>} consumables.class Class name or array of class names to test.
+	 * @param {String|Array.<String>} consumables.style Style name or array of style names to test.
+	 * @returns {Boolean|null} `true` when all tested items can be consumed, `null` when even one of the items
+	 * was never marked for consumption and `false` when even one of the items was already consumed.
 	 */
 	test( consumables ) {
 		// Check if name can be consumed.
@@ -121,7 +119,7 @@ class ViewElementConsumables {
 	}
 
 	/**
-	 * Consumes parts of {@link engine.treeView.Element view Element}. This function does not check if consumable item
+	 * Consumes parts of {@link engine.treeView.Element view element}. This function does not check if consumable item
 	 * is already consumed - it consumes all consumable items provided.
 	 * Element's name can be consumed:
 	 *
@@ -134,9 +132,9 @@ class ViewElementConsumables {
 	 *
 	 * @param {Object} consumables Object describing which parts of the element should be consumed.
 	 * @param {Boolean} consumables.name If set to `true` element's name will be consumed.
-	 * @param {String|Array} consumables.attribute Attribute name or array of attribute names to consume.
-	 * @param {String|Array} consumables.class Class name or array of class names to consume.
-	 * @param {String|Array} consumables.style Style name or array of style names to consume.
+	 * @param {String|Array.<String>} consumables.attribute Attribute name or array of attribute names to consume.
+	 * @param {String|Array.<String>} consumables.class Class name or array of class names to consume.
+	 * @param {String|Array.<String>} consumables.style Style name or array of style names to consume.
 	 */
 	consume( consumables ) {
 		if ( consumables.name ) {
@@ -163,9 +161,9 @@ class ViewElementConsumables {
 	 *
 	 * @param {Object} consumables Object describing which parts of the element should be reverted.
 	 * @param {Boolean} consumables.name If set to `true` element's name will be reverted.
-	 * @param {String|Array} consumables.attribute Attribute name or array of attribute names to revert.
-	 * @param {String|Array} consumables.class Class name or array of class names to revert.
-	 * @param {String|Array} consumables.style Style name or array of style names to revert.
+	 * @param {String|Array.<String>} consumables.attribute Attribute name or array of attribute names to revert.
+	 * @param {String|Array.<String>} consumables.class Class name or array of class names to revert.
+	 * @param {String|Array.<String>} consumables.style Style name or array of style names to revert.
 	 */
 	revert( consumables ) {
 		if ( consumables.name ) {
@@ -180,14 +178,14 @@ class ViewElementConsumables {
 	}
 
 	/**
-	 * Helper method that adds consumables from one type: attribute, class or style.
+	 * Helper method that adds consumables of a given type: attribute, class or style.
 	 *
 	 * Throws {@link utils.CKEditorError CKEditorError} `viewconsumable-invalid-attribute` when `class` or `style`
 	 * type is provided - it should be handled separately by providing actual style/class type.
 	 *
 	 * @private
 	 * @param {String} type Type of the consumable item: `attribute`, `class` or `style`.
-	 * @param {String|Array} item Consumable item or array of items.
+	 * @param {String|Array.<String>} item Consumable item or array of items.
 	 */
 	_add( type, item ) {
 		const items = isArray( item ) ? item : [ item ];
@@ -208,11 +206,11 @@ class ViewElementConsumables {
 	}
 
 	/**
-	 * Helper method that tests consumables from one type: attribute, class or style.
+	 * Helper method that tests consumables of a given type: attribute, class or style.
 	 *
 	 * @private
 	 * @param {String} type Type of the consumable item: `attribute`, `class` or `style`.
-	 * @param {String|Array} item Consumable item or array of items.
+	 * @param {String|Array.<String>} item Consumable item or array of items.
 	 * @returns {Boolean|null} Returns `true` if all items can be consumed, `null` when one of the items cannot be
 	 * consumed and `false` when one of the items is already consumed.
 	 */
@@ -245,11 +243,11 @@ class ViewElementConsumables {
 	}
 
 	/**
-	 * Helper method that consumes items from one type: attribute, class or style.
+	 * Helper method that consumes items of a given type: attribute, class or style.
 	 *
 	 * @private
 	 * @param {String} type Type of the consumable item: `attribute`, `class` or `style`.
-	 * @param {String|Array} item Consumable item or array of items.
+	 * @param {String|Array.<String>} item Consumable item or array of items.
 	 */
 	_consume( type, item ) {
 		const items = isArray( item ) ? item : [ item ];
@@ -266,11 +264,11 @@ class ViewElementConsumables {
 	}
 
 	/**
-	 * Helper method that reverts items from one type: attribute, class or style.
+	 * Helper method that reverts items of a given type: attribute, class or style.
 	 *
 	 * @private
 	 * @param {String} type Type of the consumable item: `attribute`, `class` or , `style`.
-	 * @param {String|Array} item Consumable item or array of items.
+	 * @param {String|Array.<String>} item Consumable item or array of items.
 	 */
 	_revert( type, item ) {
 		const items = isArray( item ) ? item : [ item ];
@@ -356,9 +354,9 @@ export default class ViewConsumable {
 	 * @param {engine.treeView.Element|engine.treeView.Text|engine.treeView.DocumentFragment} element
 	 * @param {Object} [consumables] Used only if first parameter is {@link engine.treeView.Element view element} instance.
 	 * @param {Boolean} consumables.name If set to true element's name will be included.
-	 * @param {String|Array} consumables.attribute Attribute name or array of attribute names.
-	 * @param {String|Array} consumables.class Class name or array of class names.
-	 * @param {String|Array} consumables.style Style name or array of style names.
+	 * @param {String|Array.<String>} consumables.attribute Attribute name or array of attribute names.
+	 * @param {String|Array.<String>} consumables.class Class name or array of class names.
+	 * @param {String|Array.<String>} consumables.style Style name or array of style names.
 	 */
 	add( element, consumables ) {
 		let elementConsumables;
@@ -404,9 +402,9 @@ export default class ViewConsumable {
 	 * @param {engine.treeView.Element|engine.treeView.Text|engine.treeView.DocumentFragment} element
 	 * @param {Object} [consumables] Used only if first parameter is {@link engine.treeView.Element view element} instance.
 	 * @param {Boolean} consumables.name If set to true element's name will be included.
-	 * @param {String|Array} consumables.attribute Attribute name or array of attribute names.
-	 * @param {String|Array} consumables.class Class name or array of class names.
-	 * @param {String|Array} consumables.style Style name or array of style names.
+	 * @param {String|Array.<String>} consumables.attribute Attribute name or array of attribute names.
+	 * @param {String|Array.<String>} consumables.class Class name or array of class names.
+	 * @param {String|Array.<String>} consumables.style Style name or array of style names.
 	 * @returns {Boolean|null} Returns `true` when all items included in method's call can be consumed. Returns `false`
 	 * when first already consumed item is found and `null` when first non-consumable item is found.
 	 */
@@ -448,9 +446,9 @@ export default class ViewConsumable {
 	 * @param {engine.treeView.Element|engine.treeView.Text|engine.treeView.DocumentFragment} element
 	 * @param {Object} [consumables] Used only if first parameter is {@link engine.treeView.Element view element} instance.
 	 * @param {Boolean} consumables.name If set to true element's name will be included.
-	 * @param {String|Array} consumables.attribute Attribute name or array of attribute names.
-	 * @param {String|Array} consumables.class Class name or array of class names.
-	 * @param {String|Array} consumables.style Style name or array of style names.
+	 * @param {String|Array.<String>} consumables.attribute Attribute name or array of attribute names.
+	 * @param {String|Array.<String>} consumables.class Class name or array of class names.
+	 * @param {String|Array.<String>} consumables.style Style name or array of style names.
 	 * @returns {Boolean} Returns `true` when all items included in method's call can be consumed,
 	 * otherwise returns `false`.
 	 */
@@ -494,9 +492,9 @@ export default class ViewConsumable {
 	 * @param {engine.treeView.Element|engine.treeView.Text|engine.treeView.DocumentFragment} element
 	 * @param {Object} [consumables] Used only if first parameter is {@link engine.treeView.Element view element} instance.
 	 * @param {Boolean} consumables.name If set to true element's name will be included.
-	 * @param {String|Array} consumables.attribute Attribute name or array of attribute names.
-	 * @param {String|Array} consumables.class Class name or array of class names.
-	 * @param {String|Array} consumables.style Style name or array of style names.
+	 * @param {String|Array.<String>} consumables.attribute Attribute name or array of attribute names.
+	 * @param {String|Array.<String>} consumables.class Class name or array of class names.
+	 * @param {String|Array.<String>} consumables.style Style name or array of style names.
 	 */
 	revert( element, consumables ) {
 		const elementConsumables = this._consumables.get( element );
@@ -560,30 +558,33 @@ export default class ViewConsumable {
 	 * Instance will contain all elements, child nodes, attributes, styles and classes added for consumption.
 	 *
 	 * @static
-	 * @param {engine.treeView.Element|engine.treeView.DocumentFragment} root
-	 * @param {engine.treeController.ViewConsumable} [instance] Optionally this instance can be used to add all
-	 * consumables from provided root. It will be returned instead of new instance.
+	 * @param {engine.treeView.Element|engine.treeView.DocumentFragment} from View element or document fragment
+	 * from which `ViewConsumable` will be created.
+	 * @param {engine.treeController.ViewConsumable} [instance] If provided, given `ViewConsumable` instance will be used
+	 * to add all consumables. It will be returned instead of a new instance.
 	 */
-	static createFromElement( root, instance ) {
+	static createFrom( from, instance ) {
 		if ( !instance ) {
 			instance = new ViewConsumable();
 		}
 
-		// Add root itself if it is an element.
-		if ( root instanceof ViewElement ) {
-			instance.add( root, ViewConsumable.consumablesFromElement( root ) );
+		if ( from instanceof ViewText ) {
+			instance.add( from );
+
+			return instance;
 		}
 
-		if ( root instanceof ViewDocumentFragment ) {
-			instance.add( root );
+		// Add `from` itself, if it is an element.
+		if ( from instanceof ViewElement ) {
+			instance.add( from, ViewConsumable.consumablesFromElement( from ) );
 		}
 
-		for ( let child of root.getChildren() ) {
-			if ( child instanceof ViewText ) {
-				instance.add( child );
-			} else {
-				instance = ViewConsumable.createFromElement( child, instance );
-			}
+		if ( from instanceof ViewDocumentFragment ) {
+			instance.add( from );
+		}
+
+		for ( let child of from.getChildren() ) {
+			instance = ViewConsumable.createFrom( child, instance );
 		}
 
 		return instance;

+ 259 - 0
packages/ckeditor5-engine/src/treecontroller/viewconversiondispatcher.js

@@ -0,0 +1,259 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import ViewConsumable from './viewconsumable.js';
+import ViewElement from '../treeview/element.js';
+import ViewText from '../treeview/text.js';
+import EmitterMixin from '../../utils/emittermixin.js';
+import utils from '../../utils/utils.js';
+import extend from '../../utils/lib/lodash/extend.js';
+
+/**
+ * `ViewConversionDispatcher` is a central point of {@link engine.treeView view} conversion, which is a process of
+ * converting given {@link engine.treeView.DocumentFragment view document fragment} or {@link engine.treeView.Element}
+ * into another structure. In default application, {@link engine.treeView view} is converted to {@link engine.treeModel}.
+ *
+ * During conversion process, for all {@link engine.treeView.Node view nodes} from the converted view document fragment,
+ * `ViewConversionDispatcher` fires corresponding events. Special callbacks called "converters" should listen to
+ * `ViewConversionDispatcher` for those events.
+ *
+ * Each callback, as a first argument, is passed a special object `data` that has `input` and `output` properties.
+ * `input` property contains {@link engine.treeView.Node view node} or {@link engine.treeView.DocumentFragment view document fragment}
+ * that is converted at the moment and might be handled by the callback. `output` property should be used to save the result
+ * of conversion. Keep in mind that the `data` parameter is customizable and may contain other values - see
+ * {@link engine.treeController.ViewConversionDispatcher#convert}. It is also shared by reference by all callbacks
+ * listening to given event. **Note**: in view to model conversion - `data` contains `context` property that is an array
+ * of {@link engine.treeModel.Element model elements}. These are model elements that will be the parent of currently
+ * converted view item. `context` property is used in examples below.
+ *
+ * The second parameter passed to a callback is an instance of {@link engine.treeController.ViewConsumable}. It stores
+ * information about what parts of processed view item are still waiting to be handled. After a piece of view item
+ * was converted, appropriate consumable value should be {@link engine.treeController.ViewConsumable#consume consumed}.
+ *
+ * The third parameter passed to a callback is an instance of {@link engine.treeController.ViewConversionDispatcher}
+ * which provides additional tools for converters.
+ *
+ * Examples of providing callbacks for `ViewConversionDispatcher`:
+ *
+ *		// Converter for paragraphs (<p>).
+ *		viewDispatcher.on( 'element:p', ( data, consumable, conversionApi ) => {
+ *			const paragraph = new ModelElement( 'paragraph' );
+ *			const schemaQuery = {
+ *				name: 'paragraph',
+ *				inside: data.context
+ *			};
+ *
+ *			if ( conversionApi.schema.checkQuery( 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 } );
+ *					paragraph.appendChildren( children );
+ *					data.output = paragraph;
+ *				}
+ *			}
+ *		} );
+ *
+ *		// Converter for links (<a>).
+ *		viewDispatcher.on( 'element:a', ( data, consumable, conversionApi ) => {
+ *			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 } );
+ *
+ *				for ( let item of Range.createFrom( data.output ) ) {
+ *					const schemaQuery = {
+ *						name: item.name || '$text',
+ *						attribute: 'link',
+ *						inside: data.context
+ *					};
+ *
+ *					if ( conversionApi.schema.checkQuery( schemaQuery ) ) {
+ *						item.setAttribute( 'link', data.input.getAttribute( 'href' ) );
+ *					}
+ *				}
+ *			}
+ *		} );
+ *
+ *		// Fire conversion.
+ *		// At the beginning, the context is empty because given `viewDocumentFragment` has no parent.
+ *		viewDispatcher.convert( viewDocumentFragment, { context: [] } );
+ *
+ * Before each conversion process, `ViewConversionDispatcher` fires {@link engine.treeController.ViewConversionDispatcher.viewCleanup}
+ * event which can be used to prepare tree view for conversion.
+ *
+ * @mixes utils.EmitterMixin
+ * @fires engine.treeController.ViewConversionDispatcher.viewCleanup
+ * @fires engine.treeController.ViewConversionDispatcher.element
+ * @fires engine.treeController.ViewConversionDispatcher.text
+ * @fires engine.treeController.ViewConversionDispatcher.documentFragment
+ *
+ * @memberOf engine.treeController
+ */
+export default class ViewConversionDispatcher {
+	/**
+	 * Creates a `ViewConversionDispatcher` that operates using passed API.
+	 *
+	 * @see engine.treeController.ViewConversionApi
+	 * @param {Object} [conversionApi] Additional properties for interface that will be passed to events fired
+	 * by `ViewConversionDispatcher`.
+	 */
+	constructor( conversionApi = {} ) {
+		/**
+		 * Interface passed by dispatcher to the events callbacks.
+		 *
+		 * @member {engine.treeController.ViewConversionApi} engine.treeController.ViewConversionDispatcher#conversionApi
+		 */
+		this.conversionApi = extend( {}, conversionApi );
+
+		// `convertItem` and `convertChildren` are bound to this `ViewConversionDispatcher` instance and
+		// set on `conversionApi`. This way only a part of `ViewConversionDispatcher` API is exposed.
+		this.conversionApi.convertItem = this._convertItem.bind( this );
+		this.conversionApi.convertChildren = this._convertChildren.bind( this );
+	}
+
+	/**
+	 * Starts the conversion process. The entry point for the conversion.
+	 *
+	 * @fires engine.treeController.ViewConversionDispatcher.element
+	 * @fires engine.treeController.ViewConversionDispatcher.text
+	 * @fires engine.treeController.ViewConversionDispatcher.documentFragment
+	 * @param {engine.treeView.DocumentFragment|engine.treeView.Element} viewItem Part of the view to be converted.
+	 * @param {Object} [additionalData] Additional data to be passed in `data` argument when firing `ViewConversionDispatcher`
+	 * events. See also {@link engine.treeController.ViewConversionDispatcher.element element event}.
+	 * @returns {engine.treeModel.DocumentFragment} Model document fragment that is a result of the conversion process.
+	 */
+	convert( viewItem, additionalData = {} ) {
+		this.fire( 'viewCleanup', viewItem );
+
+		const consumable = ViewConsumable.createFrom( viewItem );
+
+		return this._convertItem( viewItem, consumable, additionalData );
+	}
+
+	/**
+	 * @private
+	 * @see engine.treeController.ViewConversionApi#convertItem
+	 */
+	_convertItem( input, consumable, additionalData = {} ) {
+		const data = extend( additionalData, {
+			input: input,
+			output: null
+		} );
+
+		if ( input instanceof ViewElement ) {
+			this.fire( 'element:' + input.name, data, consumable, this.conversionApi );
+		} else if ( input instanceof ViewText ) {
+			this.fire( 'text', data, consumable, this.conversionApi );
+		} else {
+			this.fire( 'documentFragment', data, consumable, this.conversionApi );
+		}
+
+		return data.output;
+	}
+
+	/**
+	 * @private
+	 * @see engine.treeController.ViewConversionApi#convertChildren
+	 */
+	_convertChildren( input, consumable, additionalData = {} ) {
+		const viewChildren = Array.from( input.getChildren() );
+		const convertedChildren = viewChildren.map( ( viewChild ) => this._convertItem( viewChild, consumable, additionalData ) );
+
+		// Flatten and remove nulls.
+		return convertedChildren.reduce( ( a, b ) => b ? a.concat( b ) : a, [] );
+	}
+
+	/**
+	 * Fired before the first conversion event, at the beginning of view to model conversion process.
+	 *
+	 * @event engine.treeController.ViewConversionDispatcher.viewCleanup
+	 * @param {engine.treeView.DocumentFragment|engine.treeView.Element} viewItem Part of the view to be converted.
+	 */
+
+	/**
+	 * Fired when {@link engine.treeView.Element} is converted.
+	 *
+	 * `element` is a namespace event for a class of events. Names of actually called events follow this pattern:
+	 * `element:<elementName>` where `elementName` is the name of converted element. This way listeners may listen to
+	 * all elements conversion or to conversion of specific elements.
+	 *
+	 * @event engine.treeController.ViewConversionDispatcher.element
+	 * @param {Object} data Object containing conversion input and a placeholder for conversion output and possibly other
+	 * values (see {@link engine.treeController.ViewConversionDispatcher#convert}). Keep in mind that this object is shared
+	 * by reference between all callbacks that will be called. This means that callbacks can add their own values if needed,
+	 * and those values will be available in other callbacks.
+	 * @param {engine.treeView.Element} data.input Converted element.
+	 * @param {*} data.output The current state of conversion result. Every change to converted element should
+	 * be reflected by setting or modifying this property.
+	 * @param {engine.treeController.ViewConsumable} consumable Values to consume.
+	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ViewConversionDispatcher` constructor.
+	 * Besides of properties passed in constructor, it also has `convertItem` and `convertChildren` methods which are references
+	 * to {@link engine.treeController.ViewConversionDispatcher#_convertItem} and
+	 * {@link engine.treeController.ViewConversionDispatcher#_convertChildren}. Those methods are needed to convert
+	 * the whole view-tree they were exposed in `conversionApi` for callbacks.
+	 */
+
+	/**
+	 * Fired when {@link engine.treeView.Text} is converted.
+	 *
+	 * @event engine.treeController.ViewConversionDispatcher.text
+	 * @see engine.treeController.ViewConversionDispatcher.element
+	 */
+
+	/**
+	 * Fired when {@link engine.treeView.DocumentFragment} is converted.
+	 *
+	 * @event engine.treeController.ViewConversionDispatcher.documentFragment
+	 * @see engine.treeController.ViewConversionDispatcher.element
+	 */
+}
+
+utils.mix( ViewConversionDispatcher, EmitterMixin );
+
+/**
+ * Conversion interface that is registered for given {@link engine.treeController.ViewConversionDispatcher} and is
+ * passed as one of parameters when {@link engine.treeController.ViewConversionDispatcher dispatcher} fires it's events.
+ *
+ * `ViewConversionApi` object is built by {@link engine.treeController.ViewConversionDispatcher} constructor. The exact
+ * list of properties of this object is determined by the object passed to the constructor.
+ *
+ * @interface engine.treeController.ViewConversionApi
+ */
+
+/**
+ * Starts conversion of given item by firing an appropriate event.
+ *
+ * Every fired event is passed (as first parameter) an object with `output` property. Every event may set and/or
+ * modify that property. When all callbacks are done, the final value of `output` property is returned by this method.
+ *
+ * @memberOf engine.treeController.ViewConversionApi
+ * @function covertItem
+ * @fires engine.treeController.ViewConversionDispatcher.element
+ * @fires engine.treeController.ViewConversionDispatcher.text
+ * @fires engine.treeController.ViewConversionDispatcher.documentFragment
+ * @param {engine.treeView.DocumentFragment|engine.treeView.Element|engine.treeView.Text} input Item to convert.
+ * @param {engine.treeController.ViewConsumable} consumable Values to consume.
+ * @param {Object} [additionalData] Additional data to be passed in `data` argument when firing `ViewConversionDispatcher`
+ * events. See also {@link engine.treeController.ViewConversionDispatcher.element element event}.
+ * @returns {*} The result of item conversion, created and modified by callbacks attached to fired event.
+ */
+
+/**
+ * Starts conversion of all children of given item by firing appropriate events for all those children.
+ *
+ * @memberOf engine.treeController.ViewConversionApi
+ * @function convertChildren
+ * @fires engine.treeController.ViewConversionDispatcher.element
+ * @fires engine.treeController.ViewConversionDispatcher.text
+ * @fires engine.treeController.ViewConversionDispatcher.documentFragment
+ * @param {engine.treeView.DocumentFragment|engine.treeView.Element} input Item which children will be converted.
+ * @param {engine.treeController.ViewConsumable} consumable Values to consume.
+ * @param {Object} [additionalData] Additional data to be passed in `data` argument when firing `ViewConversionDispatcher`
+ * events. See also {@link engine.treeController.ViewConversionDispatcher.element element event}.
+ * @returns {Array.<*>} Array containing results of conversion of all children of given item.
+ */

+ 2 - 2
packages/ckeditor5-engine/src/treemodel/delta/attributedelta.js

@@ -148,7 +148,7 @@ function changeNode( batch, doc, key, value, node ) {
 				range = new Range( Position.createBefore( node ), Position.createAfter( node ) );
 			}
 
-			operation = new AttributeOperation( range, key, previousValue, value, doc.version );
+			operation = new AttributeOperation( range, key, previousValue || null, value || null, doc.version );
 		}
 
 		delta.addOperation( operation );
@@ -199,7 +199,7 @@ function changeRange( batch, doc, attributeKey, attributeValue, range ) {
 
 	function addOperation() {
 		let range = new Range( lastSplitPosition, position );
-		const operation = new AttributeOperation( range, attributeKey, attributeValueBefore || null, attributeValue, doc.version );
+		const operation = new AttributeOperation( range, attributeKey, attributeValueBefore || null, attributeValue || null, doc.version );
 
 		delta.addOperation( operation );
 		doc.applyOperation( operation );

+ 6 - 4
packages/ckeditor5-engine/src/treemodel/delta/transform.js

@@ -65,6 +65,8 @@ function updateBaseVersion( baseVersion, deltas ) {
  * This algorithm is similar to popular `dOPT` algorithm used in operational transformation, as we are in fact
  * transforming two sets of operations by each other.
  *
+ * @external engine.treeModel.delta.transform
+ * @function engine.treeModel.delta.transform.defaultTransform
  * @param {engine.treeModel.delta.Delta} a Delta that will be transformed.
  * @param {engine.treeModel.delta.Delta} b Delta to transform by.
  * @param {Boolean} isAMoreImportantThanB Flag indicating whether the delta which will be transformed (`a`) should be treated
@@ -161,11 +163,11 @@ export function defaultTransform( a, b, isAMoreImportantThanB ) {
 /**
  * Adds a special case callback for given delta classes.
  *
+ * @external engine.treeModel.delta.transform
+ * @function engine.treeModel.delta.transform.addTransformationCase
  * @param {Function} A Delta constructor which instance will get transformed.
  * @param {Function} B Delta constructor which instance will be transformed by.
  * @param {Function} resolver A callback that will handle custom special case transformation for instances of given delta classes.
- * @external engine.treeModel.delta.transform
- * @function engine.treeModel.delta.transform.addTransformationCase
  */
 export function addTransformationCase( A, B, resolver ) {
 	let casesA = specialCases.get( A );
@@ -181,10 +183,10 @@ export function addTransformationCase( A, B, resolver ) {
 /**
  * Gets a special case callback which was previously {@link engine.treeModel.delta.transform.addTransformationCase added}.
  *
- * @param {engine.treeModel.delta.Delta} a Delta to transform.
- * @param {engine.treeModel.delta.Delta} b Delta to be transformed by.
  * @external engine.treeModel.delta.transform
  * @function engine.treeModel.delta.transform.getTransformationCase
+ * @param {engine.treeModel.delta.Delta} a Delta to transform.
+ * @param {engine.treeModel.delta.Delta} b Delta to be transformed by.
  */
 export function getTransformationCase( a, b ) {
 	let casesA = specialCases.get( a.constructor );

+ 21 - 17
packages/ckeditor5-engine/src/treemodel/document.js

@@ -288,27 +288,31 @@ export default class Document {
 	 * * 'remove' when nodes are removed,
 	 * * 'reinsert' when remove is undone,
 	 * * 'move' when nodes are moved,
-	 * * 'attribute' when attributes change,
-	 * * 'rootattribute' when attributes for root element change.
+	 * * 'addAttribute' when attributes are added,
+	 * * 'removeAttribute' when attributes are removed,
+	 * * 'changeAttribute' when attributes change,
+	 * * 'addRootAttribute' when attribute for root is added,
+	 * * 'removeRootAttribute' when attribute for root is removed,
+	 * * 'changeRootAttribute' when attribute for root changes.
 	 *
 	 * Change event is fired after the change is done. This means that any ranges or positions passed in
-	 * `changeInfo` are referencing nodes and paths in updated tree model.
+	 * `data` are referencing nodes and paths in updated tree model.
 	 *
-	 * @event engine.treeModel.Document#change
-	 * @param {String} type Change type, possible option: `'insert'`, `'remove'`, `'reinsert'`, `'move'`, `'attribute'`.
-	 * @param {Object} changeInfo Additional information about the change.
-	 * @param {engine.treeModel.Range} [changeInfo.range] Range containing changed nodes. Note that for `'remove'` the range will be in the
-	 * {@link engine.treeModel.Document#graveyard graveyard root}. This is undefined for `'rootattribute'` type.
-	 * @param {engine.treeModel.RootElement} [changeInfo.root] Root element which attributes got changed. This is defined
-	 * only for `'rootattribute'` type.
-	 * @param {engine.treeModel.Position} [changeInfo.sourcePosition] Change source position. Exists for `'remove'`, `'reinsert'` and `'move'`.
+	 * @event engine.treeModel.Document.change
+	 * @param {String} type Change type, possible option: 'insert', 'remove', 'reinsert', 'move', 'attribute'.
+	 * @param {Object} data Additional information about the change.
+	 * @param {engine.treeModel.Range} data.range Range containing changed nodes. Note that for 'remove' the range will be in the
+	 * {@link engine.treeModel.Document#graveyard graveyard root}. This is undefined for root types.
+	 * @param {engine.treeModel.Position} [data.sourcePosition] Change source position. Exists for 'remove', 'reinsert' and 'move'.
 	 * Note that for 'reinsert' the source position will be in the {@link engine.treeModel.Document#graveyard graveyard root}.
-	 * @param {String} [changeInfo.key] Only for `'attribute'` type. Key of changed / inserted / removed attribute.
-	 * @param {*} [changeInfo.oldValue] Only for `'attribute'` type. If the type is `'attribute'` and `oldValue`
-	 * is `undefined` it means that new attribute was inserted. Otherwise it contains changed or removed attribute value.
-	 * @param {*} [changeInfo.newValue] Only for `'attribute'` type. If the type is `'attribute'` and `newValue`
-	 * is `undefined` it means that attribute was removed. Otherwise it contains changed or inserted attribute value.
-	 * @param {engine.treeModel.Batch} batch A batch of changes which this change is a part of.
+	 * @param {String} [data.key] Only for attribute types. Key of changed / inserted / removed attribute.
+	 * @param {*} [data.oldValue] Only for 'removeAttribute', 'removeRootAttribute', 'changeAttribute' or
+	 * 'changeRootAttribute' type.
+	 * @param {*} [data.newValue] Only for 'addAttribute', 'addRootAttribute', 'changeAttribute' or
+	 * 'changeRootAttribute' type.
+	 * @param {engine.treeModel.RootElement} [changeInfo.root] Root element which attributes got changed. This is defined
+	 * only for root types.
+	 * @param {engine.treeModel.Batch} batch A {@link engine.treeModel.Batch batch} of changes which this change is a part of.
 	 */
 
 	/**

+ 27 - 0
packages/ckeditor5-engine/src/treemodel/documentfragment.js

@@ -33,6 +33,33 @@ export default class DocumentFragment {
 		}
 	}
 
+	/**
+	 * `DocumentFragment` iterator. Returns {@link engine.treeModel.Node nodes} that are added to the `DocumentFragment`.
+	 */
+	[ Symbol.iterator ]() {
+		return this._children[ Symbol.iterator ]();
+	}
+
+	/**
+	 * The root of `DocumentFragment`. Returns itself. Added for compatibility reasons with {@link engine.treeModel.Element}.
+	 *
+	 * @readonly
+	 * @type {engine.treeModel.DocumentFragment}
+	 */
+	get root() {
+		return this;
+	}
+
+	/**
+	 * Returns path to the `DocumentFragment` This is always equal to empty array and is added for compatibility reasons
+	 * with {@link engine.treeModel.Element}.
+	 *
+	 * @returns {Array} The path.
+	 */
+	getPath() {
+		return [];
+	}
+
 	/**
 	 * Gets child at the given index.
 	 *

+ 1 - 1
packages/ckeditor5-engine/src/treemodel/element.js

@@ -135,7 +135,7 @@ export default class Element extends Node {
 	}
 
 	/**
-	 * Sets attribute on the element. If attribute with the same key already is set, it overwrites its values.
+	 * Sets attribute on the element. If attribute with the same key already is set, it overwrites its value.
 	 *
 	 * @param {String} key Key of attribute to set.
 	 * @param {*} value Attribute value.

+ 1 - 1
packages/ckeditor5-engine/src/treemodel/node.js

@@ -92,7 +92,7 @@ export default class Node {
 	 * The top parent for the node. If node has no parent it is the root itself.
 	 *
 	 * @readonly
-	 * @type {Number}
+	 * @type {engine.treeModel.Element}
 	 */
 	get root() {
 		let root = this;

+ 12 - 1
packages/ckeditor5-engine/src/treemodel/nodelist.js

@@ -136,7 +136,7 @@ export default class NodeList {
 				nodes = [ nodes ];
 			}
 
-			for ( let node of nodes ) {
+			for ( let node of getNodes( nodes ) ) {
 				let indexInNodes = this._nodes.length;
 				let mergedWithPrev = false;
 				let length = 1;
@@ -460,3 +460,14 @@ export default class NodeList {
  * @typedef {engine.treeModel.Node|engine.treeModel.Text|String|engine.treeModel.NodeList|engine.treeModel.DocumentFragment|Iterable}
  * engine.treeModel.NodeSet
  */
+
+// Helper function that "flattens" `engine.treeModel.DocumentFragment`.
+function* getNodes( nodes ) {
+	for ( let node of nodes ) {
+		if ( node instanceof DocumentFragment ) {
+			yield* node;
+		} else {
+			yield node;
+		}
+	}
+}

+ 7 - 1
packages/ckeditor5-engine/src/treemodel/operation/attributeoperation.js

@@ -71,7 +71,13 @@ export default class AttributeOperation extends Operation {
 	}
 
 	get type() {
-		return 'attribute';
+		if ( this.oldValue === null ) {
+			return 'addAttribute';
+		} else if ( this.newValue === null ) {
+			return 'removeAttribute';
+		} else {
+			return 'changeAttribute';
+		}
 	}
 
 	/**

+ 7 - 1
packages/ckeditor5-engine/src/treemodel/operation/rootattributeoperation.js

@@ -67,7 +67,13 @@ export default class RootAttributeOperation extends Operation {
 	}
 
 	get type() {
-		return 'rootattribute';
+		if ( this.oldValue === null ) {
+			return 'addRootAttribute';
+		} else if ( this.newValue === null ) {
+			return 'removeRootAttribute';
+		} else {
+			return 'changeRootAttribute';
+		}
 	}
 
 	/**

+ 1 - 0
packages/ckeditor5-engine/src/treemodel/position.js

@@ -6,6 +6,7 @@
 'use strict';
 
 import RootElement from './rootelement.js';
+import DocumentFragment from './documentfragment.js';
 import last from '../../utils/lib/lodash/last.js';
 import utils from '../../utils/utils.js';
 import CKEditorError from '../../utils/ckeditorerror.js';

+ 10 - 0
packages/ckeditor5-engine/src/treemodel/range.js

@@ -426,6 +426,16 @@ export default class Range {
 		return this.createFromParentsAndOffsets( element, 0, element, element.getChildCount() );
 	}
 
+	/**
+	 * Creates a range on given element only. The range starts just before the element and ends before the first child of the element.
+	 *
+	 * @param {engine.treeModel.Element} element Element on which range should be created.
+	 * @returns {engine.treeModel.Range} Created range.
+	 */
+	static createOnElement( element ) {
+		return this.createFromParentsAndOffsets( element.parent, element.getIndex(), element, 0 );
+	}
+
 	/**
 	 * Creates a new range spreading from specified position to the same position moved by given shift.
 	 *

+ 65 - 0
packages/ckeditor5-engine/src/treemodel/text.js

@@ -39,4 +39,69 @@ export default class Text {
 		 */
 		this._attrs = utils.toMap( attrs );
 	}
+
+	/**
+	 * Checks if the text has an attribute for given key.
+	 *
+	 * @param {String} key Key of attribute to check.
+	 * @returns {Boolean} `true` if attribute with given key is set on text, `false` otherwise.
+	 */
+	hasAttribute( key ) {
+		return this._attrs.has( key );
+	}
+
+	/**
+	 * Gets an attribute value for given key or undefined if that attribute is not set on text.
+	 *
+	 * @param {String} key Key of attribute to look for.
+	 * @returns {*} Attribute value or null.
+	 */
+	getAttribute( key ) {
+		return this._attrs.get( key );
+	}
+
+	/**
+	 * Returns iterator that iterates over this text attributes.
+	 *
+	 * @returns {Iterable.<*>}
+	 */
+	getAttributes() {
+		return this._attrs[ Symbol.iterator ]();
+	}
+
+	/**
+	 * Sets attribute on text. If attribute with the same key already is set, it overwrites its value.
+	 *
+	 * @param {String} key Key of attribute to set.
+	 * @param {*} value Attribute value.
+	 */
+	setAttribute( key, value ) {
+		this._attrs.set( key, value );
+	}
+
+	/**
+	 * Removes all attributes from text and sets given attributes.
+	 *
+	 * @param {Iterable|Object} attrs Iterable object containing attributes to be set. See {@link engine.treeModel.Text#getAttributes}.
+	 */
+	setAttributesTo( attrs ) {
+		this._attrs = utils.toMap( attrs );
+	}
+
+	/**
+	 * Removes an attribute with given key from text.
+	 *
+	 * @param {String} key Key of attribute to remove.
+	 * @returns {Boolean} `true` if the attribute was set on text, `false` otherwise.
+	 */
+	removeAttribute( key ) {
+		return this._attrs.delete( key );
+	}
+
+	/**
+	 * Removes all attributes from text.
+	 */
+	clearAttributes() {
+		this._attrs.clear();
+	}
 }

+ 3 - 3
packages/ckeditor5-engine/src/treemodel/textproxy.js

@@ -133,7 +133,7 @@ export default class TextProxy {
 	/**
 	 * Removes all attributes from the text fragment and sets given attributes.
 	 *
-	 * **Note:** Changing attributes of text fragment affects document state. This TextProxy instance properties
+	 * **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 {Iterable|Object} attrs Iterable object containing attributes to be set.
@@ -152,7 +152,7 @@ export default class TextProxy {
 	/**
 	 * Removes an attribute with given key from the text fragment.
 	 *
-	 * **Note:** Changing attributes of text fragment affects document state. This TextProxy instance properties
+	 * **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 remove.
@@ -164,7 +164,7 @@ export default class TextProxy {
 	/**
 	 * Removes all attributes from the text fragment.
 	 *
-	 * **Note:** Changing attributes of text fragment affects document state. This TextProxy instance properties
+	 * **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.
 	 */
 	clearAttributes() {

+ 8 - 1
packages/ckeditor5-engine/src/treeview/documentfragment.js

@@ -33,6 +33,13 @@ export default class DocumentFragment {
 		}
 	}
 
+	/**
+	 * Iterates over nodes added to this DocumentFragment.
+	 */
+	[ Symbol.iterator ]() {
+		return this._children[ Symbol.iterator ]();
+	}
+
 	/**
 	 * {@link engine.treeView.DocumentFragment#insertChildren Insert} a child node or a list of child nodes at the end
 	 * and sets the parent of these nodes to this fragment.
@@ -122,4 +129,4 @@ export default class DocumentFragment {
 
 		return this._children.splice( index, howMany );
 	}
-}
+}

+ 774 - 0
packages/ckeditor5-engine/tests/treecontroller/advanced-converters.js

@@ -0,0 +1,774 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treecontroller */
+
+'use strict';
+
+import ModelDocument from '/ckeditor5/engine/treemodel/document.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 ModelTextProxy from '/ckeditor5/engine/treemodel/textproxy.js';
+import ModelRange from '/ckeditor5/engine/treemodel/range.js';
+import ModelPosition from '/ckeditor5/engine/treemodel/position.js';
+import ModelWalker from '/ckeditor5/engine/treemodel/treewalker.js';
+
+import ViewDocumentFragment from '/ckeditor5/engine/treeview/documentfragment.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 ViewPosition from '/ckeditor5/engine/treeview/position.js';
+import ViewRange from '/ckeditor5/engine/treeview/range.js';
+
+import Mapper from '/ckeditor5/engine/treecontroller/mapper.js';
+import ModelConversionDispatcher from '/ckeditor5/engine/treecontroller/modelconversiondispatcher.js';
+import ViewConversionDispatcher from '/ckeditor5/engine/treecontroller/viewconversiondispatcher.js';
+
+import {
+	insertElement,
+	insertText,
+	setAttribute,
+	removeAttribute,
+	wrap,
+	unwrap,
+	move,
+	remove,
+	eventNameToConsumableType
+} from '/ckeditor5/engine/treecontroller/model-to-view-converters.js';
+import { convertToModelFragment, convertText } from '/ckeditor5/engine/treecontroller/view-to-model-converters.js';
+
+let modelDoc, modelRoot, viewRoot, mapper, writer, modelDispatcher, viewDispatcher;
+
+beforeEach( () => {
+	modelDoc = new ModelDocument();
+	modelRoot = modelDoc.createRoot( 'root' );
+	viewRoot = new ViewContainerElement( 'div' );
+
+	mapper = new Mapper();
+	mapper.bindElements( modelRoot, viewRoot );
+
+	writer = new ViewWriter();
+
+	modelDispatcher = new ModelConversionDispatcher( { mapper, writer } );
+	viewDispatcher = new ViewConversionDispatcher();
+
+	modelDispatcher.on( 'insert:$text', insertText() );
+	modelDispatcher.on( 'move', move() );
+	modelDispatcher.on( 'remove', remove() );
+	viewDispatcher.on( 'text', convertText() );
+	viewDispatcher.on( 'documentFragment', convertToModelFragment() );
+} );
+
+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;
+}
+
+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;
+}
+
+// Converter for custom `image` element that might have a `caption` element inside which changes
+// how the image is displayed in the view:
+//
+// Model:
+//
+// [image {src="foo.jpg" title="foo"}]
+//   └─ [caption]
+//       ├─ f
+//       ├─ o
+//       └─ o
+//
+// [image {src="bar.jpg" title="bar"}]
+//
+// View:
+//
+// <figure>
+//   ├─ <img src="foo.jpg" title="foo" />
+//   └─ <caption>
+//       └─ foo
+//
+// <img src="bar.jpg" title="bar" />
+describe( 'image with caption converters', () => {
+	beforeEach( () => {
+		const modelImageConverter = function( evt, data, consumable, conversionApi ) {
+			// First, consume the `image` element.
+			consumable.consume( data.item, 'insert' );
+
+			// Just create normal image element for the view.
+			// Maybe it will be "decorated" later.
+			const viewImage = new ViewContainerElement( 'img' );
+			const insertPosition = conversionApi.mapper.toViewPosition( data.range.start );
+
+			// Check if the `image` element has children.
+			if ( data.item.getChildCount() > 0 ) {
+				const modelCaption = data.item.getChild( 0 );
+
+				// `modelCaption` insertion change is consumed from consumable values.
+				// It will not be converted by other converters, but it's children (probably some text) will be.
+				// Through mapping, converters for text will know where to insert contents of `modelCaption`.
+				if ( consumable.consume( modelCaption, 'insert' ) ) {
+					const viewCaption = new ViewContainerElement( 'figcaption' );
+
+					const viewImageHolder = new ViewContainerElement( 'figure', null, [ viewImage, viewCaption ] );
+
+					conversionApi.mapper.bindElements( modelCaption, viewCaption );
+					conversionApi.mapper.bindElements( data.item, viewImageHolder );
+					conversionApi.writer.insert( insertPosition, viewImageHolder );
+				}
+			} else {
+				conversionApi.mapper.bindElements( data.item, viewImage );
+				conversionApi.writer.insert( insertPosition, viewImage );
+			}
+
+			evt.stop();
+		};
+
+		const modelImageAttributesConverter = function( evt, data, consumable, conversionApi ) {
+			if ( data.item.name != 'image' ) {
+				return;
+			}
+
+			let viewElement = conversionApi.mapper.toViewElement( data.item );
+
+			if ( viewElement.name == 'figure' ) {
+				viewElement = viewElement.getChild( 0 );
+			}
+
+			consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
+
+			if ( !data.attributeNewValue ) {
+				viewElement.removeAttribute( data.attributeKey );
+			} else {
+				viewElement.setAttribute( data.attributeKey, data.attributeNewValue );
+			}
+
+			evt.stop();
+		};
+
+		const viewFigureConverter = function( evt, data, consumable, conversionApi ) {
+			if ( consumable.consume( data.input, { name: true } ) ) {
+				const modelImage = conversionApi.convertItem( data.input.getChild( 0 ), consumable );
+				const modelCaption = conversionApi.convertItem( data.input.getChild( 1 ), consumable );
+
+				modelImage.appendChildren( modelCaption );
+
+				data.output = modelImage;
+			}
+		};
+
+		const viewImageConverter = function( evt, data, consumable ) {
+			if ( consumable.consume( data.input, { name: true } ) ) {
+				const modelImage = new ModelElement( 'image' );
+
+				for ( let attributeKey of data.input.getAttributeKeys() ) {
+					modelImage.setAttribute( attributeKey, data.input.getAttribute( attributeKey ) );
+				}
+
+				data.output = modelImage;
+			}
+		};
+
+		const viewFigcaptionConverter = function( evt, data, consumable, conversionApi ) {
+			if ( consumable.consume( data.input, { name: true } ) ) {
+				const modelCaption = new ModelElement( 'caption' );
+				const children = conversionApi.convertChildren( data.input, consumable );
+
+				modelCaption.appendChildren( children );
+
+				data.output = modelCaption;
+			}
+		};
+
+		modelDispatcher.on( 'insert:image', modelImageConverter );
+		modelDispatcher.on( 'addAttribute', modelImageAttributesConverter );
+		modelDispatcher.on( 'changeAttribute', modelImageAttributesConverter );
+		modelDispatcher.on( 'removeAttribute', modelImageAttributesConverter );
+		viewDispatcher.on( 'element:figure', viewFigureConverter );
+		viewDispatcher.on( 'element:img', viewImageConverter );
+		viewDispatcher.on( 'element:figcaption', viewFigcaptionConverter );
+	} );
+
+	it( 'should convert model images changes without caption to view', () => {
+		let modelElement = new ModelElement( 'image', { src: 'bar.jpg', title: 'bar' } );
+		modelRoot.appendChildren( modelElement );
+		modelDispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><img src="bar.jpg" title="bar"></img></div>' );
+
+		modelElement.setAttribute( 'src', 'new.jpg' );
+		modelElement.removeAttribute( 'title' );
+		modelDispatcher.convertAttribute( 'changeAttribute', ModelRange.createOnElement( modelElement ), 'src', 'bar.jpg', 'new.jpg' );
+		modelDispatcher.convertAttribute( 'removeAttribute', ModelRange.createOnElement( modelElement ), 'title', 'bar', null );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><img src="new.jpg"></img></div>' );
+	} );
+
+	it( 'should convert model images changes with caption to view', () => {
+		let modelElement = new ModelElement( 'image', { src: 'foo.jpg', title: 'foo' }, new ModelElement( 'caption', {}, 'foobar' ) );
+		modelRoot.appendChildren( modelElement );
+		modelDispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+		expect( viewToString( viewRoot ) ).to.equal(
+			'<div><figure><img src="foo.jpg" title="foo"></img><figcaption>foobar</figcaption></figure></div>'
+		);
+
+		modelElement.setAttribute( 'src', 'new.jpg' );
+		modelElement.removeAttribute( 'title' );
+		modelDispatcher.convertAttribute( 'changeAttribute', ModelRange.createOnElement( modelElement ), 'src', 'bar.jpg', 'new.jpg' );
+		modelDispatcher.convertAttribute( 'removeAttribute', ModelRange.createOnElement( modelElement ), 'title', 'bar', null );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><figure><img src="new.jpg"></img><figcaption>foobar</figcaption></figure></div>' );
+	} );
+
+	it( 'should convert view image to model', () => {
+		let viewElement = new ViewContainerElement( 'img', { src: 'bar.jpg', title: 'bar' } );
+		let modelElement = viewDispatcher.convert( viewElement );
+		// Attaching to tree so tree walker works fine in `modelToString`.
+		modelRoot.appendChildren( modelElement );
+
+		expect( modelToString( modelElement ) ).to.equal( '<image src="bar.jpg" title="bar"></image>' );
+	} );
+
+	it( 'should convert view figure to model', () => {
+		let viewElement = new ViewContainerElement(
+			'figure',
+			null,
+			[
+				new ViewContainerElement( 'img', { src: 'bar.jpg', title: 'bar' } ),
+				new ViewContainerElement( 'figcaption', null, new ViewText( 'foobar' ) )
+			]
+		);
+		let modelElement = viewDispatcher.convert( viewElement );
+		// Attaching to tree so tree walker works fine in `modelToString`.
+		modelRoot.appendChildren( modelElement );
+
+		expect( modelToString( modelElement ) ).to.equal( '<image src="bar.jpg" title="bar"><caption>foobar</caption></image>' );
+	} );
+} );
+
+// Converter overwrites default attribute converter for `linkHref` and `linkTitle` attribute is set on `quote` element.
+//
+// Model:
+//
+// [quote {linkHref='foo.html' linkTitle='Foo source'}]
+//   ├─ f
+//   ├─ o
+//   └─ o
+//
+// foo {linkHref='foo.html' linkTitle='Foo title'}
+//
+// View:
+//
+// <blockquote>
+//	 ├─ foo
+//	 └─ <a href="foo.html" title="Foo source">
+//	 	  └─ see source
+//
+// <a href="foo.html" title="Foo title">
+//	 └─ foo
+describe( 'custom attribute handling for given element', () => {
+	beforeEach( () => {
+		// NORMAL LINK MODEL TO VIEW CONVERTERS
+		modelDispatcher.on( 'addAttribute:linkHref', wrap( ( value ) => new ViewAttributeElement( 'a', { href: value } ) ), null, 99 );
+		modelDispatcher.on( 'addAttribute:linkTitle', wrap( ( value ) => new ViewAttributeElement( 'a', { title: value } ) ), null, 99 );
+
+		const changeLinkAttribute = function( elementCreator ) {
+			return ( evt, data, consumable, conversionApi ) => {
+				consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
+
+				const viewRange = conversionApi.mapper.toViewRange( data.range );
+				const viewOldA = elementCreator( data.attributeOldValue );
+				const viewNewA = elementCreator( data.attributeNewValue );
+
+				conversionApi.writer.unwrap( viewRange, viewOldA, evt.priority );
+				conversionApi.writer.wrap( viewRange, viewNewA, evt.priority );
+
+				evt.stop();
+			};
+		};
+
+		modelDispatcher.on(
+			'changeAttribute:linkHref',
+			changeLinkAttribute( ( value ) => new ViewAttributeElement( 'a', { href: value } ) ),
+			null,
+			99
+		);
+
+		modelDispatcher.on(
+			'changeAttribute:linkTitle',
+			changeLinkAttribute( ( value ) => new ViewAttributeElement( 'a', { title: value } ) ),
+			null,
+			99
+		);
+
+		modelDispatcher.on(
+			'removeAttribute:linkHref',
+			unwrap( ( value ) => new ViewAttributeElement( 'a', { href: value } ) ),
+			null,
+			99
+		);
+
+		modelDispatcher.on(
+			'removeAttribute:linkTitle',
+			unwrap( ( value ) => new ViewAttributeElement( 'a', { title: value } ) ),
+			null,
+			99
+		);
+
+		// NORMAL LINK VIEW TO MODEL CONVERTERS
+		viewDispatcher.on( 'element:a', ( evt, data, consumable, conversionApi ) => {
+			if ( consumable.consume( data.input, { name: true, attribute: 'href' } ) ) {
+				if ( !data.output ) {
+					data.output = conversionApi.convertChildren( data.input, consumable );
+				}
+
+				for ( let child of data.output ) {
+					child.setAttribute( 'linkHref', data.input.getAttribute( 'href' ) );
+				}
+			}
+		} );
+
+		viewDispatcher.on( 'element:a', ( evt, data, consumable, conversionApi ) => {
+			if ( consumable.consume( data.input, { attribute: 'title' } ) ) {
+				if ( !data.output ) {
+					data.output = conversionApi.convertChildren( data.input, consumable );
+				}
+
+				for ( let child of data.output ) {
+					child.setAttribute( 'linkTitle', data.input.getAttribute( 'title' ) );
+				}
+			}
+		} );
+
+		// QUOTE MODEL TO VIEW CONVERTERS
+		modelDispatcher.on( 'insert:quote', ( evt, data, consumable, conversionApi ) => {
+			consumable.consume( data.item, 'insert' );
+
+			const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
+			const viewElement = new ViewContainerElement( 'blockquote' );
+
+			conversionApi.mapper.bindElements( data.item, viewElement );
+			conversionApi.writer.insert( viewPosition, viewElement );
+
+			if ( consumable.consume( data.item, 'addAttribute:linkHref' ) ) {
+				const viewA = new ViewAttributeElement( 'a', { href: data.item.getAttribute( 'linkHref' ) }, new ViewText( 'see source' ) );
+
+				if ( consumable.consume( data.item, 'addAttribute:linkTitle' ) ) {
+					viewA.setAttribute( 'title', data.item.getAttribute( 'linkTitle' ) );
+				}
+
+				conversionApi.writer.insert( new ViewPosition( viewElement, viewElement.getChildCount() ), viewA );
+			}
+
+			evt.stop();
+		} );
+
+		const modelChangeLinkAttrQuoteConverter = function( evt, data, consumable, conversionApi ) {
+			let viewKey = data.attributeKey.substr( 4 ).toLowerCase();
+
+			consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
+
+			const viewElement = conversionApi.mapper.toViewElement( data.item );
+			const viewA = viewElement.getChild( viewElement.getChildCount() - 1 );
+
+			if ( data.attributeNewValue !== null ) {
+				viewA.setAttribute( viewKey, data.attributeNewValue );
+			} else {
+				viewA.removeAttribute( viewKey );
+			}
+
+			evt.stop();
+		};
+
+		modelDispatcher.on( 'changeAttribute:linkHref:quote', modelChangeLinkAttrQuoteConverter );
+		modelDispatcher.on( 'changeAttribute:linkTitle:quote', modelChangeLinkAttrQuoteConverter );
+
+		modelDispatcher.on( 'removeAttribute:linkHref:quote', ( evt, data, consumable, conversionApi ) => {
+			consumable.consume( data.item, eventNameToConsumableType( evt.name ) );
+
+			const viewElement = conversionApi.mapper.toViewElement( data.item );
+			const viewA = viewElement.getChild( viewElement.getChildCount() - 1 );
+			const aIndex = viewA.getIndex();
+
+			conversionApi.writer.remove( ViewRange.createFromParentsAndOffsets( viewElement, aIndex, viewElement, aIndex + 1 ) );
+
+			evt.stop();
+		} );
+		modelDispatcher.on( 'removeAttribute:linkTitle:quote', modelChangeLinkAttrQuoteConverter );
+
+
+		// QUOTE VIEW TO MODEL CONVERTERS
+		viewDispatcher.on( 'element:blockquote', ( evt, data, consumable, conversionApi ) => {
+			if ( consumable.consume( data.input, { name: true } ) ) {
+				data.output = new ModelElement( 'quote' );
+
+				const viewA = data.input.getChild( data.input.getChildCount() - 1 );
+
+				// Convert the special "a" first, before converting all children.
+				if ( viewA instanceof ViewElement && viewA.name == 'a' && consumable.consume( viewA, { name: true } ) ) {
+					if ( consumable.consume( viewA, { attribute: 'href' } ) ) {
+						data.output.setAttribute( 'linkHref', viewA.getAttribute( 'href' ) );
+					}
+
+					if ( consumable.consume( viewA, { attribute: 'title' } ) ) {
+						data.output.setAttribute( 'linkTitle', viewA.getAttribute( 'title' ) );
+					}
+				}
+
+				const children = conversionApi.convertChildren( data.input, consumable );
+				data.output.appendChildren( children );
+			}
+		} );
+	} );
+
+	it( 'should convert model text with linkHref and linkTitle to view', () => {
+		const modelText = new ModelText( 'foo', { linkHref: 'foo.html', linkTitle: 'Foo title' } );
+		modelRoot.appendChildren( modelText );
+
+		let range = ModelRange.createFromElement( modelRoot );
+
+		modelDispatcher.convertInsert( range );
+
+		// The expected value is different than in example or than what is really expected as it should be one link,
+		// but writer does not have merging feature yet.
+		expect( viewToString( viewRoot ) ).to.equal( '<div><a title="Foo title"><a href="foo.html">foo</a></a></div>' );
+
+		// Let's change link's attributes.
+		for ( let value of range ) {
+			value.item.setAttribute( 'linkHref', 'bar.html' );
+			value.item.setAttribute( 'linkTitle', 'Bar title' );
+		}
+		modelDispatcher.convertAttribute( 'changeAttribute', range, 'linkHref', 'foo.html', 'bar.html' );
+		modelDispatcher.convertAttribute( 'changeAttribute', range, 'linkTitle', 'Foo title', 'Bar title' );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><a title="Bar title"><a href="bar.html">foo</a></a></div>' );
+
+		// Let's remove a letter from the link.
+		const removed = modelRoot.removeChildren( 0, 1 );
+		modelDoc.graveyard.appendChildren( removed );
+		modelDispatcher.convertRemove(
+			ModelPosition.createFromParentAndOffset( modelRoot, 0 ),
+			ModelRange.createFromElement( modelDoc.graveyard )
+		);
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><a title="Bar title"><a href="bar.html">oo</a></a></div>' );
+
+		range = ModelRange.createFromElement( modelRoot );
+
+		// Let's remove just one attribute.
+		for ( let value of range ) {
+			value.item.removeAttribute( 'linkTitle' );
+		}
+		modelDispatcher.convertAttribute( 'removeAttribute', range, 'linkTitle', 'Bar title', null );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><a href="bar.html">oo</a></div>' );
+
+		// Let's remove the other attribute.
+		for ( let value of range ) {
+			value.item.removeAttribute( 'linkHref' );
+		}
+		modelDispatcher.convertAttribute( 'removeAttribute', range, 'linkHref', 'bar.html', null );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div>oo</div>' );
+	} );
+
+	it( 'should convert a view element to model', () => {
+		let viewElement = new ViewAttributeElement( 'a', { href: 'foo.html', title: 'Foo title' }, new ViewText( 'foo' ) );
+
+		let modelText = viewDispatcher.convert( viewElement )[ 0 ];
+
+		expect( modelText ).to.be.instanceof( ModelText );
+		expect( modelText.text ).to.equal( 'foo' );
+		expect( modelText.getAttribute( 'linkHref' ) ).to.equal( 'foo.html' );
+		expect( modelText.getAttribute( 'linkTitle' ) ).to.equal( 'Foo title' );
+	} );
+
+	it( 'should convert quote model element with linkHref and linkTitle attribute to view', () => {
+		let modelElement = new ModelElement( 'quote', { linkHref: 'foo.html', linkTitle: 'Foo source' }, 'foo' );
+		modelRoot.appendChildren( modelElement );
+		modelDispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+		let expected = '<div><blockquote>foo<a href="foo.html" title="Foo source">see source</a></blockquote></div>';
+		expect( viewToString( viewRoot ) ).to.equal( expected );
+
+		modelDispatcher.on( 'addAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
+		modelDispatcher.on( 'changeAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
+		modelDispatcher.on( 'removeAttribute:bold', unwrap( new ViewAttributeElement( 'strong' ) ) );
+
+		modelElement.appendChildren( new ModelText( 'bar', { bold: true } ) );
+		modelDispatcher.convertInsert( ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 6 ) );
+
+		expected = '<div><blockquote>foo<strong>bar</strong><a href="foo.html" title="Foo source">see source</a></blockquote></div>';
+		expect( viewToString( viewRoot ) ).to.equal( expected );
+
+		modelElement.removeAttribute( 'linkTitle' );
+		modelElement.setAttribute( 'linkHref', 'bar.html' );
+
+		modelDispatcher.convertAttribute( 'removeAttribute', ModelRange.createOnElement( modelElement ), 'linkTitle', 'Foo source', null );
+		modelDispatcher.convertAttribute( 'changeAttribute', ModelRange.createOnElement( modelElement ), 'linkHref', 'foo.html', 'bar.html' );
+
+		expected = '<div><blockquote>foo<strong>bar</strong><a href="bar.html">see source</a></blockquote></div>';
+		expect( viewToString( viewRoot ) ).to.equal( expected );
+
+		modelElement.removeAttribute( 'linkHref' );
+		modelDispatcher.convertAttribute( 'removeAttribute', ModelRange.createFromElement( modelRoot ), 'linkHref', 'bar.html', null );
+
+		expected = '<div><blockquote>foo<strong>bar</strong></blockquote></div>';
+		expect( viewToString( viewRoot ) ).to.equal( expected );
+	} );
+
+	it( 'should convert view blockquote with a element to model', () => {
+		let viewElement = new ViewContainerElement(
+			'blockquote',
+			null,
+			[
+				new ViewText( 'foo' ),
+				new ViewAttributeElement(
+					'a',
+					{
+						href: 'foo.html',
+						title:'Foo source'
+					},
+					new ViewText( 'see source' )
+				)
+			]
+		);
+
+		let modelElement = viewDispatcher.convert( viewElement );
+		modelRoot.appendChildren( modelElement );
+
+		expect( modelToString( modelElement ) ).to.equal( '<quote linkHref="foo.html" linkTitle="Foo source">foo</quote>' );
+	} );
+} );
+
+// Default view converter for tables that will convert table structure into paragraphs if tables are not supported.
+// TRs are supposed to become paragraphs and TDs content should be separated using space.
+it( 'default table view to model converter', () => {
+	viewDispatcher.on( 'element:a', ( evt, data, consumable, conversionApi ) => {
+		if ( consumable.consume( data.input, { name: true, attribute: 'href' } ) ) {
+			if ( !data.output ) {
+				data.output = conversionApi.convertChildren( data.input, consumable );
+			}
+
+			for ( let child of data.output ) {
+				child.setAttribute( 'linkHref', data.input.getAttribute( 'href' ) );
+			}
+		}
+	} );
+
+	viewDispatcher.on( 'element:tr', ( evt, data, consumable, conversionApi ) => {
+		if ( consumable.consume( data.input, { name: true } ) ) {
+			data.output = new ModelElement( 'paragraph' );
+			const children = conversionApi.convertChildren( data.input, consumable );
+
+			for ( let i = 1; i < children.length; i++ ) {
+				if ( children[ i ] instanceof ModelText && children[ i - 1 ] instanceof ModelText ) {
+					children[ i - 1 ].text += ' ';
+				}
+			}
+
+			data.output.appendChildren( children );
+		}
+	} );
+
+	viewDispatcher.on( 'element:table', ( evt, data, consumable, conversionApi ) => {
+		if ( consumable.consume( data.input, { name: true } ) ) {
+			data.output = conversionApi.convertChildren( data.input, consumable );
+		}
+	} );
+
+	viewDispatcher.on( 'element:td', ( evt, data, consumable, conversionApi ) => {
+		if ( consumable.consume( data.input, { name: true } ) ) {
+			data.output = conversionApi.convertChildren( data.input, consumable );
+		}
+	} );
+
+	let viewTable = new ViewContainerElement( 'table', null, [
+		new ViewContainerElement( 'tr', null, [
+			new ViewContainerElement( 'td', null, new ViewText( 'foo' ) ),
+			new ViewContainerElement( 'td', null, new ViewAttributeElement( 'a', { href: 'bar.html' }, new ViewText( 'bar' ) ) )
+		] ),
+		new ViewContainerElement( 'tr', null, [
+			new ViewContainerElement( 'td' ),
+			new ViewContainerElement( 'td', null, new ViewText( 'abc' ) )
+		] )
+	] );
+
+	let model = viewDispatcher.convert( viewTable );
+	let modelFragment = new ModelDocumentFragment( model );
+
+	expect( modelToString( modelFragment ) ).to.equal( '<paragraph>foo <$text linkHref="bar.html">bar</$text></paragraph><paragraph>abc</paragraph>' );
+} );
+
+// Model converter that converts any non-converted elements and attributes into view elements and attributes.
+// View converter that converts any non-converted elements and attributes into model elements and attributes.
+describe( 'universal converter', () => {
+	beforeEach( () => {
+		// "Universal" converters
+		modelDispatcher.on( 'insert', insertElement( ( data ) => new ViewContainerElement( data.item.name ) ), null, 99 );
+		modelDispatcher.on( 'addAttribute', setAttribute(), null, 99 );
+		modelDispatcher.on( 'changeAttribute', setAttribute(), null, 99 );
+		modelDispatcher.on( 'removeAttribute', removeAttribute(), null, 99 );
+
+		viewDispatcher.on( 'element', ( evt, data, consumable, conversionApi ) => {
+			if ( consumable.consume( data.input, { name: true } ) ) {
+				data.output = new ModelElement( data.input.name );
+
+				for ( let key of data.input.getAttributeKeys() ) {
+					if ( consumable.consume( data.input, { attribute: key } ) ) {
+						data.output.setAttribute( key, data.input.getAttribute( key ) );
+					}
+				}
+
+				data.output.appendChildren( conversionApi.convertChildren( data.input, consumable ) );
+			}
+		}, null, 99 );
+
+		// "Real" converters -- added with sooner priority. Should overwrite the "universal" converters.
+		modelDispatcher.on( 'insert:image', insertElement( new ViewContainerElement( 'img' ) ) );
+		modelDispatcher.on( 'addAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
+		modelDispatcher.on( 'changeAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
+		modelDispatcher.on( 'removeAttribute:bold', unwrap( new ViewAttributeElement( 'strong' ) ) );
+
+		viewDispatcher.on( 'element:img', ( evt, data, consumable ) => {
+			if ( consumable.consume( data.input, { name: true } ) ) {
+				const modelImage = new ModelElement( 'image' );
+
+				for ( let attributeKey of data.input.getAttributeKeys() ) {
+					modelImage.setAttribute( attributeKey, data.input.getAttribute( attributeKey ) );
+				}
+
+				data.output = modelImage;
+			}
+		} );
+		viewDispatcher.on( 'element:strong', ( evt, data, consumable, conversionApi ) => {
+			if ( consumable.consume( data.input, { name: true } ) ) {
+				if ( !data.output ) {
+					data.output = conversionApi.convertChildren( data.input, consumable );
+				}
+
+				for ( let child of data.output ) {
+					child.setAttribute( 'bold', true );
+				}
+			}
+		} );
+	} );
+
+	it( 'should convert model to view', () => {
+		let modelElement = new ModelElement( 'table', { cellpadding: 5, cellspacing: 5 }, [
+			new ModelElement( 'tr', null, [
+				new ModelElement( 'td', null, [
+					'foo ',
+					new ModelText( 'abc', { bold: true } ),
+					' bar'
+				] ),
+				new ModelElement( 'td', null, [
+					new ModelElement( 'foo', { foo: 'bar' }, 'bar' )
+				] )
+			] )
+		] );
+
+		modelRoot.appendChildren( modelElement );
+		modelDispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+		expect( viewToString( viewRoot ) ).to.equal(
+			'<div>' +
+				'<table cellpadding="5" cellspacing="5">' +
+					'<tr>' +
+						'<td>foo <strong>abc</strong> bar</td>' +
+						'<td><foo foo="bar">bar</foo></td>' +
+					'</tr>' +
+				'</table>' +
+			'</div>'
+		);
+	} );
+
+	it( 'should convert view to model', () => {
+		let viewElement = new ViewContainerElement( 'table', { cellpadding: 5, cellspacing: 5 }, [
+			new ViewContainerElement( 'tr', null, [
+				new ViewContainerElement( 'td', null, [
+					new ViewText( 'foo ' ),
+					new ViewAttributeElement( 'strong', null, new ViewText( 'abc' ) ),
+					new ViewText( ' bar' )
+				] ),
+				new ViewContainerElement( 'td', null, new ViewContainerElement( 'foo', { foo: 'bar' }, new ViewText( 'bar' ) ) )
+			] )
+		] );
+
+		let modelElement = viewDispatcher.convert( viewElement );
+		modelRoot.appendChildren( modelElement );
+
+		expect( modelToString( modelElement ) ).to.equal(
+			'<table cellpadding="5" cellspacing="5">' +
+				'<tr>' +
+					'<td>foo <$text bold="true">abc</$text> bar</td>' +
+					'<td><foo foo="bar">bar</foo></td>' +
+				'</tr>' +
+			'</table>'
+		);
+	} );
+} );

+ 24 - 0
packages/ckeditor5-engine/tests/treecontroller/mapper.js

@@ -13,10 +13,12 @@ import ModelElement from '/ckeditor5/engine/treemodel/element.js';
 import ModelRootElement from '/ckeditor5/engine/treemodel/rootelement.js';
 import ModelText from '/ckeditor5/engine/treemodel/text.js';
 import ModelPosition from '/ckeditor5/engine/treemodel/position.js';
+import ModelRange from '/ckeditor5/engine/treemodel/range.js';
 
 import ViewElement from '/ckeditor5/engine/treeview/element.js';
 import ViewText from '/ckeditor5/engine/treeview/text.js';
 import ViewPosition from '/ckeditor5/engine/treeview/position.js';
+import ViewRange from '/ckeditor5/engine/treeview/range.js';
 
 describe( 'Mapper', () => {
 	let modelDiv, modelP, modelImg;
@@ -211,6 +213,28 @@ describe( 'Mapper', () => {
 		it( 'should transform modelP 10', () => createToViewTest( modelP, 10, viewU, 2 ) );
 		it( 'should transform modelP 11', () => createToViewTest( modelP, 11, viewP, 5 ) );
 	} );
+
+	describe( 'toModelRange', () => {
+		it( 'should transform range', () => {
+			const viewRange = ViewRange.createFromParentsAndOffsets( viewDiv, 0, viewTextFOO, 2 );
+			const modelRange = mapper.toModelRange( viewRange );
+			expect( modelRange.start.parent ).to.equal( modelDiv );
+			expect( modelRange.start.offset ).to.equal( 0 );
+			expect( modelRange.end.parent ).to.equal( modelP );
+			expect( modelRange.end.offset ).to.equal( 3 );
+		} );
+	} );
+
+	describe( 'toViewRange', () => {
+		it( 'should transform range', () => {
+			const modelRange = ModelRange.createFromParentsAndOffsets( modelDiv, 0, modelP, 3 );
+			const viewRange = mapper.toViewRange( modelRange );
+			expect( viewRange.start.parent ).to.equal( viewDiv );
+			expect( viewRange.start.offset ).to.equal( 0 );
+			expect( viewRange.end.parent ).to.equal( viewTextFOO );
+			expect( viewRange.end.offset ).to.equal( 2 );
+		} );
+	} );
 } );
 
 describe( 'Mapper for widget', () => {

+ 298 - 0
packages/ckeditor5-engine/tests/treecontroller/model-to-view-converters.js

@@ -0,0 +1,298 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treecontroller */
+
+'use strict';
+
+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 ModelPosition from '/ckeditor5/engine/treemodel/position.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 {
+	insertElement,
+	insertText,
+	setAttribute,
+	removeAttribute,
+	wrap,
+	unwrap,
+	move,
+	remove
+} from '/ckeditor5/engine/treecontroller/model-to-view-converters.js';
+
+let dispatcher, modelDoc, modelRoot, mapper, viewRoot, writer;
+
+beforeEach( () => {
+	modelDoc = new ModelDocument();
+	modelRoot = modelDoc.createRoot( 'root' );
+	viewRoot = new ViewContainerElement( 'div' );
+
+	mapper = new Mapper();
+	mapper.bindElements( modelRoot, viewRoot );
+
+	writer = new ViewWriter();
+
+	dispatcher = new ModelConversionDispatcher( { mapper, writer } );
+} );
+
+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( 'insertText', () => {
+	it( 'should convert text insertion in model to view text', () => {
+		modelRoot.appendChildren( 'foobar' );
+		dispatcher.on( 'insert:$text', insertText() );
+
+		dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div>foobar</div>' );
+	} );
+} );
+
+describe( 'insertElement', () => {
+	it( 'should convert element insertion in model to and map positions for future converting', () => {
+		const modelElement = new ModelElement( 'paragraph', null, 'foobar' );
+		const viewElement = new ViewContainerElement( 'p' );
+
+		modelRoot.appendChildren( modelElement );
+		dispatcher.on( 'insert:paragraph', insertElement( viewElement ) );
+		dispatcher.on( 'insert:$text', insertText() );
+
+		dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+	} );
+
+	it( 'should take view element function generator as a parameter', () => {
+		const elementGenerator = ( data, consumable ) => {
+			if ( consumable.consume( data.item, 'addAttribute:nice' ) ) {
+				return new ViewContainerElement( 'div' );
+			} else {
+				return new ViewContainerElement( 'p' );
+			}
+		};
+		const niceP = new ModelElement( 'myParagraph', { nice: true }, 'foo' );
+		const badP = new ModelElement( 'myParagraph', null, 'bar' );
+
+		modelRoot.appendChildren( [ niceP, badP ] );
+
+		dispatcher.on( 'insert:myParagraph', insertElement( elementGenerator ) );
+		dispatcher.on( 'insert:$text', insertText() );
+
+		dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div><p>bar</p></div>' );
+	} );
+} );
+
+describe( 'setAttribute/removeAttribute', () => {
+	it( 'should convert attribute insert/change/remove on a model node', () => {
+		const modelElement = new ModelElement( 'paragraph', { class: 'foo' }, 'foobar' );
+		const viewElement = new ViewContainerElement( 'p' );
+
+		modelRoot.appendChildren( modelElement );
+		dispatcher.on( 'insert:paragraph', insertElement( viewElement ) );
+		dispatcher.on( 'insert:$text', insertText() );
+		dispatcher.on( 'addAttribute:class', setAttribute() );
+		dispatcher.on( 'changeAttribute:class', setAttribute() );
+		dispatcher.on( 'removeAttribute:class', removeAttribute() );
+
+		dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><p class="foo">foobar</p></div>' );
+
+		modelElement.setAttribute( 'class', 'bar' );
+		dispatcher.convertAttribute( 'changeAttribute', ModelRange.createOnElement( modelElement ), 'class', 'foo', 'bar' );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><p class="bar">foobar</p></div>' );
+
+		modelElement.removeAttribute( 'class' );
+		dispatcher.convertAttribute( 'removeAttribute', ModelRange.createOnElement( modelElement ), 'class', 'bar', null );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+	} );
+
+	it( 'should convert insert/change/remove with attribute generating function as a parameter', () => {
+		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 ) {
+				value += ' ' + 'fix-content';
+			}
+
+			return { key, value };
+		};
+
+		modelRoot.appendChildren( [ modelParagraph, modelDiv ] );
+		dispatcher.on( 'insert:paragraph', insertElement( new ViewContainerElement( 'p' ) ) );
+		dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
+		dispatcher.on( 'insert:$text', insertText() );
+		dispatcher.on( 'addAttribute:theme', setAttribute( themeConverter ) );
+		dispatcher.on( 'changeAttribute:theme', setAttribute( themeConverter ) );
+		dispatcher.on( 'removeAttribute:theme', removeAttribute( themeConverter ) );
+
+		dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><p class="nice fix-content">foobar</p><div class="nice"></div></div>' );
+
+		modelParagraph.setAttribute( 'theme', 'awesome' );
+		dispatcher.convertAttribute( 'changeAttribute', ModelRange.createOnElement( modelParagraph ), 'theme', 'nice', 'awesome' );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><p class="awesome fix-content">foobar</p><div class="nice"></div></div>' );
+
+		modelParagraph.removeAttribute( 'theme' );
+		dispatcher.convertAttribute( 'removeAttribute', ModelRange.createOnElement( modelParagraph ), 'theme', 'awesome', null );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p><div class="nice"></div></div>' );
+	} );
+} );
+
+describe( 'wrap/unwrap', () => {
+	it( 'should convert insert/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' );
+
+		modelRoot.appendChildren( modelElement );
+		dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
+		dispatcher.on( 'insert:$text', insertText() );
+		dispatcher.on( 'addAttribute:bold', wrap( viewB ) );
+		dispatcher.on( 'removeAttribute:bold', unwrap( viewB ) );
+
+		dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
+
+		for ( let value of ModelRange.createFromElement( modelElement ) ) {
+			value.item.removeAttribute( 'bold' );
+		}
+
+		dispatcher.convertAttribute( 'removeAttribute', ModelRange.createFromElement( modelElement ), 'bold', true, null );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+	} );
+
+	it( 'should convert insert/remove of attribute in model with wrapping element generating function as a parameter', () => {
+		const modelElement = new ModelElement( 'paragraph', null, new ModelText( 'foobar', { style: 'bold' } ) );
+		const viewP = new ViewContainerElement( 'p' );
+
+		const elementGenerator = ( value ) => {
+			if ( value == 'bold' ) {
+				return new ViewAttributeElement( 'b' );
+			}
+		};
+
+		modelRoot.appendChildren( modelElement );
+		dispatcher.on( 'insert:paragraph', insertElement( viewP ) );
+		dispatcher.on( 'insert:$text', insertText() );
+		dispatcher.on( 'addAttribute:style', wrap( elementGenerator ) );
+		dispatcher.on( 'removeAttribute:style', unwrap( elementGenerator ) );
+
+		dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
+
+		for ( let value of ModelRange.createFromElement( modelElement ) ) {
+			value.item.removeAttribute( 'style' );
+		}
+
+		dispatcher.convertAttribute( 'removeAttribute', ModelRange.createFromElement( modelElement ), 'style', 'bold', null );
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+	} );
+} );
+
+describe( 'move', () => {
+	it( 'should move items in view accordingly to changes in model', () => {
+		const modelDivA = new ModelElement( 'div', null, [ 'foo', new ModelElement( 'image' ) ] );
+		const modelDivB = new ModelElement( 'div', null, [ 'xxyy' ] );
+
+		modelRoot.appendChildren( [ modelDivA, modelDivB ] );
+		dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
+		dispatcher.on( 'insert:image', insertElement( new ViewContainerElement( 'img' ) ) );
+		dispatcher.on( 'insert:$text', insertText() );
+		dispatcher.on( 'move', move() );
+
+		dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+		const removedNodes = modelDivA.removeChildren( 2, 2 );
+		modelDivB.insertChildren( 2, removedNodes );
+
+		dispatcher.convertMove(
+			ModelPosition.createFromParentAndOffset( modelDivA, 2 ),
+			ModelRange.createFromParentsAndOffsets( modelDivB, 2, modelDivB, 4 )
+		);
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><div>fo</div><div>xxo<img></img>yy</div></div>' );
+	} );
+} );
+
+describe( 'remove', () => {
+	it( 'should remove items from view accordingly to changes in model', () => {
+		const modelDiv = new ModelElement( 'div', null, [ 'foo', new ModelElement( 'image' ) ] );
+
+		modelRoot.appendChildren( modelDiv );
+		dispatcher.on( 'insert:div', insertElement( new ViewContainerElement( 'div' ) ) );
+		dispatcher.on( 'insert:image', insertElement( new ViewContainerElement( 'img' ) ) );
+		dispatcher.on( 'insert:$text', insertText() );
+		dispatcher.on( 'remove', remove() );
+
+		dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+
+		const removedNodes = modelDiv.removeChildren( 2, 2 );
+		modelDoc.graveyard.insertChildren( 2, removedNodes );
+
+		dispatcher.convertRemove(
+			ModelPosition.createFromParentAndOffset( modelDiv, 2 ),
+			ModelRange.createFromParentsAndOffsets( modelDoc.graveyard, 0, modelDoc.graveyard, 2 )
+		);
+
+		expect( viewToString( viewRoot ) ).to.equal( '<div><div>fo</div></div>' );
+	} );
+} );

+ 144 - 0
packages/ckeditor5-engine/tests/treecontroller/modelconsumable.js

@@ -0,0 +1,144 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treecontroller */
+
+'use strict';
+
+import ModelConsumable from '/ckeditor5/engine/treecontroller/modelconsumable.js';
+import ModelElement from '/ckeditor5/engine/treemodel/element.js';
+import ModelTextProxy from '/ckeditor5/engine/treemodel/textproxy.js';
+
+describe( 'ModelConsumable', () => {
+	let modelConsumable, modelElement;
+
+	beforeEach( () => {
+		modelConsumable = new ModelConsumable();
+		modelElement = new ModelElement( 'paragraph', null, 'foobar' );
+	} );
+
+	describe( 'add', () => {
+		it( 'should add consumable value from given element of given type', () => {
+			modelConsumable.add( modelElement, 'type' );
+
+			expect( modelConsumable.test( modelElement, 'type' ) ).to.be.true;
+		} );
+
+		it( 'should store multiple values for one element', () => {
+			modelConsumable.add( modelElement, 'typeA' );
+			modelConsumable.add( modelElement, 'typeB' );
+			modelConsumable.add( modelElement, 'typeC' );
+
+			expect( modelConsumable.test( modelElement, 'typeA' ) ).to.be.true;
+			expect( modelConsumable.test( modelElement, 'typeB' ) ).to.be.true;
+			expect( modelConsumable.test( modelElement, 'typeC' ) ).to.be.true;
+		} );
+
+		it( 'should correctly add text proxy instances', () => {
+			const modelTextProxy = new ModelTextProxy( modelElement.getChild( 1 ), 3 );
+
+			modelConsumable.add( modelTextProxy, 'type' );
+
+			expect( modelConsumable.test( modelTextProxy, 'type' ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'consume', () => {
+		it( 'should remove consumable value of given type for given element and return true', () => {
+			modelConsumable.add( modelElement, 'type' );
+
+			const result = modelConsumable.consume( modelElement, 'type' );
+
+			expect( result ).to.be.true;
+			expect( modelConsumable.test( modelElement, 'type' ) ).to.be.false;
+		} );
+
+		it( 'should return false if given type of consumable was not added for given element', () => {
+			const result = modelConsumable.consume( modelElement, 'type' );
+
+			expect( result ).to.be.false;
+		} );
+
+		it( 'should correctly consume text proxy instances', () => {
+			const proxy1To4 = new ModelTextProxy( modelElement.getChild( 1 ), 3 );
+			const proxy1To5 = new ModelTextProxy( modelElement.getChild( 1 ), 4 );
+			const proxyDiv1To4 = new ModelTextProxy( new ModelElement( 'div', null, 'abcdef' ).getChild( 1 ), 3 );
+
+			modelConsumable.add( proxy1To4, 'type' );
+
+			expect( modelConsumable.consume( proxy1To5, 'type' ) ).to.be.false;
+			expect( modelConsumable.consume( proxyDiv1To4, 'type' ) ).to.be.false;
+
+			const equalProxy1To4 = new ModelTextProxy( modelElement.getChild( 1 ), 3 );
+			const result = modelConsumable.consume( equalProxy1To4, 'type' );
+
+			expect( result ).to.be.true;
+			expect( modelConsumable.test( proxy1To4, 'type' ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'revert', () => {
+		it( 'should re-add consumable value if it was already consumed and return true', () => {
+			modelConsumable.add( modelElement, 'type' );
+			modelConsumable.consume( modelElement, 'type' );
+
+			const result = modelConsumable.revert( modelElement, 'type' );
+
+			expect( result ).to.be.true;
+			expect( modelConsumable.test( modelElement, 'type' ) ).to.be.true;
+		} );
+
+		it( 'should return false if consumable value has not been yet consumed', () => {
+			modelConsumable.add( modelElement, 'type' );
+
+			const result = modelConsumable.revert( modelElement, 'type' );
+
+			expect( result ).to.be.false;
+		} );
+
+		it( 'should return null if consumable value of given type has never been added for given element', () => {
+			const result = modelConsumable.revert( modelElement, 'type' );
+
+			expect( result ).to.be.null;
+		} );
+
+		it( 'should correctly revert text proxy instances', () => {
+			const modelTextProxy = new ModelTextProxy( modelElement.getChild( 1 ), 3 );
+
+			modelConsumable.add( modelTextProxy, 'type' );
+			modelConsumable.consume( modelTextProxy, 'type' );
+
+			const result = modelConsumable.revert( modelTextProxy, 'type' );
+
+			expect( result ).to.be.true;
+			expect( modelConsumable.test( modelTextProxy, 'type' ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'test', () => {
+		it( 'should return null if consumable value of given type has never been added for given element', () => {
+			expect( modelConsumable.test( modelElement, 'typeA' ) ).to.be.null;
+
+			modelConsumable.add( modelElement, 'typeA' );
+
+			expect( modelConsumable.test( modelElement, 'typeB' ) ).to.be.null;
+		} );
+
+		it( 'should correctly test for text proxy instances', () => {
+			const proxy1To4 = new ModelTextProxy( modelElement.getChild( 1 ), 3 );
+			const proxy1To5 = new ModelTextProxy( modelElement.getChild( 1 ), 4 );
+			const proxyDiv1To4 = new ModelTextProxy( new ModelElement( 'div', null, 'abcdef' ).getChild( 1 ), 3 );
+			const equalProxy1To4 = new ModelTextProxy( modelElement.getChild( 1 ), 3 );
+
+			modelConsumable.add( proxy1To4, 'type' );
+
+			expect( modelConsumable.test( proxy1To4, 'type' ) ).to.be.true;
+			expect( modelConsumable.test( proxy1To4, 'otherType' ) ).to.be.null;
+			expect( modelConsumable.test( proxy1To5, 'type' ) ).to.be.null;
+			expect( modelConsumable.test( proxyDiv1To4, 'type' ) ).to.be.null;
+			expect( modelConsumable.test( equalProxy1To4, 'type' ) ).to.be.true;
+		} );
+	} );
+} );

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

@@ -0,0 +1,422 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treecontroller */
+
+'use strict';
+
+import ModelConversionDispatcher from '/ckeditor5/engine/treecontroller/modelconversiondispatcher.js';
+import ModelDocument from '/ckeditor5/engine/treemodel/document.js';
+import ModelText from '/ckeditor5/engine/treemodel/text.js';
+import ModelElement from '/ckeditor5/engine/treemodel/element.js';
+import ModelRange from '/ckeditor5/engine/treemodel/range.js';
+import ModelPosition from '/ckeditor5/engine/treemodel/position.js';
+import RemoveOperation from '/ckeditor5/engine/treemodel/operation/removeoperation.js';
+
+describe( 'ModelConversionDispatcher', () => {
+	let dispatcher, doc, root;
+
+	beforeEach( () => {
+		dispatcher = new ModelConversionDispatcher();
+		doc = new ModelDocument();
+		root = doc.createRoot( 'root' );
+	} );
+
+	describe( 'constructor', () => {
+		it( 'should create ModelConversionDispatcher with given api', () => {
+			const apiObj = {};
+			const dispatcher = new ModelConversionDispatcher( { apiObj } );
+
+			expect( dispatcher.conversionApi.apiObj ).to.equal( apiObj );
+		} );
+	} );
+
+	describe( 'convertChange', () => {
+		// We will do integration tests here. Unit tests will be done for methods that are used
+		// by `convertChange` internally. This way we will have two kinds of tests.
+
+		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' ] );
+
+			imagePos = ModelPosition.createBefore( image );
+
+			dispatcher.listenTo( doc, 'change', ( evt, type, changes ) => {
+				dispatcher.convertChange( type, changes );
+			} );
+		} );
+
+		it( 'should fire insert and addAttribute callbacks for insertion changes', () => {
+			const cbInsertText = sinon.spy();
+			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 );
+
+			const insertedText = new ModelText( 'foo', { key: 'value' } );
+			doc.batch().insert( ModelPosition.createFromParentAndOffset( root, 0 ), insertedText );
+
+			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', () => {
+			image.setAttribute( 'key', 'value' );
+
+			// We will just create reinsert operation by reverting remove operation
+			// because creating reinsert change is tricky and not available through batch API.
+			const removeOperation = new RemoveOperation( imagePos, 1, 0 );
+
+			// Let's apply remove operation so reinsert operation won't break.
+			doc.applyOperation( removeOperation );
+
+			const cbInsertText = sinon.spy();
+			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 );
+
+			doc.applyOperation( removeOperation.getReversed() );
+
+			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', () => {
+			const cbMove = sinon.spy();
+
+			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', () => {
+			const cbRemove = sinon.spy();
+
+			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', () => {
+			const cbAddText = sinon.spy();
+			const cbAddImage = sinon.spy();
+
+			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.
+			expect( cbAddText.called ).to.be.false;
+			expect( cbAddImage.calledOnce ).to.be.true;
+
+			doc.batch().setAttr( 'key', 'value', ModelRange.createFromParentsAndOffsets( root, 3, root, 4 ) );
+
+			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', () => {
+			const cbChangeText = sinon.spy();
+			const cbChangeImage = sinon.spy();
+
+			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.
+			expect( cbChangeText.called ).to.be.false;
+			expect( cbChangeImage.calledOnce ).to.be.true;
+
+			const range = ModelRange.createFromParentsAndOffsets( root, 3, root, 4 );
+			doc.batch().setAttr( 'key', 'value', range ).setAttr( 'key', 'newValue', range );
+
+			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', () => {
+			const cbRemoveText = sinon.spy();
+			const cbRemoveImage = sinon.spy();
+
+			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.
+			expect( cbRemoveText.called ).to.be.false;
+			expect( cbRemoveImage.calledOnce ).to.be.true;
+
+			const range = ModelRange.createFromParentsAndOffsets( root, 3, root, 4 );
+			doc.batch().setAttr( 'key', 'value', range ).removeAttr( 'key', range );
+
+			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', () => {
+			sinon.spy( dispatcher, 'fire' );
+
+			dispatcher.convertChange( 'unknown', { foo: 'bar' } );
+
+			expect( dispatcher.fire.called ).to.be.false;
+		} );
+	} );
+
+	describe( 'convertInsert', () => {
+		it( 'should fire event with correct parameters for every item in passed range', () => {
+			root.appendChildren( [
+				new ModelText( 'foo', { bold: true } ),
+				new ModelElement( 'image' ),
+				'bar',
+				new ModelElement( 'paragraph', { class: 'nice' }, new ModelText( 'xx', { italic: true } ) )
+			] );
+
+			const range = ModelRange.createFromElement( root );
+			const loggedEvents = [];
+
+			// We will check everything connected with insert event:
+			dispatcher.on( 'insert', ( evt, data, consumable ) => {
+				// Check if the item is correct.
+				const itemId = data.item.name ? data.item.name : '$text:' + data.item.text;
+				// Check if the range is correct.
+				const log = 'insert:' + itemId + ':' + data.range.start.path + ':' + data.range.end.path;
+
+				loggedEvents.push( log );
+
+				// Check if the event name is correct.
+				expect( evt.name ).to.equal( 'insert:' + ( data.item.name || '$text' ) );
+				// Check if model consumable is correct.
+				expect( consumable.consume( data.item, 'insert' ) ).to.be.true;
+			} );
+
+			// Same here.
+			dispatcher.on( 'addAttribute', ( evt, data, consumable ) => {
+				const itemId = data.item.name ? data.item.name : '$text:' + data.item.text;
+				const key = data.attributeKey;
+				const value = data.attributeNewValue;
+				const log = 'addAttribute:' + key + ':' + value + ':' + itemId + ':' + data.range.start.path + ':' + data.range.end.path;
+
+				loggedEvents.push( log );
+
+				expect( evt.name ).to.equal( 'addAttribute:' + key + ':' + ( data.item.name || '$text' ) );
+				expect( consumable.consume( data.item, 'addAttribute:' + key ) ).to.be.true;
+			} );
+
+			dispatcher.convertInsert( range );
+
+			// Check the data passed to called events and the order of them.
+			expect( loggedEvents ).to.deep.equal( [
+				'insert:$text:foo:0:3',
+				'addAttribute:bold:true:$text:foo:0:3',
+				'insert:image:3:4',
+				'insert:$text:bar:4:7',
+				'insert:paragraph:7:8',
+				'addAttribute:class:nice:paragraph:7:8',
+				'insert:$text:xx:7,0:7,2',
+				'addAttribute:italic:true:$text:xx:7,0:7,2'
+			] );
+		} );
+
+		it( 'should not fire events for already consumed parts of model', () => {
+			root.appendChildren( [
+				new ModelElement( 'image', { src: 'foo.jpg', title: 'bar', bold: true }, new ModelElement( 'caption', {}, 'title' ) )
+			] );
+
+			sinon.spy( dispatcher, 'fire' );
+
+			dispatcher.on( 'insert:image', ( evt, data, consumable ) => {
+				consumable.consume( data.item.getChild( 0 ), 'insert' );
+				consumable.consume( data.item, 'addAttribute:bold' );
+			} );
+
+			const range = ModelRange.createFromElement( root );
+
+			dispatcher.convertInsert( range );
+
+			expect( dispatcher.fire.calledWith( 'insert:image' ) ).to.be.true;
+			expect( dispatcher.fire.calledWith( 'addAttribute:src:image' ) ).to.be.true;
+			expect( dispatcher.fire.calledWith( 'addAttribute:title:image' ) ).to.be.true;
+			expect( dispatcher.fire.calledWith( 'insert:$text' ) ).to.be.true;
+
+			expect( dispatcher.fire.calledWith( 'addAttribute:bold:image' ) ).to.be.false;
+			expect( dispatcher.fire.calledWith( 'insert:caption' ) ).to.be.false;
+		} );
+	} );
+
+	describe( 'convertMove', () => {
+		it( 'should fire single event for moved range', () => {
+			root.appendChildren( 'barfoo' );
+
+			const range = ModelRange.createFromParentsAndOffsets( root, 0, root, 3 );
+			const loggedEvents = [];
+
+			dispatcher.on( 'move', ( evt, data ) => {
+				const log = 'move:' + data.sourcePosition.path + ':' + data.range.start.path + ':' + data.range.end.path;
+				loggedEvents.push( log );
+			} );
+
+			dispatcher.convertMove( ModelPosition.createFromParentAndOffset( root , 3 ), range );
+
+			expect( loggedEvents ).to.deep.equal( [ 'move:3:0:3' ] );
+		} );
+	} );
+
+	describe( 'convertRemove', () => {
+		it( 'should fire single event for removed range', () => {
+			root.appendChildren( 'foo' );
+			doc.graveyard.appendChildren( 'bar' );
+
+			const range = ModelRange.createFromParentsAndOffsets( doc.graveyard, 0, doc.graveyard, 3 );
+			const loggedEvents = [];
+
+			dispatcher.on( 'remove', ( evt, data ) => {
+				const log = 'remove:' + data.sourcePosition.path + ':' + data.range.start.path + ':' + data.range.end.path;
+				loggedEvents.push( log );
+			} );
+
+			dispatcher.convertRemove( ModelPosition.createFromParentAndOffset( root , 3 ), range );
+
+			expect( loggedEvents ).to.deep.equal( [ 'remove:3:0:3' ] );
+		} );
+	} );
+
+	describe( 'convertAttribute', () => {
+		it( 'should fire event for every item in passed range', () => {
+			root.appendChildren( [
+				new ModelText( 'foo', { bold: true } ),
+				new ModelElement( 'image', { bold: true } ),
+				new ModelElement( 'paragraph', { bold: true, class: 'nice' }, new ModelText( 'xx', { bold: true, italic: true } ) )
+			] );
+
+			const range = ModelRange.createFromElement( root );
+			const loggedEvents = [];
+
+			dispatcher.on( 'addAttribute', ( evt, data, consumable ) => {
+				const itemId = data.item.name ? data.item.name : '$text:' + data.item.text;
+				const key = data.attributeKey;
+				const value = data.attributeNewValue;
+				const log = 'addAttribute:' + key + ':' + value + ':' + itemId + ':' + data.range.start.path + ':' + data.range.end.path;
+
+				loggedEvents.push( log );
+
+				expect( evt.name ).to.equal( 'addAttribute:' + key + ':' + ( data.item.name || '$text' ) );
+				expect( consumable.consume( data.item, 'addAttribute:' + key ) ).to.be.true;
+			} );
+
+			dispatcher.convertAttribute( 'addAttribute', range, 'bold', null, true );
+
+			expect( loggedEvents ).to.deep.equal( [
+				'addAttribute:bold:true:$text:foo:0:3',
+				'addAttribute:bold:true:image:3:4',
+				'addAttribute:bold:true:paragraph:4:5',
+				'addAttribute:bold:true:$text:xx:4,0:4,2'
+			] );
+		} );
+
+		it( 'should not fire events for already consumed parts of model', () => {
+			root.appendChildren( [
+				new ModelElement( 'element', null, new ModelElement( 'inside' ) )
+			] );
+
+			sinon.spy( dispatcher, 'fire' );
+
+			dispatcher.on( 'removeAttribute:attr:element', ( evt, data, consumable ) => {
+				consumable.consume( data.item.getChild( 0 ), 'removeAttribute:attr' );
+			} );
+
+			const range = ModelRange.createFromElement( root );
+
+			dispatcher.convertAttribute( 'removeAttribute', range, 'attr', 'value', null );
+
+			expect( dispatcher.fire.calledWith( 'removeAttribute:attr:element' ) ).to.be.true;
+			expect( dispatcher.fire.calledWith( 'removeAttribute:attr:inside' ) ).to.be.false;
+		} );
+	} );
+} );

+ 107 - 0
packages/ckeditor5-engine/tests/treecontroller/view-to-model-converters.js

@@ -0,0 +1,107 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treecontroller */
+
+'use strict';
+
+import ViewConversionDispatcher from '/ckeditor5/engine/treecontroller/viewconversiondispatcher.js';
+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 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;
+
+beforeEach( () => {
+	dispatcher = new ViewConversionDispatcher();
+} );
+
+describe( 'convertText', () => {
+	it( 'should return converter converting ViewText to ModelText', () => {
+		const viewText = new ViewText( 'foobar' );
+
+		dispatcher.on( 'text', convertText() );
+
+		const result = dispatcher.convert( viewText );
+
+		expect( result ).to.be.instanceof( ModelText );
+		expect( result.text ).to.equal( 'foobar' );
+	} );
+
+	it( 'should not convert already consumed texts', () => {
+		const viewText = new ViewText( 'foofuckbafuckr' );
+
+		// Default converter for elements. Returns just converted children. Added with late priority.
+		dispatcher.on( 'text', convertText(), dispatcher, 9999 );
+		// Added with sooner priority. Should make the above converter not fire.
+		dispatcher.on( 'text', ( evt, data, consumable ) => {
+			if ( consumable.consume( data.input ) ) {
+				data.output = new ModelText( data.input.data.replace( /fuck/gi, '****' ) );
+			}
+		} );
+
+		const result = dispatcher.convert( viewText );
+
+		expect( result ).to.be.instanceof( ModelText );
+		expect( result.text ).to.equal( 'foo****ba****r' );
+	} );
+} );
+
+describe( 'convertToModelFragment', () => {
+	it( 'should return converter converting whole ViewDocumentFragment to ModelDocumentFragment', () => {
+		const viewFragment = new ViewDocumentFragment( [
+			new ViewContainerElement( 'p', null, new ViewText( 'foo' ) ),
+			new ViewText( 'bar' )
+		] );
+
+		// To get any meaningful results we have to actually convert something.
+		dispatcher.on( 'text', convertText() );
+		// This way P element won't be converted per-se but will fire converting it's children.
+		dispatcher.on( 'element', convertToModelFragment() );
+		dispatcher.on( 'documentFragment', convertToModelFragment() );
+
+		const result = dispatcher.convert( viewFragment );
+
+		expect( result ).to.be.instanceof( ModelDocumentFragment );
+		expect( result.getChildCount() ).to.equal( 6 );
+		expect( result.getChild( 0 ).character ).to.equal( 'f' );
+		expect( result.getChild( 1 ).character ).to.equal( 'o' );
+		expect( result.getChild( 2 ).character ).to.equal( 'o' );
+		expect( result.getChild( 3 ).character ).to.equal( 'b' );
+		expect( result.getChild( 4 ).character ).to.equal( 'a' );
+		expect( result.getChild( 5 ).character ).to.equal( 'r' );
+	} );
+
+	it( 'should not convert already consumed (converted) changes', () => {
+		const viewP = new ViewContainerElement( 'p', null, new ViewText( 'foo' ) );
+
+		// To get any meaningful results we have to actually convert something.
+		dispatcher.on( 'text', convertText() );
+		// Default converter for elements. Returns just converted children. Added with late priority.
+		dispatcher.on( 'element', convertToModelFragment(), dispatcher, 9999 );
+		// Added with sooner priority. Should make the above converter not fire.
+		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 ) );
+			}
+		} );
+
+		const result = dispatcher.convert( viewP );
+
+		expect( result ).to.be.instanceof( ModelElement );
+		expect( result.name ).to.equal( 'paragraph' );
+		expect( result.getChildCount() ).to.equal( 3 );
+		expect( result.getChild( 0 ).character ).to.equal( 'f' );
+		expect( result.getChild( 1 ).character ).to.equal( 'o' );
+		expect( result.getChild( 2 ).character ).to.equal( 'o' );
+	} );
+} );

+ 4 - 4
packages/ckeditor5-engine/tests/treecontroller/viewconsumable.js

@@ -520,9 +520,9 @@ describe( 'ViewConsumable', () => {
 		} );
 	} );
 
-	describe( 'createFromElement', () => {
+	describe( 'createFrom', () => {
 		it( 'should return new ViewConsumable instance', () => {
-			const newConsumable = ViewConsumable.createFromElement( el );
+			const newConsumable = ViewConsumable.createFrom( el );
 
 			expect( newConsumable ).to.be.instanceof( ViewConsumable );
 			expect( newConsumable.test( el, { name: true } ) ).to.be.true;
@@ -530,7 +530,7 @@ describe( 'ViewConsumable', () => {
 
 		it( 'should return new ViewConsumable instance from document fragment', () => {
 			const fragment = new ViewDocumentFragment();
-			const newConsumable = ViewConsumable.createFromElement( fragment );
+			const newConsumable = ViewConsumable.createFrom( fragment );
 
 			expect( newConsumable ).to.be.instanceof( ViewConsumable );
 			expect( newConsumable.test( fragment ) ).to.be.true;
@@ -544,7 +544,7 @@ describe( 'ViewConsumable', () => {
 			const child3 = new ViewElement( 'p', { 'style': 'top:10px;', 'class': 'qux bar' }, [ text2, child2 ] );
 			el.appendChildren( [ child1, child3 ] );
 
-			const newConsumable = ViewConsumable.createFromElement( el );
+			const newConsumable = ViewConsumable.createFrom( el );
 
 			expect( newConsumable.test( el, { name: true } ) ).to.be.true;
 			expect( newConsumable.test( text1 ) ).to.be.true;

+ 252 - 0
packages/ckeditor5-engine/tests/treecontroller/viewconversiondispatcher.js

@@ -0,0 +1,252 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: treecontroller */
+
+'use strict';
+
+import ViewConversionDispatcher from '/ckeditor5/engine/treecontroller/viewconversiondispatcher.js';
+import ViewContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
+import ViewAttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
+import ViewDocumentFragment from '/ckeditor5/engine/treeview/documentfragment.js';
+import ViewText from '/ckeditor5/engine/treeview/text.js';
+
+describe( 'ViewConversionDispatcher', () => {
+	describe( 'constructor', () => {
+		it( 'should create ViewConversionDispatcher with passed api', () => {
+			const apiObj = {};
+			const dispatcher = new ViewConversionDispatcher( { apiObj } );
+
+			expect( dispatcher.conversionApi.apiObj ).to.equal( apiObj );
+			expect( dispatcher.conversionApi ).to.have.property( 'convertItem' ).that.is.instanceof( Function );
+			expect( dispatcher.conversionApi ).to.have.property( 'convertChildren' ).that.is.instanceof( Function );
+		} );
+	} );
+
+	describe( 'convert', () => {
+		let dispatcher;
+
+		beforeEach( () => {
+			dispatcher = new ViewConversionDispatcher();
+		} );
+
+		it( 'should fire viewCleanup event on converted view part', () => {
+			sinon.spy( dispatcher, 'fire' );
+
+			const viewP = new ViewContainerElement( 'p' );
+			dispatcher.convert( viewP );
+
+			expect( dispatcher.fire.calledWith( 'viewCleanup', viewP ) ).to.be.true;
+		} );
+
+		it( 'should fire proper events', () => {
+			const viewText = new ViewText( 'foobar' );
+			const viewElement = new ViewContainerElement( 'p', null, viewText );
+			const viewFragment = new ViewDocumentFragment( viewElement );
+
+			sinon.spy( dispatcher, 'fire' );
+
+			dispatcher.convert( viewText );
+			dispatcher.convert( viewElement );
+			dispatcher.convert( viewFragment );
+
+			expect( dispatcher.fire.calledWith( 'text' ) ).to.be.true;
+			expect( dispatcher.fire.calledWith( 'element:p' ) ).to.be.true;
+			expect( dispatcher.fire.calledWith( 'documentFragment' ) ).to.be.true;
+		} );
+
+		it( 'should convert ViewText', () => {
+			const viewText = new ViewText( 'foobar' );
+
+			dispatcher.on( 'text', ( evt, data, consumable, conversionApi ) => {
+				const result = {
+					eventName: evt.name,
+					input: data.input,
+					// Check whether additional data has been passed.
+					foo: data.foo
+				};
+
+				// Check whether consumable has appropriate value to consume.
+				expect( consumable.consume( data.input ) ).to.be.true;
+
+				// Check whether conversionApi of `dispatcher` has been passed.
+				expect( conversionApi ).to.equal( dispatcher.conversionApi );
+
+				// Set conversion result to `output` property of `data`.
+				// Later we will check if it was returned by `convert` method.
+				data.output = result;
+			} );
+
+			// Use `additionalData` parameter to check if it was passed to the event.
+			const result = dispatcher.convert( viewText, { foo: 'bar' } );
+
+			// Check conversion result.
+			expect( result ).to.deep.equal( {
+				eventName: 'text',
+				input: viewText,
+				foo: 'bar'
+			} );
+		} );
+
+		it( 'should convert ViewContainerElement', () => {
+			const viewElement = new ViewContainerElement( 'p', { attrKey: 'attrValue' } );
+
+			dispatcher.on( 'element', ( evt, data, consumable, conversionApi ) => {
+				const result = {
+					eventName: evt.name,
+					input: data.input,
+					// Check whether additional data has been passed.
+					foo: data.foo
+				};
+
+				// Check whether consumable has appropriate value to consume.
+				expect( consumable.consume( data.input, { name: true } ) ).to.be.true;
+				expect( consumable.consume( data.input, { attribute: 'attrKey' } ) ).to.be.true;
+
+				// Check whether conversionApi of `dispatcher` has been passed.
+				expect( conversionApi ).to.equal( dispatcher.conversionApi );
+
+				// Set conversion result to `output` property of `data`.
+				// Later we will check if it was returned by `convert` method.
+				data.output = result;
+			} );
+
+			// Use `additionalData` parameter to check if it was passed to the event.
+			const result = dispatcher.convert( viewElement, { foo: 'bar' } );
+
+			// Check conversion result.
+			expect( result ).to.deep.equal( {
+				eventName: 'element:p',
+				input: viewElement,
+				foo: 'bar'
+			} );
+		} );
+
+		it( 'should convert ViewDocumentFragment', () => {
+			const viewFragment = new ViewDocumentFragment();
+
+			dispatcher.on( 'documentFragment', ( evt, data, consumable, conversionApi ) => {
+				const result = {
+					eventName: evt.name,
+					input: data.input,
+					// Check whether additional data has been passed.
+					foo: data.foo
+				};
+
+				// Check whether consumable has appropriate value to consume.
+				expect( consumable.consume( data.input ) ).to.be.true;
+
+				// Check whether conversionApi of `dispatcher` has been passed.
+				expect( conversionApi ).to.equal( dispatcher.conversionApi );
+
+				// Set conversion result to `output` property of `data`.
+				// Later we will check if it was returned by `convert` method.
+				data.output = result;
+			} );
+
+			// Use `additionalData` parameter to check if it was passed to the event.
+			const result = dispatcher.convert( viewFragment, { foo: 'bar' } );
+
+			// Check conversion result.
+			expect( result ).to.deep.equal( {
+				eventName: 'documentFragment',
+				input: viewFragment,
+				foo: 'bar'
+			} );
+		} );
+	} );
+
+	describe( 'conversionApi#convertItem', () => {
+		it( 'should convert view elements and view text', () => {
+			const dispatcher = new ViewConversionDispatcher();
+			const viewFragment = new ViewDocumentFragment( [
+				new ViewContainerElement( 'p' ), new ViewText( 'foobar' )
+			] );
+
+			dispatcher.on( 'text', ( evt, data ) => {
+				data.output = { text: data.input.data };
+			} );
+
+			dispatcher.on( 'element:p', ( evt, data ) => {
+				data.output = { name: 'p' };
+			} );
+
+			dispatcher.on( 'documentFragment', ( evt, data, consumable, conversionApi ) => {
+				data.output = [];
+
+				for ( let child of data.input.getChildren() ) {
+					data.output.push( conversionApi.convertItem( child ) );
+				}
+			} );
+
+			const result = dispatcher.convert( viewFragment );
+
+			expect( result ).to.deep.equal( [
+				{ name: 'p' },
+				{ text: 'foobar' }
+			] );
+		} );
+	} );
+
+	describe( 'conversionApi#convertChildren', () => {
+		it( 'should fire proper events for all children of passed view part', () => {
+			const dispatcher = new ViewConversionDispatcher();
+			const viewFragment = new ViewDocumentFragment( [
+				new ViewContainerElement( 'p' ), new ViewText( 'foobar' )
+			] );
+
+			dispatcher.on( 'text', ( evt, data ) => {
+				data.output = { text: data.input.data };
+			} );
+
+			dispatcher.on( 'element:p', ( evt, data ) => {
+				data.output = { name: 'p' };
+			} );
+
+			dispatcher.on( 'documentFragment', ( evt, data, consumable, conversionApi ) => {
+				data.output = conversionApi.convertChildren( data.input );
+			} );
+
+			const result = dispatcher.convert( viewFragment );
+
+			expect( result ).to.deep.equal( [
+				{ name: 'p' },
+				{ text: 'foobar' }
+			] );
+		} );
+
+		it( 'should flatten structure of non-converted elements', () => {
+			const dispatcher = new ViewConversionDispatcher();
+
+			dispatcher.on( 'text', ( evt, data ) => {
+				data.output = data.input.data;
+			} );
+
+			dispatcher.on( 'element', ( evt, data, consumable, conversionApi ) => {
+				data.output = conversionApi.convertChildren( data.input, consumable );
+			} );
+
+			const viewStructure = new ViewContainerElement( 'div', null, [
+				new ViewContainerElement( 'p', null, [
+					new ViewContainerElement( 'span', { class: 'nice' }, [
+						new ViewAttributeElement( 'a', { href: 'foo.html' }, new ViewText( 'foo' ) ),
+						new ViewText( ' bar ' ),
+						new ViewAttributeElement( 'i', null, new ViewText( 'xyz' ) )
+					] )
+				] ),
+				new ViewContainerElement( 'p', null, [
+					new ViewAttributeElement( 'strong', null, [
+						new ViewText( 'aaa ' ),
+						new ViewAttributeElement( 'span', null, new ViewText( 'bbb' ) ),
+						new ViewText( ' ' ),
+						new ViewAttributeElement( 'a', { href: 'bar.html' }, new ViewText( 'ccc' ) )
+					] )
+				] )
+			] );
+
+			expect( dispatcher.convert( viewStructure ) ).to.deep.equal( [ 'foo', ' bar ', 'xyz', 'aaa ', 'bbb', ' ', 'ccc' ] );
+		} );
+	} );
+} );

+ 3 - 3
packages/ckeditor5-engine/tests/treemodel/document/change-event.js

@@ -116,7 +116,7 @@ describe( 'Document change event', () => {
 		);
 
 		expect( changes ).to.have.length( 1 );
-		expect( types[ 0 ] ).to.equal( 'attribute' );
+		expect( types[ 0 ] ).to.equal( 'addAttribute' );
 		expect( changes[ 0 ].range ).to.deep.equal( Range.createFromParentsAndOffsets( root, 0, root, 3 ) );
 		expect( changes[ 0 ].key ).to.equal( 'key' );
 		expect( changes[ 0 ].oldValue ).to.be.null;
@@ -138,7 +138,7 @@ describe( 'Document change event', () => {
 		);
 
 		expect( changes ).to.have.length( 1 );
-		expect( types[ 0 ] ).to.equal( 'attribute' );
+		expect( types[ 0 ] ).to.equal( 'removeAttribute' );
 		expect( changes[ 0 ].range ).to.deep.equal( Range.createFromParentsAndOffsets( root, 0, elem, 0 ) );
 		expect( changes[ 0 ].key ).to.equal( 'key' );
 		expect( changes[ 0 ].oldValue ).to.equal( 'old' );
@@ -160,7 +160,7 @@ describe( 'Document change event', () => {
 		);
 
 		expect( changes ).to.have.length( 1 );
-		expect( types[ 0 ] ).to.equal( 'attribute' );
+		expect( types[ 0 ] ).to.equal( 'changeAttribute' );
 		expect( changes[ 0 ].range ).to.deep.equal( Range.createFromParentsAndOffsets( root, 0, elem, 0 ) );
 		expect( changes[ 0 ].key ).to.equal( 'key' );
 		expect( changes[ 0 ].oldValue ).to.equal( 'old' );

+ 26 - 0
packages/ckeditor5-engine/tests/treemodel/documentfragment.js

@@ -27,6 +27,32 @@ describe( 'DocumentFragment', () => {
 			expect( frag.getChild( 1 ) ).to.have.property( 'name' ).that.equals( 'p' );
 			expect( frag.getChild( 2 ) ).to.have.property( 'character' ).that.equals( 'y' );
 		} );
+
+		it( 'should have root property, equal to itself', () => {
+			let frag = new DocumentFragment();
+
+			expect( frag ).to.have.property( 'root' ).that.equals( frag );
+		} );
+	} );
+
+	describe( 'iterator', () => {
+		it( 'should iterate over all nodes added to document fragment', () => {
+			let frag = new DocumentFragment( [ 'x', new Element( 'p' ), 'y' ] );
+			let arr = Array.from( frag );
+
+			expect( arr.length ).to.equal( 3 );
+			expect( arr[ 0 ] ).to.have.property( 'character' ).that.equals( 'x' );
+			expect( arr[ 1 ] ).to.have.property( 'name' ).that.equals( 'p' );
+			expect( arr[ 2 ] ).to.have.property( 'character' ).that.equals( 'y' );
+		} );
+	} );
+
+	describe( 'getPath', () => {
+		it( 'should return empty array', () => {
+			let frag = new DocumentFragment( [ 'x', new Element( 'p' ), 'y' ] );
+
+			expect( frag.getPath() ).to.deep.equal( [] );
+		} );
 	} );
 
 	describe( 'insertChildren', () => {

+ 35 - 9
packages/ckeditor5-engine/tests/treemodel/operation/attributeoperation.js

@@ -23,16 +23,42 @@ describe( 'AttributeOperation', () => {
 		root = doc.createRoot( 'root' );
 	} );
 
-	it( 'should have proper type', () => {
-		const op = new AttributeOperation(
-			new Range( new Position( root, [ 0 ] ), new Position( root, [ 2 ] ) ),
-			'isNew',
-			null,
-			true,
-			doc.version
-		);
+	describe( 'type', () => {
+		it( 'should be addAttribute for adding attribute', () => {
+			const op = new AttributeOperation(
+				new Range( new Position( root, [ 0 ] ), new Position( root, [ 2 ] ) ),
+				'key',
+				null,
+				'newValue',
+				doc.version
+			);
+
+			expect( op.type ).to.equal( 'addAttribute' );
+		} );
+
+		it( 'should be removeAttribute for removing attribute', () => {
+			const op = new AttributeOperation(
+				new Range( new Position( root, [ 0 ] ), new Position( root, [ 2 ] ) ),
+				'key',
+				'oldValue',
+				null,
+				doc.version
+			);
+
+			expect( op.type ).to.equal( 'removeAttribute' );
+		} );
+
+		it( 'should be changeAttribute for removing attribute', () => {
+			const op = new AttributeOperation(
+				new Range( new Position( root, [ 0 ] ), new Position( root, [ 2 ] ) ),
+				'key',
+				'oldValue',
+				'newValue',
+				doc.version
+			);
 
-		expect( op.type ).to.equal( 'attribute' );
+			expect( op.type ).to.equal( 'changeAttribute' );
+		} );
 	} );
 
 	it( 'should insert attribute to the set of nodes', () => {

+ 35 - 9
packages/ckeditor5-engine/tests/treemodel/operation/rootattributeoperation.js

@@ -19,16 +19,42 @@ describe( 'RootAttributeOperation', () => {
 		root = doc.createRoot( 'root' );
 	} );
 
-	it( 'should have proper type', () => {
-		const op = new RootAttributeOperation(
-			root,
-			'isNew',
-			null,
-			true,
-			doc.version
-		);
+	describe( 'type', () => {
+		it( 'should be addRootAttribute for adding attribute', () => {
+			const op = new RootAttributeOperation(
+				root,
+				'key',
+				null,
+				'newValue',
+				doc.version
+			);
+
+			expect( op.type ).to.equal( 'addRootAttribute' );
+		} );
+
+		it( 'should be removeRootAttribute for removing attribute', () => {
+			const op = new RootAttributeOperation(
+				root,
+				'key',
+				'oldValue',
+				null,
+				doc.version
+			);
+
+			expect( op.type ).to.equal( 'removeRootAttribute' );
+		} );
+
+		it( 'should be changeRootAttribute for removing attribute', () => {
+			const op = new RootAttributeOperation(
+				root,
+				'key',
+				'oldValue',
+				'newValue',
+				doc.version
+			);
 
-		expect( op.type ).to.equal( 'rootattribute' );
+			expect( op.type ).to.equal( 'changeRootAttribute' );
+		} );
 	} );
 
 	it( 'should add attribute on the root element', () => {

+ 10 - 1
packages/ckeditor5-engine/tests/treemodel/range.js

@@ -137,7 +137,7 @@ describe( 'Range', () => {
 		//     |- f
 		//     |- o
 		//     |- z
-		before( () => {
+		beforeEach( () => {
 			f = new Text( 'f' );
 			o = new Text( 'o' );
 			z = new Text( 'z' );
@@ -156,6 +156,15 @@ describe( 'Range', () => {
 			} );
 		} );
 
+		describe( 'createOnElement', () => {
+			it( 'should return range', () => {
+				const range = Range.createOnElement( p );
+
+				expect( range.start.path ).to.deep.equal( [ 0 ] );
+				expect( range.end.path ).to.deep.equal( [ 0, 0 ] );
+			} );
+		} );
+
 		describe( 'createFromParentsAndOffsets', () => {
 			it( 'should return range', () => {
 				const range = Range.createFromParentsAndOffsets( root, 0, p, 2 );

+ 79 - 0
packages/ckeditor5-engine/tests/treemodel/text.js

@@ -27,4 +27,83 @@ describe( 'Text', () => {
 			expect( empty2.text ).to.equal( '' );
 		} );
 	} );
+
+	describe( 'attributes interface', () => {
+		let text;
+
+		beforeEach( () => {
+			text = new Text( 'bar', { foo: 'bar' } );
+		} );
+
+		describe( 'hasAttribute', () => {
+			it( 'should return true if element contains attribute with given key', () => {
+				expect( text.hasAttribute( 'foo' ) ).to.be.true;
+			} );
+
+			it( 'should return false if element does not contain attribute with given key', () => {
+				expect( text.hasAttribute( 'bar' ) ).to.be.false;
+			} );
+		} );
+
+		describe( 'getAttribute', () => {
+			it( 'should return attribute value for given key if element contains given attribute', () => {
+				expect( text.getAttribute( 'foo' ) ).to.equal( 'bar' );
+			} );
+
+			it( 'should return undefined if element does not contain given attribute', () => {
+				expect( text.getAttribute( 'bar' ) ).to.be.undefined;
+			} );
+		} );
+
+		describe( 'getAttributes', () => {
+			it( 'should return an iterator that iterates over all attributes set on the element', () => {
+				expect( Array.from( text.getAttributes() ) ).to.deep.equal( [ [ 'foo', 'bar' ] ] );
+			} );
+		} );
+
+		describe( 'setAttribute', () => {
+			it( 'should set given attribute on the element', () => {
+				text.setAttribute( 'abc', 'xyz' );
+
+				expect( text.getAttribute( 'abc' ) ).to.equal( 'xyz' );
+			} );
+		} );
+
+		describe( 'setAttributesTo', () => {
+			it( 'should remove all attributes set on element and set the given ones', () => {
+				text.setAttribute( 'abc', 'xyz' );
+				text.setAttributesTo( { bold: true } );
+
+				expect( text.getAttribute( 'bold' ) ).to.equal( true );
+				expect( text.getAttribute( 'foo' ) ).to.be.undefined;
+				expect( text.getAttribute( 'abc' ) ).to.be.undefined;
+			} );
+		} );
+
+		describe( 'removeAttribute', () => {
+			it( 'should remove attribute set on the element and return true', () => {
+				let result = text.removeAttribute( 'foo' );
+
+				expect( text.getAttribute( 'foo' ) ).to.be.undefined;
+				expect( result ).to.be.true;
+			} );
+
+			it( 'should return false if element does not contain given attribute', () => {
+				let result = text.removeAttribute( 'abc' );
+
+				expect( result ).to.be.false;
+			} );
+		} );
+
+		describe( 'clearAttributes', () => {
+			it( 'should remove all attributes from the element', () => {
+				text.setAttribute( 'abc', 'xyz' );
+
+				text.clearAttributes();
+
+				expect( text.getAttribute( 'foo' ) ).to.be.undefined;
+				expect( text.getAttribute( 'abc' ) ).to.be.undefined;
+			} );
+		} );
+	} );
 } );

+ 14 - 1
packages/ckeditor5-engine/tests/treeview/documentfragment.js

@@ -38,6 +38,19 @@ describe( 'DocumentFragment', () => {
 		} );
 	} );
 
+	describe( 'iterator', () => {
+		it( 'should iterate over all nodes added to document fragment', () => {
+			const children = [ new Element( 'p' ), new Element( 'div' ) ];
+			const fragment = new DocumentFragment( children );
+
+			const arr = Array.from( fragment );
+
+			expect( arr.length ).to.equal( 2 );
+			expect( arr[ 0 ] ).to.have.property( 'name' ).that.equals( 'p' );
+			expect( arr[ 1 ] ).to.have.property( 'name' ).that.equals( 'div' );
+		} );
+	} );
+
 	describe( 'children manipulation methods', () => {
 		let fragment, el1, el2, el3, el4;
 
@@ -196,4 +209,4 @@ describe( 'DocumentFragment', () => {
 			expect( fragment.getChild( 0 ) ).to.equal( node2 );
 		} );
 	} );
-} );
+} );