Browse Source

Merge pull request #810 from ckeditor/t/792

Feature: Model converters from markers to view.UIElement. Closes #792.
Piotr Jasiun 8 years ago
parent
commit
2bbbc672db

+ 38 - 7
packages/ckeditor5-engine/src/conversion/buildmodelconverter.js

@@ -9,8 +9,10 @@
 
 import {
 	insertElement,
+	insertUIElement,
 	setAttribute,
 	removeAttribute,
+	removeUIElement,
 	wrapItem,
 	unwrapItem,
 	wrapRange,
@@ -21,6 +23,7 @@ import { convertSelectionAttribute, convertSelectionMarker } from './model-selec
 
 import ViewAttributeElement from '../view/attributeelement';
 import ViewContainerElement from '../view/containerelement';
+import ViewUIElement from '../view/uielement';
 
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
@@ -149,7 +152,8 @@ class ModelConverterBuilder {
 	}
 
 	/**
-	 * Registers what type of marker should be converted.
+	 * Registers what type of non-collapsed marker should be converted. For collapsed markers conversion, see
+	 * {@link ~fromCollapsedMarker}.
 	 *
 	 * @chainable
 	 * @param {String} markerName Name of marker to convert.
@@ -159,7 +163,26 @@ class ModelConverterBuilder {
 		this._from = {
 			type: 'marker',
 			name: markerName,
-			priority: null
+			priority: null,
+			collapsed: false
+		};
+
+		return this;
+	}
+
+	/**
+	 * Registers what type of collapsed marker should be converted. For non-collapsed markers conversion, see {@link ~fromMarker}.
+	 *
+	 * @chainable
+	 * @param {String} markerName Name of marker to convert.
+	 * @returns {module:engine/conversion/modelconverterbuilder~ModelConverterBuilder}
+	 */
+	fromCollapsedMarker( markerName ) {
+		this._from = {
+			type: 'marker',
+			name: markerName,
+			priority: null,
+			collapsed: true
 		};
 
 		return this;
@@ -241,13 +264,21 @@ class ModelConverterBuilder {
 
 				dispatcher.on( 'selectionAttribute:' + this._from.key, convertSelectionAttribute( element ), { priority } );
 			} else {
-				// From marker to view element -> wrapRange and unwrapRange.
-				element = typeof element == 'string' ? new ViewAttributeElement( element ) : element;
+				if ( this._from.collapsed ) {
+					// From collapsed marker to view element -> insertUIElement, removeUIElement.
+					element = typeof element == 'string' ? new ViewUIElement( element ) : element;
+
+					dispatcher.on( 'addMarker:' + this._from.name, insertUIElement( element ), { priority } );
+					dispatcher.on( 'removeMarker:' + this._from.name, removeUIElement( element ), { priority } );
+				} else {
+					// From non-collapsed marker to view element -> wrapRange and unwrapRange.
+					element = typeof element == 'string' ? new ViewAttributeElement( element ) : element;
 
-				dispatcher.on( 'addMarker:' + this._from.name, wrapRange( element ), { priority } );
-				dispatcher.on( 'removeMarker:' + this._from.name, unwrapRange( element ), { priority } );
+					dispatcher.on( 'addMarker:' + this._from.name, wrapRange( element ), { priority } );
+					dispatcher.on( 'removeMarker:' + this._from.name, unwrapRange( element ), { priority } );
 
-				dispatcher.on( 'selectionMarker:' + this._from.name, convertSelectionMarker( element ), { priority } );
+					dispatcher.on( 'selectionMarker:' + this._from.name, convertSelectionMarker( element ), { priority } );
+				}
 			}
 		}
 	}

+ 19 - 10
packages/ckeditor5-engine/src/conversion/mapper.js

@@ -13,6 +13,7 @@ import ModelRange from '../model/range';
 import ViewPosition from '../view/position';
 import ViewRange from '../view/range';
 import ViewText from '../view/text';
+import ViewUIElement from '../view/uielement';
 
 import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
