Răsfoiți Sursa

Merge pull request #415 from ckeditor/t/313

T/313 Selection converters
Piotr Jasiun 9 ani în urmă
părinte
comite
b4e9d23692

+ 29 - 1
packages/ckeditor5-engine/src/treecontroller/mapper.js

@@ -254,7 +254,7 @@ export default class Mapper {
 
 		// If it equals we found the position.
 		if ( modelOffset == expectedOffset ) {
-			return new ViewPosition( viewParent, viewOffset );
+			return this._moveViewPositionToTextNode( new ViewPosition( viewParent, viewOffset ) );
 		}
 		// If it is higher we need to enter last child.
 		else {
@@ -263,4 +263,32 @@ export default class Mapper {
 			return this._findPositionIn( viewNode, expectedOffset - ( modelOffset - lastLength ) );
 		}
 	}
+
+	/**
+	 * Because we prefer positions in text nodes over positions next to text node moves view position to the text node
+	 * if it was next to it.
+	 *
+	 *		<p>[]<b>foo</b></p> -> <p>[]<b>foo</b></p> // do not touch if position is not directly next to text
+	 *		<p>foo[]<b>foo</b></p> -> <p>foo{}<b>foo</b></p> // move to text node
+	 *		<p><b>[]foo</b></p> -> <p><b>{}foo</b></p> // move to text node
+	 *
+	 * @private
+	 * @param {engine.treeView.Position} viewPosition Position potentially next to text node.
+	 * @returns {engine.treeView.Position} Position in text node if possible.
+	 */
+	_moveViewPositionToTextNode( viewPosition ) {
+		// If the position is just after text node, put it at the end of that text node.
+		// If the position is just before text node, put it at the beginning of that text node.
+		const nodeBefore = viewPosition.nodeBefore;
+		const nodeAfter = viewPosition.nodeAfter;
+
+		if ( nodeBefore instanceof ViewText ) {
+			return new ViewPosition( nodeBefore, nodeBefore.data.length );
+		} else if ( nodeAfter instanceof ViewText ) {
+			return new ViewPosition( nodeAfter, 0 );
+		}
+
+		// Otherwise, just return the given position.
+		return viewPosition;
+	}
 }

+ 8 - 3
packages/ckeditor5-engine/src/treecontroller/model-converter-builder.js

@@ -13,6 +13,8 @@ import {
 	unwrap
 } from '/ckeditor5/engine/treecontroller/model-to-view-converters.js';
 
+import { convertSelectionAttribute } from '/ckeditor5/engine/treecontroller/model-selection-to-view-converters.js';
+
 import ViewAttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
 import ViewContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
 
@@ -165,9 +167,10 @@ class ModelConverterBuilder {
 	 * Creator function will be passed different values depending whether conversion is from element or from attribute:
 	 *
 	 * * from element: dispatcher's {@link engine.treeController.ModelConversionDispatcher#event:insert insert event} parameters
-	 * will be passed
-	 * * from attribute: first parameter is attribute value, then the rest of parameters are dispatcher's
-	 * {@link engine.treeController.ModelConversionDispatcher#event:changeAttribute changeAttribute event} parameters.
+	 * will be passed,
+	 * * from attribute: there is one parameter and it is attribute value.
+	 *
+	 * This method also registers model selection to view selection converter, if conversion is from attribute.
 	 *
 	 * This method creates the converter and adds it as a callback to a proper
 	 * {@link engine.treeController.ModelConversionDispatcher conversion dispatcher} event.
@@ -190,6 +193,8 @@ class ModelConverterBuilder {
 				dispatcher.on( 'addAttribute:' + this._from.key, wrap( element ), null, priority );
 				dispatcher.on( 'changeAttribute:' + this._from.key, wrap( element ), null, priority );
 				dispatcher.on( 'removeAttribute:' + this._from.key, unwrap( element ), null, priority );
+
+				dispatcher.on( 'selectionAttribute:' + this._from.key, convertSelectionAttribute( element ), null, priority );
 			}
 		}
 	}

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

@@ -0,0 +1,210 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+import ViewElement from '../treeview/element.js';
+import ViewRange from '../treeview/range.js';
+
+/**
+ * Contains {@link engine.treeModel.Selection model selection} to {@link engine.treeView.Selection view selection} converters for
+ * {@link engine.treeController.ModelConversionDispatcher}.
+ *
+ * @namespace engine.treeController.modelSelectionToView
+ */
+
+/**
+ * Function factory, creates a converter that converts non-collapsed {@link engine.treeModel.Selection model selection} to
+ * {@link engine.treeView.Selection view selection}. The converter consumes appropriate value from `consumable` object
+ * and maps model positions from selection to view positions.
+ *
+ *		modelDispatcher.on( 'selection', convertRangeSelection() );
+ *
+ * @external engine.treeController.modelSelectionToView
+ * @function engine.treeController.modelSelectionToView.convertRangeSelection
+ * @returns {Function} Selection converter.
+ */
+export function convertRangeSelection() {
+	return ( evt, data, consumable, conversionApi ) => {
+		const selection = data.selection;
+
+		if ( selection.isCollapsed ) {
+			return;
+		}
+
+		if ( !consumable.consume( selection, 'selection' ) ) {
+			return;
+		}
+
+		conversionApi.viewSelection.removeAllRanges();
+
+		for ( let range of selection.getRanges() ) {
+			const viewRange = conversionApi.mapper.toViewRange( range );
+			conversionApi.viewSelection.addRange( viewRange, selection.isBackward );
+		}
+	};
+}
+
+/**
+ * Function factory, creates a converter that converts collapsed {@link engine.treeModel.Selection model selection} to
+ * {@link engine.treeView.Selection view selection}. The converter consumes appropriate value from `consumable` object,
+ * maps model selection position to view position and breaks {@link engine.treeView.AttributeElement attribute elements}
+ * at the selection position.
+ *
+ *		modelDispatcher.on( 'selection', convertCollapsedSelection() );
+ *
+ * Example of view state before and after converting collapsed selection:
+ *
+ *		<p><strong>f^oo<strong>bar</p>
+ *		-> <p><strong>f</strong>^<strong>oo</strong>bar</p>
+ *
+ * By breaking attribute elements like `<strong>` selection is in correct elements. See also complementary
+ * {@link engine.treeController.modelSelectionToView.convertSelectionAttribute attribute converter} for selection attributes,
+ * which wraps collapsed selection into view elements. Those converters together ensure, that selection ends up in
+ * appropriate elements.
+ *
+ * See also {@link engine.treeController.modelSelectionToView.clearAttributes} which do the clean-up by merging attributes.
+ *
+ * @external engine.treeController.modelSelectionToView
+ * @function engine.treeController.modelSelectionToView.convertCollapsedSelection
+ * @returns {Function} Selection converter.
+ */
+export function convertCollapsedSelection() {
+	return ( evt, data, consumable, conversionApi ) => {
+		const selection = data.selection;
+
+		if ( !selection.isCollapsed ) {
+			return;
+		}
+
+		if ( !consumable.consume( selection, 'selection' ) ) {
+			return;
+		}
+
+		const modelPosition = selection.getFirstPosition();
+		const viewPosition = conversionApi.mapper.toViewPosition( modelPosition );
+		const brokenPosition = conversionApi.writer.breakAttributes( viewPosition );
+
+		conversionApi.viewSelection.removeAllRanges();
+		conversionApi.viewSelection.addRange( new ViewRange( brokenPosition, brokenPosition ) );
+	};
+}
+
+/**
+ * Function factory, creates a converter that converts {@link engine.treeModel.Selection model selection} attributes to
+ * {@link engine.treeView.AttributeElement view attribute elements}. The converter works only for collapsed selection.
+ * The converter consumes appropriate value from `consumable` object, maps model selection position to view position and
+ * wraps that position into a view attribute element.
+ *
+ * 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#event:selectionAttribute selectionAttribute event}. It's expected that
+ * the function returns a {@link engine.treeView.AttributeElement}. The result of the function will be the wrapping element.
+ *
+ *		modelDispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
+ *
+ *		function styleElementCreator( styleValue ) {
+ *			if ( styleValue == 'important' ) {
+ *				return new ViewAttributeElement( 'strong', { style: 'text-transform:uppercase;' } );
+ *			} else if ( styleValue == 'gold' ) {
+ *				return new ViewAttributeElement( 'span', { style: 'color:yellow;' } );
+ *			}
+ *		}
+ *		modelDispatcher.on( 'selectionAttribute:style', convertSelectionAttribute( styleCreator ) );
+ *
+ * **Note:** You can use the same `elementCreator` function for this converter factory and {@link engine.treeController.modelToView.wrap}
+ * model to view converter, as long as the `elementCreator` function uses only the first parameter (attribute value).
+ *
+ * Example of view state after converting collapsed selection. The scenario is: selection is inside bold text (`<strong>` element)
+ * but it does not have bold attribute itself, but has italic attribute instead (let's assume that user turned off bold and turned
+ * on italic with selection collapsed):
+ *
+ *		modelDispatcher.on( 'selection', convertCollapsedSelection() );
+ *		modelDispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
+ *
+ * Example of view states before and after converting collapsed selection:
+ *
+ *		<p><em>f^oo</em>bar</p>
+ *		-> <p><em>f</em>^<em>oo</em>bar</p>
+ *		-> <p><em>f^oo</em>bar</p>
+ *
+ *		<p><strong>f^oo<strong>bar</p>
+ *		-> <p><strong>f</strong>^<strong>oo<strong>bar</p>
+ *		-> <p><strong>f</strong><em>^</em><strong>oo</strong>bar</p>
+ *
+ * In first example, nothing has changed, because first `<em>` element got broken by `convertCollapsedSelection()` converter,
+ * but then it got wrapped-back by `convertSelectionAttribute()` converter. In second example, notice how `<strong>` element
+ * is broken to prevent putting selection in it, since selection has no `bold` attribute.
+ *
+ * @external engine.treeController.modelSelectionToView
+ * @function engine.treeController.modelSelectionToView.convertCollapsedSelection
+ * @param {engine.treeView.AttributeElement|Function} elementCreator View element, or function returning a view element, which will
+ * be used for wrapping.
+ * @returns {Function} Selection converter.
+ */
+export function convertSelectionAttribute( elementCreator ) {
+	return ( evt, data, consumable, conversionApi ) => {
+		const selection = data.selection;
+
+		if ( !selection.isCollapsed ) {
+			return;
+		}
+
+		if ( !consumable.consume( selection, 'selectionAttribute:' + data.key ) ) {
+			return;
+		}
+
+		let viewPosition = conversionApi.viewSelection.getFirstPosition();
+		conversionApi.viewSelection.removeAllRanges();
+
+		const viewElement = elementCreator instanceof ViewElement ?
+				elementCreator.clone( true ) :
+				elementCreator( data.value, selection, consumable, conversionApi );
+
+		viewPosition = conversionApi.writer.wrapPosition( viewPosition, viewElement );
+
+		conversionApi.viewSelection.addRange( new ViewRange( viewPosition, viewPosition ) );
+	};
+}
+
+/**
+ * Function factory, creates a converter that clears artifacts after the previous
+ * {@link engine.treeModel.Selection model selection} conversion. It removes all empty
+ * {@link engine.treeView.AttributeElement view attribute elements} and merge sibling attributes at all start and end
+ * positions of all ranges.
+ *
+ *		<p><strong>^</strong></p>
+ *		-> <p>^</p>
+ *
+ *		<p><strong>foo</strong>^<strong>bar</strong>bar</p>
+ *		-> <p><strong>foo^bar<strong>bar</p>
+ *
+ *		<p><strong>foo</strong><em>^</em><strong>bar</strong>bar</p>
+ *		-> <p><strong>foo^bar<strong>bar</p>
+ *
+ * This listener should be assigned before any converter for the new selection:
+ *
+ *		modelDispatcher.on( 'selection', clearAttributes() );
+ *
+ * See {@link engine.treeController.modelSelectionToView.convertCollapsedSelection} which do the opposite by breaking
+ * attributes in the selection position.
+ *
+ * @external engine.treeController.modelSelectionToView
+ * @function engine.treeController.modelSelectionToView.clearAttributes
+ * @returns {Function} Selection converter.
+ */
+export function clearAttributes() {
+	return ( evt, data, consumable, conversionApi ) => {
+		for ( let range of conversionApi.viewSelection.getRanges() ) {
+			const parentNode = range.start.parent;
+
+			if ( parentNode instanceof ViewElement && parentNode.getChildCount() === 0 ) {
+				parentNode.parent.removeChildren( parentNode.getIndex() );
+			}
+		}
+
+		conversionApi.viewSelection.removeAllRanges();
+	};
+}

