浏览代码

Merge pull request #1247 from ckeditor/t/1238

Other: Simplified model to view selection conversion. Closes #1238.
Piotr Jasiun 8 年之前
父节点
当前提交
ec7892d4c5

+ 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 - 143
packages/ckeditor5-engine/src/conversion/model-selection-to-view-converters.js

@@ -3,10 +3,8 @@
  * For licensing, see LICENSE.md.
  */
 
-import ViewElement from '../view/element';
 import ViewRange from '../view/range';
 import viewWriter from '../view/writer';
-import { createViewElementFromHighlightDescriptor } from './model-to-view-converters';
 
 /**
  * Contains {@link module:engine/model/selection~Selection model selection} to
@@ -59,11 +57,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.
@@ -92,142 +88,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 );
-	};
-}
-
-/**
- * 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 );
-	};
-}
-
-// Helper function for `convertSelectionAttribute` and `convertSelectionMarker`, which perform similar task.
-function wrapCollapsedSelectionPosition( modelSelection, viewSelection, viewElement, consumable, eventName ) {
-	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 = viewWriter.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
  * {@link module:engine/view/attributeelement~AttributeElement view attribute elements} and merge sibling attributes at all start and end

+ 32 - 20
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';
@@ -278,6 +280,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
@@ -325,16 +329,21 @@ export function wrap( elementCreator ) {
 			return;
 		}
 
-		let viewRange = conversionApi.mapper.toViewRange( data.range );
+		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 );
 
-		// First, unwrap the range from current wrapper.
-		if ( data.attributeOldValue !== null ) {
-			viewRange = viewWriter.unwrap( viewRange, oldViewElement );
-		}
+			// First, unwrap the range from current wrapper.
+			if ( data.attributeOldValue !== null ) {
+				viewRange = viewWriter.unwrap( viewRange, oldViewElement );
+			}
 
-		// Then wrap with the new wrapper.
-		if ( data.attributeNewValue !== null ) {
-			viewWriter.wrap( viewRange, newViewElement );
+			if ( data.attributeNewValue !== null ) {
+				viewWriter.wrap( viewRange, newViewElement );
+			}
 		}
 	};
 }
@@ -344,6 +353,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.
@@ -357,9 +369,7 @@ export function highlightText( highlightDescriptor ) {
 			return;
 		}
 
-		const modelItem = data.item;
-
-		if ( !modelItem.is( 'textProxy' ) ) {
+		if ( !( data.item instanceof ModelSelection ) && !data.item.is( 'textProxy' ) ) {
 			return;
 		}
 
@@ -369,14 +379,18 @@ 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 );
 
-		viewWriter.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 );
+		}
 	};
 }
 
@@ -403,9 +417,7 @@ export function highlightElement( highlightDescriptor ) {
 			return;
 		}
 
-		const modelItem = data.item;
-
-		if ( !modelItem.is( 'element' ) ) {
+		if ( !( data.item instanceof ModelElement ) ) {
 			return;
 		}
 
@@ -415,18 +427,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.
@@ -232,8 +232,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 ) {
@@ -242,6 +242,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();
 
@@ -250,26 +254,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 );
 			}
 		}
 	}
@@ -392,11 +398,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;
@@ -458,7 +464,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.
@@ -469,10 +475,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.
@@ -488,38 +495,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,
@@ -527,17 +503,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

@@ -31,7 +31,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';
@@ -199,7 +198,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 ) } );
 		}
 	} ) );
@@ -211,9 +210,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.
 	modelToView.convertInsert( range );

+ 53 - 21
packages/ckeditor5-engine/src/view/writer.js

@@ -34,7 +34,6 @@ const writer = {
 	clear,
 	move,
 	wrap,
-	wrapPosition,
 	unwrap,
 	rename
 };
@@ -458,28 +457,62 @@ export function move( sourceRange, targetPosition ) {
 /**
  * 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.
  */
