Bladeren bron

Merge branch 'master' into t/ckeditor5/456

Piotrek Koszuliński 8 jaren geleden
bovenliggende
commit
4d70f33f6f

+ 26 - 41
packages/ckeditor5-engine/src/controller/datacontroller.js

@@ -8,7 +8,7 @@
  */
 
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
-import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
+import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
 
 import Mapper from '../conversion/mapper';
 
@@ -38,7 +38,7 @@ import getSelectedContent from './getselectedcontent';
  * * {@link module:engine/conversion/modelconversiondispatcher~ModelConversionDispatcher model to view} and
  * * {@link module:engine/conversion/viewconversiondispatcher~ViewConversionDispatcher view to model} converters.
  *
- * @mixes module:utils/emittermixin~EmitterMixin
+ * @mixes module:utils/emittermixin~ObservableMixin
  */
 export default class DataController {
 	/**
@@ -117,12 +117,8 @@ export default class DataController {
 		this.viewToModel.on( 'element', convertToModelFragment(), { priority: 'lowest' } );
 		this.viewToModel.on( 'documentFragment', convertToModelFragment(), { priority: 'lowest' } );
 
-		this.on( 'insertContent', ( evt, data ) => insertContent( this, data.content, data.selection, data.batch ) );
-		this.on( 'deleteContent', ( evt, data ) => deleteContent( data.selection, data.batch, data.options ) );
-		this.on( 'modifySelection', ( evt, data ) => modifySelection( this, data.selection, data.options ) );
-		this.on( 'getSelectedContent', ( evt, data ) => {
-			data.content = getSelectedContent( data.selection );
-		} );
+		[ 'insertContent', 'deleteContent', 'modifySelection', 'getSelectedContent' ]
+			.forEach( methodName => this.decorate( methodName ) );
 	}
 
 	/**
@@ -248,7 +244,7 @@ export default class DataController {
 	destroy() {}
 
 	/**
-	 * See {@link module:engine/controller/insertcontent~insertContent}.
+	 * See {@link module:engine/controller/insertcontent.insertContent}.
 	 *
 	 * @fires insertContent
 	 * @param {module:engine/model/documentfragment~DocumentFragment|module:engine/model/item~Item} content The content to insert.
@@ -257,11 +253,11 @@ export default class DataController {
 	 * changes will be added to a new batch.
 	 */
 	insertContent( content, selection, batch ) {
-		this.fire( 'insertContent', { content, selection, batch } );
+		insertContent( this, content, selection, batch );
 	}
 
 	/**
-	 * See {@link module:engine/controller/deletecontent~deleteContent}.
+	 * See {@link module:engine/controller/deletecontent.deleteContent}.
 	 *
 	 * Note: For the sake of predictability, the resulting selection should always be collapsed.
 	 * In cases where a feature wants to modify deleting behavior so selection isn't collapsed
@@ -277,81 +273,70 @@ export default class DataController {
 	 * @param {Object} options See {@link module:engine/controller/deletecontent~deleteContent}'s options.
 	 */
 	deleteContent( selection, batch, options ) {
-		this.fire( 'deleteContent', { batch, selection, options } );
+		deleteContent( selection, batch, options );
 	}
 
 	/**
-	 * See {@link module:engine/controller/modifyselection~modifySelection}.
+	 * See {@link module:engine/controller/modifyselection.modifySelection}.
 	 *
 	 * @fires modifySelection
 	 * @param {module:engine/model/selection~Selection} selection The selection to modify.
 	 * @param {Object} options See {@link module:engine/controller/modifyselection~modifySelection}'s options.
 	 */
 	modifySelection( selection, options ) {
-		this.fire( 'modifySelection', { selection, options } );
+		modifySelection( this, selection, options );
 	}
 
 	/**
-	 * See {@link module:engine/controller/getselectedcontent~getSelectedContent}.
+	 * See {@link module:engine/controller/getselectedcontent.getSelectedContent}.
 	 *
 	 * @fires module:engine/controller/datacontroller~DataController#getSelectedContent
 	 * @param {module:engine/model/selection~Selection} selection The selection of which content will be retrieved.
 	 * @returns {module:engine/model/documentfragment~DocumentFragment} Document fragment holding the clone of the selected content.
 	 */
 	getSelectedContent( selection ) {
-		const evtData = { selection };
-
-		this.fire( 'getSelectedContent', evtData );
-
-		return evtData.content;
+		return getSelectedContent( selection );
 	}
 }
 
-mix( DataController, EmitterMixin );
+mix( DataController, ObservableMixin );
 
 /**
  * Event fired when {@link #insertContent} method is called.
+ *
  * The {@link #insertContent default action of that method} is implemented as a
  * listener to this event so it can be fully customized by the features.
  *
  * @event insertContent
- * @param {Object} data
- * @param {module:engine/view/documentfragment~DocumentFragment|module:engine/model/item~Item} data.content The content to insert.
- * @param {module:engine/model/selection~Selection} data.selection Selection into which the content should be inserted.
- * @param {module:engine/model/batch~Batch} [data.batch] Batch to which deltas will be added.
+ * @param {Array} args The arguments passed to the original method.
  */
 
 /**
  * Event fired when {@link #deleteContent} method is called.
- * The {@link module:engine/controller/deletecontent~deleteContent default action of that method} is implemented as a
+ *
+ * The {@link #deleteContent default action of that method} is implemented as a
  * listener to this event so it can be fully customized by the features.
  *
  * @event deleteContent
- * @param {Object} data
- * @param {module:engine/model/batch~Batch} data.batch
- * @param {module:engine/model/selection~Selection} data.selection
- * @param {Object} data.options See {@link module:engine/controller/deletecontent~deleteContent}'s options.
+ * @param {Array} args The arguments passed to the original method.
  */
 
 /**
  * Event fired when {@link #modifySelection} method is called.
- * The {@link module:engine/controller/modifyselection~modifySelection default action of that method} is implemented as a
+ *
+ * The {@link #modifySelection default action of that method} is implemented as a
  * listener to this event so it can be fully customized by the features.
  *
  * @event modifySelection
- * @param {Object} data
- * @param {module:engine/model/selection~Selection} data.selection
- * @param {Object} data.options See {@link module:engine/controller/modifyselection~modifySelection}'s options.
+ * @param {Array} args The arguments passed to the original method.
  */
 
 /**
- * Event fired when {@link module:engine/controller/datacontroller~DataController#getSelectedContent} method is called.
- * The {@link module:engine/controller/getselectedcontent~getSelectedContent default action of that method} is implemented as a
+ * Event fired when {@link #getSelectedContent} method is called.
+ *
+ * The {@link #getSelectedContent default action of that method} is implemented as a
  * listener to this event so it can be fully customized by the features.
  *
- * @event module:engine/controller/datacontroller~DataController#getSelectedContent
- * @param {Object} data
- * @param {module:engine/model/selection~Selection} data.selection
- * @param {module:engine/model/documentfragment~DocumentFragment} data.content The document fragment to return
- * (holding a clone of the selected content).
+ * @event getSelectedContent
+ * @param {Array} args The arguments passed to the original method.
  */

