8
0
Просмотр исходного кода

Added: view.UIElement converters.

Szymon Cofalik 9 лет назад
Родитель
Сommit
ad3f5e9f06

+ 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 } );
+				}
 			}
 		}
 	}

+ 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 );
@@ -87,6 +94,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
@@ -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 );
@@ -440,6 +478,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.
  *

+ 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' );

+ 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' ) );
 		} );
 	} );