-export function wrap( range, attribute ) {
+export function 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;
+	if ( !range.isCollapsed ) {
+		// Non-collapsed range. Wrap it with the attribute element.
+		return _wrapRange( range, attribute );
+	} else {
+		// Collapsed range. Wrap position.
+		let position = range.start;
+
+		if ( position.parent.is( 'element' ) && !_hasNonUiChildren( position.parent ) ) {
+			position = position.getLastMatchingPosition( value => value.item.is( 'uiElement' ) );
+		}
+
+		position = _wrapPosition( position, attribute );
+
+		// 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 ) ] );
+		}
+
+		return new Range( position );
 	}
+}
 
+// 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}.
+//
+// @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.
+function _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;
@@ -530,22 +563,16 @@ export function wrap( range, attribute ) {
 	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.
- */
-export function wrapPosition( position, attribute ) {
-	if ( !( attribute instanceof AttributeElement ) ) {
-		throw new CKEditorError( 'view-writer-wrap-invalid-attribute' );
-	}
-
+// 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}.
+//
+// @param {module:engine/view/position~Position} position
+// @param {module:engine/view/attributeelement~AttributeElement} attribute
+// @returns {module:engine/view/position~Position} New position after wrapping.
+function _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 ) );
@@ -586,6 +613,11 @@ export function wrapPosition( position, attribute ) {
 	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' ) );
+}
+
 /**
  * Unwraps nodes within provided range from attribute element.
  *

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

@@ -143,8 +143,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

@@ -19,8 +19,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 } )
 				) );
 
@@ -292,38 +265,11 @@ describe( 'model-selection-to-view-converters', () => {
 
 				// 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.
-				dispatcher.convertInsert( ModelRange.createIn( modelRoot ) );
-				dispatcher.convertMarkerAdd( marker.name, marker.getRange() );
-
-				const markers = Array.from( model.markers.getMarkersAtPosition( modelSelection.getFirstPosition() ) );
-				dispatcher.convertSelection( modelSelection, markers );
-
-				// 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 ) );
@@ -415,15 +361,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.
 				);
 			} );
 		} );
@@ -468,13 +414,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' }
 				);
 
@@ -490,17 +433,14 @@ 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' }
 				);
 
-				// Remove <b></b> manually.
+				// Remove <strong></strong> manually.
 				mergeAttributes( viewSelection.getFirstPosition() );
 
 				const modelRange = ModelRange.createFromParentsAndOffsets( modelRoot, 1, modelRoot, 1 );
@@ -527,115 +467,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

@@ -264,45 +264,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' );
@@ -310,7 +334,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', () => {
@@ -350,24 +385,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;
 		} );
 	} );
 

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

@@ -4,6 +4,8 @@
  */
 
 import { wrap } from '../../../src/view/writer';
+import Document from '../../../src/view/document';
+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,380 +16,552 @@ 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', () => {
-	/**
-	 * 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 = wrap( selection.getFirstRange(), parse( wrapAttribute ) );
-
-		expect( stringify( view.root, newRange, { showType: true, showPriority: true } ) ).to.equal( expected );
-	}
-
 	describe( 'wrap', () => {
-		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( () => {
-				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( () => {
-				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( () => {
-				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( () => {
-				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( () => {
-				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 = 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( () => {
+					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( () => {
+					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( () => {
+					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( () => {
+					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( () => {
+					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 viewDocument, viewRoot;
+
+			beforeEach( () => {
+				viewDocument = new Document();
+				viewRoot = createViewRoot( viewDocument );
+			} );
+
+			afterEach( () => {
+				viewDocument.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 = 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( () => {
+					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 - 150
packages/ckeditor5-engine/tests/view/writer/wrapposition.js

@@ -1,150 +0,0 @@
-/**
- * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-import { wrapPosition } 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( 'wrapPosition', () => {
-	/**
-	 * 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 = wrapPosition( selection.getFirstPosition(), parse( unwrapAttribute ) );
-		expect( stringify( view, 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( () => {
-			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( () => {
-			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( () => {
-			wrapPosition( position, attributeElement );
-		} ).to.throw( CKEditorError, 'view-uielement-cannot-add' );
-	} );
-} );