8
0
Quellcode durchsuchen

Merge branch 'master' into t/ckeditor5/456

Piotrek Koszuliński vor 8 Jahren
Ursprung
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.
@@ -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 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;
+		} );
+	} );
+} );

Datei-Diff unterdrückt, da er zu groß ist
+ 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 ) );
+			}
+		} );
+	} );
 } );