Explorar o código

Merge pull request #95 from cksource/t/92

t/92: Introduce UI Controller class.
Piotrek Koszuliński %!s(int64=10) %!d(string=hai) anos
pai
achega
b0e83300ca

+ 18 - 3
packages/ckeditor5-engine/src/collection.js

@@ -79,8 +79,10 @@ CKEDITOR.define( [ 'emittermixin', 'ckeditorerror', 'utils' ], ( EmitterMixin, C
 		 *
 		 * @chainable
 		 * @param {Object} item
+		 * @param {Number} [index] The position of the item in the collection. The item
+		 * is pushed to the collection when `index` not specified.
 		 */
-		add( item ) {
+		add( item, index ) {
 			let itemId;
 			const idProperty = this._idProperty;
 
@@ -109,10 +111,23 @@ CKEDITOR.define( [ 'emittermixin', 'ckeditorerror', 'utils' ], ( EmitterMixin, C
 				item[ idProperty ] = itemId;
 			}
 
-			this._items.push( item );
+			// TODO: Use ES6 default function argument.
+			if ( index === undefined ) {
+				index = this._items.length;
+			} else if ( index > this._items.length || index < 0 ) {
+				/**
+				 * The index number has invalid value.
+				 *
+				 * @error collection-add-item-bad-index
+				 */
+				throw new CKEditorError( 'collection-add-item-invalid-index' );
+			}
+
+			this._items.splice( index, 0, item );
+
 			this._itemMap.set( itemId, item );
 
-			this.fire( 'add', item );
+			this.fire( 'add', item, index );
 
 			return this;
 		}

+ 54 - 54
packages/ckeditor5-engine/src/model.js

@@ -194,6 +194,60 @@ CKEDITOR.define( [ 'emittermixin', 'ckeditorerror', 'utils' ], ( EmitterMixin, C
 			};
 		}
 
+		/**
+		 * Removes the binding created with {@link #bind}.
+		 *
+		 *		A.unbind( 'a' );
+		 *		A.unbind();
+		 *
+		 * @param {String...} [bindAttrs] Model attributes to unbound. All the bindings will
+		 * be released if not attributes provided.
+		 */
+		unbind( ...unbindAttrs ) {
+			if ( unbindAttrs.length ) {
+				if ( !isStringArray( unbindAttrs ) ) {
+					/**
+					 * Attributes must be strings.
+					 *
+					 * @error model-unbind-wrong-attrs
+					 */
+					throw new CKEditorError( 'model-unbind-wrong-attrs: Attributes must be strings.' );
+				}
+
+				unbindAttrs.forEach( attrName => {
+					for ( let to of this._boundTo ) {
+						// TODO, ES6 destructuring.
+						const boundModel = to[ 0 ];
+						const bindings = to[ 1 ];
+
+						for ( let boundAttrName in bindings ) {
+							if ( bindings[ boundAttrName ].has( attrName ) ) {
+								bindings[ boundAttrName ].delete( attrName );
+							}
+
+							if ( !bindings[ boundAttrName ].size ) {
+								delete bindings[ boundAttrName ];
+							}
+
+							if ( !Object.keys( bindings ).length ) {
+								this._boundTo.delete( boundModel );
+								this.stopListening( boundModel, 'change' );
+							}
+						}
+					}
+
+					delete this._bound[ attrName ];
+				} );
+			} else {
+				this._boundTo.forEach( ( bindings, boundModel ) => {
+					this.stopListening( boundModel, 'change' );
+					this._boundTo.delete( boundModel );
+				} );
+
+				this._bound = {};
+			}
+		}
+
 		/**
 		 * A chaining for {@link #bind} providing `.to()` interface.
 		 *
@@ -291,60 +345,6 @@ CKEDITOR.define( [ 'emittermixin', 'ckeditorerror', 'utils' ], ( EmitterMixin, C
 
 			updateModelAttrs( this, this._bindAttrs[ 0 ] );
 		}
-
-		/**
-		 * Removes the binding created with {@link #bind}.
-		 *
-		 *		A.unbind( 'a' );
-		 *		A.unbind();
-		 *
-		 * @param {String...} [bindAttrs] Model attributes to unbound. All the bindings will
-		 * be released if not attributes provided.
-		 */
-		unbind( ...unbindAttrs ) {
-			if ( unbindAttrs.length ) {
-				if ( !isStringArray( unbindAttrs ) ) {
-					/**
-					 * Attributes must be strings.
-					 *
-					 * @error model-unbind-wrong-attrs
-					 */
-					throw new CKEditorError( 'model-unbind-wrong-attrs: Attributes must be strings.' );
-				}
-
-				unbindAttrs.forEach( attrName => {
-					for ( let to of this._boundTo ) {
-						// TODO, ES6 destructuring.
-						const boundModel = to[ 0 ];
-						const bindings = to[ 1 ];
-
-						for ( let boundAttrName in bindings ) {
-							if ( bindings[ boundAttrName ].has( attrName ) ) {
-								bindings[ boundAttrName ].delete( attrName );
-							}
-
-							if ( !bindings[ boundAttrName ].size ) {
-								delete bindings[ boundAttrName ];
-							}
-
-							if ( !Object.keys( bindings ).length ) {
-								this._boundTo.delete( boundModel );
-								this.stopListening( boundModel, 'change' );
-							}
-						}
-					}
-
-					delete this._bound[ attrName ];
-				} );
-			} else {
-				this._boundTo.forEach( ( bindings, boundModel ) => {
-					this.stopListening( boundModel, 'change' );
-					this._boundTo.delete( boundModel );
-				} );
-
-				this._bound = {};
-			}
-		}
 	}
 
 	/**

+ 192 - 0
packages/ckeditor5-engine/src/ui/controller.js

@@ -0,0 +1,192 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+CKEDITOR.define( [
+	'collection',
+	'model',
+	'ckeditorerror',
+], function( Collection, Model, CKEditorError ) {
+	class Controller extends Model {
+		/**
+		 * Creates an instance of the {@link Controller} class.
+		 *
+		 * @param {Model} [model] Model of this Controller.
+		 * @param {View} [view] View instance of this Controller.
+		 * @constructor
+		 */
+		constructor( model, view ) {
+			super();
+
+			/**
+			 * Model of this controller.
+			 *
+			 * @property {Model}
+			 */
+			this.model = model || null;
+
+			/**
+			 * Set `true` after {@link #init}.
+			 *
+			 * @property {Boolean}
+			 */
+			this.ready = false;
+
+			/**
+			 * View of this controller.
+			 *
+			 * @property {View}
+			 */
+			this.view = view || null;
+
+			/**
+			 * A collection of {@link ControllerCollection} instances containing
+			 * child controllers.
+			 *
+			 * @property {Collection}
+			 */
+			this.collections = new Collection( {
+				idProperty: 'name'
+			} );
+
+			// Listen to {@link ControllerCollection#add} and {@link ControllerCollection#remove}
+			// of newly added Collection to synchronize this controller's view and children
+			// controllers' views in the future.
+			this.collections.on( 'add', ( evt, collection ) => {
+				// Set the {@link ControllerCollection#parent} to this controller.
+				// It allows the collection to determine the {@link #ready} state of this controller
+				// and accordingly initialize a child controller when added.
+				collection.parent = this;
+
+				this.listenTo( collection, 'add', ( evt, childController, index ) => {
+					// Child view is added to corresponding region in this controller's view
+					// when a new Controller joins the collection.
+					if ( this.ready && childController.view ) {
+						this.view.addChild( collection.name, childController.view, index );
+					}
+				} );
+
+				this.listenTo( collection, 'remove', ( evt, childController ) => {
+					// Child view is removed from corresponding region in this controller's view
+					// when a new Controller is removed from the the collection.
+					if ( this.ready && childController.view ) {
+						this.view.removeChild( collection.name, childController.view );
+					}
+				} );
+			} );
+
+			this.collections.on( 'remove', ( evt, collection ) => {
+				// Release the collection. Once removed from {@link #collections}, it can be
+				// moved to another controller.
+				collection.parent = null;
+
+				this.stopListening( collection );
+			} );
+		}
+
+		/**
+		 * Initializes the controller instance. The process includes:
+		 *
+		 * 1. Initialization of the child {@link #view}.
+		 * 2. Initialization of child controllers in {@link #collections}.
+		 * 3. Setting {@link #ready} flag `true`.
+		 *
+		 * @returns {Promise} A Promise resolved when the initialization process is finished.
+		 */
+		init() {
+			if ( this.ready ) {
+				/**
+				 * This Controller already been initialized.
+				 *
+				 * @error ui-controller-init-reinit
+				 */
+				throw new CKEditorError( 'ui-controller-init-reinit: This Controller already been initialized.' );
+			}
+
+			return Promise.resolve()
+				.then( this._initView.bind( this ) )
+				.then( this._initCollections.bind( this ) )
+				.then( () => {
+					this.ready = true;
+				} );
+		}
+
+		/**
+		 * Destroys the controller instance. The process includes:
+		 *
+		 * 1. Destruction of the child {@link #view}.
+		 * 2. Destruction of child controllers in {@link #collections}.
+		 *
+		 * @returns {Promise} A Promise resolved when the destruction process is finished.
+		 */
+		destroy() {
+			let promises = [];
+			let collection, childController;
+
+			for ( collection of this.collections ) {
+				for ( childController of collection ) {
+					promises.push( childController.destroy() );
+					collection.remove( childController );
+				}
+
+				this.collections.remove( collection );
+			}
+
+			if ( this.view ) {
+				promises.push( Promise.resolve().then( () => {
+					return this.view.destroy();
+				} ) );
+			}
+
+			promises.push( Promise.resolve().then( () => {
+				this.model = this.ready = this.view = this.collections = null;
+			} ) );
+
+			return Promise.all( promises );
+		}
+
+		/**
+		 * Initializes the {@link #view} of this controller instance.
+		 *
+		 * @protected
+		 * @returns {Promise} A Promise resolved when initialization process is finished.
+		 */
+		_initView() {
+			let promise = Promise.resolve();
+
+			if ( this.view ) {
+				promise = promise.then( this.view.init.bind( this.view ) );
+			}
+
+			return promise;
+		}
+
+		/**
+		 * Initializes the {@link #collections} of this controller instance.
+		 *
+		 * @protected
+		 * @returns {Promise} A Promise resolved when initialization process is finished.
+		 */
+		_initCollections() {
+			const promises = [];
+			let collection, childController;
+
+			for ( collection of this.collections ) {
+				for ( childController of collection ) {
+					if ( this.view && childController.view ) {
+						this.view.addChild( collection.name, childController.view );
+					}
+
+					promises.push( childController.init() );
+				}
+			}
+
+			return Promise.all( promises );
+		}
+	}
+
+	return Controller;
+} );