@@ -110,6 +111,8 @@ export default class Mapper {
 	/**
 	 * Gets the corresponding model element.
 	 *
+	 * **Note:** {@link module:engine/view/uielement~UIElement} does not have corresponding element in model.
+	 *
 	 * @param {module:engine/view/element~Element} viewElement View element.
 	 * @returns {module:engine/model/element~Element|undefined} Corresponding model element or `undefined` if not found.
 	 */
@@ -312,29 +315,35 @@ export default class Mapper {
 	 * Gets the length of the view element in the model.
 	 *
 	 * The length is calculated as follows:
-	 * * length of a {@link module:engine/view/text~Text text node}
-	 * is equal to the length of it's {@link module:engine/view/text~Text#data data},
-	 * * length of a mapped {@link module:engine/view/element~Element element} is equal to 1 or to the value evaluated by the callback
-	 * added through {@link module:engine/conversion/mapper~Mapper#registerViewToModelLength},
+	 * * if {@link ~registerViewToModelLength length mapping callback} is provided for given `viewNode` it is used to
+	 * evaluate model length (`viewNode` is used as first and only parameter passed to the callback),
+	 * * length of a {@link module:engine/view/text~Text text node} is equal to the length of it's
+	 * {@link module:engine/view/text~Text#data data},
+	 * * length of a {@link module:engine/view/uielement~UIElement ui element} is equal to 0,
+	 * * length of a mapped {@link module:engine/view/element~Element element} is equal to 1,
 	 * * length of a not-mapped {@link module:engine/view/element~Element element} is equal to the length of it's children.
 	 *
 	 * Examples:
 	 *
-	 *		foo                     -> 3 // Text length is equal to it's data length.
-	 *		<p>foo</p>              -> 1 // Length of an element which is mapped is by default equal to 1.
-	 *		<b>foo</b>              -> 3 // Length of an element which is not mapped is a length of its children.
-	 *		<div><p>x</p><p>y</p>   -> 2 // Assuming that <div> is not mapped and <p> are mapped.
+	 *		foo                          -> 3 // Text length is equal to it's data length.
+	 *		<p>foo</p>                   -> 1 // Length of an element which is mapped is by default equal to 1.
+	 *		<b>foo</b>                   -> 3 // Length of an element which is not mapped is a length of its children.
+	 *		<div><p>x</p><p>y</p></div>  -> 2 // Assuming that <div> is not mapped and <p> are mapped.
 	 *
 	 * @param {module:engine/view/element~Element} viewNode View node.
 	 * @returns {Number} Length of the node in the tree model.
 	 */
 	getModelLength( viewNode ) {
-		if ( this._viewToModelMapping.has( viewNode ) ) {
+		if ( this._viewToModelLengthCallbacks.get( viewNode.name ) ) {
 			const callback = this._viewToModelLengthCallbacks.get( viewNode.name );
 
-			return callback ? callback( viewNode ) : 1;
+			return callback( viewNode );
+		} else if ( this._viewToModelMapping.has( viewNode ) ) {
+			return 1;
 		} else if ( viewNode instanceof ViewText ) {
 			return viewNode.data.length;
+		} else if ( viewNode instanceof ViewUIElement ) {
+			return 0;
 		} else {
 			let len = 0;
 

+ 129 - 62
packages/ckeditor5-engine/src/conversion/model-to-view-converters.js

@@ -8,9 +8,11 @@ import ModelPosition from '../model/position';
 import ModelElement from '../model/element';
 
 import ViewRange from '../view/range';
-import ViewPosition from '../view/position';
 import ViewElement from '../view/element';
+import ViewAttributeElement from '../view/element';
+import ViewUIElement from '../view/element';
 import ViewText from '../view/text';
+import ViewTreeWalker from '../view/treewalker';
 import viewWriter from '../view/writer';
 
 /**
@@ -50,14 +52,19 @@ import viewWriter from '../view/writer';
  */
 export function insertElement( elementCreator ) {
 	return ( evt, data, consumable, conversionApi ) => {
+		const viewElement = ( elementCreator instanceof ViewElement ) ?
+			elementCreator.clone( true ) :
+			elementCreator( data, consumable, conversionApi );
+
+		if ( !viewElement ) {
+			return;
+		}
+
 		if ( !consumable.consume( data.item, 'insert' ) ) {
 			return;
 		}
 
 		const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
-		const viewElement = ( elementCreator instanceof ViewElement ) ?
-			elementCreator.clone( true ) :
-			elementCreator( data, consumable, conversionApi );
 
 		conversionApi.mapper.bindElements( data.item, viewElement );
 		viewWriter.insert( viewPosition, viewElement );
@@ -88,6 +95,37 @@ export function insertText() {
 }
 
 /**
+ * Function factory, creates a converter that converts marker adding change to the view ui element.
+ * The view ui element that will be added to the view depends on passed parameter. See {@link ~insertElement}.
+ *
+ * **Note:** unlike {@link ~insertElement}, the converter does not bind view element to model, because this converter
+ * uses marker as "model source of data". This means that view ui element does not have corresponding model element.
+ *
+ * @param {module:engine/view/uielement~UIElement|Function} elementCreator View ui element, or function returning a view element, which
+ * will be inserted.
+ * @returns {Function} Insert element event converter.
+ */
+export function insertUIElement( elementCreator ) {
+	return ( evt, data, consumable, conversionApi ) => {
+		const viewElement = ( elementCreator instanceof ViewElement ) ?
+			elementCreator.clone( true ) :
+			elementCreator( data, consumable, conversionApi );
+
+		if ( !viewElement ) {
+			return;
+		}
+
+		if ( !consumable.consume( data.range, 'addMarker' ) ) {
+			return;
+		}
+
+		const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
+
+		viewWriter.insert( viewPosition, viewElement );
+	};
+}
+
+/**
  * Function factory, creates a converter that converts set/change attribute changes from the model to the view. Attributes
  * from model are converted to the view element attributes in the view. You may provide a custom function to generate a
  * key-value attribute pair to add/change. If not provided, model attributes will be converted to view elements attributes
@@ -209,27 +247,29 @@ export function removeAttribute( attributeCreator ) {
  */
 export function wrapItem( elementCreator ) {
 	return ( evt, data, consumable, conversionApi ) => {
-		let viewRange = conversionApi.mapper.toViewRange( data.range );
-
 		const viewElement = ( elementCreator instanceof ViewElement ) ?
 			elementCreator.clone( true ) :
 			elementCreator( data.attributeNewValue, data, consumable, conversionApi );
 
-		if ( viewElement ) {
-			if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
-				return;
-			}
+		if ( !viewElement ) {
+			return;
+		}
 
-			// If this is a change event (because old value is not empty) and the creator is a function (so
-			// it may create different view elements basing on attribute value) we have to create
-			// view element basing on old value and unwrap it before wrapping with a newly created view element.
-			if ( data.attributeOldValue !== null && !( elementCreator instanceof ViewElement ) ) {
-				const oldViewElement = elementCreator( data.attributeOldValue, data, consumable, conversionApi );
-				viewRange = viewWriter.unwrap( viewRange, oldViewElement );
-			}
+		if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
+			return;
+		}
 
-			viewWriter.wrap( viewRange, viewElement );
+		let viewRange = conversionApi.mapper.toViewRange( data.range );
+
+		// If this is a change event (because old value is not empty) and the creator is a function (so
+		// it may create different view elements basing on attribute value) we have to create
+		// view element basing on old value and unwrap it before wrapping with a newly created view element.
+		if ( data.attributeOldValue !== null && !( elementCreator instanceof ViewElement ) ) {
+			const oldViewElement = elementCreator( data.attributeOldValue, data, consumable, conversionApi );
+			viewRange = viewWriter.unwrap( viewRange, oldViewElement );
 		}
+
+		viewWriter.wrap( viewRange, viewElement );
 	};
 }
 
@@ -257,16 +297,21 @@ export function wrapItem( elementCreator ) {
  */
 export function unwrapItem( elementCreator ) {
 	return ( evt, data, consumable, conversionApi ) => {
+		const viewElement = ( elementCreator instanceof ViewElement ) ?
+			elementCreator.clone( true ) :
+			elementCreator( data.attributeOldValue, data, consumable, conversionApi );
+
+		if ( !viewElement ) {
+			return;
+		}
+
 		if ( !consumable.consume( data.item, eventNameToConsumableType( evt.name ) ) ) {
 			return;
 		}
 
 		const viewRange = conversionApi.mapper.toViewRange( data.range );
-		const viewNode = ( elementCreator instanceof ViewElement ) ?
-			elementCreator.clone( true ) :
-			elementCreator( data.attributeOldValue, data, consumable, conversionApi );
 
-		viewWriter.unwrap( viewRange, viewNode );
+		viewWriter.unwrap( viewRange, viewElement );
 	};
 }
 
@@ -304,26 +349,23 @@ export function unwrapItem( elementCreator ) {
  */
 export function wrapRange( elementCreator ) {
 	return ( evt, data, consumable, conversionApi ) => {
-		const viewRange = conversionApi.mapper.toViewRange( data.range );
-
 		const viewElement = ( elementCreator instanceof ViewElement ) ?
 			elementCreator.clone( true ) :
 			elementCreator( data, consumable, conversionApi );
 
-		if ( viewElement ) {
-			if ( !consumable.consume( data.range, 'addMarker' ) ) {
-				return;
-			}
+		if ( !viewElement ) {
+			return;
+		}
+
+		if ( !consumable.consume( data.range, 'addMarker' ) ) {
+			return;
+		}
 
-			if ( viewRange.isCollapsed ) {
-				viewWriter.wrapPosition( viewRange.start, viewElement );
-			} else {
-				const flatViewRanges = viewWriter.breakViewRangePerContainer( viewRange );
+		const viewRange = conversionApi.mapper.toViewRange( data.range );
+		const flatViewRanges = viewWriter.breakViewRangePerContainer( viewRange );
 
-				for ( let range of flatViewRanges ) {
-					viewWriter.wrap( range, viewElement );
-				}
-			}
+		for ( let range of flatViewRanges ) {
+			viewWriter.wrap( range, viewElement );
 		}
 	};
 }
@@ -351,47 +393,43 @@ export function wrapRange( elementCreator ) {
  */
 export function unwrapRange( elementCreator ) {
 	return ( evt, data, consumable, conversionApi ) => {
-		if ( !consumable.consume( data.range, 'removeMarker' ) ) {
-			return;
-		}
-
 		const viewElement = ( elementCreator instanceof ViewElement ) ?
 			elementCreator.clone( true ) :
 			elementCreator( data, consumable, conversionApi );
 
-		if ( data.range.isCollapsed ) {
-			const modelStart = data.range.start;
-			let viewStart = conversionApi.mapper.toViewPosition( modelStart );
-
-			if ( viewStart.parent instanceof ViewText ) {
-				viewStart = ViewPosition.createAfter( viewStart.parent );
-			}
+		if ( !viewElement ) {
+			return;
+		}
 
-			const viewRange = enlargeViewPosition( viewStart, conversionApi.mapper );
+		if ( !consumable.consume( data.range, 'removeMarker' ) ) {
+			return;
+		}
 
-			viewWriter.unwrap( viewRange, viewElement );
-		} else {
-			const viewRange = conversionApi.mapper.toViewRange( data.range );
-			const flatViewRanges = viewWriter.breakViewRangePerContainer( viewRange );
+		const viewRange = conversionApi.mapper.toViewRange( data.range );
+		const flatViewRanges = viewWriter.breakViewRangePerContainer( viewRange );
 
-			for ( let range of flatViewRanges ) {
-				viewWriter.unwrap( range, viewElement );
-			}
+		for ( let range of flatViewRanges ) {
+			viewWriter.unwrap( range, viewElement );
 		}
 	};
 }
 
 // Takes given `viewPosition` and returns a widest possible range that contains this position and all view elements
-// before that position and after that position which has zero length in model (in most cases empty `ViewAttributeElement`s).
+// before that position and after that position which has zero length in model (empty `ViewAttributeElement`s and `ViewUIElement`s).
 // @param {module:engine/view/position~Position} viewPosition Position to start from when looking for furthest zero length position.
-// @param {module:engine/conversion/mapper~Mapper} mapper Mapper to use when looking for furthest zero length position.
 // @returns {module:engine/view/range~Range}
-function enlargeViewPosition( viewPosition, mapper ) {
-	const start = ViewPosition.createFromPosition( viewPosition );
-	const end = ViewPosition.createFromPosition( viewPosition );
+function enlargeViewPosition( viewPosition ) {
+	const start = viewPosition;
+	let end = viewPosition;
 
-	while ( end.nodeAfter && mapper.getModelLength( end.nodeAfter ) === 0 ) {
-		end.offset++;
+	const walker = new ViewTreeWalker( { startPosition: start } );
+
+	for ( let step of walker ) {
+		if ( step.item instanceof ViewAttributeElement || step.item instanceof ViewUIElement ) {
+			end = walker.position;
+		} else {
+			break;
+		}
 	}
 
 	return new ViewRange( start, end );
@@ -441,6 +479,35 @@ export function remove() {
 }
 
 /**
+ * Function factory, creates a default model-to-view converter for removing {@link module:engine/view/uielement~UIElement ui element}
+ * basing on marker remove change.
+ *
+ * @param {module:engine/view/uielement~UIElement|Function} elementCreator View ui element, or function returning
+ * a view ui element, which will be used as a pattern when look for element to remove at the marker start position.
+ * @returns {Function} Remove ui element converter.
+ */
+export function removeUIElement( elementCreator ) {
+	return ( evt, data, consumable, conversionApi ) => {
+		const viewElement = ( elementCreator instanceof ViewElement ) ?
+			elementCreator.clone( true ) :
+			elementCreator( data, consumable, conversionApi );
+
+		if ( !viewElement ) {
+			return;
+		}
+
+		if ( !consumable.consume( data.range, 'removeMarker' ) ) {
+			return;
+		}
+
+		const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
+		const viewRange = enlargeViewPosition( viewPosition );
+
+		viewWriter.clear( viewRange, viewElement );
+	};
+}
+
+/**
  * Function factory, creates default model-to-view converter for elements which name has changed.
  *
  *		modelDispatcher.on( 'rename', rename() );

+ 21 - 1
packages/ckeditor5-engine/src/view/treewalker.js

@@ -129,6 +129,8 @@ export default class TreeWalker {
 		 * @member {module:engine/view/node~Node} module:engine/view/treewalker~TreeWalker#_boundaryEndParent
 		 */
 		this._boundaryEndParent = this.boundaries ? this.boundaries.end.parent : null;
+
+		this._fixStartPositionInText();
 	}
 
 	/**
@@ -365,7 +367,7 @@ export default class TreeWalker {
 	 * @returns {module:engine/view/treewalker~TreeWalkerValue}
 	 */
 	_formatReturnValue( type, item, previousPosition, nextPosition, length ) {
-		// Text is a specific parent, because contains string instead of childs.
+		// Text is a specific parent, because contains string instead of children.
 		// Walker doesn't enter to the Text except situations when walker is iterating over every single character,
 		// or the bound starts/ends inside the Text. So when the position is at the beginning or at the end of the Text
 		// we move it just before or just after Text.
@@ -404,6 +406,24 @@ export default class TreeWalker {
 			}
 		};
 	}
+
+	/**
+	 * Fixes tree walker start position if it is at the beginning or at the end of a {@link module:engine/view/text~Text text node}.
+	 *
+	 * Without the fix, the first returned value by tree walker would have type `elementEnd` or `elementStart` but the
+	 * item would be a {@link module:engine/view/text~Text text node}.
+	 *
+	 * @private
+	 */
+	_fixStartPositionInText() {
+		const parent = this.position.parent;
+
+		if ( parent instanceof Text && this.position.isAtStart ) {
+			this.position = Position.createBefore( parent );
+		} else if ( parent instanceof Text && this.position.isAtEnd ) {
+			this.position = Position.createAfter( parent );
+		}
+	}
 }
 
 /**

+ 58 - 0
packages/ckeditor5-engine/tests/conversion/buildmodelconverter.js

@@ -16,6 +16,7 @@ import ViewDocument from '../../src/view/document';
 import ViewElement from '../../src/view/element';
 import ViewContainerElement from '../../src/view/containerelement';
 import ViewAttributeElement from '../../src/view/attributeelement';
+import ViewUIElement from '../../src/view/uielement';
 import ViewText from '../../src/view/text';
 
 import Mapper from '../../src/conversion/mapper';
@@ -408,6 +409,63 @@ describe( 'Model converter builder', () => {
 		} );
 	} );
 
+	describe( 'model collapsed marker to view element conversion', () => {
+		let modelText, modelElement;
+
+		beforeEach( () => {
+			modelText = new ModelText( 'foobar' );
+			modelElement = new ModelElement( 'paragraph', null, [ modelText ] );
+			modelRoot.appendChildren( modelElement );
+
+			let viewText = new ViewText( 'foobar' );
+			let viewElement = new ViewContainerElement( 'p', null, [ viewText ] );
+			viewRoot.appendChildren( viewElement );
+
+			mapper.bindElements( modelElement, viewElement );
+		} );
+
+		it( 'using passed view element name', () => {
+			buildModelConverter().for( dispatcher ).fromCollapsedMarker( 'search' ).toElement( 'span' );
+
+			dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 3 ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo<span></span>bar</p></div>' );
+
+			dispatcher.convertMarker( 'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 3 ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+
+		it( 'using passed view element', () => {
+			const viewElement = new ViewUIElement( 'span', { class: 'search' } );
+			buildModelConverter().for( dispatcher ).fromCollapsedMarker( 'search' ).toElement( viewElement );
+
+			dispatcher.convertMarker( 'addMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 3 ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo<span class="search"></span>bar</p></div>' );
+
+			dispatcher.convertMarker( 'removeMarker', 'search', ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 3 ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+
+		it( 'using passed creator function', () => {
+			buildModelConverter().for( dispatcher ).fromCollapsedMarker( 'search' ).toElement( ( data ) => {
+				const className = 'search search-color-' + data.name.split( ':' )[ 1 ];
+
+				return new ViewUIElement( 'span', { class: className } );
+			} );
+
+			dispatcher.convertMarker( 'addMarker', 'search:red', ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 3 ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo<span class="search search-color-red"></span>bar</p></div>' );
+
+			dispatcher.convertMarker( 'removeMarker', 'search:red', ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 3 ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+	} );
+
 	describe( 'withPriority', () => {
 		it( 'should change default converters priority', () => {
 			buildModelConverter().for( dispatcher ).fromElement( 'custom' ).toElement( 'custom' );

+ 52 - 1
packages/ckeditor5-engine/tests/conversion/mapper.js

@@ -12,6 +12,7 @@ import ModelPosition from '../../src/model/position';
 import ModelRange from '../../src/model/range';
 
 import ViewElement from '../../src/view/element';
+import ViewUIElement from '../../src/view/uielement';
 import ViewText from '../../src/view/text';
 import ViewPosition from '../../src/view/position';
 import ViewRange from '../../src/view/range';
@@ -469,7 +470,7 @@ describe( 'Mapper', () => {
 		} );
 	} );
 
-	describe( 'List mapping (test registerViewToModelLengthCallback)', () => {
+	describe( 'List mapping (test registerViewToModelLength)', () => {
 		function createToModelTest( viewElement, viewOffset, modelElement, modelOffset ) {
 			const viewPosition = new ViewPosition( viewElement, viewOffset );
 			const modelPosition = mapper.toModelPosition( viewPosition );
@@ -538,4 +539,54 @@ describe( 'Mapper', () => {
 			it( 'should transform viewList 2', () => createToModelTest( viewList, 2, modelRoot, 4 ) );
 		} );
 	} );
+
+	describe( 'getModelLength', () => {
+		let mapper;
+
+		beforeEach( () => {
+			mapper = new Mapper();
+		} );
+
+		it( 'should return length according to callback added by registerViewToModelLength', () => {
+			const viewElement = new ViewElement( 'span' );
+
+			mapper.registerViewToModelLength( 'span', () => 4 );
+
+			expect( mapper.getModelLength( viewElement ) ).to.equal( 4 );
+		} );
+
+		it( 'should return 1 for mapped elements', () => {
+			const viewElement = new ViewElement( 'span' );
+			const modelElement = new ModelElement( 'span' );
+			mapper.bindElements( modelElement, viewElement );
+
+			expect( mapper.getModelLength( viewElement ) ).to.equal( 1 );
+		} );
+
+		it( 'should return 0 for ui elements', () => {
+			const viewUiElement = new ViewUIElement( 'span' );
+
+			expect( mapper.getModelLength( viewUiElement ) ).to.equal( 0 );
+		} );
+
+		it( 'should return length of data for text nodes', () => {
+			const viewText = new ViewText( 'foo' );
+
+			expect( mapper.getModelLength( viewText ) ).to.equal( 3 );
+		} );
+
+		it( 'should return sum of length of children for unmapped element', () => {
+			const modelP = new ModelElement( 'p' );
+			const viewP = new ViewElement( 'p' );
+			const viewUi = new ViewUIElement( 'span' );
+			const viewFoo = new ViewText( 'foo' );
+			const viewCallback = new ViewElement( 'xxx' );
+			const viewDiv = new ViewElement( 'div', null, [ viewP, viewUi, viewFoo, viewCallback ] );
+
+			mapper.bindElements( modelP, viewP );
+			mapper.registerViewToModelLength( 'xxx', () => 2 );
+
+			expect( mapper.getModelLength( viewDiv ) ).to.equal( 6 );
+		} );
+	} );
 } );

+ 206 - 136
packages/ckeditor5-engine/tests/conversion/model-to-view-converters.js

@@ -13,6 +13,7 @@ import modelWriter from '../../src/model/writer';
 import ViewElement from '../../src/view/element';
 import ViewContainerElement from '../../src/view/containerelement';
 import ViewAttributeElement from '../../src/view/attributeelement';
+import ViewUIElement from '../../src/view/uielement';
 import ViewText from '../../src/view/text';
 
 import Mapper from '../../src/conversion/mapper';
@@ -20,6 +21,7 @@ import ModelConversionDispatcher from '../../src/conversion/modelconversiondispa
 import {
 	insertElement,
 	insertText,
+	insertUIElement,
 	setAttribute,
 	removeAttribute,
 	wrapItem,
@@ -28,6 +30,7 @@ import {
 	unwrapRange,
 	move,
 	remove,
+	removeUIElement,
 	rename
 } from '../../src/conversion/model-to-view-converters';
 
@@ -146,6 +149,26 @@ describe( 'model-to-view-converters', () => {
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><div>foo</div><p>bar</p></div>' );
 		} );
+
+		it( 'should not convert and not consume if creator function returned null', () => {
+			const elementGenerator = () => null;
+
+			const modelP = new ModelElement( 'paragraph', null, new ModelText( 'foo' ) );
+
+			modelRoot.appendChildren( [ modelP ] );
+
+			sinon.spy( dispatcher, 'fire' );
+
+			dispatcher.on( 'insert:paragraph', insertElement( elementGenerator ) );
+			dispatcher.on( 'insert:paragraph', ( evt, data, consumable ) => {
+				expect( consumable.test( data.item, 'insert' ) ).to.be.true;
+			} );
+
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div></div>' );
+			expect( dispatcher.fire.calledWith( 'insert:paragraph' ) ).to.be.true;
+		} );
 	} );
 
 	describe( 'setAttribute/removeAttribute', () => {
@@ -404,10 +427,40 @@ describe( 'model-to-view-converters', () => {
 			// Nothing changed.
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p><b>foobar</b></p></div>' );
 		} );
+
+		it( 'should not convert and not consume if creator function returned null', () => {
+			const elementGenerator = () => null;
+
+			const modelText = new ModelText( 'foo', { bold: true } );
+
+			modelRoot.appendChildren( [ modelText ] );
+
+			sinon.spy( dispatcher, 'fire' );
+
+			dispatcher.on( 'insert:$text', insertText() );
+			dispatcher.on( 'addAttribute:bold', wrapItem( elementGenerator ) );
+			dispatcher.on( 'removeAttribute:bold', unwrapItem( elementGenerator ) );
+
+			dispatcher.on( 'addAttribute:bold', ( evt, data, consumable ) => {
+				expect( consumable.test( data.item, 'addAttribute:bold' ) ).to.be.true;
+			} );
+			dispatcher.on( 'removeAttribute:bold', ( evt, data, consumable ) => {
+				expect( consumable.test( data.item, 'removeAttribute:bold' ) ).to.be.true;
+			} );
+
+			dispatcher.convertInsertion( ModelRange.createIn( modelRoot ) );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div>foo</div>' );
+			expect( dispatcher.fire.calledWith( 'addAttribute:bold:$text' ) ).to.be.true;
+
+			modelText.removeAttribute( 'bold' );
+			dispatcher.convertAttribute( 'removeAttribute', ModelRange.createIn( modelRoot ), 'bold', true, null );
+			expect( dispatcher.fire.calledWith( 'removeAttribute:bold:$text' ) ).to.be.true;
+		} );
 	} );
 
 	describe( 'wrapRange/unwrapRange', () => {
-		let modelText, rangeJohn, rangeAlice, modelElement;
+		let modelText, range, modelElement;
 
 		beforeEach( () => {
 			modelText = new ModelText( 'foobar' );
@@ -420,8 +473,7 @@ describe( 'model-to-view-converters', () => {
 
 			mapper.bindElements( modelElement, viewElement );
 
-			rangeJohn = ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 );
-			rangeAlice = ModelRange.createFromParentsAndOffsets( modelElement, 1, modelElement, 1 );
+			range = ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 );
 		} );
 
 		it( 'should convert adding/removing of marker into wrapping element in a view', () => {
@@ -430,33 +482,11 @@ describe( 'model-to-view-converters', () => {
 			dispatcher.on( 'addMarker:name', wrapRange( viewSpan ) );
 			dispatcher.on( 'removeMarker:name', unwrapRange( viewSpan ) );
 
-			dispatcher.convertMarker( 'addMarker', 'name', rangeJohn );
+			dispatcher.convertMarker( 'addMarker', 'name', range );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="name">ob</span>ar</p></div>' );
 
-			dispatcher.convertMarker( 'removeMarker', 'name', rangeJohn );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-		} );
-
-		it( 'should support collapsed markers', () => {
-			const viewSpan = new ViewAttributeElement( 'span' );
-
-			dispatcher.on( 'addMarker:name', wrapRange( viewSpan ) );
-			dispatcher.on( 'removeMarker:name', unwrapRange( viewSpan ) );
-
-			const rangeP = ModelRange.createFromParentsAndOffsets( modelRoot, 0, modelRoot, 0 );
-
-			dispatcher.convertMarker( 'addMarker', 'name', rangeP );
-			dispatcher.convertMarker( 'addMarker', 'name', rangeAlice );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><span></span><p>f<span></span>oobar</p></div>' );
-
-			dispatcher.convertMarker( 'removeMarker', 'name', rangeP );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<span></span>oobar</p></div>' );
-
-			dispatcher.convertMarker( 'removeMarker', 'name', rangeAlice );
+			dispatcher.convertMarker( 'removeMarker', 'name', range );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
@@ -471,19 +501,11 @@ describe( 'model-to-view-converters', () => {
 			dispatcher.on( 'addMarker:name', wrapRange( converterCallback ) );
 			dispatcher.on( 'removeMarker:name', unwrapRange( converterCallback ) );
 
-			dispatcher.convertMarker( 'addMarker', 'name:john', rangeJohn );
+			dispatcher.convertMarker( 'addMarker', 'name:john', range );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="john">ob</span>ar</p></div>' );
 
-			dispatcher.convertMarker( 'addMarker', 'name:alice', rangeAlice );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<span class="alice"></span>o<span class="john">ob</span>ar</p></div>' );
-
-			dispatcher.convertMarker( 'removeMarker', 'name:john', rangeJohn );
-
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<span class="alice"></span>oobar</p></div>' );
-
-			dispatcher.convertMarker( 'removeMarker', 'name:alice', rangeAlice );
+			dispatcher.convertMarker( 'removeMarker', 'name:john', range );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
@@ -529,14 +551,13 @@ describe( 'model-to-view-converters', () => {
 			};
 
 			dispatcher.on( 'addMarker:name', wrapRange( converterCallback ) );
-			dispatcher.on( 'addMarker:name:alice', ( evt, data, consumable ) => {
+			dispatcher.on( 'addMarker:name:john', ( evt, data, consumable ) => {
 				consumable.consume( data.range, 'addMarker' );
 			}, { priority: 'high' } );
 
-			dispatcher.convertMarker( 'addMarker', 'name:john', rangeJohn );
-			dispatcher.convertMarker( 'addMarker', 'name:alice', rangeAlice );
+			dispatcher.convertMarker( 'addMarker', 'name:john', range );
 
-			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="john">ob</span>ar</p></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
 
 		it( 'should be possible to override unwrapRange', () => {
@@ -552,156 +573,205 @@ describe( 'model-to-view-converters', () => {
 				consumable.consume( data.range, 'removeMarker' );
 			}, { priority: 'high' } );
 
-			dispatcher.convertMarker( 'addMarker', 'name:john', rangeJohn );
+			dispatcher.convertMarker( 'addMarker', 'name:john', range );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="john">ob</span>ar</p></div>' );
 
-			dispatcher.convertMarker( 'removeMarker', 'name:john', rangeJohn );
+			dispatcher.convertMarker( 'removeMarker', 'name:john', range );
 
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="john">ob</span>ar</p></div>' );
 		} );
 
-		it( 'should not convert if view element is not returned by element generating function', () => {
+		it( 'should not convert or consume if view element is not returned by element generating function', () => {
 			const converterCallback = () => null;
 
+			sinon.spy( dispatcher, 'fire' );
+
 			dispatcher.on( 'addMarker:name', wrapRange( converterCallback ) );
 			dispatcher.on( 'addMarker:name', ( evt, data, consumable ) => {
 				// Check whether value was not consumed from `consumable`.
 				expect( consumable.test( data.range, 'addMarker' ) ).to.be.true;
 			} );
 
-			dispatcher.convertMarker( 'addMarker', 'name', rangeJohn );
+			dispatcher.on( 'removeMarker:name', unwrapRange( converterCallback ) );
+			dispatcher.on( 'removeMarker:name', ( evt, data, consumable ) => {
+				// Check whether value was not consumed from `consumable`.
+				expect( consumable.test( data.range, 'removeMarker' ) ).to.be.true;
+			} );
 
+			dispatcher.convertMarker( 'addMarker', 'name', range );
+
+			expect( dispatcher.fire.calledWith( 'addMarker:name' ) ).to.be.true;
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+
+			dispatcher.convertMarker( 'removeMarker', 'name', range );
+
+			expect( dispatcher.fire.calledWith( 'removeMarker:name' ) ).to.be.true;
 			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
 		} );
 
-		describe( 'multiple overlapping markers', () => {
-			it( 'collapsed markers', () => {
-				const converterCallbackName = ( data ) => new ViewAttributeElement( 'span', { class: data.name.split( ':' )[ 1 ] } );
-				const converterCallbackSearch = () => new ViewAttributeElement( 'em', { class: 'search' } );
+		it( 'multiple overlapping non-collapsed markers', () => {
+			const converterCallbackName = ( data ) => {
+				const name = data.name.split( ':' )[ 1 ];
+				const element = new ViewAttributeElement( 'span', { class: name } );
+				element.priority = name.charCodeAt( 0 );
 
-				dispatcher.on( 'addMarker:name', wrapRange( converterCallbackName ) );
-				dispatcher.on( 'removeMarker:name', unwrapRange( converterCallbackName ) );
-				dispatcher.on( 'addMarker:search', wrapRange( converterCallbackSearch ) );
-				dispatcher.on( 'removeMarker:search', unwrapRange( converterCallbackSearch ) );
+				return element;
+			};
+			const converterCallbackSearch = () => new ViewAttributeElement( 'em', { class: 'search' } );
 
-				const range = rangeAlice;
+			dispatcher.on( 'addMarker:name', wrapRange( converterCallbackName ) );
+			dispatcher.on( 'removeMarker:name', unwrapRange( converterCallbackName ) );
+			dispatcher.on( 'addMarker:search', wrapRange( converterCallbackSearch ) );
+			dispatcher.on( 'removeMarker:search', unwrapRange( converterCallbackSearch ) );
 
-				dispatcher.convertMarker( 'addMarker', 'name:a', range );
-				dispatcher.convertMarker( 'addMarker', 'name:b', range );
-				dispatcher.convertMarker( 'addMarker', 'search', range );
+			dispatcher.convertMarker( 'addMarker', 'name:a', range );
+			dispatcher.convertMarker( 'addMarker', 'name:b', range );
+			dispatcher.convertMarker( 'addMarker', 'search', range );
 
-				expect( viewToString( viewRoot ) ).to.equal(
-					'<div><p>f<em class="search"></em><span class="b"></span><span class="a"></span>oobar</p></div>'
-				);
+			expect( viewToString( viewRoot ) ).to.equal(
+				'<div><p>fo<em class="search"><span class="a"><span class="b">ob</span></span></em>ar</p></div>'
+			);
 
-				dispatcher.convertMarker( 'removeMarker', 'name:b', range );
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<em class="search"></em><span class="a"></span>oobar</p></div>' );
+			dispatcher.convertMarker( 'removeMarker', 'name:b', range );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<em class="search"><span class="a">ob</span></em>ar</p></div>' );
 
-				dispatcher.convertMarker( 'removeMarker', 'search', range );
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<span class="a"></span>oobar</p></div>' );
+			dispatcher.convertMarker( 'removeMarker', 'search', range );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="a">ob</span>ar</p></div>' );
 
-				dispatcher.convertMarker( 'removeMarker', 'name:a', range );
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-			} );
+			dispatcher.convertMarker( 'removeMarker', 'name:a', range );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
 
-			it( 'non-collapsed markers', () => {
-				const converterCallbackName = ( data ) => {
-					const name = data.name.split( ':' )[ 1 ];
-					const element = new ViewAttributeElement( 'span', { class: name } );
-					element.priority = name.charCodeAt( 0 );
+		it( 'multiple overlapping non-collapsed markers intersecting with same priority', () => {
+			const converterCallbackSearch = () => new ViewAttributeElement( 'em', { class: 'search' } );
 
-					return element;
-				};
-				const converterCallbackSearch = () => new ViewAttributeElement( 'em', { class: 'search' } );
+			dispatcher.on( 'addMarker:search', wrapRange( converterCallbackSearch ) );
+			dispatcher.on( 'removeMarker:search', unwrapRange( converterCallbackSearch ) );
 
-				dispatcher.on( 'addMarker:name', wrapRange( converterCallbackName ) );
-				dispatcher.on( 'removeMarker:name', unwrapRange( converterCallbackName ) );
-				dispatcher.on( 'addMarker:search', wrapRange( converterCallbackSearch ) );
-				dispatcher.on( 'removeMarker:search', unwrapRange( converterCallbackSearch ) );
+			const range1 = ModelRange.createFromParentsAndOffsets( modelElement, 1, modelElement, 3 );
+			const range2 = ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 );
+			const range3 = ModelRange.createFromParentsAndOffsets( modelElement, 4, modelElement, 6 );
 
-				const range = rangeJohn;
+			dispatcher.convertMarker( 'addMarker', 'search', range1 );
+			dispatcher.convertMarker( 'addMarker', 'search', range2 );
 
-				dispatcher.convertMarker( 'addMarker', 'name:a', range );
-				dispatcher.convertMarker( 'addMarker', 'name:b', range );
-				dispatcher.convertMarker( 'addMarker', 'search', range );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<em class="search">oob</em>ar</p></div>' );
 
-				expect( viewToString( viewRoot ) ).to.equal(
-					'<div><p>fo<em class="search"><span class="a"><span class="b">ob</span></span></em>ar</p></div>'
-				);
+			dispatcher.convertMarker( 'addMarker', 'search', range3 );
 
-				dispatcher.convertMarker( 'removeMarker', 'name:b', range );
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<em class="search"><span class="a">ob</span></em>ar</p></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<em class="search">oobar</em></p></div>' );
 
-				dispatcher.convertMarker( 'removeMarker', 'search', range );
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>fo<span class="a">ob</span>ar</p></div>' );
+			dispatcher.convertMarker( 'removeMarker', 'search', range2 );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<em class="search">o</em>ob<em class="search">ar</em></p></div>' );
 
-				dispatcher.convertMarker( 'removeMarker', 'name:a', range );
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-			} );
+			dispatcher.convertMarker( 'removeMarker', 'search', range3 );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<em class="search">o</em>obar</p></div>' );
+
+			dispatcher.convertMarker( 'removeMarker', 'search', range1 );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
+	} );
 
-			it( 'non-collapsed markers intersecting with same priority', () => {
-				const converterCallbackSearch = () => new ViewAttributeElement( 'em', { class: 'search' } );
+	describe( 'insertUIElement/removeUIElement', () => {
+		let modelText, range, modelElement;
 
-				dispatcher.on( 'addMarker:search', wrapRange( converterCallbackSearch ) );
-				dispatcher.on( 'removeMarker:search', unwrapRange( converterCallbackSearch ) );
+		beforeEach( () => {
+			modelText = new ModelText( 'foobar' );
+			modelElement = new ModelElement( 'paragraph', null, modelText );
+			modelRoot.appendChildren( modelElement );
 
-				const range1 = ModelRange.createFromParentsAndOffsets( modelElement, 1, modelElement, 3 );
-				const range2 = ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 );
-				const range3 = ModelRange.createFromParentsAndOffsets( modelElement, 4, modelElement, 6 );
+			const viewText = new ViewText( 'foobar' );
+			const viewElement = new ViewContainerElement( 'p', null, viewText );
+			viewRoot.appendChildren( viewElement );
 
-				dispatcher.convertMarker( 'addMarker', 'search', range1 );
-				dispatcher.convertMarker( 'addMarker', 'search', range2 );
+			mapper.bindElements( modelElement, viewElement );
 
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<em class="search">oob</em>ar</p></div>' );
+			range = ModelRange.createFromParentsAndOffsets( modelElement, 3, modelElement, 3 );
+		} );
 
-				dispatcher.convertMarker( 'addMarker', 'search', range3 );
+		it( 'should insert and remove ui element - element as a creator', () => {
+			const viewUi = new ViewUIElement( 'span', { 'class': 'marker' } );
 
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<em class="search">oobar</em></p></div>' );
+			dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) );
+			dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) );
 
-				dispatcher.convertMarker( 'removeMarker', 'search', range2 );
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<em class="search">o</em>ob<em class="search">ar</em></p></div>' );
+			dispatcher.convertMarker( 'addMarker', 'marker', range );
 
-				dispatcher.convertMarker( 'removeMarker', 'search', range3 );
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<em class="search">o</em>obar</p></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo<span class="marker"></span>bar</p></div>' );
 
-				dispatcher.convertMarker( 'removeMarker', 'search', range1 );
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
-			} );
+			dispatcher.convertMarker( 'removeMarker', 'marker', range );
 
-			it( 'collapsed and non-collapsed markers', () => {
-				const converterCallbackName = ( data ) => {
-					const name = data.name.split( ':' )[ 1 ];
-					const element = new ViewAttributeElement( 'span', { class: name } );
-					element.priority = name.charCodeAt( 0 );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
 
-					return element;
-				};
+		it( 'should insert and remove ui element - function as a creator', () => {
+			const viewUi = ( data ) => new ViewUIElement( 'span', { 'class': data.name } );
 
-				dispatcher.on( 'addMarker:name', wrapRange( converterCallbackName ) );
-				dispatcher.on( 'removeMarker:name', unwrapRange( converterCallbackName ) );
+			dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) );
+			dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) );
 
-				const range1 = ModelRange.createFromParentsAndOffsets( modelElement, 1, modelElement, 5 );
-				const range2 = ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 2 );
-				const range3 = ModelRange.createFromParentsAndOffsets( modelElement, 2, modelElement, 4 );
+			dispatcher.convertMarker( 'addMarker', 'marker', range );
 
-				dispatcher.convertMarker( 'addMarker', 'name:a', range1 );
-				dispatcher.convertMarker( 'addMarker', 'name:b', range2 );
-				dispatcher.convertMarker( 'addMarker', 'name:c', range3 );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foo<span class="marker"></span>bar</p></div>' );
 
-				expect( viewToString( viewRoot ) ).to.equal(
-					'<div><p>f<span class="a">o<span class="b"></span><span class="c">ob</span>a</span>r</p></div>'
-				);
+			dispatcher.convertMarker( 'removeMarker', 'marker', range );
 
-				dispatcher.convertMarker( 'removeMarker', 'name:c', range3 );
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<span class="a">o<span class="b"></span>oba</span>r</p></div>' );
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+		} );
 
-				dispatcher.convertMarker( 'removeMarker', 'name:b', range2 );
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>f<span class="a">ooba</span>r</p></div>' );
+		it( 'should not convert or consume if generator function returned null', () => {
+			const viewUi = () => null;
 
-				dispatcher.convertMarker( 'removeMarker', 'name:a', range1 );
-				expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			sinon.spy( dispatcher, 'fire' );
+
+			dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) );
+			dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) );
+
+			dispatcher.on( 'addMarker:marker', ( evt, data, consumable ) => {
+				expect( consumable.test( data.range, 'addMarker' ) ).to.be.true;
 			} );
+
+			dispatcher.on( 'removeMarker:marker', ( evt, data, consumable ) => {
+				expect( consumable.test( data.range, 'removeMarker' ) ).to.be.true;
+			} );
+
+			dispatcher.convertMarker( 'addMarker', 'marker', range );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			expect( dispatcher.fire.calledWith( 'addMarker:marker' ) );
+
+			dispatcher.convertMarker( 'removeMarker', 'marker', range );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			expect( dispatcher.fire.calledWith( 'removeMarker:marker' ) );
+		} );
+
+		it( 'should be possible to overwrite', () => {
+			const viewUi = new ViewUIElement( 'span', { 'class': 'marker' } );
+
+			sinon.spy( dispatcher, 'fire' );
+
+			dispatcher.on( 'addMarker:marker', insertUIElement( viewUi ) );
+			dispatcher.on( 'removeMarker:marker', removeUIElement( viewUi ) );
+
+			dispatcher.on( 'addMarker:marker', ( evt, data, consumable ) => {
+				consumable.consume( data.range, 'addMarker' );
+			}, { priority: 'high' } );
+
+			dispatcher.on( 'removeMarker:marker', ( evt, data, consumable ) => {
+				consumable.consume( data.range, 'removeMarker' );
+			}, { priority: 'high' } );
+
+			dispatcher.convertMarker( 'addMarker', 'marker', range );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			expect( dispatcher.fire.calledWith( 'addMarker:marker' ) );
+
+			dispatcher.convertMarker( 'removeMarker', 'marker', range );
+
+			expect( viewToString( viewRoot ) ).to.equal( '<div><p>foobar</p></div>' );
+			expect( dispatcher.fire.calledWith( 'removeMarker:marker' ) );
 		} );
 	} );
 

+ 11 - 0
packages/ckeditor5-engine/tests/view/treewalker.js

@@ -979,6 +979,17 @@ describe( 'TreeWalker', () => {
 		} );
 	} );
 
+	it( 'should not return elementEnd for a text node when iteration begins at the start or the end of that text node', () => {
+		let iterator = new TreeWalker( {
+			startPosition: Position.createAt( textAbcd, 'end' )
+		} );
+
+		const step = iterator.next();
+
+		expect( step.value.type ).to.equal( 'elementEnd' );
+		expect( step.value.item ).to.equal( bold );
+	} );
+
 	it( 'should iterate over document fragment', () => {
 		const foo = new Text( 'foo' );
 		const bar = new Text( 'bar' );