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

Merge pull request #967 from ckeditor/t/799

Feature: UIElement has it's own render method used by DomConverter and can create DOM children. Improved integration with observers and other view elements. Closes #799.
Piotr Jasiun 8 лет назад
Родитель
Сommit
ccafe25ade

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

+ 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 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.

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

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