+ 76 - 0
packages/ckeditor5-engine/src/ui/controllercollection.js

@@ -0,0 +1,76 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+'use strict';
+
+/**
+ * Manages UI Controllers.
+ *
+ * @class ControllerCollection
+ * @extends Collection
+ */
+CKEDITOR.define( [
+	'collection',
+	'ckeditorerror'
+], ( Collection, CKEditorError ) => {
+	class ControllerCollection extends Collection {
+		/**
+		 * Creates an instance of the ControllerCollection class, initializing it with a name.
+		 *
+		 * @constructor
+		 */
+		constructor( name ) {
+			super();
+
+			if ( !name ) {
+				/**
+				 * ControllerCollection must be initialized with a name.
+				 *
+				 * @error ui-controllercollection-no-name
+				 */
+				throw new CKEditorError( 'ui-controllercollection-no-name: ControllerCollection must be initialized with a name.' );
+			}
+
+			/**
+			 * Name of this collection.
+			 *
+			 * @property {String}
+			 */
+			this.name = name;
+
+			/**
+			 * Parent controller of this collection.
+			 *
+			 * @property {Controller}
+			 */
+			this.parent = null;
+		}
+
+		/**
+		 * Adds a child controller to the collection. If {@link #parent} {@link Controller}
+		 * instance is ready, the child view is initialized when added.
+		 *
+		 * @param {Controller} controller A child controller.
+		 * @param {Number} [index] Index at which the child will be added to the collection.
+		 * @returns {Promise} A Promise resolved when the child {@link Controller#init} is done.
+		 */
+		add( controller, index ) {
+			super.add( controller, index );
+
+			// ChildController.init() returns Promise.
+			let promise = Promise.resolve();
+
+			if ( this.parent && this.parent.ready && !controller.ready ) {
+				promise = promise.then( () => {
+					return controller.init();
+				} );
+			}
+
+			return promise;
+		}
+	}
+
+	return ControllerCollection;
+} );

+ 42 - 15
packages/ckeditor5-engine/src/ui/region.js

@@ -12,7 +12,10 @@
  * @extends Model
  */
 
