ソースを参照

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 年 前
コミット
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.
@@ -833,6 +870,31 @@ export default class DomConverter {
 	}
 
 	/**
+	 * 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.
 	 *
 	 * Following changes are done:

+ 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


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