+ 21 - 14
packages/ckeditor5-engine/src/treecontroller/modelconsumable.js

@@ -10,8 +10,8 @@ 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.
+ * Consumables are various aspects of the model. 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
@@ -19,14 +19,13 @@ import TextProxy from '../treemodel/textproxy.js';
  * 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>`.
+ * For model items, `ModelConsumable` stores consumable values of one of following types: `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}.
+ * In most cases, it is enough to let {@link engine.treeController.ModelConversionDispatcher} gather consumable values, so
+ * there is no need to use {@link engine.treeController.ModelConsumable#add add method} directly. However, it is important to
+ * understand how consumable values can be {@link engine.treeController.ModelConsumable#consume consumed}. See
+ * {@link engine.treeController.modelToView default model to view converters} for more information.
  *
  * 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
@@ -109,7 +108,7 @@ export default class ModelConsumable {
 		 * `ModelConsumable` translates `TextProxy` to that unique `Symbol`.
 		 *
 		 * @private
-		 * @member {Map} engine.treeController.ModelConsumable#_textParentsRegistry
+		 * @member {Map} engine.treeController.ModelConsumable#_textProxyRegistry
 		 */
 		this._textProxyRegistry = new Map();
 	}
@@ -120,8 +119,10 @@ export default class ModelConsumable {
 	 *		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.
+	 *		modelConsumable.add( modelSelection, 'selection' ); // Add `modelSelection` to consumable values.
+	 *		modelConsumable.add( modelSelection, 'selectionAttribute:bold' ); // Add `bold` attribute on `modelSelection` to consumables.
 	 *
-	 * @param {engine.treeModel.Item} item Model item that has the consumable.
+	 * @param {engine.treeModel.Item|engine.treeModel.Selection} item Model item or selection that has the consumable.
 	 * @param {String} type Consumable type.
 	 */
 	add( item, type ) {
@@ -142,8 +143,10 @@ export default class ModelConsumable {
 	 *		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.
+	 *		modelConsumable.consume( modelSelection, 'selection' ); // Remove `modelSelection` from consumable values.
+	 *		modelConsumable.consume( modelSelection, 'selectionAttribute:bold' ); // Remove `bold` on `modelSelection` from consumables.
 	 *
-	 * @param {engine.treeModel.Item} item Model item from which consumable will be consumed.
+	 * @param {engine.treeModel.Item|engine.treeModel.Selection} item Model item or selection from which consumable will be consumed.
 	 * @param {String} type Consumable type.
 	 * @returns {Boolean} `true` if consumable value was available and was consumed, `false` otherwise.
 	 */
@@ -167,8 +170,10 @@ export default class ModelConsumable {
 	 *		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.
+	 *		modelConsumable.test( modelSelection, 'selection' ); // Check if `modelSelection` is consumable.
+	 *		modelConsumable.test( modelSelection, 'selectionAttribute:bold' ); // Check if `bold` on `modelSelection` is consumable.
 	 *
-	 * @param {engine.treeModel.Item} item Model item that will be tested.
+	 * @param {engine.treeModel.Item|engine.treeModel.Selection} item Model item or selection 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.
@@ -199,8 +204,10 @@ export default class ModelConsumable {
 	 *		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`.
+	 *		modelConsumable.revert( modelSelection, 'selection' ); // Revert consuming `modelSelection`.
+	 *		modelConsumable.revert( modelSelection, 'selectionAttribute:bold' ); // Revert consuming `bold` from `modelSelection`.
 	 *
-	 * @param {engine.treeModel.Item} item Model item that will be tested.
+	 * @param {engine.treeModel.Item|engine.treeModel.Selection} item Model item or selection that will be reverted.
 	 * @param {String} type Consumable type.
 	 * @returns {null|Boolean} `true` if consumable has been reversed, `false` otherwise. `null` if the consumable has
 	 * never been added.

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

@@ -247,6 +247,32 @@ export default class ModelConversionDispatcher {
 		}
 	}
 
+	/**
+	 * Fires events for given {@link engine.treeModel.Selection selection} to start selection conversion.
+	 *
+	 * @fires engine.treeController.ModelConversionDispatcher#selection
+	 * @fires engine.treeController.ModelConversionDispatcher#selectionAttribute
+	 * @param {engine.treeModel.Selection} selection Selection to convert.
+	 */
+	convertSelection( selection ) {
+		const consumable = this._createSelectionConsumable( selection );
+		const data = {
+			selection: selection
+		};
+
+		this.fire( 'selection', data, consumable, this.conversionApi );
+
+		for ( let attr of selection.getAttributes() ) {
+			data.key = attr[ 0 ];
+			data.value = attr[ 1 ];
+
+			// Do not fire event if the attribute has been consumed.
+			if ( consumable.test( selection, 'selectionAttribute:' + data.key ) ) {
+				this.fire( 'selectionAttribute:' + data.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.
@@ -293,6 +319,25 @@ export default class ModelConversionDispatcher {
 		return consumable;
 	}
 
+	/**
+	 * Creates {@link engine.treeController.ModelConsumable} with selection consumable values.
+	 *
+	 * @private
+	 * @param {engine.treeModel.Selection} selection Selection to create consumable from.
+	 * @returns {engine.treeController.ModelConsumable} Values to consume.
+	 */
+	_createSelectionConsumable( selection ) {
+		const consumable = new Consumable();
+
+		consumable.add( selection, 'selection' );
+
+		for ( let attr of selection.getAttributes() ) {
+			consumable.add( selection, 'selectionAttribute:' + attr[ 0 ] );
+		}
+
+		return consumable;
+	}
+
 	/**
 	 * Tests passed `consumable` to check whether given event can be fired and if so, fires it.
 	 *
@@ -422,6 +467,31 @@ export default class ModelConversionDispatcher {
 	 * @param {engine.treeController.ModelConsumable} consumable Values to consume.
 	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
 	 */
+
+	/**
+	 * Fired for {@link engine.treeModel.Selection selection} changes.
+	 *
+	 * @event engine.treeController.ModelConversionDispatcher.selection
+	 * @param {engine.treeModel.Selection} selection `Selection` instance that is converted.
+	 * @param {engine.treeController.ModelConsumable} consumable Values to consume.
+	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
+	 */
+
+	/**
+	 * Fired for {@link engine.treeModel.Selection selection} attributes changes.
+	 *
+	 * `selectionAttribute` is a namespace for a class of events. Names of actually called events follow this pattern:
+	 * `selectionAttribute:<attributeKey>`. `attributeKey` is the key of selection attribute. This way listen can listen to
+	 * certain attribute, i.e. `addAttribute:bold`.
+	 *
+	 * @event engine.treeController.ModelConversionDispatcher.selectionAttribute
+	 * @param {Object} data Additional information about the change.
+	 * @param {engine.treeModel.Selection} data.selection Selection that is converted.
+	 * @param {String} data.attributeKey Key of changed attribute.
+	 * @param {*} data.attributeValue Value of changed attribute.
+	 * @param {engine.treeController.ModelConsumable} consumable Values to consume.
+	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
+	 */
 }
 
 mix( ModelConversionDispatcher, EmitterMixin );

+ 4 - 5
packages/ckeditor5-engine/src/treemodel/selection.js

@@ -482,7 +482,7 @@ export default class Selection {
 	}
 
 	/**
-	 * Updates this selection attributes based on it's position in the Tree Model.
+	 * Updates this selection attributes based on it's position in the model.
 	 *
 	 * @protected
 	 */
@@ -492,7 +492,7 @@ export default class Selection {
 
 		let attrs = null;
 
-		if ( this.isCollapsed === false ) {
+		if ( !this.isCollapsed ) {
 			// 1. If selection is a range...
 			const range = this.getFirstRange();
 
@@ -503,10 +503,9 @@ export default class Selection {
 					break;
 				}
 			}
-		}
+		} else {
+			// 2. If the selection is a caret or the range does not contain a character node...
 
-		// 2. If the selection is a caret or the range does not contain a character node...
-		if ( !attrs && this.isCollapsed === true ) {
 			const nodeBefore = positionParent.getChild( position.offset - 1 );
 			const nodeAfter = positionParent.getChild( position.offset );
 

+ 3 - 3
packages/ckeditor5-engine/src/treeview/containerelement.js

@@ -21,9 +21,9 @@ import Element from './element.js';
  * DOM properly. {@link engine.treeView.DomConverter} will ensure that `ContainerElement` is editable and it is possible
  * to put caret inside it, even if the container is empty.
  *
- * Secondly, {@link engine.treeView.Writer} use this information.
- * {@link engine.treeView.Writer#breakAttributes Break} and {@link engine.treeView.Writer#mergeAttributes Merge}
- * operations are performed only in a bounds of a container nodes.
+ * Secondly, {@link engine.treeView.Writer} uses this information.
+ * Nodes {@link engine.treeView.Writer#breakAttributes breaking} and {@link engine.treeView.Writer#mergeAttributes merging}
+ * is performed only in a bounds of a container nodes.
  *
  * For instance if `<p>` is an container and `<b>` is attribute:
  *

+ 9 - 0
packages/ckeditor5-engine/src/treeview/selection.js

@@ -103,6 +103,15 @@ export default class Selection {
 		return this._ranges.length;
 	}
 
+	/**
+	 * Specifies whether the {@link engine.treeView.Selection#focus} precedes {@link engine.treeView.Selection#anchor}.
+	 *
+	 * @type {Boolean}
+	 */
+	get isBackward() {
+		return !this.isCollapsed && this._lastRangeBackward;
+	}
+
 	/**
 	 * Adds a range to the selection. Added range is copied. This means that passed range is not saved in the
 	 * selection instance and you can safely operate on it.

+ 32 - 15
packages/ckeditor5-engine/src/treeview/writer.js

@@ -41,6 +41,10 @@ import isIterable from '../../utils/isiterable.js';
 		return parent;
 	}
 
+	breakAttributes( position ) {
+		return this._breakAttributes( position, false );
+	}
+
 	/**
 	 * Breaks attribute nodes at provided position. It breaks `attribute` nodes inside `container` node.
 	 *
@@ -53,13 +57,22 @@ import isIterable from '../../utils/isiterable.js';
 	 * @see engine.treeView.Writer#isContainer
 	 * @see engine.treeView.Writer#isAttribute
 	 *
+	 * @private
 	 * @param {engine.treeView.Position} position Position where to break attributes.
+	 * @param {Boolean} [forceSplitText] If set to `true`, will break text nodes even if they are directly in container
+	 * element. This behavior will result in incorrect view state, but is needed by other `Writer` methods which then
+	 * fixes view state. Defaults to `false`.
 	 * @returns {engine.treeView.Position} New position after breaking the attributes.
 	 */
-	breakAttributes( position ) {
+	_breakAttributes( position, forceSplitText = false ) {
 		const positionOffset = position.offset;
 		const positionParent = position.parent;
 
+		// There are no attributes to break and text nodes breaking is not forced.
+		if ( !forceSplitText && positionParent instanceof Text && positionParent.parent instanceof ContainerElement ) {
+			return Position.createFromPosition( position );
+		}
+
 		// Position's parent is container, so no attributes to break.
 		if ( positionParent instanceof ContainerElement ) {
 			return Position.createFromPosition( position );
@@ -67,7 +80,7 @@ import isIterable from '../../utils/isiterable.js';
 
 		// Break text and start again in new position.
 		if ( positionParent instanceof Text ) {
-			return this.breakAttributes( breakTextNode( position ) );
+			return this._breakAttributes( breakTextNode( position ), forceSplitText );
 		}
 
 		const length = positionParent.getChildCount();
@@ -78,7 +91,7 @@ import isIterable from '../../utils/isiterable.js';
 		if ( positionOffset == length ) {
 			const newPosition = new Position( positionParent.parent, positionParent.getIndex() + 1 );
 
-			return this.breakAttributes( newPosition );
+			return this._breakAttributes( newPosition, forceSplitText );
 		} else
 		// <p>foo<b><u>|bar</u></b></p>
 		// <p>foo<b>|<u>bar</u></b></p>
@@ -86,7 +99,7 @@ import isIterable from '../../utils/isiterable.js';
 		if ( positionOffset === 0 ) {
 			const newPosition = new Position( positionParent.parent, positionParent.getIndex() );
 
-			return this.breakAttributes( newPosition );
+			return this._breakAttributes( newPosition, forceSplitText );
 		}
 		// <p>foo<b><u>"b|ar"</u></b></p>
 		// <p>foo<b><u>"b"|"ar"</u></b></p>
@@ -111,12 +124,16 @@ import isIterable from '../../utils/isiterable.js';
 			// Create new position to work on.
 			const newPosition = new Position( positionParent.parent, offsetAfter );
 
-			return this.breakAttributes( newPosition );
+			return this._breakAttributes( newPosition, forceSplitText );
 		}
 	}
 
+	breakRange( range ) {
+		return this._breakRange( range );
+	}
+
 	/**
-	 * Uses {@link engine.treeView.Writer#breakAttributes breakAttribute} method to break attributes on
+	 * Uses {@link engine.treeView.Writer#_breakAttributes _breakAttribute} method to break attributes on
 	 * {@link engine.treeView.Range#start start} and {@link engine.treeView.Range#end end} positions of
 	 * provided {@link engine.treeView.Range Range}.
 	 *
@@ -124,11 +141,11 @@ import isIterable from '../../utils/isiterable.js';
 	 * {@link engine.treeView.Range#start start} and {@link engine.treeView.Range#end end} positions are not placed inside
 	 * same parent container.
 	 *
-	 * @see engine.treeView.Writer#breakAttribute
+	 * @see engine.treeView.Writer#_breakAttribute
 	 * @param {engine.treeView.Range} range Range which `start` and `end` positions will be used to break attributes.
 	 * @returns {engine.treeView.Range} New range with located at break positions.
 	 */
-	breakRange( range ) {
+	_breakRange( range, forceSplitText = false ) {
 		const rangeStart = range.start;
 		const rangeEnd = range.end;
 
@@ -144,14 +161,14 @@ import isIterable from '../../utils/isiterable.js';
 
 		// Break at the collapsed position. Return new collapsed range.
 		if ( range.isCollapsed ) {
-			const position = this.breakAttributes( range.start );
+			const position = this._breakAttributes( range.start, forceSplitText );
 
 			return new Range( position, position );
 		}
 
-		const breakEnd = this.breakAttributes( rangeEnd );
+		const breakEnd = this._breakAttributes( rangeEnd, forceSplitText );
 		const count = breakEnd.parent.getChildCount();
-		const breakStart = this.breakAttributes( rangeStart );
+		const breakStart = this._breakAttributes( rangeStart, forceSplitText );
 
 		// Calculate new break end offset.
 		breakEnd.offset += breakEnd.parent.getChildCount() - count;
@@ -238,7 +255,7 @@ import isIterable from '../../utils/isiterable.js';
 		validateNodesToInsert( nodes );
 
 		const container = this.getParentContainer( position );
-		const insertionPosition = this.breakAttributes( position );
+		const insertionPosition = this._breakAttributes( position, true );
 
 		const length = container.insertChildren( insertionPosition.offset, nodes );
 		const endPosition = insertionPosition.getShiftedBy( length );
@@ -287,7 +304,7 @@ import isIterable from '../../utils/isiterable.js';
 		}
 
 		// Break attributes at range start and end.
-		const { start: breakStart, end: breakEnd } = this.breakRange( range );
+		const { start: breakStart, end: breakEnd } = this._breakRange( range, true );
 		const parentContainer = breakStart.parent;
 
 		const count = breakEnd.offset - breakStart.offset;
@@ -376,7 +393,7 @@ import isIterable from '../../utils/isiterable.js';
 		}
 
 		// Break attributes at range start and end.
-		const { start: breakStart, end: breakEnd } = this.breakRange( range );
+		const { start: breakStart, end: breakEnd } = this._breakRange( range, true );
 		const parentContainer = breakStart.parent;
 
 		// Unwrap children located between break points.
@@ -506,7 +523,7 @@ import isIterable from '../../utils/isiterable.js';
 		}
 
 		// Break attributes at range start and end.
-		const { start: breakStart, end: breakEnd } = this.breakRange( range );
+		const { start: breakStart, end: breakEnd } = this._breakRange( range, true );
 		const parentContainer = breakStart.parent;
 
 		// Unwrap children located between break points.

+ 17 - 17
packages/ckeditor5-engine/tests/treecontroller/mapper.js

@@ -194,23 +194,23 @@ describe( 'Mapper', () => {
 			expect( viewPosition.offset ).to.equal( viewOffset );
 		}
 
-		it( 'should transform modelDiv 0', () => createToViewTest( modelDiv, 0, viewDiv, 0 ) );
-		it( 'should transform modelDiv 1', () => createToViewTest( modelDiv, 1, viewDiv, 1 ) );
-		it( 'should transform modelDiv 2', () => createToViewTest( modelDiv, 2, viewDiv, 2 ) );
+		it( 'should transform modelDiv 0', () => createToViewTest( modelDiv, 0, viewTextX, 0 ) );
+		it( 'should transform modelDiv 1', () => createToViewTest( modelDiv, 1, viewTextX, 1 ) );
+		it( 'should transform modelDiv 2', () => createToViewTest( modelDiv, 2, viewTextZZ, 0 ) );
 		it( 'should transform modelDiv 3', () => createToViewTest( modelDiv, 3, viewTextZZ, 1 ) );
-		it( 'should transform modelDiv 4', () => createToViewTest( modelDiv, 4, viewDiv, 3 ) );
+		it( 'should transform modelDiv 4', () => createToViewTest( modelDiv, 4, viewTextZZ, 2 ) );
 
-		it( 'should transform modelP 0', () => createToViewTest( modelP, 0, viewP, 0 ) );
-		it( 'should transform modelP 1', () => createToViewTest( modelP, 1, viewP, 1 ) );
+		it( 'should transform modelP 0', () => createToViewTest( modelP, 0, viewTextY, 0 ) );
+		it( 'should transform modelP 1', () => createToViewTest( modelP, 1, viewTextY, 1 ) );
 		it( 'should transform modelP 2', () => createToViewTest( modelP, 2, viewTextFOO, 1 ) );
 		it( 'should transform modelP 3', () => createToViewTest( modelP, 3, viewTextFOO, 2 ) );
-		it( 'should transform modelP 4', () => createToViewTest( modelP, 4, viewP, 2 ) );
+		it( 'should transform modelP 4', () => createToViewTest( modelP, 4, viewTextBAR, 0 ) );
 		it( 'should transform modelP 5', () => createToViewTest( modelP, 5, viewTextBAR, 1 ) );
 		it( 'should transform modelP 6', () => createToViewTest( modelP, 6, viewTextBAR, 2 ) );
-		it( 'should transform modelP 7', () => createToViewTest( modelP, 7, viewP, 3 ) );
+		it( 'should transform modelP 7', () => createToViewTest( modelP, 7, viewTextBAR, 3 ) );
 		it( 'should transform modelP 8', () => createToViewTest( modelP, 8, viewP, 4 ) );
-		it( 'should transform modelP 9', () => createToViewTest( modelP, 9, viewU, 1 ) );
-		it( 'should transform modelP 10', () => createToViewTest( modelP, 10, viewU, 2 ) );
+		it( 'should transform modelP 9', () => createToViewTest( modelP, 9, viewTextB, 1 ) );
+		it( 'should transform modelP 10', () => createToViewTest( modelP, 10, viewTextM, 0 ) );
 		it( 'should transform modelP 11', () => createToViewTest( modelP, 11, viewP, 5 ) );
 	} );
 
@@ -229,7 +229,7 @@ describe( 'Mapper', () => {
 		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.parent ).to.equal( viewTextX );
 			expect( viewRange.start.offset ).to.equal( 0 );
 			expect( viewRange.end.parent ).to.equal( viewTextFOO );
 			expect( viewRange.end.offset ).to.equal( 2 );
@@ -354,17 +354,17 @@ describe( 'Mapper for widget', () => {
 			expect( viewPosition.offset ).to.equal( viewOffset );
 		}
 
-		it( 'should transform modelDiv 0', () => createToViewTest( modelDiv, 0, viewDiv, 0 ) );
-		it( 'should transform modelDiv 1', () => createToViewTest( modelDiv, 1, viewDiv, 1 ) );
-		it( 'should transform modelDiv 2', () => createToViewTest( modelDiv, 2, viewDiv, 2 ) );
+		it( 'should transform modelDiv 0', () => createToViewTest( modelDiv, 0, viewTextX, 0 ) );
+		it( 'should transform modelDiv 1', () => createToViewTest( modelDiv, 1, viewTextX, 1 ) );
+		it( 'should transform modelDiv 2', () => createToViewTest( modelDiv, 2, viewTextZZ, 0 ) );
 		it( 'should transform modelDiv 3', () => createToViewTest( modelDiv, 3, viewTextZZ, 1 ) );
-		it( 'should transform modelDiv 4', () => createToViewTest( modelDiv, 4, viewDiv, 3 ) );
+		it( 'should transform modelDiv 4', () => createToViewTest( modelDiv, 4, viewTextZZ, 2 ) );
 
 		it( 'should transform modelImg 0', () => createToViewTest( modelImg, 0, viewImg, 0 ) );
 
-		it( 'should transform modelCaption 0', () => createToViewTest( modelCaption, 0, viewCaption, 0 ) );
+		it( 'should transform modelCaption 0', () => createToViewTest( modelCaption, 0, viewTextFOO, 0 ) );
 		it( 'should transform modelCaption 1', () => createToViewTest( modelCaption, 1, viewTextFOO, 1 ) );
 		it( 'should transform modelCaption 2', () => createToViewTest( modelCaption, 2, viewTextFOO, 2 ) );
-		it( 'should transform modelCaption 3', () => createToViewTest( modelCaption, 3, viewCaption, 1 ) );
+		it( 'should transform modelCaption 3', () => createToViewTest( modelCaption, 3, viewTextFOO, 3 ) );
 	} );
 } );

+ 73 - 2
packages/ckeditor5-engine/tests/treecontroller/model-converter-builder.js

@@ -13,12 +13,14 @@ 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 ViewSelection from '/ckeditor5/engine/treeview/selection.js';
 
 import Mapper from '/ckeditor5/engine/treecontroller/mapper.js';
 import ModelConversionDispatcher from '/ckeditor5/engine/treecontroller/modelconversiondispatcher.js';
@@ -29,6 +31,11 @@ import {
 	remove
 } from '/ckeditor5/engine/treecontroller/model-to-view-converters.js';
 
+import {
+	convertCollapsedSelection,
+	clearAttributes
+} from '/ckeditor5/engine/treecontroller/model-selection-to-view-converters.js';
+
 function viewAttributesToString( item ) {
 	let result = '';
 
@@ -63,7 +70,7 @@ function viewToString( item ) {
 }
 
 describe( 'Model converter builder', () => {
-	let dispatcher, modelDoc, modelRoot, viewRoot, mapper, writer;
+	let dispatcher, modelDoc, modelRoot, viewRoot, mapper, writer, viewSelection;
 
 	beforeEach( () => {
 		modelDoc = new ModelDocument();
@@ -75,8 +82,9 @@ describe( 'Model converter builder', () => {
 		mapper.bindElements( modelRoot, viewRoot );
 
 		writer = new ViewWriter();
+		viewSelection = new ViewSelection();
 
-		dispatcher = new ModelConversionDispatcher( { writer, mapper } );
+		dispatcher = new ModelConversionDispatcher( { writer, mapper, viewSelection } );
 
 		dispatcher.on( 'insert:$text', insertText() );
 		dispatcher.on( 'move', move() );
@@ -189,6 +197,69 @@ describe( 'Model converter builder', () => {
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div>foo</div>' );
 		} );
+
+		it( 'selection conversion', () => {
+			// This test requires collapsed range selection converter (breaking attributes)  and clearing "artifacts".
+			dispatcher.on( 'selection', clearAttributes() );
+			dispatcher.on( 'selection', convertCollapsedSelection() );
+
+			// Model converter builder should add selection converter.
+			BuildModelConverterFor( dispatcher ).fromAttribute( 'italic' ).toElement( ( value ) => new ViewAttributeElement( value ) );
+
+			modelRoot.appendChildren( new ModelText( 'foo', { italic: 'em' } ) );
+
+			// Set collapsed selection after "f".
+			const position = new ModelPosition( modelRoot, [ 1 ] );
+			modelDoc.selection.setRanges( [ new ModelRange( position, position ) ] );
+			modelDoc.selection._updateAttributes();
+
+			// Convert stuff.
+			dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+			dispatcher.convertSelection( modelDoc.selection );
+
+			// Check if view structure is ok.
+			expect( viewToString( viewRoot ) ).to.equal( '<div><em>foo</em></div>' );
+
+			// Check if view selection is collapsed after "f" letter.
+			let ranges = Array.from( viewSelection.getRanges() );
+			expect( ranges.length ).to.equal( 1 );
+			expect( ranges[ 0 ].start.isEqual( ranges[ 0 ].end ) ).to.be.true;
+			expect( ranges[ 0 ].start.parent ).to.be.instanceof( ViewText ); // "foo".
+			expect( ranges[ 0 ].start.offset ).to.equal( 1 );
+
+			// Change selection attribute, convert it.
+			modelDoc.selection.setAttribute( 'italic', 'i' );
+			dispatcher.convertSelection( modelDoc.selection );
+
+			// Check if view structure has changed.
+			expect( viewToString( viewRoot ) ).to.equal( '<div><em>f</em><i></i><em>oo</em></div>' );
+
+			// Check if view selection is inside new <em> element.
+			ranges = Array.from( viewSelection.getRanges() );
+			expect( ranges.length ).to.equal( 1 );
+			expect( ranges[ 0 ].start.isEqual( ranges[ 0 ].end ) ).to.be.true;
+			expect( ranges[ 0 ].start.parent.name ).to.equal( 'i' );
+			expect( ranges[ 0 ].start.offset ).to.equal( 0 );
+
+			// Some more tests checking how selection attributes changes are converted:
+			modelDoc.selection.removeAttribute( 'italic' );
+			dispatcher.convertSelection( modelDoc.selection );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><em>f</em><em>oo</em></div>' );
+			ranges = Array.from( viewSelection.getRanges() );
+			expect( ranges[ 0 ].start.parent.name ).to.equal( 'div' );
+			expect( ranges[ 0 ].start.offset ).to.equal( 1 );
+
+			modelDoc.selection.setAttribute( 'italic', 'em' );
+			dispatcher.convertSelection( modelDoc.selection );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><em>foo</em></div>' );
+			ranges = Array.from( viewSelection.getRanges() );
+			expect( ranges.length ).to.equal( 1 );
+			expect( ranges[ 0 ].start.isEqual( ranges[ 0 ].end ) ).to.be.true;
+			expect( ranges[ 0 ].start.parent ).to.be.instanceof( ViewText ); // "foo".
+			expect( ranges[ 0 ].start.offset ).to.equal( 1 );
+		} );
 	} );
 
 	describe( 'model attribute to view attribute conversion', () => {

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

@@ -0,0 +1,469 @@
+/**
+ * @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 ModelRange from '/ckeditor5/engine/treemodel/range.js';
+import ModelPosition from '/ckeditor5/engine/treemodel/position.js';
+
+import ViewContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
+import ViewAttributeElement from '/ckeditor5/engine/treeview/attributeelement.js';
+import ViewWriter from  '/ckeditor5/engine/treeview/writer.js';
+import ViewSelection from  '/ckeditor5/engine/treeview/selection.js';
+
+import Mapper from '/ckeditor5/engine/treecontroller/mapper.js';
+import ModelConversionDispatcher from '/ckeditor5/engine/treecontroller/modelconversiondispatcher.js';
+import {
+	convertRangeSelection,
+	convertCollapsedSelection,
+	convertSelectionAttribute,
+	clearAttributes
+} from '/ckeditor5/engine/treecontroller/model-selection-to-view-converters.js';
+
+import {
+	insertElement,
+	insertText,
+	wrap
+} from '/ckeditor5/engine/treecontroller/model-to-view-converters.js';
+
+let dispatcher, modelDoc, modelRoot, modelSelection, mapper, viewRoot, writer, viewSelection;
+
+beforeEach( () => {
+	modelDoc = new ModelDocument();
+	modelRoot = modelDoc.createRoot( 'main' );
+	modelSelection = modelDoc.selection;
+
+	viewRoot = new ViewContainerElement( 'viewRoot' );
+
+	mapper = new Mapper();
+	mapper.bindElements( modelRoot, viewRoot );
+
+	writer = new ViewWriter();
+	viewSelection = new ViewSelection();
+
+	dispatcher = new ModelConversionDispatcher( { mapper, writer, viewSelection } );
+
+	dispatcher.on( 'insert:$text', insertText() );
+	dispatcher.on( 'addAttribute:bold', wrap( new ViewAttributeElement( 'strong' ) ) );
+
+	// Default selection converters.
+	dispatcher.on( 'selection', clearAttributes() );
+	dispatcher.on( 'selection', convertRangeSelection() );
+	dispatcher.on( 'selection', convertCollapsedSelection() );
+} );
+
+describe( 'default converters', () => {
+	beforeEach( () => {
+		// Selection converters for selection attributes.
+		dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'strong' ) ) );
+		dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
+	} );
+
+	describe( 'range selection', () => {
+		it( 'in same container', () => {
+			test(
+				[ 1, 4 ],
+				'foobar',
+				'f{oob}ar'
+			);
+		} );
+
+		it( 'in same container, over attribute', () => {
+			test(
+				[ 1, 5 ],
+				'fo<$text bold=true>ob</$text>ar',
+				'f{o<strong>ob</strong>a}r'
+			);
+		} );
+
+		it( 'in same container, next to attribute', () => {
+			test(
+				[ 1, 2 ],
+				'fo<$text bold=true>ob</$text>ar',
+				'f{o}<strong>ob</strong>ar'
+			);
+		} );
+
+		it( 'in same attribute', () => {
+			test(
+				[ 2, 4 ],
+				'f<$text bold=true>ooba</$text>r',
+				'f<strong>o{ob}a</strong>r'
+			);
+		} );
+
+		it( 'in same attribute, selection same as attribute', () => {
+			test(
+				[ 2, 4 ],
+				'fo<$text bold=true>ob</$text>ar',
+				'fo{<strong>ob</strong>}ar'
+			);
+		} );
+
+		it( 'starts in text node, ends in attribute #1', () => {
+			test(
+				[ 1, 3 ],
+				'fo<$text bold=true>ob</$text>ar',
+				'f{o<strong>o}b</strong>ar'
+			);
+		} );
+
+		it( 'starts in text node, ends in attribute #2', () => {
+			test(
+				[ 1, 4 ],
+				'fo<$text bold=true>ob</$text>ar',
+				'f{o<strong>ob</strong>}ar'
+			);
+		} );
+
+		it( 'starts in attribute, ends in text node', () => {
+			test(
+				[ 3, 5 ],
+				'fo<$text bold=true>ob</$text>ar',
+				'fo<strong>o{b</strong>a}r'
+			);
+		} );
+
+		it( 'consumes consumable values properly', () => {
+			// Add callback that will fire before default ones.
+			// This should prevent default callback doing anything.
+			dispatcher.on( 'selection', ( evt, data, consumable ) => {
+				expect( consumable.consume( data.selection, 'selection' ) ).to.be.true;
+			}, null, 0 );
+
+			// Similar test case as the first in this suite.
+			test(
+				[ 1, 4 ],
+				'foobar',
+				'foobar' // No selection in view.
+			);
+		} );
+
+		it( 'should convert backward selection', () => {
+			test(
+				[ 1, 3, 'backward' ],
+				'foobar',
+				'f{oo}bar'
+			);
+
+			expect( viewSelection.focus.offset ).to.equal( 1 );
+		} );
+	} );
+
+	describe( 'collapsed selection', () => {
+		it( 'in container', () => {
+			test(
+				[ 1, 1 ],
+				'foobar',
+				'f{}oobar'
+			);
+		} );
+
+		it( 'in attribute', () => {
+			test(
+				[ 3, 3 ],
+				'f<$text bold=true>ooba</$text>r',
+				'f<strong>oo{}ba</strong>r'
+			);
+		} );
+
+		it( 'in container with extra attributes', () => {
+			test(
+				[ 1, 1 ],
+				'foobar',
+				'f<em>[]</em>oobar',
+				{ italic: true }
+			);
+		} );
+
+		it( 'in attribute with extra attributes', () => {
+			test(
+				[ 3, 3 ],
+				'f<$text bold=true>ooba</$text>r',
+				'f<strong>oo</strong><em><strong>[]</strong></em><strong>ba</strong>r',
+				{ italic: true }
+			);
+		} );
+
+		it( 'consumes consumable values properly', () => {
+			// Add callbacks that will fire before default ones.
+			// This should prevent default callbacks doing anything.
+			dispatcher.on( 'selection', ( evt, data, consumable ) => {
+				expect( consumable.consume( data.selection, 'selection' ) ).to.be.true;
+			}, null, 0 );
+
+			dispatcher.on( 'selectionAttribute:bold', ( evt, data, consumable ) => {
+				expect( consumable.consume( data.selection, 'selectionAttribute:bold' ) ).to.be.true;
+			}, null, 0 );
+
+			// Similar test case as above
+			test(
+				[ 3, 3 ],
+				'f<$text bold=true>ooba</$text>r',
+				'f<strong>ooba</strong>r' // No selection in view.
+			);
+		} );
+	} );
+} );
+
+describe( 'clean-up', () => {
+	describe( 'convertRangeSelection', () => {
+		it( 'should remove all ranges before adding new range', () => {
+			test(
+				[ 0, 2 ],
+				'foobar',
+				'{fo}obar'
+			);
+
+			test(
+				[ 3, 5 ],
+				'foobar',
+				'foo{ba}r'
+			);
+
+			expect( viewSelection.rangeCount ).to.equal( 1 );
+		} );
+	} );
+
+	describe( 'convertCollapsedSelection', () => {
+		it( 'should remove all ranges before adding new range', () => {
+			test(
+				[ 2, 2 ],
+				'foobar',
+				'fo{}obar'
+			);
+
+			test(
+				[ 3, 3 ],
+				'foobar',
+				'foo{}bar'
+			);
+
+			expect( viewSelection.rangeCount ).to.equal( 1 );
+		} );
+	} );
+
+	describe( 'clearAttributes', () => {
+		it( 'should remove all ranges before adding new range', () => {
+			dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'b' ) ) );
+			dispatcher.on( 'addAttribute:style', wrap( new ViewAttributeElement( 'b' ) ) );
+
+			test(
+				[ 3, 3 ],
+				'foobar',
+				'foo<b>[]</b>bar',
+				{ bold: 'true' }
+			);
+
+			const modelRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 1 );
+			modelDoc.selection.setRanges( [ modelRange ] );
+
+			dispatcher.convertSelection( modelDoc.selection );
+
+			expect( viewSelection.rangeCount ).to.equal( 1 );
+
+			const viewString = bender.view.stringify( viewRoot, viewSelection, { showType: false } );
+			expect( viewString ).to.equal( '<viewRoot>f{}oobar</viewRoot>' );
+		} );
+	} );
+} );
+
+describe( 'using element creator for attributes conversion', () => {
+	beforeEach( () => {
+		function styleElementCreator( styleValue ) {
+			if ( styleValue == 'important' ) {
+				return new ViewAttributeElement( 'strong', { style: 'text-transform:uppercase;' } );
+			} else if ( styleValue == 'gold' ) {
+				return new ViewAttributeElement( 'span', { style: 'color:yellow;' } );
+			}
+		}
+
+		dispatcher.on( 'selectionAttribute:style', convertSelectionAttribute( styleElementCreator ) );
+		dispatcher.on( 'addAttribute:style', wrap( styleElementCreator ) );
+
+		dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
+	} );
+
+	describe( 'range selection', () => {
+		it( 'in same container, over attribute', () => {
+			test(
+				[ 1, 5 ],
+				'fo<$text style="gold">ob</$text>ar',
+				'f{o<span style="color:yellow;">ob</span>a}r'
+			);
+		} );
+
+		it( 'in same attribute', () => {
+			test(
+				[ 2, 4 ],
+				'f<$text style="gold">ooba</$text>r',
+				'f<span style="color:yellow;">o{ob}a</span>r'
+			);
+		} );
+
+		it( 'in same attribute, selection same as attribute', () => {
+			test(
+				[ 2, 4 ],
+				'fo<$text style="important">ob</$text>ar',
+				'fo{<strong style="text-transform:uppercase;">ob</strong>}ar'
+			);
+		} );
+
+		it( 'starts in attribute, ends in text node', () => {
+			test(
+				[ 3, 5 ],
+				'fo<$text style="important">ob</$text>ar',
+				'fo<strong style="text-transform:uppercase;">o{b</strong>a}r'
+			);
+		} );
+	} );
+
+	describe( 'collapsed selection', () => {
+		it( 'in attribute', () => {
+			test(
+				[ 3, 3 ],
+				'f<$text style="gold">ooba</$text>r',
+				'f<span style="color:yellow;">oo{}ba</span>r'
+			);
+		} );
+
+		it( 'in container with style attribute', () => {
+			test(
+				[ 1, 1 ],
+				'foobar',
+				'f<strong style="text-transform:uppercase;">[]</strong>oobar',
+				{ style: 'important' }
+			);
+		} );
+
+		it( 'in style attribute with extra attributes #1', () => {
+			test(
+				[ 3, 3 ],
+				'f<$text style="gold">ooba</$text>r',
+				'f<span style="color:yellow;">oo</span>' +
+				'<em><span style="color:yellow;">[]</span></em>' +
+				'<span style="color:yellow;">ba</span>r',
+				{ italic: true }
+			);
+		} );
+
+		it( 'in style attribute with extra attributes #2', () => {
+			// In contrary to test above, we don't have strong + span on the selection.
+			// This is because strong and span are both created by the same attribute.
+			// Since style="important" overwrites style="gold" on selection, we have only strong element.
+			// In example above, selection has both style and italic attribute.
+			test(
+				[ 3, 3 ],
+				'f<$text style="gold">ooba</$text>r',
+				'f<span style="color:yellow;">oo</span>' +
+				'<strong style="text-transform:uppercase;">[]</strong>' +
+				'<span style="color:yellow;">ba</span>r',
+				{ style: 'important' }
+			);
+		} );
+	} );
+} );
+
+describe( 'table cell selection converter', () => {
+	beforeEach( () => {
+		// "Universal" converter to convert table structure.
+		const tableConverter = insertElement( ( data ) => new ViewContainerElement( data.item.name ) );
+		dispatcher.on( 'insert:table', tableConverter );
+		dispatcher.on( 'insert:tr', tableConverter );
+		dispatcher.on( 'insert:td', tableConverter );
+
+		// Special converter for table cells.
+		dispatcher.on( 'selection', ( evt, data, consumable, conversionApi ) => {
+			const selection = data.selection;
+
+			if ( !consumable.test( selection, 'selection' ) || selection.isCollapsed ) {
+				return;
+			}
+
+			for ( let range of selection.getRanges() ) {
+				const node = range.start.nodeAfter;
+
+				if ( node == range.end.nodeBefore && node instanceof ModelElement && node.name == 'td' ) {
+					consumable.consume( selection, 'selection' );
+
+					let viewNode = conversionApi.mapper.toViewElement( node );
+					viewNode.addClass( 'selected' );
+				}
+			}
+		}, null, 0 );
+	} );
+
+	it( 'should not be used to convert selection that is not on table cell', () => {
+		test(
+			[ 1, 5 ],
+			'f<selection>o<$text bold=true>ob</$text>a</selection>r',
+			'f{o<strong>ob</strong>a}r'
+		);
+	} );
+
+	it( 'should add a class to the selected table cell', () => {
+		test(
+			// table tr#0, table tr#1
+			[ [ 0, 0, 0 ], [ 0, 0, 1 ] ],
+			'<table><tr><td>foo</td></tr><tr><td>bar</td></tr></table>',
+			'<table><tr><td class="selected">foo</td></tr><tr><td>bar</td></tr></table>'
+		);
+	} );
+
+	it( 'should not be used if selection contains more than just a table cell', () => {
+		test(
+			// table tr td#1, table tr#2
+			[ [ 0, 0, 0, 1 ], [ 0, 0, 2 ] ],
+			'<table><tr><td>foo</td><td>bar</td></tr></table>',
+			'<table><tr><td>f{oo</td><td>bar</td>]</tr></table>'
+		);
+	} );
+} );
+
+// Tests if the selection got correctly converted.
+// Because `setData` might use selection converters itself to set the selection, we can't use it
+// to set the selection (because then we would test converters using converters).
+// Instead, the `test` function expects to be passed `selectionPaths` which is an array containing two numbers or two arrays,
+// that are offsets or paths of selection positions in root element.
+function test( selectionPaths, modelInput, expectedView, selectionAttributes = {} ) {
+	// Parse passed `modelInput` string and set it as current model.
+	bender.model.setData( modelDoc, modelInput );
+
+	// Manually set selection ranges using passed `selectionPaths`.
+	const startPath = typeof selectionPaths[ 0 ] == 'number' ? [ selectionPaths[ 0 ] ] : selectionPaths[ 0 ];
+	const endPath = typeof selectionPaths[ 1 ] == 'number' ? [ selectionPaths[ 1 ] ] : selectionPaths[ 1 ];
+	const startPos = new ModelPosition( modelRoot, startPath );
+	const endPos = new ModelPosition( modelRoot, endPath );
+	const isBackward = selectionPaths[ 2 ] === 'backward';
+	modelSelection.setRanges( [ new ModelRange( startPos, endPos ) ], isBackward );
+
+	// Updated selection attributes according to model.
+	modelSelection._updateAttributes();
+
+	// And add or remove passed attributes.
+	for ( let key in selectionAttributes ) {
+		let value = selectionAttributes[ key ];
+
+		if ( value ) {
+			modelSelection.setAttribute( key, value );
+		} else {
+			modelSelection.removeAttribute( key );
+		}
+	}
+
+	// Remove view children since we do not want to convert deletion.
+	viewRoot.removeChildren( 0, viewRoot.getChildCount() );
+
+	// Convert model to view.
+	dispatcher.convertInsert( ModelRange.createFromElement( modelRoot ) );
+	dispatcher.convertSelection( modelSelection );
+
+	// Stringify view and check if it is same as expected.
+	expect( bender.view.stringify( viewRoot, viewSelection, { showType: false } ) ).to.equal( '<viewRoot>' + expectedView + '</viewRoot>' );
+}

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

@@ -353,4 +353,76 @@ describe( 'ModelConversionDispatcher', () => {
 			expect( dispatcher.fire.calledWith( 'removeAttribute:attr:inside' ) ).to.be.false;
 		} );
 	} );
+
+	describe( 'convertSelection', () => {
+		beforeEach( () => {
+			dispatcher.off( 'selection' );
+
+			root.appendChildren( 'foobar' );
+			doc.selection.setRanges( [
+				new ModelRange( new ModelPosition( root, [ 1 ] ), new ModelPosition( root, [ 3 ] ) ),
+				new ModelRange( new ModelPosition( root, [ 4 ] ), new ModelPosition( root, [ 5 ] ) )
+			] );
+		} );
+
+		it( 'should fire selection event', () => {
+			sinon.spy( dispatcher, 'fire' );
+
+			dispatcher.convertSelection( doc.selection );
+
+			expect( dispatcher.fire.calledWith(
+				'selection',
+				{ selection: sinon.match.instanceOf( doc.selection.constructor ) }
+			) ).to.be.true;
+		} );
+
+		it( 'should prepare correct list of consumable values', () => {
+			doc.enqueueChanges( () => {
+				doc.batch()
+					.setAttr( 'bold', true, ModelRange.createFromElement( root ) )
+					.setAttr( 'italic', true, ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ) );
+			} );
+
+			dispatcher.on( 'selection', ( evt, data, consumable ) => {
+				expect( consumable.test( data.selection, 'selection' ) ).to.be.true;
+				expect( consumable.test( data.selection, 'selectionAttribute:bold' ) ).to.be.true;
+				expect( consumable.test( data.selection, 'selectionAttribute:italic' ) ).to.be.null;
+			} );
+
+			dispatcher.convertSelection( doc.selection );
+		} );
+
+		it( 'should fire attributes events for selection', () => {
+			sinon.spy( dispatcher, 'fire' );
+
+			doc.enqueueChanges( () => {
+				doc.batch()
+					.setAttr( 'bold', true, ModelRange.createFromElement( root ) )
+					.setAttr( 'italic', true, ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ) );
+			} );
+
+			dispatcher.convertSelection( doc.selection );
+
+			expect( dispatcher.fire.calledWith( 'selectionAttribute:bold' ) ).to.be.true;
+			expect( dispatcher.fire.calledWith( 'selectionAttribute:italic' ) ).to.be.false;
+		} );
+
+		it( 'should not fire attributes events if attribute has been consumed', () => {
+			sinon.spy( dispatcher, 'fire' );
+
+			dispatcher.on( 'selection', ( evt, data, consumable ) => {
+				consumable.consume( data.selection, 'selectionAttribute:bold' );
+			} );
+
+			doc.enqueueChanges( () => {
+				doc.batch()
+					.setAttr( 'bold', true, ModelRange.createFromElement( root ) )
+					.setAttr( 'italic', true, ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ) );
+			} );
+
+			dispatcher.convertSelection( doc.selection );
+
+			expect( dispatcher.fire.calledWith( 'selectionAttribute:bold' ) ).to.be.false;
+		} );
+	} );
 } );

+ 9 - 11
packages/ckeditor5-engine/tests/treemodel/delta/transform/_utils/utils.js

@@ -152,17 +152,15 @@ export function expectDelta( delta, expected ) {
 
 export function expectOperation( op, params ) {
 	for ( let i in params ) {
-		if ( params.hasOwnProperty( i ) ) {
-			if ( i == 'type' ) {
-				expect( op ).to.be.instanceof( params[ i ] );
-			}
-			else if ( i == 'nodes' ) {
-				expect( op.nodeList._nodes ).to.deep.equal( params[ i ] );
-			} else if ( params[ i ] instanceof Position || params[ i ] instanceof Range ) {
-				expect( op[ i ].isEqual( params[ i ] ) ).to.be.true;
-			} else {
-				expect( op[ i ] ).to.equal( params[ i ] );
-			}
+		if ( i == 'type' ) {
+			expect( op ).to.be.instanceof( params[ i ] );
+		}
+		else if ( i == 'nodes' ) {
+			expect( op.nodeList._nodes ).to.deep.equal( params[ i ] );
+		} else if ( params[ i ] instanceof Position || params[ i ] instanceof Range ) {
+			expect( op[ i ].isEqual( params[ i ] ) ).to.be.true;
+		} else {
+			expect( op[ i ] ).to.equal( params[ i ] );
 		}
 	}
 }

+ 21 - 0
packages/ckeditor5-engine/tests/treeview/selection.js

@@ -121,6 +121,27 @@ describe( 'Selection', () => {
 		} );
 	} );
 
+	describe( 'isBackward', () => {
+		it( 'is defined by the last added range', () => {
+			const range1 = Range.createFromParentsAndOffsets( el, 5, el, 10 );
+			const range2 = Range.createFromParentsAndOffsets( el, 15, el, 16 );
+
+			selection.addRange( range1, true );
+			expect( selection ).to.have.property( 'isBackward', true );
+
+			selection.addRange( range2 );
+			expect( selection ).to.have.property( 'isBackward', false );
+		} );
+
+		it( 'is false when last range is collapsed', () => {
+			const range = Range.createFromParentsAndOffsets( el, 5, el, 5 );
+
+			selection.addRange( range, true );
+
+			expect( selection.isBackward ).to.be.false;
+		} );
+	} );
+
 	describe( 'addRange', () => {
 		it( 'should add range to selection ranges', () => {
 			selection.addRange( range1 );

+ 22 - 18
packages/ckeditor5-engine/tests/treeview/writer/breakattributes.js

@@ -8,9 +8,6 @@
 'use strict';
 
 import Writer from '/ckeditor5/engine/treeview/writer.js';
-import ContainerElement from '/ckeditor5/engine/treeview/containerelement.js';
-import Text from '/ckeditor5/engine/treeview/text.js';
-import Position from '/ckeditor5/engine/treeview/position.js';
 import { stringify, parse } from '/tests/engine/_utils/view.js';
 
 describe( 'Writer', () => {
@@ -34,25 +31,25 @@ describe( 'Writer', () => {
 	} );
 
 	describe( 'breakAttributes', () => {
-		it( 'should move position from begin of text node to the element', () => {
-			test( '<container:p>{}foobar</container:p>', '<container:p>[]foobar</container:p>' );
+		it( 'should not break text nodes if they are not in attribute elements - middle', () => {
+			test(
+				'<container:p>foo{}bar</container:p>',
+				'<container:p>foo{}bar</container:p>'
+			);
 		} );
 
-		it( 'should split text node', () => {
-			const text = new Text( 'foobar' );
-			const container = new ContainerElement( 'p', null, text );
-			const position = new Position( text, 3 );
-
-			const newPosition = writer.breakAttributes( position );
-
-			expect( container.getChildCount() ).to.equal( 2 );
-			expect( container.getChild( 0 ) ).to.be.instanceOf( Text ).and.have.property( 'data' ).that.equal( 'foo' );
-			expect( container.getChild( 1 ) ).to.be.instanceOf( Text ).and.have.property( 'data' ).that.equal( 'bar' );
-			expect( newPosition.isEqual( new Position( container, 1 ) ) ).to.be.true;
+		it( 'should not break text nodes if they are not in attribute elements - beginning', () => {
+			test(
+				'<container:p>{}foobar</container:p>',
+				'<container:p>{}foobar</container:p>'
+			);
 		} );
 
-		it( 'should move position from end of text node to the element', () => {
-			test( '<container:p>foobar{}</container:p>', '<container:p>foobar[]</container:p>' );
+		it( 'should not break text nodes if they are not in attribute elements #2 - end', () => {
+			test(
+				'<container:p>foobar{}</container:p>',
+				'<container:p>foobar{}</container:p>'
+			);
 		} );
 
 		it( 'should split attribute element', () => {
@@ -69,6 +66,13 @@ describe( 'Writer', () => {
 			);
 		} );
 
+		it( 'should stick selection in text node if it is in container', () => {
+			test(
+				'<container:p>foo{}<attribute:b:1>bar</attribute:b:1></container:p>',
+				'<container:p>foo{}<attribute:b:1>bar</attribute:b:1></container:p>'
+			);
+		} );
+
 		it( 'should split nested attributes', () => {
 			test(
 				'<container:p><attribute:b:1><attribute:u:1>foo{}bar</attribute:u:1></attribute:b:1></container:p>',

+ 22 - 15
packages/ckeditor5-engine/tests/treeview/writer/breakrange.js

@@ -24,7 +24,7 @@ describe( 'Writer', () => {
 	function test( input, expected ) {
 		const { view, selection } = parse( input );
 		const newRange = writer.breakRange( selection.getFirstRange() );
-		expect( stringify( view, newRange, { showType: true, showPriority: true } ) ).to.equal( expected );
+		expect( stringify( view, newRange, { showType: true } ) ).to.equal( expected );
 	}
 
 	beforeEach( () => {
@@ -41,52 +41,59 @@ describe( 'Writer', () => {
 			} ).to.throw( 'treeview-writer-invalid-range-container' );
 		} );
 
-		it( 'should break at collapsed range and return collapsed one', () => {
+		it( 'should not break text nodes if they are not in attribute elements', () => {
 			test(
 				'<container:p>foo{}bar</container:p>',
-				'<container:p>foo[]bar</container:p>'
+				'<container:p>foo{}bar</container:p>'
+			);
+		} );
+
+		it( 'should break at collapsed range and return collapsed one', () => {
+			test(
+				'<container:p><attribute:b>foo{}bar</attribute:b></container:p>',
+				'<container:p><attribute:b>foo</attribute:b>[]<attribute:b>bar</attribute:b></container:p>'
 			);
 		} );
 
 		it( 'should break inside text node #1', () => {
 			test(
-				'<container:p>foo{bar}baz</container:p>',
-				'<container:p>foo[bar]baz</container:p>'
+				'<container:p><attribute:b>foo{bar}baz</attribute:b></container:p>',
+				'<container:p><attribute:b>foo</attribute:b>[<attribute:b>bar</attribute:b>]<attribute:b>baz</attribute:b></container:p>'
 			);
 		} );
 
 		it( 'should break inside text node #2', () => {
 			test(
-				'<container:p>foo{barbaz}</container:p>',
-				'<container:p>foo[barbaz]</container:p>'
+				'<container:p><attribute:b>foo{barbaz}</attribute:b></container:p>',
+				'<container:p><attribute:b>foo</attribute:b>[<attribute:b>barbaz</attribute:b>]</container:p>'
 			);
 		} );
 
 		it( 'should break inside text node #3', () => {
 			test(
-				'<container:p>foo{barbaz]</container:p>',
-				'<container:p>foo[barbaz]</container:p>'
+				'<container:p><attribute:b>foo{barbaz]</attribute:b></container:p>',
+				'<container:p><attribute:b>foo</attribute:b>[<attribute:b>barbaz</attribute:b>]</container:p>'
 			);
 		} );
 
 		it( 'should break inside text node #4', () => {
 			test(
-				'<container:p>{foo}barbaz</container:p>',
-				'<container:p>[foo]barbaz</container:p>'
+				'<container:p><attribute:b>{foo}barbaz</attribute:b></container:p>',
+				'<container:p>[<attribute:b>foo</attribute:b>]<attribute:b>barbaz</attribute:b></container:p>'
 			);
 		} );
 
 		it( 'should break inside text node #5', () => {
 			test(
-				'<container:p>[foo}barbaz</container:p>',
-				'<container:p>[foo]barbaz</container:p>'
+				'<container:p><attribute:b>[foo}barbaz</attribute:b></container:p>',
+				'<container:p>[<attribute:b>foo</attribute:b>]<attribute:b>barbaz</attribute:b></container:p>'
 			);
 		} );
 
 		it( 'should break placed inside different nodes', () => {
 			test(
-				'<container:p>foo{bar<attribute:b:1>baz}qux</attribute:b:1></container:p>',
-				'<container:p>foo[bar<attribute:b:1>baz</attribute:b:1>]<attribute:b:1>qux</attribute:b:1></container:p>'
+				'<container:p>foo{bar<attribute:b>baz}qux</attribute:b></container:p>',
+				'<container:p>foo{bar<attribute:b>baz</attribute:b>]<attribute:b>qux</attribute:b></container:p>'
 			);
 		} );
 	} );