+ 1 - 1
packages/ckeditor5-engine/src/controller/editingcontroller.js

@@ -85,7 +85,7 @@ export default class EditingController {
 		 * stop listening on {@link #destroy}.
 		 *
 		 * @private
-		 * @member {utils.EmitterMixin} #_listenter
+		 * @member {utils.EmitterMixin} #_listener
 		 */
 		this._listener = Object.create( EmitterMixin );
 

+ 11 - 3
packages/ckeditor5-engine/src/view/attributeelement.js

@@ -101,7 +101,7 @@ AttributeElement.DEFAULT_PRIORITY = DEFAULT_PRIORITY;
 // @returns {Number|null} Block filler offset or `null` if block filler is not needed.
 function getFillerOffset() {
 	// <b>foo</b> does not need filler.
-	if ( this.childCount ) {
+	if ( nonUiChildrenCount( this ) ) {
 		return null;
 	}
 
@@ -109,16 +109,24 @@ function getFillerOffset() {
 
 	// <p><b></b></p> needs filler -> <p><b><br></b></p>
 	while ( element && element.is( 'attributeElement' ) ) {
-		if ( element.childCount > 1 ) {
+		if ( nonUiChildrenCount( element ) > 1 ) {
 			return null;
 		}
 
 		element = element.parent;
 	}
 
-	if ( !element || element.childCount > 1 ) {
+	if ( !element || nonUiChildrenCount( element ) > 1 ) {
 		return null;
 	}
 
 	return 0;
 }
+
+// Returns total count of children that are not {@link module:engine/view/uielement~UIElement UIElements}.
+//
+// @param {module:engine/view/element~Element} element
+// @return {Number}
+function nonUiChildrenCount( element ) {
+	return Array.from( element.getChildren() ).filter( element => !element.is( 'uiElement' ) ).length;
+}

+ 1 - 1
packages/ckeditor5-engine/src/view/containerelement.js

@@ -78,5 +78,5 @@ export default class ContainerElement extends Element {
 //
 // @returns {Number|null} Block filler offset or `null` if block filler is not needed.
 function getFillerOffset() {
-	return this.childCount === 0 ? 0 : null;
+	return Array.from( this.getChildren() ).some( element => !element.is( 'uiElement' ) ) ? null : 0;
 }

+ 4 - 8
packages/ckeditor5-engine/src/view/document.js

@@ -117,13 +117,7 @@ export default class Document {
 
 		injectQuirksHandling( this );
 
-		// Listens `render` event on default priority.
-		// This way we can attach other listeners before or after rendering execution.
-		this.on( 'render', () => {
-			this.disableObservers();
-			this.renderer.render();
-			this.enableObservers();
-		} );
+		this.decorate( 'render' );
 	}
 
 	/**
@@ -266,7 +260,9 @@ export default class Document {
 	 * @fires render
 	 */
 	render() {
-		this.fire( 'render' );
+		this.disableObservers();
+		this.renderer.render();
+		this.enableObservers();
 	}
 
 	/**

+ 63 - 1
packages/ckeditor5-engine/src/view/domconverter.js

@@ -199,6 +199,16 @@ export default class DomConverter {
 				if ( options.bind ) {
 					this.bindDocumentFragments( domElement, viewNode );
 				}
+			} else if ( viewNode.is( 'uiElement' ) ) {
+				// UIElement has it's own render() method.
+				// https://github.com/ckeditor/ckeditor5-engine/issues/799
+				domElement = viewNode.render( domDocument );
+
+				if ( options.bind ) {
+					this.bindElements( domElement, viewNode );
+				}
+
+				return domElement;
 			} else {
 				// Create DOM element.
 				domElement = domDocument.createElement( viewNode.name );
@@ -339,6 +349,7 @@ export default class DomConverter {
 	 * Converts DOM to view. For all text nodes, not bound elements and document fragments new items will
 	 * be created. For bound elements and document fragments function will return corresponding items. For
 	 * {@link module:engine/view/filler fillers} `null` will be returned.
+	 * For all DOM elements rendered by {@link module:engine/view/uielement~UIElement} that UIElement will be returned.
 	 *
 	 * @param {Node|DocumentFragment} domNode DOM node or document fragment to transform.
 	 * @param {Object} [options] Conversion options.
@@ -353,6 +364,13 @@ export default class DomConverter {
 			return null;
 		}
 
+		// When node is inside UIElement return that UIElement as it's view representation.
+		const uiElement = this.getParentUIElement( domNode, this._domToViewMapping );
+
+		if ( uiElement ) {
+			return uiElement;
+		}
+
 		if ( this.isText( domNode ) ) {
 			if ( isInlineFiller( domNode ) ) {
 				return null;
@@ -488,6 +506,9 @@ export default class DomConverter {
 	 * If the position is inside a {@link module:engine/view/filler filler} which has no corresponding view node,
 	 * position of the filler will be converted and returned.
 	 *
+	 * If the position is inside DOM element rendered by {@link module:engine/view/uielement~UIElement}
+	 * that position will be converted to view position before that UIElement.
+	 *
 	 * If structures are too different and it is not possible to find corresponding position then `null` will be returned.
 	 *
 	 * @param {Node} domParent DOM position parent.
@@ -499,6 +520,12 @@ export default class DomConverter {
 			return this.domPositionToView( domParent.parentNode, indexOf( domParent ) );
 		}
 
+		// If position is somewhere inside UIElement - return position before that element.
+		const viewElement = this.getCorrespondingViewElement( domParent );
+		if ( viewElement && viewElement.is( 'uiElement' ) ) {
+			return ViewPosition.createBefore( viewElement );
+		}
+
 		if ( this.isText( domParent ) ) {
 			if ( isInlineFiller( domParent ) ) {
 				return this.domPositionToView( domParent.parentNode, indexOf( domParent ) );
@@ -567,12 +594,13 @@ export default class DomConverter {
 	/**
 	 * Gets corresponding view element. Returns element if an view element was
 	 * {@link module:engine/view/domconverter~DomConverter#bindElements bound} to the given DOM element or `null` otherwise.
+	 * For all DOM elements rendered by {@link module:engine/view/uielement~UIElement} that UIElement will be returned.
 	 *
 	 * @param {HTMLElement} domElement DOM element.
 	 * @returns {module:engine/view/element~Element|null} Corresponding element or `null` if no element was bound.
 	 */
 	getCorrespondingViewElement( domElement ) {
-		return this._domToViewMapping.get( domElement );
+		return this.getParentUIElement( domElement ) || this._domToViewMapping.get( domElement );
 	}
 
 	/**
@@ -597,6 +625,8 @@ export default class DomConverter {
 	 * If this is a first child in the parent and the parent is a {@link module:engine/view/domconverter~DomConverter#bindElements bound}
 	 * element, it is used to find the corresponding text node.
 	 *
+	 * For all text nodes rendered by {@link module:engine/view/uielement~UIElement} that UIElement will be returned.
+	 *
 	 * Otherwise `null` is returned.
 	 *
 	 * Note that for the block or inline {@link module:engine/view/filler filler} this method returns `null`.
@@ -610,6 +640,13 @@ export default class DomConverter {
 			return null;
 		}
 
+		// If DOM text was rendered by UIElement - return that element.
+		const uiElement = this.getParentUIElement( domText );
+
+		if ( uiElement ) {
+			return uiElement;
+		}
+
 		const previousSibling = domText.previousSibling;
 
 		// Try to use previous sibling to find the corresponding text node.
@@ -832,6 +869,31 @@ export default class DomConverter {
 		return backward;
 	}
 
+	/**
+	 * Returns parent {@link module:engine/view/uielement~UIElement} for provided DOM node. Returns null if there is no
+	 * parent UIElement.
+	 *
+	 * @param {Node} domNode
+	 * @return {module:engine/view/uielement~UIElement|null}
+	 */
+	getParentUIElement( domNode ) {
+		const ancestors = getAncestors( domNode );
+
+		// Remove domNode from the list.
+		ancestors.pop();
+
+		while ( ancestors.length ) {
+			const domNode = ancestors.pop();
+			const viewNode = this._domToViewMapping.get( domNode );
+
+			if ( viewNode && viewNode.is( 'uiElement' ) ) {
+				return viewNode;
+			}
+		}
+
+		return null;
+	}
+
 	/**
 	 * Takes text data from given {@link module:engine/view/text~Text#data} and processes it so it is correctly displayed in DOM.
 	 *

+ 12 - 0
packages/ckeditor5-engine/src/view/observer/mutationobserver.js

@@ -149,6 +149,11 @@ export default class MutationObserver extends Observer {
 			if ( mutation.type === 'childList' ) {
 				const element = domConverter.getCorrespondingViewElement( mutation.target );
 
+				// Do not collect mutations from UIElements.
+				if ( element && element.is( 'uiElement' ) ) {
+					continue;
+				}
+
 				if ( element && !this._isBogusBrMutation( mutation ) ) {
 					mutatedElements.add( element );
 				}
@@ -157,6 +162,13 @@ export default class MutationObserver extends Observer {
 
 		// Handle `characterData` mutations later, when we have the full list of nodes which changed structure.
 		for ( const mutation of domMutations ) {
+			const element = domConverter.getCorrespondingViewElement( mutation.target );
+
+			// Do not collect mutations from UIElements.
+			if ( element && element.is( 'uiElement' ) ) {
+				continue;
+			}
+
 			if ( mutation.type === 'characterData' ) {
 				const text = domConverter.getCorrespondingViewText( mutation.target );
 

+ 17 - 0
packages/ckeditor5-engine/src/view/uielement.js

@@ -63,6 +63,23 @@ export default class UIElement extends Element {
 			throw new CKEditorError( 'view-uielement-cannot-add: Cannot add child nodes to UIElement instance.' );
 		}
 	}
+
+	/**
+	 * Renders this {@link module:engine/view/uielement~UIElement} to DOM. This method is called by
+	 * {@link module:engine/view/domconverter~DomConverter}.
+	 *
+	 * @param {Document} domDocument
+	 * @return {HTMLElement}
+	 */
+	render( domDocument ) {
+		const domElement = domDocument.createElement( this.name );
+
+		for ( const key of this.getAttributeKeys() ) {
+			domElement.setAttribute( key, this.getAttribute( key ) );
+		}
+
+		return domElement;
+	}
 }
 
 // Returns `null` because block filler is not needed for UIElements.

+ 59 - 121
packages/ckeditor5-engine/tests/controller/datacontroller.js

@@ -42,94 +42,6 @@ describe( 'DataController', () => {
 
 			expect( data.processor ).to.be.undefined;
 		} );
-
-		it( 'should add insertContent listener and inserts document fragment when event fires', () => {
-			const batch = modelDocument.batch();
-			const content = new ModelDocumentFragment( [ new ModelText( 'x' ) ] );
-
-			schema.registerItem( 'paragraph', '$block' );
-
-			setData( modelDocument, '<paragraph>a[]b</paragraph>' );
-
-			data.fire( 'insertContent', { content, selection: modelDocument.selection, batch } );
-
-			expect( getData( modelDocument ) ).to.equal( '<paragraph>ax[]b</paragraph>' );
-			expect( batch.deltas.length ).to.be.above( 0 );
-		} );
-
-		it( 'should add insertContent listener and inserts an item when event fires', () => {
-			const batch = modelDocument.batch();
-			const content = new ModelText( 'x' );
-
-			schema.registerItem( 'paragraph', '$block' );
-
-			setData( modelDocument, '<paragraph>a[]b</paragraph>' );
-
-			data.fire( 'insertContent', { content, selection: modelDocument.selection, batch } );
-
-			expect( getData( modelDocument ) ).to.equal( '<paragraph>ax[]b</paragraph>' );
-			expect( batch.deltas.length ).to.be.above( 0 );
-		} );
-
-		it( 'should add deleteContent listener', () => {
-			schema.registerItem( 'paragraph', '$block' );
-
-			setData( modelDocument, '<paragraph>f[oo</paragraph><paragraph>ba]r</paragraph>' );
-
-			const batch = modelDocument.batch();
-
-			data.fire( 'deleteContent', { batch, selection: modelDocument.selection } );
-
-			expect( getData( modelDocument ) ).to.equal( '<paragraph>f[]</paragraph><paragraph>r</paragraph>' );
-			expect( batch.deltas ).to.not.be.empty;
-		} );
-
-		it( 'should add deleteContent listener which passes ', () => {
-			schema.registerItem( 'paragraph', '$block' );
-
-			setData( modelDocument, '<paragraph>f[oo</paragraph><paragraph>ba]r</paragraph>' );
-
-			const batch = modelDocument.batch();
-
-			data.fire( 'deleteContent', {
-				batch,
-				selection: modelDocument.selection,
-				options: { merge: true }
-			} );
-
-			expect( getData( modelDocument ) ).to.equal( '<paragraph>f[]r</paragraph>' );
-		} );
-
-		it( 'should add modifySelection listener', () => {
-			schema.registerItem( 'paragraph', '$block' );
-
-			setData( modelDocument, '<paragraph>foo[]bar</paragraph>' );
-
-			data.fire( 'modifySelection', {
-				selection: modelDocument.selection,
-				options: {
-					direction: 'backward'
-				}
-			} );
-
-			expect( getData( modelDocument ) )
-				.to.equal( '<paragraph>fo[o]bar</paragraph>' );
-			expect( modelDocument.selection.isBackward ).to.true;
-		} );
-
-		it( 'should add getSelectedContent listener', () => {
-			schema.registerItem( 'paragraph', '$block' );
-
-			setData( modelDocument, '<paragraph>fo[ob]ar</paragraph>' );
-
-			const evtData = {
-				selection: modelDocument.selection
-			};
-
-			data.fire( 'getSelectedContent', evtData );
-
-			expect( stringify( evtData.content ) ).to.equal( 'ob' );
-		} );
 	} );
 
 	describe( 'parse', () => {
@@ -425,58 +337,88 @@ describe( 'DataController', () => {
 	} );
 
 	describe( 'insertContent', () => {
-		it( 'should fire the insertContent event', () => {
+		it( 'should be decorated', () => {
+			schema.allow( { name: '$text', inside: '$root' } ); // To surpress warnings.
+
 			const spy = sinon.spy();
-			const content = new ModelDocumentFragment( [ new ModelText( 'x' ) ] );
-			const batch = modelDocument.batch();
-			schema.allow( { name: '$text', inside: '$root' } );
 
 			data.on( 'insertContent', spy );
 
-			data.insertContent( content, modelDocument.selection, batch );
+			data.insertContent( new ModelText( 'a' ), modelDocument.selection );
+
+			expect( spy.calledOnce ).to.be.true;
+		} );
+
+		it( 'should insert content (item)', () => {
+			schema.registerItem( 'paragraph', '$block' );
+
+			setData( modelDocument, '<paragraph>fo[]ar</paragraph>' );
+
+			data.insertContent( new ModelText( 'ob' ), modelDocument.selection );
+
+			expect( getData( modelDocument ) ).to.equal( '<paragraph>foob[]ar</paragraph>' );
+		} );
+
+		it( 'should insert content (document fragment)', () => {
+			schema.registerItem( 'paragraph', '$block' );
+
+			setData( modelDocument, '<paragraph>fo[]ar</paragraph>' );
+
+			data.insertContent( new ModelDocumentFragment( [ new ModelText( 'ob' ) ] ), modelDocument.selection );
 
-			expect( spy.args[ 0 ][ 1 ] ).to.deep.equal( {
-				batch,
-				selection: modelDocument.selection,
-				content
-			} );
+			expect( getData( modelDocument ) ).to.equal( '<paragraph>foob[]ar</paragraph>' );
 		} );
 	} );
 
 	describe( 'deleteContent', () => {
-		it( 'should fire the deleteContent event', () => {
+		it( 'should be decorated', () => {
 			const spy = sinon.spy();
-			const batch = modelDocument.batch();
 
 			data.on( 'deleteContent', spy );
 
-			data.deleteContent( modelDocument.selection, batch );
+			data.deleteContent( modelDocument.selection );
 
-			const evtData = spy.args[ 0 ][ 1 ];
+			expect( spy.calledOnce ).to.be.true;
+		} );
 
-			expect( evtData.batch ).to.equal( batch );
-			expect( evtData.selection ).to.equal( modelDocument.selection );
+		it( 'should delete selected content', () => {
+			schema.registerItem( 'paragraph', '$block' );
+
+			setData( modelDocument, '<paragraph>fo[ob]ar</paragraph>' );
+
+			data.deleteContent( modelDocument.selection, modelDocument.batch() );
+
+			expect( getData( modelDocument ) ).to.equal( '<paragraph>fo[]ar</paragraph>' );
 		} );
 	} );
 
 	describe( 'modifySelection', () => {
-		it( 'should fire the deleteContent event', () => {
+		it( 'should be decorated', () => {
+			schema.registerItem( 'paragraph', '$block' );
+			setData( modelDocument, '<paragraph>fo[ob]ar</paragraph>' );
+
 			const spy = sinon.spy();
-			const opts = { direction: 'backward' };
 
 			data.on( 'modifySelection', spy );
 
-			data.modifySelection( modelDocument.selection, opts );
+			data.modifySelection( modelDocument.selection );
 
-			const evtData = spy.args[ 0 ][ 1 ];
+			expect( spy.calledOnce ).to.be.true;
+		} );
 
-			expect( evtData.selection ).to.equal( modelDocument.selection );
-			expect( evtData.options ).to.equal( opts );
+		it( 'should modify a selection', () => {
+			schema.registerItem( 'paragraph', '$block' );
+
+			setData( modelDocument, '<paragraph>fo[ob]ar</paragraph>' );
+
+			data.modifySelection( modelDocument.selection, { direction: 'backward' } );
+
+			expect( getData( modelDocument ) ).to.equal( '<paragraph>fo[o]bar</paragraph>' );
 		} );
 	} );
 
 	describe( 'getSelectedContent', () => {
-		it( 'should fire the getSelectedContent event', () => {
+		it( 'should be decorated', () => {
 			const spy = sinon.spy();
 			const sel = new ModelSelection();
 
@@ -484,21 +426,17 @@ describe( 'DataController', () => {
 
 			data.getSelectedContent( sel );
 
-			const evtData = spy.args[ 0 ][ 1 ];
-
-			expect( evtData.selection ).to.equal( sel );
+			expect( spy.calledOnce ).to.be.true;
 		} );
 
-		it( 'should return the evtData.content of the getSelectedContent event', () => {
-			const frag = new ModelDocumentFragment();
+		it( 'should return selected content', () => {
+			schema.registerItem( 'paragraph', '$block' );
 
-			data.on( 'getSelectedContent', ( evt, data ) => {
-				data.content = frag;
+			setData( modelDocument, '<paragraph>fo[ob]ar</paragraph>' );
 
-				evt.stop();
-			}, { priority: 'high' } );
+			const content = data.getSelectedContent( modelDocument.selection );
 
-			expect( data.getSelectedContent() ).to.equal( frag );
+			expect( stringify( content ) ).to.equal( 'ob' );
 		} );
 	} );
 } );

+ 33 - 13
packages/ckeditor5-engine/tests/dev-utils/enableenginedebug.js

@@ -932,22 +932,42 @@ describe( 'debug tools', () => {
 			expect( JSON.stringify( recreated ) ).to.equal( JSON.stringify( original ) );
 		} );
 
-		it( 'provide additional logging when transformation crashes', () => {
-			const deltaA = new MoveDelta();
-			const opA = new MoveOperation( ModelPosition.createAt( root, 4 ), 4, ModelPosition.createAt( otherRoot, 4 ), 0 );
-			deltaA.addOperation( opA );
+		describe( 'provide additional logging when transformation crashes', () => {
+			it( 'with more important delta A', () => {
+				const deltaA = new MoveDelta();
+				const opA = new MoveOperation( ModelPosition.createAt( root, 4 ), 4, ModelPosition.createAt( otherRoot, 4 ), 0 );
+				deltaA.addOperation( opA );
+
+				const deltaB = new InsertDelta();
+				const opB = new InsertOperation( ModelPosition.createAt( root, 0 ), new ModelText( 'a' ), 0 );
+				deltaB.addOperation( opB );
+
+				deltaTransform.defaultTransform = () => {
+					throw new Error();
+				};
+
+				expect( () => deltaTransform.transform( deltaA, deltaB, true ) ).to.throw( Error );
+				expect( error.calledWith( deltaA.toString() + ' (important)' ) ).to.be.true;
+				expect( error.calledWith( deltaB.toString() ) ).to.be.true;
+			} );
 
-			const deltaB = new InsertDelta();
-			const opB = new InsertOperation( ModelPosition.createAt( root, 0 ), new ModelText( 'a' ), 0 );
-			deltaB.addOperation( opB );
+			it( 'with more important delta B', () => {
+				const deltaA = new MoveDelta();
+				const opA = new MoveOperation( ModelPosition.createAt( root, 4 ), 4, ModelPosition.createAt( otherRoot, 4 ), 0 );
+				deltaA.addOperation( opA );
 
-			deltaTransform.defaultTransform = () => {
-				throw new Error();
-			};
+				const deltaB = new InsertDelta();
+				const opB = new InsertOperation( ModelPosition.createAt( root, 0 ), new ModelText( 'a' ), 0 );
+				deltaB.addOperation( opB );
 
-			expect( () => deltaTransform.transform( deltaA, deltaB, true ) ).to.throw( Error );
-			expect( error.calledWith( deltaA.toString() + ' (important)' ) ).to.be.true;
-			expect( error.calledWith( deltaB.toString() ) ).to.be.true;
+				deltaTransform.defaultTransform = () => {
+					throw new Error();
+				};
+
+				expect( () => deltaTransform.transform( deltaA, deltaB, false ) ).to.throw( Error );
+				expect( error.calledWith( deltaA.toString() ) ).to.be.true;
+				expect( error.calledWith( deltaB.toString() + ' (important)' ) ).to.be.true;
+			} );
 		} );
 	} );
 

+ 15 - 0
packages/ckeditor5-engine/tests/view/attributeelement.js

@@ -137,5 +137,20 @@ describe( 'AttributeElement', () => {
 
 			expect( attribute.getFillerOffset() ).to.be.null;
 		} );
+
+		it( 'should return position 0 if it is the only nested element in the container and has UIElement inside', () => {
+			const { selection } = parse(
+				'<container:p><attribute:b><attribute:i>[]<ui:span></ui:span></attribute:i></attribute:b></container:p>' );
+			const attribute = selection.getFirstPosition().parent;
+
+			expect( attribute.getFillerOffset() ).to.equals( 0 );
+		} );
+
+		it( 'should return 0 if there is no parent container element and has UIElement inside', () => {
+			const { selection } = parse( '<attribute:b>[]<ui:span></ui:span></attribute:b>' );
+			const attribute = selection.getFirstPosition().parent;
+
+			expect( attribute.getFillerOffset() ).to.equal( 0 );
+		} );
 	} );
 } );

+ 4 - 0
packages/ckeditor5-engine/tests/view/containerelement.js

@@ -52,6 +52,10 @@ describe( 'ContainerElement', () => {
 			expect( parse( '<container:p></container:p>' ).getFillerOffset() ).to.equals( 0 );
 		} );
 
+		it( 'should return position 0 if element contains only ui elements', () => {
+			expect( parse( '<container:p><ui:span></ui:span></container:p>' ).getFillerOffset() ).to.equals( 0 );
+		} );
+
 		it( 'should return null if element is not empty', () => {
 			expect( parse( '<container:p>foo</container:p>' ).getFillerOffset() ).to.be.null;
 		} );

+ 31 - 15
packages/ckeditor5-engine/tests/view/document/document.js

@@ -26,19 +26,13 @@ describe( 'Document', () => {
 	const DEFAULT_OBSERVERS_COUNT = 5;
 	let ObserverMock, ObserverMockGlobalCount, instantiated, enabled, domRoot, viewDocument;
 
-	before( () => {
+	beforeEach( () => {
 		domRoot = createElement( document, 'div', {
 			id: 'editor',
 			contenteditable: 'true'
 		} );
 		document.body.appendChild( domRoot );
-	} );
 
-	after( () => {
-		domRoot.parentElement.removeChild( domRoot );
-	} );
-
-	beforeEach( () => {
 		instantiated = 0;
 		enabled = 0;
 
@@ -70,6 +64,7 @@ describe( 'Document', () => {
 
 	afterEach( () => {
 		viewDocument.destroy();
+		domRoot.remove();
 	} );
 
 	describe( 'constructor()', () => {
@@ -91,7 +86,7 @@ describe( 'Document', () => {
 		} );
 	} );
 
-	describe( 'createRoot', () => {
+	describe( 'createRoot()', () => {
 		it( 'should create root', () => {
 			const domP = document.createElement( 'p' );
 			const domDiv = document.createElement( 'div' );
@@ -175,7 +170,7 @@ describe( 'Document', () => {
 		} );
 	} );
 
-	describe( 'attachDomRoot', () => {
+	describe( 'attachDomRoot()', () => {
 		it( 'should create root without attach DOM element to the view element', () => {
 			const domDiv = document.createElement( 'div' );
 			const viewRoot = viewDocument.createRoot( 'div' );
@@ -217,7 +212,7 @@ describe( 'Document', () => {
 		} );
 	} );
 
-	describe( 'getRoot', () => {
+	describe( 'getRoot()', () => {
 		it( 'should return "main" root', () => {
 			viewDocument.createRoot( document.createElement( 'div' ) );
 
@@ -235,7 +230,7 @@ describe( 'Document', () => {
 		} );
 	} );
 
-	describe( 'addObserver', () => {
+	describe( 'addObserver()', () => {
 		beforeEach( () => {
 			// The variable will be overwritten.
 			viewDocument.destroy();
@@ -311,7 +306,7 @@ describe( 'Document', () => {
 		} );
 	} );
 
-	describe( 'getObserver', () => {
+	describe( 'getObserver()', () => {
 		it( 'should return observer it it is added', () => {
 			const addedObserverMock = viewDocument.addObserver( ObserverMock );
 			const getObserverMock = viewDocument.getObserver( ObserverMock );
@@ -327,7 +322,7 @@ describe( 'Document', () => {
 		} );
 	} );
 
-	describe( 'disableObservers', () => {
+	describe( 'disableObservers()', () => {
 		it( 'should disable observers', () => {
 			const addedObserverMock = viewDocument.addObserver( ObserverMock );
 
@@ -341,7 +336,7 @@ describe( 'Document', () => {
 		} );
 	} );
 
-	describe( 'enableObservers', () => {
+	describe( 'enableObservers()', () => {
 		it( 'should enable observers', () => {
 			const addedObserverMock = viewDocument.addObserver( ObserverMock );
 
@@ -369,7 +364,7 @@ describe( 'Document', () => {
 		} );
 	} );
 
-	describe( 'focus', () => {
+	describe( 'focus()', () => {
 		let domEditable, viewEditable;
 
 		beforeEach( () => {
@@ -421,4 +416,25 @@ describe( 'Document', () => {
 			expect( logSpy.args[ 0 ][ 0 ] ).to.match( /^view-focus-no-selection/ );
 		} );
 	} );
+
+	describe( 'render()', () => {
+		it( 'should fire an event', () => {
+			const spy = sinon.spy();
+
+			viewDocument.on( 'render', spy );
+
+			viewDocument.render();
+
+			expect( spy.calledOnce ).to.be.true;
+		} );
+
+		it( 'disable observers, renders and enable observers', () => {
+			const observerMock = viewDocument.addObserver( ObserverMock );
+			const renderStub = sinon.stub( viewDocument.renderer, 'render' );
+
+			viewDocument.render();
+
+			sinon.assert.callOrder( observerMock.disable, renderStub, observerMock.enable );
+		} );
+	} );
 } );

+ 50 - 0
packages/ckeditor5-engine/tests/view/domconverter/binding.js

@@ -365,4 +365,54 @@ describe( 'DomConverter', () => {
 			expect( bindSelection.isEqual( selectionCopy ) ).to.be.true;
 		} );
 	} );
+
+	describe( 'unbindDomElement', () => {
+		it( 'should unbind elements', () => {
+			const domElement = document.createElement( 'p' );
+			const viewElement = new ViewElement( 'p' );
+
+			converter.bindElements( domElement, viewElement );
+
+			expect( converter.getCorrespondingView( domElement ) ).to.equal( viewElement );
+			expect( converter.getCorrespondingDom( viewElement ) ).to.equal( domElement );
+
+			converter.unbindDomElement( domElement );
+
+			expect( converter.getCorrespondingView( domElement ) ).to.be.undefined;
+			expect( converter.getCorrespondingDom( viewElement ) ).to.be.undefined;
+		} );
+
+		it( 'should unbind element\'s child nodes', () => {
+			const domElement = document.createElement( 'p' );
+			const domChild = document.createElement( 'span' );
+			domElement.appendChild( domChild );
+
+			const viewElement = new ViewElement( 'p' );
+			const viewChild = new ViewElement( 'span' );
+
+			converter.bindElements( domElement, viewElement );
+			converter.bindElements( domChild, viewChild );
+
+			expect( converter.getCorrespondingView( domChild ) ).to.equal( viewChild );
+			expect( converter.getCorrespondingDom( viewChild ) ).to.equal( domChild );
+
+			converter.unbindDomElement( domElement );
+
+			expect( converter.getCorrespondingView( domChild ) ).to.be.undefined;
+			expect( converter.getCorrespondingDom( viewChild ) ).to.be.undefined;
+		} );
+
+		it( 'should do nothing if there are no elements bind', () => {
+			const domElement = document.createElement( 'p' );
+			const viewElement = new ViewElement( 'p' );
+
+			expect( converter.getCorrespondingView( domElement ) ).to.be.undefined;
+			expect( converter.getCorrespondingDom( viewElement ) ).to.be.undefined;
+
+			converter.unbindDomElement( domElement );
+
+			expect( converter.getCorrespondingView( domElement ) ).to.be.undefined;
+			expect( converter.getCorrespondingDom( viewElement ) ).to.be.undefined;
+		} );
+	} );
 } );

+ 145 - 0
packages/ckeditor5-engine/tests/view/domconverter/uielement.js

@@ -0,0 +1,145 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document, HTMLElement */
+
+import ViewUIElement from '../../../src/view/uielement';
+import ViewContainer from '../../../src/view/containerelement';
+import DomConverter from '../../../src/view/domconverter';
+
+describe( 'DOMConverter UIElement integration', () => {
+	let converter;
+
+	class MyUIElement extends ViewUIElement {
+		render( domDocument ) {
+			const root = super.render( domDocument );
+			root.innerHTML = '<p><span>foo</span> bar</p>';
+
+			return root;
+		}
+	}
+
+	beforeEach( () => {
+		converter = new DomConverter();
+	} );
+
+	describe( 'viewToDom', () => {
+		it( 'should create DOM element from UIElement', () => {
+			const uiElement = new ViewUIElement( 'div' );
+			const domElement = converter.viewToDom( uiElement, document );
+
+			expect( domElement ).to.be.instanceOf( HTMLElement );
+		} );
+
+		it( 'should create DOM structure from UIElement', () => {
+			const myElement = new MyUIElement( 'div' );
+			const domElement = converter.viewToDom( myElement, document );
+
+			expect( domElement ).to.be.instanceOf( HTMLElement );
+			expect( domElement.innerHTML ).to.equal( '<p><span>foo</span> bar</p>' );
+		} );
+
+		it( 'should bind only UIElement not child elements', () => {
+			const myElement = new MyUIElement( 'div' );
+			const domElement = converter.viewToDom( myElement, document, { bind: true } );
+			const domSpan = domElement.childNodes[ 0 ];
+
+			expect( converter.getCorrespondingView( domElement ) ).to.equal( myElement );
+			expect( converter.getCorrespondingView( domSpan ) ).to.be.falsy;
+			expect( converter.getCorrespondingView( domSpan.childNodes[ 0 ] ) ).to.be.falsy;
+		} );
+	} );
+
+	describe( 'domToView', () => {
+		it( 'should return UIElement itself', () => {
+			const uiElement = new MyUIElement( 'div' );
+			const domElement = converter.viewToDom( uiElement, document, { bind: true } );
+
+			expect( converter.domToView( domElement ) ).to.equal( uiElement );
+		} );
+
+		it( 'should return UIElement for nodes inside', () => {
+			const uiElement = new MyUIElement( 'div' );
+			const domElement = converter.viewToDom( uiElement, document, { bind: true } );
+
+			const domParagraph = domElement.childNodes[ 0 ];
+			const domSpan = domParagraph.childNodes[ 0 ];
+
+			expect( converter.domToView( domParagraph ) ).to.equal( uiElement );
+			expect( converter.domToView( domSpan ) ).to.be.equal( uiElement );
+			expect( converter.domToView( domParagraph.childNodes[ 0 ] ) ).equal( uiElement );
+			expect( converter.domToView( domSpan.childNodes[ 0 ] ) ).equal( uiElement );
+		} );
+	} );
+
+	describe( 'domPositionToView', () => {
+		it( 'should convert position inside UIElement to position before it', () => {
+			const uiElement = new MyUIElement( 'h1' );
+			const container = new ViewContainer( 'div', null, [ new ViewContainer( 'div' ), uiElement ] );
+			const domContainer = converter.viewToDom( container, document, { bind: true } );
+
+			const viewPosition = converter.domPositionToView( domContainer.childNodes[ 1 ], 0 );
+
+			expect( viewPosition.parent ).to.equal( container );
+			expect( viewPosition.offset ).to.equal( 1 );
+		} );
+
+		it( 'should convert position inside UIElement children to position before UIElement', () => {
+			const uiElement = new MyUIElement( 'h1' );
+			const container = new ViewContainer( 'div', null, [ new ViewContainer( 'div' ), uiElement ] );
+			const domContainer = converter.viewToDom( container, document, { bind: true } );
+
+			const viewPosition = converter.domPositionToView( domContainer.childNodes[ 1 ].childNodes[ 0 ], 1 );
+
+			expect( viewPosition.parent ).to.equal( container );
+			expect( viewPosition.offset ).to.equal( 1 );
+		} );
+	} );
+
+	describe( 'getCorrespondingViewElement', () => {
+		it( 'should return UIElement for DOM elements inside', () => {
+			const myElement = new MyUIElement( 'div' );
+			const domElement = converter.viewToDom( myElement, document, { bind: true } );
+
+			expect( converter.getCorrespondingViewElement( domElement ) ).to.equal( myElement );
+
+			const domParagraph = domElement.childNodes[ 0 ];
+			expect( converter.getCorrespondingViewElement( domParagraph ) ).to.equal( myElement );
+
+			const domSpan = domParagraph.childNodes[ 0 ];
+			expect( converter.getCorrespondingViewElement( domSpan ) ).to.equal( myElement );
+		} );
+	} );
+
+	describe( 'getCorrespondingViewText', () => {
+		it( 'should return UIElement for DOM text inside', () => {
+			const myElement = new MyUIElement( 'div' );
+			const domElement = converter.viewToDom( myElement, document, { bind: true } );
+
+			const domText = domElement.querySelector( 'span' ).childNodes[ 0 ];
+			expect( converter.getCorrespondingViewText( domText ) ).to.equal( myElement );
+		} );
+	} );
+
+	describe( 'getParentUIElement', () => {
+		it( 'should return UIElement for DOM children', () => {
+			const uiElement = new MyUIElement( 'div' );
+			const domElement = converter.viewToDom( uiElement, document, { bind: true } );
+
+			const domParagraph = domElement.childNodes[ 0 ];
+			const domSpan = domParagraph.childNodes[ 0 ];
+
+			expect( converter.getParentUIElement( domParagraph ) ).to.equal( uiElement );
+			expect( converter.getParentUIElement( domSpan ) ).to.equal( uiElement );
+		} );
+
+		it( 'should return null for element itself', () => {
+			const uiElement = new MyUIElement( 'div' );
+			const domElement = converter.viewToDom( uiElement, document, { bind: true } );
+
+			expect( converter.getParentUIElement( domElement ) ).to.be.null;
+		} );
+	} );
+} );

+ 26 - 0
packages/ckeditor5-engine/tests/view/manual/uielement.html

@@ -0,0 +1,26 @@
+<style>
+	div#container {
+		width: 600px;
+	}
+
+	figure {
+		margin-left: 0;
+		margin-right: 0;
+	}
+
+	.ui-element {
+		font-size: 8px;
+		border-top: 1px solid #dadada;
+		font-family: sans-serif;
+	}
+
+	.ui-element span {
+		background-color: #7a7a7a;
+		color: white;
+		padding: 1px 5px;
+	}
+</style>
+<div id="container">
+	<div id="editor"><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla sapien diam, pretium at laoreet eget, commodo bibendum urna. Praesent in dolor leo. Vivamus sagittis ligula tempor laoreet tincidunt. Praesent hendrerit in tortor ac sollicitudin. Curabitur eleifend blandit ultrices. Aliquam euismod ut lectus blandit faucibus. Quisque ut sapien euismod, interdum ex ac, tristique neque. In a volutpat risus. Vivamus blandit est et sodales imperdiet. Duis et metus nisi. Proin viverra pellentesque velit vel egestas. Etiam sed ornare orci. Pellentesque id quam finibus, semper ante aliquam, rhoncus tortor. Vestibulum eget porta velit. Aenean id nisl scelerisque, pulvinar ante nec, eleifend sapien.</p><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla sapien diam, pretium at laoreet eget, commodo bibendum urna. Praesent in dolor leo. Vivamus sagittis ligula tempor laoreet tincidunt. Praesent hendrerit in tortor ac sollicitudin. Curabitur eleifend blandit ultrices. Aliquam euismod ut lectus blandit faucibus. Quisque ut sapien euismod, interdum ex ac, tristique neque. In a volutpat risus. Vivamus blandit est et sodales imperdiet. Duis et metus nisi. Proin viverra pellentesque velit vel egestas. Etiam sed ornare orci. Pellentesque id quam finibus, semper ante aliquam, rhoncus tortor. Vestibulum eget porta velit. Aenean id nisl scelerisque, pulvinar ante nec, eleifend sapien.</p></div>
+</div>
+

+ 44 - 0
packages/ckeditor5-engine/tests/view/manual/uielement.js

@@ -0,0 +1,44 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic';
+import Enter from '@ckeditor/ckeditor5-enter/src/enter';
+import Typing from '@ckeditor/ckeditor5-typing/src/typing';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Undo from '@ckeditor/ckeditor5-undo/src/undo';
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import UIElement from '../../../src/view/uielement';
+
+class MyUIElement extends UIElement {
+	render( domDocument ) {
+		const root = super.render( domDocument );
+
+		root.setAttribute( 'contenteditable', 'false' );
+		root.classList.add( 'ui-element' );
+		root.innerHTML = '<span>END OF PARAGRAPH</span>';
+
+		return root;
+	}
+}
+
+class UIElementTestPlugin extends Plugin {
+	init() {
+		const editor = this.editor;
+		const editing = editor.editing;
+
+		// Add some UIElement to each paragraph.
+		editing.modelToView.on( 'insert:paragraph', ( evt, data, consumable, conversionApi ) => {
+			const viewP = conversionApi.mapper.toViewElement( data.item );
+			viewP.appendChildren( new MyUIElement( 'div' ) );
+		}, { priority: 'lowest' } );
+	}
+}
+
+ClassicEditor.create( document.querySelector( '#editor' ), {
+	plugins: [ Enter, Typing, Paragraph, Undo, UIElementTestPlugin ],
+	toolbar: [ 'undo', 'redo' ]
+} );

+ 5 - 0
packages/ckeditor5-engine/tests/view/manual/uielement.md

@@ -0,0 +1,5 @@
+### UIElement handling
+
+1. Each paragraph should have UIElement at it's bottom showing "END OF PARAGRAPH".
+1. UIElement should not block typing or prevent regular editor usage.
+1. When paragraph is split or new paragraph is created - new UIElement should be created too.

+ 47 - 0
packages/ckeditor5-engine/tests/view/observer/domeventobserver.js

@@ -8,6 +8,7 @@
 import DomEventObserver from '../../../src/view/observer/domeventobserver';
 import Observer from '../../../src/view/observer/observer';
 import ViewDocument from '../../../src/view/document';
+import UIElement from '../../../src/view/uielement';
 
 class ClickObserver extends DomEventObserver {
 	constructor( document ) {
@@ -153,6 +154,52 @@ describe( 'DomEventObserver', () => {
 		childDomElement.dispatchEvent( domEvent );
 	} );
 
+	describe( 'integration with UIElement', () => {
+		let domRoot, domEvent, evtSpy, uiElement;
+
+		class MyUIElement extends UIElement {
+			render( domDocument ) {
+				const root = super.render( domDocument );
+				root.innerHTML = '<span>foo bar</span>';
+
+				return root;
+			}
+		}
+
+		beforeEach( () => {
+			domRoot = document.createElement( 'div' );
+			const viewRoot = viewDocument.createRoot( domRoot, 'root' );
+			uiElement = new MyUIElement( 'p' );
+			viewRoot.appendChildren( uiElement );
+			viewDocument.render();
+
+			domEvent = new MouseEvent( 'click', { bubbles: true } );
+			evtSpy = sinon.spy();
+			viewDocument.addObserver( ClickObserver );
+			viewDocument.on( 'click', evtSpy );
+		} );
+
+		it( 'should fire events from UIElement itself', () => {
+			const domUiElement = domRoot.querySelector( 'p' );
+			domUiElement.dispatchEvent( domEvent );
+
+			const data = evtSpy.args[ 0 ][ 1 ];
+
+			sinon.assert.calledOnce( evtSpy );
+			expect( data.target ).to.equal( uiElement );
+		} );
+
+		it( 'events from inside of UIElement should target UIElement', () => {
+			const domUiElementChild = domRoot.querySelector( 'span' );
+			domUiElementChild.dispatchEvent( domEvent );
+
+			const data = evtSpy.args[ 0 ][ 1 ];
+
+			sinon.assert.calledOnce( evtSpy );
+			expect( data.target ).to.equal( uiElement );
+		} );
+	} );
+
 	describe( 'fire', () => {
 		it( 'should do nothing if observer is disabled', () => {
 			const testObserver = new ClickObserver( viewDocument );

+ 36 - 0
packages/ckeditor5-engine/tests/view/observer/mutationobserver.js

@@ -7,6 +7,7 @@
 
 import ViewDocument from '../../../src/view/document';
 import MutationObserver from '../../../src/view/observer/mutationobserver';
+import UIElement from '../../../src/view/uielement';
 import { parse } from '../../../src/dev-utils/view';
 
 describe( 'MutationObserver', () => {
@@ -346,6 +347,41 @@ describe( 'MutationObserver', () => {
 		expect( lastMutations[ 0 ].oldChildren.length ).to.equal( 1 );
 	} );
 
+	describe( 'UIElement integration', () => {
+		class MyUIElement extends UIElement {
+			render( domDocument ) {
+				const root = super.render( domDocument );
+				root.innerHTML = 'foo bar';
+
+				return root;
+			}
+		}
+
+		beforeEach( () => {
+			const uiElement = new MyUIElement( 'div' );
+			viewRoot.appendChildren( uiElement );
+
+			viewDocument.render();
+		} );
+
+		it( 'should not collect text mutations from UIElement', () => {
+			domEditor.childNodes[ 2 ].childNodes[ 0 ].data = 'foom';
+
+			mutationObserver.flush();
+
+			expect( lastMutations.length ).to.equal( 0 );
+		} );
+
+		it( 'should not collect child mutations from UIElement', () => {
+			const span = document.createElement( 'span' );
+			domEditor.childNodes[ 2 ].appendChild( span );
+
+			mutationObserver.flush();
+
+			expect( lastMutations.length ).to.equal( 0 );
+		} );
+	} );
+
 	function expectDomEditorNotToChange() {
 		expect( domEditor.childNodes.length ).to.equal( 2 );
 		expect( domEditor.childNodes[ 0 ].tagName ).to.equal( 'P' );

+ 24 - 0
packages/ckeditor5-engine/tests/view/uielement.js

@@ -3,6 +3,8 @@
  * For licensing, see LICENSE.md.
  */
 
+/* global document, HTMLElement */
+
 import UIElement from '../../src/view/uielement';
 import Element from '../../src/view/element';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
@@ -99,4 +101,26 @@ describe( 'UIElement', () => {
 			expect( uiElement.getFillerOffset() ).to.null;
 		} );
 	} );
+
+	describe( 'render()', () => {
+		let domElement;
+
+		beforeEach( () => {
+			domElement = uiElement.render( document );
+		} );
+
+		it( 'should return DOM element', () => {
+			expect( domElement ).to.be.instanceOf( HTMLElement );
+		} );
+
+		it( 'should use element name', () => {
+			expect( domElement.tagName.toLowerCase() ).to.equal( uiElement.name );
+		} );
+
+		it( 'should render attributes', () => {
+			for ( const key of uiElement.getAttributeKeys() ) {
+				expect( domElement.getAttribute( key ) ).to.equal( uiElement.getAttribute( key ) );
+			}
+		} );
+	} );
 } );