-CKEDITOR.define( [ 'collection', 'model' ], ( Collection, Model ) => {
+CKEDITOR.define( [
+	'collection',
+	'model'
+], ( Collection, Model ) => {
 	class Region extends Model {
 		/**
 		 * Creates an instance of the {@link Region} class.
@@ -21,41 +24,65 @@ CKEDITOR.define( [ 'collection', 'model' ], ( Collection, Model ) => {
 		 * @param {HTMLElement} [el] The element used for this region.
 		 * @constructor
 		 */
-		constructor( name, el ) {
+		constructor( name ) {
 			super();
 
 			/**
 			 * The name of the region.
+			 *
+			 * @property {String}
 			 */
 			this.name = name;
 
 			/**
-			 * The element of the region.
+			 * Views which belong to the region.
+			 *
+			 * @property {Collection}
 			 */
-			this.el = el;
+			this.views = new Collection();
 
 			/**
-			 * Views which belong to the region.
+			 * Element of this region (see {@link #init}).
+			 *
+			 * @property {HTMLElement}
 			 */
-			this.views = new Collection();
+			this.el = null;
+		}
 
-			this.views.on( 'add', ( evt, view ) => this.el && this.el.appendChild( view.el ) );
-			this.views.on( 'remove', ( evt, view ) => view.el.remove() );
+		/**
+		 * Initializes region instance with an element. Usually it comes from {@link View#init}.
+		 *
+		 * @param {HTMLElement} regiobEl Element of this region.
+		 */
+		init( regionEl ) {
+			this.el = regionEl;
+
+			if ( regionEl ) {
+				this.views.on( 'add', ( evt, childView, index ) => {
+					regionEl.insertBefore( childView.el, regionEl.childNodes[ index + 1 ] );
+				} );
+
+				this.views.on( 'remove', ( evt, childView ) => {
+					childView.el.remove();
+				} );
+			}
 		}
 
 		/**
-		 * Destroys the Region instance.
+		 * Destroys region instance.
 		 */
 		destroy() {
+			if ( this.el ) {
+				for ( let view of this.views ) {
+					view.el.remove();
+					this.views.remove( view );
+				}
+			}
+
 			// Drop the reference to HTMLElement but don't remove it from DOM.
 			// Element comes as a parameter and it could be a part of the View.
 			// Then it's up to the View what to do with it when the View is destroyed.
-			this.el = null;
-
-			// Remove and destroy views.
-			for ( let view of this.views ) {
-				this.views.remove( view ).destroy();
-			}
+			this.el = this.views = null;
 		}
 	}
 

+ 144 - 79
packages/ckeditor5-engine/src/ui/template.js

@@ -36,112 +36,172 @@ CKEDITOR.define( () => {
 		 * @returns {HTMLElement}
 		 */
 		render() {
-			return renderElement( this.def );
+			return this._renderElement( this.def, true );
 		}
-	}
 
-	function getTextUpdater() {
-		return ( el, value ) => el.innerHTML = value;
-	}
+		/**
+		 * Renders an element from definition.
+		 *
+		 * @protected
+		 * @param {TemplateDefinition} def Definition of an element.
+		 * @param {Boolean} intoFragment If set, children are rendered into DocumentFragment.
+		 * @returns {HTMLElement} A rendered element.
+		 */
+		_renderElement( def, intoFragment ) {
+			if ( !def ) {
+				return null;
+			}
 
-	function getAttributeUpdater( attr ) {
-		return ( el, value ) => el.setAttribute( attr, value );
-	}
+			const el = document.createElement( def.tag );
 
-	function renderElement( def ) {
-		if ( !def ) {
-			return null;
-		}
+			// Set the text first.
+			this._renderElementText( def, el );
 
-		const el = document.createElement( def.tag );
+			// Set attributes.
+			this._renderElementAttributes( def, el );
 
-		// Set the text first.
-		renderElementText( def, el );
+			// Invoke children recursively.
+			if ( intoFragment ) {
+				const docFragment = document.createDocumentFragment();
 
-		// Set attributes.
-		renderElementAttributes( def, el );
+				this._renderElementChildren( def, docFragment );
 
-		// Invoke children recursively.
-		renderElementChildren( def, el );
+				el.appendChild( docFragment );
+			} else {
+				this._renderElementChildren( def, el );
+			}
 
-		// Activate DOM binding for event listeners.
-		activateElementListeners( def, el );
+			// Activate DOM binding for event listeners.
+			this._activateElementListeners( def, el );
 
-		return el;
-	}
+			return el;
+		}
 
-	function renderElementText( def, el ) {
-		if ( def.text ) {
-			if ( typeof def.text == 'function' ) {
-				def.text( el, getTextUpdater() );
-			} else {
-				el.innerHTML = def.text;
+		/**
+		 * Renders element text content from definition.
+		 *
+		 * @protected
+		 * @param {TemplateDefinition} def Definition of an element.
+		 * @param {HTMLElement} el Element which is rendered.
+		 */
+		_renderElementText( def, el ) {
+			if ( def.text ) {
+				if ( typeof def.text == 'function' ) {
+					def.text( el, getTextUpdater() );
+				} else {
+					el.textContent = def.text;
+				}
 			}
 		}
-	}
 
-	function renderElementAttributes( def, el ) {
-		let value;
-		let attr;
+		/**
+		 * Renders element attributes from definition.
+		 *
+		 * @protected
+		 * @param {TemplateDefinition} def Definition of an element.
+		 * @param {HTMLElement} el Element which is rendered.
+		 */
+		_renderElementAttributes( def, el ) {
+			let attr, value;
 
-		for ( attr in def.attrs ) {
-			value = def.attrs[ attr ];
+			for ( attr in def.attrs ) {
+				value = def.attrs[ attr ];
 
-			// Attribute bound directly to the model.
-			if ( typeof value == 'function' ) {
-				value( el, getAttributeUpdater( attr ) );
-			}
-
-			// Explicit attribute definition (string).
-			else {
-				// Attribute can be an array, i.e. classes.
-				if ( Array.isArray( value ) ) {
-					value = value.join( ' ' );
+				// Attribute bound directly to the model.
+				if ( typeof value == 'function' ) {
+					value( el, getAttributeUpdater( attr ) );
 				}
 
-				el.setAttribute( attr, value );
-			}
-		}
-	}
+				// Explicit attribute definition (string).
+				else {
+					// Attribute can be an array, i.e. classes.
+					if ( Array.isArray( value ) ) {
+						value = value.join( ' ' );
+					}
 
-	function renderElementChildren( def, el ) {
-		let child;
-
-		if ( def.children ) {
-			for ( child of def.children ) {
-				el.appendChild( renderElement( child ) );
+					el.setAttribute( attr, value );
+				}
 			}
 		}
-	}
 
-	function activateElementListeners( def, el ) {
-		if ( def.on ) {
-			for ( let l in def.on ) {
-				let domEvtDef = l.split( '@' );
-				let name, selector;
+		/**
+		 * Recursively renders element children from definition by
+		 * calling {@link #_renderElement}.
+		 *
+		 * @protected
+		 * @param {TemplateDefinition} def Definition of an element.
+		 * @param {HTMLElement} el Element which is rendered.
+		 */
+		_renderElementChildren( def, el ) {
+			let child;
 
-				if ( domEvtDef.length == 2 ) {
-					name = domEvtDef[ 0 ];
-					selector = domEvtDef[ 1 ];
-				} else {
-					name = l;
-					selector = null;
+			if ( def.children ) {
+				for ( child of def.children ) {
+					el.appendChild( this._renderElement( child ) );
 				}
+			}
+		}
 
-				if ( Array.isArray( def.on[ l ] ) ) {
-					def.on[ l ].map( i => i( el, name, selector ) );
-				} else {
-					def.on[ l ]( el, name, selector );
+		/**
+		 * Activates element `on` listeners passed in element definition.
+		 *
+		 * @protected
+		 * @param {TemplateDefinition} def Definition of an element.
+		 * @param {HTMLElement} el Element which is rendered.
+		 */
+		_activateElementListeners( def, el ) {
+			if ( def.on ) {
+				let l, domEvtDef, name, selector;
+
+				for ( l in def.on ) {
+					domEvtDef = l.split( '@' );
+
+					if ( domEvtDef.length == 2 ) {
+						name = domEvtDef[ 0 ];
+						selector = domEvtDef[ 1 ];
+					} else {
+						name = l;
+						selector = null;
+					}
+
+					if ( Array.isArray( def.on[ l ] ) ) {
+						def.on[ l ].map( i => i( el, name, selector ) );
+					} else {
+						def.on[ l ]( el, name, selector );
+					}
 				}
 			}
 		}
 	}
 
+	/**
+	 * Returns a function which, when called in the context of HTMLElement,
+	 * it replaces element children with a text node of given value.
+	 *
+	 * @protected
+	 * @param {Function}
+	 */
+	function getTextUpdater() {
+		return ( el, value ) => el.textContent = value;
+	}
+
+	/**
+	 * Returns a function which, when called in the context of HTMLElement,
+	 * it updates element's attribute with given value.
+	 *
+	 * @protected
+	 * @param {String} attr A name of the attribute to be updated.
+	 * @param {Function}
+	 */
+	function getAttributeUpdater( attr ) {
+		return ( el, value ) => el.setAttribute( attr, value );
+	}
+
 	return Template;
 } );
 
 /**
- * The virtual class representing an argument of the {@link Template} constructor.
+ * Definition of {@link Template}.
  *
  *		{
  *			tag: 'p',
@@ -163,15 +223,20 @@ CKEDITOR.define( () => {
  *				...
  *			},
  *			on: {
- *				w: 'a'
- *				x: [ 'b', 'c', callback ],
- *				'y@selector': 'd',
- *				'z@selector': [ 'e', 'f', callback ],
+ *				event1: 'a'
+ *				event2: [ 'b', 'c', callback ],
+ *				'event3@selector': 'd',
+ *				'event4@selector': [ 'e', 'f', callback ],
  *				...
  *			},
  *			text: 'abc'
  *		}
  *
- * @abstract
- * @class TemplateDefinition
- */
+ * @typedef TemplateDefinition
+ * @type Object
+ * @property {String} tag
+ * @property {Array} [children]
+ * @property {Object} [attrs]
+ * @property {String} [text]
+ * @property {Object} [on]
+ */

+ 315 - 70
packages/ckeditor5-engine/src/ui/view.js

@@ -16,11 +16,12 @@
 CKEDITOR.define( [
 	'collection',
 	'model',
+	'ui/region',
 	'ui/template',
 	'ckeditorerror',
 	'ui/domemittermixin',
 	'utils'
-], ( Collection, Model, Template, CKEditorError, DOMEmitterMixin, utils ) => {
+], ( Collection, Model, Region, Template, CKEditorError, DOMEmitterMixin, utils ) => {
 	class View extends Model {
 		/**
 		 * Creates an instance of the {@link View} class.
@@ -33,135 +34,364 @@ CKEDITOR.define( [
 
 			/**
 			 * Model of this view.
+			 *
+			 * @property {Model}
+			 */
+			this.model = model || null;
+
+			/**
+			 * Regions of this view. See {@link #register}.
+			 *
+			 * @property {Collection}
 			 */
-			this.model = new Model( model );
+			this.regions = new Collection( {
+				idProperty: 'name'
+			} );
 
 			/**
-			 * Regions which belong to this view.
+			 * Template of this view.
+			 *
+			 * @property {Object}
 			 */
-			this.regions = new Collection( null, 'name' );
+			this.template = null;
 
 			/**
-			 * @property {HTMLElement} _el
+			 * Region selectors of this view. See {@link #register}.
+			 *
+			 * @private
+			 * @property {Object}
 			 */
+			this._regionsSelectors = {};
 
 			/**
-			 * @property {Template} _template
+			 * Element of this view.
+			 *
+			 * @private
+			 * @property {HTMLElement}
 			 */
+			this._el = null;
 
 			/**
-			 * @property {TemplateDefinition} template
+			 * An instance of Template to generate {@link #_el}.
+			 *
+			 * @private
+			 * @property {Template}
 			 */
+			this._template = null;
 		}
 
 		/**
-		 * Element of this view. The element is rendered on first reference.
+		 * Element of this view. The element is rendered on first reference
+		 * using {@link #template} definition and {@link #_template} object.
 		 *
 		 * @property el
 		 */
 		get el() {
-			return this._el || this.render();
+			if ( this._el ) {
+				return this._el;
+			}
+
+			if ( !this.template ) {
+				/**
+				 * Attempting to access an element of a view, which has no `template`
+				 * property.
+				 *
+				 * @error ui-view-notemplate
+				 */
+				throw new CKEditorError( 'ui-view-notemplate' );
+			}
+
+			// Prepare pre–defined listeners.
+			this._prepareElementListeners( this.template );
+
+			this._template = new Template( this.template );
+
+			return ( this._el = this._template.render() );
+		}
+
+		set el( el ) {
+			this._el = el;
+		}
+
+		/**
+		 * Initializes the view.
+		 */
+		init() {
+			this._initRegions();
+		}
+
+		/**
+		 * Adds a child view to one of the {@link #regions} (see {@link #register}) in DOM
+		 * at given, optional index position.
+		 *
+		 * @param {String} regionName One of {@link #regions} the child should be added to.
+		 * @param {View} childView A child view.
+		 * @param {Number} [index] Index at which the child will be added to the region.
+		 */
+		addChild( regionName, childView, index ) {
+			if ( !regionName ) {
+				/**
+				 * The name of the region is required.
+				 *
+				 * @error ui-view-addchild-badrname
+				 */
+				throw new CKEditorError( 'ui-view-addchild-badrname' );
+			}
+
+			const region = this.regions.get( regionName );
+
+			if ( !region ) {
+				/**
+				 * No such region of given name.
+				 *
+				 * @error ui-view-addchild-noreg
+				 */
+				throw new CKEditorError( 'ui-view-addchild-noreg' );
+			}
+
+			if ( !childView ) {
+				/**
+				 * No child view passed.
+				 *
+				 * @error ui-view-addchild-no-view
+				 */
+				throw new CKEditorError( 'ui-view-addchild-no-view' );
+			}
+
+			region.views.add( childView, index );
+		}
+
+		/**
+		 * Removes a child view from one of the {@link #regions} (see {@link #register}) in DOM.
+		 *
+		 * @param {String} regionName One of {@link #regions} the view should be removed from.
+		 * @param {View} childVIew A child view.
+		 * @returns {View} A child view instance after removal.
+		 */
+		removeChild( regionName, childView ) {
+			if ( !regionName ) {
+				/**
+				 * The name of the region is required.
+				 *
+				 * @error ui-view-removechild-badrname
+				 */
+				throw new CKEditorError( 'ui-view-removechild-badrname' );
+			}
+
+			const region = this.regions.get( regionName );
+
+			if ( !region ) {
+				/**
+				 * No such region of given name.
+				 *
+				 * @error ui-view-removechild-noreg
+				 */
+				throw new CKEditorError( 'ui-view-removechild-noreg' );
+			}
+
+			if ( !childView ) {
+				/**
+				 * The view must be an instance of View.
+				 *
+				 * @error ui-view-removechild-no-view
+				 */
+				throw new CKEditorError( 'ui-view-removechild-no-view' );
+			}
+
+			region.views.remove( childView );
+
+			return childView;
 		}
 
 		/**
-		 * Binds a `property` of View's model so the DOM of the View is updated when the `property`
+		 * Returns a child view from one of the {@link #regions}
+		 * (see {@link #register}) at given `index`.
+		 *
+		 * @param {String} regionName One of {@link #regions} the child should be retrieved from.
+		 * @param {Number} [index] An index of desired view.
+		 * @returns {View} A view instance.
+		 */
+		getChild( regionName, index ) {
+			const region = this.regions.get( regionName );
+
+			if ( !region ) {
+				/**
+				 * No such region of given name.
+				 *
+				 * @error ui-view-getchild-noreg
+				 */
+				throw new CKEditorError( 'ui-view-getchild-noreg' );
+			}
+
+			return region.views.get( index );
+		}
+
+		/**
+		 * Registers a region in {@link #regions}.
+		 *
+		 *		let view = new View();
+		 *
+		 *		// region.name == "foo", region.el == view.el.firstChild
+		 *		view.register( 'foo', el => el.firstChild );
+		 *
+		 *		// region.name == "bar", region.el == view.el.querySelector( 'span' )
+		 *		view.register( new Region( 'bar' ), 'span' );
+		 *
+		 *		// region.name == "bar", region.el == view.el.querySelector( '#div#id' )
+		 *		view.register( 'bar', 'div#id', true );
+		 *
+		 *		// region.name == "baz", region.el == null
+		 *		view.register( 'baz', true );
+		 *
+		 * @param {String|Region} stringOrRegion The name or an instance of the Region
+		 * to be registered. If `String`, the region will be created on the fly.
+		 * @param {String|Function|true} regionSelector The selector to retrieve region's element
+		 * in DOM when the region instance is initialized (see {@link Region#init}, {@link #init}).
+		 * @param {Boolean} [override] When set `true` it will allow overriding of registered regions.
+		 */
+		register( ...args ) {
+			let region, regionName;
+
+			if ( typeof args[ 0 ] === 'string' ) {
+				regionName = args[ 0 ];
+				region = this.regions.get( regionName ) || new Region( regionName );
+			} else if ( args[ 0 ] instanceof Region ) {
+				regionName = args[ 0 ].name;
+				region = args[ 0 ];
+			} else {
+				/**
+				 * A name of the region or an instance of Region is required.
+				 *
+				 * @error ui-view-register-wrongtype
+				 */
+				throw new CKEditorError( 'ui-view-register-wrongtype' );
+			}
+
+			const regionSelector = args[ 1 ];
+
+			if ( !regionSelector || !isValidRegionSelector( regionSelector ) ) {
+				/**
+				 * The selector must be String, Function or `true`.
+				 *
+				 * @error ui-view-register-badselector
+				 */
+				throw new CKEditorError( 'ui-view-register-badselector' );
+			}
+
+			const registered = this.regions.get( regionName );
+
+			if ( !registered ) {
+				this.regions.add( region );
+			} else {
+				if ( registered !== region ) {
+					if ( !args[ 2 ] ) {
+						/**
+						 * Overriding is possible only when `override` flag is set.
+						 *
+						 * @error ui-view-register-override
+						 */
+						throw new CKEditorError( 'ui-view-register-override' );
+					}
+
+					this.regions.remove( registered );
+					this.regions.add( region );
+				}
+			}
+
+			this._regionsSelectors[ regionName ] = regionSelector;
+		}
+
+		/**
+		 * Binds an `attribute` of View's model so the DOM of the View is updated when the `attribute`
 		 * changes. It returns a function which, once called in the context of a DOM element,
 		 * attaches a listener to the model which, in turn, brings changes to DOM.
 		 *
-		 * @param {String} property Property name in the model to be observed.
-		 * @param {Function} [callback] Callback function executed on property change in model.
+		 * @param {String} attribute Attribute name in the model to be observed.
+		 * @param {Function} [callback] Callback function executed on attribute change in model.
 		 * If not specified, a default DOM `domUpdater` supplied by the template is used.
 		 */
-		bind( property, callback ) {
+		bindToAttribute( attribute, callback ) {
 			/**
-			 * Attaches a listener to View's model, which updates DOM when the model's property
+			 * Attaches a listener to View's model, which updates DOM when the model's attribute
 			 * changes. DOM is either updated by the `domUpdater` function supplied by the template
 			 * (like attribute changer or `innerHTML` setter) or custom `callback` passed to {@link #bind}.
 			 *
 			 * This function is called by {@link Template#render}.
 			 *
-			 * @param {HTMLElement} el DOM element to be updated when `property` in model changes.
+			 * @param {HTMLElement} el DOM element to be updated when `attribute` in model changes.
 			 * @param {Function} domUpdater A function provided by the template which updates corresponding
 			 * DOM.
 			 */
 			return ( el, domUpdater ) => {
-				// TODO: Use ES6 default arguments syntax.
-				callback = callback || domUpdater;
+				let onModelChange;
 
-				// Execute callback when the property changes.
-				this.listenTo( this.model, 'change:' + property, onModelChange );
+				if ( callback ) {
+					onModelChange = ( evt, value ) => {
+						let processedValue = callback( el, value );
 
-				// Set the initial state of the view.
-				onModelChange( null, this.model[ property ] );
+						if ( typeof processedValue != 'undefined' ) {
+							domUpdater( el, processedValue );
+						}
+					};
+				} else {
+					onModelChange = ( evt, value ) => domUpdater( el, value );
+				}
 
-				function onModelChange( evt, value ) {
-					let processedValue = callback( el, value );
+				// Execute callback when the attribute changes.
+				this.listenTo( this.model, 'change:' + attribute, onModelChange );
 
-					if ( typeof processedValue != 'undefined' ) {
-						domUpdater( el, processedValue );
-					}
-				}
+				// Set the initial state of the view.
+				onModelChange( null, this.model[ attribute ] );
 			};
 		}
 
 		/**
-		 * Renders View's {@link el} using {@link Template} instance.
-		 *
-		 * @returns {HTMLElement} A root element of the View ({@link el}).
+		 * Destroys the view instance. The process includes:
+		 *  1. Removal of child views from {@link #regions}.
+		 *  2. Destruction of the {@link #regions}.
+		 *  3. Removal of {#link #_el} from DOM.
 		 */
-		render() {
-			if ( !this.template ) {
-				/**
-				 * This View implements no template to render.
-				 *
-				 * @error ui-view-notemplate
-				 * @param {View} view
-				 */
-				throw new CKEditorError(
-					'ui-view-notemplate: This View implements no template to render.',
-					{ view: this }
-				);
-			}
-
-			// Prepare pre–defined listeners.
-			this.prepareListeners();
+		destroy() {
+			let childView;
 
-			this._template = new Template( this.template );
+			this.stopListening();
 
-			return ( this._el = this._template.render() );
-		}
+			for ( let region of this.regions ) {
+				while ( ( childView = this.getChild( region.name, 0 ) ) ) {
+					this.removeChild( region.name, childView );
+				}
 
-		/**
-		 * Destroys the View.
-		 */
-		destroy() {
-			// Drop the reference to the model.
-			this.model = null;
+				this.regions.remove( region ).destroy();
+			}
 
-			// Remove View's element from DOM.
 			if ( this.template ) {
 				this.el.remove();
 			}
 
-			// Remove and destroy regions.
-			for ( let region of this.regions ) {
-				this.regions.remove( region ).destroy();
-			}
-
-			// Remove all listeners related to this view.
-			this.stopListening();
+			this.model = this.regions = this.template = this._regionsSelectors = this._el = this._template = null;
 		}
 
 		/**
-		 * Iterates over all "on" properties in {@link template} and replaces
-		 * listener definitions with functions which, once executed in a context of
-		 * a DOM element, will attach native DOM listeners to elements.
+		 * Initializes {@link #regions} of this view by passing a DOM element
+		 * generated from {@link #_regionsSelectors} into {@link Region#init}.
 		 *
-		 * The execution is performed by {@link Template} class.
+		 * @protected
 		 */
-		prepareListeners() {
-			if ( this.template ) {
-				this._prepareElementListeners( this.template );
+		_initRegions() {
+			let region, regionEl, regionSelector;
+
+			for ( region of this.regions ) {
+				regionSelector = this._regionsSelectors[ region.name ];
+
+				if ( typeof regionSelector == 'string' ) {
+					regionEl = this.el.querySelector( regionSelector );
+				} else if ( typeof regionSelector == 'function' ) {
+					regionEl = regionSelector( this.el );
+				} else {
+					regionEl = null;
+				}
+
+				region.init( regionEl );
 			}
 		}
 
@@ -171,6 +401,7 @@ CKEDITOR.define( [
 		 * to the element. The listener executes given callback or fires View's event
 		 * of given name.
 		 *
+		 * @protected
 		 * @param {String|Function} evtNameOrCallback Event name to be fired on View or callback to execute.
 		 * @returns {Function} A function to be executed in the context of an element.
 		 */
@@ -220,6 +451,7 @@ CKEDITOR.define( [
 		 * replace each listener declaration with a function which, once executed in a context
 		 * of an element, attaches native DOM listener to the element.
 		 *
+		 * @protected
 		 * @param {TemplateDefinition} def Template definition.
 		 */
 		_prepareElementListeners( def ) {
@@ -263,5 +495,18 @@ CKEDITOR.define( [
 
 	utils.extend( View.prototype, DOMEmitterMixin );
 
+	const validSelectorTypes = new Set( [ 'string', 'boolean', 'function' ] );
+
+	/**
+	 * Check whether region selector is valid.
+	 *
+	 * @protected
+	 * @param {*} selector Selector to be checked.
+	 * @returns {Boolean}
+	 */
+	function isValidRegionSelector( selector ) {
+		return validSelectorTypes.has( typeof selector ) && selector !== false;
+	}
+
 	return View;
 } );

+ 59 - 3
packages/ckeditor5-engine/tests/collection/collection.js

@@ -10,8 +10,10 @@ const modules = bender.amd.require( 'collection', 'ckeditorerror' );
 bender.tools.createSinonSandbox();
 
 function getItem( id, idProperty ) {
+	idProperty = idProperty || 'id';
+
 	return {
-		[ idProperty || 'id' ]: id
+		[ idProperty ]: id
 	};
 }
 
@@ -165,7 +167,61 @@ describe( 'Collection', () => {
 
 			collection.add( item );
 
-			sinon.assert.calledWithExactly( spy, sinon.match.has( 'source', collection ), item );
+			sinon.assert.calledWithExactly( spy, sinon.match.has( 'source', collection ), item, 0 );
+		} );
+
+		it( 'should support an optional index argument', () => {
+			let collection = new Collection();
+			let item1 = getItem( 'foo' );
+			let item2 = getItem( 'bar' );
+			let item3 = getItem( 'baz' );
+			let item4 = getItem( 'abc' );
+
+			collection.add( item1 );
+			collection.add( item2, 0 );
+			collection.add( item3, 1 );
+			collection.add( item4, 3 );
+
+			expect( collection.get( 0 ) ).to.equal( item2 );
+			expect( collection.get( 1 ) ).to.equal( item3 );
+			expect( collection.get( 2 ) ).to.equal( item1 );
+			expect( collection.get( 3 ) ).to.equal( item4 );
+		} );
+
+		it( 'should throw when index argument is invalid', () => {
+			let collection = new Collection();
+			let item1 = getItem( 'foo' );
+			let item2 = getItem( 'bar' );
+			let item3 = getItem( 'baz' );
+
+			collection.add( item1 );
+
+			expect( () => {
+				collection.add( item2, -1 );
+			} ).to.throw( /^collection-add-item-invalid-index/ );
+
+			expect( () => {
+				collection.add( item2, 2 );
+			} ).to.throw( /^collection-add-item-invalid-index/ );
+
+			collection.add( item2, 1 );
+			collection.add( item3, 0 );
+
+			expect( collection.length ).to.be.equal( 3 );
+		} );
+
+		it( 'should fire the "add" event with the index argument', () => {
+			let spy = sinon.spy();
+
+			collection.add( {} );
+			collection.add( {} );
+
+			collection.on( 'add', spy );
+
+			const item = {};
+			collection.add( item, 1 );
+
+			sinon.assert.calledWithExactly( spy, sinon.match.has( 'source', collection ), item, 1 );
 		} );
 	} );
 
@@ -387,4 +443,4 @@ describe( 'Collection', () => {
 			expect( items ).to.deep.equal( [ 'foo', 'bar', 'bom' ] );
 		} );
 	} );
-} );
+} );

+ 300 - 0
packages/ckeditor5-engine/tests/ui/controller.js

@@ -0,0 +1,300 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: core, ui */
+
+'use strict';
+
+const modules = bender.amd.require( 'ckeditor',
+	'ui/view',
+	'ui/controller',
+	'ui/controllercollection',
+	'ui/region',
+	'ckeditorerror',
+	'model',
+	'collection',
+	'eventinfo'
+);
+
+let View, Controller, Model, CKEditorError, Collection, ControllerCollection;
+let ParentController, ParentView;
+
+bender.tools.createSinonSandbox();
+
+describe( 'Controller', () => {
+	beforeEach( updateModuleReference );
+
+	describe( 'constructor', () => {
+		it( 'defines basic properties', () => {
+			const controller = new Controller();
+
+			expect( controller.model ).to.be.null;
+			expect( controller.ready ).to.be.false;
+			expect( controller.view ).to.be.null;
+			expect( controller.collections.length ).to.be.equal( 0 );
+		} );
+
+		it( 'should accept model and view', () => {
+			const model = new Model();
+			const view = new View();
+			const controller = new Controller( model, view );
+
+			expect( controller.model ).to.be.equal( model );
+			expect( controller.view ).to.be.equal( view );
+		} );
+	} );
+
+	describe( 'init', () => {
+		it( 'should throw when already initialized', () => {
+			const controller = new Controller();
+
+			return controller.init()
+				.then( () => {
+					controller.init();
+
+					throw new Error( 'This should not be executed.' );
+				} )
+				.catch( ( err ) => {
+					expect( err ).to.be.instanceof( CKEditorError );
+					expect( err.message ).to.match( /ui-controller-init-re/ );
+				} );
+		} );
+
+		it( 'should set #ready flag', () => {
+			const controller = new Controller();
+
+			return controller.init().then( () => {
+				expect( controller.ready ).to.be.true;
+			} );
+		} );
+
+		it( 'should initialize own view', () => {
+			const view = new View();
+			const controller = new Controller( null, view );
+			const spy = bender.sinon.spy( view, 'init' );
+
+			return controller.init().then( () => {
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+
+		it( 'should initialize child controllers in own collections', () => {
+			const parentController = new Controller();
+			const buttonCollection = new ControllerCollection( 'buttons' );
+			parentController.collections.add( buttonCollection );
+
+			const childController1 = new Controller();
+			const childController2 = new Controller();
+			const spy1 = bender.sinon.spy( childController1, 'init' );
+			const spy2 = bender.sinon.spy( childController2, 'init' );
+
+			buttonCollection.add( childController1 );
+			buttonCollection.add( childController2 );
+
+			return parentController.init().then( () => {
+				expect( buttonCollection.get( 0 ) ).to.be.equal( childController1 );
+				expect( buttonCollection.get( 1 ) ).to.be.equal( childController2 );
+
+				sinon.assert.calledOnce( spy1 );
+				sinon.assert.calledOnce( spy2 );
+			} );
+		} );
+	} );
+
+	describe( 'collections', () => {
+		describe( 'add', () => {
+			beforeEach( defineParentViewClass );
+			beforeEach( defineParentControllerClass );
+
+			it( 'should add a child controller which has no view', () => {
+				const parentController = new ParentController( null, new ParentView() );
+				const collection = parentController.collections.get( 'x' );
+				const childController = new Controller();
+
+				return parentController.init()
+					.then( () => {
+						return collection.add( childController );
+					} )
+					.then( () => {
+						expect( collection.get( 0 ) ).to.be.equal( childController );
+					} );
+			} );
+
+			it( 'should append child controller\'s view to parent controller\'s view', () => {
+				const parentView = new ParentView();
+				const parentController = new ParentController( null, parentView );
+				const collection = parentController.collections.get( 'x' );
+				const childController = new Controller( null, new View() );
+				const spy1 = bender.sinon.spy( parentView, 'addChild' );
+
+				collection.add( childController );
+
+				sinon.assert.notCalled( spy1 );
+
+				collection.remove( childController );
+
+				return parentController.init()
+					.then( () => {
+						return collection.add( childController );
+					} )
+					.then( () => {
+						sinon.assert.calledOnce( spy1 );
+						sinon.assert.calledWithExactly( spy1, 'x', childController.view, 0 );
+					} );
+			} );
+
+			it( 'should append child controller\'s view to parent controller\'s view at given index', () => {
+				const parentController = new ParentController( null, new ParentView() );
+				const collection = parentController.collections.get( 'x' );
+
+				const childView1 = new View();
+				const childController1 = new Controller( null, childView1 );
+				const childView2 = new View();
+				const childController2 = new Controller( null, childView2 );
+
+				return parentController.init()
+					.then( () => {
+						return collection.add( childController1 ).then( () => {
+							return collection.add( childController2, 0 );
+						} );
+					} )
+					.then( () => {
+						expect( parentController.view.getChild( 'x', 0 ) ).to.be.equal( childView2 );
+						expect( parentController.view.getChild( 'x', 1 ) ).to.be.equal( childView1 );
+					} );
+			} );
+		} );
+
+		describe( 'remove', () => {
+			beforeEach( defineParentViewClass );
+
+			it( 'should remove child controller\'s view from parent controller\'s view', () => {
+				const parentView = new ParentView();
+				const parentController = new ParentController( null, parentView );
+				const collection = parentController.collections.get( 'x' );
+				const childController = new Controller( null, new View() );
+				const spy = bender.sinon.spy( parentView, 'removeChild' );
+
+				collection.add( childController );
+
+				sinon.assert.notCalled( spy );
+
+				return parentController.init()
+					.then( () => {
+						collection.remove( childController );
+						sinon.assert.calledOnce( spy );
+						sinon.assert.calledWithExactly( spy, 'x', childController.view );
+					} );
+			} );
+		} );
+	} );
+
+	describe( 'destroy', () => {
+		beforeEach( defineParentViewClass );
+		beforeEach( defineParentControllerClass );
+
+		it( 'should destroy the controller', () => {
+			const view = new View();
+			const controller = new Controller( null, view );
+			const spy = bender.sinon.spy( view, 'destroy' );
+
+			return controller.init()
+				.then( () => {
+					return controller.destroy();
+				} )
+				.then( () => {
+					sinon.assert.calledOnce( spy );
+
+					expect( controller.model ).to.be.null;
+					expect( controller.ready ).to.be.null;
+					expect( controller.view ).to.be.null;
+					expect( controller.collections ).to.be.null;
+				} );
+		} );
+
+		it( 'should destroy the controller which has no view', () => {
+			const controller = new Controller( null, null );
+
+			return controller.init()
+				.then( () => {
+					return controller.destroy();
+				} )
+				.then( () => {
+					expect( controller.model ).to.be.null;
+					expect( controller.view ).to.be.null;
+					expect( controller.collections ).to.be.null;
+				} );
+		} );
+
+		it( 'should destroy child controllers in collections with their views', () => {
+			const parentController = new ParentController( null, new ParentView() );
+			const collection = parentController.collections.get( 'x' );
+			const childView = new View();
+			const childController = new Controller( null, childView );
+			const spy = bender.sinon.spy( childView, 'destroy' );
+
+			collection.add( childController );
+
+			return parentController.init()
+				.then( () => {
+					return parentController.destroy();
+				} )
+				.then( () => {
+					sinon.assert.calledOnce( spy );
+					expect( childController.model ).to.be.null;
+					expect( childController.view ).to.be.null;
+					expect( childController.collections ).to.be.null;
+				} );
+		} );
+
+		it( 'should destroy child controllers in collections when they have no views', () => {
+			const parentController = new ParentController( null, new ParentView() );
+			const collection = parentController.collections.get( 'x' );
+			const childController = new Controller( null, null );
+
+			collection.add( childController );
+
+			return parentController.init()
+				.then( () => {
+					return parentController.destroy();
+				} )
+				.then( () => {
+					expect( childController.model ).to.be.null;
+					expect( childController.view ).to.be.null;
+					expect( childController.collections ).to.be.null;
+				} );
+		} );
+	} );
+} );
+
+function updateModuleReference() {
+	View = modules[ 'ui/view' ];
+	Controller = modules[ 'ui/controller' ];
+	Model = modules.model;
+	Collection = modules.collection;
+	ControllerCollection = modules[ 'ui/controllercollection' ];
+	CKEditorError = modules.ckeditorerror;
+}
+
+function defineParentViewClass() {
+	ParentView = class extends View {
+		constructor() {
+			super();
+
+			this.el = document.createElement( 'span' );
+			this.register( 'x', true );
+		}
+	};
+}
+
+function defineParentControllerClass() {
+	ParentController = class extends Controller {
+		constructor( ...args ) {
+			super( ...args );
+
+			this.collections.add( new ControllerCollection( 'x' ) );
+		}
+	};
+}

+ 120 - 0
packages/ckeditor5-engine/tests/ui/controllercollection.js

@@ -0,0 +1,120 @@
+/**
+ * @license Copyright (c) 2003-2015, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* bender-tags: core, ui */
+
+'use strict';
+
+const modules = bender.amd.require( 'ckeditor',
+	'ui/controllercollection',
+	'ui/controller',
+	'ui/view'
+);
+
+bender.tools.createSinonSandbox();
+
+let ControllerCollection, Controller, View;
+let ParentView;
+
+describe( 'ControllerCollection', () => {
+	beforeEach( updateModuleReference );
+	beforeEach( defineParentViewClass );
+
+	describe( 'constructor', () => {
+		it( 'should throw when no name is passed', () => {
+			expect( () => {
+				new ControllerCollection();
+			} ).to.throw( /^ui-controllercollection-no-name/ );
+		} );
+	} );
+
+	describe( 'add', () => {
+		it( 'should add a child controller and return promise', () => {
+			const parentController = new Controller();
+			const childController = new Controller();
+			const collection = new ControllerCollection( 'x' );
+
+			parentController.collections.add( collection );
+
+			const returned = collection.add( childController );
+
+			expect( returned ).to.be.an.instanceof( Promise );
+			expect( collection.get( 0 ) ).to.be.equal( childController );
+		} );
+
+		it( 'should add a child controller at given position', () => {
+			const parentController = new Controller();
+			const childController1 = new Controller();
+			const childController2 = new Controller();
+			const collection = new ControllerCollection( 'x' );
+
+			parentController.collections.add( collection );
+
+			collection.add( childController1 );
+			collection.add( childController2, 0 );
+
+			expect( collection.get( 0 ) ).to.be.equal( childController2 );
+			expect( collection.get( 1 ) ).to.be.equal( childController1 );
+		} );
+
+		it( 'should initialize child controller if parent is ready', () => {
+			const parentController = new Controller( null, new ParentView() );
+			const childController = new Controller( null, new View() );
+			const spy = bender.sinon.spy( childController, 'init' );
+			const collection = new ControllerCollection( 'x' );
+
+			parentController.collections.add( collection );
+			collection.add( childController );
+			collection.remove( childController );
+
+			sinon.assert.notCalled( spy );
+
+			return parentController.init()
+				.then( () => {
+					return collection.add( childController );
+				} )
+				.then( () => {
+					sinon.assert.calledOnce( spy );
+				} );
+		} );
+
+		it( 'should not initialize child controller twice', () => {
+			const parentController = new Controller( null, new ParentView() );
+			const childController = new Controller( null, new View() );
+			const spy = bender.sinon.spy( childController, 'init' );
+			const collection = new ControllerCollection( 'x' );
+
+			parentController.collections.add( collection );
+
+			return parentController.init()
+				.then( () => {
+					return childController.init();
+				} )
+				.then( () => {
+					return collection.add( childController );
+				} )
+				.then( () => {
+					sinon.assert.calledOnce( spy );
+				} );
+		} );
+	} );
+} );
+
+function updateModuleReference() {
+	View = modules[ 'ui/view' ];
+	Controller = modules[ 'ui/controller' ];
+	ControllerCollection = modules[ 'ui/controllercollection' ];
+}
+
+function defineParentViewClass() {
+	ParentView = class extends View {
+		constructor() {
+			super();
+
+			this.el = document.createElement( 'span' );
+			this.register( 'x', true );
+		}
+	};
+}

+ 72 - 54
packages/ckeditor5-engine/tests/ui/region.js

@@ -12,86 +12,103 @@ const modules = bender.amd.require( 'ckeditor', 'ui/region', 'ui/view', 'collect
 
 bender.tools.createSinonSandbox();
 
+let Region, View;
 let TestViewA, TestViewB;
 let region, el;
 
-beforeEach( createRegionInstance );
+describe( 'View', () => {
+	beforeEach( createRegionInstance );
 
-describe( 'constructor', () => {
-	it( 'accepts name and element', () => {
-		expect( region ).to.have.property( 'name', 'foo' );
-		expect( region ).to.have.property( 'el', el );
+	describe( 'constructor', () => {
+		it( 'accepts name', () => {
+			expect( region.name ).to.be.equal( 'foo' );
+			expect( region.el ).to.be.null;
+			expect( region.views.length ).to.be.equal( 0 );
+		} );
 	} );
-} );
 
-describe( 'views collection', () => {
-	it( 'is an instance of Collection', () => {
-		const Collection = modules.collection;
-		expect( region.views ).to.be.an.instanceof( Collection );
+	describe( 'init', () => {
+		it( 'accepts region element', () => {
+			region.init( el );
+
+			expect( region.el ).to.be.equal( el );
+		} );
 	} );
 
-	it( 'updates DOM when adding views', () => {
-		expect( region.el.childNodes.length ).to.be.equal( 0 );
+	describe( 'views collection', () => {
+		it( 'updates DOM when adding views', () => {
+			region.init( el );
 
-		region.views.add( new TestViewA() );
-		expect( region.el.childNodes.length ).to.be.equal( 1 );
+			expect( region.el.childNodes.length ).to.be.equal( 0 );
 
-		region.views.add( new TestViewA() );
-		expect( region.el.childNodes.length ).to.be.equal( 2 );
-	} );
+			region.views.add( new TestViewA() );
+			expect( region.el.childNodes.length ).to.be.equal( 1 );
 
-	it( 'updates DOM when removing views', () => {
-		let viewA = new TestViewA();
-		let viewB = new TestViewB();
+			region.views.add( new TestViewA() );
+			expect( region.el.childNodes.length ).to.be.equal( 2 );
+		} );
 
-		region.views.add( viewA );
-		region.views.add( viewB );
+		it( 'does not update DOM when no region element', () => {
+			region.init();
 
-		expect( el.childNodes.length ).to.be.equal( 2 );
-		expect( el.firstChild.nodeName ).to.be.equal( 'A' );
-		expect( el.lastChild.nodeName ).to.be.equal( 'B' );
+			expect( () => {
+				region.views.add( new TestViewA() );
+				region.views.add( new TestViewA() );
+			} ).to.not.throw();
+		} );
 
-		region.views.remove( viewA );
-		expect( el.childNodes.length ).to.be.equal( 1 );
-		expect( el.firstChild.nodeName ).to.be.equal( 'B' );
+		it( 'updates DOM when removing views', () => {
+			region.init( el );
 
-		region.views.remove( viewB );
-		expect( el.childNodes.length ).to.be.equal( 0 );
-	} );
-} );
+			let viewA = new TestViewA();
+			let viewB = new TestViewB();
 
-describe( 'destroy', () => {
-	it( 'destroys the region', () => {
-		// Append the region's element to some container.
-		let container = document.createElement( 'div' );
-		container.appendChild( el );
-		expect( el.parentNode ).to.be.equal( container );
+			region.views.add( viewA );
+			region.views.add( viewB );
 
-		region.destroy();
+			expect( el.childNodes.length ).to.be.equal( 2 );
+			expect( el.firstChild.nodeName ).to.be.equal( 'A' );
+			expect( el.lastChild.nodeName ).to.be.equal( 'B' );
 
-		// Make sure destruction of the region does affect passed element.
-		expect( el.parentNode ).to.be.equal( container );
-		expect( region.el ).to.be.null;
+			region.views.remove( viewA );
+			expect( el.childNodes.length ).to.be.equal( 1 );
+			expect( el.firstChild.nodeName ).to.be.equal( 'B' );
+
+			region.views.remove( viewB );
+			expect( el.childNodes.length ).to.be.equal( 0 );
+		} );
 	} );
 
-	it( 'destroys children views', () => {
-		let view = new TestViewA();
-		let spy = bender.sinon.spy( view, 'destroy' );
+	describe( 'destroy', () => {
+		it( 'destroys the region', () => {
+			region.init( el );
+			region.views.add( new TestViewA() );
+
+			const elRef = region.el;
+			const viewsRef = region.views;
 
-		// Append the view to the region.
-		region.views.add( view );
-		expect( region.views ).to.have.length( 1 );
+			region.destroy();
 
-		region.destroy();
+			expect( elRef.parentNode ).to.be.null;
+			expect( region.name ).to.be.equal( 'foo' );
+			expect( region.views ).to.be.null;
+			expect( viewsRef.length ).to.be.equal( 0 );
+			expect( region.el ).to.be.null;
+		} );
 
-		expect( region.views ).to.have.length( 0 );
-		expect( spy.calledOnce ).to.be.true;
+		it( 'destroys an element–less region', () => {
+			region.init();
+
+			expect( () => {
+				region.destroy();
+			} ).to.not.throw();
+		} );
 	} );
 } );
 
 function createRegionInstance() {
-	const Region = modules[ 'ui/region' ];
-	const View = modules[ 'ui/view' ];
+	Region = modules[ 'ui/region' ];
+	View = modules[ 'ui/view' ];
 
 	class A extends View {
 		constructor() {
@@ -111,5 +128,6 @@ function createRegionInstance() {
 	TestViewB = B;
 
 	el = document.createElement( 'div' );
-	region = new Region( 'foo', el );
+
+	region = new Region( 'foo' );
 }

+ 157 - 155
packages/ckeditor5-engine/tests/ui/template.js

@@ -12,184 +12,186 @@ const modules = bender.amd.require( 'ckeditor', 'ui/view', 'ui/template' );
 let Template;
 
 bender.tools.createSinonSandbox();
-beforeEach( createClassReferences );
 
-describe( 'constructor', () => {
-	it( 'accepts the definition', () => {
-		let def = {
-			tag: 'p'
-		};
+describe( 'Template', () => {
+	beforeEach( createClassReferences );
 
-		expect( new Template( def ).def ).to.equal( def );
-	} );
-} );
+	describe( 'constructor', () => {
+		it( 'accepts the definition', () => {
+			let def = {
+				tag: 'p'
+			};
 
-describe( 'render', () => {
-	it( 'returns null when no definition', () => {
-		expect( new Template().render() ).to.be.null;
+			expect( new Template( def ).def ).to.equal( def );
+		} );
 	} );
 
-	it( 'creates an element', () => {
-		let el = new Template( {
-			tag: 'p',
-			attrs: {
-				'class': [ 'a', 'b' ],
-				x: 'bar'
-			},
-			text: 'foo'
-		} ).render();
+	describe( 'render', () => {
+		it( 'returns null when no definition', () => {
+			expect( new Template().render() ).to.be.null;
+		} );
+
+		it( 'creates an element', () => {
+			let el = new Template( {
+				tag: 'p',
+				attrs: {
+					'class': [ 'a', 'b' ],
+					x: 'bar'
+				},
+				text: 'foo'
+			} ).render();
+
+			expect( el ).to.be.instanceof( HTMLElement );
+			expect( el.parentNode ).to.be.null;
+			expect( el.outerHTML ).to.be.equal( '<p class="a b" x="bar">foo</p>' );
+		} );
+
+		it( 'creates element\'s children', () => {
+			let el = new Template( {
+				tag: 'p',
+				attrs: {
+					a: 'A'
+				},
+				children: [
+					{
+						tag: 'b',
+						text: 'B'
+					},
+					{
+						tag: 'i',
+						text: 'C',
+						children: [
+							{
+								tag: 'b',
+								text: 'D'
+							}
+						]
+					}
+				]
+			} ).render();
 
-		expect( el ).to.be.instanceof( HTMLElement );
-		expect( el.parentNode ).to.be.null();
+			expect( el.outerHTML ).to.be.equal( '<p a="A"><b>B</b><i>C<b>D</b></i></p>' );
+		} );
 
-		expect( el.outerHTML ).to.be.equal( '<p class="a b" x="bar">foo</p>' );
-	} );
+		describe( 'callback', () => {
+			it( 'works for attributes', () => {
+				let spy1 = bender.sinon.spy();
+				let spy2 = bender.sinon.spy();
 
-	it( 'creates element\'s children', () => {
-		let el = new Template( {
-			tag: 'p',
-			attrs: {
-				a: 'A'
-			},
-			children: [
-				{
-					tag: 'b',
-					text: 'B'
-				},
-				{
-					tag: 'i',
-					text: 'C',
+				let el = new Template( {
+					tag: 'p',
+					attrs: {
+						'class': spy1
+					},
 					children: [
 						{
-							tag: 'b',
-							text: 'D'
+							tag: 'span',
+							attrs: {
+								id: spy2
+							}
 						}
 					]
-				}
-			]
-		} ).render();
+				} ).render();
 
-		expect( el.outerHTML ).to.be.equal( '<p a="A"><b>B</b><i>C<b>D</b></i></p>' );
-	} );
-} );
+				sinon.assert.calledWithExactly( spy1, el, sinon.match.func );
+				sinon.assert.calledWithExactly( spy2, el.firstChild, sinon.match.func );
 
-describe( 'callback value', () => {
-	it( 'works for attributes', () => {
-		let spy1 = bender.sinon.spy();
-		let spy2 = bender.sinon.spy();
-
-		let el = new Template( {
-			tag: 'p',
-			attrs: {
-				'class': spy1
-			},
-			children: [
-				{
-					tag: 'span',
-					attrs: {
-						id: spy2
-					}
-				}
-			]
-		} ).render();
+				spy1.firstCall.args[ 1 ]( el, 'foo' );
+				spy2.firstCall.args[ 1 ]( el.firstChild, 'bar' );
 
-		sinon.assert.calledWithExactly( spy1, el, sinon.match.func );
-		sinon.assert.calledWithExactly( spy2, el.firstChild, sinon.match.func );
+				expect( el.outerHTML ).to.be.equal( '<p class="foo"><span id="bar"></span></p>' );
+			} );
 
-		spy1.firstCall.args[ 1 ]( el, 'foo' );
-		spy2.firstCall.args[ 1 ]( el.firstChild, 'bar' );
+			it( 'works for "text" property', () => {
+				let spy1 = bender.sinon.spy();
+				let spy2 = bender.sinon.spy();
 
-		expect( el.outerHTML ).to.be.equal( '<p class="foo"><span id="bar"></span></p>' );
-	} );
+				let el = new Template( {
+					tag: 'p',
+					text: spy1,
+					children: [
+						{
+							tag: 'span',
+							text: spy2
+						}
+					]
+				} ).render();
 
-	it( 'works for "text" property', () => {
-		let spy1 = bender.sinon.spy();
-		let spy2 = bender.sinon.spy();
-
-		let el = new Template( {
-			tag: 'p',
-			text: spy1,
-			children: [
-				{
-					tag: 'span',
-					text: spy2
-				}
-			]
-		} ).render();
-
-		sinon.assert.calledWithExactly( spy1, el, sinon.match.func );
-		sinon.assert.calledWithExactly( spy2, el.firstChild, sinon.match.func );
-
-		spy2.firstCall.args[ 1 ]( el.firstChild, 'bar' );
-		expect( el.outerHTML ).to.be.equal( '<p><span>bar</span></p>' );
-
-		spy1.firstCall.args[ 1 ]( el, 'foo' );
-		expect( el.outerHTML ).to.be.equal( '<p>foo</p>' );
-	} );
+				sinon.assert.calledWithExactly( spy1, el, sinon.match.func );
+				sinon.assert.calledWithExactly( spy2, el.firstChild, sinon.match.func );
 
-	it( 'works for "on" property', () => {
-		let spy1 = bender.sinon.spy();
-		let spy2 = bender.sinon.spy();
-		let spy3 = bender.sinon.spy();
-		let spy4 = bender.sinon.spy();
-
-		let el = new Template( {
-			tag: 'p',
-			children: [
-				{
-					tag: 'span',
-					on: {
-						bar: spy2
-					}
-				}
-			],
-			on: {
-				foo: spy1,
-				baz: [ spy3, spy4 ]
-			}
-		} ).render();
-
-		sinon.assert.calledWithExactly( spy1, el, 'foo', null );
-		sinon.assert.calledWithExactly( spy2, el.firstChild, 'bar', null );
-		sinon.assert.calledWithExactly( spy3, el, 'baz', null );
-		sinon.assert.calledWithExactly( spy4, el, 'baz', null );
-	} );
+				spy2.firstCall.args[ 1 ]( el.firstChild, 'bar' );
+				expect( el.outerHTML ).to.be.equal( '<p><span>bar</span></p>' );
 
-	it( 'works for "on" property with selectors', () => {
-		let spy1 = bender.sinon.spy();
-		let spy2 = bender.sinon.spy();
-		let spy3 = bender.sinon.spy();
-		let spy4 = bender.sinon.spy();
-
-		let el = new Template( {
-			tag: 'p',
-			children: [
-				{
-					tag: 'span',
-					attrs: {
-						'id': 'x'
+				spy1.firstCall.args[ 1 ]( el, 'foo' );
+				expect( el.outerHTML ).to.be.equal( '<p>foo</p>' );
+			} );
+
+			it( 'works for "on" property', () => {
+				let spy1 = bender.sinon.spy();
+				let spy2 = bender.sinon.spy();
+				let spy3 = bender.sinon.spy();
+				let spy4 = bender.sinon.spy();
+
+				let el = new Template( {
+					tag: 'p',
+					children: [
+						{
+							tag: 'span',
+							on: {
+								bar: spy2
+							}
+						}
+					],
+					on: {
+						foo: spy1,
+						baz: [ spy3, spy4 ]
 					}
-				},
-				{
-					tag: 'span',
-					attrs: {
-						'class': 'y'
-					},
+				} ).render();
+
+				sinon.assert.calledWithExactly( spy1, el, 'foo', null );
+				sinon.assert.calledWithExactly( spy2, el.firstChild, 'bar', null );
+				sinon.assert.calledWithExactly( spy3, el, 'baz', null );
+				sinon.assert.calledWithExactly( spy4, el, 'baz', null );
+			} );
+
+			it( 'works for "on" property with selectors', () => {
+				let spy1 = bender.sinon.spy();
+				let spy2 = bender.sinon.spy();
+				let spy3 = bender.sinon.spy();
+				let spy4 = bender.sinon.spy();
+
+				let el = new Template( {
+					tag: 'p',
+					children: [
+						{
+							tag: 'span',
+							attrs: {
+								'id': 'x'
+							}
+						},
+						{
+							tag: 'span',
+							attrs: {
+								'class': 'y'
+							},
+							on: {
+								'bar@p': spy2
+							}
+						},
+					],
 					on: {
-						'bar@p': spy2
+						'foo@span': spy1,
+						'baz@.y': [ spy3, spy4 ]
 					}
-				},
-			],
-			on: {
-				'foo@span': spy1,
-				'baz@.y': [ spy3, spy4 ]
-			}
-		} ).render();
-
-		sinon.assert.calledWithExactly( spy1, el, 'foo', 'span' );
-		sinon.assert.calledWithExactly( spy2, el.lastChild, 'bar', 'p' );
-		sinon.assert.calledWithExactly( spy3, el, 'baz', '.y' );
-		sinon.assert.calledWithExactly( spy4, el, 'baz', '.y' );
+				} ).render();
+
+				sinon.assert.calledWithExactly( spy1, el, 'foo', 'span' );
+				sinon.assert.calledWithExactly( spy2, el.lastChild, 'bar', 'p' );
+				sinon.assert.calledWithExactly( spy3, el, 'baz', '.y' );
+				sinon.assert.calledWithExactly( spy4, el, 'baz', '.y' );
+			} );
+		} );
 	} );
 } );
 

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 633 - 326
packages/ckeditor5-engine/tests/ui/view.js


Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio