Переглянути джерело

Merge branch 'master' into t/1210

Szymon Kupś 8 роки тому
батько
коміт
489a2dee27

+ 0 - 5
packages/ckeditor5-engine/src/conversion/buildmodelconverter.js

@@ -18,8 +18,6 @@ import {
 	removeHighlight
 } from './model-to-view-converters';
 
-import { convertSelectionAttribute, convertSelectionMarker } from './model-selection-to-view-converters';
-
 import ViewAttributeElement from '../view/attributeelement';
 import ViewContainerElement from '../view/containerelement';
 import ViewUIElement from '../view/uielement';
@@ -257,7 +255,6 @@ class ModelConverterBuilder {
 				element = typeof element == 'string' ? new ViewAttributeElement( element ) : element;
 
 				dispatcher.on( 'attribute:' + this._from.key, wrap( element ), { priority } );
-				dispatcher.on( 'selectionAttribute:' + this._from.key, convertSelectionAttribute( element ), { priority } );
 			} else {
 				// From marker to element.
 				const priority = this._from.priority === null ? 'normal' : this._from.priority;
@@ -327,8 +324,6 @@ class ModelConverterBuilder {
 			dispatcher.on( 'addMarker:' + this._from.name, highlightElement( highlightDescriptor ), { priority } );
 
 			dispatcher.on( 'removeMarker:' + this._from.name, removeHighlight( highlightDescriptor ), { priority } );
-
-			dispatcher.on( 'selectionMarker:' + this._from.name, convertSelectionMarker( highlightDescriptor ), { priority } );
 		}
 	}
 

+ 3 - 157
packages/ckeditor5-engine/src/conversion/model-selection-to-view-converters.js

@@ -3,9 +3,7 @@
  * For licensing, see LICENSE.md.
  */
 
-import ViewElement from '../view/element';
 import ViewRange from '../view/range';
-import { createViewElementFromHighlightDescriptor } from './model-to-view-converters';
 
 /**
  * Contains {@link module:engine/model/selection~Selection model selection} to
@@ -58,11 +56,9 @@ export function convertRangeSelection() {
  *		   <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 element. See also complementary
- * {@link module:engine/conversion/model-selection-to-view-converters~convertSelectionAttribute attribute converter}
- * for selection attributes,
- * which wraps collapsed selection into view elements. Those converters together ensure, that selection ends up in
- * appropriate attribute elements.
+ * By breaking attribute elements like `<strong>`, selection is in correct element. Then, when selection attribute is
+ * converted, the broken attributes might be merged again, or the position where the selection is may be wrapped
+ * in different, appropriate attribute elements.
  *
  * See also {@link module:engine/conversion/model-selection-to-view-converters~clearAttributes} which does a clean-up
  * by merging attributes.
@@ -90,156 +86,6 @@ export function convertCollapsedSelection() {
 	};
 }
 
-/**
- * Function factory, creates a converter that converts {@link module:engine/model/selection~Selection model selection} attributes to
- * {@link module:engine/view/attributeelement~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 module:engine/view/element~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 module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:selectionAttribute selectionAttribute event}.
- * It's expected that the function returns a {@link module:engine/view/attributeelement~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 ) );
- *		modelDispatcher.on( 'selection', convertCollapsedSelection() );
- *		modelDispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
- *		modelDispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'strong' ) ) );
- *
- * 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>
- *
- * 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 and has italic attribute instead (let's assume that user turned off bold and turned
- * on italic with selection collapsed):
- *
- *		   <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.
- *
- * @param {module:engine/view/attributeelement~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 viewElement = elementCreator instanceof ViewElement ?
-			elementCreator.clone( true ) :
-			elementCreator( data.value, data, data.selection, consumable, conversionApi );
-
-		if ( !viewElement ) {
-			return;
-		}
-
-		const consumableName = 'selectionAttribute:' + data.key;
-
-		wrapCollapsedSelectionPosition(
-			data.selection,
-			conversionApi.viewSelection,
-			viewElement,
-			consumable,
-			consumableName,
-			conversionApi.writer
-		);
-	};
-}
-
-/**
- * Performs similar conversion as {@link ~convertSelectionAttribute}, but depends on a marker name of a marker in which
- * collapsed selection is placed.
- *
- *		modelDispatcher.on( 'selectionMarker:searchResult', convertSelectionMarker( { class: 'search' } ) );
- *
- * @see module:engine/conversion/model-selection-to-view-converters~convertSelectionAttribute
- * @param {module:engine/conversion/model-to-view-converters~HighlightDescriptor|Function} highlightDescriptor Highlight
- * descriptor object or function returning a descriptor object.
- * @returns {Function} Selection converter.
- */
-export function convertSelectionMarker( highlightDescriptor ) {
-	return ( evt, data, consumable, conversionApi ) => {
-		const descriptor = typeof highlightDescriptor == 'function' ?
-			highlightDescriptor( data, consumable, conversionApi ) :
-			highlightDescriptor;
-
-		if ( !descriptor ) {
-			return;
-		}
-
-		if ( !descriptor.id ) {
-			descriptor.id = data.markerName;
-		}
-
-		const viewElement = createViewElementFromHighlightDescriptor( descriptor );
-
-		wrapCollapsedSelectionPosition(
-			data.selection,
-			conversionApi.viewSelection,
-			viewElement,
-			consumable,
-			evt.name,
-			conversionApi.writer
-		);
-	};
-}
-
-// Helper function for `convertSelectionAttribute` and `convertSelectionMarker`, which perform similar task.
-function wrapCollapsedSelectionPosition( modelSelection, viewSelection, viewElement, consumable, eventName, writer ) {
-	if ( !modelSelection.isCollapsed ) {
-		return;
-	}
-
-	if ( !consumable.consume( modelSelection, eventName ) ) {
-		return;
-	}
-
-	let viewPosition = viewSelection.getFirstPosition();
-
-	// This hack is supposed to place attribute element *after* all ui elements if the attribute element would be
-	// the only non-ui child and thus receive a block filler.
-	// This is needed to properly render ui elements. Block filler is a <br /> element. If it is placed before
-	// UI element, the ui element will most probably be incorrectly rendered (in next line). #1072.
-	if ( shouldPushAttributeElement( viewPosition.parent ) ) {
-		viewPosition = viewPosition.getLastMatchingPosition( value => value.item.is( 'uiElement' ) );
-	}
-	// End of hack.
-
-	viewPosition = writer.wrapPosition( viewPosition, viewElement );
-
-	viewSelection.removeAllRanges();
-	viewSelection.addRange( new ViewRange( viewPosition, viewPosition ) );
-}
-
-function shouldPushAttributeElement( parent ) {
-	if ( !parent.is( 'element' ) ) {
-		return false;
-	}
-
-	for ( const child of parent.getChildren() ) {
-		if ( !child.is( 'uiElement' ) ) {
-			return false;
-		}
-	}
-
-	return true;
-}
-
 /**
  * Function factory, creates a converter that clears artifacts after the previous
  * {@link module:engine/model/selection~Selection model selection} conversion. It removes all empty

+ 35 - 21
packages/ckeditor5-engine/src/conversion/model-to-view-converters.js

@@ -4,6 +4,8 @@
  */
 
 import ModelRange from '../model/range';
+import ModelSelection from '../model/selection';
+import ModelElement from '../model/element';
 
 import ViewElement from '../view/element';
 import ViewAttributeElement from '../view/attributeelement';
@@ -279,6 +281,8 @@ export function changeAttribute( attributeCreator ) {
 
 /**
  * Function factory, creates a converter that converts set/change/remove attribute changes from the model to the view.
+ * Also can be used to convert selection attributes. In that case, an empty attribute element will be created and the
+ * selection will be put inside it.
  *
  * Attributes from model are converted to a view element that will be wrapping those view nodes that are bound to
  * model elements having given attribute. This is useful for attributes like `bold`, which may be set on text nodes in model
@@ -326,17 +330,23 @@ export function wrap( elementCreator ) {
 			return;
 		}
 
-		let viewRange = conversionApi.mapper.toViewRange( data.range );
-		const writer = conversionApi.writer;
+		const viewWriter = conversionApi.writer;
 
-		// First, unwrap the range from current wrapper.
-		if ( data.attributeOldValue !== null ) {
-			viewRange = writer.unwrap( viewRange, oldViewElement );
-		}
+		if ( data.item instanceof ModelSelection ) {
+			// Selection attribute conversion.
+			viewWriter.wrap( conversionApi.viewSelection.getFirstRange(), newViewElement, conversionApi.viewSelection );
+		} else {
+			// Node attribute conversion.
+			let viewRange = conversionApi.mapper.toViewRange( data.range );
 
-		// Then wrap with the new wrapper.
-		if ( data.attributeNewValue !== null ) {
-			writer.wrap( viewRange, newViewElement );
+			// First, unwrap the range from current wrapper.
+			if ( data.attributeOldValue !== null ) {
+				viewRange = viewWriter.unwrap( viewRange, oldViewElement );
+			}
+
+			if ( data.attributeNewValue !== null ) {
+				viewWriter.wrap( viewRange, newViewElement );
+			}
 		}
 	};
 }
@@ -346,6 +356,9 @@ export function wrap( elementCreator ) {
  * {@link module:engine/view/attributeelement~AttributeElement} created from provided descriptor.
  * See {link module:engine/conversion/model-to-view-converters~createViewElementFromHighlightDescriptor}.
  *
+ * Also can be used to convert selection that is inside a marker. In that case, an empty attribute element will be
+ * created and the selection will be put inside it.
+ *
  * If the highlight descriptor will not provide `priority` property, `10` will be used.
  *
  * If the highlight descriptor will not provide `id` property, name of the marker will be used.
@@ -359,9 +372,7 @@ export function highlightText( highlightDescriptor ) {
 			return;
 		}
 
-		const modelItem = data.item;
-
-		if ( !modelItem.is( 'textProxy' ) ) {
+		if ( !( data.item instanceof ModelSelection ) && !data.item.is( 'textProxy' ) ) {
 			return;
 		}
 
@@ -371,14 +382,19 @@ export function highlightText( highlightDescriptor ) {
 			return;
 		}
 
-		if ( !consumable.consume( modelItem, evt.name ) ) {
+		if ( !consumable.consume( data.item, evt.name ) ) {
 			return;
 		}
 
 		const viewElement = createViewElementFromHighlightDescriptor( descriptor );
-		const viewRange = conversionApi.mapper.toViewRange( data.range );
+		const viewWriter = conversionApi.writer;
 
-		conversionApi.writer.wrap( viewRange, viewElement );
+		if ( data.item instanceof ModelSelection ) {
+			viewWriter.wrap( conversionApi.viewSelection.getFirstRange(), viewElement, conversionApi.viewSelection );
+		} else {
+			const viewRange = conversionApi.mapper.toViewRange( data.range );
+			viewWriter.wrap( viewRange, viewElement );
+		}
 	};
 }
 
@@ -405,9 +421,7 @@ export function highlightElement( highlightDescriptor ) {
 			return;
 		}
 
-		const modelItem = data.item;
-
-		if ( !modelItem.is( 'element' ) ) {
+		if ( !( data.item instanceof ModelElement ) ) {
 			return;
 		}
 
@@ -417,18 +431,18 @@ export function highlightElement( highlightDescriptor ) {
 			return;
 		}
 
-		if ( !consumable.test( modelItem, evt.name ) ) {
+		if ( !consumable.test( data.item, evt.name ) ) {
 			return;
 		}
 
-		const viewElement = conversionApi.mapper.toViewElement( modelItem );
+		const viewElement = conversionApi.mapper.toViewElement( data.item );
 
 		if ( viewElement && viewElement.getCustomProperty( 'addHighlight' ) ) {
 			// Consume element itself.
 			consumable.consume( data.item, evt.name );
 
 			// Consume all children nodes.
-			for ( const value of ModelRange.createIn( modelItem ) ) {
+			for ( const value of ModelRange.createIn( data.item ) ) {
 				consumable.consume( value.item, evt.name );
 			}
 

+ 0 - 4
packages/ckeditor5-engine/src/conversion/modelconsumable.js

@@ -120,7 +120,6 @@ export default class ModelConsumable {
 	 *		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.
 	 *		modelConsumable.add( modelRange, 'range' ); // Add `modelRange` to consumable values.
 	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/selection~Selection|module:engine/model/range~Range} item
@@ -149,7 +148,6 @@ export default class ModelConsumable {
 	 *		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.
 	 *		modelConsumable.consume( modelRange, 'range' ); // Remove 'modelRange' from consumable values.
 	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/selection~Selection|module:engine/model/range~Range} item
@@ -181,7 +179,6 @@ export default class ModelConsumable {
 	 *		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.
 	 *		modelConsumable.test( modelRange, 'range' ); // Check if `modelRange` is consumable.
 	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/selection~Selection|module:engine/model/range~Range} item
@@ -220,7 +217,6 @@ export default class ModelConsumable {
 	 *		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`.
 	 *		modelConsumable.revert( modelRange, 'range' ); // Revert consuming `modelRange`.
 	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/selection~Selection|module:engine/model/range~Range} item

+ 36 - 52
packages/ckeditor5-engine/src/conversion/modelconversiondispatcher.js

@@ -59,9 +59,9 @@ import extend from '@ckeditor/ckeditor5-utils/src/lib/lodash/extend';
  *
  * * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:selection}
  * which converts selection from model to view,
- * * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:selectionAttribute}
+ * * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:attribute}
  * which is fired for every selection attribute,
- * * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:selectionMarker}
+ * * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher#event:addMarker}
  * which is fired for every marker which contains selection.
  *
  * Unlike model tree and markers, events for selection are not fired for changes but for selection state.
@@ -240,8 +240,8 @@ export default class ModelConversionDispatcher {
 	 * Fires events for given {@link module:engine/model/selection~Selection selection} to start selection conversion.
 	 *
 	 * @fires selection
-	 * @fires selectionMarker
-	 * @fires selectionAttribute
+	 * @fires addMarker
+	 * @fires attribute
 	 * @param {module:engine/model/selection~Selection} selection Selection to convert.
 	 */
 	convertSelection( selection, writer ) {
@@ -251,6 +251,10 @@ export default class ModelConversionDispatcher {
 
 		this.fire( 'selection', { selection }, consumable, this.conversionApi );
 
+		if ( !selection.isCollapsed ) {
+			return;
+		}
+
 		for ( const marker of markers ) {
 			const markerRange = marker.getRange();
 
@@ -259,26 +263,28 @@ export default class ModelConversionDispatcher {
 			}
 
 			const data = {
-				selection,
+				item: selection,
 				markerName: marker.name,
 				markerRange
 			};
 
-			if ( consumable.test( selection, 'selectionMarker:' + marker.name ) ) {
-				this.fire( 'selectionMarker:' + marker.name, data, consumable, this.conversionApi );
+			if ( consumable.test( selection, 'addMarker:' + marker.name ) ) {
+				this.fire( 'addMarker:' + marker.name, data, consumable, this.conversionApi );
 			}
 		}
 
 		for ( const key of selection.getAttributeKeys() ) {
 			const data = {
-				selection,
-				key,
-				value: selection.getAttribute( key )
+				item: selection,
+				range: selection.getFirstRange(),
+				attributeKey: key,
+				attributeOldValue: null,
+				attributeNewValue: selection.getAttribute( key )
 			};
 
 			// 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 );
+			if ( consumable.test( selection, 'attribute:' + data.attributeKey ) ) {
+				this.fire( 'attribute:' + data.attributeKey, data, consumable, this.conversionApi );
 			}
 		}
 	}
@@ -405,11 +411,11 @@ export default class ModelConversionDispatcher {
 		consumable.add( selection, 'selection' );
 
 		for ( const marker of markers ) {
-			consumable.add( selection, 'selectionMarker:' + marker.name );
+			consumable.add( selection, 'addMarker:' + marker.name );
 		}
 
 		for ( const key of selection.getAttributeKeys() ) {
-			consumable.add( selection, 'selectionAttribute:' + key );
+			consumable.add( selection, 'attribute:' + key );
 		}
 
 		return consumable;
@@ -471,7 +477,7 @@ export default class ModelConversionDispatcher {
 	 */
 
 	/**
-	 * Fired when attribute has been added/changed/removed from a node.
+	 * Fired when attribute has been added/changed/removed from a node. Also fired when collapsed model selection attribute is converted.
 	 *
 	 * `attribute` is a namespace for a class of events. Names of actually called events follow this pattern:
 	 * `attribute:attributeKey:name`. `attributeKey` is the key of added/changed/removed attribute.
@@ -482,10 +488,11 @@ export default class ModelConversionDispatcher {
 	 *
 	 * @event attribute
 	 * @param {Object} data Additional information about the change.
-	 * @param {module:engine/model/item~Item} data.item Changed item.
-	 * @param {module:engine/model/range~Range} data.range Range spanning over changed item.
+	 * @param {module:engine/model/item~Item|module:engine/model/documentselection~DocumentSelection} data.item Changed item
+	 * or converted selection.
+	 * @param {module:engine/model/range~Range} data.range Range spanning over changed item or selection range.
 	 * @param {String} data.attributeKey Attribute key.
-	 * @param {*} data.attributeOldValue Attribute value before the change.
+	 * @param {*} data.attributeOldValue Attribute value before the change. This is `null` when selection attribute is converted.
 	 * @param {*} data.attributeNewValue New attribute value.
 	 * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
 	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
@@ -501,38 +508,7 @@ export default class ModelConversionDispatcher {
 	 */
 
 	/**
-	 * Fired for {@link module:engine/model/selection~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 it is possible to listen to
-	 * certain attribute, i.e. `selectionAttribute:bold`.
-	 *
-	 * @event selectionAttribute
-	 * @param {Object} data Additional information about the change.
-	 * @param {module:engine/model/selection~Selection} data.selection Selection that is converted.
-	 * @param {String} data.attributeKey Key of changed attribute.
-	 * @param {*} data.attributeValue Value of changed attribute.
-	 * @param {module:engine/conversion/modelconsumable~ModelConsumable} consumable Values to consume.
-	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
-	 */
-
-	/**
-	 * Fired for markers containing {@link module:engine/model/selection~Selection selection}.
-	 *
-	 * `selectionMarker` is a namespace for a class of events. Names of actually called events follow this pattern:
-	 * `selectionMarker:markerName`. `markerName` is the name of the marker containing selection. This way it is possible to listen to
-	 * certain marker, i.e. `selectionAttribute:highlight`.
-	 *
-	 * @event selectionMarker
-	 * @param {Object} data Additional information about the change.
-	 * @param {module:engine/model/selection~Selection} data.selection Selection that is converted.
-	 * @param {module:engine/model/range~Range} data.markerRange Marker range.
-	 * @param {String} data.markerName Marker name.
-	 * @param {Object} conversionApi Conversion interface to be used by callback, passed in `ModelConversionDispatcher` constructor.
-	 */
-
-	/**
-	 * Fired when a new marker is added to the model.
+	 * Fired when a new marker is added to the model. Also fired when collapsed model selection that is inside marker is converted.
 	 *
 	 * `addMarker` is a namespace for a class of events. Names of actually called events follow this pattern:
 	 * `addMarker:markerName`. By specifying certain marker names, you can make the events even more gradual. For example,
@@ -540,17 +516,25 @@ export default class ModelConversionDispatcher {
 	 * `addMarker:foo:bar` events.
 	 *
 	 * If the marker range is not collapsed:
+	 *
 	 * * the event is fired for each item in the marker range one by one,
 	 * * consumables object includes each item of the marker range and the consumable value is same as event name.
 	 *
 	 * If the marker range is collapsed:
+	 *
 	 * * there is only one event,
 	 * * consumables object includes marker range with event name.
 	 *
+	 * If selection inside a marker is converted:
+	 *
+	 * * there is only one event,
+	 * * consumables object includes selection instance with event name.
+	 *
 	 * @event addMarker
 	 * @param {Object} data Additional information about the change.
-	 * @param {module:engine/model/item~Item} data.item Item inside the new marker.
-	 * @param {module:engine/model/range~Range} [data.range] Range spanning over converted item. Available only if
+	 * @param {module:engine/model/item~Item|module:engine/model/selection~Selection} data.item Item inside the new marker or
+	 * the selection that is being converted.
+	 * @param {module:engine/model/range~Range} [data.range] Range spanning over converted item. Available only in marker conversion, if
 	 * the marker range was not collapsed.
 	 * @param {module:engine/model/range~Range} data.markerRange Marker range.
 	 * @param {String} data.markerName Marker name.

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

@@ -32,7 +32,6 @@ import { parse as viewParse, stringify as viewStringify } from '../../src/dev-ut
 import {
 	convertRangeSelection,
 	convertCollapsedSelection,
-	convertSelectionAttribute
 } from '../conversion/model-selection-to-view-converters';
 import { insertText, insertElement, wrap } from '../conversion/model-to-view-converters';
 import isPlainObject from '@ckeditor/ckeditor5-utils/src/lib/lodash/isPlainObject';
@@ -200,7 +199,7 @@ export function stringify( node, selectionOrPositionOrRange = null ) {
 
 	modelToView.on( 'insert:$text', insertText() );
 	modelToView.on( 'attribute', wrap( ( value, data ) => {
-		if ( data.item.is( 'textProxy' ) ) {
+		if ( data.item instanceof ModelSelection || data.item.is( 'textProxy' ) ) {
 			return new ViewAttributeElement( 'model-text-with-attributes', { [ data.attributeKey ]: stringifyAttributeValue( value ) } );
 		}
 	} ) );
@@ -212,9 +211,6 @@ export function stringify( node, selectionOrPositionOrRange = null ) {
 	} ) );
 	modelToView.on( 'selection', convertRangeSelection() );
 	modelToView.on( 'selection', convertCollapsedSelection() );
-	modelToView.on( 'selectionAttribute', convertSelectionAttribute( ( value, data ) => {
-		return new ViewAttributeElement( 'model-text-with-attributes', { [ data.key ]: value } );
-	} ) );
 
 	// Convert model to view.
 	const writer = new ViewWriter();

+ 218 - 176
packages/ckeditor5-engine/src/view/writer.js

@@ -18,9 +18,6 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import DocumentFragment from './documentfragment';
 import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable';
 
-// TODO: check all docs
-// TODO: writer should be protected
-// TODO: check errors/event descriptions if everything is up to date
 export default class Writer {
 	/**
 	 * Breaks attribute nodes at provided position or at boundaries of provided range. It breaks attribute elements inside
@@ -28,10 +25,10 @@ export default class Writer {
 	 *
 	 * In following examples `<p>` is a container, `<b>` and `<u>` are attribute nodes:
 	 *
-	 *		<p>foo<b><u>bar{}</u></b></p> -> <p>foo<b><u>bar</u></b>[]</p>
-	 *		<p>foo<b><u>{}bar</u></b></p> -> <p>foo{}<b><u>bar</u></b></p>
-	 *		<p>foo<b><u>b{}ar</u></b></p> -> <p>foo<b><u>b</u></b>[]<b><u>ar</u></b></p>
-	 *		<p><b>fo{o</b><u>ba}r</u></p> -> <p><b>fo</b><b>o</b><u>ba</u><u>r</u></b></p>
+	 *        <p>foo<b><u>bar{}</u></b></p> -> <p>foo<b><u>bar</u></b>[]</p>
+	 *        <p>foo<b><u>{}bar</u></b></p> -> <p>foo{}<b><u>bar</u></b></p>
+	 *        <p>foo<b><u>b{}ar</u></b></p> -> <p>foo<b><u>b</u></b>[]<b><u>ar</u></b></p>
+	 *        <p><b>fo{o</b><u>ba}r</u></p> -> <p><b>fo</b><b>o</b><u>ba</u><u>r</u></b></p>
 	 *
 	 * **Note:** {@link module:engine/view/documentfragment~DocumentFragment DocumentFragment} is treated like a container.
 	 *
@@ -57,8 +54,8 @@ export default class Writer {
 	 * @see module:engine/view/containerelement~ContainerElement
 	 * @see module:engine/view/writer~writer.breakContainer
 	 * @function module:engine/view/writer~writer.breakAttributes
-	 * @param {module:engine/view/position~Position|module:engine/view/range~Range} positionOrRange Position where to break
-	 * attribute elements.
+	 * @param {module:engine/view/position~Position|module:engine/view/range~Range} positionOrRange Position where
+	 * to break attribute elements.
 	 * @returns {module:engine/view/position~Position|module:engine/view/range~Range} New position or range, after breaking the attribute
 	 * elements.
 	 */
@@ -75,10 +72,10 @@ export default class Writer {
 	 * has to be directly inside container element and cannot be in root. Does not break if position is at the beginning
 	 * or at the end of it's parent element.
 	 *
-	 *		<p>foo^bar</p> -> <p>foo</p><p>bar</p>
-	 *		<div><p>foo</p>^<p>bar</p></div> -> <div><p>foo</p></div><div><p>bar</p></div>
-	 *		<p>^foobar</p> -> ^<p>foobar</p>
-	 *		<p>foobar^</p> -> <p>foobar</p>^
+	 *        <p>foo^bar</p> -> <p>foo</p><p>bar</p>
+	 *        <div><p>foo</p>^<p>bar</p></div> -> <div><p>foo</p></div><div><p>bar</p></div>
+	 *        <p>^foobar</p> -> ^<p>foobar</p>
+	 *        <p>foobar^</p> -> <p>foobar</p>^
 	 *
 	 * **Note:** Difference between {@link module:engine/view/writer~writer.breakAttributes breakAttributes} and
 	 * {@link module:engine/view/writer~writer.breakContainer breakContainer} is that `breakAttributes` breaks all
@@ -139,14 +136,14 @@ export default class Writer {
 	 *
 	 * In following examples `<p>` is a container and `<b>` is an attribute element:
 	 *
-	 *		<p>foo[]bar</p> -> <p>foo{}bar</p>
-	 *		<p><b>foo</b>[]<b>bar</b></p> -> <p><b>foo{}bar</b></p>
-	 *		<p><b foo="bar">a</b>[]<b foo="baz">b</b></p> -> <p><b foo="bar">a</b>[]<b foo="baz">b</b></p>
+	 *        <p>foo[]bar</p> -> <p>foo{}bar</p>
+	 *        <p><b>foo</b>[]<b>bar</b></p> -> <p><b>foo{}bar</b></p>
+	 *        <p><b foo="bar">a</b>[]<b foo="baz">b</b></p> -> <p><b foo="bar">a</b>[]<b foo="baz">b</b></p>
 	 *
 	 * It will also take care about empty attributes when merging:
 	 *
-	 *		<p><b>[]</b></p> -> <p>[]</p>
-	 *		<p><b>foo</b><i>[]</i><b>bar</b></p> -> <p><b>foo{}bar</b></p>
+	 *        <p><b>[]</b></p> -> <p>[]</p>
+	 *        <p><b>foo</b><i>[]</i><b>bar</b></p> -> <p><b>foo{}bar</b></p>
 	 *
 	 * **Note:** Difference between {@link module:engine/view/writer~writer.mergeAttributes mergeAttributes} and
 	 * {@link module:engine/view/writer~writer.mergeContainers mergeContainers} is that `mergeAttributes` merges two
@@ -209,8 +206,8 @@ export default class Writer {
 	 * Merges two {@link module:engine/view/containerelement~ContainerElement container elements} that are before and after given position.
 	 * Precisely, the element after the position is removed and it's contents are moved to element before the position.
 	 *
-	 *		<p>foo</p>^<p>bar</p> -> <p>foo^bar</p>
-	 *		<div>foo</div>^<p>bar</p> -> <div>foo^bar</div>
+	 *        <p>foo</p>^<p>bar</p> -> <p>foo^bar</p>
+	 *        <div>foo</div>^<p>bar</p> -> <div>foo^bar</div>
 	 *
 	 * **Note:** Difference between {@link module:engine/view/writer~writer.mergeAttributes mergeAttributes} and
 	 * {@link module:engine/view/writer~writer.mergeContainers mergeContainers} is that `mergeAttributes` merges two
@@ -440,132 +437,50 @@ export default class Writer {
 	/**
 	 * Wraps elements within range with provided {@link module:engine/view/attributeelement~AttributeElement AttributeElement}.
 	 *
+	 * If `viewSelection` was set and a collapsed range was passed, if the range is same as selection, the selection
+	 * will be moved to the inside of the wrapped attribute element.
+	 *
 	 * Throws {@link module:utils/ckeditorerror~CKEditorError} `view-writer-invalid-range-container`
 	 * when {@link module:engine/view/range~Range#start}
 	 * and {@link module:engine/view/range~Range#end} positions are not placed inside same parent container.
+	 *
 	 * Throws {@link module:utils/ckeditorerror~CKEditorError} `view-writer-wrap-invalid-attribute` when passed attribute element is not
 	 * an instance of {module:engine/view/attributeelement~AttributeElement AttributeElement}.
 	 *
 	 * @function module:engine/view/writer~writer.wrap
 	 * @param {module:engine/view/range~Range} range Range to wrap.
 	 * @param {module:engine/view/attributeelement~AttributeElement} attribute Attribute element to use as wrapper.
+	 * @param {module:engine/view/selection~Selection} [viewSelection=null] View selection to change, required when
+	 * wrapping collapsed range.
+	 * @returns {module:engine/view/range~Range} range Range after wrapping, spanning over wrapping attribute element.
 	 */
-	wrap( range, attribute ) {
+	wrap( range, attribute, viewSelection = null ) {
 		if ( !( attribute instanceof AttributeElement ) ) {
 			throw new CKEditorError( 'view-writer-wrap-invalid-attribute' );
 		}
 
 		validateRangeContainer( range );
 
-		// If range is collapsed - nothing to wrap.
-		if ( range.isCollapsed ) {
-			return range;
-		}
-
-		// Range is inside single attribute and spans on all children.
-		if ( rangeSpansOnAllChildren( range ) && wrapAttributeElement( attribute, range.start.parent ) ) {
-			const parent = range.start.parent;
-
-			const end = this.mergeAttributes( Position.createAfter( parent ) );
-			const start = this.mergeAttributes( Position.createBefore( parent ) );
-
-			return new Range( start, end );
-		}
-
-		// Break attributes at range start and end.
-		const { start: breakStart, end: breakEnd } = _breakAttributesRange( range, true );
-
-		// Range around one element.
-		if ( breakEnd.isEqual( breakStart.getShiftedBy( 1 ) ) ) {
-			const node = breakStart.nodeAfter;
-
-			if ( node instanceof AttributeElement && wrapAttributeElement( attribute, node ) ) {
-				const start = this.mergeAttributes( breakStart );
-
-				if ( !start.isEqual( breakStart ) ) {
-					breakEnd.offset--;
-				}
-
-				const end = this.mergeAttributes( breakEnd );
+		if ( !range.isCollapsed ) {
+			// Non-collapsed range. Wrap it with the attribute element.
+			return this._wrapRange( range, attribute );
+		} else {
+			// Collapsed range. Wrap position.
+			let position = range.start;
 
-				return new Range( start, end );
+			if ( position.parent.is( 'element' ) && !_hasNonUiChildren( position.parent ) ) {
+				position = position.getLastMatchingPosition( value => value.item.is( 'uiElement' ) );
 			}
-		}
-
-		const parentContainer = breakStart.parent;
-
-		// Unwrap children located between break points.
-		const unwrappedRange = this._unwrapChildren( parentContainer, breakStart.offset, breakEnd.offset, attribute );
-
-		// Wrap all children with attribute.
-		const newRange = this._wrapChildren( parentContainer, unwrappedRange.start.offset, unwrappedRange.end.offset, attribute );
 
-		// Merge attributes at the both ends and return a new range.
-		const start = this.mergeAttributes( newRange.start );
+			position = this._wrapPosition( position, attribute );
 
-		// If start position was merged - move end position back.
-		if ( !start.isEqual( newRange.start ) ) {
-			newRange.end.offset--;
-		}
-		const end = this.mergeAttributes( newRange.end );
-
-		return new Range( start, end );
-	}
-
-	/**
-	 * Wraps position with provided attribute. Returns new position after wrapping. This method will also merge newly
-	 * added attribute with its siblings whenever possible.
-	 *
-	 * Throws {@link module:utils/ckeditorerror~CKEditorError} `view-writer-wrap-invalid-attribute` when passed attribute element is not
-	 * an instance of {module:engine/view/attributeelement~AttributeElement AttributeElement}.
-	 *
-	 * @param {module:engine/view/position~Position} position
-	 * @param {module:engine/view/attributeelement~AttributeElement} attribute
-	 * @returns {module:engine/view/position~Position} New position after wrapping.
-	 */
-	wrapPosition( position, attribute ) {
-		if ( !( attribute instanceof AttributeElement ) ) {
-			throw new CKEditorError( 'view-writer-wrap-invalid-attribute' );
-		}
-
-		// Return same position when trying to wrap with attribute similar to position parent.
-		if ( attribute.isSimilar( position.parent ) ) {
-			return movePositionToTextNode( Position.createFromPosition( position ) );
-		}
-
-		// When position is inside text node - break it and place new position between two text nodes.
-		if ( position.parent.is( 'text' ) ) {
-			position = breakTextNode( position );
-		}
-
-		// Create fake element that will represent position, and will not be merged with other attributes.
-		const fakePosition = new AttributeElement();
-		fakePosition.priority = Number.POSITIVE_INFINITY;
-		fakePosition.isSimilar = () => false;
-
-		// Insert fake element in position location.
-		position.parent.insertChildren( position.offset, fakePosition );
-
-		// Range around inserted fake attribute element.
-		const wrapRange = new Range( position, position.getShiftedBy( 1 ) );
-
-		// Wrap fake element with attribute (it will also merge if possible).
-		this.wrap( wrapRange, attribute );
-
-		// Remove fake element and place new position there.
-		const newPosition = new Position( fakePosition.parent, fakePosition.index );
-		fakePosition.remove();
-
-		// If position is placed between text nodes - merge them and return position inside.
-		const nodeBefore = newPosition.nodeBefore;
-		const nodeAfter = newPosition.nodeAfter;
+			// If wrapping position is equal to view selection, move view selection inside wrapping attribute element.
+			if ( viewSelection && viewSelection.isCollapsed && viewSelection.getFirstPosition().isEqual( range.start ) ) {
+				viewSelection.setRanges( [ new Range( position ) ] );
+			}
 
-		if ( nodeBefore instanceof Text && nodeAfter instanceof Text ) {
-			return mergeTextNodes( nodeBefore, nodeAfter );
+			return new Range( position );
 		}
-
-		// If position is next to text node - move position inside.
-		return movePositionToTextNode( newPosition );
 	}
 
 	/**
@@ -658,13 +573,80 @@ export default class Writer {
 		return newElement;
 	}
 
-	// Unwraps children from provided `attribute`. Only children contained in `parent` element between
-	// `startOffset` and `endOffset` will be unwrapped.
-	//
-	// @param {module:engine/view/element~Element} parent
-	// @param {Number} startOffset
-	// @param {Number} endOffset
-	// @param {module:engine/view/element~Element} attribute
+	/**
+	 * Wraps children with provided `attribute`. Only children contained in `parent` element between
+	 * `startOffset` and `endOffset` will be wrapped.
+	 *
+	 * @private
+	 * @param {module:engine/view/element~Element} parent
+	 * @param {Number} startOffset
+	 * @param {Number} endOffset
+	 * @param {module:engine/view/element~Element} attribute
+	 */
+	_wrapChildren( parent, startOffset, endOffset, attribute ) {
+		let i = startOffset;
+		const wrapPositions = [];
+
+		while ( i < endOffset ) {
+			const child = parent.getChild( i );
+			const isText = child.is( 'text' );
+			const isAttribute = child.is( 'attributeElement' );
+			const isEmpty = child.is( 'emptyElement' );
+			const isUI = child.is( 'uiElement' );
+
+			// Wrap text, empty elements, ui elements or attributes with higher or equal priority.
+			if ( isText || isEmpty || isUI || ( isAttribute && shouldABeOutsideB( attribute, child ) ) ) {
+				// Clone attribute.
+				const newAttribute = attribute.clone();
+
+				// Wrap current node with new attribute;
+				child.remove();
+				newAttribute.appendChildren( child );
+				parent.insertChildren( i, newAttribute );
+
+				wrapPositions.push(	new Position( parent, i ) );
+			}
+			// If other nested attribute is found start wrapping there.
+			else if ( isAttribute ) {
+				this._wrapChildren( child, 0, child.childCount, attribute );
+			}
+
+			i++;
+		}
+
+		// Merge at each wrap.
+		let offsetChange = 0;
+
+		for ( const position of wrapPositions ) {
+			position.offset -= offsetChange;
+
+			// Do not merge with elements outside selected children.
+			if ( position.offset == startOffset ) {
+				continue;
+			}
+
+			const newPosition = this.mergeAttributes( position );
+
+			// If nodes were merged - other merge offsets will change.
+			if ( !newPosition.isEqual( position ) ) {
+				offsetChange++;
+				endOffset--;
+			}
+		}
+
+		return Range.createFromParentsAndOffsets( parent, startOffset, parent, endOffset );
+	}
+
+	/**
+	 * Unwraps children from provided `attribute`. Only children contained in `parent` element between
+	 * `startOffset` and `endOffset` will be unwrapped.
+	 *
+	 * @private
+	 * @param {module:engine/view/element~Element} parent
+	 * @param {Number} startOffset
+	 * @param {Number} endOffset
+	 * @param {module:engine/view/element~Element} attribute
+	 */
 	_unwrapChildren( parent, startOffset, endOffset, attribute ) {
 		let i = startOffset;
 		const unwrapPositions = [];
@@ -725,68 +707,128 @@ export default class Writer {
 		return Range.createFromParentsAndOffsets( parent, startOffset, parent, endOffset );
 	}
 
-	// Wraps children with provided `attribute`. Only children contained in `parent` element between
-	// `startOffset` and `endOffset` will be wrapped.
-	//
-	// @param {module:engine/view/element~Element} parent
-	// @param {Number} startOffset
-	// @param {Number} endOffset
-	// @param {module:engine/view/element~Element} attribute
-	_wrapChildren( parent, startOffset, endOffset, attribute ) {
-		let i = startOffset;
-		const wrapPositions = [];
+	/**
+	 * Helper function for `view.writer.wrap`. Wraps range with provided attribute element.
+	 * This method will also merge newly added attribute element with its siblings whenever possible.
+	 *
+	 * Throws {@link module:utils/ckeditorerror~CKEditorError} `view-writer-wrap-invalid-attribute` when passed attribute element is not
+	 * an instance of {module:engine/view/attributeelement~AttributeElement AttributeElement}.
+	 *
+	 * @private
+	 * @param {module:engine/view/range~Range} range
+	 * @param {module:engine/view/attributeelement~AttributeElement} attribute
+	 * @returns {module:engine/view/range~Range} New range after wrapping, spanning over wrapping attribute element.
+	 */
+	_wrapRange( range, attribute ) {
+		// Range is inside single attribute and spans on all children.
+		if ( rangeSpansOnAllChildren( range ) && wrapAttributeElement( attribute, range.start.parent ) ) {
+			const parent = range.start.parent;
 
-		while ( i < endOffset ) {
-			const child = parent.getChild( i );
-			const isText = child.is( 'text' );
-			const isAttribute = child.is( 'attributeElement' );
-			const isEmpty = child.is( 'emptyElement' );
-			const isUI = child.is( 'uiElement' );
+			const end = this.mergeAttributes( Position.createAfter( parent ) );
+			const start = this.mergeAttributes( Position.createBefore( parent ) );
 
-			// Wrap text, empty elements, ui elements or attributes with higher or equal priority.
-			if ( isText || isEmpty || isUI || ( isAttribute && shouldABeOutsideB( attribute, child ) ) ) {
-				// Clone attribute.
-				const newAttribute = attribute.clone();
+			return new Range( start, end );
+		}
 
-				// Wrap current node with new attribute;
-				child.remove();
-				newAttribute.appendChildren( child );
-				parent.insertChildren( i, newAttribute );
+		// Break attributes at range start and end.
+		const { start: breakStart, end: breakEnd } = _breakAttributesRange( range, true );
 
-				wrapPositions.push(	new Position( parent, i ) );
-			}
-			// If other nested attribute is found start wrapping there.
-			else if ( isAttribute ) {
-				this._wrapChildren( child, 0, child.childCount, attribute );
+		// Range around one element.
+		if ( breakEnd.isEqual( breakStart.getShiftedBy( 1 ) ) ) {
+			const node = breakStart.nodeAfter;
+
+			if ( node instanceof AttributeElement && wrapAttributeElement( attribute, node ) ) {
+				const start = this.mergeAttributes( breakStart );
+
+				if ( !start.isEqual( breakStart ) ) {
+					breakEnd.offset--;
+				}
+
+				const end = this.mergeAttributes( breakEnd );
+
+				return new Range( start, end );
 			}
+		}
 
-			i++;
+		const parentContainer = breakStart.parent;
+
+		// Unwrap children located between break points.
+		const unwrappedRange = this._unwrapChildren( parentContainer, breakStart.offset, breakEnd.offset, attribute );
+
+		// Wrap all children with attribute.
+		const newRange = this._wrapChildren( parentContainer, unwrappedRange.start.offset, unwrappedRange.end.offset, attribute );
+
+		// Merge attributes at the both ends and return a new range.
+		const start = this.mergeAttributes( newRange.start );
+
+		// If start position was merged - move end position back.
+		if ( !start.isEqual( newRange.start ) ) {
+			newRange.end.offset--;
 		}
+		const end = this.mergeAttributes( newRange.end );
 
-		// Merge at each wrap.
-		let offsetChange = 0;
+		return new Range( start, end );
+	}
 
-		for ( const position of wrapPositions ) {
-			position.offset -= offsetChange;
+	/**
+	 * Helper function for `view.writer.wrap`. Wraps position with provided attribute element.
+	 * This method will also merge newly added attribute element with its siblings whenever possible.
+	 *
+	 * Throws {@link module:utils/ckeditorerror~CKEditorError} `view-writer-wrap-invalid-attribute` when passed attribute element is not
+	 * an instance of {module:engine/view/attributeelement~AttributeElement AttributeElement}.
+	 *
+	 * @private
+	 * @param {module:engine/view/position~Position} position
+	 * @param {module:engine/view/attributeelement~AttributeElement} attribute
+	 * @returns {module:engine/view/position~Position} New position after wrapping.
+	 */
+	_wrapPosition( position, attribute ) {
+		// Return same position when trying to wrap with attribute similar to position parent.
+		if ( attribute.isSimilar( position.parent ) ) {
+			return movePositionToTextNode( Position.createFromPosition( position ) );
+		}
 
-			// Do not merge with elements outside selected children.
-			if ( position.offset == startOffset ) {
-				continue;
-			}
+		// When position is inside text node - break it and place new position between two text nodes.
+		if ( position.parent.is( 'text' ) ) {
+			position = breakTextNode( position );
+		}
 
-			const newPosition = this.mergeAttributes( position );
+		// Create fake element that will represent position, and will not be merged with other attributes.
+		const fakePosition = new AttributeElement();
+		fakePosition.priority = Number.POSITIVE_INFINITY;
+		fakePosition.isSimilar = () => false;
 
-			// If nodes were merged - other merge offsets will change.
-			if ( !newPosition.isEqual( position ) ) {
-				offsetChange++;
-				endOffset--;
-			}
+		// Insert fake element in position location.
+		position.parent.insertChildren( position.offset, fakePosition );
+
+		// Range around inserted fake attribute element.
+		const wrapRange = new Range( position, position.getShiftedBy( 1 ) );
+
+		// Wrap fake element with attribute (it will also merge if possible).
+		this.wrap( wrapRange, attribute );
+
+		// Remove fake element and place new position there.
+		const newPosition = new Position( fakePosition.parent, fakePosition.index );
+		fakePosition.remove();
+
+		// If position is placed between text nodes - merge them and return position inside.
+		const nodeBefore = newPosition.nodeBefore;
+		const nodeAfter = newPosition.nodeAfter;
+
+		if ( nodeBefore instanceof Text && nodeAfter instanceof Text ) {
+			return mergeTextNodes( nodeBefore, nodeAfter );
 		}
 
-		return Range.createFromParentsAndOffsets( parent, startOffset, parent, endOffset );
+		// If position is next to text node - move position inside.
+		return movePositionToTextNode( newPosition );
 	}
 }
 
+// Helper function for `view.writer.wrap`. Checks if given element has any children that are not ui elements.
+function _hasNonUiChildren( parent ) {
+	return Array.from( parent.getChildren() ).some( child => !child.is( 'uiElement' ) );
+}
+
 /**
  * Attribute element need to be instance of attribute element.
  *

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

@@ -144,8 +144,8 @@ describe( 'advanced-converters', () => {
 	describe( 'custom attribute handling for given element', () => {
 		beforeEach( () => {
 			// Normal model-to-view converters for links.
-			modelDispatcher.on( 'attribute:linkHref', wrap( value => new ViewAttributeElement( 'a', { href: value } ) ) );
-			modelDispatcher.on( 'attribute:linkTitle', wrap( value => new ViewAttributeElement( 'a', { title: value } ) ) );
+			modelDispatcher.on( 'attribute:linkHref', wrap( value => value ? new ViewAttributeElement( 'a', { href: value } ) : null ) );
+			modelDispatcher.on( 'attribute:linkTitle', wrap( value => value ? new ViewAttributeElement( 'a', { title: value } ) : null ) );
 
 			// Normal view-to-model converters for links.
 			viewDispatcher.on( 'element:a', ( evt, data, consumable, conversionApi ) => {

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

@@ -18,8 +18,6 @@ import ModelConversionDispatcher from '../../src/conversion/modelconversiondispa
 import {
 	convertRangeSelection,
 	convertCollapsedSelection,
-	convertSelectionAttribute,
-	convertSelectionMarker,
 	clearAttributes,
 	clearFakeSelection
 } from '../../src/conversion/model-selection-to-view-converters';
@@ -78,13 +76,6 @@ describe( 'model-selection-to-view-converters', () => {
 	} );
 
 	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' ) ) );
-			dispatcher.on( 'selectionMarker:marker', convertSelectionMarker( highlightDescriptor ) );
-		} );
-
 		describe( 'range selection', () => {
 			it( 'in same container', () => {
 				test(
@@ -201,24 +192,6 @@ describe( 'model-selection-to-view-converters', () => {
 				);
 			} );
 
-			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( 'in attribute and marker', () => {
 				setModelData( model, 'fo<$text bold="true">ob</$text>ar' );
 				const marker = model.markers.set( 'marker', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 5 ) );
@@ -271,7 +244,7 @@ describe( 'model-selection-to-view-converters', () => {
 			} );
 
 			it( 'in marker - using highlight descriptor creator', () => {
-				dispatcher.on( 'selectionMarker:marker2', convertSelectionMarker(
+				dispatcher.on( 'addMarker:marker2', highlightText(
 					data => ( { 'class': data.markerName } )
 				) );
 
@@ -290,40 +263,13 @@ describe( 'model-selection-to-view-converters', () => {
 					dispatcher.convertSelection( modelSelection, writer );
 				} );
 
-				// Stringify view and check if it is same as expected.
-				expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
-					.to.equal( '<div>foo<span class="marker2">[]</span>bar</div>' );
-			} );
-
-			it( 'in marker - should merge with the rest of attribute elements', () => {
-				dispatcher.on( 'addMarker:marker2', highlightText( data => ( { 'class': data.markerName } ) ) );
-				dispatcher.on( 'addMarker:marker2', highlightElement( data => ( { 'class': data.markerName } ) ) );
-				dispatcher.on( 'selectionMarker:marker2', convertSelectionMarker( data => ( { 'class': data.markerName } ) ) );
-
-				setModelData( model, 'foobar' );
-				const marker = model.markers.set( 'marker2', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 5 ) );
-
-				modelSelection.setRanges( [ new ModelRange( ModelPosition.createAt( modelRoot, 3 ) ) ] );
-
-				// Remove view children manually (without firing additional conversion).
-				viewRoot.removeChildren( 0, viewRoot.childCount );
-
-				// Convert model to view.
-				view.change( writer => {
-					dispatcher.convertInsert( ModelRange.createIn( modelRoot ), writer );
-					dispatcher.convertMarkerAdd( marker.name, marker.getRange(), writer );
-					dispatcher.convertSelection( modelSelection, writer );
-				} );
-
 				// Stringify view and check if it is same as expected.
 				expect( stringifyView( viewRoot, viewSelection, { showType: false } ) )
 					.to.equal( '<div>f<span class="marker2">oo{}ba</span>r</div>' );
 			} );
 
 			it( 'should do nothing if creator return null', () => {
-				dispatcher.on( 'selectionMarker:marker3', convertSelectionMarker( () => {
-
-				} ) );
+				dispatcher.on( 'addMarker:marker3', highlightText( () => null ) );
 
 				setModelData( model, 'foobar' );
 				const marker = model.markers.set( 'marker3', ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 5 ) );
@@ -420,15 +366,15 @@ describe( 'model-selection-to-view-converters', () => {
 					expect( consumable.consume( data.selection, 'selection' ) ).to.be.true;
 				}, { priority: 'high' } );
 
-				dispatcher.on( 'selectionAttribute:bold', ( evt, data, consumable ) => {
-					expect( consumable.consume( data.selection, 'selectionAttribute:bold' ) ).to.be.true;
+				dispatcher.on( 'attribute:bold', ( evt, data, consumable ) => {
+					expect( consumable.consume( data.item, 'attribute:bold' ) ).to.be.true;
 				}, { priority: 'high' } );
 
 				// Similar test case as above.
 				test(
 					[ 3, 3 ],
 					'f<$text bold="true">ooba</$text>r',
-					'f<strong>ooba</strong>r' // No selection in view.
+					'foobar' // No selection in view and no attribute.
 				);
 			} );
 		} );
@@ -473,13 +419,10 @@ describe( 'model-selection-to-view-converters', () => {
 
 		describe( 'clearAttributes', () => {
 			it( 'should remove all ranges before adding new range', () => {
-				dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'b' ) ) );
-				dispatcher.on( 'attribute:style', wrap( new ViewAttributeElement( 'b' ) ) );
-
 				test(
 					[ 3, 3 ],
 					'foobar',
-					'foo<b>[]</b>bar',
+					'foo<strong>[]</strong>bar',
 					{ bold: 'true' }
 				);
 
@@ -497,18 +440,15 @@ describe( 'model-selection-to-view-converters', () => {
 			} );
 
 			it( 'should do nothing if the attribute element had been already removed', () => {
-				dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( new ViewAttributeElement( 'b' ) ) );
-				dispatcher.on( 'attribute:style', wrap( new ViewAttributeElement( 'b' ) ) );
-
 				test(
 					[ 3, 3 ],
 					'foobar',
-					'foo<b>[]</b>bar',
+					'foo<strong>[]</strong>bar',
 					{ bold: 'true' }
 				);
 
 				view.change( writer => {
-					// Remove <b></b> manually.
+					// Remove <strong></strong> manually.
 					writer.mergeAttributes( viewSelection.getFirstPosition() );
 
 					const modelRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 1 );
@@ -537,115 +477,6 @@ describe( 'model-selection-to-view-converters', () => {
 		} );
 	} );
 
-	describe( 'using element creator for attributes conversion', () => {
-		beforeEach( () => {
-			function themeElementCreator( themeValue ) {
-				if ( themeValue == 'important' ) {
-					return new ViewAttributeElement( 'strong', { style: 'text-transform:uppercase' } );
-				} else if ( themeValue == 'gold' ) {
-					return new ViewAttributeElement( 'span', { style: 'color:yellow' } );
-				}
-			}
-
-			dispatcher.on( 'selectionAttribute:theme', convertSelectionAttribute( themeElementCreator ) );
-			dispatcher.on( 'attribute:theme', wrap( themeElementCreator ) );
-
-			dispatcher.on( 'selectionAttribute:italic', convertSelectionAttribute( new ViewAttributeElement( 'em' ) ) );
-		} );
-
-		describe( 'range selection', () => {
-			it( 'in same container, over attribute', () => {
-				test(
-					[ 1, 5 ],
-					'fo<$text theme="gold">ob</$text>ar',
-					'f{o<span style="color:yellow">ob</span>a}r'
-				);
-			} );
-
-			it( 'in same attribute', () => {
-				test(
-					[ 2, 4 ],
-					'f<$text theme="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 theme="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 theme="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 theme="gold">ooba</$text>r',
-					'f<span style="color:yellow">oo{}ba</span>r'
-				);
-			} );
-
-			it( 'in container with theme attribute', () => {
-				test(
-					[ 1, 1 ],
-					'foobar',
-					'f<strong style="text-transform:uppercase">[]</strong>oobar',
-					{ theme: 'important' }
-				);
-			} );
-
-			it( 'in theme attribute with extra attributes #1', () => {
-				test(
-					[ 3, 3 ],
-					'f<$text theme="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 theme 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 theme="gold">ooba</$text>r',
-					'f<span style="color:yellow">oo</span>' +
-					'<strong style="text-transform:uppercase">[]</strong>' +
-					'<span style="color:yellow">ba</span>r',
-					{ theme: 'important' }
-				);
-			} );
-
-			it( 'convertSelectionAttribute should do nothing if creator return null', () => {
-				dispatcher.on( 'selectionAttribute:bold', convertSelectionAttribute( () => {
-
-				} ) );
-
-				test(
-					[ 3, 3 ],
-					'foobar',
-					'foo{}bar',
-					{ bold: 'true' }
-				);
-			} );
-		} );
-	} );
-
 	describe( 'table cell selection converter', () => {
 		beforeEach( () => {
 			model.schema.register( 'table' );

+ 58 - 19
packages/ckeditor5-engine/tests/conversion/modelconversiondispatcher.js

@@ -278,45 +278,69 @@ describe( 'ModelConversionDispatcher', () => {
 
 			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;
+				expect( consumable.test( data.selection, 'attribute:bold' ) ).to.be.true;
+				expect( consumable.test( data.selection, 'attribute:italic' ) ).to.be.null;
 			} );
 
 			dispatcher.convertSelection( doc.selection, [] );
 		} );
 
-		it( 'should fire attributes events for selection', () => {
-			sinon.spy( dispatcher, 'fire' );
-
+		it( 'should not fire attributes events for non-collapsed selection', () => {
 			model.change( writer => {
 				writer.setAttribute( 'bold', true, ModelRange.createIn( root ) );
 				writer.setAttribute( 'italic', true, ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ) );
 			} );
 
+			sinon.spy( dispatcher, 'fire' );
+
 			dispatcher.convertSelection( doc.selection, [] );
 
-			expect( dispatcher.fire.calledWith( 'selectionAttribute:bold' ) ).to.be.true;
-			expect( dispatcher.fire.calledWith( 'selectionAttribute:italic' ) ).to.be.false;
+			expect( dispatcher.fire.calledWith( 'attribute:bold' ) ).to.be.false;
+			expect( dispatcher.fire.calledWith( 'attribute:italic' ) ).to.be.false;
 		} );
 
-		it( 'should not fire attributes events if attribute has been consumed', () => {
-			sinon.spy( dispatcher, 'fire' );
+		it( 'should fire attributes events for collapsed selection', () => {
+			doc.selection.setRanges( [
+				new ModelRange( new ModelPosition( root, [ 2 ] ), new ModelPosition( root, [ 2 ] ) )
+			] );
 
-			dispatcher.on( 'selection', ( evt, data, consumable ) => {
-				consumable.consume( data.selection, 'selectionAttribute:bold' );
+			model.change( writer => {
+				writer.setAttribute( 'bold', true, ModelRange.createIn( root ) );
 			} );
 
+			sinon.spy( dispatcher, 'fire' );
+
+			dispatcher.convertSelection( doc.selection, [] );
+
+			expect( dispatcher.fire.calledWith( 'attribute:bold' ) ).to.be.true;
+		} );
+
+		it( 'should not fire attributes events if attribute has been consumed', () => {
+			doc.selection.setRanges( [
+				new ModelRange( new ModelPosition( root, [ 2 ] ), new ModelPosition( root, [ 2 ] ) )
+			] );
+
 			model.change( writer => {
 				writer.setAttribute( 'bold', true, ModelRange.createIn( root ) );
 				writer.setAttribute( 'italic', true, ModelRange.createFromParentsAndOffsets( root, 4, root, 5 ) );
 			} );
 
+			dispatcher.on( 'selection', ( evt, data, consumable ) => {
+				consumable.consume( data.selection, 'attribute:bold' );
+			} );
+
+			sinon.spy( dispatcher, 'fire' );
+
 			dispatcher.convertSelection( doc.selection, [] );
 
-			expect( dispatcher.fire.calledWith( 'selectionAttribute:bold' ) ).to.be.false;
+			expect( dispatcher.fire.calledWith( 'attribute:bold' ) ).to.be.false;
 		} );
 
-		it( 'should fire events for each marker which contains selection', () => {
+		it( 'should fire events for markers for collapsed selection', () => {
+			doc.selection.setRanges( [
+				new ModelRange( new ModelPosition( root, [ 1 ] ), new ModelPosition( root, [ 1 ] ) )
+			] );
+
 			model.markers.set( 'name', ModelRange.createFromParentsAndOffsets( root, 0, root, 2 ) );
 
 			sinon.spy( dispatcher, 'fire' );
@@ -324,7 +348,18 @@ describe( 'ModelConversionDispatcher', () => {
 			const markers = Array.from( model.markers.getMarkersAtPosition( doc.selection.getFirstPosition() ) );
 			dispatcher.convertSelection( doc.selection, markers );
 
-			expect( dispatcher.fire.calledWith( 'selectionMarker:name' ) ).to.be.true;
+			expect( dispatcher.fire.calledWith( 'addMarker:name' ) ).to.be.true;
+		} );
+
+		it( 'should not fire events for markers for non-collapsed selection', () => {
+			model.markers.set( 'name', ModelRange.createFromParentsAndOffsets( root, 0, root, 2 ) );
+
+			sinon.spy( dispatcher, 'fire' );
+
+			const markers = Array.from( model.markers.getMarkersAtPosition( doc.selection.getFirstPosition() ) );
+			dispatcher.convertSelection( doc.selection, markers );
+
+			expect( dispatcher.fire.calledWith( 'addMarker:name' ) ).to.be.false;
 		} );
 
 		it( 'should not fire event for marker if selection is in a element with custom highlight handling', () => {
@@ -364,24 +399,28 @@ describe( 'ModelConversionDispatcher', () => {
 
 			dispatcher.convertSelection( doc.selection, markers );
 
-			expect( dispatcher.fire.calledWith( 'selectionMarker:name' ) ).to.be.false;
+			expect( dispatcher.fire.calledWith( 'addMarker:name' ) ).to.be.false;
 		} );
 
 		it( 'should not fire events if information about marker has been consumed', () => {
+			doc.selection.setRanges( [
+				new ModelRange( new ModelPosition( root, [ 1 ] ), new ModelPosition( root, [ 1 ] ) )
+			] );
+
 			model.markers.set( 'foo', ModelRange.createFromParentsAndOffsets( root, 0, root, 2 ) );
 			model.markers.set( 'bar', ModelRange.createFromParentsAndOffsets( root, 0, root, 2 ) );
 
 			sinon.spy( dispatcher, 'fire' );
 
-			dispatcher.on( 'selectionMarker:foo', ( evt, data, consumable ) => {
-				consumable.consume( data.selection, 'selectionMarker:bar' );
+			dispatcher.on( 'addMarker:foo', ( evt, data, consumable ) => {
+				consumable.consume( data.item, 'addMarker:bar' );
 			} );
 
 			const markers = Array.from( model.markers.getMarkersAtPosition( doc.selection.getFirstPosition() ) );
 			dispatcher.convertSelection( doc.selection, markers );
 
-			expect( dispatcher.fire.calledWith( 'selectionMarker:foo' ) ).to.be.true;
-			expect( dispatcher.fire.calledWith( 'selectionMarker:bar' ) ).to.be.false;
+			expect( dispatcher.fire.calledWith( 'addMarker:foo' ) ).to.be.true;
+			expect( dispatcher.fire.calledWith( 'addMarker:bar' ) ).to.be.false;
 		} );
 	} );
 

+ 546 - 371
packages/ckeditor5-engine/tests/view/writer/wrap.js

@@ -4,6 +4,8 @@
  */
 
 import Writer from '../../../src/view/writer';
+import View from '../../../src/view/view';
+import DocumentFragment from '../../../src/view/documentfragment';
 import Element from '../../../src/view/element';
 import ContainerElement from '../../../src/view/containerelement';
 import AttributeElement from '../../../src/view/attributeelement';
@@ -14,386 +16,559 @@ import Range from '../../../src/view/range';
 import Text from '../../../src/view/text';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import { stringify, parse } from '../../../src/dev-utils/view';
+import createViewRoot from '../_utils/createroot';
 
 describe( 'Writer', () => {
 	describe( 'wrap()', () => {
 		let writer;
 
-		/**
-		 * Executes test using `parse` and `stringify` utils functions.
-		 *
-		 * @param {String} input
-		 * @param {String} wrapAttribute
-		 * @param {String} expected
-		 */
-		function test( input, wrapAttribute, expected ) {
-			const { view, selection } = parse( input );
-			const newRange = writer.wrap( selection.getFirstRange(), parse( wrapAttribute ) );
-
-			expect( stringify( view.root, newRange, { showType: true, showPriority: true } ) ).to.equal( expected );
-		}
-
 		before( () => {
 			writer = new Writer();
 		} );
 
-		it( 'should do nothing on collapsed ranges', () => {
-			test(
-				'<container:p>f{}oo</container:p>',
-				'<attribute:b></attribute:b>',
-				'<container:p>f{}oo</container:p>'
-			);
-		} );
-
-		it( 'wraps single text node', () => {
-			test(
-				'<container:p>[foobar]</container:p>',
-				'<attribute:b view-priority="1"></attribute:b>',
-				'<container:p>[<attribute:b view-priority="1">foobar</attribute:b>]</container:p>'
-			);
-		} );
-
-		it( 'wraps single text node in document fragment', () => {
-			test(
-				'{foobar}',
-				'<attribute:b view-priority="1"></attribute:b>',
-				'[<attribute:b view-priority="1">foobar</attribute:b>]'
-			);
-		} );
-
-		it( 'should throw error when element is not instance of AttributeElement', () => {
-			const container = new ContainerElement( 'p', null, new Text( 'foo' ) );
-			const range = new Range(
-				new Position( container, 0 ),
-				new Position( container, 1 )
-			);
-			const b = new Element( 'b' );
-
-			expect( () => {
-				writer.wrap( range, b );
-			} ).to.throw( CKEditorError, 'view-writer-wrap-invalid-attribute' );
-		} );
-
-		it( 'should throw error when range placed in two containers', () => {
-			const container1 = new ContainerElement( 'p' );
-			const container2 = new ContainerElement( 'p' );
-			const range = new Range(
-				new Position( container1, 0 ),
-				new Position( container2, 1 )
-			);
-			const b = new AttributeElement( 'b' );
-
-			expect( () => {
-				writer.wrap( range, b );
-			} ).to.throw( CKEditorError, 'view-writer-invalid-range-container' );
-		} );
-
-		it( 'should throw when range has no parent container', () => {
-			const el = new AttributeElement( 'b' );
-			const b = new AttributeElement( 'b' );
-
-			expect( () => {
-				writer.wrap( Range.createFromParentsAndOffsets( el, 0, el, 0 ), b );
-			} ).to.throw( CKEditorError, 'view-writer-invalid-range-container' );
-		} );
-
-		it( 'wraps part of a single text node #1', () => {
-			test(
-				'<container:p>[foo}bar</container:p>',
-				'<attribute:b view-priority="1"></attribute:b>',
-				'<container:p>[<attribute:b view-priority="1">foo</attribute:b>]bar</container:p>'
-			);
-		} );
-
-		it( 'wraps part of a single text node #2', () => {
-			test(
-				'<container:p>{foo}bar</container:p>',
-				'<attribute:b view-priority="1"></attribute:b>',
-				'<container:p>[<attribute:b view-priority="1">foo</attribute:b>]bar</container:p>'
-			);
-		} );
-
-		it( 'should support unicode', () => {
-			test(
-				'<container:p>நி{லை}க்கு</container:p>',
-				'<attribute:b view-priority="1"></attribute:b>',
-				'<container:p>நி[<attribute:b view-priority="1">லை</attribute:b>]க்கு</container:p>'
-			);
-		} );
-
-		it( 'wraps part of a single text node #3', () => {
-			test(
-				'<container:p>foo{bar}</container:p>',
-				'<attribute:b view-priority="1"></attribute:b>',
-				'<container:p>foo[<attribute:b view-priority="1">bar</attribute:b>]</container:p>'
-			);
-		} );
-
-		it( 'should not wrap inside nested containers', () => {
-			test(
-				'<container:div>[foobar<container:p>baz</container:p>]</container:div>',
-				'<attribute:b view-priority="1"></attribute:b>',
-				'<container:div>[<attribute:b view-priority="1">foobar</attribute:b><container:p>baz</container:p>]</container:div>'
-			);
-		} );
-
-		it( 'wraps according to priorities', () => {
-			test(
-				'<container:p>[<attribute:u view-priority="1">foobar</attribute:u>]</container:p>',
-				'<attribute:b view-priority="2"></attribute:b>',
-				'<container:p>' +
-					'[<attribute:u view-priority="1"><attribute:b view-priority="2">foobar</attribute:b></attribute:u>]' +
-				'</container:p>'
-			);
-		} );
-
-		it( 'merges wrapped nodes #1', () => {
-			test(
-				'<container:p>' +
-					'[<attribute:b view-priority="1">foo</attribute:b>bar<attribute:b view-priority="1">baz</attribute:b>]' +
-				'</container:p>',
-				'<attribute:b view-priority="1"></attribute:b>',
-				'<container:p>[<attribute:b view-priority="1">foobarbaz</attribute:b>]</container:p>'
-			);
-		} );
-
-		it( 'merges wrapped nodes #2', () => {
-			test(
-				'<container:p><attribute:b view-priority="1">foo</attribute:b>[bar}baz</container:p>',
-				'<attribute:b view-priority="1"></attribute:b>',
-				'<container:p><attribute:b view-priority="1">foo{bar</attribute:b>]baz</container:p>'
-			);
-		} );
-
-		it( 'merges wrapped nodes #3', () => {
-			test(
-				'<container:p><attribute:b view-priority="1">foobar</attribute:b>[baz]</container:p>',
-				'<attribute:b view-priority="1"></attribute:b>',
-				'<container:p><attribute:b view-priority="1">foobar{baz</attribute:b>]</container:p>'
-			);
-		} );
-
-		it( 'merges wrapped nodes #4', () => {
-			test(
-				'<container:p>[foo<attribute:i view-priority="1">bar</attribute:i>]baz</container:p>',
-				'<attribute:b view-priority="1"></attribute:b>',
-				'<container:p>' +
-					'[<attribute:b view-priority="1">foo<attribute:i view-priority="1">bar</attribute:i></attribute:b>]baz' +
-				'</container:p>'
-			);
-		} );
-
-		it( 'merges wrapped nodes #5', () => {
-			test(
-				'<container:p>[foo<attribute:i view-priority="1">bar</attribute:i>baz]</container:p>',
-				'<attribute:b view-priority="2"></attribute:b>',
-				'<container:p>' +
-				'[' +
-					'<attribute:b view-priority="2">foo</attribute:b>' +
-					'<attribute:i view-priority="1">' +
-						'<attribute:b view-priority="2">bar</attribute:b>' +
-					'</attribute:i>' +
-					'<attribute:b view-priority="2">baz</attribute:b>' +
-				']' +
-				'</container:p>'
-			);
-		} );
-
-		it( 'merges wrapped nodes #6', () => {
-			test(
-				'<container:div>f{o<attribute:strong>ob</attribute:strong>a}r</container:div>',
-				'<attribute:span view-priority="1"></attribute:span>',
-				'<container:div>f[' +
-					'<attribute:span view-priority="1">o' +
-						'<attribute:strong view-priority="10">ob</attribute:strong>' +
-					'a</attribute:span>' +
-				']r</container:div>'
-			);
-		} );
-
-		it( 'should wrap single element by merging attributes', () => {
-			test(
-				'<container:p>[<attribute:b view-priority="1" foo="bar" one="two"></attribute:b>]</container:p>',
-				'<attribute:b view-priority="1" baz="qux" one="two"></attribute:b>',
-				'<container:p>[<attribute:b view-priority="1" baz="qux" foo="bar" one="two"></attribute:b>]</container:p>'
-			);
-		} );
-
-		it( 'should not merge attributes when they differ', () => {
-			test(
-				'<container:p>[<attribute:b view-priority="1" foo="bar">text</attribute:b>]</container:p>',
-				'<attribute:b view-priority="1" foo="baz"></attribute:b>',
-				'<container:p>' +
-					'[<attribute:b view-priority="1" foo="bar"><attribute:b view-priority="1" foo="baz">text</attribute:b></attribute:b>]' +
-				'</container:p>'
-			);
-		} );
-
-		it( 'should wrap single element by merging classes', () => {
-			test(
-				'<container:p>[<attribute:b view-priority="1" class="foo bar baz"></attribute:b>]</container:p>',
-				'<attribute:b view-priority="1" class="foo bar qux jax"></attribute:b>',
-				'<container:p>[<attribute:b view-priority="1" class="bar baz foo jax qux"></attribute:b>]</container:p>'
-			);
-		} );
-
-		it( 'should wrap single element by merging styles', () => {
-			test(
-				'<container:p>[<attribute:b view-priority="1" style="color:red; position: absolute"></attribute:b>]</container:p>',
-				'<attribute:b view-priority="1" style="color:red; top: 20px"></attribute:b>',
-				'<container:p>[<attribute:b view-priority="1" style="color:red;position:absolute;top:20px"></attribute:b>]</container:p>'
-			);
-		} );
-
-		it( 'should not merge styles when they differ', () => {
-			test(
-				'<container:p>[<attribute:b view-priority="1" style="color:red"></attribute:b>]</container:p>',
-				'<attribute:b view-priority="1" style="color:black"></attribute:b>',
-				'<container:p>' +
-				'[' +
-					'<attribute:b view-priority="1" style="color:black">' +
-						'<attribute:b view-priority="1" style="color:red"></attribute:b>' +
-					'</attribute:b>' +
-				']' +
-				'</container:p>'
-			);
-		} );
-
-		it( 'should not merge single elements when they have different priority', () => {
-			test(
-				'<container:p>[<attribute:b view-priority="2" style="color:red"></attribute:b>]</container:p>',
-				'<attribute:b view-priority="1" style="color:red"></attribute:b>',
-				'<container:p>' +
-				'[' +
-					'<attribute:b view-priority="1" style="color:red">' +
-						'<attribute:b view-priority="2" style="color:red"></attribute:b>' +
-					'</attribute:b>' +
-				']</container:p>'
-			);
-		} );
-
-		it( 'should be merged with outside element when wrapping all children', () => {
-			test(
-				'<container:p>' +
-					'<attribute:b view-priority="1" foo="bar">[foobar<attribute:i view-priority="1">baz</attribute:i>]</attribute:b>' +
-				'</container:p>',
-				'<attribute:b view-priority="1" baz="qux"></attribute:b>',
-				'<container:p>' +
-				'[' +
-					'<attribute:b view-priority="1" baz="qux" foo="bar">' +
-						'foobar' +
-						'<attribute:i view-priority="1">baz</attribute:i>' +
-					'</attribute:b>' +
-				']' +
-				'</container:p>'
-			);
-		} );
-
-		it( 'should be merged with broken element', () => {
-			test(
-				'<container:p>' +
-					'[<attribute:b view-priority="1" foo="bar">foo}bar</attribute:b>' +
-				'</container:p>',
-				'<attribute:b view-priority="1" baz="qux"></attribute:b>',
-				'<container:p>' +
-					'[<attribute:b view-priority="1" baz="qux" foo="bar">foo</attribute:b>]' +
-					'<attribute:b view-priority="1" foo="bar">bar</attribute:b>' +
-				'</container:p>'
-			);
-		} );
-
-		it( 'should be merged with broken element and merged with siblings', () => {
-			test(
-				'<container:p>' +
-					'<attribute:b view-priority="1" baz="qux" foo="bar">xyz</attribute:b>' +
-					'[<attribute:b view-priority="1" foo="bar">foo}bar</attribute:b>' +
-				'</container:p>',
-				'<attribute:b view-priority="1" baz="qux"></attribute:b>',
-				'<container:p>' +
-					'<attribute:b view-priority="1" baz="qux" foo="bar">xyz{foo</attribute:b>]' +
-					'<attribute:b view-priority="1" foo="bar">bar</attribute:b>' +
-				'</container:p>'
-			);
-		} );
-
-		it( 'should wrap EmptyElement', () => {
-			test(
-				'<container:p>[<empty:img></empty:img>]</container:p>',
-				'<attribute:b></attribute:b>',
-				'<container:p>[<attribute:b view-priority="10"><empty:img></empty:img></attribute:b>]</container:p>'
-			);
-		} );
-
-		it( 'should throw if range is inside EmptyElement', () => {
-			const emptyElement = new EmptyElement( 'img' );
-			const container = new ContainerElement( 'p', null, emptyElement );
-			const range = Range.createFromParentsAndOffsets( emptyElement, 0, container, 1 );
-
-			expect( () => {
-				writer.wrap( range, new AttributeElement( 'b' ) );
-			} ).to.throw( CKEditorError, 'view-writer-cannot-break-empty-element' );
-		} );
-
-		it( 'should wrap UIElement', () => {
-			test(
-				'<container:p>[<ui:span></ui:span>]</container:p>',
-				'<attribute:b></attribute:b>',
-				'<container:p>[<attribute:b view-priority="10"><ui:span></ui:span></attribute:b>]</container:p>'
-			);
-		} );
-
-		it( 'should throw if range is inside UIElement', () => {
-			const uiElement = new UIElement( 'span' );
-			const container = new ContainerElement( 'p', null, uiElement );
-			const range = Range.createFromParentsAndOffsets( uiElement, 0, container, 1 );
-
-			expect( () => {
-				writer.wrap( range, new AttributeElement( 'b' ) );
-			} ).to.throw( CKEditorError, 'view-writer-cannot-break-ui-element' );
-		} );
-
-		it( 'should keep stable hierarchy when wrapping with attribute with same priority', () => {
-			test(
-				'<container:p>[<attribute:span>foo</attribute:span>]</container:p>',
-				'<attribute:b></attribute:b>',
-				'<container:p>' +
-					'[<attribute:b view-priority="10">' +
-						'<attribute:span view-priority="10">foo</attribute:span>' +
-					'</attribute:b>]' +
-				'</container:p>'
-			);
-
-			test(
-				'<container:p>[<attribute:b>foo</attribute:b>]</container:p>',
-				'<attribute:span></attribute:span>',
-				'<container:p>' +
-					'[<attribute:b view-priority="10">' +
-						'<attribute:span view-priority="10">foo</attribute:span>' +
-					'</attribute:b>]' +
-				'</container:p>'
-			);
-		} );
-
-		it( 'should keep stable hierarchy when wrapping with attribute with same priority that can\'t be merged', () => {
-			test(
-				'<container:p>[<attribute:span name="foo">foo</attribute:span>]</container:p>',
-				'<attribute:span name="bar"></attribute:span>',
-				'<container:p>' +
-					'[<attribute:span view-priority="10" name="bar">' +
-						'<attribute:span view-priority="10" name="foo">foo</attribute:span>' +
-					'</attribute:span>]' +
-				'</container:p>'
-			);
-
-			test(
-				'<container:p>[<attribute:span name="bar">foo</attribute:span>]</container:p>',
-				'<attribute:span name="foo"></attribute:span>',
-				'<container:p>' +
-					'[<attribute:span view-priority="10" name="bar">' +
-						'<attribute:span view-priority="10" name="foo">foo</attribute:span>' +
-					'</attribute:span>]' +
-				'</container:p>'
-			);
+		describe( 'non-collapsed range', () => {
+			/**
+			 * Executes test using `parse` and `stringify` utils functions.
+			 *
+			 * @param {String} input
+			 * @param {String} wrapAttribute
+			 * @param {String} expected
+			 */
+			function test( input, wrapAttribute, expected ) {
+				const { view, selection } = parse( input );
+				const newRange = writer.wrap( selection.getFirstRange(), parse( wrapAttribute ) );
+
+				expect( stringify( view.root, newRange, { showType: true, showPriority: true } ) ).to.equal( expected );
+			}
+
+			it( 'wraps single text node', () => {
+				test(
+					'<container:p>[foobar]</container:p>',
+					'<attribute:b view-priority="1"></attribute:b>',
+					'<container:p>[<attribute:b view-priority="1">foobar</attribute:b>]</container:p>'
+				);
+			} );
+
+			it( 'wraps single text node in document fragment', () => {
+				test(
+					'{foobar}',
+					'<attribute:b view-priority="1"></attribute:b>',
+					'[<attribute:b view-priority="1">foobar</attribute:b>]'
+				);
+			} );
+
+			it( 'should throw error when element is not instance of AttributeElement', () => {
+				const container = new ContainerElement( 'p', null, new Text( 'foo' ) );
+				const range = new Range(
+					new Position( container, 0 ),
+					new Position( container, 1 )
+				);
+				const b = new Element( 'b' );
+
+				expect( () => {
+					writer.wrap( range, b );
+				} ).to.throw( CKEditorError, 'view-writer-wrap-invalid-attribute' );
+			} );
+
+			it( 'should throw error when range placed in two containers', () => {
+				const container1 = new ContainerElement( 'p' );
+				const container2 = new ContainerElement( 'p' );
+				const range = new Range(
+					new Position( container1, 0 ),
+					new Position( container2, 1 )
+				);
+				const b = new AttributeElement( 'b' );
+
+				expect( () => {
+					writer.wrap( range, b );
+				} ).to.throw( CKEditorError, 'view-writer-invalid-range-container' );
+			} );
+
+			it( 'should throw when range has no parent container', () => {
+				const el = new AttributeElement( 'b' );
+				const b = new AttributeElement( 'b' );
+
+				expect( () => {
+					writer.wrap( Range.createFromParentsAndOffsets( el, 0, el, 0 ), b );
+				} ).to.throw( CKEditorError, 'view-writer-invalid-range-container' );
+			} );
+
+			it( 'wraps part of a single text node #1', () => {
+				test(
+					'<container:p>[foo}bar</container:p>',
+					'<attribute:b view-priority="1"></attribute:b>',
+					'<container:p>[<attribute:b view-priority="1">foo</attribute:b>]bar</container:p>'
+				);
+			} );
+
+			it( 'wraps part of a single text node #2', () => {
+				test(
+					'<container:p>{foo}bar</container:p>',
+					'<attribute:b view-priority="1"></attribute:b>',
+					'<container:p>[<attribute:b view-priority="1">foo</attribute:b>]bar</container:p>'
+				);
+			} );
+
+			it( 'should support unicode', () => {
+				test(
+					'<container:p>நி{லை}க்கு</container:p>',
+					'<attribute:b view-priority="1"></attribute:b>',
+					'<container:p>நி[<attribute:b view-priority="1">லை</attribute:b>]க்கு</container:p>'
+				);
+			} );
+
+			it( 'wraps part of a single text node #3', () => {
+				test(
+					'<container:p>foo{bar}</container:p>',
+					'<attribute:b view-priority="1"></attribute:b>',
+					'<container:p>foo[<attribute:b view-priority="1">bar</attribute:b>]</container:p>'
+				);
+			} );
+
+			it( 'should not wrap inside nested containers', () => {
+				test(
+					'<container:div>[foobar<container:p>baz</container:p>]</container:div>',
+					'<attribute:b view-priority="1"></attribute:b>',
+					'<container:div>[<attribute:b view-priority="1">foobar</attribute:b><container:p>baz</container:p>]</container:div>'
+				);
+			} );
+
+			it( 'wraps according to priorities', () => {
+				test(
+					'<container:p>[<attribute:u view-priority="1">foobar</attribute:u>]</container:p>',
+
+					'<attribute:b view-priority="2"></attribute:b>',
+
+					'<container:p>' +
+						'[<attribute:u view-priority="1"><attribute:b view-priority="2">foobar</attribute:b></attribute:u>]' +
+					'</container:p>'
+				);
+			} );
+
+			it( 'merges wrapped nodes #1', () => {
+				test(
+					'<container:p>' +
+						'[<attribute:b view-priority="1">foo</attribute:b>bar<attribute:b view-priority="1">baz</attribute:b>]' +
+					'</container:p>',
+
+					'<attribute:b view-priority="1"></attribute:b>',
+
+					'<container:p>[<attribute:b view-priority="1">foobarbaz</attribute:b>]</container:p>'
+				);
+			} );
+
+			it( 'merges wrapped nodes #2', () => {
+				test(
+					'<container:p><attribute:b view-priority="1">foo</attribute:b>[bar}baz</container:p>',
+					'<attribute:b view-priority="1"></attribute:b>',
+					'<container:p><attribute:b view-priority="1">foo{bar</attribute:b>]baz</container:p>'
+				);
+			} );
+
+			it( 'merges wrapped nodes #3', () => {
+				test(
+					'<container:p><attribute:b view-priority="1">foobar</attribute:b>[baz]</container:p>',
+					'<attribute:b view-priority="1"></attribute:b>',
+					'<container:p><attribute:b view-priority="1">foobar{baz</attribute:b>]</container:p>'
+				);
+			} );
+
+			it( 'merges wrapped nodes #4', () => {
+				test(
+					'<container:p>[foo<attribute:i view-priority="1">bar</attribute:i>]baz</container:p>',
+
+					'<attribute:b view-priority="1"></attribute:b>',
+
+					'<container:p>' +
+						'[<attribute:b view-priority="1">foo<attribute:i view-priority="1">bar</attribute:i></attribute:b>]baz' +
+					'</container:p>'
+				);
+			} );
+
+			it( 'merges wrapped nodes #5', () => {
+				test(
+					'<container:p>[foo<attribute:i view-priority="1">bar</attribute:i>baz]</container:p>',
+
+					'<attribute:b view-priority="2"></attribute:b>',
+
+					'<container:p>' +
+						'[' +
+						'<attribute:b view-priority="2">foo</attribute:b>' +
+						'<attribute:i view-priority="1">' +
+							'<attribute:b view-priority="2">bar</attribute:b>' +
+						'</attribute:i>' +
+						'<attribute:b view-priority="2">baz</attribute:b>' +
+						']' +
+					'</container:p>'
+				);
+			} );
+
+			it( 'merges wrapped nodes #6', () => {
+				test(
+					'<container:div>f{o<attribute:strong>ob</attribute:strong>a}r</container:div>',
+
+					'<attribute:span view-priority="1"></attribute:span>',
+
+					'<container:div>f[' +
+						'<attribute:span view-priority="1">o' +
+							'<attribute:strong view-priority="10">ob</attribute:strong>' +
+						'a</attribute:span>' +
+					']r</container:div>'
+				);
+			} );
+
+			it( 'should wrap single element by merging attributes', () => {
+				test(
+					'<container:p>[<attribute:b view-priority="1" foo="bar" one="two"></attribute:b>]</container:p>',
+					'<attribute:b view-priority="1" baz="qux" one="two"></attribute:b>',
+					'<container:p>[<attribute:b view-priority="1" baz="qux" foo="bar" one="two"></attribute:b>]</container:p>'
+				);
+			} );
+
+			it( 'should not merge attributes when they differ', () => {
+				test(
+					'<container:p>[<attribute:b view-priority="1" foo="bar">text</attribute:b>]</container:p>',
+
+					'<attribute:b view-priority="1" foo="baz"></attribute:b>',
+
+					'<container:p>' +
+						'[<attribute:b view-priority="1" foo="bar">' +
+							'<attribute:b view-priority="1" foo="baz">text</attribute:b>' +
+						'</attribute:b>]' +
+					'</container:p>'
+				);
+			} );
+
+			it( 'should wrap single element by merging classes', () => {
+				test(
+					'<container:p>[<attribute:b view-priority="1" class="foo bar baz"></attribute:b>]</container:p>',
+					'<attribute:b view-priority="1" class="foo bar qux jax"></attribute:b>',
+					'<container:p>[<attribute:b view-priority="1" class="bar baz foo jax qux"></attribute:b>]</container:p>'
+				);
+			} );
+
+			it( 'should wrap single element by merging styles', () => {
+				test(
+					'<container:p>' +
+						'[<attribute:b view-priority="1" style="color:red; position: absolute"></attribute:b>]' +
+					'</container:p>',
+
+					'<attribute:b view-priority="1" style="color:red; top: 20px"></attribute:b>',
+
+					'<container:p>' +
+						'[<attribute:b view-priority="1" style="color:red;position:absolute;top:20px"></attribute:b>]' +
+					'</container:p>'
+				);
+			} );
+
+			it( 'should not merge styles when they differ', () => {
+				test(
+					'<container:p>[<attribute:b view-priority="1" style="color:red"></attribute:b>]</container:p>',
+
+					'<attribute:b view-priority="1" style="color:black"></attribute:b>',
+
+					'<container:p>' +
+						'[' +
+						'<attribute:b view-priority="1" style="color:black">' +
+							'<attribute:b view-priority="1" style="color:red"></attribute:b>' +
+						'</attribute:b>' +
+						']' +
+					'</container:p>'
+				);
+			} );
+
+			it( 'should not merge single elements when they have different priority', () => {
+				test(
+					'<container:p>[<attribute:b view-priority="2" style="color:red"></attribute:b>]</container:p>',
+
+					'<attribute:b view-priority="1" style="color:red"></attribute:b>',
+
+					'<container:p>' +
+						'[' +
+						'<attribute:b view-priority="1" style="color:red">' +
+							'<attribute:b view-priority="2" style="color:red"></attribute:b>' +
+						'</attribute:b>' +
+						']' +
+					'</container:p>'
+				);
+			} );
+
+			it( 'should be merged with outside element when wrapping all children', () => {
+				test(
+					'<container:p>' +
+						'<attribute:b view-priority="1" foo="bar">[foobar<attribute:i view-priority="1">baz</attribute:i>]</attribute:b>' +
+					'</container:p>',
+
+					'<attribute:b view-priority="1" baz="qux"></attribute:b>',
+
+					'<container:p>' +
+						'[' +
+						'<attribute:b view-priority="1" baz="qux" foo="bar">' +
+							'foobar' +
+							'<attribute:i view-priority="1">baz</attribute:i>' +
+						'</attribute:b>' +
+						']' +
+					'</container:p>'
+				);
+			} );
+
+			it( 'should be merged with broken element', () => {
+				test(
+					'<container:p>' +
+						'[<attribute:b view-priority="1" foo="bar">foo}bar</attribute:b>' +
+					'</container:p>',
+
+					'<attribute:b view-priority="1" baz="qux"></attribute:b>',
+
+					'<container:p>' +
+						'[<attribute:b view-priority="1" baz="qux" foo="bar">foo</attribute:b>]' +
+						'<attribute:b view-priority="1" foo="bar">bar</attribute:b>' +
+					'</container:p>'
+				);
+			} );
+
+			it( 'should be merged with broken element and merged with siblings', () => {
+				test(
+					'<container:p>' +
+						'<attribute:b view-priority="1" baz="qux" foo="bar">xyz</attribute:b>' +
+						'[<attribute:b view-priority="1" foo="bar">foo}bar</attribute:b>' +
+					'</container:p>',
+
+					'<attribute:b view-priority="1" baz="qux"></attribute:b>',
+
+					'<container:p>' +
+						'<attribute:b view-priority="1" baz="qux" foo="bar">xyz{foo</attribute:b>]' +
+						'<attribute:b view-priority="1" foo="bar">bar</attribute:b>' +
+					'</container:p>'
+				);
+			} );
+
+			it( 'should wrap EmptyElement', () => {
+				test(
+					'<container:p>[<empty:img></empty:img>]</container:p>',
+					'<attribute:b></attribute:b>',
+					'<container:p>[<attribute:b view-priority="10"><empty:img></empty:img></attribute:b>]</container:p>'
+				);
+			} );
+
+			it( 'should throw if range is inside EmptyElement', () => {
+				const emptyElement = new EmptyElement( 'img' );
+				const container = new ContainerElement( 'p', null, emptyElement );
+				const range = Range.createFromParentsAndOffsets( emptyElement, 0, container, 1 );
+
+				expect( () => {
+					writer.wrap( range, new AttributeElement( 'b' ) );
+				} ).to.throw( CKEditorError, 'view-writer-cannot-break-empty-element' );
+			} );
+
+			it( 'should wrap UIElement', () => {
+				test(
+					'<container:p>[<ui:span></ui:span>]</container:p>',
+					'<attribute:b></attribute:b>',
+					'<container:p>[<attribute:b view-priority="10"><ui:span></ui:span></attribute:b>]</container:p>'
+				);
+			} );
+
+			it( 'should throw if range is inside UIElement', () => {
+				const uiElement = new UIElement( 'span' );
+				const container = new ContainerElement( 'p', null, uiElement );
+				const range = Range.createFromParentsAndOffsets( uiElement, 0, container, 1 );
+
+				expect( () => {
+					writer.wrap( range, new AttributeElement( 'b' ) );
+				} ).to.throw( CKEditorError, 'view-writer-cannot-break-ui-element' );
+			} );
+
+			it( 'should keep stable hierarchy when wrapping with attribute with same priority', () => {
+				test(
+					'<container:p>[<attribute:span>foo</attribute:span>]</container:p>',
+
+					'<attribute:b></attribute:b>',
+
+					'<container:p>' +
+						'[<attribute:b view-priority="10">' +
+							'<attribute:span view-priority="10">foo</attribute:span>' +
+						'</attribute:b>]' +
+					'</container:p>'
+				);
+
+				test(
+					'<container:p>[<attribute:b>foo</attribute:b>]</container:p>',
+
+					'<attribute:span></attribute:span>',
+
+					'<container:p>' +
+						'[<attribute:b view-priority="10">' +
+							'<attribute:span view-priority="10">foo</attribute:span>' +
+						'</attribute:b>]' +
+					'</container:p>'
+				);
+			} );
+
+			it( 'should keep stable hierarchy when wrapping with attribute with same priority that can\'t be merged', () => {
+				test(
+					'<container:p>[<attribute:span name="foo">foo</attribute:span>]</container:p>',
+
+					'<attribute:span name="bar"></attribute:span>',
+
+					'<container:p>' +
+						'[<attribute:span view-priority="10" name="bar">' +
+							'<attribute:span view-priority="10" name="foo">foo</attribute:span>' +
+						'</attribute:span>]' +
+					'</container:p>'
+				);
+
+				test(
+					'<container:p>[<attribute:span name="bar">foo</attribute:span>]</container:p>',
+
+					'<attribute:span name="foo"></attribute:span>',
+
+					'<container:p>' +
+						'[<attribute:span view-priority="10" name="bar">' +
+							'<attribute:span view-priority="10" name="foo">foo</attribute:span>' +
+						'</attribute:span>]' +
+					'</container:p>'
+				);
+			} );
+		} );
+
+		describe( 'collapsed range', () => {
+			let view, viewDocument, viewRoot;
+
+			beforeEach( () => {
+				view = new View();
+				viewDocument = view.document;
+				viewRoot = createViewRoot( viewDocument );
+			} );
+
+			afterEach( () => {
+				view.destroy();
+			} );
+
+			/**
+			 * Executes test using `parse` and `stringify` utils functions.
+			 *
+			 * @param {String} input
+			 * @param {String} wrapAttribute
+			 * @param {String} expected
+			 */
+			function test( input, wrapAttribute, expected ) {
+				const { view, selection } = parse( input, { rootElement: viewRoot } );
+				viewDocument.selection.setTo( selection );
+
+				const newPosition = writer.wrap( selection.getFirstRange(), parse( wrapAttribute ) );
+
+				// Moving parsed elements to a document fragment so the view root is not shown in `stringify`.
+				const viewChildren = new DocumentFragment( view.getChildren() );
+
+				expect( stringify( viewChildren, newPosition, { showType: true, showPriority: true } ) ).to.equal( expected );
+			}
+
+			it( 'should throw error when element is not instance of AttributeElement', () => {
+				const container = new ContainerElement( 'p', null, new Text( 'foo' ) );
+				const position = new Position( container, 0 );
+				const b = new Element( 'b' );
+
+				expect( () => {
+					writer.wrap( new Range( position ), b );
+				} ).to.throw( CKEditorError, 'view-writer-wrap-invalid-attribute' );
+			} );
+
+			it( 'should wrap position at the beginning of text node', () => {
+				test(
+					'<container:p>{}foobar</container:p>',
+					'<attribute:b view-priority="1"></attribute:b>',
+					'<container:p><attribute:b view-priority="1">[]</attribute:b>foobar</container:p>'
+				);
+			} );
+
+			it( 'should wrap position inside text node', () => {
+				test(
+					'<container:p>foo{}bar</container:p>',
+					'<attribute:b view-priority="1"></attribute:b>',
+					'<container:p>foo<attribute:b view-priority="1">[]</attribute:b>bar</container:p>'
+				);
+			} );
+
+			it( 'should support unicode', () => {
+				test(
+					'<container:p>நிலை{}க்கு</container:p>',
+					'<attribute:b view-priority="1"></attribute:b>',
+					'<container:p>நிலை<attribute:b view-priority="1">[]</attribute:b>க்கு</container:p>'
+				);
+			} );
+
+			it( 'should wrap position inside document fragment', () => {
+				test(
+					'<attribute:b view-priority="1">foo</attribute:b>[]<attribute:b view-priority="3">bar</attribute:b>',
+
+					'<attribute:b view-priority="2"></attribute:b>',
+
+					'<attribute:b view-priority="1">foo</attribute:b>' +
+					'<attribute:b view-priority="2">[]</attribute:b>' +
+					'<attribute:b view-priority="3">bar</attribute:b>'
+				);
+			} );
+
+			it( 'should wrap position at the end of text node', () => {
+				test(
+					'<container:p>foobar{}</container:p>',
+					'<attribute:b view-priority="1"></attribute:b>',
+					'<container:p>foobar<attribute:b view-priority="1">[]</attribute:b></container:p>'
+				);
+			} );
+
+			it( 'should merge with existing attributes #1', () => {
+				test(
+					'<container:p><attribute:b view-priority="1">foo</attribute:b>[]</container:p>',
+					'<attribute:b view-priority="1"></attribute:b>',
+					'<container:p><attribute:b view-priority="1">foo{}</attribute:b></container:p>'
+				);
+			} );
+
+			it( 'should merge with existing attributes #2', () => {
+				test(
+					'<container:p>[]<attribute:b view-priority="1">foo</attribute:b></container:p>',
+					'<attribute:b view-priority="1"></attribute:b>',
+					'<container:p><attribute:b view-priority="1">{}foo</attribute:b></container:p>'
+				);
+			} );
+
+			it( 'should wrap when inside nested attributes', () => {
+				test(
+					'<container:p><attribute:b view-priority="1">foo{}bar</attribute:b></container:p>',
+
+					'<attribute:u view-priority="1"></attribute:u>',
+
+					'<container:p>' +
+						'<attribute:b view-priority="1">' +
+							'foo' +
+							'<attribute:u view-priority="1">[]</attribute:u>' +
+							'bar' +
+						'</attribute:b>' +
+					'</container:p>'
+				);
+			} );
+
+			it( 'should merge when wrapping between same attribute', () => {
+				test(
+					'<container:p>' +
+						'<attribute:b view-priority="1">foo</attribute:b>[]<attribute:b view-priority="1">bar</attribute:b>' +
+					'</container:p>',
+
+					'<attribute:b view-priority="1"></attribute:b>',
+
+					'<container:p><attribute:b view-priority="1">foo{}bar</attribute:b></container:p>'
+				);
+			} );
+
+			it( 'should move position to text node if in same attribute', () => {
+				test(
+					'<container:p><attribute:b view-priority="1">foobar[]</attribute:b></container:p>',
+					'<attribute:b view-priority="1"></attribute:b>',
+					'<container:p><attribute:b view-priority="1">foobar{}</attribute:b></container:p>'
+				);
+			} );
 		} );
 	} );
 } );

+ 0 - 158
packages/ckeditor5-engine/tests/view/writer/wrapposition.js

@@ -1,158 +0,0 @@
-/**
- * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import Writer from '../../../src/view/writer';
-import Text from '../../../src/view/text';
-import Element from '../../../src/view/element';
-import ContainerElement from '../../../src/view/containerelement';
-import AttributeElement from '../../../src/view/attributeelement';
-import EmptyElement from '../../../src/view/emptyelement';
-import UIElement from '../../../src/view/uielement';
-import Position from '../../../src/view/position';
-import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
-import { stringify, parse } from '../../../src/dev-utils/view';
-
-describe( 'Writer', () => {
-	describe( 'wrapPosition()', () => {
-		let writer;
-
-		// Executes test using `parse` and `stringify` utils functions.
-		//
-		// @param {String} input
-		// @param {String} unwrapAttribute
-		// @param {String} expected
-		function test( input, unwrapAttribute, expected ) {
-			const { view, selection } = parse( input );
-
-			const newPosition = writer.wrapPosition( selection.getFirstPosition(), parse( unwrapAttribute ) );
-			expect( stringify( view, newPosition, { showType: true, showPriority: true } ) ).to.equal( expected );
-		}
-
-		before( () => {
-			writer = new Writer();
-		} );
-
-		it( 'should throw error when element is not instance of AttributeElement', () => {
-			const container = new ContainerElement( 'p', null, new Text( 'foo' ) );
-			const position = new Position( container, 0 );
-			const b = new Element( 'b' );
-
-			expect( () => {
-				writer.wrapPosition( position, b );
-			} ).to.throw( CKEditorError, 'view-writer-wrap-invalid-attribute' );
-		} );
-
-		it( 'should wrap position at the beginning of text node', () => {
-			test(
-				'<container:p>{}foobar</container:p>',
-				'<attribute:b view-priority="1"></attribute:b>',
-				'<container:p><attribute:b view-priority="1">[]</attribute:b>foobar</container:p>'
-			);
-		} );
-
-		it( 'should wrap position inside text node', () => {
-			test(
-				'<container:p>foo{}bar</container:p>',
-				'<attribute:b view-priority="1"></attribute:b>',
-				'<container:p>foo<attribute:b view-priority="1">[]</attribute:b>bar</container:p>'
-			);
-		} );
-
-		it( 'should support unicode', () => {
-			test(
-				'<container:p>நிலை{}க்கு</container:p>',
-				'<attribute:b view-priority="1"></attribute:b>',
-				'<container:p>நிலை<attribute:b view-priority="1">[]</attribute:b>க்கு</container:p>'
-			);
-		} );
-
-		it( 'should wrap position inside document fragment', () => {
-			test(
-				'<attribute:b view-priority="1">foo</attribute:b>[]<attribute:b view-priority="3">bar</attribute:b>',
-				'<attribute:b view-priority="2"></attribute:b>',
-				'<attribute:b view-priority="1">foo</attribute:b><attribute:b view-priority="2">[]</attribute:b>' +
-				'<attribute:b view-priority="3">bar</attribute:b>'
-			);
-		} );
-
-		it( 'should wrap position at the end of text node', () => {
-			test(
-				'<container:p>foobar{}</container:p>',
-				'<attribute:b view-priority="1"></attribute:b>',
-				'<container:p>foobar<attribute:b view-priority="1">[]</attribute:b></container:p>'
-			);
-		} );
-
-		it( 'should merge with existing attributes #1', () => {
-			test(
-				'<container:p><attribute:b view-priority="1">foo</attribute:b>[]</container:p>',
-				'<attribute:b view-priority="1"></attribute:b>',
-				'<container:p><attribute:b view-priority="1">foo{}</attribute:b></container:p>'
-			);
-		} );
-
-		it( 'should merge with existing attributes #2', () => {
-			test(
-				'<container:p>[]<attribute:b view-priority="1">foo</attribute:b></container:p>',
-				'<attribute:b view-priority="1"></attribute:b>',
-				'<container:p><attribute:b view-priority="1">{}foo</attribute:b></container:p>'
-			);
-		} );
-
-		it( 'should wrap when inside nested attributes', () => {
-			test(
-				'<container:p><attribute:b view-priority="1">foo{}bar</attribute:b></container:p>',
-				'<attribute:u view-priority="1"></attribute:u>',
-				'<container:p>' +
-					'<attribute:b view-priority="1">' +
-						'foo' +
-						'<attribute:u view-priority="1">[]</attribute:u>' +
-						'bar' +
-					'</attribute:b>' +
-				'</container:p>'
-			);
-		} );
-
-		it( 'should merge when wrapping between same attribute', () => {
-			test(
-				'<container:p>' +
-					'<attribute:b view-priority="1">foo</attribute:b>[]<attribute:b view-priority="1">bar</attribute:b>' +
-				'</container:p>',
-				'<attribute:b view-priority="1"></attribute:b>',
-				'<container:p><attribute:b view-priority="1">foo{}bar</attribute:b></container:p>'
-			);
-		} );
-
-		it( 'should move position to text node if in same attribute', () => {
-			test(
-				'<container:p><attribute:b view-priority="1">foobar[]</attribute:b></container:p>',
-				'<attribute:b view-priority="1"></attribute:b>',
-				'<container:p><attribute:b view-priority="1">foobar{}</attribute:b></container:p>'
-			);
-		} );
-
-		it( 'should throw if position is set inside EmptyElement', () => {
-			const emptyElement = new EmptyElement( 'img' );
-			new ContainerElement( 'p', null, emptyElement ); // eslint-disable-line no-new
-			const attributeElement = new AttributeElement( 'b' );
-			const position = new Position( emptyElement, 0 );
-
-			expect( () => {
-				writer.wrapPosition( position, attributeElement );
-			} ).to.throw( CKEditorError, 'view-emptyelement-cannot-add' );
-		} );
-
-		it( 'should throw if position is set inside UIElement', () => {
-			const uiElement = new UIElement( 'span' );
-			new ContainerElement( 'p', null, uiElement ); // eslint-disable-line no-new
-			const attributeElement = new AttributeElement( 'b' );
-			const position = new Position( uiElement, 0 );
-
-			expect( () => {
-				writer.wrapPosition( position, attributeElement );
-			} ).to.throw( CKEditorError, 'view-uielement-cannot-add' );
-		} );
-	} );
-} );